@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.
- package/README.md +35 -7
- package/THIRD_PARTY_NOTICES.md +3 -3
- package/lib/client.js +564 -406
- package/package.json +17 -24
- package/src/dsh.js +26 -48
- package/src/experiment-definitions.js +6 -0
- package/src/experiments.js +50 -0
- package/src/identity.js +1 -20
- package/src/index.js +39 -74
- package/src/mcp-config.js +65 -0
- package/src/profile-files.js +44 -0
- package/src/resources.js +346 -0
- package/src/catalog.js +0 -108
- package/src/client/api.js +0 -22
- package/src/client/index.jsx +0 -201
- package/src/client/locale.js +0 -104
- package/src/client/market.css +0 -113
- package/src/client/scope.js +0 -9
- package/src/declarations.js +0 -19
- package/src/host-peers.js +0 -33
- package/src/npm-identity.js +0 -15
- package/src/provider-api.d.ts +0 -36
- package/src/provider-api.js +0 -7
- package/src/providers.js +0 -85
- package/src/registry-config.js +0 -27
- package/src/registry.js +0 -15
- package/src/release-age.js +0 -34
- package/src/service.js +0 -193
- package/src/source-plugin.js +0 -9
- package/src/source.js +0 -85
package/package.json
CHANGED
|
@@ -1,23 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaimokuza/dsh-plugin-hub",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Native DSH resource hub: Skills, MCP, third-party plugins and experiments",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.js",
|
|
10
10
|
"./client": "./lib/client.js",
|
|
11
|
-
"./package.json": "./package.json"
|
|
12
|
-
"./runtime": "./src/index.js",
|
|
13
|
-
"./catalog": "./src/catalog.js",
|
|
14
|
-
"./identity": "./src/identity.js",
|
|
15
|
-
"./client-entry": "./src/client/index.jsx",
|
|
16
|
-
"./source-plugin": "./src/source-plugin.js",
|
|
17
|
-
"./provider-api": {
|
|
18
|
-
"types": "./src/provider-api.d.ts",
|
|
19
|
-
"default": "./src/provider-api.js"
|
|
20
|
-
}
|
|
11
|
+
"./package.json": "./package.json"
|
|
21
12
|
},
|
|
22
13
|
"engines": {
|
|
23
14
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -29,29 +20,31 @@
|
|
|
29
20
|
"client": {
|
|
30
21
|
"inject": [
|
|
31
22
|
"@deepseek-ai/dsh-client-locale",
|
|
32
|
-
"@deepseek-ai/dsh-client-
|
|
23
|
+
"@deepseek-ai/dsh-client-connection",
|
|
24
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
25
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
33
26
|
],
|
|
34
27
|
"platform": "web"
|
|
35
28
|
}
|
|
36
29
|
},
|
|
37
30
|
"scripts": {
|
|
38
31
|
"build": "node scripts/build.mjs",
|
|
39
|
-
"test": "node --test tests/*.test.js",
|
|
40
|
-
"
|
|
32
|
+
"test": "node --test tests/*.test.js scripts/*.test.mjs",
|
|
33
|
+
"check": "npm test && npm run build && npm run pack && npm run verify:packed",
|
|
34
|
+
"pack": "node scripts/pack.mjs",
|
|
35
|
+
"verify:packed": "node scripts/verify-packed.mjs",
|
|
36
|
+
"verify:native": "node scripts/verify-native.mjs"
|
|
41
37
|
},
|
|
42
38
|
"dependencies": {
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"tar": "7.5.22",
|
|
46
|
-
"undici": "7.28.0",
|
|
47
|
-
"@zaimokuza/dsh-plugin-hub-catalog-demo": "0.1.2"
|
|
39
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
40
|
+
"yaml": "2.9.1"
|
|
48
41
|
},
|
|
49
42
|
"devDependencies": {
|
|
50
43
|
"esbuild": "0.28.1",
|
|
51
44
|
"react": "18.3.1"
|
|
52
45
|
},
|
|
53
46
|
"files": [
|
|
54
|
-
"src",
|
|
47
|
+
"src/*.js",
|
|
55
48
|
"lib/client.js",
|
|
56
49
|
"cordis.patch.yml",
|
|
57
50
|
"README.md",
|
|
@@ -67,8 +60,7 @@
|
|
|
67
60
|
},
|
|
68
61
|
"repository": {
|
|
69
62
|
"type": "git",
|
|
70
|
-
"url": "git+https://github.com/zaimokuza-yoshiteru/dsh-plugin-hub.git"
|
|
71
|
-
"directory": "packages/marketplace"
|
|
63
|
+
"url": "git+https://github.com/zaimokuza-yoshiteru/dsh-plugin-hub.git"
|
|
72
64
|
},
|
|
73
65
|
"homepage": "https://github.com/zaimokuza-yoshiteru/dsh-plugin-hub",
|
|
74
66
|
"bugs": {
|
|
@@ -77,7 +69,8 @@
|
|
|
77
69
|
"dshPluginHub": {
|
|
78
70
|
"hostCompatibility": "capability-based",
|
|
79
71
|
"testedDshVersions": [
|
|
80
|
-
"0.1.
|
|
72
|
+
"0.1.5-rc.1",
|
|
73
|
+
"0.1.5-rc.2"
|
|
81
74
|
]
|
|
82
75
|
}
|
|
83
76
|
}
|
package/src/dsh.js
CHANGED
|
@@ -1,25 +1,34 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
|
-
import { collectHostPeers } from './host-peers.js';
|
|
6
|
-
import { releaseAgeArgument } from './release-age.js';
|
|
7
6
|
|
|
8
7
|
/** All knowledge about the DSH process and its profile lives in this adapter. */
|
|
9
|
-
export function dshEnvironment(
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const configuredHome =
|
|
15
|
-
const home = configuredHome === '~' ? homedir() : /^~[/\\]/.test(configuredHome) ? join(homedir(), configuredHome.slice(2)) : configuredHome;
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
export function dshEnvironment(ctx, expectedProfile, runtime = process) {
|
|
9
|
+
// Only the boot root owns the active profile anchor; plugin baseUrl is its package.
|
|
10
|
+
const base = ctx.root?.baseUrl;
|
|
11
|
+
if (typeof base !== 'string' || !base.startsWith('file:')) throw new Error('无法确认当前实例的 profile,已停止管理操作');
|
|
12
|
+
const profileDir = realpathSync(fileURLToPath(base));
|
|
13
|
+
const configuredHome = runtime.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
|
|
14
|
+
const home = resolve(configuredHome === '~' ? homedir() : /^~[/\\]/.test(configuredHome) ? join(homedir(), configuredHome.slice(2)) : configuredHome);
|
|
15
|
+
const profile = basename(profileDir);
|
|
16
|
+
if (expectedProfile !== undefined && expectedProfile !== profile) throw new Error('Hub profile 配置与当前实例不一致');
|
|
17
|
+
let entryManifest, cli;
|
|
18
|
+
try {
|
|
19
|
+
const candidate = realpathSync(runtime.argv[1]);
|
|
20
|
+
const file = join(dirname(candidate), '..', 'package.json');
|
|
21
|
+
if (JSON.parse(readFileSync(file, 'utf8')).name === '@deepseek-ai/dsh') { cli = candidate; entryManifest = file; }
|
|
22
|
+
} catch { /* Desktop workers do not run through the CLI. */ }
|
|
23
|
+
entryManifest ??= createRequire(join(profileDir, 'package.json')).resolve('@deepseek-ai/dsh/package.json');
|
|
24
|
+
const manifest = JSON.parse(readFileSync(entryManifest, 'utf8'));
|
|
25
|
+
let cliProfile = false;
|
|
26
|
+
try { cliProfile = Boolean(cli) && realpathSync(join(home, 'profiles', profile)) === profileDir; } catch { /* Never redirect to another profile. */ }
|
|
27
|
+
return { cli, home, profileDir, profile, installation: cliProfile ? 'cli' : 'desktop',
|
|
28
|
+
host: { dsh: manifest.version, node: runtime.versions.node, platform: runtime.platform, arch: runtime.arch } };
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
export function
|
|
22
|
-
let child;
|
|
31
|
+
export function createDshInventory(environment) {
|
|
23
32
|
const installed = async () => {
|
|
24
33
|
let manifest;
|
|
25
34
|
try { manifest = JSON.parse(readFileSync(join(environment.profileDir, 'package.json'), 'utf8')); }
|
|
@@ -31,36 +40,5 @@ export function createDshInstaller(environment, config) {
|
|
|
31
40
|
}
|
|
32
41
|
return result;
|
|
33
42
|
};
|
|
34
|
-
|
|
35
|
-
await new Promise((resolve, reject) => {
|
|
36
|
-
child = spawn(process.execPath, args, { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: false, windowsHide: true });
|
|
37
|
-
const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('插件操作超时,请检查 profile 后重试')); }, 180000);
|
|
38
|
-
const onData = bytes => log(bytes.toString('utf8').replace(/Bearer\s+\S+/gi, 'Bearer [redacted]').slice(-1500));
|
|
39
|
-
child.stdout.on('data', onData); child.stderr.on('data', onData);
|
|
40
|
-
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
41
|
-
child.once('exit', code => { clearTimeout(timer); child = null; code === 0 ? resolve() : reject(new Error(`插件命令失败(退出码 ${code})`)); });
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
return {
|
|
45
|
-
installed,
|
|
46
|
-
async install(packageName, version, log) {
|
|
47
|
-
const args = [...process.execArgv, environment.cli, 'plugin', '--profile', environment.profile, 'add', `${packageName}@${version}`, '--save-exact', `--registry=${config.registry}`, releaseAgeArgument(config)];
|
|
48
|
-
log(`安装 ${packageName}@${version}`);
|
|
49
|
-
await run(args, log);
|
|
50
|
-
if ((await installed())[packageName] !== version) throw new Error('安装命令结束,但实际版本不匹配');
|
|
51
|
-
const manifest = JSON.parse(readFileSync(join(environment.profileDir, 'package.json'), 'utf8'));
|
|
52
|
-
if (!manifest.dsh?.profile?.bundles?.includes(packageName)) throw new Error('包已下载,但尚未注册为 DSH bundle');
|
|
53
|
-
log('安装并注册完成,请手动重启 DSH 后验证加载');
|
|
54
|
-
},
|
|
55
|
-
async uninstall(packageName, log) {
|
|
56
|
-
if (!(await installed())[packageName]) throw new Error('该插件尚未安装');
|
|
57
|
-
const args = [...process.execArgv, environment.cli, 'plugin', '--profile', environment.profile, 'remove', packageName];
|
|
58
|
-
log(`卸载 ${packageName}`);
|
|
59
|
-
await run(args, log);
|
|
60
|
-
const manifest = JSON.parse(readFileSync(join(environment.profileDir, 'package.json'), 'utf8'));
|
|
61
|
-
if (manifest.dependencies?.[packageName] || manifest.dsh?.profile?.bundles?.includes(packageName)) throw new Error('卸载命令结束,但插件仍在 profile 中');
|
|
62
|
-
log('卸载并取消注册完成,请手动重启 DSH 结束已加载实例');
|
|
63
|
-
},
|
|
64
|
-
close() { child?.kill('SIGTERM'); },
|
|
65
|
-
};
|
|
43
|
+
return { installed };
|
|
66
44
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { atomicWrite } from './profile-files.js';
|
|
6
|
+
import { agentTeams } from './experiment-definitions.js';
|
|
7
|
+
const bundles = ['@deepseek-ai/dsh-experimental-agent-team-profile', '@deepseek-ai/dsh-experimental-agent-team-web-profile'];
|
|
8
|
+
export class Experiments {
|
|
9
|
+
constructor(environment) { this.environment = environment; this.file = join(environment.profileDir, 'package.json'); this.initial = undefined; this.busy = false; }
|
|
10
|
+
async snapshot() {
|
|
11
|
+
const manifest = JSON.parse(await readFile(this.file, 'utf8'));
|
|
12
|
+
const configuredBundles = manifest.dsh?.profile?.bundles;
|
|
13
|
+
const desktopSetting = manifest.dsh?.desktop?.agentTeams;
|
|
14
|
+
const enabled = this.environment.installation === 'desktop'
|
|
15
|
+
? (typeof desktopSetting === 'boolean' ? desktopSetting : null)
|
|
16
|
+
: (Array.isArray(configuredBundles) ? bundles.every(name => configuredBundles.includes(name)) : null);
|
|
17
|
+
if (this.initial === undefined) this.initial = enabled;
|
|
18
|
+
const resolver = createRequire(this.file);
|
|
19
|
+
const installed = bundles.every(name => { try { resolver.resolve(name + '/package.json'); return true; } catch { return false; } });
|
|
20
|
+
return [{ ...agentTeams, installed, enabled, activeEnabled: this.environment.installation === 'cli' ? this.initial : null, supported: this.environment.installation === 'cli', pendingRestart: this.environment.installation === 'cli' && this.initial !== null && this.initial !== enabled, canToggle: this.environment.installation === 'cli' && Array.isArray(configuredBundles) && installed, canInstall: this.environment.installation === 'cli', busy: this.busy }];
|
|
21
|
+
}
|
|
22
|
+
async mutate(data) {
|
|
23
|
+
if (data.profile !== this.environment.profileDir || data.id !== 'agent-teams') throw new Error('实例或功能已变化,请刷新');
|
|
24
|
+
if (this.busy) throw new Error('实验性功能正在更新');
|
|
25
|
+
if (this.environment.installation !== 'cli') throw new Error('Desktop 实验性功能需通过宿主接口操作');
|
|
26
|
+
this.busy = true;
|
|
27
|
+
try {
|
|
28
|
+
if (data.expectedEnabled !== undefined && data.expectedEnabled !== (await this.snapshot())[0].enabled) throw new Error('实验性功能状态已变化,请刷新后重试');
|
|
29
|
+
if (data.action === 'install') {
|
|
30
|
+
for (const name of bundles) await new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn(process.execPath, [this.environment.cli, 'plugin', '--profile', this.environment.profile, 'add', `${name}@${this.environment.host.dsh}`, '--save-exact'], { cwd: this.environment.profileDir, env: { ...process.env, DSH_HOME: this.environment.home }, stdio: 'ignore', windowsHide: true, shell: false });
|
|
32
|
+
this.child = child;
|
|
33
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error('安装超时,请检查 DSH 插件管理状态')); }, 180000);
|
|
34
|
+
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
35
|
+
child.once('exit', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('原生 DSH 安装失败,请确认此 DSH 版本提供 Agent Teams 包')); });
|
|
36
|
+
});
|
|
37
|
+
} else {
|
|
38
|
+
if (typeof data.enabled !== 'boolean') throw new Error('无效的开关值');
|
|
39
|
+
if (!(await this.snapshot())[0].installed) throw new Error('请先安装 Agent Teams');
|
|
40
|
+
const manifest = JSON.parse(await readFile(this.file, 'utf8'));
|
|
41
|
+
const current = manifest.dsh?.profile?.bundles;
|
|
42
|
+
if (!Array.isArray(current)) throw new Error('当前 profile 未提供原生 bundle 列表');
|
|
43
|
+
manifest.dsh.profile.bundles = data.enabled ? [...current.filter(name => !bundles.includes(name)), ...bundles] : current.filter(name => !bundles.includes(name));
|
|
44
|
+
await atomicWrite(this.file, JSON.stringify(manifest, null, 2) + '\n');
|
|
45
|
+
}
|
|
46
|
+
return await this.snapshot();
|
|
47
|
+
} finally { this.busy = false; this.child = null; }
|
|
48
|
+
}
|
|
49
|
+
close() { this.child?.kill(); }
|
|
50
|
+
}
|
package/src/identity.js
CHANGED
|
@@ -1,25 +1,6 @@
|
|
|
1
1
|
export const MARKET_PACKAGE = '@zaimokuza/dsh-plugin-hub';
|
|
2
|
-
export const CATALOG_PACKAGE = '@zaimokuza/dsh-plugin-hub-catalog-demo';
|
|
3
|
-
export function normalizeRegistry(value) {
|
|
4
|
-
let url;
|
|
5
|
-
try { if (typeof value !== 'string') throw new Error(); url = new URL(value); } catch { throw new Error('npm registry must be an HTTPS URL without credentials / npm registry 必须是无凭据的 HTTPS 地址'); }
|
|
6
|
-
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw new Error('npm registry must be an HTTPS URL without credentials / npm registry 必须是无凭据的 HTTPS 地址');
|
|
7
|
-
return url.href.replace(/\/?$/, '/');
|
|
8
|
-
}
|
|
9
|
-
export function catalogVerification(value = 'if-present') {
|
|
10
|
-
if (!['if-present', 'required', 'none'].includes(value)) throw new Error('catalogVerification must be if-present, required or none');
|
|
11
|
-
return value;
|
|
12
|
-
}
|
|
13
2
|
export const DEFAULT_BRAND = Object.freeze({ title: 'Plugin Hub', subTitle: 'DSH EXTENSIONS', primaryColor: '#596579', navTitle: 'Plugin Hub' });
|
|
14
3
|
export function marketIdentity(id = 'hub') {
|
|
15
4
|
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]{0,47}$/.test(id)) throw new Error('marketId must be a lowercase identifier (max 48 characters)');
|
|
16
|
-
return { id, service: 'dshPluginHub_' + id.replaceAll('-', '_')
|
|
17
|
-
}
|
|
18
|
-
export function normalizeBrand(value = {}) {
|
|
19
|
-
const brand = { ...DEFAULT_BRAND, ...value };
|
|
20
|
-
for (const key of ['title', 'subTitle', 'navTitle']) if (typeof brand[key] !== 'string' || !brand[key].trim() || brand[key].length > 80) throw new Error(`Invalid brand ${key}`);
|
|
21
|
-
const colors = { red: '#c92536', blue: '#2563eb', green: '#16805d', purple: '#7c3aed', orange: '#c65d0b' };
|
|
22
|
-
brand.primaryColor = colors[brand.primaryColor] ?? brand.primaryColor;
|
|
23
|
-
if (!/^#[a-f\d]{6}$/i.test(brand.primaryColor)) throw new Error('primaryColor must be #RRGGBB or red/blue/green/purple/orange');
|
|
24
|
-
return Object.fromEntries(['title','subTitle','navTitle','primaryColor'].map(key => [key, brand[key]]));
|
|
5
|
+
return { id, service: 'dshPluginHub_' + id.replaceAll('-', '_') };
|
|
25
6
|
}
|
package/src/index.js
CHANGED
|
@@ -1,84 +1,49 @@
|
|
|
1
|
-
import { MARKET_PACKAGE,
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
1
|
+
import { MARKET_PACKAGE, marketIdentity } from './identity.js';
|
|
4
2
|
import { join } from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { PACKAGE_NAME, validateCatalog } from './catalog.js';
|
|
9
|
-
import { createProviderApi } from './provider-api.js';
|
|
10
|
-
import { readReleaseAge } from './release-age.js';
|
|
11
|
-
import { readRegistry } from './registry-config.js';
|
|
3
|
+
import { dshEnvironment, createDshInventory } from './dsh.js';
|
|
4
|
+
import { NativeResources } from './resources.js';
|
|
5
|
+
import { Experiments } from './experiments.js';
|
|
12
6
|
|
|
13
7
|
export const name = MARKET_PACKAGE;
|
|
14
|
-
export const inject = ['
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const json = (res, status, value) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); };
|
|
18
|
-
|
|
19
|
-
async function body(req) {
|
|
20
|
-
const buffers = []; let size = 0;
|
|
21
|
-
for await (const bytes of req) { size += bytes.length; if (size > 16384) throw new Error('请求过大'); buffers.push(bytes); }
|
|
22
|
-
return JSON.parse(Buffer.concat(buffers).toString('utf8'));
|
|
23
|
-
}
|
|
8
|
+
export const inject = ['connection', 'loader', 'skills', 'workspaceRegistry', 'agentPresets'];
|
|
24
9
|
|
|
25
10
|
export async function apply(ctx, options = {}) {
|
|
26
|
-
for (const [name, fn] of [['connection.
|
|
27
|
-
if (typeof fn !== 'function') throw new Error(`Plugin Hub cannot start: required DSH API ${name} is unavailable. /
|
|
11
|
+
for (const [name, fn] of [['connection.fetch.register', ctx.connection?.fetch?.register], ['loader.entries', ctx.loader?.entries], ['provide', ctx.provide], ['effect', ctx.effect]]) {
|
|
12
|
+
if (typeof fn !== 'function') throw new Error(`Plugin Hub cannot start: required DSH API ${name} is unavailable. / Hub 无法启动:当前 DSH 缺少必要接口 ${name}。`);
|
|
28
13
|
}
|
|
29
14
|
const identity = marketIdentity(options.marketId);
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
15
|
+
const environment = dshEnvironment(ctx, options.profile);
|
|
16
|
+
const inventory = createDshInventory(environment);
|
|
17
|
+
const resources = new NativeResources(ctx, environment, join(environment.profileDir, '.dsh-plugin-hub', identity.id), inventory);
|
|
18
|
+
const experiments = new Experiments(environment);
|
|
19
|
+
ctx.effect(() => () => { experiments.close(); return resources.close(); }, 'Plugin Hub: lifecycle');
|
|
20
|
+
await resources.init();
|
|
21
|
+
await experiments.snapshot();
|
|
22
|
+
const dispatch = async (path, data = {}, signal) => {
|
|
23
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || JSON.stringify(data).length > 32768) throw new Error('无效或过大的请求');
|
|
24
|
+
if (path === 'resources') return resources.snapshot(data.workspaceId, data.presetId);
|
|
25
|
+
if (path === 'resource-mutate') return resources.mutate(data);
|
|
26
|
+
if (path === 'mcp-config') return resources.configuration(data);
|
|
27
|
+
if (path === 'mcp-format') return resources.formatConfiguration(data);
|
|
28
|
+
if (path === 'mcp-test') return resources.test(data, signal);
|
|
29
|
+
if (path === 'open-directory') return resources.open(data);
|
|
30
|
+
if (path === 'skill-file') return resources.skillFile(data);
|
|
31
|
+
if (path === 'experiments') return experiments.snapshot();
|
|
32
|
+
if (path === 'experiment-mutate') return experiments.mutate(data);
|
|
33
|
+
throw new Error('接口不存在');
|
|
34
|
+
};
|
|
35
|
+
// The shared RPC interceptor belongs to DSH's Gateway. Exact Fetch routes
|
|
36
|
+
// compose ahead of it on both the HTTP carrier and Desktop's worker carrier.
|
|
37
|
+
for (const path of ['resources', 'resource-mutate', 'mcp-config', 'mcp-format', 'mcp-test', 'open-directory', 'skill-file', 'experiments', 'experiment-mutate']) {
|
|
38
|
+
ctx.effect(() => ctx.connection.fetch.register({ path: `/api/${identity.service}/${path}`, methods: ['POST'], requestBody: 'buffered', async fetch(request) {
|
|
39
|
+
let envelope;
|
|
40
|
+
try { envelope = await request.json(); } catch { return Response.json({ error: 'Invalid JSON' }, { status: 400 }); }
|
|
41
|
+
if (envelope?.type !== 'client-request' || typeof envelope.rpcId !== 'string' || envelope.method !== `${identity.service}/${path}`) return Response.json({ error: 'Invalid RPC envelope' }, { status: 400 });
|
|
42
|
+
let result;
|
|
43
|
+
try { result = { ok: true, value: await dispatch(path, envelope.payload, request.signal) }; }
|
|
44
|
+
catch (error) { result = { ok: false, error: { code: 'plugin-hub/request-failed', message: error.message, details: {} } }; }
|
|
45
|
+
return Response.json({ type: 'server-response', rpcId: envelope.rpcId, result }, { headers: { 'cache-control': 'no-store' } });
|
|
46
|
+
} }), 'Plugin Hub: native Connection ' + path);
|
|
49
47
|
}
|
|
50
|
-
if (options.initialCatalog) market.providers.registerSource({ id: 'bundled', displayName: brand.title, kind: 'json', getCatalog: async () => options.initialCatalog }, { primary: true });
|
|
51
|
-
// Registry availability must not delay or fail DSH boot.
|
|
52
|
-
await market.init({ refresh: false });
|
|
53
|
-
ctx.provide(identity.service, createProviderApi(market.providers));
|
|
54
|
-
void market.refresh().catch(error => { market.catalogError = error.message; });
|
|
55
|
-
ctx.effect(() => () => { installer.close(); return market.close(); }, 'Plugin Hub: lifecycle');
|
|
56
|
-
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: API.slice(0, -1), async handler(req, res) {
|
|
57
|
-
const rejection = ctx.connection.requestRejection(req);
|
|
58
|
-
if (rejection) { json(res, rejection, { error: '请先登录当前 DSH 实例' }); return; }
|
|
59
|
-
if (req.method === 'POST') {
|
|
60
|
-
let origin;
|
|
61
|
-
try { origin = new URL(req.headers.origin).host; } catch { origin = null; }
|
|
62
|
-
if (!origin || origin !== req.headers.host) { json(res, 403, { error: '请求来源不匹配' }); return; }
|
|
63
|
-
}
|
|
64
|
-
try {
|
|
65
|
-
const path = new URL(req.url, 'http://localhost').pathname.slice(API.length);
|
|
66
|
-
if (path === 'state' && req.method === 'GET') { json(res, 200, await market.snapshot()); return; }
|
|
67
|
-
if (req.method !== 'POST') { json(res, 405, { error: '不支持的请求方法' }); return; }
|
|
68
|
-
if (path === 'refresh-start' && req.method === 'POST') { void market.refresh().catch(error => { market.catalogError = error.message; }); json(res, 202, await market.snapshot()); return; }
|
|
69
|
-
if (path === 'refresh') { await market.refresh(); json(res, 200, await market.snapshot()); return; }
|
|
70
|
-
const data = await body(req);
|
|
71
|
-
if (path === 'releases') { void market.loadReleases(data.packageNames).catch(error => { market.catalogError = error.message; }); json(res, 202, await market.snapshot()); return; }
|
|
72
|
-
if (path === 'install') { json(res, 202, await market.enqueue(data.packageName, data.version)); return; }
|
|
73
|
-
if (path === 'uninstall') { json(res, 202, await market.enqueue(data.packageName, undefined, 'uninstall')); return; }
|
|
74
|
-
json(res, 404, { error: '接口不存在' });
|
|
75
|
-
} catch (error) { json(res, 400, { error: error.message }); }
|
|
76
|
-
} }), 'Plugin Hub: HTTP routes');
|
|
77
|
-
|
|
78
|
-
}
|
|
79
48
|
|
|
80
|
-
/** Generated brands use this factory without activating the default demo bundle. */
|
|
81
|
-
export function createMarketplace(defaults) {
|
|
82
|
-
marketIdentity(defaults.marketId);
|
|
83
|
-
return { name: defaults.packageName, inject, apply: (ctx, options = {}) => apply(ctx, { ...defaults, ...options, brand: { ...defaults.brand, ...options.brand }, marketId: defaults.marketId, packageName: defaults.packageName }) };
|
|
84
49
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { parseDocument, stringify } from 'yaml';
|
|
2
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
+
// This is the same serialized expression shape used by Cordis. Only environment
|
|
4
|
+
// references are accepted here; Hub never evaluates arbitrary JavaScript.
|
|
5
|
+
function envExpression(expr) {
|
|
6
|
+
const direct = /^process\.env\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr);
|
|
7
|
+
if (direct) return { name: direct[1], prefix: '', suffix: '' };
|
|
8
|
+
const template = /^`([^`$]*)\$\{process\.env\.([A-Za-z_][A-Za-z0-9_]*)\}([^`$]*)`$/.exec(expr);
|
|
9
|
+
if (template) return { name: template[2], prefix: template[1], suffix: template[3] };
|
|
10
|
+
throw new Error('仅支持 process.env.NAME 或带此前缀的模板字符串,不执行任意 JavaScript');
|
|
11
|
+
}
|
|
12
|
+
export const jsTag = {
|
|
13
|
+
tag: 'tag:yaml.org,2002:js',
|
|
14
|
+
identify: value => object(value) && Object.keys(value).length === 1 && typeof value.__jsExpr === 'string',
|
|
15
|
+
resolve: value => ({ __jsExpr: value }),
|
|
16
|
+
stringify: item => JSON.stringify(item.value.__jsExpr),
|
|
17
|
+
};
|
|
18
|
+
export function parseConfigText(text) {
|
|
19
|
+
if (typeof text !== 'string' || text.length > 24000) throw new Error('配置文本最长 24000 字符');
|
|
20
|
+
const doc = parseDocument(text, { uniqueKeys: true, schema: 'core', customTags: [jsTag] });
|
|
21
|
+
if (doc.errors.length || doc.warnings.length) throw new Error('JSON / YAML 格式无效,请检查缩进、引号和重复字段');
|
|
22
|
+
let value;
|
|
23
|
+
try { value = doc.toJS({ maxAliasCount: 0 }); } catch { throw new Error('配置不支持 YAML 别名'); }
|
|
24
|
+
if (!object(value)) throw new Error('请提供一个 MCP 配置对象');
|
|
25
|
+
// Validate reference syntax without resolving values or adding defaults.
|
|
26
|
+
resolveEnvironment(value, {}, true);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
export function parseMcpDocument(text) {
|
|
30
|
+
let value = parseConfigText(text);
|
|
31
|
+
if (Object.hasOwn(value, 'mcpServers')) {
|
|
32
|
+
if (!object(value.mcpServers) || Object.keys(value).length !== 1 || Object.keys(value.mcpServers).length !== 1) throw new Error('每次请粘贴一个 mcpServers 服务配置');
|
|
33
|
+
const [name, config] = Object.entries(value.mcpServers)[0];
|
|
34
|
+
if (!object(config)) throw new Error('MCP 配置必须是对象');
|
|
35
|
+
value = { serverName: name, ...config };
|
|
36
|
+
} else if (value.name === '@deepseek-ai/dsh-mcp-client' && object(value.config)) {
|
|
37
|
+
if (Object.keys(value).some(key => !['name', 'config', 'id'].includes(key))) throw new Error('请仅编辑 MCP 的 config 段,启停使用卡片开关');
|
|
38
|
+
value = value.config;
|
|
39
|
+
}
|
|
40
|
+
const config = { ...value };
|
|
41
|
+
if (config.type !== undefined) {
|
|
42
|
+
const transport = config.type === 'http' ? 'streamable-http' : config.type;
|
|
43
|
+
if (config.transport && config.transport !== transport) throw new Error('type 与 transport 不一致');
|
|
44
|
+
config.transport = transport; delete config.type;
|
|
45
|
+
}
|
|
46
|
+
config.transport ??= config.command ? 'stdio' : config.url ? 'streamable-http' : undefined;
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
export function resolveEnvironment(value, env = process.env, syntaxOnly = false) {
|
|
50
|
+
if (Array.isArray(value)) return value.map(item => resolveEnvironment(item, env, syntaxOnly));
|
|
51
|
+
if (!object(value)) return value;
|
|
52
|
+
if (Object.hasOwn(value, '__jsExpr')) {
|
|
53
|
+
if (Object.keys(value).length !== 1 || typeof value.__jsExpr !== 'string') throw new Error('环境变量引用无效');
|
|
54
|
+
const ref = envExpression(value.__jsExpr);
|
|
55
|
+
if (syntaxOnly) return 'environment-reference';
|
|
56
|
+
if (typeof env[ref.name] !== 'string') throw new Error(`启动 DSH 的环境中未设置 ${ref.name}`);
|
|
57
|
+
return ref.prefix + env[ref.name] + ref.suffix;
|
|
58
|
+
}
|
|
59
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveEnvironment(item, env, syntaxOnly)]));
|
|
60
|
+
}
|
|
61
|
+
export function formatMcpDocument(config, format) {
|
|
62
|
+
if (format === 'json') return JSON.stringify(config, null, 2);
|
|
63
|
+
if (format === 'yaml') return stringify(config, { lineWidth: 0, customTags: [jsTag] });
|
|
64
|
+
throw new Error('格式必须为 JSON 或 YAML');
|
|
65
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile, writeFile, realpath, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, isAbsolute } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { parseDocument, isSeq, isMap } from 'yaml';
|
|
5
|
+
import { jsTag } from './mcp-config.js';
|
|
6
|
+
export async function ownedFile(profile, file) {
|
|
7
|
+
try { const path = await realpath(file); const rel = relative(profile, path); return !isAbsolute(rel) && !rel.startsWith('..') && !rel.split(/[\\/]/).includes('node_modules') ? path : null; } catch { return null; }
|
|
8
|
+
}
|
|
9
|
+
export async function atomicWrite(file, content) {
|
|
10
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
11
|
+
await writeFile(temporary, content, { mode: 0o600, flag: 'wx' });
|
|
12
|
+
try { await rename(temporary, file); }
|
|
13
|
+
catch (error) { await rm(temporary, { force: true }); throw error; }
|
|
14
|
+
}
|
|
15
|
+
// Delete only a concrete, uniquely identified definition owned by this profile.
|
|
16
|
+
// Preserve all other YAML nodes, comments and native expression tags.
|
|
17
|
+
export async function findMcpDefinition(profile, entry) {
|
|
18
|
+
if (!entry) return null;
|
|
19
|
+
const candidates = [...new Set([entry.parent.tree.filename, join(profile, 'cordis.patch.yml'), join(profile, 'cordis.yml')].filter(Boolean))];
|
|
20
|
+
for (const file of candidates) {
|
|
21
|
+
const path = await ownedFile(profile, file); if (!path) continue;
|
|
22
|
+
const original = await readFile(path, 'utf8'); const doc = parseDocument(original, { customTags: [jsTag] });
|
|
23
|
+
if (doc.errors.length || doc.warnings.length) continue;
|
|
24
|
+
const matches = [];
|
|
25
|
+
function walk(node) {
|
|
26
|
+
if (isSeq(node)) node.items.forEach((item, index) => {
|
|
27
|
+
if (isMap(item) && item.get('id') === entry.options.id && item.get('name') === '@deepseek-ai/dsh-mcp-client') matches.push({ node, index });
|
|
28
|
+
else walk(item);
|
|
29
|
+
});
|
|
30
|
+
else if (isMap(node)) node.items.forEach(pair => walk(pair.value));
|
|
31
|
+
}
|
|
32
|
+
walk(doc.contents);
|
|
33
|
+
if (matches.length === 1) return { path, original, doc, ...matches[0] };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
export async function deleteMcpDefinition(profile, entry) {
|
|
38
|
+
const found = await findMcpDefinition(profile, entry);
|
|
39
|
+
if (!found) throw new Error('此 MCP 来自共享配置或宿主包,请在来源处删除;Hub 可禁用它');
|
|
40
|
+
if (await readFile(found.path, 'utf8') !== found.original) throw new Error('来源配置已变化,请刷新重试');
|
|
41
|
+
found.node.items.splice(found.index, 1);
|
|
42
|
+
await atomicWrite(found.path, String(found.doc));
|
|
43
|
+
await entry.parent.remove(entry.options.id);
|
|
44
|
+
}
|