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.

274 lines · 15.7 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("keeps a sign-in that still works, and is safe to run again", async () => {
56 const env = isolatedEnv();
57 await writeConfig(env, { url: srv.url, token: "dfy_good" });
58 for (let i = 0; i < 2; i++) {
59 const r = await run(["setup"], { env, openUrl: async () => expect.fail("opened a browser") });
60 expect(r.code).toBe(0);
61 expect(r.stdout).toContain(`Signed in to ${srv.url} as alice`);
62 }
63 expect(srv.find("POST", "/-/cli/token")).toHaveLength(0);
64 expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]);
65 });
66
67 it("signs in again when the saved token no longer works", async () => {
68 const env = isolatedEnv();
69 await writeConfig(env, { url: srv.url, token: "dfy_expired" });
70 const r = await run(["setup"], { env, openUrl: approve });
71 expect(r.code).toBe(0);
72 expect(r.stdout).toContain("Logged in to");
73 expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good");
74 });
75
76 describe("with gh", () => {
77 const host = "https://forge.example.com";
78 // Requests to the HTTPS host reach the fake server, since gh only signs in to HTTPS hosts.
79 beforeEach(() => {
80 const real = fetch;
81 vi.stubGlobal("fetch", (input: string | URL | Request, init?: RequestInit) => real(String(input instanceof Request ? input.url : input).replace(host, srv.url), init));
82 });
83
84 it("signs gh in with the token on stdin", async () => {
85 const gh = fakeGh();
86 const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` });
87 await writeConfig(env, { url: host, token: "dfy_good" });
88 const r = await run(["setup"], { env });
89 expect(r.code).toBe(0);
90 expect(r.stdout).toContain("gh is signed in to forge.example.com");
91 expect(readFileSync(`${gh.seen}.args`, "utf8").trim()).toBe("auth login --hostname forge.example.com --with-token --git-protocol https");
92 expect(readFileSync(`${gh.seen}.stdin`, "utf8")).toBe("dfy_good\n");
93 });
94
95 it("leaves gh to GH_ENTERPRISE_TOKEN when it's in the environment, since gh won't sign in over it", async () => {
96 const gh = fakeGh();
97 const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}`, GH_ENTERPRISE_TOKEN: "dfy_good", GH_HOST: "forge.example.com" });
98 await writeConfig(env, { url: host, token: "dfy_good" });
99 const r = await run(["setup"], { env });
100 expect(r.code).toBe(0);
101 expect(r.stdout).toContain("gh uses GH_ENTERPRISE_TOKEN for forge.example.com");
102 expect(() => readFileSync(`${gh.seen}.args`)).toThrow();
103 const other = await run(["setup"], { env: { ...env, GH_ENTERPRISE_TOKEN: "dfy_other" } });
104 expect(other.code).toBe(1);
105 expect(other.stderr).toContain("isn't your Diffinery token");
106 expect(other.stdout + other.stderr).not.toMatch(/dfy_(good|other)/);
107 });
108
109 it("says when gh isn't installed", async () => {
110 // A PATH with git and nothing else, wherever gh lives on this machine.
111 const dir = tempDir("diffinery-no-gh-");
112 symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(dir, "git"));
113 const env = isolatedEnv({ PATH: dir });
114 await writeConfig(env, { url: host, token: "dfy_good" });
115 const r = await run(["setup"], { env });
116 expect(r.code).toBe(0);
117 expect(r.stderr).toContain("Skipped gh, which isn't installed");
118 });
119
120 it("finishes the rest and exits 1 when gh fails", async () => {
121 const gh = fakeGh(true);
122 const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` });
123 await writeConfig(env, { url: host, token: "dfy_good" });
124 const r = await run(["setup"], { env });
125 expect(r.code).toBe(1);
126 expect(r.stderr).toContain("gh auth login failed: gh: nope");
127 expect(r.stdout).toContain("git will use diffinery");
128 expect(r.stdout).toContain(`${host}/docs/connect`);
129 });
130 });
131});
132
133describe("diffinery setup --repo (an agent environment, as the hosted agent-setup.sh runs it)", () => {
134 let srv: FakeServer;
135 let push: boolean;
136 beforeEach(async () => {
137 const root = tempDir("diffinery-git-root-");
138 execFileSync("git", ["init", "-q", "--bare", "-b", "main", path.join(root, "ada", "zutrix.git")]);
139 const seed = gitRepo(isolatedEnv(), path.join(root, "ada", "zutrix.git"));
140 execFileSync("git", ["push", "-q", "origin", "main"], { cwd: seed });
141 srv = await fakeServer({ gitRoot: root });
142 push = true;
143 srv.on("GET", "/healthz", { body: { ok: true } });
144 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" } }));
145 srv.on("GET", "/api/v3/repos/ada/zutrix", () => ({ body: { full_name: "ada/zutrix", permissions: { pull: true, push } } }));
146 srv.on("POST", "/api/v3/repos/ada/zutrix/diffinery/agent-setup/checks", { status: 201, body: { ok: true } });
147 });
148 afterEach(() => srv.close());
149
150 /** A PATH with git and a diffinery command, and no gh. */
151 const machine = (extra: NodeJS.ProcessEnv = {}) => {
152 const bin = tempDir("diffinery-agent-bin-");
153 symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(bin, "git"));
154 writeFileSync(path.join(bin, "diffinery"), "#!/bin/sh\nexit 0\n");
155 chmodSync(path.join(bin, "diffinery"), 0o755);
156 return isolatedEnv({ PATH: bin, DIFFINERY_CLONE_DIR: tempDir("diffinery-clones-"), ...extra });
157 };
158 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 }> });
159
160 it("checks each part with the token from the environment, clones, reports, and writes no token to disk", async () => {
161 const env = machine({ DIFFINERY_TOKEN: "dfy_agent" });
162 const r = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url], { env });
163 expect(r.stderr).toBe("");
164 expect(r.code).toBe(0);
165 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);
166 expect(r.stdout).not.toContain("dfy_agent");
167 expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url });
168 const [report] = reports();
169 expect(report!.checks.every((l) => l.ok)).toBe(true);
170 // Again, as at each session start: the clone is fetched, not cloned again.
171 const again = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url], { env });
172 expect(again.code).toBe(0);
173 expect(again.stdout).toContain("(up to date)");
174 });
175
176 it("says what to change, exits 1, and still reports, when a check fails", async () => {
177 push = false;
178 const env = machine({ DIFFINERY_TOKEN: "dfy_agent" });
179 const r = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env });
180 expect(r.code).toBe(1);
181 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`);
182 expect(r.stdout).toContain("1 of");
183 expect(reports()[0]!.checks.find((l) => l.name === "repository")!.ok).toBe(false);
184 });
185
186 it("names the token that's missing or refused, with where to get a new one", async () => {
187 const missing = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env: machine() });
188 expect(missing.code).toBe(1);
189 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)`);
190 const refused = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env: machine({ DIFFINERY_TOKEN: "dfy_revoked" }) });
191 expect(refused.code).toBe(1);
192 expect(refused.stdout).toContain("✗ DIFFINERY_TOKEN: refused (revoked or expired)");
193 expect(refused.stdout).not.toContain("dfy_revoked");
194 expect(reports()).toHaveLength(0);
195 });
196
197 it("refuses a --repo that isn't OWNER/NAME", async () => {
198 const r = await run(["setup", "--repo", "zutrix", "--url", srv.url], { env: machine({ DIFFINERY_TOKEN: "dfy_agent" }) });
199 expect(r.code).toBe(2);
200 expect(r.stderr).toContain("--repo expects OWNER/NAME");
201 });
202});
203
204describe("Claude Code plugins in diffinery setup", () => {
205 let srv: FakeServer;
206 const zips = { diffinery: "PK\u0003\u0004 diffinery plugin", "diffinery-ci": "PK\u0003\u0004 diffinery-ci plugin" } as const;
207 const sha = (s: string) => createHash("sha256").update(s).digest("hex");
208 beforeEach(async () => {
209 srv = await fakeServer();
210 srv.on("GET", "/api/v3/user", { body: { login: "alice", type: "User" } });
211 for (const [name, body] of Object.entries(zips))
212 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) } });
213 });
214 afterEach(() => srv.close());
215
216 /** A machine with Claude Code: its settings directory, with `settings` in it when given. */
217 const withClaude = async (settings?: unknown) => {
218 const env = isolatedEnv({ DIFFINERY_NO_CLAUDE_PLUGINS: "" });
219 await writeConfig(env, { url: srv.url, token: "dfy_good" });
220 mkdirSync(path.join(env.HOME!, ".claude"));
221 if (settings !== undefined) writeFileSync(path.join(env.HOME!, ".claude", "settings.json"), typeof settings === "string" ? settings : JSON.stringify(settings));
222 return env;
223 };
224 const settingsOf = (env: NodeJS.ProcessEnv) => JSON.parse(readFileSync(path.join(env.HOME!, ".claude", "settings.json"), "utf8"));
225 const dirsOf = (env: NodeJS.ProcessEnv) => (settingsOf(env).env.CLAUDE_CODE_PLUGIN_DIRS as string).split(path.delimiter);
226
227 it("saves both plugins and names them in Claude Code's settings, keeping what was there, and runs again cleanly", async () => {
228 const env = await withClaude({ model: "opus", env: { FOO: "1", CLAUDE_CODE_PLUGIN_DIRS: "/opt/mine" } });
229 for (let i = 0; i < 2; i++) {
230 const r = await run(["setup"], { env, openUrl: async () => expect.fail("opened a browser") });
231 expect(r.code).toBe(0);
232 expect(r.stdout).toContain("✓ Claude Code plugins: diffinery 0.1.1 and diffinery-ci 0.2.0 load in new Claude Code sessions");
233 }
234 const dir = path.join(env.HOME!, ".claude", "diffinery-plugins");
235 expect(dirsOf(env)).toEqual(["/opt/mine", path.join(dir, "diffinery.zip"), path.join(dir, "diffinery-ci.zip")]);
236 expect(settingsOf(env)).toMatchObject({ model: "opus", env: { FOO: "1" } });
237 expect(readFileSync(path.join(dir, "diffinery-ci.zip"), "utf8")).toBe(zips["diffinery-ci"]);
238 });
239
240 it("does nothing without Claude Code, with --no-claude-plugins, or for a plugin installed from the marketplace", async () => {
241 // No Claude Code: no ~/.claude, and a PATH with git alone.
242 const bin = tempDir("diffinery-no-claude-");
243 symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(bin, "git"));
244 const none = isolatedEnv({ DIFFINERY_NO_CLAUDE_PLUGINS: "", PATH: bin });
245 await writeConfig(none, { url: srv.url, token: "dfy_good" });
246 expect((await run(["setup"], { env: none })).stdout).not.toContain("Claude Code plugins");
247 expect(existsSync(path.join(none.HOME!, ".claude"))).toBe(false);
248
249 const skip = await withClaude();
250 expect((await run(["setup", "--no-claude-plugins"], { env: skip })).stdout).not.toContain("Claude Code plugins");
251 expect(existsSync(path.join(skip.HOME!, ".claude", "settings.json"))).toBe(false);
252
253 const market = await withClaude({ enabledPlugins: { "diffinery-ci@diffinery": true } });
254 const r = await run(["setup"], { env: market });
255 expect(r.stdout).toContain("diffinery 0.1.1 load in new Claude Code sessions");
256 expect(r.stdout).toContain("diffinery-ci already installed from the Diffinery marketplace");
257 expect(dirsOf(market)).toEqual([path.join(market.HOME!, ".claude", "diffinery-plugins", "diffinery.zip")]);
258 });
259
260 it("leaves settings it can't read alone, and refuses a download that doesn't match its checksum", async () => {
261 const broken = await withClaude("{ not json");
262 const r = await run(["setup"], { env: broken });
263 expect(r.code).toBe(1);
264 expect(r.stderr).toContain("isn't valid JSON, so it was left alone");
265 expect(readFileSync(path.join(broken.HOME!, ".claude", "settings.json"), "utf8")).toBe("{ not json");
266
267 srv.on("GET", "/-/claude/diffinery-ci.zip", { body: "tampered", headers: { "content-type": "application/zip", "x-diffinery-plugin-sha256": sha("other") } });
268 const bad = await withClaude();
269 const r2 = await run(["setup"], { env: bad });
270 expect(r2.code).toBe(1);
271 expect(r2.stderr).toContain("didn't match its checksum");
272 expect(existsSync(path.join(bad.HOME!, ".claude", "settings.json"))).toBe(false);
273 });
274});