@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/index.js CHANGED
@@ -16,16 +16,16 @@
16
16
  import z from "@deepseek-ai/schemastery";
17
17
  import { defineTool } from "@deepseek-ai/dsh-tools";
18
18
  import { existsSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
19
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
19
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
20
20
  import { spawn } from "node:child_process";
21
21
  import { createHash, randomBytes } from "node:crypto";
22
- import { fileURLToPath } from "node:url";
22
+ import { fileURLToPath, pathToFileURL } from "node:url";
23
23
  import { createRequire } from "node:module";
24
24
  import { tmpdir } from "node:os";
25
25
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
26
26
  import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
27
27
  import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof, persistPluginDisabled } from "./installer.js";
28
- import { preflightInstall, inspectRemoteCandidate, recoverProfile } from "./guard.js";
28
+ import { preflightInstall, inspectRemoteCandidate, recoverProfile, describeRollbackRebuild } from "./guard.js";
29
29
 
30
30
  export const name = "@1e0zj/dsh-plugin-mall";
31
31
  // `loader` 用来读装配树、并对单个 entry 做热开关(entry.update)。读法照抄
@@ -36,14 +36,158 @@ export const name = "@1e0zj/dsh-plugin-mall";
36
36
  export const inject = ["tools", "jobs", "systemPrompt", "loader"];
37
37
 
38
38
  export const Config = z.object({
39
- defaultProfile: z.string().default("web"),
39
+ // 没有 .default("web"):这个字段必须能分辨「用户显式选了 web」和「用户没配」。
40
+ // 带默认值时 apply 永远收到 "web",下面的自动识别根本轮不到——而写死 web
41
+ // 的代价不只是装错地方,启动恢复也会去动一个本次没启动的 profile。
42
+ defaultProfile: z.string(),
40
43
  apiBase: z.string().default("https://api.github.com"),
41
44
  npmRegistry: z.string().default(""),
42
45
  rawSources: z.array(z.string()).default([]),
43
- perPageMax: z.number().default(30),
46
+ // 实际语义一直是 1–30 的整数(搜索路径两处都在 clamp)。约束写进 schema,
47
+ // 坏配置就在插件加载时失败,而不是被静默夹回边界。
48
+ perPageMax: z.natural().min(1).max(30).default(30),
44
49
  allowRestart: z.boolean().default(true),
45
50
  });
46
51
 
