bareloop 0.20.0 → 0.21.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/src/bundle.js ADDED
@@ -0,0 +1,719 @@
1
+ // Export M1 — `docs/product/EXPORT-BUILD.md` (frozen 2026-09-05). A BUNDLE is a
2
+ // directory an importer can `npm install` and run without this repo: the signed
3
+ // job spec (close paths rewritten to `$BARELOOP_BUNDLE/close/<script>`), the
4
+ // close scripts it runs, and the job's whole registry history (bridges). This
5
+ // module is the pure, testable half — it writes/reads the directory and its
6
+ // signature; nothing here talks to a provider or runs a close.
7
+ //
8
+ // N4 (the spec's negative POC, load-bearing): the manifest's `bundleHash`
9
+ // verify is not a courtesy. `jobSpecHash` covers `close[].cmd` as a PATH, not
10
+ // the script's bytes, so a swapped close script leaves the spec's own
11
+ // signature intact and the close-first precheck would read the fake result as
12
+ // an early green. `readBundle`'s `bundle-tampered` red is therefore the ONLY
13
+ // thing standing between a swapped script and a fake green, and every caller
14
+ // (M2's runner) must run it before anything else touches the bundle.
15
+ //
16
+ // Bridges and history are OUTSIDE the bundle hash on purpose (they are
17
+ // records, not the arbiter) — only `spec.json` and `close/*` are signed.
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { createRequire } from 'node:module';
21
+ import {
22
+ existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, appendFileSync,
23
+ } from 'node:fs';
24
+ import { basename, isAbsolute, join, resolve } from 'node:path';
25
+ import { isObj, isNonEmptyString } from './validate.js';
26
+ import { jobSpecHash } from './job.js';
27
+ import { loadRegistry } from './bridges.js';
28
+ import { closeStagesOf } from './plan.js';
29
+ import { shapeForkName } from './reuse.js';
30
+ // The live export list IS `src/index.js`'s own `Object.keys` — never a second,
31
+ // hand-kept copy that can drift from the real surface a close script can
32
+ // legally import. This module is itself exported from `index.js` (a cycle by
33
+ // design): the binding is only READ inside function bodies, at call time,
34
+ // long after both modules have finished evaluating, so the cycle is safe.
35
+ import * as bareloopExports from './index.js';
36
+
37
+ /** @typedef {{code: string, path: string, detail?: string}} Red */
38
+
39
+ const BUNDLE_SCHEMA = 'bundle-v1';
40
+ const BUNDLE_TOKEN = '$BARELOOP_BUNDLE';
41
+
42
+ /** @param {string} p @returns {boolean} */
43
+ function isDir(p) {
44
+ try { return statSync(p).isDirectory(); } catch { return false; }
45
+ }
46
+
47
+ /** @param {Buffer|string} content @returns {string} sha256 hex */
48
+ function sha256(content) {
49
+ return createHash('sha256').update(content).digest('hex');
50
+ }
51
+
52
+ /**
53
+ * The ONE bundle-hash formula: sha256 over the sorted `path:contentSha256`
54
+ * lines of a files map. `bundleHash(dir)` and `exportBundle` both go through
55
+ * this so minting and re-verifying can never drift into two spellings.
56
+ * @param {Record<string, string>} files path -> sha256 hex
57
+ * @returns {string} sha256 hex
58
+ */
59
+ function hashFilesMap(files) {
60
+ const lines = Object.keys(files).sort().map((p) => `${p}:${files[p]}`);
61
+ return sha256(lines.join('\n'));
62
+ }
63
+
64
+ /**
65
+ * Read `spec.json` and every `close/*` file off disk and hash them the same
66
+ * way `exportBundle` did at mint time. Never throws: a missing file (a
67
+ * deleted close script, a missing spec.json) is reported as absent so a
68
+ * caller comparing against a stored hash reads it as tampered rather than
69
+ * crashing.
70
+ * @param {string} dir bundle directory
71
+ * @returns {{ files: Record<string, string>, missing: string[] }}
72
+ */
73
+ function collectHashedFiles(dir) {
74
+ /** @type {Record<string, string>} */
75
+ const files = {};
76
+ /** @type {string[]} */
77
+ const missing = [];
78
+ const specPath = join(dir, 'spec.json');
79
+ if (existsSync(specPath)) files['spec.json'] = sha256(readFileSync(specPath));
80
+ else missing.push('spec.json');
81
+ const closeDir = join(dir, 'close');
82
+ if (isDir(closeDir)) {
83
+ for (const f of readdirSync(closeDir).sort()) {
84
+ files[`close/${f}`] = sha256(readFileSync(join(closeDir, f)));
85
+ }
86
+ } else missing.push('close/');
87
+ return { files, missing };
88
+ }
89
+
90
+ /**
91
+ * Recompute the bundle hash from what is ACTUALLY on disk right now (never
92
+ * trusts the stored manifest). Throws only if `dir` itself does not exist —
93
+ * every other absence (a missing spec.json, an empty close/) folds into the
94
+ * hash as an omitted file, which is exactly what makes a swapped/deleted
95
+ * script change the hash.
96
+ * @param {string} dir bundle directory
97
+ * @returns {string} sha256 hex
98
+ */
99
+ export function bundleHash(dir) {
100
+ if (!isDir(dir)) throw new Error(`bundleHash: not a directory: ${dir}`);
101
+ const { files } = collectHashedFiles(dir);
102
+ return hashFilesMap(files);
103
+ }
104
+
105
+ /**
106
+ * The pattern a close script's import line must match to be rewritten/checked:
107
+ * a relative specifier that reaches into this repo's `src/` (`../src/x.js`,
108
+ * `./src/x.js`, `../../src/x.js`, …). A script importing `bareloop` itself
109
+ * (the installed package, post-rewrite) matches on the literal name instead.
110
+ * @param {string} spec the quoted module specifier
111
+ * @returns {boolean}
112
+ */
113
+ function isBareloopSrcImport(spec) {
114
+ return spec === 'bareloop' || /^(\.\.?\/)+src\//.test(spec);
115
+ }
116
+
117
+ // Every STATIC import statement, clause captured whole (undefined when there
118
+ // is no `from` — a bare side-effect `import 'x'`). Deliberately permissive:
119
+ // the shape check below is what decides legality, not this regex.
120
+ const STATIC_IMPORT_RE = /import\s+(?:([^'";]*?)\s+from\s+)?(['"])([^'"]+)\2/g;
121
+ // Dynamic `import('x')` (and `await import('x')`, `import ('x')`, …) — no
122
+ // clause exists for this form at all, so it is ALWAYS unparsed when it
123
+ // targets something this bundle would otherwise have to check.
124
+ const DYNAMIC_IMPORT_RE = /import\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
125
+
126
+ /** @param {string} spec @returns {boolean} */
127
+ function isRelative(spec) {
128
+ return spec.startsWith('./') || spec.startsWith('../');
129
+ }
130
+
131
+ // Every quoted string literal (single/double/backtick). Backtick literals
132
+ // carrying `${…}` interpolation are filtered out below — they are not a
133
+ // fixed literal, so they cannot be "a path baked into the script" at all.
134
+ const STRING_LITERAL_RE = /'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"|`([^`\\]*(?:\\.[^`\\]*)*)`/g;
135
+
136
+ // A close script is entitled to name a real system path (`/usr/bin/env`,
137
+ // `/etc/hosts`, …) without that being the F8/F129 hazard — only a path this
138
+ // bundle would actually relocate (a patient checkout, a scratch dir) is.
139
+ const SYSTEM_PATH_PREFIXES = ['/usr/', '/bin/', '/sbin/', '/lib/', '/lib64/', '/dev/', '/etc/', '/proc/', '/sys/', '/opt/'];
140
+
141
+ /**
142
+ * F8/F129 — a close judges the cwd the runner gives it, never a path baked
143
+ * into the script itself (`src/ralph.js`'s `cwd` is load-bearing precisely
144
+ * because every close script in this repo's OWN test suite named an
145
+ * absolute path, so cwd never mattered there — see F8). This scans every
146
+ * quoted string literal in a close script's source for an absolute POSIX
147
+ * path (`/…`) that EXISTS on the exporting machine right now and is not
148
+ * under an allow-listed system prefix.
149
+ *
150
+ * Deliberately monotone and simple, not clever, with named limits: a
151
+ * nonexistent absolute-looking string is treated as a NAME, not a path —
152
+ * `existsSync` is the only oracle this has for "load-bearing", so it is not
153
+ * flagged; a path built by string concatenation (never one whole literal)
154
+ * is invisible to this scan; and a literal that only exists inside a `//` or
155
+ * `/* *\/` comment is naturally excluded because a comment carries no quote
156
+ * characters of its own around the path text.
157
+ * @param {string} source close script text
158
+ * @returns {string[]} the offending literal path values, in source order
159
+ */
160
+ function absolutePathLiteralsOf(source) {
161
+ /** @type {string[]} */
162
+ const hits = [];
163
+ for (const m of source.matchAll(STRING_LITERAL_RE)) {
164
+ const isBacktick = m[3] !== undefined;
165
+ const value = m[1] ?? m[2] ?? m[3];
166
+ if (value === undefined) continue;
167
+ if (isBacktick && value.includes('${')) continue; // interpolated, not a fixed literal
168
+ if (!value.startsWith('/')) continue;
169
+ if (SYSTEM_PATH_PREFIXES.some((p) => value.startsWith(p))) continue;
170
+ if (!existsSync(value)) continue; // a nonexistent path is a name, not a hazard
171
+ hits.push(value);
172
+ }
173
+ return hits;
174
+ }
175
+
176
+ /**
177
+ * Every import in a close script this bundle has an opinion about: a
178
+ * relative import (src-bound or not — a sibling file is exactly the case
179
+ * that must be caught) and the bare `'bareloop'` package specifier. A
180
+ * node:/third-party specifier is filtered out here and never reaches the
181
+ * legality check — this module has nothing to say about those.
182
+ * @param {string} source close script text
183
+ * @returns {{ raw: string, spec: string, clause: string|null, dynamic: boolean }[]}
184
+ */
185
+ function relevantImportsOf(source) {
186
+ /** @type {{ raw: string, spec: string, clause: string|null, dynamic: boolean }[]} */
187
+ const out = [];
188
+ for (const m of source.matchAll(STATIC_IMPORT_RE)) {
189
+ const [raw, clause, , spec] = m;
190
+ if (!isRelative(spec) && spec !== 'bareloop') continue;
191
+ out.push({ raw: raw.trim(), spec, clause: clause === undefined ? null : clause.trim(), dynamic: false });
192
+ }
193
+ for (const m of source.matchAll(DYNAMIC_IMPORT_RE)) {
194
+ const [raw, , spec] = m;
195
+ if (!isRelative(spec) && spec !== 'bareloop') continue;
196
+ out.push({ raw: raw.trim(), spec, clause: null, dynamic: true });
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * The ONLY import shape this module can verify: a plain `{ a, b as c }`
203
+ * named clause. Anything else — a default import, `* as ns`, a mix of
204
+ * default+named/namespace, a bare side-effect import, or (by construction,
205
+ * since it carries no clause at all) a dynamic `import()` — is UNPARSED, not
206
+ * silently accepted: a legality check that skips what it cannot read is a
207
+ * blind instrument, and blind is not the same as clean.
208
+ * @param {string|null} clause @param {boolean} dynamic
209
+ * @returns {boolean}
210
+ */
211
+ function isPlainNamedClause(clause, dynamic) {
212
+ return !dynamic && clause !== null && /^\{[^{}]*\}$/.test(clause);
213
+ }
214
+
215
+ /**
216
+ * Rewrite every `from '<relative-into-src>'` specifier in a close script's
217
+ * source to `from 'bareloop'` — the one substitution the spec names (N-facts,
218
+ * "close scripts copied verbatim except …"). Only the specifier moves; the
219
+ * imported names are untouched, so a script importing `JUDGED_MARKER` from
220
+ * `../src/kinds.js` imports it from `bareloop` instead, byte-identical
221
+ * otherwise.
222
+ * @param {string} source
223
+ * @returns {string}
224
+ */
225
+ function rewriteSrcImports(source) {
226
+ return source.replace(/from\s+(['"])(\.\.?\/)+src\/[^'"]*\1/g, "from 'bareloop'");
227
+ }
228
+
229
+ /**
230
+ * Parse one `close[].cmd` into its no-shell argv (the runner spawns it
231
+ * without a shell — `src/ralph.js`, whitespace split). Refuses anything that
232
+ * is not exactly `node <absolute path ending .mjs> <args…>` — the one shape
233
+ * this bundle can relocate, because it is the one shape whose first argv
234
+ * token IS a path this module controls.
235
+ * @param {string} cmd
236
+ * @returns {{ ok: true, scriptPath: string, rest: string[] } | { ok: false }}
237
+ */
238
+ function parseNodeCmd(cmd) {
239
+ if (!isNonEmptyString(cmd)) return { ok: false };
240
+ const parts = cmd.trim().split(/\s+/);
241
+ if (parts.length < 2 || parts[0] !== 'node') return { ok: false };
242
+ const scriptPath = parts[1];
243
+ if (!isAbsolute(scriptPath) || !scriptPath.endsWith('.mjs')) return { ok: false };
244
+ return { ok: true, scriptPath, rest: parts.slice(2) };
245
+ }
246
+
247
+ /**
248
+ * EXPORT — mints a bundle directory from a signed job spec (`docs/product/
249
+ * EXPORT-BUILD.md`, "The bundle"). Every check below runs BEFORE anything
250
+ * touches disk (typed reds, nothing written): a partially-written bundle
251
+ * would be worse than no bundle, since `readBundle`'s tamper check has
252
+ * nothing trustworthy to compare against yet.
253
+ *
254
+ * Refusals, each a distinct red code:
255
+ * - `no-bridge-at-hash` — neither of the job's two possible bridge names —
256
+ * `spec.job` itself, or its SHAPE FORK `shapeForkName(spec.job,
257
+ * closeStagesOf(spec))` (the name `writeGreenRow`/`writeRunGreenRow` in
258
+ * `src/reuse.js` mint whenever a run's close-stage set differs from the
259
+ * base bridge's `closeStageNames` — same derivation, imported rather than
260
+ * re-spelled, so the two names cannot drift) — carries a recorded specHash
261
+ * (bridge-level or on some version) equal to `jobSpecHash(spec)`: nothing
262
+ * has ever greened THIS exact spec, so there is nothing proven to ship.
263
+ * Every candidate bridge that DOES exist (base and/or fork) still ships
264
+ * into `bridges/` — the spec says "the job's WHOLE registry history
265
+ * (greens and reds)", not just the one that matched.
266
+ * - `close-cmd-unrelocatable` — a `close[].cmd` is not `node <abs .mjs> …`.
267
+ * - `close-script-missing` — a cmd names a script `closeScripts` does not
268
+ * carry the source for.
269
+ * - `close-script-collision` — two different script paths share a basename;
270
+ * copying both into one flat `close/` directory would silently clobber one.
271
+ * - `close-import-unexported` — a close script imports a name from
272
+ * bareloop's own src that `src/index.js` does not export.
273
+ * - `close-import-unparsed` — a close script's import is a shape this
274
+ * module cannot verify at all (default import, `* as ns`, mixed
275
+ * default+named/namespace, a bare side-effect import, or a dynamic
276
+ * `import()`), or is a relative import that does not point into
277
+ * `src/` (a sibling file `close/` never ships, so it would be missing
278
+ * from the bundle). Fails safe rather than silently accepting what it
279
+ * cannot read.
280
+ * - `close-absolute-path` — a close script's source bakes in a string
281
+ * literal that is an absolute POSIX path which exists on the exporting
282
+ * machine and is outside the system-prefix allow-list (see
283
+ * `absolutePathLiteralsOf`): a close judges the cwd the runner gives it,
284
+ * never a path baked into the script — F8/F129, the live defect this
285
+ * guard would have refused at export time before any run.
286
+ * - `outdir-not-empty` — `outDir` exists and already holds files.
287
+ * - `registry-unreadable` — `registryDir` itself could not be read
288
+ * (bubbled from `loadRegistry`).
289
+ * @param {{ spec: any, closeScripts: Record<string, string>, registryDir: string, outDir: string, bareloopVersion: string }} o
290
+ * @returns {{ ok: boolean, reds: Red[], dir: string|null, bundleHash: string|null, manifest: any|null }}
291
+ */
292
+ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloopVersion }) {
293
+ /** @type {Red[]} */
294
+ const reds = [];
295
+ const red = (/** @type {string} */ code, /** @type {string} */ path, /** @type {string} */ detail) => reds.push({ code, path, detail });
296
+ const fail = () => ({ ok: false, reds, dir: null, bundleHash: null, manifest: null });
297
+
298
+ if (!isObj(spec) || !isNonEmptyString(spec.job)) {
299
+ red('invalid-value', 'spec.job', 'a job spec with a job slug is required');
300
+ return fail();
301
+ }
302
+ if (!Array.isArray(spec.close) || spec.close.length === 0) {
303
+ red('invalid-value', 'spec.close', 'a non-empty ordered close stage list is required to export');
304
+ return fail();
305
+ }
306
+ const scripts = isObj(closeScripts) ? closeScripts : {};
307
+
308
+ // outDir: refuse a non-empty destination before any other check runs, since
309
+ // this is the cheapest way to fail and the one most likely to be a mistake
310
+ // (re-exporting into a directory that already holds a prior bundle/blessing).
311
+ if (existsSync(outDir)) {
312
+ if (!isDir(outDir)) { red('invalid-value', 'outDir', 'exists and is not a directory'); return fail(); }
313
+ if (readdirSync(outDir).length > 0) { red('outdir-not-empty', outDir, 'exportBundle never merges into an existing directory'); return fail(); }
314
+ }
315
+
316
+ // registry: the job must have a bridge minted at THIS exact spec's hash.
317
+ const reg = loadRegistry(registryDir);
318
+ if (!reg.ok && reg.bridges.length === 0) {
319
+ red('registry-unreadable', registryDir, reg.reds.map((r) => `${r.code}:${r.path}`).join(', ') || 'the registry could not be read');
320
+ return fail();
321
+ }
322
+ const currentHash = jobSpecHash(spec);
323
+ // Two candidate bridge names for this job: its own name, and the SHAPE FORK
324
+ // `writeGreenRow` mints when a run's close-stage set differs from the base
325
+ // bridge's stored `closeStageNames` (src/reuse.js `shapeForkName`). The
326
+ // stage names are derived through the same `closeStagesOf` the writer uses,
327
+ // never re-spelled, so the fork name this reads can never drift from the
328
+ // fork name that gets minted.
329
+ const stageNames = (closeStagesOf(spec) ?? []).map((/** @type {any} */ s) => s.name);
330
+ const forkName = shapeForkName(spec.job, stageNames);
331
+ const candidateNames = new Set([spec.job, forkName]);
332
+ const candidateBridges = reg.bridges.filter((/** @type {any} */ b) => candidateNames.has(b.name));
333
+ const hashMatches = candidateBridges.some((/** @type {any} */ b) => (
334
+ b.specHash === currentHash
335
+ || (Array.isArray(b.versions) && b.versions.some((/** @type {any} */ v) => v.specHash === currentHash))
336
+ ));
337
+ if (!hashMatches) {
338
+ red('no-bridge-at-hash', 'spec', `neither "${spec.job}" nor its shape fork "${forkName}" in ${registryDir} carries a green at this spec's current hash (${currentHash.slice(0, 12)}…) — export ships a run that actually happened, never an unproven draft`);
339
+ }
340
+
341
+ // close[].cmd shape + import legality, over EVERY stage before writing any of them.
342
+ const exportNames = new Set(Object.keys(bareloopExports));
343
+ /** @type {Map<string, string>} scriptPath -> basename, dedup'd */
344
+ const byPath = new Map();
345
+ /** @type {Map<string, string>} basename -> scriptPath (collision detection) */
346
+ const byName = new Map();
347
+ spec.close.forEach((/** @type {any} */ stage, /** @type {number} */ i) => {
348
+ const at = `close.${i}.cmd`;
349
+ const parsed = parseNodeCmd(isObj(stage) ? stage.cmd : undefined);
350
+ if (!parsed.ok) { red('close-cmd-unrelocatable', at, `must be "node <absolute path>.mjs …" — got ${JSON.stringify(isObj(stage) ? stage.cmd : stage)}`); return; }
351
+ const { scriptPath } = parsed;
352
+ if (byPath.has(scriptPath)) return; // already checked this path from an earlier stage
353
+ const name = basename(scriptPath);
354
+ const collidesWith = byName.get(name);
355
+ if (collidesWith && collidesWith !== scriptPath) {
356
+ red('close-script-collision', at, `"${name}" is already claimed by ${collidesWith} — two different close scripts cannot share one basename in a flat close/ directory`);
357
+ return;
358
+ }
359
+ byName.set(name, scriptPath);
360
+ byPath.set(scriptPath, name);
361
+ const source = scripts[scriptPath];
362
+ if (!isNonEmptyString(source)) { red('close-script-missing', at, `closeScripts has no source for ${scriptPath}`); return; }
363
+ for (const imp of relevantImportsOf(source)) {
364
+ if (!isPlainNamedClause(imp.clause, imp.dynamic) || (isRelative(imp.spec) && !isBareloopSrcImport(imp.spec))) {
365
+ red('close-import-unparsed', at, `${basename(scriptPath)}: "${imp.raw}" is not a plain named import this bundle can verify — a default/namespace/side-effect/dynamic import, or a relative import outside src/ (close/ ships only the named script, so a sibling file would be missing from the bundle)`);
366
+ continue;
367
+ }
368
+ // isPlainNamedClause guarantees clause is a non-null `{ ... }` string here
369
+ const names = /** @type {string} */ (imp.clause).slice(1, -1).split(',')
370
+ .map((raw) => raw.trim().split(/\s+as\s+/)[0].trim())
371
+ .filter(Boolean);
372
+ for (const name of names) {
373
+ if (!exportNames.has(name)) {
374
+ red('close-import-unexported', at, `${basename(scriptPath)} imports "${name}" from bareloop's src, which src/index.js does not export`);
375
+ }
376
+ }
377
+ }
378
+ for (const lit of absolutePathLiteralsOf(source)) {
379
+ red('close-absolute-path', at, `${basename(scriptPath)}: bakes in the absolute path "${lit}" — a close judges the cwd the runner gives it, never a path baked into the script (F8/F129)`);
380
+ }
381
+ });
382
+
383
+ if (reds.length > 0) return fail();
384
+
385
+ // ── everything validated — now write, in one pass ──────────────────────
386
+ mkdirSync(outDir, { recursive: true });
387
+ const closeDir = join(outDir, 'close');
388
+ mkdirSync(closeDir, { recursive: true });
389
+ const bridgesDir = join(outDir, 'bridges');
390
+ mkdirSync(bridgesDir, { recursive: true });
391
+
392
+ /** @type {Record<string, string>} */
393
+ const files = {};
394
+ for (const [scriptPath, name] of byPath) {
395
+ const rewritten = rewriteSrcImports(scripts[scriptPath]);
396
+ writeFileSync(join(closeDir, name), rewritten);
397
+ files[`close/${name}`] = sha256(rewritten);
398
+ }
399
+
400
+ const specOut = { ...spec, close: spec.close.map((/** @type {any} */ stage) => {
401
+ const parsed = parseNodeCmd(stage.cmd);
402
+ // unreachable: every stage's cmd already passed this same parse above, or
403
+ // the function returned before reaching this write pass at all
404
+ if (!parsed.ok) throw new Error(`unreachable: ${stage.cmd} was validated as a relocatable close cmd`);
405
+ const name = byPath.get(parsed.scriptPath);
406
+ const cmd = ['node', `${BUNDLE_TOKEN}/close/${name}`, ...parsed.rest].join(' ');
407
+ return { ...stage, cmd };
408
+ }) };
409
+ const specText = `${JSON.stringify(specOut, null, 2)}\n`;
410
+ writeFileSync(join(outDir, 'spec.json'), specText);
411
+ files['spec.json'] = sha256(specText);
412
+
413
+ // Bundle EVERY candidate bridge that exists (base and/or shape fork) — the
414
+ // spec says "the job's whole registry history (greens and reds)", not just
415
+ // whichever one happened to carry the matching hash.
416
+ for (const b of candidateBridges) {
417
+ writeFileSync(join(bridgesDir, `${b.name}.json`), `${JSON.stringify(b, null, 2)}\n`);
418
+ }
419
+
420
+ const hash = hashFilesMap(files);
421
+ const manifest = {
422
+ schema: BUNDLE_SCHEMA,
423
+ job: spec.job,
424
+ exportedAt: new Date().toISOString(),
425
+ bareloopVersion,
426
+ files,
427
+ bundleHash: hash,
428
+ };
429
+ writeFileSync(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
430
+
431
+ // `package.json` — required by the frozen spec's bundle table so `npm install
432
+ // <bundle dir>` (validation step 2) works in a clean consumer. Deliberately
433
+ // OUTSIDE `bundleHash`: the hash covers spec.json + close/* only (per spec),
434
+ // so this file is written after `hashFilesMap(files)` above and never folded
435
+ // into `files`/`manifest.files`.
436
+ const npmName = `${spec.job}.bareloop`.toLowerCase().replace(/[^a-z0-9._-]/g, '-');
437
+ const packageJson = {
438
+ name: npmName,
439
+ version: '1.0.0',
440
+ private: true,
441
+ type: 'module',
442
+ dependencies: { bareloop: `^${bareloopVersion}` },
443
+ bareloop: { manifest: 'manifest.json' },
444
+ };
445
+ writeFileSync(join(outDir, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`);
446
+
447
+ const readme = [
448
+ `# ${spec.job}`,
449
+ '',
450
+ spec.goal ? spec.goal : '',
451
+ '',
452
+ `bundleHash: ${hash}`,
453
+ `exported: ${manifest.exportedAt}`,
454
+ `bareloop: ${bareloopVersion}`,
455
+ '',
456
+ '## Install this bundle\'s own dependency first (F128)',
457
+ '',
458
+ ' cd <this directory> && npm install',
459
+ '',
460
+ 'This bundle\'s own package.json declares "bareloop" as its dependency — its close',
461
+ 'scripts import from it at run time. `npm install <this directory>` from SOMEWHERE',
462
+ 'ELSE installs this bundle AS that other project\'s dependency instead, which never',
463
+ 'installs this bundle\'s own node_modules and crashes every close stage. Run npm',
464
+ 'install INSIDE this directory, then use the bareloop binary it just installed:',
465
+ '',
466
+ '## Running this bundle',
467
+ '',
468
+ ' ./node_modules/.bin/bareloop run . --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]',
469
+ '',
470
+ 'Answer these, in the order `bareloop run` asks them:',
471
+ '',
472
+ '1. Provider key — set ANTHROPIC_API_KEY in your environment (never on the',
473
+ ' command line, never in a file). Absent, `bareloop run` prints these',
474
+ ' questions and the bundleHash again, and spends nothing.',
475
+ `2. Approve — the FIRST run on this machine requires --approve ${hash}`,
476
+ " (this bundle's own signed hash, shown above) to prove a human read and",
477
+ ' accepted it before anything runs. Once that first run GREENS, the',
478
+ ' bundle is blessed and later runs need no re-approval ("no-resign") as',
479
+ ' long as it stays unchanged.',
480
+ '3. Repo — --repo <path> must be a git repository with at least one commit.',
481
+ ' Every run creates a FRESH detached worktree under',
482
+ ' <repo>/.bareloop/wt/<runid> and never reuses or deletes one for you;',
483
+ ' your checkout is never touched.',
484
+ '4. Budget/wall (optional) — --budget <usd> and --wall <minutes> may only',
485
+ ` TIGHTEN this bundle's own signed ceiling${spec.maxWallMs != null ? 's' : ''} (budgetUsd ${spec.budgetUsd ?? '?'}`
486
+ + `${spec.maxWallMs != null ? `, maxWallMs ${spec.maxWallMs}` : ''}); raising either is a`,
487
+ ' re-export, never a runner flag.',
488
+ '',
489
+ ].join('\n');
490
+ writeFileSync(join(outDir, 'README.md'), readme);
491
+
492
+ return { ok: true, reds: [], dir: outDir, bundleHash: hash, manifest };
493
+ }
494
+
495
+ /** @param {string} file @returns {{ ok: true, value: any } | { ok: false, error: string }} */
496
+ function readJsonFile(file) {
497
+ let text;
498
+ try { text = readFileSync(file, 'utf8'); } catch (err) {
499
+ return { ok: false, error: String(/** @type {Error} */ (err).message) };
500
+ }
501
+ try { return { ok: true, value: JSON.parse(text) }; } catch (err) {
502
+ return { ok: false, error: String(/** @type {Error} */ (err).message) };
503
+ }
504
+ }
505
+
506
+ /**
507
+ * READ a bundle directory back: the manifest/spec, tamper-checked against
508
+ * what is actually on disk, plus the record-only bridges/blessing/history a
509
+ * runner needs beside them. Never throws — every failure is a typed red.
510
+ *
511
+ * `bundle-tampered` (N4) is a RED, never a warning, and runs whenever both a
512
+ * manifest and a spec could be read at all — a swapped close script must be
513
+ * caught here, before any caller reaches `runJob`.
514
+ * @param {string} dir bundle directory
515
+ * @returns {{ ok: boolean, reds: Red[], spec: any, manifest: any, bridges: any[], blessing: any, history: any[] }}
516
+ */
517
+ export function readBundle(dir) {
518
+ /** @type {Red[]} */
519
+ const reds = [];
520
+ const red = (/** @type {string} */ code, /** @type {string} */ path, /** @type {string} */ detail) => reds.push({ code, path, detail });
521
+
522
+ if (!isDir(dir)) {
523
+ red('bundle-missing', dir, 'not a directory');
524
+ return { ok: false, reds, spec: null, manifest: null, bridges: [], blessing: null, history: [] };
525
+ }
526
+
527
+ const manifestR = readJsonFile(join(dir, 'manifest.json'));
528
+ if (!manifestR.ok) red('manifest-invalid', 'manifest.json', manifestR.error);
529
+ const specR = readJsonFile(join(dir, 'spec.json'));
530
+ if (!specR.ok) red('spec-invalid', 'spec.json', specR.error);
531
+
532
+ if (manifestR.ok && specR.ok) {
533
+ let recomputed = null;
534
+ try { recomputed = bundleHash(dir); } catch { recomputed = null; }
535
+ if (recomputed === null || recomputed !== manifestR.value.bundleHash) {
536
+ red('bundle-tampered', 'manifest.bundleHash', `stored ${manifestR.value.bundleHash ?? '<none>'} vs recomputed ${recomputed ?? '<unreadable>'}`);
537
+ }
538
+ }
539
+
540
+ /** @type {any[]} */
541
+ const bridges = [];
542
+ const bridgesDir = join(dir, 'bridges');
543
+ if (isDir(bridgesDir)) {
544
+ for (const f of readdirSync(bridgesDir).filter((n) => n.endsWith('.json')).sort()) {
545
+ const r = readJsonFile(join(bridgesDir, f));
546
+ if (r.ok) bridges.push(r.value);
547
+ }
548
+ }
549
+
550
+ const blessingR = readJsonFile(join(dir, 'blessing.json'));
551
+
552
+ /** @type {any[]} */
553
+ const history = [];
554
+ const historyFile = join(dir, 'history.jsonl');
555
+ if (existsSync(historyFile)) {
556
+ for (const line of readFileSync(historyFile, 'utf8').split('\n')) {
557
+ if (!line.trim()) continue;
558
+ try { history.push(JSON.parse(line)); } catch { /* a malformed line is skipped, not fatal to the read */ }
559
+ }
560
+ }
561
+
562
+ return {
563
+ ok: reds.length === 0,
564
+ reds,
565
+ spec: specR.ok ? specR.value : null,
566
+ manifest: manifestR.ok ? manifestR.value : null,
567
+ bridges,
568
+ blessing: blessingR.ok ? blessingR.value : null,
569
+ history,
570
+ };
571
+ }
572
+
573
+ /**
574
+ * The IN-MEMORY `$BARELOOP_BUNDLE` substitution (the frozen spec's "two
575
+ * facts": the runner never touches the spec on disk, it substitutes the
576
+ * absolute bundle directory into a copy before `runJob`, and signs THAT
577
+ * substituted spec's hash — not the on-disk one). `approveHash` is the value
578
+ * an `--approve` flag / a `history.jsonl` row must record.
579
+ * @param {{ spec: any }} bundle a `readBundle` result (or anything carrying `.spec`)
580
+ * @param {string} bundleDir absolute or relative bundle directory
581
+ * @returns {{ spec: any, approveHash: string }}
582
+ */
583
+ export function resolveBundleSpec(bundle, bundleDir) {
584
+ const absDir = resolve(bundleDir);
585
+ const spec = JSON.parse(JSON.stringify(bundle.spec));
586
+ if (Array.isArray(spec.close)) {
587
+ spec.close = spec.close.map((/** @type {any} */ stage) => (
588
+ isObj(stage) && typeof stage.cmd === 'string'
589
+ ? { ...stage, cmd: stage.cmd.split(BUNDLE_TOKEN).join(absDir) }
590
+ : stage
591
+ ));
592
+ }
593
+ return { spec, approveHash: jobSpecHash(spec) };
594
+ }
595
+
596
+ /**
597
+ * TIGHTEN-ONLY envelope check (hamr Q8: raising the ceiling is a re-export,
598
+ * never a runner flag). Each GIVEN number must be ≤ the bundle spec's own —
599
+ * an absent field in the envelope trivially passes (nothing was asked to
600
+ * tighten), and a spec with no ceiling of its own for that field (only
601
+ * `maxWallMs` is ever absent — `budgetUsd` is required) has nothing to
602
+ * widen, so a given number passes there too.
603
+ * @param {any} spec the (already `$BARELOOP_BUNDLE`-resolved) bundle spec
604
+ * @param {{ budgetUsd?: number, maxWallMs?: number }} envelope
605
+ * @returns {{ ok: boolean, reds: Red[] }}
606
+ */
607
+ export function checkEnvelope(spec, envelope = {}) {
608
+ /** @type {Red[]} */
609
+ const reds = [];
610
+ const s = isObj(spec) ? spec : {};
611
+ const e = isObj(envelope) ? envelope : {};
612
+ for (const field of /** @type {const} */ (['budgetUsd', 'maxWallMs'])) {
613
+ if (e[field] === undefined) continue;
614
+ if (typeof e[field] !== 'number' || !Number.isFinite(e[field]) || e[field] <= 0) {
615
+ reds.push({ code: 'invalid-value', path: field, detail: 'a positive number' });
616
+ continue;
617
+ }
618
+ if (typeof s[field] === 'number' && e[field] > s[field]) {
619
+ reds.push({ code: 'envelope-widen', path: field, detail: `${e[field]} exceeds the bundle's signed ${field} (${s[field]}) — raising a ceiling is a re-export, never a runner flag` });
620
+ }
621
+ }
622
+ return { ok: reds.length === 0, reds };
623
+ }
624
+
625
+ /**
626
+ * Write `blessing.json`: the FIRST GREEN RUN on the importer's own machine
627
+ * signs the bundle as trustworthy (hamr Q2), so later runs need no
628
+ * `--approve` (hamr: "no-resign"). Overwrites any prior blessing — there is
629
+ * only ever one, for the bundle's current `bundleHash`.
630
+ * @param {string} dir bundle directory
631
+ * @param {{ bundleHash: string, runid: string, outcome: string, host?: string, blessedAt?: string }} record
632
+ * @returns {any} the blessing object written
633
+ */
634
+ export function bless(dir, record) {
635
+ const o = isObj(record) ? record : {};
636
+ const blessing = {
637
+ bundleHash: o.bundleHash,
638
+ blessedAt: o.blessedAt ?? new Date().toISOString(),
639
+ runid: o.runid ?? null,
640
+ outcome: o.outcome ?? null,
641
+ host: o.host ?? null,
642
+ };
643
+ writeFileSync(join(dir, 'blessing.json'), `${JSON.stringify(blessing, null, 2)}\n`);
644
+ return blessing;
645
+ }
646
+
647
+ /**
648
+ * Read `blessing.json` back and judge it against the bundle's CURRENT
649
+ * manifest hash. Three outcomes, never conflated: no blessing at all
650
+ * (`unblessed: true` — the bundle has simply never greened here, not a red);
651
+ * a blessing whose hash no longer matches the manifest (`blessing-stale` —
652
+ * the bundle was re-exported/edited since the run that blessed it); a match.
653
+ * @param {{ manifest: any, blessing: any }} bundle a `readBundle` result
654
+ * @returns {{ ok: boolean, unblessed: boolean, reds: Red[] }}
655
+ */
656
+ export function verifyBlessing(bundle) {
657
+ const b = isObj(bundle) ? bundle : {};
658
+ if (!isObj(b.blessing)) return { ok: false, unblessed: true, reds: [] };
659
+ const manifestHash = isObj(b.manifest) ? b.manifest.bundleHash : undefined;
660
+ if (b.blessing.bundleHash !== manifestHash) {
661
+ return { ok: false, unblessed: false, reds: [{ code: 'blessing-stale', path: 'blessing.bundleHash', detail: `blessed ${b.blessing.bundleHash ?? '<none>'} vs manifest ${manifestHash ?? '<none>'} — re-export and re-approve` }] };
662
+ }
663
+ return { ok: true, unblessed: false, reds: [] };
664
+ }
665
+
666
+ /**
667
+ * F128 preflight — can this bundle's close scripts actually resolve `import
668
+ * ... from 'bareloop'` at runtime? The bundle's own `package.json` declares
669
+ * `bareloop` as ITS dependency (`npm install` must be run INSIDE the bundle
670
+ * directory — validation step 2 was corrected 2026-09-06, see the dated
671
+ * addendum in `docs/product/EXPORT-BUILD.md`); nothing installs it for the
672
+ * bundle as a side effect of the bundle being installed as someone else's
673
+ * dependency. Left unchecked, a missing `node_modules` crashes every close
674
+ * stage deep inside the close-first precheck with a bare
675
+ * `ERR_MODULE_NOT_FOUND`, which surfaces as a generic `close-red` with no
676
+ * cure line (the live defect this fixes) — this check runs first and fails
677
+ * fast with the exact command to run.
678
+ *
679
+ * Resolves the bare specifier `'bareloop'` exactly as a close script would,
680
+ * from inside the bundle's own `close/` directory (the probe path itself
681
+ * need not exist — `createRequire`/`resolve` only use it to anchor the
682
+ * `node_modules` walk). Going through `require.resolve` also walks
683
+ * `bareloop`'s own `exports` map (the package root, `"."` -> `./src/
684
+ * index.js`), so a bare successful resolve already proves a real,
685
+ * exports-map-shaped `bareloop` package is reachable from THIS bundle's own
686
+ * dependency chain — not some unrelated global install. Any resolution
687
+ * counts (a direct `node_modules/bareloop`, a symlink into it, or a parent
688
+ * `node_modules/bareloop` up the directory chain) — it only has to resolve.
689
+ * @param {string} bundleDir
690
+ * @returns {{ ok: boolean, reds: Red[] }}
691
+ */
692
+ export function checkBundleDeps(bundleDir) {
693
+ /** @type {Red[]} */
694
+ const reds = [];
695
+ const probeFrom = join(bundleDir, 'close', '__bundle_deps_probe__.mjs');
696
+ try {
697
+ createRequire(probeFrom).resolve('bareloop');
698
+ } catch {
699
+ reds.push({
700
+ code: 'bundle-deps-missing',
701
+ path: bundleDir,
702
+ detail: `close scripts cannot resolve their own "bareloop" dependency — run: cd ${bundleDir} && npm install`,
703
+ });
704
+ return { ok: false, reds };
705
+ }
706
+ return { ok: true, reds: [] };
707
+ }
708
+
709
+ /**
710
+ * Append one row to `history.jsonl` — one `bareloop run` on this machine per
711
+ * line. Same convention as `spine.js`/`ledger.js`: `appendFileSync` opens
712
+ * with O_APPEND, so one line under PIPE_BUF is written atomically with
713
+ * respect to other appenders; there is no read-modify-write here to race.
714
+ * @param {string} dir bundle directory
715
+ * @param {any} row
716
+ */
717
+ export function appendHistory(dir, row) {
718
+ appendFileSync(join(dir, 'history.jsonl'), `${JSON.stringify(row)}\n`);
719
+ }