@1e0zj/dsh-plugin-mall 0.3.2 → 0.3.3

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
@@ -73,9 +73,11 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --pro
73
73
 
74
74
  **A plain `dsh web` already resolves pending installs.** The marketplace plugin runs
75
75
  recovery when it loads: reaching that point proves dsh booted far enough to compose
76
- the profile, so the pending marker is committed (or rolled back when the profile
77
- fails validation). Without this a single install would wedge the profile every
78
- later install and uninstall refuses while a marker is outstanding.
76
+ the profile, so the pending marker is committed or rolled back, either because the
77
+ profile fails validation or because the install was left paused at the build-script
78
+ approval gate (an unapproved install is never committed, however healthy it looks).
79
+ Without this a single install would wedge the profile — every later install and
80
+ uninstall refuses while a marker is outstanding.
79
81
 
80
82
  **`guard launch` is still strictly better**, because it also covers what a plain
81
83
  start cannot: a plugin that boots fine and then crashes seconds later. It checks the
@@ -83,13 +85,42 @@ profile's pending-install marker before starting the command after `--`:
83
85
 
84
86
  - **No pending install** — the command runs as-is, inheriting the terminal, and its exit code is preserved.
85
87
  - **Clearly broken on disk** — the profile is rolled back to its pre-install snapshot *before* launch, then the command starts on the restored state.
86
- - **Alive through the grace period** (default **10 seconds**; `--grace-ms <ms>` to change) — the pending snapshot is committed and the wrapper keeps waiting on the process.
88
+ - **Alive through the grace period** (default **10 seconds**; `--grace-ms <ms>` to change) — the pending snapshot is committed and the wrapper keeps waiting on the process. An install still paused at the approval gate is rolled back instead: surviving probation only proves the JS loads, not that you approved its build scripts.
87
89
  - **Exits 0 inside the grace period** (one-shot command) — the pending snapshot is committed.
88
90
  - **Crashes or exits nonzero inside the grace period** — the profile is rolled back and the *exact same command* is restarted once with the restored state (never in a loop); the restarted process's exit code is preserved. SIGINT/SIGTERM are forwarded to the child where the platform supports it; on Windows `.cmd`/`.bat` shims go through `%ComSpec%` with strict per-argument quoting.
89
91
 
90
92
  Limitations: the grace window is the probation period — a failure that only surfaces **after** it (a plugin that crashes minutes in, or on a specific interaction) cannot be rolled back automatically, because committing deletes the active snapshot and `guard recover` then has nothing to restore. `guard validate` still diagnoses the on-disk state, but a post-commit failure needs manual repair — uninstall and reinstall the plugin, or restore a backup you kept separately. Both commands do only **static on-disk validation**; neither proves the plugin actually loads. A corrupt pending marker fails closed: the command is not launched and no unvalidated path is deleted. Preserve the snapshot and repair or restore a trustworthy marker, then run `guard recover`; quarantine the marker only after you have independently verified the profile, or decided to abandon automatic recovery.
91
93
 
92
94
 
95
+ ## dsh won't start after an update? (affects 0.2.0 – 0.3.2, fixed in 0.3.3)
96
+
97
+ Symptom: `dsh web` exits with `cannot resolve profile bundle "<package>"`.
98
+
99
+ Cause: **updating an already-installed plugin** into a rollback (most often the
100
+ target carries build scripts and the flow paused at the approval card) could lose
101
+ the package — the rollback's reinstall of the old version was fooled by pnpm's
102
+ "Already up to date" short-circuit, so the plugin left node_modules while its
103
+ bundles declaration stayed. Fresh installs and removals are unaffected.
104
+
105
+ Recovery: add the package back exactly as the profile's `package.json` declares
106
+ it, then run `dsh web`.
107
+
108
+ ```bash
109
+ # <profile> = %USERPROFILE%\.dsh\profiles\web or ~/.dsh/profiles/web
110
+ # npm package — package.json says "dsh-better-sidebar": "^0.13.1"
111
+ pnpm --dir <profile> add "dsh-better-sidebar@^0.13.1" --ignore-scripts
112
+ # GitHub source — package.json says "dsh-at-file": "github:omdsh-dev/dsh-at-file"
113
+ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
114
+ ```
115
+
116
+ > Do not reach for 0.3.2's `dsh-plugin-guard recover` here. Its per-package
117
+ > fallback refuses `^` (a cmd metacharacter) and skips `github:` entirely, and
118
+ > pnpm writes nearly every dependency with one or the other — so the fallback
119
+ > never fires and recovery fails closed. Fixed on main: the fallback now pins the
120
+ > version (or commit) the lockfile resolved. Fixed in 0.3.3 — on that version
121
+ > `dsh-plugin-guard recover` does repair this.
122
+
123
+
93
124
  ## Agent tools
94
125
 
95
126
  | Tool | What it does |
