@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.
package/src/service.js ADDED
@@ -0,0 +1,192 @@
1
+ import { verifyNpmIdentity } from './npm-identity.js';
2
+ import { MARKET_PACKAGE } from './identity.js';
3
+ import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { releaseList } from './catalog.js';
7
+ import { fetchJson } from './source.js';
8
+ import { CatalogProviders } from './providers.js';
9
+
10
+ async function readSaved(file) {
11
+ try { return JSON.parse(await readFile(file, 'utf8')); }
12
+ catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return null; throw error; }
13
+ }
14
+
15
+ export class Marketplace {
16
+ constructor({ config, host, fetcher, installer, demo }) {
17
+ Object.assign(this, { config, host, fetcher, installer, demo });
18
+ this.metadataPending = new Set(); this.metadataWork = Promise.resolve();
19
+ this.metadataCache = new Map(); this.metadataProgress = { running:false, done:0, total:0 };
20
+ this.plugins = []; this.jobs = []; this.catalog = null; this.catalogError = null;
21
+ this.refreshing = null; this.worker = null; this.closed = false; this.persistence = Promise.resolve();
22
+ this.providers = new CatalogProviders({ ...config, fetcher, onChange: () => {
23
+ if (!this.initialized || this.closed) return;
24
+ this.plugins = this.plugins.filter(p => this.providers.entries.has(p.catalogSource.id));
25
+ this.needsRefresh = true; void this.refresh().catch(error => { this.catalogError = error.message; });
26
+ } });
27
+ const configured = config.sources ?? [{ id: 'company', displayName: 'Company catalog', priority: 100, ...config.source }];
28
+ configured.forEach((source, index) => this.providers.registerSource(source, { primary: index === 0 }));
29
+ this.simulatedInstalled = demo ? { '@zaimokuza/dsh-acp-adapter': '0.1.2-rc.1.1', 'dsh-markdown-tools': '1.2.0' } : {};
30
+ }
31
+ async init({ refresh = true } = {}) {
32
+ await mkdir(this.config.cacheDir, { recursive: true });
33
+ const operations = await readSaved(join(this.config.cacheDir, this.demo ? 'demo-jobs.json' : 'jobs.json'));
34
+ if (Array.isArray(operations)) this.jobs = operations.slice(-30).map(job => ['queued', 'installing'].includes(job.status) ? { ...job, status: 'failed', error: '上次进程中断,请重新安装' } : job);
35
+ for (const job of this.jobs) if (this.demo && job.status === 'completed') {
36
+ if (job.action === 'uninstall') delete this.simulatedInstalled[job.packageName];
37
+ else this.simulatedInstalled[job.packageName] = job.version;
38
+ }
39
+ this.initialized = true;
40
+ if (refresh) await this.refresh();
41
+ }
42
+ persistJobs() {
43
+ // Serialize concurrent queue mutations and atomically replace the persisted snapshot.
44
+ const file = join(this.config.cacheDir, this.demo ? 'demo-jobs.json' : 'jobs.json');
45
+ const contents = JSON.stringify(this.jobs, null, 2);
46
+ this.persistence = this.persistence.catch(() => { /* The caller received the earlier write failure; allow a later snapshot to recover. */ }).then(async () => {
47
+ await writeFile(file + '.tmp', contents);
48
+ await rename(file + '.tmp', file);
49
+ });
50
+ return this.persistence;
51
+ }
52
+ async refresh() {
53
+ if (this.refreshing) return this.refreshing;
54
+ this.refreshing = (async () => { do { this.needsRefresh = false; await this.refreshData(); } while (this.needsRefresh && !this.closed); })().finally(() => { this.refreshing = null; });
55
+ return this.refreshing;
56
+ }
57
+ async refreshData() {
58
+ await this.providers.refresh();
59
+ const activeRows = new Map(this.providers.entries);
60
+ const merged = this.providers.snapshot();
61
+ this.catalog = { catalog: { plugins: merged.plugins }, version: merged.sources.map(s => s.version).filter(Boolean).join(' / ') || null, updatedAt: merged.sources.map(s => s.updatedAt).filter(Boolean).sort().at(-1) ?? null, source: merged.sources.map(s => s.packageName ?? s.id).join(', ') };
62
+ this.catalogError = merged.sources.filter(s => s.error).map(s => s.error).join('; ') || null;
63
+ const plugins = merged.plugins.map(plugin => ({ ...plugin, versions: [], recommendedVersion: null, queryError: null, metadataLoading: true }));
64
+ this.plugins = plugins;
65
+ this.metadataProgress = { running:true, done:0, total:plugins.length };
66
+ this.lazyMetadata = plugins.length > 200;
67
+ if(this.lazyMetadata){ await this.loadReleases(plugins.slice(0,40).map(p=>p.packageName)); return; }
68
+ // Keep registry load bounded even when the catalog grows.
69
+ for (let i = 0; !this.closed && i < this.catalog.catalog.plugins.length; i += 6) {
70
+ const batch = await Promise.all(this.catalog.catalog.plugins.slice(i, i + 6).map(async plugin => {
71
+ return this.resolvePlugin(plugin);
72
+ }));
73
+ plugins.splice(i, batch.length, ...batch);
74
+ this.plugins = plugins.filter(p => this.providers.entries.get(p.catalogSource.id) === activeRows.get(p.catalogSource.id));
75
+ this.metadataProgress.done = Math.min(i+batch.length,plugins.length);
76
+ }
77
+ this.metadataProgress.running = false;
78
+ this.plugins = plugins.filter(p => this.providers.entries.get(p.catalogSource.id) === activeRows.get(p.catalogSource.id));
79
+ }
80
+ async resolvePlugin(plugin) {
81
+ try {
82
+ const cached = this.metadataCache.get(plugin.packageName);
83
+ const value = !this.demo && cached && Date.now()-cached.time < 300000 ? cached.value : (await fetchJson(this.fetcher, this.config.registry + encodeURIComponent(plugin.packageName))).value;
84
+ if(!this.demo) { this.metadataCache.set(plugin.packageName,{time:Date.now(),value}); if(this.metadataCache.size>100)this.metadataCache.delete(this.metadataCache.keys().next().value); }
85
+ verifyNpmIdentity(value, plugin);
86
+ const versions = releaseList(value, this.host, Date.now(), this.config.minimumAgeHours, plugin);
87
+ if (!versions.length) throw new Error('仓库没有返回有效的发行版本');
88
+ return { ...plugin, versions, recommendedVersion: versions.find(v => v.canInstall)?.version ?? null, queryError: null };
89
+ } catch (error) { return { ...plugin, versions: [], recommendedVersion: null, queryError: error.status === 404 ? '当前 npm 仓库未找到此包(404)' : error.message }; }
90
+ }
91
+ async loadReleases(names) {
92
+ if(!Array.isArray(names)||names.length>40||names.some(name=>typeof name!=='string'))throw new Error('Invalid release request');
93
+ const wanted=new Set(names);
94
+ const targets=this.plugins.filter(p=>wanted.has(p.packageName)&&p.metadataLoading&&!this.metadataPending.has(p));
95
+ for(const plugin of targets)this.metadataPending.add(plugin);
96
+ if(!targets.length)return this.metadataWork;
97
+ this.metadataWork=this.metadataWork.catch(()=>{}).then(async()=>{
98
+ this.metadataProgress.running=true;
99
+ try {
100
+ for(let i=0;!this.closed&&i<targets.length;i+=6){
101
+ const batch=targets.slice(i,i+6);const values=await Promise.all(batch.map(p=>this.resolvePlugin(p)));
102
+ batch.forEach((target,index)=>{if(this.plugins.includes(target))Object.assign(target,values[index],{metadataLoading:false});});
103
+ this.metadataProgress.done=this.plugins.filter(p=>!p.metadataLoading).length;
104
+ }
105
+ }finally{for(const p of targets)this.metadataPending.delete(p);this.metadataProgress.running=false;}
106
+ });
107
+ return this.metadataWork;
108
+ }
109
+ async snapshot() {
110
+ const installed = this.demo ? this.simulatedInstalled : await this.installer.installed();
111
+ const sourceState = this.providers.snapshot();
112
+ return {
113
+ metadataProgress: this.metadataProgress, lazyMetadata: this.lazyMetadata,
114
+ minimumAgeMinutes: this.config.minimumAgeMinutes ?? Math.round(this.config.minimumAgeHours * 60), releaseAgeSource: this.config.releaseAgeSource ?? 'default',
115
+ brand: this.config.brand, marketId: this.config.identity?.id, sources: sourceState.sources, sourceConflicts: sourceState.conflicts,
116
+ marketVersion: '0.1.0', host: this.host, demo: Boolean(this.demo), minimumAgeHours: this.config.minimumAgeHours,
117
+ catalog: { version: this.catalog?.version ?? null, updatedAt: this.catalog?.updatedAt ?? null, source: this.catalog?.source ?? this.config.source?.packageName ?? '', error: this.catalogError, stale: sourceState.sources.some(s => s.stale), count: this.plugins.length },
118
+ plugins: this.plugins.map(plugin => ({ ...plugin, installedVersion: installed[plugin.packageName] ?? null })),
119
+ jobs: this.jobs, pendingRestart: this.jobs.filter(job => job.status === 'completed').length,
120
+ scenario: this.demo?.state.scenario, requests: this.demo?.state.requests ?? [],
121
+ };
122
+ }
123
+ async enqueue(packageName, version, action = 'install') {
124
+ if (!['install', 'uninstall'].includes(action)) throw new Error('无效的插件操作');
125
+ if ([MARKET_PACKAGE, this.config.packageName].includes(packageName)) throw new Error('请通过 DSH CLI 管理市场插件自身');
126
+ if (this.closed) throw new Error('市场正在关闭');
127
+ if (!this.plugins.some(plugin => plugin.packageName === packageName)) throw new Error('插件不在当前目录中');
128
+ const pending = this.jobs.find(job => job.packageName === packageName && ['queued', 'installing'].includes(job.status));
129
+ if (pending) {
130
+ if ((pending.action ?? 'install') !== action) throw new Error('该插件已有其它操作,请等待完成');
131
+ return pending;
132
+ }
133
+ if (action === 'uninstall') {
134
+ const installed = this.demo ? this.simulatedInstalled : await this.installer.installed();
135
+ version = installed[packageName];
136
+ if (!version) throw new Error('该插件尚未安装');
137
+ } else {
138
+ const { value } = await fetchJson(this.fetcher, this.config.registry + encodeURIComponent(packageName));
139
+ const plugin = this.plugins.find(item => item.packageName === packageName);
140
+ verifyNpmIdentity(value, plugin);
141
+ const release = releaseList(value, this.host, Date.now(), this.config.minimumAgeHours, plugin).find(item => item.version === version);
142
+ if (!release?.canInstall) throw new Error(release?.reasons.join(';') || '该版本不可安装');
143
+ }
144
+ // Recheck after network I/O so simultaneous clicks cannot enqueue duplicate work.
145
+ const raced = this.jobs.find(job => job.packageName === packageName && ['queued', 'installing'].includes(job.status));
146
+ if (raced) {
147
+ if ((raced.action ?? 'install') !== action) throw new Error('该插件已有其它操作,请等待完成');
148
+ return raced;
149
+ }
150
+ if (this.jobs.filter(job => ['queued', 'installing'].includes(job.status)).length >= 20) throw new Error('安装队列已满');
151
+ const job = { id: randomUUID(), packageName, version, action, status: 'queued', demo: Boolean(this.demo), createdAt: new Date().toISOString(), log: [], error: null };
152
+ this.jobs.push(job);
153
+ if (this.jobs.length > 30) this.jobs = this.jobs.filter(j => ['queued', 'installing'].includes(j.status) || this.jobs.indexOf(j) >= this.jobs.length - 20);
154
+ await this.persistJobs();
155
+ if (!this.worker) this.worker = this.drain().finally(() => { this.worker = null; });
156
+ return job;
157
+ }
158
+ async drain() {
159
+ while (!this.closed) {
160
+ const job = this.jobs.find(item => item.status === 'queued');
161
+ if (!job) return;
162
+ job.status = 'installing'; await this.persistJobs();
163
+ try {
164
+ const log = text => { job.log.push(text); job.log = job.log.slice(-12); };
165
+ if (this.demo) {
166
+ log(job.action === 'uninstall' ? '演示:模拟卸载,不修改真实插件' : '演示:模拟下载与安装,不运行 npm/pnpm,也不修改真实插件');
167
+ await new Promise(resolve => setTimeout(resolve, 1400));
168
+ if (job.action === 'uninstall') delete this.simulatedInstalled[job.packageName];
169
+ else this.simulatedInstalled[job.packageName] = job.version;
170
+ log(job.action === 'uninstall' ? '演示卸载完成;正式卸载需手动重启结束已加载实例' : '演示安装完成;正式安装将在手动重启后加载');
171
+ } else if (job.action === 'uninstall') await this.installer.uninstall(job.packageName, log);
172
+ else await this.installer.install(job.packageName, job.version, log);
173
+ job.status = 'completed';
174
+ } catch (error) { job.status = 'failed'; job.error = error.message; }
175
+ await this.persistJobs();
176
+ }
177
+ }
178
+ async setScenario(scenario) {
179
+ if (!this.demo || !['normal', 'broken', 'offline', 'updated'].includes(scenario)) throw new Error('无效的演示场景');
180
+ if (this.refreshing) await this.refreshing;
181
+ this.demo.state.scenario = scenario;
182
+ await this.refresh();
183
+ }
184
+ async resetDemo() {
185
+ if (!this.demo) throw new Error('只在演示模式可用');
186
+ if (this.worker) await this.worker;
187
+ this.jobs = [];
188
+ this.simulatedInstalled = { '@zaimokuza/dsh-acp-adapter': '0.1.2-rc.1.1', 'dsh-markdown-tools': '1.2.0' };
189
+ await this.persistJobs();
190
+ }
191
+ async close() { this.closed = true; this.providers.close(); if (this.refreshing) await this.refreshing; if (this.worker) await this.worker; await this.metadataWork; await this.demo?.close(); }
192
+ }
@@ -0,0 +1,9 @@
1
+ import { marketIdentity } from './identity.js';
2
+ /** A provider has no UI bundle. Its host service dependency binds it to one market. */
3
+ export function createSourcePlugin({ packageName, catalogPackage, marketId = 'hub', ...source }) {
4
+ const { service } = marketIdentity(marketId);
5
+ return { name: packageName, inject: [service], apply(ctx) {
6
+ if (ctx[service].apiVersion !== 1) throw new Error('Unsupported marketplace source API');
7
+ ctx.effect(() => ctx[service].registerSource({ ...source, ...(source.kind === 'npm' ? { packageName: catalogPackage } : {}) }), 'Plugin Hub source registration');
8
+ } };
9
+ }
package/src/source.js ADDED
@@ -0,0 +1,69 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import { Parser } from 'tar';
3
+ import { validateCatalog } from './catalog.js';
4
+
5
+ const LIMIT = 5 * 1024 * 1024;
6
+ const CATALOG_LIMIT = 32 * 1024 * 1024;
7
+
8
+ export async function boundedBytes(response, limit = LIMIT) {
9
+ if (!response.ok) { const error = new Error(`请求失败(HTTP ${response.status})`); error.status=response.status; throw error; }
10
+ const parts = []; let size = 0;
11
+ for await (const part of response.body) {
12
+ size += part.length;
13
+ if (size > limit) throw new Error('响应超过大小限制');
14
+ parts.push(Buffer.from(part));
15
+ }
16
+ return Buffer.concat(parts);
17
+ }
18
+
19
+ export async function fetchJson(fetcher, url) {
20
+ const response = await fetcher(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15000) });
21
+ const bytes = await boundedBytes(response);
22
+ try { return { value: JSON.parse(bytes.toString('utf8')), response }; }
23
+ catch { throw new Error('返回内容不是有效 JSON,已保留上一份可用数据'); }
24
+ }
25
+
26
+ export function checkIntegrity(bytes, integrity) {
27
+ if (typeof integrity !== 'string') throw new Error('目录 npm 包没有 integrity,无法校验');
28
+ const options = integrity.split(/\s+/).map(token => token.match(/^(sha512|sha384|sha256)-([A-Za-z0-9+/=]+)$/)).filter(Boolean);
29
+ if (!options.some(([, algorithm, digest]) => {
30
+ const expected = Buffer.from(digest, 'base64');
31
+ const actual = createHash(algorithm).update(bytes).digest();
32
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
33
+ })) throw new Error('目录 npm 包完整性校验失败');
34
+ }
35
+
36
+ /** Read only the data entry from the archive; never extract paths or execute scripts. */
37
+ export function readCatalogTarball(bytes) {
38
+ return new Promise((resolve, reject) => {
39
+ let result; let found = 0;
40
+ const parser = new Parser({ strict: true, onReadEntry(entry) {
41
+ if (entry.path !== 'package/plugins.json' || entry.type !== 'File') { entry.resume(); return; }
42
+ if (++found > 1 || entry.size > CATALOG_LIMIT) { reject(new Error('目录包 plugins.json 重复或过大')); entry.resume(); return; }
43
+ const chunks = []; let size = 0;
44
+ entry.on('data', chunk => { size += chunk.length; if (size > CATALOG_LIMIT) reject(new Error('目录过大')); else chunks.push(chunk); });
45
+ entry.on('end', () => { result = Buffer.concat(chunks).toString('utf8'); });
46
+ } });
47
+ parser.on('error', reject);
48
+ parser.on('end', () => {
49
+ if (found !== 1 || result === undefined) { reject(new Error('目录包缺少 plugins.json')); return; }
50
+ try { resolve(validateCatalog(JSON.parse(result))); } catch (error) { reject(error instanceof SyntaxError ? new Error('返回内容不是有效 JSON,已保留上一份可用数据') : error); }
51
+ });
52
+ parser.end(bytes);
53
+ });
54
+ }
55
+
56
+ export async function loadCatalogSource(source, registry, fetcher) {
57
+ if (source.kind !== 'npm') throw new Error('目录来源必须是 npm 数据包');
58
+ const { value: metadata } = await fetchJson(fetcher, registry + encodeURIComponent(source.packageName));
59
+ const version = metadata['dist-tags']?.latest;
60
+ const dist = metadata.versions?.[version]?.dist;
61
+ if (!dist?.tarball) throw new Error('目录 npm 包没有可下载的 latest 版本');
62
+ // A catalog must stay on its configured registry; no public fallback or credentials forwarding.
63
+ const url = new URL(dist.tarball);
64
+ if (url.origin !== new URL(registry).origin || url.username || url.password) throw new Error('目录 tarball 地址不属于配置的仓库');
65
+ const response = await fetcher(url, { signal: AbortSignal.timeout(15000), redirect: 'error' });
66
+ const bytes = await boundedBytes(response, CATALOG_LIMIT);
67
+ checkIntegrity(bytes, dist.integrity);
68
+ return { catalog: await readCatalogTarball(bytes), version, source: source.packageName };
69
+ }