@1e0zj/dsh-plugin-mall 0.1.12 → 0.1.15

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/src/installer.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // @deepseek-ai/dsh-app-boot APIs for profile resolution and initialization.
8
8
 
9
9
  import { spawn } from "node:child_process";
10
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { createRequire } from "node:module";
13
13
  import { load } from "js-yaml";
@@ -31,6 +31,69 @@ export function normalizeSpec(raw) {
31
31
  return spec; // bare npm package name
32
32
  }
33
33
 
34
+ // ── guarded config writes ───────────────────────────────────────────────────
35
+ //
36
+ // Installing someone else's plugin must never be able to leave a profile that
37
+ // dsh or pnpm refuses to load. Every write to a shared profile config goes
38
+ // through here: the new bytes are parsed back, and anything that does not
39
+ // parse is rolled back to the previous bytes before the error propagates. A
40
+ // bug in our own editing then costs a failed install, not a profile the user
41
+ // has to repair by hand.
42
+ //
43
+ // This is not hypothetical. Editing `allowBuilds` as a YAML sequence while
44
+ // pnpm had already written its own mapping stub produced a file no parser
45
+ // accepted, and every later pnpm operation in that profile — install,
46
+ // uninstall, update, any plugin at all — failed until the file was fixed
47
+ // manually.
48
+
49
+ /**
50
+ * Write a config file only if the result still parses.
51
+ * @param filePath - the file to write.
52
+ * @param nextContent - the full new contents.
53
+ * @param parse - throws when the content is not valid.
54
+ * @param label - file name used in error messages.
55
+ * @returns a rollback function restoring the pre-write bytes.
56
+ */
57
+ function writeChecked(filePath, nextContent, parse, label) {
58
+ const previous = existsSync(filePath) ? readFileSync(filePath, "utf8") : undefined;
59
+ const rollback = () => {
60
+ if (previous === undefined) rmSync(filePath, { force: true });
61
+ else writeFileSync(filePath, previous);
62
+ };
63
+ writeFileSync(filePath, nextContent);
64
+ try {
65
+ parse(nextContent);
66
+ } catch (error) {
67
+ rollback();
68
+ throw new Error(`${label} would not parse after the edit and was restored unchanged (${error.message}) — this is a bug in dsh-plugin-mall, please report it`);
69
+ }
70
+ return rollback;
71
+ }
72
+
73
+ /** writeChecked for YAML profile configs. */
74
+ function writeYamlChecked(filePath, nextContent, label) {
75
+ return writeChecked(filePath, nextContent, (text) => load(text), label);
76
+ }
77
+
78
+ /** writeChecked for JSON profile configs. */
79
+ function writeJsonChecked(filePath, nextContent, label) {
80
+ return writeChecked(filePath, nextContent, (text) => JSON.parse(text), label);
81
+ }
82
+
83
+ /**
84
+ * writeChecked for cordis.patch.yml — the loader patch layer. Beyond parsing,
85
+ * the contract is a top-level array; anything else and dsh fails to boot, so
86
+ * that is checked here too rather than discovered at the next start.
87
+ */
88
+ function writePatchChecked(filePath, nextContent) {
89
+ return writeChecked(filePath, nextContent, (text) => {
90
+ const doc = load(text);
91
+ if (doc !== null && doc !== undefined && !Array.isArray(doc)) {
92
+ throw new Error("expected a top-level array of patch entries");
93
+ }
94
+ }, "cordis.patch.yml");
95
+ }
96
+
34
97
  // ── profile management ──────────────────────────────────────────────────────
35
98
 
36
99
  /** Resolve and initialize (on first use) the target profile directory. */
@@ -115,7 +178,7 @@ export function reconcileBundles(profileDir, beforeDeps = new Set()) {
115
178
  bundles: result,
116
179
  },
117
180
  };
118
- writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + "\n");
181
+ writeJsonChecked(manifestPath, JSON.stringify(manifest, undefined, 2) + "\n", "package.json");
119
182
  }
120
183
  return result;
121
184
  }
@@ -144,6 +207,91 @@ export function listInstalled(profile) {
144
207
  return { dir, deps };
145
208
  }
146
209
 
