import { execSync } from 'child_process'; import { writeFileSync, mkdirSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; // Askpass helper: git invokes this script (path is what shows up in ps/args), // and it reads the actual token from an env var — never from argv or the URL. // This keeps the token out of the process table and out of any git error text. let _askpassPath = null; function askpassScript() { if (_askpassPath) return _askpassPath; const dir = join(tmpdir(), 'agap-mcp-wiki'); mkdirSync(dir, { recursive: true }); const scriptPath = join(dir, 'git-askpass.sh'); writeFileSync(scriptPath, '#!/bin/sh\nprintf %s "$GITEA_ASKPASS_TOKEN"\n', { mode: 0o700 }); _askpassPath = scriptPath; return scriptPath; } const BASE = () => process.env.GITEA_URL || 'http://localhost:3000'; let _token = null; export function initGitea(token) { _token = token; console.log('Gitea: ready'); } function token() { if (!_token) throw new Error('Gitea not initialized'); return _token; } async function api(path, opts = {}) { const res = await fetch(`${BASE()}/api/v1${path}`, { ...opts, headers: { Authorization: `token ${token()}`, 'Content-Type': 'application/json', ...opts.headers }, }); if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); return res.json(); } export async function giteaListRepos() { return api('/repos/search?limit=50').then(r => r.data.map(r => ({ name: r.full_name, description: r.description, stars: r.stars_count, }))); } export async function giteaReadFile(repo, path, ref = 'HEAD') { const data = await api(`/repos/${repo}/contents/${path}?ref=${ref}`); return Buffer.from(data.content, 'base64').toString('utf8'); } export async function giteaWikiList(repo = 'alvis/AgapHost') { const data = await api(`/repos/${repo}/wiki/pages?limit=50`); return data.map(p => ({ name: p.title, updated: p.last_commit?.created })); } export async function giteaWikiRead(page, repo = 'alvis/AgapHost') { const data = await api(`/repos/${repo}/wiki/page/${encodeURIComponent(page)}`); return Buffer.from(data.content_base64, 'base64').toString('utf8'); } export async function giteaWikiWrite(page, content, message, repo = 'alvis/AgapHost') { const dir = join(tmpdir(), 'agap-mcp-wiki'); // Username in the URL is not secret; the password/token is supplied out-of-band // via GIT_ASKPASS + GITEA_ASKPASS_TOKEN, so it never appears in the URL, the // execSync command string, ps/process args, or surfaced git error output. const wikiUrl = `${BASE().replace('http://', 'http://alvis@')}/alvis/AgapHost.wiki.git`; const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'agap-mcp', GIT_AUTHOR_EMAIL: 'allogn@gmail.com', GIT_COMMITTER_NAME: 'agap-mcp', GIT_COMMITTER_EMAIL: 'allogn@gmail.com', GIT_ASKPASS: askpassScript(), GIT_TERMINAL_PROMPT: '0', GITEA_ASKPASS_TOKEN: token(), }; try { execSync(`git -C ${dir} pull ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' }); } catch { execSync(`git clone ${wikiUrl} ${dir}`, { env: gitEnv, stdio: 'pipe' }); } const file = join(dir, `${page}.md`); writeFileSync(file, content); execSync(`git -C ${dir} add "${page}.md"`, { env: gitEnv, stdio: 'pipe' }); execSync(`git -C ${dir} commit -m "${message || `Update ${page}`}"`, { env: gitEnv, stdio: 'pipe' }); execSync(`git -C ${dir} push ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' }); return `${page} updated`; } export async function giteaListIssues(repo, state = 'open') { return api(`/repos/${repo}/issues?state=${state}&type=issues&limit=50`).then(issues => issues.map(i => ({ number: i.number, title: i.title, state: i.state, labels: i.labels.map(l => l.name) })) ); }