|
| 1 | +import { execFile } from "node:child_process"; |
| 2 | +import fs from "node:fs"; |
| 3 | +import os from "node:os"; |
| 4 | +import path from "node:path"; |
| 5 | + |
| 6 | +/** |
| 7 | + * One read through the person's own `gh` login (#320 decision 3): `gh api <path> --method GET --include` |
| 8 | + * on github.com. Modex never sees or stores the token, and nothing here writes. |
| 9 | + */ |
| 10 | +export interface GhCall { |
| 11 | + /** A path main built itself, every segment encoded; checked again here. */ |
| 12 | + endpoint: string; |
| 13 | + accept: (typeof MEDIA_TYPES)[number]; |
| 14 | + /** The ETag of the copy main holds, for a conditional read that GitHub does not charge. */ |
| 15 | + etag: string | null; |
| 16 | + maxBytes: number; |
| 17 | + timeoutMs: number; |
| 18 | + signal: AbortSignal; |
| 19 | +} |
| 20 | + |
| 21 | +/** An HTTP answer, or why there is none. Nothing gh prints to stderr is kept: it can name accounts or tokens. */ |
| 22 | +export type GhAnswer = |
| 23 | + | { kind: "http"; status: number; headers: Record<string, string>; body: Buffer } |
| 24 | + | { kind: "no-cli" | "signed-out" | "offline" | "timeout" | "cancelled" | "too-large" | "failed" }; |
| 25 | + |
| 26 | +export type GhTransport = (call: GhCall) => Promise<GhAnswer>; |
| 27 | + |
| 28 | +const MEDIA_TYPES = ["application/vnd.github+json", "application/vnd.github.raw"] as const; |
| 29 | +const SEGMENT = "[A-Za-z0-9%_.~*'()!-]+"; |
| 30 | +const QUERY = "[A-Za-z0-9%=&+_.~*'()!-]+"; |
| 31 | +/** `user`, the issue search, or a path under `repos/`: no flag, host, placeholder, space or dot segment. */ |
| 32 | +const ENDPOINT = new RegExp(`^(?:user|search/issues\\?${QUERY}|repos(?:/${SEGMENT}){2,}(?:\\?${QUERY})?)$`); |
| 33 | +const ETAG = /^(?:W\/)?"[\x21\x23-\x7e]{1,200}"$/; |
| 34 | +/** What gh prints when it cannot reach the API at all. */ |
| 35 | +const NETWORK = /dial tcp|no such host|connection (?:refused|reset)|network is unreachable|i\/o timeout|TLS handshake|proxyconnect|EOF/i; |
| 36 | + |
| 37 | +export function ghArgs(call: GhCall): string[] { |
| 38 | + const route = call.endpoint.split("?")[0]!; |
| 39 | + if (!ENDPOINT.test(call.endpoint) || route.split("/").some((segment) => segment === "." || segment === "..")) { |
| 40 | + throw new Error("Code Review endpoint is not allowed."); |
| 41 | + } |
| 42 | + if (!MEDIA_TYPES.includes(call.accept)) throw new Error("Unsupported Code Review media type."); |
| 43 | + if (call.etag !== null && !ETAG.test(call.etag)) throw new Error("Invalid ETag."); |
| 44 | + return ["api", call.endpoint, "--hostname", "github.com", "--method", "GET", "--include", "-H", `Accept: ${call.accept}`, |
| 45 | + "-H", "X-GitHub-Api-Version: 2022-11-28", ...(call.etag ? ["-H", `If-None-Match: ${call.etag}`] : [])]; |
| 46 | +} |
| 47 | + |
| 48 | +/** gh's `--include` output: a status line ending in LF, headers ending in CRLF, a blank line, then the body. */ |
| 49 | +export function parseIncluded(stdout: Buffer): { status: number; headers: Record<string, string>; body: Buffer } | null { |
| 50 | + const lineEnd = stdout.indexOf(0x0a); |
| 51 | + if (lineEnd < 0) return null; |
| 52 | + const match = /^HTTP\/\d(?:\.\d)? (\d{3})(?: [^\r\n]*)?$/.exec(stdout.subarray(0, lineEnd).toString("latin1").replace(/\r$/, "")); |
| 53 | + if (!match) return null; |
| 54 | + const rest = stdout.subarray(lineEnd + 1); |
| 55 | + let block: string, body: Buffer; |
| 56 | + if (rest.subarray(0, 2).toString("latin1") === "\r\n") { |
| 57 | + block = ""; |
| 58 | + body = rest.subarray(2); |
| 59 | + } else { |
| 60 | + const end = rest.indexOf("\r\n\r\n"); |
| 61 | + if (end < 0) return null; |
| 62 | + block = rest.subarray(0, end).toString("latin1"); |
| 63 | + body = rest.subarray(end + 4); |
| 64 | + } |
| 65 | + const headers: Record<string, string> = {}; |
| 66 | + for (const line of block ? block.split(/\r?\n/) : []) { |
| 67 | + const colon = line.indexOf(":"); |
| 68 | + if (colon <= 0) return null; |
| 69 | + const name = line.slice(0, colon).trim().toLowerCase(), value = line.slice(colon + 1).trim(); |
| 70 | + headers[name] = Object.hasOwn(headers, name) ? `${headers[name]}, ${value}` : value; |
| 71 | + } |
| 72 | + return { status: Number(match[1]), headers, body }; |
| 73 | +} |
| 74 | + |
| 75 | +/** No shell, prompt, pager, colour, update check or debug output, and only absolute PATH entries. */ |
| 76 | +function environment(): NodeJS.ProcessEnv { |
| 77 | + const searchPath = [...new Set([...(process.env.PATH ?? "").split(path.delimiter).filter(path.isAbsolute), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"])].join(path.delimiter); |
| 78 | + const env: NodeJS.ProcessEnv = { ...process.env, PATH: searchPath, GH_PROMPT_DISABLED: "1", GH_PAGER: "cat", NO_COLOR: "1", CLICOLOR: "0", |
| 79 | + GH_NO_UPDATE_NOTIFIER: "1", GH_SPINNER_DISABLED: "1" }; |
| 80 | + delete env.GH_DEBUG; |
| 81 | + delete env.DEBUG; |
| 82 | + return env; |
| 83 | +} |
| 84 | + |
| 85 | +export function ghTransport(bin = "gh"): GhTransport { |
| 86 | + return (call) => { |
| 87 | + const args = ghArgs(call); |
| 88 | + return new Promise((resolve) => { |
| 89 | + execFile(bin, args, { cwd: os.homedir(), env: environment(), encoding: "buffer", timeout: call.timeoutMs, maxBuffer: call.maxBytes, signal: call.signal, windowsHide: true }, |
| 90 | + (error, stdout, stderr) => { |
| 91 | + const failure = error as (Omit<NodeJS.ErrnoException, "code"> & { killed?: boolean; code?: string | number }) | null; |
| 92 | + if (call.signal.aborted) return resolve({ kind: "cancelled" }); |
| 93 | + if (failure?.code === "ENOENT") return resolve({ kind: "no-cli" }); |
| 94 | + if (failure?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return resolve({ kind: "too-large" }); |
| 95 | + if (failure?.killed) return resolve({ kind: "timeout" }); |
| 96 | + const answer = parseIncluded(stdout); |
| 97 | + // gh exits 1 for a 304 or a 4xx it printed in full; after a 2xx it means the body was cut short. |
| 98 | + if (answer && failure && answer.status >= 200 && answer.status < 300) return resolve({ kind: "failed" }); |
| 99 | + if (answer) return resolve({ kind: "http", ...answer }); |
| 100 | + if (failure?.code === 4) return resolve({ kind: "signed-out" }); |
| 101 | + resolve({ kind: failure && NETWORK.test(stderr.toString("utf8")) ? "offline" : "failed" }); |
| 102 | + }); |
| 103 | + }); |
| 104 | + }; |
| 105 | +} |
| 106 | + |
| 107 | +/** |
| 108 | + * A fingerprint of gh's hosts.yml, which `gh auth switch`, `login` and `logout` rewrite: a change means |
| 109 | + * the account may have changed under main. "absent" when there is none; null where it can't be read, |
| 110 | + * and main then confirms the account after every read instead. |
| 111 | + */ |
| 112 | +export function ghHostsStamp(): string | null { |
| 113 | + const dir = process.env.GH_CONFIG_DIR || (process.env.XDG_CONFIG_HOME ? path.join(process.env.XDG_CONFIG_HOME, "gh") |
| 114 | + : process.platform === "win32" && process.env.AppData ? path.join(process.env.AppData, "GitHub CLI") : path.join(os.homedir(), ".config", "gh")); |
| 115 | + try { |
| 116 | + const stat = fs.statSync(path.join(dir, "hosts.yml")); |
| 117 | + // The change time too: a same-size rewrite within one modification-time tick still moves it. |
| 118 | + return `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; |
| 119 | + } catch (error) { |
| 120 | + return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : null; |
| 121 | + } |
| 122 | +} |
0 commit comments