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.

358 lines · 19.5 KBRaw History
1import { Api, ApiError } from "../api.js";
2import { Ctx, str, type Command, type Group } from "../cli.js";
3import { DEFAULT_URL, normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js";
4import { spawn } from "node:child_process";
5import { gitCapture } from "../git.js";
6import { CliError, promptYesNo, readAll } from "../io.js";
7import { browserLikely, browserLogin, deviceLogin, revokeCliToken, type Scope } from "../login.js";
8import { json } from "../output.js";
9import { agentSetup, ghEnvToken, setupRepo } from "./agent-setup.js";
10import { setupClaudePlugins } from "./claude-plugins.js";
11import { setupCodexMcp } from "./codex.js";
12
13interface User {
14 login: string;
15 name?: string | null;
16 type?: string;
17 email?: string | null;
18 html_url?: string;
19}
20
21const SCOPES = ["read", "write", "admin"] as const;
22
23const login: Command = {
24 name: "login",
25 summary: "Sign in through your browser (or with a token)",
26 description: `Opens your browser on the server's authorize page; approve the sign-in there and the CLI
27gets its own token, valid for 90 days and listed under Settings → Access tokens.
28
29On a machine without a browser (over SSH, in a container), --device prints a short code to
30enter at <server>/login/device from any browser; the CLI picks this by itself when it sees no
31display. For scripts and CI, pass a token with --token, or pipe one in with --with-token
32(piping into a plain \`diffinery login\` still works). The config file is written with mode 0600.`,
33 options: {
34 url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" },
35 scope: { type: "string", desc: "Access to ask for: read, write (default) or admin. You can change it on the authorize page", arg: "SCOPE" },
36 web: { type: "boolean", desc: "Sign in through the browser on this computer (the default in a terminal)" },
37 device: { type: "boolean", desc: "Sign in with a code you enter in a browser on any device" },
38 token: { type: "string", desc: "Use this token (dfy_...) instead of signing in through the browser", arg: "TOKEN" },
39 "with-token": { type: "boolean", desc: "Read a token from stdin" },
40 },
41 maxArgs: 0,
42 async run(ctx, o) {
43 const stored = await readConfig(ctx.io.env);
44 const cfg = await resolveConfig(ctx.io);
45 const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url;
46 const scope = (str(o, "scope") ?? "write") as Scope;
47 if (!SCOPES.includes(scope)) throw new CliError(`--scope must be one of ${SCOPES.join(", ")}`, 2);
48 const modes = (["web", "device", "with-token"] as const).filter((k) => o[k] === true);
49 if (modes.length + (str(o, "token") !== undefined ? 1 : 0) > 1) throw new CliError("use only one of --web, --device, --token and --with-token", 2);
50
51 let token: string;
52 let note = "";
53 if (str(o, "token") !== undefined || o["with-token"] || (!modes.length && !ctx.io.stdin.isTTY)) {
54 token = (str(o, "token") ?? (await readAll(ctx.io.stdin)).replace(/\r?\n$/, "")).trim();
55 if (!token) throw new CliError(o["with-token"] || str(o, "token") !== undefined ? "no token given" : "no token on stdin; run `diffinery login` in a terminal to sign in through your browser, or pass --token");
56 } else {
57 const device = o.device === true || (o.web !== true && !browserLikely(ctx.io));
58 const grant = device ? await deviceLogin(ctx, url, scope) : await browserLogin(ctx, url, scope);
59 token = grant.access_token;
60 note = ` with ${grant.scope} access until ${new Date(grant.expires_at).toLocaleDateString("en-US", { dateStyle: "medium" })}`;
61 }
62 const me = await new Api(url, token).get<User>("/user");
63 const file = await writeConfig(ctx.io.env, { ...stored, url, token });
64 // The token this one replaces: revoke it if a CLI sign-in made it (never a token someone pasted, which may be used elsewhere).
65 if (stored.token && stored.token !== token) await revokeCliToken(stored.url ? normalizeUrl(stored.url) : url, stored.token);
66 ctx.out(`${ctx.c.green("✓")} Logged in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}${note}`);
67 ctx.err(ctx.c.dim(`Saved to ${file}`));
68 if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is set and overrides the saved token"));
69 },
70};
71
72const logout: Command = {
73 name: "logout",
74 summary: "Sign out: forget the saved token, and revoke it if a browser sign-in created it",
75 maxArgs: 0,
76 async run(ctx) {
77 const stored = await readConfig(ctx.io.env);
78 if (!stored.token) ctx.out("Not logged in.");
79 else {
80 const url = stored.url ? normalizeUrl(stored.url) : DEFAULT_URL;
81 const revoked = await revokeCliToken(url, stored.token);
82 await writeConfig(ctx.io.env, { url: stored.url });
83 ctx.out(`Logged out of ${url}${revoked ? " and revoked the CLI's token" : ""}.`);
84 }
85 if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is still set in your environment"));
86 },
87};
88
89const whoami: Command = {
90 name: "whoami",
91 summary: "Show the account the current token acts as",
92 json: true,
93 maxArgs: 0,
94 async run(ctx) {
95 const cfg = await ctx.config();
96 const me = await (await ctx.authedApi()).get<User>("/user");
97 if (ctx.json) return json(ctx.io, me);
98 ctx.out(`${ctx.c.bold(me.login)}${me.name && me.name !== me.login ? ` (${me.name})` : ""}${me.type === "Bot" ? " [agent]" : ""}`);
99 ctx.out(ctx.c.dim(`${cfg.url} · token from ${cfg.tokenSource === "env" ? "DIFFINERY_TOKEN" : cfg.file}`));
100 },
101};
102
103const setupGit: Command = {
104 name: "auth setup-git",
105 summary: "Make git use your Diffinery token for HTTPS remotes",
106 description: `Runs: git config --global credential.<url>.helper "!diffinery auth git-credential"
107(after resetting other helpers for that URL), so clone/fetch/push to the Diffinery host
108authenticate with the token from the config file or DIFFINERY_TOKEN.`,
109 maxArgs: 0,
110 async run(ctx) {
111 const cfg = await ctx.config();
112 const key = `credential.${cfg.url}.helper`;
113 await gitCapture(ctx.io, ["config", "--global", "--unset-all", key]);
114 // An empty helper resets the list so a generic helper (e.g. a keychain) does not answer first.
115 for (const value of ["", "!diffinery auth git-credential"]) {
116 const r = await gitCapture(ctx.io, ["config", "--global", "--add", key, value]);
117 if (r.code !== 0) throw new CliError(`git config failed: ${r.stderr.trim()}`);
118 }
119 ctx.out(`${ctx.c.green("✓")} git will use diffinery for ${cfg.url}`);
120 },
121};
122
123/** Runs gh with `input` on stdin; output is captured, so a token on stdin never reaches the terminal. */
124function ghCapture(env: NodeJS.ProcessEnv, args: string[], input: string): Promise<{ code: number; stderr: string }> {
125 return new Promise((resolve) => {
126 let child;
127 try {
128 child = spawn("gh", args, { env, stdio: ["pipe", "pipe", "pipe"] });
129 } catch (e) {
130 return resolve({ code: 127, stderr: String((e as Error).message) });
131 }
132 let stderr = "";
133 child.stdout.on("data", () => {});
134 child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
135 child.on("error", (e) => resolve({ code: 127, stderr: (e as NodeJS.ErrnoException).code === "ENOENT" ? "gh is not installed (https://cli.github.com)" : e.message }));
136 child.on("close", (code) => resolve({ code: code ?? 1, stderr }));
137 child.stdin.on("error", () => {});
138 child.stdin.end(input);
139 });
140}
141
142const setupGh: Command = {
143 name: "auth setup-gh",
144 summary: "Sign the GitHub CLI (gh) in to Diffinery with your token",
145 description: `Runs: gh auth login --hostname <host> --with-token --git-protocol https, with the token
146from the config file or DIFFINERY_TOKEN on its standard input. gh then treats the Diffinery host
147like GitHub Enterprise Server: gh pr create, view, checks, status, merge and the rest work in
148clones of Diffinery repositories, and so does the pull request badge of Claude Code, which
149asks gh for its token.`,
150 maxArgs: 0,
151 async run(ctx) {
152 const cfg = await ctx.config();
153 if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN");
154 const url = new URL(cfg.url);
155 if (url.protocol !== "https:") throw new CliError(`gh only talks to hosts over HTTPS; ${cfg.url} is ${url.protocol.replace(/:$/, "")}`);
156 // gh refuses to sign in while a token for the host is in its environment, and uses that one instead.
157 const fromEnv = ghEnvToken(ctx.io.env, url.host);
158 if (fromEnv) {
159 if (fromEnv.value !== cfg.token) throw new CliError(`gh uses ${fromEnv.name} from the environment for ${url.host}, which isn't your Diffinery token: set it to the same token, or unset it`);
160 ctx.out(`${ctx.c.green("✓")} gh uses ${fromEnv.name} for ${url.host}${ctx.io.env.GH_HOST === url.host ? "" : ` (set GH_HOST=${url.host} too, so gh finds it in clones)`}`);
161 return;
162 }
163 const r = await ghCapture(ctx.io.env, ["auth", "login", "--hostname", url.host, "--with-token", "--git-protocol", "https"], `${cfg.token}\n`);
164 if (r.code !== 0) throw new CliError(`gh auth login failed: ${r.stderr.trim() || `exit ${r.code}`}`);
165 ctx.out(`${ctx.c.green("✓")} gh is signed in to ${url.host}`);
166 },
167};
168
169/** Parses the git credential helper protocol's key=value lines. */
170export function parseCredentialInput(text: string): Record<string, string> {
171 const out: Record<string, string> = {};
172 for (const line of text.split(/\r?\n/)) {
173 if (!line) break;
174 const i = line.indexOf("=");
175 if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
176 }
177 return out;
178}
179
180const gitCredential: Command = {
181 name: "auth git-credential",
182 summary: "Git credential helper (used by 'auth setup-git')",
183 args: "<get|store|erase>",
184 minArgs: 1,
185 maxArgs: 1,
186 async run(ctx, _o, [op]) {
187 const input = parseCredentialInput(await readAll(ctx.io.stdin));
188 if (op !== "get") return; // store/erase: the token lives in the config file
189 const cfg = await ctx.config();
190 if (!cfg.token) return;
191 const want = new URL(cfg.url);
192 const proto = (input.protocol ?? "").toLowerCase();
193 const host = (input.host ?? "").toLowerCase();
194 if (proto !== want.protocol.replace(/:$/, "") || host !== want.host.toLowerCase()) return;
195 ctx.io.stdout.write(`protocol=${proto}\nhost=${input.host}\nusername=x-token\npassword=${cfg.token}\n`);
196 },
197};
198
199const mcpConfig: Command = {
200 name: "mcp-config",
201 summary: "Print an MCP server config snippet for Claude Code and other MCP clients",
202 description: `By default the token is written as \${DIFFINERY_TOKEN} (expanded by clients such as
203Claude Code from the environment). Pass --show-token to embed the saved token instead.`,
204 options: { "show-token": { type: "boolean", desc: "Embed the actual token in the snippet" } },
205 maxArgs: 0,
206 async run(ctx, o) {
207 const cfg = await ctx.config();
208 let token = "${DIFFINERY_TOKEN}";
209 if (o["show-token"]) {
210 if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN");
211 token = cfg.token;
212 }
213 json(ctx.io, { mcpServers: { diffinery: { type: "http", url: `${cfg.url}/mcp`, headers: { Authorization: `Bearer ${token}` } } } });
214 },
215};
216
217/** The signed-in account, or null when there is no token or the server turns it down. */
218async function signedInAs(url: string, token: string | null): Promise<User | null> {
219 if (!token) return null;
220 try {
221 return await new Api(url, token).get<User>("/user");
222 } catch (e) {
223 if (e instanceof ApiError && e.status === 401) return null;
224 throw e;
225 }
226}
227
228/**
229 * The last step of `diffinery setup` for a person: offer to turn on auto-merge for the pull requests they open
230 * (Settings → Pull requests), asked on a terminal only, or turned on straight away with --auto-merge.
231 */
232async function offerAutoMerge(ctx: Ctx, url: string, o: { yes: boolean; skip: boolean }): Promise<void> {
233 if (o.skip) return;
234 const api = await ctx.authedApi();
235 const [me, now] = await Promise.all([api.get<User>("/user").catch(() => null), api.get<{ enabled: boolean }>("/diffinery/auto-merge").catch(() => null)]);
236 // An agent's owner sets it for the agent; and a server without the setting has nothing to offer.
237 if (!me || me.type === "Bot" || !now) return;
238 const page = `${url}/settings/pull-requests`;
239 if (now.enabled) return ctx.out(`${ctx.c.green("✓")} Auto-merge turns on by itself for pull requests you open (${page})`);
240 let yes = o.yes;
241 if (!yes) {
242 if (!ctx.io.stdin.isTTY) return;
243 ctx.out("");
244 ctx.out("Pull requests can merge by themselves once their checks pass: Diffinery turns on auto-merge as you open each one,");
245 ctx.out("wherever the branch has required checks, and it still waits for every rule of the branch, approvals included.");
246 yes = await promptYesNo(ctx.io, "Turn on auto-merge for pull requests you open? [y/N] ");
247 }
248 if (!yes) return ctx.err(ctx.c.dim(`Auto-merge stays off. Turn it on in ${page}, or for one pull request with diffinery pr create --auto.`));
249 await api.put("/diffinery/auto-merge", { enabled: true });
250 ctx.out(`${ctx.c.green("✓")} Auto-merge turns on by itself for pull requests you open (change it in ${page})`);
251}
252
253const setup: Command = {
254 name: "setup",
255 summary: "Set up this computer in one go: sign in, then git and gh",
256 description: `Signs in through your browser (or with a code, on a machine without one) unless the CLI is
257signed in already, then runs \`diffinery auth setup-git\` so git clone, fetch and push use the
258sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Where Claude Code is
259installed, it also adds Claude Code's Diffinery plugins (diffinery and diffinery-ci): they're saved in
260~/.claude/diffinery-plugins and named in ~/.claude/settings.json, so every new Claude Code session loads
261them, with their Diffinery URL setting pointed at this server. Skip that with --no-claude-plugins (or
262DIFFINERY_NO_CLAUDE_PLUGINS=1). For a server other than diffinery.com (--url, a self-hosted Diffinery), it
263also points Codex's diffinery MCP server there when Codex is installed (codex mcp add; skip with
264DIFFINERY_NO_CODEX=1). Last, on a terminal, it asks
265whether to turn on auto-merge for the pull requests you open (--auto-merge says yes without asking,
266--no-auto-merge skips the question). Safe to run again; it brings the plugins up to date.
267
268With --repo OWNER/NAME it sets up an agent environment instead (a Claude Code cloud environment,
269Codex, a CI runner), with no browser and no prompt: git and gh use the token in DIFFINERY_TOKEN
270(and GH_ENTERPRISE_TOKEN with GH_HOST for gh) straight from the environment, so no token is written
271to disk; the repository is cloned unless it's there already; then a self-test prints one line per
272check, saying what to change when one fails, and reports the result to the repository's Agent setup
273page. Exits 1 when a check fails. This is what <server>/OWNER/NAME/agent-setup.sh runs.`,
274 options: {
275 url: login.options!.url!,
276 scope: login.options!.scope!,
277 web: login.options!.web!,
278 device: login.options!.device!,
279 repo: { type: "string", desc: "Set up an agent environment for this repository instead, with the token in DIFFINERY_TOKEN (what <server>/OWNER/NAME/agent-setup.sh runs)", arg: "OWNER/NAME" },
280 "no-clone": { type: "boolean", desc: "With --repo: don't clone the repository (also DIFFINERY_NO_CLONE=1)" },
281 "clone-dir": { type: "string", desc: "With --repo: where the clone goes (default: DIFFINERY_CLONE_DIR, /home/user in a Claude Code cloud environment, else the current directory)", arg: "DIR" },
282 "no-report": { type: "boolean", desc: "With --repo: don't report the result to the repository's Agent setup page" },
283 "no-claude-plugins": { type: "boolean", desc: "Don't add Claude Code's Diffinery plugins (also DIFFINERY_NO_CLAUDE_PLUGINS=1)" },
284 "auto-merge": { type: "boolean", desc: "Turn on auto-merge for pull requests you open, without asking (it still waits for each branch's required checks and approvals)" },
285 "no-auto-merge": { type: "boolean", desc: "Don't ask about auto-merge" },
286 },
287 maxArgs: 0,
288 async run(ctx, o) {
289 if (str(o, "repo") !== undefined) {
290 // Run by a setup script: never a browser, never a prompt.
291 const cfg = await ctx.config();
292 const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url;
293 let next = ctx;
294 if (url !== cfg.url) {
295 if (cfg.urlSource === "env") throw new CliError(`DIFFINERY_URL is ${cfg.url}, but this sets up ${url}: unset DIFFINERY_URL or set it to ${url}`, 2);
296 // The server is saved (its URL only: a saved token belongs to the other server), so git's credential
297 // helper and later commands use it in sessions that don't set DIFFINERY_URL.
298 await writeConfig(ctx.io.env, { url });
299 next = new Ctx(ctx.io, ctx.opts);
300 }
301 return agentSetup(next, { repo: setupRepo(str(o, "repo")!), url, clone: o["no-clone"] !== true && !ctx.io.env.DIFFINERY_NO_CLONE, cloneDir: str(o, "clone-dir"), report: o["no-report"] !== true, claudePlugins: o["no-claude-plugins"] !== true }, (c) => setupGit.run(new Ctx(c.io, {}), {}, []) as Promise<void>);
302 }
303 const cfg = await ctx.config();
304 const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url;
305 const me = url === cfg.url ? await signedInAs(url, cfg.token) : null;
306 if (me) ctx.out(`${ctx.c.green("✓")} Signed in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}`);
307 else {
308 // Unlike login, setup never reads a token from stdin: run by an agent or a script, it still signs in a person.
309 const how = o.web === true || o.device === true ? {} : browserLikely(ctx.io) ? { web: true } : { device: true };
310 await login.run(ctx, { ...o, ...how }, []);
311 }
312 // A fresh context, so the next steps read the sign-in just saved.
313 const next = new Ctx(ctx.io, {});
314 await setupGit.run(next, {}, []);
315 let failed = false;
316 if (new URL(url).protocol !== "https:") ctx.err(ctx.c.dim("Skipped gh: it only talks to hosts over HTTPS."));
317 else if ((await ghCapture(ctx.io.env, ["--version"], "")).code !== 0) ctx.err(ctx.c.dim("Skipped gh, which isn't installed. Run `diffinery auth setup-gh` after installing it (https://cli.github.com)."));
318 else
319 try {
320 await setupGh.run(next, {}, []);
321 } catch (e) {
322 if (!(e instanceof CliError)) throw e;
323 ctx.err(`${ctx.c.yellow("warning:")} ${e.message}`);
324 failed = true;
325 }
326 if (o["no-claude-plugins"] !== true) {
327 try {
328 const p = await setupClaudePlugins(ctx.io.env, url);
329 if (p?.ok) ctx.out(`${ctx.c.green("✓")} Claude Code plugins: ${p.message}`);
330 else if (p) {
331 ctx.err(`${ctx.c.yellow("warning:")} Claude Code plugins: ${p.message}`);
332 failed = true;
333 }
334 } catch (e) {
335 ctx.err(`${ctx.c.yellow("warning:")} Claude Code plugins: ${(e as Error).message}`);
336 failed = true;
337 }
338 }
339 try {
340 const p = await setupCodexMcp(ctx.io.env, url);
341 if (p?.ok) ctx.out(`${ctx.c.green("✓")} Codex: ${p.message}`);
342 else if (p) ctx.err(`${ctx.c.yellow("warning:")} Codex: ${p.message}`);
343 } catch (e) {
344 ctx.err(`${ctx.c.yellow("warning:")} Codex: ${(e as Error).message}`);
345 }
346 try {
347 await offerAutoMerge(new Ctx(ctx.io, {}), url, { yes: o["auto-merge"] === true, skip: o["no-auto-merge"] === true });
348 } catch (e) {
349 ctx.err(`${ctx.c.yellow("warning:")} auto-merge: ${(e as Error).message}`);
350 }
351 ctx.out("");
352 ctx.out(`Connect Claude Code, Codex, Cursor and other AI apps: ${url}/docs/connect`);
353 return failed ? 1 : 0;
354 },
355};
356
357export const authCommands: Command[] = [login, logout, whoami, setup, mcpConfig];
358export const authGroup: Group = { name: "auth", summary: "Git and GitHub CLI integration", commands: [setupGit, setupGh, gitCredential] };