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.
- package/CHANGELOG.md +124 -0
- package/README.md +182 -72
- package/README.zh-CN.md +185 -85
- 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 +68 -0
- package/cordis.patch.yml +39 -31
- package/docs/bundle-design.md +186 -0
- package/manifest/bundle.manifest.json +399 -0
- package/package.json +22 -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
package/bin/rea-shim.mjs
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* rea-shim — the process the DSH MCP row launches.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the published `cordis.patch.yml` must carry no machine path,
|
|
6
|
+
* yet REA needs absolute paths for every dependency. The shim is the seam: it
|
|
7
|
+
* reads one machine-local JSON file, exports exactly the variables REA reads,
|
|
8
|
+
* and execs the vendored REA CLI with the arguments the row was given.
|
|
9
|
+
*
|
|
10
|
+
* It downloads nothing, writes nothing, and never touches PATH.
|
|
11
|
+
*
|
|
12
|
+
* Exit codes: 0 child exited normally * child's own status
|
|
13
|
+
* 78 EX_CONFIG bundle not provisioned (or unusable) — the row has
|
|
14
|
+
* `failOnStartupError: false`, so the harness stays up
|
|
15
|
+
* and the message is the operator's remediation.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import process from "node:process";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const EX_CONFIG = 78;
|
|
25
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const PLUGIN_ROOT = path.resolve(HERE, "..");
|
|
27
|
+
|
|
28
|
+
/** DSH home, by the same rule setup.mjs and the published patch use. */
|
|
29
|
+
function dshHome() {
|
|
30
|
+
const fromEnv = process.env.DSH_HOME;
|
|
31
|
+
if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
|
|
32
|
+
return path.join(homedir(), ".dsh");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where setup writes the machine-local truth.
|
|
37
|
+
*
|
|
38
|
+
* Two copies of this file exist and they need different answers, so the copy has
|
|
39
|
+
* to be identified before the root can be:
|
|
40
|
+
*
|
|
41
|
+
* * The **mirrored** copy at `<bundle>/bin/rea-shim.mjs` is what the published
|
|
42
|
+
* row launches, and its own location is the bundle root. It must not consult
|
|
43
|
+
* DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
|
|
44
|
+
* environment (case-insensitively, because Windows is), so a custom DSH_HOME
|
|
45
|
+
* would send it to `~/.dsh`, where it would report "not provisioned" for a
|
|
46
|
+
* bundle sitting right next to it — and start a second ~800 MB run there.
|
|
47
|
+
* * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
|
|
48
|
+
* `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
|
|
49
|
+
* directory is the plugin, not the bundle, so resolving to itself would report
|
|
50
|
+
* "not provisioned" on a fully provisioned machine — and a bare
|
|
51
|
+
* `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
|
|
52
|
+
*
|
|
53
|
+
* Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
|
|
54
|
+
* REA_BUNDLE_ROOT stays as an explicit override for tests and unusual layouts.
|
|
55
|
+
*/
|
|
56
|
+
function bundleRoot() {
|
|
57
|
+
const override = process.env.REA_BUNDLE_ROOT;
|
|
58
|
+
if (override && override.trim() !== "") return path.resolve(override);
|
|
59
|
+
if (!existsSync(path.join(PLUGIN_ROOT, "cordis.patch.yml"))) return PLUGIN_ROOT;
|
|
60
|
+
return path.join(dshHome(), "plugins", "rea-bundle");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A directory can provision only when it holds both files a run reads: the script
|
|
65
|
+
* and the manifest it loads at startup. A bundle provisioned before the manifest
|
|
66
|
+
* was mirrored has the script but not the manifest, so recommending that copy
|
|
67
|
+
* would send the operator straight into an ENOENT.
|
|
68
|
+
*/
|
|
69
|
+
function usableSetup(dir) {
|
|
70
|
+
if (typeof dir !== "string" || dir.trim() === "") return null;
|
|
71
|
+
const base = path.resolve(dir);
|
|
72
|
+
const script = path.join(base, "scripts", "setup.mjs");
|
|
73
|
+
const manifest = path.join(base, "manifest", "bundle.manifest.json");
|
|
74
|
+
return existsSync(script) && existsSync(manifest) ? script : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Which setup.mjs to run.
|
|
79
|
+
*
|
|
80
|
+
* The mirrored copy is a snapshot: after a plugin upgrade it still holds the
|
|
81
|
+
* previous manifest, so it would provision the previous versions forever. setup
|
|
82
|
+
* writes <bundle>/mirror.json naming the real checkout; prefer that while it is
|
|
83
|
+
* usable, and fall back to the local copy — which is also all a first run has.
|
|
84
|
+
*/
|
|
85
|
+
function provisioningScript(root) {
|
|
86
|
+
try {
|
|
87
|
+
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
|
|
88
|
+
const recorded = usableSetup(record && record.pluginRoot);
|
|
89
|
+
if (recorded) return recorded;
|
|
90
|
+
} catch {
|
|
91
|
+
/* no record, or not readable: the local copy is the only option */
|
|
92
|
+
}
|
|
93
|
+
return usableSetup(PLUGIN_ROOT);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* On first run there is nothing to launch. Provisioning is a long download, so
|
|
98
|
+
* it is started detached: the harness may kill this row (or the whole session)
|
|
99
|
+
* long before 800 MB arrive, and the downloader is resumable and idempotent, so
|
|
100
|
+
* a killed attempt is simply continued on the next start.
|
|
101
|
+
*
|
|
102
|
+
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
|
|
103
|
+
* Returns the log path when a run was started, otherwise null.
|
|
104
|
+
*/
|
|
105
|
+
function maybeAutoProvision(root) {
|
|
106
|
+
if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return null;
|
|
107
|
+
const setupScript = provisioningScript(root);
|
|
108
|
+
if (!setupScript) return null;
|
|
109
|
+
const lock = path.join(root, "setup.lock");
|
|
110
|
+
const logPath = path.join(root, "logs", "setup.log");
|
|
111
|
+
if (existsSync(lock)) {
|
|
112
|
+
// A crashed run must not wedge provisioning forever: honour the lock only
|
|
113
|
+
// while it is fresh; setup removes it itself when it finishes cleanly.
|
|
114
|
+
let age = Infinity;
|
|
115
|
+
try {
|
|
116
|
+
age = Date.now() - Number(readFileSync(lock, "utf8").trim() || 0);
|
|
117
|
+
} catch {
|
|
118
|
+
age = Infinity;
|
|
119
|
+
}
|
|
120
|
+
if (Number.isFinite(age) && age < 6 * 60 * 60 * 1000) return logPath;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
mkdirSync(path.join(root, "logs"), { recursive: true });
|
|
124
|
+
// Exclusive create: check-then-write is not atomic, so two rows starting at
|
|
125
|
+
// once could both decide to provision. EEXIST means another run owns it.
|
|
126
|
+
writeFileSync(lock, String(Date.now()), { flag: "wx" });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error?.code === "EEXIST") return logPath;
|
|
129
|
+
/* a read-only bundle root must never break the harness */
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const child = spawn(process.execPath, [setupScript, "--root", root], {
|
|
134
|
+
detached: true,
|
|
135
|
+
stdio: "ignore",
|
|
136
|
+
windowsHide: true,
|
|
137
|
+
});
|
|
138
|
+
child.unref();
|
|
139
|
+
return logPath;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fail(message, detail) {
|
|
146
|
+
const lines = [
|
|
147
|
+
"",
|
|
148
|
+
" dsh-plugin-rea (bundle): REA is not provisioned on this machine.",
|
|
149
|
+
"",
|
|
150
|
+
" " + message,
|
|
151
|
+
];
|
|
152
|
+
if (detail) lines.push("", " detail: " + detail);
|
|
153
|
+
// Point at the real checkout when the record allows it: running the mirrored
|
|
154
|
+
// copy is fine, but it can only ever provision the manifest it was mirrored
|
|
155
|
+
// with — and a bundle mirrored before the manifest travelled with it has no
|
|
156
|
+
// usable copy at all, so the honest answer is then "run the plugin's own script".
|
|
157
|
+
const setupScript = provisioningScript(bundleRoot());
|
|
158
|
+
lines.push(
|
|
159
|
+
"",
|
|
160
|
+
" Finish setup with:",
|
|
161
|
+
setupScript
|
|
162
|
+
? " node \"" + setupScript + "\""
|
|
163
|
+
: " node \"<installed plugin directory>/scripts/setup.mjs\"",
|
|
164
|
+
);
|
|
165
|
+
if (!setupScript) {
|
|
166
|
+
lines.push("", " (Run it from the installed plugin directory: the copy in this bundle has no manifest.)");
|
|
167
|
+
}
|
|
168
|
+
lines.push(
|
|
169
|
+
"",
|
|
170
|
+
" Then restart DSH (or reload the profile) so the MCP row reconnects.",
|
|
171
|
+
"",
|
|
172
|
+
" The harness keeps running: this row declares failOnStartupError: false.",
|
|
173
|
+
"",
|
|
174
|
+
);
|
|
175
|
+
process.stderr.write(lines.join("\n"));
|
|
176
|
+
return EX_CONFIG;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readBundleConfig(root) {
|
|
180
|
+
const file = path.join(root, "bundle.json");
|
|
181
|
+
if (!existsSync(file)) return { ok: false, code: "missing", file };
|
|
182
|
+
let parsed;
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return { ok: false, code: "malformed", file, detail: String(error && error.message) };
|
|
187
|
+
}
|
|
188
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
189
|
+
return { ok: false, code: "malformed", file, detail: "bundle.json is not an object" };
|
|
190
|
+
}
|
|
191
|
+
return { ok: true, file, config: parsed };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Resolve the vendored REA entry, or report precisely what is missing. */
|
|
195
|
+
function resolveRea(config, root) {
|
|
196
|
+
const paths = config.paths ?? {};
|
|
197
|
+
const node = paths.node;
|
|
198
|
+
const reaEntry = paths.reaEntry
|
|
199
|
+
?? path.join(root, "vendor", "rea-agents", "scripts", "rea.mjs");
|
|
200
|
+
|
|
201
|
+
if (typeof node !== "string" || node.trim() === "") {
|
|
202
|
+
return { ok: false, reason: "bundle.json has no paths.node" };
|
|
203
|
+
}
|
|
204
|
+
if (!existsSync(node)) {
|
|
205
|
+
return { ok: false, reason: `the recorded Node runtime is gone: ${node}` };
|
|
206
|
+
}
|
|
207
|
+
if (!existsSync(reaEntry)) {
|
|
208
|
+
return { ok: false, reason: `the vendored REA CLI is gone: ${reaEntry}` };
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, node, reaEntry };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Export only what REA actually reads, and only when the bundle recorded it.
|
|
215
|
+
* An absent variable is better than a wrong one: REA then reports its own
|
|
216
|
+
* availability with its own remediation text.
|
|
217
|
+
*/
|
|
218
|
+
function childEnvironment(config) {
|
|
219
|
+
const env = { ...process.env };
|
|
220
|
+
const paths = config.paths ?? {};
|
|
221
|
+
|
|
222
|
+
const set = (name, value) => {
|
|
223
|
+
if (typeof value === "string" && value.trim() !== "") env[name] = value;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Analysis engines.
|
|
227
|
+
set("GHIDRA_INSTALL_DIR", paths.ghidra);
|
|
228
|
+
set("JAVA_HOME", paths.jdk);
|
|
229
|
+
// Optional providers/tools; wired only when the bundle provisioned them.
|
|
230
|
+
set("REA_ADB_PATH", paths.adb);
|
|
231
|
+
set("REA_APKTOOL_COMMAND", paths.apktoolCommand);
|
|
232
|
+
set("REA_JADX_MCP_JAR", paths.jadxJar);
|
|
233
|
+
set("REA_BROWSER_EXECUTABLE", paths.browser);
|
|
234
|
+
set("REA_ILSPY_CMD_PATH", paths.ilspycmd);
|
|
235
|
+
|
|
236
|
+
// Keep the vendored Node first for any grandchild the bridge may spawn.
|
|
237
|
+
if (typeof paths.node === "string" && paths.node.trim() !== "") {
|
|
238
|
+
const nodeDir = path.dirname(paths.node);
|
|
239
|
+
const current = env.PATH ?? "";
|
|
240
|
+
const parts = current.split(path.delimiter).filter((part) => part !== "");
|
|
241
|
+
if (!parts.some((part) => path.resolve(part) === path.resolve(nodeDir))) {
|
|
242
|
+
env.PATH = [nodeDir, ...parts].join(path.delimiter);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return env;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Usage text, also what a bare invocation prints.
|
|
250
|
+
*
|
|
251
|
+
* `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
|
|
252
|
+
* shim can only forward the arguments a user typed — it cannot append a flag. So a
|
|
253
|
+
* bare `rea-bundle-status`, which is exactly what that name invites, used to fall
|
|
254
|
+
* through to the launch path: it started REA, and on an unprovisioned machine it
|
|
255
|
+
* began an ~800 MB download. Reporting usage instead makes the command's behaviour
|
|
256
|
+
* match its name, and the row is unaffected because it always passes `mcp`.
|
|
257
|
+
*/
|
|
258
|
+
function usage() {
|
|
259
|
+
return [
|
|
260
|
+
"rea-shim: launches the vendored REA MCP server for a DSH MCP row.",
|
|
261
|
+
"",
|
|
262
|
+
" node bin/rea-shim.mjs [rea arguments...] (normal use: 'mcp')",
|
|
263
|
+
" node bin/rea-shim.mjs --shim-status provisioning status, no launch",
|
|
264
|
+
" node bin/rea-shim.mjs --shim-print-env the environment it would export",
|
|
265
|
+
"",
|
|
266
|
+
` bundle root: ${bundleRoot()}`,
|
|
267
|
+
` plugin root: ${PLUGIN_ROOT}`,
|
|
268
|
+
"",
|
|
269
|
+
].join("\n");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function main() {
|
|
273
|
+
const argv = process.argv.slice(2);
|
|
274
|
+
|
|
275
|
+
if (argv.includes("--shim-help") || argv.length === 0) {
|
|
276
|
+
process.stdout.write(usage());
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const root = bundleRoot();
|
|
281
|
+
const loaded = readBundleConfig(root);
|
|
282
|
+
|
|
283
|
+
if (argv.includes("--shim-status")) {
|
|
284
|
+
const report = {
|
|
285
|
+
bundleRoot: root,
|
|
286
|
+
bundleFile: loaded.file,
|
|
287
|
+
provisioned: loaded.ok,
|
|
288
|
+
reason: loaded.ok ? undefined : loaded.code,
|
|
289
|
+
autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
|
|
290
|
+
};
|
|
291
|
+
if (loaded.ok) {
|
|
292
|
+
report.paths = loaded.config.paths ?? {};
|
|
293
|
+
report.versions = loaded.config.versions ?? {};
|
|
294
|
+
report.rea = resolveRea(loaded.config, root);
|
|
295
|
+
}
|
|
296
|
+
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
297
|
+
return loaded.ok ? 0 : EX_CONFIG;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!loaded.ok) {
|
|
301
|
+
const message =
|
|
302
|
+
loaded.code === "missing"
|
|
303
|
+
? `No bundle state at ${loaded.file}.`
|
|
304
|
+
: `Bundle state at ${loaded.file} could not be parsed.`;
|
|
305
|
+
const logPath = loaded.code === "missing" ? maybeAutoProvision(root) : null;
|
|
306
|
+
if (logPath) {
|
|
307
|
+
process.stderr.write(
|
|
308
|
+
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${logPath}\n`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return fail(message, loaded.detail);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const rea = resolveRea(loaded.config, root);
|
|
315
|
+
if (!rea.ok) return fail(rea.reason);
|
|
316
|
+
|
|
317
|
+
const env = childEnvironment(loaded.config);
|
|
318
|
+
if (argv.includes("--shim-print-env")) {
|
|
319
|
+
const interesting = Object.fromEntries(
|
|
320
|
+
Object.entries(env).filter(([key]) =>
|
|
321
|
+
/^(GHIDRA_INSTALL_DIR|JAVA_HOME|REA_[A-Z0-9_]+|PATH)$/.test(key),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
process.stdout.write(JSON.stringify(interesting, null, 2) + "\n");
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Pass through whatever the row supplied; 'mcp' in the normal case.
|
|
329
|
+
const childArgs = [rea.reaEntry, ...(argv.length > 0 ? argv : ["mcp"])];
|
|
330
|
+
const child = spawn(rea.node, childArgs, {
|
|
331
|
+
env,
|
|
332
|
+
stdio: "inherit",
|
|
333
|
+
windowsHide: true,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
child.on("error", (error) => {
|
|
337
|
+
process.stderr.write(
|
|
338
|
+
`rea-shim: could not start ${rea.node}\n ${String(error && error.message)}\n`,
|
|
339
|
+
);
|
|
340
|
+
process.exit(EX_CONFIG);
|
|
341
|
+
});
|
|
342
|
+
child.on("exit", (code, signal) => {
|
|
343
|
+
if (signal) {
|
|
344
|
+
// Re-raising the signal is not portable: process.kill(pid, "SIGTERM")
|
|
345
|
+
// throws ERR_UNKNOWN_SIGNAL on Windows. Report the conventional 128+n
|
|
346
|
+
// status instead, which the harness reads the same way.
|
|
347
|
+
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
|
|
348
|
+
process.exit(128 + (signals[signal] ?? 0));
|
|
349
|
+
}
|
|
350
|
+
process.exit(code ?? 0);
|
|
351
|
+
});
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const status = main();
|
|
356
|
+
if (typeof status === "number") process.exit(status);
|
package/check-updates.ps1
CHANGED
|
@@ -46,6 +46,10 @@ function Test-NodeEngines([string]$node, [string]$engines) {
|
|
|
46
46
|
# 之后 $node.Major 为 $null,所有 ^ / ~ 分支都会判假(曾因此把 Node 24 判成不满足)。
|
|
47
47
|
$v = Get-NumericVersion $node
|
|
48
48
|
if (-not $v) { return $null }
|
|
49
|
+
# 只要有一个 term 的形态不认识,整个判断就只能给"无法解析":返回 $false 会把
|
|
50
|
+
# `22.x` / `>=22` 这类合法但未实现的 pattern 误报成"不满足",让用户去升级一个
|
|
51
|
+
# 本来就满足的 Node。
|
|
52
|
+
$recognized = $true
|
|
49
53
|
foreach ($term in ($engines -split '\|\|')) {
|
|
50
54
|
$t = $term.Trim()
|
|
51
55
|
if ($t -match '^\^(\d+\.\d+\.\d+)$') {
|
|
@@ -56,8 +60,11 @@ function Test-NodeEngines([string]$node, [string]$engines) {
|
|
|
56
60
|
} elseif ($t -match '^~(\d+\.\d+\.\d+)$') {
|
|
57
61
|
$base = [version]$Matches[1]
|
|
58
62
|
if ($v.Major -eq $base.Major -and $v.Minor -eq $base.Minor -and $v -ge $base) { return $true }
|
|
63
|
+
} else {
|
|
64
|
+
$recognized = $false
|
|
59
65
|
}
|
|
60
66
|
}
|
|
67
|
+
if (-not $recognized) { return $null }
|
|
61
68
|
return $false
|
|
62
69
|
}
|
|
63
70
|
|
|
@@ -216,6 +223,67 @@ if (Test-Path $profPatch) {
|
|
|
216
223
|
}
|
|
217
224
|
}
|
|
218
225
|
|
|
226
|
+
# ------------------------------------------------- 一体包(bundle 分支)
|
|
227
|
+
# 设计文档承诺了这个分区:一体包把组件版本钉在 manifest 里,供应结果记在
|
|
228
|
+
# bundle.json 里。两者不一致 = 有一次 pin 更新还没重新供给,或盘上的组件被换过。
|
|
229
|
+
Head '一体包供给状态(bundle 分支)'
|
|
230
|
+
$pluginRoot = $PSScriptRoot
|
|
231
|
+
$manifestPath = Join-Path $pluginRoot 'manifest\bundle.manifest.json'
|
|
232
|
+
$bundleRoot = $env:REA_BUNDLE_ROOT
|
|
233
|
+
if (-not $bundleRoot) {
|
|
234
|
+
$homeDir = $env:DSH_HOME
|
|
235
|
+
if (-not $homeDir) { $homeDir = Join-Path $HOME '.dsh' }
|
|
236
|
+
$bundleRoot = Join-Path $homeDir 'plugins\rea-bundle'
|
|
237
|
+
}
|
|
238
|
+
$bundleJson = Join-Path $bundleRoot 'bundle.json'
|
|
239
|
+
|
|
240
|
+
if (-not (Test-Path $manifestPath)) {
|
|
241
|
+
Info '未找到 manifest/bundle.manifest.json —— 这不是一体包分支,跳过'
|
|
242
|
+
} else {
|
|
243
|
+
Info "bundle 根目录: $bundleRoot"
|
|
244
|
+
try {
|
|
245
|
+
$manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
246
|
+
if (Test-Path $bundleJson) {
|
|
247
|
+
$state = Get-Content $bundleJson -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
248
|
+
Ok "已供给:$($state.provisionedAt)"
|
|
249
|
+
$drift = 0
|
|
250
|
+
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
|
|
251
|
+
if ($name -eq 'nodeEngines') { continue }
|
|
252
|
+
$pinned = [string]$manifest.pins.$name
|
|
253
|
+
$installed = ''
|
|
254
|
+
if ($state.versions -and ($state.versions.PSObject.Properties.Name -contains $name)) {
|
|
255
|
+
$installed = [string]$state.versions.$name
|
|
256
|
+
}
|
|
257
|
+
if (-not $installed) { continue }
|
|
258
|
+
# Node 的 pin 是引擎范围,不是版本号;其它组件比前缀即可
|
|
259
|
+
$same = $installed.Equals($pinned) -or $installed.StartsWith(($pinned -split '\.')[0] + '.')
|
|
260
|
+
if ($same) { Info " $name = $installed(pin $pinned)" }
|
|
261
|
+
else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
|
|
262
|
+
}
|
|
263
|
+
if ($drift -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
|
|
264
|
+
if ($state.unverifiedChecksums -and @($state.unverifiedChecksums).Count -gt 0) {
|
|
265
|
+
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
|
|
266
|
+
}
|
|
267
|
+
$mirrored = Join-Path $bundleRoot 'bin\rea-shim.mjs'
|
|
268
|
+
if (Test-Path $mirrored) { Ok 'MCP 行指向的 shim 已就位' }
|
|
269
|
+
else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
|
|
270
|
+
# 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
|
|
271
|
+
# 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
|
|
272
|
+
# 而且它已写下的 lock 会让重试被抑制 6 小时。
|
|
273
|
+
$mirroredManifest = Join-Path $bundleRoot 'manifest\bundle.manifest.json'
|
|
274
|
+
if (Test-Path $mirroredManifest) { Ok '镜像副本的 manifest 已就位' }
|
|
275
|
+
else { Bad "镜像副本缺少 manifest: $mirroredManifest —— 请从插件目录重跑一次 node scripts/setup.mjs(旧镜像无法自愈)" }
|
|
276
|
+
} else {
|
|
277
|
+
Warn "尚未供给:$bundleJson 不存在 —— 运行 node scripts/setup.mjs(或直接装插件,行会自行触发)"
|
|
278
|
+
}
|
|
279
|
+
$pins = @()
|
|
280
|
+
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
|
|
281
|
+
if ($name -ne 'nodeEngines') { $pins += "$name=$($manifest.pins.$name)" }
|
|
282
|
+
}
|
|
283
|
+
Info ('manifest 钉子: ' + ($pins -join ', '))
|
|
284
|
+
} catch { Warn "bundle 状态解析失败: $($_.Exception.Message)" }
|
|
285
|
+
}
|
|
286
|
+
|
|
219
287
|
# ------------------------------------------------------------------ 汇总
|
|
220
288
|
Head '结论'
|
|
221
289
|
if ($actions.Count -eq 0) { Ok '无需动作:整条链路与上游保持一致' }
|
package/cordis.patch.yml
CHANGED
|
@@ -1,33 +1,31 @@
|
|
|
1
|
-
# dsh-plugin-rea —
|
|
1
|
+
# dsh-plugin-rea — one-plugin bundle
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# Registers the REA MCP server as mcp__rea__* and launches it through the
|
|
4
|
+
# bundle's own shim, which reads the machine-local state written by
|
|
5
|
+
# `scripts/setup.mjs` and exports exactly the variables REA reads.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# PORTABLE BY CONSTRUCTION — no machine path appears in this file.
|
|
8
|
+
# * `command` is the running Node executable (`process.execPath`), so the
|
|
9
|
+
# launcher needs no PATH entry and no shim resolution by the OS.
|
|
10
|
+
# * `args[0]` is computed at boot from DSH_HOME, where setup mirrors the shim.
|
|
11
|
+
# * FORWARD SLASHES ONLY in the JS string literals below. A single-quoted YAML
|
|
12
|
+
# scalar keeps backslashes literal, and JavaScript then eats them: '\plugins'
|
|
13
|
+
# is not "\plugins" but the BEL control character, '\rea-bundle' becomes a
|
|
14
|
+
# carriage return and '\bin' a backspace, so the path is silently destroyed.
|
|
15
|
+
# Forward slashes are accepted on Windows and are correct on POSIX, so one
|
|
16
|
+
# expression serves every platform.
|
|
17
|
+
# * `path`/`require` are not in scope for a `!!js` expression (`ctx` is the
|
|
18
|
+
# loader scope), hence plain string concatenation.
|
|
19
|
+
# * No DSH_HOME (undefined/absent/empty) falls back to the SAME rule the shim
|
|
20
|
+
# and setup.mjs use — `<home>/.dsh` — never to the bare home directory.
|
|
21
|
+
# Getting this wrong is silent: setup would mirror the shim into
|
|
22
|
+
# `<home>/.dsh/plugins/rea-bundle/bin/` while the row looked for it one level
|
|
23
|
+
# up, so the row could never start on a machine that merely happened to have
|
|
24
|
+
# DSH_HOME unset.
|
|
9
25
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# Windows : command: cmd.exe args: ['/c', 'rea', 'mcp'] (as below)
|
|
14
|
-
# macOS/Linux: command: 'rea' args: ['mcp']
|
|
15
|
-
# → see examples/cordis.patch.posix.yml
|
|
16
|
-
#
|
|
17
|
-
# Analysis engines: REA reads GHIDRA_INSTALL_DIR from its environment.
|
|
18
|
-
# - Option 1 (recommended): set it as an OS-level environment variable,
|
|
19
|
-
# then start/restart the harness so the MCP child inherits it.
|
|
20
|
-
# - Option 2: put an `env:` block in the row —
|
|
21
|
-
# see examples/cordis.patch.with-env.yml.
|
|
22
|
-
# Without it, native analysis reports the engine as unavailable while every
|
|
23
|
-
# other REA capability keeps working. Static JavaScript/Electron and .NET
|
|
24
|
-
# analysis need no engine at all.
|
|
25
|
-
#
|
|
26
|
-
# Only ONE row may use serverName: rea in a profile. To adapt this bundle for
|
|
27
|
-
# your machine without touching the upstream file, override `mcp-rea` from
|
|
28
|
-
# your profile's own cordis.patch.yml (id-targeted config override) — that
|
|
29
|
-
# file is never published.
|
|
30
|
-
# ---------------------------------------------------------------------------
|
|
26
|
+
# One row may use serverName: rea in a profile. To pin a different launch
|
|
27
|
+
# command on this machine, override `mcp-rea` from the profile's own
|
|
28
|
+
# cordis.patch.yml (id-targeted config override) — that file is never published.
|
|
31
29
|
|
|
32
30
|
- insert:
|
|
33
31
|
- id: mcp-rea
|
|
@@ -35,9 +33,19 @@
|
|
|
35
33
|
config:
|
|
36
34
|
serverName: rea
|
|
37
35
|
transport: stdio
|
|
38
|
-
command:
|
|
36
|
+
command: !!js process.execPath
|
|
39
37
|
args:
|
|
40
|
-
-
|
|
41
|
-
|
|
42
|
-
-
|
|
38
|
+
# DOUBLE-QUOTED on purpose. A bare scalar containing "? " and ": " is not
|
|
39
|
+
# one scalar at all: YAML reads `? a : b` as an explicit key/value pair,
|
|
40
|
+
# so js-yaml hands the loader a mapping instead of an expression node and
|
|
41
|
+
# args[0] becomes an object the launcher cannot use. Quoting makes the
|
|
42
|
+
# scalar unambiguous; the JS string literals inside use single quotes so
|
|
43
|
+
# they cannot collide with the YAML ones.
|
|
44
|
+
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
|
|
45
|
+
- mcp
|
|
46
|
+
env:
|
|
47
|
+
# Only meaningful when the harness itself runs on Electron; harmless
|
|
48
|
+
# for a plain Node host. Explicit is better than relying on this
|
|
49
|
+
# variable happening to survive the child-env scrub.
|
|
50
|
+
ELECTRON_RUN_AS_NODE: '1'
|
|
43
51
|
failOnStartupError: false
|