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