dsh-armory 1.10.0 → 2.0.0

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.
@@ -0,0 +1,291 @@
1
+ // CodeBuddy 插件市场(T03/R5)—— sourceFor("plugins") 的 ZIP 数据源与目录投影。
2
+ //
3
+ // 数据链路(与连接器市场同一套形状,缓存目录不同):
4
+ // 1. 源:lib/wb-registry.js sourceFor("plugins") —— 内置默认
5
+ // codebuddy-plugins-official(https://download.codebuddy.cn/...zip),
6
+ // 用户注册表条目可覆盖;读不到源时抛错(不发明第二处硬编码)。
7
+ // 2. 缓存:~/.dsh/armory/plugin-market/ 下 plugin.zip + meta.json{etag,fetchedAt},
8
+ // ETag 命中就不重复下载(与 wb-market.js 的连接器缓存同构;目录挂在 armory
9
+ // 根下而不是 ~/.dsh/wb-market/,因为这是 armory 自己引入的市场)。
10
+ // 3. 解析(zip 内嵌套清单,实测形状):
11
+ // .codebuddy-plugin/marketplace.json —— 市场索引(plugins 数组,取插件目录名)
12
+ // external_plugins/<name>/.codebuddy-plugin/plugin.json —— 每个插件的清单,
13
+ // 投影 {name,description,author,repository,homepage,license,version,dependencies,lspServers}。
14
+ //
15
+ // 纪律:安装动作不在这里(客户端直接走官方 pluginManager.installBundle,T04 的 R8
16
+ // 才接 wb-install-state 记账);本文件只做「取数 + 投影」,零副作用除缓存写盘。
17
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { ZipReader } from "./wb-zip.js";
21
+ import { sourceFor } from "./wb-registry.js";
22
+
23
+ const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)";
24
+ const MAX_ZIP_BYTES = 64 * 1024 * 1024;
25
+
26
+ /** 缓存根目录:~/.dsh/armory/plugin-market(测试可用 options.rootDir 覆盖)。 */
27
+ function pluginMarketDir(options = {}) {
28
+ return join(options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory"), "plugin-market");
29
+ }
30
+ function zipPathOf(options) {
31
+ return join(pluginMarketDir(options), "plugin.zip");
32
+ }
33
+ function metaPathOf(options) {
34
+ return join(pluginMarketDir(options), "meta.json");
35
+ }
36
+
37
+ async function readJsonAt(path) {
38
+ try {
39
+ return JSON.parse(await readFile(path, "utf8"));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /** 模块级缓存:一次进程内只解析一次 zip(与 wb-market cachedZip 同构)。 */
46
+ let cachedPluginZip = null;
47
+
48
+ async function downloadPluginZip(sourceUrl, etag) {
49
+ const response = await fetch(sourceUrl, {
50
+ headers: { "user-agent": USER_AGENT, ...(etag ? { "if-none-match": etag } : {}) },
51
+ signal: AbortSignal.timeout(120_000),
52
+ });
53
+ if (response.status === 304 && etag) return null;
54
+ if (!response.ok) throw new Error(`插件市场下载失败:HTTP ${response.status}`);
55
+ const buffer = Buffer.from(await response.arrayBuffer());
56
+ if (buffer.length > MAX_ZIP_BYTES) throw new Error(`插件市场包超过 ${Math.round(MAX_ZIP_BYTES / 1024 / 1024)}MB 上限,疑似异常响应`);
57
+ return { buffer, etag: response.headers.get("etag") || "" };
58
+ }
59
+
60
+ /** 下载(或复用磁盘缓存)并解析插件市场 zip。返回 { zip, loadedFrom }。 */
61
+ async function loadPluginZip(options = {}) {
62
+ if (cachedPluginZip) return cachedPluginZip;
63
+ const source = await sourceFor("plugins", options);
64
+ const sourceUrl = source?.source?.url;
65
+ if (typeof sourceUrl !== "string" || !sourceUrl) throw new Error("注册表里没有可用的 plugins 市场源");
66
+ const dir = pluginMarketDir(options);
67
+ await mkdir(dir, { recursive: true });
68
+ const meta = (await readJsonAt(metaPathOf(options))) || {};
69
+ const writeThrough = async (fresh) => {
70
+ await writeFile(zipPathOf(options), fresh.buffer);
71
+ await writeFile(metaPathOf(options), JSON.stringify({ etag: fresh.etag, fetchedAt: Date.now() }));
72
+ };
73
+ let zip = null;
74
+ if (meta.etag) {
75
+ try {
76
+ const local = await readFile(zipPathOf(options));
77
+ zip = new ZipReader(local);
78
+ const fresh = await downloadPluginZip(sourceUrl, meta.etag);
79
+ if (fresh === null) {
80
+ cachedPluginZip = { zip, loadedFrom: "disk" };
81
+ return cachedPluginZip;
82
+ }
83
+ zip = new ZipReader(fresh.buffer);
84
+ await writeThrough(fresh);
85
+ cachedPluginZip = { zip, loadedFrom: "network" };
86
+ return cachedPluginZip;
87
+ } catch {
88
+ zip = null; // 磁盘缓存损坏 → 走完整重下
89
+ }
90
+ }
91
+ const fresh = await downloadPluginZip(sourceUrl, "");
92
+ zip = new ZipReader(fresh.buffer);
93
+ await writeThrough(fresh);
94
+ cachedPluginZip = { zip, loadedFrom: "network" };
95
+ return cachedPluginZip;
96
+ }
97
+
98
+ // ── 清单解析(嵌套投影)──────────────────────────────────────────────────
99
+
100
+ /** 字符串字段防御读取。 */
101
+ function asString(value, fallback = "") {
102
+ return typeof value === "string" ? value.trim() : fallback;
103
+ }
104
+
105
+ /**
106
+ * marketplace.json → 插件目录名列表。实测形状 { plugins: ["name-a", ...] };
107
+ * 兼容 { plugins: [{ name }] } 与 { plugins: { ... } }(对象键即目录名)两种变体。
108
+ * 畸形一律返回 [],不抛错。
109
+ */
110
+ export function pluginNamesFromMarketplace(indexJson) {
111
+ const raw = indexJson?.plugins;
112
+ if (Array.isArray(raw)) {
113
+ return raw
114
+ .map((item) => (typeof item === "string" ? item : asString(item?.name)))
115
+ .filter(Boolean);
116
+ }
117
+ if (raw && typeof raw === "object") return Object.keys(raw).filter(Boolean);
118
+ return [];
119
+ }
120
+
121
+ /** 去掉末尾斜杠并统一为 / 分隔(listByPrefix 是小写前缀匹配,路径形态无关紧要)。 */
122
+ /** zip 内插件清单的标准嵌套路径(导出给测试对形状做锚定)。 */
123
+ export function pluginJsonPath(name) {
124
+ return `external_plugins/${name}/.codebuddy-plugin/plugin.json`;
125
+ }
126
+
127
+ /**
128
+ * plugin.json → 面板投影。防御性提取:全部字段缺失不炸;dependencies 缺失 = []
129
+ * (R6:实测清单无该字段,出现时展示、绝不自动安装);lspServers 仅透传展示(X7)。
130
+ */
131
+ export function projectPlugin(name, pluginJson) {
132
+ const raw = pluginJson && typeof pluginJson === "object" ? pluginJson : {};
133
+ const deps = Array.isArray(raw.dependencies)
134
+ ? raw.dependencies
135
+ .map((dep) => (typeof dep === "string"
136
+ ? { name: dep.trim(), version: "" }
137
+ : { name: asString(dep?.name), version: asString(dep?.version || dep?.spec || "") }))
138
+ .filter((dep) => dep.name)
139
+ : [];
140
+ return {
141
+ name: asString(raw.name) || asString(name),
142
+ description: asString(raw.description),
143
+ author: asString(raw.author),
144
+ repository: asString(raw.repository),
145
+ homepage: asString(raw.homepage),
146
+ license: asString(raw.license),
147
+ version: asString(raw.version),
148
+ dependencies: deps,
149
+ lspServers: raw.lspServers && typeof raw.lspServers === "object" ? raw.lspServers : null,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * 从已解析的 ZipReader 枚举全部插件投影。先读市场索引拿目录名,再逐条读
155
+ * external_plugins/<name>/.codebuddy-plugin/plugin.json(缺清单的目录跳过并计数)。
156
+ */
157
+ export function readPluginCatalog(zip) {
158
+ const marketManifestName = ".codebuddy-plugin/marketplace.json";
159
+ if (!zip.has(marketManifestName)) {
160
+ return { plugins: [], skipped: 0 };
161
+ }
162
+ let indexJson = null;
163
+ try {
164
+ indexJson = JSON.parse(zip.extract(marketManifestName).toString("utf8"));
165
+ } catch {
166
+ return { plugins: [], skipped: 0 };
167
+ }
168
+ const names = pluginNamesFromMarketplace(indexJson);
169
+ const plugins = [];
170
+ let skipped = 0;
171
+ for (const name of names) {
172
+ const manifestPath = pluginJsonPath(name);
173
+ if (!zip.has(manifestPath)) {
174
+ skipped += 1;
175
+ continue;
176
+ }
177
+ try {
178
+ const pluginJson = JSON.parse(zip.extract(manifestPath).toString("utf8"));
179
+ plugins.push(projectPlugin(name, pluginJson));
180
+ } catch {
181
+ skipped += 1; // 单个插件清单损坏不拖垮整个目录
182
+ }
183
+ }
184
+ return { plugins, skipped };
185
+ }
186
+
187
+ /**
188
+ * 目录投影:catalog 返回 {ok, total, loadedFrom, plugins, skipped}。
189
+ * 投影只保留列表需要的字段(详情从 detail 再取全量),保持响应轻。
190
+ */
191
+ export async function wbPluginCatalog(options = {}) {
192
+ const { zip, loadedFrom } = await loadPluginZip(options);
193
+ const { plugins, skipped } = readPluginCatalog(zip);
194
+ return {
195
+ ok: true,
196
+ loadedFrom,
197
+ total: plugins.length,
198
+ skipped,
199
+ plugins: plugins.map((plugin) => ({
200
+ name: plugin.name,
201
+ description: plugin.description,
202
+ author: plugin.author,
203
+ repository: plugin.repository,
204
+ version: plugin.version,
205
+ license: plugin.license,
206
+ dependencies: plugin.dependencies,
207
+ })),
208
+ };
209
+ }
210
+
211
+ /**
212
+ * 单插件详情:全量投影(repository/homepage/lspServers 只在详情透出)。
213
+ * name 未给或 zip 里没有 → ok:false(不抛错,客户端按「不可用」渲染)。
214
+ */
215
+ export async function wbPluginDetail(name, options = {}) {
216
+ const wanted = String(name ?? "").trim();
217
+ if (!wanted) return { ok: false, error: "缺少插件名" };
218
+ const { zip, loadedFrom } = await loadPluginZip(options);
219
+ const manifestPath = pluginJsonPath(wanted);
220
+ if (!zip.has(manifestPath)) return { ok: false, error: `zip 里没有插件 ${wanted}` };
221
+ try {
222
+ const pluginJson = JSON.parse(zip.extract(manifestPath).toString("utf8"));
223
+ return { ok: true, loadedFrom, plugin: projectPlugin(wanted, pluginJson) };
224
+ } catch (error) {
225
+ return { ok: false, error: `插件清单解析失败:${error.message}` };
226
+ }
227
+ }
228
+
229
+ /** 供测试与未来 R8(T04)检查缓存是否在场;只读,不联网。 */
230
+ export async function wbPluginCachedInfo(options = {}) {
231
+ const meta = (await readJsonAt(metaPathOf(options))) || {};
232
+ return { hasCache: Boolean(meta.etag), etag: meta.etag || "", fetchedAt: meta.fetchedAt || 0 };
233
+ }
234
+
235
+ // writeAtomic 同款 staging+rename(本文件缓存写用直写即可,这里导出给测试复用形态)。
236
+ export async function writeCacheStaging(path, data) {
237
+ await mkdir(join(path, ".."), { recursive: true });
238
+ await writeFile(`${path}.staging`, data);
239
+ await rm(path, { force: true });
240
+ await rename(`${path}.staging`, path);
241
+ }
242
+
243
+ /**
244
+ * R9(T05):插件市场更新探测 —— 只做条件请求,**绝不落盘**。
245
+ *
246
+ * 判定口径(设计文档):ETag 与上次落盘的 meta.json 不一致即有更新;
247
+ * HTTP 304 = 无更新,HTTP 200 = 有更新。HEAD 不传包体做轻量探测;
248
+ * 服务端对 HEAD 返回 405/501 时降级为 GET,并立即 cancel() 丢弃响应体,
249
+ * 不把 zip 读进内存。纯探测:不写 zip、不写 meta.json、不更新内存缓存。
250
+ */
251
+ export async function wbPluginUpdateCheck(options = {}) {
252
+ try {
253
+ const source = await sourceFor("plugins", options);
254
+ const sourceUrl = source?.source?.url;
255
+ const meta = (await readJsonAt(metaPathOf(options))) || {};
256
+ const localEtag = meta.etag || "";
257
+ if (typeof sourceUrl !== "string" || !sourceUrl) {
258
+ // 市场源检查必须先于 no-cache 分支:市场没配置时绝不能提示「有新版本」。
259
+ return { ok: false, hasUpdate: false, reason: "no-source", localEtag, remoteEtag: "", checkedAt: Date.now(), error: "注册表里没有可用的 plugins 市场源" };
260
+ }
261
+ if (!localEtag) {
262
+ // 本地没缓存过 → 无法比较,视为有更新,引导用户首次加载(纯探测仍不下载)。
263
+ return { ok: true, hasUpdate: true, reason: "no-cache", localEtag: "", remoteEtag: "", checkedAt: Date.now() };
264
+ }
265
+ const headers = { "user-agent": USER_AGENT, "if-none-match": localEtag };
266
+ let response;
267
+ try {
268
+ response = await fetch(sourceUrl, { method: "HEAD", headers, signal: AbortSignal.timeout(30_000) });
269
+ if (response.status === 405 || response.status === 501) {
270
+ // HEAD 不被支持 → 降级 GET,立刻 cancel() 丢弃响应体,不读进内存。
271
+ response.body?.cancel?.();
272
+ response = await fetch(sourceUrl, { method: "GET", headers, signal: AbortSignal.timeout(30_000) });
273
+ response.body?.cancel?.();
274
+ }
275
+ } catch (error) {
276
+ return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `更新探测失败:${error.message}` };
277
+ }
278
+ if (response.status === 304) {
279
+ // 304:ETag 命中,服务端不回显 ETag;回显 localEtag 便于客户端展示。
280
+ return { ok: true, hasUpdate: false, reason: "up-to-date", localEtag, remoteEtag: localEtag, checkedAt: Date.now() };
281
+ }
282
+ if (response.ok) {
283
+ const remoteEtag = response.headers.get("etag") || "";
284
+ const hasUpdate = remoteEtag !== localEtag;
285
+ return { ok: true, hasUpdate, reason: hasUpdate ? "update-available" : "up-to-date", localEtag, remoteEtag, checkedAt: Date.now() };
286
+ }
287
+ return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `插件市场更新探测失败:HTTP ${response.status}` };
288
+ } catch (error) {
289
+ return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag: "", remoteEtag: "", checkedAt: Date.now(), error: `更新探测异常:${error.message}` };
290
+ }
291
+ }
@@ -0,0 +1,224 @@
1
+ // 多市场注册表(R4)—— ~/.dsh/armory/marketplaces.json 的读写层。
2
+ //
3
+ // 职责(设计文档 docs/system_design.md 8.3 / 类图 MarketRegistry):
4
+ // - 注册表文件不存在或损坏时,回落到**内置默认两条**:连接器源(与 lib/wb-market.js:27
5
+ // 的 CONNECTOR_ZIP_URL 保持同一现值,禁止在此发明第二处硬编码真相)与
6
+ // codebuddy-plugins-official 插件源;首读时把默认项落盘。
7
+ // - 合并投影:内置默认永远在场(文件内同 id 条目可覆盖其字段),用户自定义条目追加其后。
8
+ // - 增删改一律 staging + rename 原子替换(沿用 wbSkillInstall 的 staging 模式)。
9
+ //
10
+ // 纯数据层:不做任何网络请求,不依赖 Remote/UI。测试可通过 options.rootDir 或
11
+ // 环境变量 DSH_ARMORY_HOME 把存储根目录指到临时目录,绝不碰真实 ~/.dsh。
12
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ // 与 lib/wb-market.js:27 现值一致(改动连接器源时两处必须同步,注册表化接入后此值
17
+ // 仅作为「内置默认项」的数据来源,不再被 wb-market 直接消费)。
18
+ const CONNECTOR_ZIP_URL = "https://static.workbuddy.cn/connectors-config-v2/connectors-config.zip";
19
+
20
+ export const REGISTRY_VERSION = 1;
21
+
22
+ /** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory。 */
23
+ function armoryRoot(options = {}) {
24
+ return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
25
+ }
26
+
27
+ /** 注册表文件绝对路径。 */
28
+ export function registryPath(options = {}) {
29
+ return join(armoryRoot(options), "marketplaces.json");
30
+ }
31
+
32
+ /**
33
+ * 内置默认市场条目(代码内声明,首读落盘)。顺序即 sourceFor 的优先序:
34
+ * 1. connectors → 现有 connectors-config.zip(WorkBuddy 连接器市场分发地址)
35
+ * 2. plugins → codebuddy-plugins-official(清单实测在 .codebuddy-plugin/marketplace.json)
36
+ */
37
+ export function builtinMarketplaces() {
38
+ return [
39
+ {
40
+ id: "workbuddy-connectors",
41
+ kind: "connectors",
42
+ manifestName: "workbuddy-connectors",
43
+ type: "zip",
44
+ source: { url: CONNECTOR_ZIP_URL, path: "" },
45
+ manifestPath: ".codebuddy-connector/connectors.json",
46
+ autoUpdate: false,
47
+ isBuiltIn: true,
48
+ description: "WorkBuddy 官方连接器市场(connectors-config.zip)",
49
+ addedAt: 0,
50
+ lastUpdated: 0,
51
+ },
52
+ {
53
+ id: "codebuddy-plugins-official",
54
+ kind: "plugins",
55
+ manifestName: "codebuddy-plugins-official",
56
+ type: "zip",
57
+ source: {
58
+ url: "https://download.codebuddy.cn/plugin-marketplace/codebuddy-plugins-official-4b9366ac-f7e5-4cef-b60a-99a5229ce2b2.zip",
59
+ path: "",
60
+ },
61
+ manifestPath: ".codebuddy-plugin/marketplace.json",
62
+ autoUpdate: false,
63
+ isBuiltIn: true,
64
+ description: "CodeBuddy 官方插件市场",
65
+ addedAt: 0,
66
+ lastUpdated: 0,
67
+ },
68
+ ];
69
+ }
70
+
71
+ const KINDS = new Set(["connectors", "plugins"]);
72
+
73
+ /** 字符串字段防御读取:非字符串一律回落默认值。 */
74
+ function asString(value, fallback = "") {
75
+ return typeof value === "string" ? value : fallback;
76
+ }
77
+
78
+ /** 布尔字段防御读取。 */
79
+ function asBool(value, fallback = false) {
80
+ return typeof value === "boolean" ? value : fallback;
81
+ }
82
+
83
+ /** 数字时间戳防御读取:非有限非负数一律回落 fallback。 */
84
+ function asTimestamp(value, fallback = 0) {
85
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
86
+ }
87
+
88
+ /**
89
+ * 把任意原始对象规范成一条完整的 MarketplaceEntry:缺失字段防御性补全
90
+ * (缺 addedAt/lastUpdated 补 0、缺 description 补空串等),畸形条目不炸调用方。
91
+ */
92
+ export function normalizeEntry(raw, { isBuiltIn = false } = {}) {
93
+ const source = raw && typeof raw.source === "object" && raw.source !== null ? raw.source : {};
94
+ return {
95
+ id: asString(raw?.id).trim(),
96
+ kind: KINDS.has(raw?.kind) ? raw.kind : "connectors",
97
+ manifestName: asString(raw?.manifestName, asString(raw?.id).trim()),
98
+ type: raw?.type === "directory" ? "directory" : "zip",
99
+ source: { url: asString(source.url), path: asString(source.path) },
100
+ manifestPath: asString(raw?.manifestPath),
101
+ autoUpdate: asBool(raw?.autoUpdate, false),
102
+ isBuiltIn: asBool(raw?.isBuiltIn, isBuiltIn),
103
+ description: asString(raw?.description),
104
+ addedAt: asTimestamp(raw?.addedAt, 0),
105
+ lastUpdated: asTimestamp(raw?.lastUpdated, 0),
106
+ };
107
+ }
108
+
109
+ /** 从磁盘读原始文档;不存在/损坏一律返回 null(由调用方决定回落语义)。 */
110
+ async function readRawDoc(path) {
111
+ try {
112
+ return JSON.parse(await readFile(path, "utf8"));
113
+ } catch {
114
+ return null; // ENOENT / ENOTDIR / JSON 解析失败同途处理
115
+ }
116
+ }
117
+
118
+ /** staging + rename 原子写:先写 `<file>.staging`,再删旧文件换入(Windows rename 不覆盖)。 */
119
+ async function writeAtomic(path, doc) {
120
+ await mkdir(armoryPathDirname(path), { recursive: true });
121
+ await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
122
+ await rm(path, { force: true });
123
+ await rename(`${path}.staging`, path);
124
+ }
125
+
126
+ /** 拆出目录部分(避免为 dirname 多引一个依赖,路径既有 / 也有 \ 的 Windows 形态)。 */
127
+ function armoryPathDirname(path) {
128
+ const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
129
+ return cut > 0 ? path.slice(0, cut) : path;
130
+ }
131
+
132
+ /**
133
+ * 读注册表并返回合并投影:
134
+ * - 文件不存在 / 解析失败 / marketplaces 非数组 → 内置默认两条(并尽力落盘);
135
+ * - 文件内与内置默认同 id 的条目**覆盖**默认项字段;
136
+ * - 其余文件条目按原顺序追加在默认项之后。
137
+ * 返回 { version, marketplaces },永不抛错。
138
+ */
139
+ export async function readRegistry(options = {}) {
140
+ const path = registryPath(options);
141
+ const raw = await readRawDoc(path);
142
+ const fileEntries = Array.isArray(raw?.marketplaces)
143
+ ? raw.marketplaces.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
144
+ : null;
145
+
146
+ if (fileEntries === null) {
147
+ // 首读落盘:把内置默认写进文件,失败(只读盘等)不影响返回值。
148
+ const defaults = builtinMarketplaces();
149
+ try {
150
+ await writeAtomic(path, { version: REGISTRY_VERSION, marketplaces: defaults });
151
+ } catch {
152
+ /* 落盘失败不致命:下次读仍回落内置默认 */
153
+ }
154
+ return { version: REGISTRY_VERSION, marketplaces: defaults };
155
+ }
156
+
157
+ const normalized = fileEntries.map((entry) => normalizeEntry(entry));
158
+ const byId = new Map(normalized.map((entry) => [entry.id, entry]));
159
+ const merged = builtinMarketplaces().map(
160
+ (builtin) => (byId.has(builtin.id) ? byId.get(builtin.id) : builtin),
161
+ );
162
+ const builtinIds = new Set(builtinMarketplaces().map((entry) => entry.id));
163
+ for (const entry of normalized) {
164
+ if (!builtinIds.has(entry.id)) merged.push(entry);
165
+ }
166
+ const version = raw && typeof raw.version === "number" ? raw.version : REGISTRY_VERSION;
167
+ return { version, marketplaces: merged };
168
+ }
169
+
170
+ /**
171
+ * 新增或更新一条市场条目(按 id 定位)。新条目补 addedAt,任何写入都刷新 lastUpdated。
172
+ * 用户 upsert 内置同 id 条目即形成覆盖记录(删掉后内置默认回归)。
173
+ * 返回写入后的合并投影。
174
+ */
175
+ export async function upsertMarketplace(entry, options = {}) {
176
+ const normalized = normalizeEntry(entry);
177
+ if (!normalized.id) throw new Error("市场条目缺少 id,无法登记");
178
+ const path = registryPath(options);
179
+ const raw = await readRawDoc(path);
180
+ const list = Array.isArray(raw?.marketplaces)
181
+ ? raw.marketplaces.filter((item) => item && typeof item === "object" && !Array.isArray(item))
182
+ : [];
183
+ const now = Date.now();
184
+ const existingIndex = list.findIndex((item) => normalizeEntry(item).id === normalized.id);
185
+ if (existingIndex >= 0) {
186
+ const existing = normalizeEntry(list[existingIndex]);
187
+ list[existingIndex] = {
188
+ ...normalized,
189
+ addedAt: existing.addedAt || now,
190
+ lastUpdated: now,
191
+ };
192
+ } else {
193
+ list.push({ ...normalized, addedAt: normalized.addedAt || now, lastUpdated: now });
194
+ }
195
+ await writeAtomic(path, { version: REGISTRY_VERSION, marketplaces: list });
196
+ return readRegistry(options);
197
+ }
198
+
199
+ /**
200
+ * 按 id 删除一条**文件内**条目。内置默认不受影响:删除同 id 的覆盖记录后,
201
+ * 合并投影里内置默认回归。id 不存在时为无害空操作。
202
+ * 返回写入后的合并投影。
203
+ */
204
+ export async function removeMarketplace(id, options = {}) {
205
+ const target = String(id ?? "").trim();
206
+ if (!target) throw new Error("缺少要删除的市场 id");
207
+ const path = registryPath(options);
208
+ const raw = await readRawDoc(path);
209
+ if (!Array.isArray(raw?.marketplaces)) {
210
+ return readRegistry(options); // 文件本就没有用户条目,直接回落投影
211
+ }
212
+ const list = raw.marketplaces.filter(
213
+ (item) => !(item && typeof item === "object" && normalizeEntry(item).id === target),
214
+ );
215
+ await writeAtomic(path, { version: REGISTRY_VERSION, marketplaces: list });
216
+ return readRegistry(options);
217
+ }
218
+
219
+ /** 取某个 kind 的第一条市场源(合并投影序 = 内置默认优先);没有则返回 null。 */
220
+ export async function sourceFor(kind, options = {}) {
221
+ const wanted = String(kind ?? "").trim();
222
+ const { marketplaces } = await readRegistry(options);
223
+ return marketplaces.find((entry) => entry.kind === wanted) ?? null;
224
+ }
package/lib/whats-new.js CHANGED
@@ -333,4 +333,41 @@ export const WHATS_NEW = [
333
333
  },
334
334
  ],
335
335
  },
