dsh-plugin-pack 1.0.4 → 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 +405 -6
- package/lib/fetch-bundle.js +396 -0
- package/lib/server.js +51 -13
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -108,6 +108,16 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
108
108
|
'import.source.bundle': '整合包',
|
|
109
109
|
'import.source.plugin': '本地插件',
|
|
110
110
|
'import.notBundle': 'zip 不是整合包',
|
|
111
|
+
'import.reading': '正在读取包内容…',
|
|
112
|
+
'import.uploading': '正在上传…',
|
|
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': '下载到的不是整合包',
|
|
111
121
|
'drop.title': '把 zip / tgz 或插件文件夹拖到这里',
|
|
112
122
|
'drop.release': '松手就导入',
|
|
113
123
|
'drop.running': '正在导入…',
|
|
@@ -146,6 +156,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
146
156
|
'jobs.kindImport': '导入整合包',
|
|
147
157
|
'jobs.kindImportPlugin': '导入插件',
|
|
148
158
|
'jobs.kindRollback': '回滚',
|
|
159
|
+
'jobs.kindFetch': '网络安装',
|
|
149
160
|
'jobs.running': '进行中',
|
|
150
161
|
'jobs.done': '已完成',
|
|
151
162
|
'jobs.failed': '失败',
|
|
@@ -266,6 +277,16 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
266
277
|
'import.source.bundle': 'Bundle',
|
|
267
278
|
'import.source.plugin': 'Local plugin',
|
|
268
279
|
'import.notBundle': 'This zip is not a bundle',
|
|
280
|
+
'import.reading': 'Reading package contents…',
|
|
281
|
+
'import.uploading': 'Uploading…',
|
|
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',
|
|
269
290
|
'drop.title': 'Drop a zip / tgz or plugin folder here',
|
|
270
291
|
'drop.release': 'Release to import',
|
|
271
292
|
'drop.running': 'Importing…',
|
|
@@ -303,6 +324,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
303
324
|
'jobs.kindImport': 'Import bundle',
|
|
304
325
|
'jobs.kindImportPlugin': 'Import plugin',
|
|
305
326
|
'jobs.kindRollback': 'Rollback',
|
|
327
|
+
'jobs.kindFetch': 'Network install',
|
|
306
328
|
'jobs.running': 'Running',
|
|
307
329
|
'jobs.done': 'Done',
|
|
308
330
|
'jobs.failed': 'Failed',
|
|
@@ -487,6 +509,137 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
487
509
|
const getWaiting = () => api('/plugin-pack/waiting');
|
|
488
510
|
const cancelWaiting = (id, reason) =>
|
|
489
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) });
|
|
514
|
+
|
|
515
|
+
/* ------------------------- 浏览器端读 zip 头部 ------------------------- */
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* 只读 zip 的中央目录,取出条目名(不解压内容)。
|
|
519
|
+
*
|
|
520
|
+
* 为什么在浏览器里做:拖入一个几百 MB 的包,原来的流程是
|
|
521
|
+
* 「整个文件 POST 上去 → 服务端 dryRun → 才弹冲突窗」,
|
|
522
|
+
* 用户要等上传完才看到"哪些要覆盖"。
|
|
523
|
+
*
|
|
524
|
+
* zip 的中央目录在文件尾部(最后 22 字节是 EOCD,里面记着目录偏移),
|
|
525
|
+
* 所以只读尾部 ~1 MB 就能得到全部条目名,不用传整个文件。
|
|
526
|
+
* 这样拖进来几百毫秒就能弹出冲突列表,点确认之后才真正上传。
|
|
527
|
+
*
|
|
528
|
+
* @param {File} file
|
|
529
|
+
* @returns {Promise<{ names: string[], manifest: any | null } | null>}
|
|
530
|
+
* 不是有效 zip 时返回 null(交给服务端给出正式错误)
|
|
531
|
+
*/
|
|
532
|
+
async function peekZip(file) {
|
|
533
|
+
try {
|
|
534
|
+
const size = file.size;
|
|
535
|
+
if (size < 22) return null;
|
|
536
|
+
|
|
537
|
+
// 1) 读尾部,找 EOCD
|
|
538
|
+
const tailLen = Math.min(size, 2 * 1024 * 1024);
|
|
539
|
+
const tail = new Uint8Array(await file.slice(size - tailLen).arrayBuffer());
|
|
540
|
+
let eocd = -1;
|
|
541
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
542
|
+
if (tail[i] === 0x50 && tail[i + 1] === 0x4b && tail[i + 2] === 0x05 && tail[i + 3] === 0x06) {
|
|
543
|
+
eocd = i;
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (eocd < 0) return null;
|
|
548
|
+
|
|
549
|
+
const total = tail[eocd + 10] | (tail[eocd + 11] << 8);
|
|
550
|
+
const cdSize = tail[eocd + 12] | (tail[eocd + 13] << 8) | (tail[eocd + 14] << 16) | (tail[eocd + 15] << 24);
|
|
551
|
+
const cdOffset = tail[eocd + 16] | (tail[eocd + 17] << 8) | (tail[eocd + 18] << 16) | (tail[eocd + 19] << 24);
|
|
552
|
+
|
|
553
|
+
// 2) 按需读中央目录那一段
|
|
554
|
+
let cd = tail;
|
|
555
|
+
if (cdOffset < size - tailLen) {
|
|
556
|
+
cd = new Uint8Array(await file.slice(cdOffset, cdOffset + cdSize).arrayBuffer());
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const names = [];
|
|
560
|
+
let p = 0;
|
|
561
|
+
const base = cdOffset < size - tailLen ? 0 : cdOffset - (size - tailLen);
|
|
562
|
+
p = base;
|
|
563
|
+
while (p + 46 <= cd.length && names.length < total) {
|
|
564
|
+
if (!(cd[p] === 0x50 && cd[p + 1] === 0x4b && cd[p + 2] === 0x01 && cd[p + 3] === 0x02)) break;
|
|
565
|
+
const nameLen = cd[p + 28] | (cd[p + 29] << 8);
|
|
566
|
+
const extraLen = cd[p + 30] | (cd[p + 31] << 8);
|
|
567
|
+
const commentLen = cd[p + 32] | (cd[p + 33] << 8);
|
|
568
|
+
const raw = cd.subarray(p + 46, p + 46 + nameLen);
|
|
569
|
+
let name = '';
|
|
570
|
+
for (let k = 0; k < raw.length; k += 1) name += String.fromCharCode(raw[k]);
|
|
571
|
+
try {
|
|
572
|
+
name = decodeURIComponent(escape(name));
|
|
573
|
+
} catch {
|
|
574
|
+
/* 保持原样 */
|
|
575
|
+
}
|
|
576
|
+
names.push(name.replace(/\\/g, '/'));
|
|
577
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
578
|
+
}
|
|
579
|
+
if (!names.length) return null;
|
|
580
|
+
|
|
581
|
+
// 3) 有 manifest.json 才解压它(其余一律不解压)
|
|
582
|
+
let manifest = null;
|
|
583
|
+
const manifestEntry = names.find((n) => n === 'manifest.json');
|
|
584
|
+
if (manifestEntry) {
|
|
585
|
+
manifest = await readZipEntryAsJson(file, cd, base, manifestEntry, cdOffset < size - tailLen);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return { names, manifest };
|
|
589
|
+
} catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** 从中央目录找到某条目,解压它并 JSON.parse。失败返回 null。 */
|
|
595
|
+
async function readZipEntryAsJson(file, cd, base, targetName, cdIsSeparate) {
|
|
596
|
+
try {
|
|
597
|
+
let p = base;
|
|
598
|
+
let found = null;
|
|
599
|
+
while (p + 46 <= cd.length) {
|
|
600
|
+
if (!(cd[p] === 0x50 && cd[p + 1] === 0x4b && cd[p + 2] === 0x01 && cd[p + 3] === 0x02)) break;
|
|
601
|
+
const method = cd[p + 10] | (cd[p + 11] << 8);
|
|
602
|
+
const compSize = cd[p + 20] | (cd[p + 21] << 8) | (cd[p + 22] << 16) | (cd[p + 23] << 24);
|
|
603
|
+
const nameLen = cd[p + 28] | (cd[p + 29] << 8);
|
|
604
|
+
const extraLen = cd[p + 30] | (cd[p + 31] << 8);
|
|
605
|
+
const commentLen = cd[p + 32] | (cd[p + 33] << 8);
|
|
606
|
+
const localOffset = cd[p + 42] | (cd[p + 43] << 8) | (cd[p + 44] << 16) | (cd[p + 45] << 24);
|
|
607
|
+
const raw = cd.subarray(p + 46, p + 46 + nameLen);
|
|
608
|
+
let name = '';
|
|
609
|
+
for (let k = 0; k < raw.length; k += 1) name += String.fromCharCode(raw[k]);
|
|
610
|
+
try {
|
|
611
|
+
name = decodeURIComponent(escape(name));
|
|
612
|
+
} catch {
|
|
613
|
+
/* 保持原样 */
|
|
614
|
+
}
|
|
615
|
+
if (name.replace(/\\/g, '/') === targetName) {
|
|
616
|
+
found = { method, compSize, localOffset };
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
620
|
+
}
|
|
621
|
+
if (!found) return null;
|
|
622
|
+
|
|
623
|
+
// 读本地头拿到文件名/extra 的实际长度
|
|
624
|
+
const local = new Uint8Array(await file.slice(found.localOffset, found.localOffset + 30).arrayBuffer());
|
|
625
|
+
const lNameLen = local[26] | (local[27] << 8);
|
|
626
|
+
const lExtraLen = local[28] | (local[29] << 8);
|
|
627
|
+
const dataStart = found.localOffset + 30 + lNameLen + lExtraLen;
|
|
628
|
+
const raw = new Uint8Array(await file.slice(dataStart, dataStart + found.compSize).arrayBuffer());
|
|
629
|
+
|
|
630
|
+
let bytes = raw;
|
|
631
|
+
if (found.method === 8) {
|
|
632
|
+
// deflate 解压用浏览器内置的 DecompressionStream
|
|
633
|
+
const stream = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
|
634
|
+
const buf = await new Response(stream).arrayBuffer();
|
|
635
|
+
bytes = new Uint8Array(buf);
|
|
636
|
+
}
|
|
637
|
+
const text = new TextDecoder('utf-8').decode(bytes);
|
|
638
|
+
return JSON.parse(text);
|
|
639
|
+
} catch {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
490
643
|
|
|
491
644
|
/** 上传一个 zip,返回宿主落盘后的绝对路径。 */
|
|
492
645
|
async function uploadZip(file) {
|
|
@@ -1042,7 +1195,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1042
1195
|
* @param {boolean} [props.busy]
|
|
1043
1196
|
* @param {string} [props.hint]
|
|
1044
1197
|
*/
|
|
1045
|
-
function DropZone({ onFile, busy = false, hint, dropRef }) {
|
|
1198
|
+
function DropZone({ onFile, busy = false, hint, dropRef, phase }) {
|
|
1046
1199
|
const [over, setOver] = useState(false);
|
|
1047
1200
|
const [hover, setHover] = useState(false);
|
|
1048
1201
|
const inputRef = useRef(null);
|
|
@@ -1099,7 +1252,11 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1099
1252
|
h(
|
|
1100
1253
|
'div',
|
|
1101
1254
|
{ style: { fontSize: 13, fontWeight: 500 } },
|
|
1102
|
-
busy
|
|
1255
|
+
busy
|
|
1256
|
+
? phase || translate('drop.running')
|
|
1257
|
+
: over
|
|
1258
|
+
? translate('drop.release')
|
|
1259
|
+
: translate('drop.title'),
|
|
1103
1260
|
),
|
|
1104
1261
|
h('div', { className: 'pp-hint', style: { marginTop: 4 } }, hint ?? translate('drop.hint')),
|
|
1105
1262
|
h('input', {
|
|
@@ -1299,6 +1456,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1299
1456
|
import: t('jobs.kindImport'),
|
|
1300
1457
|
'import-plugin': t('jobs.kindImportPlugin'),
|
|
1301
1458
|
rollback: t('jobs.kindRollback'),
|
|
1459
|
+
fetch: t('jobs.kindFetch'),
|
|
1302
1460
|
})[kind] ?? kind;
|
|
1303
1461
|
|
|
1304
1462
|
const statusColor = (status) =>
|
|
@@ -1447,6 +1605,12 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1447
1605
|
const [pendingImport, setPendingImport] = useState(null);
|
|
1448
1606
|
/** 当前弹窗对应的服务端任务 id(取消时要通知服务端)。 */
|
|
1449
1607
|
const pendingJobId = useRef(null);
|
|
1608
|
+
/** 导入进度阶段文案(读取中 / 上传中 / 检查冲突…),空串表示没在跑。 */
|
|
1609
|
+
const [phase, setPhase] = useState('');
|
|
1610
|
+
/** 网络安装:地址输入框的内容。 */
|
|
1611
|
+
const [netSource, setNetSource] = useState('');
|
|
1612
|
+
/** 网络安装的结果/错误。 */
|
|
1613
|
+
const [netResult, setNetResult] = useState(null);
|
|
1450
1614
|
/** 后台任务列表:切走画面回来靠它恢复 */
|
|
1451
1615
|
const [jobs, setJobs] = useState([]);
|
|
1452
1616
|
/** 体积上限(MiB),0 = 不限制。默认跟着服务端配置走。 */
|
|
@@ -1620,10 +1784,66 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1620
1784
|
// 两者都走"上传 → 导入",具体分流由服务端按扩展名决定。
|
|
1621
1785
|
const isArchive = /\.(zip|tgz|tar\.gz)$/i.test(name);
|
|
1622
1786
|
if (isArchive) {
|
|
1787
|
+
/* 第一步:先在浏览器里只读 zip 尾部,几百毫秒就知道包里有哪些插件、
|
|
1788
|
+
* 哪些跟已装的重名 —— 不用等整个文件传完。
|
|
1789
|
+
* 用户把几百 MB 的包拖进来时,这个差别是"秒弹"和"等半分钟"的区别。 */
|
|
1790
|
+
const isZip = /\.zip$/i.test(name);
|
|
1791
|
+
let quick = null;
|
|
1792
|
+
if (isZip) {
|
|
1793
|
+
setPhase(t('import.reading'));
|
|
1794
|
+
quick = await peekZip(file);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
if (quick && Array.isArray(quick.names) && quick.names.length) {
|
|
1798
|
+
const installed = new Map((status?.plugins ?? []).map((plugin) => [plugin.name, plugin]));
|
|
1799
|
+
const packPlugins = (quick.manifest?.plugins ?? []).filter(Boolean);
|
|
1800
|
+
const conflicts = packPlugins
|
|
1801
|
+
.filter((plugin) => installed.has(plugin.name))
|
|
1802
|
+
.map((plugin) => ({
|
|
1803
|
+
name: plugin.name,
|
|
1804
|
+
version: plugin.version ?? null,
|
|
1805
|
+
installedVersion: installed.get(plugin.name)?.version ?? null,
|
|
1806
|
+
dest: installed.get(plugin.name)?.dir ?? null,
|
|
1807
|
+
bytes: plugin.bytes ?? null,
|
|
1808
|
+
sameVersion: (installed.get(plugin.name)?.version ?? null) === (plugin.version ?? null),
|
|
1809
|
+
}));
|
|
1810
|
+
|
|
1811
|
+
// 不是整合包就直接报,省得白传一遍
|
|
1812
|
+
const looksLikeBundle = Boolean(quick.manifest) && String(quick.manifest?.format ?? '').startsWith('dsh-plugin-pack@');
|
|
1813
|
+
if (!looksLikeBundle) {
|
|
1814
|
+
setImportResult({
|
|
1815
|
+
error: `zip 不是整合包:这个 zip 里没有 dsh-plugin-pack 的 manifest.json(共 ${quick.names.length} 个文件)。它可能是普通的压缩包,或者是别的工具打的包。`,
|
|
1816
|
+
code: 'NOT_A_BUNDLE',
|
|
1817
|
+
});
|
|
1818
|
+
setBusy(false);
|
|
1819
|
+
setPhase('');
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
if (conflicts.length) {
|
|
1824
|
+
/* 先把冲突列表给出来,用户选完才上传 + 真导入。
|
|
1825
|
+
* 这时还没上传,所以待决策任务在服务端也不存在 —— 全靠确认时再登记。 */
|
|
1826
|
+
setPendingImport({
|
|
1827
|
+
kind: 'bundle',
|
|
1828
|
+
pendingFile: file, // 还没上传,先拿着 File 对象
|
|
1829
|
+
file: null,
|
|
1830
|
+
conflicts,
|
|
1831
|
+
plugins: packPlugins,
|
|
1832
|
+
quick: true,
|
|
1833
|
+
});
|
|
1834
|
+
setBusy(false);
|
|
1835
|
+
setPhase('');
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// 第二步:确认无冲突(或拿不到快速清单)才上传
|
|
1841
|
+
setPhase(t('import.uploading'));
|
|
1623
1842
|
const uploaded = await uploadZip(file);
|
|
1624
1843
|
setZipPath(uploaded.file);
|
|
1625
1844
|
|
|
1626
1845
|
// 先 dryRun 探一次:有重名冲突就弹窗让用户决定
|
|
1846
|
+
setPhase(t('import.checking'));
|
|
1627
1847
|
const preview = await doImport({ file: uploaded.file, dryRun: true });
|
|
1628
1848
|
const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
|
|
1629
1849
|
if (conflicts.length) {
|
|
@@ -1637,6 +1857,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1637
1857
|
plugins: preview.plugins ?? [],
|
|
1638
1858
|
});
|
|
1639
1859
|
setBusy(false);
|
|
1860
|
+
setPhase('');
|
|
1640
1861
|
return;
|
|
1641
1862
|
}
|
|
1642
1863
|
|
|
@@ -1668,37 +1889,155 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1668
1889
|
if (/\.(zip|tgz|tar\.gz)$/i.test(String(file?.name ?? ''))) setImportResult({ error: message, code });
|
|
1669
1890
|
else setImportPluginResult({ error: message, code });
|
|
1670
1891
|
} finally {
|
|
1892
|
+
setPhase('');
|
|
1671
1893
|
setBusy(false);
|
|
1672
1894
|
lastDrop.dataTransfer = null;
|
|
1673
1895
|
}
|
|
1674
1896
|
};
|
|
1675
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
|
+
|
|
1676
1998
|
/** 用户在冲突弹窗里做了选择:带着 decisions 真正导入。 */
|
|
1677
1999
|
const confirmImport = async (decisions) => {
|
|
1678
2000
|
const pending = pendingImport;
|
|
1679
2001
|
if (!pending) return;
|
|
1680
|
-
const jobId = pending.jobId ?? pendingJobId.current;
|
|
2002
|
+
const jobId = pending.jobId ?? pendingJobId.current ?? null;
|
|
1681
2003
|
// 明确了选择,就别让卸载钩子把它当"未决策"取消掉
|
|
1682
2004
|
pendingJobId.current = null;
|
|
1683
2005
|
setPendingImport(null);
|
|
1684
2006
|
setBusy(true);
|
|
1685
2007
|
setImportResult(null);
|
|
1686
2008
|
try {
|
|
2009
|
+
/* quick 模式:拖进来时只读了 zip 尾部就弹了冲突窗,文件还没上传。
|
|
2010
|
+
* 现在用户确认了,才真正把文件传上去。 */
|
|
2011
|
+
let file = pending.file;
|
|
2012
|
+
if (!file && pending.pendingFile) {
|
|
2013
|
+
setPhase(t('import.uploading'));
|
|
2014
|
+
const uploaded = await uploadZip(pending.pendingFile);
|
|
2015
|
+
setZipPath(uploaded.file);
|
|
2016
|
+
file = uploaded.file;
|
|
2017
|
+
setPhase(t('import.checking'));
|
|
2018
|
+
// 服务端 dryRun 一次,把待决策任务登记上(这样切走也能取消)
|
|
2019
|
+
const preview = await doImport({ file, dryRun: true });
|
|
2020
|
+
pendingJobId.current = preview?.jobId ?? null;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
setPhase('');
|
|
1687
2024
|
const result = await doImport({
|
|
1688
|
-
file
|
|
2025
|
+
file,
|
|
1689
2026
|
dryRun: false,
|
|
1690
2027
|
restoreData: true,
|
|
1691
2028
|
linkNodeModules: true,
|
|
1692
2029
|
decisions,
|
|
1693
2030
|
// 复用同一个任务记录,界面上是同一条从"待决策"变成"完成"
|
|
1694
|
-
...(jobId ? { jobId } : {}),
|
|
2031
|
+
...(jobId || pendingJobId.current ? { jobId: jobId ?? pendingJobId.current } : {}),
|
|
1695
2032
|
});
|
|
2033
|
+
pendingJobId.current = null;
|
|
1696
2034
|
setImportResult(result);
|
|
1697
2035
|
await load();
|
|
1698
2036
|
await refreshJobs();
|
|
1699
2037
|
} catch (err) {
|
|
1700
2038
|
setImportResult({ error: String(err?.message ?? err) });
|
|
1701
2039
|
} finally {
|
|
2040
|
+
setPhase('');
|
|
1702
2041
|
setBusy(false);
|
|
1703
2042
|
}
|
|
1704
2043
|
};
|
|
@@ -2003,7 +2342,67 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
2003
2342
|
const importPage = h(
|
|
2004
2343
|
'div',
|
|
2005
2344
|
null,
|
|
2006
|
-
h(DropZone, { onFile: runDrop, busy, dropRef: lastDrop }),
|
|
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
|
+
|
|
2007
2406
|
// 后台任务:切走画面回来能看到刚才那次导入的结果
|
|
2008
2407
|
h(JobList, { jobs, onClear: clearJobHistory, t }),
|
|
2009
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',
|