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,4 @@
|
|||||||
|
node_modules/
|
||||||
|
*.bak
|
||||||
|
*.orig
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# git-forge-plugin
|
||||||
|
|
||||||
|
GitHub and Gitea/Forgejo integration for the DeepSeek Harness (DSH): **clone repositories into new workspaces** from the Web UI and give agents **`forge_*` tools** for repos, issues, pull requests and authenticated push. Tokens never leave the Host — they stay with the `gh` and `tea`/`tea-cli` CLIs.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
- **Add workspace → Clone repository**: browse repos of every authorized account (or paste any git URL) and clone straight into a new workspace (default clone root: your home directory).
|
||||||
|
- **Add workspace → Open local folder**: folder browser (up/home/show hidden).
|
||||||
|
- **Settings → Plugins → Git Forge → Accounts**: list authorized accounts, check tokens, log out, and add accounts:
|
||||||
|
- GitHub via `gh`: browser device-code flow or a personal access token (classic, scopes `repo`, `read:org`, `gist`).
|
||||||
|
- Gitea / Forgejo via `tea-cli`: host + token, or user + password (tea creates a token).
|
||||||
|
|
||||||
|
### Agent tools
|
||||||
|
| Tool | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `forge_accounts` | List authorized GitHub (gh) and Gitea/Forgejo (tea-cli) accounts. |
|
||||||
|
| `forge_repos` | list / search / get / create / edit / fork / delete / branches / releases. |
|
||||||
|
| `forge_issues` | list / get / create / comment / edit / close / reopen. |
|
||||||
|
| `forge_pulls` | list / get / diff / create / comment / merge / close / reopen. |
|
||||||
|
| `forge_clone` | Clone with account credentials (private repos; SSH URLs switched to HTTPS+token when no SSH key). |
|
||||||
|
| `forge_push` | Push with the account token (sets upstream; `--force-with-lease` only on request). |
|
||||||
|
| `forge_api` | Raw authenticated REST call (GitHub `https://api.github.com`, Gitea/Forgejo `/api/v1`). |
|
||||||
|
|
||||||
|
Clone and push use a bundled git **credential helper** (`credential-helper.mjs`) passed via `git -c credential.helper=…`; it answers with the token of the gh/tea account whose host matches, and prints nothing otherwise (git falls through to your normal helpers). Your global git config is not modified.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- [`gh`](https://cli.github.com/) for GitHub accounts (optional).
|
||||||
|
- [`tea`](https://gitea.com/gitea/tea) (binary named `tea-cli` or `tea`) for Gitea/Forgejo accounts (optional).
|
||||||
|
- `git` and Node.js (already required by DSH).
|
||||||
|
|
||||||
|
At least one of `gh` / `tea` must be installed and logged in for the account features to do anything.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| `index.js` | Host plugin: HTTP routes under `/git-forge/*` (accounts, repos, auth, ls, clone) and the `forge_*` tools. Injects `webServer`, `connection`, `tools`. |
|
||||||
|
| `forge.mjs` | Account discovery (gh + tea), REST access, git URL parsing, clone and push. |
|
||||||
|
| `auth.mjs` | Drives `gh auth login` / `tea login add` / logout for the settings panel. |
|
||||||
|
| `credential-helper.mjs` | git credential helper used by clone/push. |
|
||||||
|
| `client.js` | Web client: clone dialog, local folder picker, accounts settings panel. |
|
||||||
|
| `locale/*.json` | Plugin title/description (en, zh). |
|
||||||
|
| `cordis.patch.yml` | Bundle patch inserting the `git-forge` row. |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Requirements: a working DeepSeek Harness (`dsh`) install with the **web** profile (or any profile that includes the Web UI).
|
||||||
|
|
||||||
|
### Option A — install from git (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/git-forge-plugin.git
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also paste `git+https://git.rizzy.dev/DSH-Plugins/git-forge-plugin.git` into **Settings → Plugins → Install** in the Web UI, or ask the agent to install it with its `plugin_manager` tool (`install_bundle`).
|
||||||
|
|
||||||
|
The plugin manager adds `@local/git-forge` to `dependencies` and to `dsh.profile.bundles` in `~/.dsh/profiles/web/package.json`.
|
||||||
|
|
||||||
|
### Option B — local checkout (for development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.rizzy.dev/DSH-Plugins/git-forge-plugin.git ~/dsh-plugins/git-forge-plugin
|
||||||
|
dsh plugin --profile web add link:$HOME/dsh-plugins/git-forge-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
With a `link:` install, edits in the checkout are picked up directly (client changes reload on page refresh, Host changes after a DSH restart or HMR reload).
|
||||||
|
|
||||||
|
### Enable / verify
|
||||||
|
|
||||||
|
1. Make sure `@local/git-forge` is listed in `dsh.profile.bundles` of `~/.dsh/profiles/web/package.json` (the installer does this; add it by hand if you edited `package.json` manually).
|
||||||
|
2. Restart DSH (`dsh web`) or reload the page.
|
||||||
|
3. Check **Settings → Plugins**: the `@local/git-forge` bundle should be enabled.
|
||||||
|
|
||||||
|
### Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dsh plugin --profile web remove @local/git-forge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating
|
||||||
|
|
||||||
|
Re-run the `add` command from Option A (or `git pull` in your Option B checkout).
|
||||||
|
|
||||||
|
## Setup after install
|
||||||
|
|
||||||
|
1. Install and log in the CLIs on the Host machine (or use the settings panel to do it):
|
||||||
|
```bash
|
||||||
|
gh auth login # GitHub
|
||||||
|
tea login add --url https://gitea.example.com --token <TOKEN> # Gitea/Forgejo
|
||||||
|
```
|
||||||
|
2. Open **Settings → Plugins → Git Forge** and press **Check tokens** — each account should show *Token valid*.
|
||||||
|
3. In a chat, ask the agent e.g. *"list my repos"*; it will use `forge_repos`.
|
||||||
@@ -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
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- insert:
|
||||||
|
- id: git-forge
|
||||||
|
name: '@local/git-forge'
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// git credential helper: answers `get` with the token of a gh / tea-cli account
|
||||||
|
// whose host matches the request. Prints nothing (git falls through) otherwise.
|
||||||
|
import { listAccounts } from './forge.mjs'
|
||||||
|
|
||||||
|
const op = process.argv[2]
|
||||||
|
if (op !== 'get') process.exit(0)
|
||||||
|
|
||||||
|
let input = ''
|
||||||
|
process.stdin.setEncoding('utf8')
|
||||||
|
for await (const chunk of process.stdin) input += chunk
|
||||||
|
const req = {}
|
||||||
|
for (const line of input.split('\n')) {
|
||||||
|
const i = line.indexOf('=')
|
||||||
|
if (i > 0) req[line.slice(0, i)] = line.slice(i + 1)
|
||||||
|
}
|
||||||
|
if (!req.host || (req.protocol && !/^https?$/.test(req.protocol))) process.exit(0)
|
||||||
|
|
||||||
|
const accounts = await listAccounts().catch(() => [])
|
||||||
|
const host = req.host.toLowerCase()
|
||||||
|
const owner = (req.path || '').split('/')[0]?.toLowerCase()
|
||||||
|
const matches = accounts.filter((a) => a.host.toLowerCase() === host || a.host.split(':')[0].toLowerCase() === host)
|
||||||
|
const want = req.username?.toLowerCase()
|
||||||
|
const acct = (want && matches.find((a) => a.user.toLowerCase() === want))
|
||||||
|
|| (owner && matches.find((a) => a.user.toLowerCase() === owner))
|
||||||
|
|| matches.find((a) => a.active !== false)
|
||||||
|
|| matches[0]
|
||||||
|
if (!acct) process.exit(0)
|
||||||
|
|
||||||
|
const username = acct.kind === 'github' ? 'x-access-token' : (acct.user || 'oauth2')
|
||||||
|
process.stdout.write(`username=${username}\npassword=${acct.token}\n`)
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
// @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, 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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),
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---- 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')
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "meta": { "title": "Git Forge", "description": "Clone repositories from your GitHub (gh) and Gitea/Forgejo (tea-cli) accounts or any git URL straight into a new workspace, and give agents forge_* tools for repos, issues, pull requests and authenticated push." } }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "meta": { "title": "Git Forge", "description": "从 GitHub(gh)和 Gitea/Forgejo(tea-cli)账号或任意 git 地址克隆仓库为新工作区,并为智能体提供仓库、议题、拉取请求和认证推送等 forge_* 工具。" } }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@local/git-forge",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Clone repositories from GitHub (gh) and Gitea/Forgejo (tea-cli) accounts into new workspaces, plus forge_* agent tools for repos, issues, pull requests and authenticated push.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js",
|
||||||
|
"./client": "./client.js",
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
"./locale/*.json": "./locale/*.json"
|
||||||
|
},
|
||||||
|
"files": ["index.js", "forge.mjs", "auth.mjs", "credential-helper.mjs", "client.js", "cordis.patch.yml", "locale/*.json"],
|
||||||
|
"dsh": {
|
||||||
|
"bundle": { "patch": "./cordis.patch.yml" },
|
||||||
|
"client": {
|
||||||
|
"platform": "web",
|
||||||
|
"immediately": true,
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-locale",
|
||||||
|
"@deepseek-ai/dsh-client-ui-workspace",
|
||||||
|
"@deepseek-ai/dsh-client-ui-directory-picker-browse"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user