dsh-m 0.0.3 → 0.1.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/DESIGN.md +140 -0
- package/README.en.md +89 -0
- package/README.md +78 -11
- package/cordis.patch.yml +3 -0
- package/lib/.keep +0 -0
- package/lib/cli.js +196 -0
- package/lib/client.js +1159 -0
- package/lib/core/dsh-cli.js +320 -0
- package/lib/core/env.js +18 -0
- package/lib/core/httpx.js +94 -0
- package/lib/core/installed.js +186 -0
- package/lib/core/live-plugin.js +42 -0
- package/lib/core/market.js +198 -0
- package/lib/core/progress.js +113 -0
- package/lib/core/registry.js +167 -0
- package/lib/core/restart.js +213 -0
- package/lib/core/versions.js +75 -0
- package/lib/host.js +199 -0
- package/lib/tools.js +341 -0
- package/package.json +58 -7
- package/registry.json +92 -0
- package/index.js +0 -9
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn `dsh plugin`。与 skillhub 同款关键约束:
|
|
3
|
+
* - 不经 agent 的沙箱 shell(它写不了 profile 目录);
|
|
4
|
+
* - 在 dsh web 宿主进程内时,用 `node <自身 entry> plugin ...` 重入自身;
|
|
5
|
+
* - 目标串白名单校验;超时对进程组发 SIGTERM;只保留末 256KB 输出;
|
|
6
|
+
* - prepare 被拦 → 写 dangerouslyAllowAllBuilds 重试(明确报告,DESIGN.md §3 基线 4)。
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
11
|
+
import { installTimeoutMs, WEB_PROFILE, webProfileDir } from './env.js';
|
|
12
|
+
import { createProgressTracker } from './progress.js';
|
|
13
|
+
const TARGET_RE = /^[A-Za-z0-9@:./_#+-]+$/;
|
|
14
|
+
const NDJSON_COMMANDS = new Set(['add', 'remove', 'install']);
|
|
15
|
+
export const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`;
|
|
16
|
+
export const progress = {
|
|
17
|
+
active: false,
|
|
18
|
+
target: '',
|
|
19
|
+
startedAt: 0,
|
|
20
|
+
lastLine: '',
|
|
21
|
+
phase: null,
|
|
22
|
+
done: 0,
|
|
23
|
+
total: null,
|
|
24
|
+
currentPackage: null,
|
|
25
|
+
downloaded: null,
|
|
26
|
+
size: null,
|
|
27
|
+
ndjson: false,
|
|
28
|
+
error: null,
|
|
29
|
+
};
|
|
30
|
+
export function publicInstallStatus() {
|
|
31
|
+
return {
|
|
32
|
+
active: progress.active,
|
|
33
|
+
target: progress.target,
|
|
34
|
+
seconds: progress.active ? Math.round((Date.now() - progress.startedAt) / 1000) : 0,
|
|
35
|
+
lastLine: progress.lastLine,
|
|
36
|
+
phase: progress.phase,
|
|
37
|
+
done: progress.done,
|
|
38
|
+
total: progress.total,
|
|
39
|
+
currentPackage: progress.currentPackage,
|
|
40
|
+
downloaded: progress.downloaded,
|
|
41
|
+
size: progress.size,
|
|
42
|
+
ndjson: progress.ndjson,
|
|
43
|
+
error: progress.error,
|
|
44
|
+
boot: BOOT_ID,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function beginProgress(target) {
|
|
48
|
+
progress.active = true;
|
|
49
|
+
progress.target = target;
|
|
50
|
+
progress.startedAt = Date.now();
|
|
51
|
+
progress.lastLine = '';
|
|
52
|
+
progress.phase = null;
|
|
53
|
+
progress.done = 0;
|
|
54
|
+
progress.total = null;
|
|
55
|
+
progress.currentPackage = null;
|
|
56
|
+
progress.downloaded = null;
|
|
57
|
+
progress.size = null;
|
|
58
|
+
progress.ndjson = false;
|
|
59
|
+
progress.error = null;
|
|
60
|
+
return createProgressTracker();
|
|
61
|
+
}
|
|
62
|
+
function makeProgressFeeder(tracker) {
|
|
63
|
+
let lineBuffer = '';
|
|
64
|
+
return (chunk) => {
|
|
65
|
+
lineBuffer += chunk;
|
|
66
|
+
let nl;
|
|
67
|
+
while ((nl = lineBuffer.indexOf('\n')) !== -1) {
|
|
68
|
+
const line = lineBuffer.slice(0, nl);
|
|
69
|
+
lineBuffer = lineBuffer.slice(nl + 1);
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (trimmed === '')
|
|
72
|
+
continue;
|
|
73
|
+
tracker.feed(trimmed);
|
|
74
|
+
if (!trimmed.startsWith('{'))
|
|
75
|
+
progress.lastLine = trimmed.slice(0, 200);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function syncProgress(tracker) {
|
|
80
|
+
const snap = tracker.snapshot;
|
|
81
|
+
progress.phase = snap.phase;
|
|
82
|
+
progress.done = snap.done;
|
|
83
|
+
progress.total = snap.total;
|
|
84
|
+
progress.currentPackage = snap.currentPackage;
|
|
85
|
+
progress.downloaded = snap.downloaded;
|
|
86
|
+
progress.size = snap.size;
|
|
87
|
+
progress.ndjson = snap.seen;
|
|
88
|
+
if (snap.error !== null)
|
|
89
|
+
progress.error = snap.error;
|
|
90
|
+
}
|
|
91
|
+
export function webProfileName() {
|
|
92
|
+
return WEB_PROFILE;
|
|
93
|
+
}
|
|
94
|
+
export function isSafePluginTarget(target) {
|
|
95
|
+
return TARGET_RE.test(target);
|
|
96
|
+
}
|
|
97
|
+
export function nodeExecutable(argv0 = process.argv0, execPath = process.execPath) {
|
|
98
|
+
if (argv0 !== undefined && argv0 !== '' && isAbsolute(argv0) && existsSync(argv0))
|
|
99
|
+
return argv0;
|
|
100
|
+
return execPath;
|
|
101
|
+
}
|
|
102
|
+
/** 宿主进程内时重入自身 entry(skillhub 同款);否则退回 PATH 上的 dsh。 */
|
|
103
|
+
export function dshArgv(input = {}) {
|
|
104
|
+
const argv = input.argv ?? process.argv;
|
|
105
|
+
const execArgv = input.execArgv ?? process.execArgv;
|
|
106
|
+
const execPath = input.execPath ?? process.execPath;
|
|
107
|
+
const argv0 = input.argv0 ?? process.argv0;
|
|
108
|
+
const platform = input.platform ?? process.platform;
|
|
109
|
+
const node = nodeExecutable(argv0, execPath);
|
|
110
|
+
const entry = argv[1];
|
|
111
|
+
if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
|
|
112
|
+
const abs = resolve(entry);
|
|
113
|
+
return { file: node, args: [...execArgv, abs], cwd: dirname(abs), viaShell: false };
|
|
114
|
+
}
|
|
115
|
+
return { file: 'dsh', args: [], cwd: undefined, viaShell: platform === 'win32' };
|
|
116
|
+
}
|
|
117
|
+
/** pnpm 9 需要 -w 于 workspace 根;其他主版本在非 workspace 下拒绝 -w。 */
|
|
118
|
+
export function pluginArgsFor(profileDirectory, pluginArgs) {
|
|
119
|
+
const args = [...pluginArgs];
|
|
120
|
+
if (args[0] !== 'add' && args[0] !== 'remove')
|
|
121
|
+
return args;
|
|
122
|
+
if (!existsSync(join(profileDirectory, 'pnpm-workspace.yaml')))
|
|
123
|
+
return args;
|
|
124
|
+
return [args[0], '-w', ...args.slice(1)];
|
|
125
|
+
}
|
|
126
|
+
/** add/remove/install 追加 ndjson reporter,供进度解析(人类回退行由 feeder 记录 lastLine)。 */
|
|
127
|
+
export function preparePluginArgs(profileDirectory, pluginArgs) {
|
|
128
|
+
const args = pluginArgsFor(profileDirectory, pluginArgs);
|
|
129
|
+
const command = args[0];
|
|
130
|
+
if (command !== undefined && NDJSON_COMMANDS.has(command))
|
|
131
|
+
return [...args, '--reporter=ndjson'];
|
|
132
|
+
return args;
|
|
133
|
+
}
|
|
134
|
+
export function isPrepareBlocked(text) {
|
|
135
|
+
return /needs to execute build scripts|allowBuilds|ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED|ERR_PNPM_IGNORED_BUILDS/i.test(text);
|
|
136
|
+
}
|
|
137
|
+
export function withDangerouslyAllowAllBuilds(yaml) {
|
|
138
|
+
if (/(?:^|\n)dangerouslyAllowAllBuilds:\s*true\s*(?:\n|$)/.test(yaml))
|
|
139
|
+
return yaml;
|
|
140
|
+
if (/(?:^|\n)dangerouslyAllowAllBuilds:\s*/m.test(yaml)) {
|
|
141
|
+
return yaml.replace(/^dangerouslyAllowAllBuilds:\s*.*$/m, 'dangerouslyAllowAllBuilds: true');
|
|
142
|
+
}
|
|
143
|
+
if (yaml.trim() === '')
|
|
144
|
+
return 'dangerouslyAllowAllBuilds: true\n';
|
|
145
|
+
return `${yaml.replace(/\s*$/u, '\n')}\ndangerouslyAllowAllBuilds: true\n`;
|
|
146
|
+
}
|
|
147
|
+
/** 基线 §17.4:放行构建脚本前必须能被明确报告(返回值带 usedAllowAllBuilds)。 */
|
|
148
|
+
function writeDangerouslyAllowAllBuilds(profileDirectory) {
|
|
149
|
+
const file = join(profileDirectory, 'pnpm-workspace.yaml');
|
|
150
|
+
let yaml = '';
|
|
151
|
+
try {
|
|
152
|
+
yaml = readFileSync(file, 'utf8');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* created below */
|
|
156
|
+
}
|
|
157
|
+
const next = withDangerouslyAllowAllBuilds(yaml);
|
|
158
|
+
if (next === yaml)
|
|
159
|
+
return false;
|
|
160
|
+
mkdirSync(profileDirectory, { recursive: true });
|
|
161
|
+
writeFileSync(file, next);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
export function rewritePnpmError(err) {
|
|
165
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
166
|
+
if (isPrepareBlocked(text)) {
|
|
167
|
+
return new Error('该插件需要执行构建脚本(prepare),pnpm 默认拦截。dsh-m 已写入 profile 的 dangerouslyAllowAllBuilds 并重试;若仍失败请检查 web profile 是否可写。');
|
|
168
|
+
}
|
|
169
|
+
if (/ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF/.test(text)) {
|
|
170
|
+
return new Error('当前 profile 的 node_modules 由不同主版本的 pnpm 生成,安装前需要先重建依赖。');
|
|
171
|
+
}
|
|
172
|
+
return err instanceof Error ? err : new Error(text);
|
|
173
|
+
}
|
|
174
|
+
export async function runCommand(command, args, options) {
|
|
175
|
+
return new Promise((resolvePromise, reject) => {
|
|
176
|
+
const child = spawn(command, args, {
|
|
177
|
+
cwd: options.cwd,
|
|
178
|
+
env: { ...process.env, ...options.env, CI: 'true' },
|
|
179
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
180
|
+
shell: options.viaShell === true,
|
|
181
|
+
detached: options.detached === true && process.platform !== 'win32',
|
|
182
|
+
});
|
|
183
|
+
let out = '';
|
|
184
|
+
let settled = false;
|
|
185
|
+
const finish = (err) => {
|
|
186
|
+
if (settled)
|
|
187
|
+
return;
|
|
188
|
+
settled = true;
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
191
|
+
if (err)
|
|
192
|
+
reject(err);
|
|
193
|
+
else
|
|
194
|
+
resolvePromise(out);
|
|
195
|
+
};
|
|
196
|
+
const killChild = () => {
|
|
197
|
+
if (process.platform !== 'win32' && child.pid !== undefined) {
|
|
198
|
+
try {
|
|
199
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* fall through */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
child.kill('SIGTERM');
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
/* already gone */
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const timer = setTimeout(() => {
|
|
214
|
+
killChild();
|
|
215
|
+
finish(new Error(`命令超时 ${options.timeoutMs}ms`));
|
|
216
|
+
}, options.timeoutMs);
|
|
217
|
+
const onAbort = () => {
|
|
218
|
+
killChild();
|
|
219
|
+
finish(new Error('命令已取消'));
|
|
220
|
+
};
|
|
221
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
222
|
+
child.stdout?.on('data', (chunk) => {
|
|
223
|
+
const text = chunk.toString();
|
|
224
|
+
out = (out + text).slice(-256 * 1024);
|
|
225
|
+
options.onChunk?.(text);
|
|
226
|
+
});
|
|
227
|
+
child.stderr?.on('data', (chunk) => {
|
|
228
|
+
const text = chunk.toString();
|
|
229
|
+
out = (out + text).slice(-256 * 1024);
|
|
230
|
+
options.onChunk?.(text);
|
|
231
|
+
});
|
|
232
|
+
child.on('error', (err) => finish(err));
|
|
233
|
+
child.on('close', (code) => {
|
|
234
|
+
if (code === 0)
|
|
235
|
+
finish();
|
|
236
|
+
else
|
|
237
|
+
finish(new Error(`命令失败 (exit ${code}): ${out.trim().slice(-800) || 'no output'}`));
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
export async function runDshPlugin(profile, pluginArgs, deps = {}) {
|
|
242
|
+
if (profile !== WEB_PROFILE)
|
|
243
|
+
throw new Error('仅支持 web profile');
|
|
244
|
+
const target = pluginArgs[pluginArgs.length - 1] ?? '';
|
|
245
|
+
if (!isSafePluginTarget(target))
|
|
246
|
+
throw new Error(`拒绝不安全的安装目标: ${target}`);
|
|
247
|
+
const argv = (deps.dshArgv ?? dshArgv)();
|
|
248
|
+
const prepared = preparePluginArgs(deps.profileDir ?? webProfileDir(), pluginArgs);
|
|
249
|
+
const tracker = beginProgress(target);
|
|
250
|
+
const feed = makeProgressFeeder(tracker);
|
|
251
|
+
const run = deps.runCommand ?? runCommand;
|
|
252
|
+
try {
|
|
253
|
+
return await run(argv.file, [...argv.args, 'plugin', '--profile', profile, ...prepared], {
|
|
254
|
+
cwd: argv.cwd,
|
|
255
|
+
timeoutMs: deps.timeoutMs ?? installTimeoutMs(),
|
|
256
|
+
env: { CI: 'true' },
|
|
257
|
+
viaShell: argv.viaShell,
|
|
258
|
+
detached: process.platform !== 'win32',
|
|
259
|
+
onChunk: (text) => {
|
|
260
|
+
feed(text);
|
|
261
|
+
syncProgress(tracker);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
// 失败/超时时也把原因写入状态端点,轮询侧能看到错误终态
|
|
267
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
268
|
+
if (progress.error === null)
|
|
269
|
+
progress.error = text.slice(0, 800);
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
progress.active = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* 安装。返回 usedAllowAllBuilds 供 UI 明确报告「该插件执行了构建脚本」。
|
|
278
|
+
* source 形如:`pkg@1.2.3`(npm 精确锁定)或 `github:owner/repo#sha`(锁 SHA)。
|
|
279
|
+
*/
|
|
280
|
+
export async function addDshPlugin(source, deps = {}) {
|
|
281
|
+
const run = deps.runDshPlugin ?? runDshPlugin;
|
|
282
|
+
const allowAllBuilds = deps.allowAllBuilds ?? writeDangerouslyAllowAllBuilds;
|
|
283
|
+
const retryAfterPrepare = async () => {
|
|
284
|
+
const changed = allowAllBuilds(deps.profileDir ?? webProfileDir());
|
|
285
|
+
try {
|
|
286
|
+
return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: true };
|
|
287
|
+
}
|
|
288
|
+
catch (retryErr) {
|
|
289
|
+
throw rewritePnpmError(retryErr);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
try {
|
|
293
|
+
return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: false };
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
297
|
+
if (text.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')) {
|
|
298
|
+
await run(WEB_PROFILE, ['install', '--no-frozen-lockfile']);
|
|
299
|
+
try {
|
|
300
|
+
return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: false };
|
|
301
|
+
}
|
|
302
|
+
catch (retryErr) {
|
|
303
|
+
if (!isPrepareBlocked(retryErr instanceof Error ? retryErr.message : String(retryErr))) {
|
|
304
|
+
throw rewritePnpmError(retryErr);
|
|
305
|
+
}
|
|
306
|
+
return retryAfterPrepare();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!isPrepareBlocked(text))
|
|
310
|
+
throw rewritePnpmError(err);
|
|
311
|
+
return retryAfterPrepare();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** 卸载(转发 pnpm remove;调用方须先做 live-disable)。 */
|
|
315
|
+
export async function removeDshPlugin(pkg, deps = {}) {
|
|
316
|
+
if (!isSafePluginTarget(pkg))
|
|
317
|
+
throw new Error(`无效插件包名: ${pkg}`);
|
|
318
|
+
const run = deps.runDshPlugin ?? runDshPlugin;
|
|
319
|
+
return run(WEB_PROFILE, ['remove', pkg]);
|
|
320
|
+
}
|
package/lib/core/env.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** 路径与环境约定(DESIGN.md §11:profile 是唯一事实源) */
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export const WEB_PROFILE = 'web';
|
|
5
|
+
export function dshHome() {
|
|
6
|
+
return process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
7
|
+
}
|
|
8
|
+
export function webProfileDir() {
|
|
9
|
+
return join(dshHome(), 'profiles', WEB_PROFILE);
|
|
10
|
+
}
|
|
11
|
+
/** registry 缓存目录(可被 DSHM_CACHE_DIR 覆盖,便于测试) */
|
|
12
|
+
export function cacheDir() {
|
|
13
|
+
return process.env.DSHM_CACHE_DIR || join(dshHome(), 'dshm', 'cache');
|
|
14
|
+
}
|
|
15
|
+
/** 安装类操作的超时(毫秒) */
|
|
16
|
+
export function installTimeoutMs() {
|
|
17
|
+
return Number(process.env.DSHM_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000;
|
|
18
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 安全基线 §17.1:仅 HTTPS(loopback http 例外)+ 响应大小上限 + 超时。
|
|
3
|
+
* skillhub 的缺口(无统一体积上限)在这里补齐。
|
|
4
|
+
*/
|
|
5
|
+
export class HttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const MAX_DEFAULT = 2 * 1024 * 1024; // 2MB:registry/npm metadata 足够
|
|
13
|
+
export function assertSafeUrl(raw) {
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(raw);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new HttpError(400, `无效 URL: ${raw}`);
|
|
20
|
+
}
|
|
21
|
+
if (url.protocol === 'https:')
|
|
22
|
+
return url;
|
|
23
|
+
if (url.protocol === 'http:' && ['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) {
|
|
24
|
+
return url; // 本地 registry 覆盖调试用(DESIGN.md §2.1)
|
|
25
|
+
}
|
|
26
|
+
throw new HttpError(400, `仅允许 HTTPS: ${raw}`);
|
|
27
|
+
}
|
|
28
|
+
async function readCapped(res, maxBytes) {
|
|
29
|
+
const declared = Number(res.headers.get('content-length') || 0);
|
|
30
|
+
if (declared > maxBytes)
|
|
31
|
+
throw new HttpError(502, `响应过大: ${declared} > ${maxBytes}`);
|
|
32
|
+
const reader = res.body?.getReader();
|
|
33
|
+
if (!reader)
|
|
34
|
+
return Buffer.alloc(0);
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (;;) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done)
|
|
40
|
+
break;
|
|
41
|
+
total += value.byteLength;
|
|
42
|
+
if (total > maxBytes) {
|
|
43
|
+
await reader.cancel().catch(() => undefined);
|
|
44
|
+
throw new HttpError(502, `响应超过上限 ${maxBytes} 字节`);
|
|
45
|
+
}
|
|
46
|
+
chunks.push(Buffer.from(value));
|
|
47
|
+
}
|
|
48
|
+
return Buffer.concat(chunks);
|
|
49
|
+
}
|
|
50
|
+
export async function fetchJsonLimited(url, opts = {}) {
|
|
51
|
+
assertSafeUrl(url);
|
|
52
|
+
const res = await fetch(url, {
|
|
53
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 20_000),
|
|
54
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-m (personal marketplace)', ...opts.headers },
|
|
55
|
+
redirect: 'follow',
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok)
|
|
58
|
+
throw new HttpError(res.status, `HTTP ${res.status}: ${url}`);
|
|
59
|
+
const buf = await readCapped(res, opts.maxBytes ?? MAX_DEFAULT);
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(buf.toString('utf8'));
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
throw new HttpError(502, `响应不是合法 JSON: ${url}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function fetchTextLimited(url, opts = {}) {
|
|
68
|
+
assertSafeUrl(url);
|
|
69
|
+
const res = await fetch(url, {
|
|
70
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 20_000),
|
|
71
|
+
headers: { 'user-agent': 'dsh-m (personal marketplace)', ...opts.headers },
|
|
72
|
+
redirect: 'follow',
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
throw new HttpError(res.status, `HTTP ${res.status}: ${url}`);
|
|
76
|
+
const buf = await readCapped(res, opts.maxBytes ?? MAX_DEFAULT);
|
|
77
|
+
return buf.toString('utf8');
|
|
78
|
+
}
|
|
79
|
+
/** 可达性探测(icon/homepage 校验用):2xx 即可达。 */
|
|
80
|
+
export async function isReachable(url, timeoutMs = 8000) {
|
|
81
|
+
try {
|
|
82
|
+
assertSafeUrl(url);
|
|
83
|
+
const res = await fetch(url, {
|
|
84
|
+
method: 'HEAD',
|
|
85
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
86
|
+
headers: { 'user-agent': 'dsh-m (personal marketplace)' },
|
|
87
|
+
redirect: 'follow',
|
|
88
|
+
});
|
|
89
|
+
return res.ok || res.status === 405; // 有的站点拒绝 HEAD,视为可达
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 已装插件识别(DESIGN.md §3):profile 的 package.json 是唯一事实源,
|
|
3
|
+
* 不引入额外状态文件。移植自 skillhub installed-plugins.ts(去 README 暂缓)。
|
|
4
|
+
*/
|
|
5
|
+
import { open, readFile } from 'node:fs/promises';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { isSafePluginTarget, removeDshPlugin } from './dsh-cli.js';
|
|
8
|
+
import { webProfileDir } from './env.js';
|
|
9
|
+
const PKG_NAME_RE = /^(@[A-Za-z0-9-*~][A-Za-z0-9-*._~]*\/)?[A-Za-z0-9-._~]+$/;
|
|
10
|
+
export function isSafePkgName(raw) {
|
|
11
|
+
const name = String(raw || '').trim();
|
|
12
|
+
if (!name || name.length > 214)
|
|
13
|
+
return false;
|
|
14
|
+
if (!PKG_NAME_RE.test(name))
|
|
15
|
+
return false;
|
|
16
|
+
return !name.split('/').some((part) => part === '' || part === '.' || part.startsWith('.') || part.startsWith('_') || part.includes('..'));
|
|
17
|
+
}
|
|
18
|
+
export function parseSpecSource(spec) {
|
|
19
|
+
const raw = String(spec || '').trim();
|
|
20
|
+
if (raw.startsWith('link:'))
|
|
21
|
+
return 'link';
|
|
22
|
+
if (raw.startsWith('file:'))
|
|
23
|
+
return 'file';
|
|
24
|
+
if (raw.startsWith('github:') || /^https:\/\/github\.com\//i.test(raw))
|
|
25
|
+
return 'github';
|
|
26
|
+
if (raw)
|
|
27
|
+
return 'npm';
|
|
28
|
+
return 'unknown';
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 解析依赖的包目录:
|
|
32
|
+
* - `link:` 是活的开发目录符号链接,解析真实目标(已装页展示本地路径有价值);
|
|
33
|
+
* - 其余(npm / github / file-tarball / file-dir)pnpm 都会把内容物化到 node_modules/<pkg>,
|
|
34
|
+
* 统一从那里读。skillhub 同款语义——file: 特判回 tarball 路径是错的(读不到 package.json)。
|
|
35
|
+
*/
|
|
36
|
+
export function resolvePluginDir(profileDir, pkg, spec) {
|
|
37
|
+
if (!isSafePkgName(pkg))
|
|
38
|
+
return null;
|
|
39
|
+
const source = parseSpecSource(spec);
|
|
40
|
+
if (source === 'link') {
|
|
41
|
+
const target = String(spec).slice('link:'.length).trim();
|
|
42
|
+
if (!target.startsWith('/') || target.includes('\0'))
|
|
43
|
+
return null;
|
|
44
|
+
return resolve(target);
|
|
45
|
+
}
|
|
46
|
+
return join(resolve(profileDir), 'node_modules', pkg);
|
|
47
|
+
}
|
|
48
|
+
/** 从 repository 字段(字符串或 {url},git+/ssh/https 形态)提取 github owner/repo。 */
|
|
49
|
+
export function githubRepoFromRepository(raw) {
|
|
50
|
+
let url = '';
|
|
51
|
+
if (typeof raw === 'string')
|
|
52
|
+
url = raw;
|
|
53
|
+
else if (raw && typeof raw === 'object') {
|
|
54
|
+
const u = raw.url;
|
|
55
|
+
if (typeof u === 'string')
|
|
56
|
+
url = u;
|
|
57
|
+
}
|
|
58
|
+
const m = /github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i.exec(url.trim());
|
|
59
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
60
|
+
}
|
|
61
|
+
export async function readPkgJson(dir) {
|
|
62
|
+
try {
|
|
63
|
+
const raw = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
|
|
64
|
+
return raw && typeof raw === 'object' ? raw : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function readProfileDeps(profileDir) {
|
|
71
|
+
try {
|
|
72
|
+
const raw = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
73
|
+
if (!raw || typeof raw !== 'object' || !raw.dependencies || typeof raw.dependencies !== 'object')
|
|
74
|
+
return {};
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const [name, spec] of Object.entries(raw.dependencies)) {
|
|
77
|
+
if (typeof spec === 'string' && spec !== '')
|
|
78
|
+
out[name] = spec;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function sanitizePkgJson(raw, fallbackName) {
|
|
87
|
+
return {
|
|
88
|
+
name: typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim().slice(0, 200) : fallbackName,
|
|
89
|
+
version: typeof raw.version === 'string' ? raw.version.trim().slice(0, 64) : '',
|
|
90
|
+
description: typeof raw.description === 'string' ? raw.description.trim().slice(0, 500) : '',
|
|
91
|
+
homepage: typeof raw.homepage === 'string' && /^https?:\/\//i.test(raw.homepage) ? raw.homepage.slice(0, 300) : '',
|
|
92
|
+
path: '',
|
|
93
|
+
githubRepo: githubRepoFromRepository(raw.repository),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** 枚举 web profile 已安装插件(只读)。 */
|
|
97
|
+
export async function listInstalledPlugins(profileDir = webProfileDir()) {
|
|
98
|
+
const root = resolve(profileDir);
|
|
99
|
+
const deps = await readProfileDeps(root);
|
|
100
|
+
const items = [];
|
|
101
|
+
let others = 0;
|
|
102
|
+
for (const pkg of Object.keys(deps).sort()) {
|
|
103
|
+
const spec = deps[pkg];
|
|
104
|
+
const dir = resolvePluginDir(root, pkg, spec);
|
|
105
|
+
const raw = dir ? await readPkgJson(dir) : null;
|
|
106
|
+
if (!raw || !('dsh' in raw)) {
|
|
107
|
+
others += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const info = sanitizePkgJson(raw, pkg);
|
|
111
|
+
items.push({
|
|
112
|
+
pkg,
|
|
113
|
+
name: info.name,
|
|
114
|
+
version: info.version,
|
|
115
|
+
description: info.description,
|
|
116
|
+
homepage: info.homepage,
|
|
117
|
+
spec,
|
|
118
|
+
source: parseSpecSource(spec),
|
|
119
|
+
dsh: true,
|
|
120
|
+
path: dir,
|
|
121
|
+
githubRepo: githubRepoFromRepository(raw.repository),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return { items, others, profileDir: root };
|
|
125
|
+
}
|
|
126
|
+
/** 从 web profile 卸载已安装的 dsh 插件。pkg 必须来自 profile 依赖(先 live-disable,见 market.ts)。 */
|
|
127
|
+
export async function removeInstalledPlugin(pkg, profileDir = webProfileDir(), deps = {}) {
|
|
128
|
+
const key = String(pkg || '').trim();
|
|
129
|
+
if (!isSafePkgName(key) || !isSafePluginTarget(key))
|
|
130
|
+
throw new Error(`无效插件包名: ${pkg}`);
|
|
131
|
+
const root = resolve(profileDir);
|
|
132
|
+
const listed = await readProfileDeps(root);
|
|
133
|
+
if (!(key in listed))
|
|
134
|
+
throw new Error(`web profile 未安装该插件: ${key}`);
|
|
135
|
+
const dir = resolvePluginDir(root, key, listed[key]);
|
|
136
|
+
const raw = dir ? await readPkgJson(dir) : null;
|
|
137
|
+
if (!raw || !('dsh' in raw))
|
|
138
|
+
throw new Error(`不是 dsh 插件: ${key}`);
|
|
139
|
+
await removeDshPlugin(key, deps);
|
|
140
|
+
return { pkg: key };
|
|
141
|
+
}
|
|
142
|
+
// ---------- README 预览(借鉴 skillhub,64KB 截断) ----------
|
|
143
|
+
const README_MAX_BYTES = 64 * 1024;
|
|
144
|
+
const README_FILES = ['README.md', 'README.markdown', 'README'];
|
|
145
|
+
/** 限量读取文本文件:只读前 limit 字节,超限标记 truncated。 */
|
|
146
|
+
async function readTextLimited(path, limit) {
|
|
147
|
+
let fh;
|
|
148
|
+
try {
|
|
149
|
+
fh = await open(path, 'r');
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const buf = Buffer.alloc(limit + 1);
|
|
156
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
|
157
|
+
return {
|
|
158
|
+
text: buf.subarray(0, Math.min(bytesRead, limit)).toString('utf8'),
|
|
159
|
+
truncated: bytesRead > limit,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await fh.close().catch(() => undefined);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** 读取单个已安装插件的 README(UTF-8,≤64KB,超限截断)。pkg 必须来自 profile 依赖。 */
|
|
167
|
+
export async function readInstalledPluginReadme(pkg, profileDir = webProfileDir()) {
|
|
168
|
+
const key = String(pkg || '').trim();
|
|
169
|
+
if (!isSafePkgName(key))
|
|
170
|
+
throw new Error(`无效插件包名: ${pkg}`);
|
|
171
|
+
const root = resolve(profileDir);
|
|
172
|
+
const deps = await readProfileDeps(root);
|
|
173
|
+
if (!(key in deps))
|
|
174
|
+
throw new Error(`web profile 未安装该插件: ${key}`);
|
|
175
|
+
const dir = resolvePluginDir(root, key, deps[key]);
|
|
176
|
+
if (!dir)
|
|
177
|
+
throw new Error(`无法解析插件目录: ${key}`);
|
|
178
|
+
const raw = await readPkgJson(dir);
|
|
179
|
+
const name = raw && typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : key;
|
|
180
|
+
for (const file of README_FILES) {
|
|
181
|
+
const text = await readTextLimited(join(dir, file), README_MAX_BYTES);
|
|
182
|
+
if (text)
|
|
183
|
+
return { pkg: key, name, readme: text.text, truncated: text.truncated };
|
|
184
|
+
}
|
|
185
|
+
return { pkg: key, name, readme: '', truncated: false };
|
|
186
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 卸载前先让 loader 里的 live fiber 下线,否则 client-modules 仍列出该包,
|
|
3
|
+
* 浏览器请求 /plugins/<name>/client.js 会 404。移植自 skillhub live-plugin.ts。
|
|
4
|
+
*/
|
|
5
|
+
let boundHost;
|
|
6
|
+
export function bindLoaderHost(host) {
|
|
7
|
+
boundHost = host;
|
|
8
|
+
}
|
|
9
|
+
export function loaderHost() {
|
|
10
|
+
return boundHost;
|
|
11
|
+
}
|
|
12
|
+
export async function setLivePluginDisabled(pkg, disabled, host = boundHost) {
|
|
13
|
+
const name = String(pkg || '').trim();
|
|
14
|
+
const entries = host?.loader?.entries;
|
|
15
|
+
if (!name || typeof entries !== 'function')
|
|
16
|
+
return false;
|
|
17
|
+
let found = false;
|
|
18
|
+
for (const entry of entries.call(host.loader)) {
|
|
19
|
+
if (!entry || entry.options?.name !== name)
|
|
20
|
+
continue;
|
|
21
|
+
if (typeof entry.update !== 'function')
|
|
22
|
+
continue;
|
|
23
|
+
found = (await flipEntry(entry, disabled)) || found;
|
|
24
|
+
}
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
async function flipEntry(entry, disabled) {
|
|
28
|
+
const flag = disabled ? true : null;
|
|
29
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
30
|
+
try {
|
|
31
|
+
await entry.update({ disabled: flag }, false, true);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const live = entry.fiber !== undefined;
|
|
37
|
+
if (live !== disabled)
|
|
38
|
+
return true;
|
|
39
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|