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.

168 lines · 8.3 KBRaw History
1import { Api } from "../api.js";
2import { str, type Command, type Group } from "../cli.js";
3import { DEFAULT_URL, normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js";
4import { gitCapture } from "../git.js";
5import { CliError, readAll } from "../io.js";
6import { browserLikely, browserLogin, deviceLogin, revokeCliToken, type Scope } from "../login.js";
7import { json } from "../output.js";
8
9interface User {
10 login: string;
11 name?: string | null;
12 type?: string;
13 email?: string | null;
14 html_url?: string;
15}
16
17const SCOPES = ["read", "write", "admin"] as const;
18
19const login: Command = {
20 name: "login",
21 summary: "Sign in through your browser (or with a token)",
22 description: `Opens your browser on the server's authorize page; approve the sign-in there and the CLI
23gets its own token, valid for 90 days and listed under Settings → Access tokens.
24
25On a machine without a browser (over SSH, in a container), --device prints a short code to
26enter at <server>/login/device from any browser; the CLI picks this by itself when it sees no
27display. For scripts and CI, pass a token with --token, or pipe one in with --with-token
28(piping into a plain \`diffinery login\` still works). The config file is written with mode 0600.`,
29 options: {
30 url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" },
31 scope: { type: "string", desc: "Access to ask for: read, write (default) or admin. You can change it on the authorize page", arg: "SCOPE" },
32 web: { type: "boolean", desc: "Sign in through the browser on this computer (the default in a terminal)" },
33 device: { type: "boolean", desc: "Sign in with a code you enter in a browser on any device" },
34 token: { type: "string", desc: "Use this token (dfy_...) instead of signing in through the browser", arg: "TOKEN" },
35 "with-token": { type: "boolean", desc: "Read a token from stdin" },
36 },
37 maxArgs: 0,
38 async run(ctx, o) {
39 const stored = await readConfig(ctx.io.env);
40 const cfg = await resolveConfig(ctx.io);
41 const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url;
42 const scope = (str(o, "scope") ?? "write") as Scope;
43 if (!SCOPES.includes(scope)) throw new CliError(`--scope must be one of ${SCOPES.join(", ")}`, 2);
44 const modes = (["web", "device", "with-token"] as const).filter((k) => o[k] === true);
45 if (modes.length + (str(o, "token") !== undefined ? 1 : 0) > 1) throw new CliError("use only one of --web, --device, --token and --with-token", 2);
46
47 let token: string;
48 let note = "";
49 if (str(o, "token") !== undefined || o["with-token"] || (!modes.length && !ctx.io.stdin.isTTY)) {
50 token = (str(o, "token") ?? (await readAll(ctx.io.stdin)).replace(/\r?\n$/, "")).trim();
51 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");
52 } else {
53 const device = o.device === true || (o.web !== true && !browserLikely(ctx.io));
54 const grant = device ? await deviceLogin(ctx, url, scope) : await browserLogin(ctx, url, scope);
55 token = grant.access_token;
56 note = ` with ${grant.scope} access until ${new Date(grant.expires_at).toLocaleDateString("en-US", { dateStyle: "medium" })}`;
57 }
58 const me = await new Api(url, token).get<User>("/user");
59 const file = await writeConfig(ctx.io.env, { ...stored, url, token });
60 // The token this one replaces: revoke it if a CLI sign-in made it (never a token someone pasted, which may be used elsewhere).
61 if (stored.token && stored.token !== token) await revokeCliToken(stored.url ? normalizeUrl(stored.url) : url, stored.token);
62 ctx.out(`${ctx.c.green("✓")} Logged in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}${note}`);
63 ctx.err(ctx.c.dim(`Saved to ${file}`));
64 if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is set and overrides the saved token"));
65 },
66};
67
68const logout: Command = {
69 name: "logout",
70 summary: "Sign out: forget the saved token, and revoke it if a browser sign-in created it",
71 maxArgs: 0,
72 async run(ctx) {
73 const stored = await readConfig(ctx.io.env);
74 if (!stored.token) ctx.out("Not logged in.");
75 else {
76 const url = stored.url ? normalizeUrl(stored.url) : DEFAULT_URL;
77 const revoked = await revokeCliToken(url, stored.token);
78 await writeConfig(ctx.io.env, { url: stored.url });
79 ctx.out(`Logged out of ${url}${revoked ? " and revoked the CLI's token" : ""}.`);
80 }
81 if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is still set in your environment"));
82 },
83};
84
85const whoami: Command = {
86 name: "whoami",
87 summary: "Show the account the current token acts as",
88 json: true,
89 maxArgs: 0,
90 async run(ctx) {
91 const cfg = await ctx.config();
92 const me = await (await ctx.authedApi()).get<User>("/user");
93 if (ctx.json) return json(ctx.io, me);
94 ctx.out(`${ctx.c.bold(me.login)}${me.name && me.name !== me.login ? ` (${me.name})` : ""}${me.type === "Bot" ? " [agent]" : ""}`);
95 ctx.out(ctx.c.dim(`${cfg.url} · token from ${cfg.tokenSource === "env" ? "DIFFINERY_TOKEN" : cfg.file}`));
96 },
97};
98
99const setupGit: Command = {
100 name: "auth setup-git",
101 summary: "Make git use your Diffinery token for HTTPS remotes",
102 description: `Runs: git config --global credential.<url>.helper "!diffinery auth git-credential"
103(after resetting other helpers for that URL), so clone/fetch/push to the Diffinery host
104authenticate with the token from the config file or DIFFINERY_TOKEN.`,
105 maxArgs: 0,
106 async run(ctx) {
107 const cfg = await ctx.config();
108 const key = `credential.${cfg.url}.helper`;
109 await gitCapture(ctx.io, ["config", "--global", "--unset-all", key]);
110 // An empty helper resets the list so a generic helper (e.g. a keychain) does not answer first.
111 for (const value of ["", "!diffinery auth git-credential"]) {
112 const r = await gitCapture(ctx.io, ["config", "--global", "--add", key, value]);
113 if (r.code !== 0) throw new CliError(`git config failed: ${r.stderr.trim()}`);
114 }
115 ctx.out(`${ctx.c.green("✓")} git will use diffinery for ${cfg.url}`);
116 },
117};
118
119/** Parses the git credential helper protocol's key=value lines. */
120export function parseCredentialInput(text: string): Record<string, string> {
121 const out: Record<string, string> = {};
122 for (const line of text.split(/\r?\n/)) {
123 if (!line) break;
124 const i = line.indexOf("=");
125 if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
126 }
127 return out;
128}
129
130const gitCredential: Command = {
131 name: "auth git-credential",
132 summary: "Git credential helper (used by 'auth setup-git')",
133 args: "<get|store|erase>",
134 minArgs: 1,
135 maxArgs: 1,
136 async run(ctx, _o, [op]) {
137 const input = parseCredentialInput(await readAll(ctx.io.stdin));
138 if (op !== "get") return; // store/erase: the token lives in the config file
139 const cfg = await ctx.config();
140 if (!cfg.token) return;
141 const want = new URL(cfg.url);
142 const proto = (input.protocol ?? "").toLowerCase();
143 const host = (input.host ?? "").toLowerCase();
144 if (proto !== want.protocol.replace(/:$/, "") || host !== want.host.toLowerCase()) return;
145 ctx.io.stdout.write(`protocol=${proto}\nhost=${input.host}\nusername=x-token\npassword=${cfg.token}\n`);
146 },
147};
148
149const mcpConfig: Command = {
150 name: "mcp-config",
151 summary: "Print an MCP server config snippet for Claude Code and other MCP clients",
152 description: `By default the token is written as \${DIFFINERY_TOKEN} (expanded by clients such as
153Claude Code from the environment). Pass --show-token to embed the saved token instead.`,
154 options: { "show-token": { type: "boolean", desc: "Embed the actual token in the snippet" } },
155 maxArgs: 0,
156 async run(ctx, o) {
157 const cfg = await ctx.config();
158 let token = "${DIFFINERY_TOKEN}";
159 if (o["show-token"]) {
160 if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN");
161 token = cfg.token;
162 }
163 json(ctx.io, { mcpServers: { diffinery: { type: "http", url: `${cfg.url}/mcp`, headers: { Authorization: `Bearer ${token}` } } } });
164 },
165};
166
167export const authCommands: Command[] = [login, logout, whoami, mcpConfig];
168export const authGroup: Group = { name: "auth", summary: "Git credential integration", commands: [setupGit, gitCredential] };