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.
- package/CHANGELOG.md +139 -1
- package/README.md +210 -210
- package/README.zh-CN.md +239 -0
- package/assets/make-screenshots.ps1 +14 -8
- package/assets/screenshot-1.png +0 -0
- package/bin/rea-shim.mjs +356 -0
- package/check-updates.ps1 +71 -1
- package/cordis.patch.yml +39 -31
- package/docs/bundle-design.md +186 -0
- package/manifest/bundle.manifest.json +399 -0
- package/package.json +23 -3
- package/scripts/audit-manifest.mjs +445 -0
- package/scripts/rea-bundle-bootstrap.ps1 +192 -0
- package/scripts/setup.mjs +1453 -0
- package/scripts/setup.test.mjs +418 -0
- package/scripts/verify-branch.ps1 +131 -0
|
@@ -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
|