dsh-m 0.0.2 → 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/DESIGN.md +140 -0
- package/README.md +54 -10
- package/cordis.patch.yml +3 -0
- package/lib/.keep +0 -0
- package/lib/cli.js +196 -0
- package/lib/client.js +786 -0
- package/lib/core/dsh-cli.js +212 -0
- package/lib/core/env.js +18 -0
- package/lib/core/httpx.js +94 -0
- package/lib/core/installed.js +121 -0
- package/lib/core/live-plugin.js +42 -0
- package/lib/core/market.js +193 -0
- package/lib/core/registry.js +165 -0
- package/lib/core/restart.js +213 -0
- package/lib/core/versions.js +47 -0
- package/lib/host.js +187 -0
- package/lib/tools.js +340 -0
- package/package.json +57 -7
- package/registry.json +26 -0
- package/index.js +0 -9
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry(DESIGN.md §2):repo 内手工 curated 的 registry.json。
|
|
3
|
+
* 分发顺序:源覆盖 → jsDelivr @main → raw @main → TTL 缓存 → 包内快照兜底。
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { cacheDir } from './env.js';
|
|
9
|
+
import { fetchJsonLimited } from './httpx.js';
|
|
10
|
+
export const CATEGORIES = ['market', 'tools', 'ui', 'search', 'media', 'other'];
|
|
11
|
+
const REPO = 'iasiv5/dsh-m';
|
|
12
|
+
const DEFAULT_URLS = [
|
|
13
|
+
{ source: 'jsdelivr', url: `https://cdn.jsdelivr.net/gh/${REPO}@main/registry.json` },
|
|
14
|
+
{ source: 'raw', url: `https://raw.githubusercontent.com/${REPO}/main/registry.json` },
|
|
15
|
+
];
|
|
16
|
+
// ---------- 校验 ----------
|
|
17
|
+
const GITHUB_RE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
|
|
18
|
+
const HTTPS_URL_RE = /^https:\/\/\S+$/i;
|
|
19
|
+
export function validateRegistry(raw) {
|
|
20
|
+
const errors = [];
|
|
21
|
+
if (!raw || typeof raw !== 'object')
|
|
22
|
+
return { ok: false, errors: ['registry 根必须是对象'], registry: null };
|
|
23
|
+
const obj = raw;
|
|
24
|
+
if (obj.version !== 1)
|
|
25
|
+
errors.push('version 必须为 1');
|
|
26
|
+
if (!Array.isArray(obj.plugins))
|
|
27
|
+
return { ok: false, errors: [...errors, 'plugins 必须是数组'], registry: null };
|
|
28
|
+
const plugins = [];
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
obj.plugins.forEach((item, index) => {
|
|
31
|
+
const where = `plugins[${index}]`;
|
|
32
|
+
if (!item || typeof item !== 'object') {
|
|
33
|
+
errors.push(`${where}: 必须是对象`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const e = item;
|
|
37
|
+
const id = String(e.id || '').trim();
|
|
38
|
+
if (!id || id.length > 64)
|
|
39
|
+
return errors.push(`${where}.id 无效`);
|
|
40
|
+
if (seen.has(id))
|
|
41
|
+
return errors.push(`${where}.id 重复: ${id}`);
|
|
42
|
+
seen.add(id);
|
|
43
|
+
const category = String(e.category || '');
|
|
44
|
+
if (!CATEGORIES.includes(category))
|
|
45
|
+
return errors.push(`${where}.category 无效: ${category}`);
|
|
46
|
+
const source = String(e.source || '');
|
|
47
|
+
if (source !== 'npm' && source !== 'github')
|
|
48
|
+
return errors.push(`${where}.source 无效: ${source}`);
|
|
49
|
+
const entry = {
|
|
50
|
+
id,
|
|
51
|
+
name: String(e.name || id).trim().slice(0, 100),
|
|
52
|
+
description: String(e.description || '').trim().slice(0, 500),
|
|
53
|
+
category: category,
|
|
54
|
+
tags: Array.isArray(e.tags) ? e.tags.map((t) => String(t).trim().slice(0, 30)).filter(Boolean).slice(0, 10) : [],
|
|
55
|
+
source,
|
|
56
|
+
};
|
|
57
|
+
if (source === 'npm') {
|
|
58
|
+
const npm = String(e.npm || '').trim();
|
|
59
|
+
if (!npm)
|
|
60
|
+
return errors.push(`${where}.npm 必填(source=npm)`);
|
|
61
|
+
entry.npm = npm;
|
|
62
|
+
}
|
|
63
|
+
if (source === 'github') {
|
|
64
|
+
const gh = String(e.github || '').trim();
|
|
65
|
+
if (!GITHUB_RE.test(gh))
|
|
66
|
+
return errors.push(`${where}.github 必须是 owner/repo(source=github)`);
|
|
67
|
+
entry.github = gh;
|
|
68
|
+
}
|
|
69
|
+
if (e.npm && source !== 'npm')
|
|
70
|
+
entry.npm = String(e.npm).trim();
|
|
71
|
+
if (e.github && source !== 'github' && GITHUB_RE.test(String(e.github).trim()))
|
|
72
|
+
entry.github = String(e.github).trim();
|
|
73
|
+
for (const key of ['homepage', 'icon']) {
|
|
74
|
+
const v = e[key];
|
|
75
|
+
if (v === undefined || v === null || v === '')
|
|
76
|
+
continue;
|
|
77
|
+
const s = String(v).trim();
|
|
78
|
+
if (!HTTPS_URL_RE.test(s))
|
|
79
|
+
return errors.push(`${where}.${key} 必须是 https URL`);
|
|
80
|
+
entry[key] = s;
|
|
81
|
+
}
|
|
82
|
+
plugins.push(entry);
|
|
83
|
+
});
|
|
84
|
+
return { ok: errors.length === 0, errors, registry: errors.length === 0 ? { version: 1, plugins } : null };
|
|
85
|
+
}
|
|
86
|
+
// ---------- 包内快照兜底 ----------
|
|
87
|
+
function bundledSnapshot() {
|
|
88
|
+
const require = createRequire(import.meta.url);
|
|
89
|
+
for (const rel of ['../registry.json', '../../registry.json']) {
|
|
90
|
+
try {
|
|
91
|
+
const raw = require(rel);
|
|
92
|
+
const parsed = validateRegistry(raw);
|
|
93
|
+
if (parsed.registry)
|
|
94
|
+
return parsed.registry;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* try next */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { version: 1, plugins: [] };
|
|
101
|
+
}
|
|
102
|
+
function cachePath() {
|
|
103
|
+
return join(cacheDir(), 'registry.json');
|
|
104
|
+
}
|
|
105
|
+
function readCache() {
|
|
106
|
+
try {
|
|
107
|
+
const raw = JSON.parse(readFileSync(cachePath(), 'utf8'));
|
|
108
|
+
if (!raw || typeof raw !== 'object' || !raw.registry || !Array.isArray(raw.registry.plugins))
|
|
109
|
+
return null;
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function writeCache(file) {
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(cacheDir(), { recursive: true });
|
|
119
|
+
writeFileSync(cachePath(), JSON.stringify(file, null, 2));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* 缓存写失败不致命 */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function cacheFresh(file, ttlMin) {
|
|
126
|
+
const t = Date.parse(file.fetchedAt || '');
|
|
127
|
+
if (!Number.isFinite(t))
|
|
128
|
+
return false;
|
|
129
|
+
return Date.now() - t < ttlMin * 60_000;
|
|
130
|
+
}
|
|
131
|
+
// ---------- 加载 ----------
|
|
132
|
+
export async function loadRegistry(cfg = {}, opts = {}) {
|
|
133
|
+
const errors = [];
|
|
134
|
+
const timeoutMs = cfg.timeoutMs ?? 20_000;
|
|
135
|
+
const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
|
|
136
|
+
const cached = readCache();
|
|
137
|
+
if (!opts.force && cached && cacheFresh(cached, ttlMin)) {
|
|
138
|
+
return { registry: cached.registry, source: 'cache', fetchedAt: cached.fetchedAt, errors };
|
|
139
|
+
}
|
|
140
|
+
const candidates = [];
|
|
141
|
+
if (cfg.registryUrl && cfg.registryUrl.trim()) {
|
|
142
|
+
candidates.push({ source: 'override', url: cfg.registryUrl.trim() });
|
|
143
|
+
}
|
|
144
|
+
candidates.push(...DEFAULT_URLS);
|
|
145
|
+
for (const candidate of candidates) {
|
|
146
|
+
try {
|
|
147
|
+
const raw = await fetchJsonLimited(candidate.url, { timeoutMs });
|
|
148
|
+
const parsed = validateRegistry(raw);
|
|
149
|
+
if (!parsed.ok || !parsed.registry) {
|
|
150
|
+
errors.push(`${candidate.source}: registry 校验失败 — ${parsed.errors.slice(0, 3).join('; ')}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const fetchedAt = new Date().toISOString();
|
|
154
|
+
writeCache({ fetchedAt, source: candidate.source, registry: parsed.registry });
|
|
155
|
+
return { registry: parsed.registry, source: candidate.source, fetchedAt, errors };
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const he = err;
|
|
159
|
+
errors.push(`${candidate.source}: ${he?.status ? `HTTP ${he.status}` : err instanceof Error ? err.message : String(err)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (cached)
|
|
163
|
+
return { registry: cached.registry, source: 'cache', fetchedAt: cached.fetchedAt, errors };
|
|
164
|
+
return { registry: bundledSnapshot(), source: 'bundled', fetchedAt: null, errors };
|
|
165
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自重启:移植自 skillhub restart.ts(tag→OIDC 同源的 dsh-web shim 环境已验证)。
|
|
3
|
+
* 优先 systemd 单元重启(本机 = deepseek-harness.service,dsh-web.service 为转发 shim);
|
|
4
|
+
* 无 systemd 时退回 detached helper 等端口释放后换身重拉。
|
|
5
|
+
* 安全:restart 端点必须通过 trustedRestartRequest(Origin 与 Host 同源)。
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { dshArgv, nodeExecutable } from './dsh-cli.js';
|
|
12
|
+
function headerString(raw) {
|
|
13
|
+
if (raw === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
16
|
+
const trimmed = String(value || '').trim();
|
|
17
|
+
return trimmed === '' ? undefined : trimmed.split(',')[0].trim();
|
|
18
|
+
}
|
|
19
|
+
function parseHost(raw) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(raw.includes('://') ? raw : `http://${raw}`);
|
|
22
|
+
return { hostname: parsed.hostname.toLowerCase(), port: parsed.port };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function isLoopbackHost(host) {
|
|
29
|
+
const parsed = parseHost(host);
|
|
30
|
+
if (parsed === null)
|
|
31
|
+
return false;
|
|
32
|
+
return parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '::1';
|
|
33
|
+
}
|
|
34
|
+
function hostsMatch(originHost, candidate) {
|
|
35
|
+
const a = parseHost(originHost);
|
|
36
|
+
const b = parseHost(candidate);
|
|
37
|
+
if (a === null || b === null)
|
|
38
|
+
return false;
|
|
39
|
+
if (a.hostname !== b.hostname)
|
|
40
|
+
return false;
|
|
41
|
+
if (a.port !== '' && b.port !== '' && a.port !== b.port)
|
|
42
|
+
return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
export function servingPort(request) {
|
|
46
|
+
const host = headerString(request.headers.host);
|
|
47
|
+
if (host === undefined || !isLoopbackHost(host))
|
|
48
|
+
return null;
|
|
49
|
+
const match = /:(\d{1,5})$/u.exec(host);
|
|
50
|
+
if (match === null)
|
|
51
|
+
return null;
|
|
52
|
+
const port = Number(match[1]);
|
|
53
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
54
|
+
}
|
|
55
|
+
export function trustedRestartRequest(request) {
|
|
56
|
+
const origin = headerString(request.headers.origin);
|
|
57
|
+
if (origin === undefined)
|
|
58
|
+
return false;
|
|
59
|
+
let from;
|
|
60
|
+
try {
|
|
61
|
+
const parsed = new URL(origin);
|
|
62
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
63
|
+
return false;
|
|
64
|
+
from = parsed.host;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const host = headerString(request.headers.host);
|
|
70
|
+
const forwardedHost = headerString(request.headers['x-forwarded-host']);
|
|
71
|
+
const candidates = [host, forwardedHost].filter((value) => value !== undefined);
|
|
72
|
+
return candidates.some((candidate) => hostsMatch(from, candidate));
|
|
73
|
+
}
|
|
74
|
+
export function readProcCgroup(readFile = (path, encoding) => readFileSync(path, encoding)) {
|
|
75
|
+
try {
|
|
76
|
+
return readFile('/proc/self/cgroup', 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function systemdUnitName(cgroupText) {
|
|
83
|
+
for (const line of cgroupText.split(/\r?\n/u)) {
|
|
84
|
+
if (line.trim() === '')
|
|
85
|
+
continue;
|
|
86
|
+
const path = line.includes(':') ? line.slice(line.lastIndexOf(':') + 1) : line;
|
|
87
|
+
const parts = path.split('/').filter(Boolean);
|
|
88
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
89
|
+
const part = parts[i];
|
|
90
|
+
if (!part.endsWith('.service'))
|
|
91
|
+
continue;
|
|
92
|
+
if (/^user@\d+\.service$/u.test(part))
|
|
93
|
+
continue;
|
|
94
|
+
return part;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
export function systemdRestartArgv(opts) {
|
|
100
|
+
const unit = systemdUnitName(opts.cgroup);
|
|
101
|
+
if (unit === null)
|
|
102
|
+
return null;
|
|
103
|
+
if (opts.cgroup.includes('/user.slice/')) {
|
|
104
|
+
return { file: 'systemctl', args: ['--user', 'restart', '--no-block', unit] };
|
|
105
|
+
}
|
|
106
|
+
if (opts.uid === 0) {
|
|
107
|
+
return { file: 'systemctl', args: ['restart', '--no-block', unit] };
|
|
108
|
+
}
|
|
109
|
+
return { file: 'sudo', args: ['-n', 'systemctl', 'restart', '--no-block', unit] };
|
|
110
|
+
}
|
|
111
|
+
export function restartLaunch() {
|
|
112
|
+
const launch = dshArgv();
|
|
113
|
+
return {
|
|
114
|
+
...launch,
|
|
115
|
+
args: [...launch.args, ...process.argv.slice(2)],
|
|
116
|
+
cwd: launch.cwd ?? process.cwd(),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function respawnInvocation(launch, platform = process.platform) {
|
|
120
|
+
if (platform !== 'win32') {
|
|
121
|
+
return { file: launch.file, args: launch.args, viaShell: launch.viaShell, detached: true };
|
|
122
|
+
}
|
|
123
|
+
const quote = (part) => `'${part.replace(/'/g, "''")}'`;
|
|
124
|
+
return {
|
|
125
|
+
file: 'powershell.exe',
|
|
126
|
+
args: ['-NoProfile', '-WindowStyle', 'Hidden', '-Command',
|
|
127
|
+
[`& ${quote(launch.file)}`, ...launch.args.map(quote)].join(' ')],
|
|
128
|
+
viaShell: false,
|
|
129
|
+
detached: false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function restartHelperSource(spawned, launch, logs, port) {
|
|
133
|
+
return [
|
|
134
|
+
"const { spawn } = require('node:child_process')",
|
|
135
|
+
"const fs = require('node:fs')",
|
|
136
|
+
"const net = require('node:net')",
|
|
137
|
+
`const file = ${JSON.stringify(spawned.file)}`,
|
|
138
|
+
`const args = ${JSON.stringify(spawned.args)}`,
|
|
139
|
+
`const cwd = ${JSON.stringify(launch.cwd)}`,
|
|
140
|
+
`const viaShell = ${JSON.stringify(spawned.viaShell)}`,
|
|
141
|
+
`const detached = ${JSON.stringify(spawned.detached)}`,
|
|
142
|
+
`const logOut = ${JSON.stringify(logs.out)}`,
|
|
143
|
+
`const logErr = ${JSON.stringify(logs.err)}`,
|
|
144
|
+
`const port = ${JSON.stringify(port)}`,
|
|
145
|
+
'const sleep = (ms) => new Promise(r => setTimeout(r, ms))',
|
|
146
|
+
"const note = (line) => { try { fs.appendFileSync(logErr, `[dsh-m] ${line}\\n`) } catch {} }",
|
|
147
|
+
'const listening = () => new Promise((resolve) => {',
|
|
148
|
+
' const probe = net.connect({ host: "127.0.0.1", port })',
|
|
149
|
+
' const done = (value) => { probe.destroy(); resolve(value) }',
|
|
150
|
+
' probe.on("connect", () => done(true))',
|
|
151
|
+
' probe.on("error", () => done(false))',
|
|
152
|
+
' setTimeout(() => done(false), 500)',
|
|
153
|
+
'})',
|
|
154
|
+
'const main = async () => {',
|
|
155
|
+
' if (port) {',
|
|
156
|
+
' const until = Date.now() + 30000',
|
|
157
|
+
' while (Date.now() < until && await listening()) await sleep(250)',
|
|
158
|
+
' if (await listening()) note(`port ${port} was still in use after 30s; starting anyway`)',
|
|
159
|
+
' await sleep(300)',
|
|
160
|
+
' } else {',
|
|
161
|
+
' await sleep(1500)',
|
|
162
|
+
' }',
|
|
163
|
+
" let child",
|
|
164
|
+
' try {',
|
|
165
|
+
' const out = fs.openSync(logOut, "a")',
|
|
166
|
+
' const err = fs.openSync(logErr, "a")',
|
|
167
|
+
' child = spawn(file, args, { cwd, detached, stdio: ["ignore", out, err], env: process.env, shell: viaShell })',
|
|
168
|
+
' child.on("error", (error) => note(`could not start the replacement: ${error && error.message ? error.message : error}`))',
|
|
169
|
+
' child.unref()',
|
|
170
|
+
' } catch (error) {',
|
|
171
|
+
' note(`could not start the replacement: ${error && error.message ? error.message : error}`)',
|
|
172
|
+
' return',
|
|
173
|
+
' }',
|
|
174
|
+
' if (!port) { await sleep(3000); return }',
|
|
175
|
+
' const upBy = Date.now() + 20000',
|
|
176
|
+
' while (Date.now() < upBy && !(await listening())) await sleep(500)',
|
|
177
|
+
' if (!(await listening())) note(`the replacement did not bind port ${port} within 20s — see the output log beside this one`)',
|
|
178
|
+
'}',
|
|
179
|
+
'main()',
|
|
180
|
+
].join('\n');
|
|
181
|
+
}
|
|
182
|
+
export function scheduleRestart(port = null, deps = {}) {
|
|
183
|
+
const pid = deps.pid ?? process.pid;
|
|
184
|
+
const systemd = systemdRestartArgv({
|
|
185
|
+
cgroup: deps.cgroup ?? readProcCgroup(),
|
|
186
|
+
uid: deps.uid ?? (typeof process.getuid === 'function' ? process.getuid() : 1),
|
|
187
|
+
});
|
|
188
|
+
if (systemd !== null) {
|
|
189
|
+
;
|
|
190
|
+
(deps.setTimeout ?? setTimeout)(() => {
|
|
191
|
+
const helper = (deps.spawn ?? spawn)(systemd.file, systemd.args, {
|
|
192
|
+
detached: true,
|
|
193
|
+
stdio: 'ignore',
|
|
194
|
+
env: process.env,
|
|
195
|
+
});
|
|
196
|
+
helper.unref();
|
|
197
|
+
}, 500);
|
|
198
|
+
return { pid, helperPid: undefined, via: 'systemd' };
|
|
199
|
+
}
|
|
200
|
+
const launch = (deps.restartLaunch ?? restartLaunch)();
|
|
201
|
+
const spawned = respawnInvocation(launch);
|
|
202
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
203
|
+
const logOut = join(tmpdir(), `dshm-restart-${stamp}.out.log`);
|
|
204
|
+
const logErr = join(tmpdir(), `dshm-restart-${stamp}.err.log`);
|
|
205
|
+
const helper = (deps.spawn ?? spawn)((deps.nodeExecutable ?? nodeExecutable)(), ['-e', restartHelperSource(spawned, launch, { out: logOut, err: logErr }, port)], {
|
|
206
|
+
detached: true,
|
|
207
|
+
stdio: 'ignore',
|
|
208
|
+
env: process.env,
|
|
209
|
+
});
|
|
210
|
+
helper.unref();
|
|
211
|
+
(deps.setTimeout ?? setTimeout)(() => (deps.kill ?? process.kill)(pid, 'SIGTERM'), 500);
|
|
212
|
+
return { pid, helperPid: helper.pid, via: 'helper' };
|
|
213
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 最新版本解析(DESIGN.md §3:版本不写死,运行时实查)。
|
|
3
|
+
* npm:registry /latest(pnpm 安装本身会按 lock integrity 校验 tarball)。
|
|
4
|
+
* GitHub:api.github.com 解析 HEAD commit SHA(未认证限额 60 次/小时,自用足够)。
|
|
5
|
+
*/
|
|
6
|
+
import { fetchJsonLimited } from './httpx.js';
|
|
7
|
+
export async function npmLatest(pkg, timeoutMs = 20_000) {
|
|
8
|
+
// 允许 scoped 包名:@scope/name(isSafePkgName 同款字符集)
|
|
9
|
+
if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
|
|
10
|
+
throw new Error(`无效 npm 包名: ${pkg}`);
|
|
11
|
+
const data = await fetchJsonLimited(`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`, { timeoutMs });
|
|
12
|
+
const version = typeof data.version === 'string' ? data.version : '';
|
|
13
|
+
if (!version)
|
|
14
|
+
throw new Error(`npm 未返回版本: ${pkg}`);
|
|
15
|
+
return {
|
|
16
|
+
version,
|
|
17
|
+
integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
|
|
18
|
+
tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export async function githubHeadSha(repo, timeoutMs = 20_000) {
|
|
22
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/.test(repo))
|
|
23
|
+
throw new Error(`无效 GitHub 仓库: ${repo}`);
|
|
24
|
+
const data = await fetchJsonLimited(`https://api.github.com/repos/${repo}/commits/HEAD`, {
|
|
25
|
+
timeoutMs,
|
|
26
|
+
headers: { accept: 'application/vnd.github+json' },
|
|
27
|
+
});
|
|
28
|
+
const sha = typeof data.sha === 'string' ? data.sha : '';
|
|
29
|
+
if (!/^[0-9a-f]{40}$/.test(sha))
|
|
30
|
+
throw new Error(`GitHub 未返回有效 SHA: ${repo}`);
|
|
31
|
+
return sha;
|
|
32
|
+
}
|
|
33
|
+
export function isNewerVersion(candidate, current) {
|
|
34
|
+
const parse = (v) => String(v || '')
|
|
35
|
+
.replace(/^v/i, '')
|
|
36
|
+
.split(/[-+.]/)
|
|
37
|
+
.map((part) => (/^\d+$/.test(part) ? Number(part) : part))
|
|
38
|
+
.map((n) => (typeof n === 'number' ? n : 0));
|
|
39
|
+
const a = parse(candidate);
|
|
40
|
+
const b = parse(current);
|
|
41
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
42
|
+
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
|
43
|
+
if (diff !== 0)
|
|
44
|
+
return diff > 0;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
package/lib/host.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
3
|
+
import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin, upgradePlugin, withMutationLock, } from './core/market.js';
|
|
4
|
+
import { bindLoaderHost } from './core/live-plugin.js';
|
|
5
|
+
import { scheduleRestart, servingPort, trustedRestartRequest } from './core/restart.js';
|
|
6
|
+
import { registerTools } from './tools.js';
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const pkg = require('../package.json');
|
|
9
|
+
export const name = 'dshm';
|
|
10
|
+
// dshm_* 七个工具(src/tools.ts)
|
|
11
|
+
export const inject = ['tools'];
|
|
12
|
+
export const Config = Schema.object({
|
|
13
|
+
registryUrl: Schema.string().description('registry 源覆盖(默认 jsDelivr @main)'),
|
|
14
|
+
timeoutMs: Schema.number().default(20000).description('上游请求超时(毫秒)'),
|
|
15
|
+
cacheTtlMin: Schema.number().default(60).description('registry 缓存时长(分钟)'),
|
|
16
|
+
});
|
|
17
|
+
export function apply(ctx, config) {
|
|
18
|
+
const cfg = { ...config };
|
|
19
|
+
// 卸载前的 live-disable 依赖 loader(skillhub 同款)
|
|
20
|
+
bindLoaderHost(ctx);
|
|
21
|
+
// dshm_* 七个 agent 工具 + systemPrompt 注入
|
|
22
|
+
registerTools(ctx, cfg);
|
|
23
|
+
// 本地 API:单路由 + method 分发(skillhub 同款)
|
|
24
|
+
ctx.inject(['webServer'], (c) => {
|
|
25
|
+
const server = c.webServer;
|
|
26
|
+
server.register({
|
|
27
|
+
kind: 'exact',
|
|
28
|
+
path: '/dshm',
|
|
29
|
+
handler: (req, res) => {
|
|
30
|
+
void handleApi(req, res, cfg);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
// 设置页(GUI 设置卡片的宿主命名空间)
|
|
35
|
+
ctx.inject(['settings'], (c) => {
|
|
36
|
+
const settings = c.settings;
|
|
37
|
+
settings.register('dshm', Config, { base: config });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async function handleApi(req, res, cfg) {
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
43
|
+
const body = req.method === 'POST' ? await readBody(req) : {};
|
|
44
|
+
const method = String(body.method || url.searchParams.get('method') || 'ping');
|
|
45
|
+
switch (method) {
|
|
46
|
+
case 'ping':
|
|
47
|
+
return sendJson(res, 200, {
|
|
48
|
+
ok: true,
|
|
49
|
+
plugin: pkg.name,
|
|
50
|
+
version: pkg.version,
|
|
51
|
+
node: process.version,
|
|
52
|
+
boot: `${process.pid}`,
|
|
53
|
+
});
|
|
54
|
+
case 'self-check': {
|
|
55
|
+
try {
|
|
56
|
+
const { npmLatest } = await import('./core/versions.js');
|
|
57
|
+
const latest = await npmLatest(pkg.name, cfg.timeoutMs ?? 20_000);
|
|
58
|
+
const { isNewerVersion } = await import('./core/versions.js');
|
|
59
|
+
return sendJson(res, 200, {
|
|
60
|
+
ok: true,
|
|
61
|
+
current: pkg.version,
|
|
62
|
+
latest: latest.version,
|
|
63
|
+
outdated: isNewerVersion(latest.version, pkg.version),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return sendJson(res, 200, {
|
|
68
|
+
ok: true,
|
|
69
|
+
current: pkg.version,
|
|
70
|
+
latest: null,
|
|
71
|
+
outdated: false,
|
|
72
|
+
error: err instanceof Error ? err.message : String(err),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
case 'self-upgrade': {
|
|
77
|
+
const { npmLatest } = await import('./core/versions.js');
|
|
78
|
+
const latest = await npmLatest(pkg.name, cfg.timeoutMs ?? 20_000);
|
|
79
|
+
const result = await withMutationLock(async () => {
|
|
80
|
+
const { addDshPlugin } = await import('./core/dsh-cli.js');
|
|
81
|
+
return addDshPlugin(`${pkg.name}@${latest.version}`);
|
|
82
|
+
});
|
|
83
|
+
return sendJson(res, 200, {
|
|
84
|
+
ok: true,
|
|
85
|
+
pkg: pkg.name,
|
|
86
|
+
version: latest.version,
|
|
87
|
+
usedAllowAllBuilds: result.usedAllowAllBuilds,
|
|
88
|
+
needsRestart: true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
case 'registry': {
|
|
92
|
+
const loaded = await loadRegistrySafe(cfg, boolArg(body.force));
|
|
93
|
+
return sendJson(res, 200, {
|
|
94
|
+
ok: true,
|
|
95
|
+
plugins: loaded.registry.plugins,
|
|
96
|
+
source: loaded.source,
|
|
97
|
+
fetchedAt: loaded.fetchedAt,
|
|
98
|
+
errors: loaded.errors,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
case 'market': {
|
|
102
|
+
const result = await listMarket(cfg, { force: boolArg(body.force) });
|
|
103
|
+
return sendJson(res, 200, { ok: true, ...result });
|
|
104
|
+
}
|
|
105
|
+
case 'installed': {
|
|
106
|
+
const result = await listInstalledWithMeta(cfg);
|
|
107
|
+
return sendJson(res, 200, { ok: true, ...result });
|
|
108
|
+
}
|
|
109
|
+
case 'install': {
|
|
110
|
+
const id = String(body.id || '').trim();
|
|
111
|
+
if (!id)
|
|
112
|
+
return sendJson(res, 400, { ok: false, error: '缺少 id' });
|
|
113
|
+
const version = typeof body.version === 'string' ? body.version : undefined;
|
|
114
|
+
const result = await withMutationLock(() => installFromRegistry(id, cfg, { version }));
|
|
115
|
+
return sendJson(res, 200, { ok: true, ...result });
|
|
116
|
+
}
|
|
117
|
+
case 'uninstall': {
|
|
118
|
+
const target = String(body.pkg || '').trim();
|
|
119
|
+
if (!target)
|
|
120
|
+
return sendJson(res, 400, { ok: false, error: '缺少 pkg' });
|
|
121
|
+
const result = await withMutationLock(() => uninstallPlugin(target, cfg));
|
|
122
|
+
return sendJson(res, 200, { ok: true, ...result });
|
|
123
|
+
}
|
|
124
|
+
case 'upgrade': {
|
|
125
|
+
const target = String(body.pkg || '').trim();
|
|
126
|
+
if (!target)
|
|
127
|
+
return sendJson(res, 400, { ok: false, error: '缺少 pkg' });
|
|
128
|
+
const result = await withMutationLock(() => upgradePlugin(target, cfg));
|
|
129
|
+
return sendJson(res, 200, { ok: true, ...result });
|
|
130
|
+
}
|
|
131
|
+
case 'restart': {
|
|
132
|
+
if (!trustedRestartRequest(req)) {
|
|
133
|
+
return sendJson(res, 403, { ok: false, error: '拒绝跨源重启请求' });
|
|
134
|
+
}
|
|
135
|
+
const result = scheduleRestart(servingPort(req));
|
|
136
|
+
return sendJson(res, 200, { ok: true, ...result });
|
|
137
|
+
}
|
|
138
|
+
default:
|
|
139
|
+
return sendJson(res, 404, { ok: false, error: `未知 method: ${method}` });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function loadRegistrySafe(cfg, force) {
|
|
147
|
+
const { loadRegistry } = await import('./core/registry.js');
|
|
148
|
+
return loadRegistry(cfg, { force });
|
|
149
|
+
}
|
|
150
|
+
function boolArg(v) {
|
|
151
|
+
return v === true || v === 'true' || v === 1 || v === '1';
|
|
152
|
+
}
|
|
153
|
+
function readBody(req, maxBytes = 1 << 20) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const chunks = [];
|
|
156
|
+
let size = 0;
|
|
157
|
+
req.on('data', (c) => {
|
|
158
|
+
size += c.length;
|
|
159
|
+
if (size > maxBytes) {
|
|
160
|
+
reject(new Error('请求体过大'));
|
|
161
|
+
req.destroy();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
chunks.push(c);
|
|
165
|
+
});
|
|
166
|
+
req.on('end', () => {
|
|
167
|
+
if (!chunks.length)
|
|
168
|
+
return resolve({});
|
|
169
|
+
try {
|
|
170
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
resolve({});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
req.on('error', reject);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function sendJson(res, status, value) {
|
|
180
|
+
const body = JSON.stringify(value);
|
|
181
|
+
res.writeHead(status, {
|
|
182
|
+
'content-type': 'application/json; charset=utf-8',
|
|
183
|
+
'content-length': Buffer.byteLength(body),
|
|
184
|
+
'cache-control': 'no-store',
|
|
185
|
+
});
|
|
186
|
+
res.end(body);
|
|
187
|
+
}
|