52
+ /** Compare two absolute paths, tolerating symlinks and Windows case. */
53
+ function samePath(a, b) {
54
+ const canon = (value) => {
55
+ let path = value;
56
+ try { path = realpathSync(value); } catch { /* 不在磁盘上就按字面比 */ }
57
+ return process.platform === "win32" ? path.toLowerCase() : path;
58
+ };
59
+ return canon(a) === canon(b);
60
+ }
61
+
62
+ /**
63
+ * The profile this process actually booted, or undefined when it cannot be
64
+ * established.
65
+ *
66
+ * There is no profile service to ask — the host exposes no `activeProfile`
67
+ * anywhere. What it does expose is the config-tree anchor: `boot()` sets
68
+ *
69
+ * ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + "/"
70
+ *
71
+ * and `absoluteConfigPath` is `<home>/profiles/<name>/cordis.yml`, so the
72
+ * anchor IS the profile directory. The whole tree composes over that single
73
+ * root — bundle layers are read as patch objects and merged, not mounted
74
+ * through `include` — so every entry inherits it. Official plugins already
75
+ * treat it as load-bearing: dsh-client-modules and dsh-typert-loader both
76
+ * throw outright when it is unset. This is the host's own answer about which
77
+ * profile is running, not an inference from argv.
78
+ *
79
+ * Returns undefined rather than guessing, because an `include` from elsewhere
80
+ * re-anchors baseUrl and a wrong answer here is worse than no answer: it would
81
+ * aim every install AND the startup recovery at a profile the user never
82
+ * booted. The name has to round-trip through resolveProfileDir() — the same
83
+ * validation every other profile input passes — for the answer to count.
84
+ *
85
+ * @param baseUrl - the consuming context's `baseUrl`.
86
+ * @param home - the Harness home; tests pass a temp root, callers omit it.
87
+ */
88
+ export function detectProfile(baseUrl, home) {
89
+ if (typeof baseUrl !== "string" || !baseUrl.startsWith("file:")) return undefined;
90
+ let dir;
91
+ try {
92
+ dir = resolve(fileURLToPath(baseUrl));
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ const name = basename(dir);
97
+ let expected;
98
+ try {
99
+ expected = resolve(resolveProfileDir(name, home)); // 非法 profile 名在此抛出
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ return samePath(expected, dir) ? name : undefined;
104
+ }
105
+
106
+ /**
107
+ * Split the one thing that used to be a single `defaultProfile` into the two
108
+ * different questions it was silently answering:
109
+ *
110
+ * - `installProfile` — where installs go when the caller names no profile.
111
+ * A user preference: an explicit config wins, because targeting another
112
+ * profile from here is a legitimate thing to want.
113
+ * - `runningProfile` — which profile THIS process booted, or undefined when
114
+ * that cannot be established. Not a preference; a fact, and the only thing
115
+ * startup recovery may act on.
116
+ *
117
+ * Collapsing them is what the previous version got wrong: recovery ran against
118
+ * `configured ?? detected ?? "web"`, so `defaultProfile: web` while booting
119
+ * profile-a sent recovery at web — the exact cross-profile write this was
120
+ * supposed to end, just reached through the config instead of a hardcoded
121
+ * literal.
122
+ *
123
+ * @param configured - the `defaultProfile` config value, if any.
124
+ * @param baseUrl - the consuming context's `baseUrl`.
125
+ * @param home - the Harness home; tests pass a temp root, callers omit it.
126
+ * @param log - console sink; tests pass a collector.
127
+ */
128
+ export function resolveProfileTargets({ configured, baseUrl, home, log = console } = {}) {
129
+ const explicit = typeof configured === "string" && configured.trim().length > 0
130
+ ? configured.trim()
131
+ : undefined;
132
+ const runningProfile = detectProfile(baseUrl, home);
133
+ const installProfile = explicit ?? runningProfile ?? "web";
134
+ if (explicit === undefined && runningProfile === undefined) {
135
+ log.warn(`[dsh-plugin-mall] could not determine the running profile from the config-tree anchor; installs default to "web" — set defaultProfile if that is wrong`);
136
+ }
137
+ return { installProfile, runningProfile };
138
+ }
139
+
140
+ /**
141
+ * Startup recovery. A pending install marker blocks every later install and
142
+ * uninstall in that profile until something resolves it — and until now the
143
+ * only thing that did was `guard launch`, a wrapper nobody uses: people type
144
+ * `dsh web`. One install then wedged the profile permanently, with an error
145
+ * telling users to run a CLI they have never heard of.
146
+ *
147
+ * Reaching `apply` IS the proof the pending install did not break the host:
148
+ * this code only runs because dsh booted far enough to compose the profile and
149
+ * load this plugin. So resolve the marker right here — recoverProfile commits
150
+ * when the profile validates and rolls back when it does not. The grace-window
151
+ * probation of `guard launch` stays strictly better (it also catches a crash
152
+ * seconds later); this is the floor for a plain start.
153
+ *
154
+ * That proof covers EXACTLY ONE profile: the one that booted. Recovering any
155
+ * other from here would commit its half-finished install on the strength of a
156
+ * boot that never exercised it — and delete the snapshot it would have been
157
+ * rolled back to — while the booted profile's own marker stayed forever. So
158
+ * when the running profile is unknown this skips rather than falling back:
159
+ * a blocked profile the user can still repair beats a wrongly committed one
160
+ * they cannot.
161
+ *
162
+ * @param runningProfile - the booted profile, or undefined when unestablished.
163
+ * @param recover - recovery implementation; tests inject a spy.
164
+ * @param log - console sink; tests pass a collector.
165
+ */
166
+ export function runStartupRecovery(runningProfile, { recover = recoverProfile, log = console } = {}) {
167
+ if (runningProfile === undefined) {
168
+ log.warn(`[dsh-plugin-mall] startup recovery skipped: this boot's profile could not be established, and no other profile's pending install may be settled on the strength of it. A pending install stays blocked until it is resolved.`);
169
+ return { action: "skipped" };
170
+ }
171
+ try {
172
+ const result = recover(resolveProfileDir(runningProfile));
173
+ if (result.action === "committed") {
174
+ log.log(`[dsh-plugin-mall] startup recovery: committed the pending install for profile "${runningProfile}"`);
175
+ } else if (result.action === "rolled-back") {
176
+ log.warn(`[dsh-plugin-mall] startup recovery: rolled back the pending install for profile "${runningProfile}" — ${result.reason ?? "profile failed validation"}`);
177
+ // What the rebuild did, when it did anything. A rollback that relinked a
178
+ // package used to be silent, so a reconcile that silently no-opped and a
179
+ // fallback add that saved the profile looked exactly alike afterwards.
180
+ const rebuild = describeRollbackRebuild(result.rebuild);
181
+ if (rebuild !== undefined) log.warn(`[dsh-plugin-mall] startup recovery: node_modules rebuild — ${rebuild}`);
182
+ }
183
+ return result;
184
+ } catch (error) {
185
+ // 恢复失败绝不能拖垮插件加载:报出来,让市场照常可用,而不是连界面都进不去。
186
+ log.error("[dsh-plugin-mall] startup recovery failed:", error);
187
+ return { action: "failed", error };
188
+ }
189
+ }
190
+
47
191
  /**
48
192
  * The registry to query for a profile: an explicit `npmRegistry` config wins,
49
193
  * otherwise follow whatever pnpm installs from (profile .npmrc → pnpm config →
@@ -829,6 +973,9 @@ export function createJobTracker({ producerFactory } = {}) {
829
973
  record.status = status;
830
974
  record.detail = outcome?.detail;
831
975
  record.needsApproval = outcome?.needsApproval;
976
+ // 原因活不过一次重启的失败(被别的未了结事务挡住):浏览器据此在
977
+ // 重启后撤掉记录,而不是把一段现在时的描述留在面板上当现状读。
978
+ record.staleOnRestart = outcome?.staleOnRestart === true;
832
979
  record.finishedAt = Date.now();
833
980
 
834
981
  if (status === "completed") {
@@ -940,6 +1087,7 @@ export function createJobTracker({ producerFactory } = {}) {
940
1087
  status: record.status,
941
1088
  detail: record.detail,
942
1089
  needsApproval: record.needsApproval,
1090
+ staleOnRestart: record.staleOnRestart,
943
1091
  approvalToken: isSameSession ? record.approvalToken : undefined,
944
1092
  // extras(如预检结论)同样只对同一 session 可见,与 approvalToken 同规格。
945
1093
  extras: isSameSession ? record.extras : undefined,
@@ -977,6 +1125,7 @@ export function createJobTracker({ producerFactory } = {}) {
977
1125
  status: record.status,
978
1126
  detail: record.detail,
979
1127
  needsApproval: record.needsApproval,
1128
+ staleOnRestart: record.staleOnRestart,
980
1129
  approvalToken: isSameSession ? record.approvalToken : undefined,
981
1130
  extras: isSameSession ? record.extras : undefined,
982
1131
  spec: record.spec,
@@ -1615,33 +1764,14 @@ function registerRpcChannel(ctx, config, token) {
1615
1764
  }
1616
1765
 
1617
1766
  export function apply(ctx, config = {}) {
1618
- const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
1767
+ const { apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
1768
+ const { installProfile: defaultProfile, runningProfile } = resolveProfileTargets({
1769
+ configured: config.defaultProfile,
1770
+ baseUrl: ctx?.baseUrl,
1771
+ });
1619
1772
  const token = process.env.GITHUB_TOKEN ?? process.env.DSH_MARKET_GITHUB_TOKEN;
1620
1773
 
1621
- // Startup recovery. A pending install marker blocks every later install and
1622
- // uninstall in that profile until something resolves it — and until now the
1623
- // only thing that did was `guard launch`, a wrapper nobody uses: people type
1624
- // `dsh web`. One install then wedged the profile permanently, with an error
1625
- // telling users to run a CLI they have never heard of.
1626
- //
1627
- // Reaching `apply` IS the proof the pending install did not break the host:
1628
- // this code only runs because dsh booted far enough to compose the profile
1629
- // and load this plugin. So resolve the marker right here — recoverProfile
1630
- // commits when the profile validates and rolls back when it does not. The
1631
- // grace-window probation of `guard launch` stays strictly better (it also
1632
- // catches a crash seconds later); this is the floor for a plain start.
1633
- try {
1634
- const result = recoverProfile(resolveProfileDir(defaultProfile));
1635
- if (result.action === "committed") {
1636
- console.log(`[dsh-plugin-mall] startup recovery: committed the pending install for profile "${defaultProfile}"`);
1637
- } else if (result.action === "rolled-back") {
1638
- console.warn(`[dsh-plugin-mall] startup recovery: rolled back the pending install for profile "${defaultProfile}" — ${result.reason ?? "profile failed validation"}`);
1639
- }
1640
- } catch (error) {
1641
- // 恢复失败绝不能拖垮插件加载:报出来,让市场照常可用(用户还能手动
1642
- // `dsh-plugin-guard guard recover`),而不是连界面都进不去。
1643
- console.error("[dsh-plugin-mall] startup recovery failed:", error);
1644
- }
1774
+ runStartupRecovery(runningProfile);
1645
1775
 
1646
1776
  ctx.systemPrompt.section({
1647
1777
  name: "tool:market",
@@ -1954,7 +2084,9 @@ export function apply(ctx, config = {}) {
1954
2084
  }),
1955
2085
  }));
1956
2086
 
1957
- registerRpcChannel(ctx, config, token);
2087
+ // 解析后的 defaultProfile 必须一起传下去:Web 侧和 Agent 侧共用同一个目标
2088
+ // profile,两边判定不能分叉。
2089
+ registerRpcChannel(ctx, { ...config, defaultProfile }, token);
1958
2090
  }
1959
2091
 
1960
2092
  // ── offline fixtures / self-test ────────────────────────────────────────────
@@ -2483,6 +2615,147 @@ export async function runSelfTests() {
2483
2615
  check("configId 缺失时可被识别(调用方据此拒绝写 patch)", loaderEntriesByPackage(fakeLoaderCtx([
2484
2616
  { id: "anon-1", options: { name: "no-id-pkg" }, disabled: false },
2485
2617
  ]))["no-id-pkg"]?.entries[0].configId === undefined);
2618
+
2619
+ // ── 9. 当前 profile 识别 ────────────────────────────────────────────────
2620
+ // 判错的代价不是「装错地方」而已:apply 的启动恢复会据此提交或回滚半装
2621
+ // 状态,指错 profile 等于拿一次无关的启动为另一个 profile 的完整性背书。
2622
+ // 所以这里的重点全在「什么时候必须返回 undefined」。
2623
+ const fakeHome = join(root, "detect-home");
2624
+ const detectDir = join(fakeHome, "profiles", "guard-test");
2625
+ mkdirSync(detectDir, { recursive: true });
2626
+ const urlOf = (path) => pathToFileURL(path).href.replace(/\/?$/, "/");
2627
+
2628
+ check("profile 目录锚点 → 识别出目录名", detectProfile(urlOf(detectDir), fakeHome) === "guard-test");
2629
+ check("锚点带尾斜杠与否都识别", detectProfile(pathToFileURL(detectDir).href, fakeHome) === "guard-test");
2630
+
2631
+ // 以下每一条都必须是 undefined —— 宁可退回配置/兜底,也不能猜。
2632
+ check("非 file: 锚点 → 不猜", detectProfile("https://example.com/profiles/web/", fakeHome) === undefined);
2633
+ check("锚点缺失 → 不猜", detectProfile(undefined, fakeHome) === undefined);
2634
+ check("锚点非字符串 → 不猜", detectProfile({ href: urlOf(detectDir) }, fakeHome) === undefined);
2635
+ // include 从别处重锚:目录名碰巧合法,但不在 <home>/profiles/ 下。
2636
+ const strayDir = join(root, "elsewhere", "guard-test");
2637
+ mkdirSync(strayDir, { recursive: true });
2638
+ check("profiles/ 之外的同名目录 → 不猜", detectProfile(urlOf(strayDir), fakeHome) === undefined);
2639
+ // profiles 目录本身:basename 是 "profiles",回算得到 profiles/profiles。
2640
+ check("锚点指向 profiles/ 本身 → 不猜", detectProfile(urlOf(join(fakeHome, "profiles")), fakeHome) === undefined);
2641
+ // 深一层:<home>/profiles/web/node_modules 的 basename 是 node_modules,
2642
+ // 而 resolveProfileDir 明确拒绝这个名字。
2643
+ const nestedDir = join(fakeHome, "profiles", "web", "node_modules");
2644
+ mkdirSync(nestedDir, { recursive: true });
2645
+ check("锚点指向 profile 内的 node_modules → 不猜", detectProfile(urlOf(nestedDir), fakeHome) === undefined);
2646
+
2647
+ // ── 10. Config schema 约束 ──────────────────────────────────────────────
2648
+ // perPageMax 的 1–30 语义此前只活在两处 clamp 里,坏配置被静默夹回边界。
2649
+ check("perPageMax 合法值通过", Config({ defaultProfile: "web", perPageMax: 10 }).perPageMax === 10);
2650
+ check("perPageMax 缺省为 30", Config({ defaultProfile: "web" }).perPageMax === 30);
2651
+ const rejectsConfig = (value) => {
2652
+ try { Config({ defaultProfile: "web", perPageMax: value }); return false; } catch { return true; }
2653
+ };
2654
+ check("perPageMax 超上限被拒", rejectsConfig(31));
2655
+ check("perPageMax 为 0 被拒", rejectsConfig(0));
2656
+ check("perPageMax 为负被拒", rejectsConfig(-1));
2657
+ check("perPageMax 非整数被拒", rejectsConfig(2.5));
2658
+ // defaultProfile 没有默认值,才能让 apply 分辨「没配」。摘掉默认值时最该
2659
+ // 怕的是它变成必填:真实 profile 里我们那行压根没有 config: 键,Config
2660
+ // 收到的是 undefined,一旦这里抛错插件直接加载不了。
2661
+ check("defaultProfile 未配置时保持 undefined", Config({}).defaultProfile === undefined);
2662
+ check("条目无 config: 键(Config 收到 undefined)不抛错", (() => {
2663
+ try { return Config(undefined).defaultProfile === undefined; } catch { return false; }
2664
+ })());
2665
+
2666
+ // ── 10b. 发布的 bundle patch 不许钉死 defaultProfile ────────────────────
2667
+ // 这个文件随包发布,被每个装了市场的 profile 当 bundle 层读取,所以写在
2668
+ // 里面的任何值在所有 profile 里都是「显式配置」。此前它钉着
2669
+ // defaultProfile: web,于是自动识别对所有真实用户都是空转——而且那个值
2670
+ // 和用户自己配的分辨不开。真机实测才暴露出来,fixture 之前够不着。
2671
+ const bundlePatchPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cordis.patch.yml");
2672
+ const bundlePatch = readFileSync(bundlePatchPath, "utf8");
2673
+ const pinsDefaultProfile = bundlePatch
2674
+ .split(/\r?\n/)
2675
+ .some((line) => line.trim().startsWith("defaultProfile:"));
2676
+ check("发布的 bundle patch 未钉死 defaultProfile(钉了自动识别就永远不触发)", !pinsDefaultProfile);
2677
+
2678
+ // ── 11. 安装目标 vs 恢复目标:两个问题,不能共用一个答案 ────────────────
2679
+ // 上一版把两者合成一个 defaultProfile,于是「显式配置」和「兜底 web」都能
2680
+ // 把恢复指向一个没启动的 profile——正是本次要根除的跨 profile 写入,只是
2681
+ // 换成从配置绕进来。这里按「谁能证明什么」逐一钉死。
2682
+ const quietLog = () => {
2683
+ const lines = [];
2684
+ return { lines, warn: (m) => lines.push(m), log: (m) => lines.push(m), error: (m) => lines.push(m) };
2685
+ };
2686
+ const targetsFor = (configured, dirName) => resolveProfileTargets({
2687
+ configured,
2688
+ baseUrl: dirName === undefined ? undefined : urlOf(join(fakeHome, "profiles", dirName)),
2689
+ home: fakeHome,
2690
+ log: quietLog(),
2691
+ });
2692
+ mkdirSync(join(fakeHome, "profiles", "profile-a"), { recursive: true });
2693
+
2694
+ // 场景 1:启动 profile-a,却配了 defaultProfile: profile-b。
2695
+ const crossed = targetsFor("profile-b", "profile-a");
2696
+ check("配置指向别的 profile:安装目标听配置", crossed.installProfile === "profile-b");
2697
+ check("配置指向别的 profile:恢复目标仍是启动的那个", crossed.runningProfile === "profile-a");
2698
+
2699
+ // 场景 2:识别不出运行 profile。安装兜底 web 可以接受(用户还能改配置);
2700
+ // 恢复不行——本次启动没有证明 web 是好的。
2701
+ const unknown = targetsFor(undefined, undefined);
2702
+ check("识别失败:安装目标兜底 web", unknown.installProfile === "web");
2703
+ check("识别失败:恢复目标为 undefined,不兜底", unknown.runningProfile === undefined);
2704
+
2705
+ // 场景 3:识别失败 + 显式配置。配置只喂安装侧,喂不到恢复侧。
2706
+ const unknownConfigured = targetsFor("profile-b", undefined);
2707
+ check("识别失败但有配置:安装目标听配置", unknownConfigured.installProfile === "profile-b");
2708
+ check("识别失败但有配置:恢复目标仍为 undefined", unknownConfigured.runningProfile === undefined);
2709
+
2710
+ // 场景 4:正常情况——没配置,识别成功,两者一致。
2711
+ const plain = targetsFor(undefined, "profile-a");
2712
+ check("未配置且识别成功:两个目标都是启动的 profile",
2713
+ plain.installProfile === "profile-a" && plain.runningProfile === "profile-a");
2714
+ check("空白字符串配置视同未配置", targetsFor(" ", "profile-a").installProfile === "profile-a");
2715
+
2716
+ // 只有「没配置且识别不出」才该提醒安装兜底;有配置时兜底不存在,不该吵。
2717
+ const warnLog = quietLog();
2718
+ resolveProfileTargets({ configured: undefined, baseUrl: undefined, home: fakeHome, log: warnLog });
2719
+ check("识别失败且未配置 → 提示安装兜底 web", warnLog.lines.some((line) => line.includes(`default to "web"`)));
2720
+ const quietWhenConfigured = quietLog();
2721
+ resolveProfileTargets({ configured: "profile-b", baseUrl: undefined, home: fakeHome, log: quietWhenConfigured });
2722
+ check("识别失败但已配置 → 不提示兜底", quietWhenConfigured.lines.length === 0);
2723
+
2724
+ // ── 12. 恢复执行:识别不出就一次都不许调用 ──────────────────────────────
2725
+ // 前面钉的是「算出什么」,这里钉「据此做了什么」——两者之间正是上一版
2726
+ // 出问题的地方,只测前者等于没测。
2727
+ let recoverCalls = [];
2728
+ const spyRecover = (dir) => { recoverCalls.push(dir); return { action: "none" }; };
2729
+
2730
+ recoverCalls = [];
2731
+ const skipLog = quietLog();
2732
+ const skipped = runStartupRecovery(undefined, { recover: spyRecover, log: skipLog });
2733
+ check("运行 profile 未知 → recoverProfile 一次都不调用", recoverCalls.length === 0);
2734
+ check("运行 profile 未知 → 结算为 skipped", skipped.action === "skipped");
2735
+ check("跳过时说明原因(不静默)", skipLog.lines.some((line) => line.includes("startup recovery skipped")));
2736
+
2737
+ recoverCalls = [];
2738
+ runStartupRecovery("profile-a", { recover: spyRecover, log: quietLog() });
2739
+ check("运行 profile 已知 → 只对该 profile 调用一次",
2740
+ recoverCalls.length === 1 && basename(recoverCalls[0]) === "profile-a");
2741
+
2742
+ // 恢复抛错不能拖垮插件加载:这条是 apply 能否起来的底线。
2743
+ recoverCalls = [];
2744
+ const throwLog = quietLog();
2745
+ const threw = runStartupRecovery("profile-a", {
2746
+ recover: () => { throw new Error("boom"); },
2747
+ log: throwLog,
2748
+ });
2749
+ check("恢复抛错 → 吞掉并记录,不向上抛", threw.action === "failed");
2750
+ check("恢复抛错 → 有日志", throwLog.lines.some((line) => line.includes("startup recovery failed")));
2751
+
2752
+ // 提交/回滚两条播报路径。
2753
+ const committedLog = quietLog();
2754
+ runStartupRecovery("profile-a", { recover: () => ({ action: "committed" }), log: committedLog });
2755
+ check("提交路径播报所恢复的 profile 名", committedLog.lines.some((line) => line.includes('committed the pending install for profile "profile-a"')));
2756
+ const rolledLog = quietLog();
2757
+ runStartupRecovery("profile-a", { recover: () => ({ action: "rolled-back", reason: "静态校验未通过" }), log: rolledLog });
2758
+ check("回滚路径播报原因", rolledLog.lines.some((line) => line.includes("rolled back") && line.includes("静态校验未通过")));
2486
2759
  } finally {
2487
2760
  rmSync(root, { recursive: true, force: true });
2488
2761
  }