@zaimokuza/dsh-plugin-hub 0.1.2 → 0.2.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.
@@ -0,0 +1,346 @@
1
+ import { readFile, mkdir, realpath } from 'node:fs/promises';
2
+ import { join, relative, isAbsolute } from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { spawn } from 'node:child_process';
5
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
6
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
7
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
8
+
9
+ import { parseMcpDocument, formatMcpDocument, parseConfigText, resolveEnvironment } from './mcp-config.js';
10
+
11
+ import { findMcpDefinition, deleteMcpDefinition, atomicWrite } from './profile-files.js';
12
+
13
+ const MCP = '@deepseek-ai/dsh-mcp-client';
14
+ const fingerprint = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
15
+ const inside = (root, path) => { const rel = relative(root, path); return !rel.startsWith('..') && !isAbsolute(rel); };
16
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
17
+ const safeIcon = value => { try { const url = new URL(value); return url.protocol === 'https:' && !url.username && !url.password && !url.search ? url.href : null; } catch { return null; } };
18
+ const safeUrl = value => { try { const u = new URL(value); return `${u.protocol}//${u.host}${u.pathname}`; } catch { return ''; } };
19
+
20
+ /** Card metadata excludes credentials, executable arguments and URL queries. */
21
+ export function describeMcp(config = {}) {
22
+ return { name: typeof config.serverName === 'string' ? config.serverName : 'MCP', transport: typeof config.transport === 'string' ? config.transport : 'unknown',
23
+ endpoint: config.transport === 'stdio' ? (typeof config.command === 'string' ? config.command : '') : safeUrl(config.url),
24
+ };
25
+ }
26
+
27
+ export function validateMcp(input) {
28
+ if (!record(input) || !/^[A-Za-z0-9_-]{1,32}$/.test(input.serverName)) throw new Error('MCP 名称只允许 1–32 位字母、数字、下划线和连字符');
29
+ const common = ['serverName', 'transport', 'toolCallTimeoutMs', 'failOnStartupError', 'reconnect'];
30
+ const allowed = [...common, ...(input.transport === 'stdio' ? ['command', 'args', 'env', 'cwd'] : ['url', 'headers'])];
31
+ if (Object.keys(input).some(key => !allowed.includes(key))) throw new Error('配置包含宿主不支持的字段,请核对 MCP 配置');
32
+ const config = { serverName: input.serverName, transport: input.transport };
33
+ if (input.transport === 'stdio') {
34
+ if (typeof input.command !== 'string' || !input.command.trim() || input.command.includes('\0')) throw new Error('需要有效的可执行命令');
35
+ config.command = input.command;
36
+ config.args = input.args ?? [];
37
+ if (!Array.isArray(config.args) || config.args.some(x => typeof x !== 'string')) throw new Error('args 必须是字符串数组');
38
+ config.env = input.env ?? {};
39
+ config.cwd = input.cwd ?? '';
40
+ if (typeof config.cwd !== 'string' || (config.cwd && !isAbsolute(config.cwd))) throw new Error('cwd 必须是绝对路径');
41
+ } else if (input.transport === 'streamable-http') {
42
+ let url; try { url = new URL(input.url); } catch { throw new Error('需要有效的 MCP URL'); }
43
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('MCP URL 必须使用 HTTP(S),凭据请放入 headers');
44
+ config.url = url.href; config.headers = input.headers ?? {};
45
+ } else throw new Error('不支持的 MCP 协议');
46
+ for (const map of [config.env, config.headers].filter(Boolean)) {
47
+ if (!record(map) || Object.values(map).some(x => typeof x !== 'string')) throw new Error('env / headers 必须是字符串键值对象');
48
+ }
49
+ config.toolCallTimeoutMs = input.toolCallTimeoutMs ?? 60000;
50
+ if (!Number.isInteger(config.toolCallTimeoutMs) || config.toolCallTimeoutMs < 1000 || config.toolCallTimeoutMs > 300000) throw new Error('超时时间必须在 1000–300000 毫秒之间');
51
+ config.failOnStartupError = input.failOnStartupError ?? false;
52
+ if (typeof config.failOnStartupError !== 'boolean') throw new Error('failOnStartupError 必须是布尔值');
53
+ if (input.reconnect !== undefined) {
54
+ if (!record(input.reconnect) || Object.keys(input.reconnect).some(key => !['enabled', 'initialDelayMs', 'maxDelayMs', 'maxAttempts'].includes(key))) throw new Error('reconnect 配置无效');
55
+ config.reconnect = { ...input.reconnect };
56
+ for (const [key, value] of Object.entries(config.reconnect)) {
57
+ if (key === 'enabled' ? typeof value !== 'boolean' : !Number.isSafeInteger(value) || value < 1 || (key !== 'maxAttempts' && value > 2147483647)) throw new Error('reconnect 配置无效');
58
+ }
59
+ if (config.reconnect.initialDelayMs && config.reconnect.maxDelayMs && config.reconnect.initialDelayMs > config.reconnect.maxDelayMs) throw new Error('重连初始间隔不能超过最大间隔');
60
+ }
61
+ return config;
62
+ }
63
+
64
+ /** Native resource adapter. Its durable state belongs exclusively to the boot profile. */
65
+ export class NativeResources {
66
+ constructor(ctx, environment, directory, inventory) {
67
+ Object.assign(this, { ctx, environment, directory, inventory });
68
+ this.file = join(directory, 'resources.json');
69
+ this.state = { version: 1, skills: [], mcps: [], overrides: {} };
70
+ this.tail = Promise.resolve(); this.fibers = new Map(); this.applied = new WeakMap(); this.probes = new Map(); this.loadErrors = new Map();
71
+ this.providerName = `plugin-hub-${fingerprint(directory).slice(0, 12)}`;
72
+ this.invalidators = new Set(); this.invalidate = () => { for (const invalidate of this.invalidators) invalidate(); }; this.closed = false; this.scopes = new Map(); this.entryIds = new WeakMap(); this.lifecycle = new AbortController();
73
+ }
74
+ async init() {
75
+ try { this.state = JSON.parse(await readFile(this.file, 'utf8')); }
76
+ catch (error) { if (error.code !== 'ENOENT') throw new Error('Hub 资源配置不可读取,已停止管理,原文件未改动', { cause: error }); }
77
+ if (this.state.version !== 1 || !Array.isArray(this.state.skills) || !Array.isArray(this.state.mcps) || !record(this.state.overrides)) throw new Error('Hub 资源配置格式无效');
78
+ this.registerPolicy(this.ctx);
79
+ if (this.ctx.agentPresets) {
80
+ this.scopeApi = await this.ctx.loader.import('@deepseek-ai/dsh-scope');
81
+ this.presetApi = await this.ctx.loader.import('@deepseek-ai/dsh-agent-presets');
82
+ this.ctx.on('agent/created', ({ agent }) => {
83
+ if (this.ctx.agentPresets.composedPreset(agent.ctx) !== undefined) this.registerScope(this.scopeApi.scopeOf(agent.ctx));
84
+ });
85
+ this.ctx.on('agent/disposed', ({ agent }) => {
86
+ const key = this.scopeApi.scopeOf(agent.ctx); const scope = this.scopes.get(key);
87
+ if (scope) { this.scopes.delete(key); void scope.dispose(); }
88
+ });
89
+ this.attachMountedPolicies();
90
+ }
91
+ // Start after this plugin's activation; awaiting loader readiness here would deadlock boot.
92
+ this.timer = setInterval(() => { void this.serial(() => this.reconcile()).catch(() => {}); }, 2000);
93
+ this.timer.unref?.();
94
+ }
95
+ registerPolicy(ctx) {
96
+ if (!ctx.skills?.registerProvider) return;
97
+ ctx.skills.registerProvider(control => {
98
+ this.invalidators.add(control.invalidate);
99
+ control.signal.addEventListener('abort', () => this.invalidators.delete(control.invalidate), { once: true });
100
+ return { name: this.providerName,
101
+ list: ({ cwd } = {}) => this.state.skills.filter(row => !row.cwd || (cwd && inside(row.cwd, cwd))).map(row => ({
102
+ ...row.summary, provider: this.providerName, source: 'custom', rank: -1e9, locator: row.name,
103
+ invocation: { modelInvocable: false, userInvocable: false },
104
+ })),
105
+ get: async candidate => ({ ...candidate, content: '', invocation: { modelInvocable: false, userInvocable: false } }),
106
+ };
107
+ });
108
+ }
109
+ registerScope(key) {
110
+ if (!key || this.scopes.has(key)) return;
111
+ const scope = this.scopeApi.createScope(this.ctx, key);
112
+ this.scopes.set(key, scope); this.registerPolicy(scope.ctx);
113
+ }
114
+ mounts() { return this.presetApi?.livePresetMounts?.(this.ctx.root.fiber) ?? []; }
115
+ attachMountedPolicies() { for (const mount of this.mounts()) this.registerScope(mount.key); }
116
+ async view(presetId) {
117
+ if (!this.ctx.agentPresets) return { presets: [] };
118
+ const inventory = await this.ctx.agentPresets.compositionInventory();
119
+ const presets = inventory.filter(preset => !preset.broken && preset.rows.some(row => row.moduleName === '@deepseek-ai/dsh-skill-filesystem' || row.moduleName === MCP || row.moduleName.startsWith('@deepseek-ai/dsh-tool-'))).map(row => ({ id: row.id, name: row.name ?? row.id }));
120
+ const id = presetId || (inventory.find(row => row.id === this.ctx.agentPresets.defaultId && !row.broken && row.rows.some(item => item.moduleName === '@deepseek-ai/dsh-skill-filesystem'))?.id ?? inventory.find(row => !row.broken && row.rows.some(item => item.moduleName === '@deepseek-ai/dsh-skill-filesystem'))?.id ?? presets[0]?.id);
121
+ if (!id) return { presets };
122
+ if (!presets.some(row => row.id === id)) throw new Error('此预设没有原生 Skill 或 MCP 资源');
123
+ const scope = await this.ctx.agentPresets.standingKeyFor(id);
124
+ this.registerScope(scope); return { scope, presetId: id, presets };
125
+ }
126
+
127
+ serial(run) { const task = this.tail.then(() => { if (this.closed) throw new Error('Hub 正在关闭'); return run(); }); this.tail = task.catch(() => {}); return task; }
128
+ async save(next) {
129
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
130
+ if (!inside(this.environment.profileDir, await realpath(this.directory))) throw new Error('Hub 配置目录不在当前 profile 内');
131
+ await atomicWrite(this.file, JSON.stringify(next, null, 2) + '\n');
132
+ this.state = next; this.invalidate();
133
+ }
134
+ workspaces() { return this.ctx.workspaceRegistry?.list?.().map(({ id, title, path }) => ({ id, title, path })) ?? []; }
135
+ workspace(id) {
136
+ if (!id) return undefined;
137
+ const item = this.workspaces().find(w => w.id === id);
138
+ if (!item) throw new Error('当前实例没有这个工作区,请刷新');
139
+ return item;
140
+ }
141
+ entries() {
142
+ const root = [...(this.ctx.loader?.entries?.() ?? [])];
143
+ for (const entry of root) this.entryIds.set(entry, entry.id);
144
+ const scoped = this.mounts().flatMap(mount => [...mount.tree.entries()].map(entry => { this.entryIds.set(entry, `preset:${mount.presetId}:${entry.id}`); return entry; }));
145
+ return [...root, ...scoped].filter(entry => entry.options.name === MCP);
146
+ }
147
+ entryId(entry) { return this.entryIds.get(entry) ?? entry.id; }
148
+ async reconcile() {
149
+ this.attachMountedPolicies();
150
+ for (const row of this.state.mcps) {
151
+ if (row.enabled && !this.fibers.has(row.id)) {
152
+ try {
153
+ const module = await this.ctx.loader.import(MCP);
154
+ this.fibers.set(row.id, this.ctx.plugin(module, validateMcp(resolveEnvironment(row.config))));
155
+ this.loadErrors.delete(row.id);
156
+ } catch (error) { this.loadErrors.set(row.id, error.message); }
157
+ }
158
+ }
159
+ for (const entry of this.entries()) {
160
+ const override = this.state.overrides[this.entryId(entry)];
161
+ if (!override || entry._initTask || this.applied.get(entry) === fingerprint({ override, options: entry.options })) continue;
162
+ // Entry.update changes the live node only. Tree.update would rewrite a shipped bundle.
163
+ await entry.update({ disabled: !override.enabled, ...(override.config ? { config: override.replaceConfig ? override.config : { ...entry.options.config, ...override.config } } : {}) }, false, true);
164
+ this.applied.set(entry, fingerprint({ override, options: entry.options }));
165
+ }
166
+ }
167
+ async snapshot(workspaceId, presetId) {
168
+ const workspace = this.workspace(workspaceId);
169
+ const view = await this.view(presetId);
170
+ const options = { scope: view.scope, ...(workspace ? { cwd: workspace.path } : {}) };
171
+ const catalog = this.ctx.skills?.snapshot ? await this.ctx.skills.snapshot(options) : { skills: [], complete: false };
172
+ const skills = catalog.skills.map(summary => {
173
+ const disabled = this.state.skills.find(row => row.name === summary.name && (!row.cwd || (workspace && inside(row.cwd, workspace.path))));
174
+ const original = summary.provider === this.providerName && disabled ? disabled.summary : summary;
175
+ return { id: summary.name, name: summary.name, description: original.description, source: original.source,
176
+ provider: original.provider, directory: original.resourceBase?.kind === 'directory' ? original.resourceBase.path : null,
177
+ enabled: summary.invocation.modelInvocable || summary.invocation.userInvocable,
178
+ managed: Boolean(disabled), inherited: Boolean(disabled && disabled.cwd !== workspace?.path && disabled.cwd !== undefined),
179
+ canToggle: Boolean(this.ctx.skills?.registerProvider) && (!disabled || (disabled.cwd ?? null) === (workspace?.path ?? null)),
180
+ };
181
+ });
182
+ const mcps = [
183
+ ...this.entries().map(entry => ({ id: this.entryId(entry), ...describeMcp(entry.options.config),
184
+ description: '', source: entry.parent.tree.filename ?? 'DSH', enabled: !entry.disabled,
185
+ activation: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'][entry.fiber?.state] ?? (entry.disabled ? 'disabled' : 'unknown'),
186
+ connection: 'unknown', managed: false, revision: fingerprint({ id: this.entryId(entry), config: entry.options.config, disabled: entry.disabled, override: this.state.overrides[this.entryId(entry)] }),
187
+ })),
188
+ ...this.state.mcps.map(row => ({ id: row.id, ...describeMcp(row.config), description: '', source: this.file,
189
+ enabled: row.enabled, activation: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'][this.fibers.get(row.id)?.state] ?? (row.enabled ? 'pending' : 'disabled'),
190
+ connection: 'unknown', managed: true, revision: fingerprint(row),
191
+ })),
192
+ ].map(row => ({ ...row, description: this.probes.get(row.id)?.description ?? '', probe: this.probes.get(row.id) ?? null }));
193
+ for (const row of mcps) {
194
+ row.canDelete = row.managed || Boolean(await findMcpDefinition(this.environment.profileDir, this.entries().find(entry => this.entryId(entry) === row.id)));
195
+ if (this.loadErrors.has(row.id)) row.activation = 'failed';
196
+ row.version = row.probe?.version ?? null; row.icon = safeIcon(row.probe?.icon);
197
+ }
198
+ const installed = await this.inventory.installed();
199
+ const plugins = await Promise.all(Object.entries(installed).map(async ([name, version]) => {
200
+ let manifest = {}; try { manifest = JSON.parse(await readFile(join(this.environment.profileDir, 'node_modules', name, 'package.json'), 'utf8')); } catch {}
201
+ return { name, version, icon: safeIcon(manifest.icon ?? manifest.dsh?.icon), description: typeof manifest.description === 'string' ? manifest.description : '', source: typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url ?? '',
202
+ native: Boolean(manifest.dsh?.bundle || manifest.dsh?.client), directory: join(this.environment.profileDir, 'node_modules', name),
203
+ };
204
+ }));
205
+ return { profile: { name: this.environment.profile, directory: this.environment.profileDir, installation: this.environment.installation, platform: this.environment.host?.platform ?? process.platform },
206
+ presets: view.presets, presetId: view.presetId ?? null, workspaces: this.workspaces(), workspaceId: workspace?.id ?? null, skills, skillsComplete: catalog.complete,
207
+ mcps, plugins: plugins.filter(row => row.native && !row.name.startsWith('@deepseek-ai/')), updatedAt: new Date().toISOString() };
208
+ }
209
+ configuration(data) {
210
+ return this.serial(async () => {
211
+ if (data.profile !== this.environment.profileDir) throw new Error('实例 profile 已变化,请刷新');
212
+ this.checkRevision(await this.snapshot(data.workspaceId), data);
213
+ const row = this.state.mcps.find(row => row.id === data.id);
214
+ const entry = this.entries().find(entry => this.entryId(entry) === data.id);
215
+ const override = this.state.overrides[data.id];
216
+ const config = row?.config ?? (override?.replaceConfig ? override.config : { ...entry?.options.config, ...override?.config });
217
+ if (!config?.serverName) throw new Error('当前 MCP 没有可编辑的配置');
218
+ return { text: formatMcpDocument(config, data.format ?? 'json') };
219
+ });
220
+ }
221
+ formatConfiguration(data) {
222
+ if (data.profile !== this.environment.profileDir) throw new Error('实例 profile 已变化,请刷新');
223
+ return { text: formatMcpDocument(parseConfigText(data.text), data.format) };
224
+ }
225
+ async mutate(data) {
226
+ return this.serial(async () => {
227
+ const snapshot = await this.snapshot(data.workspaceId, data.presetId);
228
+ if (data.profile !== this.environment.profileDir) throw new Error('实例 profile 已变化,请刷新后重试');
229
+ const next = structuredClone(this.state);
230
+ if (data.action === 'skill-toggle') {
231
+ if (typeof data.enabled !== 'boolean') throw new Error('无效的开关值');
232
+ const skill = snapshot.skills.find(row => row.id === data.id);
233
+ if (!skill?.canToggle) throw new Error('此 Skill 的开关属于其它作用域,请切换范围');
234
+ const cwd = this.workspace(data.workspaceId)?.path;
235
+ next.skills = next.skills.filter(row => row.name !== data.id || row.cwd !== cwd);
236
+ if (!data.enabled) {
237
+ const { skills } = await this.ctx.skills.snapshot({ ...await this.view(data.presetId), ...(cwd ? { cwd } : {}) });
238
+ const summary = skills.find(row => row.name === data.id);
239
+ next.skills.push({ name: data.id, ...(cwd ? { cwd } : {}), summary });
240
+ }
241
+ await this.save(next);
242
+ } else if (data.action === 'mcp-add' || data.action === 'mcp-edit') {
243
+ const previous = next.mcps.find(row => row.id === data.id);
244
+ const entry = this.entries().find(entry => this.entryId(entry) === data.id);
245
+ const raw = data.text !== undefined ? parseMcpDocument(data.text) : { ...(previous?.config ?? entry?.options.config ?? {}), ...data.config };
246
+ validateMcp(resolveEnvironment(raw));
247
+ const config = raw;
248
+ if (snapshot.mcps.some(row => row.name === config.serverName && row.id !== data.id)) throw new Error('当前 profile 已有同名 MCP');
249
+ if (data.action === 'mcp-add') next.mcps.push({ id: `hub-${randomUUID()}`, config, enabled: true });
250
+ else {
251
+ const row = next.mcps.find(row => row.id === data.id);
252
+ this.checkRevision(snapshot, data);
253
+ if (row) { row.config = config; delete row.description; }
254
+ else {
255
+ if (!entry) throw new Error('MCP 已不存在');
256
+ next.overrides[data.id] = { ...next.overrides[data.id], enabled: !entry.disabled, config, replaceConfig: true };
257
+ }
258
+ }
259
+ await this.save(next);
260
+ if (data.id) await this.stopMcp(data.id);
261
+ await this.reconcile();
262
+ } else if (['mcp-toggle', 'mcp-delete', 'mcp-reconnect'].includes(data.action)) {
263
+ this.checkRevision(snapshot, data);
264
+ const row = next.mcps.find(row => row.id === data.id);
265
+ if (row) {
266
+ if (data.action === 'mcp-delete') next.mcps = next.mcps.filter(row => row.id !== data.id);
267
+ if (data.action === 'mcp-toggle') { if (typeof data.enabled !== 'boolean') throw new Error('无效的开关值'); row.enabled = data.enabled; }
268
+ } else {
269
+ const entry = this.entries().find(entry => this.entryId(entry) === data.id);
270
+ if (!entry) throw new Error('MCP 已不存在');
271
+ if (data.action === 'mcp-delete') {
272
+ await deleteMcpDefinition(this.environment.profileDir, entry);
273
+ delete next.overrides[data.id];
274
+ } else if (data.action === 'mcp-reconnect') {
275
+ if (entry.disabled) throw new Error('请先启用 MCP');
276
+ await entry.update({ disabled: true }, false, true);
277
+ await entry.update({ disabled: false }, false, true);
278
+ } else {
279
+ if (typeof data.enabled !== 'boolean') throw new Error('无效的开关值');
280
+ next.overrides[data.id] = { ...next.overrides[data.id], enabled: data.enabled };
281
+ }
282
+ }
283
+ await this.save(next); this.probes.delete(data.id);
284
+ if (row) await this.stopMcp(data.id);
285
+ await this.reconcile();
286
+ } else throw new Error('未知的资源操作');
287
+ return this.snapshot(data.workspaceId, data.presetId);
288
+ });
289
+ }
290
+ checkRevision(snapshot, data) {
291
+ if (snapshot.mcps.find(row => row.id === data.id)?.revision !== data.revision) throw new Error('MCP 配置已变化,请刷新后重试');
292
+ }
293
+ async stopMcp(id) { const fiber = this.fibers.get(id); if (fiber) { await fiber.dispose(); this.fibers.delete(id); } this.probes.delete(id); this.loadErrors.delete(id); }
294
+ test(data, signal) { return this.serial(() => this.probe(data, signal)); }
295
+ async probe(data, signal) {
296
+ const snapshot = await this.snapshot(data.workspaceId, data.presetId); this.checkRevision(snapshot, data);
297
+ if (data.profile !== this.environment.profileDir) throw new Error('实例 profile 已变化,请刷新');
298
+ const row = this.state.mcps.find(row => row.id === data.id);
299
+ const entry = this.entries().find(entry => this.entryId(entry) === data.id);
300
+ const raw = row?.config ?? entry?.options.config;
301
+ const config = raw && resolveEnvironment(raw);
302
+ if (!config) throw new Error('当前 MCP 没有可用的已解析配置,请先启用');
303
+ const client = new Client({ name: 'dsh-plugin-hub-probe', version: '1.0.0' });
304
+ const abort = AbortSignal.any([this.lifecycle.signal, signal ?? new AbortController().signal, AbortSignal.timeout(15000)]);
305
+ const transport = config.transport === 'stdio' ? new StdioClientTransport({ command: config.command, args: config.args ?? [],
306
+ env: { ...Object.fromEntries(['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'TMPDIR', 'TEMP', 'LANG'].filter(k => process.env[k]).map(k => [k, process.env[k]])), ...config.env },
307
+ cwd: config.cwd || undefined, stderr: 'ignore',
308
+ }) : new StreamableHTTPClientTransport(new URL(config.url), { requestInit: { headers: config.headers } });
309
+ const started = Date.now();
310
+ let onAbort;
311
+ const cancellation = new Promise((_, reject) => {
312
+ onAbort = () => { void transport.close(); reject(abort.reason); };
313
+ abort.addEventListener('abort', onAbort, { once: true });
314
+ });
315
+ try {
316
+ if (abort.aborted) throw abort.reason;
317
+ const result = await Promise.race([(async () => { await client.connect(transport); return client.getServerCapabilities()?.tools ? client.listTools({}, { signal: abort, timeout: 15000 }) : { tools: [] }; })(), cancellation]);
318
+ this.probes.set(data.id, { status: 'success', tools: result.tools.length, version: client.getServerVersion()?.version ?? null, icon: safeIcon(client.getServerVersion()?.icons?.[0]?.src), description: String(client.getServerVersion()?.description ?? client.getInstructions() ?? '').slice(0, 8000), durationMs: Date.now() - started, at: new Date().toISOString() });
319
+ } catch { this.probes.set(data.id, { status: 'failed', at: new Date().toISOString(), durationMs: Date.now() - started }); }
320
+ finally { abort.removeEventListener('abort', onAbort); await client.close().catch(() => {}); }
321
+ return this.snapshot(data.workspaceId, data.presetId);
322
+ }
323
+ async skillFile(data) {
324
+ const snapshot = await this.snapshot(data.workspaceId);
325
+ if (data.profile !== this.environment.profileDir) throw new Error('实例 profile 已变化,请刷新');
326
+ const row = snapshot.skills.find(row => row.id === data.id);
327
+ if (!row?.directory) throw new Error('此 Skill 没有本地文件');
328
+ const directory = await realpath(row.directory);
329
+ const path = await realpath(join(directory, 'SKILL.md'));
330
+ if (!inside(directory, path)) throw new Error('Skill 文件不在来源目录内');
331
+ const text = await readFile(path, 'utf8');
332
+ if (text.length > 1000000) throw new Error('Skill 文件过大,请打开目录查看');
333
+ return { path, text };
334
+ }
335
+ async open(data) {
336
+ const snapshot = await this.snapshot(data.workspaceId, data.presetId);
337
+ if (data.profile !== this.environment.profileDir) throw new Error('实例 profile 已变化,请刷新');
338
+ const path = data.kind === 'plugin' ? snapshot.plugins.find(row => row.name === data.id)?.directory : snapshot.skills.find(row => row.id === data.id)?.directory;
339
+ if (!path) throw new Error('这个资源没有本地目录');
340
+ const canonical = await realpath(path);
341
+ const [command, args] = process.platform === 'darwin' ? ['open', [canonical]] : process.platform === 'win32' ? ['explorer.exe', [canonical]] : ['xdg-open', [canonical]];
342
+ await new Promise((resolve, reject) => { const child = spawn(command, args, { shell: false, stdio: 'ignore' }); child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error('宿主无法打开目录,请根据来源路径手动打开'))); });
343
+ return { opened: true };
344
+ }
345
+ async close() { this.closed = true; this.lifecycle.abort(); clearInterval(this.timer); await this.tail; for (const id of this.fibers.keys()) await this.stopMcp(id); for (const scope of this.scopes.values()) await scope.dispose(); }
346
+ }
package/src/catalog.js DELETED
@@ -1,108 +0,0 @@
1
- import { githubRepository } from './npm-identity.js';
2
- import semver from 'semver';
3
- import { dshDeclaration } from './declarations.js';
4
-
5
- export const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
6
- export const TAG_LABELS = { agent: 'Agent 接入', 'developer-tools': '开发工具', knowledge: '知识文档', productivity: '效率工具', integration: '系统集成', ui: '界面' };
7
-
8
- /** Validate external catalog data before replacing the last usable snapshot. */
9
- export function validateCatalog(value) {
10
- if (!value || value.schemaVersion !== 1 || !Array.isArray(value.plugins)) throw new Error('目录格式不受支持:需要 schemaVersion: 1 和 plugins 数组');
11
- if (value.plugins.length > 25000) throw new Error('目录最多支持 25000 个插件');
12
- const seen = new Set();
13
- const plugins = value.plugins.map((item, index) => {
14
- const fail = message => { throw new Error(`目录第 ${index + 1} 条:${message}`); };
15
- if (!item || typeof item !== 'object') fail('插件记录必须为对象');
16
- if (typeof item.packageName !== 'string' || !PACKAGE_NAME.test(item.packageName) || item.packageName.length > 214) fail('npm 包名无效');
17
- if (seen.has(item.packageName)) fail('npm 包名重复');
18
- seen.add(item.packageName);
19
- for (const [key, limit] of [['displayName', 80], ['description', 300]]) {
20
- if (typeof item[key] !== 'string' || !item[key].trim() || item[key].length > limit) fail(`${key} 无效`);
21
- }
22
- if (item.owner !== undefined && (typeof item.owner !== 'string' || !item.owner.trim() || item.owner.length > 80)) fail('owner 无效');
23
- if (item.origin !== undefined && !['internal', 'community'].includes(item.origin)) fail('origin 无效');
24
- if (!Array.isArray(item.tags) || item.tags.length < 1 || item.tags.length > 5 || new Set(item.tags).size !== item.tags.length || item.tags.some(tag => typeof tag !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(tag))) fail('tags 无效');
25
- for (const key of ['documentationUrl', 'troubleshootingUrl', 'repositoryUrl']) {
26
- if (item[key] === undefined) continue;
27
- let url;
28
- try { url = new URL(item[key]); } catch { fail(`${key} 不是有效 URL`); }
29
- if (typeof item[key] !== 'string' || url.protocol !== 'https:' || url.username || url.password) fail(`${key} 必须是无凭据的 HTTPS 链接`);
30
- }
31
- if (item.stars !== undefined && (!Number.isSafeInteger(item.stars) || item.stars < 0)) fail('stars 无效');
32
- if (item.verification !== undefined) {
33
- const v = item.verification;
34
- if (!v || v.kind !== 'bundle-manifest' || !/^[a-f0-9]{40}$/.test(v.commit ?? '') || !Number.isFinite(Date.parse(v.checkedAt)) || v.manifestPath !== 'package.json' || typeof v.patchPath !== 'string' || !item.repositoryUrl) fail('verification 无效');
35
- }
36
- const locales = {};
37
- if (item.locales !== undefined) {
38
- if (!item.locales || typeof item.locales !== 'object' || Array.isArray(item.locales) || Object.keys(item.locales).length > 10) fail('locales 无效');
39
- for (const [language, copy] of Object.entries(item.locales)) {
40
- if (!/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(language) || !copy || typeof copy !== 'object' || Array.isArray(copy)) fail('locales 无效');
41
- locales[language] = {};
42
- for (const [key, limit] of [['displayName', 80], ['description', 300]]) {
43
- if (copy[key] === undefined) continue;
44
- if (typeof copy[key] !== 'string' || !copy[key].trim() || copy[key].length > limit) fail(`locales.${language}.${key} 无效`);
45
- locales[language][key] = copy[key];
46
- }
47
- }
48
- }
49
- const result = Object.fromEntries(['packageName', 'displayName', 'description', 'owner', 'origin', 'tags', 'documentationUrl', 'troubleshootingUrl', 'repositoryUrl', 'stars', 'verification'].filter(key => item[key] !== undefined).map(key => [key, item[key]]));
50
- return Object.keys(locales).length ? { ...result, locales } : result;
51
- });
52
- return { schemaVersion: 1, plugins };
53
- }
54
-
55
- /** Declared compatibility is not a promise that a package has been runtime-tested. */
56
- export function evaluateVersion(version, manifest, publishedAt, host, now, minimumAgeHours = 48) {
57
- const peerEvidence = Object.entries(manifest.peerDependencies ?? {}).some(([name, range]) => name.startsWith('@deepseek-ai/dsh-') && typeof range === 'string' && semver.validRange(range) && host.peers[name]);
58
- const declaration = dshDeclaration(manifest, host, peerEvidence);
59
- const dshRange = declaration.range;
60
- const reasons = [...declaration.reasons];
61
- let compatibility = declaration.status;
62
- if (manifest.engines?.node && (!semver.validRange(manifest.engines.node) || !semver.satisfies(host.node, manifest.engines.node))) {
63
- compatibility = 'incompatible'; reasons.push(`要求 Node ${manifest.engines.node}`);
64
- }
65
- const extraNode = manifest.dsh?.compatibility?.node;
66
- if (extraNode !== undefined && (typeof extraNode !== 'string' || !semver.validRange(extraNode) || !semver.satisfies(host.node, extraNode))) {
67
- compatibility = 'incompatible'; reasons.push(`要求 Node ${extraNode}`);
68
- }
69
- const profiles = manifest.dsh?.compatibility?.profiles;
70
- if (Array.isArray(profiles) && !profiles.includes(host.profile ?? 'web')) { compatibility = 'incompatible'; reasons.push('声明不支持当前 DSH profile'); }
71
- for (const [name, range] of Object.entries(manifest.peerDependencies ?? {})) {
72
- if (!name.startsWith('@deepseek-ai/')) continue;
73
- const actual = host.peers[name];
74
- if (!actual) {
75
- if (manifest.peerDependenciesMeta?.[name]?.optional) continue;
76
- if (compatibility !== 'incompatible') compatibility = 'unknown';
77
- reasons.push(`无法确认宿主接口 ${name}`);
78
- } else if (typeof range !== 'string' || !semver.validRange(range) || !semver.satisfies(actual, range)) {
79
- compatibility = 'incompatible'; reasons.push(`${name} 要求 ${range},当前为 ${actual}`);
80
- }
81
- }
82
- for (const [key, actual] of [['os', host.platform], ['cpu', host.arch]]) {
83
- const values = manifest[key];
84
- if (Array.isArray(values) && (values.includes(`!${actual}`) || (values.some(v => !v.startsWith('!')) && !values.includes(actual) && !values.includes('any')))) {
85
- compatibility = 'incompatible'; reasons.push(`不支持当前 ${key}: ${actual}`);
86
- }
87
- }
88
- const publishedMs = typeof publishedAt === 'string' ? Date.parse(publishedAt) : NaN;
89
- const minimumAgeMinutes = Math.round(minimumAgeHours * 60);
90
- const eligibleAt = Number.isFinite(publishedMs) ? new Date(publishedMs + minimumAgeMinutes * 60000).toISOString() : null;
91
- const age = eligibleAt === null ? 'unknown' : Date.parse(eligibleAt) <= now ? 'ready' : 'waiting';
92
- if (age === 'unknown') reasons.push('仓库未返回有效发布时间');
93
- if (age === 'waiting') reasons.push(Number.isInteger(minimumAgeHours) ? `发布未满 ${minimumAgeHours} 小时` : `发布未满 ${minimumAgeMinutes} 分钟`);
94
- if (manifest.deprecated) reasons.push(`已弃用:${manifest.deprecated}`);
95
- const canInstall = compatibility === 'compatible' && age === 'ready' && !manifest.deprecated;
96
- return { version, compatibilityBasis: declaration.basis, publishedAt: Number.isFinite(publishedMs) ? new Date(publishedMs).toISOString() : null, dshRange, compatibility, age, eligibleAt, canInstall, reasons };
97
- }
98
-
99
- export function releaseList(metadata, host, now, minimumAgeHours, plugin) {
100
- return Object.entries(metadata.versions ?? {})
101
- .filter(([version, manifest]) => semver.valid(version) && manifest && typeof manifest === 'object')
102
- .sort(([a], [b]) => semver.rcompare(a, b))
103
- .map(([version, manifest]) => {
104
- const release = evaluateVersion(version, manifest, metadata.time?.[version], host, now, minimumAgeHours);
105
- if((plugin?.verification || githubRepository(plugin?.repositoryUrl)) && githubRepository(manifest.repository)!==githubRepository(plugin.repositoryUrl)) { release.canInstall=false; release.reasons.push('该版本的 npm 来源无法与目录仓库核对'); }
106
- return release;
107
- });
108
- }
package/src/client/api.js DELETED
@@ -1,22 +0,0 @@
1
- export class RequestError extends Error {
2
- constructor(key, detail = '') { super(key); this.detail = detail; }
3
- }
4
-
5
- export async function request(path, data, { signal, fetcher = fetch, base = '/dsh-plugin-hub/hub/api/' } = {}) {
6
- let response;
7
- try {
8
- response = await fetcher(base + path, {
9
- signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000),
10
- ...(data === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(data) }),
11
- });
12
- } catch (error) {
13
- if (signal?.aborted) throw error;
14
- throw new RequestError(error.name === 'TimeoutError' ? '请求超时,正在等待 DSH 响应。' : '连接已断开,请确认本地 DSH 实例正在运行。恢复后页面会自动重连。');
15
- }
16
- if (response.status === 401) throw new RequestError('请先登录当前 DSH 实例');
17
- let value;
18
- try { value = await response.json(); }
19
- catch { throw new RequestError('服务返回了无效响应,请刷新 DSH 页面。'); }
20
- if (!response.ok) throw new RequestError(value.error ?? `HTTP ${response.status}`);
21
- return value;
22
- }