@@ -191,22 +222,48 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --pro
191
222
  ```
192
223
 
193
224
  **普通的 `dsh web` 就会了结 pending 安装。** 本插件加载时即执行恢复——能加载
194
- 本身就证明 dsh 已经组装好 profile、启动到了这一步,于是提交 pending 标记
195
- profile 校验不过则回滚)。没有这一步的话,装完一个插件就会把 profile 卡住:
196
- 只要标记还在,之后所有安装和卸载都会被拒绝。
225
+ 本身就证明 dsh 已经组装好 profile、启动到了这一步,于是提交 pending 标记;两种
226
+ 情况改为回滚:profile 校验不过,或者那次安装停在构建脚本批准闸而未获批准
227
+ (没批准的安装绝不提交,哪怕它看起来一切正常)。没有这一步的话,装完一个插件
228
+ 就会把 profile 卡住:只要标记还在,之后所有安装和卸载都会被拒绝。
197
229
 
198
230
  **`guard launch` 仍然更强**,因为它覆盖普通启动覆盖不了的情况:插件启动正常、
199
231
  几秒后才崩。它在启动 `--` 之后的命令前检查该 profile 的 pending 安装标记:
200
232
 
201
233
  - **无 pending 安装** —— 命令原样运行(继承终端),透传退出码;
202
234
  - **静态校验明显过不了** —— 启动*之前*先把 profile 回滚到安装前快照,再在恢复后的状态上启动;
203
- - **活过缓刑期**(默认 **10 秒**,`--grace-ms <ms>` 可调)—— 提交 pending 快照,包装器继续守候该进程;
235
+ - **活过缓刑期**(默认 **10 秒**,`--grace-ms <ms>` 可调)—— 提交 pending 快照,包装器继续守候该进程;但仍停在批准闸的安装改为回滚:活过缓刑期只证明 JS 能加载,不证明你批准了它的构建脚本;
204
236
  - **缓刑期内以 0 退出**(一次性命令)—— 同样提交 pending 快照;
205
237
  - **缓刑期内崩溃或非零退出** —— 回滚 profile,并用恢复后的状态**原样重启同一命令一次**(绝不循环),透传重启进程的退出码。支持的平台会把 SIGINT/SIGTERM 转发给子进程;Windows 上 `.cmd`/`.bat` 经 `%ComSpec%` 启动,逐参数严格加引号。
206
238
 
207
239
  限制:缓刑期就是观察期——**之后**才暴露的故障(跑了几分钟才崩、或某个特定操作才触发)无法自动回滚:提交会删掉当前快照,此时 `guard recover` 已无可恢复的东西。`guard validate` 仍能诊断落盘状态,但提交之后的故障只能手工修复——卸载并重装插件(或恢复你另行保留的备份)。两条命令都只做**静态落盘校验**,都不证明插件真的能加载。pending 标记损坏时关闭式失败:不启动命令、不删除任何未校验路径。要**保留快照**、修复或恢复一个可信的标记后再跑 `guard recover`;只有在你已经独立核实过 profile、或决定放弃自动恢复之后,才去隔离(删除/移走)标记。
208
240
 
209
241
 
242
+ ## 升级后 dsh 起不来?(0.2.0 – 0.3.2 受影响,0.3.3 已修复)
243
+
244
+ 症状:`dsh web` 报 `cannot resolve profile bundle "<包名>"` 直接退出。
245
+
246
+ 原因:**更新已装插件**时若走到回滚(最常见:目标插件带构建脚本、停在批准卡),
247
+ 回滚里「装回旧版本」的一步会被 pnpm 的 "Already up to date" 空转骗过——包从
248
+ node_modules 消失而 bundles 声明还在。新装、卸载不受影响。
249
+
250
+ 恢复:照 profile `package.json` 里原本的写法把包装回去,然后 `dsh web`。
251
+
252
+ ```bash
253
+ # <profile> = %USERPROFILE%\.dsh\profiles\web 或 ~/.dsh/profiles/web
254
+ # npm 包 —— package.json 里是 "dsh-better-sidebar": "^0.13.1"
255
+ pnpm --dir <profile> add "dsh-better-sidebar@^0.13.1" --ignore-scripts
256
+ # GitHub 源 —— package.json 里是 "dsh-at-file": "github:omdsh-dev/dsh-at-file"
257
+ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
258
+ ```
259
+
260
+ > 别指望 0.3.2 的 `dsh-plugin-guard recover` 修这个:它的 per-package 兜底会
261
+ > 拒掉 `^`(cmd 转义符),`github:` 更是整个跳过,而 pnpm 存依赖几乎不是前者
262
+ > 就是后者——兜底一次也不会触发,恢复只会 fail-closed。main 上已修:兜底改钉
263
+ > lockfile 解析出的版本(或 commit)。0.3.3 已修复——那个版本的
264
+ > `dsh-plugin-guard recover` 确实能修这个故障。
265
+
266
+
210
267
  ## 工作原理
211
268
 
212
269
  - 双面包(dual-face)插件:`dsh.bundle` 半边挂在 **host 平面**(profile bundle 层),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cli.js CHANGED
@@ -40,13 +40,18 @@ import { tmpdir } from "node:os";
40
40
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
41
41
  import { fileURLToPath } from "node:url";
42
42
  import {
43
+ clearPendingApprovalPause,
43
44
  commitPendingSnapshot,
44
45
  createProfileSnapshot,
46
+ describeRollbackRebuild,
45
47
  listPendingSnapshots,
48
+ markPendingApprovalPause,
46
49
  markPendingSnapshot,
50
+ pendingApprovalPaused,
47
51
  pnpmGuardEnv,
48
52
  preflightInstall,
49
53
  readPendingSnapshot,
54
+ readValidatedPendingSnapshot,
50
55
  recoverAll,
51
56
  recoverProfile,
52
57
  resolveDshHome,
@@ -380,6 +385,8 @@ function cmdRecover({ home, profileDir }) {
380
385
  } else if (entry.action === "rolled-back") {
381
386
  console.log(`ROLLED BACK ${scope}: ${(entry.issues ?? []).map((issueEntry) => issueEntry.title).join("; ") || "profile would not load"}`);
382
387
  if (entry.removed?.length) console.log(` removed from node_modules: ${entry.removed.join(", ")}`);
388
+ const rebuild = describeRollbackRebuild(entry.rebuild);
389
+ if (rebuild !== undefined) console.log(` node_modules rebuild: ${rebuild}`);
383
390
  } else if (entry.action === "none") {
384
391
  console.log(`no pending ${scope}`);
385
392
  } else {
@@ -693,9 +700,25 @@ async function runPlain(command, args) {
693
700
  * Commit the pending snapshot once startup probation passes. A commit failure
694
701
  * is a warning, not a launch failure — the process is already running and
695
702
  * healthy, and the marker simply stays pending for the next launch.
703
+ *
704
+ * An approval-paused marker must never commit: the new version sits there with
705
+ * its build scripts never approved, so staying alive only proves the JS loads.
706
+ * Roll it back to the pre-install snapshot instead (a rollback failure keeps
707
+ * the marker for the next attempt, same as recoverProfile).
696
708
  */
697
709
  function commitLaunchSnapshot(profileDir) {
698
710
  try {
711
+ let pending;
712
+ try {
713
+ pending = readValidatedPendingSnapshot(profileDir);
714
+ } catch {
715
+ pending = undefined; // unreadable marker: leave it to guard recover
716
+ }
717
+ if (pending !== undefined && pendingApprovalPaused(pending) !== undefined) {
718
+ rollbackPendingSnapshot(profileDir);
719
+ console.log(`[guard] startup probation passed, but the install was abandoned at the approval gate — profile rolled back for ${profileDir}`);
720
+ return;
721
+ }
699
722
  commitPendingSnapshot(profileDir);
700
723
  console.log(`[guard] startup probation passed — pending snapshot committed for ${profileDir}`);
701
724
  } catch (error) {
@@ -1156,6 +1179,31 @@ async function selfTest() {
1156
1179
  const validated = validateInstalledProfile(profileDir);
1157
1180
  if (validated.ok !== true) throw new Error("healthy profile should validate clean (cli)");
1158
1181
 
1182
+ // commitLaunchSnapshot: a marker paused at the approval gate must roll back
1183
+ // instead of committing even after a healthy probation — the candidate sits
1184
+ // there with its build scripts never approved, and committing would delete
1185
+ // the only rollback snapshot. Layout keeps the candidate a NEW dependency
1186
+ // so the rollback prunes node_modules without spawning pnpm.
1187
+ {
1188
+ const pauseProfile = join(root, "pause-home", "profiles", "web");
1189
+ mkdirSync(join(pauseProfile, "node_modules"), { recursive: true });
1190
+ writeFileSync(join(pauseProfile, "package.json"), JSON.stringify({ dependencies: {} }));
1191
+ writeFileSync(join(pauseProfile, "cordis.patch.yml"), "[]\n");
1192
+ const snap = createProfileSnapshot(pauseProfile, { fixture: true });
1193
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "bundle" } } });
1194
+ // 暂停现场:候选已装、声明已写,静态校验过得去——正是不许提交的原因。
1195
+ writeFileSync(join(pauseProfile, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
1196
+ mkdirSync(join(pauseProfile, "node_modules", "good"), { recursive: true });
1197
+ writeFileSync(join(pauseProfile, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
1198
+ markPendingApprovalPause(pauseProfile);
1199
+ commitLaunchSnapshot(pauseProfile);
1200
+ if (readPendingSnapshot(pauseProfile) !== undefined) throw new Error("commitLaunchSnapshot must consume (roll back) an approval-paused marker");
1201
+ if (existsSync(snap.dir)) throw new Error("the paused rollback must delete the snapshot dir");
1202
+ if (JSON.parse(readFileSync(join(pauseProfile, "package.json"), "utf8")).dependencies?.good !== undefined) {
1203
+ throw new Error("the paused rollback must restore the pre-install manifest");
1204
+ }
1205
+ }
1206
+
1159
1207
  // guarded remove: exact official argv + shell:false runner seam, snapshot
1160
1208
  // before mutation, immediate commit after a statically safe removal.
1161
1209
  {
package/src/client.js CHANGED
@@ -188,6 +188,7 @@ window.__ModuleLoader__.load({
188
188
  status: snapshot.status,
189
189
  detail: snapshot.detail,
190
190
  needsApproval: snapshot.needsApproval,
191
+ staleOnRestart: snapshot.staleOnRestart,
191
192
  approvalToken: snapshot.approvalToken,
192
193
  kind: snapshot.kind,
193
194
  output: output,
@@ -249,6 +250,7 @@ window.__ModuleLoader__.load({
249
250
  spec: snap.spec,
250
251
  detail: snap.detail,
251
252
  needsApproval: snap.needsApproval,
253
+ staleOnRestart: snap.staleOnRestart,
252
254
  approvalToken: snap.approvalToken,
253
255
  kind: snap.kind,
254
256
  output: entry.output || "",
@@ -259,9 +261,13 @@ window.__ModuleLoader__.load({
259
261
  // 不在本次服务器列表里的条目属于上一次宿主会话(进程重启后
260
262
  // tracker 清空)。已兑现的直接翻篇撤掉:completed 的重启已经
261
263
  // 发生;needsApproval 暂停的批准卡片已随进程失效(事务由启动
262
- // 恢复处置),留着只会让人点一个必然失败的按钮。running 的标
263
- // 中断,别让轮询对着不存在的 id 空转。failed 保留——日志还有
264
- // 排障价值。这个判据不依赖 finishedAt(旧镜像里没有该字段)。
264
+ // 恢复处置),留着只会让人点一个必然失败的按钮;staleOnRestart
265
+ // 的失败是「被另一个未了结事务挡住」,而那个事务必然已被启动恢复
266
+ // 处置——它的报错是现在时写的(「还没做完」「现在无法安装」),
267
+ // 留到重启之后会被当成当前状态读,而它描述的情形已经不存在。
268
+ // running 的标中断,别让轮询对着不存在的 id 空转。其余 failed
269
+ // 保留——网络、预检阻断这类原因重启后可能仍然成立,日志有排障
270
+ // 价值。这个判据不依赖 finishedAt(旧镜像里没有该字段)。
265
271
  for (var key in next) {
266
272
  if (serverIds[key]) continue;
267
273
  var stale = next[key];
@@ -270,7 +276,9 @@ window.__ModuleLoader__.load({
270
276
  status: "killed",
271
277
  detail: "宿主进程已重启,该任务的记录随之丢失",
272
278
  });
273
- } else if (stale.status === "completed" || (Array.isArray(stale.needsApproval) && stale.needsApproval.length > 0)) {
279
+ } else if (stale.status === "completed"
280
+ || stale.staleOnRestart === true
281
+ || (Array.isArray(stale.needsApproval) && stale.needsApproval.length > 0)) {
274
282
  delete next[key];
275
283
  }
276
284
  }
package/src/guard.js CHANGED
@@ -1008,6 +1008,32 @@ function addedDependencyNames(pending, originalDependencies = pending?.dependenc
1008
1008
  // that used to live there. Restoring manifest + lockfile is then only half the
1009
1009
  // job: the tree must be rebuilt from the restored lockfile, or the profile is
1010
1010
  // left declaring a dependency nothing provides.
1011
+ //
1012
+ // WHEN the reconcile short-circuits (the reason the per-package add fallback
1013
+ // below exists at all). pnpm decides "up to date" by comparing its virtual
1014
+ // store bookkeeping (node_modules/.pnpm/lock.yaml) against the profile's
1015
+ // pnpm-lock.yaml — and reconcileNodeModules deletes node_modules/<name>
1016
+ // WITHOUT touching either. So the outcome hinges on how far the failed install
1017
+ // got before the rollback:
1018
+ //
1019
+ // pnpm add SUCCEEDED (e.g. it installed the new version and only then
1020
+ // stopped at the build-script approval gate) — .pnpm/lock.yaml already
1021
+ // records the new version, the restored pnpm-lock.yaml records the old
1022
+ // one, they disagree, pnpm does the work and relinks the old copy. The
1023
+ // fallback never runs.
1024
+ //
1025
+ // pnpm add FAILED EARLY (or never ran) — .pnpm/lock.yaml still matches the
1026
+ // restored pnpm-lock.yaml, so pnpm answers `install --frozen` with exit 0
1027
+ // and does nothing while the package it was asked about is gone. Only the
1028
+ // per-package add relinks it.
1029
+ //
1030
+ // This is why an approval-pause rollback is the WRONG scenario to validate the
1031
+ // fallback with: it is precisely the branch that never reaches it (confirmed on
1032
+ // a real profile — reconcile exit 0, package restored, fallback untouched). To
1033
+ // exercise it, reproduce the second row: install the package, leave
1034
+ // .pnpm/lock.yaml in agreement with pnpm-lock.yaml, mark a pending UPDATE
1035
+ // transaction, and roll back without running any pnpm in between. Both the `^`
1036
+ // range and the github: pinning paths were verified that way.
1011
1037
 
1012
1038
  /**
1013
1039
  * Env for any pnpm the guard (or its callers) spawns: peer auto-install stays
@@ -1062,26 +1088,86 @@ function fallbackAddArgs(target) {
1062
1088
  }
1063
1089
 
1064
1090
  /**
1065
- * The argv target for a fallback `pnpm add` of one restored dependency, or
1066
- * undefined when that spec cannot be added offline and safely. Semver ranges
1067
- * become `name@range`, local file:/link: paths add by the spec itself;
1068
- * github:/git+ specs need the network or git and stay fail-closed, and
1069
- * anything carrying shell metacharacters (a multi-clause range like
1070
- * `^1.0.0 || ^2.0.0` contains spaces and pipes) is skipped — the
1071
- * shell-wrapped pnpm spawn joins argv with spaces without quoting.
1091
+ * The lockfile's pinned resolution for one direct dependency, or undefined. In
1092
+ * a rollback the lockfile has just been restored from the snapshot, so this IS
1093
+ * the exact thing the rollback is trying to get back — not a guess. For a
1094
+ * semver dependency it is the resolved version (`0.13.1`); for a git-hosted one
1095
+ * it is the resolved tarball URL carrying the commit sha, which is precisely
1096
+ * what makes a moving `github:` spec pinnable.
1097
+ *
1098
+ * Only the profile's own importer (`.`) is read — a dsh profile is a single
1099
+ * package, never a workspace. A version carrying pnpm's peer suffix
1100
+ * (`1.2.3(react@18.0.0)`, emitted when a peer is resolved from inside the
1101
+ * project) is not a legal add spec; it is returned as-is and the caller's
1102
+ * assertSafeSpec rejects it on the parens, so the fallback fails closed rather
1103
+ * than adding something wrong. Profiles install with auto-install-peers off and
1104
+ * take their peers from the host, so this has not been observed in practice.
1072
1105
  */
1073
- function fallbackAddTarget(name, spec) {
1074
- const range = String(spec ?? "");
1075
- if (range.length === 0) return undefined;
1076
- const isLocal = /^(?:file:|link:)/i.test(range);
1077
- if (!isLocal && validRange(range) === null) return undefined;
1078
- const target = isLocal ? range : `${name}@${range}`;
1106
+ function pinnedLockfileVersion(profileDir, name) {
1107
+ try {
1108
+ const doc = load(readFileSync(join(profileDir, "pnpm-lock.yaml"), "utf8"));
1109
+ const version = doc?.importers?.["."]?.dependencies?.[name]?.version;
1110
+ return typeof version === "string" && version.length > 0 ? version : undefined;
1111
+ } catch {
1112
+ return undefined;
1113
+ }
1114
+ }
1115
+
1116
+ /** The target when it survives the spec blacklist, undefined when it does not. */
1117
+ function safeAddTarget(target) {
1079
1118
  try {
1080
1119
  assertSafeSpec(target);
1120
+ return target;
1081
1121
  } catch {
1082
1122
  return undefined;
1083
1123
  }
1084
- return target;
1124
+ }
1125
+
1126
+ /**
1127
+ * The argv target for a fallback `pnpm add` of one restored dependency, or
1128
+ * undefined when that spec cannot be added offline and safely.
1129
+ *
1130
+ * - `file:`/`link:` paths add by the spec itself: a local path is not a moving
1131
+ * target, it names one fixed thing.
1132
+ * - `github:owner/repo` MUST be pinned to the lockfile's resolution and is
1133
+ * never added by the spec itself. A bare github spec means "whatever HEAD is
1134
+ * now", but a rollback needs "what I had" — and the freshest thing in pnpm's
1135
+ * resolution cache and store is exactly the commit the failed update just
1136
+ * fetched, i.e. the version being rolled back FROM. Adding the bare spec
1137
+ * would relink that commit, and candidateRestoredCompatible cannot catch it:
1138
+ * a non-semver spec has no range to check, so a present package passes on
1139
+ * name alone. The rollback would then clear the marker and delete the
1140
+ * snapshot, leaving node_modules on the rejected version, the lockfile
1141
+ * claiming the old one, and no recovery evidence at all — a fail-OPEN worse
1142
+ * than not trying. The pinned tarball URL carries the commit sha, so pnpm
1143
+ * either relinks that exact commit from the store or exits nonzero.
1144
+ * - Semver ranges: `name@range` only when the range carries no shell
1145
+ * metacharacters — and `^` (the near-universal pnpm save prefix!) is one
1146
+ * (cmd's escape character: it mangles the argv through the shell-wrapped
1147
+ * spawn, so assertSafeSpec refuses it). For those — including multi-clause
1148
+ * ranges like `^1.0.0 || ^2.0.0` — the target becomes
1149
+ * `name@<lockfile pinned version>`: the lockfile is the authority this
1150
+ * rollback just restored, so its pinned version is by definition a legal
1151
+ * restore target for any range.
1152
+ *
1153
+ * Everything that cannot be pinned stays fail-closed (marker + snapshot kept
1154
+ * for `guard recover` or manual repair), which is the whole point: an unpinned
1155
+ * guess is not a recovery.
1156
+ */
1157
+ function fallbackAddTarget(name, spec, profileDir) {
1158
+ const range = String(spec ?? "");
1159
+ if (range.length === 0) return undefined;
1160
+ if (/^(?:file:|link:)/i.test(range)) return safeAddTarget(range);
1161
+ const isGit = /^github:/i.test(range);
1162
+ if (!isGit) {
1163
+ if (validRange(range) === null) return undefined;
1164
+ // A shell-safe range splices directly; `^` and friends fall through.
1165
+ const direct = safeAddTarget(`${name}@${range}`);
1166
+ if (direct !== undefined) return direct;
1167
+ }
1168
+ const pinned = pinnedLockfileVersion(profileDir, name);
1169
+ if (pinned === undefined) return undefined;
1170
+ return safeAddTarget(`${name}@${pinned}`);
1085
1171
  }
1086
1172
 
1087
1173
  /**
@@ -1263,6 +1349,14 @@ export function rollbackPendingSnapshot(profileDir) {
1263
1349
  // newly added candidate (absent from the restored manifest) needs nothing
1264
1350
  // reinstalled: removing its node_modules entry above is sufficient.
1265
1351
  // Without a lockfile a frozen install can never succeed, so it is skipped.
1352
+ // What the rebuild actually did, reported back to the caller. A successful
1353
+ // rollback used to be completely silent: reconcile and the per-package add
1354
+ // leave no trace, so after the fact nobody can tell which one relinked the
1355
+ // package — or whether either ran at all. That matters here more than usual,
1356
+ // because the add fallback exists precisely for the case where reconcile
1357
+ // silently no-ops, and "the profile looks right afterwards" does not
1358
+ // distinguish the two.
1359
+ const rebuild = { reconcile: undefined, fallback: [] };
1266
1360
  let attempt;
1267
1361
  if (isRemove) {
1268
1362
  // A failed/no-op remove commonly leaves the original direct package fully
@@ -1277,6 +1371,7 @@ export function rollbackPendingSnapshot(profileDir) {
1277
1371
  } else if (wasUpdate && pending.files?.["pnpm-lock.yaml"]?.present === true) {
1278
1372
  attempt = runReconcileInstall(profileDir);
1279
1373
  }
1374
+ if (attempt !== undefined) rebuild.reconcile = { exitCode: attempt.exitCode };
1280
1375
 
1281
1376
  // Before clearing marker/snapshot after rollback, strictly verify ALL
1282
1377
  // direct dependencies declared by the restored manifest exist in that
@@ -1302,15 +1397,21 @@ export function rollbackPendingSnapshot(profileDir) {
1302
1397
  // whatever pnpm wrote: the add is only the means to relink node_modules,
1303
1398
  // the snapshot stays authoritative for the declaration files.
1304
1399
  for (const depName of [...unsatisfied]) {
1305
- const target = fallbackAddTarget(depName, restoredDependencies[depName]);
1306
- if (target === undefined) continue; // not offline-addable — fail closed below
1400
+ const target = fallbackAddTarget(depName, restoredDependencies[depName], profileDir);
1401
+ if (target === undefined) {
1402
+ // Not offline-addable (an unpinnable spec, no lockfile entry) — record
1403
+ // the refusal too, it is the reason the throw below is about to fire.
1404
+ rebuild.fallback.push({ name: depName, target: undefined, exitCode: undefined, restored: false });
1405
+ continue; // fail closed below
1406
+ }
1307
1407
  const addAttempt = runFallbackAdd(profileDir, target);
1408
+ let restored = false;
1308
1409
  if (addAttempt.exitCode === 0) {
1309
1410
  restoreProfileSnapshot(pending);
1310
- if (candidateRestoredCompatible(profileDir, depName, restoredDependencies[depName])) {
1311
- unsatisfied.splice(unsatisfied.indexOf(depName), 1);
1312
- }
1411
+ restored = candidateRestoredCompatible(profileDir, depName, restoredDependencies[depName]);
1412
+ if (restored) unsatisfied.splice(unsatisfied.indexOf(depName), 1);
1313
1413
  }
1414
+ rebuild.fallback.push({ name: depName, target, exitCode: addAttempt.exitCode, restored });
1314
1415
  }
1315
1416
  }
1316
1417
 
@@ -1334,7 +1435,25 @@ export function rollbackPendingSnapshot(profileDir) {
1334
1435
 
1335
1436
  rmSync(pendingPath(profileDir), { force: true });
1336
1437
  rmSync(pending.dir, { recursive: true, force: true });
1337
- return pending;
1438
+ return { ...pending, rebuild };
1439
+ }
1440
+
1441
+ /**
1442
+ * One line describing what a rollback's node_modules rebuild did, or undefined
1443
+ * when there was nothing to rebuild (a fresh install's rollback only prunes).
1444
+ * Kept next to the producer so the CLI and the plugin's startup recovery report
1445
+ * it identically.
1446
+ */
1447
+ export function describeRollbackRebuild(rebuild) {
1448
+ if (rebuild === null || typeof rebuild !== "object") return undefined;
1449
+ const parts = [];
1450
+ if (rebuild.reconcile !== undefined) parts.push(`reconcile exit ${rebuild.reconcile.exitCode}`);
1451
+ for (const entry of rebuild.fallback ?? []) {
1452
+ parts.push(entry.target === undefined
1453
+ ? `add ${entry.name}: refused (no pinnable offline target)`
1454
+ : `add ${entry.target}: exit ${entry.exitCode}${entry.restored ? ", restored" : ", NOT restored"}`);
1455
+ }
1456
+ return parts.length > 0 ? parts.join("; ") : undefined;
1338
1457
  }
1339
1458
 
1340
1459
  // ── pending-snapshot recovery (startup + external CLI) ───────────────────────
@@ -1426,11 +1545,149 @@ export function validateRemoveCompletion(profileDir, candidateName) {
1426
1545
  * @param profileDir - the profile directory (may come straight from the marker).
1427
1546
  * @returns {{action: "none"|"committed"|"rolled-back", issues?, removed?}}
1428
1547
  */
1548
+ // ── approval-pause mark ──────────────────────────────────────────────────────
1549
+ //
1550
+ // A needsApproval pause otherwise lives only in console output: the marker
1551
+ // carries no trace of it, so a restart that passes the STATIC validation would
1552
+ // commit the new version with its build scripts never approved — a natively
1553
+ // built plugin is then left installed-but-broken and the rollback snapshot is
1554
+ // deleted. Both recovery commit points (recoverProfile here, cli.js's
1555
+ // commitLaunchSnapshot) must check this mark and roll back instead.
1556
+ //
1557
+ // Deliberately NOT mirrored into snapshot.json (it is written before the
1558
+ // transaction begins and cannot know about a later pause) and NOT a
1559
+ // SNAPSHOT_VERSION bump (a bump would fail-close every marker already written
1560
+ // by earlier versions, pushing users from auto-recoverable to manual).
1561
+ // sanitizeSnapshot ignores unknown metadata fields, so a missing `paused`
1562
+ // simply reads as "not paused" and old markers keep their behavior. Tampering
1563
+ // with the mark is fail-safe: forging it forces a rollback (refuses the new
1564
+ // plugin); deleting it restores the pre-mark behavior.
1565
+
1566
+ /** The pause record on a validated pending marker, or undefined. */
1567
+ export function pendingApprovalPaused(pending) {
1568
+ const paused = pending?.metadata?.paused;
1569
+ return paused !== null && typeof paused === "object" ? paused : undefined;
1570
+ }
1571
+
1572
+ /**
1573
+ * How the profile looked BEFORE a paused install began, for the one package
1574
+ * that install is about, or undefined when nothing is paused (or the snapshot
1575
+ * cannot be read, in which case callers should leave their own view alone).
1576
+ *
1577
+ * A paused transaction has already written its half: `pnpm add` swapped
1578
+ * node_modules/<name> to the new version and the manifest declares it, but the
1579
+ * build scripts were never approved, dsh has not loaded any of it, and the next
1580
+ * startup rolls the whole thing back. Reporting that half-state as "installed"
1581
+ * inverts the truth for the user — an UPDATE shows the new version number while
1582
+ * the old one is what is actually running and what a restart will restore. So
1583
+ * the marketplace lists this package the way the snapshot has it instead:
1584
+ *
1585
+ * present: true — it was already installed (an update). Show `version`, the
1586
+ * version the snapshot's lockfile pins: what runs now and
1587
+ * what a restart goes back to.
1588
+ * present: false — it was not installed at all (a fresh install). It should
1589
+ * not appear in the list; nothing about it took effect.
1590
+ *
1591
+ * @returns {{name: string, present: boolean, spec?: string, version?: string}|undefined}
1592
+ */
1593
+ export function pausedCandidateBeforeState(profileDir) {
1594
+ let pending;
1595
+ try {
1596
+ pending = readValidatedPendingSnapshot(profileDir);
1597
+ } catch {
1598
+ return undefined;
1599
+ }
1600
+ if (pending === undefined || pendingApprovalPaused(pending) === undefined) return undefined;
1601
+ const name = pending.preflight?.candidate?.name ?? pending.candidate?.name;
1602
+ if (typeof name !== "string" || name.length === 0) return undefined;
1603
+ let spec;
1604
+ try {
1605
+ // The snapshot's manifest — not the live one, which the paused install
1606
+ // already rewrote — decides whether this package existed beforehand.
1607
+ spec = readJson(join(pending.dir, "package.json"))?.dependencies?.[name];
1608
+ } catch {
1609
+ return undefined; // unreadable snapshot: do not touch the caller's view
1610
+ }
1611
+ if (spec === undefined) return { name, present: false };
1612
+ // The snapshot's lockfile pins what that install would have kept running.
1613
+ return { name, present: true, spec, version: pinnedLockfileVersion(pending.dir, name) };
1614
+ }
1615
+
1616
+ /**
1617
+ * Validate the pending marker, let `mutate` edit its metadata, write it back.
1618
+ * The whole read-mutate-write sits under ONE try: the marker can disappear or
1619
+ * be replaced between the validating read and the write (an install pausing
1620
+ * while startup recovery consumes the same marker), and a pause mark is never
1621
+ * worth turning that race into a thrown error in the middle of an install.
1622
+ * Both callers below promise a boolean, so the failure is reported that way.
1623
+ * @param mutate - returns true when it changed something worth persisting.
1624
+ * @returns true when the marker was rewritten; false when there was nothing to
1625
+ * do or anything failed — never creates or heals a marker.
1626
+ */
1627
+ function updatePendingMarkerMetadata(profileDir, mutate) {
1628
+ try {
1629
+ if (readValidatedPendingSnapshot(profileDir) === undefined) return false;
1630
+ const markerPath = pendingPath(profileDir);
1631
+ const marker = readJson(markerPath);
1632
+ if (marker === null || typeof marker !== "object") return false;
1633
+ // Validation above already guarantees metadata is a plain object.
1634
+ const metadata = marker.metadata ?? {};
1635
+ if (mutate(metadata) !== true) return false;
1636
+ marker.metadata = metadata;
1637
+ writeFileSync(markerPath, JSON.stringify(marker, undefined, 2) + "\n");
1638
+ return true;
1639
+ } catch {
1640
+ return false;
1641
+ }
1642
+ }
1643
+
1644
+ /**
1645
+ * Mark the profile's existing pending marker as paused at the approval gate.
1646
+ * @returns true when a marker was marked; false when there is nothing to mark
1647
+ * (no marker) or it fails validation (fail closed — never create or heal one).
1648
+ */
1649
+ export function markPendingApprovalPause(profileDir, reason = "paused for build-script approval") {
1650
+ return updatePendingMarkerMetadata(profileDir, (metadata) => {
1651
+ metadata.paused = { reason, at: Date.now() };
1652
+ return true;
1653
+ });
1654
+ }
1655
+
1656
+ /**
1657
+ * Clear the approval-pause mark: a token retry resumed the transaction, so its
1658
+ * eventual completion must commit normally instead of being rolled back.
1659
+ * @returns true when a mark was removed, false when there was none to remove.
1660
+ */
1661
+ export function clearPendingApprovalPause(profileDir) {
1662
+ return updatePendingMarkerMetadata(profileDir, (metadata) => {
1663
+ // Same notion of "a mark exists" as pendingApprovalPaused: a non-object
1664
+ // reads as not paused, so there is nothing to clear.
1665
+ if (metadata.paused === null || typeof metadata.paused !== "object") return false;
1666
+ delete metadata.paused;
1667
+ return true;
1668
+ });
1669
+ }
1670
+
1429
1671
  export function recoverProfile(profileDir) {
1430
1672
  const pending = readValidatedPendingSnapshot(profileDir);
1431
1673
  if (pending === undefined) return { action: "none" };
1432
- const validation = validateInstalledProfile(profileDir);
1433
1674
  const isRemove = pending.operation === "remove";
1675
+ // 批准闸暂停后被放弃:静态校验过得去也不许提交——那会把「构建脚本从未
1676
+ // 批准」的新版本以已提交状态留下(原生构建插件装着但坏),且快照被删、
1677
+ // 回滚目标消失。一律回滚到第一次安装前。
1678
+ const pause = pendingApprovalPaused(pending);
1679
+ if (!isRemove && pause !== undefined) {
1680
+ const rolled = rollbackPendingSnapshot(profileDir);
1681
+ return {
1682
+ action: "rolled-back",
1683
+ reason: "批准闸暂停后被放弃(构建脚本未获批准),已回滚到安装前状态",
1684
+ issues: [issue("warn", "approval-paused-abandoned", "批准闸暂停后被放弃,已回滚",
1685
+ `安装停在构建脚本批准处未被批准(${pause.reason}),profile 已回滚到安装前状态`)],
1686
+ removed: addedDependencyNames(pending),
1687
+ rebuild: rolled?.rebuild,
1688
+ };
1689
+ }
1690
+ const validation = validateInstalledProfile(profileDir);
1434
1691
  const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
1435
1692
  const removeValidation = isRemove
1436
1693
  ? validateRemoveCompletion(pending.profileDir, candidateName)
@@ -1441,8 +1698,20 @@ export function recoverProfile(profileDir) {
1441
1698
  }
1442
1699
  const recoveryIssues = [...validation.issues, ...removeValidation.issues];
1443
1700
  const added = isRemove ? [] : addedDependencyNames(pending);
1444
- rollbackPendingSnapshot(profileDir);
1445
- return { action: "rolled-back", issues: recoveryIssues, removed: added };
1701
+ const rolled = rollbackPendingSnapshot(profileDir);
1702
+ // The blockers are the reason; naming them beats the old generic wording,
1703
+ // which said "profile failed validation" for every rollback including the
1704
+ // ones that were not validation failures at all.
1705
+ const blockers = recoveryIssues.filter((entry) => entry.severity === "block");
1706
+ return {
1707
+ action: "rolled-back",
1708
+ reason: blockers.length > 0
1709
+ ? `profile 静态校验未通过:${blockers.map((entry) => entry.title).join("; ")}`
1710
+ : "profile 静态校验未通过",
1711
+ issues: recoveryIssues,
1712
+ removed: added,
1713
+ rebuild: rolled?.rebuild,
1714
+ };
1446
1715
  }
1447
1716
 
1448
1717
  /** Recover every profile with a pending marker under a dsh home. */
@@ -1682,6 +1951,105 @@ async function selfTest() {
1682
1951
  if (existsSync(snap.dir)) throw new Error("recoverProfile commit should delete the snapshot dir");
1683
1952
  }
1684
1953
 
1954
+ // Approval-pause mark, part 1: a paused marker must NEVER commit on
1955
+ // recovery — not even when the static validation would pass (the version
1956
+ // sits there with its build scripts never approved; committing would drop
1957
+ // the only rollback snapshot). Recovery rolls back to the pre-install
1958
+ // state. Layout: the candidate is a NEW dependency (snapshot has none), so
1959
+ // the rollback only prunes node_modules and never spawns pnpm.
1960
+ {
1961
+ const p = join(root, "profiles", "approval-pause");
1962
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
1963
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
1964
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1965
+ if (markPendingApprovalPause(p) !== false) throw new Error("markPendingApprovalPause without a marker must return false, not create one");
1966
+ const snap = createProfileSnapshot(p, { fixture: true });
1967
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "bundle" } } });
1968
+ // 暂停现场:pnpm 已把候选装上、声明也写了——静态校验完全过得去,
1969
+ // 这正是危险所在(提交 = 脚本从未批准的版本以已提交状态留下)。
1970
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
1971
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
1972
+ if (markPendingApprovalPause(p) !== true) throw new Error("markPendingApprovalPause must mark an existing marker");
1973
+ const pausedMarker = readJson(pendingPath(p));
1974
+ if (pausedMarker?.metadata?.paused?.reason !== "paused for build-script approval") throw new Error("the pause mark must persist on the marker file");
1975
+ const recPaused = recoverProfile(p);
1976
+ if (recPaused.action !== "rolled-back") throw new Error(`a paused marker must roll back even when validation would pass, got ${recPaused.action}`);
1977
+ if (!recPaused.issues.some((entry) => entry.code === "approval-paused-abandoned")) throw new Error("the rollback must carry the approval-paused-abandoned issue");
1978
+ if (readJson(join(p, "package.json")).dependencies?.good !== undefined) throw new Error("rollback must restore the pre-install manifest (no candidate)");
1979
+ if (existsSync(join(p, "node_modules", "good"))) throw new Error("rollback must prune the never-approved candidate");
1980
+ if (readPendingSnapshot(p) !== undefined) throw new Error("the rolled-back pause must consume its marker");
1981
+ }
1982
+
1983
+ // pausedCandidateBeforeState: what the marketplace must SHOW while an
1984
+ // install sits paused. The half-written profile says the new version is
1985
+ // installed; the truth is that nothing took effect and a restart undoes it.
1986
+ {
1987
+ // An UPDATE: the package existed before, so the list keeps showing the
1988
+ // version that is actually running — the snapshot's pinned one.
1989
+ const p = join(root, "profiles", "paused-view-update");
1990
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
1991
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^1.0.0" } }));
1992
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1993
+ writeFileSync(join(p, "pnpm-lock.yaml"), [
1994
+ "lockfileVersion: '9.0'", "importers:", " .:", " dependencies:",
1995
+ " good:", " specifier: ^1.0.0", " version: 1.0.0", "",
1996
+ ].join("\n"));
1997
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
1998
+ const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
1999
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
2000
+ if (pausedCandidateBeforeState(p) !== undefined) throw new Error("a marker with no pause mark must not rewrite the view");
2001
+ // The paused half: pnpm already swapped in 2.0.0 and the manifest says so.
2002
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
2003
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
2004
+ markPendingApprovalPause(p);
2005
+ const view = pausedCandidateBeforeState(p);
2006
+ if (view?.name !== "good" || view.present !== true) throw new Error(`a paused update must report the package as previously present, got ${JSON.stringify(view)}`);
2007
+ if (view.version !== "1.0.0") throw new Error(`the reported version must be the snapshot's pin (what actually runs), got ${JSON.stringify(view.version)}`);
2008
+ if (view.spec !== "^1.0.0") throw new Error(`the reported spec must be the snapshot's, got ${JSON.stringify(view.spec)}`);
2009
+ rmSync(pendingPath(p), { force: true });
2010
+ rmSync(snap.dir, { recursive: true, force: true });
2011
+ }
2012
+ {
2013
+ // A FRESH install: the package did not exist before, so it must drop out
2014
+ // of the list entirely — nothing about it took effect.
2015
+ const p = join(root, "profiles", "paused-view-fresh");
2016
+ mkdirSync(join(p, "node_modules"), { recursive: true });
2017
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
2018
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2019
+ const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
2020
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
2021
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
2022
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
2023
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
2024
+ markPendingApprovalPause(p);
2025
+ const view = pausedCandidateBeforeState(p);
2026
+ if (view?.name !== "good" || view.present !== false) throw new Error(`a paused fresh install must report the package as absent beforehand, got ${JSON.stringify(view)}`);
2027
+ if (view.version !== undefined) throw new Error("an absent package has no version to show");
2028
+ rmSync(pendingPath(p), { force: true });
2029
+ rmSync(snap.dir, { recursive: true, force: true });
2030
+ if (pausedCandidateBeforeState(p) !== undefined) throw new Error("no marker means no view rewrite");
2031
+ }
2032
+
2033
+ // Approval-pause mark, part 2: a cleared mark (token retry resumed and
2034
+ // finished the transaction) commits normally — the mark must not outlive
2035
+ // the transaction it belonged to.
2036
+ {
2037
+ const p = join(root, "profiles", "approval-pause-cleared");
2038
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
2039
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" }, dsh: { profile: { bundles: ["good"] } } }));
2040
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2041
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
2042
+ writeFileSync(join(p, "node_modules", "good", "cordis.patch.yml"), "- insert:\n - id: good\n name: good\n");
2043
+ const snap = createProfileSnapshot(p, { fixture: true });
2044
+ markPendingSnapshot(snap, { spec: "good", preflight: { candidate: { name: "good", version: "1.0.0", kind: "bundle" } } });
2045
+ markPendingApprovalPause(p);
2046
+ if (clearPendingApprovalPause(p) !== true) throw new Error("clearPendingApprovalPause must remove an existing mark");
2047
+ const rec = recoverProfile(p);
2048
+ if (rec.action !== "committed") throw new Error(`after the mark is cleared a healthy install must commit, got ${rec.action}`);
2049
+ if (readPendingSnapshot(p) !== undefined) throw new Error("the commit must clear the marker");
2050
+ if (clearPendingApprovalPause(p) !== false) throw new Error("clearPendingApprovalPause without a mark must return false");
2051
+ }
2052
+
1685
2053
  // Remove rollback, no-op failure: the official command failed before
