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.

260 lines · 9.6 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 throw new CliError(`the origin remote (${r.stdout.trim()}) does not point at ${host}; pass -R OWNER/NAME`);
91 }
92 throw new CliError("no repository given: pass -R OWNER/NAME or run inside a clone of a Diffinery repository");
93 }
94}
95
96export function parseRepoArg(v: string): RepoRef {
97 const s = v.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "").replace(/\/+$/, "");
98 const [owner, name, extra] = s.split("/");
99 if (!owner || !name || extra !== undefined) throw new CliError(`expected OWNER/NAME, got "${v}"`, 2);
100 return { owner, name };
101}
102
103export const repoPath = (r: RepoRef) => `/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.name)}`;
104
105// ---------------------------------------------------------------- help
106
107const COMMON: Record<string, OptSpec> = {
108 repo: { type: "string", short: "R", desc: "Repository (default: inferred from the git origin remote)", arg: "OWNER/NAME" },
109 json: { type: "boolean", desc: "Print raw JSON" },
110 help: { type: "boolean", short: "h", desc: "Show help" },
111};
112
113export function optionsFor(cmd: Command): Record<string, OptSpec> {
114 return { ...(cmd.repo ? { repo: COMMON.repo! } : {}), ...cmd.options, ...(cmd.json ? { json: COMMON.json! } : {}), help: COMMON.help! };
115}
116
117export function commandHelp(cmd: Command): string {
118 const opts = optionsFor(cmd);
119 const lines = [`${cmd.summary}`, "", `Usage: diffinery ${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}${Object.keys(opts).length > 1 ? " [options]" : ""}`];
120 if (cmd.description) lines.push("", cmd.description.trim());
121 const rows = Object.entries(opts).map(([k, o]) => {
122 const flag = `${o.short ? `-${o.short}, ` : " "}--${k}${o.type === "string" ? ` ${o.arg ?? "VALUE"}` : ""}`;
123 return [flag, o.desc + (o.multiple ? " (repeatable)" : "")];
124 });
125 const w = Math.max(...rows.map((r) => r[0]!.length));
126 lines.push("", "Options:", ...rows.map(([f, d]) => ` ${f!.padEnd(w)} ${d}`));
127 return lines.join("\n") + "\n";
128}
129
130export function groupHelp(group: string, summary: string, cmds: Command[]): string {
131 const w = Math.max(...cmds.map((c) => c.name.length));
132 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";
133}
134
135// ---------------------------------------------------------------- dispatch
136
137export interface Group {
138 name: string;
139 summary: string;
140 commands: Command[];
141}
142
143export interface Registry {
144 groups: Group[];
145 commands: Command[];
146}
147
148export function topHelp(reg: Registry): string {
149 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])];
150 rows.sort((a, b) => a[0].localeCompare(b[0]));
151 const w = Math.max(...rows.map((r) => r[0].length));
152 return [
153 `diffinery ${VERSION}: command-line client for Diffinery`,
154 "",
155 "Usage: diffinery <command> [subcommand] [options]",
156 "",
157 "Commands:",
158 ...rows.map(([n, s]) => ` ${n.padEnd(w)} ${s}`),
159 "",
160 "Global:",
161 " --help, -h Show help for any command",
162 " --version Print the version",
163 "",
164 "Configuration: ~/.config/diffinery/config.json (respects XDG_CONFIG_HOME);",
165 "DIFFINERY_URL and DIFFINERY_TOKEN override it.",
166 ].join("\n") + "\n";
167}
168
169export async function dispatch(reg: Registry, argv: string[], io: Io): Promise<number> {
170 const [first, ...rest] = argv;
171 if (!first || first === "--help" || first === "-h" || first === "help") {
172 if (first === "help" && rest.length) return dispatch(reg, [...rest, "--help"], io);
173 io.stdout.write(topHelp(reg));
174 return first ? 0 : argv.length ? 0 : 2;
175 }
176 if (first === "--version" || first === "-v" || first === "version") {
177 io.stdout.write(`diffinery ${VERSION}\n`);
178 return 0;
179 }
180 const group = reg.groups.find((g) => g.name === first);
181 if (group) {
182 const [sub, ...args] = rest;
183 if (!sub || sub === "--help" || sub === "-h") {
184 io.stdout.write(groupHelp(group.name, group.summary, group.commands));
185 return sub ? 0 : 2;
186 }
187 const cmd = group.commands.find((c) => c.name === `${group.name} ${sub}`);
188 if (!cmd) {
189 io.stderr.write(`unknown command "${group.name} ${sub}"\n\n${groupHelp(group.name, group.summary, group.commands)}`);
190 return 2;
191 }
192 return runCommand(cmd, args, io);
193 }
194 const cmd = reg.commands.find((c) => c.name === first);
195 if (!cmd) {
196 io.stderr.write(`unknown command "${first}"; run 'diffinery --help'\n`);
197 return 2;
198 }
199 return runCommand(cmd, rest, io);
200}
201
202export async function runCommand(cmd: Command, argv: string[], io: Io): Promise<number> {
203 const specs = optionsFor(cmd);
204 let values: Values;
205 let positionals: string[];
206 try {
207 const parsed = parseArgs({
208 args: argv,
209 options: Object.fromEntries(Object.entries(specs).map(([k, o]) => [k, { type: o.type, ...(o.short ? { short: o.short } : {}), ...(o.multiple ? { multiple: true } : {}) }])),
210 allowPositionals: true,
211 strict: true,
212 });
213 values = parsed.values as Values;
214 positionals = parsed.positionals;
215 } catch (e) {
216 io.stderr.write(`error: ${(e as Error).message.replace(/^TypeError \[[A-Z_]+\]: /, "")}\n\n${commandHelp(cmd)}`);
217 return 2;
218 }
219 if (values.help) {
220 io.stdout.write(commandHelp(cmd));
221 return 0;
222 }
223 if ((cmd.minArgs !== undefined && positionals.length < cmd.minArgs) || (cmd.maxArgs !== undefined && positionals.length > cmd.maxArgs)) {
224 io.stderr.write(`error: ${positionals.length < (cmd.minArgs ?? 0) ? "missing arguments" : `unexpected argument "${positionals[cmd.maxArgs ?? 0]}"`}\n\n${commandHelp(cmd)}`);
225 return 2;
226 }
227 const ctx = new Ctx(io, values);
228 try {
229 const code = await cmd.run(ctx, values, positionals);
230 return typeof code === "number" ? code : 0;
231 } catch (e) {
232 if (e instanceof ExitCode) return e.code;
233 if (e instanceof CliError) {
234 io.stderr.write(`${ctx.c.red("error:")} ${e.message}\n`);
235 return e.code;
236 }
237 throw e;
238 }
239}
240
241// ---------------------------------------------------------------- small helpers for commands
242
243export const str = (o: Values, k: string): string | undefined => (typeof o[k] === "string" ? (o[k] as string) : undefined);
244export const list = (o: Values, k: string): string[] => (Array.isArray(o[k]) ? (o[k] as string[]) : typeof o[k] === "string" ? [o[k] as string] : []);
245
246export function positiveInt(v: string | undefined, what: string): number {
247 const n = Number(String(v ?? "").replace(/^#/, ""));
248 if (!Number.isInteger(n) || n < 1) throw new CliError(`${what} must be a positive number, got "${v ?? ""}"`, 2);
249 return n;
250}
251
252export function exactlyOne(o: Values, keys: string[], fallback?: string): string {
253 const set = keys.filter((k) => o[k] === true);
254 if (set.length > 1) throw new CliError(`use only one of ${keys.map((k) => `--${k}`).join(", ")}`, 2);
255 if (set.length === 0) {
256 if (fallback) return fallback;
257 throw new CliError(`one of ${keys.map((k) => `--${k}`).join(", ")} is required`, 2);
258 }
259 return set[0]!;
260}