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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -133,10 +133,29 @@ window.__ModuleLoader__.load({
133
133
  // onPreflightSettled:预检 job(kind=dsh-plugin-preflight)落定时回调,
134
134
  // 携带 (spec, report)。safe 由调用方直接续装;有风险由调用方出内联卡片。
135
135
  function useJobPolling(call, onSettled, onApprovalToken, onPreflightSettled) {
136
- var jobsRef = useRef({});
137
- var _jobs = useState({});
136
+ // 重挂载垫底:安装完成的收尾会写 cordis.patch.yml,dsh 随之重放装配树、
137
+ // 整个市场 UI 重挂载,React state 归零——面板先空一拍再被异步的
138
+ // call("jobs") 恢复,用户看到「任务清掉又回来」的割裂(实测反馈)。
139
+ // sessionStorage 镜像让重挂载的第一帧直接渲染上一帧的面板,随后的
140
+ // RPC 恢复用后端权威数据覆盖。tab 级存储,随 tab 关闭而清,
141
+ // 与内存里的任务数据同生命周期。
142
+ var JOBS_MIRROR_KEY = "@1e0zj/dsh-plugin-mall:jobs";
143
+ var readJobsMirror = function () {
144
+ try {
145
+ var parsed = JSON.parse(window.sessionStorage.getItem(JOBS_MIRROR_KEY) || "null");
146
+ return parsed && typeof parsed === "object" ? parsed : {};
147
+ } catch (e) { return {}; }
148
+ };
149
+ var jobsRef = useRef(null);
150
+ if (jobsRef.current === null) jobsRef.current = readJobsMirror();
151
+ var _jobs = useState(Object.assign({}, jobsRef.current));
138
152
  var jobs = _jobs[0];
139
153
  var setJobs = _jobs[1];
154
+ var commit = useCallback(function (next) {
155
+ jobsRef.current = next;
156
+ setJobs(Object.assign({}, next));
157
+ try { window.sessionStorage.setItem(JOBS_MIRROR_KEY, JSON.stringify(next)); } catch (e) { /* 存储被禁/写满不致命 */ }
158
+ }, []);
140
159
  useEffect(function () {
141
160
  var timer = setInterval(function () {
142
161
  var current = jobsRef.current;
@@ -165,15 +184,15 @@ window.__ModuleLoader__.load({
165
184
  }
166
185
  }
167
186
  var output = (old.output || "") + (value.output || "");
168
- jobsRef.current = Object.assign({}, jobsRef.current, { [id]: Object.assign({}, old, {
187
+ commit(Object.assign({}, jobsRef.current, { [id]: Object.assign({}, old, {
169
188
  status: snapshot.status,
170
189
  detail: snapshot.detail,
171
190
  needsApproval: snapshot.needsApproval,
172
191
  approvalToken: snapshot.approvalToken,
173
192
  kind: snapshot.kind,
174
193
  output: output,
175
- }) });
176
- setJobs(Object.assign({}, jobsRef.current));
194
+ finishedAt: snapshot.finishedAt,
195
+ }) }));
177
196
  }).catch(function () { /* keep polling */ });
178
197
  });
179
198
  }, 1200);
@@ -182,6 +201,9 @@ window.__ModuleLoader__.load({
182
201
  // carryFromId:把上一阶段(预检)的日志接过来并撤掉它的条目。一次点击
183
202
  // 只应该在面板里留下一个任务,日志连续——而不是 market-1 预检、
184
203
  // market-2 安装两条并排,让人以为自己点了两次。
204
+ // 重试同理:同一 spec 上一轮失败/完成的终态条目一并撤掉——用户没点
205
+ // 「清空」就重试时,面板照样只留新一轮一条。旧失败日志不拼进新任务
206
+ // (两轮 pnpm 输出混在一起没法读);running 的不动,那是真并发任务。
185
207
  var track = useCallback(function (id, spec, carryFromId) {
186
208
  var next = Object.assign({}, jobsRef.current);
187
209
  var carried = "";
@@ -189,21 +211,72 @@ window.__ModuleLoader__.load({
189
211
  carried = next[carryFromId].output || "";
190
212
  delete next[carryFromId];
191
213
  }
214
+ for (var key in next) {
215
+ if (key !== id && next[key] && next[key].spec === spec && next[key].status !== "running") {
216
+ delete next[key];
217
+ }
218
+ }
192
219
  next[id] = { status: "running", spec: spec, output: carried };
193
- jobsRef.current = next;
194
- setJobs(Object.assign({}, next));
195
- }, []);
220
+ commit(next);
221
+ }, [commit]);
196
222
  var clear = useCallback(function () {
197
- jobsRef.current = {};
198
- setJobs({});
199
- }, []);
223
+ commit({});
224
+ }, [commit]);
200
225
  var drop = useCallback(function (id) {
201
226
  var next = Object.assign({}, jobsRef.current);
202
227
  delete next[id];
203
- jobsRef.current = next;
204
- setJobs(next);
205
- }, []);
206
- return { jobs: jobs, track: track, clear: clear, drop: drop };
228
+ commit(next);
229
+ }, [commit]);
230
+ // 恢复后端任务记录(tracker.list 的形状):安装事务改写
231
+ // cordis.patch.yml 会让 dsh 重放装配树、整个市场 UI 重挂载,React
232
+ // state 全丢——任务面板、完成提醒、重启按钮一起消失(真实事故:
233
+ // 更新其实成功了,用户靠手动重启+查版本才确认)。记录在后端活着,
234
+ // 挂载时拉回来。两个细节:恢复出的**已落定**预检任务标
235
+ // preflightHandled,否则轮询的第一拍会重放 onPreflightSettled——
236
+ // 那等于页面一刷新就自动续装一次;running 的不标,交回轮询线。
237
+ var restore = useCallback(function (entries) {
238
+ var next = Object.assign({}, jobsRef.current);
239
+ var serverIds = {};
240
+ for (var index = 0; index < (entries || []).length; index++) {
241
+ var entry = entries[index] || {};
242
+ var snap = entry.snapshot || {};
243
+ if (!entry.id) continue;
244
+ serverIds[entry.id] = true;
245
+ // 服务器记录是权威(覆盖垫底镜像);本地独有的 id 保留——后端
246
+ // 修剪掉的旧条目不至于从面板上闪没。
247
+ next[entry.id] = {
248
+ status: snap.status,
249
+ spec: snap.spec,
250
+ detail: snap.detail,
251
+ needsApproval: snap.needsApproval,
252
+ approvalToken: snap.approvalToken,
253
+ kind: snap.kind,
254
+ output: entry.output || "",
255
+ finishedAt: snap.finishedAt,
256
+ preflightHandled: snap.kind === "dsh-plugin-preflight" && snap.status !== "running",
257
+ };
258
+ }
259
+ // 不在本次服务器列表里的条目属于上一次宿主会话(进程重启后
260
+ // tracker 清空)。已兑现的直接翻篇撤掉:completed 的重启已经
261
+ // 发生;needsApproval 暂停的批准卡片已随进程失效(事务由启动
262
+ // 恢复处置),留着只会让人点一个必然失败的按钮。running 的标
263
+ // 中断,别让轮询对着不存在的 id 空转。failed 保留——日志还有
264
+ // 排障价值。这个判据不依赖 finishedAt(旧镜像里没有该字段)。
265
+ for (var key in next) {
266
+ if (serverIds[key]) continue;
267
+ var stale = next[key];
268
+ if (stale.status === "running") {
269
+ next[key] = Object.assign({}, stale, {
270
+ status: "killed",
271
+ detail: "宿主进程已重启,该任务的记录随之丢失",
272
+ });
273
+ } else if (stale.status === "completed" || (Array.isArray(stale.needsApproval) && stale.needsApproval.length > 0)) {
274
+ delete next[key];
275
+ }
276
+ }
277
+ commit(next);
278
+ }, [commit]);
279
+ return { jobs: jobs, track: track, clear: clear, drop: drop, restore: restore };
207
280
  }
