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.

155 lines · 7.3 KBRaw History
1import { existsSync, readFileSync } from "node:fs";
2import { mkdir, readFile, writeFile } from "node:fs/promises";
3import path from "node:path";
4import { errMsg, failure } from "../job.js";
5import { maxSatisfying, parseVer, satisfies, truthyInput } from "../util.js";
6import type { Builtin, BuiltinContext } from "./index.js";
7import { fetchJson, installTarball, isCached, packageCache, probe, readVersionFile, rootLockHash, toolArch, toolDir } from "./tools.js";
8
9interface NodeDistEntry {
10 version: string; // "v22.11.0"
11 lts: string | false;
12 files: string[];
13}
14
15export function nodeMirror(): string {
16 return (process.env.DIFFINERY_NODE_MIRROR ?? "https://nodejs.org/dist").replace(/\/$/, "");
17}
18
19/** Resolves a setup-node version spec against nodejs.org's index. */
20export function resolveNodeVersion(index: NodeDistEntry[], spec: string): string | null {
21 const s = spec.trim().toLowerCase();
22 const arch = `linux-${toolArch("node")}`;
23 const avail = index.filter((e) => e.files.includes(arch) || e.files.length === 0);
24 if (s.startsWith("lts/")) {
25 const code = s.slice(4);
26 const lts = avail.filter((e) => e.lts && (code === "*" || String(e.lts).toLowerCase() === code));
27 if (code.startsWith("-")) {
28 // lts/-1: previous LTS line
29 const n = Number(code.slice(1));
30 const majors = [...new Set(avail.filter((e) => e.lts).map((e) => parseVer(e.version)![0]))].sort((a, b) => b - a);
31 const major = majors[n];
32 return major === undefined ? null : maxSatisfying(avail.filter((e) => e.lts).map((e) => e.version), String(major))?.replace(/^v/, "") ?? null;
33 }
34 return maxSatisfying(lts.map((e) => e.version), "*")?.replace(/^v/, "") ?? null;
35 }
36 if (s === "latest" || s === "current" || s === "node") return maxSatisfying(avail.map((e) => e.version), "*")?.replace(/^v/, "") ?? null;
37 return maxSatisfying(avail.map((e) => e.version), s.replace(/^v/, ""))?.replace(/^v/, "") ?? null;
38}
39
40export const setupNode: Builtin = async (c) => {
41 let spec = c.input("node-version");
42 const file = c.input("node-version-file");
43 if (!spec && file) spec = await readVersionFile(c, file, "node");
44 const installed = (await probe(c, "node", ["--version"]))?.replace(/^v/, "") ?? null;
45 let version = installed;
46
47 if (spec) {
48 const s = spec.trim().replace(/^v(?=\d)/, "");
49 let ok = false;
50 if (installed && !truthyInput(c.input("check-latest"), false)) {
51 if (/^(lts\/|latest|current|node$)/i.test(s)) {
52 try {
53 const target = resolveNodeVersion(await fetchJson<NodeDistEntry[]>(`${nodeMirror()}/index.json`), s);
54 ok = !!target && parseVer(target)![0] === parseVer(installed)![0];
55 } catch (e) {
56 c.warn(`Could not resolve '${s}' (${errMsg(e)}); using installed node ${installed}.`);
57 ok = true;
58 }
59 } else {
60 ok = satisfies(installed, s);
61 }
62 }
63 if (ok) {
64 c.log(`Found in cache @ ${installed} (pre-installed node satisfies '${s}')`);
65 } else {
66 let index: NodeDistEntry[];
67 try {
68 index = await fetchJson<NodeDistEntry[]>(`${nodeMirror()}/index.json`);
69 } catch (e) {
70 throw failure(`Unable to fetch the Node.js version index: ${errMsg(e)}`);
71 }
72 const v = resolveNodeVersion(index, s);
73 if (!v) throw failure(`Unable to find Node version '${s}' for platform linux and architecture ${toolArch("node")}.`);
74 const dir = toolDir(c, "node", v, toolArch("node"));
75 if (!isCached(dir)) {
76 await installTarball(c, `${nodeMirror()}/v${v}/node-v${v}-linux-${toolArch("node")}.tar.gz`, dir);
77 } else c.log(`Found in cache @ ${dir}`);
78 c.addPath(path.join(dir, "bin"));
79 version = v;
80 }
81 } else {
82 c.log(`No node-version specified; using the pre-installed node ${installed ?? "(none)"}.`);
83 }
84 if (version) c.setOutput("node-version", `v${version}`);
85
86 const registry = c.input("registry-url");
87 if (registry) await configureRegistry(c, registry);
88
89 let pm = c.input("cache").toLowerCase();
90 // setup-node v5+: caches npm automatically when package.json names npm as its package manager.
91 if (!pm && major(c) >= 5 && truthyInput(c.input("package-manager-cache"), true) && packageJsonUsesNpm(c.job.workspace)) {
92 c.log("Detected npm as the package manager from package.json's packageManager field. Auto caching has been enabled for npm. If you want to disable it, set package-manager-cache input to false");
93 pm = "npm";
94 }
95 if (pm) {
96 const env = c.toolEnv();
97 let dir: string | null;
98 let lockFiles: string[];
99 if (pm === "npm") {
100 dir = await probe(c, "npm", ["config", "get", "cache"], env);
101 lockFiles = ["package-lock.json", "npm-shrinkwrap.json", "yarn.lock"];
102 } else if (pm === "pnpm") {
103 dir = await probe(c, "pnpm", ["store", "path", "--silent"], env);
104 lockFiles = ["pnpm-lock.yaml"];
105 } else if (pm === "yarn") {
106 const ver = await probe(c, "yarn", ["--version"], env);
107 dir = ver && parseVer(ver)![0] >= 2 ? await probe(c, "yarn", ["config", "get", "cacheFolder"], env) : await probe(c, "yarn", ["cache", "dir"], env);
108 lockFiles = ["yarn.lock"];
109 } else {
110 throw failure(`Caching for '${pm}' is not supported`);
111 }
112 const hash = rootLockHash(c, c.input("cache-dependency-path"), lockFiles);
113 if (!hash) throw failure("Some specified paths were not resolved, unable to cache dependencies.");
114 const key = `node-cache-Linux-${toolArch("node")}-${pm}-${hash}`;
115 c.setOutput("cache-primary-key", key);
116 await packageCache(c, { key, paths: dir ? [dir] : [], what: pm });
117 }
118};
119
120function major(c: { uses: string }): number {
121 return Number(/@v?(\d+)/.exec(c.uses)?.[1] ?? 99);
122}
123
124function packageJsonUsesNpm(workspace: string): boolean {
125 const npm = /^(\^)?npm(@.*)?$/;
126 try {
127 const pkg = JSON.parse(readFileSync(path.join(workspace, "package.json"), "utf8")) as { packageManager?: unknown; devEngines?: { packageManager?: unknown } };
128 const dev = pkg.devEngines?.packageManager;
129 for (const o of Array.isArray(dev) ? dev : dev ? [dev] : []) if (typeof (o as { name?: unknown })?.name === "string" && npm.test((o as { name: string }).name)) return true;
130 return typeof pkg.packageManager === "string" && npm.test(pkg.packageManager);
131 } catch {
132 return false;
133 }
134}
135
136/** setup-node's `registry-url`: an .npmrc in RUNNER_TEMP that reads NODE_AUTH_TOKEN. */
137async function configureRegistry(c: BuiltinContext, registry: string): Promise<void> {
138 const url = registry.endsWith("/") ? registry : `${registry}/`;
139 let scope = c.input("scope");
140 if (!scope && url.includes("npm.pkg.github.com")) scope = c.job.spec.repo.owner;
141 if (scope && !scope.startsWith("@")) scope = `@${scope}`;
142 const prefix = scope ? `${scope.toLowerCase()}:` : "";
143 const npmrc = path.join(c.job.tempDir, ".npmrc");
144 await mkdir(c.job.tempDir, { recursive: true });
145 let contents = "";
146 if (existsSync(npmrc)) {
147 for (const line of (await readFile(npmrc, "utf8")).split("\n")) if (!line.toLowerCase().startsWith(`${prefix}registry`)) contents += `${line}\n`;
148 }
149 contents += `${url.replace(/^\w+:/, "")}:_authToken=\${NODE_AUTH_TOKEN}\n${prefix}registry=${url}`;
150 const alwaysAuth = c.input("always-auth");
151 if (major(c) < 5 || alwaysAuth) contents += `\nalways-auth=${alwaysAuth || "false"}`;
152 await writeFile(npmrc, contents);
153 c.exportVariable("NPM_CONFIG_USERCONFIG", npmrc);
154 c.exportVariable("NODE_AUTH_TOKEN", c.toolEnv().NODE_AUTH_TOKEN || "XXXXX-XXXXX-XXXXX-XXXXX");
155}