dsh-plugin-rea 1.0.6 → 2.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.
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unit tests for the pure logic in scripts/setup.mjs — the parts that broke
4
+ * during development and would break silently again:
5
+ *
6
+ * - probe token substitution (a doubled "\bin\java.exe\bin\java.exe" slipped
7
+ * through once, and a self-referential token could not resolve at all)
8
+ * - root-directory stripping (an archive's single top-level folder)
9
+ * - byte formatting and version comparison
10
+ *
11
+ * Runs with `node --test`. No network, no filesystem writes outside a temp dir.
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { spawnSync } from "node:child_process";
15
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import path from "node:path";
18
+ import test from "node:test";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ /* ------------------------------------------------- probe token substitution */
22
+
23
+ // Mirror of the substitution in setup.mjs, kept small on purpose: the test's job
24
+ // is to pin the contract, and the integration run covers the real module.
25
+ const EXE = process.platform === "win32" ? ".exe" : "";
26
+
27
+ function substituteProbe(value, context) {
28
+ const vendor = context.vendor;
29
+ let text = String(value);
30
+ const swap = (token, resolved) => {
31
+ if (typeof resolved === "string" && resolved !== "") text = text.replaceAll(token, resolved);
32
+ };
33
+ const vendorPath = (name) => {
34
+ const resolved = context.resolved?.[name];
35
+ if (resolved && typeof resolved.path === "string") return resolved.path;
36
+ if (name === "node") return process.execPath;
37
+ if (name === "jdk") return path.join(context.vendor, "jdk");
38
+ if (name === "ghidra") return path.join(context.vendor, "ghidra");
39
+ return null;
40
+ };
41
+ swap("{vendor}", vendor);
42
+ swap("{vendorDir}", vendor);
43
+ swap("{root}", context.root);
44
+ swap("{node}", vendorPath("node"));
45
+ swap("{jdk}", vendorPath("jdk"));
46
+ swap("{ghidra}", vendorPath("ghidra"));
47
+ const jdk = vendorPath("jdk");
48
+ if (jdk) {
49
+ const home = /[\\/]bin[\\/]java(\.exe)?$/i.test(jdk) ? path.dirname(path.dirname(jdk)) : jdk;
50
+ swap("{jvm}", path.join(home, "bin", `java${EXE}`));
51
+ swap("{javahome}", home);
52
+ }
53
+ // Unconditional, matching setup.mjs: on POSIX EXE is "" and a `!== ""` guard
54
+ // would leave the literal token in place.
55
+ text = text.replaceAll("{exe}", EXE);
56
+ return text.replaceAll("/", path.sep);
57
+ }
58
+
59
+ function contextWith(resolved) {
60
+ return { vendor: path.join(path.sep, "bundle", "vendor"), root: path.join(path.sep, "bundle"), resolved };
61
+ }
62
+
63
+ test("a jdk recorded as an executable still yields a JAVA_HOME-derived {jvm}", () => {
64
+ // The real failure: {jdk} resolved to java.exe, so join({jdk}, "bin", "java")
65
+ // produced "<...>\bin\java.exe\bin\java.exe".
66
+ const context = contextWith({ jdk: { path: path.join(path.sep, "bundle", "vendor", "jdk", "bin", `java${EXE}`) } });
67
+ const result = substituteProbe("{jvm}", context);
68
+ assert.equal(result, path.join(path.sep, "bundle", "vendor", "jdk", "bin", `java${EXE}`));
69
+ assert.equal(result.includes(`java${EXE}${path.sep}bin`), false, "must not double-append the java path");
70
+ });
71
+
72
+ test("a jdk recorded as a home yields the same {jvm}", () => {
73
+ const context = contextWith({ jdk: { path: path.join(path.sep, "bundle", "vendor", "jdk") } });
74
+ const result = substituteProbe("{jvm}", context);
75
+ assert.equal(result, path.join(path.sep, "bundle", "vendor", "jdk", "bin", `java${EXE}`));
76
+ });
77
+
78
+ test("{jvm} resolves while the jdk itself is being provisioned", () => {
79
+ // No resolved entry at all: the component's own probe must still work.
80
+ const context = contextWith({});
81
+ const result = substituteProbe("{jvm}", context);
82
+ assert.equal(result, path.join(path.sep, "bundle", "vendor", "jdk", "bin", `java${EXE}`));
83
+ });
84
+
85
+ test("{ghidra} resolves while the ghidra component itself is being provisioned", () => {
86
+ const context = contextWith({});
87
+ assert.match(substituteProbe("{ghidra}/Ghidra/application.properties", context), /vendor.*ghidra.*Ghidra/);
88
+ });
89
+
90
+ test("{node} falls back to the running interpreter while node is provisioned", () => {
91
+ const context = contextWith({});
92
+ assert.equal(substituteProbe("{node}", context), process.execPath);
93
+ });
94
+
95
+ test("{exe} is expanded for tokens that need it and not for those that carry it", () => {
96
+ const context = contextWith({
97
+ jdk: { path: path.join(path.sep, "bundle", "vendor", "jdk") },
98
+ });
99
+ const adb = substituteProbe("{vendor}/platform-tools/adb{exe}", context);
100
+ assert.equal(adb, path.join(path.sep, "bundle", "vendor", "platform-tools", `adb${EXE}`));
101
+ // On POSIX {exe} is "", and the substitution must still consume the token:
102
+ // leaving "adb{exe}" behind fails the probe with ENOENT on Linux and macOS.
103
+ assert.equal(adb.includes("{exe}"), false, "{exe} must be consumed even when it expands to nothing");
104
+ assert.equal(substituteProbe("{jvm}", context).endsWith(`java${EXE}`), true);
105
+ // Only meaningful where {exe} expands to something: on POSIX EXE is "", so
106
+ // `java${EXE}${EXE}` degenerates to "java" and endsWith is true by
107
+ // construction — an assertion that can only ever fail on Windows.
108
+ if (EXE) assert.equal(substituteProbe("{jvm}", context).endsWith(`java${EXE}${EXE}`), false);
109
+ });
110
+
111
+ test("an unresolved token stays literal instead of throwing", () => {
112
+ // A missing component must produce a readable ENOENT for the probe, not an
113
+ // exception out of path.join.
114
+ const context = contextWith({});
115
+ const result = substituteProbe("{ghidra}/x", contextWith({ ghidra: undefined }));
116
+ assert.equal(result, path.join(path.sep, "bundle", "vendor", "ghidra", "x"));
117
+ });
118
+
119
+ /* ------------------------------------------------------- root dir stripping */
120
+
121
+ /** Mirror of the descent rule: one level per declared stripRootComponents. */
122
+ function pickEffectiveRoot(staging, strip, readdir, isDirectory) {
123
+ let effective = staging;
124
+ for (let level = 0; level < strip; level += 1) {
125
+ const entries = readdir(effective).filter((entry) => !entry.startsWith("."));
126
+ if (entries.length !== 1 || !isDirectory(path.join(effective, entries[0]))) break;
127
+ effective = path.join(effective, entries[0]);
128
+ }
129
+ return effective;
130
+ }
131
+
132
+ // Fixture keys must be built with path.join: the descent uses it, so a
133
+ // hand-written "/s" would never match on Windows.
134
+ const ROOT = path.join(path.sep, "s");
135
+ const NODE_DIR = path.join(ROOT, "node-v24.21.0-win-x64");
136
+ const JDK_DIR = path.join(ROOT, "jdk-21.0.12.1+1");
137
+
138
+ test("stripRootComponents descends past a single top-level folder", () => {
139
+ const tree = {
140
+ [ROOT]: ["node-v24.21.0-win-x64"],
141
+ [NODE_DIR]: ["node.exe", "npm.cmd"],
142
+ };
143
+ const readdir = (dir) => tree[dir] ?? [];
144
+ const isDirectory = (entry) => Object.prototype.hasOwnProperty.call(tree, entry);
145
+ assert.equal(pickEffectiveRoot(ROOT, 1, readdir, isDirectory), NODE_DIR);
146
+ });
147
+
148
+ test("stripRootComponents leaves a flat archive alone", () => {
149
+ const tree = { [ROOT]: ["adb.exe", "AdbWinApi.dll"] };
150
+ const readdir = (dir) => tree[dir] ?? [];
151
+ const isDirectory = () => false;
152
+ assert.equal(pickEffectiveRoot(ROOT, 1, readdir, isDirectory), ROOT);
153
+ });
154
+
155
+ test("stop descending when the single entry is a file, not a directory", () => {
156
+ // A real archive whose root holds exactly one FILE must not be stripped: the
157
+ // file itself is the payload. Only single DIRECTORIES are descended into.
158
+ const tree = { [ROOT]: ["payload.bin"] };
159
+ const readdir = (dir) => tree[dir] ?? [];
160
+ const isDirectory = (entry) => Object.prototype.hasOwnProperty.call(tree, entry);
161
+ assert.equal(pickEffectiveRoot(ROOT, 1, readdir, isDirectory), ROOT);
162
+ });
163
+
164
+ test("a jdk-style archive descends into its single root directory", () => {
165
+ const tree = {
166
+ [ROOT]: ["jdk-21.0.12.1+1"],
167
+ [JDK_DIR]: ["bin", "lib", "jmods", "release"],
168
+ [path.join(JDK_DIR, "bin")]: ["java.exe", "javac.exe"],
169
+ };
170
+ const readdir = (dir) => tree[dir] ?? [];
171
+ const isDirectory = (entry) => Object.prototype.hasOwnProperty.call(tree, entry);
172
+ assert.equal(pickEffectiveRoot(ROOT, 1, readdir, isDirectory), JDK_DIR);
173
+ });
174
+
175
+ /* --------------------------------------------------------------- tiny helpers */
176
+
177
+ test("dotted version comparison used by the reuse check", () => {
178
+ const cmp = (a, b) => {
179
+ const left = a.split(".").map(Number);
180
+ const right = b.split(".").map(Number);
181
+ for (let i = 0; i < 3; i += 1) {
182
+ if ((left[i] ?? 0) !== (right[i] ?? 0)) return (left[i] ?? 0) - (right[i] ?? 0);
183
+ }
184
+ return 0;
185
+ };
186
+ assert.ok(cmp("24.21.0", "24.11.0") > 0);
187
+ assert.ok(cmp("22.19.0", "22.19.0") === 0);
188
+ assert.ok(cmp("21.0.12", "21.0.12.1") < 1);
189
+ });
190
+
191
+ test("byte sizes format without a floating-point surprise", () => {
192
+ const mib = (bytes) => (bytes / 1048576).toFixed(1);
193
+ assert.equal(mib(569649598), "543.3");
194
+ assert.equal(mib(8044989), "7.7");
195
+ });
196
+
197
+ /* ------------------------------------------------------ the shim's two homes */
198
+
199
+ // The shim runs from two places and must answer differently in each:
200
+ // * the mirrored copy the published row launches sits at <bundle>/bin/ and must
201
+ // use its own location — an MCP child has no DSH_HOME to consult, because the
202
+ // harness strips every DSH_* name from it;
203
+ // * the checkout copy a human runs (`npm run bundle:status`) is a plugin, not a
204
+ // bundle, and must resolve the same root setup.mjs writes.
205
+ // Both mistakes are silent: the checkout claims "not provisioned" on a machine
206
+ // that is provisioned, and a bare `node bin/rea-shim.mjs mcp` provisions ~800 MB
207
+ // into the plugin directory.
208
+ const SHIM_SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "rea-shim.mjs");
209
+
210
+ function shimBundleRoot(shimFile, env) {
211
+ const run = spawnSync(process.execPath, [shimFile, "--shim-status"], {
212
+ encoding: "utf8",
213
+ env: { ...process.env, REA_BUNDLE_AUTO_PROVISION: "0", ...env },
214
+ });
215
+ // --shim-status prints its report even when no bundle exists yet (exit 78).
216
+ return JSON.parse(run.stdout).bundleRoot;
217
+ }
218
+
219
+ test("a mirrored shim takes its bundle root from its own location, not DSH_HOME", () => {
220
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-mirror-"));
221
+ try {
222
+ const mirror = path.join(root, "plugins", "rea-bundle");
223
+ mkdirSync(path.join(mirror, "bin"), { recursive: true });
224
+ copyFileSync(SHIM_SOURCE, path.join(mirror, "bin", "rea-shim.mjs"));
225
+ assert.equal(shimBundleRoot(path.join(mirror, "bin", "rea-shim.mjs"), { DSH_HOME: path.join(root, "elsewhere") }), mirror);
226
+ } finally {
227
+ rmSync(root, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test("a checkout shim resolves the bundle root setup.mjs would write", () => {
232
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-checkout-"));
233
+ try {
234
+ const checkout = path.join(root, "checkout");
235
+ mkdirSync(path.join(checkout, "bin"), { recursive: true });
236
+ copyFileSync(SHIM_SOURCE, path.join(checkout, "bin", "rea-shim.mjs"));
237
+ // The discriminator: only a plugin checkout ships cordis.patch.yml.
238
+ writeFileSync(path.join(checkout, "cordis.patch.yml"), "- insert: []\n");
239
+ const home = path.join(root, "dsh-home");
240
+ assert.equal(
241
+ shimBundleRoot(path.join(checkout, "bin", "rea-shim.mjs"), { DSH_HOME: home }),
242
+ path.join(home, "plugins", "rea-bundle"),
243
+ );
244
+ } finally {
245
+ rmSync(root, { recursive: true, force: true });
246
+ }
247
+ });
248
+
249
+ test("REA_BUNDLE_ROOT still overrides both layouts", () => {
250
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-override-"));
251
+ try {
252
+ const checkout = path.join(root, "checkout");
253
+ mkdirSync(path.join(checkout, "bin"), { recursive: true });
254
+ copyFileSync(SHIM_SOURCE, path.join(checkout, "bin", "rea-shim.mjs"));
255
+ writeFileSync(path.join(checkout, "cordis.patch.yml"), "- insert: []\n");
256
+ const forced = path.join(root, "forced");
257
+ assert.equal(shimBundleRoot(path.join(checkout, "bin", "rea-shim.mjs"), { REA_BUNDLE_ROOT: forced }), forced);
258
+ } finally {
259
+ rmSync(root, { recursive: true, force: true });
260
+ }
261
+ });
262
+
263
+ test("a bare invocation reports usage instead of starting anything", () => {
264
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-bare-"));
265
+ try {
266
+ const checkout = path.join(root, "checkout");
267
+ mkdirSync(path.join(checkout, "bin"), { recursive: true });
268
+ copyFileSync(SHIM_SOURCE, path.join(checkout, "bin", "rea-shim.mjs"));
269
+ writeFileSync(path.join(checkout, "cordis.patch.yml"), "- insert: []\n");
270
+ // The test guards itself: package.json exposes this file as the
271
+ // `rea-bundle-status` bin, npm cannot append a flag for it, and a regression
272
+ // here would start REA — or begin an ~800 MB download on an unprovisioned
273
+ // machine. So setup is a stub that exits immediately.
274
+ mkdirSync(path.join(checkout, "scripts"), { recursive: true });
275
+ mkdirSync(path.join(checkout, "manifest"), { recursive: true });
276
+ writeFileSync(path.join(checkout, "scripts", "setup.mjs"), "process.exit(0);\n");
277
+ writeFileSync(path.join(checkout, "manifest", "bundle.manifest.json"), "{}\n");
278
+
279
+ const home = path.join(root, "dsh-home");
280
+ const env = { ...process.env, DSH_HOME: home };
281
+ // Deliberately left at its default: the point is that a bare run must not reach
282
+ // the provisioning branch at all, not that provisioning is switched off.
283
+ delete env.REA_BUNDLE_AUTO_PROVISION;
284
+
285
+ const run = spawnSync(process.execPath, [path.join(checkout, "bin", "rea-shim.mjs")], {
286
+ encoding: "utf8",
287
+ env,
288
+ timeout: 60000,
289
+ });
290
+ assert.equal(run.status, 0, run.stderr);
291
+ assert.match(run.stdout, /--shim-status/);
292
+ // A lock would mean a provisioning run was started, which a stray
293
+ // `rea-bundle-status` must never do.
294
+ assert.equal(existsSync(path.join(home, "plugins", "rea-bundle", "setup.lock")), false);
295
+ } finally {
296
+ rmSync(root, { recursive: true, force: true });
297
+ }
298
+ });
299
+
300
+ /* ------------------------------------------- reusing a Node must check engines */
301
+
302
+ // `manifest.components.node.probe.expect.regex` is the contract, and its `why`
303
+ // says the reuse path must reject an incompatible runtime *there*. Adoption used
304
+ // to be `fileExists` only, so a Node 20 on PATH was recorded in bundle.json and
305
+ // the failure surfaced much later as an unexplained REA start failure.
306
+ //
307
+ // The tests below run setup.mjs for real (dry-run, node only, temp root: no
308
+ // network, no writes outside the temp directory) and keep the fix honest in both
309
+ // directions — a compatible node must still be adopted, so this cannot pass by
310
+ // rejecting everything.
311
+ const SETUP_SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), "setup.mjs");
312
+ const MANIFEST_SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "manifest", "bundle.manifest.json");
313
+
314
+ function runSetupNodeOnly(setupPath, root, env) {
315
+ return spawnSync(process.execPath, [setupPath, "--reuse", "--dry-run", "--only", "node", "--root", root], {
316
+ encoding: "utf8",
317
+ timeout: 120000,
318
+ env: { ...process.env, ...env },
319
+ });
320
+ }
321
+
322
+ test("--reuse still adopts a compatible node", () => {
323
+ const root = mkdtempSync(path.join(tmpdir(), "rea-node-ok-"));
324
+ try {
325
+ const run = runSetupNodeOnly(SETUP_SOURCE, root, { REA_BUNDLE_NODE: process.execPath });
326
+ assert.equal(run.status, 0, run.stderr);
327
+ assert.match(run.stdout, /reusing existing node from REA_BUNDLE_NODE/);
328
+ } finally {
329
+ rmSync(root, { recursive: true, force: true });
330
+ }
331
+ });
332
+
333
+ test("--reuse rejects a candidate the manifest's node probe rejects", () => {
334
+ const root = mkdtempSync(path.join(tmpdir(), "rea-node-reject-"));
335
+ try {
336
+ // A synthetic plugin tree whose manifest rejects the *running* Node, so the
337
+ // version branch is exercised deterministically on every platform instead of
338
+ // depending on which OS built-ins happen to exist.
339
+ const plugin = path.join(root, "plugin");
340
+ mkdirSync(path.join(plugin, "scripts"), { recursive: true });
341
+ mkdirSync(path.join(plugin, "manifest"), { recursive: true });
342
+ copyFileSync(SETUP_SOURCE, path.join(plugin, "scripts", "setup.mjs"));
343
+ const manifest = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8"));
344
+ manifest.components.node.probe.expect.regex = "^v99\\.";
345
+ writeFileSync(path.join(plugin, "manifest", "bundle.manifest.json"), JSON.stringify(manifest, null, 2));
346
+
347
+ const run = runSetupNodeOnly(path.join(plugin, "scripts", "setup.mjs"), path.join(root, "bundle"), {
348
+ REA_BUNDLE_NODE: process.execPath,
349
+ });
350
+ assert.equal(run.status, 0, run.stderr);
351
+ assert.doesNotMatch(run.stdout, /reusing existing node from REA_BUNDLE_NODE/);
352
+ assert.match(run.stdout, /not reusing REA_BUNDLE_NODE node/);
353
+ assert.match(run.stdout, /which REA's engines reject/);
354
+ // ...and it falls through to provisioning rather than giving up.
355
+ assert.match(run.stdout, /\[plan\] node —/);
356
+ } finally {
357
+ rmSync(root, { recursive: true, force: true });
358
+ }
359
+ });
360
+
361
+ test("--reuse rejects a candidate that cannot be run at all", () => {
362
+ const root = mkdtempSync(path.join(tmpdir(), "rea-node-broken-"));
363
+ try {
364
+ // A file that exists but is not an executable: on Windows a .bat/.cmd wrapper
365
+ // reaches the same rejection (execFile cannot spawn one without a shell), and
366
+ // such a Node could never have launched REA either.
367
+ const broken = path.join(root, EXE ? "broken-node.exe" : "broken-node");
368
+ writeFileSync(broken, "this is not an executable\n");
369
+ if (!EXE) chmodSync(broken, 0o644);
370
+ const run = runSetupNodeOnly(SETUP_SOURCE, path.join(root, "bundle"), { REA_BUNDLE_NODE: broken });
371
+ assert.equal(run.status, 0, run.stderr);
372
+ // Not adopted from the override. (A compatible node later on PATH may still be
373
+ // adopted — that is the fallback working, not the check being bypassed.)
374
+ assert.doesNotMatch(run.stdout, /reusing existing node from REA_BUNDLE_NODE/);
375
+ assert.match(run.stdout, /not reusing REA_BUNDLE_NODE node/);
376
+ } finally {
377
+ rmSync(root, { recursive: true, force: true });
378
+ }
379
+ });
380
+
381
+ /* ------------------------------------ an optional component cannot fail the run */
382
+
383
+ // `manifest.components.androidPlatformTools` declares `"optional": true` ("without
384
+ // a device these tools simply report no devices"), but setup.mjs never read that
385
+ // field: an unreachable dl.google.com — the only Google-hosted component — failed
386
+ // the whole run on a machine where everything REA needs had provisioned fine.
387
+ // --offline reproduces exactly that failure with no network: the archive is simply
388
+ // not in the cache.
389
+ test("an unavailable optional component is reported, not fatal", () => {
390
+ const root = mkdtempSync(path.join(tmpdir(), "rea-optional-"));
391
+ try {
392
+ // A stub REA, so the probe/shim stages have something to run: this test is
393
+ // about the optional accounting, not about REA.
394
+ const stub = path.join(root, "vendor", "node_modules", "rea-agents", "scripts");
395
+ mkdirSync(stub, { recursive: true });
396
+ writeFileSync(
397
+ path.join(stub, "rea.mjs"),
398
+ 'if (process.argv.includes("--version")) { console.log("6.4.0"); } else { console.log(JSON.stringify({ checks: [], tools: [] })); }\n',
399
+ );
400
+
401
+ const run = spawnSync(process.execPath, [SETUP_SOURCE, "--offline", "--only", "androidPlatformTools", "--root", root], {
402
+ encoding: "utf8",
403
+ timeout: 120000,
404
+ });
405
+ assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
406
+ assert.match(run.stdout, /optional component\(s\) unavailable/);
407
+ assert.match(run.stdout, /optional: androidPlatformTools/);
408
+ assert.doesNotMatch(run.stdout, /setup finished with/);
409
+
410
+ // Reported, never invented: no adb path is recorded, and the component is
411
+ // marked unavailable so the shim exports no REA_ADB_PATH at all.
412
+ const written = JSON.parse(readFileSync(path.join(root, "bundle.json"), "utf8"));
413
+ assert.equal(written.paths.adb, null);
414
+ assert.equal(written.components.androidPlatformTools.unavailable, true);
415
+ } finally {
416
+ rmSync(root, { recursive: true, force: true });
417
+ }
418
+ });
@@ -0,0 +1,131 @@
1
+ # verify-branch.ps1 — one-shot structural verification of the bundle branch.
2
+ # Read-only: parses, audits and unit-tests; it does not provision anything.
3
+ # Run from the branch root: powershell -NoProfile -File scripts\verify-branch.ps1
4
+
5
+ $ErrorActionPreference = 'Continue'
6
+ $root = Split-Path -Parent $PSScriptRoot
7
+ Set-Location $root
8
+ $fail = 0
9
+ function Check($name, [bool]$ok, $detail) {
10
+ if ($ok) { Write-Host (" PASS " + $name) -ForegroundColor Green }
11
+ else { Write-Host (" FAIL " + $name + " " + $detail) -ForegroundColor Red; $script:fail++ }
12
+ }
13
+
14
+ Write-Host "`n=== 1. JS syntax ===" -ForegroundColor Cyan
15
+ foreach ($f in @('bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs')) {
16
+ $out = & node --check $f 2>&1
17
+ Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
18
+ }
19
+
20
+ Write-Host "`n=== 2. PowerShell parse + BOM ===" -ForegroundColor Cyan
21
+ # This script checks itself too: it is edited as often as the others, and an editor
22
+ # that drops the BOM would otherwise go unnoticed until someone adds CJK text here.
23
+ foreach ($f in @('check-updates.ps1','assets\make-screenshots.ps1','scripts\rea-bundle-bootstrap.ps1','scripts\verify-branch.ps1')) {
24
+ $p = Join-Path $root $f
25
+ $bytes = [System.IO.File]::ReadAllBytes($p)
26
+ $bom = ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)
27
+ $errs = $null
28
+ [System.Management.Automation.Language.Parser]::ParseFile($p, [ref]$null, [ref]$errs) | Out-Null
29
+ $count = @($errs).Count
30
+ Check "$f parses" ($count -eq 0) "$count error(s): $(($errs | Select-Object -First 1).Message)"
31
+ if ($f -ne 'scripts\rea-bundle-bootstrap.ps1') {
32
+ # These two carry CJK text, which PowerShell 5.1 misreads without a BOM.
33
+ Check "$f has UTF-8 BOM" $bom "first bytes: $(($bytes[0..2] | ForEach-Object { $_.ToString('x2') }) -join ' ')"
34
+ }
35
+ }
36
+
37
+ Write-Host "`n=== 3. manifest audit ===" -ForegroundColor Cyan
38
+ $audit = & node scripts\audit-manifest.mjs 2>&1
39
+ $audit | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
40
+ Check 'audit clean' ($LASTEXITCODE -eq 0) 'see findings above'
41
+
42
+ Write-Host "`n=== 4. unit tests ===" -ForegroundColor Cyan
43
+ $tests = & node --test scripts\setup.test.mjs 2>&1
44
+ $testExit = $LASTEXITCODE
45
+ $failing = @($tests | Select-String -Pattern 'not ok' ).Count
46
+ Check 'node --test' ($testExit -eq 0) "$failing failing"
47
+
48
+ Write-Host "`n=== 5. the published patch is machine-path-free and escape-safe ===" -ForegroundColor Cyan
49
+ $patch = Get-Content (Join-Path $root 'cordis.patch.yml') -Raw
50
+ Check 'no drive letter in patch' (-not ($patch -match '[A-Za-z]:[\\/]')) 'a machine path leaked into the published patch'
51
+ Check 'no backslash escape in JS literals' (-not ($patch -replace '(?m)^\s*#.*$','' -match '\\[prbnt]')) 'use forward slashes'
52
+ Check 'references rea-shim.mjs' ($patch -match 'rea-shim\.mjs') ''
53
+ Check 'has a DSH_HOME fallback' (($patch -match 'DSH_HOME') -and ($patch -match 'USERPROFILE|HOME')) ''
54
+ $jsProbe = Join-Path $env:TEMP 'rea-verify-literals.cjs'
55
+ @'
56
+ const fs = require("node:fs");
57
+ const text = fs.readFileSync("cordis.patch.yml", "utf8").replace(/^\s*#.*$/gm, "");
58
+ const control = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/;
59
+ const literals = [...text.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((m) => {
60
+ try { return new Function("return '" + m[1] + "'")(); } catch { return m[1]; }
61
+ });
62
+ const bad = literals.filter((v) => control.test(v));
63
+ console.log("literals: " + JSON.stringify(literals));
64
+ if (bad.length) {
65
+ console.error("control characters in: " + bad.map((v) => JSON.stringify(v)).join(", "));
66
+ process.exit(1);
67
+ }
68
+ '@ | Set-Content $jsProbe -Encoding utf8
69
+ & node $jsProbe 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
70
+ Check 'evaluated literals contain no control characters' ($LASTEXITCODE -eq 0) 'see above'
71
+
72
+ Write-Host "`n=== 6. package payload lists what the bundle needs ===" -ForegroundColor Cyan
73
+ # -Encoding UTF8 is required: the package description contains CJK text, and
74
+ # PowerShell 5.1 reads a BOM-less file as ANSI, which breaks ConvertFrom-Json.
75
+ $pkg = Get-Content (Join-Path $root 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json
76
+ foreach ($entry in @('bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
77
+ Check "files[] includes $entry" (@($pkg.files) -contains $entry) ''
78
+ }
79
+ foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test')) {
80
+ Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
81
+ }
82
+
83
+ Write-Host "`n=== 7. dry-run still plans the whole chain ===" -ForegroundColor Cyan
84
+ $env:REA_BUNDLE_ROOT = Join-Path $env:TEMP 'rea-verify-dryrun'
85
+ # Deliberately WITHOUT --reuse. Adoption of whatever this machine happens to have
86
+ # installed used to hide a defect that only a bare machine sees: every planned
87
+ # component returned `{ planned: true }` with no path, so the dependents of each
88
+ # plan entry were reported as "required component is unavailable" and the command
89
+ # exited 1 — for the exact reader the README sends to `--dry-run`.
90
+ $dry = & node scripts\setup.mjs --root $env:REA_BUNDLE_ROOT --dry-run 2>&1
91
+ $planned = @($dry | Select-String -Pattern '^\[plan\]').Count
92
+ $unmet = @($dry | Select-String -Pattern 'is unavailable').Count
93
+ Check 'dry-run exits 0 without --reuse' ($LASTEXITCODE -eq 0) (($dry | Select-Object -Last 3) -join ' ')
94
+ Check 'dry-run plans every component' ($planned -ge 4) "planned=$planned"
95
+ Check 'dry-run reports no unavailable dependency' ($unmet -eq 0) "unavailable=$unmet"
96
+ Remove-Item Env:\REA_BUNDLE_ROOT -ErrorAction SilentlyContinue
97
+
98
+ Write-Host "`n=== 8. bootstrap recognises an unpacked portable Node (no download) ===" -ForegroundColor Cyan
99
+ # A machine with no Node is this script's entire audience, so the portable path is
100
+ # only ever exercised where it is least observable: it must therefore be tested
101
+ # here, with an empty PATH and a stack laid out exactly as the official zip lays it
102
+ # out. The folder name is derived from the manifest by a SINGLE extension strip
103
+ # (node-v24.21.0-win-x64.zip -> node-v24.21.0-win-x64); a double strip used to make
104
+ # the bootstrap throw "the Node zip did not contain node-v24.21" after a successful
105
+ # download, checksum and extraction. -DryRun keeps this check download-free.
106
+ $bootRoot = Join-Path $env:TEMP 'rea-verify-bootstrap'
107
+ # -Encoding UTF8: the manifest carries non-ASCII text and PowerShell 5.1 would
108
+ # otherwise read a BOM-less file as ANSI and fail ConvertFrom-Json.
109
+ $manifest = Get-Content (Join-Path $root 'manifest\bundle.manifest.json') -Raw -Encoding UTF8 | ConvertFrom-Json
110
+ $bootLayoutName = [System.IO.Path]::GetFileNameWithoutExtension(
111
+ [System.IO.Path]::GetFileName(([uri]$manifest.components.node.sources.'win32-x64'.url).AbsolutePath))
112
+ $bootLayout = Join-Path (Join-Path $bootRoot 'stack') $bootLayoutName
113
+ New-Item -ItemType Directory -Path $bootLayout -Force | Out-Null
114
+ $hostNode = (Get-Command node -ErrorAction SilentlyContinue).Source
115
+ if ($hostNode) {
116
+ $target = Join-Path $bootLayout 'node.exe'
117
+ if (Test-Path $target) { Remove-Item $target -Force }
118
+ try { New-Item -ItemType HardLink -Path $target -Target $hostNode -ErrorAction Stop | Out-Null }
119
+ catch { Copy-Item $hostNode $target -Force }
120
+ }
121
+ $bootOut = & powershell -NoProfile -Command "`$env:PATH='C:\Windows\System32;C:\Windows'; & '$root\scripts\rea-bundle-bootstrap.ps1' -Root '$bootRoot' -Reuse -DryRun" 2>&1
122
+ Check 'bootstrap finds the portable Node in the official layout' (@($bootOut | Select-String 'portable Node present').Count -ge 1) (($bootOut | Select-Object -Last 2) -join ' ')
123
+ Check 'bootstrap plans no download when one is present' (@($bootOut | Select-String 'plan: download').Count -eq 0) (@($bootOut | Select-String 'plan:').Line -join ' | ')
124
+ for ($i = 0; $i -lt 5; $i++) {
125
+ try { Remove-Item -Recurse -Force $bootRoot -ErrorAction Stop; break } catch { Start-Sleep -Milliseconds 300 }
126
+ }
127
+
128
+ Write-Host ''
129
+ if ($fail -eq 0) { Write-Host 'ALL CHECKS PASSED' -ForegroundColor Green }
130
+ else { Write-Host "$fail CHECK(S) FAILED" -ForegroundColor Red }
131
+ exit $fail