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.

262 lines · 12.8 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, readAll } from "../io.js";
7import { browserLikely, browserLogin, deviceLogin, revokeCliToken, type Scope } from "../login.js";
8import { json } from "../output.js";
9
10interface User {
11 login: string;
12 name?: string | null;
13 type?: string;
14 email?: string | null;
15 html_url?: string;
16}
17
18const SCOPES = ["read", "write", "admin"] as const;
19
20const login: Command = {
21 name: "login",
22 summary: "Sign in through your browser (or with a token)",
23 description: `Opens your browser on the server's authorize page; approve the sign-in there and the CLI
24gets its own token, valid for 90 days and listed under Settings → Access tokens.
25
26On a machine without a browser (over SSH, in a container), --device prints a short code to
27enter at <server>/login/device from any browser; the CLI picks this by itself when it sees no
28display. For scripts and CI, pass a token with --token, or pipe one in with --with-token
29(piping into a plain \`diffinery login\` still works). The config file is written with mode 0600.`,
30 options: {
31 url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" },
32 scope: { type: "string", desc: "Access to ask for: read, write (default) or admin. You can change it on the authorize page", arg: "SCOPE" },
33 web: { type: "boolean", desc: "Sign in through the browser on this computer (the default in a terminal)" },
34 device: { type: "boolean", desc: "Sign in with a code you enter in a browser on any device" },
35 token: { type: "string", desc: "Use this token (dfy_...) instead of signing in through the browser", arg: "TOKEN" },
36 "with-token": { type: "boolean", desc: "Read a token from stdin" },
37 },
38 maxArgs: 0,
39 async run(ctx, o) {
40 const stored = await readConfig(ctx.io.env);
41 const cfg = await resolveConfig(ctx.io);
42 const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url;
43 const scope = (str(o, "scope") ?? "write") as Scope;
44 if (!SCOPES.includes(scope)) throw new CliError(`--scope must be one of ${SCOPES.join(", ")}`, 2);
45 const modes = (["web", "device", "with-token"] as const).filter((k) => o[k] === true);
46 if (modes.length + (str(o, "token") !== undefined ? 1 : 0) > 1) throw new CliError("use only one of --web, --device, --token and --with-token", 2);
47
48 let token: string;
49 let note = "";
50 if (str(o, "token") !== undefined || o["with-token"] || (!modes.length && !ctx.io.stdin.isTTY)) {
51 token = (str(o, "token") ?? (await readAll(ctx.io.stdin)).replace(/\r?\n$/, "")).trim();
52 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");
53 } else {
54 const device = o.device === true || (o.web !== true && !browserLikely(ctx.io));
55 const grant = device ? await deviceLogin(ctx, url, scope) : await browserLogin(ctx, url, scope);
56 token = grant.access_token;
57 note = ` with ${grant.scope} access until ${new Date(grant.expires_at).toLocaleDateString("en-US", { dateStyle: "medium" })}`;
58 }
59 const me = await new Api(url, token).get<User>("/user");
60 const file = await writeConfig(ctx.io.env, { ...stored, url, token });
61 // The token this one replaces: revoke it if a CLI sign-in made it (never a token someone pasted, which may be used elsewhere).
62 if (stored.token && stored.token !== token) await revokeCliToken(stored.url ? normalizeUrl(stored.url) : url, stored.token);
63 ctx.out(`${ctx.c.green("✓")} Logged in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}${note}`);
64 ctx.err(ctx.c.dim(`Saved to ${file}`));
65 if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is set and overrides the saved token"));
66 },
67};
68
69const logout: Command = {
70 name: "logout",
71 summary: "Sign out: forget the saved token, and revoke it if a browser sign-in created it",
72 maxArgs: 0,
73 async run(ctx) {
74 const stored = await readConfig(ctx.io.env);
75 if (!stored.token) ctx.out("Not logged in.");
76 else {
77 const url = stored.url ? normalizeUrl(stored.url) : DEFAULT_URL;
78 const revoked = await revokeCliToken(url, stored.token);
79 await writeConfig(ctx.io.env, { url: stored.url });
80 ctx.out(`Logged out of ${url}${revoked ? " and revoked the CLI's token" : ""}.`);
81 }
82 if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is still set in your environment"));
83 },
84};
85
86const whoami: Command = {
87 name: "whoami",
88 summary: "Show the account the current token acts as",
89 json: true,
90 maxArgs: 0,
91 async run(ctx) {
92 const cfg = await ctx.config();
93 const me = await (await ctx.authedApi()).get<User>("/user");
94 if (ctx.json) return json(ctx.io, me);
95 ctx.out(`${ctx.c.bold(me.login)}${me.name && me.name !== me.login ? ` (${me.name})` : ""}${me.type === "Bot" ? " [agent]" : ""}`);
96 ctx.out(ctx.c.dim(`${cfg.url} · token from ${cfg.tokenSource === "env" ? "DIFFINERY_TOKEN" : cfg.file}`));
97 },
98};
99
100const setupGit: Command = {
101 name: "auth setup-git",
102 summary: "Make git use your Diffinery token for HTTPS remotes",
103 description: `Runs: git config --global credential.<url>.helper "!diffinery auth git-credential"
104(after resetting other helpers for that URL), so clone/fetch/push to the Diffinery host
105authenticate with the token from the config file or DIFFINERY_TOKEN.`,
106 maxArgs: 0,
107 async run(ctx) {
108 const cfg = await ctx.config();
109 const key = `credential.${cfg.url}.helper`;
110 await gitCapture(ctx.io, ["config", "--global", "--unset-all", key]);
111 // An empty helper resets the list so a generic helper (e.g. a keychain) does not answer first.
112 for (const value of ["", "!diffinery auth git-credential"]) {
113 const r = await gitCapture(ctx.io, ["config", "--global", "--add", key, value]);
114 if (r.code !== 0) throw new CliError(`git config failed: ${r.stderr.trim()}`);
115 }
116 ctx.out(`${ctx.c.green("✓")} git will use diffinery for ${cfg.url}`);
117 },
118};
119
120/** Runs gh with `input` on stdin; output is captured, so a token on stdin never reaches the terminal. */
121function ghCapture(env: NodeJS.ProcessEnv, args: string[], input: string): Promise<{ code: number; stderr: string }> {
122 return new Promise((resolve) => {
123 let child;
124 try {
125 child = spawn("gh", args, { env, stdio: ["pipe", "pipe", "pipe"] });
126 } catch (e) {
127 return resolve({ code: 127, stderr: String((e as Error).message) });
128 }
129 let stderr = "";
130 child.stdout.on("data", () => {});
131 child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
132 child.on("error", (e) => resolve({ code: 127, stderr: (e as NodeJS.ErrnoException).code === "ENOENT" ? "gh is not installed (https://cli.github.com)" : e.message }));
133 child.on("close", (code) => resolve({ code: code ?? 1, stderr }));
134 child.stdin.on("error", () => {});
135 child.stdin.end(input);
136 });
137}
138
139const setupGh: Command = {
140 name: "auth setup-gh",
141 summary: "Sign the GitHub CLI (gh) in to Diffinery with your token",
142 description: `Runs: gh auth login --hostname <host> --with-token --git-protocol https, with the token
143from the config file or DIFFINERY_TOKEN on its standard input. gh then treats the Diffinery host
144like GitHub Enterprise Server: gh pr create, view, checks, status, merge and the rest work in
145clones of Diffinery repositories, and so does the pull request badge of Claude Code, which
146asks gh for its token.`,
147 maxArgs: 0,
148 async run(ctx) {
149 const cfg = await ctx.config();
150 if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN");
151 const url = new URL(cfg.url);
152 if (url.protocol !== "https:") throw new CliError(`gh only talks to hosts over HTTPS; ${cfg.url} is ${url.protocol.replace(/:$/, "")}`);
153 const r = await ghCapture(ctx.io.env, ["auth", "login", "--hostname", url.host, "--with-token", "--git-protocol", "https"], `${cfg.token}\n`);
154 if (r.code !== 0) throw new CliError(`gh auth login failed: ${r.stderr.trim() || `exit ${r.code}`}`);
155 ctx.out(`${ctx.c.green("✓")} gh is signed in to ${url.host}`);
156 },
157};
158
159/** Parses the git credential helper protocol's key=value lines. */
160export function parseCredentialInput(text: string): Record<string, string> {
161 const out: Record<string, string> = {};
162 for (const line of text.split(/\r?\n/)) {
163 if (!line) break;
164 const i = line.indexOf("=");
165 if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
166 }
167 return out;
168}
169
170const gitCredential: Command = {
171 name: "auth git-credential",
172 summary: "Git credential helper (used by 'auth setup-git')",
173 args: "<get|store|erase>",
174 minArgs: 1,
175 maxArgs: 1,
176 async run(ctx, _o, [op]) {
177 const input = parseCredentialInput(await readAll(ctx.io.stdin));
178 if (op !== "get") return; // store/erase: the token lives in the config file
179 const cfg = await ctx.config();
180 if (!cfg.token) return;
181 const want = new URL(cfg.url);
182 const proto = (input.protocol ?? "").toLowerCase();
183 const host = (input.host ?? "").toLowerCase();
184 if (proto !== want.protocol.replace(/:$/, "") || host !== want.host.toLowerCase()) return;
185 ctx.io.stdout.write(`protocol=${proto}\nhost=${input.host}\nusername=x-token\npassword=${cfg.token}\n`);
186 },
187};
188
189const mcpConfig: Command = {
190 name: "mcp-config",
191 summary: "Print an MCP server config snippet for Claude Code and other MCP clients",
192 description: `By default the token is written as \${DIFFINERY_TOKEN} (expanded by clients such as
193Claude Code from the environment). Pass --show-token to embed the saved token instead.`,
194 options: { "show-token": { type: "boolean", desc: "Embed the actual token in the snippet" } },
195 maxArgs: 0,
196 async run(ctx, o) {
197 const cfg = await ctx.config();
198 let token = "${DIFFINERY_TOKEN}";
199 if (o["show-token"]) {
200 if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN");
201 token = cfg.token;
202 }
203 json(ctx.io, { mcpServers: { diffinery: { type: "http", url: `${cfg.url}/mcp`, headers: { Authorization: `Bearer ${token}` } } } });
204 },
205};
206
207/** The signed-in account, or null when there is no token or the server turns it down. */
208async function signedInAs(url: string, token: string | null): Promise<User | null> {
209 if (!token) return null;
210 try {
211 return await new Api(url, token).get<User>("/user");
212 } catch (e) {
213 if (e instanceof ApiError && e.status === 401) return null;
214 throw e;
215 }
216}
217
218const setup: Command = {
219 name: "setup",
220 summary: "Set up this computer in one go: sign in, then git and gh",
221 description: `Signs in through your browser (or with a code, on a machine without one) unless the CLI is
222signed in already, then runs \`diffinery auth setup-git\` so git clone, fetch and push use the
223sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Safe to run again.`,
224 options: {
225 url: login.options!.url!,
226 scope: login.options!.scope!,
227 web: login.options!.web!,
228 device: login.options!.device!,
229 },
230 maxArgs: 0,
231 async run(ctx, o) {
232 const cfg = await ctx.config();
233 const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url;
234 const me = url === cfg.url ? await signedInAs(url, cfg.token) : null;
235 if (me) ctx.out(`${ctx.c.green("✓")} Signed in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}`);
236 else {
237 // Unlike login, setup never reads a token from stdin: run by an agent or a script, it still signs in a person.
238 const how = o.web === true || o.device === true ? {} : browserLikely(ctx.io) ? { web: true } : { device: true };
239 await login.run(ctx, { ...o, ...how }, []);
240 }
241 // A fresh context, so the next steps read the sign-in just saved.
242 const next = new Ctx(ctx.io, {});
243 await setupGit.run(next, {}, []);
244 let failed = false;
245 if (new URL(url).protocol !== "https:") ctx.err(ctx.c.dim("Skipped gh: it only talks to hosts over HTTPS."));
246 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)."));
247 else
248 try {
249 await setupGh.run(next, {}, []);
250 } catch (e) {
251 if (!(e instanceof CliError)) throw e;
252 ctx.err(`${ctx.c.yellow("warning:")} ${e.message}`);
253 failed = true;
254 }
255 ctx.out("");
256 ctx.out(`Connect Claude Code, Codex, Cursor and other AI apps: ${url}/docs/connect`);
257 return failed ? 1 : 0;
258 },
259};
260
261export const authCommands: Command[] = [login, logout, whoami, setup, mcpConfig];
262export const authGroup: Group = { name: "auth", summary: "Git and GitHub CLI integration", commands: [setupGit, setupGh, gitCredential] };