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

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/src/installer.js CHANGED
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
16
16
  import { dump, load } from "js-yaml";
17
17
  import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
18
18
  import { describeBuildScripts, npmNameOf } from "./github.js";
19
- import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
19
+ import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, markPendingApprovalPause, markPendingSnapshot, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
20
20
 
21
21
  // ── spec normalization ──────────────────────────────────────────────────────
22
22
 
@@ -188,7 +188,20 @@ export function reconcileBundles(profileDir, beforeDeps = new Set()) {
188
188
  return result;
189
189
  }
190
190
 
191
- /** List a profile's installed plugins (dependencies with classification + version). */
191
+ /**
192
+ * List a profile's installed plugins (dependencies with classification +
193
+ * version).
194
+ *
195
+ * An install paused at the build-script approval gate is reported as the
196
+ * snapshot has it, not as the half-written profile has it: the candidate's
197
+ * scripts were never approved, dsh has not loaded it, and the next startup
198
+ * rolls it back, so calling it "installed" tells the user the opposite of what
199
+ * is true — an update would show the NEW version while the old one is what is
200
+ * actually running and what a restart restores. A paused fresh install drops
201
+ * out of the list entirely. Every consumer goes through here (the browser's
202
+ * installed panel, the `updates` check that decides whether an update button
203
+ * appears, and the `market_installed` agent tool), so they all agree.
204
+ */
192
205
  export function listInstalled(profile) {
193
206
  const dir = resolveProfileDir(profile);
194
207
  const manifestPath = join(dir, "package.json");
@@ -209,7 +222,13 @@ export function listInstalled(profile) {
209
222
  }
210
223
  return { name, version, kind };
211
224
  });
212
- return { dir, deps };
225
+ const before = pausedCandidateBeforeState(dir);
226
+ if (before === undefined) return { dir, deps };
227
+ if (!before.present) return { dir, deps: deps.filter((dep) => dep.name !== before.name) };
228
+ return {
229
+ dir,
230
+ deps: deps.map((dep) => (dep.name === before.name ? { ...dep, version: before.version ?? "?" } : dep)),
231
+ };
213
232
  }
214
233
 
215
234
  // ── npm registry resolution ─────────────────────────────────────────────────
@@ -1094,6 +1113,9 @@ export function createJobTracker() {
1094
1113
  // 待批准的构建脚本清单:浏览器侧据此渲染「允许并继续」,没有它就只有
1095
1114
  // 一段文本,用户看不出要批准的到底是什么。
1096
1115
  record.needsApproval = outcome.needsApproval;
1116
+ // 这次失败的原因活不过一次重启(见 failedNow),浏览器据此在重启后
1117
+ // 撤掉记录,而不是把一段现在时的描述当成当前状态留在面板上。
1118
+ record.staleOnRestart = outcome.staleOnRestart === true;
1097
1119
  record.finishedAt = Date.now();
1098
1120
  onSettled?.(outcome);
1099
1121
  });
