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.

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