@co0ontty/wand 4.72.1 → 4.72.2-beta.g26c27fb

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,517 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 把 `render-bin` 子模块里 pin 住的 wand-render 产物 stage 到 `dist/native/<triple>/`,
4
+ * 供 npm 包内嵌(`dist/` 在 package.json 的 `files` 里)。
5
+ *
6
+ * 为什么必须有这一步,而不是运行时去读 `render-bin/`:
7
+ * - npm 全局安装的结果里只有 `dist/` + `browser-extension` + 少量 `scripts/`;
8
+ * `render-bin` 子模块**不会**出现在安装结果里。包要在发布前就把二进制塞进 dist。
9
+ * - 二进制经 npm / git 传输会丢可执行位(legacy 的 node-pty spawn-helper 就栽在这),
10
+ * 所以 stage 完必须显式 chmod 0755,并留下 `.sha256` sidecar 让下游可校验。
11
+ * - 三重校验(sha256 / 可执行且版本一致 / 不是 stub)是硬门槛:曾经有一个只打印
12
+ * `(stub)` 的 302KB 假二进制进过分发目录。校验失败一律**报错退出**,绝不「放行让下游发现」。
13
+ * - `render-bin` 子模块没初始化时要**警告并跳过**(退出码 0):没拉子模块的开发机上
14
+ * `npm run build` 不该因此失败;`--strict` 才是「必须内嵌」的场景(CI 发布链路)。
15
+ *
16
+ * 用法见 `--help`;布局与升级路径见 `docs/render-upgrade-path.md`。
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import { createHash, randomBytes } from "node:crypto";
20
+ import {
21
+ chmodSync,
22
+ copyFileSync,
23
+ mkdirSync,
24
+ mkdtempSync,
25
+ readFileSync,
26
+ renameSync,
27
+ rmSync,
28
+ statSync,
29
+ writeFileSync,
30
+ } from "node:fs";
31
+ import os from "node:os";
32
+ import path from "node:path";
33
+ import process from "node:process";
34
+ import { fileURLToPath } from "node:url";
35
+
36
+ /** 本脚本所在仓库 / npm 包的根目录(脚本随包一起分发)。 */
37
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
38
+
39
+ /** 已发布产物的子模块目录与索引文件名。 */
40
+ export const RENDER_BIN_DIR_NAME = "render-bin";
41
+ export const MANIFEST_FILE_NAME = "manifest.json";
42
+
43
+ export const BINARY_NAME = "wand-render";
44
+ export const VERSION_FILE_NAME = "wand-render.version";
45
+ export const SHA256_FILE_NAME = "wand-render.sha256";
46
+
47
+ /**
48
+ * 支持的平台三元组,必须与 `render-bin/manifest.json` 的键和
49
+ * `src/render-binary.ts` 的 `SUPPORTED_RENDER_TRIPLES` 一致。
50
+ * `src` 侧与 `scripts` 侧的解析逻辑各自独立(TS 不能直接被构建脚本 import),
51
+ * 一致性由 `tests/render-staging.test.ts` 的对照用例守住。
52
+ */
53
+ export const SUPPORTED_TRIPLES = ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"];
54
+
55
+ export const EXIT_OK = 0;
56
+ export const EXIT_FAILED = 1;
57
+
58
+ const VERSION_PROBE_TIMEOUT_MS = 5_000;
59
+ const ARCH_PROBE_TIMEOUT_MS = 1_000;
60
+ const SEMVER = /\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?/;
61
+
62
+ /** `uname -m` 的架构别名 → Node 的 process.arch 命名。 */
63
+ const MACHINE_ARCH_ALIASES = {
64
+ arm64: "arm64",
65
+ aarch64: "arm64",
66
+ x86_64: "x64",
67
+ amd64: "x64",
68
+ x64: "x64",
69
+ };
70
+
71
+ /** `uname -m` 输出归一化;认不出返回 null。 */
72
+ export function normalizeMachineArch(machine) {
73
+ if (typeof machine !== "string") return null;
74
+ return MACHINE_ARCH_ALIASES[machine.trim().toLowerCase()] ?? null;
75
+ }
76
+
77
+ /**
78
+ * 平台三元组;不在支持列表里返回 null(调用方给出明确结论,不静默降级)。
79
+ *
80
+ * 用 `uname -m` 而不是 `process.arch`:macOS 上 Node 跑在 Rosetta 里时它报 `x64`,
81
+ * 机器其实是 arm64;`rosettaTranslated` 是反向兜底(翻译态下 `uname -m` 也报 x86_64)。
82
+ */
83
+ export function resolvePlatformTriple(platform, arch, unameMachine, rosettaTranslated = false) {
84
+ let normalizedArch = normalizeMachineArch(unameMachine) ?? arch;
85
+ if (platform === "darwin" && normalizedArch === "x64" && rosettaTranslated) normalizedArch = "arm64";
86
+ const triple = `${platform}-${normalizedArch}`;
87
+ return SUPPORTED_TRIPLES.includes(triple) ? triple : null;
88
+ }
89
+
90
+ function runUnameMachine() {
91
+ if (process.platform === "win32") return null;
92
+ try {
93
+ const result = spawnSync("uname", ["-m"], {
94
+ encoding: "utf8",
95
+ timeout: ARCH_PROBE_TIMEOUT_MS,
96
+ stdio: ["ignore", "pipe", "ignore"],
97
+ });
98
+ if (result.error || result.status !== 0) return null;
99
+ return (result.stdout ?? "").trim() || null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function isRosettaTranslated() {
106
+ if (process.platform !== "darwin") return false;
107
+ try {
108
+ const result = spawnSync("sysctl", ["-n", "sysctl.proc_translated"], {
109
+ encoding: "utf8",
110
+ timeout: ARCH_PROBE_TIMEOUT_MS,
111
+ stdio: ["ignore", "pipe", "ignore"],
112
+ });
113
+ if (result.error || result.status !== 0) return false;
114
+ return (result.stdout ?? "").trim() === "1";
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /** 文件 sha256(hex,小写)。产物不到 1 MB,整块读即可。 */
121
+ export function sha256File(filePath) {
122
+ return createHash("sha256").update(readFileSync(filePath)).digest("hex");
123
+ }
124
+
125
+ function isFile(candidate) {
126
+ try {
127
+ return statSync(candidate).isFile();
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 执行二进制拿版本。
135
+ *
136
+ * `isStub` 单独判一次是为了给出可读的报错(stub 通常连语义化版本号都没有,
137
+ * 只按版本比对会报「版本不一致」,看不出真正原因)。
138
+ */
139
+ export function probeRenderBinary(binaryPath) {
140
+ const result = spawnSync(binaryPath, ["--version"], {
141
+ encoding: "utf8",
142
+ timeout: VERSION_PROBE_TIMEOUT_MS,
143
+ // 不接 stdin:万一对方是「不认 --version 就直接当守护进程跑」的旧二进制,
144
+ // 也不会因为等 TTY 输入而挂满超时窗口。
145
+ stdio: ["ignore", "pipe", "pipe"],
146
+ });
147
+ const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
148
+ const ran = !result.error && result.status === 0;
149
+ return {
150
+ ran,
151
+ output,
152
+ version: ran ? SEMVER.exec(output)?.[0] ?? null : null,
153
+ isStub: /stub/i.test(output),
154
+ };
155
+ }
156
+
157
+ /**
158
+ * 读 manifest。区分「子模块没初始化」(缺文件 → 可跳过)与「文件坏了」(硬错误)。
159
+ */
160
+ export function readManifest(manifestPath) {
161
+ if (!isFile(manifestPath)) return { ok: false, missing: true, error: `${manifestPath} 不存在` };
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
165
+ } catch (error) {
166
+ return { ok: false, missing: false, error: `${manifestPath} 不是合法 JSON:${error instanceof Error ? error.message : String(error)}` };
167
+ }
168
+ const latest = parsed && typeof parsed.latest === "string" ? parsed.latest : null;
169
+ const entry = latest && parsed.versions && typeof parsed.versions === "object" ? parsed.versions[latest] : null;
170
+ if (!latest || !entry || typeof entry !== "object") {
171
+ return { ok: false, missing: false, error: `${manifestPath} 缺少 latest/versions 结构` };
172
+ }
173
+ const triples = entry.triples && typeof entry.triples === "object" ? entry.triples : null;
174
+ if (!triples) {
175
+ return { ok: false, missing: false, error: `${manifestPath} 的 versions["${latest}"] 缺少 triples` };
176
+ }
177
+ return { ok: true, missing: false, data: { latest, entry, triples } };
178
+ }
179
+
180
+ /** 在临时目录里造一份 0755 的可执行副本再探测(不碰 dist/,也不假设源文件带可执行位)。 */
181
+ function withExecutableCopy(sourcePath, body) {
182
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "wand-render-probe-"));
183
+ try {
184
+ const tempPath = path.join(tempDir, BINARY_NAME);
185
+ copyFileSync(sourcePath, tempPath);
186
+ chmodSync(tempPath, 0o755);
187
+ return body(tempPath);
188
+ } finally {
189
+ rmSync(tempDir, { recursive: true, force: true });
190
+ }
191
+ }
192
+
193
+ /**
194
+ * 校验单个产物:尺寸、sha256、能否执行、版本是否与 manifest 一致、是否 stub。
195
+ * @returns {string | null} 通过返回 null,否则返回可直接打印的原因
196
+ */
197
+ function verifyArtifact({ triple, artifact, sourcePath, expectedVersion }) {
198
+ if (!isFile(sourcePath)) return `${triple}: 产物缺失 ${sourcePath}(manifest 指向的路径不对?)`;
199
+
200
+ const actualSize = statSync(sourcePath).size;
201
+ if (typeof artifact.size === "number" && actualSize !== artifact.size) {
202
+ return `${triple}: 尺寸与 manifest 不一致(实际 ${actualSize},manifest ${artifact.size})`;
203
+ }
204
+ const actualSha = sha256File(sourcePath);
205
+ const expectedSha = typeof artifact.sha256 === "string" ? artifact.sha256.trim().toLowerCase() : "";
206
+ if (!expectedSha) return `${triple}: manifest 里没有 sha256,拒绝安装未固定哈希的产物`;
207
+ if (actualSha !== expectedSha) {
208
+ return `${triple}: sha256 与 manifest 不一致(实际 ${actualSha},manifest ${expectedSha})——产物被改过或仓库损坏`;
209
+ }
210
+
211
+ const probe = withExecutableCopy(sourcePath, probeRenderBinary);
212
+ if (!probe.ran) {
213
+ return `${triple}: 无法执行 ${sourcePath} --version(架构不符 / 动态库缺失 / 文件损坏):${probe.output || "无输出"}`;
214
+ }
215
+ if (probe.isStub) return `${triple}: 产物是 stub(--version 输出 ${JSON.stringify(probe.output)}),拒绝进入分发目录`;
216
+ if (!probe.version) return `${triple}: --version 输出里读不出语义化版本号:${JSON.stringify(probe.output)}`;
217
+ if (probe.version !== expectedVersion) {
218
+ return `${triple}: 产物版本 ${probe.version} 与 manifest latest ${expectedVersion} 不一致`;
219
+ }
220
+ return null;
221
+ }
222
+
223
+ function writeTextAtomically(filePath, content) {
224
+ const tempPath = `${filePath}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
225
+ try {
226
+ writeFileSync(tempPath, content, { mode: 0o644 });
227
+ renameSync(tempPath, filePath);
228
+ } catch (error) {
229
+ try {
230
+ rmSync(tempPath, { force: true });
231
+ } catch {
232
+ // 清理失败不该掩盖真正的错误
233
+ }
234
+ throw error;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * 把单个 triple 的产物 stage 到 dist。写路径用「临时文件 → chmod → rename」:
240
+ * 绝不留下半截的 `wand-render`,也绝不就地改写一个可能正在被执行的二进制。
241
+ * @returns {{ error: string | null, targetPath: string, sha256: string, version: string }}
242
+ */
243
+ function stageTriple({ triple, artifact, sourcePath, expectedVersion, distDir, write }) {
244
+ const targetPath = path.join(distDir, "native", triple, BINARY_NAME);
245
+ const sha256 = sha256File(sourcePath);
246
+ if (!write) return { error: null, targetPath, sha256, version: expectedVersion };
247
+
248
+ mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 });
249
+ const tempPath = `${targetPath}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
250
+ try {
251
+ copyFileSync(sourcePath, tempPath);
252
+ // 可执行位必须在 rename 之前设好:先就位再 chmod 会留下一个短暂的不可执行文件。
253
+ chmodSync(tempPath, 0o755);
254
+ const probe = probeRenderBinary(tempPath);
255
+ if (!probe.ran || probe.isStub || probe.version !== expectedVersion) {
256
+ throw new Error(
257
+ `就位后的副本自检失败(ran=${probe.ran} stub=${probe.isStub} version=${probe.version},期望 ${expectedVersion}):${probe.output || "无输出"}`,
258
+ );
259
+ }
260
+ renameSync(tempPath, targetPath);
261
+ } catch (error) {
262
+ try {
263
+ rmSync(tempPath, { force: true });
264
+ } catch {
265
+ // 同上
266
+ }
267
+ return { error: `${triple}: ${error instanceof Error ? error.message : String(error)}`, targetPath, sha256, version: expectedVersion };
268
+ }
269
+ writeTextAtomically(path.join(path.dirname(targetPath), VERSION_FILE_NAME), `${expectedVersion}\n`);
270
+ writeTextAtomically(path.join(path.dirname(targetPath), SHA256_FILE_NAME), `${sha256} ${BINARY_NAME}\n`);
271
+ return { error: null, targetPath, sha256, version: expectedVersion };
272
+ }
273
+
274
+ /**
275
+ * 核心实现(除了 dist/native 之外不写任何磁盘;不联网、不读 config、不杀进程)。
276
+ *
277
+ * @param {{
278
+ * check?: boolean, dryRun?: boolean, all?: boolean, strict?: boolean,
279
+ * triples?: string[], packageRoot?: string, renderBinDir?: string, distDir?: string,
280
+ * platform?: string, arch?: string, unameMachine?: string | null, rosettaTranslated?: boolean,
281
+ * logger?: (message: string) => void,
282
+ * }} [options]
283
+ * @returns {{
284
+ * exitCode: number,
285
+ * action: "staged" | "would-stage" | "checked" | "skipped" | "failed",
286
+ * version: string | null,
287
+ * triples: string[],
288
+ * staged: { triple: string, sourcePath: string, targetPath: string, sha256: string, version: string }[],
289
+ * warnings: string[],
290
+ * messages: string[],
291
+ * }}
292
+ */
293
+ export function stageRenderBinaries(options = {}) {
294
+ const messages = [];
295
+ const log = (message) => {
296
+ messages.push(message);
297
+ (options.logger ?? (() => {}))(message);
298
+ };
299
+ const warnings = [];
300
+ const warn = (message) => {
301
+ warnings.push(message);
302
+ log(`warning: ${message}`);
303
+ };
304
+
305
+ const check = options.check === true;
306
+ const dryRun = options.dryRun === true;
307
+ const strict = options.strict === true;
308
+ const all = options.all === true;
309
+ const requested = (options.triples ?? []).filter((triple) => typeof triple === "string" && triple);
310
+ const packageRoot = path.resolve(options.packageRoot ?? PACKAGE_ROOT);
311
+ const renderBinDir = path.resolve(options.renderBinDir ?? path.join(packageRoot, RENDER_BIN_DIR_NAME));
312
+ const distDir = path.resolve(options.distDir ?? path.join(packageRoot, "dist"));
313
+ const manifestPath = path.join(renderBinDir, MANIFEST_FILE_NAME);
314
+ const platform = options.platform ?? process.platform;
315
+ const arch = options.arch ?? process.arch;
316
+ const unameMachine = options.unameMachine !== undefined ? options.unameMachine : runUnameMachine();
317
+ const rosettaTranslated = options.rosettaTranslated ?? isRosettaTranslated();
318
+ const base = { version: null, triples: [], staged: [], warnings, messages };
319
+
320
+ if (all && requested.length > 0) {
321
+ log("error: --all 与 --triple 不能同时使用");
322
+ return { ...base, exitCode: EXIT_FAILED, action: "failed" };
323
+ }
324
+
325
+ const manifest = readManifest(manifestPath);
326
+ if (!manifest.ok) {
327
+ if (manifest.missing && !strict) {
328
+ // 没拉子模块的开发机不该因为缺少发布产物而构建失败。
329
+ warn(`${manifest.error};跳过内嵌平台二进制(本机构建仍可用,只是包里没有 wand-render)`);
330
+ log(`需要内嵌产物时:git submodule update --init ${RENDER_BIN_DIR_NAME}`);
331
+ return { ...base, exitCode: EXIT_OK, action: "skipped" };
332
+ }
333
+ log(`error: ${manifest.error}${manifest.missing ? "(--strict 下视为失败)" : ""}`);
334
+ return { ...base, exitCode: EXIT_FAILED, action: "failed" };
335
+ }
336
+
337
+ const { latest, triples: versionTriples } = manifest.data;
338
+ const available = Object.keys(versionTriples);
339
+ log(`${RENDER_BIN_DIR_NAME} v${latest}(协议 ${JSON.stringify(manifest.data.entry.protocolVersion ?? "?")}),可用平台:${available.join(", ") || "无"}`);
340
+ log(`目标目录:${path.join(distDir, "native")}`);
341
+
342
+ let selected;
343
+ if (all) {
344
+ selected = available.slice().sort();
345
+ } else if (requested.length > 0) {
346
+ const unknown = requested.filter((triple) => !available.includes(triple));
347
+ if (unknown.length > 0) {
348
+ log(`error: manifest v${latest} 里没有这些平台:${unknown.join(", ")}`);
349
+ return { ...base, version: latest, exitCode: EXIT_FAILED, action: "failed" };
350
+ }
351
+ selected = requested;
352
+ } else {
353
+ const triple = resolvePlatformTriple(platform, arch, unameMachine, rosettaTranslated);
354
+ if (!triple) {
355
+ warn(`不支持的平台:${platform}-${normalizeMachineArch(unameMachine) ?? arch}(支持 ${SUPPORTED_TRIPLES.join(" / ")})`);
356
+ return { ...base, version: latest, exitCode: strict ? EXIT_FAILED : EXIT_OK, action: strict ? "failed" : "skipped" };
357
+ }
358
+ if (!available.includes(triple)) {
359
+ warn(`manifest v${latest} 里没有 ${triple} 的产物(本平台可能未分发)`);
360
+ return { ...base, version: latest, exitCode: strict ? EXIT_FAILED : EXIT_OK, action: strict ? "failed" : "skipped" };
361
+ }
362
+ selected = [triple];
363
+ }
364
+
365
+ const staged = [];
366
+ const failures = [];
367
+ for (const triple of selected) {
368
+ const artifact = versionTriples[triple] ?? {};
369
+ const sourcePath = path.join(renderBinDir, typeof artifact.path === "string" && artifact.path ? artifact.path : path.join(`v${latest}`, triple, BINARY_NAME));
370
+ const problem = verifyArtifact({ triple, artifact, sourcePath, expectedVersion: latest });
371
+ if (problem) {
372
+ failures.push(problem);
373
+ log(`error: ${problem}`);
374
+ continue;
375
+ }
376
+ const sizeLabel = typeof artifact.size === "number" ? `${artifact.size} bytes` : "size 未知";
377
+ log(`${triple}: 校验通过(sha256 ${sha256File(sourcePath).slice(0, 12)}…,${sizeLabel},v${latest})`);
378
+
379
+ const result = stageTriple({ triple, artifact, sourcePath, expectedVersion: latest, distDir, write: !check && !dryRun });
380
+ if (result.error) {
381
+ failures.push(result.error);
382
+ log(`error: ${result.error}`);
383
+ continue;
384
+ }
385
+ staged.push({ triple, sourcePath, targetPath: result.targetPath, sha256: result.sha256, version: result.version });
386
+
387
+ if (check) {
388
+ if (!isFile(result.targetPath)) {
389
+ warn(`${result.targetPath} 尚未 stage(构建时由本脚本写入)`);
390
+ if (strict) {
391
+ const message = `${triple}: --check --strict 要求 dist 里已经有产物,但 ${result.targetPath} 不存在`;
392
+ failures.push(message);
393
+ log(`error: ${message}`);
394
+ }
395
+ } else {
396
+ const stagedSha = sha256File(result.targetPath);
397
+ if (stagedSha !== result.sha256) {
398
+ failures.push(`${triple}: 已 stage 的 ${result.targetPath} sha256 与产物不一致(实际 ${stagedSha})`);
399
+ log(`error: ${failures[failures.length - 1]}`);
400
+ } else {
401
+ log(`${triple}: 已 stage 的副本一致(${result.targetPath})`);
402
+ }
403
+ }
404
+ continue;
405
+ }
406
+ if (dryRun) {
407
+ log(`${triple}: 将写入 ${result.targetPath}(mode 0755)与 ${VERSION_FILE_NAME} / ${SHA256_FILE_NAME} sidecar`);
408
+ continue;
409
+ }
410
+ log(`${triple}: 已 stage ${result.targetPath}(mode 0755,v${latest})`);
411
+ }
412
+
413
+ if (failures.length > 0) {
414
+ log(`error: ${failures.length} 个平台校验/就位失败,未写入的产物不会被内嵌`);
415
+ return { ...base, version: latest, triples: selected, staged, exitCode: EXIT_FAILED, action: "failed" };
416
+ }
417
+ const action = check ? "checked" : dryRun ? "would-stage" : "staged";
418
+ return { ...base, version: latest, triples: selected, staged, exitCode: EXIT_OK, action };
419
+ }
420
+
421
+ /**
422
+ * @param {string[]} argv
423
+ * @returns {{
424
+ * check: boolean, dryRun: boolean, all: boolean, strict: boolean, help: boolean,
425
+ * triples: string[], renderBinDir?: string, distDir?: string, error?: string,
426
+ * }}
427
+ */
428
+ export function parseArgs(argv) {
429
+ const options = { check: false, dryRun: false, all: false, strict: false, help: false, triples: [] };
430
+ for (let index = 0; index < argv.length; index += 1) {
431
+ const arg = argv[index];
432
+ const [name, inlineValue] = arg.startsWith("--") && arg.includes("=")
433
+ ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)]
434
+ : [arg, null];
435
+ const takeValue = () => {
436
+ if (inlineValue !== null) return inlineValue;
437
+ index += 1;
438
+ return argv[index];
439
+ };
440
+ if (name === "--check") options.check = true;
441
+ else if (name === "--dry-run") options.dryRun = true;
442
+ else if (name === "--all") options.all = true;
443
+ else if (name === "--strict") options.strict = true;
444
+ else if (name === "--help" || name === "-h") options.help = true;
445
+ else if (name === "--triple") {
446
+ const value = takeValue();
447
+ if (!value) return { ...options, error: "--triple 需要一个三元组名(如 darwin-arm64)" };
448
+ options.triples.push(value);
449
+ } else if (name === "--render-bin") {
450
+ const value = takeValue();
451
+ if (!value) return { ...options, error: "--render-bin 需要一个目录" };
452
+ options.renderBinDir = value;
453
+ } else if (name === "--dist") {
454
+ const value = takeValue();
455
+ if (!value) return { ...options, error: "--dist 需要一个目录" };
456
+ options.distDir = value;
457
+ } else {
458
+ return { ...options, error: `未知参数 ${arg}` };
459
+ }
460
+ }
461
+ return options;
462
+ }
463
+
464
+ const USAGE = `用法:node scripts/stage-render-binaries.js [选项]
465
+
466
+ 把 ${RENDER_BIN_DIR_NAME}/manifest.json 里 pin 住的 wand-render 产物 stage 到 dist/native/<triple>/,
467
+ 供 npm 包内嵌(dist/ 在 package.json 的 files 里)。
468
+
469
+ 选项:
470
+ --triple <name> 只 stage 指定三元组(可重复;默认按当前平台,含 Rosetta / uname 判定)
471
+ --all stage manifest 列出的所有平台
472
+ --check 只校验(sha256 / 可执行 / 版本 / stub;并比对已 stage 的副本),不写磁盘
473
+ --dry-run 只报告将写入什么,不写磁盘(仍做全部校验)
474
+ --strict ${RENDER_BIN_DIR_NAME} 子模块未初始化 / 本平台无产物 / --check 时 dist 里没有已 stage 的产物 → 视为失败(CI 发布链路用)
475
+ --render-bin <dir> 指定 ${RENDER_BIN_DIR_NAME} 目录(默认 <pkg>/${RENDER_BIN_DIR_NAME})
476
+ --dist <dir> 指定 dist 目录(默认 <pkg>/dist)
477
+ -h, --help 显示本帮助
478
+
479
+ 退出码:
480
+ 0 已 stage / 可 stage / --check 校验通过(子模块未初始化时也是 0:警告并跳过)
481
+ 1 校验失败(sha256 不符 / 版本不符 / stub / 无法执行)、参数错误、--strict 下的缺产物
482
+
483
+ 双重校验说明:sha256 是唯一「产物是否被改动」的判据(manifest 是 pin);--version 自检
484
+ 是唯一「这个二进制真能跑、且不是 stub」的判据。两者都不通过就不写进 dist。`;
485
+
486
+ /**
487
+ * @param {string[]} argv
488
+ * @returns {number} 退出码
489
+ */
490
+ export function main(argv) {
491
+ const options = parseArgs(argv);
492
+ if (options.help) {
493
+ console.log(USAGE);
494
+ return EXIT_OK;
495
+ }
496
+ if (options.error) {
497
+ console.error(`error: ${options.error}`);
498
+ console.error(USAGE);
499
+ return EXIT_FAILED;
500
+ }
501
+ const result = stageRenderBinaries({
502
+ check: options.check,
503
+ dryRun: options.dryRun,
504
+ all: options.all,
505
+ strict: options.strict,
506
+ triples: options.triples,
507
+ renderBinDir: options.renderBinDir,
508
+ distDir: options.distDir,
509
+ logger: (message) => console.log(`[wand-render] ${message}`),
510
+ });
511
+ return result.exitCode;
512
+ }
513
+
514
+ // 只有被直接执行时才跑 main;被 import(测试 / Server 自检)时只暴露函数。
515
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
516
+ process.exitCode = main(process.argv.slice(2));
517
+ }