@1e0zj/dsh-plugin-mall 0.2.1 → 0.3.1

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
@@ -234,6 +234,16 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --pro
234
234
  (这行会出现在任务日志里)。更新检查读的是 registry 的 `/latest` 端点,
235
235
  不经过该策略,所以两边看到的「最新版本」本就可能不同。
236
236
  首次安装(卡片按钮)不带版本,沿用 pnpm 的策略默认值即可。
237
+ - **启用 / 停用,不必卸载**:已装面板每行一个开关,关掉即刻卸载该插件的
238
+ fiber,重新打开时它、以及因依赖它而挂起的插件都会回来。三层落地:内存用
239
+ `entry.update({disabled})`;持久化改写 profile 的 `cordis.patch.yml`(保留
240
+ 注释),由 dsh 自己的 `watchUserPatches` 事务性重放,所以重启后状态保持;
241
+ 写入前自动备份到 `<profile>/backups/`(留最近 20 份)。
242
+ 市场插件自身不给开关——停用了就没有界面再打开它。用户手写的
243
+ `disabled: !!js …` 条件表达式会被**拒绝接管**并提示手改:那是条件逻辑,
244
+ 两态开关覆盖它等于把条件永久压成固定值。
245
+ (界面类插件的变化需要刷新页面才反映:浏览器那半边靠页面加载时注入的
246
+ 启动清单,后端的开关立即生效,已加载的模块不会自行卸载。)
237
247
  - **一次点击一个任务,日志从第一毫秒开始流**:预检本身就是一个任务,点安装
238
248
  的瞬间就出现在面板里,隔离探针的 pnpm 输出实时写入——而不是让按钮干等几秒