1686
2054
  // touching the package. Rollback must preserve the healthy direct package
1687
2055
  // and must not run the install/update path's candidate pruning logic.
@@ -2021,6 +2389,69 @@ async function selfTest() {
2021
2389
  if (!args.includes("--ignore-scripts")) throw new Error("probe args must keep install scripts disabled");
2022
2390
  }
2023
2391
 
2392
+ // fallbackAddTarget (pure): what one restored dependency may be offline
2393
+ // re-added as. `^` ranges (pnpm's near-universal save prefix) cannot be
2394
+ // spliced into a shell-wrapped argv (cmd eats the caret), so they resolve
2395
+ // to the lockfile's pinned version — exactly what a rollback is restoring
2396
+ // to. A `github:` spec resolves to its pinned tarball URL for a different
2397
+ // and sharper reason: the bare spec means "HEAD now", and the freshest
2398
+ // thing in pnpm's cache/store is the commit the failed update just fetched,
2399
+ // so adding it bare would relink the very version being rolled back FROM —
2400
+ // and a non-semver spec has no range for candidateRestoredCompatible to
2401
+ // check, so that wrong copy would pass on name alone, clear the marker and
2402
+ // delete the snapshot. Pinning is what keeps the fallback fail-closed.
2403
+ {
2404
+ const lockRoot = join(root, "profiles", "fbtarget");
2405
+ mkdirSync(lockRoot, { recursive: true });
2406
+ const tarball = "https://codeload.github.com/owner/repo/tar.gz/898369ece56ae6ec41afd8e014f187bb5b723409";
2407
+ writeFileSync(join(lockRoot, "pnpm-lock.yaml"), [
2408
+ "lockfileVersion: '9.0'",
2409
+ "importers:",
2410
+ " .:",
2411
+ " dependencies:",
2412
+ " good:",
2413
+ " specifier: ^1.0.0",
2414
+ " version: 1.0.0",
2415
+ " hosted:",
2416
+ " specifier: github:owner/repo",
2417
+ ` version: ${tarball}`,
2418
+ " peered:",
2419
+ " specifier: ^1.0.0",
2420
+ " version: 1.0.0(@deepseek-ai/cordis@4.0.1)",
2421
+ "",
2422
+ ].join("\n"));
2423
+ if (fallbackAddTarget("good", "1.0.0", lockRoot) !== "good@1.0.0") throw new Error("a plain range splices directly");
2424
+ if (fallbackAddTarget("good", "^1.0.0", lockRoot) !== "good@1.0.0") throw new Error("a ^-range must resolve to the lockfile pinned version");
2425
+ if (fallbackAddTarget("good", "^1.0.0", join(root, "profiles", "no-lock-here")) !== undefined) {
2426
+ throw new Error("a ^-range without a readable lockfile must stay fail-closed");
2427
+ }
2428
+ // A bare github spec must NEVER be added as itself: pinning to the
2429
+ // lockfile's tarball URL is the only thing that names the old commit.
2430
+ if (fallbackAddTarget("hosted", "github:owner/repo", lockRoot) !== `hosted@${tarball}`) {
2431
+ throw new Error("a github spec must resolve to the lockfile pinned tarball, never to the moving bare spec");
2432
+ }
2433
+ if (fallbackAddTarget("hosted", "github:owner/repo", join(root, "profiles", "no-lock-here")) !== undefined) {
2434
+ throw new Error("a github spec without a readable lockfile must stay fail-closed, not fall back to the bare spec");
2435
+ }
2436
+ if (fallbackAddTarget("absent", "github:owner/absent", lockRoot) !== undefined) {
2437
+ throw new Error("a github spec with no lockfile entry must stay fail-closed");
2438
+ }
2439
+ // pnpm's peer suffix is not a legal add spec — the parens hit the spec
2440
+ // blacklist, so the fallback fails closed instead of adding something odd.
2441
+ if (fallbackAddTarget("peered", "^1.0.0", lockRoot) !== undefined) {
2442
+ throw new Error("a peer-suffixed pinned version must stay fail-closed");
2443
+ }
2444
+ if (fallbackAddTarget("good", "file:D:\\pkg.tgz", lockRoot) !== "file:D:\\pkg.tgz") throw new Error("local file target expected");
2445
+ // 多区间 range 直拼必被拒(空格/管道),但 lockfile 的 pinned 对任何
2446
+ // range 都是合法恢复目标(lockfile 即权威)——同样走 pinned,
2447
+ // 只有 lockfile 不可读/无该条目才 fail-closed。
2448
+ if (fallbackAddTarget("good", "^1.0.0 || ^2.0.0", lockRoot) !== "good@1.0.0") throw new Error("a multi-clause range must resolve to the lockfile pinned version");
2449
+ if (fallbackAddTarget("good", "^1.0.0 || ^2.0.0", join(root, "profiles", "no-lock-here")) !== undefined) {
2450
+ throw new Error("a multi-clause range without a readable lockfile must stay fail-closed");
2451
+ }
2452
+ if (fallbackAddTarget("good", "", lockRoot) !== undefined) throw new Error("an empty spec has no target");
2453
+ }
2454
+
2024
2455
  // pnpmSpawnPlan (pure, plus a real spawn on Windows): the .cmd shim path
