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.

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