239
249
  再冒出结果。预检通过后由安装任务接管同一条日志、撤掉预检条目,所以面板上
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -66,6 +66,12 @@ window.__ModuleLoader__.load({
66
66
  ".mkt_approveCmd{max-height:none;margin:0}",
67
67
  ".mkt_jobDone{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:2px}",
68
68
  ".mkt_logBlock{display:flex;flex-direction:column;gap:4px;align-items:flex-start}",
69
+ ".mkt_depOff{opacity:.5;text-decoration:line-through}",
70
+ ".mkt_switch{position:relative;width:34px;height:18px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-secondary,#e5e7eb);cursor:pointer;padding:0;transition:background .15s,border-color .15s}",
71
+ ".mkt_switch:disabled{opacity:.55;cursor:default}",
72
+ ".mkt_switchOn{background:var(--dsw-alias-state-business-primary,#2b6cb0);border-color:transparent}",
73
+ ".mkt_switchKnob{position:absolute;top:1px;left:1px;width:14px;height:14px;border-radius:50%;background:#fff;transition:transform .15s;box-shadow:0 1px 2px rgba(0,0,0,.25)}",
74
+ ".mkt_switchOn .mkt_switchKnob{transform:translateX(16px)}",
69
75
  ".mkt_issueList{list-style:none;display:flex;flex-direction:column;gap:8px;margin:0;padding:0}",
70
76
  ".mkt_issue{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 10px;background:var(--dsw-alias-bg-secondary,#fff)}",
71
77
  ".mkt_issueBlock{border-color:var(--dsw-alias-state-error-primary)}",
@@ -176,6 +182,9 @@ window.__ModuleLoader__.load({
176
182
  // carryFromId:把上一阶段(预检)的日志接过来并撤掉它的条目。一次点击
177
183
  // 只应该在面板里留下一个任务,日志连续——而不是 market-1 预检、
178
184
  // market-2 安装两条并排,让人以为自己点了两次。
185
+ // 重试同理:同一 spec 上一轮失败/完成的终态条目一并撤掉——用户没点
186
+ // 「清空」就重试时,面板照样只留新一轮一条。旧失败日志不拼进新任务
187
+ // (两轮 pnpm 输出混在一起没法读);running 的不动,那是真并发任务。
179
188
  var track = useCallback(function (id, spec, carryFromId) {
180
189
  var next = Object.assign({}, jobsRef.current);
181
190
  var carried = "";
@@ -183,6 +192,11 @@ window.__ModuleLoader__.load({
183
192
  carried = next[carryFromId].output || "";
184
193
  delete next[carryFromId];
185
194
  }
195
+ for (var key in next) {
196
+ if (key !== id && next[key] && next[key].spec === spec && next[key].status !== "running") {
197
+ delete next[key];
198
+ }
199
+ }
186
200
  next[id] = { status: "running", spec: spec, output: carried };
187
201
  jobsRef.current = next;
188
202
  setJobs(Object.assign({}, next));
@@ -338,6 +352,8 @@ window.__ModuleLoader__.load({
338
352
  );
339
353
  }
340
354
 
355
+ var MARKET_PACKAGE = "@1e0zj/dsh-plugin-mall";
356
+
341
357
  function jobKindLabel(kind) {
342
358
  if (kind === "dsh-plugin-preflight") return "预检 ";
343
359
  if (kind === "dsh-plugin-uninstall") return "卸载 ";
@@ -462,10 +478,23 @@ window.__ModuleLoader__.load({
462
478
  : h("div", { className: "mkt_depList" }, (installed.deps || []).map(function (dep) {
463
479
  var busy = (props.removing || {})[dep.name] === true;
464
480
  var upd = (props.updates || {})[dep.name];
481
+ var entry = (props.entries || {})[dep.name];
482
+ // 没在装配树里的依赖(普通依赖、或声明了 client 但没挂载的)
483
+ // 没有可切换的东西,不给开关。
484
+ var togglable = entry !== undefined && dep.name !== MARKET_PACKAGE;
485
+ var enabled = entry === undefined ? true : entry.enabled !== false;
486
+ var toggling = (props.toggling || {})[dep.name] === true;
465
487
  return h("div", { key: dep.name, className: "mkt_depRow" },
466
- h("span", { className: "mkt_desc" }, dep.name + "@" + dep.version),
488
+ h("span", { className: "mkt_desc" + (enabled ? "" : " mkt_depOff") }, dep.name + "@" + dep.version),
467
489
  h("span", { className: "mkt_depActions" },
468
- h("span", { className: "mkt_badge" }, kindLabel(dep.kind)),
490
+ h("span", { className: "mkt_badge" }, enabled ? kindLabel(dep.kind) : "已停用"),
491
+ togglable ? h("button", {
492
+ className: "mkt_switch" + (enabled ? " mkt_switchOn" : ""),
493
+ disabled: toggling,
494
+ title: enabled ? "停用(立即生效,不卸载)" : "启用(立即生效)",
495
+ "aria-pressed": enabled ? "true" : "false",
496
+ onClick: function () { props.onToggle(dep.name, !enabled); },
497
+ }, h("span", { className: "mkt_switchKnob" })) : null,
469
498
  upd && upd.hasUpdate ? h("button", {
470
499
  className: "mkt_btn mkt_btnSm",
471
500
  onClick: function () { props.onInstallSpec(dep.name + "@" + upd.latest); },
@@ -506,6 +535,9 @@ window.__ModuleLoader__.load({
506
535
  var _removing = useState({});
507
536
  var removing = _removing[0];
508
537
  var setRemoving = _removing[1];
538
+ var _toggling = useState({});
539
+ var toggling = _toggling[0];
540
+ var setToggling = _toggling[1];
509
541
  var _page = useState(1);
510
542
  var page = _page[0];
511
543
  var setPage = _page[1];
@@ -631,6 +663,26 @@ window.__ModuleLoader__.load({
631
663
  return function () { observer.disconnect(); };
632
664
  }, [loadMore]);
633
665
 
666
+ // 启用/停用:热生效,不重启也不重装——所以成功后只刷新已装列表,
667
+ // 不提示重启,也不动任务面板(它不是一个需要看日志的长任务)。
668
+ var doToggle = useCallback(function (packageName, enabled) {
669
+ setToggling(function (prev) { return Object.assign({}, prev, { [packageName]: true }); });
670
+ setError(null);
671
+ call("togglePlugin", { package: packageName, enabled: enabled }).then(function (value) {
672
+ setInstalled(function (prev) {
673
+ return prev && !prev.error ? Object.assign({}, prev, { entries: value.entries }) : prev;
674
+ });
675
+ }).catch(function (e) {
676
+ setError(errorText(e));
677
+ }).finally(function () {
678
+ setToggling(function (prev) {
679
+ var next = Object.assign({}, prev);
680
+ delete next[packageName];
681
+ return next;
682
+ });
683
+ });
684
+ }, [call]);
685
+
634
686
  var refreshInstalled = useCallback(function () {
635
687
  call("installed", {}).then(function (value) {
636
688
  setInstalled(value);
@@ -828,7 +880,16 @@ window.__ModuleLoader__.load({
828
880
  "只看已验证插件")
829
881
  ),
830
882
  error ? h("div", { className: "mkt_error" }, error) : null,
831
- h(InstalledPanel, { installed: installed, removing: removing, updates: updates, onUninstall: doUninstall, onInstallSpec: preflightAndInstall }),
883
+ h(InstalledPanel, {
884
+ installed: installed,
885
+ removing: removing,
886
+ updates: updates,
887
+ entries: installed && !installed.error ? installed.entries : undefined,
888
+ toggling: toggling,
889
+ onToggle: doToggle,
890
+ onUninstall: doUninstall,
891
+ onInstallSpec: preflightAndInstall,
892
+ }),
832
893
  h(JobsPanel, {
833
894
  jobs: jobs,
834
895
  onClear: function () {
package/src/guard.js CHANGED
@@ -583,9 +583,9 @@ export function inspectRemoteCandidate({ profileDir, manifest, patchText, spec }
583
583
  }
584
584
 
585
585
  /** Locate a binary on PATH by explicit extension (no shell, no PATHEXT guessing). */
586
- function findOnPath(binary, extensions) {
587
- const separator = process.platform === "win32" ? ";" : ":";
588
- for (const dir of String(process.env.PATH ?? "").split(separator)) {
586
+ function findOnPath(binary, { platform, pathEnv, extensions }) {
587
+ const separator = platform === "win32" ? ";" : ":";
588
+ for (const dir of String(pathEnv ?? "").split(separator)) {
589
589
  if (dir.length === 0) continue;
590
590
  for (const ext of extensions) {
591
591
  const candidate = join(dir, `${binary}${ext}`);
@@ -596,16 +596,32 @@ function findOnPath(binary, extensions) {
596
596
  }
597
597
 
598
598
  /**
599
- * pnpm spawn plan, mirroring installer.js pnpmSpawnPlan: a real .exe spawns
600
- * without a shell; only the .cmd shim forces a cmd wrapper on Windows
601
- * (shell:true, and Node's DEP0190 warning with it).
599
+ * How to spawn pnpm on this platform. installer.js consumes this too, so the
600
+ * plan lives here exactly once instead of as two drifting copies (the mirror
601
+ * copies had already diverged once; that is how the quoting bug below stayed
602
+ * invisible on both sides).
603
+ * @returns {{ command: string, shell: boolean, treeKill: boolean }}
604
+ * treeKill marks the shell-wrapped case: cancel must taskkill /T the tree.
602
605
  */
603
- function pnpmSpawnPlan() {
604
- if (process.platform !== "win32") return { command: "pnpm", shell: false };
605
- const exe = findOnPath("pnpm", [".exe"]);
606
- if (exe !== undefined) return { command: exe, shell: false };
607
- const cmd = findOnPath("pnpm", [".cmd"]);
608
- return { command: cmd ?? "pnpm", shell: true };
606
+ export function pnpmSpawnPlan({ platform = process.platform, pathEnv = process.env.PATH } = {}) {
607
+ if (platform !== "win32") return { command: "pnpm", shell: false, treeKill: false };
608
+ // A real .exe spawns without a shell — cancel then kills pnpm itself.
609
+ const exe = findOnPath("pnpm", { platform, pathEnv, extensions: [".exe"] });
610
+ if (exe !== undefined) return { command: exe, shell: false, treeKill: false };
611
+ // Only the .cmd shim: Node refuses batch files with shell:false (EINVAL
612
+ // since the batch-file argument-injection fix), so a cmd wrapper is
613
+ // unavoidable — flag it so cancel kills the whole tree, not the wrapper.
614
+ const cmd = findOnPath("pnpm", { platform, pathEnv, extensions: [".cmd"] });
615
+ if (cmd === undefined) return { command: "pnpm", shell: true, treeKill: true };
616
+ // The shim usually sits in a directory with a space (`D:\Program Files\nodejs`
617
+ // is Node's default install layout). Under shell:true Node joins command and
618
+ // args with spaces WITHOUT quoting per argument — the same fact the
619
+ // UNSAFE_SPEC_RE comment below argues from — so an unquoted path is cut at
620
+ // the first space and cmd answers `'D:\Program' is not recognized`, killing
621
+ // every preflight and install on such machines. Quote the command ourselves;
622
+ // the args joined after it are fixed flags plus an assertSafeSpec-validated
623
+ // spec, none of which carry spaces.
624
+ return { command: `"${cmd}"`, shell: true, treeKill: true };
609
625
  }
610
626
 
611
627
  function spawnCapture(command, args, options, onOutput) {
@@ -641,7 +657,9 @@ function spawnCapture(command, args, options, onOutput) {
641
657
  // not only by the agent/browser callers that happen to check first: anything
642
658
  // carrying shell metacharacters, or a Windows `file:`/`link:` path with spaces,
643
659
  // is refused before a single byte of filesystem work and before pnpm is spawned.
644
- const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
660
+ // `%` is on the list like everywhere else: cmd performs `%VAR%` expansion, and
661
+ // the expanded value (often full of spaces and semicolons) reshapes the argv.
662
+ const UNSAFE_SPEC_RE = /[;&|`$()<>^%!"*\n\r]/;
645
663
 
646
664
  function assertSafeSpec(spec) {
647
665
  const value = String(spec ?? "");
@@ -1888,6 +1906,12 @@ async function selfTest() {
1888
1906
  if (rep.verdict !== "blocked" || !rep.issues.some((entry) => entry.code === "unsafe-spec")) {
1889
1907
  throw new Error("preflightInstall should reject a spec with shell metacharacters before spawning");
1890
1908
  }
1909
+ // `%` 单独成案:它不是 POSIX 元字符,但 cmd 会做 %VAR% 展开,展开值
1910
+ // 里的分号/空格足以重塑 argv——黑名单曾漏掉它,与 cli.js 漂移过。
1911
+ const repPct = await preflightInstall({ profileDir: p, spec: "evil-pkg%PATH%" });
1912
+ if (repPct.verdict !== "blocked" || !repPct.issues.some((entry) => entry.code === "unsafe-spec")) {
1913
+ throw new Error("preflightInstall should reject a spec carrying cmd %VAR% expansion");
1914
+ }
1891
1915
  if (process.platform === "win32") {
1892
1916
  const repWin = await preflightInstall({ profileDir: p, spec: "file:C:\\some dir\\pkg" });
1893
1917
  if (repWin.verdict !== "blocked" || !repWin.issues.some((entry) => entry.code === "unsafe-spec")) {
@@ -1906,6 +1930,36 @@ async function selfTest() {
1906
1930
  if (!args.includes("--ignore-scripts")) throw new Error("probe args must keep install scripts disabled");
1907
1931
  }
1908
1932
 
1933
+ // pnpmSpawnPlan (pure, plus a real spawn on Windows): the .cmd shim path
1934
+ // must carry its own quotes. Node's shell:true joins command and args
1935
+ // without per-argument quoting, so `D:\Program Files\nodejs\pnpm.CMD`
1936
+ // would be cut at the first space and cmd would answer
1937
+ // `'D:\Program' is not recognized` — that exact failure blocked every
1938
+ // preflight on a real machine (Node's default install layout has a space).
1939
+ {
1940
+ const shimRoot = join(root, "path with space");
1941
+ mkdirSync(shimRoot, { recursive: true });
1942
+ writeFileSync(join(shimRoot, "pnpm.cmd"), "@echo probe-ok\r\n");
1943
+ const plan = pnpmSpawnPlan({ platform: "win32", pathEnv: shimRoot });
1944
+ if (plan.shell !== true || plan.treeKill !== true || plan.command !== `"${join(shimRoot, "pnpm.cmd")}"`) {
1945
+ throw new Error(`quoted .cmd plan expected, got ${JSON.stringify(plan)}`);
1946
+ }
1947
+ if (pnpmSpawnPlan({ platform: "linux", pathEnv: shimRoot }).command !== "pnpm") {
1948
+ throw new Error("posix plan should spawn pnpm directly");
1949
+ }
1950
+ const noShim = pnpmSpawnPlan({ platform: "win32", pathEnv: join(root, "no-shim-here") });
1951
+ if (noShim.command !== "pnpm" || noShim.shell !== true || noShim.treeKill !== true) {
1952
+ throw new Error(`missing-shim fallback expected, got ${JSON.stringify(noShim)}`);
1953
+ }
1954
+ // 引用不是摆设:Windows 真机端到端 spawn 一轮(CI 是 Linux,只跑静态断言)。
1955
+ if (process.platform === "win32") {
1956
+ const probe = spawnSync(plan.command, ["--version"], { shell: plan.shell, encoding: "utf8", timeout: 15000 });
1957
+ if (probe.status !== 0 || !/probe-ok/.test(probe.stdout ?? "")) {
1958
+ throw new Error(`quoted shim must actually run: status=${probe.status} stdout=${JSON.stringify(probe.stdout)} stderr=${JSON.stringify(probe.stderr)}`);
1959
+ }
1960
+ }
1961
+ }
1962
+
1909
1963
  // Rollback reconcile args/env (pure): scripts off, the restored lockfile
1910
1964
  // authoritative, peer auto-install off, strictly offline — there is no
1911
1965
  // online retry path or args at all, and nothing user-controlled anywhere
package/src/index.js CHANGED
@@ -24,11 +24,16 @@ import { createRequire } from "node:module";
24
24
  import { tmpdir } from "node:os";
25
25
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
26
26
  import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
27
- import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof } from "./installer.js";
27
+ import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof, persistPluginDisabled } from "./installer.js";
28
28
  import { preflightInstall, inspectRemoteCandidate, recoverProfile } from "./guard.js";
29
29
 
30
30
  export const name = "@1e0zj/dsh-plugin-mall";
31
- export const inject = ["tools", "jobs", "systemPrompt"];
31
+ // `loader` 用来读装配树、并对单个 entry 做热开关(entry.update)。读法照抄
32
+ // 官方的 @deepseek-ai/dsh-host-plugin-inventory —— 它是只读投影
33
+ // ("Read-only Remote projection of current Cordis Loader plugin state"),
34
+ // 写入侧留白,正是这里补的位置。持久化不走 loader(见 togglePlugin 的说明)。
35
+ // loader 必然存在——没有它我们根本加载不了。
36
+ export const inject = ["tools", "jobs", "systemPrompt", "loader"];
32
37
 
33
38
  export const Config = z.object({
34
39
  defaultProfile: z.string().default("web"),
@@ -973,6 +978,53 @@ export function createJobTracker({ producerFactory } = {}) {
973
978
  };
974
979
  }
975
980
 
981
+ /**
982
+ * Group the loader's mounted entries by the package that provides them, so a
983
+ * profile dependency can be shown (and toggled) as one row.
984
+ *
985
+ * One package can insert several rows, so `enabled` means EVERY row of that
986
+ * package is live — a half-disabled package is reported as disabled, and
987
+ * toggling acts on the whole set. Group rows are skipped, mirroring the
988
+ * official read-only projection in @deepseek-ai/dsh-host-plugin-inventory.
989
+ */
990
+ export function loaderEntriesByPackage(ctx) {
991
+ const byPackage = {};
992
+ try {
993
+ for (const entry of ctx.loader.entries()) {
994
+ if (entry.options?.group) continue;
995
+ const moduleName = entry.options?.name;
996
+ if (typeof moduleName !== "string" || moduleName.length === 0) continue;
997
+ const bucket = byPackage[moduleName] ??= { entryIds: [], entries: [], enabled: true };
998
+ // 两个 id 必须分清:
999
+ // entry.id 运行时全路径,父链拼出来的(`include:dsh-at-file`)
1000
+ // entry.options.id 配置文件里写的那个(`dsh-at-file`)
1001
+ // patch 层的 id 定向覆盖按后者匹配(applyEntryPatches 从组装数据建
1002
+ // entryMap,键是各 patch 声明的 id)。拿前者去写 patch,那条覆盖行
1003
+ // 永远匹配不到目标,dsh 只会 warn 一句然后忽略——停用看着成功了,
1004
+ // 重启后插件照常回来。
1005
+ const configId = entry.options?.id;
1006
+ bucket.entryIds.push(entry.id);
1007
+ bucket.entries.push({ id: entry.id, configId, entry });
1008
+ if (entry.disabled) bucket.enabled = false;
1009
+ }
1010
+ } catch {
1011
+ /* loader 读不到就不给开关,安装/卸载照常可用 */
1012
+ }
1013
+ return byPackage;
1014
+ }
1015
+
1016
+ /**
1017
+ * The serializable half of loaderEntriesByPackage — live `entry` objects must
1018
+ * never reach the RPC envelope (they carry the whole fiber graph).
1019
+ */
1020
+ function serializableEntries(byPackage) {
1021
+ const out = {};
1022
+ for (const [moduleName, bucket] of Object.entries(byPackage)) {
1023
+ out[moduleName] = { entryIds: bucket.entryIds, enabled: bucket.enabled };
1024
+ }
1025
+ return out;
1026
+ }
1027
+
976
1028
  /** Render one preflight issue as a compact line for model/error output. */
977
1029
  function renderPreflightIssue(entry) {
978
1030
  const badge = entry.severity === "block" ? "BLOCK" : "WARN";
@@ -1193,7 +1245,60 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1193
1245
  } catch (error) {
1194
1246
  return rpcFail(new Error(`invalid profile: ${error.message}`));
1195
1247
  }
1196
- return rpcOk(listInstalled(profile));
1248
+ // 带上每个依赖在装配树里的启用状态,浏览器据此渲染开关。
1249
+ return rpcOk({ ...listInstalled(profile), entries: serializableEntries(loaderEntriesByPackage(ctx)) });
1250
+ }
1251
+ case "togglePlugin": {
1252
+ // 启用/停用,三层(同 cynch18/plugin-switch 的做法):
1253
+ // 1. 内存 —— entry.update({disabled}) 立即 dispose/start 对应 fiber
1254
+ // 2. 持久化 —— 文本改写用户的 cordis.patch.yml,由 dsh 自己的
1255
+ // watchUserPatches 事务性重放(启动时若无 HMR 会当场创建一个,
1256
+ // 见 @deepseek-ai/dsh 的 profile-boot:ctx.loader.create(hmr) →
1257
+ // watchUserPatches(profile patch) + watchUserPatches(home patch))
1258
+ // 3. 保险 —— 写前备份到 <profile>/backups/,留最近 20 份
1259
+ // 刻意不用 ctx.loader.update():它的 tree.write() 写的是 cordis.yml,
1260
+ // 那是组装产物;用户的选择该留在自己的 patch 层。
1261
+ const profile = String(payload?.profile ?? defaultProfile).trim();
1262
+ const packageName = String(payload?.package ?? "").trim();
1263
+ const enabled = payload?.enabled === true;
1264
+ if (packageName.length === 0) return rpcFail(new Error("togglePlugin: package name is required"));
1265
+ if (packageName === name) {
1266
+ // 停用市场自己 = 关掉正在操作的这个界面,之后只能手改配置文件才能回来。
1267
+ return rpcFail(new Error("refusing to disable the marketplace itself — you would lose the UI needed to re-enable it"));
1268
+ }
1269
+ let profileDir;
1270
+ try {
1271
+ profileDir = resolveProfileDir(profile);
1272
+ } catch (error) {
1273
+ return rpcFail(new Error(`invalid profile: ${error.message}`));
1274
+ }
1275
+ const targets = loaderEntriesByPackage(ctx)[packageName]?.entries ?? [];
1276
+ if (targets.length === 0) {
1277
+ return rpcFail(new Error(`no loader entry found for ${packageName} — it may not be mounted in this profile`));
1278
+ }
1279
+ // 先持久化:patch 层写不了(!!js 表达式等)就整个放弃,不留下
1280
+ // 「内存里关了、重启又回来」的错位状态。
1281
+ const backups = [];
1282
+ try {
1283
+ for (const target of targets) {
1284
+ // 用 options.id(配置文件里的 id),不是 entry.id(运行时全路径)。
1285
+ if (typeof target.configId !== "string" || target.configId.length === 0) {
1286
+ throw new Error(`${packageName} has a loader entry without a configured id — it cannot be targeted from the patch layer`);
1287
+ }
1288
+ const result = persistPluginDisabled(profileDir, target.configId, !enabled, packageName);
1289
+ if (result.backup !== undefined) backups.push(result.backup);
1290
+ }
1291
+ } catch (error) {
1292
+ return rpcFail(error);
1293
+ }
1294
+ try {
1295
+ for (const target of targets) {
1296
+ await target.entry.update({ disabled: enabled ? undefined : true });
1297
+ }
1298
+ } catch (error) {
1299
+ return rpcFail(new Error(`${packageName} was written to the patch layer but the live toggle failed: ${error.message} — restart dsh to apply it`));
1300
+ }
1301
+ return rpcOk({ package: packageName, enabled, backups, entries: serializableEntries(loaderEntriesByPackage(ctx)) });
1197
1302
  }
1198
1303
  case "preflight": {
1199
1304
  // 预检本身做成 job:点击安装的瞬间任务就出现在面板里,探针的 pnpm
@@ -2235,6 +2340,39 @@ export async function runSelfTests() {
2235
2340
  "tracker rejection fixture 使用注入 producer,不触碰真实 profile",
2236
2341
  producerCalls === 1 && trackerSnapshot.status === "failed" && settledOutcome?.status === "failed",
2237
2342
  );
2343
+
2344
+ // ── 8. 启用/停用:装配树条目按包分组 ─────────────────────────────────
2345
+ // 一个包可以插入多行,所以分组、以及「有一行停用就算整体停用」是这块最
2346
+ // 容易写错的地方;group 行必须跳过(照 dsh-host-plugin-inventory 的读法)。
2347
+ const fakeLoaderCtx = (entries) => ({ loader: { entries: () => entries } });
2348
+ const grouped = loaderEntriesByPackage(fakeLoaderCtx([
2349
+ { id: "e1", options: { name: "dsh-at-file" }, disabled: false },
2350
+ { id: "e2", options: { name: "multi-row" }, disabled: false },
2351
+ { id: "e3", options: { name: "multi-row" }, disabled: true },
2352
+ { id: "g1", options: { name: "some-group", group: true }, disabled: false },
2353
+ { id: "e4", options: {}, disabled: false },
2354
+ ]));
2355
+ check("单行包:分组并标记启用", grouped["dsh-at-file"]?.entryIds.length === 1 && grouped["dsh-at-file"].enabled === true);
2356
+ check("多行包:合并为一项", grouped["multi-row"]?.entryIds.length === 2);
2357
+ check("多行包有一行停用 → 整体判为停用", grouped["multi-row"]?.enabled === false);
2358
+ check("group 行被跳过", grouped["some-group"] === undefined);
2359
+ check("无名条目被跳过", Object.keys(grouped).length === 2);
2360
+ check("loader 抛错时降级为空表,不拖垮已装列表", Object.keys(loaderEntriesByPackage({
2361
+ loader: { entries: () => { throw new Error("loader unavailable"); } },
2362
+ })).length === 0);
2363
+
2364
+ // entry.id 是运行时全路径(父链拼接,`include:dsh-at-file`),
2365
+ // entry.options.id 才是配置文件里的 id(`dsh-at-file`)。patch 层的
2366
+ // id 定向覆盖按后者匹配——用错了那条覆盖行永远命中不了目标,
2367
+ // 停用看着成功、重启后插件照常回来(真实环境踩过)。
2368
+ const prefixed = loaderEntriesByPackage(fakeLoaderCtx([
2369
+ { id: "include:dsh-at-file", options: { id: "dsh-at-file", name: "dsh-at-file" }, disabled: false },
2370
+ ]));
2371
+ check("运行时 id 与配置 id 分别保留", prefixed["dsh-at-file"]?.entries[0].id === "include:dsh-at-file"
2372
+ && prefixed["dsh-at-file"]?.entries[0].configId === "dsh-at-file");
2373
+ check("configId 缺失时可被识别(调用方据此拒绝写 patch)", loaderEntriesByPackage(fakeLoaderCtx([
2374
+ { id: "anon-1", options: { name: "no-id-pkg" }, disabled: false },
2375
+ ]))["no-id-pkg"]?.entries[0].configId === undefined);
2238
2376
  } finally {
2239
2377
  rmSync(root, { recursive: true, force: true });
2240
2378
  }
package/src/installer.js CHANGED
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
16
16
  import { dump, load } from "js-yaml";
17
17
  import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
18
18
  import { describeBuildScripts, npmNameOf } from "./github.js";
19
- import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, rollbackPendingSnapshot } from "./guard.js";
19
+ import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, pnpmSpawnPlan, rollbackPendingSnapshot } from "./guard.js";
20
20
 
21
21
  // ── spec normalization ──────────────────────────────────────────────────────
22
22
 
@@ -368,6 +368,134 @@ export function removeClientRow(profileDir, packageName) {
368
368
  return { removed: true, rowId };
369
369
  }
370
370
 
371
+ // ── enable / disable persistence ────────────────────────────────────────────
372
+ //
373
+ // Toggling a plugin is three layers, and only the middle one lives here:
374
+ // 1. memory — `entry.update({disabled})` disposes/starts the fiber (index.js)
375
+ // 2. persistence — rewrite the profile's cordis.patch.yml, replayed
376
+ // transactionally by dsh's own `watchUserPatches` (this file)
377
+ // 3. safety — back the file up before every edit so a bad write is undoable
378
+ //
379
+ // Persistence deliberately does NOT go through `loader.update()` even though
380
+ // that would write for us: its `tree.write()` targets `cordis.yml`, the
381
+ // composed artifact. A user's choice belongs in the patch layer they own, not
382
+ // baked into the thing composition regenerates. Same conclusion as
383
+ // cynch18/plugin-switch, which spells it out in its header comment.
384
+
385
+ /** A patch row's `disabled:` line, when it is a plain literal we may rewrite. */
386
+ const DISABLED_LINE_RE = /^(\s*)disabled\s*:\s*(.*?)\s*$/;
387
+
388
+ /**
389
+ * Text-level edit of one entry's `disabled` in a patch file, preserving every
390
+ * other byte (comments included — users hand-write this file).
391
+ *
392
+ * A profile's patch layer normally starts EMPTY (`[]`): plugins are mounted by
393
+ * the bundle layers, not by the user's file. So "no row for this id" is the
394
+ * common case, not an error — we append an id-targeted override row, which is
395
+ * exactly what the patch layer is for (dsh-app-boot's applyEntryPatches treats
396
+ * a non-insert row as "override these keys on the entry with this id", and
397
+ * warns on a `name` mismatch, so we pass `name` as a guard).
398
+ *
399
+ * @param content - current cordis.patch.yml text.
400
+ * @param entryId - the loader entry id whose row to edit.
401
+ * @param disabled - desired state.
402
+ * @param moduleName - the entry's module name, written alongside a NEW row so
403
+ * dsh can detect a stale patch if the id is ever reused.
404
+ * @returns the new text, or undefined when it already reads that way.
405
+ * @throws when the row's `disabled` is a `!!js` expression.
406
+ */
407
+ export function setPatchRowDisabled(content, entryId, disabled, moduleName) {
408
+ const lines = String(content ?? "").split("\n");
409
+ // 行尾允许跟注释:`- id: at-file # 我的备注` 是用户会写的形状。
410
+ const idPattern = new RegExp(`^(\\s*)-?\\s*id\\s*:\\s*['"]?${entryId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]?\\s*(?:#.*)?$`);
411
+ const rowIndex = lines.findIndex((line) => idPattern.test(line));
412
+ if (rowIndex === -1) {
413
+ // patch 层里还没有这一条——这是常态(profile 的 patch 层默认是空的 `[]`,
414
+ // 插件由 bundle 层挂载)。追加一条 id 定向覆盖行。
415
+ if (!disabled) return undefined; // 没有覆盖行 = 本来就是启用状态
416
+ const block = moduleName === undefined
417
+ ? `- id: ${entryId}\n disabled: true\n`
418
+ : `- id: ${entryId}\n name: '${moduleName}'\n disabled: true\n`;
419
+ const trimmed = String(content ?? "").trim();
420
+ // 模板是注释 + `[]`,整体替换掉那个空数组;否则在末尾追加。
421
+ if (trimmed.endsWith("[]")) {
422
+ return `${trimmed.slice(0, trimmed.lastIndexOf("[]")).trimEnd()}\n${block}`.replace(/^\n/, "");
423
+ }
424
+ return `${trimmed.length === 0 ? "" : `${trimmed}\n`}${block}`;
425
+ }
426
+ const indent = (idPattern.exec(lines[rowIndex])[1] ?? "").length;
427
+ // 同一条目的后续行:缩进更深,或与 `- id:` 的内容对齐。遇到下一个条目/顶格即止。
428
+ let existing = -1;
429
+ for (let index = rowIndex + 1; index < lines.length; index++) {
430
+ const line = lines[index];
431
+ if (line.trim().length === 0) continue;
432
+ const lead = line.length - line.trimStart().length;
433
+ if (lead <= indent && /^\s*-\s/.test(line)) break; // 下一个条目
434
+ if (lead < indent) break; // 退出该块
435
+ const match = DISABLED_LINE_RE.exec(line);
436
+ if (match !== null) { existing = index; break; }
437
+ }
438
+ if (existing !== -1) {
439
+ const value = DISABLED_LINE_RE.exec(lines[existing])[2];
440
+ // 用户写的是条件逻辑(如「只在 Windows 上停用」)。我们的开关只有两态,
441
+ // 覆盖它等于把条件永久压成固定值,而且用户不会察觉——拒绝接管,让人手改。
442
+ if (value.startsWith("!!js")) {
443
+ throw new Error(`cannot toggle ${entryId}: its "disabled" is a !!js expression — edit cordis.patch.yml by hand`);
444
+ }
445
+ if ((value === "true") === disabled) return undefined; // 已是目标状态
446
+ lines[existing] = lines[existing].replace(DISABLED_LINE_RE, `$1disabled: ${disabled}`);
447
+ return lines.join("\n");
448
+ }
449
+ if (!disabled) return undefined; // 没有 disabled 行本就是启用状态
450
+ // 插在 id 行之后,缩进与 id 的内容列对齐。
451
+ lines.splice(rowIndex + 1, 0, `${" ".repeat(indent + 2)}disabled: true`);
452
+ return lines.join("\n");
453
+ }
454
+
455
+ /** Keep the most recent N backups of a profile file, oldest pruned first. */
456
+ const PATCH_BACKUP_KEEP = 20;
457
+
458
+ /**
459
+ * Snapshot cordis.patch.yml before editing it. The file is hand-editable and
460
+ * carries the user's own rows; a bad automated write must be undoable without
461
+ * reaching for git.
462
+ * @returns the backup path, or undefined when there was nothing to back up.
463
+ */
464
+ export function backupProfilePatch(profileDir) {
465
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
466
+ if (!existsSync(patchPath)) return undefined;
467
+ const dir = join(profileDir, "backups");
468
+ mkdirSync(dir, { recursive: true });
469
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
470
+ const target = join(dir, `cordis.patch.${stamp}.yml`);
471
+ writeFileSync(target, readFileSync(patchPath, "utf8"));
472
+ try {
473
+ const kept = readdirSync(dir).filter((entry) => /^cordis\.patch\..*\.yml$/.test(entry)).sort();
474
+ for (const stale of kept.slice(0, Math.max(0, kept.length - PATCH_BACKUP_KEEP))) {
475
+ rmSync(join(dir, stale), { force: true });
476
+ }
477
+ } catch {
478
+ /* 清理失败不该让切换失败 */
479
+ }
480
+ return target;
481
+ }
482
+
483
+ /**
484
+ * Persist a toggle into the profile's patch layer: back up, edit, write through
485
+ * the checked writer. dsh's own `watchUserPatches` replays the file
486
+ * transactionally, so this is also what makes the change survive a restart.
487
+ * @returns `{changed, backup?}`; `changed:false` means it already read that way.
488
+ */
489
+ export function persistPluginDisabled(profileDir, entryId, disabled, moduleName) {
490
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
491
+ const content = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "[]\n";
492
+ const next = setPatchRowDisabled(content, entryId, disabled, moduleName);
493
+ if (next === undefined) return { changed: false };
494
+ const backup = backupProfilePatch(profileDir);
495
+ writePatchChecked(patchPath, next);
496
+ return { changed: true, backup };
497
+ }
498
+
371
499
  // ── build-script allow-listing ──────────────────────────────────────────────
372
500
 
373
501
  /** Extract package names from pnpm's "Ignored build scripts: ..." output. */
@@ -1004,8 +1132,10 @@ export function createJobTracker() {
1004
1132
 
1005
1133
  // Windows spawn 走 shell,spec 会被拼进 cmd 行;agent 传入的参数不可信。
1006
1134
  // 合法的 npm 名 / github:owner\/repo / git·file·link·URL spec 都不含这些
1007
- // shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。
1008
- const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
1135
+ // shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。`%` 在列:cmd 会
1136
+ // `%VAR%` 环境变量展开,展开结果常含分号/空格,足以改变参数切分。
1137
+ // (cli.js 的同款黑名单一直含 %,此前三处已经漂移。)
1138
+ const UNSAFE_SPEC_RE = /[;&|`$()<>^%!"*\n\r]/;
1009
1139
 
1010
1140
  /** Reject install/remove specs carrying shell metacharacters. */
1011
1141
  export function assertSafeSpec(spec) {
@@ -1130,36 +1260,11 @@ function serializedProducer(lockKey, start) {
1130
1260
  // (a .cmd shim cannot be spawned with shell:false on modern Node), cancel
1131
1261
  // terminates the whole process tree and the done chain waits for the
1132
1262
  // wrapper's 'close' before any rollback runs.
1133
-
1134
- /** First `binary<ext>` found on PATH, or undefined. */
1135
- function findOnPath(binary, { platform, pathEnv, extensions }) {
1136
- const separator = platform === "win32" ? ";" : ":";
1137
- for (const dir of String(pathEnv ?? "").split(separator)) {
1138
- if (dir.length === 0) continue;
1139
- for (const ext of extensions) {
1140
- const candidate = join(dir, `${binary}${ext}`);
1141
- if (existsSync(candidate)) return candidate;
1142
- }
1143
- }
1144
- return undefined;
1145
- }
1146
-
1147
- /**
1148
- * How to spawn pnpm on this platform.
1149
- * @returns {{ command: string, shell: boolean, treeKill: boolean }}
1150
- * treeKill marks the shell-wrapped case: cancel must taskkill /T the tree.
1151
- */
1152
- function pnpmSpawnPlan({ platform = process.platform, pathEnv = process.env.PATH } = {}) {
1153
- if (platform !== "win32") return { command: "pnpm", shell: false, treeKill: false };
1154
- // A real .exe spawns without a shell — cancel then kills pnpm itself.
1155
- const exe = findOnPath("pnpm", { platform, pathEnv, extensions: [".exe"] });
1156
- if (exe !== undefined) return { command: exe, shell: false, treeKill: false };
1157
- // Only the .cmd shim: Node refuses batch files with shell:false (EINVAL
1158
- // since the batch-file argument-injection fix), so a cmd wrapper is
1159
- // unavoidable — flag it so cancel kills the whole tree, not the wrapper.
1160
- const cmd = findOnPath("pnpm", { platform, pathEnv, extensions: [".cmd"] });
1161
- return { command: cmd ?? "pnpm", shell: true, treeKill: true };
1162
- }
1263
+ //
1264
+ // The plan itself is pnpmSpawnPlan in guard.js (imported above) — one
1265
+ // implementation, not a local mirror. It quotes the .cmd path: Node's
1266
+ // shell:true joins without per-argument quoting, and `D:\Program Files\…`
1267
+ // would be cut at the first space (`'D:\Program' is not recognized`).
1163
1268
 
1164
1269
  /**
1165
1270
  * Terminate a shell-wrapped process tree (Windows): taskkill /T /F kills the
@@ -1923,6 +2028,17 @@ async function runTransactionFixtures() {
1923
2028
  const tick = () => new Promise((resolve) => setTimeout(resolve, 1));
1924
2029
  const flush = async (rounds = 5) => { for (let index = 0; index < rounds; index++) await tick(); };
1925
2030
 
2031
+ // 纯函数前置:spec 黑名单。`%` 必须在内——cmd 的 %VAR% 展开元字符,
2032
+ // 展开值里的分号/空格足以重塑 argv;三处黑名单曾漂移(cli.js 一直有,
2033
+ // installer/guard 漏过)。
2034
+ {
2035
+ const rejects = (value) => {
2036
+ try { assertSafeSpec(value); return false; } catch { return true; }
2037
+ };
2038
+ check("assertSafeSpec:cmd 的 %VAR% 展开元字符被拒绝", rejects("evil-pkg%PATH%"));
2039
+ check("assertSafeSpec:正常 spec 不受影响", !rejects("some-plugin@1.0.0") && !rejects("github:owner/repo"));
2040
+ }
2041
+
1926
2042
  // 0. hashPackageTree 确定性与符号链接越界防御
1927
2043
  {
1928
2044
  const tempDir = mkdtempSync(join(tmpdir(), "dsh-mall-selftest-hash-"));
@@ -2439,7 +2555,9 @@ async function runTransactionFixtures() {
2439
2555
  }
2440
2556
 
2441
2557
  // 4a. spawn 计划(纯函数):非 Windows 无 shell;Windows 有 .exe 则
2442
- // shell:false,仅 .cmd 则 shell:true + treeKill,全找不到回退 "pnpm"。
2558
+ // shell:false,仅 .cmd 则 shell:true + treeKill 且 command 自带引号
2559
+ // (.cmd 常在 `D:\Program Files\nodejs` 这类带空格的目录里,shell:true 下
2560
+ // Node 只拼空格不逐参数引用,不引用就从空格截断),全找不到回退 "pnpm"。
2443
2561
  {
2444
2562
  const shimDir = mkdtempSync(join(tmpdir(), "dsh-mall-selftest-path-"));
2445
2563
  try {
@@ -2449,15 +2567,36 @@ async function runTransactionFixtures() {
2449
2567
  const missingOk = planMissing.command === "pnpm" && planMissing.shell === true && planMissing.treeKill === true;
2450
2568
  writeFileSync(join(shimDir, "pnpm.cmd"), "@echo off\r\n");
2451
2569
  const planCmd = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
2452
- const cmdOk = planCmd.command === join(shimDir, "pnpm.cmd") && planCmd.shell === true && planCmd.treeKill === true;
2570
+ const cmdOk = planCmd.command === `"${join(shimDir, "pnpm.cmd")}"` && planCmd.shell === true && planCmd.treeKill === true;
2453
2571
  writeFileSync(join(shimDir, "pnpm.exe"), "MZ");
2454
2572
  const planExe = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
2455
2573
  const exeOk = planExe.command === join(shimDir, "pnpm.exe") && planExe.shell === false && planExe.treeKill === false;
2456
2574
  check(
2457
- "pnpmSpawnPlan:posix 直起 / win32 优先 .exe 无 shell / 仅 .cmd treeKill",
2575
+ "pnpmSpawnPlan:posix 直起 / win32 优先 .exe 无 shell / 仅 .cmd 则带引号 + treeKill",
2458
2576
  posixOk && missingOk && cmdOk && exeOk,
2459
2577
  JSON.stringify({ planPosix, planMissing, planCmd, planExe }),
2460
2578
  );
2579
+ // 空格目录不是假想敌:Node 默认装进 Program Files,真实机器上命令从
2580
+ // 空格截断曾让「'D:\Program' 不是内部或外部命令」拦下所有预检。
2581
+ const spaceDir = join(shimDir, "dir with space");
2582
+ mkdirSync(spaceDir, { recursive: true });
2583
+ writeFileSync(join(spaceDir, "pnpm.cmd"), "@echo probe-ok\r\n");
2584
+ const planSpace = pnpmSpawnPlan({ platform: "win32", pathEnv: spaceDir });
2585
+ check(
2586
+ "pnpmSpawnPlan:带空格的 .cmd 路径 → command 自带引号",
2587
+ planSpace.shell === true && planSpace.command === `"${join(spaceDir, "pnpm.cmd")}"`,
2588
+ JSON.stringify(planSpace),
2589
+ );
2590
+ // 引用必须真的可跑:Windows 真机端到端 spawn 一轮假 shim(CI 是 Linux,
2591
+ // 只跑静态断言;Windows 上这一条覆盖完整链路)。
2592
+ if (process.platform === "win32") {
2593
+ const probe = spawnSync(planSpace.command, ["--version"], { shell: planSpace.shell, encoding: "utf8", timeout: 15000 });
2594
+ check(
2595
+ "pnpmSpawnPlan:带引号 command 真实 spawn 不再被空格截断",
2596
+ probe.status === 0 && /probe-ok/.test(probe.stdout ?? ""),
2597
+ `status=${probe.status} stdout=${JSON.stringify((probe.stdout ?? "").slice(0, 80))} stderr=${JSON.stringify((probe.stderr ?? "").slice(0, 80))}`,
2598
+ );
2599
+ }
2461
2600
  } finally {
2462
2601
  rmSync(shimDir, { recursive: true, force: true });
2463
2602
  }
@@ -2492,9 +2631,76 @@ async function runTransactionFixtures() {
2492
2631
  return failed;
2493
2632
  }
2494
2633
 
2634
+ /**
2635
+ * The patch-layer edit behind enable/disable. Text surgery on a file users
2636
+ * hand-write, so every shape it can meet is pinned here.
2637
+ */
2638
+ function runToggleFixtures() {
2639
+ let failed = 0;
2640
+ const check = (label, ok, extra = "") => {
2641
+ if (!ok) failed++;
2642
+ console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` ${extra}`}`);
2643
+ };
2644
+ // 真实环境里 patch 层默认就是这个样子——注释 + 空数组。插件由 bundle 层
2645
+ // 挂载,用户文件里一条都没有。第一版只测了「行已存在」的情形,于是停用
2646
+ // 被静默跳过、重启后插件又回来了。这组用例先钉死这个场景。
2647
+ const stockTemplate = "# Your patch layer for this dsh profile\n[]\n";
2648
+ const fresh = setPatchRowDisabled(stockTemplate, "at-file", true, "dsh-at-file");
2649
+ check("空 patch 层(模板 [])→ 追加 id 定向覆盖行", /- id: at-file/.test(fresh ?? "") && /disabled: true/.test(fresh ?? ""), JSON.stringify(fresh));
2650
+ check("空 patch 层:替换掉 [] 而不是留着", !/\[\]/.test(fresh ?? ""), JSON.stringify(fresh));
2651
+ check("空 patch 层:保留原有注释", (fresh ?? "").includes("# Your patch layer"));
2652
+ check("新建行带 name 便于 dsh 校验陈旧 patch", /name: 'dsh-at-file'/.test(fresh ?? ""));
2653
+ check("空 patch 层 + 要启用 → 不改动", setPatchRowDisabled(stockTemplate, "at-file", false, "dsh-at-file") === undefined);
2654
+ check("新建的行能被 YAML 解析且是数组", (() => {
2655
+ try { return Array.isArray(load(fresh)); } catch { return false; }
2656
+ })());
2657
+ check("新建行的语义正确(id + disabled)", (() => {
2658
+ try { const doc = load(fresh); return doc[0].id === "at-file" && doc[0].disabled === true; } catch { return false; }
2659
+ })());
2660
+
2661
+ const base = "- id: at-file\n name: dsh-at-file\n";
2662
+
2663
+ const off = setPatchRowDisabled(base, "at-file", true);
2664
+ check("无 disabled 行 → 插入 disabled: true", /^\s{2}disabled: true$/m.test(off ?? ""), JSON.stringify(off));
2665
+ check("插入后其余字节不变", (off ?? "").includes("name: dsh-at-file"));
2666
+
2667
+ const on = setPatchRowDisabled(`- id: at-file\n name: dsh-at-file\n disabled: true\n`, "at-file", false);
2668
+ check("已停用 → 改回 false", /disabled: false/.test(on ?? ""), JSON.stringify(on));
2669
+
2670
+ check("已是目标状态 → 不改动", setPatchRowDisabled(`- id: at-file\n disabled: true\n`, "at-file", true) === undefined);
2671
+ check("本就启用且要启用 → 不改动", setPatchRowDisabled(base, "at-file", false) === undefined);
2672
+ check("条目不在 patch 层 → 追加新行,原有条目不动", (() => {
2673
+ const out = setPatchRowDisabled(base, "other-id", true, "pkg-other");
2674
+ return /- id: other-id/.test(out ?? "") && (out ?? "").includes("- id: at-file");
2675
+ })());
2676
+
2677
+ // 用户写的条件逻辑不能被两态开关压平——必须拒绝并让人手改。
2678
+ let threw = false;
2679
+ try { setPatchRowDisabled(`- id: at-file\n disabled: !!js process.platform === 'win32'\n`, "at-file", false); }
2680
+ catch (error) { threw = /!!js expression/.test(error.message); }
2681
+ check("disabled 是 !!js 表达式 → 拒绝接管", threw);
2682
+
2683
+ // 注释是用户手写的,一个字节都不能动。
2684
+ const commented = "# 我的覆盖\n- id: at-file # 保留这个注释\n name: dsh-at-file\n";
2685
+ const kept = setPatchRowDisabled(commented, "at-file", true);
2686
+ check("注释原样保留", (kept ?? "").includes("# 我的覆盖") && (kept ?? "").includes("# 保留这个注释"));
2687
+
2688
+ // 多条目:只动目标那条,相邻条目不受影响。
2689
+ const multi = "- id: a\n name: pkg-a\n- id: at-file\n name: dsh-at-file\n- id: z\n name: pkg-z\n disabled: true\n";
2690
+ const one = setPatchRowDisabled(multi, "at-file", true);
2691
+ check("多条目:只动目标条目", (one ?? "").split("disabled: true").length - 1 === 2 && (one ?? "").includes("- id: z"));
2692
+ check("多条目:不误伤相邻条目的 disabled", setPatchRowDisabled(multi, "a", true)?.includes("- id: z\n name: pkg-z\n disabled: true") === true);
2693
+
2694
+ check("带引号的 id 也能匹配", setPatchRowDisabled(`- id: '@scope/pkg'\n name: x\n`, "@scope/pkg", true) !== undefined);
2695
+ return failed;
2696
+ }
2697
+
2495
2698
  if (process.argv[1]?.endsWith("installer.js") && process.argv.includes("--self-test")) {
2699
+ console.log("启用/停用 patch 层 fixtures:");
2700
+ const toggleFailed = runToggleFixtures();
2701
+ console.log();
2496
2702
  console.log("allowBuilds 合并 fixtures:");
2497
- const failed = runAllowBuildsFixtures();
2703
+ const failed = runAllowBuildsFixtures() + toggleFailed;
2498
2704
  console.log(`${ALLOW_BUILDS_FIXTURES.length - failed}/${ALLOW_BUILDS_FIXTURES.length} passed`);
2499
2705
  // 实装 pnpm add 的参数/环境(纯函数):peer 自动安装必须关闭,否则
2500
2706
  // marketplace 安装会把 @deepseek-ai 宿主依赖栈拉进 profile;构建脚本必须