@forwardimpact/libharness 0.1.20 → 1.0.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.
- package/LICENSE +21 -201
- package/README.md +196 -80
- package/bin/fit-benchmark.js +44 -0
- package/bin/fit-harness.js +358 -0
- package/bin/fit-selfedit.js +165 -0
- package/bin/fit-trace.js +510 -0
- package/package.json +42 -12
- package/src/agent-runner.js +256 -0
- package/src/benchmark/apm-installer.js +207 -0
- package/src/benchmark/env-loader.js +158 -0
- package/src/benchmark/hook-env.js +40 -0
- package/src/benchmark/invariants.js +141 -0
- package/src/benchmark/judge.js +187 -0
- package/src/benchmark/npm-installer.js +87 -0
- package/src/benchmark/report.js +522 -0
- package/src/benchmark/result.js +127 -0
- package/src/benchmark/runner.js +583 -0
- package/src/benchmark/task-family.js +260 -0
- package/src/benchmark/workdir.js +298 -0
- package/src/commands/assert.js +153 -0
- package/src/commands/benchmark-definition.js +165 -0
- package/src/commands/benchmark-invariants.js +73 -0
- package/src/commands/benchmark-report.js +51 -0
- package/src/commands/benchmark-run.js +111 -0
- package/src/commands/by-discussion.js +94 -0
- package/src/commands/callback.js +119 -0
- package/src/commands/discuss.js +132 -0
- package/src/commands/facilitate.js +123 -0
- package/src/commands/output.js +36 -0
- package/src/commands/run.js +152 -0
- package/src/commands/supervise.js +136 -0
- package/src/commands/task-input.js +54 -0
- package/src/commands/tee.js +53 -0
- package/src/commands/trace.js +630 -0
- package/src/commands/work-tracker.js +35 -0
- package/src/cost.js +79 -0
- package/src/discuss-tools.js +173 -0
- package/src/discusser.js +394 -0
- package/src/events/github.js +161 -0
- package/src/facilitator.js +205 -0
- package/src/inbox-poller.js +81 -0
- package/src/index.js +72 -2
- package/src/judge.js +210 -0
- package/src/message-bus.js +118 -0
- package/src/orchestration-loop.js +330 -0
- package/src/orchestration-toolkit.js +441 -0
- package/src/orchestrator-helpers.js +23 -0
- package/src/profile-prompt.js +266 -0
- package/src/redaction.js +253 -0
- package/src/render/line-renderer.js +54 -0
- package/src/render/orchestrator-filter.js +19 -0
- package/src/render/palette.js +63 -0
- package/src/render/tool-hints.js +154 -0
- package/src/render/turn-renderer.js +96 -0
- package/src/reply-emitter.js +47 -0
- package/src/sequence-counter.js +21 -0
- package/src/signature-filter.js +27 -0
- package/src/supervisor.js +236 -0
- package/src/tee-writer.js +150 -0
- package/src/trace-collector.js +444 -0
- package/src/trace-github.js +473 -0
- package/src/trace-multi.js +101 -0
- package/src/trace-query.js +748 -0
- package/src/trace-render.js +211 -0
- package/src/trace-usage.js +249 -0
- package/src/fixture/assertions.js +0 -42
- package/src/fixture/cache.js +0 -50
- package/src/fixture/eval.js +0 -146
- package/src/fixture/index.js +0 -9
- package/src/fixture/pathway.js +0 -451
- package/src/fixture/services.js +0 -56
- package/src/mock/clients.js +0 -135
- package/src/mock/config.js +0 -45
- package/src/mock/data.js +0 -46
- package/src/mock/fs.js +0 -111
- package/src/mock/grpc.js +0 -94
- package/src/mock/http.js +0 -60
- package/src/mock/index.js +0 -36
- package/src/mock/infra.js +0 -219
- package/src/mock/logger.js +0 -42
- package/src/mock/observer.js +0 -74
- package/src/mock/resource-index.js +0 -95
- package/src/mock/service-callbacks.js +0 -39
- package/src/mock/services.js +0 -79
- package/src/mock/spy.js +0 -44
- package/src/mock/storage.js +0 -118
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invariants — runs `<task.paths.hooks>/invariants.sh` from the template path
|
|
3
|
+
* against the post-run agent CWD. The exit code is authoritative for the
|
|
4
|
+
* verdict; structured per-check rows arrive on fd 3 (`$RESULTS_FD=3`) as NDJSON.
|
|
5
|
+
*
|
|
6
|
+
* Subprocess access flows through `runtime.subprocess.spawn`; the fd-3 backing
|
|
7
|
+
* store and the stderr log use the sync filesystem surface (`runtime.fsSync`) —
|
|
8
|
+
* the only surface this module touches, per design Decision 7.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { buildHookEnv } from "./hook-env.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} InvariantsResult
|
|
17
|
+
* @property {"pass" | "fail"} verdict
|
|
18
|
+
* @property {Array<object>} details
|
|
19
|
+
* @property {number} exitCode
|
|
20
|
+
* @property {string} [stderr] - Trimmed script stderr, present only when the
|
|
21
|
+
* script wrote to stderr. Surfaces hook failures (e.g. a missing tool) that
|
|
22
|
+
* leave `details` empty, so they read distinctly from a real invariant miss.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run the task's invariants script.
|
|
27
|
+
* @param {import("./task-family.js").Task} task
|
|
28
|
+
* @param {{cwd: string, port: number, runDir: string, familyDir?: string|null}} ctx
|
|
29
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
30
|
+
* @returns {Promise<InvariantsResult>}
|
|
31
|
+
*/
|
|
32
|
+
export async function runInvariants(task, ctx, runtime) {
|
|
33
|
+
if (!runtime) throw new Error("runtime is required");
|
|
34
|
+
if (!task.paths.invariants) {
|
|
35
|
+
return { verdict: "pass", details: [], exitCode: 0 };
|
|
36
|
+
}
|
|
37
|
+
const fsSync = runtime.fsSync;
|
|
38
|
+
const script = task.paths.invariants;
|
|
39
|
+
const stderrLogPath = join(ctx.runDir, "invariants.stderr.log");
|
|
40
|
+
|
|
41
|
+
// Bun's child_process pipe setup for fd >= 3 is racy under load (it
|
|
42
|
+
// creates a unix socket pair and the connect() can return ENOENT). Use
|
|
43
|
+
// a temp file as the fd-3 backing store instead — the script still
|
|
44
|
+
// writes via `$RESULTS_FD`, but we hand it a real file descriptor.
|
|
45
|
+
const fd3Path = join(ctx.runDir, "invariants.fd3.ndjson");
|
|
46
|
+
const fd3File = fsSync.openSync(fd3Path, "w+");
|
|
47
|
+
|
|
48
|
+
let child;
|
|
49
|
+
try {
|
|
50
|
+
child = runtime.subprocess.spawn(script, [], {
|
|
51
|
+
env: {
|
|
52
|
+
...buildHookEnv(runtime.proc.env, {
|
|
53
|
+
cwd: ctx.cwd,
|
|
54
|
+
port: ctx.port,
|
|
55
|
+
taskId: task.id,
|
|
56
|
+
taskDir: task.paths.taskDir,
|
|
57
|
+
hooksDir: task.paths.hooks,
|
|
58
|
+
familyDir: ctx.familyDir,
|
|
59
|
+
}),
|
|
60
|
+
RESULTS_FD: "3",
|
|
61
|
+
},
|
|
62
|
+
stdio: ["inherit", "pipe", "pipe", fd3File],
|
|
63
|
+
});
|
|
64
|
+
} catch (e) {
|
|
65
|
+
tryClose(fsSync, fd3File);
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Drain stdout (do not require consumers to read it); capture stderr to log.
|
|
70
|
+
const drainStdout = (async () => {
|
|
71
|
+
for await (const _chunk of child.stdout) {
|
|
72
|
+
// discard
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
let stderr = "";
|
|
76
|
+
for await (const chunk of child.stderr) stderr += chunk.toString();
|
|
77
|
+
await drainStdout;
|
|
78
|
+
const code = await child.exitCode;
|
|
79
|
+
|
|
80
|
+
fsSync.writeFileSync(stderrLogPath, stderr);
|
|
81
|
+
tryClose(fsSync, fd3File);
|
|
82
|
+
|
|
83
|
+
const raw = readAndUnlink(fsSync, fd3Path);
|
|
84
|
+
const details = [];
|
|
85
|
+
parseFd3Buffer(raw, details);
|
|
86
|
+
const exitCode = typeof code === "number" ? code : -1;
|
|
87
|
+
const result = {
|
|
88
|
+
verdict: exitCode === 0 ? "pass" : "fail",
|
|
89
|
+
details,
|
|
90
|
+
exitCode,
|
|
91
|
+
};
|
|
92
|
+
const trimmedStderr = stderr.trim();
|
|
93
|
+
if (trimmedStderr) result.stderr = trimmedStderr;
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function pushRow(line, details) {
|
|
98
|
+
const trimmed = line.trim();
|
|
99
|
+
if (!trimmed) return;
|
|
100
|
+
try {
|
|
101
|
+
details.push(JSON.parse(trimmed));
|
|
102
|
+
} catch {
|
|
103
|
+
details.push({ raw: trimmed, parseError: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function tryClose(fsSync, fd) {
|
|
108
|
+
try {
|
|
109
|
+
fsSync.closeSync(fd);
|
|
110
|
+
} catch {
|
|
111
|
+
// already closed
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readAndUnlink(fsSync, path) {
|
|
116
|
+
let raw = "";
|
|
117
|
+
try {
|
|
118
|
+
raw = fsSync.readFileSync(path, "utf8");
|
|
119
|
+
} catch {
|
|
120
|
+
// empty
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
fsSync.unlinkSync(path);
|
|
124
|
+
} catch {
|
|
125
|
+
// best-effort cleanup
|
|
126
|
+
}
|
|
127
|
+
return raw;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse the fd-3 buffer (read from the temp-file backing) into one NDJSON
|
|
132
|
+
* row per detail entry.
|
|
133
|
+
*/
|
|
134
|
+
function parseFd3Buffer(buf, details) {
|
|
135
|
+
if (!buf) return;
|
|
136
|
+
const parts = buf.split("\n");
|
|
137
|
+
for (let i = 0; i < parts.length - 1; i++) pushRow(parts[i], details);
|
|
138
|
+
if (parts[parts.length - 1].trim()) {
|
|
139
|
+
pushRow(parts[parts.length - 1], details);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark adapter for the libharness `Judge`. Templates the family's
|
|
3
|
+
* `judge.task.md` with structured context variables, runs the judge against
|
|
4
|
+
* the post-run agent CWD, and returns the verdict in the benchmark's
|
|
5
|
+
* `pass`/`fail` vocabulary (mapped from libharness's `success`/`failure`).
|
|
6
|
+
*
|
|
7
|
+
* Template variables available in `judge.task.md`:
|
|
8
|
+
*
|
|
9
|
+
* {{AGENT_INSTRUCTIONS}} — contents of agent.task.md
|
|
10
|
+
* {{AGENT_PROFILE}} — agent profile body (empty string if none)
|
|
11
|
+
* {{AGENT_TRACE_PATH}} — path to agent.ndjson
|
|
12
|
+
* {{INVARIANTS_RESULT}} — JSON invariants object
|
|
13
|
+
* {{SKILL_SET_HASH}} — SHA-256 from apm.lock.yaml
|
|
14
|
+
* {{TASK_ID}} — task name (directory under tasks/)
|
|
15
|
+
* {{TASK_DIR}} — agent working directory path
|
|
16
|
+
*
|
|
17
|
+
* The judge verdict is captured from the orchestration context's
|
|
18
|
+
* `concluded` flag directly — no trace parsing on the happy path.
|
|
19
|
+
* `parseConcludeFromTrace` is preserved for offline analysis and as a
|
|
20
|
+
* fallback when the runtime ctx isn't available (e.g. re-grading a
|
|
21
|
+
* historical run from its judge.ndjson file).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createJudge } from "../judge.js";
|
|
25
|
+
import { createRedactor } from "../redaction.js";
|
|
26
|
+
import { sumTraceCost } from "../cost.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} JudgeVerdict
|
|
30
|
+
* @property {"pass" | "fail"} verdict
|
|
31
|
+
* @property {string} summary
|
|
32
|
+
* @property {number} costUsd - Cost of the judge's own SDK session.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} JudgeContext
|
|
37
|
+
* @property {string} agentInstructions - Contents of agent.task.md.
|
|
38
|
+
* @property {string} agentProfile - Agent profile body (empty string if none).
|
|
39
|
+
* @property {string} skillSetHash - SHA-256 fingerprint from apm.lock.yaml.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run the judge over a completed task run.
|
|
44
|
+
* @param {import("./task-family.js").Task} task
|
|
45
|
+
* @param {import("./workdir.js").Workdir} workdir
|
|
46
|
+
* @param {import("./invariants.js").InvariantsResult} invariants
|
|
47
|
+
* @param {{query: Function, model: string, judgeProfile?: string, profilesDir?: string, runtime: import("@forwardimpact/libutil/runtime").Runtime}} deps
|
|
48
|
+
* @param {JudgeContext} [context]
|
|
49
|
+
* @returns {Promise<JudgeVerdict>}
|
|
50
|
+
*/
|
|
51
|
+
export async function runJudge(task, workdir, invariants, deps, context) {
|
|
52
|
+
const runtime = deps.runtime;
|
|
53
|
+
if (!runtime) throw new Error("runtime is required");
|
|
54
|
+
const fs = runtime.fs;
|
|
55
|
+
const template = await fs.readFile(task.paths.judge, "utf8");
|
|
56
|
+
const invariantsJson = JSON.stringify(invariants, null, 2);
|
|
57
|
+
const taskText = template
|
|
58
|
+
.replaceAll("{{INVARIANTS_RESULT}}", invariantsJson)
|
|
59
|
+
.replaceAll("{{AGENT_TRACE_PATH}}", workdir.agentTracePath)
|
|
60
|
+
.replaceAll("{{AGENT_INSTRUCTIONS}}", context?.agentInstructions ?? "")
|
|
61
|
+
.replaceAll("{{AGENT_PROFILE}}", context?.agentProfile ?? "")
|
|
62
|
+
.replaceAll("{{SKILL_SET_HASH}}", context?.skillSetHash ?? "")
|
|
63
|
+
.replaceAll("{{TASK_ID}}", task.id)
|
|
64
|
+
.replaceAll("{{TASK_DIR}}", workdir.cwd);
|
|
65
|
+
|
|
66
|
+
const output = fs.createWriteStream(workdir.judgeTracePath);
|
|
67
|
+
const judge = createJudge({
|
|
68
|
+
cwd: workdir.cwd,
|
|
69
|
+
query: deps.query,
|
|
70
|
+
output,
|
|
71
|
+
model: deps.model,
|
|
72
|
+
judgeProfile: deps.judgeProfile,
|
|
73
|
+
profilesDir: deps.profilesDir,
|
|
74
|
+
maxTurns: 25,
|
|
75
|
+
redactor: createRedactor({ runtime }),
|
|
76
|
+
runtime,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let outcome;
|
|
80
|
+
try {
|
|
81
|
+
outcome = await judge.run(taskText);
|
|
82
|
+
} finally {
|
|
83
|
+
await new Promise((r) => output.end(r));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The judge is its own SDK session; its spend lands in the judge trace we
|
|
87
|
+
// just wrote, not in the supervisor's combined trace. Read it back so the
|
|
88
|
+
// benchmark record's cost includes the judge.
|
|
89
|
+
const judgeTrace = await fs
|
|
90
|
+
.readFile(workdir.judgeTracePath, "utf8")
|
|
91
|
+
.catch(() => "");
|
|
92
|
+
const { totalCostUsd } = sumTraceCost(judgeTrace.split("\n"));
|
|
93
|
+
|
|
94
|
+
if (outcome.verdict === null) {
|
|
95
|
+
return {
|
|
96
|
+
verdict: "fail",
|
|
97
|
+
summary: "judge did not conclude",
|
|
98
|
+
costUsd: totalCostUsd,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
verdict: outcome.verdict === "success" ? "pass" : "fail",
|
|
103
|
+
summary: outcome.summary ?? "",
|
|
104
|
+
costUsd: totalCostUsd,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse the last judge-source (or supervisor-source, for backward compat
|
|
110
|
+
* with pre-Judge-class traces) `Conclude` tool call from an NDJSON trace
|
|
111
|
+
* and map the verdict (`success → pass`, `failure → fail`). Preserved for
|
|
112
|
+
* offline analysis; not used on the runtime happy path.
|
|
113
|
+
* @param {string} tracePath
|
|
114
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
115
|
+
* @returns {Promise<JudgeVerdict | null>}
|
|
116
|
+
*/
|
|
117
|
+
export async function parseConcludeFromTrace(tracePath, runtime) {
|
|
118
|
+
if (!runtime) throw new Error("runtime is required");
|
|
119
|
+
const content = await runtime.fs.readFile(tracePath, "utf8");
|
|
120
|
+
let last = null;
|
|
121
|
+
for (const line of content.split("\n")) {
|
|
122
|
+
const candidate = extractConcludeInput(line);
|
|
123
|
+
if (candidate) last = candidate;
|
|
124
|
+
}
|
|
125
|
+
if (!last) return null;
|
|
126
|
+
return {
|
|
127
|
+
verdict: last.verdict === "success" ? "pass" : "fail",
|
|
128
|
+
summary: last.summary ?? "",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Return the `Conclude` tool input if the line carries a judge-source or
|
|
134
|
+
* supervisor-source assistant message ending in a `Conclude` tool_use
|
|
135
|
+
* block; null otherwise.
|
|
136
|
+
* @param {string} line
|
|
137
|
+
* @returns {{verdict: string, summary?: string} | null}
|
|
138
|
+
*/
|
|
139
|
+
function extractConcludeInput(line) {
|
|
140
|
+
const trimmed = line.trim();
|
|
141
|
+
if (!trimmed) return null;
|
|
142
|
+
let event;
|
|
143
|
+
try {
|
|
144
|
+
event = JSON.parse(trimmed);
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const wrapped =
|
|
149
|
+
event.event && typeof event.source === "string"
|
|
150
|
+
? { source: event.source, inner: event.event }
|
|
151
|
+
: { source: null, inner: event };
|
|
152
|
+
if (
|
|
153
|
+
wrapped.source !== null &&
|
|
154
|
+
wrapped.source !== "judge" &&
|
|
155
|
+
wrapped.source !== "supervisor"
|
|
156
|
+
) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
if (wrapped.inner.type !== "assistant") return null;
|
|
160
|
+
const content = wrapped.inner.message?.content ?? wrapped.inner.content;
|
|
161
|
+
if (!Array.isArray(content)) return null;
|
|
162
|
+
let found = null;
|
|
163
|
+
for (const block of content) {
|
|
164
|
+
if (
|
|
165
|
+
block.type === "tool_use" &&
|
|
166
|
+
isConcludeToolName(block.name) &&
|
|
167
|
+
block.input
|
|
168
|
+
) {
|
|
169
|
+
found = block.input;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return found;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The Claude Agent SDK reports MCP tool names as
|
|
177
|
+
* `mcp__<server>__<tool>` when the model invokes them — the orchestration
|
|
178
|
+
* `Conclude` arrives as `mcp__orchestration__Conclude`. Pre-baked
|
|
179
|
+
* supervisor traces (and the libharness-internal envelopes) sometimes carry
|
|
180
|
+
* the bare `Conclude` name. Accept both forms so the parser is robust to
|
|
181
|
+
* trace source.
|
|
182
|
+
*/
|
|
183
|
+
function isConcludeToolName(name) {
|
|
184
|
+
if (typeof name !== "string") return false;
|
|
185
|
+
if (name === "Conclude") return true;
|
|
186
|
+
return name.endsWith("__Conclude");
|
|
187
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NpmInstaller — runs `bun install` in the family root when a package.json
|
|
3
|
+
* is present, then copies the resulting `node_modules/` into the staging
|
|
4
|
+
* directory so WorkdirManager can seed each per-task CWD.
|
|
5
|
+
*
|
|
6
|
+
* Symmetric to ApmInstaller: the subprocess and filesystem flow through the
|
|
7
|
+
* injected `runtime` bag (`runtime.subprocess.spawn` + `runtime.fs`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
/** Run `bun install` in the family root and stage node_modules/ for per-task CWDs. */
|
|
13
|
+
export class NpmInstaller {
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} deps
|
|
16
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} deps.runtime -
|
|
17
|
+
* Ambient collaborators; uses `subprocess.spawn` and `fs`.
|
|
18
|
+
*/
|
|
19
|
+
constructor({ runtime }) {
|
|
20
|
+
if (!runtime) throw new Error("runtime is required");
|
|
21
|
+
this.runtime = runtime;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {import("./task-family.js").TaskFamily} family
|
|
26
|
+
* @param {string} stagingDir - The staging directory (created by ApmInstaller).
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
async install(family, stagingDir) {
|
|
30
|
+
const fs = this.runtime.fs;
|
|
31
|
+
const pkgJson = join(family.rootPath, "package.json");
|
|
32
|
+
const hasPkg = await fs
|
|
33
|
+
.access(pkgJson)
|
|
34
|
+
.then(() => true)
|
|
35
|
+
.catch(() => false);
|
|
36
|
+
if (!hasPkg) return;
|
|
37
|
+
|
|
38
|
+
await this.#runBunInstall(family.rootPath);
|
|
39
|
+
|
|
40
|
+
const sourceModules = join(family.rootPath, "node_modules");
|
|
41
|
+
try {
|
|
42
|
+
await fs.access(sourceModules);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`bun install did not produce node_modules/ at ${sourceModules}; check the family's package.json`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await fs.cp(sourceModules, join(stagingDir, "node_modules"), {
|
|
50
|
+
recursive: true,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async #runBunInstall(cwd) {
|
|
55
|
+
const child = this.runtime.subprocess.spawn("bun", ["install"], {
|
|
56
|
+
cwd,
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
58
|
+
});
|
|
59
|
+
let stderr = "";
|
|
60
|
+
const drainStdout = (async () => {
|
|
61
|
+
for await (const _chunk of child.stdout) {
|
|
62
|
+
// discard
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
for await (const chunk of child.stderr) stderr += chunk.toString();
|
|
66
|
+
await drainStdout;
|
|
67
|
+
const code = await child.exitCode;
|
|
68
|
+
if (code !== 0) {
|
|
69
|
+
throw new Error(`bun install exited ${code}: ${stderr}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Factory function — wires real dependencies. */
|
|
75
|
+
export function createNpmInstaller(deps) {
|
|
76
|
+
return new NpmInstaller(deps);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Free-function shorthand for callers that thread a runtime bag.
|
|
81
|
+
* @param {import("./task-family.js").TaskFamily} family
|
|
82
|
+
* @param {string} stagingDir
|
|
83
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
84
|
+
*/
|
|
85
|
+
export function installNpm(family, stagingDir, runtime) {
|
|
86
|
+
return new NpmInstaller({ runtime }).install(family, stagingDir);
|
|
87
|
+
}
|