@1e0zj/dsh-plugin-mall 0.1.13 → 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/README.md CHANGED
@@ -140,9 +140,17 @@ dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
140
140
  (文本级精准移除,用户手写的行不受影响)。
141
141
  - 安装源解析:`github:owner/repo` 优先改写为同名 npm 包(仅当 registry
142
142
  条目的 repository 指回该仓库,防止抢注),否则用 GitHub 全仓库 spec。
143
- - GitHub 源的插件安装时要跑 prepare 构建脚本,pnpm 默认拦截;本插件检测到
144
- 拦截后会把包名自动合并进 profile 的 `pnpm-workspace.yaml` 的 `allowBuilds`
145
- 并自动重试一次(解析只认合法 npm 包名,写入一律加引号,不会写坏 YAML)。
143
+ - GitHub 源的插件、以及带原生模块的包安装时要跑构建脚本,pnpm 默认拦截;
144
+ 本插件检测到拦截后会把包名自动合并进 profile 的 `pnpm-workspace.yaml` 的
145
+ `allowBuilds` 并自动重试一次。合并时跟随文件里已有的形状(pnpm 两种都认:
146
+ 序列 `- name` 和映射 `name: true`,但**混在一起就是无效 YAML**),没有已有
147
+ 形状时用 pnpm 自己写的映射格式;pnpm 留下的未决占位符
148
+ (`name: set this to true or false`)会被改写成 `true` 而不是当作已放行。
149
+ - **对 profile 配置的每一次写入都是先写后校验、解析不过就回滚**
150
+ (`writeChecked`):装别人的插件失败,绝不能留下 dsh 或 pnpm 加载不了的
151
+ profile。覆盖 `pnpm-workspace.yaml`、`package.json`、`cordis.patch.yml`
152
+ 三处。`allowBuilds` 是持久化的安全配置,所以安装最终失败时这次放宽会被
153
+ **撤销** —— 否则一个没装成的插件会让那个包名从此静默获得构建脚本执行权。
146
154
  - 配置(`cordis.patch.yml` 中可改):`defaultProfile`(默认装进哪个 profile,
147
155
  默认 `web`)、`apiBase`(GitHub API 地址)、`npmRegistry`(npm 查询源,留空
148
156
  则跟随 pnpm 实际安装源)、`rawSources`(验证用的 package.json 源模板列表,
@@ -169,4 +177,10 @@ npm publish --access public
169
177
  - node 半边导出具名成员 `{ name, inject, Config, apply }`,**不要** `export default`
170
178
  (cordis loader 会做 `exports.default ?? exports` 解包,default 会吞掉 inject/Config);
171
179
  client 半边是 `window.__ModuleLoader__.load({id, factory})`,导出 `{ apply, inject }`。
172
- - 单测 `src/github.js`(无 harness 依赖):`node src/github.js --self-test`。
180
+ - 单测 `src/github.js`(无 harness 依赖):`node src/github.js --self-test`,
181
+ 加 `--offline` 只跑不联网的 fixture(宿主依赖检测的判据固化在那里 ——
182
+ 它当初的实测对象 dsh-TUI 已被上报修复,网络上不再有可复现的回归用例,
183
+ 所以改 `HOST_PACKAGES` 前请先跑这组)。
184
+ - `src/installer.js` 也有一组 fixture,固化 `allowBuilds` 合并的全部形状
185
+ (改 `mergeAllowBuilds` 前必跑)。它有宿主依赖,所以要从**已安装副本**运行:
186
+ `node ~/.dsh/profiles/web/node_modules/@1e0zj/dsh-plugin-mall/src/installer.js --self-test`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/github.js CHANGED
@@ -289,7 +289,17 @@ export function compareVersions(a, b) {
289
289
 
290
290
  /** Cap on concurrent outbound requests — shared by verification and update checks. */
291
291
  export const NETWORK_CONCURRENCY = 8;
292
+ // repo -> {kind, name, version, hostDeps, ts}
292
293
  const verifyCache = new Map();
294
+ // Verdicts expire. Caching a success for the process lifetime freezes the badge
295
+ // on whatever the repo looked like the first time it was seen: when dsh-TUI
296
+ // moved its @deepseek-ai/* deps to peerDependencies, the red "宿主依赖风险"
297
+ // badge stayed up until dsh restarted, still accusing a repo that had been
298
+ // fixed. Ten minutes keeps a browsing session on cache while letting a fix
299
+ // surface on its own.
300
+ const VERIFY_TTL = 600000;
301
+ // Network failures retry sooner — "unknown" carries no information worth keeping.
302
+ const VERIFY_TTL_UNKNOWN = 60000;
293
303
 
294
304
  /**
295
305
  * Run `task` over `items` with at most `limit` workers in flight. The one
@@ -354,10 +364,21 @@ async function fetchRawPackageJson(repo, signal, sources) {
354
364
  * peerDependencies) installs real copies into the profile — the loader then
355
365
  * holds two module instances, symbol identities split, and every tool call
356
366
  * crashes with an undiagnosable "reading 'prepare'". See installer docs.
367
+ *
368
+ * ONLY the @deepseek-ai scope belongs here. This used to also match the bare
369
+ * upstream `cosmokit` and `schemastery` on the theory that dsh forked the
370
+ * cordis trio and therefore shipped those too. It does not: the host tree has
371
+ * no bare copy at any depth, and none of its 195 @deepseek-ai packages depends
372
+ * on one — the fork is complete and cut its ties to the upstream names. A
373
+ * plugin depending on bare `schemastery` shadows nothing, so flagging it was a
374
+ * pure false positive, and an expensive one: it hard-blocked 7 real plugins in
375
+ * the topic's top 300, `omdsh-dev/DSH-better-sidebar` (★1832) among them —
376
+ * every one of which declares its @deepseek-ai/* deps as peers correctly.
377
+ * Fixtures at the bottom of this file pin the distinction.
357
378
  */
358
- const HOST_PACKAGES = /^(@deepseek-ai\/|cosmokit$|schemastery$)/;
379
+ const HOST_PACKAGES = /^@deepseek-ai\//;
359
380
 
360
- /** The @deepseek-ai/* (and cordis runtime) entries inside `dependencies`. */
381
+ /** The @deepseek-ai/* entries inside `dependencies`. */
361
382
  function hostShadowDependencies(pkg) {
362
383
  if (pkg === undefined || typeof pkg !== "object") return undefined;
363
384
  const deps = Object.keys(pkg.dependencies ?? {});
@@ -376,11 +397,12 @@ export async function verifyPlugins({ repos, signal, sources }) {
376
397
  // which caller passes them in.
377
398
  const wanted = [...new Set((Array.isArray(repos) ? repos : []).map(String)
378
399
  .filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
379
- // "unknown"(网络失败)60s 后过期重试;成功结果进程内永久缓存。
400
+ // 每条判定都带时间戳:成功 10 分钟过期,网络失败 60 秒过期。
380
401
  const pending = wanted.filter((repo) => {
381
402
  const cached = verifyCache.get(repo);
382
403
  if (cached === undefined) return true;
383
- return cached.kind === "unknown" && Date.now() - (cached.ts ?? 0) > 60000;
404
+ const ttl = cached.kind === "unknown" ? VERIFY_TTL_UNKNOWN : VERIFY_TTL;
405
+ return Date.now() - (cached.ts ?? 0) > ttl;
384
406
  });
385
407
  await mapLimit(pending, NETWORK_CONCURRENCY, async (repo) => {
386
408
  try {
@@ -389,18 +411,17 @@ export async function verifyPlugins({ repos, signal, sources }) {
389
411
  : typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
390
412
  : pkg.dsh?.client !== undefined ? "client"
391
413
  : "plain";
392
- verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg) });
414
+ verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg), ts: Date.now() });
393
415
  } catch (error) {
394
416
  if (error?.name === "AbortError") throw error;
395
417
  verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
396
418
  }
397
419
  });
420
+ // 一律重建对象:内部的 ts 不该出现在发给浏览器的响应里。
398
421
  const results = {};
399
422
  for (const repo of wanted) {
400
423
  const cached = verifyCache.get(repo);
401
- results[repo] = cached === undefined || cached.ts !== undefined
402
- ? { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps }
403
- : cached;
424
+ results[repo] = { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps };
404
425
  }
405
426
  return { results };
406
427
  }
