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.

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