Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

276 lines · 13.2 KBRaw History
1import { spawn } from "node:child_process";
2import { existsSync, statSync } from "node:fs";
3import { access, constants } from "node:fs/promises";
4import path from "node:path";
5import { Api, ApiError, enc } from "../api.js";
6import { Ctx, type RepoRef } from "../cli.js";
7import { gitCapture, parseRemote } from "../git.js";
8import { CliError } from "../io.js";
9import { VERSION } from "../version.js";
10
11/**
12 * `diffinery setup --repo OWNER/NAME`: sets up an agent environment (a Claude Code cloud environment,
13 * Codex, a CI runner) for one repository, without a person at the keyboard. What the hosted script
14 * (<server>/<owner>/<repo>/agent-setup.sh, from Settings → Agent setup) runs once it has installed the CLI.
15 *
16 * The token comes from DIFFINERY_TOKEN and is never written to disk: git asks `diffinery auth
17 * git-credential`, which reads it from the environment each time, and gh reads GH_ENTERPRISE_TOKEN. So a
18 * snapshot of the machine (Claude Code caches what a setup script leaves on disk) holds no credential,
19 * and a new token works as soon as the environment variables change. Then a self-test, one line per
20 * check saying what to change when it fails, and a report to the repository's Agent setup page (its Check).
21 */
22
23export interface CheckLine {
24 name: string;
25 ok: boolean;
26 message: string;
27}
28
29interface Proc {
30 code: number;
31 stdout: string;
32 stderr: string;
33}
34
35/** Runs a program and captures its output; a program that isn't installed exits 127. */
36export function capture(cmd: string, args: string[], o: { env: NodeJS.ProcessEnv; cwd?: string; input?: string }): Promise<Proc> {
37 return new Promise((resolve) => {
38 let child;
39 try {
40 child = spawn(cmd, args, { env: o.env, cwd: o.cwd, stdio: [o.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
41 } catch (e) {
42 return resolve({ code: 127, stdout: "", stderr: (e as Error).message });
43 }
44 let stdout = "";
45 let stderr = "";
46 child.stdout?.on("data", (d: Buffer) => (stdout += d.toString()));
47 child.stderr?.on("data", (d: Buffer) => (stderr += d.toString()));
48 child.on("error", (e) => resolve({ code: (e as NodeJS.ErrnoException).code === "ENOENT" ? 127 : 1, stdout, stderr: stderr + e.message }));
49 child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
50 if (child.stdin) {
51 child.stdin.on("error", () => {});
52 child.stdin.end(o.input);
53 }
54 });
55}
56
57/** The gh token environment variables for a host other than github.com, in gh's order. */
58const GH_ENTERPRISE_VARS = ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] as const;
59
60/** Which environment variable gives gh its token for `host`, if any (gh then refuses `gh auth login`). */
61export function ghEnvToken(env: NodeJS.ProcessEnv, host: string): { name: string; value: string } | null {
62 if (/^(github\.com|.*\.ghe\.com)$/i.test(host)) return null;
63 for (const name of GH_ENTERPRISE_VARS) if (env[name]) return { name, value: env[name]! };
64 return null;
65}
66
67/** First line of a command's error output, without anything token-like. */
68const firstLine = (s: string) =>
69 (s.trim().split("\n").find((l) => l.trim()) ?? "")
70 .replace(/dfy_[A-Za-z0-9]+/g, "dfy_…")
71 .slice(0, 200);
72
73/** Where the clone goes: --clone-dir, DIFFINERY_CLONE_DIR, a Claude Code cloud session's /home/user, or here. */
74export function cloneParent(env: NodeJS.ProcessEnv, cwd: string, flag?: string): string {
75 const chosen = flag ?? env.DIFFINERY_CLONE_DIR;
76 if (chosen) return path.resolve(cwd, chosen);
77 // Claude Code cloud sessions start in /home/user, but setup scripts run elsewhere.
78 const claude = "/home/user";
79 const inside = cwd === claude || cwd.startsWith(`${claude}/`);
80 if (!inside && isDir(claude)) return claude;
81 return cwd;
82}
83
84function isDir(p: string): boolean {
85 try {
86 return statSync(p).isDirectory();
87 } catch {
88 return false;
89 }
90}
91
92/** Whether `dir`, or a directory above it, is a clone of `repo` on `host`. Returns its top level. */
93async function cloneRoot(ctx: Ctx, dir: string, host: string, repo: RepoRef): Promise<string | null> {
94 if (!isDir(dir)) return null;
95 const top = await gitCapture(ctx.io, ["rev-parse", "--show-toplevel"], { cwd: dir });
96 if (top.code !== 0) return null;
97 const url = await gitCapture(ctx.io, ["remote", "get-url", "origin"], { cwd: dir });
98 const r = url.code === 0 ? parseRemote(url.stdout, host) : null;
99 return r && r.owner.toLowerCase() === repo.owner.toLowerCase() && r.name.toLowerCase() === repo.name.toLowerCase() ? top.stdout.trim() : null;
100}
101
102/** An executable named `name` in one of PATH's directories. */
103async function onPath(name: string, pathVar: string | undefined): Promise<string | null> {
104 for (const dir of (pathVar ?? "").split(path.delimiter)) {
105 if (!dir) continue;
106 const file = path.join(dir, name);
107 try {
108 await access(file, constants.X_OK);
109 return file;
110 } catch {
111 /* next */
112 }
113 }
114 return null;
115}
116
117export interface AgentSetupOptions {
118 repo: RepoRef;
119 url: string;
120 clone: boolean;
121 cloneDir?: string;
122 report: boolean;
123}
124
125export async function agentSetup(ctx: Ctx, o: AgentSetupOptions, wireGit: (ctx: Ctx) => Promise<void>): Promise<number> {
126 const { io } = ctx;
127 const env = io.env;
128 const url = o.url;
129 const host = new URL(url).host;
130 const slug = `${o.repo.owner}/${o.repo.name}`;
131 const page = `${url}/${slug}/settings/agent-setup`;
132 const lines: CheckLine[] = [];
133 const line = (name: string, ok: boolean, message: string) => {
134 lines.push({ name, ok, message });
135 ctx.out(`${ok ? ctx.c.green("✓") : ctx.c.red("✗")} ${name}: ${message}`);
136 };
137
138 const cfg = await ctx.config();
139 const token = cfg.token;
140
141 // 1. The server.
142 let reachable = false;
143 try {
144 const res = await fetch(`${url}/healthz`, { headers: { "user-agent": `diffinery-cli/${VERSION}` } });
145 reachable = res.ok;
146 line("server", res.ok, res.ok ? `${host} is reachable` : `${host} answered ${res.status}; try again in a minute`);
147 } catch (e) {
148 const cause = (e as { cause?: { code?: string } }).cause?.code ?? (e as Error).message;
149 line("server", false, `can't reach ${host} (${cause}): allow ${host} in the environment's network settings (Custom with ${host}, or Full)`);
150 }
151
152 // 2. The diffinery command, for git's credential helper and for the agent.
153 const cmd = await onPath("diffinery", env.DIFFINERY_SETUP_PATH ?? env.PATH);
154 line("diffinery on PATH", !!cmd, cmd ? `${cmd} (${VERSION})` : "not on PATH: run the setup script again, or add ~/.diffinery/bin to PATH");
155
156 // 3. The token and what it can do.
157 let login: string | null = null;
158 let canPush = false;
159 if (!token) {
160 line("DIFFINERY_TOKEN", false, `not set: add DIFFINERY_TOKEN=<token> to the environment variables (generate one on ${page})`);
161 } else if (reachable) {
162 const api = new Api(url, token);
163 try {
164 const me = await api.get<{ login: string; type?: string }>("/user");
165 login = me.login;
166 line("DIFFINERY_TOKEN", true, `acts as ${me.login}${me.type === "Bot" ? " (agent)" : ""}`);
167 } catch (e) {
168 line("DIFFINERY_TOKEN", false, e instanceof ApiError && e.status === 401 ? `refused (revoked or expired): generate a new token on ${page} and paste it into DIFFINERY_TOKEN and GH_ENTERPRISE_TOKEN` : firstLine((e as Error).message));
169 }
170 if (login) {
171 try {
172 const r = await api.get<{ permissions?: { push?: boolean } }>(`/repos/${enc(o.repo.owner)}/${enc(o.repo.name)}`);
173 canPush = !!r.permissions?.push;
174 line("repository", canPush, canPush ? `${slug} with write access` : `${login} can read ${slug} but not push: use a token from ${page}`);
175 } catch (e) {
176 line("repository", false, e instanceof ApiError && e.status === 404 ? `the token can't see ${slug}: use a token from ${page}` : firstLine((e as Error).message));
177 }
178 }
179 }
180
181 // 4. git: the credential helper reads the token from the environment whenever git asks.
182 try {
183 await wireGit(ctx);
184 } catch (e) {
185 line("git", false, firstLine((e as Error).message));
186 }
187 const remote = `${url}/${slug}.git`;
188 const gitEnv = { ...env, GIT_TERMINAL_PROMPT: "0" };
189 if (token && reachable && !lines.some((l) => l.name === "git")) {
190 const r = await gitCapture(io, ["ls-remote", "--heads", remote], { env: gitEnv });
191 line("git", r.code === 0, r.code === 0 ? `git can reach ${remote} with no prompt` : `git ls-remote failed: ${firstLine(r.stderr) || `exit ${r.code}`}`);
192 }
193
194 // 5. The clone.
195 let clone: string | null = null;
196 if (o.clone && token && reachable) {
197 const parent = cloneParent(env, io.cwd, o.cloneDir);
198 clone = (await cloneRoot(ctx, io.cwd, host, o.repo)) ?? null;
199 const target = clone ?? path.join(parent, o.repo.name);
200 if (!clone && existsSync(target)) {
201 clone = await cloneRoot(ctx, target, host, o.repo);
202 if (!clone) line("clone", false, `${target} exists and isn't a clone of ${slug}: move it, or set DIFFINERY_CLONE_DIR to another directory`);
203 }
204 if (clone) {
205 const f = await gitCapture(io, ["fetch", "--quiet", "origin"], { cwd: clone, env: gitEnv });
206 let note = "fetched";
207 if (f.code === 0) {
208 // A clean checkout moves up to its branch on the server, as a fresh clone would be.
209 const dirty = (await gitCapture(io, ["status", "--porcelain"], { cwd: clone })).stdout.trim();
210 const up = await gitCapture(io, ["rev-parse", "--abbrev-ref", "@{upstream}"], { cwd: clone });
211 if (!dirty && up.code === 0 && (await gitCapture(io, ["merge", "--ff-only", "--quiet", up.stdout.trim()], { cwd: clone })).code === 0) note = "up to date";
212 }
213 line("clone", f.code === 0, f.code === 0 ? `${clone} (${note})` : `git fetch failed in ${clone}: ${firstLine(f.stderr)}`);
214 } else if (!lines.some((l) => l.name === "clone")) {
215 const r = await gitCapture(io, ["clone", "--quiet", remote, target], { env: gitEnv });
216 if (r.code === 0) clone = target;
217 line("clone", r.code === 0, r.code === 0 ? `cloned into ${target}` : `git clone failed: ${firstLine(r.stderr) || `exit ${r.code}`}`);
218 }
219 }
220
221 // 6. gh: GH_ENTERPRISE_TOKEN in the environment, or gh's own sign-in when it isn't set.
222 const ghVersion = await capture("gh", ["--version"], { env });
223 if (ghVersion.code !== 0) {
224 line("gh", true, "skipped: the GitHub CLI isn't installed (install it for gh pr and gh api: https://cli.github.com)");
225 } else if (token && reachable && login) {
226 const fromEnv = ghEnvToken(env, host);
227 let ready = true;
228 if (fromEnv && fromEnv.value !== token) {
229 line("gh", false, `${fromEnv.name} isn't the same token as DIFFINERY_TOKEN: paste the same token into both`);
230 ready = false;
231 } else if (!fromEnv) {
232 // No token for gh in the environment: sign gh in with this one (gh keeps it in its own config).
233 const r = await capture("gh", ["auth", "login", "--hostname", host, "--with-token", "--git-protocol", "https"], { env, input: `${token}\n` });
234 if (r.code !== 0) {
235 line("gh", false, `gh auth login failed: ${firstLine(r.stderr)}; or add GH_ENTERPRISE_TOKEN=<token> and GH_HOST=${host} to the environment variables`);
236 ready = false;
237 }
238 }
239 if (ready && fromEnv && env.GH_HOST !== host) {
240 line("GH_HOST", false, env.GH_HOST ? `is ${env.GH_HOST}: set GH_HOST=${host} in the environment variables` : `not set: add GH_HOST=${host} to the environment variables`);
241 ready = false;
242 }
243 if (ready) {
244 const r = await capture("gh", ["api", "user", "--hostname", host, "--jq", ".login"], { env });
245 const who = r.stdout.trim();
246 line("gh", r.code === 0 && who === login, r.code === 0 ? (who === login ? `gh api acts as ${who}` : `gh acts as ${who}, not ${login}: paste the same token into GH_ENTERPRISE_TOKEN and DIFFINERY_TOKEN`) : `gh api user failed: ${firstLine(r.stderr)}`);
247 if (clone && r.code === 0 && who === login) {
248 const prs = await capture("gh", ["pr", "list", "--state", "all", "--limit", "1", "--json", "number"], { env, cwd: clone });
249 line("gh in the clone", prs.code === 0, prs.code === 0 ? "gh pr list works in the clone" : `gh pr list failed in ${clone}: ${firstLine(prs.stderr)}`);
250 }
251 }
252 }
253
254 // 7. The report, which is the Check on the repository's Agent setup page.
255 const ok = lines.every((l) => l.ok);
256 if (o.report && token && reachable && login) {
257 try {
258 await new Api(url, token).post(`/repos/${enc(o.repo.owner)}/${enc(o.repo.name)}/diffinery/agent-setup/checks`, { checks: lines, cli_version: VERSION });
259 ctx.out(ctx.c.dim(`Reported to ${page}`));
260 } catch (e) {
261 ctx.err(`${ctx.c.yellow("warning:")} couldn't report the check to ${page}: ${firstLine((e as Error).message)}`);
262 }
263 }
264 const failed = lines.filter((l) => !l.ok).length;
265 ctx.out("");
266 ctx.out(ok ? `${ctx.c.green("✓")} Ready: git, gh and the diffinery CLI work with ${slug}${clone ? ` (in ${clone})` : ""}.` : `${ctx.c.red("✗")} ${failed} of ${lines.length} checks failed; fix them as each line says, then run this again.`);
267 return ok ? 0 : 1;
268}
269
270/** Parses --repo for setup, which must name the repository explicitly. */
271export function setupRepo(v: string): RepoRef {
272 const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9._-]+)$/.exec(v.trim().replace(/\.git$/, ""));
273 if (!m) throw new CliError(`--repo expects OWNER/NAME, got "${v}"`, 2);
274 return { owner: m[1]!, name: m[2]! };
275}
276