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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **An open plugin marketplace for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh): search every GitHub repo tagged `topic:dsh-plugin`, automatically verify which ones are real dsh plugins, install and update with one click.**
4
4
 
5
- [中文说明](#中文说明) · [Install](#install) · [Why another marketplace](#why-another-marketplace)
5
+ [中文说明](#中文说明) · [Install](#install) · [Why another marketplace](#why-another-marketplace) · [Roadmap](ROADMAP.md)
6
6
 
7
7
  Two surfaces: a **Settings → Plugins → Marketplace** tab in the dsh web UI, and five agent tools usable from any session.
8
8
 
@@ -47,6 +47,13 @@ Restart dsh after installing.
47
47
  > spec (recipe in the 安装 section below). Do not hand-overwrite files under
48
48
  > `node_modules`: they are hard-linked into pnpm's global store, and any later
49
49
  > `pnpm add/remove` rebuilds the tree and restores them anyway.
50
+ >
51
+ > Re-packing and running `add` again does **nothing**: the `file:` spec and the
52
+ > version are unchanged, so pnpm calls it already installed and never compares
53
+ > the tarball's bytes. The command succeeds, `package.json` looks right, and
54
+ > `node_modules` still holds the previous build. Always `remove` before `add`,
55
+ > or give the test build its own version. Then restart dsh completely — the
56
+ > install ran inside the host process still executing the old code.
50
57
 
51
58
  ## Startup protection (guard CLI)
52
59
 
@@ -137,6 +144,8 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
137
144
 
138
145
  **dsh 插件市场** — 搜索 GitHub `dsh-plugin` 话题下的 DeepSeek Harness 插件仓库,自动验证哪些是真 dsh 插件,一键安装与更新。
139
146
 
147
+ [路线图](ROADMAP.md)(安全 > 便捷 > 精简,以及明确不做的)
148
+
140
149
  与策展列表不同:**任何打上 `topic:dsh-plugin` 的仓库推送后立即可被发现**——无需投稿、无需审批。为保证开放性可用,做了这些事:
141
150
 
142
151
  - **自动验证**:逐仓库拉取 `package.json`(jsDelivr/raw 双源 CDN,不占 API 配额),按官方 `dsh.bundle` / `dsh.client` 声明打徽章;默认"只看已验证"视图过滤约 73% 的话题噪音
@@ -181,7 +190,16 @@ dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
181
190
  > ```
182
191
  >
183
192
  > 这样 pnpm 的规范副本本身就是新代码,后续任何 `pnpm add/remove` 重建依赖树都不会
184
- > 把它换掉;顺带还验证了 `files` 字段没漏文件。改完代码重新 `npm pack` + 重装即可。
193
+ > 把它换掉;顺带还验证了 `files` 字段没漏文件。
194
+ >
195
+ > **`remove` 那一步不能省。** 改完代码重新 `npm pack` 之后只跑 `add`,pnpm 会
196
+ > **什么都不做**:spec(`file:` 路径)和版本号都没变,它就判定「已经装好了」而
197
+ > 跳过,根本不去比对 tarball 的字节。表现是命令成功返回、`package.json` 看着也
198
+ > 对,但 `node_modules` 里还是上一版代码——排查时极难想到这一层。要么每次都
199
+ > `remove` + `add`,要么给测试包换一个版本号(如 `0.3.5-test.1`)。
200
+ >
201
+ > 同理,**装完必须完整重启 dsh**(不是刷新页面):卸载/安装是由**正在运行的那个
202
+ > 宿主进程**执行的,它内存里跑的还是旧代码。新装的代码要下一次启动才生效。
185
203
  >
186
204
  > **不要用直接覆盖 `node_modules` 里文件的办法。** 它有两个坑:
187
205
  > 一是 pnpm 装出来的文件是**硬链接**(与全局 store 共享 inode),直接 `cp` 覆盖会
@@ -322,10 +340,14 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
322
340
  三处。`allowBuilds` 是持久化的安全配置,所以安装最终失败时这次放宽会被
323
341
  **撤销** —— 否则一个没装成的插件会让那个包名从此静默获得构建脚本执行权。
324
342
  - 配置(`cordis.patch.yml` 中可改):`defaultProfile`(默认装进哪个 profile,
325
- 默认 `web`)、`apiBase`(GitHub API 地址)、`npmRegistry`(npm 查询源,留空
326
- 则跟随 pnpm 实际安装源)、`rawSources`(验证用的 package.json 源模板列表,
327
- `{repo}` 会替换成 owner/name,留空用内置的 jsDelivr + raw 双源)、
328
- `perPageMax`(搜索单页上限)、`allowRestart`(是否允许一键重启,默认 `true`)。
343
+ **不配则跟随本进程实际启动的那个** —— loader 的配置树锚点 `ctx.baseUrl`
344
+ 推导,那正是 profile 目录;推不出来才退回 `web` 并打日志)、`apiBase`
345
+ (GitHub API 地址)、`npmRegistry`(npm 查询源,留空则跟随 pnpm 实际安装
346
+ 源)、`rawSources`(验证用的 package.json 源模板列表,`{repo}` 会替换成
347
+ owner/name,留空用内置的 jsDelivr + raw 双源)、`perPageMax`(搜索单页上限,
348
+ 1–30 的整数,默认 30)、`allowRestart`(是否允许一键重启,默认 `true`)。
349
+ 自动识别同时决定**启动恢复作用在哪个 profile 上**:能在此提交半装状态,
350
+ 凭据是「本次启动成功了」,而那只能证明启动的那个 profile 是好的。
329
351
 
330
352
  ## 发布
331
353
 
package/cordis.patch.yml CHANGED
@@ -5,8 +5,19 @@
5
5
  - id: dsh-plugin-mall
6
6
  name: '@1e0zj/dsh-plugin-mall'
7
7
  config:
8
- # The profile market_install targets when the caller names none.
9
- defaultProfile: web
8
+ # defaultProfile is deliberately NOT set here.
9
+ #
10
+ # This file ships inside the package and is read as a bundle layer by
11
+ # every profile that installs the marketplace, so anything written here
12
+ # is an explicit config value in ALL of them. Pinning `defaultProfile:
13
+ # web` (as this file used to) meant a marketplace running in any other
14
+ # profile still defaulted its installs at web — and the value looked
15
+ # like the user's own choice, so nothing could tell it apart from one.
16
+ #
17
+ # Left unset, the plugin follows the profile this process actually
18
+ # booted (derived from the loader's config-tree anchor). Set it only to
19
+ # override that:
20
+ # defaultProfile: web
10
21
  # GitHub REST API base (override for self-hosted mirrors/proxies).
11
22
  apiBase: https://api.github.com
12
23
  # npm registry for anti-squatting, update checks and the host-dependency
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -16,10 +16,10 @@
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";
@@ -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 →
@@ -1620,38 +1764,14 @@ function registerRpcChannel(ctx, config, token) {
1620
1764
  }
1621
1765
 
1622
1766
  export function apply(ctx, config = {}) {
1623
- 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
+ });
1624
1772
  const token = process.env.GITHUB_TOKEN ?? process.env.DSH_MARKET_GITHUB_TOKEN;
1625
1773
 
1626
- // Startup recovery. A pending install marker blocks every later install and
1627
- // uninstall in that profile until something resolves it — and until now the
1628
- // only thing that did was `guard launch`, a wrapper nobody uses: people type
1629
- // `dsh web`. One install then wedged the profile permanently, with an error
1630
- // telling users to run a CLI they have never heard of.
1631
- //
1632
- // Reaching `apply` IS the proof the pending install did not break the host:
1633
- // this code only runs because dsh booted far enough to compose the profile
1634
- // and load this plugin. So resolve the marker right here — recoverProfile
1635
- // commits when the profile validates and rolls back when it does not. The
1636
- // grace-window probation of `guard launch` stays strictly better (it also
1637
- // catches a crash seconds later); this is the floor for a plain start.
1638
- try {
1639
- const result = recoverProfile(resolveProfileDir(defaultProfile));
1640
- if (result.action === "committed") {
1641
- console.log(`[dsh-plugin-mall] startup recovery: committed the pending install for profile "${defaultProfile}"`);
1642
- } else if (result.action === "rolled-back") {
1643
- console.warn(`[dsh-plugin-mall] startup recovery: rolled back the pending install for profile "${defaultProfile}" — ${result.reason ?? "profile failed validation"}`);
1644
- // What the rebuild did, when it did anything. A rollback that relinked a
1645
- // package used to be silent, so a reconcile that silently no-opped and a
1646
- // fallback add that saved the profile looked exactly alike afterwards.
1647
- const rebuild = describeRollbackRebuild(result.rebuild);
1648
- if (rebuild !== undefined) console.warn(`[dsh-plugin-mall] startup recovery: node_modules rebuild — ${rebuild}`);
1649
- }
1650
- } catch (error) {
1651
- // 恢复失败绝不能拖垮插件加载:报出来,让市场照常可用(用户还能手动
1652
- // `dsh-plugin-guard guard recover`),而不是连界面都进不去。
1653
- console.error("[dsh-plugin-mall] startup recovery failed:", error);
1654
- }
1774
+ runStartupRecovery(runningProfile);
1655
1775
 
1656
1776
  ctx.systemPrompt.section({
1657
1777
  name: "tool:market",
@@ -1964,7 +2084,9 @@ export function apply(ctx, config = {}) {
1964
2084
  }),
1965
2085
  }));
1966
2086
 
1967
- registerRpcChannel(ctx, config, token);
2087
+ // 解析后的 defaultProfile 必须一起传下去:Web 侧和 Agent 侧共用同一个目标
2088
+ // profile,两边判定不能分叉。
2089
+ registerRpcChannel(ctx, { ...config, defaultProfile }, token);
1968
2090
  }
1969
2091
 
1970
2092
  // ── offline fixtures / self-test ────────────────────────────────────────────
@@ -2493,6 +2615,147 @@ export async function runSelfTests() {
2493
2615
  check("configId 缺失时可被识别(调用方据此拒绝写 patch)", loaderEntriesByPackage(fakeLoaderCtx([
2494
2616
  { id: "anon-1", options: { name: "no-id-pkg" }, disabled: false },
2495
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("静态校验未通过")));
2496
2759
  } finally {
2497
2760
  rmSync(root, { recursive: true, force: true });
2498
2761
  }