@1e0zj/dsh-plugin-mall 0.3.1 → 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.1",
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);
@@ -198,20 +217,66 @@ window.__ModuleLoader__.load({
198
217
  }
199
218
  }
200
219
  next[id] = { status: "running", spec: spec, output: carried };
201
- jobsRef.current = next;
202
- setJobs(Object.assign({}, next));
203
- }, []);
220
+ commit(next);
221
+ }, [commit]);
204
222
  var clear = useCallback(function () {
205
- jobsRef.current = {};
206
- setJobs({});
207
- }, []);
223
+ commit({});
224
+ }, [commit]);
208
225
  var drop = useCallback(function (id) {
209
226
  var next = Object.assign({}, jobsRef.current);
210
227
  delete next[id];
211
- jobsRef.current = next;
212
- setJobs(next);
213
- }, []);
214
- 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 };
215
280
  }
216
281
 
217
282
  // ── plugin verification badge ───────────────────────────────────────────
@@ -425,10 +490,12 @@ window.__ModuleLoader__.load({
425
490
  needsApproval: job.needsApproval,
426
491
  busy: props.approving === job.spec,
427
492
  onApprove: function (names) {
428
- if (typeof props.onDrop === "function") {
429
- props.onDrop(id);
430
- }
431
- 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);
432
499
  },
433
500
  onDismiss: function () { props.onDismiss(id); },
434
501
  })
