@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
268 lines (267 loc) • 13.4 kB
JavaScript
import { spawn } from "node:child_process";
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import { BridgeError } from "./protocol.js";
import { git, gitRoot } from "./projects.js";
import { countGit } from "./metrics.js";
import { defaultBranch, invalidatePulls, invalidateTree } from "./git.js";
import { z } from "zod";
import { detectHost, providerFor, unknownHost } from "./git-hosts.js";
import { writeHostToken } from "./git-host-auth.js";
import { HostApiError, tokenDestination } from "./git-host-http.js";
/** Finishing a session from the phone: commit what is staged, push the
* current branch, open a pull request. Each acts on the repository the
* existing /v1/git/* routes resolve (the pane's, a child's worktree, or a
* listed worktree id). A refusal from Git itself (a hook, a rejected push,
* gh's own error) is a normal answer carrying the output verbatim, since the
* phone's error channel flattens and truncates text. */
const MAX_OUTPUT = 65_536;
const COMMIT_TIMEOUT_MS = 110_000;
const PUSH_TIMEOUT_MS = 110_000;
const MAX_MESSAGE = 20_000;
/** After a timeout's SIGTERM: SIGKILL after this long, answer after twice it. */
const KILL_GRACE_MS = 2_000;
/** One process with stdout and stderr interleaved in arrival order, bounded,
* never attached to a terminal and never prompting. */
export function runCombined(command, args, cwd, timeout, extraEnv = {}, killGraceMs = KILL_GRACE_MS) {
return new Promise(resolve => {
let output = "", truncated = false, timedOut = false, settled = false;
let deadline;
const finish = (run) => {
if (settled)
return;
// The SIGKILL escalation is left to fire: git exiting on SIGTERM must
// not spare a hook's child that ignores it.
settled = true;
clearTimeout(deadline);
resolve(run);
};
// Its own process group, so a timeout reaches what a Git hook started too.
const group = process.platform !== "win32";
const child = spawn(command, args, {
cwd, stdio: ["ignore", "pipe", "pipe"], detached: group,
env: nonInteractiveGitEnv({ ...process.env, ...extraEnv, GIT_CONFIG_NOSYSTEM: "1", GIT_EDITOR: "true", GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", NO_COLOR: "1" }),
});
const append = (chunk) => {
if (truncated)
return;
output += chunk.toString("utf8");
if (output.length > MAX_OUTPUT) {
output = output.slice(0, MAX_OUTPUT) + "\n[output truncated]";
truncated = true;
}
};
child.stdout.on("data", append);
child.stderr.on("data", append);
const kill = (signal) => {
try {
if (group && child.pid)
process.kill(-child.pid, signal);
else
child.kill(signal);
}
catch {
child.kill(signal);
}
};
// The SIGKILL goes only to a group that still has a member: a group id is
// not reused while any member lives, so a hook's child that ignored
// SIGTERM is still ours. Residual race, not closed: if every member exited
// during the grace and the id was then taken by a new group, the probe
// cannot tell the two apart; that needs both an emptied group and an id
// reuse inside one grace period.
const escalate = () => {
if (!group || !child.pid) {
child.kill("SIGKILL");
return;
}
try {
process.kill(-child.pid, 0);
}
catch {
return;
}
try {
process.kill(-child.pid, "SIGKILL");
}
catch { /* gone since the probe */ }
};
// A timeout signals the group at most twice: SIGTERM now, and one grace
// later SIGKILL if the group is still there, whether or not git has
// exited; never again after that.
const timer = setTimeout(() => {
timedOut = true;
kill("SIGTERM");
setTimeout(escalate, killGraceMs).unref();
// A descendant outside the group can hold the pipes open forever:
// answer at the deadline whether or not 'close' ever comes.
deadline = setTimeout(() => {
child.stdout.destroy();
child.stderr.destroy();
finish({ code: null, output: output.trimEnd(), missing: false, timedOut: true });
}, killGraceMs * 2);
}, timeout);
child.on("error", (error) => {
clearTimeout(timer);
finish({ code: null, output: error.message, missing: error.code === "ENOENT", timedOut: false });
});
child.on("close", code => {
clearTimeout(timer);
finish({ code, output: output.trimEnd(), missing: false, timedOut });
});
});
}
async function repository(cwd) {
const root = await gitRoot(cwd);
if (!root)
throw new BridgeError(409, "This pane is not in a Git repository.");
return root;
}
async function currentBranch(root) {
const branch = (await git(root, "branch", "--show-current")).trim();
if (!branch)
throw new BridgeError(409, "HEAD is detached. Check out a branch on the computer first.");
return branch;
}
async function config(root, key) {
return (await git(root, "config", "--get", key).catch(() => "")).trim() || undefined;
}
/** `git commit` of the index only: no `-a`, no `--no-verify`, no amend. A
* hook's refusal comes back as `{ ok: false, output }`, exactly as printed. */
export async function gitCommit(cwd, message) {
if (typeof message !== "string" || !message.trim())
throw new BridgeError(400, "Write a commit message first.");
if (message.length > MAX_MESSAGE || message.includes("\0"))
throw new BridgeError(400, "The commit message is too long.");
const root = await repository(cwd);
// `diff --cached --quiet` exits 1 when something is staged.
const staged = await git(root, "diff", "--cached", "--quiet").then(() => false, () => true);
if (!staged)
throw new BridgeError(409, "Nothing is staged. Stage the files to commit first.");
invalidateTree(root);
try {
countGit("projects");
const run = await runCombined("git", ["-C", root, "--no-pager", "commit", "--quiet", "--cleanup=strip", "-m", message], root, COMMIT_TIMEOUT_MS);
if (run.code !== 0) {
return { ok: false, output: run.timedOut ? `${run.output}\n[git commit did not finish within ${COMMIT_TIMEOUT_MS / 1000} seconds]`.trim() : run.output || "git commit failed." };
}
const [sha, short, subject] = (await git(root, "log", "-1", "--format=%H%x00%h%x00%s")).trim().split("\0");
return { ok: true, sha, short, subject, branch: (await git(root, "branch", "--show-current")).trim(), ...(run.output ? { output: run.output } : {}) };
}
finally {
invalidateTree(root);
}
}
/** The current branch to its upstream, or to `origin` with the upstream set.
* Never forced: no `--force`, no `+` refspec, no `--no-verify`. The default
* branch needs the phone's explicit `confirmDefault`. */
export async function gitPush(cwd, confirmDefault) {
const root = await repository(cwd);
const branch = await currentBranch(root);
const upstreamRemote = await config(root, `branch.${branch}.remote`);
const merge = await config(root, `branch.${branch}.merge`);
const tracked = !!upstreamRemote && upstreamRemote !== "." && !!merge?.startsWith("refs/heads/");
const remote = tracked ? upstreamRemote : "origin";
const remotes = (await git(root, "remote").catch(() => "")).split("\n").map(line => line.trim()).filter(Boolean);
if (!remotes.includes(remote)) {
throw new BridgeError(409, tracked ? `The upstream remote ${remote} is not configured.` : "This branch has no upstream and the repository has no origin remote.");
}
const destination = tracked ? merge.slice("refs/heads/".length) : branch;
const fallback = await defaultBranch(root, remote);
if ((destination === fallback || branch === fallback) && confirmDefault !== true) {
throw new BridgeError(409, `${branch} is the default branch. Confirm to push it.`, { defaultBranch: fallback });
}
const args = ["-C", root, "--no-pager", "push", "--porcelain", ...(tracked ? [] : ["--set-upstream"]), remote, `refs/heads/${branch}:refs/heads/${destination}`];
countGit("projects");
const run = await runCombined("git", args, root, PUSH_TIMEOUT_MS);
invalidateTree(root);
if (run.code !== 0) {
return { ok: false, output: run.timedOut ? `${run.output}\n[git push did not finish within ${PUSH_TIMEOUT_MS / 1000} seconds]`.trim() : run.output || "git push failed." };
}
return { ok: true, branch, remote, upstream: `${remote}/${destination}`, setUpstream: !tracked, output: run.output };
}
/** Open a pull or merge request for the current branch on the remote's host
* (git-hosts.ts). GitHub's: `gh pr create --fill`; a missing or signed-out
* CLI is `{ ok: false, reason: "missing" | "auth" }`, the host's own refusal
* `reason: "failed"` with its output, and a request that already exists for
* the branch is success with `existing: true`. A stubbed host is
* `reason: "unsupported"`, an unrecognised one `reason: "unknown-host"`. */
export async function gitPullRequest(cwd, draft) {
const root = await repository(cwd);
const branch = await currentBranch(root);
const host = await detectHost(root, branch);
const provider = providerFor(host);
if (!provider) {
const { reason, message } = unknownHost(host);
return { ok: false, reason, message, host };
}
try {
return { ...await provider.open(root, branch, draft === true, host), host };
}
finally {
invalidatePulls(root);
}
}
const MergeBody = z.object({
number: z.number().int().positive(),
method: z.enum(["merge", "squash", "rebase"]).default("merge"),
expectedHeadSha: z.string().regex(/^[0-9a-f]{7,64}$/i).optional(),
deleteBranch: z.boolean().optional(),
});
/** Merge one pull or merge request on the remote's host. The host decides:
* its refusal (checks, approvals, conflicts, a head that moved past
* `expectedHeadSha`) is `{ ok: false, reason: "blocked", message }`. */
export async function gitMergeRequest(cwd, data) {
const request = MergeBody.parse(data);
const root = await repository(cwd);
const branch = (await git(root, "branch", "--show-current").catch(() => "")).trim();
const host = await detectHost(root, branch);
const provider = providerFor(host);
if (!provider) {
const { reason, message } = unknownHost(host);
return { ok: false, reason, message, host };
}
try {
return { ...await provider.merge(root, request, host), host };
}
finally {
invalidatePulls(root);
}
}
/** Connect (or, with an empty token, disconnect) this repository's GitLab or
* gitboy host: the token is checked against the host first and stored in the
* Hook's git-hosts.json with the account it signs in as. GitHub keeps gh's. */
export async function gitHostToken(cwd, data) {
const { token, expectedHost } = z.object({ token: z.string().max(4096), expectedHost: z.string().trim().min(1).max(253).optional() }).parse(data);
const root = await repository(cwd);
const branch = (await git(root, "branch", "--show-current").catch(() => "")).trim();
const host = await detectHost(root, branch);
const { api, apiHost } = await tokenDestination(root, host);
host.apiHost = apiHost;
// Pin the resolved endpoint through validation, even if config changes now.
if (expectedHost !== undefined && apiHost !== expectedHost.toLowerCase()) {
throw new BridgeError(409, "The project's remote changed. Reopen to review the new host.", { code: "host-changed" });
}
const provider = providerFor(host);
if (!provider || !host.domain || !host.kind) {
const { reason, message } = unknownHost(host);
return { ok: false, reason, message, host };
}
if (host.kind === "github" || !provider.whoami || !api)
return { ok: false, reason: "unsupported", message: "GitHub uses the GitHub CLI's sign-in: run gh auth login on this computer.", host };
const kind = host.kind;
if (!token.trim()) {
await writeHostToken(host.domain, kind, "");
return { ok: true, disconnected: true, domain: host.domain, host };
}
try {
const identity = await provider.whoami(root, host, token.trim(), api);
const user = identity.user.replaceAll(token.trim(), "[redacted]");
await writeHostToken(host.domain, kind, token.trim(), user || undefined);
return { ok: true, domain: host.domain, kind, ...(user ? { user } : {}), host };
}
catch (error) {
if (error instanceof HostApiError)
return { ok: false, reason: error.reason, message: error.reason === "auth" ? `${host.name} refused that token.` : `${host.name} could not validate that token.`, host };
throw error;
}
}