UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

338 lines (337 loc) • 16.1 kB
import { nonInteractiveGitEnv } from "../utils-helpers.js"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { request } from "node:http"; import { userInfo } from "node:os"; import { homeDir } from "../home-paths.js"; import { readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { phrenStoreRoot } from "./transcripts.js"; import { locateProject } from "./locate.js"; import { BridgeError } from "./protocol.js"; import { countGit } from "./metrics.js"; const exec = promisify(execFile); export async function git(cwd, ...args) { countGit("projects"); return (await exec("git", ["-C", cwd, "--no-pager", ...args], { timeout: 10_000, maxBuffer: 4_194_304, env: nonInteractiveGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_CONFIG_NOSYSTEM: "1" }), })).stdout; } export async function gitRoot(dir) { try { return await realpath((await git(dir, "rev-parse", "--show-toplevel")).trim()); } catch { return undefined; } } /** `git status` as the app lists it: one record per file, a staged and an * unstaged section where each has a patch. Limited to `pathspecs` when given. */ async function statusFiles(root, pathspecs = []) { const status = (await git(root, "status", "--porcelain=v1", "-z", "--untracked-files=normal", "--", ...pathspecs.map(spec => `:(literal)${spec}`))).split("\0"); const files = []; for (let index = 0; index < status.length && files.length < 500; index++) { const record = status[index]; if (!record) continue; const code = record.slice(0, 2), file = record.slice(3); if (/[RC]/.test(code)) index++; // porcelain -z carries a second pathname. const sections = []; for (const [kind, staged] of [["staged", true], ["unstaged", false]]) { if (code === "??") continue; const patch = await git(root, "diff", "--no-ext-diff", "--no-textconv", ...(staged ? ["--cached"] : []), "--", `:(literal)${file}`); if (patch) sections.push({ id: `${kind}:${file}`, kind, binary: patch.includes("Binary files"), loadState: "loaded", patch }); } files.push({ path: file, status: code, sections }); } return files; } /** The last commit that touched `pathspec` in the past half hour — what a * command changed when a hook (phren's own Stop hook, say) committed it before * anyone looked. */ async function committed(root, pathspec) { const log = await git(root, "log", "-1", "--since=30.minutes", "--format=%h%x1f%s%x1f%cr", "-p", "--no-ext-diff", "--no-textconv", "--no-color", "--", `:(literal)${pathspec}`).catch(() => ""); const newline = log.indexOf("\n"); if (newline < 0) return undefined; const [hash, subject, when] = log.slice(0, newline).split("\x1f"); const patch = log.slice(newline + 1).replace(/^\n+/, ""); if (!patch) return undefined; return { id: `committed:${pathspec}`, kind: "committed", binary: patch.includes("Binary files"), loadState: "loaded", patch, note: `${hash} · ${subject.slice(0, 120)} · ${when}` }; } /** A path a command named, made absolute and real — `~/` expanded, relative * ones taken from the pane. The caller checks conversation scope after * resolution. Missing files resolve through their nearest existing parent so * a deleted file still finds its repository. */ async function resolveTouched(raw, cwd) { if (typeof raw !== "string" || !raw || raw.length > 4096 || raw.includes("\0")) return undefined; const home = homeDir(); const absolute = raw === "~" || raw.startsWith("~/") ? path.join(home, raw.slice(1)) : path.resolve(cwd, raw); let existing = absolute, rest = []; while (!(await stat(existing).catch(() => undefined))) { const parent = path.dirname(existing); if (parent === existing) return undefined; rest.unshift(path.basename(existing)); existing = parent; } const real = path.join(await realpath(existing), ...rest); return real; } /** The pane's working tree, plus anything the command named: files in the * same repository that a hook already committed, and files in other * repositories — the phren store, a sibling checkout — grouped by root. */ export async function repositoryDiff(cwd, touched = [], allowedPaths = []) { const root = await gitRoot(cwd); if (!root) throw new BridgeError(409, "This pane is not in a Git repository."); const allowed = (await Promise.all([root, phrenStoreRoot(), ...allowedPaths].map(raw => resolveTouched(raw, cwd)))).filter((p) => !!p); const requested = []; for (const raw of touched.slice(0, 24)) { const file = await resolveTouched(raw, cwd); if (!file || !allowed.some(base => file === base || file.startsWith(base + path.sep))) throw new BridgeError(403, "This path is outside the conversation's recorded changes and commands."); requested.push(file); } const branch = (await git(root, "branch", "--show-current")).trim(); const files = await statusFiles(root); const byRoot = new Map(); for (const file of requested) { const owner = await gitRoot((await stat(file).catch(() => undefined))?.isDirectory() ? file : path.dirname(file)); if (!owner) continue; // git speaks forward slashes on every platform. const rel = (path.relative(owner, file) || ".").split(path.sep).join("/"); if (rel.startsWith(":")) throw new BridgeError(400, "Invalid diff pathspec."); const list = byRoot.get(owner) ?? []; if (!list.includes(rel)) list.push(rel); byRoot.set(owner, list); } const related = []; for (const [owner, specs] of byRoot) { if (related.length >= 8) break; const listed = owner === root ? files : await statusFiles(owner, specs); const seen = new Set(listed.map(file => file.path)); for (const spec of specs) { // Uncommitted changes under the path are already listed; otherwise show the commit. if ([...seen].some(file => file === spec || spec === "." || file.startsWith(spec + "/"))) continue; const section = await committed(owner, spec); if (section) { listed.push({ path: spec, status: " ", sections: [section] }); seen.add(spec); } } if (owner !== root && listed.length) related.push({ root: owner, branch: (await git(owner, "branch", "--show-current")).trim(), files: listed }); } return { branch, root, launchPath: cwd, files, ...(related.length ? { related } : {}) }; } /** The HEAD file of the repository holding `cwd` (`.git/HEAD`, or a linked * worktree's through its `.git` file), found without spawning git. */ export async function headFile(cwd) { let directory = cwd; for (let depth = 0; depth < 64; depth++) { const dotGit = path.join(directory, ".git"); const info = await stat(dotGit).catch(() => undefined); if (info?.isDirectory()) return path.join(dotGit, "HEAD"); if (info?.isFile()) { const match = /^gitdir:\s*(.+)$/m.exec(await readFile(dotGit, "utf8").catch(() => "")); return match ? path.join(path.resolve(directory, match[1].trim()), "HEAD") : undefined; } const parent = path.dirname(directory); if (parent === directory) return undefined; directory = parent; } return undefined; } async function headState(cwd) { const file = await headFile(cwd); if (!file) return undefined; const text = await readFile(file, "utf8").catch(() => undefined); return text === undefined ? undefined : `${file}\n${text}`; } /** How long a branch is reused outright, and the longest it is kept while * the repository's HEAD file reads the same. */ const BRANCH_FRESH_MS = 10_000, BRANCH_MAX_MS = 300_000; /** The pane's current branch for the chat header and the overview cards. * Cached per directory: the status stream asks every 1.5s, the overview every * 3s, and a branch rarely moves. Past ten seconds the cached branch is kept * while HEAD's file and contents are unchanged (a checkout rewrites it), so * `git branch` runs again only after a switch or after five minutes. */ const branches = new Map(); export async function repositoryBranch(cwd) { const cached = branches.get(cwd); const now = Date.now(); if (cached && now - cached.checked < BRANCH_FRESH_MS) return cached.value; const head = await headState(cwd); if (cached && head !== undefined && head === cached.head && now - cached.at < BRANCH_MAX_MS) { cached.checked = now; return cached.value; } let value; try { countGit("branch"); const { stdout } = await exec("git", ["-C", cwd, "--no-pager", "branch", "--show-current"], { timeout: 5_000, maxBuffer: 65_536, env: nonInteractiveGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_CONFIG_NOSYSTEM: "1" }), }); value = stdout.trim().slice(0, 200) || undefined; } catch { value = undefined; } if (branches.size >= 64) branches.delete(branches.keys().next().value); branches.set(cwd, { at: Date.now(), checked: Date.now(), head, value }); return value; } const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }; /** Page titles arrive HTML-escaped ("Safety &amp; Quality"); show them as text. */ function decodeEntities(text) { return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity) => { if (entity[0] === "#") { const code = entity[1]?.toLowerCase() === "x" ? Number.parseInt(entity.slice(2), 16) : Number(entity.slice(1)); return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match; } return ENTITIES[entity.toLowerCase()] ?? match; }); } function probe(port, host) { return new Promise(resolve => { let settled = false; const finish = (value) => { if (!settled) { settled = true; resolve(value); } }; const req = request({ hostname: host, port, path: "/", method: "GET", timeout: 1200, headers: { Host: `localhost:${port}` } }, res => { let body = ""; res.on("data", data => { body += data.toString(); if (body.length > 32_768) res.destroy(); }); const end = () => finish(decodeEntities(/<title[^>]*>([^<]{1,300})<\/title>/i.exec(body)?.[1] ?? "").trim() || `Web server on port ${port}`); res.on("end", end); res.on("close", end); res.on("error", () => finish(null)); }); req.on("error", () => finish(null)); req.on("timeout", () => { req.destroy(); finish(null); }); req.end(); }); } const LISTEN_HOST = /^(\*|127\.0\.0\.1|localhost|0\.0\.0\.0|\[::\]|\[::1\]|::|::1)$/; /** Linux ephemeral range starts here; ports below it are far more likely to be a * dev server than a browser's devtools or IPC listener. */ const EPHEMERAL_PORT = 32_768; function addListener(ports, address, port, processName, pid) { if (!LISTEN_HOST.test(address) || !Number.isInteger(port) || port <= 0) return; const host = address.includes(":") ? "[::1]" : "127.0.0.1"; ports.set(`${host}:${port}`, { name: "", port, origin: `http://${host}:${port}`, process: processName, pid }); } /** Listening TCP sockets owned by this user, via lsof (always present on macOS). */ async function listenersFromLsof(ports) { const result = await exec(process.platform === "darwin" ? "/usr/sbin/lsof" : "lsof", ["-nP", "-a", "-u", userInfo().username, "-iTCP", "-sTCP:LISTEN", "-Fpcn"], { timeout: 4000, maxBuffer: 1_048_576 }); let pid, processName; for (const line of result.stdout.split("\n")) { if (line.startsWith("p")) pid = Number(line.slice(1)); if (line.startsWith("c")) processName = line.slice(1); if (!line.startsWith("n")) continue; const split = line.lastIndexOf(":"); if (split < 0) continue; addListener(ports, line.slice(1, split), Number(line.slice(split + 1)), processName, pid); } } /** Listening TCP sockets via iproute2's ss, which every Linux ships even when * lsof is absent (Arch/Omarchy, minimal containers). Lines look like * `LISTEN 0 512 *:3000 *:* users:(("bun",pid=123,fd=24))`. */ async function listenersFromSs(ports) { const result = await exec("ss", ["-ltnpH"], { timeout: 4000, maxBuffer: 1_048_576 }); for (const line of result.stdout.split("\n")) { const columns = line.trim().split(/\s+/); if (columns.length < 4) continue; const local = columns[3]; const split = local.lastIndexOf(":"); if (split < 0) continue; const owner = /users:\(\("([^"]*)",pid=(\d+)/.exec(line); addListener(ports, local.slice(0, split), Number(local.slice(split + 1)), owner?.[1], owner ? Number(owner[2]) : undefined); } } export async function webServers() { const ports = new Map(); // Prefer ss on Linux and lsof elsewhere; try the other if the first is missing. const readers = process.platform === "linux" ? [listenersFromSs, listenersFromLsof] : [listenersFromLsof, listenersFromSs]; let lastError; for (const reader of readers) { try { await reader(ports); lastError = undefined; break; } catch (error) { lastError = error; } } if (lastError && ports.size === 0) throw new BridgeError(503, "No socket listing tool found: install lsof (or iproute2's ss on Linux)."); // Probe well-known ports first so a browser's dozens of ephemeral listeners // cannot push a dev server on :3000 past the cap. const candidates = [...ports.values()] .sort((a, b) => Number(a.port >= EPHEMERAL_PORT) - Number(b.port >= EPHEMERAL_PORT) || a.port - b.port) .slice(0, 64); const found = []; for (let i = 0; i < candidates.length; i += 8) { await Promise.all(candidates.slice(i, i + 8).map(async (server) => { const name = await probe(server.port, server.origin.includes("[::1]") ? "::1" : "127.0.0.1"); if (name !== null) found.push({ ...server, name }); })); } return found.sort((a, b) => a.port - b.port); } /** Launch only in real local directories under home or a locator candidate. */ export async function launchDirectory(raw, activity = [], located = []) { if (typeof raw !== "string" || raw.length > 4096 || !path.isAbsolute(raw) || /[\x00-\x1f\x7f]/.test(raw)) throw new BridgeError(400, "Invalid workspace directory."); let dir; try { dir = await realpath(raw); if (!(await stat(dir)).isDirectory()) throw new Error(); } catch { throw new BridgeError(400, "Workspace directory does not exist."); } const home = await realpath(homeDir()); if (dir === home || dir.startsWith(home + path.sep)) return dir; for (const candidate of located) { const real = await realpath(candidate).catch(() => undefined); if (real && (dir === real || dir.startsWith(real + path.sep))) return dir; } const names = [...new Set(dir.split(path.sep).filter(name => /^[a-z0-9][a-z0-9-]{0,99}$/.test(name)))]; for (const name of names) { for (const candidate of await locateProject(name, activity)) { if (dir === candidate.directory || dir.startsWith(candidate.directory + path.sep)) return dir; } } throw new BridgeError(403, "Workspace directory must be under home or a located project."); }