@1e0zj/dsh-plugin-mall 0.3.4 → 0.3.5

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.
Files changed (3) hide show
  1. package/README.md +17 -1
  2. package/package.json +1 -1
  3. package/src/installer.js +771 -35
package/README.md CHANGED
@@ -47,6 +47,13 @@ Restart dsh after installing.
47
47
  > spec (recipe in the 安装 section below). Do not hand-overwrite files under
48
48
  > `node_modules`: they are hard-linked into pnpm's global store, and any later
49
49
  > `pnpm add/remove` rebuilds the tree and restores them anyway.
50
+ >
51
+ > Re-packing and running `add` again does **nothing**: the `file:` spec and the
52
+ > version are unchanged, so pnpm calls it already installed and never compares
53
+ > the tarball's bytes. The command succeeds, `package.json` looks right, and
54
+ > `node_modules` still holds the previous build. Always `remove` before `add`,
55
+ > or give the test build its own version. Then restart dsh completely — the
56
+ > install ran inside the host process still executing the old code.
50
57
 
51
58
  ## Startup protection (guard CLI)
52
59
 
@@ -183,7 +190,16 @@ dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
183
190
  > ```
184
191
  >
185
192
  > 这样 pnpm 的规范副本本身就是新代码,后续任何 `pnpm add/remove` 重建依赖树都不会
186
- > 把它换掉;顺带还验证了 `files` 字段没漏文件。改完代码重新 `npm pack` + 重装即可。
193
+ > 把它换掉;顺带还验证了 `files` 字段没漏文件。
194
+ >
195
+ > **`remove` 那一步不能省。** 改完代码重新 `npm pack` 之后只跑 `add`,pnpm 会
196
+ > **什么都不做**:spec(`file:` 路径)和版本号都没变,它就判定「已经装好了」而
197
+ > 跳过,根本不去比对 tarball 的字节。表现是命令成功返回、`package.json` 看着也
198
+ > 对,但 `node_modules` 里还是上一版代码——排查时极难想到这一层。要么每次都
199
+ > `remove` + `add`,要么给测试包换一个版本号(如 `0.3.5-test.1`)。
200
+ >
201
+ > 同理,**装完必须完整重启 dsh**(不是刷新页面):卸载/安装是由**正在运行的那个
202
+ > 宿主进程**执行的,它内存里跑的还是旧代码。新装的代码要下一次启动才生效。
187
203
  >
188
204
  > **不要用直接覆盖 `node_modules` 里文件的办法。** 它有两个坑:
189
205
  > 一是 pnpm 装出来的文件是**硬链接**(与全局 store 共享 inode),直接 `cp` 覆盖会
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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 { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, markPendingApprovalPause, markPendingSnapshot, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
19
+ import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, describeRollbackRebuild, markPendingApprovalPause, markPendingSnapshot, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot, validateInstalledProfile, validateRemoveCompletion } from "./guard.js";
20
20
 
21
21
  // ── spec normalization ──────────────────────────────────────────────────────
22
22
 
@@ -93,12 +93,34 @@ function writeJsonChecked(filePath, nextContent, label) {
93
93
  function writePatchChecked(filePath, nextContent) {
94
94
  return writeChecked(filePath, nextContent, (text) => {
95
95
  const doc = load(text);
96
- if (doc !== null && doc !== undefined && !Array.isArray(doc)) {
97
- throw new Error("expected a top-level array of patch entries");
96
+ // null/undefined 曾被放行——那是一个只剩注释的文档,我们的校验说它没问题,
97
+ // dsh parsePatchList 明确 `if (!Array.isArray(parsed)) throw ... must
98
+ // be a top-level YAML array`,于是 profile 写完就起不来。写后回读校验的全部
99
+ // 意义是「写出去的东西消费方能吃」,判据必须和消费方一致,不能更宽松。
100
+ if (!Array.isArray(doc)) {
101
+ throw new Error("expected a top-level array of patch entries (dsh refuses to boot on anything else, including a comments-only file)");
98
102
  }
99
103
  }, "cordis.patch.yml");
100
104
  }
101
105
 
106
+ /**
107
+ * Serialize a patch file back after rows were spliced out.
108
+ *
109
+ * A file that keeps its header comments but loses every entry parses as `null`,
110
+ * not `[]` — and dsh refuses to boot on it. So the empty result has to carry an
111
+ * explicit `[]`, exactly like the stock template does.
112
+ *
113
+ * @param lines - the remaining lines after splicing.
114
+ * @returns the text to write.
115
+ */
116
+ function serializePatchLines(lines) {
117
+ const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
118
+ if (next.length === 0) return "[]\n";
119
+ // 还有条目就原样保留;一条不剩(只余注释)必须补回空数组。
120
+ const hasEntries = next.split("\n").some((line) => /^\s*-\s/.test(line));
121
+ return hasEntries ? `${next}\n` : `${next}\n[]\n`;
122
+ }
123
+
102
124
  // ── profile management ──────────────────────────────────────────────────────
103
125
 
104
126
  /** Resolve and initialize (on first use) the target profile directory. */
@@ -131,6 +153,23 @@ function isBundlePackage(packageName, profileDir) {
131
153
  return classifyPackage(packageName, profileDir) === "bundle";
132
154
  }
133
155
 
