| 1 | import { spawn } from "node:child_process"; |
| 2 | import { existsSync, statSync } from "node:fs"; |
| 3 | import { access, constants } from "node:fs/promises"; |
| 4 | import path from "node:path"; |
| 5 | import { Api, ApiError, enc } from "../api.js"; |
| 6 | import { Ctx, type RepoRef } from "../cli.js"; |
| 7 | import { gitCapture, parseRemote } from "../git.js"; |
| 8 | import { CliError } from "../io.js"; |
| 9 | import { VERSION } from "../version.js"; |
| 10 | |
| 11 | /** |
| 12 | * `diffinery setup --repo OWNER/NAME`: sets up an agent environment (a Claude Code cloud environment, |
| 13 | * Codex, a CI runner) for one repository, without a person at the keyboard. What the hosted script |
| 14 | * (<server>/<owner>/<repo>/agent-setup.sh, from Settings → Agent setup) runs once it has installed the CLI. |
| 15 | * |
| 16 | * The token comes from DIFFINERY_TOKEN and is never written to disk: git asks `diffinery auth |
| 17 | * git-credential`, which reads it from the environment each time, and gh reads GH_ENTERPRISE_TOKEN. So a |
| 18 | * snapshot of the machine (Claude Code caches what a setup script leaves on disk) holds no credential, |
| 19 | * and a new token works as soon as the environment variables change. Then a self-test, one line per |
| 20 | * check saying what to change when it fails, and a report to the repository's Agent setup page (its Check). |
| 21 | */ |
| 22 | |
| 23 | export interface CheckLine { |
| 24 | name: string; |
| 25 | ok: boolean; |
| 26 | message: string; |
| 27 | } |
| 28 | |
| 29 | interface Proc { |
| 30 | code: number; |
| 31 | stdout: string; |
| 32 | stderr: string; |
| 33 | } |
| 34 | |
| 35 | /** Runs a program and captures its output; a program that isn't installed exits 127. */ |
| 36 | export function capture(cmd: string, args: string[], o: { env: NodeJS.ProcessEnv; cwd?: string; input?: string }): Promise<Proc> { |
| 37 | return new Promise((resolve) => { |
| 38 | let child; |
| 39 | try { |
| 40 | child = spawn(cmd, args, { env: o.env, cwd: o.cwd, stdio: [o.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] }); |
| 41 | } catch (e) { |
| 42 | return resolve({ code: 127, stdout: "", stderr: (e as Error).message }); |
| 43 | } |
| 44 | let stdout = ""; |
| 45 | let stderr = ""; |
| 46 | child.stdout?.on("data", (d: Buffer) => (stdout += d.toString())); |
| 47 | child.stderr?.on("data", (d: Buffer) => (stderr += d.toString())); |
| 48 | child.on("error", (e) => resolve({ code: (e as NodeJS.ErrnoException).code === "ENOENT" ? 127 : 1, stdout, stderr: stderr + e.message })); |
| 49 | child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); |
| 50 | if (child.stdin) { |
| 51 | child.stdin.on("error", () => {}); |
| 52 | child.stdin.end(o.input); |
| 53 | } |
| 54 | }); |
| 55 | } |
| 56 | |
| 57 | /** The gh token environment variables for a host other than github.com, in gh's order. */ |
| 58 | const GH_ENTERPRISE_VARS = ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] as const; |
| 59 | |
| 60 | /** Which environment variable gives gh its token for `host`, if any (gh then refuses `gh auth login`). */ |
| 61 | export function ghEnvToken(env: NodeJS.ProcessEnv, host: string): { name: string; value: string } | null { |
| 62 | if (/^(github\.com|.*\.ghe\.com)$/i.test(host)) return null; |
| 63 | for (const name of GH_ENTERPRISE_VARS) if (env[name]) return { name, value: env[name]! }; |
| 64 | return null; |
| 65 | } |
| 66 | |
| 67 | /** First line of a command's error output, without anything token-like. */ |
| 68 | const firstLine = (s: string) => |
| 69 | (s.trim().split("\n").find((l) => l.trim()) ?? "") |
| 70 | .replace(/dfy_[A-Za-z0-9]+/g, "dfy_…") |
| 71 | .slice(0, 200); |
| 72 | |
| 73 | /** Where the clone goes: --clone-dir, DIFFINERY_CLONE_DIR, a Claude Code cloud session's /home/user, or here. */ |
| 74 | export function cloneParent(env: NodeJS.ProcessEnv, cwd: string, flag?: string): string { |
| 75 | const chosen = flag ?? env.DIFFINERY_CLONE_DIR; |
| 76 | if (chosen) return path.resolve(cwd, chosen); |
| 77 | // Claude Code cloud sessions start in /home/user, but setup scripts run elsewhere. |
| 78 | const claude = "/home/user"; |
| 79 | const inside = cwd === claude || cwd.startsWith(`${claude}/`); |
| 80 | if (!inside && isDir(claude)) return claude; |
| 81 | return cwd; |
| 82 | } |
| 83 | |
| 84 | function isDir(p: string): boolean { |
| 85 | try { |
| 86 | return statSync(p).isDirectory(); |
| 87 | } catch { |
| 88 | return false; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /** Whether `dir`, or a directory above it, is a clone of `repo` on `host`. Returns its top level. */ |
| 93 | async function cloneRoot(ctx: Ctx, dir: string, host: string, repo: RepoRef): Promise<string | null> { |
| 94 | if (!isDir(dir)) return null; |
| 95 | const top = await gitCapture(ctx.io, ["rev-parse", "--show-toplevel"], { cwd: dir }); |
| 96 | if (top.code !== 0) return null; |
| 97 | const url = await gitCapture(ctx.io, ["remote", "get-url", "origin"], { cwd: dir }); |
| 98 | const r = url.code === 0 ? parseRemote(url.stdout, host) : null; |
| 99 | return r && r.owner.toLowerCase() === repo.owner.toLowerCase() && r.name.toLowerCase() === repo.name.toLowerCase() ? top.stdout.trim() : null; |
| 100 | } |
| 101 | |
| 102 | /** An executable named `name` in one of PATH's directories. */ |
| 103 | async function onPath(name: string, pathVar: string | undefined): Promise<string | null> { |
| 104 | for (const dir of (pathVar ?? "").split(path.delimiter)) { |
| 105 | if (!dir) continue; |
| 106 | const file = path.join(dir, name); |
| 107 | try { |
| 108 | await access(file, constants.X_OK); |
| 109 | return file; |
| 110 | } catch { |
| 111 | /* next */ |
| 112 | } |
| 113 | } |
| 114 | return null; |
| 115 | } |
| 116 | |
| 117 | export interface AgentSetupOptions { |
| 118 | repo: RepoRef; |
| 119 | url: string; |
| 120 | clone: boolean; |
| 121 | cloneDir?: string; |
| 122 | report: boolean; |
| 123 | } |
| 124 | |
| 125 | export async function agentSetup(ctx: Ctx, o: AgentSetupOptions, wireGit: (ctx: Ctx) => Promise<void>): Promise<number> { |
| 126 | const { io } = ctx; |
| 127 | const env = io.env; |
| 128 | const url = o.url; |
| 129 | const host = new URL(url).host; |
| 130 | const slug = `${o.repo.owner}/${o.repo.name}`; |
| 131 | const page = `${url}/${slug}/settings/agent-setup`; |
| 132 | const lines: CheckLine[] = []; |
| 133 | const line = (name: string, ok: boolean, message: string) => { |
| 134 | lines.push({ name, ok, message }); |
| 135 | ctx.out(`${ok ? ctx.c.green("✓") : ctx.c.red("✗")} ${name}: ${message}`); |
| 136 | }; |
| 137 | |
| 138 | const cfg = await ctx.config(); |
| 139 | const token = cfg.token; |
| 140 | |
| 141 | // 1. The server. |
| 142 | let reachable = false; |
| 143 | try { |
| 144 | const res = await fetch(`${url}/healthz`, { headers: { "user-agent": `diffinery-cli/${VERSION}` } }); |
| 145 | reachable = res.ok; |
| 146 | line("server", res.ok, res.ok ? `${host} is reachable` : `${host} answered ${res.status}; try again in a minute`); |
| 147 | } catch (e) { |
| 148 | const cause = (e as { cause?: { code?: string } }).cause?.code ?? (e as Error).message; |
| 149 | line("server", false, `can't reach ${host} (${cause}): allow ${host} in the environment's network settings (Custom with ${host}, or Full)`); |
| 150 | } |
| 151 | |
| 152 | // 2. The diffinery command, for git's credential helper and for the agent. |
| 153 | const cmd = await onPath("diffinery", env.DIFFINERY_SETUP_PATH ?? env.PATH); |
| 154 | line("diffinery on PATH", !!cmd, cmd ? `${cmd} (${VERSION})` : "not on PATH: run the setup script again, or add ~/.diffinery/bin to PATH"); |
| 155 | |
| 156 | // 3. The token and what it can do. |
| 157 | let login: string | null = null; |
| 158 | let canPush = false; |
| 159 | if (!token) { |
| 160 | line("DIFFINERY_TOKEN", false, `not set: add DIFFINERY_TOKEN=<token> to the environment variables (generate one on ${page})`); |
| 161 | } else if (reachable) { |
| 162 | const api = new Api(url, token); |
| 163 | try { |
| 164 | const me = await api.get<{ login: string; type?: string }>("/user"); |
| 165 | login = me.login; |
| 166 | line("DIFFINERY_TOKEN", true, `acts as ${me.login}${me.type === "Bot" ? " (agent)" : ""}`); |
| 167 | } catch (e) { |
| 168 | line("DIFFINERY_TOKEN", false, e instanceof ApiError && e.status === 401 ? `refused (revoked or expired): generate a new token on ${page} and paste it into DIFFINERY_TOKEN and GH_ENTERPRISE_TOKEN` : firstLine((e as Error).message)); |
| 169 | } |
| 170 | if (login) { |
| 171 | try { |
| 172 | const r = await api.get<{ permissions?: { push?: boolean } }>(`/repos/${enc(o.repo.owner)}/${enc(o.repo.name)}`); |
| 173 | canPush = !!r.permissions?.push; |
| 174 | line("repository", canPush, canPush ? `${slug} with write access` : `${login} can read ${slug} but not push: use a token from ${page}`); |
| 175 | } catch (e) { |
| 176 | line("repository", false, e instanceof ApiError && e.status === 404 ? `the token can't see ${slug}: use a token from ${page}` : firstLine((e as Error).message)); |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // 4. git: the credential helper reads the token from the environment whenever git asks. |
| 182 | try { |
| 183 | await wireGit(ctx); |
| 184 | } catch (e) { |
| 185 | line("git", false, firstLine((e as Error).message)); |
| 186 | } |
| 187 | const remote = `${url}/${slug}.git`; |
| 188 | const gitEnv = { ...env, GIT_TERMINAL_PROMPT: "0" }; |
| 189 | if (token && reachable && !lines.some((l) => l.name === "git")) { |
| 190 | const r = await gitCapture(io, ["ls-remote", "--heads", remote], { env: gitEnv }); |
| 191 | line("git", r.code === 0, r.code === 0 ? `git can reach ${remote} with no prompt` : `git ls-remote failed: ${firstLine(r.stderr) || `exit ${r.code}`}`); |
| 192 | } |
| 193 | |
| 194 | // 5. The clone. |
| 195 | let clone: string | null = null; |
| 196 | if (o.clone && token && reachable) { |
| 197 | const parent = cloneParent(env, io.cwd, o.cloneDir); |
| 198 | clone = (await cloneRoot(ctx, io.cwd, host, o.repo)) ?? null; |
| 199 | const target = clone ?? path.join(parent, o.repo.name); |
| 200 | if (!clone && existsSync(target)) { |
| 201 | clone = await cloneRoot(ctx, target, host, o.repo); |
| 202 | if (!clone) line("clone", false, `${target} exists and isn't a clone of ${slug}: move it, or set DIFFINERY_CLONE_DIR to another directory`); |
| 203 | } |
| 204 | if (clone) { |
| 205 | const f = await gitCapture(io, ["fetch", "--quiet", "origin"], { cwd: clone, env: gitEnv }); |
| 206 | let note = "fetched"; |
| 207 | if (f.code === 0) { |
| 208 | // A clean checkout moves up to its branch on the server, as a fresh clone would be. |
| 209 | const dirty = (await gitCapture(io, ["status", "--porcelain"], { cwd: clone })).stdout.trim(); |
| 210 | const up = await gitCapture(io, ["rev-parse", "--abbrev-ref", "@{upstream}"], { cwd: clone }); |
| 211 | if (!dirty && up.code === 0 && (await gitCapture(io, ["merge", "--ff-only", "--quiet", up.stdout.trim()], { cwd: clone })).code === 0) note = "up to date"; |
| 212 | } |
| 213 | line("clone", f.code === 0, f.code === 0 ? `${clone} (${note})` : `git fetch failed in ${clone}: ${firstLine(f.stderr)}`); |
| 214 | } else if (!lines.some((l) => l.name === "clone")) { |
| 215 | const r = await gitCapture(io, ["clone", "--quiet", remote, target], { env: gitEnv }); |
| 216 | if (r.code === 0) clone = target; |
| 217 | line("clone", r.code === 0, r.code === 0 ? `cloned into ${target}` : `git clone failed: ${firstLine(r.stderr) || `exit ${r.code}`}`); |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // 6. gh: GH_ENTERPRISE_TOKEN in the environment, or gh's own sign-in when it isn't set. |
| 222 | const ghVersion = await capture("gh", ["--version"], { env }); |
| 223 | if (ghVersion.code !== 0) { |
| 224 | line("gh", true, "skipped: the GitHub CLI isn't installed (install it for gh pr and gh api: https://cli.github.com)"); |
| 225 | } else if (token && reachable && login) { |
| 226 | const fromEnv = ghEnvToken(env, host); |
| 227 | let ready = true; |
| 228 | if (fromEnv && fromEnv.value !== token) { |
| 229 | line("gh", false, `${fromEnv.name} isn't the same token as DIFFINERY_TOKEN: paste the same token into both`); |
| 230 | ready = false; |
| 231 | } else if (!fromEnv) { |
| 232 | // No token for gh in the environment: sign gh in with this one (gh keeps it in its own config). |
| 233 | const r = await capture("gh", ["auth", "login", "--hostname", host, "--with-token", "--git-protocol", "https"], { env, input: `${token}\n` }); |
| 234 | if (r.code !== 0) { |
| 235 | line("gh", false, `gh auth login failed: ${firstLine(r.stderr)}; or add GH_ENTERPRISE_TOKEN=<token> and GH_HOST=${host} to the environment variables`); |
| 236 | ready = false; |
| 237 | } |
| 238 | } |
| 239 | if (ready && fromEnv && env.GH_HOST !== host) { |
| 240 | line("GH_HOST", false, env.GH_HOST ? `is ${env.GH_HOST}: set GH_HOST=${host} in the environment variables` : `not set: add GH_HOST=${host} to the environment variables`); |
| 241 | ready = false; |
| 242 | } |
| 243 | if (ready) { |
| 244 | const r = await capture("gh", ["api", "user", "--hostname", host, "--jq", ".login"], { env }); |
| 245 | const who = r.stdout.trim(); |
| 246 | line("gh", r.code === 0 && who === login, r.code === 0 ? (who === login ? `gh api acts as ${who}` : `gh acts as ${who}, not ${login}: paste the same token into GH_ENTERPRISE_TOKEN and DIFFINERY_TOKEN`) : `gh api user failed: ${firstLine(r.stderr)}`); |
| 247 | if (clone && r.code === 0 && who === login) { |
| 248 | const prs = await capture("gh", ["pr", "list", "--state", "all", "--limit", "1", "--json", "number"], { env, cwd: clone }); |
| 249 | line("gh in the clone", prs.code === 0, prs.code === 0 ? "gh pr list works in the clone" : `gh pr list failed in ${clone}: ${firstLine(prs.stderr)}`); |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // 7. The report, which is the Check on the repository's Agent setup page. |
| 255 | const ok = lines.every((l) => l.ok); |
| 256 | if (o.report && token && reachable && login) { |
| 257 | try { |
| 258 | await new Api(url, token).post(`/repos/${enc(o.repo.owner)}/${enc(o.repo.name)}/diffinery/agent-setup/checks`, { checks: lines, cli_version: VERSION }); |
| 259 | ctx.out(ctx.c.dim(`Reported to ${page}`)); |
| 260 | } catch (e) { |
| 261 | ctx.err(`${ctx.c.yellow("warning:")} couldn't report the check to ${page}: ${firstLine((e as Error).message)}`); |
| 262 | } |
| 263 | } |
| 264 | const failed = lines.filter((l) => !l.ok).length; |
| 265 | ctx.out(""); |
| 266 | ctx.out(ok ? `${ctx.c.green("✓")} Ready: git, gh and the diffinery CLI work with ${slug}${clone ? ` (in ${clone})` : ""}.` : `${ctx.c.red("✗")} ${failed} of ${lines.length} checks failed; fix them as each line says, then run this again.`); |
| 267 | return ok ? 0 : 1; |
| 268 | } |
| 269 | |
| 270 | /** Parses --repo for setup, which must name the repository explicitly. */ |
| 271 | export function setupRepo(v: string): RepoRef { |
| 272 | const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9._-]+)$/.exec(v.trim().replace(/\.git$/, "")); |
| 273 | if (!m) throw new CliError(`--repo expects OWNER/NAME, got "${v}"`, 2); |
| 274 | return { owner: m[1]!, name: m[2]! }; |
| 275 | } |
| 276 | |