Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

261 lines · 9.8 KBRaw History
1import { parseArgs } from "node:util";
2import { Api } from "./api.js";
3import { resolveConfig, type ResolvedConfig } from "./config.js";
4import { gitCapture, parseRemote } from "./git.js";
5import { CliError, ExitCode, type Io } from "./io.js";
6import { colors, type Colors } from "./output.js";
7import { VERSION } from "./version.js";
8
9export 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
18export type Values = Record<string, string | boolean | string[] | undefined>;
19
20export 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
37export interface RepoRef {
38 owner: string;
39 name: string;
40}
41
42export 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
97export 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
104export const repoPath = (r: RepoRef) => `/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.name)}`;
105
106// ---------------------------------------------------------------- help
107
108const 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
114export 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
118export 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
131export 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
138export interface Group {
139 name: string;
140 summary: string;
141 commands: Command[];
142}
143
144export interface Registry {
145 groups: Group[];
146 commands: Command[];
147}
148
149export 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
170export 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
203export 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
244export const str = (o: Values, k: string): string | undefined => (typeof o[k] === "string" ? (o[k] as string) : undefined);
245export const list = (o: Values, k: string): string[] => (Array.isArray(o[k]) ? (o[k] as string[]) : typeof o[k] === "string" ? [o[k] as string] : []);
246
247export 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
253export 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}