dsh-plugin-rea 1.0.5 → 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,445 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * manifest audit — validate the component manifest before it is published.
4
+ *
5
+ * JSON.parse cannot see duplicate keys (the last one silently wins), and this
6
+ * manifest is long enough that a duplicated field is easy to introduce while
7
+ * editing. So the checks are:
8
+ *
9
+ * 1. duplicate object keys inside any single object (parse-level)
10
+ * 2. every fetch entry has a sha256 that is either 64-hex or the literal
11
+ * "unverified", never a placeholder or an invented value
12
+ * 3. every fetch entry declares a size, except formats where the vendor does
13
+ * not publish one
14
+ * 4. every platform we claim to support has every entry it needs
15
+ * 5. the apktool wrapper for a platform is the right script (the osx wrapper
16
+ * is not the linux one) and jar+wrapper both land in the same directory
17
+ * 6. every probe token is one the substitution step actually understands
18
+ * 7. `requires` names refer to real components and cannot form a cycle
19
+ *
20
+ * Exit code 0 = clean, 1 = findings. Also usable as `npm run bundle:audit`.
21
+ */
22
+ import { readFileSync } from "node:fs";
23
+ import path from "node:path";
24
+ import process from "node:process";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
28
+ const MANIFEST = path.join(HERE, "..", "manifest", "bundle.manifest.json");
29
+ const raw = readFileSync(MANIFEST, "utf8");
30
+
31
+ const findings = [];
32
+ const add = (severity, where, message) => findings.push({ severity, where, message });
33
+
34
+ /* -------------------------------------------------- 1. duplicate object keys */
35
+
36
+ /** Walk the raw text and report any key that appears twice in one object. */
37
+ function findDuplicateKeys(text) {
38
+ const duplicates = [];
39
+ const stack = [];
40
+ let i = 0;
41
+ while (i < text.length) {
42
+ const ch = text[i];
43
+ if (ch === "{") {
44
+ stack.push(new Set());
45
+ i += 1;
46
+ continue;
47
+ }
48
+ if (ch === "}") {
49
+ stack.pop();
50
+ i += 1;
51
+ continue;
52
+ }
53
+ if (ch === '"') {
54
+ const start = i;
55
+ i += 1;
56
+ while (i < text.length) {
57
+ if (text[i] === "\\") i += 2;
58
+ else if (text[i] === '"') break;
59
+ else i += 1;
60
+ }
61
+ const key = text.slice(start + 1, i);
62
+ i += 1;
63
+ // A key is followed by ':' after optional whitespace.
64
+ let j = i;
65
+ while (j < text.length && /\s/.test(text[j])) j += 1;
66
+ if (text[j] === ":" && stack.length > 0) {
67
+ const frame = stack[stack.length - 1];
68
+ if (frame.has(key)) duplicates.push({ key, index: start });
69
+ frame.add(key);
70
+ }
71
+ continue;
72
+ }
73
+ i += 1;
74
+ }
75
+ return duplicates;
76
+ }
77
+
78
+ const duplicates = findDuplicateKeys(raw);
79
+ if (duplicates.length > 0) {
80
+ const lineOf = (index) => raw.slice(0, index).split("\n").length;
81
+ for (const duplicate of duplicates) {
82
+ add("error", `line ${lineOf(duplicate.index)}`, `duplicate object key "${duplicate.key}" — JSON.parse silently keeps only the last one`);
83
+ }
84
+ }
85
+
86
+ let manifest;
87
+ try {
88
+ manifest = JSON.parse(raw);
89
+ } catch (error) {
90
+ add("error", "manifest", `not valid JSON: ${String(error.message)}`);
91
+ }
92
+
93
+ if (manifest) {
94
+ /* ------------------------------------------- 2/3. every fetch entry is sound */
95
+
96
+ const HEX64 = /^[0-9a-f]{64}$/;
97
+ const fetchEntries = [];
98
+ for (const [componentName, component] of Object.entries(manifest.components ?? {})) {
99
+ if (component.sources) {
100
+ for (const [key, source] of Object.entries(component.sources)) {
101
+ if (key === "npm") continue;
102
+ fetchEntries.push({ where: `${componentName}.sources.${key}`, entry: source });
103
+ }
104
+ }
105
+ if (component.entries) {
106
+ for (const [key, list] of Object.entries(component.entries)) {
107
+ if (key === "byPlatform") {
108
+ for (const [platform, platformList] of Object.entries(list)) {
109
+ for (const [index, entry] of platformList.entries()) {
110
+ fetchEntries.push({ where: `${componentName}.entries.byPlatform.${platform}[${index}]`, entry });
111
+ }
112
+ }
113
+ } else {
114
+ for (const [index, entry] of list.entries()) {
115
+ fetchEntries.push({ where: `${componentName}.entries.${key}[${index}]`, entry });
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ for (const { where, entry } of fetchEntries) {
123
+ const sha = entry.sha256;
124
+ if (sha === undefined) {
125
+ add("error", where, "has no sha256 field");
126
+ } else if (sha !== "unverified" && !HEX64.test(sha)) {
127
+ add("error", where, `sha256 is neither 64 lowercase hex nor "unverified": ${JSON.stringify(sha)}`);
128
+ }
129
+ if (entry.size === undefined && entry.format !== "npm") {
130
+ add("warn", where, "has no size, so a truncated transfer cannot be detected by length");
131
+ }
132
+ if (!entry.url) add("error", where, "has no url");
133
+ if (entry.url && /localhost|127\.0\.0\.1/.test(entry.url)) add("error", where, "url points at loopback");
134
+ if (entry.sha256 === "unverified" && !entry.sha256Source) {
135
+ add("warn", where, "is unverified but does not say why");
136
+ }
137
+ }
138
+
139
+ /* ------------------------------------------------- 4. platform coverage */
140
+
141
+ const DECLARED_PLATFORMS = ["win32-x64", "darwin-x64", "darwin-arm64", "linux-x64", "linux-arm64"];
142
+ for (const [name, component] of Object.entries(manifest.components ?? {})) {
143
+ if (!component.sources) continue;
144
+ // An `all` source is platform-independent and an npm source has no platform
145
+ // at all; neither is expected to enumerate every platform.
146
+ if (component.sources.all || component.kind === "npm" || component.sources.npm) continue;
147
+ for (const platform of DECLARED_PLATFORMS) {
148
+ if (!component.sources[platform]) {
149
+ add("error", `${name}.sources`, `no source for declared platform ${platform}`);
150
+ }
151
+ }
152
+ }
153
+
154
+ /* ------------------------------- 5. apktool wrapper mapping is not crossed */
155
+
156
+ const apktool = manifest.components?.apktool;
157
+ if (apktool?.entries?.byPlatform) {
158
+ const byPlatform = apktool.entries.byPlatform;
159
+ const jarDirectory = apktool.entries.all?.[0]?.moveTo;
160
+ for (const [platform, list] of Object.entries(byPlatform)) {
161
+ for (const entry of list) {
162
+ const wantsWindows = platform.startsWith("win32");
163
+ const wantsOsx = platform.startsWith("darwin");
164
+ if (wantsWindows && !entry.url.includes("/windows/")) {
165
+ add("error", `apktool.entries.byPlatform.${platform}`, `expects the windows wrapper but points at ${entry.url}`);
166
+ }
167
+ if (wantsOsx && !entry.url.includes("/osx/")) {
168
+ add("error", `apktool.entries.byPlatform.${platform}`, `macOS must use scripts/osx/apktool, not ${entry.url}`);
169
+ }
170
+ if (platform.startsWith("linux") && !entry.url.includes("/linux/")) {
171
+ add("error", `apktool.entries.byPlatform.${platform}`, `expects the linux wrapper but points at ${entry.url}`);
172
+ }
173
+ if (entry.moveTo !== jarDirectory) {
174
+ add("error", `apktool.entries.byPlatform.${platform}`, `wrapper lands in ${entry.moveTo} but the jar lands in ${jarDirectory}; the wrapper resolves the jar next to itself`);
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ /* ------------------------------------------------------- 6. probe tokens */
181
+
182
+ const KNOWN_TOKENS = ["{root}", "{vendor}", "{vendorDir}", "{node}", "{jdk}", "{jvm}", "{javahome}", "{ghidra}", "{exe}"];
183
+ const probes = [];
184
+ for (const [name, component] of Object.entries(manifest.components ?? {})) {
185
+ if (component.probe?.argv) for (const value of component.probe.argv) probes.push({ where: `${name}.probe`, value });
186
+ if (component.probe?.file) probes.push({ where: `${name}.probe`, value: component.probe.file });
187
+ }
188
+ for (const { where, value } of probes) {
189
+ for (const token of String(value).match(/\{[a-zA-Z]+\}/g) ?? []) {
190
+ if (!KNOWN_TOKENS.includes(token)) add("error", where, `unknown probe token ${token} in ${JSON.stringify(value)}`);
191
+ }
192
+ }
193
+
194
+ /* -------------------------------------------------- 7. requires integrity */
195
+
196
+ const names = new Set(Object.keys(manifest.components ?? {}));
197
+ for (const [name, component] of Object.entries(manifest.components ?? {})) {
198
+ for (const requirement of component.requires ?? []) {
199
+ if (!names.has(requirement)) add("error", `${name}.requires`, `unknown component "${requirement}"`);
200
+ if (requirement === name) add("error", `${name}.requires`, "depends on itself");
201
+ }
202
+ }
203
+ // Cycle check over `requires`.
204
+ const visiting = new Set();
205
+ const visited = new Set();
206
+ const walk = (name, trail) => {
207
+ if (visited.has(name)) return;
208
+ if (visiting.has(name)) {
209
+ add("error", `${name}.requires`, `dependency cycle: ${[...trail, name].join(" -> ")}`);
210
+ return;
211
+ }
212
+ visiting.add(name);
213
+ for (const requirement of manifest.components[name]?.requires ?? []) {
214
+ if (names.has(requirement)) walk(requirement, [...trail, name]);
215
+ }
216
+ visiting.delete(name);
217
+ visited.add(name);
218
+ };
219
+ for (const name of names) walk(name, []);
220
+
221
+ /* ------------------------------------- 8. the published patch's JS escapes */
222
+
223
+ // This check exists because of a real bug: the row used
224
+ // process.env.DSH_HOME + '\plugins\rea-bundle\bin\rea-shim.mjs'
225
+ // YAML single quotes keep the backslashes literal, and JavaScript then eats
226
+ // them — '\p' is not a path separator, '\r' is a carriage return and '\b' is a
227
+ // backspace, so the evaluated path was a string full of control characters
228
+ // that could never start. Nothing in a manifest audit would notice; evaluating
229
+ // the expression does.
230
+ const patchPath = path.join(HERE, "..", "cordis.patch.yml");
231
+ const patchText = readFileSync(patchPath, "utf8");
232
+ const controlChar = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/;
233
+
234
+ // Evaluate every JS string literal in the patch the way JavaScript would, so
235
+ // the escape handling is the real one and not a re-implementation.
236
+ const literals = [...patchText.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((match) => match[1]);
237
+ const evaluated = literals.map((literal) => {
238
+ try {
239
+ // eslint-disable-next-line no-new-func
240
+ return new Function(`return '${literal}'`)();
241
+ } catch {
242
+ return literal;
243
+ }
244
+ });
245
+ for (const value of evaluated) {
246
+ if (controlChar.test(value)) {
247
+ add(
248
+ "error",
249
+ "cordis.patch.yml",
250
+ `a single-quoted literal evaluates to a string containing control characters (${JSON.stringify(value.slice(0, 60))}) — use forward slashes in JS string literals`,
251
+ );
252
+ }
253
+ }
254
+ if (/\\p|\\r|\\b|\\n|\\t/.test(patchText.replace(/^\s*#.*$/gm, ""))) {
255
+ add("error", "cordis.patch.yml", "a JS string literal contains a backslash escape (\\p, \\r, \\b, ...); use forward slashes");
256
+ }
257
+ if (!/rea-shim\.mjs/.test(patchText)) {
258
+ add("error", "cordis.patch.yml", "the patch does not reference rea-shim.mjs");
259
+ }
260
+ if (!/DSH_HOME/.test(patchText) || !/USERPROFILE|HOME/.test(patchText)) {
261
+ add("error", "cordis.patch.yml", "no fallback for an unset DSH_HOME was found");
262
+ }
263
+
264
+ /* --------------------------- 9. the row, the shim and setup agree on DSH_HOME */
265
+
266
+ // This check exists because of a real bug: the row fell back to the bare home
267
+ // directory while the shim, setup.mjs and bootstrap.ps1 all fall back to
268
+ // `<home>/.dsh`. setup therefore mirrored the launcher one level below where
269
+ // the row looked for it, and the row could never start — but only on a machine
270
+ // where DSH_HOME happened to be unset, so it stayed invisible. Evaluating the
271
+ // published expression is the only way to catch it.
272
+ // A bare `!!js` scalar containing "? " and ": " is not one scalar at all: YAML
273
+ // reads `? a : b` as an explicit key/value pair, so the loader receives a
274
+ // mapping instead of an expression node. Reproduced against the harness's own
275
+ // schema (JSON_SCHEMA + the !!js Type from app.asar): args[0] became
276
+ // {"[object Object]": "..."} and the row could not start. Quoting the scalar
277
+ // removes the ambiguity, so this check requires quotes around any expression
278
+ // that would otherwise be read as a mapping.
279
+ for (const match of patchText.matchAll(/^[ \t]*-[ \t]*!!js[ \t]+(\S.*)$/gm)) {
280
+ const scalar = match[1].trim();
281
+ if (scalar.startsWith('"') || scalar.startsWith("'")) continue;
282
+ if (/\?\s/.test(scalar) && /:\s/.test(scalar)) {
283
+ add(
284
+ "error",
285
+ "cordis.patch.yml",
286
+ `a bare !!js scalar contains "? " and ": " (${scalar.slice(0, 48)}...), which YAML reads as a mapping: quote the scalar so the loader receives an expression`,
287
+ );
288
+ }
289
+ }
290
+
291
+ // Pull the shim-path expression out of the row — quoted (required for a ternary)
292
+ // or bare. The trailing YAML quote follows the expression's own closing quote,
293
+ // so the match takes the rest of the line and strips the outer quoting.
294
+ const rowMatch = patchText.match(/^[ \t]*-[ \t]*!!js[ \t]+(.+rea-shim\.mjs.*)$/m);
295
+ let jsExpression = rowMatch ? rowMatch[1].trim() : null;
296
+ if (jsExpression) {
297
+ const first = jsExpression[0];
298
+ const last = jsExpression[jsExpression.length - 1];
299
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
300
+ jsExpression = jsExpression.slice(1, -1);
301
+ }
302
+ }
303
+ const shimSuffix = "/.dsh/plugins/rea-bundle/bin/rea-shim.mjs";
304
+ if (!jsExpression) {
305
+ add("error", "cordis.patch.yml", "the !!js expression that computes the shim path was not found");
306
+ } else {
307
+ const evaluate = (env) => {
308
+ // eslint-disable-next-line no-new-func
309
+ return new Function("process", `return ${jsExpression}`)({ env });
310
+ };
311
+ for (const [label, env] of [
312
+ ["DSH_HOME unset", { USERPROFILE: "C:\\Users\\probe", HOME: "/home/probe" }],
313
+ ["DSH_HOME empty", { DSH_HOME: " ", USERPROFILE: "C:\\Users\\probe", HOME: "/home/probe" }],
314
+ ["DSH_HOME unset, POSIX only", { HOME: "/home/probe" }],
315
+ ]) {
316
+ let value;
317
+ try {
318
+ value = evaluate(env);
319
+ } catch (error) {
320
+ add("error", "cordis.patch.yml", `the !!js expression throws with ${label}: ${error && error.message}`);
321
+ continue;
322
+ }
323
+ if (typeof value !== "string" || !value.endsWith(shimSuffix)) {
324
+ add(
325
+ "error",
326
+ "cordis.patch.yml",
327
+ `with ${label} the row resolves to ${JSON.stringify(value)}; it must end in ${shimSuffix} — the shim, setup.mjs and bootstrap.ps1 all fall back to <home>/.dsh, so the row would look one level too high for a file provisioning writes`,
328
+ );
329
+ }
330
+ }
331
+ let withHome;
332
+ try {
333
+ withHome = evaluate({ DSH_HOME: "/custom/home" });
334
+ } catch (error) {
335
+ withHome = `threw: ${error && error.message}`;
336
+ }
337
+ if (withHome !== "/custom/home/plugins/rea-bundle/bin/rea-shim.mjs") {
338
+ add("error", "cordis.patch.yml", `with DSH_HOME set the row resolves to ${JSON.stringify(withHome)}, expected /custom/home/plugins/rea-bundle/bin/rea-shim.mjs`);
339
+ }
340
+
341
+ // A machine with no DSH_HOME, USERPROFILE and HOME resolves the row to the
342
+ // filesystem root. That is a known, harmless divergence rather than a bug: the
343
+ // row reports MODULE_NOT_FOUND, writes and downloads nothing, and the harness
344
+ // carries on (`failOnStartupError: false`). It cannot be reconciled from inside
345
+ // a `!!js` expression either — setup.mjs resolves such a machine through
346
+ // `os.homedir()`, which falls back to the passwd entry on POSIX. Pinned as a
347
+ // warning so the behaviour stays known instead of being rediscovered as a bug.
348
+ let rootless;
349
+ try {
350
+ rootless = evaluate({});
351
+ } catch (error) {
352
+ rootless = `threw: ${error && error.message}`;
353
+ }
354
+ if (rootless !== "/.dsh/plugins/rea-bundle/bin/rea-shim.mjs") {
355
+ add(
356
+ "warn",
357
+ "cordis.patch.yml",
358
+ `with DSH_HOME, USERPROFILE and HOME all absent the row resolves to ${JSON.stringify(rootless)}; the documented behaviour is "/.dsh/plugins/rea-bundle/bin/rea-shim.mjs"`,
359
+ );
360
+ }
361
+
362
+ // Both READMEs print this row so a reader can copy it, and a documented row
363
+ // that has drifted from the shipped one is what people actually install.
364
+ for (const doc of ["README.md", "README.zh-CN.md"]) {
365
+ const text = readFileSync(path.join(HERE, "..", doc), "utf8");
366
+ if (!text.includes(jsExpression)) {
367
+ add("error", doc, "the documented !!js expression differs from cordis.patch.yml, so copied instructions would install the wrong row");
368
+ }
369
+ }
370
+ }
371
+
372
+ /* ---------------------------------- 9b. the two READMEs stay parallel */
373
+
374
+ // They are maintained by hand, and an edit that lands in only one of them leaves
375
+ // half the readers with stale instructions — the patch expression above is the
376
+ // one piece already compared, but sections drift the same way. The prose differs
377
+ // by language, so the check compares the structure that must stay parallel.
378
+ const headingCounts = (doc) => {
379
+ const text = readFileSync(path.join(HERE, "..", doc), "utf8");
380
+ const count = (marker) => (text.match(new RegExp(`^${marker} `, "gm")) ?? []).length;
381
+ return { sections: count("##"), subsections: count("###") };
382
+ };
383
+ const englishHeadings = headingCounts("README.md");
384
+ const chineseHeadings = headingCounts("README.zh-CN.md");
385
+ if (englishHeadings.sections !== chineseHeadings.sections || englishHeadings.subsections !== chineseHeadings.subsections) {
386
+ add(
387
+ "error",
388
+ "README.md",
389
+ `the two READMEs drifted apart in structure: README.md has ${englishHeadings.sections} sections / ${englishHeadings.subsections} subsections, README.zh-CN.md has ${chineseHeadings.sections} / ${chineseHeadings.subsections}`,
390
+ );
391
+ }
392
+
393
+ /* ------------------------------------------- 10. the mirror list is complete */
394
+
395
+ // Another real bug: mirrorLauncher() copied the shim and setup.mjs but not the
396
+ // manifest. The mirrored setup.mjs derives its plugin root from its own
397
+ // location, so loadManifest() then read <bundle>/manifest/bundle.manifest.json
398
+ // — a file nothing created — and every later run died with a bare ENOENT that
399
+ // the detached auto-provision swallowed.
400
+ const setupText = readFileSync(path.join(HERE, "..", "scripts", "setup.mjs"), "utf8");
401
+ const mirrorBody = setupText.match(/function mirrorLauncher\(context\) \{([\s\S]*?)\n\}/)?.[1] ?? "";
402
+ if (!mirrorBody) {
403
+ add("error", "scripts/setup.mjs", "mirrorLauncher() was not found, so the mirror list can no longer be verified");
404
+ } else {
405
+ // Only the array literal counts. Scanning the whole function body would also
406
+ // match the explanatory comment above it, which is exactly how this check
407
+ // first let a sabotaged mirror list pass.
408
+ const sourcesBody = mirrorBody.match(/const sources = \[([\s\S]*?)\];/)?.[1];
409
+ if (sourcesBody === undefined) {
410
+ add("error", "scripts/setup.mjs", "the mirror list (const sources = [...]) was not found");
411
+ } else {
412
+ for (const needed of ["bin/rea-shim.mjs", "scripts/setup.mjs", "manifest/bundle.manifest.json"]) {
413
+ if (!sourcesBody.includes(`"${needed}"`)) {
414
+ add("error", "scripts/setup.mjs", `mirrorLauncher() does not mirror ${needed}, which the mirrored copy reads at run time`);
415
+ }
416
+ }
417
+ // The shim tells its two copies apart by `cordis.patch.yml`, which only the
418
+ // checkout ships: a mirrored copy resolves its bundle from its own location
419
+ // (an MCP child has no DSH_HOME to consult), a checkout copy resolves the
420
+ // same root setup writes. Mirroring the discriminator would silently flip a
421
+ // mirrored copy back to the DSH_HOME rule — wrong wherever DSH_HOME is
422
+ // custom, and wrong in every MCP child, where it is stripped.
423
+ if (/cordis\.patch\.yml/.test(sourcesBody)) {
424
+ add(
425
+ "error",
426
+ "scripts/setup.mjs",
427
+ "the mirror list ships cordis.patch.yml, which bin/rea-shim.mjs uses to tell a checkout from a mirrored copy — a mirrored copy would then resolve its bundle through DSH_HOME, which an MCP child does not have",
428
+ );
429
+ }
430
+ }
431
+ }
432
+
433
+ /* ------------------------------------------------------------- summary */
434
+
435
+ const pinned = fetchEntries.filter((entry) => entry.entry.sha256 && entry.entry.sha256 !== "unverified").length;
436
+ const tofu = fetchEntries.length - pinned;
437
+ console.log(`manifest audit: ${fetchEntries.length} fetch entries — ${pinned} hash-pinned, ${tofu} TOFU`);
438
+ const errors = findings.filter((finding) => finding.severity === "error");
439
+ const warnings = findings.filter((finding) => finding.severity === "warn");
440
+ for (const finding of findings) {
441
+ console.log(` ${finding.severity === "error" ? "ERROR" : "warn "} ${finding.where}: ${finding.message}`);
442
+ }
443
+ console.log(errors.length === 0 ? `clean${warnings.length ? ` (${warnings.length} warning(s))` : ""}` : `${errors.length} error(s), ${warnings.length} warning(s)`);
444
+ process.exit(errors.length === 0 ? 0 : 1);
445
+ }
@@ -0,0 +1,192 @@
1
+ # rea-bundle-bootstrap.ps1 — get from "no Node at all" to a provisioned bundle.
2
+ #
3
+ # Why this exists: setup.mjs is written in Node, so a machine with no Node
4
+ # cannot run it. This script uses only PowerShell + Expand-Archive (no
5
+ # downloads other than the official Node.js zip and then the components), gets a
6
+ # portable Node, and hands the rest to scripts/setup.mjs.
7
+ #
8
+ # powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1
9
+ #
10
+ # Options:
11
+ # -Root <dir> bundle root (default: $env:DSH_HOME\plugins\rea-bundle,
12
+ # falling back to ~\.dsh\plugins\rea-bundle)
13
+ # -Reuse adopt an existing Ghidra/JDK instead of downloading them
14
+ # -DryRun print the plan and stop
15
+ #
16
+ # Nothing is installed system-wide and no registry key is touched: the runtime
17
+ # is unpacked next to the bundle and used from there.
18
+
19
+ [CmdletBinding()]
20
+ param(
21
+ [string]$Root,
22
+ [switch]$Reuse,
23
+ [switch]$DryRun
24
+ )
25
+
26
+ $ErrorActionPreference = 'Stop'
27
+ $ProgressPreference = 'SilentlyContinue'
28
+
29
+ function Write-Step([string]$Message) { Write-Host "[bootstrap] $Message" }
30
+
31
+ # --- where things go -------------------------------------------------------
32
+
33
+ if (-not $Root -or $Root.Trim() -eq '') {
34
+ $home2 = $env:DSH_HOME
35
+ if (-not $home2 -or $home2.Trim() -eq '') { $home2 = Join-Path $HOME '.dsh' }
36
+ $Root = Join-Path $home2 'plugins\rea-bundle'
37
+ }
38
+ $Root = [System.IO.Path]::GetFullPath($Root)
39
+ $stack = Join-Path $Root 'stack'
40
+ $cache = Join-Path $Root 'cache'
41
+ $logs = Join-Path $Root 'logs'
42
+ foreach ($dir in @($Root, $stack, $cache, $logs)) {
43
+ if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
44
+ }
45
+
46
+ $pluginRoot = Split-Path -Parent $PSScriptRoot
47
+ $setupScript = Join-Path $pluginRoot 'scripts\setup.mjs'
48
+ if (-not (Test-Path $setupScript)) { throw "setup.mjs not found next to this script: $setupScript" }
49
+
50
+ Write-Step "bundle root : $Root"
51
+ Write-Step "plugin root : $pluginRoot"
52
+ Write-Step "platform : $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)"
53
+ Write-Step "architecture: $env:PROCESSOR_ARCHITECTURE"
54
+
55
+ if ($env:PROCESSOR_ARCHITECTURE -notmatch '64') {
56
+ throw "This bootstrap supports 64-bit Windows (PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE)."
57
+ }
58
+
59
+ # --- Node: reuse if compatible, else fetch the portable zip -----------------
60
+ #
61
+ # The version and checksum come from the manifest, which is the single source of
62
+ # truth the rest of provisioning already reads. Hard-coding a second copy here
63
+ # meant two numbers that could drift apart silently.
64
+
65
+ $manifestPath = Join-Path $pluginRoot 'manifest\bundle.manifest.json'
66
+ if (-not (Test-Path $manifestPath)) { throw "manifest not found: $manifestPath" }
67
+ $manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
68
+
69
+ $hostKey = 'win32-' + $(if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' })
70
+ $nodeSource = $manifest.components.node.sources.$hostKey
71
+ if (-not $nodeSource) { throw "the manifest has no Node source for $hostKey" }
72
+ $nodeVersion = $nodeSource.version
73
+ $nodeSha256 = $nodeSource.sha256
74
+ $nodeUrl = $nodeSource.url
75
+ $nodeZipName = [System.IO.Path]::GetFileName(([uri]$nodeUrl).AbsolutePath)
76
+ # The official archive unpacks to exactly one top-level directory named after the
77
+ # zip: node-v24.21.0-win-x64.zip -> node-v24.21.0-win-x64. The extension is
78
+ # therefore stripped EXACTLY ONCE. Stripping twice yielded 'node-v24.21', so the
79
+ # checks below looked for <stack>\node-v24.21\node.exe and the run threw
80
+ # "the Node zip did not contain node-v24.21" *after* a successful download,
81
+ # checksum and extraction — on every machine that had no Node, which is the only
82
+ # audience this script has. Invisible wherever a Node is already on PATH.
83
+ $nodeFolder = [System.IO.Path]::GetFileNameWithoutExtension($nodeZipName)
84
+ if ($nodeSha256 -eq 'unverified') { throw "the manifest pins no checksum for $hostKey; refusing to download unverified bytes" }
85
+ Write-Step "Node source: $hostKey v$nodeVersion"
86
+
87
+ function Test-NodeUsable([string]$Exe) {
88
+ if (-not $Exe -or -not (Test-Path $Exe)) { return $null }
89
+ try {
90
+ $version = (& $Exe -v 2>$null)
91
+ } catch { return $null }
92
+ if (-not $version) { return $null }
93
+ $clean = $version.TrimStart('v').Trim()
94
+ $parts = $clean.Split('.')
95
+ if ($parts.Count -lt 2) { return $null }
96
+ $major = [int]$parts[0]
97
+ $minor = [int]$parts[1]
98
+ # REA's engines: ^22.19.0 || ^24.11.0 || >=26.0.0
99
+ $ok = ($major -eq 22 -and $minor -ge 19) -or ($major -eq 24 -and $minor -ge 11) -or ($major -ge 26)
100
+ if (-not $ok) { return $null }
101
+ return [pscustomobject]@{ Path = (Resolve-Path $Exe).Path; Version = $clean }
102
+ }
103
+
104
+ # The directory the zip actually created. The derived name is preferred, but any
105
+ # directory carrying node.exe is accepted: a naming drift must not strand a machine
106
+ # that has already downloaded and checksum-verified the bytes.
107
+ function Get-PortableNodeDir {
108
+ $exact = Join-Path $stack $nodeFolder
109
+ if (Test-Path (Join-Path $exact 'node.exe')) { return $exact }
110
+ $found = Get-ChildItem -Path $stack -Directory -ErrorAction SilentlyContinue |
111
+ Where-Object { Test-Path (Join-Path $_.FullName 'node.exe') } |
112
+ Select-Object -First 1
113
+ if ($found) { return $found.FullName }
114
+ return $exact
115
+ }
116
+
117
+ function Get-PortableNode([switch]$NoDownload) {
118
+ $exe = Join-Path (Get-PortableNodeDir) 'node.exe'
119
+ $found = Test-NodeUsable $exe
120
+ if ($found) { Write-Step "portable Node present: $($found.Path) (v$($found.Version))"; return $found }
121
+ # -DryRun still *looks* for the portable copy (that is the only way the plan can
122
+ # tell the truth about whether a download is needed) but never fetches anything.
123
+ if ($NoDownload) { return $null }
124
+
125
+ $zip = Join-Path $cache $nodeZipName
126
+ if (Test-Path $zip) {
127
+ $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
128
+ if ($hash -ne $nodeSha256) { Write-Step "cached Node zip failed its checksum; re-downloading"; Remove-Item $zip -Force }
129
+ }
130
+ if (-not (Test-Path $zip)) {
131
+ Write-Step "downloading $nodeZipName (~35 MiB)"
132
+ $partial = "$zip.part"
133
+ if (Test-Path $partial) { Remove-Item $partial -Force }
134
+ # Invoke-WebRequest buffers the whole body before writing, so a slow mirror
135
+ # looks frozen; say so plainly rather than appearing stuck.
136
+ Invoke-WebRequest -Uri $nodeUrl -OutFile $partial -UseBasicParsing -TimeoutSec 1800
137
+ Move-Item -LiteralPath $partial -Destination $zip -Force
138
+ }
139
+ $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
140
+ if ($actual -ne $nodeSha256) { throw "Node.js zip checksum mismatch.`n expected $nodeSha256`n actual $actual" }
141
+ Write-Step "checksum ok: $actual"
142
+
143
+ Write-Step "extracting into $stack"
144
+ Expand-Archive -LiteralPath $zip -DestinationPath $stack -Force
145
+ $unpacked = Get-PortableNodeDir
146
+ if (-not (Test-Path (Join-Path $unpacked 'node.exe'))) { throw "the Node zip left no node.exe under $stack (expected $nodeFolder)" }
147
+ $found = Test-NodeUsable (Join-Path $unpacked 'node.exe')
148
+ if (-not $found) { throw "the extracted node.exe could not be executed" }
149
+ Write-Step "portable Node ready: $($found.Path) (v$($found.Version))"
150
+ return $found
151
+ }
152
+
153
+ $node = $null
154
+ $candidates = @()
155
+ if ($env:REA_BUNDLE_NODE) { $candidates += $env:REA_BUNDLE_NODE }
156
+ $onPath = (Get-Command node -ErrorAction SilentlyContinue)
157
+ if ($onPath) { $candidates += $onPath.Source }
158
+ foreach ($candidate in $candidates) {
159
+ $node = Test-NodeUsable $candidate
160
+ if ($node) { Write-Step "using existing Node: $($node.Path) (v$($node.Version))"; break }
161
+ }
162
+
163
+ # A portable Node already unpacked next to the bundle counts as an existing
164
+ # install, and asking about it is what makes the -DryRun plan tell the truth.
165
+ if (-not $node) { $node = Get-PortableNode -NoDownload:$DryRun }
166
+
167
+ if ($DryRun) {
168
+ if ($node) { Write-Step "plan: use Node $($node.Path) (v$($node.Version))" }
169
+ else { Write-Step "plan: download and extract $nodeZipName into $stack" }
170
+ Write-Step "plan: node `"$setupScript`" --root `"$Root`"$(if ($Reuse) { ' --reuse' })"
171
+ Write-Step "dry run complete; nothing was changed."
172
+ exit 0
173
+ }
174
+
175
+ if (-not $node) { $node = Get-PortableNode }
176
+
177
+ # --- hand over to setup.mjs ------------------------------------------------
178
+
179
+ $setupArgs = @($setupScript, '--root', $Root)
180
+ if ($Reuse) { $setupArgs += '--reuse' }
181
+
182
+ Write-Step "running setup: node $($setupArgs -join ' ')"
183
+ & $node.Path @setupArgs
184
+ $code = $LASTEXITCODE
185
+
186
+ if ($code -eq 0) {
187
+ Write-Step "done. Restart DeepSeek Harness so the mcp-rea row reconnects."
188
+ } else {
189
+ Write-Step "setup reported unmet components (exit $code). Re-run after fixing the cause."
190
+ Write-Step "log: $(Join-Path $logs 'setup.log')"
191
+ }
192
+ exit $code