156
+ /**
157
+ * The version of `packageName` as it exists in the profile right now, or
158
+ * undefined when it is absent or its manifest is unreadable. Transitive
159
+ * packages count: the build gate is about what is on disk, not about what the
160
+ * profile declares.
161
+ */
162
+ export function installedVersionOf(packageName, profileDir) {
163
+ const path = packageJsonPathOf(packageName, profileDir);
164
+ if (path === undefined) return undefined;
165
+ try {
166
+ const version = JSON.parse(readFileSync(path, "utf8"))?.version;
167
+ return typeof version === "string" && version.length > 0 ? version : undefined;
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ }
172
+
134
173
  /**
135
174
  * Classify an installed package by its `dsh` declaration:
136
175
  * `bundle` (a profile patch layer), `client` (a browser-side UI plugin),
@@ -382,11 +421,74 @@ export function removeClientRow(profileDir, packageName) {
382
421
  break;
383
422
  }
384
423
  if (!removed) return { removed: false };
385
- const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
386
- writePatchChecked(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
424
+ writePatchChecked(patchPath, serializePatchLines(lines));
387
425
  return { removed: true, rowId };
388
426
  }
389
427
 
428
+ /**
429
+ * Drop the enable/disable override rows this profile holds for `packageName`.
430
+ *
431
+ * Uninstall has cleaned up the *insert* row it writes for browser plugins since
432
+ * it existed (v0.1.17). The enable/disable feature arrived three days later
433
+ * (v0.3.0) and introduced a second kind of row — an id-targeted override —
434
+ * which nothing ever removed. Uninstalling a plugin you had toggled therefore
435
+ * left a row pointing at an entry that no longer exists, and dsh warns about it
436
+ * on every boot: `patch: entry "x" not found`.
437
+ *
438
+ * The warning is the mild half. The row also **comes back to life on reinstall**
439
+ * — a plugin uninstalled while disabled returns disabled, looking installed and
440
+ * doing nothing, with no visible reason.
441
+ *
442
+ * Only rows in exactly the shape {@link setPatchRowDisabled} writes are removed:
443
+ * an id, our `name` guard, and a literal boolean `disabled`. Anything else is
444
+ * the user's own content and stays, warning and all —
445
+ *
446
+ * - `disabled: !!js …` is a condition they wrote (see setPatchRowDisabled,
447
+ * which refuses to overwrite it for the same reason). The package is gone,
448
+ * but the expression is still theirs.
449
+ * - a row carrying `config:` or other keys means more than a toggle.
450
+ *
451
+ * A leftover warning costs a line of console noise. Deleting user configuration
452
+ * silently costs something we cannot give back.
453
+ *
454
+ * @param profileDir - the profile whose patch layer to edit.
455
+ * @param packageName - the module name in the rows' `name:` guard.
456
+ * @returns the entry ids whose rows were removed.
457
+ */
458
+ export function removeToggleRows(profileDir, packageName) {
459
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
460
+ if (!existsSync(patchPath)) return { removed: [] };
461
+ const content = readFileSync(patchPath, "utf8");
462
+ // 文件坏了就整个不碰——和 mergeAllowBuilds 同样的态度:只会越弄越糟。
463
+ try {
464
+ load(content);
465
+ } catch {
466
+ return { removed: [] };
467
+ }
468
+ const lines = content.split("\n");
469
+ const quoted = `'${packageName}'`;
470
+ const removed = [];
471
+ // 从后往前删,前面的下标才不会被影响。
472
+ for (let index = lines.length - 3; index >= 0; index--) {
473
+ const idMatch = /^-\s+id:\s*(\S+)\s*$/.exec(lines[index]);
474
+ if (idMatch === null) continue;
475
+ const nameMatch = /^\s{2}name:\s*(\S+)\s*$/.exec(lines[index + 1]);
476
+ if (nameMatch === null) continue;
477
+ if (nameMatch[1] !== quoted && nameMatch[1] !== packageName) continue;
478
+ // 第三行必须是字面量布尔的 disabled,且第四行不能还属于这一条——
479
+ // 多一个键就说明这行不只是个开关,留给用户。
480
+ if (!/^\s{2}disabled:\s*(?:true|false)\s*$/.test(lines[index + 2])) continue;
481
+ // 这一条不能还有别的内容。额外键必然是缩进的(` config:`),而顶格的
482
+ // 注释或下一条 `- ` 不属于它——把注释当成额外键会让这行永远删不掉。
483
+ if (/^\s+\S/.test(lines[index + 3] ?? "")) continue;
484
+ lines.splice(index, 3);
485
+ removed.unshift(idMatch[1]);
486
+ }
487
+ if (removed.length === 0) return { removed: [] };
488
+ writePatchChecked(patchPath, serializePatchLines(lines));
489
+ return { removed };
490
+ }
491
+
390
492
  // ── enable / disable persistence ────────────────────────────────────────────
391
493
  //
392
494
  // Toggling a plugin is three layers, and only the middle one lives here:
@@ -641,12 +743,51 @@ export function mergeAllowBuilds(content, names) {
641
743
  }
642
744
 
643
745
  /**
644
- * Neutralize allowBuilds in pnpm-workspace.yaml so that all lifecycle scripts
645
- * are strictly blocked by pnpm on the initial install.
746
+ * Split an allowBuilds key into its package name and the spec pnpm matched it
747
+ * by, if the key carries one. `node-pty` name only; `node-pty@1.1.0` and
748
+ * `pkg@file:../pkg` → name plus spec. Mirrors the selector parsing in
749
+ * {@link parseIgnoredBuilds}, because these two have to agree on what a key
750
+ * refers to or an approval will be preserved against the wrong package.
751
+ */
752
+ function splitBuildSelector(key) {
753
+ const text = String(key ?? "");
754
+ const suffix = /@(?:([\w.+-]+)|https?:\/\/\S+|file:\S+|link:\S+|github:\S+)$/.exec(text);
755
+ if (suffix === null) return NPM_NAME_RE.test(text) ? { name: text } : undefined;
756
+ const name = text.slice(0, suffix.index);
757
+ return NPM_NAME_RE.test(name) ? { name, spec: text.slice(suffix.index + 1) } : undefined;
758
+ }
759
+
760
+ /**
761
+ * Neutralize allowBuilds in pnpm-workspace.yaml so that pnpm strictly blocks
762
+ * the lifecycle scripts of everything this transaction introduces.
763
+ *
764
+ * The property being defended is narrow: a package that is NEW to this install
765
+ * must not run scripts on the strength of an approval the user gave to some
766
+ * earlier package. Wiping the whole allow-list enforced that — and also
767
+ * re-blocked packages that were already installed and already approved, which
768
+ * had nothing to do with the install in flight. Installing an unrelated plugin
769
+ * then re-asked about, say, `node-pty` pulled in months ago by a different
770
+ * plugin, with the disclosure card correctly but uselessly reporting it as "a
771
+ * transitive dependency — NOT the package you asked for".
772
+ *
773
+ * That is worse than noise. An approval prompt that fires on every install for
774
+ * a package the user never chose is the fastest way to train people to approve
775
+ * without reading, which is the entire value of the gate.
776
+ *
777
+ * So preserve exactly the approvals that cannot cover anything new: a package
778
+ * already on disk, still at the version it was approved at. Bare-name keys are
779
+ * pinned to the installed version on the way in (`node-pty` →
780
+ * `node-pty@1.1.0`), so the same transaction pulling a DIFFERENT version of an
781
+ * approved package still meets a closed gate. Anything whose installed version
782
+ * cannot be established is dropped — fail closed, the user is asked again.
783
+ *
646
784
  * @param content - current pnpm-workspace.yaml contents.
785
+ * @param resolveInstalledVersion - `(name) => version | undefined` for what is
786
+ * on disk right now; omitted (tests, callers without a profile) drops every
787
+ * approval, which is the old all-or-nothing behaviour.
647
788
  * @returns the neutralized workspace yaml.
648
789
  */
649
- export function neutralizeWorkspaceContent(content) {
790
+ export function neutralizeWorkspaceContent(content, resolveInstalledVersion) {
650
791
  const source = typeof content === "string" ? content : DEFAULT_WORKSPACE_YAML;
651
792
  let parsed;
652
793
  try {
@@ -658,12 +799,46 @@ export function neutralizeWorkspaceContent(content) {
658
799
  if (typeof parsed !== "object" || Array.isArray(parsed)) {
659
800
  throw new Error("pnpm-workspace.yaml root must be a mapping");
660
801
  }
661
- parsed.allowBuilds = {};
802
+ parsed.allowBuilds = preserveInstalledApprovals(parsed.allowBuilds, resolveInstalledVersion);
662
803
  parsed.onlyBuiltDependencies = [];
663
804
  parsed.dangerouslyAllowAllBuilds = false;
664
805
  return dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false });
665
806
  }
666
807
 
808
+ /**
809
+ * The subset of `current` that provably cannot authorize anything new, keyed by
810
+ * the selector pnpm will match. See {@link neutralizeWorkspaceContent}.
811
+ */
812
+ function preserveInstalledApprovals(current, resolveInstalledVersion) {
813
+ if (typeof resolveInstalledVersion !== "function") return {};
814
+ const approvedKeys = Array.isArray(current)
815
+ ? current.map((entry) => String(entry))
816
+ : current !== null && typeof current === "object"
817
+ ? Object.entries(current).filter(([, value]) => value === true).map(([key]) => key)
818
+ : [];
819
+ const preserved = {};
820
+ for (const key of approvedKeys) {
821
+ const parsedKey = splitBuildSelector(key);
822
+ if (parsedKey === undefined) continue;
823
+ let installed;
824
+ try {
825
+ installed = resolveInstalledVersion(parsedKey.name);
826
+ } catch {
827
+ continue; // 读不出来就当没批准过——失败方向朝「再问一次」
828
+ }
829
+ if (typeof installed !== "string" || installed.length === 0) continue;
830
+ if (parsedKey.spec === undefined) {
831
+ // 裸名:钉到当前已装版本,别让同一次事务换上来的新版本蹭到。
832
+ preserved[`${parsedKey.name}@${installed}`] = true;
833
+ } else if (parsedKey.spec === installed) {
834
+ preserved[key] = true;
835
+ }
836
+ // 非 registry 的 selector(file:/link:/github:)对不上已装版本号,
837
+ // 无法证明它只覆盖眼下这一份,一律丢弃重问。
838
+ }
839
+ return preserved;
840
+ }
841
+
667
842
  /** Enable exactly the selectors pnpm itself reported while every broad build
668
843
  * policy switch stays disabled. The caller restores these temporary bytes as
669
844
  * soon as the rebuild process closes. */
@@ -1516,10 +1691,17 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1516
1691
  }
1517
1692
 
1518
1693
  // Neutralize existing allowBuilds before every first pnpm add so strict-dep-builds
1519
- // always blocks candidate lifecycle scripts regardless of pre-existing workspace policy.
1694
+ // blocks the lifecycle scripts of everything this transaction introduces,
1695
+ // regardless of pre-existing workspace policy. Approvals for packages already
1696
+ // on disk are pinned to their installed version and kept — they cannot cover
1697
+ // anything new, and re-asking about them on every unrelated install is how a
1698
+ // consent gate gets trained into a reflex. See neutralizeWorkspaceContent.
1520
1699
  try {
1521
1700
  if (originalWorkspaceBytes !== undefined) {
1522
- const neutralized = neutralizeWorkspaceContent(originalWorkspaceBytes.toString("utf8"));
1701
+ const neutralized = neutralizeWorkspaceContent(
1702
+ originalWorkspaceBytes.toString("utf8"),
1703
+ (name) => installedVersionOf(name, profileDir),
1704
+ );
1523
1705
  writeYamlChecked(workspacePath, neutralized, "pnpm-workspace.yaml");
1524
1706
  } else {
1525
1707
  writeYamlChecked(workspacePath, DEFAULT_WORKSPACE_YAML, "pnpm-workspace.yaml");
@@ -1725,7 +1907,24 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1725
1907
  return { status: "failed", detail: `pnpm add ${spec} still failed after allowing build scripts (exit code ${retryOutcome.exitCode}). See job output.` };
1726
1908
  };
1727
1909
 
1728
- const first = spawnAdd();
1910
+ // spawn 也可能**同步**抛(无效参数、平台细节)。此刻 marker 已写下,且
1911
+ // pnpm-workspace.yaml 正停在被中和的状态——异常若绕过下面的 .catch 冒出去,
1912
+ // restoreOriginalWorkspace() 永远不会执行。那不只是遗留一个 marker:启动
1913
+ // 恢复看到 profile 校验通过就会 commit 并删掉快照,用户的 allowBuilds 批准
1914
+ // 就此永久丢失。所以这里必须自己接住并走完整的收尾。
1915
+ let first;
1916
+ try {
1917
+ first = spawnAdd();
1918
+ } catch (error) {
1919
+ restoreOriginalWorkspace();
1920
+ const detail = `could not start pnpm: ${error?.message ?? String(error)}`;
1921
+ try {
1922
+ rollbackPendingSnapshot(profileDir);
1923
+ return { cancel: () => {}, done: Promise.resolve({ status: "failed", detail: `${detail}; the profile was restored to its pre-install state` }), readOutput: () => deltaQueue.splice(0).join("") };
1924
+ } catch (rollbackError) {
1925
+ return { cancel: () => {}, done: Promise.resolve({ status: "failed", detail: `${detail}; rollback also failed and the pending marker was kept for recovery: ${rollbackError.message}` }), readOutput: () => deltaQueue.splice(0).join("") };
1926
+ }
1927
+ }
1729
1928
  current = first;
1730
1929
  const done = first.done
1731
1930
  .then((outcome) => settle(outcome))
@@ -1862,14 +2061,69 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
1862
2061
  };
1863
2062
  let current = undefined;
1864
2063
 
2064
+ // Snapshot + pending marker, exactly as an install does.
2065
+ //
2066
+ // `pnpm remove` was treated as atomic here, and it is not: a real removal
2067
+ // deleted node_modules/<pkg>, then failed writing pnpm-lock.yaml (EPERM on
2068
+ // Windows — the rename target was held by another process) and exited
2069
+ // nonzero. This function reported "failed" and returned, leaving a profile
2070
+ // whose package.json still declared the package as a bundle layer while its
2071
+ // directory was gone. dsh then refused to boot at all: resolveBundleDir
2072
+ // throws while composing the profile, which happens BEFORE any plugin — so
2073
+ // the startup recovery inside apply() could never have run either, and no
2074
+ // marker existed for it to act on regardless.
2075
+ //
2076
+ // The install path has carried this protection from the start, and so has
2077
+ // `guard remove` in the CLI. Only this one, the path the marketplace UI and
2078
+ // the agent tool both use, was left outside it.
2079
+ let snapshot;
2080
+ try {
2081
+ snapshot = createProfileSnapshot(profileDir, { operation: "remove", packageName });
2082
+ } catch (error) {
2083
+ return failedNow(`cannot snapshot profile "${profile}" before removing ${packageName}: ${error.message} — refusing to touch the profile`);
2084
+ }
2085
+ try {
2086
+ markPendingSnapshot(snapshot, { operation: "remove", candidate: { name: packageName } });
2087
+ } catch (error) {
2088
+ rmSync(snapshot.dir, { recursive: true, force: true });
2089
+ return failedNow(`cannot register the remove pending marker for ${packageName}: ${error.message} — refusing to touch the profile`);
2090
+ }
2091
+
2092
+ /** Restore the pre-remove bytes and settle the marker; never throws. */
2093
+ const rollbackRemove = (reason) => {
2094
+ try {
2095
+ const rolled = rollbackPendingSnapshot(profileDir);
2096
+ if (rolled === undefined) {
2097
+ // marker 不见了(外部删除、或本轮压根没登记成功)——没有还原目标,
2098
+ // 就绝不能声称已还原。说实话比说好听重要:用户据此决定要不要手工检查。
2099
+ return { status: "failed", detail: `${reason}; no pending marker was found, so the profile could NOT be restored automatically — check it before the next start (\`dsh-plugin-guard guard validate --profile ${profile}\`)` };
2100
+ }
2101
+ const rebuild = describeRollbackRebuild(rolled.rebuild);
2102
+ return { status: "failed", detail: `${reason}; the profile was restored to its pre-remove state${rebuild === undefined ? "" : ` (node_modules rebuild — ${rebuild})`}` };
2103
+ } catch (rollbackError) {
2104
+ // 回滚失败时**保留 marker**:磁盘状态未知,交给启动恢复/`guard recover`,
2105
+ // 绝不能声称已还原。
2106
+ return { status: "failed", detail: `${reason}; rollback also failed and the pending marker was kept for recovery: ${rollbackError.message}` };
2107
+ }
2108
+ };
2109
+
1865
2110
  const plan = _spawn === undefined ? pnpmSpawnPlan() : { command: "pnpm", shell: false, treeKill: false };
1866
- const proc = (_spawn ?? spawn)(plan.command, ["remove", packageName, "--reporter=append-only"], {
1867
- cwd: profileDir,
1868
- env: process.env,
1869
- shell: plan.shell,
1870
- stdio: ["ignore", "pipe", "pipe"],
1871
- windowsHide: true,
1872
- });
2111
+ // spawn 也可能**同步**抛(无效参数、平台细节),而 marker 此刻已经写下了。
2112
+ // 不接住的话异常会绕过 rollbackRemove 直接冒到 serializedProducer,marker
2113
+ // 留在盘上挡住这个 profile 后续所有安装和卸载,直到下次启动恢复收拾它。
2114
+ let proc;
2115
+ try {
2116
+ proc = (_spawn ?? spawn)(plan.command, ["remove", packageName, "--reporter=append-only"], {
2117
+ cwd: profileDir,
2118
+ env: process.env,
2119
+ shell: plan.shell,
2120
+ stdio: ["ignore", "pipe", "pipe"],
2121
+ windowsHide: true,
2122
+ });
2123
+ } catch (error) {
2124
+ const settled = rollbackRemove(`could not start pnpm: ${error?.message ?? String(error)}`);
2125
+ return { cancel: () => {}, done: Promise.resolve(settled), readOutput: () => deltaQueue.splice(0).join("") };
2126
+ }
1873
2127
  current = { proc, treeKill: plan.treeKill };
1874
2128
  const done = new Promise((resolve) => {
1875
2129
  proc.on("error", (error) => resolve({ spawnError: error }));
@@ -1881,31 +2135,58 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
1881
2135
  // outcome,返回 producer 本体会让 tracker 把成功任务记成 failed)。
1882
2136
  if (outcome.spawnError.code === "ENOENT" && selfHealed !== true) {
1883
2137
  const healed = await enablePnpmViaCorepack(push);
1884
- if (healed) return await runRemoveInner({ profile, packageName, _profileDir, _spawn }, true).done;
2138
+ // 重试前先把本轮的事务状态收掉——否则重试那一轮会被自己的 marker 挡住。
2139
+ // 收不掉就不许重试:递归只会撞上自己的 marker 并返回笼统的「有未了结
2140
+ // 事务」,把真正的回滚错因盖掉,而那正是用户需要看到的东西。
2141
+ if (healed) {
2142
+ try {
2143
+ rollbackPendingSnapshot(profileDir);
2144
+ } catch (rollbackError) {
2145
+ return { status: "failed", detail: `pnpm was missing and corepack enabled it, but clearing this remove's pending marker failed, so the retry was not attempted: ${rollbackError.message}. The marker was kept for recovery (restart dsh, or run \`dsh-plugin-guard guard recover\`).` };
2146
+ }
2147
+ return await runRemoveInner({ profile, packageName, _profileDir, _spawn }, true).done;
2148
+ }
1885
2149
  }
1886
2150
  const hint = outcome.spawnError.code === "ENOENT"
1887
2151
  ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
1888
2152
  : `could not start pnpm: ${outcome.spawnError.message}`;
1889
- return { status: "failed", detail: hint };
2153
+ return rollbackRemove(hint);
1890
2154
  }
1891
2155
  if (outcome.exitCode === null) {
1892
- return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
2156
+ // 取消也要还原:pnpm 可能已经删掉了 node_modules 里的目录。
2157
+ const killed = rollbackRemove(outcome.signal ? `signal: ${outcome.signal}` : "killed before exit");
2158
+ return { ...killed, status: "killed" };
1893
2159
  }
1894
2160
  if (outcome.exitCode !== 0) {
1895
- return { status: "failed", detail: `pnpm remove ${packageName} failed (exit code ${outcome.exitCode}). See job output.` };
2161
+ return rollbackRemove(`pnpm remove ${packageName} failed (exit code ${outcome.exitCode}). See job output.`);
1896
2162
  }
1897
2163
  // 卸完后的对账(bundle 列表、client 行)抛错也必须落成 terminal failed,
1898
2164
  // 不能让 done 拒绝。
1899
2165
  try {
1900
2166
  const bundles = reconcileBundles(profileDir, beforeDeps);
1901
2167
  const clientRow = removeClientRow(profileDir, packageName);
2168
+ // 启用/停用留下的覆盖行也要一起带走——否则它会在重装时复活。
2169
+ const toggleRows = removeToggleRows(profileDir, packageName);
2170
+ // 退出码 0 不等于卸干净了。落盘校验用的是启动恢复同一套判据:
2171
+ // profile 整体仍然自洽,且这个包确实从清单和装配层里消失了。任何一条
2172
+ // 不过就还原——一个「装着但坏」的 profile 比一个没卸掉的插件糟得多。
2173
+ const profileCheck = validateInstalledProfile(profileDir);
2174
+ const removeCheck = validateRemoveCompletion(profileDir, packageName);
2175
+ if (!profileCheck.ok || !removeCheck.ok) {
2176
+ const blockers = [...profileCheck.issues, ...removeCheck.issues]
2177
+ .filter((entry) => entry.severity === "block")
2178
+ .map((entry) => entry.title);
2179
+ return rollbackRemove(`pnpm remove ${packageName} exited 0 but the profile did not validate afterwards${blockers.length > 0 ? `: ${blockers.join("; ")}` : ""}`);
2180
+ }
2181
+ commitPendingSnapshot(profileDir);
1902
2182
  const notes = [`bundle layer(s) now: ${bundles.join(", ") || "none (template only)"}`];
1903
2183
  if (clientRow.removed) notes.push(`removed client loader row "${clientRow.rowId}" from cordis.patch.yml`);
2184
+ if (toggleRows.removed.length > 0) notes.push(`removed enable/disable row(s) ${toggleRows.removed.map((id) => `"${id}"`).join(", ")} from cordis.patch.yml`);
1904
2185
  return { status: "completed", detail: `removed ${packageName} from profile "${profile}" — ${notes.join("; ")}. Restart dsh for the change to take effect.` };
1905
2186
  } catch (error) {
1906
- return { status: "failed", detail: `pnpm removed ${packageName} but post-remove reconciliation failed: ${error?.message ?? String(error)}` };
2187
+ return rollbackRemove(`pnpm removed ${packageName} but post-remove reconciliation failed: ${error?.message ?? String(error)}`);
1907
2188
  }
1908
- }).catch((error) => ({ status: "failed", detail: `remove of ${packageName} hit an internal error: ${error?.message ?? String(error)}` }));
2189
+ }).catch((error) => rollbackRemove(`remove of ${packageName} hit an internal error: ${error?.message ?? String(error)}`));
1909
2190
  proc.stdout?.on("data", (data) => push(data.toString()));