2025
2456
  // must carry its own quotes. Node's shell:true joins command and args
2026
2457
  // without per-argument quoting, so `D:\Program Files\nodejs\pnpm.CMD`
@@ -2188,12 +2619,27 @@ async function selfTest() {
2188
2619
  markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
2189
2620
  const previousPath = process.env.PATH;
2190
2621
  process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
2622
+ let rolled;
2191
2623
  try {
2192
- rollbackPendingSnapshot(p);
2624
+ rolled = rollbackPendingSnapshot(p);
2193
2625
  } finally {
2194
2626
  if (previousPath === undefined) delete process.env.PATH;
2195
2627
  else process.env.PATH = previousPath;
2196
2628
  }
2629
+ // The rebuild report is the ONLY way to tell "reconcile relinked it" from
2630
+ // "reconcile no-opped and the add fallback saved it" after the fact —
2631
+ // both leave an identical-looking profile behind.
2632
+ if (rolled?.rebuild?.reconcile?.exitCode !== 0) throw new Error("the rebuild report must record the no-op reconcile and its exit code");
2633
+ if (rolled.rebuild.fallback.length !== 1) throw new Error(`the rebuild report must record exactly one fallback add, got ${rolled.rebuild.fallback.length}`);
2634
+ const [addReport] = rolled.rebuild.fallback;
2635
+ if (addReport.name !== "good" || addReport.target !== "good@1.0.0" || addReport.exitCode !== 0 || addReport.restored !== true) {
2636
+ throw new Error(`the fallback report must name the package, target, exit code and outcome, got ${JSON.stringify(addReport)}`);
2637
+ }
2638
+ const described = describeRollbackRebuild(rolled.rebuild);
2639
+ if (!/reconcile exit 0/.test(described) || !/add good@1\.0\.0: exit 0, restored/.test(described)) {
2640
+ throw new Error(`describeRollbackRebuild must render both steps, got ${JSON.stringify(described)}`);
2641
+ }
2642
+ if (describeRollbackRebuild(undefined) !== undefined) throw new Error("no rebuild means no line to print");
2197
2643
  if (readPendingSnapshot(p) !== undefined) throw new Error("an add-fallback-rescued rollback must clear the marker");
2198
2644
  if (existsSync(snap.dir)) throw new Error("an add-fallback-rescued rollback must delete the snapshot dir");
2199
2645
  if (readJson(join(p, "node_modules", "good", "package.json")).version !== "1.0.0") throw new Error("the add fallback must relink the old version");
package/src/index.js CHANGED
@@ -25,7 +25,7 @@ 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
27
  import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof, persistPluginDisabled } from "./installer.js";
