@1e0zj/dsh-plugin-mall 0.3.3 → 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/README.md +11 -5
- package/cordis.patch.yml +13 -2
- package/package.json +1 -1
- package/src/index.js +298 -35
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
|
|
|
@@ -137,6 +137,8 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
|
|
|
137
137
|
|
|
138
138
|
**dsh 插件市场** — 搜索 GitHub `dsh-plugin` 话题下的 DeepSeek Harness 插件仓库,自动验证哪些是真 dsh 插件,一键安装与更新。
|
|
139
139
|
|
|
140
|
+
[路线图](ROADMAP.md)(安全 > 便捷 > 精简,以及明确不做的)
|
|
141
|
+
|
|
140
142
|
与策展列表不同:**任何打上 `topic:dsh-plugin` 的仓库推送后立即可被发现**——无需投稿、无需审批。为保证开放性可用,做了这些事:
|
|
141
143
|
|
|
142
144
|
- **自动验证**:逐仓库拉取 `package.json`(jsDelivr/raw 双源 CDN,不占 API 配额),按官方 `dsh.bundle` / `dsh.client` 声明打徽章;默认"只看已验证"视图过滤约 73% 的话题噪音
|
|
@@ -322,10 +324,14 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
|
|
|
322
324
|
三处。`allowBuilds` 是持久化的安全配置,所以安装最终失败时这次放宽会被
|
|
323
325
|
**撤销** —— 否则一个没装成的插件会让那个包名从此静默获得构建脚本执行权。
|
|
324
326
|
- 配置(`cordis.patch.yml` 中可改):`defaultProfile`(默认装进哪个 profile,
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
`
|
|
327
|
+
**不配则跟随本进程实际启动的那个** —— 从 loader 的配置树锚点 `ctx.baseUrl`
|
|
328
|
+
推导,那正是 profile 目录;推不出来才退回 `web` 并打日志)、`apiBase`
|
|
329
|
+
(GitHub API 地址)、`npmRegistry`(npm 查询源,留空则跟随 pnpm 实际安装
|
|
330
|
+
源)、`rawSources`(验证用的 package.json 源模板列表,`{repo}` 会替换成
|
|
331
|
+
owner/name,留空用内置的 jsDelivr + raw 双源)、`perPageMax`(搜索单页上限,
|
|
332
|
+
1–30 的整数,默认 30)、`allowRestart`(是否允许一键重启,默认 `true`)。
|
|
333
|
+
自动识别同时决定**启动恢复作用在哪个 profile 上**:能在此提交半装状态,
|
|
334
|
+
凭据是「本次启动成功了」,而那只能证明启动的那个 profile 是好的。
|
|
329
335
|
|
|
330
336
|
## 发布
|
|
331
337
|
|
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
|
-
#
|
|
9
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|