1910
2191
  proc.stderr?.on("data", (data) => push(data.toString()));
1911
2192
 
@@ -2014,6 +2295,102 @@ function runAllowBuildsFixtures() {
2014
2295
  if (!ok && out !== undefined) console.log(` 产出:\n${out.split("\n").map((l) => ` | ${l}`).join("\n")}`);
2015
2296
  if (!ok && error !== undefined) console.log(` 抛错: ${error.message}`);
2016
2297
  }
2298
+ failed += runNeutralizeFixtures();
2299
+ return failed;
2300
+ }
2301
+
2302
+ // ── neutralizeWorkspaceContent:保留哪些批准 ────────────────────────────────
2303
+ //
2304
+ // 实测确定的 pnpm 11 行为,这组用例建立在它之上:
2305
+ // - allowBuilds 里 registry 包用裸名或 `name@version` 都能放行;
2306
+ // - `file:`/`link:` 依赖只认 pnpm 自己的完整 selector,裸名无效;
2307
+ // - allowBuilds 被清空后,pnpm 会重新报树里**任何**带脚本的包,
2308
+ // 哪怕这次安装跟它毫无关系 —— 这正是 node-pty 反复弹窗的来源。
2309
+ //
2310
+ // 所以这里钉的是范围:已装且版本未变的批准要留下(并钉上版本),
2311
+ // 其余一律丢弃。丢弃的方向是「再问一次」,永远不是「默默放行」。
2312
+ const NEUTRALIZE_FIXTURES = [
2313
+ {
2314
+ label: "已装且已批准(裸名)→ 保留,并钉到已装版本",
2315
+ content: "packages:\n - .\nallowBuilds:\n node-pty: true\n",
2316
+ installed: { "node-pty": "1.1.0" },
2317
+ check: (d) => d.allowBuilds["node-pty@1.1.0"] === true && d.allowBuilds["node-pty"] === undefined,
2318
+ },
2319
+ {
2320
+ label: "已装且已批准(带版本且一致)→ 原样保留",
2321
+ content: "packages:\n - .\nallowBuilds:\n 'node-pty@1.1.0': true\n",
2322
+ installed: { "node-pty": "1.1.0" },
2323
+ check: (d) => d.allowBuilds["node-pty@1.1.0"] === true,
2324
+ },
2325
+ {
2326
+ label: "批准的版本与已装版本不符 → 丢弃(升级必须重新批准)",
2327
+ content: "packages:\n - .\nallowBuilds:\n 'node-pty@1.0.0': true\n",
2328
+ installed: { "node-pty": "1.1.0" },
2329
+ check: (d) => Object.keys(d.allowBuilds).length === 0,
2330
+ },
2331
+ {
2332
+ label: "批准过但树里没有 → 丢弃(新引入的同名包不许蹭)",
2333
+ content: "packages:\n - .\nallowBuilds:\n node-pty: true\n",
2334
+ installed: {},
2335
+ check: (d) => Object.keys(d.allowBuilds).length === 0,
2336
+ },
2337
+ {
2338
+ label: "序列形态的 allowBuilds 同样按已装版本钉住",
2339
+ content: "packages:\n - .\nallowBuilds:\n - 'node-pty'\n - 'esbuild'\n",
2340
+ installed: { "node-pty": "1.1.0" },
2341
+ check: (d) => d.allowBuilds["node-pty@1.1.0"] === true && Object.keys(d.allowBuilds).length === 1,
2342
+ },
2343
+ {
2344
+ label: "值不是 true 的未决占位符 → 不算批准",
2345
+ content: "packages:\n - .\nallowBuilds:\n node-pty: set this to true or false\n",
2346
+ installed: { "node-pty": "1.1.0" },
2347
+ check: (d) => Object.keys(d.allowBuilds).length === 0,
2348
+ },
2349
+ {
2350
+ label: "file: selector 无法与版本号对应 → 丢弃重问",
2351
+ content: "packages:\n - .\nallowBuilds:\n 'pkg@file:../pkg': true\n",
2352
+ installed: { pkg: "1.0.0" },
2353
+ check: (d) => Object.keys(d.allowBuilds).length === 0,
2354
+ },
2355
+ {
2356
+ label: "不传解析器 → 退回全清(老行为,绝不放宽)",
2357
+ content: "packages:\n - .\nallowBuilds:\n node-pty: true\n",
2358
+ installed: undefined,
2359
+ check: (d) => Object.keys(d.allowBuilds).length === 0,
2360
+ },
2361
+ {
2362
+ label: "解析器抛错 → 当作没批准过",
2363
+ content: "packages:\n - .\nallowBuilds:\n node-pty: true\n",
2364
+ resolver: () => { throw new Error("node_modules unreadable"); },
2365
+ check: (d) => Object.keys(d.allowBuilds).length === 0,
2366
+ },
2367
+ {
2368
+ label: "两个广义开关始终关闭",
2369
+ content: "packages:\n - .\ndangerouslyAllowAllBuilds: true\nonlyBuiltDependencies:\n - anything\n",
2370
+ installed: { "node-pty": "1.1.0" },
2371
+ check: (d) => d.dangerouslyAllowAllBuilds === false && Array.isArray(d.onlyBuiltDependencies) && d.onlyBuiltDependencies.length === 0,
2372
+ },
2373
+ {
2374
+ label: "其余键原样保留(不碰用户的别的设置)",
2375
+ content: "packages:\n - .\nnodeLinker: hoisted\nminimumReleaseAgeExclude:\n - 'a@1.0.0'\nallowBuilds:\n node-pty: true\n",
2376
+ installed: { "node-pty": "1.1.0" },
2377
+ check: (d) => d.nodeLinker === "hoisted" && d.minimumReleaseAgeExclude[0] === "a@1.0.0",
2378
+ },
2379
+ ];
2380
+
2381
+ function runNeutralizeFixtures() {
2382
+ let failed = 0;
2383
+ for (const fx of NEUTRALIZE_FIXTURES) {
2384
+ const resolver = fx.resolver ?? (fx.installed === undefined ? undefined : (name) => fx.installed[name]);
2385
+ let ok;
2386
+ try {
2387
+ ok = fx.check(load(neutralizeWorkspaceContent(fx.content, resolver))) === true;
2388
+ } catch {
2389
+ ok = false;
2390
+ }
2391
+ if (!ok) failed++;
2392
+ console.log(` ${ok ? "PASS" : "FAIL"} neutralize: ${fx.label}`);
2393
+ }
2017
2394
  return failed;
2018
2395
  }
