| 1 | import { Api } from "../api.js"; |
| 2 | import { str, type Command, type Group } from "../cli.js"; |
| 3 | import { normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js"; |
| 4 | import { gitCapture } from "../git.js"; |
| 5 | import { CliError, readAll, valueFromFlagOrStdin } from "../io.js"; |
| 6 | import { json } from "../output.js"; |
| 7 | |
| 8 | interface User { |
| 9 | login: string; |
| 10 | name?: string | null; |
| 11 | type?: string; |
| 12 | email?: string | null; |
| 13 | html_url?: string; |
| 14 | } |
| 15 | |
| 16 | const 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). |
| 20 | The token is read from --token, from stdin when piped, or prompted for. |
| 21 | Create a token in the web UI (Settings → Access tokens).`, |
| 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 | |
| 41 | const 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 | |
| 56 | const 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 | |
| 70 | const 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 |
| 75 | authenticate 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. */ |
| 91 | export 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 | |
| 101 | const 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 | |
| 120 | const 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 |
| 124 | Claude 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 | |
| 138 | export const authCommands: Command[] = [login, logout, whoami, mcpConfig]; |
| 139 | export const authGroup: Group = { name: "auth", summary: "Git credential integration", commands: [setupGit, gitCredential] }; |