commit e303c51b2386a0371342e1527b13e9703e14f292 Author: Agent Date: Sat Oct 10 17:37:32 2026 +0000 Initial commit: git-forge-plugin DSH plugin with docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb38758 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.bak +*.orig +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..26d07ba --- /dev/null +++ b/README.md @@ -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 # 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`. diff --git a/auth.mjs b/auth.mjs new file mode 100644 index 0000000..6e254a7 --- /dev/null +++ b/auth.mjs @@ -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 + })) +} diff --git a/client.js b/client.js new file mode 100644 index 0000000..ef90013 --- /dev/null +++ b/client.js @@ -0,0 +1,1052 @@ +window.__ModuleLoader__.load({ + id: '@local/git-forge', + factory(require) { + const React = require('react'); + const h = React.createElement; + const { useState, useEffect, useMemo, useRef, useCallback } = React; + const P = require('@deepseek-ai/dsh-client-ui-primitives'); + + const NS = 'git-forge'; + const SLOT_HERO = 'conversation.hero.workspace.directoryFlow'; + const SLOT_SIDEBAR = 'sidebar.workspaces.directoryFlow'; + const LS_MODE = 'git-forge.mode'; + const LS_PARENT = 'git-forge.parent'; + + // ---------------------------------------------------------------- locale + + const en = { + 'title': 'Add workspace', + 'close': 'Close', + 'tabs.label': 'Workspace source', + 'tab.clone': 'Clone repository', + 'tab.local': 'Open local folder', + 'menu.local': 'Open local folder…', + 'local.title': 'Open local folder', + 'local.up': 'Up', + 'local.home': 'Home', + 'local.hidden': 'Show hidden', + 'local.open': 'Open', + 'local.empty': 'No subfolders', + 'local.path': 'Folder path', + 'settings.title': 'Accounts', + 'settings.hint': 'Accounts authorized through gh (GitHub) and tea-cli (Gitea/Forgejo). Tokens stay with the CLIs on the host.', + 'settings.refresh': 'Refresh', + 'settings.verify': 'Check tokens', + 'settings.none': 'No accounts authorized yet.', + 'settings.logout': 'Log out', + 'settings.logoutConfirm': 'Log out {id}? This removes the credentials from {cli}.', + 'settings.ok': 'Token valid', + 'settings.bad': 'Token error', + 'settings.cli': 'CLI tools', + 'settings.missing': 'not installed', + 'settings.add': 'Add account', + 'settings.github': 'GitHub (gh)', + 'settings.gitea': 'Gitea / Forgejo (tea-cli)', + 'settings.host': 'Host', + 'settings.method': 'Method', + 'settings.web': 'Browser (device code)', + 'settings.token': 'Personal access token', + 'settings.password': 'User + password', + 'settings.tokenHint': 'Classic token with at least repo, read:org and gist scopes.', + 'settings.start': 'Start login', + 'settings.code': 'Open {url} and enter this code:', + 'settings.copy': 'Copy', + 'settings.waiting': 'Waiting for authorization…', + 'settings.cancel': 'Cancel', + 'settings.login': 'Log in', + 'settings.name': 'Login name', + 'settings.url': 'Server URL', + 'settings.user': 'User', + 'settings.pwd': 'Password', + 'settings.insecure': 'Skip TLS verification', + 'settings.giteaHint': 'Create a token under Settings → Applications on the server (scopes: repository, issue, user, organization).', + 'settings.added': 'Account added.', + 'settings.removed': 'Account removed.', + 'menu.clone': 'Clone repository…', + 'menu.loading': 'Loading workspaces…', + 'error.title': 'Could not add workspace', + 'url.label': 'Repository URL', + 'url.placeholder': 'https://github.com/owner/repo.git or git@host:owner/repo.git', + 'accounts.heading': 'Or choose from your accounts', + 'accounts.all': 'All', + 'accounts.none': 'No authorized accounts found. Run `gh auth login` (GitHub) or `tea-cli login add` (Gitea/Forgejo) on the host to list your repositories here.', + 'accounts.error': '{account}: {message}', + 'search.placeholder': 'Search repositories', + 'refresh': 'Refresh', + 'loading': 'Loading repositories…', + 'repos.empty': 'No repositories match.', + 'repos.count': '{shown} of {total} repositories', + 'parent.label': 'Clone into', + 'parent.placeholder': '/path/to/parent/directory', + 'name.label': 'Folder name', + 'branch.label': 'Branch', + 'branch.placeholder': 'default branch', + 'cancel': 'Cancel', + 'clone': 'Clone', + 'cloning': 'Cloning…', + 'private': 'private', + 'fork': 'fork', + 'archived': 'archived', + 'kind.github': 'GitHub', + 'kind.gitea': 'Gitea', + 'kind.forgejo': 'Forgejo', + 'local.missing': 'The local folder browser is not available. Clone a repository instead.', + 'target': 'Will clone to {path}', + }; + + const zh = { + 'title': '添加工作区', + 'close': '关闭', + 'tabs.label': '工作区来源', + 'tab.clone': '克隆仓库', + 'tab.local': '打开本地文件夹', + 'menu.local': '打开本地文件夹…', + 'local.title': '打开本地文件夹', + 'local.up': '上一级', + 'local.home': '主目录', + 'local.hidden': '显示隐藏', + 'local.open': '打开', + 'local.empty': '没有子文件夹', + 'local.path': '文件夹路径', + 'settings.title': '账号', + 'settings.hint': '通过 gh(GitHub)和 tea-cli(Gitea/Forgejo)授权的账号。令牌由主机上的 CLI 保存。', + 'settings.refresh': '刷新', + 'settings.verify': '检查令牌', + 'settings.none': '尚未授权任何账号。', + 'settings.logout': '退出登录', + 'settings.logoutConfirm': '退出 {id}?这会从 {cli} 中删除凭据。', + 'settings.ok': '令牌有效', + 'settings.bad': '令牌错误', + 'settings.cli': '命令行工具', + 'settings.missing': '未安装', + 'settings.add': '添加账号', + 'settings.github': 'GitHub(gh)', + 'settings.gitea': 'Gitea / Forgejo(tea-cli)', + 'settings.host': '主机', + 'settings.method': '方式', + 'settings.web': '浏览器(设备码)', + 'settings.token': '个人访问令牌', + 'settings.password': '用户名 + 密码', + 'settings.tokenHint': '经典令牌,至少需要 repo、read:org 和 gist 权限。', + 'settings.start': '开始登录', + 'settings.code': '打开 {url} 并输入此代码:', + 'settings.copy': '复制', + 'settings.waiting': '正在等待授权…', + 'settings.cancel': '取消', + 'settings.login': '登录', + 'settings.name': '登录名', + 'settings.url': '服务器地址', + 'settings.user': '用户', + 'settings.pwd': '密码', + 'settings.insecure': '跳过 TLS 验证', + 'settings.giteaHint': '在服务器的 设置 → 应用 中创建令牌(权限:repository、issue、user、organization)。', + 'settings.added': '已添加账号。', + 'settings.removed': '已移除账号。', + 'menu.clone': '克隆仓库…', + 'menu.loading': '正在加载工作区…', + 'error.title': '无法添加工作区', + 'url.label': '仓库地址', + 'url.placeholder': 'https://github.com/owner/repo.git 或 git@host:owner/repo.git', + 'accounts.heading': '或从你的账号中选择', + 'accounts.all': '全部', + 'accounts.none': '未找到已授权的账号。在主机上运行 `gh auth login`(GitHub)或 `tea-cli login add`(Gitea/Forgejo)后即可在此列出你的仓库。', + 'accounts.error': '{account}:{message}', + 'search.placeholder': '搜索仓库', + 'refresh': '刷新', + 'loading': '正在加载仓库…', + 'repos.empty': '没有匹配的仓库。', + 'repos.count': '{shown} / {total} 个仓库', + 'parent.label': '克隆到', + 'parent.placeholder': '/父目录/路径', + 'name.label': '文件夹名称', + 'branch.label': '分支', + 'branch.placeholder': '默认分支', + 'cancel': '取消', + 'clone': '克隆', + 'cloning': '正在克隆…', + 'private': '私有', + 'fork': '派生', + 'archived': '已归档', + 'kind.github': 'GitHub', + 'kind.gitea': 'Gitea', + 'kind.forgejo': 'Forgejo', + 'local.missing': '本地文件夹浏览器不可用,请改为克隆仓库。', + 'target': '将克隆到 {path}', + }; + + const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars && vars[k] !== undefined ? vars[k] : '')); + + // ---------------------------------------------------------------- helpers + + async function call(path, init) { + const res = await fetch('git-forge/' + path, { + ...init, + headers: { accept: 'application/json', ...(init && init.body ? { 'content-type': 'application/json' } : {}) }, + }); + let json; + try { json = await res.json(); } catch { json = {}; } + if (!res.ok) throw new Error(json.message || res.statusText || String(res.status)); + return json; + } + const post = (path, body) => call(path, { method: 'POST', body: JSON.stringify(body || {}) }); + + function lsGet(key) { + try { return window.localStorage.getItem(key); } catch { return null; } + } + function lsSet(key, value) { + try { window.localStorage.setItem(key, value); } catch { /* ignore */ } + } + const rememberedMode = () => (lsGet(LS_MODE) === 'local' ? 'local' : 'clone'); + + /** Folder name derived from a git URL: last path segment without `.git`. */ + function nameFromUrl(url) { + const s = String(url || '').trim().replace(/[?#].*$/, '').replace(/\/+$/, ''); + if (!s) return ''; + const seg = s.split(/[/:]/).pop() || ''; + return seg.replace(/\.git$/i, ''); + } + + function joinPath(parent, name) { + if (!parent || !name) return ''; + return parent.replace(/\/+$/, '') + '/' + name; + } + + const icon = (name, size) => (P[name] ? h(P[name], size ? { size } : null) : null); + + // ---------------------------------------------------------------- styles + + const color = { + primary: 'var(--dsw-alias-label-primary, inherit)', + secondary: 'var(--dsw-alias-label-secondary, inherit)', + tertiary: 'var(--dsw-alias-label-tertiary, inherit)', + border1: 'var(--dsw-alias-border-l1, rgba(128,128,128,.25))', + border2: 'var(--dsw-alias-border-l2, rgba(128,128,128,.4))', + hover: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12))', + error: 'var(--dsw-alias-state-error-primary, #d33)', + platform: 'var(--dsw-alias-bg-module-platform, transparent)', + }; + const S = { + body: { display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }, + field: { display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }, + label: { fontSize: 13, fontWeight: 600, color: color.primary }, + hint: { fontSize: 12, color: color.secondary, lineHeight: 1.45 }, + caption: { fontSize: 11, color: color.tertiary, whiteSpace: 'nowrap' }, + error: { fontSize: 12, color: color.error, lineHeight: 1.45, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }, + errorBox: { + fontSize: 12, color: color.error, border: '1px solid ' + color.error, borderRadius: 8, + padding: '8px 10px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 160, overflow: 'auto', + }, + section: { + display: 'flex', flexDirection: 'column', gap: 8, padding: 10, borderRadius: 10, + border: '1px solid ' + color.border1, background: color.platform, minWidth: 0, + }, + chips: { display: 'flex', flexWrap: 'wrap', gap: 6 }, + chip: (active) => ({ + display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, padding: '3px 10px', borderRadius: 999, + cursor: 'pointer', color: active ? color.primary : color.secondary, fontWeight: active ? 600 : 400, + border: '1px solid ' + (active ? color.border2 : color.border1), + background: active ? color.hover : 'transparent', + }), + badge: { fontSize: 10, color: color.tertiary, textTransform: 'uppercase', letterSpacing: 0.3 }, + toolbar: { display: 'flex', alignItems: 'center', gap: 6 }, + list: { + maxHeight: 360, overflowY: 'auto', display: 'flex', flexDirection: 'column', + border: '1px solid ' + color.border1, borderRadius: 8, + }, + row: (selected, hovered) => ({ + display: 'flex', alignItems: 'center', gap: 10, padding: '7px 10px', cursor: 'pointer', textAlign: 'left', + border: 'none', borderBottom: '1px solid ' + color.border1, width: '100%', font: 'inherit', + color: color.primary, background: selected || hovered ? color.hover : 'transparent', + boxShadow: selected ? 'inset 3px 0 0 ' + color.border2 : 'none', + }), + rowMain: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0, flex: 1 }, + rowTitle: { display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }, + fullName: { fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, + desc: { fontSize: 12, color: color.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, + rowSide: { display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, flexShrink: 0 }, + empty: { fontSize: 12, color: color.secondary, padding: '14px 10px', textAlign: 'center' }, + split: { display: 'flex', gap: 10, minWidth: 0 }, + footer: { display: 'flex', justifyContent: 'flex-end', gap: 8 }, + code: { fontFamily: 'var(--dsw-font-mono, monospace)' }, + }; + + // ---------------------------------------------------------------- repo picker + + function RepoRow({ repo, selected, onPick, t }) { + const [hovered, setHovered] = useState(false); + return h('button', { + type: 'button', + style: S.row(selected, hovered), + 'aria-pressed': selected, + title: repo.htmlUrl || repo.cloneUrl, + onMouseEnter: () => setHovered(true), + onMouseLeave: () => setHovered(false), + onClick: () => onPick(repo), + }, + h('div', { style: S.rowMain }, + h('div', { style: S.rowTitle }, + h('span', { style: S.fullName }, repo.fullName), + repo.private ? h(P.Tag, { tone: 'quiet' }, t('private')) : null, + repo.fork ? h(P.Tag, { tone: 'quiet' }, t('fork')) : null, + repo.archived ? h(P.Tag, { tone: 'warning' }, t('archived')) : null), + repo.description ? h('div', { style: S.desc }, repo.description) : null), + h('div', { style: S.rowSide }, + h('span', { style: S.caption }, repo.host), + h('span', { style: S.badge }, t('kind.' + (repo.flavor || repo.kind)) || repo.flavor || repo.kind))); + } + + function AccountChip({ active, onClick, children }) { + return h('button', { type: 'button', style: S.chip(active), 'aria-pressed': active, onClick }, children); + } + + function RepoPicker({ t, open, selectedUrl, onPick }) { + const [accounts, setAccounts] = useState(null); // null = not loaded yet + const [repos, setRepos] = useState([]); + const [errors, setErrors] = useState([]); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(''); + const [account, setAccount] = useState('all'); + const [query, setQuery] = useState(''); + const seq = useRef(0); + + const load = useCallback(async (refresh) => { + const my = ++seq.current; + setLoading(true); + setLoadError(''); + try { + const [acc, rep] = await Promise.all([ + call('accounts'), + call('repos' + (refresh ? '?refresh=1' : '')), + ]); + if (my !== seq.current) return; + setAccounts(acc.accounts || []); + setRepos(rep.repos || []); + setErrors(rep.errors || []); + } catch (e) { + if (my !== seq.current) return; + setAccounts((prev) => prev || []); + setLoadError(e && e.message ? e.message : String(e)); + } finally { + if (my === seq.current) setLoading(false); + } + }, []); + + // Lazy load once per open. + useEffect(() => { + if (!open) return undefined; + load(false); + return () => { seq.current++; }; + }, [open, load]); + + const visible = useMemo(() => { + const q = query.trim().toLowerCase(); + return repos.filter((r) => { + if (account !== 'all' && r.account !== account) return false; + if (!q) return true; + return (String(r.fullName || '') + ' ' + String(r.description || '')).toLowerCase().includes(q); + }); + }, [repos, account, query]); + + const accountLabel = (id) => { + const a = (accounts || []).find((x) => x.id === id); + return a ? a.user + '@' + a.host : id; + }; + + const noAccounts = accounts !== null && accounts.length === 0 && !loading && !loadError; + + let listBody; + if (loading && repos.length === 0) listBody = h('div', { style: S.empty }, t('loading')); + else if (visible.length === 0) listBody = h('div', { style: S.empty }, t('repos.empty')); + else { + listBody = visible.map((r) => h(RepoRow, { + key: r.account + ':' + r.fullName, + repo: r, + t, + selected: !!selectedUrl && (selectedUrl === r.cloneUrl || selectedUrl === r.sshUrl), + onPick, + })); + } + + return h('div', { style: S.section }, + h('div', { style: S.label }, t('accounts.heading')), + noAccounts + ? h('div', { style: S.hint }, t('accounts.none')) + : h(React.Fragment, null, + accounts && accounts.length > 0 ? h('div', { style: S.chips }, + h(AccountChip, { active: account === 'all', onClick: () => setAccount('all') }, t('accounts.all')), + accounts.map((a) => h(AccountChip, { key: a.id, active: account === a.id, onClick: () => setAccount(a.id) }, + h('span', null, a.user + '@' + a.host), + h('span', { style: S.badge }, t('kind.' + (a.flavor || a.kind)) || a.flavor || a.kind)))) : null, + h('div', { style: S.toolbar }, + h('div', { style: { flex: 1, minWidth: 0 } }, + h(P.Input, { + icon: icon('IconSearchOutlineRegular'), + value: query, + placeholder: t('search.placeholder'), + onChange: (e) => setQuery(e.target.value), + })), + h(P.Button, { + variant: 'toolbar', + size: 'sm', + icon: icon('IconRefreshOutlineRegular'), + disabled: loading, + title: t('refresh'), + 'aria-label': t('refresh'), + onClick: () => load(true), + })), + loading && repos.length > 0 ? h('div', { style: S.hint }, t('loading')) : null, + h('div', { style: S.list, role: 'listbox' }, listBody), + repos.length > 0 ? h('div', { style: S.caption }, fill(t('repos.count'), { shown: visible.length, total: repos.length })) : null), + loadError ? h('div', { style: S.error }, loadError) : null, + errors.map((e, i) => h('div', { key: i, style: S.error }, + fill(t('accounts.error'), { account: accountLabel(e.account), message: e.message })))); + } + + // ---------------------------------------------------------------- clone form + + function CloneDialog({ t, open, busy, onCancel, onPicked, onTab, localMissing }) { + const [url, setUrl] = useState(''); + const [parent, setParent] = useState(() => lsGet(LS_PARENT) || ''); + const [name, setName] = useState(''); + const [nameEdited, setNameEdited] = useState(false); + const [branch, setBranch] = useState(''); + const [cloning, setCloning] = useState(false); + const [error, setError] = useState(''); + const mounted = useRef(true); + useEffect(() => () => { mounted.current = false; }, []); + + // Reset the form each time the dialog opens; fill the default parent dir. + useEffect(() => { + if (!open) return; + setUrl(''); + setName(''); + setNameEdited(false); + setBranch(''); + setError(''); + const saved = lsGet(LS_PARENT); + if (saved) { setParent(saved); return; } + let alive = true; + call('defaults').then((d) => { + if (alive && d && d.cloneRoot) setParent((p) => p || d.cloneRoot); + }).catch(() => { /* leave empty; user can type it */ }); + return () => { alive = false; }; + }, [open]); + + const changeUrl = (value) => { + setUrl(value); + if (!nameEdited) setName(nameFromUrl(value)); + }; + + const pick = (repo) => { + changeUrl(repo.cloneUrl); + setError(''); + }; + + const disabled = !url.trim() || !parent.trim() || !(name.trim() || nameFromUrl(url)) || cloning || busy; + + const submit = async () => { + if (disabled) return; + setCloning(true); + setError(''); + try { + const body = { url: url.trim(), parent: parent.trim(), name: name.trim() || nameFromUrl(url) }; + if (branch.trim()) body.branch = branch.trim(); + const result = await post('clone', body); + lsSet(LS_PARENT, body.parent); + if (mounted.current) setCloning(false); + onPicked(result.path); + } catch (e) { + if (!mounted.current) return; + setCloning(false); + setError(e && e.message ? e.message : String(e)); + } + }; + + const target = joinPath(parent.trim(), name.trim() || nameFromUrl(url)); + + const footer = h('div', { style: S.footer }, + h(P.Button, { variant: 'ghost', disabled: cloning, onClick: onCancel }, t('cancel')), + h(P.Button, { variant: 'primary', disabled, onClick: submit }, cloning ? t('cloning') : t('clone'))); + + return h(P.Modal, { + open, + onClose: () => { if (!cloning) onCancel(); }, + title: onTab ? t('title') : t('tab.clone'), + closeLabel: t('close'), + footer, + className: 'git-forge-dialog', + }, + h('style', null, '.git-forge-dialog{width:min(720px,100%)!important;max-height:100%;}'), + h('div', { style: S.body }, + onTab ? h(P.SegmentedTabs, { + label: t('tabs.label'), + value: 'clone', + onChange: onTab, + items: [ + { value: 'clone', label: t('tab.clone'), id: 'git-forge-tab-clone', panelId: 'git-forge-panel' }, + { value: 'local', label: t('tab.local'), id: 'git-forge-tab-local', panelId: 'git-forge-panel' }, + ], + }) : null, + h('div', { id: 'git-forge-panel', role: 'tabpanel', 'aria-labelledby': 'git-forge-tab-clone', style: S.body }, + localMissing ? h('div', { style: S.errorBox }, t('local.missing')) : null, + h('label', { style: S.field }, + h('span', { style: S.label }, t('url.label')), + h(P.Input, { + value: url, + autoFocus: true, + spellCheck: false, + placeholder: t('url.placeholder'), + onChange: (e) => changeUrl(e.target.value), + onKeyDown: (e) => { if (e.key === 'Enter') submit(); }, + })), + h(RepoPicker, { t, open, selectedUrl: url.trim(), onPick: pick }), + h('div', { style: S.split }, + h('label', { style: { ...S.field, flex: 2 } }, + h('span', { style: S.label }, t('parent.label')), + h(P.Input, { + icon: icon('IconFolderCloseRegular'), + value: parent, + spellCheck: false, + placeholder: t('parent.placeholder'), + onChange: (e) => setParent(e.target.value), + })), + h('label', { style: { ...S.field, flex: 1 } }, + h('span', { style: S.label }, t('name.label')), + h(P.Input, { + value: name, + spellCheck: false, + onChange: (e) => { setName(e.target.value); setNameEdited(e.target.value !== ''); }, + }))), + h('label', { style: S.field }, + h('span', { style: S.label }, t('branch.label')), + h(P.Input, { + value: branch, + spellCheck: false, + placeholder: t('branch.placeholder'), + onChange: (e) => setBranch(e.target.value), + })), + target ? h('div', { style: { ...S.hint, ...S.code } }, fill(t('target'), { path: target })) : null, + error ? h('div', { style: S.errorBox, role: 'alert' }, error) : null))); + } + + // ---------------------------------------------------------------- local-mode switcher + + // The shadowed browse picker renders its own modal, so we float the same + // tab strip just above it (separate portal, stacked over the modal layer) + // to let the user return to the clone form. + function ModeSwitcher({ t, onTab }) { + const [pos, setPos] = useState(null); + const self = useRef(null); + useEffect(() => { + let frame = 0; + const measure = () => { + const dialogs = [...document.querySelectorAll('[role="dialog"][aria-modal="true"]')] + .filter((d) => !(self.current && self.current.contains(d))); + const dlg = dialogs[dialogs.length - 1]; + if (dlg) { + const r = dlg.getBoundingClientRect(); + const next = { top: Math.max(8, r.top - 52), left: r.left + r.width / 2 }; + setPos((p) => (p && p.top === next.top && p.left === next.left ? p : next)); + } + frame = requestAnimationFrame(measure); + }; + measure(); + return () => cancelAnimationFrame(frame); + }, []); + const ReactDOM = require('react-dom'); + const node = h('div', { + ref: self, + style: { + position: 'fixed', zIndex: 1001, + top: pos ? pos.top : 16, left: pos ? pos.left : '50%', transform: 'translateX(-50%)', + padding: 4, borderRadius: 12, + background: 'var(--dsw-alias-bg-layer-2)', boxShadow: 'var(--dsw-elevation-prominent)', + visibility: pos ? 'visible' : 'hidden', + }, + }, + h(P.SegmentedTabs, { + label: t('tabs.label'), + value: 'local', + onChange: onTab, + items: [ + { value: 'clone', label: t('tab.clone'), id: 'git-forge-switch-clone' }, + { value: 'local', label: t('tab.local'), id: 'git-forge-switch-local' }, + ], + })); + return ReactDOM.createPortal(node, document.body); + } + + // ---------------------------------------------------------------- slot occupant + + function AddWorkspaceFlow(props) { + const { t, open } = props; + const [mode, setModeState] = useState(rememberedMode); + const wasOpen = useRef(open); + + // Reset to the remembered mode whenever the flow opens. + useEffect(() => { + if (open && !wasOpen.current) setModeState(rememberedMode()); + wasOpen.current = open; + }, [open]); + + const setMode = (next) => { + lsSet(LS_MODE, next); + setModeState(next); + }; + + let sh = null; + try { sh = typeof props.shadowed === 'function' ? props.shadowed() : null; } catch { sh = null; } + + if (mode === 'local') { + const has = !!(sh && sh.Component); + return h(React.Fragment, null, + h(has ? sh.Component : LocalFolderDialog, { + ...(has ? sh.props : { t, listDirectory: props.listDirectory }), + open: props.open, + busy: props.busy, + onPicked: props.onPicked, + onCancel: props.onCancel, + onError: props.onError, + }), + props.open ? h(ModeSwitcher, { t, onTab: setMode }) : null); + } + + return h(CloneDialog, { + t, + open: !!open, + busy: !!props.busy, + onCancel: () => props.onCancel && props.onCancel(), + onPicked: (path) => props.onPicked && props.onPicked(path), + onTab: setMode, + localMissing: mode === 'local' && !(sh && sh.Component), + }); + } + + + // ---------------------------------------------------------------- fallback local folder dialog + + // Used when no core directory picker occupant can be found to delegate to. + function LocalFolderDialog({ t, open, busy, onPicked, onCancel, listDirectory }) { + const [dir, setDir] = useState(null); + const [draft, setDraft] = useState(''); + const [hidden, setHidden] = useState(false); + const [err, setErr] = useState(''); + const load = useCallback((path, showHidden) => { + setErr(''); + // Prefer the core directory-picker service (no Host plugin route needed). + const viaHost = () => call('ls?' + new URLSearchParams({ ...(path ? { path } : {}), hidden: showHidden ? '1' : '0' })); + const viaCore = typeof listDirectory === 'function' + ? Promise.resolve().then(() => listDirectory(path || undefined)).then((r) => { + const crumbs = r.crumbs || []; + return { + path: r.path, + home: r.home, + parent: crumbs.length > 1 ? crumbs[crumbs.length - 2].path : '', + dirs: (r.entries || []).filter((e) => showHidden || !e.hidden).map((e) => e.name), + }; + }, (e) => (String(e && e.message) === 'no-list' ? viaHost() : Promise.reject(e))) + : viaHost(); + viaCore + .then((r) => { setDir(r); setDraft(r.path); }) + .catch((e) => setErr(String(e.message || e))); + }, []); + useEffect(() => { if (open) load(lsGet(LS_PARENT) || '', hidden); }, [open]); + if (!open) return null; + const go = (p) => load(p, hidden); + return h(P.Modal, { + open: true, + onClose: onCancel, + closeLabel: t('close'), + title: t('local.title'), + className: 'git-forge-dialog', + footer: h(React.Fragment, null, + h(P.Button, { variant: 'outline', onClick: onCancel }, t('cancel')), + h(P.Button, { variant: 'primary', disabled: !dir || busy, onClick: () => dir && onPicked(dir.path) }, t('local.open'))), + }, + h('style', null, '.git-forge-dialog{width:min(720px,100%)!important;max-height:100%;}'), + h('div', { style: S.body }, + h('form', { style: S.toolbar, onSubmit: (e) => { e.preventDefault(); go(draft); } }, + h('div', { style: { flex: 1, minWidth: 0 } }, h(P.Input, { value: draft, 'aria-label': t('local.path'), spellCheck: false, onChange: (e) => setDraft(e.target.value) })), + h(P.Button, { variant: 'ghost', size: 'sm', disabled: !dir || !dir.parent, onClick: () => go(dir.parent) }, t('local.up')), + h(P.Button, { variant: 'ghost', size: 'sm', onClick: () => go(dir ? dir.home : '') }, t('local.home'))), + h('label', { style: { ...S.hint, display: 'flex', alignItems: 'center', gap: 6 } }, + h('input', { type: 'checkbox', checked: hidden, onChange: (e) => { setHidden(e.target.checked); load(dir && dir.path, e.target.checked); } }), t('local.hidden')), + err ? h('div', { style: S.errorBox, role: 'alert' }, err) : null, + h('div', { style: S.list }, + dir && dir.dirs.length === 0 ? h('div', { style: S.empty }, t('local.empty')) : null, + ...(dir ? dir.dirs.map((name) => h('button', { + key: name, type: 'button', style: S.row(false, false), + onClick: () => go(dir.path.replace(/\/+$/, '') + '/' + name), + }, icon('IconFolderCloseRegular', 16), h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, name))) : [])))); + } + + // ---------------------------------------------------------------- settings: accounts + + function Field({ label, children }) { + return h('label', { style: { ...S.field, flex: 1 } }, h('span', { style: S.label }, label), children); + } + + function SettingsPanel({ t, view }) { + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(''); + const [msg, setMsg] = useState(null); + const [ghHost, setGhHost] = useState('github.com'); + const [ghMethod, setGhMethod] = useState('web'); + const [ghToken, setGhToken] = useState(''); + const [flow, setFlow] = useState(null); + const [gt, setGt] = useState({ url: '', name: '', method: 'token', token: '', user: '', password: '', insecure: false }); + + const refresh = useCallback((verify) => { + setBusy(verify ? 'verify' : 'refresh'); + return call('auth/status' + (verify ? '?verify=1' : '')) + .then(setStatus) + .catch((e) => setMsg({ bad: true, text: String(e.message || e) })) + .finally(() => setBusy('')); + }, []); + useEffect(() => { refresh(true); }, [refresh]); + + // Poll the device flow until gh finishes. + useEffect(() => { + if (!flow || flow.state !== 'pending') return undefined; + const id = setInterval(() => { + call('auth/github/device/status?id=' + encodeURIComponent(flow.id)).then((f) => { + setFlow(f); + if (f.state === 'success') { setMsg({ text: t('settings.added') }); refresh(true); } + else if (f.state === 'error') setMsg({ bad: true, text: f.message || 'gh error' }); + }).catch(() => {}); + }, 2000); + return () => clearInterval(id); + }, [flow && flow.id, flow && flow.state]); + + if (view === 'summary') return null; + + const act = (name, fn) => async () => { + setBusy(name); setMsg(null); + try { await fn(); } catch (e) { setMsg({ bad: true, text: String(e.message || e) }); } finally { setBusy(''); } + }; + const ghStart = act('gh', async () => { + if (ghMethod === 'token') { + await post('auth/github/token', { hostname: ghHost, token: ghToken }); + setGhToken(''); setMsg({ text: t('settings.added') }); await refresh(true); + } else { + setFlow(await post('auth/github/device', { hostname: ghHost })); + } + }); + const ghCancel = () => { if (flow) post('auth/github/device/cancel', { id: flow.id }).catch(() => {}); setFlow(null); }; + const gtLogin = act('gt', async () => { + await post('auth/gitea', { + url: gt.url, name: gt.name || undefined, insecure: gt.insecure, + ...(gt.method === 'token' ? { token: gt.token } : { user: gt.user, password: gt.password }), + }); + setGt((g) => ({ ...g, token: '', password: '' })); setMsg({ text: t('settings.added') }); await refresh(true); + }); + const doLogout = (a) => act('logout:' + a.id, async () => { + const cli = a.kind === 'github' ? 'gh' : 'tea-cli'; + if (!window.confirm(fill(t('settings.logoutConfirm'), { id: a.id, cli }))) return; + await post('auth/logout', { account: a.id }); setMsg({ text: t('settings.removed') }); await refresh(true); + })(); + + const accounts = (status && status.accounts) || []; + const cli = status && status.cli; + const ghOk = !cli || cli.gh.available; + const teaOk = !cli || cli.tea.available; + const setG = (k) => (e) => { const v = e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e; setGt((g) => ({ ...g, [k]: v })); }; + const radio = (value, cur, set, label) => h('label', { style: { ...S.hint, display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: 12 } }, + h('input', { type: 'radio', checked: cur === value, onChange: () => set(value) }), label); + + return h('div', { style: { ...S.body, gap: 16, padding: '4px 0' } }, + // accounts + h('div', { style: S.section }, + h('div', { style: { ...S.toolbar, justifyContent: 'space-between' } }, + h('span', { style: S.label }, t('settings.title')), + h('div', { style: S.toolbar }, + h(P.Button, { variant: 'ghost', size: 'sm', disabled: !!busy, onClick: () => refresh(false) }, t('settings.refresh')), + h(P.Button, { variant: 'ghost', size: 'sm', disabled: !!busy, onClick: () => refresh(true) }, t('settings.verify')))), + h('div', { style: S.hint }, t('settings.hint')), + !status ? h('div', { style: S.hint }, '…') : accounts.length === 0 ? h('div', { style: S.empty }, t('settings.none')) : + h('div', { style: { ...S.list, maxHeight: 'none' } }, ...accounts.map((a) => h('div', { key: a.id, style: { ...S.row(false, false), cursor: 'default' } }, + h('div', { style: S.rowMain }, + h('div', { style: S.rowTitle }, + h('span', { style: { fontSize: 13, fontWeight: 600 } }, a.user || a.name), + h('span', { style: S.badge }, t('kind.' + (a.flavor || a.kind)) || a.kind), + a.status === 'ok' ? h('span', { style: { ...S.caption, color: 'var(--dsw-alias-state-business-primary, #2a2)' } }, '● ' + t('settings.ok')) : null, + a.status === 'error' ? h('span', { style: { ...S.caption, color: color.error }, title: a.error }, '● ' + t('settings.bad')) : null), + h('div', { style: S.caption }, a.id + ' · ' + a.url)), + h(P.Button, { variant: 'outline', size: 'sm', disabled: !!busy, onClick: () => doLogout(a) }, t('settings.logout'))))), + cli ? h('div', { style: S.caption }, + t('settings.cli') + ': gh ' + (cli.gh.available ? cli.gh.version.replace(/^gh version\s*/, '') : t('settings.missing')) + + ' · ' + (cli.tea.binary || 'tea-cli') + ' ' + (cli.tea.available ? cli.tea.version.replace(/^Version:\s*/, '') : t('settings.missing'))) : null), + msg ? h('div', { style: msg.bad ? S.errorBox : S.hint, role: msg.bad ? 'alert' : 'status' }, msg.text) : null, + // github + h('div', { style: S.section }, + h('span', { style: S.label }, t('settings.add') + ' — ' + t('settings.github')), + !ghOk ? h('div', { style: S.error }, 'gh ' + t('settings.missing')) : h(React.Fragment, null, + h('div', { style: S.split }, + h(Field, { label: t('settings.host') }, h(P.Input, { value: ghHost, spellCheck: false, onChange: (e) => setGhHost(e.target.value) })), + h(Field, { label: t('settings.method') }, h('div', { style: { paddingTop: 6 } }, + radio('web', ghMethod, setGhMethod, t('settings.web')), radio('token', ghMethod, setGhMethod, t('settings.token'))))), + ghMethod === 'token' ? h(Field, { label: t('settings.token') }, + h(P.Input, { type: 'password', value: ghToken, autoComplete: 'off', onChange: (e) => setGhToken(e.target.value) }), + h('span', { style: S.hint }, t('settings.tokenHint'))) : null, + flow && flow.state === 'pending' ? h('div', { style: { ...S.section, background: 'transparent' } }, + h('span', { style: S.hint }, fill(t('settings.code'), { url: '' }), h('a', { href: flow.verifyUrl, target: '_blank', rel: 'noreferrer' }, flow.verifyUrl)), + h('div', { style: S.toolbar }, + h('code', { style: { fontSize: 20, fontWeight: 700, letterSpacing: 2 } }, flow.code), + h(P.Button, { variant: 'ghost', size: 'sm', onClick: () => { try { navigator.clipboard.writeText(flow.code); } catch { /* ignore */ } } }, t('settings.copy'))), + h('div', { style: S.toolbar }, + h('span', { style: S.hint }, t('settings.waiting')), + h(P.Button, { variant: 'outline', size: 'sm', onClick: ghCancel }, t('settings.cancel')))) : + h('div', null, h(P.Button, { + variant: 'primary', size: 'sm', disabled: !!busy || (ghMethod === 'token' && !ghToken.trim()), onClick: ghStart, + }, ghMethod === 'token' ? t('settings.login') : t('settings.start'))))), + // gitea + h('div', { style: S.section }, + h('span', { style: S.label }, t('settings.add') + ' — ' + t('settings.gitea')), + !teaOk ? h('div', { style: S.error }, 'tea-cli ' + t('settings.missing')) : h(React.Fragment, null, + h('div', { style: S.split }, + h(Field, { label: t('settings.url') }, h(P.Input, { value: gt.url, placeholder: 'https://gitea.example.com', spellCheck: false, onChange: setG('url') })), + h(Field, { label: t('settings.name') }, h(P.Input, { value: gt.name, placeholder: 'optional', spellCheck: false, onChange: setG('name') }))), + h(Field, { label: t('settings.method') }, h('div', null, + radio('token', gt.method, setG('method'), t('settings.token')), radio('password', gt.method, setG('method'), t('settings.password')))), + gt.method === 'token' ? h(Field, { label: t('settings.token') }, + h(P.Input, { type: 'password', value: gt.token, autoComplete: 'off', onChange: setG('token') }), + h('span', { style: S.hint }, t('settings.giteaHint'))) : + h('div', { style: S.split }, + h(Field, { label: t('settings.user') }, h(P.Input, { value: gt.user, autoComplete: 'off', onChange: setG('user') })), + h(Field, { label: t('settings.pwd') }, h(P.Input, { type: 'password', value: gt.password, autoComplete: 'off', onChange: setG('password') }))), + h('label', { style: { ...S.hint, display: 'flex', alignItems: 'center', gap: 6 } }, + h('input', { type: 'checkbox', checked: gt.insecure, onChange: setG('insecure') }), t('settings.insecure')), + h('div', null, h(P.Button, { + variant: 'primary', size: 'sm', + disabled: !!busy || !gt.url.trim() || (gt.method === 'token' ? !gt.token.trim() : !(gt.user && gt.password)), + onClick: gtLogin, + }, t('settings.login')))))); + } + + // ---------------------------------------------------------------- hero workspace menu + + const SLOT_PICKER = 'conversation.hero.workspace'; + const MENU_LOCAL = '::git-forge-local'; + const MENU_CLONE = '::git-forge-clone'; + + // Replaces the core workspace menu so it offers two separate entries: + // "Open local folder…" (the core browse picker) and "Clone repository…". + function HeroWorkspacePicker(props) { + const { t, wsT, open, anchorRef, selectedId, onPick, onClose, useWorkspaces } = props; + const coreList = props.listDirectory; + const snapshot = useWorkspaces ? useWorkspaces((state) => state) : { items: [], phase: 'ready' }; + const workspaces = (snapshot && snapshot.items) || []; + const [flow, setFlow] = useState(null); // null | 'local' | 'clone' + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const getAnchorRect = useCallback(() => (anchorRef && anchorRef.current ? anchorRef.current.getBoundingClientRect() : null), [anchorRef]); + + let core = null; + try { core = props.core ? props.core() : null; } catch { core = null; } + let browse = null; + try { browse = props.browse ? props.browse() : null; } catch { browse = null; } + + const adopt = (path) => { + const create = core && core.props && core.props.createWorkspace; + if (typeof create !== 'function') return Promise.reject(new Error('workspace service unavailable')); + return create({ path }).then((ws) => { setFlow(null); onPick && onPick(ws.workspaceId); }); + }; + const fail = (reason) => { setFlow(null); setError(reason && reason.message ? reason.message : String(reason)); }; + + const startFlow = (mode) => { onClose && onClose(); setError(''); setFlow(mode); }; + + const defaultName = wsT ? wsT('workspace.defaultName') : 'Default workspace'; + const flowBusy = flow !== null || busy; + const addEntries = [ + { id: MENU_LOCAL, label: t('menu.local'), icon: icon('IconFolderOpenOutlineRegular', 16) || icon('IconPlusOutlineRegular', 16), disabled: flowBusy }, + { id: MENU_CLONE, label: t('menu.clone'), icon: icon('IconDownloadOutlineRegular', 16) || icon('IconBranchOutlineRegular', 16), disabled: flowBusy }, + ]; + const pinAdd = workspaces.length > 0; + const items = pinAdd ? workspaces.map((w) => ({ + id: w.workspaceId, + label: w.title === 'default-workspace' ? defaultName : w.title, + icon: icon('IconFolderCloseRegular', 16), + disabled: flowBusy, + })) : addEntries; + + const onSelect = (id) => { + if (id === MENU_LOCAL) return startFlow('local'); + if (id === MENU_CLONE) return startFlow('clone'); + onPick && onPick(id); + }; + + return h(React.Fragment, null, + h(P.Menu, { + open: !!open, + anchor: null, + items, + ...(pinAdd ? { footer: addEntries } : {}), + selectedId, + onSelect, + onClose, + side: 'bottom', + portal: true, + getAnchorRect, + }), + flow === 'local' ? h(browse && browse.Component ? browse.Component : LocalFolderDialog, { + ...(browse && browse.Component ? browse.props : { t, listDirectory: coreList }), + open: true, + busy, + onPicked: (path) => { setBusy(true); adopt(path).catch(fail).finally(() => setBusy(false)); }, + onCancel: () => setFlow(null), + onError: fail, + }) : null, + flow === 'clone' ? h(CloneDialog, { + t, + open: true, + busy, + onCancel: () => setFlow(null), + onPicked: (path) => { setBusy(true); adopt(path).catch(fail).finally(() => setBusy(false)); }, + }) : null, + h(P.Modal, { + open: !!error, + onClose: () => setError(''), + closeLabel: t('close'), + title: t('error.title'), + footer: h(P.Button, { variant: 'primary', onClick: () => setError('') }, t('close')), + }, h('div', { role: 'alert', style: { color: 'var(--dsw-alias-state-danger)', wordBreak: 'break-word' } }, error))); + } + + // ---------------------------------------------------------------- menu relabel + + const RELABEL = { en: 'Add or clone workspace…', zh: '添加或克隆工作区…' }; + + function relabelAddWorkspace(locale) { + const restores = []; + let timer = null; + let tries = 0; + let disposed = false; + + const publish = () => { + try { + if (typeof locale.publish === 'function' && locale.snapshot && locale.snapshot.active) { + locale.publish(locale.snapshot.active, false); + } + } catch { /* best effort */ } + }; + + const attempt = () => { + timer = null; + if (disposed) return; + let applied = false; + try { + const dicts = locale.dicts; + const m = dicts && typeof dicts.get === 'function' ? dicts.get('workspace') : null; + if (m && typeof m.get === 'function' && typeof m.set === 'function') { + for (const lang of Object.keys(RELABEL)) { + const old = m.get(lang); + if (!old || typeof old !== 'object') continue; + m.set(lang, { ...old, 'menu.addWorkspace': RELABEL[lang] }); + restores.push(() => { m.set(lang, old); }); + applied = true; + } + } + } catch { /* best effort */ } + if (applied) publish(); + else if (++tries < 10) timer = setTimeout(attempt, 500); + }; + + attempt(); + + return () => { + disposed = true; + if (timer) clearTimeout(timer); + if (restores.length) { + for (const r of restores.splice(0)) { try { r(); } catch { /* ignore */ } } + publish(); + } + }; + } + + // ---------------------------------------------------------------- plugin + + return { + inject: ['slots', 'locale'], + apply(ctx) { + ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'git-forge: dictionaries'); + + ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ + name: 'plugins.bundle.config', + key: '@local/git-forge', + locale: NS, + inject: () => ({ t: ctx.locale.bind(NS) }), + }, SettingsPanel)); + + // uiWorkspace is optional: borrow listDirectory from the browse picker's + // injected props, else the service if present, else the host /ls route. + const listDirectory = (path, signal) => { + let svc; + try { svc = ctx.get('uiWorkspace'); } catch { svc = undefined; } + if (svc && typeof svc.listDirectory === 'function') return svc.listDirectory(path, signal); + for (const key of [SLOT_HERO, SLOT_SIDEBAR]) { + try { + for (const e of ctx.slots.entries(key) || []) { + const p = e && e.component !== AddWorkspaceFlow && e.inject ? e.inject() : null; + if (p && typeof p.listDirectory === 'function') return p.listDirectory(path, signal); + } + } catch { /* ignore */ } + } + return Promise.reject(new Error('no-list')); + }; + + const findOther = (key, self) => { + const entries = ctx.slots.entries(key) || []; + let best = null; + for (const entry of entries) { + if (!entry || entry.component === self) continue; + const pr = entry.options && typeof entry.options.priority === 'number' ? entry.options.priority : Infinity; + const bp = best && best.options && typeof best.options.priority === 'number' ? best.options.priority : Infinity; + if (best === null || pr < bp) best = entry; + } + return best ? { Component: best.component, props: best.inject ? best.inject() || {} : {} } : null; + }; + + ctx.slots.inject(SLOT_PICKER, () => ctx.slots.register({ + name: SLOT_PICKER, + // Shadow the core picker (priority 0); lowest priority renders. + priority: -10, + inject: () => ({ + t: ctx.locale.bind(NS), + wsT: ctx.locale.bind('workspace'), + listDirectory, + core: () => findOther(SLOT_PICKER, HeroWorkspacePicker), + browse: () => findOther(SLOT_HERO, AddWorkspaceFlow) || findOther(SLOT_SIDEBAR, AddWorkspaceFlow), + }), + }, HeroWorkspacePicker)); + + const findShadowed = (key) => { + try { + const entries = ctx.slots.entries(key) || []; + let best = null; + let bestPriority = Infinity; + for (const entry of entries) { + if (!entry || entry.component === AddWorkspaceFlow) continue; + const pr = entry.options && typeof entry.options.priority === 'number' ? entry.options.priority : Infinity; + if (best === null || pr < bestPriority) { best = entry; bestPriority = pr; } + } + if (!best) return null; + return { Component: best.component, props: best.inject ? best.inject() || {} : {} }; + } catch { + return null; + } + }; + + const injected = () => ({ t: ctx.locale.bind(NS), listDirectory }); + + // The hero menu has its own "Open local folder…" / "Clone repository…" + // entries, so the hero directory-flow hole stays with the default + // browse picker. Only the sidebar "+" gets the combined clone/local flow. + ctx.slots.inject(SLOT_SIDEBAR, () => ctx.slots.register({ + name: SLOT_SIDEBAR, + priority: -10, + inject: () => ({ ...injected(), shadowed: () => findShadowed(SLOT_SIDEBAR) }), + }, AddWorkspaceFlow)); + }, + }; + }, +}); diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..aa4a40a --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: git-forge + name: '@local/git-forge' diff --git a/credential-helper.mjs b/credential-helper.mjs new file mode 100644 index 0000000..9b07ab5 --- /dev/null +++ b/credential-helper.mjs @@ -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`) diff --git a/forge.mjs b/forge.mjs new file mode 100644 index 0000000..03711ea --- /dev/null +++ b/forge.mjs @@ -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 } +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..7a3447e --- /dev/null +++ b/index.js @@ -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') +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..bdc4ef9 --- /dev/null +++ b/locale/en.json @@ -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." } } diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..e22c973 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1 @@ +{ "meta": { "title": "Git Forge", "description": "从 GitHub(gh)和 Gitea/Forgejo(tea-cli)账号或任意 git 地址克隆仓库为新工作区,并为智能体提供仓库、议题、拉取请求和认证推送等 forge_* 工具。" } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..927f0c9 --- /dev/null +++ b/package.json @@ -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" + ] + } + } +}