@zaimokuza/dsh-plugin-hub 0.1.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,113 @@
1
+ /* Brand and page layout only. Interactive controls use DSH primitives unchanged. */
2
+ .hub-market {
3
+ box-sizing: border-box;
4
+ --red: #596579;
5
+ --ink: var(--dsw-alias-label-primary, #25272b);
6
+ --muted: var(--dsw-alias-label-tertiary, #7b7e86);
7
+ --line: var(--dsw-alias-border-l4, #e9e9ed);
8
+ color: var(--ink);
9
+ font: var(--dsw-font-xs-13);
10
+ padding: 5px 3px 20px;
11
+ width: 100%;
12
+ position: relative;
13
+ isolation: isolate;
14
+ container-type: inline-size;
15
+ }
16
+ .hub-market *, .hub-detail-body * { box-sizing: border-box; }
17
+ .hub-market a:focus-visible, .hub-help-link:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; }
18
+ .hub-market svg { flex-shrink: 0; }
19
+ .hub-topline { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 20px; }
20
+ .hub-brand { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: 1px; font-weight: 700; color: var(--muted); }
21
+ .hub-brandmark { display: grid; place-items: center; width: 25px; height: 25px; color: white; background: var(--red); border-radius: 50%; font: 700 20px Georgia; letter-spacing: 0; }
22
+ .hub-divider { width: 1px; height: 13px; background: var(--line); margin: 0 3px; }
23
+ .hub-runtime { display: flex; align-items: center; gap: 5px; font: 10px ui-monospace, monospace; color: var(--muted); white-space: nowrap; }
24
+ .hub-runtime i, .hub-footer i { width: 5px; height: 5px; border-radius: 50%; background: var(--dsw-alias-state-success-primary); display: inline-block; }
25
+ .hub-header { display: flex; justify-content: space-between; align-items: center; gap: 18px; margin-bottom: 18px; }
26
+ .hub-eyebrow { font-size: 9px; letter-spacing: 1.9px; color: var(--red); font-weight: 700; }
27
+ .hub-header h1 { font-size: 30px; letter-spacing: -1px; margin: 4px 0 5px; font-weight: 650; line-height: 1.4; }
28
+ .hub-header h1 span { color: var(--red); }
29
+ .hub-header p { margin: 0; color: var(--muted); font-size: 12px; }
30
+ .hub-header-art { position: relative; display: grid; place-items: center; color: var(--red); background: linear-gradient(145deg, color-mix(in srgb, var(--red) 5%, white), color-mix(in srgb, var(--red) 14%, white)); width: 80px; height: 74px; border-radius: 20px; transform: rotate(-8deg); flex-shrink: 0; border: 1px solid color-mix(in srgb, var(--red) 15%, transparent); }
31
+ .hub-art-plus { position: absolute; right: -5px; bottom: 6px; background: white; box-shadow: 0 3px 9px #47172515; border-radius: 9px; width: 28px; height: 28px; text-align: center; font-size: 21px; line-height: 26px; transform: rotate(8deg); }
32
+ .hub-art-dot { position: absolute; width: 8px; height: 8px; background: color-mix(in srgb, var(--red) 40%, white); border-radius: 50%; top: 9px; left: -6px; }
33
+ .hub-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 25px; padding: 12px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin-bottom: 14px; }
34
+ .hub-stats > div { display: flex; align-items: baseline; gap: 7px; }
35
+ .hub-stats strong { font-size: 23px; font-weight: 600; letter-spacing: -.6px; font-variant-numeric: tabular-nums; }
36
+ .hub-stats span { font-size: 11px; color: var(--muted); }
37
+ .hub-tabs { display: flex; flex-wrap: wrap; gap: 8px; padding-bottom: 12px; border-bottom: 1px solid var(--line); margin-bottom: 14px; }
38
+ .hub-tab-count { font-size: 10px; color: var(--muted); margin-left: 5px; }
39
+ .hub-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
40
+ .hub-search-input { flex: 1; min-width: 180px; }
41
+ .hub-results { display: flex; justify-content: space-between; align-items: center; gap: 10px; color: var(--muted); font-size: 11px; margin: 20px 0 12px; }
42
+ .hub-results b { margin-left: 4px; font-weight: 500; }
43
+ .hub-results small { font-size: 9px; }
44
+ .hub-pagination { display: flex; justify-content: center; padding: 20px 0 0; }
45
+ .hub-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; }
46
+ .hub-card { border: 1px solid var(--line); border-radius: 11px; padding: 16px; background: var(--dsw-alias-bg-layer-2, #fff); display: flex; flex-direction: column; min-width: 0; }
47
+ .hub-card-top { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
48
+ .hub-plugin-icon { display: grid; place-items: center; border-radius: 10px; width: 40px; height: 40px; background: #f4f0ea; color: #9c754f; }
49
+ .hub-plugin-icon.icon-knowledge { background: #eaf3f2; color: #4c8b80; }
50
+ .hub-plugin-icon.icon-developer-tools { background: #edf0fa; color: #6278ac; }
51
+ .hub-plugin-icon.icon-agent { background: #f8e9ed; color: #b94b63; }
52
+ .hub-plugin-icon.icon-integration { background: #f1eefa; color: #8c6db2; }
53
+ .hub-card-heading { font-size: 14px; margin: 0 -8px; }
54
+ .hub-description { font-size: 12px; color: var(--muted); line-height: 1.85; min-height: 44px; margin: 13px 0 12px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
55
+ .hub-tags { display: flex; gap: 5px; flex-wrap: wrap; margin: 0 0 14px; }
56
+ .hub-version-row { border-top: 1px solid var(--line); padding-top: 12px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px; font-size: 11px; color: var(--muted); margin-top: auto; }
57
+ .hub-version-note { font-size: 10px; color: var(--muted); overflow-wrap: anywhere; margin: 7px 0 13px; min-height: 16px; }
58
+ .hub-card-actions { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 9px; }
59
+ .hub-footer { display: flex; justify-content: space-between; flex-wrap: wrap; font-size: 9px; color: var(--muted); margin-top: 21px; gap: 8px; }
60
+ .hub-footer i { margin-right: 5px; }
61
+ .hub-footnote { font-size: 9px; color: var(--muted); margin: 7px 0 0; line-height: 1.7; }
62
+ .hub-empty { padding: 50px 10px; text-align: center; color: var(--muted); }
63
+ .hub-empty svg { margin: auto; }
64
+ .hub-empty h2, .hub-empty h3 { color: var(--ink); font-weight: 500; }
65
+ .hub-notice { display: flex; gap: 8px; align-items: center; font-size: 11px; padding: 12px; border-radius: 7px; margin: 10px 0; }
66
+ .hub-notice.amber { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 10%, var(--dsw-alias-bg-layer-2)); color: var(--dsw-alias-label-primary); }
67
+ .hub-notice.red { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, var(--dsw-alias-bg-layer-2)); color: var(--dsw-alias-label-primary); }
68
+ .hub-notice > div, .hub-notice > span { flex: 1; }
69
+ .hub-notice b, .hub-notice div > span { display: block; }
70
+ .hub-notice b { font-weight: 500; }
71
+ .hub-notice div > span { font-size: 10px; }
72
+ .hub-queue { position: sticky; bottom: 0; z-index: 4; margin-top: 16px; background: var(--dsw-alias-bg-layer-2, #fff); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--dsw-elevation-prominent); overflow: hidden; }
73
+ .hub-queue-summary { display: flex; align-items: center; width: 100%; gap: 10px; padding: 12px 15px; color: var(--ink); }
74
+ .hub-queue-summary > span:nth-child(2) { flex: 1; }
75
+ .hub-queue-icon { display: grid; place-items: center; border-radius: 8px; width: 33px; height: 33px; background: color-mix(in srgb, var(--red) 9%, white); color: var(--red); }
76
+ .hub-queue-summary b { font-size: 11px; font-weight: 500; display: block; }
77
+ .hub-queue-summary small { font-size: 9px; color: var(--muted); display: block; }
78
+ .hub-jobs { border-top: 1px solid var(--line); max-height: 170px; overflow: auto; padding: 0 15px; }
79
+ .hub-jobs > div { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
80
+ .hub-jobs > div > span:first-child { flex: 1; }
81
+ .hub-jobs b { font-weight: 500; font-size: 10px; }
82
+ .hub-jobs small { font-size: 9px; margin-left: 7px; color: var(--muted); }
83
+ .hub-jobs p { font-size: 10px; width: 100%; color: var(--red); margin: 0; }
84
+ .hub-detail-dialog { width: min(500px, 100%); max-height: 85vh; }
85
+ .hub-detail-scroll { overflow: auto; }
86
+ .hub-detail-body { color: var(--dsw-alias-label-primary); font: var(--dsw-font-xs-13); }
87
+ .hub-package-name { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
88
+ .hub-detail-info { display: flex; flex-wrap: wrap; gap: 25px; font-size: 11px; color: var(--dsw-alias-label-tertiary); padding: 16px 0; }
89
+ .hub-detail-info b { display: block; color: var(--dsw-alias-label-secondary); font-weight: 500; margin-top: 3px; }
90
+ .hub-detail-body h3 { font-size: 13px; margin: 10px 0; }
91
+ .hub-releases { display: grid; gap: 12px; }
92
+ .hub-release { border-bottom: 1px solid var(--dsw-alias-border-l4); padding-bottom: 12px; }
93
+ .hub-release > div { display: flex; justify-content: space-between; align-items: center; }
94
+ .hub-release small, .hub-release p { font-size: 11px; color: var(--dsw-alias-label-tertiary); margin: 6px 0 0; display: block; }
95
+ .hub-help-link:hover { color: var(--dsw-alias-brand-primary); text-decoration: underline; }
96
+ .hub-help-link { color: var(--dsw-alias-label-secondary); font-size: 11px; display: inline-flex; align-items: center; gap: 4px; text-decoration: none; }
97
+ .spinning { animation: hub-spin .9s linear infinite; }
98
+ @keyframes hub-spin { to { transform: rotate(360deg); } }
99
+ @container (max-width: 470px) {
100
+ .hub-grid { grid-template-columns: 1fr; }
101
+ .hub-topline { flex-wrap: wrap; }
102
+ .hub-header-art { display: none; }
103
+ .hub-results small { display: none; }
104
+ .hub-stats { gap: 15px; }
105
+ }
106
+ @media (prefers-reduced-motion: reduce) { .spinning { animation: none; } }
107
+
108
+ .hub-card-package { font-size: 10px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; margin-top: 5px; }
109
+ .hub-document-links { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
110
+ .hub-detail-actions { width: 100%; }
111
+ .hub-detail-actions > div:first-child { display: flex; flex-wrap: wrap; gap: 8px; }
112
+
113
+ .hub-source-label { display: block; margin-top: 8px; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
@@ -0,0 +1,9 @@
1
+ /** Source tabs show catalog membership while sharing the single merged package record. */
2
+ export function inScope(plugin, scope) {
3
+ if (scope === 'all') return true;
4
+ if (scope === 'installed') return Boolean(plugin.installedVersion);
5
+ return scope.startsWith('source:') && (plugin.catalogSourceIds ?? [plugin.catalogSource?.id]).includes(scope.slice(7));
6
+ }
7
+ export function resolveScope(scope, sources) {
8
+ return scope === 'all' || scope === 'installed' || sources.some(source => scope === 'source:' + source.id) ? scope : 'all';
9
+ }
@@ -0,0 +1,19 @@
1
+ import semver from 'semver';
2
+
3
+ /** Interpret author declarations; conflicting formats never become a positive result. */
4
+ export function dshDeclaration(manifest, host, peerEvidence) {
5
+ const entries = [['engines.dsh', manifest.engines?.dsh], ['dsh.engines.dsh', manifest.dsh?.engines?.dsh], ['dsh.compatibility.dsh', manifest.dsh?.compatibility?.dsh]].filter(([, range]) => range !== undefined);
6
+ const exact = manifest.dsh?.compatibility?.dshReleases?.[host.dsh];
7
+ // Plugin Hub's explicit opt-in is independent of SemVer, so future RCs are
8
+ // allowed too. Never widen an author's version constraint or peer dependency.
9
+ const capabilityBased = manifest.dshPluginHub?.hostCompatibility === 'capability-based';
10
+ const basis = entries.map(([key]) => key).concat(exact !== undefined ? ['dsh.compatibility.dshReleases'] : []).join(' + ') || (capabilityBased ? 'dshPluginHub.hostCompatibility' : peerEvidence ? 'peerDependencies' : 'undeclared');
11
+ const range = entries.map(([, range]) => range).filter(range => typeof range === 'string').join(' & ') || null;
12
+ if (!semver.valid(host.dsh) || entries.some(([, range]) => typeof range !== 'string' || !semver.validRange(range)) || exact !== undefined && !['compatible', 'incompatible'].includes(exact)) return { basis, range, status: 'unknown', reasons: ['未提供有效的 DSH 兼容声明'] };
13
+ const results = entries.map(([, range]) => semver.satisfies(host.dsh, range));
14
+ if (exact !== undefined) results.push(exact === 'compatible');
15
+ if (results.includes(true) && results.includes(false)) return { basis, range, status: 'unknown', reasons: ['兼容声明存在冲突,需要维护者确认'] };
16
+ if (results.includes(false)) return { basis, range, status: 'incompatible', reasons: [range ? `要求 DSH ${range},当前为 ${host.dsh}` : `作者声明不兼容 DSH ${host.dsh}`] };
17
+ if (!results.length && !peerEvidence && !capabilityBased) return { basis, range, status: 'unknown', reasons: ['未提供有效的 DSH 兼容声明'] };
18
+ return { basis, range, status: 'compatible', reasons: [] };
19
+ }
package/src/dsh.js ADDED
@@ -0,0 +1,66 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { collectHostPeers } from './host-peers.js';
6
+ import { releaseAgeArgument } from './release-age.js';
7
+
8
+ /** All knowledge about the DSH process and its profile lives in this adapter. */
9
+ export function dshEnvironment(profile = 'web') {
10
+ if (!/^[a-zA-Z0-9_-]+$/.test(profile)) throw new Error('profile 名称无效');
11
+ const cli = realpathSync(process.argv[1]);
12
+ const manifest = JSON.parse(readFileSync(join(dirname(cli), '..', 'package.json'), 'utf8'));
13
+ if (manifest.name !== '@deepseek-ai/dsh') throw new Error('请通过 DSH CLI 启动此插件');
14
+ const configuredHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
15
+ const home = configuredHome === '~' ? homedir() : /^~[/\\]/.test(configuredHome) ? join(homedir(), configuredHome.slice(2)) : configuredHome;
16
+ const profileDir = resolve(home, 'profiles', profile);
17
+ const peers = collectHostPeers(join(dirname(cli), '..', 'package.json'));
18
+ return { cli, profileDir, profile, host: { dsh: manifest.version, node: process.versions.node, platform: process.platform, arch: process.arch, peers } };
19
+ }
20
+
21
+ export function createDshInstaller(environment, config) {
22
+ let child;
23
+ const installed = async () => {
24
+ let manifest;
25
+ try { manifest = JSON.parse(readFileSync(join(environment.profileDir, 'package.json'), 'utf8')); }
26
+ catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
27
+ const result = {};
28
+ for (const name of Object.keys(manifest.dependencies ?? {})) {
29
+ try { result[name] = JSON.parse(readFileSync(join(environment.profileDir, 'node_modules', name, 'package.json'), 'utf8')).version; }
30
+ catch { /* Missing files are not an installed version, even if a manifest entry remains. */ }
31
+ }
32
+ return result;
33
+ };
34
+ async function run(args, log) {
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
+ };
66
+ }
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+
4
+ const frontendModules = ['@deepseek-ai/dsh-client-store', '@deepseek-ai/dsh-client-ui-primitives', '@deepseek-ai/dsh-client-ui-slots'];
5
+ // Official tag a66e470: packages/client/web/src/seed.ts and the three package manifests.
6
+ // Published frontends bundle these modules and do not install their devDependencies.
7
+ const verifiedFrontendReleases = { '0.1.2-rc.1': Object.fromEntries(frontendModules.map(name => [name, '0.1.2-rc.1'])) };
8
+
9
+ export function collectHostPeers(entryManifest) {
10
+ const peers = {}, queue = [entryManifest], visited = new Set();
11
+ let frontend;
12
+ while (queue.length) {
13
+ const file = queue.shift(); let manifest;
14
+ try { manifest = JSON.parse(readFileSync(file, 'utf8')); } catch { continue; }
15
+ if (visited.has(manifest.name)) continue;
16
+ visited.add(manifest.name);
17
+ if (manifest.name.startsWith('@deepseek-ai/')) peers[manifest.name] = manifest.version;
18
+ if (manifest.name === '@deepseek-ai/dsh-web-frontend') frontend = { file, version: manifest.version };
19
+ const resolver = createRequire(file);
20
+ for (const name of Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies }).filter(name => name.startsWith('@deepseek-ai/'))) {
21
+ try { queue.push(resolver.resolve(name + '/package.json')); } catch { /* Unavailable packages stay unknown. */ }
22
+ }
23
+ }
24
+ if (frontend) {
25
+ const resolver = createRequire(frontend.file);
26
+ for (const name of frontendModules) {
27
+ if (peers[name]) continue;
28
+ try { peers[name] = JSON.parse(readFileSync(resolver.resolve(name + '/package.json'), 'utf8')).version; }
29
+ catch { if (verifiedFrontendReleases[frontend.version]?.[name]) peers[name] = verifiedFrontendReleases[frontend.version][name]; }
30
+ }
31
+ }
32
+ return peers;
33
+ }
@@ -0,0 +1,15 @@
1
+ export const MARKET_PACKAGE = '@zaimokuza/dsh-plugin-hub';
2
+ export const CATALOG_PACKAGE = '@zaimokuza/dsh-plugin-hub-catalog-demo';
3
+ export const DEFAULT_BRAND = Object.freeze({ title: 'Plugin Hub', subTitle: 'DSH EXTENSIONS', primaryColor: '#596579', navTitle: 'Plugin Hub' });
4
+ export function marketIdentity(id = 'hub') {
5
+ 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)');
6
+ return { id, service: 'dshPluginHub_' + id.replaceAll('-', '_'), api: `/dsh-plugin-hub/${id}/api/`, help: `/dsh-plugin-hub/${id}/demo/help` };
7
+ }
8
+ export function normalizeBrand(value = {}) {
9
+ const brand = { ...DEFAULT_BRAND, ...value };
10
+ 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}`);
11
+ const colors = { red: '#c92536', blue: '#2563eb', green: '#16805d', purple: '#7c3aed', orange: '#c65d0b' };
12
+ brand.primaryColor = colors[brand.primaryColor] ?? brand.primaryColor;
13
+ if (!/^#[a-f\d]{6}$/i.test(brand.primaryColor)) throw new Error('primaryColor must be #RRGGBB or red/blue/green/purple/orange');
14
+ return Object.fromEntries(['title','subTitle','navTitle','primaryColor'].map(key => [key, brand[key]]));
15
+ }
package/src/index.js ADDED
@@ -0,0 +1,85 @@
1
+ import { MARKET_PACKAGE, CATALOG_PACKAGE, marketIdentity, normalizeBrand } from './identity.js';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import { join } from 'node:path';
5
+ import { createRegistryFetch } from './registry.js';
6
+ import { Marketplace } from './service.js';
7
+ import { dshEnvironment, createDshInstaller } from './dsh.js';
8
+ import { PACKAGE_NAME, validateCatalog } from './catalog.js';
9
+ import { createProviderApi } from './provider-api.js';
10
+ import { readReleaseAge } from './release-age.js';
11
+
12
+ export const name = MARKET_PACKAGE;
13
+ export const inject = ['webServer', 'connection'];
14
+
15
+
16
+ const json = (res, status, value) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); };
17
+
18
+ async function body(req) {
19
+ const buffers = []; let size = 0;
20
+ for await (const bytes of req) { size += bytes.length; if (size > 16384) throw new Error('请求过大'); buffers.push(bytes); }
21
+ return JSON.parse(Buffer.concat(buffers).toString('utf8'));
22
+ }
23
+
24
+ export async function apply(ctx, options = {}) {
25
+ for (const [name, fn] of [['connection.requestRejection', ctx.connection?.requestRejection], ['webServer.register', ctx.webServer?.register], ['provide', ctx.provide], ['effect', ctx.effect]]) {
26
+ if (typeof fn !== 'function') throw new Error(`Plugin Hub cannot start: required DSH API ${name} is unavailable. / 市场无法启动:当前 DSH 缺少必要接口 ${name}。`);
27
+ }
28
+ const identity = marketIdentity(options.marketId);
29
+ const API = identity.api;
30
+ const brand = normalizeBrand(options.brand);
31
+ const environment = dshEnvironment(options.profile ?? 'web');
32
+ const source = { kind: 'npm', packageName: options.catalogPackage ?? CATALOG_PACKAGE };
33
+ if (options.source) throw new Error('目录来源已改为 Nexus npm 包,请使用 catalogPackage 配置');
34
+ if (!PACKAGE_NAME.test(source.packageName)) throw new Error('目录 npm 包名无效');
35
+ const registry = new URL(options.registry ?? 'https://registry.npmjs.org/');
36
+ if (registry.protocol !== 'https:' || registry.username || registry.password) throw new Error('npm registry 必须是无凭据的 HTTPS 地址');
37
+ const releaseAge = await readReleaseAge(environment.profileDir);
38
+ const sources = options.catalogSources ?? [{ id: 'company', displayName: source.packageName === CATALOG_PACKAGE ? 'Demo catalog' : brand.title, ...source, priority: 100 }];
39
+ if (!Array.isArray(sources) || sources.length > 20 || sources.some(s => s?.kind !== 'npm')) throw new Error('catalogSources must contain at most 20 npm sources; JSON providers register through the plugin API');
40
+ const config = { source, sources, brand, identity, packageName: options.packageName ?? MARKET_PACKAGE, registry: registry.href.replace(/\/?$/, '/'), cacheDir: join(environment.profileDir, '.dsh-plugin-hub', identity.id), ...releaseAge };
41
+ const installer = createDshInstaller(environment, config);
42
+ const market = new Marketplace({ config, host: environment.host, fetcher: createRegistryFetch(config.registry), installer });
43
+ // The public package ships an independent catalog snapshot for first-open discovery.
44
+ // Enterprise wrappers do not read it: their catalog name differs from CATALOG_PACKAGE.
45
+ if (source.packageName === CATALOG_PACKAGE && !options.catalogSources) {
46
+ try {
47
+ const file = createRequire(import.meta.url).resolve(CATALOG_PACKAGE + '/plugins.json');
48
+ market.providers.entries.get('company').value = { catalog: validateCatalog(JSON.parse(await readFile(file, 'utf8'))), source: CATALOG_PACKAGE, version: 'bundled', updatedAt: null };
49
+ } catch { /* A registry refresh remains available if a snapshot is absent. */ }
50
+ }
51
+ if (options.initialCatalog) market.providers.registerSource({ id: 'bundled', displayName: brand.title, kind: 'json', getCatalog: async () => options.initialCatalog }, { primary: true });
52
+ // Registry availability must not delay or fail DSH boot.
53
+ await market.init({ refresh: false });
54
+ ctx.provide(identity.service, createProviderApi(market.providers));
55
+ void market.refresh().catch(error => { market.catalogError = error.message; });
56
+ ctx.effect(() => () => { installer.close(); return market.close(); }, 'Plugin Hub: lifecycle');
57
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: API.slice(0, -1), async handler(req, res) {
58
+ const rejection = ctx.connection.requestRejection(req);
59
+ if (rejection) { json(res, rejection, { error: '请先登录当前 DSH 实例' }); return; }
60
+ if (req.method === 'POST') {
61
+ let origin;
62
+ try { origin = new URL(req.headers.origin).host; } catch { origin = null; }
63
+ if (!origin || origin !== req.headers.host) { json(res, 403, { error: '请求来源不匹配' }); return; }
64
+ }
65
+ try {
66
+ const path = new URL(req.url, 'http://localhost').pathname.slice(API.length);
67
+ if (path === 'state' && req.method === 'GET') { json(res, 200, await market.snapshot()); return; }
68
+ if (req.method !== 'POST') { json(res, 405, { error: '不支持的请求方法' }); return; }
69
+ if (path === 'refresh-start' && req.method === 'POST') { void market.refresh().catch(error => { market.catalogError = error.message; }); json(res, 202, await market.snapshot()); return; }
70
+ if (path === 'refresh') { await market.refresh(); json(res, 200, await market.snapshot()); return; }
71
+ const data = await body(req);
72
+ if (path === 'releases') { void market.loadReleases(data.packageNames).catch(error => { market.catalogError = error.message; }); json(res, 202, await market.snapshot()); return; }
73
+ if (path === 'install') { json(res, 202, await market.enqueue(data.packageName, data.version)); return; }
74
+ if (path === 'uninstall') { json(res, 202, await market.enqueue(data.packageName, undefined, 'uninstall')); return; }
75
+ json(res, 404, { error: '接口不存在' });
76
+ } catch (error) { json(res, 400, { error: error.message }); }
77
+ } }), 'Plugin Hub: HTTP routes');
78
+
79
+ }
80
+
81
+ /** Generated brands use this factory without activating the default demo bundle. */
82
+ export function createMarketplace(defaults) {
83
+ marketIdentity(defaults.marketId);
84
+ return { name: defaults.packageName, inject, apply: (ctx, options = {}) => apply(ctx, { ...defaults, ...options, brand: { ...defaults.brand, ...options.brand }, marketId: defaults.marketId, packageName: defaults.packageName }) };
85
+ }
@@ -0,0 +1,15 @@
1
+ export function githubRepository(value) {
2
+ let url=typeof value==='string'?value:value?.url;
3
+ if(typeof url!=='string')return null;
4
+ url=url.replace(/^git\+/, '').replace(/^git@github\.com:/, 'https://github.com/');
5
+ try{const parsed=new URL(url);if(parsed.hostname.toLowerCase()!=='github.com')return null;const parts=parsed.pathname.replace(/\.git\/?$/,'').split('/').filter(Boolean);return parts.length===2?parts.join('/').toLowerCase():null;}catch{return null;}
6
+ }
7
+ /** A repository package name is not enough to identify the same published npm project. */
8
+ export function verifyNpmIdentity(metadata, plugin) {
9
+ if (!plugin.verification && !githubRepository(plugin.repositoryUrl)) return;
10
+ const expected=githubRepository(plugin.repositoryUrl);
11
+ const latest=metadata.versions?.[metadata['dist-tags']?.latest];
12
+ const actual=githubRepository(latest?.repository??metadata.repository);
13
+ if(!actual)throw new Error('npm 包未声明可核对的 GitHub 来源');
14
+ if(actual!==expected)throw new Error('npm 包对应的 GitHub 仓库与目录不一致');
15
+ }
@@ -0,0 +1,36 @@
1
+ /** Plugin Hub API v1. Host-side data contributions, not browser UI slots. */
2
+ export interface CatalogPlugin {
3
+ packageName: string;
4
+ displayName: string;
5
+ description: string;
6
+ owner: string;
7
+ origin: 'internal' | 'community';
8
+ tags: string[];
9
+ documentationUrl?: string;
10
+ troubleshootingUrl?: string;
11
+ repositoryUrl?: string;
12
+ locales?: Record<string, { displayName?: string; description?: string }>;
13
+ }
14
+ export interface Catalog {
15
+ schemaVersion: 1;
16
+ plugins: CatalogPlugin[];
17
+ }
18
+ export interface SourceOptions {
19
+ /** Unique within one marketplace; lowercase letters, digits, dot, dash, underscore. */
20
+ id: string;
21
+ displayName?: string;
22
+ /** -1000 to 1000; higher wins between contributors. Contributors beat main data. */
23
+ priority?: number;
24
+ /** Change when cache identity/meaning changes. Never put credentials here. */
25
+ cacheVersion?: string;
26
+ }
27
+ export type CatalogSource = SourceOptions & (
28
+ | { kind: 'json'; getCatalog: (options: { signal: AbortSignal }) => Catalog | Promise<Catalog> }
29
+ | { kind: 'npm'; packageName: string }
30
+ );
31
+ export interface ProviderApi {
32
+ readonly apiVersion: 1;
33
+ /** Returns an idempotent disposer; bind it to the contributing plugin's lifecycle. */
34
+ registerSource(source: CatalogSource): () => void;
35
+ }
36
+ export function createProviderApi(providers: Pick<ProviderApi, 'registerSource'>): ProviderApi;
@@ -0,0 +1,7 @@
1
+ /** Public host-side contract; contributors never receive installer or registry access. */
2
+ export function createProviderApi(providers) {
3
+ return Object.freeze({
4
+ apiVersion: 1,
5
+ registerSource: source => providers.registerSource(source),
6
+ });
7
+ }
@@ -0,0 +1,83 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { PACKAGE_NAME, validateCatalog } from './catalog.js';
5
+ import { loadCatalogSource } from './source.js';
6
+
7
+ /** Host-only v1 API. Providers contribute data; registry and installation policy stay market-owned. */
8
+ export class CatalogProviders {
9
+ constructor({ cacheDir, registry, fetcher, onChange = () => {}, timeoutMs = 15000 }) {
10
+ Object.assign(this, { cacheDir, registry, fetcher, onChange, timeoutMs });
11
+ this.entries = new Map();
12
+ }
13
+ registerSource(input, { primary = false } = {}) {
14
+ if (!input || typeof input.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,79}$/.test(input.id)) throw new Error('Invalid catalog source id');
15
+ if (this.entries.has(input.id)) throw new Error(`Duplicate catalog source id: ${input.id}`);
16
+ if (this.entries.size >= 20) throw new Error('At most 20 catalog sources are supported');
17
+ if (!['npm', 'json'].includes(input.kind)) throw new Error('Catalog source kind must be npm or json');
18
+ if (input.kind === 'npm' && (typeof input.packageName !== 'string' || !PACKAGE_NAME.test(input.packageName) || input.packageName.length > 214)) throw new Error('Invalid catalog package name');
19
+ if (input.kind === 'json' && typeof input.getCatalog !== 'function') throw new Error('JSON source requires getCatalog({ signal })');
20
+ if (input.priority !== undefined && (!Number.isInteger(input.priority) || Math.abs(input.priority) > 1000)) throw new Error('Source priority must be an integer between -1000 and 1000');
21
+ if (input.displayName !== undefined && (typeof input.displayName !== 'string' || input.displayName.length > 80)) throw new Error('Invalid source displayName');
22
+ if (input.cacheVersion !== undefined && (typeof input.cacheVersion !== 'string' || input.cacheVersion.length > 80)) throw new Error('Invalid source cacheVersion');
23
+ const source = Object.freeze({ ...input, displayName: input.displayName || input.id, priority: input.priority ?? 0 });
24
+ const key = JSON.stringify([this.registry, source.id, source.kind, source.packageName, source.cacheVersion ?? '1']);
25
+ const row = { source, primary, key, value: null, error: null, loaded: false, controller: null };
26
+ row.file = join(this.cacheDir, 'sources', createHash('sha256').update(key).digest('hex') + '.json');
27
+ this.entries.set(source.id, row); this.onChange();
28
+ return () => {
29
+ if (this.entries.get(source.id) !== row) return;
30
+ this.entries.delete(source.id); row.controller?.abort(); this.onChange();
31
+ };
32
+ }
33
+ async refresh() {
34
+ // Bounded batches keep a failed or slow provider from exhausting the registry.
35
+ const rows = [...this.entries.values()];
36
+ for (let i = 0; i < rows.length; i += 4) await Promise.all(rows.slice(i, i + 4).map(row => this.refreshOne(row)));
37
+ }
38
+ async refreshOne(row) {
39
+ if (this.entries.get(row.source.id) !== row) return;
40
+ if (!row.loaded) {
41
+ row.loaded = true;
42
+ try {
43
+ const saved = JSON.parse(await readFile(row.file, 'utf8'));
44
+ if (saved.key === row.key) row.value = { ...saved.value, catalog: validateCatalog(saved.value.catalog) };
45
+ } catch { /* No valid cached snapshot; fetch a fresh one. */ }
46
+ }
47
+ const controller = new AbortController(); row.controller = controller;
48
+ let timer;
49
+ try {
50
+ const load = async () => {
51
+ if (row.source.kind === 'npm') return loadCatalogSource(row.source, this.registry, this.fetcher);
52
+ const data = await row.source.getCatalog({ signal: controller.signal });
53
+ if (Buffer.byteLength(JSON.stringify(data)) > 32 * 1024 * 1024) throw new Error('响应超过大小限制');
54
+ return { catalog: validateCatalog(data), source: row.source.id, version: row.source.cacheVersion ?? '1' };
55
+ };
56
+ const timeout = new Promise((_, reject) => {
57
+ timer = setTimeout(() => { reject(new Error('目录来源读取超时')); controller.abort(); }, this.timeoutMs);
58
+ controller.signal.addEventListener('abort', () => reject(new Error('目录来源读取已取消')), { once: true });
59
+ });
60
+ const next = await Promise.race([load(), timeout]);
61
+ if (this.entries.get(row.source.id) !== row) return;
62
+ const value = { ...next, updatedAt: new Date().toISOString() };
63
+ await mkdir(join(this.cacheDir, 'sources'), { recursive: true });
64
+ await writeFile(row.file + '.tmp', JSON.stringify({ key: row.key, value }));
65
+ await rename(row.file + '.tmp', row.file);
66
+ if (this.entries.get(row.source.id) === row) { row.value = value; row.error = null; }
67
+ } catch (error) { row.error = error.message; }
68
+ finally { clearTimeout(timer); row.controller = null; }
69
+ }
70
+ snapshot() {
71
+ const rows = [...this.entries.values()].sort((a, b) => Number(a.primary) - Number(b.primary) || b.source.priority - a.source.priority || (a.source.id < b.source.id ? -1 : a.source.id > b.source.id ? 1 : 0));
72
+ const plugins = new Map(); const conflicts = [];
73
+ for (const row of rows) for (const plugin of row.value?.catalog.plugins ?? []) {
74
+ if (plugins.has(plugin.packageName)) { plugins.get(plugin.packageName).catalogSourceIds.push(row.source.id); conflicts.push({ packageName: plugin.packageName, selectedSource: plugins.get(plugin.packageName).catalogSource.id, ignoredSource: row.source.id }); continue; }
75
+ plugins.set(plugin.packageName, { ...plugin, catalogSourceIds: [row.source.id], catalogSource: { id: row.source.id, displayName: row.source.displayName } });
76
+ }
77
+ return {
78
+ plugins: [...plugins.values()], conflicts,
79
+ sources: rows.map(({ source, primary, value, error }) => ({ primary, id: source.id, displayName: source.displayName, kind: source.kind, packageName: source.packageName, priority: source.priority, version: value?.version ?? null, updatedAt: value?.updatedAt ?? null, count: value?.catalog.plugins.length ?? 0, error, stale: Boolean(error && value) })),
80
+ };
81
+ }
82
+ close() { for (const row of this.entries.values()) row.controller?.abort(); this.entries.clear(); }
83
+ }
@@ -0,0 +1,15 @@
1
+ import { fetch } from 'undici';
2
+ import getRegistryAuthToken from 'registry-auth-token';
3
+
4
+ /** Use existing npm credentials for the configured Nexus origin; never send them to redirects. */
5
+ export function createRegistryFetch(registry, { fetcher = fetch, authLookup = getRegistryAuthToken } = {}) {
6
+ const origin = new URL(registry).origin;
7
+ return async (value, options = {}) => {
8
+ const url = new URL(value);
9
+ if (url.origin !== origin || url.protocol !== 'https:' || url.username || url.password) throw new Error('仓库请求地址不属于配置的 Nexus');
10
+ const auth = authLookup(url.href, { recursive: true });
11
+ const headers = new Headers(options.headers);
12
+ if (auth) headers.set('authorization', `${auth.type} ${auth.token}`);
13
+ return fetcher(url, { ...options, headers, redirect: 'error' });
14
+ };
15
+ }
@@ -0,0 +1,34 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const runFile = promisify(execFile);
5
+ const DEFAULT_MINUTES = 48 * 60;
6
+
7
+ export function parseReleaseAge(output) {
8
+ // pnpm can print a workspace warning before the queried scalar.
9
+ const line = String(output).trim().split(/\r?\n/).at(-1);
10
+ let value;
11
+ try { value = JSON.parse(line); } catch { value = line; }
12
+ if (typeof value === 'string' && /^\d+$/.test(value.trim())) value = Number(value.trim());
13
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
14
+ && Number.isSafeInteger(value * 60000) && Date.now() + value * 60000 <= 8640000000000000 ? value : null;
15
+ }
16
+
17
+ /** Ask the locally installed pnpm for the config effective in the DSH profile. */
18
+ export async function readReleaseAge(cwd, { run = runFile, env = process.env, platform = process.platform } = {}) {
19
+ let minutes = null;
20
+ try {
21
+ const { stdout } = await run(platform === 'win32' ? 'pnpm.cmd' : 'pnpm', ['config', 'get', 'minimumReleaseAge', '--json'], {
22
+ cwd, timeout: 5000, maxBuffer: 16384, windowsHide: true, shell: platform === 'win32',
23
+ // Config inspection must not download a different package manager.
24
+ env: { ...env, COREPACK_ENABLE_NETWORK: '0', COREPACK_ENABLE_PROJECT_SPEC: '0',
25
+ npm_config_manage_package_manager_versions: 'false', pnpm_config_manage_package_manager_versions: 'false' },
26
+ });
27
+ minutes = parseReleaseAge(stdout);
28
+ } catch { /* Missing executable, unsupported command or timeout: use the fallback. */ }
29
+ return { minimumAgeMinutes: minutes ?? DEFAULT_MINUTES, minimumAgeHours: (minutes ?? DEFAULT_MINUTES) / 60, releaseAgeSource: minutes === null ? 'default' : 'pnpm' };
30
+ }
31
+
32
+ export function releaseAgeArgument(config) {
33
+ return `--config.minimumReleaseAge=${config.minimumAgeMinutes ?? Math.round(config.minimumAgeHours * 60)}`;
34
+ }