| 1 | import { createHash } from "node:crypto"; |
| 2 | import { execFileSync } from "node:child_process"; |
| 3 | import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; |
| 4 | import path from "node:path"; |
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 6 | import { withUrlOption } from "../src/commands/claude-plugins.js"; |
| 7 | import { configPath, writeConfig } from "../src/config.js"; |
| 8 | import { fakeServer, gitRepo, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; |
| 9 | |
| 10 | const 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. */ |
| 13 | function 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 | |
| 21 | describe("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 | |
| 180 | describe("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 | |
| 251 | describe("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 | |
| 339 | describe("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 | }); |