336
+ {
337
+ version: '2.0.0',
338
+ items: [
339
+ {
340
+ text: '**来了第三个市场源:插件页**:新开「插件」页,把三类来源并进一个可搜索、可筛选的目录——**官方**(DSH 宿主自带组件,随宿主分发、不可单独安装)、**CodeBuddy 官方**(ZIP 分发)、**DSH 社区**(`awesome-dsh-plugins` 社区清单,jsdelivr 走 CDN)。安装统一走宿主 `pluginManager` 拉取落盘,**装完需重启宿主才生效**;宿主没提供 `pluginManager` 时会明确报错而不是装作成功。条目没给 npm 包名 / 没有 repository 的,文案直说「暂不支持安装」「仅展示」,不做猜的安装',
341
+ where: '面板「插件」页',
342
+ },
343
+ {
344
+ text: '**需要 token 的连接器,现在安装时就让你填**:安装前自动扫出 `mcpServers` 里的 `${...}` 占位符,弹「填写 {连接器} 的凭据」逐槽填写后再装;留空表示稍后自行补全,装完还会提示「另有 N 个占位符未填写,配置里保留原样」。没有占位符的连接器直接放行,不多弹一层',
345
+ where: 'MCP 市场 → 任一带占位符的连接器 → 安装',
346
+ },
347
+ {
348
+ text: '**MCP OAuth 一键授权**:以前远端 MCP 只认 `requestInit.headers`、没有 authProvider,OAuth 根本装不上,只能手搓 token。现在按 RFC 9728 / 8414 / 7591 / 7636 / 8707 走完整流程:发现受保护资源与授权服务器 → 动态客户端注册 → PKCE(S256) → 开浏览器授权 → 拿 code 换 token → 写进 patch 的 headers,到期自动刷新。回调通道**三级降级**:本地 loopback 自动收(主流实现)→ 服务器只接受自定义 scheme 时粘贴整条回调地址 → 都不通就直接贴 Bearer token',
349
+ where: 'MCP 市场 → 返回 401 的连接器 → 授权',
350
+ },
351
+ {
352
+ text: '**安装状态账本 + 跨页「已安装」筛选**:新增 `~/.dsh/armory/install-state.json` 记录每个连接器 / 技能 / 插件是谁装的、来自哪个市场,市场页据此把「本插件装的」和「别处来的」分开,并支持**跨页**筛出已安装项(技能侧仍只筛当前页,两处文案已分别说明)。登记失败不会让安装失败,只补一句「已安装,但状态登记失败」',
353
+ where: '市场各页顶部筛选 / 已安装计数',
354
+ },
355
+ {
356
+ text: '**技能看板会分类了**:技能按 **编程开发 / 设计与创作 / 文档办公 / 效率与自动化 / 开发运维 / 未分类** 六类归置,网格与列表双视图可切,可按分类过滤;详情新增所属分类、来源目录、文件位置,并能一键「复制 SKILL.md 路径」「复制 @会话引用」',
357
+ where: '技能标签页',
358
+ },
359
+ {
360
+ text: '**社区目录秒开**:上次成功的社区清单镜像进浏览器 `localStorage`(7 天 / 4MB 上限,超配额就保留旧快照),首屏先渲染快照、再被真实结果静默替换,**0 网络 0 IPC**;源不可达时明确写「目录缓存已过期,显示的是本地缓存」,而不是给一个空列表',
361
+ where: '插件 → DSH 社区',
362
+ },
363
+ {
364
+ text: '**安装轻提示 + 注入规则只读展示**:安装过程挂一个不随面板卸载而消失的浮层(纯 DOM、只写 `textContent`,不走 `createPortal`,top 64px);连接器详情里解析 `connectors.json` 的 `auth_injection_rules`,标出哪些连接器依赖 WB 账号体系「代注入」——**DSH 不代注入,只解析展示**,两者边界在代码注释与 UI 上都写明',
365
+ where: '任意安装动作 / MCP → 连接器详情',
366
+ },
367
+ {
368
+ text: '**多市场注册表**:市场源不再硬编码,落到 `~/.dsh/armory/marketplaces.json`(缺失或损坏回落内置默认项,首读即落盘),支持增删改;写盘一律 staging + rename 原子替换,避免半截文件',
369
+ where: '—(`~/.dsh/armory/marketplaces.json`)',
370
+ },
371
+ ],
372
+ },
336
373
  ]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "1.10.0",
4
- "description": "Unified Skill + MCP + Expert board for DeepSeek Harness (desktop app and Web GUI) — global panel page, JSON import, WorkBuddy connector & SkillHub & Expert market installs, tool-usage tailor, and profile-backed Host Remote management.",
3
+ "version": "2.0.0",
4
+ "description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & CodeBuddy plugin & awesome-dsh-plugins market installs, MCP OAuth authorization, tool-usage tailor, and profile-backed Host Remote management.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/2026heshao/dsh-armory.git"