forgedock 1.27.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +10 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +542 -38
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +190 -58
  16. package/bin/lean/conformance.test.mjs +116 -7
  17. package/bin/lean/credentials.mjs +29 -0
  18. package/bin/lean/depcache.mjs +312 -0
  19. package/bin/lean/depcache.test.mjs +298 -0
  20. package/bin/lean/drift.mjs +198 -0
  21. package/bin/lean/drift.test.mjs +223 -0
  22. package/bin/lean/effects.mjs +85 -8
  23. package/bin/lean/execution.e2e.test.mjs +81 -0
  24. package/bin/lean/execution.mjs +431 -0
  25. package/bin/lean/execution.test.mjs +583 -0
  26. package/bin/lean/heavy.mjs +289 -0
  27. package/bin/lean/heavy.test.mjs +207 -0
  28. package/bin/lean/lean.mjs +572 -139
  29. package/bin/lean/lean.test.mjs +1193 -31
  30. package/bin/lean/ledger.mjs +27 -2
  31. package/bin/lean/ledger.test.mjs +36 -1
  32. package/bin/lean/limits.mjs +58 -0
  33. package/bin/lean/limits.test.mjs +150 -0
  34. package/bin/lean/orchestrate.mjs +312 -19
  35. package/bin/lean/prompts.mjs +89 -15
  36. package/bin/lean/refactor.mjs +276 -0
  37. package/bin/lean/refactor.test.mjs +359 -0
  38. package/bin/lean/render.mjs +24 -5
  39. package/bin/lean/review-trigger.mjs +134 -0
  40. package/bin/lean/review-trigger.test.mjs +104 -0
  41. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  42. package/bin/lean/sandbox-remote.mjs +142 -0
  43. package/bin/lean/sched.mjs +221 -0
  44. package/bin/lean/sched.test.mjs +310 -0
  45. package/bin/lean/schedd.mjs +247 -0
  46. package/bin/lean/schedd.test.mjs +180 -0
  47. package/bin/lean/toolchain.mjs +228 -0
  48. package/bin/lean/toolchain.test.mjs +154 -0
  49. package/bin/lean/verify.mjs +20 -9
  50. package/bin/lean/verify.test.mjs +26 -3
  51. package/bin/lean/worktree.mjs +69 -0
  52. package/bin/lean/worktree.test.mjs +141 -0
  53. package/bin/runner.mjs +53 -10
  54. package/bin/stats/github-source.mjs +1 -1
  55. package/bin/stats/index.mjs +14 -2
  56. package/bin/stats/query.mjs +72 -0
  57. package/bin/stats/stats.test.mjs +54 -0
  58. package/commands/audit.md +2 -0
  59. package/commands/autopilot.md +2 -0
  60. package/commands/bootstrap.md +35 -0
  61. package/commands/cleanup.md +7 -22
  62. package/commands/forge-stats.md +2 -1
  63. package/commands/forgedock-init.md +7 -0
  64. package/commands/issue.md +89 -830
  65. package/commands/milestone.md +55 -6
  66. package/commands/orchestrate.md +10 -2
  67. package/commands/upgrade-deps.md +3 -3
  68. package/commands/work-on/build/validate.md +4 -0
  69. package/commands/work-on/close.md +2 -0
  70. package/commands/work-on/decompose.md +24 -6
  71. package/docs/CI.md +3 -3
  72. package/docs/CONFIG.md +177 -2
  73. package/docs/OUTCOME-CODES.md +8 -0
  74. package/docs/TELEMETRY.md +1 -1
  75. package/docs/site/command-reference.md +22 -0
  76. package/docs/site/getting-started.md +2 -0
  77. package/docs/site/start-a-new-project.md +327 -0
  78. package/package.json +1 -1
  79. package/scripts/affected-files-check.sh +91 -0
  80. package/scripts/affected-files-check.test.sh +80 -0
  81. package/scripts/bootstrap-create.mjs +393 -0
  82. package/scripts/bootstrap-validate.mjs +36 -2
  83. package/scripts/calibration.mjs +116 -0
  84. package/scripts/check-devdocs-freshness.sh +9 -4
  85. package/scripts/check-devdocs-freshness.test.sh +11 -1
  86. package/scripts/check-spec-markers.sh +3 -0
  87. package/scripts/check-spec-size.sh +29 -11
  88. package/scripts/check-spec-size.test.sh +27 -4
  89. package/scripts/ci-local/lib.mjs +8 -1
  90. package/scripts/ci-local/parallelism.test.mjs +27 -0
  91. package/scripts/ci-local/run.mjs +3 -3
  92. package/scripts/ci-local/scoping.mjs +82 -0
  93. package/scripts/ci-local.test.sh +1 -1
  94. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  95. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  96. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  97. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  98. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  99. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  100. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  101. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  102. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  103. package/scripts/forge-root.test.sh +1 -1
  104. package/scripts/hotspots.sh +100 -0
  105. package/scripts/hotspots.test.sh +101 -0
  106. package/scripts/issue-create.sh +197 -0
  107. package/scripts/issue-create.test.sh +166 -0
  108. package/scripts/maintenance-scan.sh +185 -0
  109. package/scripts/maintenance-scan.test.sh +89 -0
  110. package/scripts/milestone-drift.sh +206 -0
  111. package/scripts/milestone-drift.test.sh +120 -0
  112. package/scripts/refactor-verify.sh +368 -0
  113. package/scripts/refactor-verify.test.sh +202 -0
  114. package/scripts/structure-delta.sh +10 -5
  115. package/scripts/structure-delta.test.sh +3 -2
  116. package/scripts/symbol-index.mjs +253 -0
  117. package/scripts/symbol-index.py +354 -0
  118. package/scripts/symbol-index.test.sh +119 -0
  119. package/scripts/symbol-move-ts.test.sh +161 -0
  120. package/scripts/symbol-move.mjs +379 -0
  121. package/scripts/symbol-move.py +383 -0
  122. package/scripts/symbol-move.test.sh +168 -0
  123. package/templates/bootstrap/interview.md +34 -0
  124. package/templates/bootstrap/issue-body.md +42 -0
  125. package/templates/bootstrap/needs-human-issue.md +45 -0
  126. package/templates/bootstrap/stack-profiles.md +34 -0
  127. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,247 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Host scheduler daemon (#4192): one process hosts the `orchestrate()` loop of every batch on the host, instead of one
5
+ * dispatcher per `forgedock orchestrate`. Single instance: `schedd.lock` (the daemon pid, created with O_EXCL; a dead
6
+ * owner's lock is taken over) plus the unix socket `schedd.sock`, both in `schedDir()` (`~/.forge/sched/`, or
7
+ * `FORGEDOCK_SCHED_DIR`). Auto-spawned by the first `orchestrate` client, or run in the foreground by a systemd-user unit.
8
+ *
9
+ * Protocol: newline-delimited JSON, one request per connection.
10
+ * {op:"submit", argv, cwd, env} → {type:"batch", id, dir}, {type:"row", row}…, {type:"line", text}…, {type:"done", result, report}
11
+ * {op:"attach", id} → log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
12
+ * ({type:"done", replay:true}); a batch nobody hosts is resumed here
13
+ * {op:"set-parallel", id, k} → {type:"ok", parallel}
14
+ * {op:"status"} → {type:"status", pid, batches:[{id, running}]}
15
+ * Failures answer {type:"error", message}. A client closing its connection never cancels a batch, and stopping the daemon
16
+ * never stops a lane (lanes are detached sessions). On start the daemon resumes every daemon-hosted batch without a
17
+ * report, so live lanes are re-adopted through the batch runner's `adopt`. It exits after `idleMs` with no batch and no client.
18
+ * `runBatch({argv, cwd, env, sink, keepBreaker})` is injected (engine-cli `runBatch`); `sink` gets `batch(b)`, `row(r)`, `line(text)`.
19
+ */
20
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
21
+ import { createConnection, createServer } from "node:net";
22
+ import { spawn } from "node:child_process";
23
+ import { join } from "node:path";
24
+ import { schedDir, pidAlive } from "./sched.mjs";
25
+ import { batchRoot, listBatches, openBatch } from "./batch.mjs";
26
+
27
+ export const IDLE_MS = 10 * 60 * 1000;
28
+
29
+ export function scheddPaths(dir = schedDir()) {
30
+ return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
31
+ }
32
+
33
+ // Env keys a batch keeps for its lanes (account and backend selectors, tool paths); persisted in plan.json.
34
+ const ENV_KEEP = /^(PATH|HOME|USER|SHELL|LANG|LC_ALL|TMPDIR|FORGEDOCK_.*|CLAUDE_.*|ANTHROPIC_.*|GH_.*|GITHUB_.*)$/;
35
+
36
+ /** The allowlisted part of `env`. */
37
+ export function batchEnv(env = process.env) {
38
+ return Object.fromEntries(Object.entries(env).filter(([k, v]) => ENV_KEEP.test(k) && typeof v === "string"));
39
+ }
40
+
41
+ /** `base` without its allowlisted keys, plus `kept`: an account selector the client did not set never leaks in from the daemon. */
42
+ export function mergeEnv(base, kept) {
43
+ const out = Object.fromEntries(Object.entries(base).filter(([k]) => !ENV_KEEP.test(k)));
44
+ return { ...out, ...kept };
45
+ }
46
+
47
+ /** Take the daemon lock; null when another live daemon holds it. */
48
+ function takeLock(lock, pid, alive) {
49
+ for (let i = 0; i < 5; i++) {
50
+ try {
51
+ const fd = openSync(lock, "wx");
52
+ try { writeSync(fd, String(pid)); } finally { closeSync(fd); }
53
+ return true;
54
+ } catch (e) { if (e?.code !== "EEXIST") throw e; }
55
+ let owner = NaN;
56
+ try { owner = parseInt(readFileSync(lock, "utf8"), 10); } catch { continue; }
57
+ if (Number.isInteger(owner) && owner !== pid && alive(owner)) return null;
58
+ rmSync(lock, { force: true });
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Start the daemon. @returns {Promise<{running:true, pid, close, closed:Promise} | {running:false, owner:number}>}
65
+ */
66
+ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
67
+ root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true } = {}) {
68
+ const p = scheddPaths(dir);
69
+ mkdirSync(dir, { recursive: true });
70
+ if (!takeLock(p.lock, pid, alive)) {
71
+ let owner = NaN;
72
+ try { owner = parseInt(readFileSync(p.lock, "utf8"), 10); } catch { /* gone */ }
73
+ log(`schedd: already running (pid ${owner})`);
74
+ return { running: false, owner };
75
+ }
76
+ rmSync(p.sock, { force: true });
77
+
78
+ const hosted = new Map(); // batch id -> {id, dir, subs:Set<fn>}
79
+ const conns = new Set();
80
+ let lastActive = Date.now();
81
+ const touch = () => { lastActive = Date.now(); };
82
+
83
+ /** Run one batch here. Resolves with {id, dir} once the batch exists, rejects when it fails before that. */
84
+ const host = ({ argv, cwd, env, keepBreaker = false }, first = null) => new Promise((resolve, reject) => {
85
+ const ent = { id: null, dir: null, subs: new Set(first ? [first] : []) };
86
+ const emit = (m) => { for (const s of ent.subs) s(m); };
87
+ let started = false;
88
+ const sink = {
89
+ batch: (b) => { ent.id = b.id; ent.dir = b.dir; hosted.set(b.id, ent); started = true; resolve(ent); },
90
+ row: (row) => emit({ type: "row", row }),
91
+ line: (text) => emit({ type: "line", text }),
92
+ };
93
+ touch();
94
+ Promise.resolve().then(() => runBatch({ argv, cwd, env, sink, keepBreaker })).then((result) => {
95
+ const report = ent.dir ? openBatch(root, ent.id).readReport()?.report ?? null : null;
96
+ emit({ type: "done", result, report });
97
+ }, (e) => {
98
+ const message = e?.message ?? String(e);
99
+ if (!started) reject(new Error(message)); else { log(`schedd: batch ${ent.id} failed: ${message}`); emit({ type: "error", message }); }
100
+ }).finally(() => { if (ent.id) hosted.delete(ent.id); ent.subs.clear(); touch(); });
101
+ });
102
+
103
+ const handle = async (req, send) => {
104
+ if (req.op === "status") return send({ type: "status", pid, batches: [...hosted.keys()].map((id) => ({ id, running: true })) }, true);
105
+ if (req.op === "set-parallel") {
106
+ const b = openBatch(root, req.id);
107
+ if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
108
+ b.setParallel(Number(req.k));
109
+ return send({ type: "ok", parallel: Number(req.k) }, true);
110
+ }
111
+ const sub = (m) => send(m, m.type === "done" || m.type === "error");
112
+ if (req.op === "submit") {
113
+ const ent = await host({ argv: req.argv ?? [], cwd: req.cwd ?? process.cwd(), env: req.env ?? {} }, sub);
114
+ return send({ type: "batch", id: ent.id, dir: ent.dir });
115
+ }
116
+ if (req.op === "attach") {
117
+ const live = hosted.get(req.id);
118
+ if (live) {
119
+ // Synchronous read + subscribe: no row is lost or doubled between the replay and the live stream.
120
+ for (const row of openBatch(root, req.id).readLog()) send({ type: "row", row, replay: true });
121
+ live.subs.add(sub);
122
+ return send({ type: "batch", id: live.id, dir: live.dir });
123
+ }
124
+ const b = openBatch(root, req.id);
125
+ if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
126
+ const rep = b.readReport();
127
+ if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held")) {
128
+ return send({ type: "done", replay: true, result: { rows: rep.rows ?? [], crossLane: rep.crossLane ?? [], batch: b.id, ok: !!rep.ok }, report: rep.report ?? null }, true);
129
+ }
130
+ const ent = await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub);
131
+ return send({ type: "batch", id: ent.id, dir: ent.dir });
132
+ }
133
+ throw new Error(`unknown op ${JSON.stringify(req.op)}`);
134
+ };
135
+
136
+ const server = createServer((conn) => {
137
+ conns.add(conn);
138
+ touch();
139
+ let buf = "", seen = false, ended = false;
140
+ const send = (m, last = false) => {
141
+ if (ended || conn.destroyed) return;
142
+ conn.write(JSON.stringify(m) + "\n");
143
+ if (last) { ended = true; conn.end(); }
144
+ };
145
+ conn.on("error", () => { /* a client went away */ });
146
+ conn.on("close", () => { conns.delete(conn); ended = true; touch(); });
147
+ conn.on("data", (d) => {
148
+ if (seen) return;
149
+ buf += d;
150
+ const i = buf.indexOf("\n");
151
+ if (i < 0) return;
152
+ seen = true;
153
+ let req;
154
+ try { req = JSON.parse(buf.slice(0, i)); } catch { return send({ type: "error", message: "bad request" }, true); }
155
+ handle(req, send).catch((e) => send({ type: "error", message: e?.message ?? String(e) }, true));
156
+ });
157
+ });
158
+ await new Promise((res, rej) => { server.once("error", rej); server.listen(p.sock, () => { server.off("error", rej); res(); }); });
159
+ log(`schedd: pid ${pid} listening on ${p.sock}`);
160
+
161
+ let closing = null, resolveClosed;
162
+ const closed = new Promise((r) => { resolveClosed = r; });
163
+ const close = () => {
164
+ if (closing) return closing;
165
+ clearInterval(idleTimer);
166
+ for (const c of conns) c.destroy();
167
+ closing = new Promise((r) => server.close(() => r())).then(() => {
168
+ try { if (parseInt(readFileSync(p.lock, "utf8"), 10) === pid) rmSync(p.lock, { force: true }); } catch { /* gone */ }
169
+ rmSync(p.sock, { force: true });
170
+ resolveClosed();
171
+ });
172
+ return closing;
173
+ };
174
+ const idleTimer = setInterval(() => {
175
+ if (!hosted.size && !conns.size && Date.now() - lastActive >= idleMs) { log("schedd: idle, exiting"); close(); }
176
+ }, Math.min(idleMs, 30_000));
177
+ idleTimer.unref?.();
178
+
179
+ // Daemon restart: resume every daemon-hosted batch that never wrote its report; live lanes are re-adopted.
180
+ if (resumeOnStart) {
181
+ for (const id of listBatches(root)) {
182
+ const b = openBatch(root, id);
183
+ if (!b.readPlan()?.daemon || b.readReport()) continue;
184
+ log(`schedd: resuming batch ${id}`);
185
+ host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true }).catch((e) => log(`schedd: resume ${id} failed: ${e?.message ?? e}`));
186
+ }
187
+ }
188
+ return { running: true, pid, sock: p.sock, close, closed };
189
+ }
190
+
191
+ /** Connect to the daemon, spawning it (`spawnDaemon()`) when nobody listens. @returns {Promise<import("node:net").Socket>} */
192
+ export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpawn, waitMs = 10_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
193
+ const { sock } = scheddPaths(dir);
194
+ const tryConnect = () => new Promise((res, rej) => {
195
+ const c = createConnection(sock);
196
+ c.once("connect", () => { c.off("error", rej); res(c); });
197
+ c.once("error", rej);
198
+ });
199
+ try { return await tryConnect(); } catch (e) { if (!["ENOENT", "ECONNREFUSED"].includes(e?.code) || !spawnDaemon) throw e; }
200
+ spawnDaemon({ dir });
201
+ const end = Date.now() + waitMs;
202
+ for (;;) {
203
+ await sleep(100);
204
+ try { return await tryConnect(); } catch (e) { if (Date.now() > end) throw new Error(`schedd did not start within ${waitMs} ms (see ${scheddPaths(dir).log}): ${e?.message ?? e}`); }
205
+ }
206
+ }
207
+
208
+ /** `forgedock schedd` in its own session, output to `schedd.log`. */
209
+ export function defaultSpawn({ dir = schedDir(), script = new URL("../forgedock.mjs", import.meta.url).pathname, env = process.env } = {}) {
210
+ mkdirSync(dir, { recursive: true });
211
+ const out = openSync(scheddPaths(dir).log, "a");
212
+ try {
213
+ const child = spawn(process.execPath, [script, "schedd"], { env, detached: true, stdio: ["ignore", out, out] });
214
+ child.on?.("error", () => { /* the connect retry reports it */ });
215
+ child.unref?.();
216
+ } finally { closeSync(out); }
217
+ }
218
+
219
+ /**
220
+ * Send one request and feed every reply to `onMsg`. Resolves with the last message (`done`, `ok`, `status`), rejects on
221
+ * `error` or when the daemon closes the stream first.
222
+ */
223
+ export function request(sock, msg, onMsg = () => {}) {
224
+ return new Promise((resolve, reject) => {
225
+ let buf = "", last = null, settled = false;
226
+ const finish = (fn, v) => { if (!settled) { settled = true; fn(v); } };
227
+ sock.on("data", (d) => {
228
+ buf += d;
229
+ let i;
230
+ while ((i = buf.indexOf("\n")) >= 0) {
231
+ const line = buf.slice(0, i); buf = buf.slice(i + 1);
232
+ if (!line) continue;
233
+ let m;
234
+ try { m = JSON.parse(line); } catch { continue; }
235
+ last = m;
236
+ if (m.type === "error") return finish(reject, new Error(m.message));
237
+ onMsg(m);
238
+ if (["done", "ok", "status"].includes(m.type)) { sock.end(); return finish(resolve, m); }
239
+ }
240
+ });
241
+ sock.on("error", (e) => finish(reject, e));
242
+ sock.on("close", () => finish(reject, Object.assign(new Error("schedd closed the connection before the batch finished"), { last })));
243
+ sock.write(JSON.stringify(msg) + "\n");
244
+ });
245
+ }
246
+
247
+ export const scheddExists = (dir = schedDir()) => existsSync(scheddPaths(dir).sock);
@@ -0,0 +1,180 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { openBatch, isLaneAlive, listBatches } from "./batch.mjs";
10
+ import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv } from "./schedd.mjs";
11
+ import { formatTable } from "./orchestrate.mjs";
12
+ import { orchestrateFromCli } from "../engine-cli.mjs";
13
+
14
+ const until = async (cond, ms = 15000) => {
15
+ const end = Date.now() + ms;
16
+ while (!cond()) { if (Date.now() > end) throw new Error("timed out waiting"); await new Promise((r) => setTimeout(r, 25)); }
17
+ };
18
+ const capture = async (fn) => {
19
+ const lines = [], l = console.log;
20
+ console.log = (...a) => lines.push(a.join(" "));
21
+ try { return { res: await fn(), lines }; } finally { console.log = l; }
22
+ };
23
+
24
+ /** A host: sched + batch dirs, a stub lane that waits for `release`, and a daemon script with injected lanes. */
25
+ function host() {
26
+ const work = tmpDir("schedd-");
27
+ const h = { work, sched: join(work, "s"), root: join(work, "batches"), launches: join(work, "launches"), release: join(work, "release"), procs: [] };
28
+ writeFileSync(h.launches, "");
29
+ const stub = join(work, "stub-lane.mjs");
30
+ writeFileSync(stub, `import { appendFileSync, existsSync, writeFileSync, renameSync } from "node:fs";
31
+ const a = process.argv.slice(2); const n = Number(a[1]); const rf = a[a.indexOf("--result-file") + 1];
32
+ appendFileSync(${JSON.stringify(h.launches)}, n + "\\n");
33
+ const t = setInterval(() => { if (!existsSync(${JSON.stringify(h.release)})) return; clearInterval(t);
34
+ writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "merged", pr: 100 + n, cost_usd: 0 })); renameSync(rf + ".tmp", rf); }, 20);
35
+ `);
36
+ h.daemonScript = join(work, "daemon.mjs");
37
+ writeFileSync(h.daemonScript, `import { startDaemon } from ${JSON.stringify(new URL("./schedd.mjs", import.meta.url).href)};
38
+ import { runBatch } from ${JSON.stringify(new URL("../engine-cli.mjs", import.meta.url).href)};
39
+ const deps = { allowTmpfs: true, laneScript: ${JSON.stringify(stub)}, pollMs: 30, orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\\n" },
40
+ leanIo: { gh: async (a) => a.includes("milestone") ? JSON.stringify({ milestone: null }) : a[0] === "repo" ? "main\\n" : JSON.stringify({ body: a[2] === "3" ? "Depends on #1" : "", labels: [] }),
41
+ git: async (a) => (a[0] === "ls-remote" ? "abc\\trefs/heads/staging\\n" : "") } };
42
+ const d = await startDaemon({ runBatch: (a) => runBatch({ ...a, daemon: true, deps }), idleMs: 600000 });
43
+ if (!d.running) process.exit(3);
44
+ process.once("SIGTERM", () => d.close());
45
+ await d.closed;
46
+ `);
47
+ h.env = { ...process.env, FORGEDOCK_SCHED_DIR: h.sched, FORGEDOCK_BATCH_DIR: h.root, FORGEDOCK_LIMITS_FILE: join(work, "limits.json") };
48
+ h.daemon = async () => {
49
+ const before = existsSync(scheddPaths(h.sched).lock) ? readFileSync(scheddPaths(h.sched).lock, "utf8") : null;
50
+ const child = spawn(process.execPath, [h.daemonScript], { env: h.env, stdio: "ignore" });
51
+ h.procs.push(child);
52
+ await until(() => existsSync(scheddPaths(h.sched).sock) && existsSync(scheddPaths(h.sched).lock) && readFileSync(scheddPaths(h.sched).lock, "utf8") === String(child.pid) && readFileSync(scheddPaths(h.sched).lock, "utf8") !== before);
53
+ // Wait for the socket to accept.
54
+ for (let i = 0; i < 100; i++) { try { (await connectDaemon({ dir: h.sched, spawnDaemon: null })).destroy(); break; } catch { await new Promise((r) => setTimeout(r, 30)); } }
55
+ return child;
56
+ };
57
+ h.client = { scheddDir: h.sched, spawnDaemon: null };
58
+ h.launched = () => readFileSync(h.launches, "utf8").trim().split("\n").filter(Boolean).map(Number).sort();
59
+ h.lanePids = () => listBatches(h.root).flatMap((id) => Object.values(openBatch(h.root, id).store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean));
60
+ h.cleanup = () => {
61
+ writeFileSync(h.release, "");
62
+ for (const p of h.procs) { try { p.kill("SIGKILL"); } catch { /* gone */ } }
63
+ };
64
+ return h;
65
+ }
66
+
67
+ describe("schedd host scheduler (#4192)", () => {
68
+ // The client and the daemon both resolve the batch root from FORGEDOCK_BATCH_DIR.
69
+ const withRoot = async (h, fn) => {
70
+ const prev = process.env.FORGEDOCK_BATCH_DIR;
71
+ process.env.FORGEDOCK_BATCH_DIR = h.root;
72
+ try { return await fn(); } finally { if (prev === undefined) delete process.env.FORGEDOCK_BATCH_DIR; else process.env.FORGEDOCK_BATCH_DIR = prev; }
73
+ };
74
+
75
+ it("one daemon hosts every batch: registry slots are the daemon's, its RSS stays ≤ 150 MB, set-parallel goes through it", async () => {
76
+ const h = host();
77
+ try {
78
+ const d = await h.daemon();
79
+ const submit = (issues) => orchestrateFromCli([...issues, "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
80
+ // One capture for the three concurrent clients (console.log is process-wide).
81
+ const all = capture(() => Promise.all([submit(["1", "2"]), submit(["4"]), submit(["5"])]));
82
+ await until(() => h.launched().length === 4 && h.lanePids().length === 4);
83
+ const reg = JSON.parse(readFileSync(join(h.sched, "registry.json"), "utf8"));
84
+ assert.equal(Object.keys(reg.slots).length, 4);
85
+ for (const s of Object.values(reg.slots)) assert.equal(s.pid, d.pid, "every lane slot belongs to the one daemon");
86
+ const st = await request(await connectDaemon({ dir: h.sched, spawnDaemon: null }), { op: "status" });
87
+ assert.equal(st.pid, d.pid);
88
+ assert.equal(st.batches.length, 3);
89
+ const rssKb = Number(/VmRSS:\s+(\d+)/.exec(readFileSync(`/proc/${d.pid}/status`, "utf8"))[1]);
90
+ assert.ok(rssKb <= 150 * 1024, `daemon RSS ${rssKb} kB hosting 3 batches`);
91
+ writeFileSync(h.release, "");
92
+ const { res: done, lines } = await all;
93
+ assert.deepEqual(done.map((res) => res.rows.map((r) => [r.issue, r.outcome, r.pr])), [[[1, "merged", 101], [2, "merged", 102]], [[4, "merged", 104]], [[5, "merged", 105]]]);
94
+ assert.ok(lines.some((l) => /^#1 → merged$/.test(l)), "rows stream to the client");
95
+ for (const res of done) assert.ok(lines.some((l) => l.includes(formatTable(res.rows))), "each report table is printed");
96
+ const id = done[0].batch;
97
+ await withRoot(h, () => capture(() => orchestrateFromCli(["--set-parallel", id, "5"], h.client)));
98
+ assert.equal(openBatch(h.root, id).readParallel(), 5);
99
+ } finally { h.cleanup(); }
100
+ });
101
+
102
+ it("a client that disconnects does not stop the batch; --resume reattaches, and replays the report once done", async () => {
103
+ const h = host();
104
+ try {
105
+ await h.daemon();
106
+ const clientScript = join(h.work, "client.mjs");
107
+ writeFileSync(clientScript, `import { orchestrateFromCli } from ${JSON.stringify(new URL("../engine-cli.mjs", import.meta.url).href)};
108
+ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], { scheddDir: ${JSON.stringify(h.sched)}, spawnDaemon: null });
109
+ `);
110
+ const client = spawn(process.execPath, [clientScript], { env: h.env, stdio: "ignore" });
111
+ h.procs.push(client);
112
+ const exited = new Promise((r) => client.on("exit", r));
113
+ await until(() => h.launched().length === 2 && h.lanePids().length === 2);
114
+ client.kill("SIGKILL");
115
+ await exited;
116
+ const [id] = listBatches(h.root);
117
+ for (const pid of h.lanePids()) assert.equal(isLaneAlive(pid, null), true, "lanes outlive the client");
118
+ const attached = capture(() => orchestrateFromCli(["--resume", id], h.client));
119
+ setTimeout(() => writeFileSync(h.release, ""), 200);
120
+ const { res } = await attached;
121
+ assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102]]);
122
+ assert.deepEqual(h.launched(), [1, 2], "no lane relaunched");
123
+ // Finished: the report is replayed from report.json, nothing runs again.
124
+ const again = await capture(() => orchestrateFromCli(["--resume", id], h.client));
125
+ assert.deepEqual(again.res.rows, res.rows);
126
+ assert.ok(again.lines.join("\n").includes(formatTable(res.rows)));
127
+ assert.deepEqual(h.launched(), [1, 2]);
128
+ } finally { h.cleanup(); }
129
+ });
130
+
131
+ it("a daemon restart resumes the batch from state and re-adopts its live lanes without duplicate launches", async () => {
132
+ const h = host();
133
+ try {
134
+ const d1 = await h.daemon();
135
+ const first = capture(() => orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client)).then(() => null, (e) => e);
136
+ await until(() => h.launched().length === 2 && h.lanePids().length === 2);
137
+ const lanes = h.lanePids();
138
+ const d1exit = new Promise((r) => d1.on("exit", r));
139
+ d1.kill("SIGKILL");
140
+ await d1exit;
141
+ assert.match(String((await first)?.message), /--resume/, "the client is told how to reattach");
142
+ for (const pid of lanes) assert.equal(isLaneAlive(pid, null), true, "lanes outlive the daemon");
143
+ const [id] = listBatches(h.root);
144
+ await h.daemon(); // takes over the dead daemon's lock and socket, resumes the batch
145
+ const attached = capture(() => orchestrateFromCli(["--resume", id], h.client));
146
+ setTimeout(() => writeFileSync(h.release, ""), 200);
147
+ const { res } = await attached;
148
+ assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102], [3, "merged", 103]]);
149
+ assert.deepEqual(h.launched(), [1, 2, 3], "adopted, not relaunched");
150
+ assert.deepEqual(openBatch(h.root, id).store.load().running, {});
151
+ } finally { h.cleanup(); }
152
+ });
153
+
154
+ it("single instance: a live daemon keeps its lock; a dead owner's lock and socket are taken over", async () => {
155
+ const h = host();
156
+ try {
157
+ const d = await h.daemon();
158
+ const second = await startDaemon({ dir: h.sched, root: h.root, runBatch: async () => ({}), log: () => {}, resumeOnStart: false });
159
+ assert.deepEqual(second, { running: false, owner: d.pid });
160
+ const dexit = new Promise((r) => d.on("exit", r));
161
+ d.kill("SIGKILL");
162
+ await dexit;
163
+ assert.ok(existsSync(scheddPaths(h.sched).lock) && existsSync(scheddPaths(h.sched).sock), "stale lock and socket left behind");
164
+ const third = await startDaemon({ dir: h.sched, root: h.root, runBatch: async () => ({}), log: () => {}, resumeOnStart: false });
165
+ try {
166
+ assert.equal(third.running, true);
167
+ const st = await request(await connectDaemon({ dir: h.sched, spawnDaemon: null }), { op: "status" });
168
+ assert.equal(st.pid, process.pid);
169
+ } finally { await third.close(); }
170
+ assert.equal(existsSync(scheddPaths(h.sched).lock), false, "lock released on close");
171
+ } finally { h.cleanup(); }
172
+ });
173
+
174
+ it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
175
+ const kept = batchEnv({ PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", SECRET_X: "no", FORGEDOCK_SCHED_DIR: "/s" });
176
+ assert.deepEqual(kept, { PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s" });
177
+ const env = mergeEnv({ PATH: "/d", ANTHROPIC_API_KEY: "daemon-key", NODE_OPTIONS: "x" }, kept);
178
+ assert.deepEqual(env, { NODE_OPTIONS: "x", PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s" });
179
+ });
180
+ });
@@ -0,0 +1,228 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Toolchain pins for a lane (#4163). Reads the target repo's own pins (package.json `packageManager` / `engines.node`,
5
+ * `.nvmrc`, `.node-version`, `.tool-versions`) and writes a per-run shim dir for the pinned package manager, prepended
6
+ * to the runner's PATH so the builder, its validate commands and git hooks all resolve the pinned version. Zero LLM.
7
+ * A repo without pins gets nothing: no shim dir, no env, no warning.
8
+ */
9
+ import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
10
+ import { accessSync, constants } from "node:fs";
11
+ import { execFileSync } from "node:child_process";
12
+ import { join, delimiter, resolve } from "node:path";
13
+
14
+ const PMS = ["pnpm", "yarn", "npm"];
15
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
16
+
17
+ /** "pnpm@10.4.1+sha512.ab" -> {name, version}; null for anything not a known manager at an exact version. */
18
+ export function parsePackageManager(s) {
19
+ const m = /^(pnpm|yarn|npm)@([^+\s]+)(?:\+\S*)?$/.exec(String(s ?? "").trim());
20
+ return m && VERSION_RE.test(m[2]) ? { name: m[1], version: m[2] } : null;
21
+ }
22
+
23
+ /** `.tool-versions` lines -> {tool: firstVersion}. */
24
+ function parseToolVersions(text) {
25
+ const out = {};
26
+ for (const l of String(text ?? "").split("\n")) {
27
+ const [tool, ver] = l.replace(/#.*/, "").trim().split(/\s+/);
28
+ if (tool && ver && !(tool in out)) out[tool] = ver;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * The repo's pins. `override` is forge.yaml `env.toolchain`: "<pm>@<version>" replaces the package-manager pin, "off"
35
+ * disables detection. Returns {pm:{name,version,source}|null, node:{spec,source}|null, notes:[]}.
36
+ */
37
+ export async function detectPins(dir, { override = null, read = (p) => readFile(p, "utf8") } = {}) {
38
+ const res = { pm: null, node: null, notes: [] };
39
+ const ov = typeof override === "string" ? override.trim() : "";
40
+ if (ov.toLowerCase() === "off") return res;
41
+ const tryRead = async (f) => { try { return String(await read(join(dir, f))); } catch { return null; } };
42
+ let pkg = null;
43
+ const pkgText = await tryRead("package.json");
44
+ if (pkgText !== null) { try { pkg = JSON.parse(pkgText); } catch { res.notes.push("package.json is not valid JSON; its pins are ignored"); } }
45
+ const tv = parseToolVersions(await tryRead(".tool-versions"));
46
+ if (ov) {
47
+ const pm = parsePackageManager(ov);
48
+ if (pm) res.pm = { ...pm, source: "forge.yaml env.toolchain" };
49
+ else res.notes.push(`forge.yaml env.toolchain "${ov.slice(0, 60)}" is not <pnpm|yarn|npm>@<x.y.z> or "off"; ignored`);
50
+ }
51
+ if (!res.pm && typeof pkg?.packageManager === "string") {
52
+ const pm = parsePackageManager(pkg.packageManager);
53
+ if (pm) res.pm = { ...pm, source: "package.json packageManager" };
54
+ else res.notes.push(`package.json packageManager "${pkg.packageManager.slice(0, 60)}" is not <pnpm|yarn|npm>@<x.y.z>; not pinned`);
55
+ }
56
+ if (!res.pm) {
57
+ const name = PMS.find((n) => VERSION_RE.test(tv[n] ?? ""));
58
+ if (name) res.pm = { name, version: tv[name], source: ".tool-versions" };
59
+ }
60
+ for (const [f, get] of [[".nvmrc", (t) => t], [".node-version", (t) => t]]) {
61
+ if (res.node) break;
62
+ const t = await tryRead(f);
63
+ const spec = t === null ? "" : get(t).split("\n")[0].trim();
64
+ if (spec) res.node = { spec, source: f };
65
+ }
66
+ if (!res.node && (tv.nodejs || tv.node)) res.node = { spec: tv.nodejs || tv.node, source: ".tool-versions" };
67
+ if (!res.node && typeof pkg?.engines?.node === "string" && pkg.engines.node.trim()) res.node = { spec: pkg.engines.node.trim(), source: "package.json engines.node" };
68
+ return res;
69
+ }
70
+
71
+ // ---- minimal semver satisfaction for node pins ----
72
+ const parseV = (s) => {
73
+ const m = /^v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?$/i.exec(String(s).trim());
74
+ if (!m) return null;
75
+ const n = (x) => (x === undefined || /^[x*]$/i.test(x) ? null : Number(x));
76
+ return [Number(m[1]), n(m[2]), n(m[3])];
77
+ };
78
+ const cmp = (a, b) => { for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; } return 0; };
79
+ const fill = (p, v = 0) => p.map((x) => (x === null ? v : x));
80
+ /** host [M,m,p] within partial p (20 -> 20.x.x, 20.11 -> 20.11.x)? */
81
+ const within = (h, p) => p.every((x, i) => x === null || x === h[i]);
82
+ const upper = (p) => (p[1] === null ? [p[0] + 1, 0, 0] : p[2] === null ? [p[0], p[1] + 1, 0] : null);
83
+
84
+ /** true/false, or null when the comparator can't be evaluated. */
85
+ function satisfiesOne(h, c) {
86
+ const m = /^(\^|~|>=|<=|>|<|=)?\s*(\S+)$/.exec(c.trim());
87
+ if (!m) return null;
88
+ const p = parseV(m[2]);
89
+ if (!p) return null;
90
+ const lo = fill(p);
91
+ switch (m[1] ?? "") {
92
+ case "": case "=": return within(h, p);
93
+ case ">=": return cmp(h, lo) >= 0;
94
+ case ">": return p[1] === null || p[2] === null ? cmp(h, upper(p)) >= 0 : cmp(h, lo) > 0;
95
+ case "<": return cmp(h, lo) < 0;
96
+ case "<=": return p[1] === null || p[2] === null ? cmp(h, upper(p)) < 0 : cmp(h, lo) <= 0;
97
+ case "~": return cmp(h, lo) >= 0 && cmp(h, p[1] === null ? [p[0] + 1, 0, 0] : [p[0], p[1] + 1, 0]) < 0;
98
+ case "^": return cmp(h, lo) >= 0 && cmp(h, lo[0] > 0 ? [lo[0] + 1, 0, 0] : lo[1] > 0 ? [0, lo[1] + 1, 0] : [0, 0, lo[2] + 1]) < 0;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /** Does host version `v` satisfy node spec? true/false, or null when the spec can't be evaluated (aliases, unknown syntax). */
104
+ export function nodeSatisfies(spec, v) {
105
+ const h = parseV(v);
106
+ if (!h || h.includes(null)) return null;
107
+ const alts = String(spec).split("||").map((s) => s.trim()).filter(Boolean);
108
+ if (!alts.length) return null;
109
+ let unknown = false;
110
+ for (const alt of alts) {
111
+ const hy = /^(\S+)\s+-\s+(\S+)$/.exec(alt); // hyphen range "18 - 20" = ">=18 <=20"
112
+ const parts = hy ? [`>=${hy[1]}`, `<=${hy[2]}`] : alt.replace(/(\^|~|>=|<=|>|<|=)\s+/g, "$1").split(/\s+/);
113
+ let ok = true;
114
+ for (const c of parts) {
115
+ const r = satisfiesOne(h, c);
116
+ if (r === null) { unknown = true; ok = false; break; }
117
+ if (!r) { ok = false; break; }
118
+ }
119
+ if (ok) return true;
120
+ }
121
+ return unknown ? null : false;
122
+ }
123
+
124
+ /** One-line trajectory warning when the host node does not satisfy the pin; null when it does or nothing is pinned. */
125
+ export function nodeWarning(pin, hostVersion = process.version) {
126
+ if (!pin?.spec) return null;
127
+ if (/^(lts\/|node$|stable$|latest$|current$|system$)/i.test(pin.spec)) return null;
128
+ const ok = nodeSatisfies(pin.spec, hostVersion);
129
+ if (ok === true) return null;
130
+ if (ok === null) return `Toolchain: cannot evaluate node pin "${pin.spec}" (${pin.source}) against host node ${hostVersion}; check it manually`;
131
+ return `host node ${hostVersion} does not satisfy the ${pin.source} pin "${pin.spec}"; installs, builds and tests may behave differently from the repo's CI`;
132
+ }
133
+
134
+ /** First executable `name` on `pathStr`, skipping `exclude` dirs (the shim dir itself, so a shim never calls itself). */
135
+ export function which(name, pathStr = process.env.PATH ?? "", exclude = []) {
136
+ const skip = new Set(exclude.map((d) => resolve(d)));
137
+ for (const d of String(pathStr).split(delimiter)) {
138
+ if (!d || skip.has(resolve(d))) continue;
139
+ const p = join(d, name);
140
+ try { accessSync(p, constants.X_OK); return p; } catch { /* next */ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
146
+ const HEADER = "#!/bin/sh\n# forgedock lane toolchain shim (#4163): pinned by the target repo; do not edit.\nexport COREPACK_ENABLE_DOWNLOAD_PROMPT=0 COREPACK_ENABLE_STRICT=0\n";
147
+
148
+ /**
149
+ * Shim scripts {name: content} for the pinned manager: `corepack <pm>@<ver>` when corepack is on the host PATH, else
150
+ * `npx -y`. Absolute paths are resolved here, so a shim never re-enters the shim dir. Returns {} when neither exists.
151
+ */
152
+ export function shimScripts(pm, { corepack = null, npx = null } = {}) {
153
+ if (!pm || !PMS.includes(pm.name) || !VERSION_RE.test(pm.version)) return {};
154
+ const at = `${pm.name}@${pm.version}`;
155
+ const out = {};
156
+ if (pm.name === "npm") {
157
+ if (npx) { out.npm = `${HEADER}exec ${q(npx)} -y ${q(at)} "$@"\n`; out.npx = `${HEADER}exec ${q(npx)} -y -p ${q(at)} npx "$@"\n`; }
158
+ return out;
159
+ }
160
+ if (corepack) {
161
+ out[pm.name] = `${HEADER}exec ${q(corepack)} ${q(at)} "$@"\n`;
162
+ if (pm.name === "pnpm") out.pnpx = `${HEADER}exec ${q(corepack)} ${q(at)} dlx "$@"\n`;
163
+ return out;
164
+ }
165
+ if (!npx) return out;
166
+ // yarn >= 2 is published as @yarnpkg/cli-dist; the npm "yarn" package is 1.x only.
167
+ const pkg = pm.name === "yarn" && Number(pm.version.split(".")[0]) >= 2 ? `@yarnpkg/cli-dist@${pm.version}` : at;
168
+ out[pm.name] = `${HEADER}exec ${q(npx)} -y -p ${q(pkg)} ${pm.name} "$@"\n`;
169
+ if (pm.name === "pnpm") out.pnpx = `${HEADER}exec ${q(npx)} -y -p ${q(pkg)} pnpm dlx "$@"\n`;
170
+ return out;
171
+ }
172
+
173
+ /** Runner env for a shim dir: PATH with the shim dir first, corepack prompts off. */
174
+ export function toolchainEnv(binDir, basePath = process.env.PATH ?? "") {
175
+ return { PATH: basePath ? `${binDir}${delimiter}${basePath}` : binDir, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", COREPACK_ENABLE_STRICT: "0" };
176
+ }
177
+
178
+ /** Version of the host `node` the builder's shells resolve (PATH), falling back to this process. */
179
+ export function hostNodeVersion(pathStr = process.env.PATH ?? "") {
180
+ const n = which("node", pathStr);
181
+ if (n) { try { return String(execFileSync(n, ["--version"], { timeout: 10000 })).trim(); } catch { /* fall through */ } }
182
+ return process.version;
183
+ }
184
+
185
+ async function writeShimFile(p, content) { await writeFile(p, content); await chmod(p, 0o755); }
186
+
187
+ /**
188
+ * Write executable (0755) shims for the pinned manager into `binDir`; launchers are resolved from `path` with `binDir`
189
+ * excluded. Returns the shim names written ([] when neither corepack nor npx is on `path`).
190
+ */
191
+ export async function writeShims(binDir, pm, { path = process.env.PATH ?? "", find = which, writeShim = writeShimFile, mkdirp = (d) => mkdir(d, { recursive: true }) } = {}) {
192
+ const scripts = shimScripts(pm, { corepack: find("corepack", path, [binDir]), npx: find("npx", path, [binDir]) });
193
+ if (!Object.keys(scripts).length) return [];
194
+ await mkdirp(binDir);
195
+ for (const [name, content] of Object.entries(scripts)) await writeShim(join(binDir, name), content);
196
+ return Object.keys(scripts);
197
+ }
198
+
199
+ /** Short pin text for prompts and decisions, e.g. "pnpm@10.4.1 (package.json packageManager), node 20 (.nvmrc)"; null when nothing is pinned. */
200
+ export function summary(pins) {
201
+ const parts = [...(pins?.pm ? [`${pins.pm.name}@${pins.pm.version} (${pins.pm.source})`] : []), ...(pins?.node ? [`node ${pins.node.spec} (${pins.node.source})`] : [])];
202
+ return parts.length ? parts.join(", ") : null;
203
+ }
204
+
205
+ /**
206
+ * Detect pins in `dir` and, for a pinned package manager, write shims to `binDir`.
207
+ * @returns {Promise<{pins, env:?object, shims:string[], summary:?string, warning:?string, notes:string[]}>}
208
+ * env is null and shims empty without a usable pm pin; summary is null when nothing is pinned: the caller changes nothing.
209
+ */
210
+ export async function prepareToolchain({ dir, binDir, override = null, read, basePath = process.env.PATH ?? "",
211
+ writeShim = writeShimFile, mkdirp = (d) => mkdir(d, { recursive: true }), find = which, hostNode = () => hostNodeVersion(basePath) } = {}) {
212
+ const pins = await detectPins(dir, { override, ...(read ? { read } : {}) });
213
+ const out = { pins, env: null, shims: [], summary: null, warning: null, notes: [...pins.notes] };
214
+ if (pins.node) {
215
+ let v = null;
216
+ try { v = hostNode(); } catch { v = null; }
217
+ out.warning = v ? nodeWarning(pins.node, v) : null;
218
+ }
219
+ const pm = pins.pm;
220
+ let effective = pins;
221
+ if (pm && binDir) {
222
+ out.shims = await writeShims(binDir, pm, { path: basePath, find, writeShim, mkdirp });
223
+ if (out.shims.length) out.env = toolchainEnv(binDir, basePath);
224
+ else { out.notes.push(`Toolchain: ${pm.name}@${pm.version} is pinned but neither corepack nor npx is on PATH; the host ${pm.name} is used`); effective = { ...pins, pm: null }; }
225
+ }
226
+ out.summary = summary(effective);
227
+ return out;
228
+ }