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