@@ -437,11 +504,16 @@ window.__ModuleLoader__.load({
437
504
  // 也不再重复一个绿色「完成」:状态行已经写了「· 完成」。
438
505
  done && job.status === "completed" && job.kind !== "dsh-plugin-preflight"
439
506
  ? h("div", { className: "mkt_jobDone" },
440
- h("button", {
441
- className: "mkt_btn mkt_btnPrimary mkt_btnSm",
442
- disabled: props.restarting === true,
443
- onClick: props.onRestart,
444
- }, 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 生效"))
445
517
  : job.status === "failed" && !(job.needsApproval && job.needsApproval.length > 0)
446
518
  ? h("div", { className: "mkt_error" }, "失败,见下方输出")
447
519
  : null,
@@ -567,6 +639,11 @@ window.__ModuleLoader__.load({
567
639
  var _restarting = useState(false);
568
640
  var restarting = _restarting[0];
569
641
  var setRestarting = _restarting[1];
642
+ // 本次宿主进程的启动时间(jobs 端点带回):完成时间早于它的任务,
643
+ // 其「重启 dsh 生效」按钮已经兑现,改显示「重启已生效」。
644
+ var _hostStartedAt = useState(0);
645
+ var hostStartedAt = _hostStartedAt[0];
646
+ var setHostStartedAt = _hostStartedAt[1];
570
647
  var _preflight = useState(null);
571
648
  var preflight = _preflight[0];
572
649
  var setPreflight = _preflight[1];
@@ -724,6 +801,14 @@ window.__ModuleLoader__.load({
724
801
 
725
802
  useEffect(function () {
726
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 () { /* 恢复失败不阻塞面板 */ });
727
812
  // eslint-disable-next-line react-hooks/exhaustive-deps
728
813
  }, []);
729
814
 
@@ -748,14 +833,14 @@ window.__ModuleLoader__.load({
748
833
  });
749
834
  }, [call, track]);
750
835
 
751
- var doApprove = useCallback(function (spec, names, token) {
836
+ var doApprove = useCallback(function (spec, names, token, carryFromId) {
752
837
  var extra = { allowBuildScripts: names };
753
838
  var apprToken = token || approvalTokensRef.current[spec];
754
839
  if (apprToken) {
755
840
  extra.approvalToken = apprToken;
756
841
  delete approvalTokensRef.current[spec];
757
842
  }
758
- doRawInstall(spec, extra);
843
+ doRawInstall(spec, extra, carryFromId);
759
844
  }, [doRawInstall]);
760
845
 
761
846
  // 预检落定后的去向:safe 直接续装,其余出内联风险卡片。
@@ -912,6 +997,7 @@ window.__ModuleLoader__.load({
912
997
  onDrop: dropJob,
913
998
  onRestart: doRestart,
914
999
  restarting: restarting,
1000
+ hostStartedAt: hostStartedAt,
915
1001
  approving: Object.keys(installing).filter(function (s) { return installing[s]; })[0],
916
1002
  }),
917
1003
  preflight ? h(PreflightCard, {
package/src/guard.js CHANGED
@@ -880,7 +880,7 @@ function sanitizeSnapshot(marker, home) {
880
880
  }
881
881
 
882
882
  /** Read and validate a profile's pending marker; undefined when none exists. */
883
- function readValidatedPendingSnapshot(profileDir) {
883
+ export function readValidatedPendingSnapshot(profileDir) {
884
884
  const resolved = resolve(profileDir);
885
885
  const filePath = pendingPath(resolved);
886
886
  if (!existsSync(filePath)) return undefined;
@@ -1042,6 +1042,74 @@ function reconcileInstallArgs() {
1042
1042
  ];
1043
1043
  }
1044
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
+
1045
1113
  /**
1046
1114
  * Run the offline reconcile install synchronously (rollback is a sync
1047
1115
  * recovery path, called from CLI/startup contexts that cannot await). Never
@@ -1224,6 +1292,28 @@ export function rollbackPendingSnapshot(profileDir) {
1224
1292
  }
1225
1293
  }
1226
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
+
1227
1317
  if (unsatisfied.length > 0) {
1228
1318
  const why =
1229
1319
  wasUpdate && pending.files?.["pnpm-lock.yaml"]?.present !== true
@@ -1235,9 +1325,10 @@ export function rollbackPendingSnapshot(profileDir) {
1235
1325
  : "node_modules missing direct dependency";
1236
1326
  const tail = String(attempt?.output ?? "").replace(/\s+/g, " ").trim().slice(-400);
1237
1327
  // KEEP the marker + snapshot: the profile files are already restored,
1238
- // 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.
1239
1330
  throw new Error(
1240
- `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}` : ""}`
1241
1332
  );
1242
1333
  }
1243
1334
 
@@ -2068,6 +2159,91 @@ async function selfTest() {
2068
2159
  if (attempts.length !== 1) throw new Error(`the rollback must run exactly ONE offline reconcile, got ${attempts.length}`);
2069
2160
  }
2070
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
+
2071
2247
  // The same interrupted update, but the reconcile never relinks the old
2072
2248
  // copy and only an ANCESTOR node_modules still provides a satisfying
2073
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, pnpmSpawnPlan, rollbackPendingSnapshot } from "./guard.js";
19
+ import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
20
20
 
21
21
  // ── spec normalization ──────────────────────────────────────────────────────
22
22
 
@@ -1444,17 +1444,38 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1444
1444
  // first live pnpm add runs. These are the files that decide what pnpm
1445
1445
  // installs and what dsh loads; the marker is what lets startup/CLI recovery
1446
1446
  // roll the profile back if the plugin proves unloadable.
1447
- 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;
1448
1455
  try {
1449
- snapshot = createProfileSnapshot(profileDir, { spec });
1456
+ existingMarker = readValidatedPendingSnapshot(profileDir);
1450
1457
  } catch (error) {
1451
- 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`);
1452
1459
  }
1453
- try {
1454
- markPendingSnapshot(snapshot, { spec, preflight });
1455
- } catch (error) {
1456
- rmSync(snapshot.dir, { recursive: true, force: true });
1457
- 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
+ }
1458
1479
  }
1459
1480
 
1460
1481
  // Neutralize existing allowBuilds before every first pnpm add so strict-dep-builds
@@ -1687,6 +1708,19 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1687
1708
  }
1688
1709
  if (result.status === "completed") {
1689
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");
1690
1724
  } else {
1691
1725
  try {
1692
1726
  rollbackPendingSnapshot(profileDir);
@@ -2093,7 +2127,9 @@ async function runTransactionFixtures() {
2093
2127
  }
2094
2128
 
2095
2129
  // 1a. exit 0 + "Ignored build scripts" 且未批准:必须停在批准闸(failed +
2096
- // needsApproval),携带 proof,绝不 finalize,回滚收掉 marker,且只 spawn 一次。
2130
+ // needsApproval),携带 proof,绝不 finalize,且是暂停不是失败——现场与
2131
+ // marker 原样保留给带 token 的重试(回滚会让重试的 approval token 必死,
2132
+ // 见收尾处的真实事故注释),只 spawn 一次。
2097
2133
  {
2098
2134
  const { profileDir, cleanup } = makeTempProfile("ignored-gate");
2099
2135
  try {
@@ -2118,8 +2154,10 @@ async function runTransactionFixtures() {
2118
2154
  }],
2119
2155
  });
2120
2156
  const outcome = await producer.done;
2157
+ const output = producer.readOutput();
2158
+ const markerBefore = pendingMarkerPath(profileDir);
2121
2159
  check(
2122
- "退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize",
2160
+ "退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
2123
2161
  outcome.status === "failed"
2124
2162
  && Array.isArray(outcome.needsApproval)
2125
2163
  && outcome.needsApproval.some((entry) => entry.name === "node-pty")
@@ -2133,9 +2171,54 @@ async function runTransactionFixtures() {
2133
2171
  && entry.contentHash !== "0".repeat(64)
2134
2172
  && entry.weeklyDownloads === 123)
2135
2173
  && calls.length === 1
2136
- && !existsSync(pendingMarkerPath(profileDir)),
2137
- `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)}`,
2138
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
+ }
2139
2222
  const call = calls[0] ?? { args: [], options: {} };
2140
2223
  check(
2141
2224
  "实装 argv/env:strict-dep-builds + peer 关闭 + cwd/shell 正确",
@@ -2498,7 +2581,7 @@ async function runTransactionFixtures() {
2498
2581
  check(
2499
2582
  "损坏/既有 pending marker → install 不 spawn、不覆盖证据、不遗留新 snapshot",
2500
2583
  outcome.status === "failed"
2501
- && /already has a pending install marker/.test(outcome.detail ?? "")
2584
+ && /unreadable pending marker/.test(outcome.detail ?? "")
2502
2585
  && calls.length === 0
2503
2586
  && readFileSync(marker, "utf8") === corruptBytes
2504
2587
  && leftoverSnapshots.length === 0,