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.

139 lines · 5.9 KBRaw History
1import { Api } from "../api.js";
2import { str, type Command, type Group } from "../cli.js";
3import { normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js";
4import { gitCapture } from "../git.js";
5import { CliError, readAll, valueFromFlagOrStdin } from "../io.js";
6import { json } from "../output.js";
7
8interface User {
9 login: string;
10 name?: string | null;
11 type?: string;
12 email?: string | null;
13 html_url?: string;
14}
15
16const login: Command = {
17 name: "login",
18 summary: "Log in with a personal or agent token",
19 description: `Stores the server URL and token in the config file (mode 0600).
20The token is read from --token, from stdin when piped, or prompted for.
21Create a token in the web UI (Settings → Access tokens) or with 'diffinery token create'.`,
22 options: {
23 url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" },
24 token: { type: "string", desc: "Token (dfy_...)", arg: "TOKEN" },
25 },
26 maxArgs: 0,
27 async run(ctx, o) {
28 const stored = await readConfig(ctx.io.env);
29 const cfg = await resolveConfig(ctx.io);
30 const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url;
31 const token = (await valueFromFlagOrStdin(ctx.io, str(o, "token"), `Paste a token for ${url}: `)).trim();
32 if (!token) throw new CliError("no token given");
33 const me = await new Api(url, token).get<User>("/user");
34 const file = await writeConfig(ctx.io.env, { ...stored, url, token });
35 ctx.out(`${ctx.c.green("✓")} Logged in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}`);
36 ctx.err(ctx.c.dim(`Saved to ${file}`));
37 if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is set and overrides the saved token"));
38 },
39};
40
41const logout: Command = {
42 name: "logout",
43 summary: "Remove the saved token",
44 maxArgs: 0,
45 async run(ctx) {
46 const stored = await readConfig(ctx.io.env);
47 if (!stored.token) ctx.out("Not logged in.");
48 else {
49 await writeConfig(ctx.io.env, { url: stored.url });
50 ctx.out(`Logged out of ${stored.url ?? "Diffinery"}.`);
51 }
52 if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is still set in your environment"));
53 },
54};
55
56const whoami: Command = {
57 name: "whoami",
58 summary: "Show the account the current token acts as",
59 json: true,
60 maxArgs: 0,
61 async run(ctx) {
62 const cfg = await ctx.config();
63 const me = await (await ctx.authedApi()).get<User>("/user");
64 if (ctx.json) return json(ctx.io, me);
65 ctx.out(`${ctx.c.bold(me.login)}${me.name && me.name !== me.login ? ` (${me.name})` : ""}${me.type === "Bot" ? " [agent]" : ""}`);
66 ctx.out(ctx.c.dim(`${cfg.url} · token from ${cfg.tokenSource === "env" ? "DIFFINERY_TOKEN" : cfg.file}`));
67 },
68};
69
70const setupGit: Command = {
71 name: "auth setup-git",
72 summary: "Make git use your Diffinery token for HTTPS remotes",
73 description: `Runs: git config --global credential.<url>.helper "!diffinery auth git-credential"
74(after resetting other helpers for that URL), so clone/fetch/push to the Diffinery host
75authenticate with the token from the config file or DIFFINERY_TOKEN.`,
76 maxArgs: 0,
77 async run(ctx) {
78 const cfg = await ctx.config();
79 const key = `credential.${cfg.url}.helper`;
80 await gitCapture(ctx.io, ["config", "--global", "--unset-all", key]);
81 // An empty helper resets the list so a generic helper (e.g. a keychain) does not answer first.
82 for (const value of ["", "!diffinery auth git-credential"]) {
83 const r = await gitCapture(ctx.io, ["config", "--global", "--add", key, value]);
84 if (r.code !== 0) throw new CliError(`git config failed: ${r.stderr.trim()}`);
85 }
86 ctx.out(`${ctx.c.green("✓")} git will use diffinery for ${cfg.url}`);
87 },
88};
89
90/** Parses the git credential helper protocol's key=value lines. */
91export function parseCredentialInput(text: string): Record<string, string> {
92 const out: Record<string, string> = {};
93 for (const line of text.split(/\r?\n/)) {
94 if (!line) break;
95 const i = line.indexOf("=");
96 if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
97 }
98 return out;
99}
100
101const gitCredential: Command = {
102 name: "auth git-credential",
103 summary: "Git credential helper (used by 'auth setup-git')",
104 args: "<get|store|erase>",
105 minArgs: 1,
106 maxArgs: 1,
107 async run(ctx, _o, [op]) {
108 const input = parseCredentialInput(await readAll(ctx.io.stdin));
109 if (op !== "get") return; // store/erase: the token lives in the config file
110 const cfg = await ctx.config();
111 if (!cfg.token) return;
112 const want = new URL(cfg.url);
113 const proto = (input.protocol ?? "").toLowerCase();
114 const host = (input.host ?? "").toLowerCase();
115 if (proto !== want.protocol.replace(/:$/, "") || host !== want.host.toLowerCase()) return;
116 ctx.io.stdout.write(`protocol=${proto}\nhost=${input.host}\nusername=x-token\npassword=${cfg.token}\n`);
117 },
118};
119
120const mcpConfig: Command = {
121 name: "mcp-config",
122 summary: "Print an MCP server config snippet for Claude Code and other MCP clients",
123 description: `By default the token is written as \${DIFFINERY_TOKEN} (expanded by clients such as
124Claude Code from the environment). Pass --show-token to embed the saved token instead.`,
125 options: { "show-token": { type: "boolean", desc: "Embed the actual token in the snippet" } },
126 maxArgs: 0,
127 async run(ctx, o) {
128 const cfg = await ctx.config();
129 let token = "${DIFFINERY_TOKEN}";
130 if (o["show-token"]) {
131 if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN");
132 token = cfg.token;
133 }
134 json(ctx.io, { mcpServers: { diffinery: { type: "http", url: `${cfg.url}/mcp`, headers: { Authorization: `Bearer ${token}` } } } });
135 },
136};
137
138export const authCommands: Command[] = [login, logout, whoami, mcpConfig];
139export const authGroup: Group = { name: "auth", summary: "Git credential integration", commands: [setupGit, gitCredential] };