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