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.

157 lines · 6.9 KBRaw History
1import { randomUUID } from "node:crypto";
2import { createWriteStream, existsSync } from "node:fs";
3import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4import path from "node:path";
5import { Readable } from "node:stream";
6import { pipeline } from "node:stream/promises";
7import type { ReadableStream as WebReadableStream } from "node:stream/web";
8import { failure } from "../job.js";
9import { hashFiles, inputList } from "../util.js";
10import { isExactKeyMatch, restoreCache, saveCache } from "./cache.js";
11import type { BuiltinContext } from "./index.js";
12
13/** Downloads a public URL to a file with a few retries. */
14export async function fetchToFile(url: string, file: string): Promise<void> {
15 let last: unknown;
16 for (let i = 0; i < 4; i++) {
17 if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
18 try {
19 const res = await fetch(url, { headers: { "user-agent": "diffinery-runner" }, redirect: "follow" });
20 if (res.status === 404) throw failure(`Not found: ${url}`);
21 if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} for ${url}`);
22 await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(file));
23 return;
24 } catch (e) {
25 if (e instanceof Error && e.message.startsWith("Not found:")) throw e;
26 last = e;
27 }
28 }
29 throw last instanceof Error ? last : new Error(String(last));
30}
31
32export async function fetchJson<T>(url: string): Promise<T> {
33 let last: unknown;
34 for (let i = 0; i < 3; i++) {
35 if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
36 try {
37 const res = await fetch(url, { headers: { "user-agent": "diffinery-runner", accept: "application/json" } });
38 if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
39 return (await res.json()) as T;
40 } catch (e) {
41 last = e;
42 }
43 }
44 throw last instanceof Error ? last : new Error(String(last));
45}
46
47export function toolArch(kind: "node" | "go" | "python"): string {
48 const a = process.arch;
49 if (kind === "go") return a === "arm64" ? "arm64" : a === "arm" ? "armv6l" : "amd64";
50 return a === "arm64" ? "arm64" : a === "arm" ? "armv7l" : "x64";
51}
52
53export function toolDir(c: BuiltinContext, tool: string, version: string, arch: string): string {
54 return path.join(c.job.toolCache, tool, version, arch);
55}
56
57export function isCached(dir: string): boolean {
58 return existsSync(`${dir}.complete`) && existsSync(dir);
59}
60
61/** Downloads a tarball and extracts it into `dir` (stripping the top-level folder). */
62export async function installTarball(c: BuiltinContext, url: string, dir: string, strip = 1): Promise<void> {
63 c.log(`Downloading ${url}`);
64 const file = path.join(c.job.tempDir, `tool-${randomUUID()}${url.endsWith(".xz") ? ".tar.xz" : ".tar.gz"}`);
65 try {
66 await fetchToFile(url, file);
67 await rm(dir, { recursive: true, force: true });
68 await mkdir(dir, { recursive: true });
69 c.log(`Extracting to ${dir}`);
70 await c.run("tar", [url.endsWith(".xz") ? "-xJf" : "-xzf", file, "-C", dir, `--strip-components=${strip}`], { silent: true });
71 await writeFile(`${dir}.complete`, "");
72 } finally {
73 await rm(file, { force: true });
74 }
75}
76
77/** Runs `cmd args` and returns trimmed stdout, or null if it fails. */
78export async function probe(c: BuiltinContext, cmd: string, args: string[], env?: NodeJS.ProcessEnv): Promise<string | null> {
79 const r = await c.exec(cmd, args, { capture: true, silent: true, env });
80 if (r.exitCode !== 0) return null;
81 return (r.stdout || r.stderr).trim();
82}
83
84export async function readVersionFile(c: BuiltinContext, file: string, kind: "node" | "python" | "go"): Promise<string> {
85 const p = path.resolve(c.job.workspace, file);
86 if (!existsSync(p)) throw failure(`The specified ${kind} version file at: ${p} does not exist`);
87 const content = await readFile(p, "utf8");
88 const base = path.basename(p);
89 if (base === "package.json") {
90 try {
91 const j = JSON.parse(content) as { volta?: { node?: string }; engines?: { node?: string }; devEngines?: { runtime?: { name?: string; version?: string } } };
92 const v = j.volta?.node ?? j.devEngines?.runtime?.version ?? j.engines?.node;
93 if (v) return v;
94 } catch {
95 /* fallthrough */
96 }
97 throw failure(`No node version found in ${p}`);
98 }
99 if (base === "go.mod" || base === "go.work") {
100 const tc = /^toolchain\s+go(\S+)/m.exec(content);
101 const g = /^go\s+(\d+(?:\.\d+){0,2})/m.exec(content);
102 const v = tc?.[1] ?? g?.[1];
103 if (!v) throw failure(`No go version found in ${p}`);
104 return v;
105 }
106 if (base === ".tool-versions") {
107 const names = kind === "node" ? ["nodejs", "node"] : kind === "python" ? ["python"] : ["golang", "go"];
108 for (const line of content.split("\n")) {
109 const [n, v] = line.trim().split(/\s+/);
110 if (n && v && names.includes(n)) return v;
111 }
112 throw failure(`No ${kind} version found in ${p}`);
113 }
114 if (base === "pyproject.toml") {
115 const m = /requires-python\s*=\s*["']([^"']+)["']/.exec(content);
116 if (m) return m[1]!.replace(/,/g, " ");
117 throw failure(`No python version found in ${p}`);
118 }
119 const first = content.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#"));
120 if (!first) throw failure(`The ${kind} version file at ${p} is empty`);
121 return first.replace(/^v(?=\d)/, "");
122}
123
124/**
125 * Restores a package-manager cache and registers a post-step save, the way
126 * setup-node/setup-python/setup-go `cache:` do (same keys and paths, so their
127 * caches are shared with the official actions).
128 */
129export async function packageCache(c: BuiltinContext, o: { key: string; paths: string[]; restoreKeys?: string[]; what: string }): Promise<void> {
130 const paths = o.paths.filter(Boolean);
131 if (!paths.length) {
132 c.warn(`Could not determine the ${o.what} cache directory; skipping cache.`);
133 return;
134 }
135 const r = await restoreCache(c, paths, o.key, o.restoreKeys ?? []);
136 c.setOutput("cache-hit", isExactKeyMatch(o.key, r.matchedKey) ? "true" : "false");
137 c.log(r.matchedKey ? `Cache restored from key: ${r.matchedKey}` : `${o.what} cache is not found`);
138 c.registerPost(`Post ${c.name}`, "success()", async (pc) => {
139 if (isExactKeyMatch(o.key, r.matchedKey)) {
140 pc.log(`Cache hit occurred on the primary key ${o.key}, not saving cache.`);
141 return;
142 }
143 if (!paths.some((p) => existsSync(p))) {
144 pc.log(`The ${o.what} cache directories do not exist; not saving cache.`);
145 return;
146 }
147 await saveCache(pc, paths, o.key);
148 });
149}
150
151/** The hash of the first lock file that exists in the workspace root (setup-node/setup-go's default). */
152export function rootLockHash(c: BuiltinContext, input: string, rootFiles: string[]): string {
153 if (input) return hashFiles(c.job.workspace, inputList(input));
154 const found = rootFiles.find((f) => existsSync(path.join(c.job.workspace, f)));
155 if (!found) throw failure(`Dependencies lock file is not found in ${c.job.workspace}. Supported file patterns: ${rootFiles.join(",")}`);
156 return hashFiles(c.job.workspace, [found]);
157}