@@ -445,8 +466,69 @@ export async function repoInfo({ repo, apiBase, token, signal }) {
445
466
  };
446
467
  }
447
468
 
448
- // Self-test entry: node src/github.js
469
+ // ── offline fixtures ────────────────────────────────────────────────────────
470
+ //
471
+ // The host-shadow check has no live regression case any more: dsh-TUI, the
472
+ // plugin it was built against, moved its @deepseek-ai/* deps to
473
+ // peerDependencies after being reported, and nothing else on the network pins
474
+ // the bare-vs-scoped distinction. These manifests do. Shapes are taken from
475
+ // real repositories in the dsh-plugin topic, trimmed to the relevant fields.
476
+ const HOST_SHADOW_FIXTURES = [
477
+ {
478
+ label: "omdsh-dev/DSH-better-sidebar — 上游裸包,宿主不提供,放行",
479
+ pkg: {
480
+ dependencies: { schemastery: "^3.18.0", ws: "^8.18.0", clsx: "^2.1.1", "node-pty": "^1.1.0" },
481
+ peerDependencies: { "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", "@deepseek-ai/cordis": "^4.0.1" },
482
+ },
483
+ expect: undefined,
484
+ },
485
+ {
486
+ label: "裸 cordis / cosmokit — fork 已与上游断开,放行",
487
+ pkg: { dependencies: { cordis: "^4.0.0-rc.7", cosmokit: "^1.6.3" } },
488
+ expect: undefined,
489
+ },
490
+ {
491
+ label: "作用域包写进 dependencies — 真·宿主重复,拦截",
492
+ pkg: { dependencies: { "@deepseek-ai/schemastery": "^1.0.0" } },
493
+ expect: ["@deepseek-ai/schemastery"],
494
+ },
495
+ {
496
+ label: "合法 peer + 非法 dep 混合 — 仍须拦截",
497
+ pkg: {
498
+ dependencies: { "@deepseek-ai/dsh-settings": "*", clsx: "^2" },
499
+ peerDependencies: { "@deepseek-ai/dsh-tools": "*" },
500
+ },
501
+ expect: ["@deepseek-ai/dsh-settings"],
502
+ },
503
+ {
504
+ label: "只在 peerDependencies 声明 — 正确写法,放行",
505
+ pkg: { peerDependencies: { "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/cordis": "^4.0.1" } },
506
+ expect: undefined,
507
+ },
508
+ { label: "无 dependencies 字段", pkg: {}, expect: undefined },
509
+ { label: "非对象清单", pkg: undefined, expect: undefined },
510
+ ];
511
+
512
+ /** Run the offline fixtures; returns the failure count. */
513
+ function runHostShadowFixtures() {
514
+ let failed = 0;
515
+ for (const { label, pkg, expect } of HOST_SHADOW_FIXTURES) {
516
+ const actual = hostShadowDependencies(pkg);
517
+ const ok = JSON.stringify(actual ?? null) === JSON.stringify(expect ?? null);
518
+ if (!ok) failed++;
519
+ console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` 期望 ${JSON.stringify(expect)},实得 ${JSON.stringify(actual)}`}`);
520
+ }
521
+ return failed;
522
+ }
523
+
524
+ // Self-test entry: node src/github.js --self-test
525
+ // Offline fixtures run first and gate the network smoke test below.
449
526
  if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test")) {
527
+ console.log("宿主依赖检测 fixtures:");
528
+ const failed = runHostShadowFixtures();
529
+ console.log(`${HOST_SHADOW_FIXTURES.length - failed}/${HOST_SHADOW_FIXTURES.length} passed\n`);
530
+ if (failed > 0) process.exit(1);
531
+ if (process.argv.includes("--offline")) process.exit(0);
450
532
  const apiBase = "https://api.github.com";
451
533
  const result = await searchPlugins({ query: "", perPage: 3, apiBase });
452
534
  console.log(`total=${result.total} page=${result.page} perPage=${result.perPage}`);
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
  }
@@ -263,12 +326,12 @@ export function ensureClientRow(profileDir, packageName) {
263
326
  if (alreadyByName) return { added: false };
264
327
  const rowId = clientRowId(packageName);
265
328
  const block = `- insert:\n - id: ${rowId}\n name: '${packageName}'\n`;
266
- if (Array.isArray(parsed) && parsed.length === 0) {
329
+ const next = (Array.isArray(parsed) && parsed.length === 0)
267
330
  // The stock template is a comment plus `[]`; replace it wholesale.
268
- writeFileSync(patchPath, block);
269
- } else {
270
- writeFileSync(patchPath, content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`);
271
- }
331
+ ? block
332
+ : content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
333
+ // 这个文件是 dsh 的装配补丁层:写坏了宿主直接起不来。
334
+ writePatchChecked(patchPath, next);
272
335
  return { added: true, rowId };
