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.

320 lines · 18.6 KBRaw History
1import { createHash } from "node:crypto";
2import { execFileSync } from "node:child_process";
3import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
4import path from "node:path";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6import { configPath, writeConfig } from "../src/config.js";
7import { fakeServer, gitRepo, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
8
9const helpers = (env: NodeJS.ProcessEnv, url: string) => execFileSync("git", ["config", "--global", "--get-all", `credential.${url}.helper`], { env }).toString().split("\n").slice(0, -1);
10
11/** A gh that records how it was called; `fail` makes `auth login` fail. */
12function fakeGh(fail = false): { dir: string; seen: string } {
13 const dir = tempDir("diffinery-fake-gh-");
14 const seen = path.join(dir, "seen");
15 writeFileSync(path.join(dir, "gh"), `#!/bin/sh\n[ "$1" = --version ] && exit 0\nprintf '%s ' "$@" > "${seen}.args"\ncat > "${seen}.stdin"\n${fail ? "echo 'gh: nope' >&2; exit 1\n" : ""}`);
16 chmodSync(path.join(dir, "gh"), 0o755);
17 return { dir, seen };
18}
19
20describe("diffinery setup", () => {
21 let srv: FakeServer;
22 beforeEach(async () => {
23 srv = await fakeServer();
24 srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } }));
25 srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_good", token_type: "bearer", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } });
26 });
27 afterEach(() => {
28 vi.unstubAllGlobals();
29 return srv.close();
30 });
31
32 /** A browser where the person clicks Authorize. */
33 const approve = async (url: string) => {
34 const u = new URL(url);
35 await fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?code=c&state=${u.searchParams.get("state")}`, { redirect: "manual" });
36 return true;
37 };
38
39 it("signs in through the browser, then sets up git, even when an agent runs it without a terminal", async () => {
40 const env = isolatedEnv();
41 let opened = 0;
42 const r = await run(["setup", "--url", srv.url], { env, stdinTTY: false, openUrl: (u) => (opened++, approve(u)) });
43 expect(r.stderr).not.toContain("error");
44 expect(r.code).toBe(0);
45 expect(opened).toBe(1);
46 expect(r.stdout).toContain(`Logged in to ${srv.url} as alice`);
47 expect(r.stdout).toContain(`git will use diffinery for ${srv.url}`);
48 expect(r.stdout).toContain(`${srv.url}/docs/connect`);
49 expect(r.stderr).toContain("Skipped gh");
50 expect(r.stdout + r.stderr).not.toContain("dfy_good");
51 expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url, token: "dfy_good" });
52 expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]);
53 });
54
55 it("offers auto-merge for the person's pull requests on a terminal, and turns it on with yes", async () => {
56 const env = isolatedEnv();
57 await writeConfig(env, { url: srv.url, token: "dfy_good" });
58 let enabled = false;
59 srv.on("GET", "/api/v3/diffinery/auto-merge", () => ({ body: { enabled, merge_method: enabled ? "default" : null } }));
60 srv.on("PUT", "/api/v3/diffinery/auto-merge", () => ((enabled = true), { body: { enabled: true, merge_method: "default" } }));
61 // No terminal: nothing asked, nothing changed.
62 const quiet = await run(["setup"], { env, stdinTTY: false });
63 expect(quiet.code).toBe(0);
64 expect(quiet.stderr).not.toContain("Turn on auto-merge");
65 expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(0);
66 // Asked and declined.
67 const no = await run(["setup"], { env, stdinTTY: true, stdin: "\n" });
68 expect(no.code).toBe(0);
69 expect(no.stderr).toContain("Turn on auto-merge for pull requests you open? [y/N]");
70 expect(no.stderr).toContain("Auto-merge stays off");
71 expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(0);
72 // Asked and accepted.
73 const yes = await run(["setup"], { env, stdinTTY: true, stdin: "y\n" });
74 expect(yes.code).toBe(0);
75 expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")[0]!.body).toEqual({ enabled: true });
76 expect(yes.stdout).toContain(`Auto-merge turns on by itself for pull requests you open (change it in ${srv.url}/settings/pull-requests)`);
77 // Once on, it only says so.
78 const again = await run(["setup"], { env, stdinTTY: true, stdin: "" });
79 expect(again.stderr).not.toContain("[y/N]");
80 expect(again.stdout).toContain("Auto-merge turns on by itself for pull requests you open");
81 expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(1);
82 });
83
84 it("turns auto-merge on without asking with --auto-merge, and never asks with --no-auto-merge or for an agent", async () => {
85 const env = isolatedEnv();
86 await writeConfig(env, { url: srv.url, token: "dfy_good" });
87 srv.on("GET", "/api/v3/diffinery/auto-merge", { body: { enabled: false, merge_method: null } });
88 srv.on("PUT", "/api/v3/diffinery/auto-merge", { body: { enabled: true, merge_method: "default" } });
89 const skip = await run(["setup", "--no-auto-merge"], { env, stdinTTY: true, stdin: "y\n" });
90 expect(skip.stderr).not.toContain("[y/N]");
91 expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(0);
92 const yes = await run(["setup", "--auto-merge"], { env, stdinTTY: false });
93 expect(yes.code).toBe(0);
94 expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(1);
95 srv.on("GET", "/api/v3/user", { body: { login: "alice-bot", type: "Bot" } });
96 const bot = await run(["setup", "--auto-merge"], { env, stdinTTY: true, stdin: "y\n" });
97 expect(bot.stderr).not.toContain("[y/N]");
98 expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(1);
99 });
100
101 it("keeps a sign-in that still works, and is safe to run again", async () => {
102 const env = isolatedEnv();
103 await writeConfig(env, { url: srv.url, token: "dfy_good" });
104 for (let i = 0; i < 2; i++) {
105 const r = await run(["setup"], { env, openUrl: async () => expect.fail("opened a browser") });
106 expect(r.code).toBe(0);
107 expect(r.stdout).toContain(`Signed in to ${srv.url} as alice`);
108 }
109 expect(srv.find("POST", "/-/cli/token")).toHaveLength(0);
110 expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]);
111 });
112
113 it("signs in again when the saved token no longer works", async () => {
114 const env = isolatedEnv();
115 await writeConfig(env, { url: srv.url, token: "dfy_expired" });
116 const r = await run(["setup"], { env, openUrl: approve });
117 expect(r.code).toBe(0);
118 expect(r.stdout).toContain("Logged in to");
119 expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good");
120 });
121
122 describe("with gh", () => {
123 const host = "https://forge.example.com";
124 // Requests to the HTTPS host reach the fake server, since gh only signs in to HTTPS hosts.
125 beforeEach(() => {
126 const real = fetch;
127 vi.stubGlobal("fetch", (input: string | URL | Request, init?: RequestInit) => real(String(input instanceof Request ? input.url : input).replace(host, srv.url), init));
128 });
129
130 it("signs gh in with the token on stdin", async () => {
131 const gh = fakeGh();
132 const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` });
133 await writeConfig(env, { url: host, token: "dfy_good" });
134 const r = await run(["setup"], { env });
135 expect(r.code).toBe(0);
136 expect(r.stdout).toContain("gh is signed in to forge.example.com");
137 expect(readFileSync(`${gh.seen}.args`, "utf8").trim()).toBe("auth login --hostname forge.example.com --with-token --git-protocol https");
138 expect(readFileSync(`${gh.seen}.stdin`, "utf8")).toBe("dfy_good\n");
139 });
140
141 it("leaves gh to GH_ENTERPRISE_TOKEN when it's in the environment, since gh won't sign in over it", async () => {
142 const gh = fakeGh();
143 const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}`, GH_ENTERPRISE_TOKEN: "dfy_good", GH_HOST: "forge.example.com" });
144 await writeConfig(env, { url: host, token: "dfy_good" });
145 const r = await run(["setup"], { env });
146 expect(r.code).toBe(0);
147 expect(r.stdout).toContain("gh uses GH_ENTERPRISE_TOKEN for forge.example.com");
148 expect(() => readFileSync(`${gh.seen}.args`)).toThrow();
149 const other = await run(["setup"], { env: { ...env, GH_ENTERPRISE_TOKEN: "dfy_other" } });
150 expect(other.code).toBe(1);
151 expect(other.stderr).toContain("isn't your Diffinery token");
152 expect(other.stdout + other.stderr).not.toMatch(/dfy_(good|other)/);
153 });
154
155 it("says when gh isn't installed", async () => {
156 // A PATH with git and nothing else, wherever gh lives on this machine.
157 const dir = tempDir("diffinery-no-gh-");
158 symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(dir, "git"));
159 const env = isolatedEnv({ PATH: dir });
160 await writeConfig(env, { url: host, token: "dfy_good" });
161 const r = await run(["setup"], { env });
162 expect(r.code).toBe(0);
163 expect(r.stderr).toContain("Skipped gh, which isn't installed");
164 });
165
166 it("finishes the rest and exits 1 when gh fails", async () => {
167 const gh = fakeGh(true);
168 const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` });
169 await writeConfig(env, { url: host, token: "dfy_good" });
170 const r = await run(["setup"], { env });
171 expect(r.code).toBe(1);
172 expect(r.stderr).toContain("gh auth login failed: gh: nope");
173 expect(r.stdout).toContain("git will use diffinery");
174 expect(r.stdout).toContain(`${host}/docs/connect`);
175 });
176 });
177});
178
179describe("diffinery setup --repo (an agent environment, as the hosted agent-setup.sh runs it)", () => {
180 let srv: FakeServer;
181 let push: boolean;
182 beforeEach(async () => {
183 const root = tempDir("diffinery-git-root-");
184 execFileSync("git", ["init", "-q", "--bare", "-b", "main", path.join(root, "ada", "zutrix.git")]);
185 const seed = gitRepo(isolatedEnv(), path.join(root, "ada", "zutrix.git"));
186 execFileSync("git", ["push", "-q", "origin", "main"], { cwd: seed });
187 srv = await fakeServer({ gitRoot: root });
188 push = true;
189 srv.on("GET", "/healthz", { body: { ok: true } });
190 srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_agent" ? { body: { login: "zutrix-agent", type: "Bot" } } : { status: 401, body: { message: "bad credentials" } }));
191 srv.on("GET", "/api/v3/repos/ada/zutrix", () => ({ body: { full_name: "ada/zutrix", permissions: { pull: true, push } } }));
192 srv.on("POST", "/api/v3/repos/ada/zutrix/diffinery/agent-setup/checks", { status: 201, body: { ok: true } });
193 });
194 afterEach(() => srv.close());
195
196 /** A PATH with git and a diffinery command, and no gh. */
197 const machine = (extra: NodeJS.ProcessEnv = {}) => {
198 const bin = tempDir("diffinery-agent-bin-");
199 symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(bin, "git"));
200 writeFileSync(path.join(bin, "diffinery"), "#!/bin/sh\nexit 0\n");
201 chmodSync(path.join(bin, "diffinery"), 0o755);
202 return isolatedEnv({ PATH: bin, DIFFINERY_CLONE_DIR: tempDir("diffinery-clones-"), ...extra });
203 };
204 const reports = () => srv.find("POST", "/api/v3/repos/ada/zutrix/diffinery/agent-setup/checks").map((r) => r.body as { checks: Array<{ name: string; ok: boolean; message: string }> });
205
206 it("checks each part with the token from the environment, clones, reports, and writes no token to disk", async () => {
207 const env = machine({ DIFFINERY_TOKEN: "dfy_agent" });
208 const r = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url], { env });
209 expect(r.stderr).toBe("");
210 expect(r.code).toBe(0);
211 for (const s of [`✓ server: ${srv.host} is reachable`, "✓ DIFFINERY_TOKEN: acts as zutrix-agent (agent)", "✓ repository: ada/zutrix with write access", "✓ git: git can reach", `✓ clone: cloned into ${path.join(env.DIFFINERY_CLONE_DIR!, "zutrix")}`, "✓ gh: skipped", "Ready:"]) expect(r.stdout).toContain(s);
212 expect(r.stdout).not.toContain("dfy_agent");
213 expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url });
214 const [report] = reports();
215 expect(report!.checks.every((l) => l.ok)).toBe(true);
216 // Again, as at each session start: the clone is fetched, not cloned again.
217 const again = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url], { env });
218 expect(again.code).toBe(0);
219 expect(again.stdout).toContain("(up to date)");
220 });
221
222 it("says what to change, exits 1, and still reports, when a check fails", async () => {
223 push = false;
224 const env = machine({ DIFFINERY_TOKEN: "dfy_agent" });
225 const r = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env });
226 expect(r.code).toBe(1);
227 expect(r.stdout).toContain(`✗ repository: zutrix-agent can read ada/zutrix but not push: use a token from ${srv.url}/ada/zutrix/settings/agent-setup`);
228 expect(r.stdout).toContain("1 of");
229 expect(reports()[0]!.checks.find((l) => l.name === "repository")!.ok).toBe(false);
230 });
231
232 it("names the token that's missing or refused, with where to get a new one", async () => {
233 const missing = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env: machine() });
234 expect(missing.code).toBe(1);
235 expect(missing.stdout).toContain(`✗ DIFFINERY_TOKEN: not set: add DIFFINERY_TOKEN=<token> to the environment variables (generate one on ${srv.url}/ada/zutrix/settings/agent-setup)`);
236 const refused = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env: machine({ DIFFINERY_TOKEN: "dfy_revoked" }) });
237 expect(refused.code).toBe(1);
238 expect(refused.stdout).toContain("✗ DIFFINERY_TOKEN: refused (revoked or expired)");
239 expect(refused.stdout).not.toContain("dfy_revoked");
240 expect(reports()).toHaveLength(0);
241 });
242
243 it("refuses a --repo that isn't OWNER/NAME", async () => {
244 const r = await run(["setup", "--repo", "zutrix", "--url", srv.url], { env: machine({ DIFFINERY_TOKEN: "dfy_agent" }) });
245 expect(r.code).toBe(2);
246 expect(r.stderr).toContain("--repo expects OWNER/NAME");
247 });
248});
249
250describe("Claude Code plugins in diffinery setup", () => {
251 let srv: FakeServer;
252 const zips = { diffinery: "PK\u0003\u0004 diffinery plugin", "diffinery-ci": "PK\u0003\u0004 diffinery-ci plugin" } as const;
253 const sha = (s: string) => createHash("sha256").update(s).digest("hex");
254 beforeEach(async () => {
255 srv = await fakeServer();
256 srv.on("GET", "/api/v3/user", { body: { login: "alice", type: "User" } });
257 for (const [name, body] of Object.entries(zips))
258 srv.on("GET", `/-/claude/${name}.zip`, { body, headers: { "content-type": "application/zip", "x-diffinery-plugin-version": name === "diffinery" ? "0.1.1" : "0.2.0", "x-diffinery-plugin-sha256": sha(body) } });
259 });
260 afterEach(() => srv.close());
261
262 /** A machine with Claude Code: its settings directory, with `settings` in it when given. */
263 const withClaude = async (settings?: unknown) => {
264 const env = isolatedEnv({ DIFFINERY_NO_CLAUDE_PLUGINS: "" });
265 await writeConfig(env, { url: srv.url, token: "dfy_good" });
266 mkdirSync(path.join(env.HOME!, ".claude"));
267 if (settings !== undefined) writeFileSync(path.join(env.HOME!, ".claude", "settings.json"), typeof settings === "string" ? settings : JSON.stringify(settings));
268 return env;
269 };
270 const settingsOf = (env: NodeJS.ProcessEnv) => JSON.parse(readFileSync(path.join(env.HOME!, ".claude", "settings.json"), "utf8"));
271 const dirsOf = (env: NodeJS.ProcessEnv) => (settingsOf(env).env.CLAUDE_CODE_PLUGIN_DIRS as string).split(path.delimiter);
272
273 it("saves both plugins and names them in Claude Code's settings, keeping what was there, and runs again cleanly", async () => {
274 const env = await withClaude({ model: "opus", env: { FOO: "1", CLAUDE_CODE_PLUGIN_DIRS: "/opt/mine" } });
275 for (let i = 0; i < 2; i++) {
276 const r = await run(["setup"], { env, openUrl: async () => expect.fail("opened a browser") });
277 expect(r.code).toBe(0);
278 expect(r.stdout).toContain("✓ Claude Code plugins: diffinery 0.1.1 and diffinery-ci 0.2.0 load in new Claude Code sessions");
279 }
280 const dir = path.join(env.HOME!, ".claude", "diffinery-plugins");
281 expect(dirsOf(env)).toEqual(["/opt/mine", path.join(dir, "diffinery.zip"), path.join(dir, "diffinery-ci.zip")]);
282 expect(settingsOf(env)).toMatchObject({ model: "opus", env: { FOO: "1" } });
283 expect(readFileSync(path.join(dir, "diffinery-ci.zip"), "utf8")).toBe(zips["diffinery-ci"]);
284 });
285
286 it("does nothing without Claude Code, with --no-claude-plugins, or for a plugin installed from the marketplace", async () => {
287 // No Claude Code: no ~/.claude, and a PATH with git alone.
288 const bin = tempDir("diffinery-no-claude-");
289 symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(bin, "git"));
290 const none = isolatedEnv({ DIFFINERY_NO_CLAUDE_PLUGINS: "", PATH: bin });
291 await writeConfig(none, { url: srv.url, token: "dfy_good" });
292 expect((await run(["setup"], { env: none })).stdout).not.toContain("Claude Code plugins");
293 expect(existsSync(path.join(none.HOME!, ".claude"))).toBe(false);
294
295 const skip = await withClaude();
296 expect((await run(["setup", "--no-claude-plugins"], { env: skip })).stdout).not.toContain("Claude Code plugins");
297 expect(existsSync(path.join(skip.HOME!, ".claude", "settings.json"))).toBe(false);
298
299 const market = await withClaude({ enabledPlugins: { "diffinery-ci@diffinery": true } });
300 const r = await run(["setup"], { env: market });
301 expect(r.stdout).toContain("diffinery 0.1.1 load in new Claude Code sessions");
302 expect(r.stdout).toContain("diffinery-ci already installed from the Diffinery marketplace");
303 expect(dirsOf(market)).toEqual([path.join(market.HOME!, ".claude", "diffinery-plugins", "diffinery.zip")]);
304 });
305
306 it("leaves settings it can't read alone, and refuses a download that doesn't match its checksum", async () => {
307 const broken = await withClaude("{ not json");
308 const r = await run(["setup"], { env: broken });
309 expect(r.code).toBe(1);
310 expect(r.stderr).toContain("isn't valid JSON, so it was left alone");
311 expect(readFileSync(path.join(broken.HOME!, ".claude", "settings.json"), "utf8")).toBe("{ not json");
312
313 srv.on("GET", "/-/claude/diffinery-ci.zip", { body: "tampered", headers: { "content-type": "application/zip", "x-diffinery-plugin-sha256": sha("other") } });
314 const bad = await withClaude();
315 const r2 = await run(["setup"], { env: bad });
316 expect(r2.code).toBe(1);
317 expect(r2.stderr).toContain("didn't match its checksum");
318 expect(existsSync(path.join(bad.HOME!, ".claude", "settings.json"))).toBe(false);
319 });
320});