| 1 | import { parseArgs } from "node:util"; |
| 2 | import { Api } from "./api.js"; |
| 3 | import { resolveConfig, type ResolvedConfig } from "./config.js"; |
| 4 | import { gitCapture, parseRemote } from "./git.js"; |
| 5 | import { CliError, ExitCode, type Io } from "./io.js"; |
| 6 | import { colors, type Colors } from "./output.js"; |
| 7 | import { VERSION } from "./version.js"; |
| 8 | |
| 9 | export interface OptSpec { |
| 10 | type: "string" | "boolean"; |
| 11 | short?: string; |
| 12 | multiple?: boolean; |
| 13 | desc: string; |
| 14 | /** Placeholder shown in help, e.g. "NAME". */ |
| 15 | arg?: string; |
| 16 | } |
| 17 | |
| 18 | export type Values = Record<string, string | boolean | string[] | undefined>; |
| 19 | |
| 20 | export interface Command { |
| 21 | /** Full command path, e.g. "pr create". */ |
| 22 | name: string; |
| 23 | summary: string; |
| 24 | /** Positional part of the usage line, e.g. "<number>". */ |
| 25 | args?: string; |
| 26 | description?: string; |
| 27 | options?: Record<string, OptSpec>; |
| 28 | /** Accepts -R/--repo OWNER/NAME (defaults to the git origin). */ |
| 29 | repo?: boolean; |
| 30 | /** Accepts --json. */ |
| 31 | json?: boolean; |
| 32 | minArgs?: number; |
| 33 | maxArgs?: number; |
| 34 | run(ctx: Ctx, o: Values, args: string[]): Promise<number | void>; |
| 35 | } |
| 36 | |
| 37 | export interface RepoRef { |
| 38 | owner: string; |
| 39 | name: string; |
| 40 | } |
| 41 | |
| 42 | export class Ctx { |
| 43 | readonly c: Colors; |
| 44 | private cfg?: Promise<ResolvedConfig>; |
| 45 | constructor( |
| 46 | readonly io: Io, |
| 47 | readonly opts: Values, |
| 48 | ) { |
| 49 | this.c = colors(io); |
| 50 | } |
| 51 | |
| 52 | get json(): boolean { |
| 53 | return this.opts.json === true; |
| 54 | } |
| 55 | |
| 56 | config(): Promise<ResolvedConfig> { |
| 57 | return (this.cfg ??= resolveConfig(this.io)); |
| 58 | } |
| 59 | |
| 60 | async api(): Promise<Api> { |
| 61 | const cfg = await this.config(); |
| 62 | return new Api(cfg.url, cfg.token); |
| 63 | } |
| 64 | |
| 65 | /** Requires a token (for commands that make no sense anonymously). */ |
| 66 | async authedApi(): Promise<Api> { |
| 67 | const cfg = await this.config(); |
| 68 | if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN"); |
| 69 | return new Api(cfg.url, cfg.token); |
| 70 | } |
| 71 | |
| 72 | out(s: string): void { |
| 73 | this.io.stdout.write(s.endsWith("\n") ? s : `${s}\n`); |
| 74 | } |
| 75 | |
| 76 | err(s: string): void { |
| 77 | this.io.stderr.write(s.endsWith("\n") ? s : `${s}\n`); |
| 78 | } |
| 79 | |
| 80 | /** -R owner/name, else the current git repository's origin when it points at the configured host. */ |
| 81 | async repo(flag?: string): Promise<RepoRef> { |
| 82 | const v = flag ?? (typeof this.opts.repo === "string" ? this.opts.repo : undefined); |
| 83 | if (v) return parseRepoArg(v); |
| 84 | const cfg = await this.config(); |
| 85 | const host = new URL(cfg.url).host; |
| 86 | const r = await gitCapture(this.io, ["remote", "get-url", "origin"]); |
| 87 | if (r.code === 0) { |
| 88 | const parsed = parseRemote(r.stdout, host); |
| 89 | if (parsed) return parsed; |
| 90 | // A remote on another host is often another Diffinery instance (a self-hosted one): say how to switch. |
| 91 | throw new CliError(`the origin remote (${r.stdout.trim()}) does not point at ${host}, the Diffinery this CLI uses; pass -R OWNER/NAME, or if it's another Diffinery, sign in there with diffinery login --url https://HOST (or set DIFFINERY_URL)`); |
| 92 | } |
| 93 | throw new CliError("no repository given: pass -R OWNER/NAME or run inside a clone of a Diffinery repository"); |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | export function parseRepoArg(v: string): RepoRef { |
| 98 | const s = v.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "").replace(/\/+$/, ""); |
| 99 | const [owner, name, extra] = s.split("/"); |
| 100 | if (!owner || !name || extra !== undefined) throw new CliError(`expected OWNER/NAME, got "${v}"`, 2); |
| 101 | return { owner, name }; |
| 102 | } |
| 103 | |
| 104 | export const repoPath = (r: RepoRef) => `/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.name)}`; |
| 105 | |
| 106 | // ---------------------------------------------------------------- help |
| 107 | |
| 108 | const COMMON: Record<string, OptSpec> = { |
| 109 | repo: { type: "string", short: "R", desc: "Repository (default: inferred from the git origin remote)", arg: "OWNER/NAME" }, |
| 110 | json: { type: "boolean", desc: "Print raw JSON" }, |
| 111 | help: { type: "boolean", short: "h", desc: "Show help" }, |
| 112 | }; |
| 113 | |
| 114 | export function optionsFor(cmd: Command): Record<string, OptSpec> { |
| 115 | return { ...(cmd.repo ? { repo: COMMON.repo! } : {}), ...cmd.options, ...(cmd.json ? { json: COMMON.json! } : {}), help: COMMON.help! }; |
| 116 | } |
| 117 | |
| 118 | export function commandHelp(cmd: Command): string { |
| 119 | const opts = optionsFor(cmd); |
| 120 | const lines = [`${cmd.summary}`, "", `Usage: diffinery ${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}${Object.keys(opts).length > 1 ? " [options]" : ""}`]; |
| 121 | if (cmd.description) lines.push("", cmd.description.trim()); |
| 122 | const rows = Object.entries(opts).map(([k, o]) => { |
| 123 | const flag = `${o.short ? `-${o.short}, ` : " "}--${k}${o.type === "string" ? ` ${o.arg ?? "VALUE"}` : ""}`; |
| 124 | return [flag, o.desc + (o.multiple ? " (repeatable)" : "")]; |
| 125 | }); |
| 126 | const w = Math.max(...rows.map((r) => r[0]!.length)); |
| 127 | lines.push("", "Options:", ...rows.map(([f, d]) => ` ${f!.padEnd(w)} ${d}`)); |
| 128 | return lines.join("\n") + "\n"; |
| 129 | } |
| 130 | |
| 131 | export function groupHelp(group: string, summary: string, cmds: Command[]): string { |
| 132 | const w = Math.max(...cmds.map((c) => c.name.length)); |
| 133 | return [summary, "", `Usage: diffinery ${group} <command> [options]`, "", "Commands:", ...cmds.map((c) => ` ${c.name.padEnd(w)} ${c.summary}`), "", `Run 'diffinery ${group} <command> --help' for details.`].join("\n") + "\n"; |
| 134 | } |
| 135 | |
| 136 | // ---------------------------------------------------------------- dispatch |
| 137 | |
| 138 | export interface Group { |
| 139 | name: string; |
| 140 | summary: string; |
| 141 | commands: Command[]; |
| 142 | } |
| 143 | |
| 144 | export interface Registry { |
| 145 | groups: Group[]; |
| 146 | commands: Command[]; |
| 147 | } |
| 148 | |
| 149 | export function topHelp(reg: Registry): string { |
| 150 | const rows: Array<[string, string]> = [...reg.groups.map((g) => [g.name, g.summary] as [string, string]), ...reg.commands.map((c) => [c.name, c.summary] as [string, string])]; |
| 151 | rows.sort((a, b) => a[0].localeCompare(b[0])); |
| 152 | const w = Math.max(...rows.map((r) => r[0].length)); |
| 153 | return [ |
| 154 | `diffinery ${VERSION}: command-line client for Diffinery`, |
| 155 | "", |
| 156 | "Usage: diffinery <command> [subcommand] [options]", |
| 157 | "", |
| 158 | "Commands:", |
| 159 | ...rows.map(([n, s]) => ` ${n.padEnd(w)} ${s}`), |
| 160 | "", |
| 161 | "Global:", |
| 162 | " --help, -h Show help for any command", |
| 163 | " --version Print the version", |
| 164 | "", |
| 165 | "Configuration: ~/.config/diffinery/config.json (respects XDG_CONFIG_HOME);", |
| 166 | "DIFFINERY_URL and DIFFINERY_TOKEN override it.", |
| 167 | ].join("\n") + "\n"; |
| 168 | } |
| 169 | |
| 170 | export async function dispatch(reg: Registry, argv: string[], io: Io): Promise<number> { |
| 171 | const [first, ...rest] = argv; |
| 172 | if (!first || first === "--help" || first === "-h" || first === "help") { |
| 173 | if (first === "help" && rest.length) return dispatch(reg, [...rest, "--help"], io); |
| 174 | io.stdout.write(topHelp(reg)); |
| 175 | return first ? 0 : argv.length ? 0 : 2; |
| 176 | } |
| 177 | if (first === "--version" || first === "-v" || first === "version") { |
| 178 | io.stdout.write(`diffinery ${VERSION}\n`); |
| 179 | return 0; |
| 180 | } |
| 181 | const group = reg.groups.find((g) => g.name === first); |
| 182 | if (group) { |
| 183 | const [sub, ...args] = rest; |
| 184 | if (!sub || sub === "--help" || sub === "-h") { |
| 185 | io.stdout.write(groupHelp(group.name, group.summary, group.commands)); |
| 186 | return sub ? 0 : 2; |
| 187 | } |
| 188 | const cmd = group.commands.find((c) => c.name === `${group.name} ${sub}`); |
| 189 | if (!cmd) { |
| 190 | io.stderr.write(`unknown command "${group.name} ${sub}"\n\n${groupHelp(group.name, group.summary, group.commands)}`); |
| 191 | return 2; |
| 192 | } |
| 193 | return runCommand(cmd, args, io); |
| 194 | } |
| 195 | const cmd = reg.commands.find((c) => c.name === first); |
| 196 | if (!cmd) { |
| 197 | io.stderr.write(`unknown command "${first}"; run 'diffinery --help'\n`); |
| 198 | return 2; |
| 199 | } |
| 200 | return runCommand(cmd, rest, io); |
| 201 | } |
| 202 | |
| 203 | export async function runCommand(cmd: Command, argv: string[], io: Io): Promise<number> { |
| 204 | const specs = optionsFor(cmd); |
| 205 | let values: Values; |
| 206 | let positionals: string[]; |
| 207 | try { |
| 208 | const parsed = parseArgs({ |
| 209 | args: argv, |
| 210 | options: Object.fromEntries(Object.entries(specs).map(([k, o]) => [k, { type: o.type, ...(o.short ? { short: o.short } : {}), ...(o.multiple ? { multiple: true } : {}) }])), |
| 211 | allowPositionals: true, |
| 212 | strict: true, |
| 213 | }); |
| 214 | values = parsed.values as Values; |
| 215 | positionals = parsed.positionals; |
| 216 | } catch (e) { |
| 217 | io.stderr.write(`error: ${(e as Error).message.replace(/^TypeError \[[A-Z_]+\]: /, "")}\n\n${commandHelp(cmd)}`); |
| 218 | return 2; |
| 219 | } |
| 220 | if (values.help) { |
| 221 | io.stdout.write(commandHelp(cmd)); |
| 222 | return 0; |
| 223 | } |
| 224 | if ((cmd.minArgs !== undefined && positionals.length < cmd.minArgs) || (cmd.maxArgs !== undefined && positionals.length > cmd.maxArgs)) { |
| 225 | io.stderr.write(`error: ${positionals.length < (cmd.minArgs ?? 0) ? "missing arguments" : `unexpected argument "${positionals[cmd.maxArgs ?? 0]}"`}\n\n${commandHelp(cmd)}`); |
| 226 | return 2; |
| 227 | } |
| 228 | const ctx = new Ctx(io, values); |
| 229 | try { |
| 230 | const code = await cmd.run(ctx, values, positionals); |
| 231 | return typeof code === "number" ? code : 0; |
| 232 | } catch (e) { |
| 233 | if (e instanceof ExitCode) return e.code; |
| 234 | if (e instanceof CliError) { |
| 235 | io.stderr.write(`${ctx.c.red("error:")} ${e.message}\n`); |
| 236 | return e.code; |
| 237 | } |
| 238 | throw e; |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | // ---------------------------------------------------------------- small helpers for commands |
| 243 | |
| 244 | export const str = (o: Values, k: string): string | undefined => (typeof o[k] === "string" ? (o[k] as string) : undefined); |
| 245 | export const list = (o: Values, k: string): string[] => (Array.isArray(o[k]) ? (o[k] as string[]) : typeof o[k] === "string" ? [o[k] as string] : []); |
| 246 | |
| 247 | export function positiveInt(v: string | undefined, what: string): number { |
| 248 | const n = Number(String(v ?? "").replace(/^#/, "")); |
| 249 | if (!Number.isInteger(n) || n < 1) throw new CliError(`${what} must be a positive number, got "${v ?? ""}"`, 2); |
| 250 | return n; |
| 251 | } |
| 252 | |
| 253 | export function exactlyOne(o: Values, keys: string[], fallback?: string): string { |
| 254 | const set = keys.filter((k) => o[k] === true); |
| 255 | if (set.length > 1) throw new CliError(`use only one of ${keys.map((k) => `--${k}`).join(", ")}`, 2); |
| 256 | if (set.length === 0) { |
| 257 | if (fallback) return fallback; |
| 258 | throw new CliError(`one of ${keys.map((k) => `--${k}`).join(", ")} is required`, 2); |
| 259 | } |
| 260 | return set[0]!; |
| 261 | } |