@@ -1112,6 +1134,7 @@ export function createJobTracker() {
1112
1134
  status: record.status,
1113
1135
  detail: record.detail,
1114
1136
  needsApproval: record.needsApproval,
1137
+ staleOnRestart: record.staleOnRestart,
1115
1138
  spec: record.spec,
1116
1139
  startedAt: record.startedAt,
1117
1140
  finishedAt: record.finishedAt,
@@ -1455,14 +1478,28 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1455
1478
  try {
1456
1479
  existingMarker = readValidatedPendingSnapshot(profileDir);
1457
1480
  } catch (error) {
1458
- return failedNow(`profile has an unreadable pending marker — refusing to install ${spec} (${error.message}); run \`dsh-plugin-guard guard recover\` first`);
1481
+ return failedNow(`profile 里有一个读不出来的安装记录,无法判断它是什么,因此拒绝安装 ${spec}(${error.message})。请先运行 \`dsh-plugin-guard guard recover\` 处理它。`);
1459
1482
  }
1460
1483
  if (existingMarker !== undefined) {
1461
1484
  const previous = existingMarker.metadata?.spec ?? existingMarker.metadata?.packageName ?? "unknown";
1462
1485
  if (existingMarker.operation !== "install" || existingMarker.metadata?.spec !== spec) {
1463
- return failedNow(`profile has a pending ${existingMarker.operation} transaction for ${JSON.stringify(previous)} — refusing to install ${spec}; restart dsh (startup recovery) or run \`dsh-plugin-guard guard recover\` first`);
1486
+ // 拒绝是对的(marker 是一次性事务,不能被覆盖),但用户看不见 marker,
1487
+ // 所以要说清楚挡路的是什么、以及怎么让它让开。暂停在批准闸的那种最
1488
+ // 常见——它正是用户刚点过取消的那次安装。
1489
+ const paused = pendingApprovalPaused(existingMarker) !== undefined;
1490
+ const what = existingMarker.operation === "remove" ? "卸载" : "安装";
1491
+ return failedNow(paused
1492
+ ? `${previous} 的${what}还没做完——它停在「允许安装依赖」那一步等你决定,没有批准就不会真正装上。现在无法安装 ${spec}。重启 dsh 会撤回那次未批准的${what},之后就能重新操作;也可以运行 \`dsh-plugin-guard guard recover\` 立即撤回。`
1493
+ : `${previous} 的${what}还没了结,现在无法安装 ${spec}。重启 dsh 会自动了结它(装好的提交、没批准的撤回),也可以运行 \`dsh-plugin-guard guard recover\` 手动处理。`,
1494
+ { staleOnRestart: true });
1464
1495
  }
1465
1496
  push(`[dsh-plugin-mall] resuming the paused install transaction for ${spec} — its original snapshot stays the rollback target\n`);
1497
+ // 事务复活:清掉暂停标记,否则重试成功后的启动提交会被它拦下错误回滚。
1498
+ try {
1499
+ clearPendingApprovalPause(profileDir);
1500
+ } catch (pauseError) {
1501
+ push(`[dsh-plugin-mall] WARNING: could not clear the approval-pause mark: ${pauseError.message}\n`);
1502
+ }
1466
1503
  } else {
1467
1504
  let snapshot;
1468
1505
  try {
@@ -1719,7 +1756,15 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1719
1756
  // The retry's rebuild branch also needs this tree in place. Leave the
1720
1757
  // on-disk state and the marker for the token retry; an abandoned pause
1721
1758
  // is settled by startup recovery / `guard recover`, whose rollback
1722
- // target is still the pre-first-attempt snapshot.
1759
+ // target is still the pre-first-attempt snapshot. The pause is also
1760
+ // marked ON the marker: without the mark a restart that passes the
1761
+ // static validation would commit the never-approved version and drop
1762
+ // the snapshot (both recovery commit points check it).
1763
+ try {
1764
+ markPendingApprovalPause(profileDir);
1765
+ } catch (pauseError) {
1766
+ push(`[dsh-plugin-mall] WARNING: could not mark the pause on the pending marker: ${pauseError.message}\n`);
1767
+ }
1723
1768
  push("\n[dsh-plugin-mall] install paused for build-script approval — the candidate stays installed with its scripts blocked; approve in the UI to finish, or restart dsh / run `dsh-plugin-guard guard recover` to roll back\n");
1724
1769
  } else {
1725
1770
  try {
@@ -1747,10 +1792,21 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1747
1792
  // ── the background uninstall job ────────────────────────────────────────────
1748
1793
 
1749
1794
  /** A terminal producer for fast-fail cases (no pnpm spawn needed). */
1750
- function failedNow(detail) {
1795
+ /**
1796
+ * @param staleOnRestart - true when this failure's CAUSE cannot outlive a
1797
+ * restart, so the browser should drop the record instead of keeping it as
1798
+ * history. Only the "another transaction owns this profile" refusals qualify:
1799
+ * startup recovery settles that transaction on the way up, so the message
1800
+ * ("X is still waiting at the approval gate, so Y cannot install") describes a
1801
+ * situation that is guaranteed gone — and it is written in the present tense,
1802
+ * so a reader after the restart takes it for the current state. Ordinary
1803
+ * failures (network, preflight blockers, pnpm errors) may well still apply
1804
+ * after a restart and keep their diagnostic value, so they stay.
1805
+ */
1806
+ function failedNow(detail, { staleOnRestart = false } = {}) {
1751
1807
  return {
1752
1808
  cancel: () => {},
1753
- done: Promise.resolve({ status: "failed", detail }),
1809
+ done: Promise.resolve({ status: "failed", detail, staleOnRestart }),
1754
1810
  readOutput: () => "",
1755
1811
  };
1756
1812
  }
@@ -1786,7 +1842,11 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
1786
1842
  // hard as a valid one, and is left untouched for the recovery path.
1787
1843
  const markerPath = pendingMarkerPath(profileDir);
1788
1844
  if (existsSync(markerPath)) {
1789
- return failedNow(`profile "${profile}" has a pending install transaction (${markerPath}) — refusing to remove ${packageName} until it is resolved; restart dsh (startup recovery) or run \`dsh-plugin-guard guard recover\` first`);
1845
+ // 存在性判断(同 markPendingSnapshot):坏 marker 也照样挡路。所以这里
1846
+ // 只能拿到「有」而拿不到「是什么」,文案相应保持笼统,但仍要给出路。
1847
+ return failedNow(
1848
+ `profile "${profile}" 里有一个还没了结的安装事务,在它了结之前无法卸载 ${packageName}。重启 dsh 会自动了结它(装好的提交、没批准的撤回),也可以运行 \`dsh-plugin-guard guard recover\` 手动处理(事务记录:${markerPath})。`,
1849
+ { staleOnRestart: true });
1790
1850
  }
1791
1851
  const manifestPath = join(profileDir, "package.json");
1792
1852
  if (!existsSync(manifestPath)) {
@@ -2175,13 +2235,50 @@ async function runTransactionFixtures() {
2175
2235
  && /paused for build-script approval/.test(output),
2176
2236
  `status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
2177
2237
  );
2238
+ check(
2239
+ "暂停必须落盘到 marker(metadata.paused)——重启后的恢复靠它区分「装完待验证」与「停在批准闸被放弃」",
2240
+ (() => {
2241
+ try {
2242
+ const marker = JSON.parse(readFileSync(markerBefore, "utf8"));
2243
+ return marker?.metadata?.paused?.reason === "paused for build-script approval";
2244
+ } catch { return false; }
2245
+ })(),
2246
+ );
2247
+
2248
+ // 1a-ter. 暂停期间装别的包:拒绝是对的(marker 是一次性事务),但用户
2249
+ // 看不见 marker,所以报错必须点名挡路的是那次「停在允许安装依赖」的
2250
+ // 安装,并给出让它让开的办法。这里 marker 仍带 paused。
2251
+ {
2252
+ const during = scriptedSpawn([{ code: 0, out: "Done\n" }]);
2253
+ const duringOutcome = await runInstall({
2254
+ profile: "p",
2255
+ spec: "other-during-pause",
2256
+ preflight: preflightStub("other-during-pause"),
2257
+ _profileDir: profileDir,
2258
+ _spawn: during.spawnFn,
2259
+ _describe: async () => [],
2260
+ }).done;
2261
+ check(
2262
+ // staleOnRestart:这条报错是现在时写的,而挡路的事务必然被启动恢复
2263
+ // 了结——留到重启之后会被当成当前状态读,所以面板要撤掉它。
2264
+ "暂停期间装别的包 → 拒绝,报错点名「停在允许安装依赖」+ 撤回办法 + 标记重启后失效",
2265
+ duringOutcome.status === "failed"
2266
+ && /停在「允许安装依赖」/.test(duringOutcome.detail ?? "")
2267
+ && /重启 dsh/.test(duringOutcome.detail ?? "")
2268
+ && duringOutcome.staleOnRestart === true
2269
+ && during.calls.length === 0,
2270
+ `status=${duringOutcome.status} detail=${(duringOutcome.detail ?? "").slice(0, 120)}`,
2271
+ );
2272
+ }
2178
2273
 
2179
2274
  // 1a-bis. 同 spec 重试接管暂停的 marker:不再新建快照(回滚目标仍是
2180
- // 第一次安装前的现场),继续 spawn pnpm;异 spec 则拒绝且不 spawn。
2275
+ // 第一次安装前的现场),继续 spawn pnpm;这次给批准后的成功路径——
2276
+ // completed 后 marker 保留给启动提交,且暂停标记必须已被接管清掉
2277
+ // (否则启动提交会被它拦下错误回滚)。异 spec 则拒绝且不 spawn。
2181
2278
  {
2182
2279
  const snapshotRootDir = join(dirname(dirname(profileDir)), "guard", "snapshots");
2183
2280
  const snapshotsBefore = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
2184
- const retry = scriptedSpawn([{ code: 0, out: "Ignored build scripts: node-pty@1.0.0\nDone\n" }]);
2281
+ const retry = scriptedSpawn([{ code: 0, out: "Done in 1s\n" }]);
2185
2282
  const retryProducer = runInstall({
2186
2283
  profile: "p",
2187
2284
  spec: "some-plugin",
@@ -2194,12 +2291,18 @@ async function runTransactionFixtures() {
2194
2291
  const retryOutput = retryProducer.readOutput();
2195
2292
  const snapshotsAfter = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
2196
2293
  check(
2197
- "同 spec 重试接管暂停的 marker → 复用原快照,继续安装而非拒绝",
2198
- retryOutcome.status === "failed"
2199
- && Array.isArray(retryOutcome.needsApproval)
2294
+ "同 spec 重试接管暂停的 marker → 复用原快照、继续安装并清掉暂停标记",
2295
+ retryOutcome.status === "completed"
2200
2296
  && retry.calls.length === 1
2201
2297
  && /resuming the paused install transaction/.test(retryOutput)
2202
- && snapshotsAfter === snapshotsBefore,
2298
+ && snapshotsAfter === snapshotsBefore
2299
+ && existsSync(markerBefore)
2300
+ && (() => {
2301
+ try {
2302
+ const marker = JSON.parse(readFileSync(markerBefore, "utf8"));
2303
+ return marker?.metadata?.paused === undefined;
2304
+ } catch { return false; }
2305
+ })(),
2203
2306
  `status=${retryOutcome.status} calls=${retry.calls.length} snapshots=${snapshotsBefore}->${snapshotsAfter}`,
2204
2307
  );
2205
2308
  const other = scriptedSpawn([{ code: 0, out: "Done\n" }]);
@@ -2212,9 +2315,13 @@ async function runTransactionFixtures() {
2212
2315
  _describe: async () => [],
2213
2316
  }).done;
2214
2317
  check(
2215
- "异 spec 遇暂停 marker → 拒绝且不 spawn",
2318
+ // 此刻 paused 已被上面的接管清掉,所以报错走的是「未了结」那一支,
2319
+ // 而不是「停在允许安装依赖」那一支。
2320
+ "异 spec 遇既有(已非暂停)marker → 拒绝且不 spawn",
2216
2321
  otherOutcome.status === "failed"
2217
- && /pending install transaction/.test(otherOutcome.detail ?? "")
2322
+ && /还没了结/.test(otherOutcome.detail ?? "")
2323
+ && !/停在「允许安装依赖」/.test(otherOutcome.detail ?? "")
2324
+ && otherOutcome.staleOnRestart === true
2218
2325
  && other.calls.length === 0,
2219
2326
  `status=${otherOutcome.status} calls=${other.calls.length}`,
2220
2327
  );
@@ -2551,6 +2658,8 @@ async function runTransactionFixtures() {
2551
2658
  && installOutcome.status === "failed"
2552
2659
  && removeOutcome.status === "failed"
2553
2660
  && /not a dependency/.test(removeOutcome.detail ?? "")
2661
+ // 普通失败不标记:重启治不好「这个包本来就不在依赖里」。
2662
+ && removeOutcome.staleOnRestart !== true
2554
2663
  && procs.length === 1,
2555
2664
  `procs=${procs.length} install=${installOutcome.status} remove=${removeOutcome.status} ${JSON.stringify(removeOutcome.detail)}`,
2556
2665
  );
@@ -2579,9 +2688,12 @@ async function runTransactionFixtures() {
2579
2688
  const snapshotsDir = join(dirname(marker), "snapshots");
2580
2689
  const leftoverSnapshots = existsSync(snapshotsDir) ? readdirSync(snapshotsDir) : [];
2581
2690
  check(
2582
- "损坏/既有 pending marker install 不 spawn、不覆盖证据、不遗留新 snapshot",
2691
+ // 不标 staleOnRestart:损坏的 marker 重启后仍然 fail-closed 留给人工
2692
+ // 检查,不会被启动恢复了结——这条失败的原因活得过重启,面板要留着。
2693
+ "损坏/既有 pending marker → install 不 spawn、不覆盖证据、不遗留新 snapshot,且不标记重启后失效",
2583
2694
  outcome.status === "failed"
2584
- && /unreadable pending marker/.test(outcome.detail ?? "")
2695
+ && /读不出来的安装记录/.test(outcome.detail ?? "")
2696
+ && outcome.staleOnRestart !== true
2585
2697
  && calls.length === 0
2586
2698
  && readFileSync(marker, "utf8") === corruptBytes
2587
2699
  && leftoverSnapshots.length === 0,
@@ -2604,7 +2716,8 @@ async function runTransactionFixtures() {
2604
2716
  check(
2605
2717
  "pending marker 存在 → remove 拒绝执行且不 spawn pnpm,marker 保留",
2606
2718
  outcome.status === "failed"
2607
- && /pending install transaction/.test(outcome.detail ?? "")
2719
+ && /还没了结的安装事务/.test(outcome.detail ?? "")
2720
+ && outcome.staleOnRestart === true
2608
2721
  && procs.length === 0
2609
2722
  && existsSync(pendingMarkerPath(profileDir)),
2610
2723
  `status=${outcome.status} procs=${procs.length} ${JSON.stringify(outcome.detail)}`,