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.

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