| 1 | import { Api } from "../api.js"; |
| 2 | import { str, type Command, type Group } from "../cli.js"; |
| 3 | import { DEFAULT_URL, normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js"; |
| 4 | import { gitCapture } from "../git.js"; |
| 5 | import { CliError, readAll } from "../io.js"; |
| 6 | import { browserLikely, browserLogin, deviceLogin, revokeCliToken, type Scope } from "../login.js"; |
| 7 | import { json } from "../output.js"; |
| 8 | |
| 9 | interface User { |
| 10 | login: string; |
| 11 | name?: string | null; |
| 12 | type?: string; |
| 13 | email?: string | null; |
| 14 | html_url?: string; |
| 15 | } |
| 16 | |
| 17 | const SCOPES = ["read", "write", "admin"] as const; |
| 18 | |
| 19 | const 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 |
| 23 | gets its own token, valid for 90 days and listed under Settings → Access tokens. |
| 24 | |
| 25 | On a machine without a browser (over SSH, in a container), --device prints a short code to |
| 26 | enter at <server>/login/device from any browser; the CLI picks this by itself when it sees no |
| 27 | display. 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 | |
| 68 | const 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 | |
| 85 | const 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 | |
| 99 | const 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 |
| 104 | authenticate 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. */ |
| 120 | export 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 | |
| 130 | const 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 | |
| 149 | const 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 |
| 153 | Claude 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 | |
| 167 | export const authCommands: Command[] = [login, logout, whoami, mcpConfig]; |
| 168 | export const authGroup: Group = { name: "auth", summary: "Git credential integration", commands: [setupGit, gitCredential] }; |