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.

201 lines · 11.1 KBRaw History
1import { execFileSync } from "node:child_process";
2import { chmodSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
3import path from "node:path";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5import { configPath, writeConfig } from "../src/config.js";
6import { fakeServer, gitRepo, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
7
8const helpers = (env: NodeJS.ProcessEnv, url: string) => execFileSync("git", ["config", "--global", "--get-all", `credential.${url}.helper`], { env }).toString().split("\n").slice(0, -1);
9
10/** A gh that records how it was called; `fail` makes `auth login` fail. */
11function fakeGh(fail = false): { dir: string; seen: string } {
12 const dir = tempDir("diffinery-fake-gh-");
13 const seen = path.join(dir, "seen");
14 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" : ""}`);
15 chmodSync(path.join(dir, "gh"), 0o755);
16 return { dir, seen };
17}
18
19describe("diffinery setup", () => {
20 let srv: FakeServer;
21 beforeEach(async () => {
22 srv = await fakeServer();
23 srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } }));
24 srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_good", token_type: "bearer", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } });
25 });
26 afterEach(() => {
27 vi.unstubAllGlobals();
28 return srv.close();
29 });
30
31 /** A browser where the person clicks Authorize. */
32 const approve = async (url: string) => {
33 const u = new URL(url);
34 await fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?code=c&state=${u.searchParams.get("state")}`, { redirect: "manual" });
35 return true;
36 };
37
38 it("signs in through the browser, then sets up git, even when an agent runs it without a terminal", async () => {
39 const env = isolatedEnv();
40 let opened = 0;
41 const r = await run(["setup", "--url", srv.url], { env, stdinTTY: false, openUrl: (u) => (opened++, approve(u)) });
42 expect(r.stderr).not.toContain("error");
43 expect(r.code).toBe(0);
44 expect(opened).toBe(1);
45 expect(r.stdout).toContain(`Logged in to ${srv.url} as alice`);
46 expect(r.stdout).toContain(`git will use diffinery for ${srv.url}`);
47 expect(r.stdout).toContain(`${srv.url}/docs/connect`);
48 expect(r.stderr).toContain("Skipped gh");
49 expect(r.stdout + r.stderr).not.toContain("dfy_good");
50 expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url, token: "dfy_good" });
51 expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]);
52 });
53
54 it("keeps a sign-in that still works, and is safe to run again", async () => {
55 const env = isolatedEnv();
56 await writeConfig(env, { url: srv.url, token: "dfy_good" });
57 for (let i = 0; i < 2; i++) {
58 const r = await run(["setup"], { env, openUrl: async () => expect.fail("opened a browser") });
59 expect(r.code).toBe(0);
60 expect(r.stdout).toContain(`Signed in to ${srv.url} as alice`);
61 }
62 expect(srv.find("POST", "/-/cli/token")).toHaveLength(0);
63 expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]);
64 });
65
66 it("signs in again when the saved token no longer works", async () => {
67 const env = isolatedEnv();
68 await writeConfig(env, { url: srv.url, token: "dfy_expired" });
69 const r = await run(["setup"], { env, openUrl: approve });
70 expect(r.code).toBe(0);
71 expect(r.stdout).toContain("Logged in to");
72 expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good");
73 });
74
75 describe("with gh", () => {
76 const host = "https://forge.example.com";
77 // Requests to the HTTPS host reach the fake server, since gh only signs in to HTTPS hosts.
78 beforeEach(() => {
79 const real = fetch;
80 vi.stubGlobal("fetch", (input: string | URL | Request, init?: RequestInit) => real(String(input instanceof Request ? input.url : input).replace(host, srv.url), init));
81 });
82
83 it("signs gh in with the token on stdin", async () => {
84 const gh = fakeGh();
85 const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` });
86 await writeConfig(env, { url: host, token: "dfy_good" });
87 const r = await run(["setup"], { env });
88 expect(r.code).toBe(0);
89 expect(r.stdout).toContain("gh is signed in to forge.example.com");
90 expect(readFileSync(`${gh.seen}.args`, "utf8").trim()).toBe("auth login --hostname forge.example.com --with-token --git-protocol https");
91 expect(readFileSync(`${gh.seen}.stdin`, "utf8")).toBe("dfy_good\n");
92 });
93
94 it("leaves gh to GH_ENTERPRISE_TOKEN when it's in the environment, since gh won't sign in over it", async () => {
95 const gh = fakeGh();
96 const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}`, GH_ENTERPRISE_TOKEN: "dfy_good", GH_HOST: "forge.example.com" });
97 await writeConfig(env, { url: host, token: "dfy_good" });
98 const r = await run(["setup"], { env });
99 expect(r.code).toBe(0);
100 expect(r.stdout).toContain("gh uses GH_ENTERPRISE_TOKEN for forge.example.com");
101 expect(() => readFileSync(`${gh.seen}.args`)).toThrow();
102 const other = await run(["setup"], { env: { ...env, GH_ENTERPRISE_TOKEN: "dfy_other" } });
103 expect(other.code).toBe(1);
104 expect(other.stderr).toContain("isn't your Diffinery token");
105 expect(other.stdout + other.stderr).not.toMatch(/dfy_(good|other)/);
106 });
107
108 it("says when gh isn't installed", async () => {
109 // A PATH with git and nothing else, wherever gh lives on this machine.
110 const dir = tempDir("diffinery-no-gh-");
111 symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(dir, "git"));
112 const env = isolatedEnv({ PATH: dir });
113 await writeConfig(env, { url: host, token: "dfy_good" });
114 const r = await run(["setup"], { env });
115 expect(r.code).toBe(0);
116 expect(r.stderr).toContain("Skipped gh, which isn't installed");
117 });
118
119 it("finishes the rest and exits 1 when gh fails", async () => {
120 const gh = fakeGh(true);
121 const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` });
122 await writeConfig(env, { url: host, token: "dfy_good" });
123 const r = await run(["setup"], { env });
124 expect(r.code).toBe(1);
125 expect(r.stderr).toContain("gh auth login failed: gh: nope");
126 expect(r.stdout).toContain("git will use diffinery");
127 expect(r.stdout).toContain(`${host}/docs/connect`);
128 });
129 });
130});
131
132describe("diffinery setup --repo (an agent environment, as the hosted agent-setup.sh runs it)", () => {
133 let srv: FakeServer;
134 let push: boolean;
135 beforeEach(async () => {
136 const root = tempDir("diffinery-git-root-");
137 execFileSync("git", ["init", "-q", "--bare", "-b", "main", path.join(root, "ada", "zutrix.git")]);
138 const seed = gitRepo(isolatedEnv(), path.join(root, "ada", "zutrix.git"));
139 execFileSync("git", ["push", "-q", "origin", "main"], { cwd: seed });
140 srv = await fakeServer({ gitRoot: root });
141 push = true;
142 srv.on("GET", "/healthz", { body: { ok: true } });
143 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" } }));
144 srv.on("GET", "/api/v3/repos/ada/zutrix", () => ({ body: { full_name: "ada/zutrix", permissions: { pull: true, push } } }));
145 srv.on("POST", "/api/v3/repos/ada/zutrix/diffinery/agent-setup/checks", { status: 201, body: { ok: true } });
146 });
147 afterEach(() => srv.close());
148
149 /** A PATH with git and a diffinery command, and no gh. */
150 const machine = (extra: NodeJS.ProcessEnv = {}) => {
151 const bin = tempDir("diffinery-agent-bin-");
152 symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(bin, "git"));
153 writeFileSync(path.join(bin, "diffinery"), "#!/bin/sh\nexit 0\n");
154 chmodSync(path.join(bin, "diffinery"), 0o755);
155 return isolatedEnv({ PATH: bin, DIFFINERY_CLONE_DIR: tempDir("diffinery-clones-"), ...extra });
156 };
157 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 }> });
158
159 it("checks each part with the token from the environment, clones, reports, and writes no token to disk", async () => {
160 const env = machine({ DIFFINERY_TOKEN: "dfy_agent" });
161 const r = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url], { env });
162 expect(r.stderr).toBe("");
163 expect(r.code).toBe(0);
164 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);
165 expect(r.stdout).not.toContain("dfy_agent");
166 expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url });
167 const [report] = reports();
168 expect(report!.checks.every((l) => l.ok)).toBe(true);
169 // Again, as at each session start: the clone is fetched, not cloned again.
170 const again = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url], { env });
171 expect(again.code).toBe(0);
172 expect(again.stdout).toContain("(up to date)");
173 });
174
175 it("says what to change, exits 1, and still reports, when a check fails", async () => {
176 push = false;
177 const env = machine({ DIFFINERY_TOKEN: "dfy_agent" });
178 const r = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env });
179 expect(r.code).toBe(1);
180 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`);
181 expect(r.stdout).toContain("1 of");
182 expect(reports()[0]!.checks.find((l) => l.name === "repository")!.ok).toBe(false);
183 });
184
185 it("names the token that's missing or refused, with where to get a new one", async () => {
186 const missing = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env: machine() });
187 expect(missing.code).toBe(1);
188 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)`);
189 const refused = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env: machine({ DIFFINERY_TOKEN: "dfy_revoked" }) });
190 expect(refused.code).toBe(1);
191 expect(refused.stdout).toContain("✗ DIFFINERY_TOKEN: refused (revoked or expired)");
192 expect(refused.stdout).not.toContain("dfy_revoked");
193 expect(reports()).toHaveLength(0);
194 });
195
196 it("refuses a --repo that isn't OWNER/NAME", async () => {
197 const r = await run(["setup", "--repo", "zutrix", "--url", srv.url], { env: machine({ DIFFINERY_TOKEN: "dfy_agent" }) });
198 expect(r.code).toBe(2);
199 expect(r.stderr).toContain("--repo expects OWNER/NAME");
200 });
201});