| 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 { configPath, writeConfig } from "../src/config.js"; |
| 7 | import { fakeServer, gitRepo, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; |
| 8 | |
| 9 | const 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. */ |
| 12 | function 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 | |
| 20 | describe("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 | |
| 133 | describe("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 | |
| 204 | describe("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 | }); |