@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
338 lines (337 loc) • 16.1 kB
JavaScript
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 & 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.");
}