28
- import { preflightInstall, inspectRemoteCandidate, recoverProfile } from "./guard.js";
28
+ import { preflightInstall, inspectRemoteCandidate, recoverProfile, describeRollbackRebuild } from "./guard.js";
29
29
 
30
30
  export const name = "@1e0zj/dsh-plugin-mall";
31
31
  // `loader` 用来读装配树、并对单个 entry 做热开关(entry.update)。读法照抄
@@ -829,6 +829,9 @@ export function createJobTracker({ producerFactory } = {}) {
829
829
  record.status = status;
830
830
  record.detail = outcome?.detail;
831
831
  record.needsApproval = outcome?.needsApproval;
832
+ // 原因活不过一次重启的失败(被别的未了结事务挡住):浏览器据此在
833
+ // 重启后撤掉记录,而不是把一段现在时的描述留在面板上当现状读。
834
+ record.staleOnRestart = outcome?.staleOnRestart === true;
832
835
  record.finishedAt = Date.now();
833
836
 
834
837
  if (status === "completed") {
@@ -940,6 +943,7 @@ export function createJobTracker({ producerFactory } = {}) {
940
943
  status: record.status,
941
944
  detail: record.detail,
942
945
  needsApproval: record.needsApproval,
946
+ staleOnRestart: record.staleOnRestart,
943
947
  approvalToken: isSameSession ? record.approvalToken : undefined,
944
948
  // extras(如预检结论)同样只对同一 session 可见,与 approvalToken 同规格。
945
949
  extras: isSameSession ? record.extras : undefined,
@@ -977,6 +981,7 @@ export function createJobTracker({ producerFactory } = {}) {
977
981
  status: record.status,
978
982
  detail: record.detail,
979
983
  needsApproval: record.needsApproval,
984
+ staleOnRestart: record.staleOnRestart,
980
985
  approvalToken: isSameSession ? record.approvalToken : undefined,
981
986
  extras: isSameSession ? record.extras : undefined,
982
987
  spec: record.spec,
@@ -1636,6 +1641,11 @@ export function apply(ctx, config = {}) {
1636
1641
  console.log(`[dsh-plugin-mall] startup recovery: committed the pending install for profile "${defaultProfile}"`);
1637
1642
  } else if (result.action === "rolled-back") {
1638
1643
  console.warn(`[dsh-plugin-mall] startup recovery: rolled back the pending install for profile "${defaultProfile}" — ${result.reason ?? "profile failed validation"}`);
1644
+ // What the rebuild did, when it did anything. A rollback that relinked a
1645
+ // package used to be silent, so a reconcile that silently no-opped and a
1646
+ // fallback add that saved the profile looked exactly alike afterwards.
1647
+ const rebuild = describeRollbackRebuild(result.rebuild);
1648
+ if (rebuild !== undefined) console.warn(`[dsh-plugin-mall] startup recovery: node_modules rebuild — ${rebuild}`);
1639
1649
  }
1640
1650
  } catch (error) {
1641
1651
  // 恢复失败绝不能拖垮插件加载:报出来,让市场照常可用(用户还能手动
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, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
19
+ import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, markPendingApprovalPause, markPendingSnapshot, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
20
20
 
21
21
  // ── spec normalization ──────────────────────────────────────────────────────
22
22
 
@@ -188,7 +188,20 @@ export function reconcileBundles(profileDir, beforeDeps = new Set()) {
188
188
  return result;
189
189
  }
190
190
 
191
- /** List a profile's installed plugins (dependencies with classification + version). */
191
+ /**
192
+ * List a profile's installed plugins (dependencies with classification +
193
+ * version).
194
+ *
195
+ * An install paused at the build-script approval gate is reported as the
196
+ * snapshot has it, not as the half-written profile has it: the candidate's
197
+ * scripts were never approved, dsh has not loaded it, and the next startup
198
+ * rolls it back, so calling it "installed" tells the user the opposite of what
199
+ * is true — an update would show the NEW version while the old one is what is
200
+ * actually running and what a restart restores. A paused fresh install drops
201
+ * out of the list entirely. Every consumer goes through here (the browser's
202
+ * installed panel, the `updates` check that decides whether an update button
203
+ * appears, and the `market_installed` agent tool), so they all agree.
204
+ */
192
205
  export function listInstalled(profile) {
193
206
  const dir = resolveProfileDir(profile);
194
207
  const manifestPath = join(dir, "package.json");
@@ -209,7 +222,13 @@ export function listInstalled(profile) {
209
222
  }
210
223
  return { name, version, kind };
211
224
  });
212
- return { dir, deps };
225
+ const before = pausedCandidateBeforeState(dir);
226
+ if (before === undefined) return { dir, deps };
227
+ if (!before.present) return { dir, deps: deps.filter((dep) => dep.name !== before.name) };
228
+ return {
229
+ dir,
230
+ deps: deps.map((dep) => (dep.name === before.name ? { ...dep, version: before.version ?? "?" } : dep)),
231
+ };
213
232
  }
214
233
 
215
234
  // ── npm registry resolution ─────────────────────────────────────────────────
@@ -1094,6 +1113,9 @@ export function createJobTracker() {
1094
1113
  // 待批准的构建脚本清单:浏览器侧据此渲染「允许并继续」,没有它就只有
1095
1114
  // 一段文本,用户看不出要批准的到底是什么。
1096
1115
  record.needsApproval = outcome.needsApproval;
1116
+ // 这次失败的原因活不过一次重启(见 failedNow),浏览器据此在重启后
1117
+ // 撤掉记录,而不是把一段现在时的描述当成当前状态留在面板上。
1118
+ record.staleOnRestart = outcome.staleOnRestart === true;
1097
1119
  record.finishedAt = Date.now();
1098
1120
  onSettled?.(outcome);
1099
1121
  });
@@ -1112,6 +1134,7 @@ export function createJobTracker() {
1112
1134
  status: record.status,
1113
1135
  detail: record.detail,
1114
1136
  needsApproval: record.needsApproval,
1137
+ staleOnRestart: record.staleOnRestart,
1115
1138
  spec: record.spec,
1116
1139
  startedAt: record.startedAt,
1117
1140
  finishedAt: record.finishedAt,
@@ -1455,14 +1478,28 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1455
1478
  try {
1456
1479
  existingMarker = readValidatedPendingSnapshot(profileDir);
1457
1480
  } catch (error) {
1458
- return failedNow(`profile has an unreadable pending marker — refusing to install ${spec} (${error.message}); run \`dsh-plugin-guard guard recover\` first`);
1481
+ return failedNow(`profile 里有一个读不出来的安装记录,无法判断它是什么,因此拒绝安装 ${spec}(${error.message})。请先运行 \`dsh-plugin-guard guard recover\` 处理它。`);
1459
1482
  }
1460
1483
  if (existingMarker !== undefined) {
1461
1484
  const previous = existingMarker.metadata?.spec ?? existingMarker.metadata?.packageName ?? "unknown";
1462
1485
  if (existingMarker.operation !== "install" || existingMarker.metadata?.spec !== spec) {
1463
- return failedNow(`profile has a pending ${existingMarker.operation} transaction for ${JSON.stringify(previous)} — refusing to install ${spec}; restart dsh (startup recovery) or run \`dsh-plugin-guard guard recover\` first`);
1486
+ // 拒绝是对的(marker 是一次性事务,不能被覆盖),但用户看不见 marker,
1487
+ // 所以要说清楚挡路的是什么、以及怎么让它让开。暂停在批准闸的那种最
1488
+ // 常见——它正是用户刚点过取消的那次安装。
1489
+ const paused = pendingApprovalPaused(existingMarker) !== undefined;
1490
+ const what = existingMarker.operation === "remove" ? "卸载" : "安装";
1491
+ return failedNow(paused
1492
+ ? `${previous} 的${what}还没做完——它停在「允许安装依赖」那一步等你决定,没有批准就不会真正装上。现在无法安装 ${spec}。重启 dsh 会撤回那次未批准的${what},之后就能重新操作;也可以运行 \`dsh-plugin-guard guard recover\` 立即撤回。`
1493
+ : `${previous} 的${what}还没了结,现在无法安装 ${spec}。重启 dsh 会自动了结它(装好的提交、没批准的撤回),也可以运行 \`dsh-plugin-guard guard recover\` 手动处理。`,
1494
+ { staleOnRestart: true });
1464
1495
  }
1465
1496
  push(`[dsh-plugin-mall] resuming the paused install transaction for ${spec} — its original snapshot stays the rollback target\n`);
1497
+ // 事务复活:清掉暂停标记,否则重试成功后的启动提交会被它拦下错误回滚。
1498
+ try {
1499
+ clearPendingApprovalPause(profileDir);
1500
+ } catch (pauseError) {
1501
+ push(`[dsh-plugin-mall] WARNING: could not clear the approval-pause mark: ${pauseError.message}\n`);
1502
+ }
1466
1503
  } else {
1467
1504
  let snapshot;
1468
1505
  try {
@@ -1719,7 +1756,15 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1719
1756
  // The retry's rebuild branch also needs this tree in place. Leave the
1720
1757
  // on-disk state and the marker for the token retry; an abandoned pause
1721
1758
  // is settled by startup recovery / `guard recover`, whose rollback
1722
- // target is still the pre-first-attempt snapshot.
1759
+ // target is still the pre-first-attempt snapshot. The pause is also
1760
+ // marked ON the marker: without the mark a restart that passes the
1761
+ // static validation would commit the never-approved version and drop
1762
+ // the snapshot (both recovery commit points check it).
1763
+ try {
1764
+ markPendingApprovalPause(profileDir);
1765
+ } catch (pauseError) {
1766
+ push(`[dsh-plugin-mall] WARNING: could not mark the pause on the pending marker: ${pauseError.message}\n`);
1767
+ }
1723
1768
  push("\n[dsh-plugin-mall] install paused for build-script approval — the candidate stays installed with its scripts blocked; approve in the UI to finish, or restart dsh / run `dsh-plugin-guard guard recover` to roll back\n");
1724
1769
  } else {
1725
1770
  try {
@@ -1747,10 +1792,21 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1747
1792
  // ── the background uninstall job ────────────────────────────────────────────
1748
1793
 
1749
1794
  /** A terminal producer for fast-fail cases (no pnpm spawn needed). */
1750
- function failedNow(detail) {
1795
+ /**
1796
+ * @param staleOnRestart - true when this failure's CAUSE cannot outlive a
1797
+ * restart, so the browser should drop the record instead of keeping it as
1798
+ * history. Only the "another transaction owns this profile" refusals qualify:
1799
+ * startup recovery settles that transaction on the way up, so the message
1800
+ * ("X is still waiting at the approval gate, so Y cannot install") describes a
1801
+ * situation that is guaranteed gone — and it is written in the present tense,
1802
+ * so a reader after the restart takes it for the current state. Ordinary
1803
+ * failures (network, preflight blockers, pnpm errors) may well still apply
1804
+ * after a restart and keep their diagnostic value, so they stay.
1805
+ */
1806
+ function failedNow(detail, { staleOnRestart = false } = {}) {
1751
1807
  return {
1752
1808
  cancel: () => {},
1753
- done: Promise.resolve({ status: "failed", detail }),
1809
+ done: Promise.resolve({ status: "failed", detail, staleOnRestart }),
1754
1810
  readOutput: () => "",
1755
1811
  };
1756
1812
  }
@@ -1786,7 +1842,11 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
1786
1842
  // hard as a valid one, and is left untouched for the recovery path.
1787
1843
  const markerPath = pendingMarkerPath(profileDir);
1788
1844
  if (existsSync(markerPath)) {
1789
- return failedNow(`profile "${profile}" has a pending install transaction (${markerPath}) — refusing to remove ${packageName} until it is resolved; restart dsh (startup recovery) or run \`dsh-plugin-guard guard recover\` first`);
1845
+ // 存在性判断(同 markPendingSnapshot):坏 marker 也照样挡路。所以这里
1846
+ // 只能拿到「有」而拿不到「是什么」,文案相应保持笼统,但仍要给出路。
1847
+ return failedNow(
1848
+ `profile "${profile}" 里有一个还没了结的安装事务,在它了结之前无法卸载 ${packageName}。重启 dsh 会自动了结它(装好的提交、没批准的撤回),也可以运行 \`dsh-plugin-guard guard recover\` 手动处理(事务记录:${markerPath})。`,
1849
+ { staleOnRestart: true });
1790
1850
  }
1791
1851
  const manifestPath = join(profileDir, "package.json");
1792
1852
  if (!existsSync(manifestPath)) {
@@ -2175,13 +2235,50 @@ async function runTransactionFixtures() {
2175
2235
  && /paused for build-script approval/.test(output),
2176
2236
  `status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
2177
2237
  );
2238
+ check(
2239
+ "暂停必须落盘到 marker(metadata.paused)——重启后的恢复靠它区分「装完待验证」与「停在批准闸被放弃」",
2240
+ (() => {
2241
+ try {
2242
+ const marker = JSON.parse(readFileSync(markerBefore, "utf8"));
2243
+ return marker?.metadata?.paused?.reason === "paused for build-script approval";
2244
+ } catch { return false; }
2245
+ })(),
2246
+ );
2247
+
2248
+ // 1a-ter. 暂停期间装别的包:拒绝是对的(marker 是一次性事务),但用户
2249
+ // 看不见 marker,所以报错必须点名挡路的是那次「停在允许安装依赖」的
2250
+ // 安装,并给出让它让开的办法。这里 marker 仍带 paused。
2251
+ {
2252
+ const during = scriptedSpawn([{ code: 0, out: "Done\n" }]);
2253
+ const duringOutcome = await runInstall({
2254
+ profile: "p",
2255
+ spec: "other-during-pause",
2256
+ preflight: preflightStub("other-during-pause"),
2257
+ _profileDir: profileDir,
2258
+ _spawn: during.spawnFn,
2259
+ _describe: async () => [],
2260
+ }).done;
2261
+ check(
2262
+ // staleOnRestart:这条报错是现在时写的,而挡路的事务必然被启动恢复
2263
+ // 了结——留到重启之后会被当成当前状态读,所以面板要撤掉它。
2264
+ "暂停期间装别的包 → 拒绝,报错点名「停在允许安装依赖」+ 撤回办法 + 标记重启后失效",
2265
+ duringOutcome.status === "failed"
2266
+ && /停在「允许安装依赖」/.test(duringOutcome.detail ?? "")
2267
+ && /重启 dsh/.test(duringOutcome.detail ?? "")
2268
+ && duringOutcome.staleOnRestart === true
2269
+ && during.calls.length === 0,
2270
+ `status=${duringOutcome.status} detail=${(duringOutcome.detail ?? "").slice(0, 120)}`,
2271
+ );
2272
+ }
2178
2273
 
2179
2274
  // 1a-bis. 同 spec 重试接管暂停的 marker:不再新建快照(回滚目标仍是
2180
- // 第一次安装前的现场),继续 spawn pnpm;异 spec 则拒绝且不 spawn。
2275
+ // 第一次安装前的现场),继续 spawn pnpm;这次给批准后的成功路径——
2276
+ // completed 后 marker 保留给启动提交,且暂停标记必须已被接管清掉
2277
+ // (否则启动提交会被它拦下错误回滚)。异 spec 则拒绝且不 spawn。
2181
2278
  {
2182
2279
  const snapshotRootDir = join(dirname(dirname(profileDir)), "guard", "snapshots");
2183
2280
  const snapshotsBefore = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
2184
- const retry = scriptedSpawn([{ code: 0, out: "Ignored build scripts: node-pty@1.0.0\nDone\n" }]);
2281
+ const retry = scriptedSpawn([{ code: 0, out: "Done in 1s\n" }]);
2185
2282
  const retryProducer = runInstall({
2186
2283
  profile: "p",
2187
2284
  spec: "some-plugin",
@@ -2194,12 +2291,18 @@ async function runTransactionFixtures() {
2194
2291
  const retryOutput = retryProducer.readOutput();
2195
2292
  const snapshotsAfter = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
2196
2293
  check(
2197
- "同 spec 重试接管暂停的 marker → 复用原快照,继续安装而非拒绝",
2198
- retryOutcome.status === "failed"
2199
- && Array.isArray(retryOutcome.needsApproval)
2294
+ "同 spec 重试接管暂停的 marker → 复用原快照、继续安装并清掉暂停标记",
2295
+ retryOutcome.status === "completed"
2200
2296
  && retry.calls.length === 1
2201
2297
  && /resuming the paused install transaction/.test(retryOutput)
2202
- && snapshotsAfter === snapshotsBefore,
2298
+ && snapshotsAfter === snapshotsBefore
2299
+ && existsSync(markerBefore)
2300
+ && (() => {
2301
+ try {
2302
+ const marker = JSON.parse(readFileSync(markerBefore, "utf8"));
2303
+ return marker?.metadata?.paused === undefined;
2304
+ } catch { return false; }
2305
+ })(),
2203
2306
  `status=${retryOutcome.status} calls=${retry.calls.length} snapshots=${snapshotsBefore}->${snapshotsAfter}`,
2204
2307
  );
2205
2308
  const other = scriptedSpawn([{ code: 0, out: "Done\n" }]);
@@ -2212,9 +2315,13 @@ async function runTransactionFixtures() {
2212
2315
  _describe: async () => [],
2213
2316
  }).done;
2214
2317
  check(
2215
- "异 spec 遇暂停 marker → 拒绝且不 spawn",
2318
+ // 此刻 paused 已被上面的接管清掉,所以报错走的是「未了结」那一支,
2319
+ // 而不是「停在允许安装依赖」那一支。
2320
+ "异 spec 遇既有(已非暂停)marker → 拒绝且不 spawn",
2216
2321
  otherOutcome.status === "failed"
2217
- && /pending install transaction/.test(otherOutcome.detail ?? "")
2322
+ && /还没了结/.test(otherOutcome.detail ?? "")
2323
+ && !/停在「允许安装依赖」/.test(otherOutcome.detail ?? "")
2324
+ && otherOutcome.staleOnRestart === true
2218
2325
  && other.calls.length === 0,
2219
2326
  `status=${otherOutcome.status} calls=${other.calls.length}`,
2220
2327
  );
@@ -2551,6 +2658,8 @@ async function runTransactionFixtures() {
2551
2658
  && installOutcome.status === "failed"
2552
2659
  && removeOutcome.status === "failed"
2553
2660
  && /not a dependency/.test(removeOutcome.detail ?? "")
2661
+ // 普通失败不标记:重启治不好「这个包本来就不在依赖里」。
2662
+ && removeOutcome.staleOnRestart !== true
2554
2663
  && procs.length === 1,
2555
2664
  `procs=${procs.length} install=${installOutcome.status} remove=${removeOutcome.status} ${JSON.stringify(removeOutcome.detail)}`,
2556
2665
  );
@@ -2579,9 +2688,12 @@ async function runTransactionFixtures() {
2579
2688
  const snapshotsDir = join(dirname(marker), "snapshots");
2580
2689
  const leftoverSnapshots = existsSync(snapshotsDir) ? readdirSync(snapshotsDir) : [];
2581
2690
  check(
2582
- "损坏/既有 pending marker install 不 spawn、不覆盖证据、不遗留新 snapshot",
2691
+ // 不标 staleOnRestart:损坏的 marker 重启后仍然 fail-closed 留给人工
2692
+ // 检查,不会被启动恢复了结——这条失败的原因活得过重启,面板要留着。
2693
+ "损坏/既有 pending marker → install 不 spawn、不覆盖证据、不遗留新 snapshot,且不标记重启后失效",
2583
2694
  outcome.status === "failed"
2584
- && /unreadable pending marker/.test(outcome.detail ?? "")
2695
+ && /读不出来的安装记录/.test(outcome.detail ?? "")
2696
+ && outcome.staleOnRestart !== true
2585
2697
  && calls.length === 0
2586
2698
  && readFileSync(marker, "utf8") === corruptBytes
2587
2699
  && leftoverSnapshots.length === 0,
@@ -2604,7 +2716,8 @@ async function runTransactionFixtures() {
2604
2716
  check(
2605
2717
  "pending marker 存在 → remove 拒绝执行且不 spawn pnpm,marker 保留",
2606
2718
  outcome.status === "failed"
2607
- && /pending install transaction/.test(outcome.detail ?? "")
2719
+ && /还没了结的安装事务/.test(outcome.detail ?? "")
2720
+ && outcome.staleOnRestart === true
2608
2721
  && procs.length === 0
2609
2722
  && existsSync(pendingMarkerPath(profileDir)),
2610
2723
  `status=${outcome.status} procs=${procs.length} ${JSON.stringify(outcome.detail)}`,