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.

440 lines · 14.0 KBRaw History
1import { spawn, type ChildProcess } from "node:child_process";
2import { createHash } from "node:crypto";
3import { existsSync, statSync, accessSync, readdirSync, readFileSync, constants as fsc } from "node:fs";
4import path from "node:path";
5
6export const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
7
8export function nowIso(): string {
9 return new Date().toISOString();
10}
11
12/** Splits a command line into words, honouring single and double quotes. */
13export function shellWords(s: string): string[] {
14 const out: string[] = [];
15 let cur = "";
16 let has = false;
17 let q: string | null = null;
18 for (let i = 0; i < s.length; i++) {
19 const ch = s[i]!;
20 if (q) {
21 if (ch === q) q = null;
22 else if (ch === "\\" && q === '"' && (s[i + 1] === '"' || s[i + 1] === "\\")) cur += s[++i];
23 else cur += ch;
24 continue;
25 }
26 if (ch === '"' || ch === "'") {
27 q = ch;
28 has = true;
29 } else if (/\s/.test(ch)) {
30 if (has || cur) out.push(cur);
31 cur = "";
32 has = false;
33 } else {
34 cur += ch;
35 has = true;
36 }
37 }
38 if (has || cur) out.push(cur);
39 return out;
40}
41
42/** Locates an executable in a PATH string. */
43export function which(cmd: string, pathEnv: string | undefined = process.env.PATH): string | null {
44 if (cmd.includes("/")) return existsSync(cmd) ? cmd : null;
45 for (const dir of (pathEnv ?? "").split(path.delimiter)) {
46 if (!dir) continue;
47 const p = path.join(dir, cmd);
48 try {
49 accessSync(p, fsc.X_OK);
50 if (statSync(p).isFile()) return p;
51 } catch {
52 /* next */
53 }
54 }
55 return null;
56}
57
58export interface ExecOptions {
59 cwd?: string;
60 env?: NodeJS.ProcessEnv;
61 onLine?: (line: string, stream: "stdout" | "stderr") => void;
62 input?: string;
63 timeoutMs?: number;
64 /** Called with the child so callers can kill it on cancellation. */
65 onSpawn?: (child: ChildProcess) => void;
66 /** Collect stdout/stderr (bounded). */
67 capture?: boolean;
68 maxCapture?: number;
69}
70
71export interface ExecResult {
72 exitCode: number;
73 signal: NodeJS.Signals | null;
74 stdout: string;
75 stderr: string;
76 timedOut: boolean;
77 stdoutTruncated: boolean;
78 stderrTruncated: boolean;
79}
80
81/** Kills a detached child's whole process group. */
82export function killTree(child: ChildProcess, graceMs = 5000): void {
83 if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
84 const pid = child.pid;
85 const k = (sig: NodeJS.Signals) => {
86 try {
87 process.kill(-pid, sig);
88 } catch {
89 try {
90 child.kill(sig);
91 } catch {
92 /* gone */
93 }
94 }
95 };
96 k("SIGTERM");
97 const t = setTimeout(() => k("SIGKILL"), graceMs);
98 t.unref();
99 child.once("exit", () => clearTimeout(t));
100}
101
102/** Splits a stream into lines, invoking `cb` per line (partial last line flushed at end). */
103export class LineSplitter {
104 private buf = "";
105 constructor(private cb: (line: string) => void) {}
106 write(chunk: string): void {
107 this.buf += chunk;
108 let i: number;
109 while ((i = this.buf.indexOf("\n")) >= 0) {
110 const line = this.buf.slice(0, i).replace(/\r$/, "");
111 this.buf = this.buf.slice(i + 1);
112 this.cb(line);
113 }
114 }
115 end(): void {
116 if (this.buf.length) {
117 const l = this.buf.replace(/\r$/, "");
118 this.buf = "";
119 this.cb(l);
120 }
121 }
122}
123
124/** Spawns a process in its own process group and streams its output by line. */
125export function exec(cmd: string, args: string[], o: ExecOptions = {}): Promise<ExecResult> {
126 return new Promise((resolve) => {
127 const max = o.maxCapture ?? 1024 * 1024;
128 let stdout = "";
129 let stderr = "";
130 let stdoutTruncated = false;
131 let stderrTruncated = false;
132 let timedOut = false;
133 let child: ChildProcess;
134 try {
135 child = spawn(cmd, args, {
136 cwd: o.cwd,
137 env: o.env,
138 detached: true,
139 stdio: [o.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
140 });
141 } catch (e) {
142 resolve({ exitCode: 127, signal: null, stdout: "", stderr: String(e), timedOut: false, stdoutTruncated, stderrTruncated });
143 return;
144 }
145 o.onSpawn?.(child);
146 const so = new LineSplitter((l) => o.onLine?.(l, "stdout"));
147 const se = new LineSplitter((l) => o.onLine?.(l, "stderr"));
148 child.stdout!.setEncoding("utf8");
149 child.stderr!.setEncoding("utf8");
150 child.stdout!.on("data", (d: string) => {
151 if (o.capture) {
152 if (stdout.length < max) stdout += d;
153 else stdoutTruncated = true;
154 }
155 so.write(d);
156 });
157 child.stderr!.on("data", (d: string) => {
158 if (o.capture) {
159 if (stderr.length < max) stderr += d;
160 else stderrTruncated = true;
161 }
162 se.write(d);
163 });
164 if (o.input !== undefined) {
165 child.stdin!.on("error", () => {});
166 child.stdin!.end(o.input);
167 }
168 let timer: NodeJS.Timeout | undefined;
169 if (o.timeoutMs && o.timeoutMs > 0) {
170 timer = setTimeout(() => {
171 timedOut = true;
172 killTree(child);
173 }, o.timeoutMs);
174 }
175 let spawnError: Error | null = null;
176 child.on("error", (e) => {
177 spawnError = e;
178 });
179 child.on("close", (code, signal) => {
180 if (timer) clearTimeout(timer);
181 so.end();
182 se.end();
183 if (stdout.length > max) {
184 stdout = stdout.slice(0, max);
185 stdoutTruncated = true;
186 }
187 if (stderr.length > max) {
188 stderr = stderr.slice(0, max);
189 stderrTruncated = true;
190 }
191 if (spawnError && code === null) {
192 const msg = (spawnError as NodeJS.ErrnoException).code === "ENOENT" ? `${cmd}: command not found` : String(spawnError);
193 o.onLine?.(msg, "stderr");
194 resolve({ exitCode: 127, signal: null, stdout, stderr: stderr + msg, timedOut, stdoutTruncated, stderrTruncated });
195 return;
196 }
197 resolve({ exitCode: code ?? (signal ? 128 : 1), signal, stdout, stderr, timedOut, stdoutTruncated, stderrTruncated });
198 });
199 });
200}
201
202// ---------------------------------------------------------------- glob
203
204function globToRegex(pattern: string): RegExp {
205 let re = "";
206 for (let i = 0; i < pattern.length; i++) {
207 const c = pattern[i]!;
208 if (c === "*") {
209 if (pattern[i + 1] === "*") {
210 // `**/` matches zero or more directories
211 if (pattern[i + 2] === "/") {
212 re += "(?:.*/)?";
213 i += 2;
214 } else {
215 re += ".*";
216 i++;
217 }
218 } else re += "[^/]*";
219 } else if (c === "?") re += "[^/]";
220 else if (c === "[") {
221 const j = pattern.indexOf("]", i);
222 if (j > i) {
223 re += pattern.slice(i, j + 1).replace(/^\[!/, "[^");
224 i = j;
225 } else re += "\\[";
226 } else re += c.replace(/[.+^${}()|\\]/g, "\\$&");
227 }
228 return new RegExp(`^${re}$`);
229}
230
231function walk(root: string, rel: string, out: string[], skip: (rel: string) => boolean, includeDirs: boolean): void {
232 let entries;
233 try {
234 entries = readdirSync(path.join(root, rel), { withFileTypes: true });
235 } catch {
236 return;
237 }
238 for (const e of entries) {
239 const r = rel ? `${rel}/${e.name}` : e.name;
240 if (skip(r)) continue;
241 if (e.isDirectory()) {
242 if (includeDirs) out.push(r + "/");
243 walk(root, r, out, skip, includeDirs);
244 } else out.push(r);
245 }
246}
247
248/**
249 * Expands glob patterns (relative to `root`, or absolute) into absolute file
250 * paths. Supports `*`, `**`, `?`, `[...]` and `!negation`. Literal paths
251 * (no glob chars) are returned as-is when they exist (files or directories).
252 */
253export function globPaths(root: string, patterns: string[], opts: { filesOnly?: boolean } = {}): string[] {
254 const include: Array<{ base: string; re: RegExp | null; literal: string | null }> = [];
255 const exclude: RegExp[] = [];
256 const results = new Set<string>();
257 for (let p of patterns) {
258 p = p.trim();
259 if (!p || p.startsWith("#")) continue;
260 const neg = p.startsWith("!");
261 if (neg) p = p.slice(1);
262 if (p.startsWith("~/")) p = path.join(process.env.HOME ?? "/root", p.slice(2));
263 const abs = path.isAbsolute(p) ? p : path.join(root, p);
264 if (neg) {
265 exclude.push(globToRegex(path.normalize(abs).replace(/\/$/, "")));
266 exclude.push(globToRegex(path.normalize(abs).replace(/\/$/, "") + "/**"));
267 continue;
268 }
269 if (!/[*?[]/.test(abs)) {
270 include.push({ base: abs, re: null, literal: path.normalize(abs) });
271 continue;
272 }
273 // base dir = longest prefix without glob chars
274 const parts = path.normalize(abs).split("/");
275 const baseParts: string[] = [];
276 for (const part of parts) {
277 if (/[*?[]/.test(part)) break;
278 baseParts.push(part);
279 }
280 include.push({ base: baseParts.join("/") || "/", re: globToRegex(path.normalize(abs)), literal: null });
281 }
282 for (const inc of include) {
283 if (inc.literal) {
284 if (existsSync(inc.literal)) {
285 if (opts.filesOnly && statSync(inc.literal).isDirectory()) {
286 const files: string[] = [];
287 walk(inc.literal, "", files, () => false, false);
288 for (const f of files) results.add(path.join(inc.literal, f));
289 } else results.add(inc.literal);
290 }
291 continue;
292 }
293 const files: string[] = [];
294 walk(inc.base, "", files, (r) => r === ".git" || r.endsWith("/.git"), !opts.filesOnly);
295 for (const f of files) {
296 const full = path.join(inc.base, f.replace(/\/$/, ""));
297 if (inc.re!.test(full)) results.add(full);
298 }
299 }
300 let list = [...results];
301 if (exclude.length) list = list.filter((f) => !exclude.some((re) => re.test(f)));
302 // drop entries that are inside another included directory (avoid duplicates in tarballs)
303 if (!opts.filesOnly) {
304 const set = new Set(list);
305 list = list.filter((f) => {
306 let d = path.dirname(f);
307 while (d !== "/" && d !== ".") {
308 if (set.has(d)) return false;
309 d = path.dirname(d);
310 }
311 return true;
312 });
313 }
314 return list.sort();
315}
316
317/** `hashFiles()` as on GitHub: sha256 over the sha256 of each matched file (workspace-relative). */
318export function hashFiles(workspace: string, patterns: string[]): string {
319 const files = globPaths(workspace, patterns, { filesOnly: true }).filter((f) => f.startsWith(workspace + "/"));
320 if (files.length === 0) return "";
321 const outer = createHash("sha256");
322 for (const f of files) {
323 try {
324 outer.update(createHash("sha256").update(readFileSync(f)).digest());
325 } catch {
326 /* unreadable: skip */
327 }
328 }
329 return outer.digest("hex");
330}
331
332// ---------------------------------------------------------------- semver-ish
333
334export type Ver = [number, number, number];
335
336export function parseVer(v: string): Ver | null {
337 const m = /^\s*[vV]?(?:go)?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(v);
338 if (!m) return null;
339 return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)];
340}
341
342export function cmpVer(a: Ver, b: Ver): number {
343 return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
344}
345
346/** Matches a version against a range like `22`, `22.x`, `^20.1`, `>=18 <21`, `20 || 22`, `*`. */
347export function satisfies(version: string, range: string): boolean {
348 const v = parseVer(version);
349 if (!v) return false;
350 const r = range.trim();
351 if (r === "" || r === "*" || r === "x" || /^(latest|current|node|stable)$/i.test(r)) return true;
352 return r.split("||").some((alt) => {
353 const comps = alt.trim().replace(/([<>=~^]+)\s+/g, "$1").split(/\s+/).filter(Boolean);
354 // hyphen range "a - b"
355 const hy = /^(\S+)\s+-\s+(\S+)$/.exec(alt.trim());
356 if (hy) {
357 const lo = parseVer(hy[1]!);
358 const hi = parseVer(hy[2]!);
359 return !!lo && !!hi && cmpVer(v, lo) >= 0 && cmpVer(v, hi) <= 0;
360 }
361 return comps.every((c) => matchComparator(v, c));
362 });
363}
364
365function matchComparator(v: Ver, c: string): boolean {
366 const m = /^(>=|<=|>|<|=|\^|~)?\s*[vV]?(.*)$/.exec(c)!;
367 const op = m[1] ?? "";
368 const body = m[2]!;
369 const parts = body.split(".");
370 const nums: Array<number | null> = [0, 1, 2].map((i) => {
371 const p = parts[i];
372 if (p === undefined || p === "x" || p === "X" || p === "*" || p === "") return null;
373 const n = parseInt(p, 10);
374 return Number.isNaN(n) ? null : n;
375 });
376 if (nums[0] === null) return true;
377 const full: Ver = [nums[0]!, nums[1] ?? 0, nums[2] ?? 0];
378 const specified = nums[1] === null ? 1 : nums[2] === null ? 2 : 3;
379 const cmp = cmpVer(v, full);
380 switch (op) {
381 case ">=": return cmp >= 0;
382 case "<=": return specified === 3 ? cmp <= 0 : cmpVer(v, bump(full, specified)) < 0;
383 case ">": return specified === 3 ? cmp > 0 : cmpVer(v, bump(full, specified)) >= 0;
384 case "<": return cmp < 0;
385 case "^": {
386 const hi: Ver = full[0] > 0 || specified === 1 ? [full[0] + 1, 0, 0] : full[1] > 0 || specified === 2 ? [0, full[1] + 1, 0] : [0, 0, full[2] + 1];
387 return cmp >= 0 && cmpVer(v, hi) < 0;
388 }
389 case "~": {
390 const hi: Ver = specified === 1 ? [full[0] + 1, 0, 0] : [full[0], full[1] + 1, 0];
391 return cmp >= 0 && cmpVer(v, hi) < 0;
392 }
393 default:
394 return cmp >= 0 && cmpVer(v, bump(full, specified)) < 0;
395 }
396}
397
398function bump(v: Ver, specified: number): Ver {
399 if (specified === 1) return [v[0] + 1, 0, 0];
400 if (specified === 2) return [v[0], v[1] + 1, 0];
401 return [v[0], v[1], v[2] + 1];
402}
403
404/** Picks the highest version from `versions` that satisfies `range`. */
405export function maxSatisfying(versions: string[], range: string): string | null {
406 let best: string | null = null;
407 let bestV: Ver | null = null;
408 for (const s of versions) {
409 if (!satisfies(s, range)) continue;
410 const v = parseVer(s)!;
411 if (!bestV || cmpVer(v, bestV) > 0) {
412 best = s;
413 bestV = v;
414 }
415 }
416 return best;
417}
418
419export function runnerArch(): "X64" | "ARM64" | "ARM" | "X86" {
420 switch (process.arch) {
421 case "arm64": return "ARM64";
422 case "arm": return "ARM";
423 case "ia32": return "X86";
424 default: return "X64";
425 }
426}
427
428/** Splits a multi-line / comma-ish input into entries. */
429export function inputList(v: string | undefined, splitComma = false): string[] {
430 if (!v) return [];
431 return v
432 .split(splitComma ? /[\n,]/ : /\n/)
433 .map((s) => s.trim())
434 .filter(Boolean);
435}
436
437export function truthyInput(v: string | undefined, dflt: boolean): boolean {
438 if (v === undefined || v === "") return dflt;
439 return /^(true|yes|1|on)$/i.test(v.trim());
440}