273
336
  }
274
337
 
@@ -296,7 +359,7 @@ export function removeClientRow(profileDir, packageName) {
296
359
  }
297
360
  if (!removed) return { removed: false };
298
361
  const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
299
- writeFileSync(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
362
+ writePatchChecked(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
300
363
  return { removed: true, rowId };
301
364
  }
302
365
 
@@ -328,40 +391,95 @@ function parseIgnoredBuilds(output) {
328
391
  return [...names];
329
392
  }
330
393
 
331
- /** Merge new names into the profile's pnpm-workspace.yaml `allowBuilds` list. */
332
- function ensureAllowBuilds(profileDir, names) {
333
- const valid = names.filter((name) => NPM_NAME_RE.test(name));
334
- if (valid.length === 0) return;
335
- const workspacePath = join(profileDir, "pnpm-workspace.yaml");
336
- let content = existsSync(workspacePath)
337
- ? readFileSync(workspacePath, "utf8")
338
- : "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`);
339
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;
340
451
  const keyIndex = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
341
452
  if (keyIndex === -1) {
342
- if (!content.endsWith("\n")) content += "\n";
343
- // Quote every entry: a bare `@scope/name` starts with YAML's reserved
344
- // `@` indicator and fails to parse.
345
- 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`;
346
455
  } else {
347
- const existing = new Set();
456
+ // 块内 = 缩进行;空行不终止块;顶格行是下一个 key。
348
457
  let insertIndex = keyIndex + 1;
349
458
  for (let index = keyIndex + 1; index < lines.length; index++) {
350
- const item = /^\s*-\s+(.+?)\s*$/.exec(lines[index]);
351
- if (item) {
352
- existing.add(item[1].replace(/^['"](.*)['"]$/, "$1"));
353
- insertIndex = index + 1;
354
- continue;
355
- }
356
- if (/^\S/.test(lines[index])) break;
357
- }
358
- const additions = valid.filter((name) => !existing.has(name));
359
- if (additions.length > 0) {
360
- lines.splice(insertIndex, 0, ...additions.map((name) => ` - '${name}'`));
361
- content = lines.join("\n");
459
+ if (lines[index].trim().length === 0) continue;
460
+ if (/^\s/.test(lines[index])) { insertIndex = index + 1; continue; }
461
+ break;
362
462
  }
463
+ if (additions.length > 0) lines.splice(insertIndex, 0, ...additions.map(render));
464
+ next = lines.join("\n");
363
465
  }
364
- 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");
365
483
  }
366
484
 
367
485
  // ── in-process install tracker (browser RPC surface) ────────────────────────
@@ -591,18 +709,39 @@ export function runInstall({ profile, spec }) {
591
709
  if (ignored.length === 0) {
592
710
  return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
593
711
  }
594
- push(`\n[dsh-plugin-mall] pnpm blocked build scripts: ${ignored.join(", ")} — merging into allowBuilds and retrying once\n`);
595
- 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
+ };
596
733
  const retry = spawnAdd();
597
734
  current = retry.proc;
598
735
  const retryOutcome = await retry.done;
599
736
  if (retryOutcome.spawnError !== undefined) {
737
+ revert();
600
738
  return { status: "failed", detail: `retry could not start pnpm: ${retryOutcome.spawnError.message}` };
601
739
  }
602
740
  if (retryOutcome.exitCode === 0) {
603
741
  return finalizeSuccess();
604
742
  }
605
- 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.` };
606
745
  };
607
746
 
608
747
  const first = spawnAdd();
@@ -710,3 +849,106 @@ export function runRemove({ profile, packageName }, selfHealed = false) {
710
849
  },
711
850
  };
712
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
+ }