2019
2396
 
@@ -2443,47 +2820,124 @@ async function runTransactionFixtures() {
2443
2820
  }
2444
2821
  }
2445
2822
 
2446
- // 1d. 攻击防御:profile 预先存在的 allowBuilds: true 无法绕过首次审批警告
2447
- {
2823
+ // 1d. 攻击防御:预先存在的 allowBuilds 不能替**新东西**盖章。
2824
+ //
2825
+ // 这条原本断言的是「任何预先存在的 allowBuilds 一律不认」。那个范围太宽:
2826
+ // 它同时把「早已落盘、早已批准、这次根本没动」的包也重新拦下,于是装任何
2827
+ // 不相干的插件都会为 node-pty 之类的传递依赖再弹一次审批卡。一个每次安装
2828
+ // 都因为你没选的包而弹的同意框,训练出来的是无脑点同意——恰好摧毁这道闸
2829
+ // 在它本职场景里的作用。
2830
+ //
2831
+ // 所以守的边界改成:**盖章只对「此刻就在盘上、且这次装完还是同一份」的包
2832
+ // 有效**。下面两条钉住它拦得住的、一条钉住它该放行的;三条都同时验证两个
2833
+ // 广义开关被强制关闭、事后恢复用户原本的 workspace 字节。
2834
+ // 时序很要紧:neutralize 在 pnpm 跑之前决定放行名单,那一刻本次事务要装的
2835
+ // 东西还没落盘。所以「新包」在决策点上就是「盘上没有」,这两条照这个时序
2836
+ // 模拟——由 scriptedSpawn 的 beforeExit 扮演 pnpm 把包装上去。
2837
+ const preexistingAllowCases = [
2838
+ {
2839
+ label: "本次新引入的包 → 预先盖的章无效,仍触发审批闸",
2840
+ preinstalled: undefined, // 决策点上盘里没有
2841
+ reported: "evil-script-pkg@1.0.0", // pnpm 装完后报的
2842
+ landed: "1.0.0",
2843
+ expectPins: {}, // 章被丢弃
2844
+ },
2845
+ {
2846
+ label: "已批准的包被本次升级 → 旧章只钉住旧版本,新版本仍被拦",
2847
+ preinstalled: "1.0.0", // 决策点上是 1.0.0,章有效
2848
+ reported: "evil-script-pkg@1.1.0", // 但装上来的是 1.1.0
2849
+ landed: "1.1.0",
2850
+ expectPins: { "evil-script-pkg@1.0.0": true },
2851
+ },
2852
+ ];
2853
+ for (const testCase of preexistingAllowCases) {
2448
2854
  const { profileDir, cleanup } = makeTempProfile("bypass-preexisting-allow");
2449
2855
  try {
2450
2856
  const initialWs = "packages:\n - .\n\nallowBuilds:\n evil-script-pkg: true\nonlyBuiltDependencies:\n - evil-script-pkg\ndangerouslyAllowAllBuilds: true\n\nnodeLinker: hoisted\n";
2451
2857
  writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
2452
2858
  materializeFakePackage(profileDir, "some-plugin", "1.0.0");
2453
- materializeFakePackage(profileDir, "evil-script-pkg", "1.0.0", { postinstall: "node evil.js" });
2859
+ if (testCase.preinstalled !== undefined) {
2860
+ materializeFakePackage(profileDir, "evil-script-pkg", testCase.preinstalled, { postinstall: "node evil.js" });
2861
+ }
2454
2862
 
2455
2863
  let firstProbeWorkspace;
2456
2864
  const { spawnFn, calls } = scriptedSpawn([
2457
2865
  {
2458
2866
  code: 0,
2459
- out: "Ignored build scripts: evil-script-pkg@1.0.0\n",
2867
+ out: `Ignored build scripts: ${testCase.reported}\n`,
2460
2868
  beforeExit: () => {
2461
2869
  firstProbeWorkspace = load(readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8"));
2870
+ // pnpm 此刻把包落盘(新装或升级),披露据此计算。
2871
+ materializeFakePackage(profileDir, "evil-script-pkg", testCase.landed, { postinstall: "node evil.js" });
2462
2872
  },
2463
2873
  },
2464
2874
  ]);
2465
- const producer = runInstall({
2875
+ const outcome = await runInstall({
2466
2876
  profile: "p",
2467
2877
  spec: "some-plugin",
2468
2878
  preflight: preflightStub("some-plugin"),
2469
2879
  _profileDir: profileDir,
2470
2880
  _spawn: spawnFn,
2471
2881
  _describe: describeStub,
2472
- });
2473
- const outcome = await producer.done;
2882
+ }).done;
2474
2883
  const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
2475
2884
  check(
2476
- "攻击防御:预先存在的 allowBuilds 在首轮被中和 → 依然触发审批闸,且事后恢复用户原本的 workspace 字节",
2885
+ `攻击防御:${testCase.label}(且两个广义开关强制关闭、事后恢复原字节)`,
2477
2886
  outcome.status === "failed"
2478
2887
  && Array.isArray(outcome.needsApproval)
2479
2888
  && outcome.needsApproval.some((e) => e.name === "evil-script-pkg")
2480
2889
  && calls.length === 1
2481
- && Object.keys(firstProbeWorkspace?.allowBuilds ?? {}).length === 0
2890
+ && JSON.stringify(firstProbeWorkspace?.allowBuilds) === JSON.stringify(testCase.expectPins)
2482
2891
  && Array.isArray(firstProbeWorkspace?.onlyBuiltDependencies)
2483
2892
  && firstProbeWorkspace.onlyBuiltDependencies.length === 0
2484
2893
  && firstProbeWorkspace?.dangerouslyAllowAllBuilds === false
2485
2894
  && finalWs === initialWs,
2486
- `status=${outcome.status} calls=${calls.length} finalWs=${JSON.stringify(finalWs)}`,
2895
+ `status=${outcome.status} calls=${calls.length} allowBuilds=${JSON.stringify(firstProbeWorkspace?.allowBuilds)} needsApproval=${JSON.stringify(outcome.needsApproval?.map((e) => `${e.name}@${e.version}`))} wsSame=${finalWs === initialWs}`,
2896
+ );
2897
+ } finally {
2898
+ cleanup();
2899
+ }
2900
+ }
2901
+
2902
+ // 1d-b. 该放行的那一侧:已落盘、已批准、版本没变的包,装别的东西时不再重问。
2903
+ // 交给 pnpm 的是钉了版本的 selector,所以同一次事务若换上另一个版本,
2904
+ // 那个版本并不在放行名单里。
2905
+ {
2906
+ const { profileDir, cleanup } = makeTempProfile("preserve-installed-allow");
2907
+ try {
2908
+ const initialWs = "packages:\n - .\n\nallowBuilds:\n native-pkg: true\ndangerouslyAllowAllBuilds: true\n\nnodeLinker: hoisted\n";
2909
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
2910
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
2911
+ materializeFakePackage(profileDir, "native-pkg", "1.1.0", { install: "node build.js" });
2912
+
2913
+ let firstProbeWorkspace;
2914
+ const { spawnFn, calls } = scriptedSpawn([
2915
+ {
2916
+ code: 0,
2917
+ out: "Done\n",
2918
+ beforeExit: () => {
2919
+ firstProbeWorkspace = load(readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8"));
2920
+ },
2921
+ },
2922
+ ]);
2923
+ const outcome = await runInstall({
2924
+ profile: "p",
2925
+ spec: "some-plugin",
2926
+ preflight: preflightStub("some-plugin"),
2927
+ _profileDir: profileDir,
2928
+ _spawn: spawnFn,
2929
+ _describe: describeStub,
2930
+ }).done;
2931
+ const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
2932
+ check(
2933
+ "已装且版本未变的批准被保留(钉到已装版本),装不相干的包不再重弹审批",
2934
+ outcome.status === "completed"
2935
+ && calls.length === 1
2936
+ && firstProbeWorkspace?.allowBuilds?.["native-pkg@1.1.0"] === true
2937
+ && firstProbeWorkspace?.allowBuilds?.["native-pkg"] === undefined
2938
+ && firstProbeWorkspace?.dangerouslyAllowAllBuilds === false
2939
+ && finalWs === initialWs,
2940
+ `status=${outcome.status} detail=${outcome.detail} allowBuilds=${JSON.stringify(firstProbeWorkspace?.allowBuilds)}`,
2487
2941
  );
2488
2942
  } finally {
2489
2943
  cleanup();
@@ -2727,6 +3181,204 @@ async function runTransactionFixtures() {
2727
3181
  }
2728
3182
  }
2729
3183
 
3184
+ // 3b-2. 卸载中途失败必须留下可恢复的状态 —— 这条钉的是一次真实故障。
3185
+ //
3186
+ // `pnpm remove` 删掉了 node_modules/<pkg>,随后写 pnpm-lock.yaml 时 EPERM
3187
+ // 失败并非零退出。当时 runRemove 没有任何事务保护,只是报了个 failed 就
3188
+ // 返回,于是 package.json 仍把该包声明为 bundle 层、目录却没了。dsh 从此
3189
+ // 拒绝启动:resolveBundleDir 在组装 profile 时抛错,那发生在任何插件加载
3190
+ // 之前,所以 apply() 里的启动恢复根本执行不到——何况当时也没有 marker 可
3191
+ // 供它接手。
3192
+ //
3193
+ // 现在的要求:中途失败之后,profile 要么被还原,要么留下 marker 让恢复路径
3194
+ // 接手。**绝不允许「既没还原、也没 marker」这第三种结局。**
3195
+ {
3196
+ const { profileDir, cleanup } = makeTempProfile("remove-midway-failure", { "pkg-c": "1.0.0" });
3197
+ try {
3198
+ materializeFakePackage(profileDir, "pkg-c", "1.0.0");
3199
+ const manifestBefore = readFileSync(join(profileDir, "package.json"), "utf8");
3200
+ const { spawnFn } = scriptedSpawn([{
3201
+ code: 1,
3202
+ out: "removing pkg-c\nEPERM: operation not permitted, rename '...pnpm-lock.yaml.tmp' -> 'pnpm-lock.yaml'\n",
3203
+ // pnpm 已经把目录删掉了才失败 —— 正是那次故障的形状。
3204
+ beforeExit: () => rmSync(join(profileDir, "node_modules", "pkg-c"), { recursive: true, force: true }),
3205
+ }]);
3206
+ const outcome = await runRemove({ profile: "p", packageName: "pkg-c", _profileDir: profileDir, _spawn: spawnFn }).done;
3207
+ const manifestAfter = readFileSync(join(profileDir, "package.json"), "utf8");
3208
+ const declaresPkg = JSON.parse(manifestAfter).dependencies?.["pkg-c"] !== undefined;
3209
+ const pkgOnDisk = existsSync(join(profileDir, "node_modules", "pkg-c"));
3210
+ const markerLeft = existsSync(pendingMarkerPath(profileDir));
3211
+ // 自洽 = 「声明了就得在盘上」。要么两者都在(已还原),要么 marker 还在
3212
+ // (交给恢复路径)。声明着却不在盘上、且没有 marker,就是那次砖化的形状。
3213
+ const consistent = declaresPkg === pkgOnDisk;
3214
+ check(
3215
+ "卸载中途失败(node_modules 已删)→ 要么还原、要么留 marker,绝不留下「声明了却不在盘上」且无人接手的 profile",
3216
+ outcome.status === "failed"
3217
+ && manifestAfter === manifestBefore
3218
+ && (consistent || markerLeft),
3219
+ `status=${outcome.status} declares=${declaresPkg} onDisk=${pkgOnDisk} marker=${markerLeft} detail=${JSON.stringify(outcome.detail)}`,
3220
+ );
3221
+ } finally {
3222
+ cleanup();
3223
+ }
3224
+ }
3225
+
3226
+ // 3b-3. 早期失败(pnpm 还没动 node_modules)→ 干净回滚,marker 收掉,
3227
+ // profile 与卸载前逐字节一致。
3228
+ {
3229
+ const { profileDir, cleanup } = makeTempProfile("remove-early-failure", { "pkg-d": "1.0.0" });
3230
+ try {
3231
+ materializeFakePackage(profileDir, "pkg-d", "1.0.0");
3232
+ const manifestBefore = readFileSync(join(profileDir, "package.json"), "utf8");
3233
+ const { spawnFn } = scriptedSpawn([{ code: 1, out: "ERR_PNPM_NO_MATCHING_VERSION\n" }]);
3234
+ const outcome = await runRemove({ profile: "p", packageName: "pkg-d", _profileDir: profileDir, _spawn: spawnFn }).done;
3235
+ check(
3236
+ "卸载早期失败 → 回滚到卸载前字节、marker 收掉、包仍在盘上",
3237
+ outcome.status === "failed"
3238
+ && /restored to its pre-remove state/.test(outcome.detail ?? "")
3239
+ && readFileSync(join(profileDir, "package.json"), "utf8") === manifestBefore
3240
+ && existsSync(join(profileDir, "node_modules", "pkg-d"))
3241
+ && !existsSync(pendingMarkerPath(profileDir)),
3242
+ `status=${outcome.status} marker=${existsSync(pendingMarkerPath(profileDir))} detail=${JSON.stringify(outcome.detail)}`,
3243
+ );
3244
+ } finally {
3245
+ cleanup();
3246
+ }
3247
+ }
3248
+
3249
+ // 3b-4. 退出码 0 不等于卸干净了:包还留在清单里就必须回滚,
3250
+ // 不能把一个半卸的 profile 当成功提交。
3251
+ {
3252
+ const { profileDir, cleanup } = makeTempProfile("remove-exit0-incomplete", { "pkg-e": "1.0.0" });
3253
+ try {
3254
+ materializeFakePackage(profileDir, "pkg-e", "1.0.0");
3255
+ // pnpm 声称成功,却没有改动清单(半完成)。
3256
+ const { spawnFn } = scriptedSpawn([{ code: 0, out: "Done\n" }]);
3257
+ const outcome = await runRemove({ profile: "p", packageName: "pkg-e", _profileDir: profileDir, _spawn: spawnFn }).done;
3258
+ check(
3259
+ "pnpm remove 退 0 但包仍在清单里 → 判为未完成并回滚,不提交",
3260
+ outcome.status === "failed"
3261
+ && /did not validate afterwards/.test(outcome.detail ?? ""),
3262
+ `status=${outcome.status} detail=${JSON.stringify(outcome.detail)}`,
3263
+ );
3264
+ } finally {
3265
+ cleanup();
3266
+ }
3267
+ }
3268
+
3269
+ // 3b-5. 卸载成功 → marker 与 snapshot 都必须被提交清理干净。
3270
+ // 留下任何一个都会挡住这个 profile 后续所有安装和卸载,直到下次启动恢复。
3271
+ {
3272
+ const { profileDir, cleanup } = makeTempProfile("remove-success-cleanup", { "pkg-f": "1.0.0" });
3273
+ try {
3274
+ materializeFakePackage(profileDir, "pkg-f", "1.0.0");
3275
+ const { spawnFn } = scriptedSpawn([{
3276
+ code: 0,
3277
+ out: "Done\n",
3278
+ // pnpm 真正卸掉:清单与目录都拿走,落盘校验才会通过。
3279
+ beforeExit: () => {
3280
+ const manifest = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
3281
+ delete manifest.dependencies["pkg-f"];
3282
+ writeFileSync(join(profileDir, "package.json"), JSON.stringify(manifest, undefined, 2) + "\n");
3283
+ rmSync(join(profileDir, "node_modules", "pkg-f"), { recursive: true, force: true });
3284
+ },
3285
+ }]);
3286
+ const outcome = await runRemove({ profile: "p", packageName: "pkg-f", _profileDir: profileDir, _spawn: spawnFn }).done;
3287
+ const snapshotsLeft = existsSync(join(profileDir, ".dsh-plugin-guard"))
3288
+ ? readdirSync(join(profileDir, ".dsh-plugin-guard")).filter((entry) => entry !== "pending.json")
3289
+ : [];
3290
+ check(
3291
+ "卸载成功 → marker 与 snapshot 都被提交清理,不留残留",
3292
+ outcome.status === "completed"
3293
+ && !existsSync(pendingMarkerPath(profileDir))
3294
+ && snapshotsLeft.length === 0,
3295
+ `status=${outcome.status} marker=${existsSync(pendingMarkerPath(profileDir))} snapshots=${snapshotsLeft.join(",")} detail=${JSON.stringify(outcome.detail)}`,
3296
+ );
3297
+ } finally {
3298
+ cleanup();
3299
+ }
3300
+ }
3301
+
3302
+ // 3b-6. 在途取消:必须等进程真正退出后才回滚,否则会与还在写盘的 pnpm 抢。
3303
+ // 安装路径早有这条,卸载路径此前没有。
3304
+ {
3305
+ const { profileDir, cleanup } = makeTempProfile("remove-cancel-inflight", { "pkg-g": "1.0.0" });
3306
+ try {
3307
+ materializeFakePackage(profileDir, "pkg-g", "1.0.0");
3308
+ const { spawnFn, procs } = blockingSpawn();
3309
+ const producer = runRemove({ profile: "p", packageName: "pkg-g", _profileDir: profileDir, _spawn: spawnFn });
3310
+ await flush();
3311
+ // 与 install 的取消用例同规格:spawn 起来了、marker 已登记,取消之后
3312
+ // 结局是 killed 且 marker 被收掉。回滚接在 'close' 的 promise 之后,
3313
+ // 所以它必然晚于进程退出(FakeProc.kill 同步触发 close,时序无法在
3314
+ // 用例里再细分,由代码结构保证)。
3315
+ const spawnedAndMarked = procs.length === 1 && existsSync(pendingMarkerPath(profileDir));
3316
+ producer.cancel();
3317
+ const outcome = await producer.done;
3318
+ check(
3319
+ "取消在途 remove → killed + 回滚收 marker,包仍在盘上",
3320
+ spawnedAndMarked
3321
+ && outcome.status === "killed"
3322
+ && !existsSync(pendingMarkerPath(profileDir))
3323
+ && existsSync(join(profileDir, "node_modules", "pkg-g")),
3324
+ `spawnedAndMarked=${spawnedAndMarked} status=${outcome.status} marker=${existsSync(pendingMarkerPath(profileDir))} detail=${JSON.stringify(outcome.detail)}`,
3325
+ );
3326
+ } finally {
3327
+ cleanup();
3328
+ }
3329
+ }
3330
+
3331
+ // 3b-7. spawn 同步抛错:marker 已经写下了,异常绝不能绕过收尾。
3332
+ // 安装侧后果更重——workspace 正停在被中和的状态,漏掉恢复就等于把用户的
3333
+ // allowBuilds 批准弄丢(启动恢复会 commit 掉快照,此后再也拿不回来)。
3334
+ {
3335
+ const throwingSpawn = () => { throw new TypeError("spawn EINVAL (synthetic)"); };
3336
+ {
3337
+ const { profileDir, cleanup } = makeTempProfile("remove-spawn-throw", { "pkg-h": "1.0.0" });
3338
+ try {
3339
+ materializeFakePackage(profileDir, "pkg-h", "1.0.0");
3340
+ const outcome = await runRemove({ profile: "p", packageName: "pkg-h", _profileDir: profileDir, _spawn: throwingSpawn }).done;
3341
+ check(
3342
+ "remove 的 spawn 同步抛错 → 走完收尾,不遗留 marker",
3343
+ outcome.status === "failed"
3344
+ && /could not start pnpm/.test(outcome.detail ?? "")
3345
+ && !existsSync(pendingMarkerPath(profileDir)),
3346
+ `status=${outcome.status} marker=${existsSync(pendingMarkerPath(profileDir))} detail=${JSON.stringify(outcome.detail)}`,
3347
+ );
3348
+ } finally {
3349
+ cleanup();
3350
+ }
3351
+ }
3352
+ {
3353
+ const { profileDir, cleanup } = makeTempProfile("install-spawn-throw", { "pkg-i": "1.0.0" });
3354
+ try {
3355
+ const initialWs = "packages:\n - .\n\nallowBuilds:\n native-pkg: true\n\nnodeLinker: hoisted\n";
3356
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
3357
+ // 清单里声明的直系依赖必须都在盘上,否则回滚的完整性校验会失败并
3358
+ // 保留 marker——那是另一条正确行为,会盖住这条要测的东西。
3359
+ materializeFakePackage(profileDir, "pkg-i", "1.0.0");
3360
+ materializeFakePackage(profileDir, "native-pkg", "1.0.0", { install: "node build.js" });
3361
+ const outcome = await runInstall({
3362
+ profile: "p",
3363
+ spec: "some-plugin",
3364
+ preflight: preflightStub("some-plugin"),
3365
+ _profileDir: profileDir,
3366
+ _spawn: throwingSpawn,
3367
+ _describe: describeStub,
3368
+ }).done;
3369
+ check(
3370
+ "install 的 spawn 同步抛错 → 恢复 workspace 原字节且不遗留 marker(否则用户的 allowBuilds 会被永久中和)",
3371
+ outcome.status === "failed"
3372
+ && readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8") === initialWs
3373
+ && !existsSync(pendingMarkerPath(profileDir)),
3374
+ `status=${outcome.status} marker=${existsSync(pendingMarkerPath(profileDir))} ws=${JSON.stringify(readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8"))}`,
3375
+ );
3376
+ } finally {
3377
+ cleanup();
3378
+ }
3379
+ }
3380
+ }
3381
+
2730
3382
  // 3c. 排队中被取消:killed 结局,pnpm 从未启动;锁随后正常释放。
2731
3383
  {
2732
3384
  const { profileDir, cleanup } = makeTempProfile("cancel-queued");
@@ -2888,6 +3540,90 @@ function runToggleFixtures() {
2888
3540
  check("多条目:不误伤相邻条目的 disabled", setPatchRowDisabled(multi, "a", true)?.includes("- id: z\n name: pkg-z\n disabled: true") === true);
2889
3541
 
2890
3542
  check("带引号的 id 也能匹配", setPatchRowDisabled(`- id: '@scope/pkg'\n name: x\n`, "@scope/pkg", true) !== undefined);
3543
+
3544
+ // ── 卸载时清理启用/停用覆盖行 ─────────────────────────────────────────────
3545
+ //
3546
+ // 这些行是 v0.3.0 的 toggle 功能引入的,而卸载的 patch 清理写于 v0.1.17,
3547
+ // 只认 insert 行。于是「停用过再卸载」会留下一条指向不存在条目的覆盖行:
3548
+ // 每次启动一条 `patch: entry "x" not found`,更要紧的是**重装时它会复活**,
3549
+ // 插件带着停用状态回来、界面显示已装却不工作。
3550
+ //
3551
+ // 这组用例的重点全在「什么不许删」:patch 层是用户手写的文件,删错的代价
3552
+ // 是无声丢配置,而留着的代价只是一行警告。
3553
+ const toggleCleanup = (content, packageName = "dsh-at-file") => {
3554
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mall-toggle-clean-"));
3555
+ try {
3556
+ writeFileSync(join(dir, PROFILE_PATCH_FILENAME), content);
3557
+ const result = removeToggleRows(dir, packageName);
3558
+ return { result, text: readFileSync(join(dir, PROFILE_PATCH_FILENAME), "utf8") };
3559
+ } finally {
3560
+ rmSync(dir, { recursive: true, force: true });
3561
+ }
3562
+ };
3563
+
3564
+ const ourShape = "- id: at-file\n name: 'dsh-at-file'\n disabled: false\n";
3565
+ const cleaned = toggleCleanup(ourShape);
3566
+ check("我们写的形状 → 删掉,文件回到空列表",
3567
+ cleaned.result.removed.join(",") === "at-file" && cleaned.text.trim() === "[]",
3568
+ JSON.stringify(cleaned.text));
3569
+
3570
+ // 删空一个**带头部注释**的文件时,剩下的只有注释——那解析成 null 而不是
3571
+ // [],dsh 的 parsePatchList 会直接拒绝启动。真实 profile 的模板恰恰带着
3572
+ // 这段注释,所以这是必经路径,不是边角。
3573
+ const stockShaped = "# Your patch layer for this dsh profile\n# 第二行注释\n- id: at-file\n name: 'dsh-at-file'\n disabled: false\n";
3574
+ const stockCleaned = toggleCleanup(stockShaped);
3575
+ check("删光带注释文件的最后一行 → 补回 [],保持 dsh 能解析的形状",
3576
+ Array.isArray(load(stockCleaned.text))
3577
+ && stockCleaned.text.includes("# Your patch layer")
3578
+ && stockCleaned.text.includes("[]"),
3579
+ JSON.stringify(stockCleaned.text));
3580
+
3581
+ const jsExpr = "- id: at-file\n name: 'dsh-at-file'\n disabled: !!js process.platform === 'win32'\n";
3582
+ check("disabled 是 !!js 表达式 → 留着(那是用户写的条件逻辑)",
3583
+ toggleCleanup(jsExpr).text === jsExpr);
3584
+
3585
+ const withConfig = "- id: at-file\n name: 'dsh-at-file'\n disabled: false\n config:\n foo: 1\n";
3586
+ check("行里还带 config: → 留着(不只是个开关)",
3587
+ toggleCleanup(withConfig).text === withConfig);
3588
+
3589
+ const noNameGuard = "- id: at-file\n disabled: false\n";
3590
+ check("没有 name 守卫的行 → 留着(无法确认属于这个包)",
3591
+ toggleCleanup(noNameGuard).text === noNameGuard);
3592
+
3593
+ const otherPkg = "- id: sidebar\n name: 'dsh-better-sidebar'\n disabled: true\n";
3594
+ check("别的包的行 → 一个字节不动",
3595
+ toggleCleanup(otherPkg).text === otherPkg && toggleCleanup(otherPkg).result.removed.length === 0);
3596
+
3597
+ // 一个包可以插入多行(loaderEntriesByPackage 就是为此存在的),要全删。
3598
+ const multiRow = "- id: a1\n name: 'dsh-at-file'\n disabled: true\n- id: keep\n name: 'other'\n disabled: true\n- id: a2\n name: 'dsh-at-file'\n disabled: false\n";
3599
+ const multiCleaned = toggleCleanup(multiRow);
3600
+ check("同一个包的多条行 → 全部删除,且顺序保持",
3601
+ multiCleaned.result.removed.join(",") === "a1,a2" && multiCleaned.text.includes("- id: keep"),
3602
+ JSON.stringify(multiCleaned.text));
3603
+ check("多行清理后不误伤相邻条目",
3604
+ multiCleaned.text.includes("name: 'other'") && !multiCleaned.text.includes("dsh-at-file"));
3605
+
3606
+ const broken = "- id: at-file\n name: 'dsh-at-file'\n disabled: [oops\n";
3607
+ check("文件解析不过 → 整个不碰",
3608
+ toggleCleanup(broken).text === broken);
3609
+
3610
+ const commentedRow = "# 我手写的说明\n- id: at-file\n name: 'dsh-at-file'\n disabled: false\n# 尾部注释\n";
3611
+ const commentCleaned = toggleCleanup(commentedRow);
3612
+ check("删除目标行时保留周围注释",
3613
+ commentCleaned.text.includes("# 我手写的说明") && commentCleaned.text.includes("# 尾部注释")
3614
+ && !commentCleaned.text.includes("dsh-at-file"),
3615
+ JSON.stringify(commentCleaned.text));
3616
+
3617
+ check("patch 文件不存在 → 安静返回,不创建文件", (() => {
3618
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mall-toggle-none-"));
3619
+ try {
3620
+ const result = removeToggleRows(dir, "dsh-at-file");
3621
+ return result.removed.length === 0 && !existsSync(join(dir, PROFILE_PATCH_FILENAME));
3622
+ } finally {
3623
+ rmSync(dir, { recursive: true, force: true });
3624
+ }
3625
+ })());
3626
+
2891
3627
  return failed;
2892
3628
  }
2893
3629