210
+ // ── npm registry resolution ─────────────────────────────────────────────────
211
+ //
212
+ // Registry lookups (anti-squatting, update checks, the host-shadow guard) have
213
+ // to hit the same registry pnpm installs from. Hardcoding npmjs while the user
214
+ // is on a mirror breaks all three silently — see the header comment in
215
+ // github.js. Resolution order mirrors pnpm's own: the profile's .npmrc, then
216
+ // `pnpm config get registry` (which folds in the user and global .npmrc
217
+ // chain), then npmjs. Cached per profile for the process lifetime; changing a
218
+ // registry needs a dsh restart anyway, like every other profile setting.
219
+
220
+ const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
221
+ const registryCache = new Map(); // profile -> Promise<string>
222
+
223
+ /** The `registry=` value from a profile-local .npmrc, if it sets one. */
224
+ function registryFromNpmrc(profileDir) {
225
+ const npmrcPath = join(profileDir, ".npmrc");
226
+ if (!existsSync(npmrcPath)) return undefined;
227
+ try {
228
+ for (const line of readFileSync(npmrcPath, "utf8").split("\n")) {
229
+ const match = /^\s*registry\s*=\s*(\S+)\s*$/.exec(line);
230
+ if (match !== null) return match[1];
231
+ }
232
+ } catch {
233
+ /* unreadable .npmrc — fall through to pnpm */
234
+ }
235
+ return undefined;
236
+ }
237
+
238
+ /** `pnpm config get registry`, or undefined if pnpm is missing, slow, or unset. */
239
+ function registryFromPnpm() {
240
+ return new Promise((resolve) => {
241
+ let proc;
242
+ try {
243
+ proc = spawn("pnpm", ["config", "get", "registry"], {
244
+ env: process.env,
245
+ shell: process.platform === "win32",
246
+ stdio: ["ignore", "pipe", "pipe"],
247
+ windowsHide: true,
248
+ });
249
+ } catch {
250
+ resolve(undefined);
251
+ return;
252
+ }
253
+ let out = "";
254
+ // 一次安装不该被一个探测子进程拖住:5s 没结果就当没有,走兜底。
255
+ const timer = setTimeout(() => {
256
+ proc.kill();
257
+ resolve(undefined);
258
+ }, 5000);
259
+ proc.stdout?.on("data", (data) => { out += data.toString(); });
260
+ proc.on("error", () => { clearTimeout(timer); resolve(undefined); });
261
+ proc.on("close", (code) => {
262
+ clearTimeout(timer);
263
+ const value = out.trim();
264
+ // pnpm prints "undefined" for an unset key — only take a real URL.
265
+ resolve(code === 0 && /^https?:\/\//i.test(value) ? value : undefined);
266
+ });
267
+ });
268
+ }
269
+
270
+ /**
271
+ * The registry pnpm installs from for this profile.
272
+ * @param profile - profile name.
273
+ * @returns a promise for the registry base URL, without a trailing slash.
274
+ */
275
+ export function resolveRegistry(profile) {
276
+ const key = String(profile ?? "");
277
+ const cached = registryCache.get(key);
278
+ if (cached !== undefined) return cached;
279
+ const pending = (async () => {
280
+ let dir;
281
+ try {
282
+ dir = resolveProfileDir(key);
283
+ } catch {
284
+ dir = undefined; // invalid profile name — the caller reports it, we just fall back
285
+ }
286
+ const fromNpmrc = dir === undefined ? undefined : registryFromNpmrc(dir);
287
+ if (fromNpmrc !== undefined) return fromNpmrc.replace(/\/+$/, "");
288
+ const fromPnpm = await registryFromPnpm();
289
+ return (fromPnpm ?? DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
290
+ })();
291
+ registryCache.set(key, pending);
292
+ return pending;
293
+ }
294
+
147
295
  // ── client-plugin row registration ──────────────────────────────────────────
148
296
 
149
297
  /** Profile patch file name (the user's own layer, applied after bundle layers). */
@@ -178,12 +326,12 @@ export function ensureClientRow(profileDir, packageName) {
178
326
  if (alreadyByName) return { added: false };
179
327
  const rowId = clientRowId(packageName);
180
328
  const block = `- insert:\n - id: ${rowId}\n name: '${packageName}'\n`;
181
- if (Array.isArray(parsed) && parsed.length === 0) {
329
+ const next = (Array.isArray(parsed) && parsed.length === 0)
182
330
  // The stock template is a comment plus `[]`; replace it wholesale.
183
- writeFileSync(patchPath, block);
184
- } else {
185
- writeFileSync(patchPath, content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`);
186
- }
331
+ ? block
332
+ : content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
333
+ // 这个文件是 dsh 的装配补丁层:写坏了宿主直接起不来。
334
+ writePatchChecked(patchPath, next);
187
335
  return { added: true, rowId };
188
336
  }
189
337
 
@@ -211,7 +359,7 @@ export function removeClientRow(profileDir, packageName) {
211
359
  }
212
360
  if (!removed) return { removed: false };
213
361
  const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
214
- writeFileSync(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
362
+ writePatchChecked(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
215
363
  return { removed: true, rowId };
216
364
  }
217
365
 
@@ -243,40 +391,95 @@ function parseIgnoredBuilds(output) {
243
391
  return [...names];
244
392
  }
245
393
 
246
- /** Merge new names into the profile's pnpm-workspace.yaml `allowBuilds` list. */
247
- function ensureAllowBuilds(profileDir, names) {
248
- const valid = names.filter((name) => NPM_NAME_RE.test(name));
249
- if (valid.length === 0) return;
250
- const workspacePath = join(profileDir, "pnpm-workspace.yaml");
251
- let content = existsSync(workspacePath)
252
- ? readFileSync(workspacePath, "utf8")
253
- : "packages:\n - .\n\nnodeLinker: hoisted\n";
394
+ /**
395
+ * Merge names into the profile's `pnpm-workspace.yaml` `allowBuilds`.
396
+ *
397
+ * pnpm accepts both shapes — a sequence (`- name`) and a mapping
398
+ * (`name: true`) — but never both under one key, and pnpm writes a mapping
399
+ * stub of its own (`name: set this to true or false`) when it blocks a build.
400
+ * Appending a sequence item to that stub is what produced an unparseable file.
401
+ * So: read the current shape through a real YAML parse, match it, and default
402
+ * to pnpm's own mapping shape when there is nothing to match, which keeps our
403
+ * edits and pnpm's from ever colliding again.
404
+ *
405
+ * A name already present as a mapping entry whose value is NOT `true` (pnpm's
406
+ * undecided stub) is rewritten rather than skipped — treating the stub as
407
+ * "already allowed" would leave the build still blocked on retry.
408
+ *
409
+ * Pure: takes the current file contents, returns the new contents (or
410
+ * undefined when nothing needs changing). The fs half is ensureAllowBuilds.
411
+ * Split out because this text surgery is the part that broke a profile, so it
412
+ * is the part the fixtures at the bottom of this file have to pin.
413
+ *
414
+ * @param content - current pnpm-workspace.yaml contents.
415
+ * @param names - package names to allow.
416
+ * @returns the new contents, or undefined when already satisfied.
417
+ * @throws when `content` does not parse as YAML.
418
+ */
419
+ export function mergeAllowBuilds(content, names) {
420
+ const valid = (Array.isArray(names) ? names : []).filter((name) => NPM_NAME_RE.test(name));
421
+ if (valid.length === 0) return undefined;
422
+ // A file that is already broken is not ours to edit — we could only make it
423
+ // worse, and the user needs to see the real reason.
424
+ let parsed;
425
+ try {
426
+ parsed = load(content);
427
+ } catch (error) {
428
+ throw new Error(`pnpm-workspace.yaml does not parse, refusing to edit it: ${error.message}`);
429
+ }
430
+ const current = parsed?.allowBuilds;
431
+ const asSequence = Array.isArray(current);
432
+ const asMapping = !asSequence && current !== null && typeof current === "object";
433
+ const allowed = new Set(asSequence
434
+ ? current.map((entry) => String(entry))
435
+ : asMapping ? Object.entries(current).filter(([, value]) => value === true).map(([key]) => key) : []);
436
+ // pnpm 的未决占位符(值不是 true)要改写,不能当成已放行跳过。
437
+ const stubs = asMapping ? valid.filter((name) => name in current && current[name] !== true) : [];
438
+ const additions = valid.filter((name) => !allowed.has(name) && !stubs.includes(name));
439
+ if (additions.length === 0 && stubs.length === 0) return undefined;
440
+
441
+ // Quote sequence entries: a bare `@scope/name` opens with YAML's reserved
442
+ // `@` indicator. Mapping keys do not need it.
443
+ const render = (name) => (asSequence ? ` - '${name}'` : ` ${name}: true`);
254
444
  const lines = content.split("\n");
445
+ for (const name of stubs) {
446
+ const pattern = new RegExp(`^(\\s*)(['"]?)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\2\\s*:.*$`);
447
+ const index = lines.findIndex((line) => pattern.test(line));
448
+ if (index !== -1) lines[index] = ` ${name}: true`;
449
+ }
450
+ let next;
255
451
  const keyIndex = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
256
452
  if (keyIndex === -1) {
257
- if (!content.endsWith("\n")) content += "\n";
258
- // Quote every entry: a bare `@scope/name` starts with YAML's reserved
259
- // `@` indicator and fails to parse.
260
- content += `\nallowBuilds:\n${valid.map((name) => ` - '${name}'`).join("\n")}\n`;
453
+ const base = lines.join("\n");
454
+ next = `${base.endsWith("\n") ? base : `${base}\n`}\nallowBuilds:\n${additions.map(render).join("\n")}\n`;
261
455
  } else {
262
- const existing = new Set();
456
+ // 块内 = 缩进行;空行不终止块;顶格行是下一个 key。
263
457
  let insertIndex = keyIndex + 1;
264
458
  for (let index = keyIndex + 1; index < lines.length; index++) {
265
- const item = /^\s*-\s+(.+?)\s*$/.exec(lines[index]);
266
- if (item) {
267
- existing.add(item[1].replace(/^['"](.*)['"]$/, "$1"));
268
- insertIndex = index + 1;
269
- continue;
270
- }
271
- if (/^\S/.test(lines[index])) break;
272
- }
273
- const additions = valid.filter((name) => !existing.has(name));
274
- if (additions.length > 0) {
275
- lines.splice(insertIndex, 0, ...additions.map((name) => ` - '${name}'`));
276
- content = lines.join("\n");
459
+ if (lines[index].trim().length === 0) continue;
460
+ if (/^\s/.test(lines[index])) { insertIndex = index + 1; continue; }
461
+ break;
277
462
  }
463
+ if (additions.length > 0) lines.splice(insertIndex, 0, ...additions.map(render));
464
+ next = lines.join("\n");
278
465
  }
279
- writeFileSync(workspacePath, content);
466
+ return next;
467
+ }
468
+
469
+ /** Default pnpm-workspace.yaml for a profile that has none yet. */
470
+ const DEFAULT_WORKSPACE_YAML = "packages:\n - .\n\nnodeLinker: hoisted\n";
471
+
472
+ /**
473
+ * Apply mergeAllowBuilds to the profile's pnpm-workspace.yaml through the
474
+ * guarded writer.
475
+ * @returns a rollback function, or undefined when nothing needed changing.
476
+ */
477
+ function ensureAllowBuilds(profileDir, names) {
478
+ const workspacePath = join(profileDir, "pnpm-workspace.yaml");
479
+ const content = existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : DEFAULT_WORKSPACE_YAML;
480
+ const next = mergeAllowBuilds(content, names);
481
+ if (next === undefined) return undefined;
482
+ return writeYamlChecked(workspacePath, next, "pnpm-workspace.yaml");
280
483
  }
281
484
 
282
485
  // ── in-process install tracker (browser RPC surface) ────────────────────────
@@ -370,8 +573,16 @@ const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
370
573
 
371
574
  /** Reject install/remove specs carrying shell metacharacters. */
372
575
  export function assertSafeSpec(spec) {
373
- if (UNSAFE_SPEC_RE.test(String(spec ?? ""))) {
374
- throw new Error(`spec contains characters that are not allowed in an install spec: ${JSON.stringify(String(spec))}`);
576
+ const value = String(spec ?? "");
577
+ if (UNSAFE_SPEC_RE.test(value)) {
578
+ throw new Error(`spec contains characters that are not allowed in an install spec: ${JSON.stringify(value)}`);
579
+ }
580
+ // Windows 下 pnpm 走 shell,而 Node 只是把参数用空格 join 后交给 cmd,
581
+ // 不逐参加引号——带空格的本地路径会被拆成两个参数,pnpm 报一个和路径
582
+ // 毫无关系的错。用户也没法自己加引号绕过:`"` 就在上面的黑名单里。
583
+ // 与其让它以看不懂的方式失败,不如在这里说清楚。
584
+ if (process.platform === "win32" && /^(?:file:|link:)/i.test(value) && /\s/.test(value)) {
585
+ throw new Error(`local path specs cannot contain spaces on Windows — pnpm is spawned through cmd, which would split the path into two arguments: ${JSON.stringify(value)}`);
375
586
  }
376
587
  }
377
588
 
@@ -498,18 +709,39 @@ export function runInstall({ profile, spec }) {
498
709
  if (ignored.length === 0) {
499
710
  return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
500
711
  }
501
- push(`\n[dsh-plugin-mall] pnpm blocked build scripts: ${ignored.join(", ")} — merging into allowBuilds and retrying once\n`);
502
- ensureAllowBuilds(profileDir, ignored);
712
+ // 放行构建脚本 = 允许这些包在安装期执行自己的任意代码,正是 pnpm 默认
713
+ // 拦下来的东西。说明白,别让它淹在 pnpm 的刷屏输出里。
714
+ push(`\n[dsh-plugin-mall] pnpm blocked install scripts for: ${ignored.join(", ")}\n`);
715
+ push(`[dsh-plugin-mall] allowing them in the profile's pnpm-workspace.yaml and retrying once — these packages will run their own install-time code.\n`);
716
+ let rollbackAllowBuilds;
717
+ try {
718
+ rollbackAllowBuilds = ensureAllowBuilds(profileDir, ignored);
719
+ } catch (error) {
720
+ return { status: "failed", detail: `could not allow the blocked build scripts: ${error.message}. The profile was left untouched — approve them yourself with \`pnpm approve-builds\` in ${profileDir}, then retry.` };
721
+ }
722
+ // allowBuilds 是持久化的安全配置。为一次没装成的插件单向放宽它,等于以后
723
+ // 这个包名再出现(哪怕是别人的传递依赖)就静默放行——失败必须收回。
724
+ const revert = () => {
725
+ if (rollbackAllowBuilds === undefined) return;
726
+ try {
727
+ rollbackAllowBuilds();
728
+ push("[dsh-plugin-mall] install failed — reverted the allowBuilds change, the profile is as it was\n");
729
+ } catch {
730
+ /* 还原失败不该盖掉真正的失败原因 */
731
+ }
732
+ };
503
733
  const retry = spawnAdd();
504
734
  current = retry.proc;
505
735
  const retryOutcome = await retry.done;
506
736
  if (retryOutcome.spawnError !== undefined) {
737
+ revert();
507
738
  return { status: "failed", detail: `retry could not start pnpm: ${retryOutcome.spawnError.message}` };
508
739
  }
509
740
  if (retryOutcome.exitCode === 0) {
510
741
  return finalizeSuccess();
511
742
  }
512
- return { status: "failed", detail: `pnpm add ${spec} still failed after allowing build scripts (exit code ${retryOutcome.exitCode}). See job output.` };
743
+ revert();
744
+ return { status: "failed", detail: `pnpm add ${spec} still failed after allowing build scripts (exit code ${retryOutcome.exitCode}). See job output. The allowBuilds change was reverted; pnpm may still have left the dependency in the profile's package.json — market_uninstall removes it.` };
513
745
  };
514
746
 
515
747
  const first = spawnAdd();
@@ -617,3 +849,106 @@ export function runRemove({ profile, packageName }, selfHealed = false) {
617
849
  },
618
850
  };
619
851
  }
852
+
853
+ // ── offline fixtures ────────────────────────────────────────────────────────
854
+ //
855
+ // mergeAllowBuilds is the text surgery that bricked a profile: it appended a
856
+ // YAML sequence item under a key pnpm had already written as a mapping, and
857
+ // the resulting file parsed nowhere, so every later pnpm operation in that
858
+ // profile failed until it was repaired by hand. These cases pin every shape it
859
+ // can meet. Run them from an INSTALLED copy (the bare imports at the top of
860
+ // this file resolve through the host, not through a bare checkout):
861
+ // node ~/.dsh/profiles/web/node_modules/@1e0zj/dsh-plugin-mall/src/installer.js --self-test
862
+ const BASE_WS = "packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n";
863
+
864
+ const ALLOW_BUILDS_FIXTURES = [
865
+ {
866
+ label: "没有 allowBuilds —— 新建,用 pnpm 自己的映射格式",
867
+ content: BASE_WS,
868
+ names: ["node-pty"],
869
+ check: (out) => load(out).allowBuilds?.["node-pty"] === true,
870
+ },
871
+ {
872
+ label: "pnpm 的未决占位符 —— 改写成 true,而不是当作已放行跳过",
873
+ content: `${BASE_WS}allowBuilds:\n node-pty: set this to true or false\n`,
874
+ names: ["node-pty"],
875
+ check: (out) => load(out).allowBuilds?.["node-pty"] === true,
876
+ },
877
+ {
878
+ label: "已有映射条目 —— 追加同为映射,不混格式",
879
+ content: `${BASE_WS}allowBuilds:\n esbuild: true\n`,
880
+ names: ["node-pty"],
881
+ check: (out) => { const a = load(out).allowBuilds; return a?.esbuild === true && a?.["node-pty"] === true; },
882
+ },
883
+ {
884
+ label: "已有序列条目 —— 追加同为序列,不混格式(正是砖化那次的形状)",
885
+ content: `${BASE_WS}allowBuilds:\n - 'esbuild'\n`,
886
+ names: ["node-pty"],
887
+ check: (out) => { const a = load(out).allowBuilds; return Array.isArray(a) && a.includes("esbuild") && a.includes("node-pty"); },
888
+ },
889
+ {
890
+ label: "序列里已存在 —— 无需改动",
891
+ content: `${BASE_WS}allowBuilds:\n - 'node-pty'\n`,
892
+ names: ["node-pty"],
893
+ expectNoChange: true,
894
+ },
895
+ {
896
+ label: "映射里已是 true —— 无需改动",
897
+ content: `${BASE_WS}allowBuilds:\n node-pty: true\n`,
898
+ names: ["node-pty"],
899
+ expectNoChange: true,
900
+ },
901
+ {
902
+ label: "scoped 包名在序列里要加引号(@ 是 YAML 保留指示符)",
903
+ content: `${BASE_WS}allowBuilds:\n - 'esbuild'\n`,
904
+ names: ["@scope/native-thing"],
905
+ check: (out) => load(out).allowBuilds?.includes("@scope/native-thing"),
906
+ },
907
+ {
908
+ label: "allowBuilds 后面还有别的 key —— 不插到别人块里",
909
+ content: `packages:\n - .\n\nallowBuilds:\n esbuild: true\n\nnodeLinker: hoisted\n`,
910
+ names: ["node-pty"],
911
+ check: (out) => { const d = load(out); return d.allowBuilds?.["node-pty"] === true && d.nodeLinker === "hoisted"; },
912
+ },
913
+ {
914
+ label: "非法包名被过滤,无合法名时不动文件",
915
+ content: BASE_WS,
916
+ names: ["9 | - pkg", "Run \"pnpm approve-builds\""],
917
+ expectNoChange: true,
918
+ },
919
+ {
920
+ label: "文件本来就坏 —— 拒绝编辑,不让它更坏",
921
+ content: "allowBuilds:\n - 'a'\n a: b\n",
922
+ names: ["node-pty"],
923
+ expectThrow: true,
924
+ },
925
+ ];
926
+
927
+ function runAllowBuildsFixtures() {
928
+ let failed = 0;
929
+ for (const fx of ALLOW_BUILDS_FIXTURES) {
930
+ let out, error;
931
+ try { out = mergeAllowBuilds(fx.content, fx.names); } catch (e) { error = e; }
932
+ let ok;
933
+ if (fx.expectThrow) ok = error !== undefined;
934
+ else if (error !== undefined) ok = false;
935
+ else if (fx.expectNoChange) ok = out === undefined;
936
+ else ok = out !== undefined && (() => { try { return fx.check(out) === true; } catch { return false; } })();
937
+ // 任何产出都必须是可解析的 YAML —— 这是这组用例存在的全部理由。
938
+ if (ok && out !== undefined) {
939
+ try { load(out); } catch { ok = false; }
940
+ }
941
+ if (!ok) failed++;
942
+ console.log(` ${ok ? "PASS" : "FAIL"} ${fx.label}`);
943
+ if (!ok && out !== undefined) console.log(` 产出:\n${out.split("\n").map((l) => ` | ${l}`).join("\n")}`);
944
+ if (!ok && error !== undefined) console.log(` 抛错: ${error.message}`);
945
+ }
946
+ return failed;
947
+ }
948
+
949
+ if (process.argv[1]?.endsWith("installer.js") && process.argv.includes("--self-test")) {
950
+ console.log("allowBuilds 合并 fixtures:");
951
+ const failed = runAllowBuildsFixtures();
952
+ console.log(`${ALLOW_BUILDS_FIXTURES.length - failed}/${ALLOW_BUILDS_FIXTURES.length} passed`);
953
+ process.exit(failed === 0 ? 0 : 1);
954
+ }