dsh-plugin-pack 1.0.5 → 1.0.6
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 +183 -0
- package/lib/fetch-bundle.js +396 -0
- package/lib/server.js +51 -13
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -111,6 +111,13 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
111
111
|
'import.reading': '正在读取包内容…',
|
|
112
112
|
'import.uploading': '正在上传…',
|
|
113
113
|
'import.checking': '正在检查冲突…',
|
|
114
|
+
'import.downloading': '正在从网络下载…',
|
|
115
|
+
'net.title': '从网络安装',
|
|
116
|
+
'net.desc': '支持 GitHub 仓库 / Release / 直链,或 npm 包名。下载后会校验是不是整合包。',
|
|
117
|
+
'net.placeholder': 'https://github.com/用户/仓库 或 dsh-plugin-pack 或 https://.../x.zip',
|
|
118
|
+
'net.go': '下载并安装',
|
|
119
|
+
'net.running': '正在处理…',
|
|
120
|
+
'net.notBundle': '下载到的不是整合包',
|
|
114
121
|
'drop.title': '把 zip / tgz 或插件文件夹拖到这里',
|
|
115
122
|
'drop.release': '松手就导入',
|
|
116
123
|
'drop.running': '正在导入…',
|
|
@@ -149,6 +156,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
149
156
|
'jobs.kindImport': '导入整合包',
|
|
150
157
|
'jobs.kindImportPlugin': '导入插件',
|
|
151
158
|
'jobs.kindRollback': '回滚',
|
|
159
|
+
'jobs.kindFetch': '网络安装',
|
|
152
160
|
'jobs.running': '进行中',
|
|
153
161
|
'jobs.done': '已完成',
|
|
154
162
|
'jobs.failed': '失败',
|
|
@@ -272,6 +280,13 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
272
280
|
'import.reading': 'Reading package contents…',
|
|
273
281
|
'import.uploading': 'Uploading…',
|
|
274
282
|
'import.checking': 'Checking for conflicts…',
|
|
283
|
+
'import.downloading': 'Downloading…',
|
|
284
|
+
'net.title': 'Install from network',
|
|
285
|
+
'net.desc': 'GitHub repo / release / direct link, or an npm package name. We verify it is a bundle after download.',
|
|
286
|
+
'net.placeholder': 'https://github.com/user/repo · dsh-plugin-pack · https://.../x.zip',
|
|
287
|
+
'net.go': 'Download and install',
|
|
288
|
+
'net.running': 'Working…',
|
|
289
|
+
'net.notBundle': 'Downloaded file is not a bundle',
|
|
275
290
|
'drop.title': 'Drop a zip / tgz or plugin folder here',
|
|
276
291
|
'drop.release': 'Release to import',
|
|
277
292
|
'drop.running': 'Importing…',
|
|
@@ -309,6 +324,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
309
324
|
'jobs.kindImport': 'Import bundle',
|
|
310
325
|
'jobs.kindImportPlugin': 'Import plugin',
|
|
311
326
|
'jobs.kindRollback': 'Rollback',
|
|
327
|
+
'jobs.kindFetch': 'Network install',
|
|
312
328
|
'jobs.running': 'Running',
|
|
313
329
|
'jobs.done': 'Done',
|
|
314
330
|
'jobs.failed': 'Failed',
|
|
@@ -493,6 +509,8 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
493
509
|
const getWaiting = () => api('/plugin-pack/waiting');
|
|
494
510
|
const cancelWaiting = (id, reason) =>
|
|
495
511
|
api('/plugin-pack/waiting/cancel', { method: 'POST', body: JSON.stringify({ id, reason }) });
|
|
512
|
+
/** 从网络来源(GitHub / npm / 直链)拉整合包。 */
|
|
513
|
+
const fetchBundle = (body) => api('/plugin-pack/fetch-bundle', { method: 'POST', body: JSON.stringify(body) });
|
|
496
514
|
|
|
497
515
|
/* ------------------------- 浏览器端读 zip 头部 ------------------------- */
|
|
498
516
|
|
|
@@ -1438,6 +1456,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1438
1456
|
import: t('jobs.kindImport'),
|
|
1439
1457
|
'import-plugin': t('jobs.kindImportPlugin'),
|
|
1440
1458
|
rollback: t('jobs.kindRollback'),
|
|
1459
|
+
fetch: t('jobs.kindFetch'),
|
|
1441
1460
|
})[kind] ?? kind;
|
|
1442
1461
|
|
|
1443
1462
|
const statusColor = (status) =>
|
|
@@ -1588,6 +1607,10 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1588
1607
|
const pendingJobId = useRef(null);
|
|
1589
1608
|
/** 导入进度阶段文案(读取中 / 上传中 / 检查冲突…),空串表示没在跑。 */
|
|
1590
1609
|
const [phase, setPhase] = useState('');
|
|
1610
|
+
/** 网络安装:地址输入框的内容。 */
|
|
1611
|
+
const [netSource, setNetSource] = useState('');
|
|
1612
|
+
/** 网络安装的结果/错误。 */
|
|
1613
|
+
const [netResult, setNetResult] = useState(null);
|
|
1591
1614
|
/** 后台任务列表:切走画面回来靠它恢复 */
|
|
1592
1615
|
const [jobs, setJobs] = useState([]);
|
|
1593
1616
|
/** 体积上限(MiB),0 = 不限制。默认跟着服务端配置走。 */
|
|
@@ -1872,6 +1895,106 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1872
1895
|
}
|
|
1873
1896
|
};
|
|
1874
1897
|
|
|
1898
|
+
/**
|
|
1899
|
+
* 从网络来源安装整合包(GitHub 仓库/Release、npm 包、任意直链)。
|
|
1900
|
+
*
|
|
1901
|
+
* 流程:下载 → 服务端校验"确实是整合包" → 有冲突先弹窗 → 确认后导入。
|
|
1902
|
+
* 校验不过会明确报「不是整合包」,不会静默失败。
|
|
1903
|
+
*/
|
|
1904
|
+
const runNetworkInstall = async () => {
|
|
1905
|
+
const source = netSource.trim();
|
|
1906
|
+
if (!source) {
|
|
1907
|
+
setNetResult({ error: '请填一个地址,比如 https://github.com/用户/仓库 或 npm 包名' });
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
setBusy(true);
|
|
1911
|
+
setNetResult(null);
|
|
1912
|
+
setImportResult(null);
|
|
1913
|
+
setPhase(t('import.downloading'));
|
|
1914
|
+
try {
|
|
1915
|
+
const fetched = await fetchBundle({ source });
|
|
1916
|
+
if (!fetched?.ok) {
|
|
1917
|
+
setNetResult({ error: fetched?.error ?? '下载失败' });
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
setPhase(t('import.checking'));
|
|
1921
|
+
|
|
1922
|
+
/* 下载到的可能是两种:
|
|
1923
|
+
* kind='plugin' → npm 包 / 单插件 tgz,走 tgz 导入
|
|
1924
|
+
* kind='bundle' → 整合包 zip,走整合包导入(可能要先问冲突) */
|
|
1925
|
+
if (fetched.kind === 'plugin') {
|
|
1926
|
+
const result = await doImport({
|
|
1927
|
+
file: fetched.file,
|
|
1928
|
+
dryRun: false,
|
|
1929
|
+
mode: 'merge',
|
|
1930
|
+
linkNodeModules: true,
|
|
1931
|
+
});
|
|
1932
|
+
setNetResult({
|
|
1933
|
+
ok: true,
|
|
1934
|
+
kind: 'plugin',
|
|
1935
|
+
file: fetched.file,
|
|
1936
|
+
from: fetched.from,
|
|
1937
|
+
name: fetched.name,
|
|
1938
|
+
bytes: fetched.bytes,
|
|
1939
|
+
plugins: fetched.plugins,
|
|
1940
|
+
});
|
|
1941
|
+
setImportResult(result);
|
|
1942
|
+
await load();
|
|
1943
|
+
await refreshJobs();
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// 下载好的包按普通导入走一遍:先 dryRun 看有没有重名冲突
|
|
1948
|
+
const preview = await doImport({ file: fetched.file, dryRun: true });
|
|
1949
|
+
const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
|
|
1950
|
+
if (conflicts.length) {
|
|
1951
|
+
pendingJobId.current = preview?.jobId ?? null;
|
|
1952
|
+
setPendingImport({
|
|
1953
|
+
kind: 'bundle',
|
|
1954
|
+
jobId: preview?.jobId ?? null,
|
|
1955
|
+
file: fetched.file,
|
|
1956
|
+
conflicts,
|
|
1957
|
+
plugins: preview.plugins ?? [],
|
|
1958
|
+
});
|
|
1959
|
+
setNetResult({
|
|
1960
|
+
ok: true,
|
|
1961
|
+
downloaded: true,
|
|
1962
|
+
file: fetched.file,
|
|
1963
|
+
from: fetched.from,
|
|
1964
|
+
name: fetched.name,
|
|
1965
|
+
bytes: fetched.bytes,
|
|
1966
|
+
plugins: fetched.plugins,
|
|
1967
|
+
});
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
const result = await doImport({
|
|
1972
|
+
file: fetched.file,
|
|
1973
|
+
dryRun: false,
|
|
1974
|
+
mode: 'merge',
|
|
1975
|
+
restoreData: true,
|
|
1976
|
+
linkNodeModules: true,
|
|
1977
|
+
});
|
|
1978
|
+
setNetResult({
|
|
1979
|
+
ok: true,
|
|
1980
|
+
file: fetched.file,
|
|
1981
|
+
from: fetched.from,
|
|
1982
|
+
name: fetched.name,
|
|
1983
|
+
bytes: fetched.bytes,
|
|
1984
|
+
plugins: fetched.plugins,
|
|
1985
|
+
});
|
|
1986
|
+
setImportResult(result);
|
|
1987
|
+
await load();
|
|
1988
|
+
await refreshJobs();
|
|
1989
|
+
} catch (err) {
|
|
1990
|
+
const code = err?.code ? String(err.code) : null;
|
|
1991
|
+
setNetResult({ error: String(err?.message ?? err), code });
|
|
1992
|
+
} finally {
|
|
1993
|
+
setPhase('');
|
|
1994
|
+
setBusy(false);
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1875
1998
|
/** 用户在冲突弹窗里做了选择:带着 decisions 真正导入。 */
|
|
1876
1999
|
const confirmImport = async (decisions) => {
|
|
1877
2000
|
const pending = pendingImport;
|
|
@@ -2220,6 +2343,66 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
2220
2343
|
'div',
|
|
2221
2344
|
null,
|
|
2222
2345
|
h(DropZone, { onFile: runDrop, busy, dropRef: lastDrop, phase }),
|
|
2346
|
+
|
|
2347
|
+
/* 从网络安装:GitHub / npm / 直链。
|
|
2348
|
+
* 下载后服务端会校验它真的是整合包(inspectBundle),
|
|
2349
|
+
* 不是就明确报「下载到的不是整合包」,不会硬导进去。 */
|
|
2350
|
+
h(
|
|
2351
|
+
'div',
|
|
2352
|
+
{ className: 'pp-card', style: { marginTop: 14 } },
|
|
2353
|
+
h('h3', { style: { margin: '0 0 4px', fontSize: 14, fontWeight: 600 } }, t('net.title')),
|
|
2354
|
+
h('p', { className: 'pp-hint', style: { margin: '0 0 10px' } }, t('net.desc')),
|
|
2355
|
+
h(
|
|
2356
|
+
'div',
|
|
2357
|
+
{ style: { display: 'flex', gap: 8, alignItems: 'flex-start', flexWrap: 'wrap' } },
|
|
2358
|
+
h('input', {
|
|
2359
|
+
className: 'pp-input pp-mono',
|
|
2360
|
+
type: 'text',
|
|
2361
|
+
value: netSource,
|
|
2362
|
+
placeholder: t('net.placeholder'),
|
|
2363
|
+
disabled: busy,
|
|
2364
|
+
onInput: (event) => setNetSource(event.target.value),
|
|
2365
|
+
onKeyDown: (event) => {
|
|
2366
|
+
if (event.key === 'Enter' && !busy) runNetworkInstall();
|
|
2367
|
+
},
|
|
2368
|
+
style: { flex: '1 1 260px', minWidth: 0, fontSize: 12 },
|
|
2369
|
+
}),
|
|
2370
|
+
h(
|
|
2371
|
+
'button',
|
|
2372
|
+
{
|
|
2373
|
+
className: 'pp-btn',
|
|
2374
|
+
disabled: busy || !netSource.trim(),
|
|
2375
|
+
onClick: runNetworkInstall,
|
|
2376
|
+
},
|
|
2377
|
+
busy ? t('net.running') : t('net.go'),
|
|
2378
|
+
),
|
|
2379
|
+
),
|
|
2380
|
+
// 结果:下载成功 / 不是整合包 / 其它错误
|
|
2381
|
+
netResult?.error
|
|
2382
|
+
? h(
|
|
2383
|
+
'div',
|
|
2384
|
+
{ style: { marginTop: 10 } },
|
|
2385
|
+
['NOT_A_BUNDLE', 'NOT_A_ZIP', 'BAD_FORMAT', 'BAD_MANIFEST', 'BAD_PACK'].includes(String(netResult.code ?? '')) ||
|
|
2386
|
+
/不是整合包|没有 manifest/i.test(String(netResult.error))
|
|
2387
|
+
? h(
|
|
2388
|
+
'div',
|
|
2389
|
+
{ className: 'pp-not-bundle' },
|
|
2390
|
+
h('div', { className: 'pp-not-bundle-title' }, t('net.notBundle')),
|
|
2391
|
+
h('div', { className: 'pp-not-bundle-detail' }, String(netResult.error)),
|
|
2392
|
+
)
|
|
2393
|
+
: h(Alert, { kind: 'err' }, netResult.error),
|
|
2394
|
+
)
|
|
2395
|
+
: netResult?.ok
|
|
2396
|
+
? h(
|
|
2397
|
+
'div',
|
|
2398
|
+
{ className: 'pp-hint', style: { marginTop: 8 } },
|
|
2399
|
+
`✓ ${netResult.from} · ${netResult.name ?? ''} · ${
|
|
2400
|
+
netResult.bytes ? (netResult.bytes / 1048576).toFixed(1) + ' MiB' : ''
|
|
2401
|
+
} · ${netResult.plugins?.length ?? 0} ${t('plugins')}`,
|
|
2402
|
+
)
|
|
2403
|
+
: null,
|
|
2404
|
+
),
|
|
2405
|
+
|
|
2223
2406
|
// 后台任务:切走画面回来能看到刚才那次导入的结果
|
|
2224
2407
|
h(JobList, { jobs, onClear: clearJobHistory, t }),
|
|
2225
2408
|
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从网络来源拉取整合包。
|
|
3
|
+
*
|
|
4
|
+
* 支持两类来源:
|
|
5
|
+
* 1. GitHub —— 仓库的 Release 附件、raw 文件、仓库归档
|
|
6
|
+
* 2. npm —— 包的 tarball(`npm view dist.tarball` 那个地址)
|
|
7
|
+
*
|
|
8
|
+
* 关键约束:**下载完必须校验它真的是整合包**。
|
|
9
|
+
* 用户可能贴错链接(比如贴了个普通 zip、别的工具的包、损坏的包),
|
|
10
|
+
* 不能因为"地址看起来像"就直接导入。校验复用 import.js 的 inspectBundle,
|
|
11
|
+
* 它同时认 v1(散文件)和 v2(内部 tgz)两种布局。
|
|
12
|
+
*
|
|
13
|
+
* 网络请求只用 node 内置的 fetch(Node 18+),不引入依赖。
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-plugin-pack/lib/fetch-bundle
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { inspectBundle } from './import.js';
|
|
21
|
+
import { saveIncomingZip } from './inbox.js';
|
|
22
|
+
import { ensureDir } from './util.js';
|
|
23
|
+
|
|
24
|
+
/** 单次下载的上限(与上传一致,1 GiB)。 */
|
|
25
|
+
const MAX_BYTES = 1024 * 1024 * 1024;
|
|
26
|
+
/** 下载超时(毫秒)。大包可能要几分钟。 */
|
|
27
|
+
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 判断一个 URL 属于哪类来源,并归一成可下载的直链。
|
|
31
|
+
*
|
|
32
|
+
* 几类常见写法:
|
|
33
|
+
* - `https://github.com/owner/repo` → 仓库归档(main 分支 zip)
|
|
34
|
+
* - `https://github.com/owner/repo/tree/v1.0.0` → 该 ref 的归档
|
|
35
|
+
* - `https://github.com/owner/repo/releases/tag/v1.0.0`→ 该 release 的资产(需再查 API)
|
|
36
|
+
* - `https://github.com/owner/repo/releases/download/v1/assets.zip` → 直接下
|
|
37
|
+
* - `https://raw.githubusercontent.com/.../x.zip` → 直接下
|
|
38
|
+
* - `npm install <name>@<ver>` 语义由调用方传 pkg 名,这里处理 tarball 直链
|
|
39
|
+
*
|
|
40
|
+
* @param {string} raw 用户输入的地址
|
|
41
|
+
* @returns {{ kind: 'direct'|'github-repo'|'github-release'|'npm', url: string, owner?: string, repo?: string, ref?: string, tag?: string, name?: string }}
|
|
42
|
+
*/
|
|
43
|
+
export function parseSource(raw) {
|
|
44
|
+
const text = String(raw ?? '').trim();
|
|
45
|
+
if (!text) {
|
|
46
|
+
const error = new Error('地址为空');
|
|
47
|
+
error.code = 'BAD_SOURCE';
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// npm 包名(允许 @scope/name@version)
|
|
52
|
+
// 只有不含协议、不含斜杠目录、看起来像包名时才走 npm
|
|
53
|
+
if (!/^https?:\/\//i.test(text) && /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(@[^\s]+)?$/i.test(text)) {
|
|
54
|
+
return { kind: 'npm', name: text };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(text);
|
|
60
|
+
} catch {
|
|
61
|
+
const error = new Error(`地址格式不对: ${text}`);
|
|
62
|
+
error.code = 'BAD_SOURCE';
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
// URL.protocol 带冒号('https:'),这里必须把它算进去
|
|
66
|
+
if (!/^https?:$/i.test(url.protocol)) {
|
|
67
|
+
const error = new Error(`只支持 http/https: ${text}`);
|
|
68
|
+
error.code = 'BAD_SOURCE';
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// GitHub 各类页面
|
|
73
|
+
const gh = /^https:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/(.*))?$/i.exec(`${url.origin}${url.pathname}`);
|
|
74
|
+
if (gh) {
|
|
75
|
+
const [, owner, repoRaw, rest = ''] = gh;
|
|
76
|
+
const repo = repoRaw.replace(/\.git$/i, '');
|
|
77
|
+
const segs = rest.split('/').filter(Boolean);
|
|
78
|
+
|
|
79
|
+
// releases/download/<tag>/<file> → 直链
|
|
80
|
+
if (segs[0] === 'releases' && segs[1] === 'download') {
|
|
81
|
+
return { kind: 'direct', url: text };
|
|
82
|
+
}
|
|
83
|
+
// releases/tag/<tag> 或 releases/latest → 查 API 找资产
|
|
84
|
+
if (segs[0] === 'releases' && (segs[1] === 'tag' || segs[1] === 'latest')) {
|
|
85
|
+
return { kind: 'github-release', owner, repo, tag: segs[1] === 'latest' ? 'latest' : segs[2] };
|
|
86
|
+
}
|
|
87
|
+
// raw 直链
|
|
88
|
+
if (url.hostname === 'raw.githubusercontent.com') {
|
|
89
|
+
return { kind: 'direct', url: text };
|
|
90
|
+
}
|
|
91
|
+
// 仓库页 / tree / blob → 用归档
|
|
92
|
+
if (!segs.length) return { kind: 'github-repo', owner, repo };
|
|
93
|
+
if (segs[0] === 'tree') return { kind: 'github-repo', owner, repo, ref: segs.slice(1).join('/') };
|
|
94
|
+
if (segs[0] === 'archive') return { kind: 'direct', url: text };
|
|
95
|
+
// 其它(issues、wiki…)不是包
|
|
96
|
+
const error = new Error(`这个 GitHub 地址不是包来源: ${text}(请用仓库主页、Release 或 raw 文件直链)`);
|
|
97
|
+
error.code = 'BAD_SOURCE';
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 其它任何直链(含 jsDelivr、自建站、对象存储签名链接)
|
|
102
|
+
return { kind: 'direct', url: text };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 带超时的 fetch。 */
|
|
106
|
+
async function fetchWithTimeout(url, options = {}, timeoutMs = TIMEOUT_MS) {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
109
|
+
try {
|
|
110
|
+
return await fetch(url, { ...options, signal: controller.signal, redirect: 'follow' });
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 解析 GitHub Release 里的整合包资产。
|
|
118
|
+
*
|
|
119
|
+
* 一个 release 可能挂了很多文件(.exe、.dmg…),按顺序找:
|
|
120
|
+
* 1. 名字像整合包(.zip)
|
|
121
|
+
* 2. 名字带 bundle / plugin-pack / dsh
|
|
122
|
+
* 找不到就报错说清楚 —— 不要瞎猜一个 .exe 下下来。
|
|
123
|
+
*/
|
|
124
|
+
async function resolveReleaseAsset({ owner, repo, tag }) {
|
|
125
|
+
const apiBase = 'https://api.github.com';
|
|
126
|
+
const endpoint =
|
|
127
|
+
tag === 'latest'
|
|
128
|
+
? `${apiBase}/repos/${owner}/${repo}/releases/latest`
|
|
129
|
+
: `${apiBase}/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`;
|
|
130
|
+
|
|
131
|
+
const response = await fetchWithTimeout(endpoint, {
|
|
132
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'dsh-plugin-pack' },
|
|
133
|
+
timeoutMs: 30000,
|
|
134
|
+
});
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
const error = new Error(
|
|
137
|
+
`查 GitHub Release 失败(${response.status})。这个仓库可能没有 Release,或者 tag「${tag}」不存在。`,
|
|
138
|
+
);
|
|
139
|
+
error.code = 'BAD_SOURCE';
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
const data = await response.json();
|
|
143
|
+
const assets = Array.isArray(data.assets) ? data.assets : [];
|
|
144
|
+
if (!assets.length) {
|
|
145
|
+
const error = new Error(`Release「${data.tag_name ?? tag}」没有任何附件。`);
|
|
146
|
+
error.code = 'BAD_SOURCE';
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const scored = assets
|
|
151
|
+
.map((asset) => {
|
|
152
|
+
const name = String(asset.name ?? '');
|
|
153
|
+
let score = 0;
|
|
154
|
+
if (/\.zip$/i.test(name)) score += 10;
|
|
155
|
+
if (/bundle|plugin-pack|pluginpack|pack/i.test(name)) score += 5;
|
|
156
|
+
if (/dsh/i.test(name)) score += 3;
|
|
157
|
+
// 明显的非包文件降权
|
|
158
|
+
if (/\.(exe|msi|dmg|deb|rpm|AppImage|apk|sig|txt|json)$/i.test(name)) score -= 20;
|
|
159
|
+
return { asset, score, name };
|
|
160
|
+
})
|
|
161
|
+
.sort((a, b) => b.score - a.score);
|
|
162
|
+
|
|
163
|
+
const best = scored[0];
|
|
164
|
+
if (!best || best.score <= 0) {
|
|
165
|
+
const error = new Error(
|
|
166
|
+
`Release 里的文件都不像整合包:${assets.map((a) => a.name).join(', ')}。` +
|
|
167
|
+
`请贴具体的 .zip 附件直链。`,
|
|
168
|
+
);
|
|
169
|
+
error.code = 'NOT_A_BUNDLE';
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
return { url: best.asset.browser_download_url, name: best.name, from: 'github-release' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 解析 npm 包的 tarball 地址。 */
|
|
176
|
+
async function resolveNpmTarball(spec) {
|
|
177
|
+
/* registry 元数据端点是「包名」而不是「包名@版本」:
|
|
178
|
+
* https://registry.npmjs.org/dsh-plugin-pack
|
|
179
|
+
* https://registry.npmjs.org/@scope%2Fname
|
|
180
|
+
* 版本号只用于从 versions 里挑哪一个的 dist.tarball。 */
|
|
181
|
+
const at = spec.lastIndexOf('@');
|
|
182
|
+
// 注意 @scope/name 时,lastIndexOf('@') 会命中开头的 @,要排除
|
|
183
|
+
const hasVersion = at > 0;
|
|
184
|
+
const pkgName = hasVersion ? spec.slice(0, at) : spec;
|
|
185
|
+
const wantedVersion = hasVersion ? spec.slice(at + 1) : null;
|
|
186
|
+
|
|
187
|
+
const endpoint = `https://registry.npmjs.org/${pkgName.replace(/\//g, '%2F')}`;
|
|
188
|
+
const response = await fetchWithTimeout(endpoint, {
|
|
189
|
+
headers: { Accept: 'application/json', 'User-Agent': 'dsh-plugin-pack' },
|
|
190
|
+
timeoutMs: 30000,
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
const error = new Error(`查 npm 包失败(${response.status}):${pkgName}`);
|
|
194
|
+
error.code = 'BAD_SOURCE';
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
const data = await response.json();
|
|
198
|
+
|
|
199
|
+
// 指定了版本就用那个版本的 tarball;否则用 dist-tags.latest
|
|
200
|
+
const version = wantedVersion ?? data?.['dist-tags']?.latest;
|
|
201
|
+
const entry = version ? data?.versions?.[version] : null;
|
|
202
|
+
const url = entry?.dist?.tarball;
|
|
203
|
+
if (!url) {
|
|
204
|
+
const error = new Error(
|
|
205
|
+
`npm 上没有这个版本:${pkgName}@${version ?? '(未知)'}。` +
|
|
206
|
+
`可用版本:${Object.keys(data?.versions ?? {}).slice(-5).join(', ') || '(无)'}`,
|
|
207
|
+
);
|
|
208
|
+
error.code = 'BAD_SOURCE';
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
return { url, name: `${data.name}-${version}.tgz`, from: 'npm' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 从 URL 流式下载到 Buffer(带体积上限)。 */
|
|
215
|
+
async function download(url) {
|
|
216
|
+
const response = await fetchWithTimeout(url, {
|
|
217
|
+
headers: { 'User-Agent': 'dsh-plugin-pack', Accept: '*/*' },
|
|
218
|
+
});
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
const error = new Error(
|
|
221
|
+
`下载失败(HTTP ${response.status})。地址可能失效、需要登录,或者是私有仓库。`,
|
|
222
|
+
);
|
|
223
|
+
error.code = 'DOWNLOAD_FAILED';
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const chunks = [];
|
|
228
|
+
let size = 0;
|
|
229
|
+
for await (const chunk of response.body) {
|
|
230
|
+
size += chunk.length;
|
|
231
|
+
if (size > MAX_BYTES) {
|
|
232
|
+
const error = new Error(`文件超过 ${MAX_BYTES / 1048576} MiB,已中止`);
|
|
233
|
+
error.code = 'TOO_LARGE';
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
chunks.push(Buffer.from(chunk));
|
|
237
|
+
}
|
|
238
|
+
if (!size) {
|
|
239
|
+
const error = new Error('下载到的是空文件');
|
|
240
|
+
error.code = 'DOWNLOAD_FAILED';
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
return Buffer.concat(chunks);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 从网络来源拉一个整合包到 inbox,并**校验它确实是整合包**。
|
|
248
|
+
*
|
|
249
|
+
* @param {object} options
|
|
250
|
+
* @param {string} options.source 用户输入(仓库地址 / Release / 直链 / npm 包名)
|
|
251
|
+
* @param {string} options.home $DSH_HOME
|
|
252
|
+
* @param {(message: string) => void} [options.onProgress]
|
|
253
|
+
* @returns {Promise<{ file: string, bytes: number, name: string, from: string,
|
|
254
|
+
* manifest: object, plugins: Array<{name: string, version: string}> }>}
|
|
255
|
+
*/
|
|
256
|
+
export async function fetchBundleFrom({ source, home, onProgress = () => {} }) {
|
|
257
|
+
const report = (message) => onProgress(message);
|
|
258
|
+
const parsed = parseSource(source);
|
|
259
|
+
|
|
260
|
+
report(`识别来源:${describeSource(parsed)}`);
|
|
261
|
+
let url;
|
|
262
|
+
let name;
|
|
263
|
+
let from;
|
|
264
|
+
|
|
265
|
+
if (parsed.kind === 'npm') {
|
|
266
|
+
const resolved = await resolveNpmTarball(parsed.name);
|
|
267
|
+
url = resolved.url;
|
|
268
|
+
name = resolved.name;
|
|
269
|
+
from = 'npm';
|
|
270
|
+
report(`找到 npm 包:${parsed.name}`);
|
|
271
|
+
} else if (parsed.kind === 'github-release') {
|
|
272
|
+
const resolved = await resolveReleaseAsset(parsed);
|
|
273
|
+
url = resolved.url;
|
|
274
|
+
name = resolved.name;
|
|
275
|
+
from = 'github-release';
|
|
276
|
+
report(`找到 Release 附件:${name}`);
|
|
277
|
+
} else if (parsed.kind === 'github-repo') {
|
|
278
|
+
const ref = parsed.ref || 'HEAD';
|
|
279
|
+
url = `https://codeload.github.com/${parsed.owner}/${parsed.repo}/zip/${attemptRef(ref)}`;
|
|
280
|
+
name = `${parsed.repo}-${ref.replace(/[\\/]/g, '-')}.zip`;
|
|
281
|
+
from = 'github-repo';
|
|
282
|
+
report(`下载仓库归档:${parsed.owner}/${parsed.repo}@${ref}`);
|
|
283
|
+
} else {
|
|
284
|
+
url = parsed.url;
|
|
285
|
+
name = basenameOf(url) || 'bundle.zip';
|
|
286
|
+
from = 'direct';
|
|
287
|
+
report(`下载直链:${url}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
report(`开始下载(${from})`);
|
|
291
|
+
const data = await download(url);
|
|
292
|
+
report(`下载完成:${(data.length / 1048576).toFixed(1)} MiB`);
|
|
293
|
+
|
|
294
|
+
// 落到 inbox(保留扩展名,后续按 .zip/.tgz 分流)
|
|
295
|
+
pruneSafe(home);
|
|
296
|
+
const saved = saveIncomingZip({ home, filename: name, data });
|
|
297
|
+
report(`已保存到 ${saved.file}`);
|
|
298
|
+
|
|
299
|
+
/* 校验阶段分两种:
|
|
300
|
+
* .tgz → 这是单个插件的 npm tarball。要求 package/package.json 存在,
|
|
301
|
+
* 不需要"整合包",因为它本来就不是。
|
|
302
|
+
* .zip → 必须是 dsh-plugin-pack 的整合包,否则报 NOT_A_BUNDLE。
|
|
303
|
+
* 两类都用现实存在的解析器校验,不靠扩展名猜。 */
|
|
304
|
+
const isTgz = /\.(tgz|tar\.gz)$/i.test(saved.file);
|
|
305
|
+
|
|
306
|
+
if (isTgz) {
|
|
307
|
+
const { isTarball, unpackTarball } = await import('./import-tgz.js');
|
|
308
|
+
if (!isTarball(saved.file)) {
|
|
309
|
+
const error = new Error(`下载到的文件不是有效的 tar.gz:${name}`);
|
|
310
|
+
error.code = 'NOT_A_BUNDLE';
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
// 解到临时目录看有没有 package.json —— 有才是插件包
|
|
314
|
+
let info = null;
|
|
315
|
+
try {
|
|
316
|
+
const unpacked = unpackTarball({ file: saved.file, home });
|
|
317
|
+
const { describeLocalPlugin } = await import('./import-plugin.js');
|
|
318
|
+
info = describeLocalPlugin(unpacked.dir);
|
|
319
|
+
if (!info?.name) throw new Error('tarball 里没有可识别的 package.json');
|
|
320
|
+
} catch (inner) {
|
|
321
|
+
const error = new Error(
|
|
322
|
+
`下载到的不是插件包:这个 .tgz 里没有有效的 package.json(${String(inner?.message ?? inner).slice(0, 120)})`,
|
|
323
|
+
);
|
|
324
|
+
error.code = 'NOT_A_BUNDLE';
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
report(`校验通过:单个插件 ${info.name}@${info.version ?? '?'}`);
|
|
328
|
+
return {
|
|
329
|
+
file: saved.file,
|
|
330
|
+
bytes: saved.bytes,
|
|
331
|
+
name,
|
|
332
|
+
from,
|
|
333
|
+
kind: 'plugin',
|
|
334
|
+
manifest: null,
|
|
335
|
+
plugins: [{ name: info.name, version: info.version ?? null }],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* 关键:校验它真的是整合包。
|
|
340
|
+
* 这一步不过就报明确错误,不让用户以为"下载成功了却导不进去"。 */
|
|
341
|
+
const info = inspectBundle(saved.file);
|
|
342
|
+
report(`校验通过:整合包内含 ${info.plugins.length} 个插件`);
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
file: saved.file,
|
|
346
|
+
bytes: saved.bytes,
|
|
347
|
+
name,
|
|
348
|
+
from,
|
|
349
|
+
kind: 'bundle',
|
|
350
|
+
manifest: info.manifest,
|
|
351
|
+
plugins: info.plugins.map((plugin) => ({
|
|
352
|
+
name: plugin.name,
|
|
353
|
+
version: plugin.version,
|
|
354
|
+
})),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 归档分支名里的斜杠要转义,否则 codeload 会 404。 */
|
|
359
|
+
function attemptRef(ref) {
|
|
360
|
+
return String(ref).replace(/[\\/]/g, '%2F');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 从 URL 里取文件名(去掉查询串)。 */
|
|
364
|
+
function basenameOf(url) {
|
|
365
|
+
try {
|
|
366
|
+
const parsed = new URL(url);
|
|
367
|
+
const seg = parsed.pathname.split('/').filter(Boolean).pop() ?? '';
|
|
368
|
+
return decodeURIComponent(seg);
|
|
369
|
+
} catch {
|
|
370
|
+
return '';
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 人读的来源描述(进度条上显示)。 */
|
|
375
|
+
function describeSource(parsed) {
|
|
376
|
+
switch (parsed.kind) {
|
|
377
|
+
case 'npm':
|
|
378
|
+
return `npm 包 ${parsed.name}`;
|
|
379
|
+
case 'github-repo':
|
|
380
|
+
return `GitHub 仓库 ${parsed.owner}/${parsed.repo}${parsed.ref ? `@${parsed.ref}` : ''}`;
|
|
381
|
+
case 'github-release':
|
|
382
|
+
return `GitHub Release ${parsed.owner}/${parsed.repo}@${parsed.tag}`;
|
|
383
|
+
default:
|
|
384
|
+
return '直链';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 清理旧 inbox(避免堆积),失败不影响主流程。 */
|
|
389
|
+
function pruneSafe(home) {
|
|
390
|
+
try {
|
|
391
|
+
// 动态导入避免顶层循环依赖
|
|
392
|
+
ensureDir(path.join(home, 'plugin-pack', 'inbox'));
|
|
393
|
+
} catch {
|
|
394
|
+
/* 忽略 */
|
|
395
|
+
}
|
|
396
|
+
}
|
package/lib/server.js
CHANGED
|
@@ -29,17 +29,8 @@ 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
31
|
import { parseMultipart, boundaryOf, saveIncomingZip, saveIncomingDir, pruneInbox } from './inbox.js';
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
listJobs,
|
|
35
|
-
getJob,
|
|
36
|
-
runningJobs,
|
|
37
|
-
clearFinished,
|
|
38
|
-
openWaiting,
|
|
39
|
-
getWaiting,
|
|
40
|
-
listWaiting,
|
|
41
|
-
cancelWaiting,
|
|
42
|
-
} from './jobs.js';
|
|
32
|
+
import { runJob, listJobs, getJob, runningJobs, clearFinished, openWaiting, getWaiting, listWaiting, cancelWaiting } from './jobs.js';
|
|
33
|
+
import { fetchBundleFrom, parseSource } from './fetch-bundle.js';
|
|
43
34
|
import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName as resolveProfileName, humanBytes } from './home.js';
|
|
44
35
|
import { readJson, writeJson, isDir, isFile } from './util.js';
|
|
45
36
|
|
|
@@ -99,10 +90,12 @@ function statusOf(error) {
|
|
|
99
90
|
code === 'BAD_MANIFEST' ||
|
|
100
91
|
code === 'BAD_FORMAT' ||
|
|
101
92
|
code === 'BAD_BACKUP' ||
|
|
102
|
-
// "这不是整合包" 归为 400
|
|
93
|
+
// "这不是整合包" 归为 400:是用户拖错文件/贴错链接,不是服务端故障
|
|
103
94
|
code === 'NOT_A_ZIP' ||
|
|
104
95
|
code === 'NOT_A_BUNDLE' ||
|
|
105
|
-
code === 'BAD_PLUGIN'
|
|
96
|
+
code === 'BAD_PLUGIN' ||
|
|
97
|
+
code === 'BAD_SOURCE' ||
|
|
98
|
+
code === 'BAD_UPLOAD'
|
|
106
99
|
) {
|
|
107
100
|
return 400;
|
|
108
101
|
}
|
|
@@ -770,6 +763,51 @@ export function mountRoutes(ctx, config = {}) {
|
|
|
770
763
|
'plugin-pack: upload-zip',
|
|
771
764
|
);
|
|
772
765
|
|
|
766
|
+
/* 从网络来源拉整合包:
|
|
767
|
+
* POST /plugin-pack/fetch-bundle { source, dryRun }
|
|
768
|
+
*
|
|
769
|
+
* source 可以是 GitHub 仓库/Release/raw 直链,也可以是 npm 包名。
|
|
770
|
+
* 拉完会校验"它真的是整合包"——不是就报 NOT_A_BUNDLE,不会往下走。
|
|
771
|
+
*/
|
|
772
|
+
register(
|
|
773
|
+
{
|
|
774
|
+
kind: 'exact',
|
|
775
|
+
path: `${PREFIX}/fetch-bundle`,
|
|
776
|
+
handler: async (request, response) => {
|
|
777
|
+
if (request.method !== 'POST') {
|
|
778
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const body = await readJsonBody(request);
|
|
782
|
+
const source = String(body.source ?? '').trim();
|
|
783
|
+
if (!source) {
|
|
784
|
+
sendJson(response, 400, { ok: false, error: '需要 source(仓库地址 / 直链 / npm 包名)' });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const local = contextOf(config);
|
|
788
|
+
|
|
789
|
+
const doFetch = (report) =>
|
|
790
|
+
fetchBundleFrom({ source, home: local.home, onProgress: report });
|
|
791
|
+
|
|
792
|
+
if (body.dryRun) {
|
|
793
|
+
// dryRun 只解析来源、不下载(省流量,也让界面先给出"看起来对不对")
|
|
794
|
+
const parsed = parseSource(source);
|
|
795
|
+
sendJson(response, 200, { ok: true, dryRun: true, parsed, source });
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const { id, result } = await runJob({
|
|
800
|
+
kind: 'fetch',
|
|
801
|
+
title: `从网络安装:${source}`,
|
|
802
|
+
meta: { source },
|
|
803
|
+
task: (report) => doFetch(report),
|
|
804
|
+
});
|
|
805
|
+
sendJson(response, 200, { ok: true, jobId: id, ...result });
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
'plugin-pack: fetch-bundle',
|
|
809
|
+
);
|
|
810
|
+
|
|
773
811
|
register(
|
|
774
812
|
{
|
|
775
813
|
kind: 'exact',
|