dsh-armory 2.2.1 → 2.3.1

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.
@@ -1,291 +0,0 @@
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
- }