dsh-plugin-pack 1.0.8 → 1.0.10
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/client.js +29 -3
- package/lib/fetch-bundle.js +149 -23
- package/lib/inbox.js +85 -5
- package/lib/server.js +46 -4
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -130,6 +130,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
130
130
|
'net.warnTitle': '安装前请确认来源可信',
|
|
131
131
|
'net.warnBody': '整合包会覆盖已装插件的文件与配置,来源不明的包可能损坏 DeepSeek Harness。安装前会先列出重名插件,由你逐个决定覆盖还是跳过。',
|
|
132
132
|
'net.done': '下载完成,已导入',
|
|
133
|
+
'net.hintTitle': '可以这样处理:',
|
|
133
134
|
'import.pageTitle': '导入插件整合包',
|
|
134
135
|
'drop.title': '把 zip / tgz 或插件文件夹拖到这里',
|
|
135
136
|
'drop.release': '松手就导入',
|
|
@@ -312,6 +313,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
312
313
|
'net.warnTitle': 'Verify the source before installing',
|
|
313
314
|
'net.warnBody': 'A bundle overwrites installed plugins and their config. Untrusted sources can damage DeepSeek Harness. You will be asked per-plugin whether to overwrite or skip.',
|
|
314
315
|
'net.done': 'Downloaded and imported',
|
|
316
|
+
'net.hintTitle': 'What you can do: ',
|
|
315
317
|
'import.pageTitle': 'Import plugin bundle',
|
|
316
318
|
'drop.title': 'Drop a zip / tgz or plugin folder here',
|
|
317
319
|
'drop.release': 'Release to import',
|
|
@@ -504,6 +506,8 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
504
506
|
const error = new Error((data && (data.error || data.message)) || `${response.status} ${response.statusText}`);
|
|
505
507
|
// 把服务端的错误码带出来 —— 界面据此区分"拖错文件"和"真出故障"
|
|
506
508
|
if (data?.code) error.code = String(data.code);
|
|
509
|
+
// 网络类错误带处置建议(挂代理 / 换网络 / 稍后重试)
|
|
510
|
+
if (data?.hint) error.hint = String(data.hint);
|
|
507
511
|
throw error;
|
|
508
512
|
}
|
|
509
513
|
return data;
|
|
@@ -1459,7 +1463,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1459
1463
|
h('div', null, t('net.warnBody')),
|
|
1460
1464
|
),
|
|
1461
1465
|
|
|
1462
|
-
// 结果区:成功 / 不是整合包 / 其它错误
|
|
1466
|
+
// 结果区:成功 / 不是整合包 / 网络失败(带处置建议)/ 其它错误
|
|
1463
1467
|
result
|
|
1464
1468
|
? h(
|
|
1465
1469
|
'div',
|
|
@@ -1472,7 +1476,29 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1472
1476
|
h('div', { className: 'pp-not-bundle-title' }, t('net.notBundle')),
|
|
1473
1477
|
h('div', { className: 'pp-not-bundle-detail' }, String(result.error)),
|
|
1474
1478
|
)
|
|
1475
|
-
: h(
|
|
1479
|
+
: h(
|
|
1480
|
+
'div',
|
|
1481
|
+
null,
|
|
1482
|
+
h(Alert, { kind: 'err' }, String(result.error)),
|
|
1483
|
+
// 网络类错误:把"该怎么做"单独列出来,别让用户卡在报错上
|
|
1484
|
+
result.hint
|
|
1485
|
+
? h(
|
|
1486
|
+
'div',
|
|
1487
|
+
{
|
|
1488
|
+
className: 'pp-hint',
|
|
1489
|
+
style: {
|
|
1490
|
+
marginTop: 6,
|
|
1491
|
+
padding: '8px 10px',
|
|
1492
|
+
borderRadius: 8,
|
|
1493
|
+
background: 'color-mix(in srgb,var(--dsw-alias-label-secondary,#6b7280) 8%,transparent)',
|
|
1494
|
+
lineHeight: 1.7,
|
|
1495
|
+
},
|
|
1496
|
+
},
|
|
1497
|
+
h('b', null, t('net.hintTitle')),
|
|
1498
|
+
String(result.hint),
|
|
1499
|
+
)
|
|
1500
|
+
: null,
|
|
1501
|
+
)
|
|
1476
1502
|
: h(
|
|
1477
1503
|
'div',
|
|
1478
1504
|
{ className: 'pp-hint' },
|
|
@@ -2196,7 +2222,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
2196
2222
|
await refreshJobs();
|
|
2197
2223
|
} catch (err) {
|
|
2198
2224
|
const code = err?.code ? String(err.code) : null;
|
|
2199
|
-
setNetResult({ error: String(err?.message ?? err), code });
|
|
2225
|
+
setNetResult({ error: String(err?.message ?? err), code, hint: err?.hint ?? null });
|
|
2200
2226
|
} finally {
|
|
2201
2227
|
setPhase('');
|
|
2202
2228
|
setBusy(false);
|
package/lib/fetch-bundle.js
CHANGED
|
@@ -128,10 +128,15 @@ async function resolveReleaseAsset({ owner, repo, tag }) {
|
|
|
128
128
|
? `${apiBase}/repos/${owner}/${repo}/releases/latest`
|
|
129
129
|
: `${apiBase}/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`;
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
let response;
|
|
132
|
+
try {
|
|
133
|
+
response = await fetchWithTimeout(endpoint, {
|
|
134
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'dsh-plugin-pack' },
|
|
135
|
+
timeoutMs: 30000,
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw explainNetworkError(error, endpoint, '查 GitHub Release');
|
|
139
|
+
}
|
|
135
140
|
if (!response.ok) {
|
|
136
141
|
const error = new Error(
|
|
137
142
|
`查 GitHub Release 失败(${response.status})。这个仓库可能没有 Release,或者 tag「${tag}」不存在。`,
|
|
@@ -185,10 +190,15 @@ async function resolveNpmTarball(spec) {
|
|
|
185
190
|
const wantedVersion = hasVersion ? spec.slice(at + 1) : null;
|
|
186
191
|
|
|
187
192
|
const endpoint = `https://registry.npmjs.org/${pkgName.replace(/\//g, '%2F')}`;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
193
|
+
let response;
|
|
194
|
+
try {
|
|
195
|
+
response = await fetchWithTimeout(endpoint, {
|
|
196
|
+
headers: { Accept: 'application/json', 'User-Agent': 'dsh-plugin-pack' },
|
|
197
|
+
timeoutMs: 30000,
|
|
198
|
+
});
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw explainNetworkError(error, endpoint, '查 npm 包');
|
|
201
|
+
}
|
|
192
202
|
if (!response.ok) {
|
|
193
203
|
const error = new Error(`查 npm 包失败(${response.status}):${pkgName}`);
|
|
194
204
|
error.code = 'BAD_SOURCE';
|
|
@@ -211,32 +221,147 @@ async function resolveNpmTarball(spec) {
|
|
|
211
221
|
return { url, name: `${data.name}-${version}.tgz`, from: 'npm' };
|
|
212
222
|
}
|
|
213
223
|
|
|
224
|
+
/**
|
|
225
|
+
* 把底层网络错误翻译成看得懂、能动手的中文。
|
|
226
|
+
*
|
|
227
|
+
* 为什么需要:Node 的 fetch 在网络不通时只抛 `TypeError: fetch failed`,
|
|
228
|
+
* 真正的原因藏在 error.cause 里(ENOTFOUND / ECONNRESET / 证书错误…)。
|
|
229
|
+
* 直接抛出去用户只看到"fetch failed",不知道是网断了、域名错了,
|
|
230
|
+
* 还是被墙了、要挂代理。
|
|
231
|
+
*
|
|
232
|
+
* @param {unknown} error 原始错误
|
|
233
|
+
* @param {string} url 正在访问的地址
|
|
234
|
+
* @param {string} [stage] 阶段描述(下载 / 查 registry / 查 Release)
|
|
235
|
+
* @returns {Error & { code: string, hint?: string }}
|
|
236
|
+
*/
|
|
237
|
+
export function explainNetworkError(error, url, stage = '下载') {
|
|
238
|
+
let host = '';
|
|
239
|
+
try {
|
|
240
|
+
host = new URL(url).host;
|
|
241
|
+
} catch {
|
|
242
|
+
host = url;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 把 cause 链上的信息拼起来判断(message + code + errno)
|
|
246
|
+
const cause = error?.cause ?? error;
|
|
247
|
+
const raw = [cause?.code, cause?.errno, cause?.message, error?.message]
|
|
248
|
+
.filter(Boolean)
|
|
249
|
+
.join(' ')
|
|
250
|
+
.toLowerCase();
|
|
251
|
+
|
|
252
|
+
const make = (message, hint) => {
|
|
253
|
+
const wrapped = new Error(message);
|
|
254
|
+
wrapped.code = 'NETWORK';
|
|
255
|
+
if (hint) wrapped.hint = hint;
|
|
256
|
+
return wrapped;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// 超时(我们自己 abort 的)
|
|
260
|
+
if (error?.name === 'AbortError' || /aborted|timeout|timed out|etimedout|und_err_connect_timeout/.test(raw)) {
|
|
261
|
+
return make(
|
|
262
|
+
`${stage}超时:${host} 在限定时间内没有响应。`,
|
|
263
|
+
'网络慢或被劫持时常见。可以稍后重试;如果一直超时,试试换网络或配代理。',
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// DNS:域名解析不出来
|
|
268
|
+
if (/enotfound|eai_again|getaddrinfo|dns/.test(raw)) {
|
|
269
|
+
return make(
|
|
270
|
+
`${stage}失败:解析不了域名 ${host}。`,
|
|
271
|
+
'检查地址有没有拼错;如果域名是对的,通常是 DNS 问题或该域名被污染。',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 连接被重置 / 主动断开:国内访问 GitHub 最常见
|
|
276
|
+
if (/econnreset|socket hang up|epipe|ecanceled/.test(raw)) {
|
|
277
|
+
return make(
|
|
278
|
+
`${stage}失败:与 ${host} 的连接被重置。`,
|
|
279
|
+
host.includes('github')
|
|
280
|
+
? 'GitHub 在国内经常被这样重置。挂代理后重试,或者把包下载到本地,用拖拽导入。'
|
|
281
|
+
: '连接中途断开。检查网络稳定性后重试。',
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 连不上(端口不通 / 代理没起)
|
|
286
|
+
if (/econnrefused|ehostunreach|enetunreach|connect failed/.test(raw)) {
|
|
287
|
+
return make(
|
|
288
|
+
`${stage}失败:连不上 ${host}。`,
|
|
289
|
+
'目标拒绝连接,或者你的代理设置指向了一个没在运行的端口。',
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 证书问题
|
|
294
|
+
if (/cert|self.signed|unable to verify|ssl|tls/.test(raw)) {
|
|
295
|
+
return make(
|
|
296
|
+
`${stage}失败:${host} 的 HTTPS 证书校验不通过。`,
|
|
297
|
+
'可能是公司网络中间人代理或系统时间不对。确认来源可信后再决定是否继续。',
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 代理相关
|
|
302
|
+
if (/proxy/.test(raw)) {
|
|
303
|
+
return make(
|
|
304
|
+
`${stage}失败:代理连接出错(访问 ${host})。`,
|
|
305
|
+
'检查 HTTP_PROXY / HTTPS_PROXY 环境变量或系统代理设置。',
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 兜底:把真实原因也带上,别吞掉
|
|
310
|
+
const detail = String(cause?.message ?? error?.message ?? error).slice(0, 160);
|
|
311
|
+
return make(
|
|
312
|
+
`${stage}失败:访问 ${host} 时网络出错。`,
|
|
313
|
+
`原始信息:${detail}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
214
317
|
/** 从 URL 流式下载到 Buffer(带体积上限)。 */
|
|
215
318
|
async function download(url) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
319
|
+
let response;
|
|
320
|
+
try {
|
|
321
|
+
response = await fetchWithTimeout(url, {
|
|
322
|
+
headers: { 'User-Agent': 'dsh-plugin-pack', Accept: '*/*' },
|
|
323
|
+
});
|
|
324
|
+
} catch (error) {
|
|
325
|
+
// 连都没连上 —— 翻译成人话再抛
|
|
326
|
+
throw explainNetworkError(error, url, '下载');
|
|
327
|
+
}
|
|
328
|
+
|
|
219
329
|
if (!response.ok) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
330
|
+
// 按状态码给不同的处置建议,而不是一句"下载失败"
|
|
331
|
+
const hints = {
|
|
332
|
+
401: '需要登录才能下载(私有仓库)。',
|
|
333
|
+
403: '服务器拒绝访问。可能是私有仓库、触发限流,或者需要认证。',
|
|
334
|
+
404: '地址不存在。检查链接是否失效、仓库是否改名、Release 是否被删。',
|
|
335
|
+
429: '请求太频繁被限流,等几分钟再试。',
|
|
336
|
+
};
|
|
337
|
+
const base = `下载失败(HTTP ${response.status})。`;
|
|
338
|
+
const error = new Error(base + (hints[response.status] ?? '服务器返回了错误状态。'));
|
|
223
339
|
error.code = 'DOWNLOAD_FAILED';
|
|
224
340
|
throw error;
|
|
225
341
|
}
|
|
226
342
|
|
|
227
343
|
const chunks = [];
|
|
228
344
|
let size = 0;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
345
|
+
try {
|
|
346
|
+
for await (const chunk of response.body) {
|
|
347
|
+
size += chunk.length;
|
|
348
|
+
if (size > MAX_BYTES) {
|
|
349
|
+
const error = new Error(`文件超过 ${MAX_BYTES / 1048576} MiB,已中止`);
|
|
350
|
+
error.code = 'TOO_LARGE';
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
chunks.push(Buffer.from(chunk));
|
|
235
354
|
}
|
|
236
|
-
|
|
355
|
+
} catch (error) {
|
|
356
|
+
// 下到一半断了(大文件走代理时很常见)
|
|
357
|
+
if (error?.code === 'TOO_LARGE') throw error;
|
|
358
|
+
const wrapped = explainNetworkError(error, url, '下载');
|
|
359
|
+
wrapped.message = `${wrapped.message}(已收到 ${(size / 1048576).toFixed(1)} MiB,可能没下完)`;
|
|
360
|
+
throw wrapped;
|
|
237
361
|
}
|
|
362
|
+
|
|
238
363
|
if (!size) {
|
|
239
|
-
const error = new Error('
|
|
364
|
+
const error = new Error('下载到的是空文件(地址可能指向了一个空响应)。');
|
|
240
365
|
error.code = 'DOWNLOAD_FAILED';
|
|
241
366
|
throw error;
|
|
242
367
|
}
|
|
@@ -292,8 +417,9 @@ export async function fetchBundleFrom({ source, home, onProgress = () => {} }) {
|
|
|
292
417
|
report(`下载完成:${(data.length / 1048576).toFixed(1)} MiB`);
|
|
293
418
|
|
|
294
419
|
// 落到 inbox(保留扩展名,后续按 .zip/.tgz 分流)
|
|
420
|
+
// origin='network':安装成功后可以自动删掉这份副本,不占用户磁盘
|
|
295
421
|
pruneSafe(home);
|
|
296
|
-
const saved = saveIncomingZip({ home, filename: name, data });
|
|
422
|
+
const saved = saveIncomingZip({ home, filename: name, data, origin: 'network' });
|
|
297
423
|
report(`已保存到 ${saved.file}`);
|
|
298
424
|
|
|
299
425
|
/* 校验阶段分两种:
|
package/lib/inbox.js
CHANGED
|
@@ -20,6 +20,32 @@ import { ensureDir, removeTree } from './util.js';
|
|
|
20
20
|
/** 单次落盘上限(默认 1 GiB,够大包也够防呆)。 */
|
|
21
21
|
const MAX_BYTES = 1024 * 1024 * 1024;
|
|
22
22
|
|
|
23
|
+
/** 记录这份 inbox 副本来源的标记文件名。 */
|
|
24
|
+
const ORIGIN_MARK = '.origin';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 在 root 下建一个唯一的子目录:`<时间戳>-<4位随机>`。
|
|
28
|
+
*
|
|
29
|
+
* stamp() 只到秒,同一秒内多次落盘会撞名;加随机后缀既保持可读
|
|
30
|
+
* (按时间排序仍然有效),又能避免互相覆盖。
|
|
31
|
+
*
|
|
32
|
+
* @param {string} root
|
|
33
|
+
* @returns {string} 已创建的目录绝对路径
|
|
34
|
+
*/
|
|
35
|
+
function uniqueDir(root) {
|
|
36
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
37
|
+
const suffix = Math.random().toString(16).slice(2, 6).padEnd(4, '0');
|
|
38
|
+
const dir = path.join(root, `${stamp()}-${suffix}`);
|
|
39
|
+
if (fs.existsSync(dir)) continue;
|
|
40
|
+
ensureDir(dir);
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
// 极端情况下兜底:用纳秒精度的时间戳
|
|
44
|
+
const dir = path.join(root, `${stamp()}-${Date.now() % 100000}`);
|
|
45
|
+
ensureDir(dir);
|
|
46
|
+
return dir;
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
/**
|
|
24
50
|
* 从 multipart/form-data 里解析出文件条目。
|
|
25
51
|
*
|
|
@@ -27,8 +53,7 @@ const MAX_BYTES = 1024 * 1024 * 1024;
|
|
|
27
53
|
* 不引第三方库。
|
|
28
54
|
*
|
|
29
55
|
* @param {Buffer} body 整个请求体
|
|
30
|
-
* @param {string} boundary boundary 字符串(不含前置 --)
|
|
31
|
-
* @returns {Array<{ name: string, filename: string|null, data: Buffer }>}
|
|
56
|
+
* @param {string} boundary boundary 字符串(不含前置 --) * @returns {Array<{ name: string, filename: string|null, data: Buffer }>}
|
|
32
57
|
*/
|
|
33
58
|
export function parseMultipart(body, boundary) {
|
|
34
59
|
const delimiter = Buffer.from(`--${boundary}`);
|
|
@@ -87,9 +112,12 @@ export function boundaryOf(contentType) {
|
|
|
87
112
|
* @param {string} options.home $DSH_HOME
|
|
88
113
|
* @param {string} options.filename 原始文件名
|
|
89
114
|
* @param {Buffer} options.data 内容
|
|
115
|
+
* @param {'upload'|'network'} [options.origin]
|
|
116
|
+
* 来源。`network` = 从 GitHub/npm 下载来的,装完可以自动删;
|
|
117
|
+
* `upload` = 用户从本地拖/选的,装完要留着(用户自己的文件)。
|
|
90
118
|
* @returns {{ file: string, bytes: number }}
|
|
91
119
|
*/
|
|
92
|
-
export function saveIncomingZip({ home, filename, data }) {
|
|
120
|
+
export function saveIncomingZip({ home, filename, data, origin = 'upload' }) {
|
|
93
121
|
if (!data?.length) {
|
|
94
122
|
const error = new Error('上传内容为空');
|
|
95
123
|
error.code = 'BAD_UPLOAD';
|
|
@@ -101,8 +129,16 @@ export function saveIncomingZip({ home, filename, data }) {
|
|
|
101
129
|
throw error;
|
|
102
130
|
}
|
|
103
131
|
const base = safeName(String(filename ?? '').trim() || `bundle-${stamp()}.zip`);
|
|
104
|
-
|
|
105
|
-
|
|
132
|
+
/* 目录名要唯一:stamp() 只到秒,同一秒内连拖两个包会落到同一个目录,
|
|
133
|
+
* 后一个会覆盖前一个的 .origin 标记(甚至同名时覆盖文件本身)。
|
|
134
|
+
* 加个短随机后缀避开。 */
|
|
135
|
+
const dir = uniqueDir(path.join(home, 'plugin-pack', 'inbox'));
|
|
136
|
+
// 记下来源:安装成功后据此决定要不要删掉这份落盘副本
|
|
137
|
+
try {
|
|
138
|
+
fs.writeFileSync(path.join(dir, ORIGIN_MARK), origin === 'network' ? 'network' : 'upload');
|
|
139
|
+
} catch {
|
|
140
|
+
/* 标记写不进去不影响导入 */
|
|
141
|
+
}
|
|
106
142
|
// 保留原始扩展名:.zip = 整合包,.tgz/.tar.gz = 单个插件的 npm tarball
|
|
107
143
|
const lower = base.toLowerCase();
|
|
108
144
|
const hasKnownExt = lower.endsWith('.zip') || lower.endsWith('.tgz') || lower.endsWith('.tar.gz');
|
|
@@ -192,3 +228,47 @@ export function pruneInbox(home, days = 7) {
|
|
|
192
228
|
}
|
|
193
229
|
return removed;
|
|
194
230
|
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 读这份 inbox 副本的来源。
|
|
234
|
+
*
|
|
235
|
+
* @param {string} file inbox 里的文件绝对路径
|
|
236
|
+
* @returns {'upload'|'network'|null} 读不到标记时返回 null(老版本落盘的文件)
|
|
237
|
+
*/
|
|
238
|
+
export function inboxOrigin(file) {
|
|
239
|
+
try {
|
|
240
|
+
const mark = path.join(path.dirname(String(file)), ORIGIN_MARK);
|
|
241
|
+
const text = fs.readFileSync(mark, 'utf8').trim();
|
|
242
|
+
return text === 'network' ? 'network' : 'upload';
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 删掉 inbox 里的一份落盘副本(连它所在的时间戳目录一起删)。
|
|
250
|
+
*
|
|
251
|
+
* 只删 inbox 内部的目录:传进来的路径必须在 `<home>/plugin-pack/inbox` 之下,
|
|
252
|
+
* 否则直接拒绝 —— 免得哪天参数传错把用户自己的文件删了。
|
|
253
|
+
*
|
|
254
|
+
* @param {string} home $DSH_HOME
|
|
255
|
+
* @param {string} file inbox 里的文件路径
|
|
256
|
+
* @returns {boolean} 是否真的删了
|
|
257
|
+
*/
|
|
258
|
+
export function removeInboxEntry(home, file) {
|
|
259
|
+
try {
|
|
260
|
+
const root = path.resolve(home, 'plugin-pack', 'inbox');
|
|
261
|
+
const target = path.resolve(String(file));
|
|
262
|
+
// 必须严格在 inbox 之下,且不能就是 inbox 本身
|
|
263
|
+
if (target === root || !target.startsWith(root + path.sep)) return false;
|
|
264
|
+
const dir = path.dirname(target);
|
|
265
|
+
// 目录必须是 inbox 的直接子目录(时间戳目录),不能是更外层
|
|
266
|
+
if (path.dirname(dir) !== root) return false;
|
|
267
|
+
// 目录本身要存在,否则不算"删掉了"
|
|
268
|
+
if (!fs.existsSync(dir)) return false;
|
|
269
|
+
removeTree(dir);
|
|
270
|
+
return true;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
package/lib/server.js
CHANGED
|
@@ -28,7 +28,15 @@ import { importBundle, inspectBundle, rollbackImport, listBackups } from './impo
|
|
|
28
28
|
import { reloadClientPackage, clientStatus, localVerdict } from '../hotload.js';
|
|
29
29
|
import { importLocalPlugin } from './import-plugin.js';
|
|
30
30
|
import { importTarball, isTarball } from './import-tgz.js';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
parseMultipart,
|
|
33
|
+
boundaryOf,
|
|
34
|
+
saveIncomingZip,
|
|
35
|
+
saveIncomingDir,
|
|
36
|
+
pruneInbox,
|
|
37
|
+
inboxOrigin,
|
|
38
|
+
removeInboxEntry,
|
|
39
|
+
} from './inbox.js';
|
|
32
40
|
import { runJob, listJobs, getJob, runningJobs, clearFinished, openWaiting, getWaiting, listWaiting, cancelWaiting } from './jobs.js';
|
|
33
41
|
import { fetchBundleFrom, parseSource } from './fetch-bundle.js';
|
|
34
42
|
import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName as resolveProfileName, humanBytes } from './home.js';
|
|
@@ -80,6 +88,28 @@ function sendJson(response, status, value) {
|
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
/** 把异常翻译成 HTTP 状态;其余仍是 500。 */
|
|
91
|
+
/**
|
|
92
|
+
* 安装成功后清理 inbox 里的落盘副本。
|
|
93
|
+
*
|
|
94
|
+
* 只删「从网络下载」的那份;用户自己拖/选的本地文件永远保留 ——
|
|
95
|
+
* 那是他自己的文件,工具不该动它。
|
|
96
|
+
*
|
|
97
|
+
* @param {object} config
|
|
98
|
+
* @param {string} file 本次导入用的文件路径
|
|
99
|
+
* @returns {boolean} 是否真的删掉了
|
|
100
|
+
*/
|
|
101
|
+
function autoCleanInbox(config, file) {
|
|
102
|
+
try {
|
|
103
|
+
if (!file) return false;
|
|
104
|
+
const home = contextOf(config).home;
|
|
105
|
+
// 只有网络下载的来源才删
|
|
106
|
+
if (inboxOrigin(file) !== 'network') return false;
|
|
107
|
+
return removeInboxEntry(home, file);
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
83
113
|
function statusOf(error) {
|
|
84
114
|
const explicit = Number(error?.status ?? error?.statusCode);
|
|
85
115
|
if (Number.isFinite(explicit) && explicit >= 400 && explicit < 600) return explicit;
|
|
@@ -423,7 +453,13 @@ export function mountRoutes(ctx, config = {}) {
|
|
|
423
453
|
try {
|
|
424
454
|
return await spec.handler(request, response);
|
|
425
455
|
} catch (error) {
|
|
426
|
-
sendJson(response, statusOf(error), {
|
|
456
|
+
sendJson(response, statusOf(error), {
|
|
457
|
+
ok: false,
|
|
458
|
+
error: String(error?.message ?? error),
|
|
459
|
+
code: error?.code,
|
|
460
|
+
// 网络类错误带处置建议,界面会显示在错误下面
|
|
461
|
+
...(error?.hint ? { hint: String(error.hint) } : {}),
|
|
462
|
+
});
|
|
427
463
|
}
|
|
428
464
|
},
|
|
429
465
|
},
|
|
@@ -584,7 +620,9 @@ export function mountRoutes(ctx, config = {}) {
|
|
|
584
620
|
meta: { file: body.file },
|
|
585
621
|
task: (report) => doImport(report),
|
|
586
622
|
});
|
|
587
|
-
|
|
623
|
+
// 网络下载来的 tgz,装完同样清掉副本
|
|
624
|
+
const cleanedTgz = autoCleanInbox(config, body.file);
|
|
625
|
+
sendJson(response, 200, { ...result, jobId: id, ...(cleanedTgz ? { inboxCleaned: true } : {}) });
|
|
588
626
|
return;
|
|
589
627
|
}
|
|
590
628
|
const dryRun = Boolean(body.dryRun);
|
|
@@ -635,7 +673,11 @@ export function mountRoutes(ctx, config = {}) {
|
|
|
635
673
|
jobId: body.jobId ? String(body.jobId) : undefined,
|
|
636
674
|
task: (report) => doImportBundle(report),
|
|
637
675
|
});
|
|
638
|
-
|
|
676
|
+
/* 装完清掉网络下载的副本:
|
|
677
|
+
* 从 GitHub/npm 下下来的包,装好后原件就没用了,留着白占磁盘。
|
|
678
|
+
* 但用户自己拖/选的本地文件(origin='upload')一律保留 —— 那是他的东西。 */
|
|
679
|
+
const cleaned = autoCleanInbox(config, body.file);
|
|
680
|
+
sendJson(response, 200, { ...result, jobId: id, ...(cleaned ? { inboxCleaned: true } : {}) });
|
|
639
681
|
},
|
|
640
682
|
},
|
|
641
683
|
'plugin-pack: import',
|
package/package.json
CHANGED