208
281
 
209
282
  // ── plugin verification badge ───────────────────────────────────────────
@@ -417,10 +490,12 @@ window.__ModuleLoader__.load({
417
490
  needsApproval: job.needsApproval,
418
491
  busy: props.approving === job.spec,
419
492
  onApprove: function (names) {
420
- if (typeof props.onDrop === "function") {
421
- props.onDrop(id);
422
- }
423
- props.onApprove(job.spec, names, job.approvalToken);
493
+ // 不先 drop:旧条目由重试任务的 track(carryFromId) 原子接管
494
+ // (撤条目 + 日志接续一拍完成)。先删的话,call("install")
495
+ // 要走数秒(重试还会重跑一次隔离预检),面板会空白一段,
496
+ // 「批准后任务消失、开始安装才冒出来」的割裂就是这么来的。
497
+ // 等待期间按钮由 approving 态显示「继续中…」。
498
+ props.onApprove(job.spec, names, job.approvalToken, id);
424
499
  },
425
500
  onDismiss: function () { props.onDismiss(id); },
426
501
  })
@@ -429,11 +504,16 @@ window.__ModuleLoader__.load({
429
504
  // 也不再重复一个绿色「完成」:状态行已经写了「· 完成」。
430
505
  done && job.status === "completed" && job.kind !== "dsh-plugin-preflight"
431
506
  ? h("div", { className: "mkt_jobDone" },
432
- h("button", {
433
- className: "mkt_btn mkt_btnPrimary mkt_btnSm",
434
- disabled: props.restarting === true,
435
- onClick: props.onRestart,
436
- }, props.restarting ? "重启中…" : "重启 dsh 生效"))
507
+ // 完成时间早于本次宿主启动 = 重启已经发生过了(防御分支:
508
+ // 已兑现的条目通常在恢复时就被撤掉了):换成说明文字,
509
+ // 不再催一次没必要的重启。
510
+ props.hostStartedAt && job.finishedAt && job.finishedAt < props.hostStartedAt
511
+ ? h("span", { className: "mkt_meta" }, "重启已生效")
512
+ : h("button", {
513
+ className: "mkt_btn mkt_btnPrimary mkt_btnSm",
514
+ disabled: props.restarting === true,
515
+ onClick: props.onRestart,
516
+ }, props.restarting ? "重启中…" : "重启 dsh 生效"))
437
517
  : job.status === "failed" && !(job.needsApproval && job.needsApproval.length > 0)
438
518
  ? h("div", { className: "mkt_error" }, "失败,见下方输出")
439
519
  : null,
@@ -559,6 +639,11 @@ window.__ModuleLoader__.load({
559
639
  var _restarting = useState(false);
560
640
  var restarting = _restarting[0];
561
641
  var setRestarting = _restarting[1];
642
+ // 本次宿主进程的启动时间(jobs 端点带回):完成时间早于它的任务,
643
+ // 其「重启 dsh 生效」按钮已经兑现,改显示「重启已生效」。
644
+ var _hostStartedAt = useState(0);
645
+ var hostStartedAt = _hostStartedAt[0];
646
+ var setHostStartedAt = _hostStartedAt[1];
562
647
  var _preflight = useState(null);
563
648
  var preflight = _preflight[0];
564
649
  var setPreflight = _preflight[1];
@@ -716,6 +801,14 @@ window.__ModuleLoader__.load({
716
801
 
717
802
  useEffect(function () {
718
803
  doSearch();
804
+ // 挂载即恢复任务面板(见 useJobPolling.restore 的注释):重挂载丢掉
805
+ // 的完成提醒、重启按钮、暂停中的批准卡片都从后端拉回来。
806
+ // hostStartedAt:本次宿主进程的启动时间——早于它的完成任务说明
807
+ // 重启已经发生,按钮要换成「重启已生效」而不是再催一次。
808
+ call("jobs", {}).then(function (value) {
809
+ if (value.hostStartedAt) setHostStartedAt(value.hostStartedAt);
810
+ polling.restore(value.jobs);
811
+ }).catch(function () { /* 恢复失败不阻塞面板 */ });
719
812
  // eslint-disable-next-line react-hooks/exhaustive-deps
720
813
  }, []);
721
814
 
@@ -740,14 +833,14 @@ window.__ModuleLoader__.load({
740
833
  });
741
834
  }, [call, track]);
742
835
 
743
- var doApprove = useCallback(function (spec, names, token) {
836
+ var doApprove = useCallback(function (spec, names, token, carryFromId) {
744
837
  var extra = { allowBuildScripts: names };
745
838
  var apprToken = token || approvalTokensRef.current[spec];
746
839
  if (apprToken) {
747
840
  extra.approvalToken = apprToken;
748
841
  delete approvalTokensRef.current[spec];
749
842
  }
750
- doRawInstall(spec, extra);
843
+ doRawInstall(spec, extra, carryFromId);
751
844
  }, [doRawInstall]);
752
845
 
753
846
  // 预检落定后的去向:safe 直接续装,其余出内联风险卡片。
@@ -904,6 +997,7 @@ window.__ModuleLoader__.load({
904
997
  onDrop: dropJob,
905
998
  onRestart: doRestart,
906
999
  restarting: restarting,
1000
+ hostStartedAt: hostStartedAt,
907
1001
  approving: Object.keys(installing).filter(function (s) { return installing[s]; })[0],
908
1002
  }),
909
1003
  preflight ? h(PreflightCard, {
package/src/guard.js CHANGED
@@ -583,9 +583,9 @@ export function inspectRemoteCandidate({ profileDir, manifest, patchText, spec }
583
583
  }
584
584
 
585
585
  /** Locate a binary on PATH by explicit extension (no shell, no PATHEXT guessing). */
586
- function findOnPath(binary, extensions) {
587
- const separator = process.platform === "win32" ? ";" : ":";
588
- for (const dir of String(process.env.PATH ?? "").split(separator)) {
586
+ function findOnPath(binary, { platform, pathEnv, extensions }) {
587
+ const separator = platform === "win32" ? ";" : ":";
588
+ for (const dir of String(pathEnv ?? "").split(separator)) {
589
589
  if (dir.length === 0) continue;
590
590
  for (const ext of extensions) {
591
591
  const candidate = join(dir, `${binary}${ext}`);
@@ -596,16 +596,32 @@ function findOnPath(binary, extensions) {
596
596
  }
597
597
 
598
598
  /**
599
- * pnpm spawn plan, mirroring installer.js pnpmSpawnPlan: a real .exe spawns
600
- * without a shell; only the .cmd shim forces a cmd wrapper on Windows
601
- * (shell:true, and Node's DEP0190 warning with it).
599
+ * How to spawn pnpm on this platform. installer.js consumes this too, so the
600
+ * plan lives here exactly once instead of as two drifting copies (the mirror
601
+ * copies had already diverged once; that is how the quoting bug below stayed
602
+ * invisible on both sides).
603
+ * @returns {{ command: string, shell: boolean, treeKill: boolean }}
604
+ * treeKill marks the shell-wrapped case: cancel must taskkill /T the tree.
602
605
  */
603
- function pnpmSpawnPlan() {
604
- if (process.platform !== "win32") return { command: "pnpm", shell: false };
605
- const exe = findOnPath("pnpm", [".exe"]);
606
- if (exe !== undefined) return { command: exe, shell: false };
607
- const cmd = findOnPath("pnpm", [".cmd"]);
608
- return { command: cmd ?? "pnpm", shell: true };
606
+ export function pnpmSpawnPlan({ platform = process.platform, pathEnv = process.env.PATH } = {}) {
607
+ if (platform !== "win32") return { command: "pnpm", shell: false, treeKill: false };
608
+ // A real .exe spawns without a shell — cancel then kills pnpm itself.
609
+ const exe = findOnPath("pnpm", { platform, pathEnv, extensions: [".exe"] });
610
+ if (exe !== undefined) return { command: exe, shell: false, treeKill: false };
611
+ // Only the .cmd shim: Node refuses batch files with shell:false (EINVAL
612
+ // since the batch-file argument-injection fix), so a cmd wrapper is
613
+ // unavoidable — flag it so cancel kills the whole tree, not the wrapper.
614
+ const cmd = findOnPath("pnpm", { platform, pathEnv, extensions: [".cmd"] });
615
+ if (cmd === undefined) return { command: "pnpm", shell: true, treeKill: true };
616
+ // The shim usually sits in a directory with a space (`D:\Program Files\nodejs`
617
+ // is Node's default install layout). Under shell:true Node joins command and
618
+ // args with spaces WITHOUT quoting per argument — the same fact the
619
+ // UNSAFE_SPEC_RE comment below argues from — so an unquoted path is cut at
620
+ // the first space and cmd answers `'D:\Program' is not recognized`, killing
621
+ // every preflight and install on such machines. Quote the command ourselves;
622
+ // the args joined after it are fixed flags plus an assertSafeSpec-validated
623
+ // spec, none of which carry spaces.
624
+ return { command: `"${cmd}"`, shell: true, treeKill: true };
609
625
  }
610
626
 
611
627
  function spawnCapture(command, args, options, onOutput) {
@@ -641,7 +657,9 @@ function spawnCapture(command, args, options, onOutput) {
641
657
  // not only by the agent/browser callers that happen to check first: anything
642
658
  // carrying shell metacharacters, or a Windows `file:`/`link:` path with spaces,
643
659
  // is refused before a single byte of filesystem work and before pnpm is spawned.
644
- const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
660
+ // `%` is on the list like everywhere else: cmd performs `%VAR%` expansion, and
661
+ // the expanded value (often full of spaces and semicolons) reshapes the argv.
662
+ const UNSAFE_SPEC_RE = /[;&|`$()<>^%!"*\n\r]/;
645
663
 
646
664
  function assertSafeSpec(spec) {
647
665
  const value = String(spec ?? "");
@@ -862,7 +880,7 @@ function sanitizeSnapshot(marker, home) {
862
880
  }
863
881
 
864
882
  /** Read and validate a profile's pending marker; undefined when none exists. */
865
- function readValidatedPendingSnapshot(profileDir) {
883
+ export function readValidatedPendingSnapshot(profileDir) {
866
884
  const resolved = resolve(profileDir);
867
885
  const filePath = pendingPath(resolved);
868
886
  if (!existsSync(filePath)) return undefined;
@@ -1024,6 +1042,74 @@ function reconcileInstallArgs() {
1024
1042
  ];
1025
1043
  }
1026
1044
 
1045
+ /**
1046
+ * Args for the per-package fallback reinstall. pnpm 11's headless "up to date"
1047
+ * short-circuit (node_modules/.pnpm/lock.yaml) can skip a `--frozen` install
1048
+ * even while the package is actually missing — `--force` does not bypass it
1049
+ * (verified on a real profile, twice, each time leaving the profile without
1050
+ * the plugin and dsh unable to boot). `pnpm add <spec>` always goes through
1051
+ * full resolution, so it is the reliable way to relink one missing direct
1052
+ * dependency. Still strictly offline.
1053
+ */
1054
+ function fallbackAddArgs(target) {
1055
+ return [
1056
+ "add", target,
1057
+ "--ignore-scripts",
1058
+ "--config.auto-install-peers=false",
1059
+ "--reporter=append-only",
1060
+ "--offline",
1061
+ ];
1062
+ }
1063
+
1064
+ /**
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.
1072
+ */
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}`;
1079
+ try {
1080
+ assertSafeSpec(target);
1081
+ } catch {
1082
+ return undefined;
1083
+ }
1084
+ return target;
1085
+ }
1086
+
1087
+ /**
1088
+ * Run one fallback `pnpm add` synchronously. Same contract as
1089
+ * runReconcileInstall: never throws, failures surface as a nonzero exit.
1090
+ */
1091
+ function runFallbackAdd(profileDir, target) {
1092
+ let result;
1093
+ try {
1094
+ const plan = pnpmSpawnPlan();
1095
+ result = spawnSync(plan.command, fallbackAddArgs(target), {
1096
+ cwd: profileDir,
1097
+ env: pnpmGuardEnv(),
1098
+ shell: plan.shell,
1099
+ encoding: "utf8",
1100
+ timeout: 180000,
1101
+ windowsHide: true,
1102
+ });
1103
+ } catch (error) {
1104
+ return { exitCode: 1, output: "", error };
1105
+ }
1106
+ return {
1107
+ exitCode: typeof result.status === "number" ? result.status : 1,
1108
+ output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
1109
+ error: result.error,
1110
+ };
1111
+ }
1112
+
1027
1113
  /**
1028
1114
  * Run the offline reconcile install synchronously (rollback is a sync
1029
1115
  * recovery path, called from CLI/startup contexts that cannot await). Never
@@ -1206,6 +1292,28 @@ export function rollbackPendingSnapshot(profileDir) {
1206
1292
  }
1207
1293
  }
1208
1294
 
1295
+ if (unsatisfied.length > 0) {
1296
+ // pnpm 11's headless short-circuit (node_modules/.pnpm/lock.yaml) can turn
1297
+ // the reconcile above into a no-op ("Already up to date") while the package
1298
+ // is actually gone — `--force` does not bypass it. Twice on a real profile
1299
+ // that left the plugin missing and dsh unable to boot. Retry the still-
1300
+ // missing direct dependencies one at a time with `pnpm add` (full
1301
+ // resolution, still offline), then put the snapshot bytes back over
1302
+ // whatever pnpm wrote: the add is only the means to relink node_modules,
1303
+ // the snapshot stays authoritative for the declaration files.
1304
+ for (const depName of [...unsatisfied]) {
1305
+ const target = fallbackAddTarget(depName, restoredDependencies[depName]);
1306
+ if (target === undefined) continue; // not offline-addable — fail closed below
1307
+ const addAttempt = runFallbackAdd(profileDir, target);
1308
+ if (addAttempt.exitCode === 0) {
1309
+ restoreProfileSnapshot(pending);
1310
+ if (candidateRestoredCompatible(profileDir, depName, restoredDependencies[depName])) {
1311
+ unsatisfied.splice(unsatisfied.indexOf(depName), 1);
1312
+ }
1313
+ }
1314
+ }
1315
+ }
1316
+
1209
1317
  if (unsatisfied.length > 0) {
1210
1318
  const why =
1211
1319
  wasUpdate && pending.files?.["pnpm-lock.yaml"]?.present !== true
@@ -1217,9 +1325,10 @@ export function rollbackPendingSnapshot(profileDir) {
1217
1325
  : "node_modules missing direct dependency";
1218
1326
  const tail = String(attempt?.output ?? "").replace(/\s+/g, " ").trim().slice(-400);
1219
1327
  // KEEP the marker + snapshot: the profile files are already restored,
1220
- // so a retry (`guard recover`) or a manual pnpm install finishes it.
1328
+ // so a retry (`guard recover`), the per-package `pnpm add` above, or a
1329
+ // manual pnpm install finishes it.
1221
1330
  throw new Error(
1222
- `rollback restored the profile files and removed the failed install, but direct dependencies in node_modules are missing or incompatible (${unsatisfied.join(", ")}; ${why}) — the pending marker and snapshot were KEPT; re-run \`guard recover\` or run \`pnpm install --ignore-scripts --frozen-lockfile\` in ${profileDir} manually${tail ? `. pnpm output: ${tail}` : ""}`
1331
+ `rollback restored the profile files and removed the failed install, but direct dependencies in node_modules are missing or incompatible even after the offline reconcile and per-package add fallback (${unsatisfied.join(", ")}; ${why}) — the pending marker and snapshot were KEPT; re-run \`guard recover\` or run \`pnpm install --ignore-scripts --frozen-lockfile\` in ${profileDir} manually${tail ? `. pnpm output: ${tail}` : ""}`
1223
1332
  );
1224
1333
  }
1225
1334
 
@@ -1888,6 +1997,12 @@ async function selfTest() {
1888
1997
  if (rep.verdict !== "blocked" || !rep.issues.some((entry) => entry.code === "unsafe-spec")) {
1889
1998
  throw new Error("preflightInstall should reject a spec with shell metacharacters before spawning");
1890
1999
  }
2000
+ // `%` 单独成案:它不是 POSIX 元字符,但 cmd 会做 %VAR% 展开,展开值
2001
+ // 里的分号/空格足以重塑 argv——黑名单曾漏掉它,与 cli.js 漂移过。
2002
+ const repPct = await preflightInstall({ profileDir: p, spec: "evil-pkg%PATH%" });
2003
+ if (repPct.verdict !== "blocked" || !repPct.issues.some((entry) => entry.code === "unsafe-spec")) {
2004
+ throw new Error("preflightInstall should reject a spec carrying cmd %VAR% expansion");
2005
+ }
1891
2006
  if (process.platform === "win32") {
1892
2007
  const repWin = await preflightInstall({ profileDir: p, spec: "file:C:\\some dir\\pkg" });
1893
2008
  if (repWin.verdict !== "blocked" || !repWin.issues.some((entry) => entry.code === "unsafe-spec")) {
@@ -1906,6 +2021,36 @@ async function selfTest() {
1906
2021
  if (!args.includes("--ignore-scripts")) throw new Error("probe args must keep install scripts disabled");
1907
2022
  }
1908
2023
 
2024
+ // pnpmSpawnPlan (pure, plus a real spawn on Windows): the .cmd shim path
2025
+ // must carry its own quotes. Node's shell:true joins command and args
2026
+ // without per-argument quoting, so `D:\Program Files\nodejs\pnpm.CMD`
2027
+ // would be cut at the first space and cmd would answer
2028
+ // `'D:\Program' is not recognized` — that exact failure blocked every
2029
+ // preflight on a real machine (Node's default install layout has a space).
2030
+ {
2031
+ const shimRoot = join(root, "path with space");
2032
+ mkdirSync(shimRoot, { recursive: true });
2033
+ writeFileSync(join(shimRoot, "pnpm.cmd"), "@echo probe-ok\r\n");
2034
+ const plan = pnpmSpawnPlan({ platform: "win32", pathEnv: shimRoot });
2035
+ if (plan.shell !== true || plan.treeKill !== true || plan.command !== `"${join(shimRoot, "pnpm.cmd")}"`) {
2036
+ throw new Error(`quoted .cmd plan expected, got ${JSON.stringify(plan)}`);
2037
+ }
2038
+ if (pnpmSpawnPlan({ platform: "linux", pathEnv: shimRoot }).command !== "pnpm") {
2039
+ throw new Error("posix plan should spawn pnpm directly");
2040
+ }
2041
+ const noShim = pnpmSpawnPlan({ platform: "win32", pathEnv: join(root, "no-shim-here") });
2042
+ if (noShim.command !== "pnpm" || noShim.shell !== true || noShim.treeKill !== true) {
2043
+ throw new Error(`missing-shim fallback expected, got ${JSON.stringify(noShim)}`);
2044
+ }
2045
+ // 引用不是摆设:Windows 真机端到端 spawn 一轮(CI 是 Linux,只跑静态断言)。
2046
+ if (process.platform === "win32") {
2047
+ const probe = spawnSync(plan.command, ["--version"], { shell: plan.shell, encoding: "utf8", timeout: 15000 });
2048
+ if (probe.status !== 0 || !/probe-ok/.test(probe.stdout ?? "")) {
2049
+ throw new Error(`quoted shim must actually run: status=${probe.status} stdout=${JSON.stringify(probe.stdout)} stderr=${JSON.stringify(probe.stderr)}`);
2050
+ }
2051
+ }
2052
+ }
2053
+
1909
2054
  // Rollback reconcile args/env (pure): scripts off, the restored lockfile
1910
2055
  // authoritative, peer auto-install off, strictly offline — there is no
1911
2056
  // online retry path or args at all, and nothing user-controlled anywhere
@@ -2014,6 +2159,91 @@ async function selfTest() {
2014
2159
  if (attempts.length !== 1) throw new Error(`the rollback must run exactly ONE offline reconcile, got ${attempts.length}`);
2015
2160
  }
2016
2161
 
2162
+ // The reconcile no-op trap, met twice on a real profile: pnpm 11's headless
2163
+ // short-circuit answers `install --frozen` with exit 0 / "Already up to
2164
+ // date" while the package is actually missing (--force does not bypass it),
2165
+ // leaving dsh unable to boot. The stub models exactly that: `install` exits
2166
+ // 0 and does NOTHING; only the per-package `add` fallback relinks the old
2167
+ // copy — and it also rewrites package.json the way a real pnpm would
2168
+ // ("^1.0.0"), which the snapshot-byte restore must overwrite. Expected:
2169
+ // reconcile attempt, add attempt, marker + snapshot cleared, node_modules
2170
+ // restored, and the declaration files byte-identical to the snapshot.
2171
+ {
2172
+ const p = join(root, "profiles", "update-reconcile-noop");
2173
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
2174
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
2175
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2176
+ writeFileSync(join(p, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n\nimporters: {}\n");
2177
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
2178
+ const binDir = join(root, "stub-bin-noop-install");
2179
+ mkdirSync(binDir);
2180
+ const attemptsFile = join(binDir, "attempts.txt");
2181
+ const isWin = process.platform === "win32";
2182
+ const stubPath = join(binDir, isWin ? "pnpm.cmd" : "pnpm");
2183
+ writeFileSync(stubPath, isWin
2184
+ ? `@echo off\r\nif "%1"=="add" goto add\r\necho install>> "${attemptsFile}"\r\nexit /b 0\r\n:add\r\nmkdir node_modules\\good 2>nul\r\necho {"name":"good","version":"1.0.0"}> node_modules\\good\\package.json\r\necho {"dependencies":{"good":"^^1.0.0"}}> package.json\r\necho add>> "${attemptsFile}"\r\nexit /b 0\r\n`
2185
+ : `#!/bin/sh\nif [ "$1" = "add" ]; then\n mkdir -p node_modules/good\n printf '%s' '{"name":"good","version":"1.0.0"}' > node_modules/good/package.json\n printf '%s' '{"dependencies":{"good":"^1.0.0"}}' > package.json\n echo add >> '${attemptsFile}'\n exit 0\nfi\necho install >> '${attemptsFile}'\nexit 0\n`);
2186
+ if (!isWin) chmodSync(stubPath, 0o755);
2187
+ const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
2188
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
2189
+ const previousPath = process.env.PATH;
2190
+ process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
2191
+ try {
2192
+ rollbackPendingSnapshot(p);
2193
+ } finally {
2194
+ if (previousPath === undefined) delete process.env.PATH;
2195
+ else process.env.PATH = previousPath;
2196
+ }
2197
+ if (readPendingSnapshot(p) !== undefined) throw new Error("an add-fallback-rescued rollback must clear the marker");
2198
+ if (existsSync(snap.dir)) throw new Error("an add-fallback-rescued rollback must delete the snapshot dir");
2199
+ if (readJson(join(p, "node_modules", "good", "package.json")).version !== "1.0.0") throw new Error("the add fallback must relink the old version");
2200
+ if (readJson(join(p, "package.json")).dependencies?.good !== "1.0.0") throw new Error("snapshot bytes must win over the add's manifest rewrite");
2201
+ const attempts = readFileSync(attemptsFile, "utf8").split(/\r?\n/).filter((line) => line.trim().length > 0);
2202
+ if (attempts.join(",") !== "install,add") throw new Error(`expected the no-op reconcile then exactly one add fallback, got ${attempts.join(",")}`);
2203
+ }
2204
+
2205
+ // The same no-op reconcile with an add that also fails (exit 1): rollback
2206
+ // must stay fail-closed — throw, KEEP marker + snapshot, declarations stay
2207
+ // at their snapshot bytes.
2208
+ {
2209
+ const p = join(root, "profiles", "update-reconcile-noop-fail");
2210
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
2211
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
2212
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2213
+ writeFileSync(join(p, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n\nimporters: {}\n");
2214
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
2215
+ const binDir = join(root, "stub-bin-add-fails");
2216
+ mkdirSync(binDir);
2217
+ const attemptsFile = join(binDir, "attempts.txt");
2218
+ const isWin = process.platform === "win32";
2219
+ const stubPath = join(binDir, isWin ? "pnpm.cmd" : "pnpm");
2220
+ writeFileSync(stubPath, isWin
2221
+ ? `@echo off\r\necho %1>> "${attemptsFile}"\r\nexit /b 1\r\n`
2222
+ : `#!/bin/sh\necho "$1" >> '${attemptsFile}'\nexit 1\n`);
2223
+ if (!isWin) chmodSync(stubPath, 0o755);
2224
+ const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
2225
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
2226
+ const previousPath = process.env.PATH;
2227
+ process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
2228
+ let threw = false;
2229
+ try {
2230
+ rollbackPendingSnapshot(p);
2231
+ } catch {
2232
+ threw = true;
2233
+ } finally {
2234
+ if (previousPath === undefined) delete process.env.PATH;
2235
+ else process.env.PATH = previousPath;
2236
+ }
2237
+ if (!threw) throw new Error("a rollback whose reconcile no-ops AND add fallback fails must throw");
2238
+ if (readPendingSnapshot(p)?.id !== snap.id) throw new Error("the failed rollback must KEEP the pending marker");
2239
+ if (!existsSync(snap.dir)) throw new Error("the failed rollback must KEEP the snapshot dir");
2240
+ if (readJson(join(p, "package.json")).dependencies?.good !== "1.0.0") throw new Error("the manifest must stay restored when the add fallback fails");
2241
+ const attempts = readFileSync(attemptsFile, "utf8").split(/\r?\n/).filter((line) => line.trim().length > 0);
2242
+ if (attempts.join(",") !== "install,add") throw new Error(`expected exactly one reconcile and one add attempt, got ${attempts.join(",")}`);
2243
+ rmSync(pendingPath(p), { force: true });
2244
+ rmSync(snap.dir, { recursive: true, force: true });
2245
+ }
2246
+
2017
2247
  // The same interrupted update, but the reconcile never relinks the old
2018
2248
  // copy and only an ANCESTOR node_modules still provides a satisfying
2019
2249
  // version. Falling back to Node resolution would accept that copy — the
package/src/index.js CHANGED
@@ -144,6 +144,13 @@ export function computeProfileFingerprint(profileDir) {
144
144
  const compatCache = new Map();
145
145
  const COMPAT_TTL = 600000;
146
146
 
147
+ // When this plugin instance loaded — for a host process this is effectively the
148
+ // host's start time (the loader mounts plugins at boot). Sent with `jobs` so a
149
+ // remounted client can tell "completed, restart still pending" from "completed
150
+ // and the restart already happened": a finishedAt older than this value means
151
+ // the task's Restart-dsh button has already done its job.
152
+ const pluginLoadedAt = Date.now();
153
+
147
154
  function compatCacheGet(key) {
148
155
  const cached = compatCache.get(key);
149
156
  if (cached === undefined) return undefined;
@@ -918,6 +925,13 @@ export function createJobTracker({ producerFactory } = {}) {
918
925
  const record = records.get(String(jobId));
919
926
  if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
920
927
  const isSameSession = record.surface !== "browser" || (record.session !== "" && record.session === session);
928
+ const delta = typeof record.readOutput === "function"
929
+ ? record.readOutput()
930
+ : typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "";
931
+ // Accumulate the drained deltas so `list` can restore the full log after a
932
+ // remount: the polling client drains destructively, so without this the
933
+ // backend would hold no history at all.
934
+ record.log = String(record.log ?? "") + delta;
921
935
  return {
922
936
  snapshot: {
923
937
  id: record.id,
@@ -933,12 +947,48 @@ export function createJobTracker({ producerFactory } = {}) {
933
947
  startedAt: record.startedAt,
934
948
  finishedAt: record.finishedAt,
935
949
  },
936
- output: typeof record.readOutput === "function"
937
- ? record.readOutput()
938
- : typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "",
950
+ output: delta,
939
951
  };
940
952
  },
941
953
 
954
+ /**
955
+ * Every live record as {id, snapshot, output}, oldest first — for a freshly
956
+ * mounted client to restore its task panel. The install of a plugin whose
957
+ * bundle patch rewrites cordis.patch.yml replays the assembly tree and
958
+ * remounts this very UI mid-flight, dropping every React state; the backend
959
+ * records survive (1h/20-entry prune), so the remounted panel can show the
960
+ * finished task, its log, and the restart button instead of going blank
961
+ * with no signal at all (real incident: an update finished, the panel
962
+ * vanished, and the user learned it worked only by checking versions after
963
+ * a manual restart). Session visibility mirrors get(); dismissed records
964
+ * are skipped — "清空" must survive a remount too.
965
+ */
966
+ list(session) {
967
+ const out = [];
968
+ for (const record of records.values()) {
969
+ if (record.dismissed === true) continue;
970
+ const isSameSession = record.surface !== "browser" || (record.session !== "" && record.session === session);
971
+ out.push({
972
+ id: record.id,
973
+ snapshot: {
974
+ id: record.id,
975
+ kind: record.kind,
976
+ label: record.label,
977
+ status: record.status,
978
+ detail: record.detail,
979
+ needsApproval: record.needsApproval,
980
+ approvalToken: isSameSession ? record.approvalToken : undefined,
981
+ extras: isSameSession ? record.extras : undefined,
982
+ spec: record.spec,
983
+ startedAt: record.startedAt,
984
+ finishedAt: record.finishedAt,
985
+ },
986
+ output: String(record.log ?? ""),
987
+ });
988
+ }
989
+ return out;
990
+ },
991
+
942
992
  cancel(jobId, session) {
943
993
  const record = records.get(String(jobId));
944
994
  if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
@@ -969,6 +1019,9 @@ export function createJobTracker({ producerFactory } = {}) {
969
1019
  if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
970
1020
  return false;
971
1021
  }
1022
+ // Marked, not deleted: `list` (panel restore after a remount) skips these,
1023
+ // so a cleared panel stays cleared across remounts.
1024
+ record.dismissed = true;
972
1025
  if (record.approvalToken) {
973
1026
  invalidateApprovalToken(record.approvalToken, record.session || undefined, record.surface);
974
1027
  record.approvalToken = undefined;
@@ -1482,6 +1535,17 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1482
1535
  return rpcFail(error);
1483
1536
  }
1484
1537
  }
1538
+ case "jobs": {
1539
+ // Panel restore for a freshly mounted client: an install that rewrites
1540
+ // cordis.patch.yml replays the assembly tree and remounts this UI,
1541
+ // dropping all React state. The task records live here.
1542
+ try {
1543
+ const session = requireBrowserSession(payload?.session);
1544
+ return rpcOk({ jobs: tracker.list(session), hostStartedAt: pluginLoadedAt });
1545
+ } catch (error) {
1546
+ return rpcFail(error);
1547
+ }
1548
+ }
1485
1549
  case "restart": {
1486
1550
  const profile = String(payload?.profile ?? defaultProfile).trim();
1487
1551
  try {
@@ -2291,6 +2355,52 @@ export async function runSelfTests() {
2291
2355
  const snapAfterDismiss = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
2292
2356
  check("dismiss 后 job snapshot 中 approvalToken 为 undefined", snapAfterDismiss.approvalToken === undefined);
2293
2357
 
2358
+ // ── 5b. list(重挂载恢复):日志累积、dismissed 过滤、session 隔离 ────────
2359
+ // 安装事务改写 cordis.patch.yml 会让 dsh 重放装配树、市场 UI 整体重挂载,
2360
+ // 前端 state 全丢。任务记录在后端活着——list 就是恢复通道:drain 过的
2361
+ // 日志增量必须已在后端累积成全量,「清空」过的条目不得被拉回。
2362
+ {
2363
+ const pendingLines = ["line-a\n", "line-b\n"];
2364
+ const lineProducer = {
2365
+ cancel: () => {},
2366
+ done: Promise.resolve({ status: "completed", detail: "done" }),
2367
+ readOutput: () => pendingLines.shift() ?? "",
2368
+ };
2369
+ const listTracker = createJobTracker({ producerFactory: () => lineProducer });
2370
+ const listJobId = listTracker.start({
2371
+ profile: "web",
2372
+ spec: "log-pkg",
2373
+ profileDir,
2374
+ surface: "browser",
2375
+ session: "session-alpha",
2376
+ });
2377
+ await new Promise((resolvePromise) => setImmediate(resolvePromise));
2378
+ const drain1 = listTracker.get(listJobId, "session-alpha").output;
2379
+ const drain2 = listTracker.get(listJobId, "session-alpha").output;
2380
+ const restoredJob = listTracker.list("session-alpha").find((entry) => entry.id === listJobId);
2381
+ check(
2382
+ "list 恢复全量日志(drain 过的增量在后端累积成完整历史)",
2383
+ drain1 === "line-a\n" && drain2 === "line-b\n"
2384
+ && restoredJob !== undefined
2385
+ && restoredJob.output === "line-a\nline-b\n"
2386
+ && restoredJob.snapshot.status === "completed"
2387
+ && restoredJob.snapshot.spec === "log-pkg",
2388
+ JSON.stringify({ drain1, drain2, restored: restoredJob?.output }),
2389
+ );
2390
+ const crossSessionJob = listTracker.list("session-beta").find((entry) => entry.id === listJobId);
2391
+ check(
2392
+ "list 对异 session 不暴露 approvalToken/extras(与 get 同规格)",
2393
+ crossSessionJob !== undefined
2394
+ && crossSessionJob.snapshot.approvalToken === undefined
2395
+ && crossSessionJob.snapshot.extras === undefined,
2396
+ );
2397
+ listTracker.dismiss(listJobId, "session-alpha");
2398
+ check(
2399
+ "dismiss 后 list 不再返回该条(「清空」跨重挂载存活)",
2400
+ listTracker.list("session-alpha").every((entry) => entry.id !== listJobId),
2401
+ );
2402
+ }
2403
+
2294
2404
  // ── 6. Windows profile names & restart plan ──────────────────────────────
2295
2405
  check("合法 profile 名称识别", isSafeProfileName("web", true) && isSafeProfileName("profile_1", true) && isSafeProfileName("dev-test", true));
2296
2406
  check("Windows 尾随点拒绝", !isSafeProfileName("web.", true) && !isSafeProfileName("test..", true));
package/src/installer.js CHANGED
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
16
16
  import { dump, load } from "js-yaml";
17
17
  import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
18
18
  import { describeBuildScripts, npmNameOf } from "./github.js";
19
- import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, rollbackPendingSnapshot } from "./guard.js";
19
+ import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
20
20
 
21
21
  // ── spec normalization ──────────────────────────────────────────────────────
22
22
 
@@ -1132,8 +1132,10 @@ export function createJobTracker() {
1132
1132
 
1133
1133
  // Windows spawn 走 shell,spec 会被拼进 cmd 行;agent 传入的参数不可信。
1134
1134
  // 合法的 npm 名 / github:owner\/repo / git·file·link·URL spec 都不含这些
1135
- // shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。
1136
- const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
1135
+ // shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。`%` 在列:cmd 会
1136
+ // `%VAR%` 环境变量展开,展开结果常含分号/空格,足以改变参数切分。
1137
+ // (cli.js 的同款黑名单一直含 %,此前三处已经漂移。)
1138
+ const UNSAFE_SPEC_RE = /[;&|`$()<>^%!"*\n\r]/;
1137
1139
 
1138
1140
  /** Reject install/remove specs carrying shell metacharacters. */
1139
1141
  export function assertSafeSpec(spec) {
@@ -1258,36 +1260,11 @@ function serializedProducer(lockKey, start) {
1258
1260
  // (a .cmd shim cannot be spawned with shell:false on modern Node), cancel
1259
1261
  // terminates the whole process tree and the done chain waits for the
1260
1262
  // wrapper's 'close' before any rollback runs.
1261
-
1262
- /** First `binary<ext>` found on PATH, or undefined. */
1263
- function findOnPath(binary, { platform, pathEnv, extensions }) {
1264
- const separator = platform === "win32" ? ";" : ":";
1265
- for (const dir of String(pathEnv ?? "").split(separator)) {
1266
- if (dir.length === 0) continue;
1267
- for (const ext of extensions) {
1268
- const candidate = join(dir, `${binary}${ext}`);
1269
- if (existsSync(candidate)) return candidate;
1270
- }
1271
- }
1272
- return undefined;
1273
- }
1274
-
1275
- /**
1276
- * How to spawn pnpm on this platform.
1277
- * @returns {{ command: string, shell: boolean, treeKill: boolean }}
1278
- * treeKill marks the shell-wrapped case: cancel must taskkill /T the tree.
1279
- */
1280
- function pnpmSpawnPlan({ platform = process.platform, pathEnv = process.env.PATH } = {}) {
1281
- if (platform !== "win32") return { command: "pnpm", shell: false, treeKill: false };
1282
- // A real .exe spawns without a shell — cancel then kills pnpm itself.
1283
- const exe = findOnPath("pnpm", { platform, pathEnv, extensions: [".exe"] });
1284
- if (exe !== undefined) return { command: exe, shell: false, treeKill: false };
1285
- // Only the .cmd shim: Node refuses batch files with shell:false (EINVAL
1286
- // since the batch-file argument-injection fix), so a cmd wrapper is
1287
- // unavoidable — flag it so cancel kills the whole tree, not the wrapper.
1288
- const cmd = findOnPath("pnpm", { platform, pathEnv, extensions: [".cmd"] });
1289
- return { command: cmd ?? "pnpm", shell: true, treeKill: true };
1290
- }
1263
+ //
1264
+ // The plan itself is pnpmSpawnPlan in guard.js (imported above) — one
1265
+ // implementation, not a local mirror. It quotes the .cmd path: Node's
1266
+ // shell:true joins without per-argument quoting, and `D:\Program Files\…`
1267
+ // would be cut at the first space (`'D:\Program' is not recognized`).
1291
1268
 
1292
1269
  /**
1293
1270
  * Terminate a shell-wrapped process tree (Windows): taskkill /T /F kills the
@@ -1467,17 +1444,38 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1467
1444
  // first live pnpm add runs. These are the files that decide what pnpm
1468
1445
  // installs and what dsh loads; the marker is what lets startup/CLI recovery
1469
1446
  // roll the profile back if the plugin proves unloadable.
1470
- let snapshot;
1447
+ //
1448
+ // An existing marker means one of two things. A needsApproval pause from a
1449
+ // previous attempt of THIS SAME install resumes: its snapshot keeps the
1450
+ // rollback target at "before this install first began", and re-snapshotting
1451
+ // now would capture the paused half-installed state as the rollback target.
1452
+ // Anything else (different spec, remove transaction, corrupt marker) is
1453
+ // refused — the recovery path owns it.
1454
+ let existingMarker;
1471
1455
  try {
1472
- snapshot = createProfileSnapshot(profileDir, { spec });
1456
+ existingMarker = readValidatedPendingSnapshot(profileDir);
1473
1457
  } catch (error) {
1474
- return failedNow(`cannot snapshot profile before installing ${spec}: ${error.message} refusing to touch the profile`);
1458
+ return failedNow(`profile has an unreadable pending marker — refusing to install ${spec} (${error.message}); run \`dsh-plugin-guard guard recover\` first`);
1475
1459
  }
1476
- try {
1477
- markPendingSnapshot(snapshot, { spec, preflight });
1478
- } catch (error) {
1479
- rmSync(snapshot.dir, { recursive: true, force: true });
1480
- return failedNow(`cannot register the install pending marker for ${spec}: ${error.message} — refusing to touch the profile`);
1460
+ if (existingMarker !== undefined) {
1461
+ const previous = existingMarker.metadata?.spec ?? existingMarker.metadata?.packageName ?? "unknown";
1462
+ 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`);
1464
+ }
1465
+ push(`[dsh-plugin-mall] resuming the paused install transaction for ${spec} — its original snapshot stays the rollback target\n`);
1466
+ } else {
1467
+ let snapshot;
1468
+ try {
1469
+ snapshot = createProfileSnapshot(profileDir, { spec });
1470
+ } catch (error) {
1471
+ return failedNow(`cannot snapshot profile before installing ${spec}: ${error.message} — refusing to touch the profile`);
1472
+ }
1473
+ try {
1474
+ markPendingSnapshot(snapshot, { spec, preflight });
1475
+ } catch (error) {
1476
+ rmSync(snapshot.dir, { recursive: true, force: true });
1477
+ return failedNow(`cannot register the install pending marker for ${spec}: ${error.message} — refusing to touch the profile`);
1478
+ }
1481
1479
  }
1482
1480
 
1483
1481
  // Neutralize existing allowBuilds before every first pnpm add so strict-dep-builds
@@ -1710,6 +1708,19 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1710
1708
  }
1711
1709
  if (result.status === "completed") {
1712
1710
  // Keep the marker as-is
1711
+ } else if (Array.isArray(result.needsApproval) && result.needsApproval.length > 0) {
1712
+ // needsApproval is a PAUSE awaiting the user's decision, not a terminal
1713
+ // failure — do not roll back. Rolling back would tear out the candidate
1714
+ // pnpm just installed and rewrite the manifest to the old version, so
1715
+ // the retry's profile fingerprint and preflight report drift with the
1716
+ // changed on-disk state and the approval token's digest check can never
1717
+ // pass (real incident: dsh-better-sidebar 0.12.3 → 0.13.0 update died
1718
+ // exactly here, "invalid approval token: preflight report changed").
1719
+ // The retry's rebuild branch also needs this tree in place. Leave the
1720
+ // on-disk state and the marker for the token retry; an abandoned pause
1721
+ // is settled by startup recovery / `guard recover`, whose rollback
1722
+ // target is still the pre-first-attempt snapshot.
1723
+ 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");
1713
1724
  } else {
1714
1725
  try {
1715
1726
  rollbackPendingSnapshot(profileDir);
@@ -2051,6 +2062,17 @@ async function runTransactionFixtures() {
2051
2062
  const tick = () => new Promise((resolve) => setTimeout(resolve, 1));
2052
2063
  const flush = async (rounds = 5) => { for (let index = 0; index < rounds; index++) await tick(); };
2053
2064
 
2065
+ // 纯函数前置:spec 黑名单。`%` 必须在内——cmd 的 %VAR% 展开元字符,
2066
+ // 展开值里的分号/空格足以重塑 argv;三处黑名单曾漂移(cli.js 一直有,
2067
+ // installer/guard 漏过)。
2068
+ {
2069
+ const rejects = (value) => {
2070
+ try { assertSafeSpec(value); return false; } catch { return true; }
2071
+ };
2072
+ check("assertSafeSpec:cmd 的 %VAR% 展开元字符被拒绝", rejects("evil-pkg%PATH%"));
2073
+ check("assertSafeSpec:正常 spec 不受影响", !rejects("some-plugin@1.0.0") && !rejects("github:owner/repo"));
2074
+ }
2075
+
2054
2076
  // 0. hashPackageTree 确定性与符号链接越界防御
2055
2077
  {
2056
2078
  const tempDir = mkdtempSync(join(tmpdir(), "dsh-mall-selftest-hash-"));
@@ -2105,7 +2127,9 @@ async function runTransactionFixtures() {
2105
2127
  }
2106
2128
 
2107
2129
  // 1a. exit 0 + "Ignored build scripts" 且未批准:必须停在批准闸(failed +
2108
- // needsApproval),携带 proof,绝不 finalize,回滚收掉 marker,且只 spawn 一次。
2130
+ // needsApproval),携带 proof,绝不 finalize,且是暂停不是失败——现场与
2131
+ // marker 原样保留给带 token 的重试(回滚会让重试的 approval token 必死,
2132
+ // 见收尾处的真实事故注释),只 spawn 一次。
2109
2133
  {
2110
2134
  const { profileDir, cleanup } = makeTempProfile("ignored-gate");
2111
2135
  try {
@@ -2130,8 +2154,10 @@ async function runTransactionFixtures() {
2130
2154
  }],
2131
2155
  });
2132
2156
  const outcome = await producer.done;
2157
+ const output = producer.readOutput();
2158
+ const markerBefore = pendingMarkerPath(profileDir);
2133
2159
  check(
2134
- "退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize",
2160
+ "退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
2135
2161
  outcome.status === "failed"
2136
2162
  && Array.isArray(outcome.needsApproval)
2137
2163
  && outcome.needsApproval.some((entry) => entry.name === "node-pty")
@@ -2145,9 +2171,54 @@ async function runTransactionFixtures() {
2145
2171
  && entry.contentHash !== "0".repeat(64)
2146
2172
  && entry.weeklyDownloads === 123)
2147
2173
  && calls.length === 1
2148
- && !existsSync(pendingMarkerPath(profileDir)),
2149
- `status=${outcome.status} calls=${calls.length} marker=${existsSync(pendingMarkerPath(profileDir))}`,
2174
+ && existsSync(markerBefore)
2175
+ && /paused for build-script approval/.test(output),
2176
+ `status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
2150
2177
  );
2178
+
2179
+ // 1a-bis. 同 spec 重试接管暂停的 marker:不再新建快照(回滚目标仍是
2180
+ // 第一次安装前的现场),继续 spawn pnpm;异 spec 则拒绝且不 spawn。
2181
+ {
2182
+ const snapshotRootDir = join(dirname(dirname(profileDir)), "guard", "snapshots");
2183
+ 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" }]);
2185
+ const retryProducer = runInstall({
2186
+ profile: "p",
2187
+ spec: "some-plugin",
2188
+ preflight: preflightStub("some-plugin"),
2189
+ _profileDir: profileDir,
2190
+ _spawn: retry.spawnFn,
2191
+ _describe: async () => [],
2192
+ });
2193
+ const retryOutcome = await retryProducer.done;
2194
+ const retryOutput = retryProducer.readOutput();
2195
+ const snapshotsAfter = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
2196
+ check(
2197
+ "同 spec 重试接管暂停的 marker → 复用原快照,继续安装而非拒绝",
2198
+ retryOutcome.status === "failed"
2199
+ && Array.isArray(retryOutcome.needsApproval)
2200
+ && retry.calls.length === 1
2201
+ && /resuming the paused install transaction/.test(retryOutput)
2202
+ && snapshotsAfter === snapshotsBefore,
2203
+ `status=${retryOutcome.status} calls=${retry.calls.length} snapshots=${snapshotsBefore}->${snapshotsAfter}`,
2204
+ );
2205
+ const other = scriptedSpawn([{ code: 0, out: "Done\n" }]);
2206
+ const otherOutcome = await runInstall({
2207
+ profile: "p",
2208
+ spec: "another-plugin",
2209
+ preflight: preflightStub("another-plugin"),
2210
+ _profileDir: profileDir,
2211
+ _spawn: other.spawnFn,
2212
+ _describe: async () => [],
2213
+ }).done;
2214
+ check(
2215
+ "异 spec 遇暂停 marker → 拒绝且不 spawn",
2216
+ otherOutcome.status === "failed"
2217
+ && /pending install transaction/.test(otherOutcome.detail ?? "")
2218
+ && other.calls.length === 0,
2219
+ `status=${otherOutcome.status} calls=${other.calls.length}`,
2220
+ );
2221
+ }
2151
2222
  const call = calls[0] ?? { args: [], options: {} };
2152
2223
  check(
2153
2224
  "实装 argv/env:strict-dep-builds + peer 关闭 + cwd/shell 正确",
@@ -2510,7 +2581,7 @@ async function runTransactionFixtures() {
2510
2581
  check(
2511
2582
  "损坏/既有 pending marker → install 不 spawn、不覆盖证据、不遗留新 snapshot",
2512
2583
  outcome.status === "failed"
2513
- && /already has a pending install marker/.test(outcome.detail ?? "")
2584
+ && /unreadable pending marker/.test(outcome.detail ?? "")
2514
2585
  && calls.length === 0
2515
2586
  && readFileSync(marker, "utf8") === corruptBytes
2516
2587
  && leftoverSnapshots.length === 0,
@@ -2567,7 +2638,9 @@ async function runTransactionFixtures() {
2567
2638
  }
2568
2639
 
2569
2640
  // 4a. spawn 计划(纯函数):非 Windows 无 shell;Windows 有 .exe 则
2570
- // shell:false,仅 .cmd 则 shell:true + treeKill,全找不到回退 "pnpm"。
2641
+ // shell:false,仅 .cmd 则 shell:true + treeKill 且 command 自带引号
2642
+ // (.cmd 常在 `D:\Program Files\nodejs` 这类带空格的目录里,shell:true 下
2643
+ // Node 只拼空格不逐参数引用,不引用就从空格截断),全找不到回退 "pnpm"。
2571
2644
  {
2572
2645
  const shimDir = mkdtempSync(join(tmpdir(), "dsh-mall-selftest-path-"));
2573
2646
  try {
@@ -2577,15 +2650,36 @@ async function runTransactionFixtures() {
2577
2650
  const missingOk = planMissing.command === "pnpm" && planMissing.shell === true && planMissing.treeKill === true;
2578
2651
  writeFileSync(join(shimDir, "pnpm.cmd"), "@echo off\r\n");
2579
2652
  const planCmd = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
2580
- const cmdOk = planCmd.command === join(shimDir, "pnpm.cmd") && planCmd.shell === true && planCmd.treeKill === true;
2653
+ const cmdOk = planCmd.command === `"${join(shimDir, "pnpm.cmd")}"` && planCmd.shell === true && planCmd.treeKill === true;
2581
2654
  writeFileSync(join(shimDir, "pnpm.exe"), "MZ");
2582
2655
  const planExe = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
2583
2656
  const exeOk = planExe.command === join(shimDir, "pnpm.exe") && planExe.shell === false && planExe.treeKill === false;
2584
2657
  check(
2585
- "pnpmSpawnPlan:posix 直起 / win32 优先 .exe 无 shell / 仅 .cmd treeKill",
2658
+ "pnpmSpawnPlan:posix 直起 / win32 优先 .exe 无 shell / 仅 .cmd 则带引号 + treeKill",
2586
2659
  posixOk && missingOk && cmdOk && exeOk,
2587
2660
  JSON.stringify({ planPosix, planMissing, planCmd, planExe }),
2588
2661
  );
2662
+ // 空格目录不是假想敌:Node 默认装进 Program Files,真实机器上命令从
2663
+ // 空格截断曾让「'D:\Program' 不是内部或外部命令」拦下所有预检。
2664
+ const spaceDir = join(shimDir, "dir with space");
2665
+ mkdirSync(spaceDir, { recursive: true });
2666
+ writeFileSync(join(spaceDir, "pnpm.cmd"), "@echo probe-ok\r\n");
2667
+ const planSpace = pnpmSpawnPlan({ platform: "win32", pathEnv: spaceDir });
2668
+ check(
2669
+ "pnpmSpawnPlan:带空格的 .cmd 路径 → command 自带引号",
2670
+ planSpace.shell === true && planSpace.command === `"${join(spaceDir, "pnpm.cmd")}"`,
2671
+ JSON.stringify(planSpace),
2672
+ );
2673
+ // 引用必须真的可跑:Windows 真机端到端 spawn 一轮假 shim(CI 是 Linux,
2674
+ // 只跑静态断言;Windows 上这一条覆盖完整链路)。
2675
+ if (process.platform === "win32") {
2676
+ const probe = spawnSync(planSpace.command, ["--version"], { shell: planSpace.shell, encoding: "utf8", timeout: 15000 });
2677
+ check(
2678
+ "pnpmSpawnPlan:带引号 command 真实 spawn 不再被空格截断",
2679
+ probe.status === 0 && /probe-ok/.test(probe.stdout ?? ""),
2680
+ `status=${probe.status} stdout=${JSON.stringify((probe.stdout ?? "").slice(0, 80))} stderr=${JSON.stringify((probe.stderr ?? "").slice(0, 80))}`,
2681
+ );
2682
+ }
2589
2683
  } finally {
2590
2684
  rmSync(shimDir, { recursive: true, force: true });
2591
2685
  }