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