dsh-plugin-pack 1.0.5 → 1.0.7

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 CHANGED
@@ -111,6 +111,26 @@ 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': '例如 dsh-plugin-pack 或 https://github.com/用户/仓库',
118
+ 'net.go': '下载并安装',
119
+ 'net.running': '正在处理…',
120
+ 'net.notBundle': '下载到的不是整合包',
121
+ 'net.button': '添加整合包',
122
+ 'net.dialogTitle': '添加整合包',
123
+ 'net.install': '下载并安装',
124
+ 'net.close': '关闭',
125
+ 'net.examples': '来源示例',
126
+ 'net.ex1': '下载仓库归档',
127
+ 'net.ex2': '取该 Release 里的 zip 附件',
128
+ 'net.ex3': '任意直链',
129
+ 'net.ex4': 'npm 包(单插件)',
130
+ 'net.warnTitle': '安装前请确认来源可信',
131
+ 'net.warnBody': '整合包会覆盖已装插件的文件与配置,来源不明的包可能损坏 DeepSeek Harness。安装前会先列出重名插件,由你逐个决定覆盖还是跳过。',
132
+ 'net.done': '下载完成,已导入',
133
+ 'import.pageTitle': '导入插件整合包',
114
134
  'drop.title': '把 zip / tgz 或插件文件夹拖到这里',
115
135
  'drop.release': '松手就导入',
116
136
  'drop.running': '正在导入…',
@@ -149,6 +169,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
149
169
  'jobs.kindImport': '导入整合包',
150
170
  'jobs.kindImportPlugin': '导入插件',
151
171
  'jobs.kindRollback': '回滚',
172
+ 'jobs.kindFetch': '网络安装',
152
173
  'jobs.running': '进行中',
153
174
  'jobs.done': '已完成',
154
175
  'jobs.failed': '失败',
@@ -272,6 +293,26 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
272
293
  'import.reading': 'Reading package contents…',
273
294
  'import.uploading': 'Uploading…',
274
295
  'import.checking': 'Checking for conflicts…',
296
+ 'import.downloading': 'Downloading…',
297
+ 'net.title': 'Install from network',
298
+ 'net.desc': 'Enter a bundle source: GitHub repo / release / direct link, or an npm package name. We verify it is a bundle after download.',
299
+ 'net.placeholder': 'e.g. dsh-plugin-pack or https://github.com/user/repo',
300
+ 'net.go': 'Download and install',
301
+ 'net.running': 'Working…',
302
+ 'net.notBundle': 'Downloaded file is not a bundle',
303
+ 'net.button': 'Add bundle',
304
+ 'net.dialogTitle': 'Add bundle',
305
+ 'net.install': 'Download and install',
306
+ 'net.close': 'Close',
307
+ 'net.examples': 'Source examples',
308
+ 'net.ex1': 'download repo archive',
309
+ 'net.ex2': 'pick the zip asset of that release',
310
+ 'net.ex3': 'any direct link',
311
+ 'net.ex4': 'npm package (single plugin)',
312
+ 'net.warnTitle': 'Verify the source before installing',
313
+ '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
+ 'net.done': 'Downloaded and imported',
315
+ 'import.pageTitle': 'Import plugin bundle',
275
316
  'drop.title': 'Drop a zip / tgz or plugin folder here',
276
317
  'drop.release': 'Release to import',
277
318
  'drop.running': 'Importing…',
@@ -309,6 +350,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
309
350
  'jobs.kindImport': 'Import bundle',
310
351
  'jobs.kindImportPlugin': 'Import plugin',
311
352
  'jobs.kindRollback': 'Rollback',
353
+ 'jobs.kindFetch': 'Network install',
312
354
  'jobs.running': 'Running',
313
355
  'jobs.done': 'Done',
314
356
  'jobs.failed': 'Failed',
@@ -493,6 +535,8 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
493
535
  const getWaiting = () => api('/plugin-pack/waiting');
494
536
  const cancelWaiting = (id, reason) =>
495
537
  api('/plugin-pack/waiting/cancel', { method: 'POST', body: JSON.stringify({ id, reason }) });
538
+ /** 从网络来源(GitHub / npm / 直链)拉整合包。 */
539
+ const fetchBundle = (body) => api('/plugin-pack/fetch-bundle', { method: 'POST', body: JSON.stringify(body) });
496
540
 
497
541
  /* ------------------------- 浏览器端读 zip 头部 ------------------------- */
498
542
 
@@ -1269,6 +1313,191 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1269
1313
  * @param {() => void} props.onCancel
1270
1314
  * @param {(msg: string) => string} props.t
1271
1315
  */
1316
+ /**
1317
+ * 「添加整合包」弹窗。
1318
+ *
1319
+ * 布局对齐 DSH 自带的「添加插件」:
1320
+ * 标题 → 说明 → 输入框 → 折叠的示例 → 提示块 → 大按钮
1321
+ *
1322
+ * 下载后会校验是不是整合包;不是就当场用醒目样式报出来,
1323
+ * 不会让你以为"装上了"结果其实是普通压缩包。
1324
+ *
1325
+ * @param {object} props
1326
+ * @param {boolean} props.open
1327
+ * @param {string} props.value 输入框内容
1328
+ * @param {(v: string) => void} props.onChange
1329
+ * @param {object|null} props.result 上一次下载的结果或错误
1330
+ * @param {boolean} props.busy
1331
+ * @param {string} props.phase 进度文案
1332
+ * @param {() => void} props.onInstall
1333
+ * @param {() => void} props.onClose
1334
+ * @param {(key: string) => string} props.t
1335
+ */
1336
+ function AddBundleDialog({ open, value, onChange, result, busy, phase, onInstall, onClose, t }) {
1337
+ const [showExamples, setShowExamples] = useState(false);
1338
+
1339
+ // Esc 关闭
1340
+ useEffect(() => {
1341
+ if (!open) return;
1342
+ const onKey = (event) => {
1343
+ if (event.key === 'Escape' && !busy) onClose();
1344
+ };
1345
+ window.addEventListener('keydown', onKey);
1346
+ return () => window.removeEventListener('keydown', onKey);
1347
+ }, [open, busy, onClose]);
1348
+
1349
+ if (!open) return null;
1350
+
1351
+ const isNotBundle =
1352
+ ['NOT_A_BUNDLE', 'NOT_A_ZIP', 'BAD_FORMAT', 'BAD_MANIFEST', 'BAD_PACK'].includes(String(result?.code ?? '')) ||
1353
+ /不是整合包|没有 manifest|不是插件包/i.test(String(result?.error ?? ''));
1354
+
1355
+ return h(
1356
+ 'div',
1357
+ {
1358
+ style: {
1359
+ position: 'fixed',
1360
+ inset: 0,
1361
+ zIndex: 10070,
1362
+ background: 'rgba(0,0,0,.45)',
1363
+ display: 'flex',
1364
+ alignItems: 'center',
1365
+ justifyContent: 'center',
1366
+ padding: 16,
1367
+ },
1368
+ // 点遮罩关闭(跑的时候不关,避免误触中断)
1369
+ onClick: () => !busy && onClose(),
1370
+ },
1371
+ h(
1372
+ 'div',
1373
+ {
1374
+ style: {
1375
+ width: 'min(560px, 100%)',
1376
+ background: 'var(--dsw-alias-bg-layer-1,#fff)',
1377
+ borderRadius: 14,
1378
+ padding: '20px 20px 16px',
1379
+ boxShadow: '0 12px 40px rgba(0,0,0,.22)',
1380
+ border: '.5px solid var(--dsw-alias-border-l1,#e6e6e6)',
1381
+ },
1382
+ onClick: (event) => event.stopPropagation(),
1383
+ },
1384
+ // 标题行 + 关闭
1385
+ h(
1386
+ 'div',
1387
+ { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 } },
1388
+ h('h2', { style: { margin: 0, fontSize: 17, fontWeight: 600, flex: 1 } }, t('net.dialogTitle')),
1389
+ h(
1390
+ 'button',
1391
+ {
1392
+ className: 'pp-btn sm',
1393
+ style: { flex: 'none', padding: '2px 8px' },
1394
+ disabled: busy,
1395
+ onClick: onClose,
1396
+ title: t('net.close'),
1397
+ },
1398
+ '✕',
1399
+ ),
1400
+ ),
1401
+ h('p', { className: 'pp-hint', style: { margin: '0 0 12px' } }, t('net.desc')),
1402
+
1403
+ // 输入框(自动聚焦,回车即安装)
1404
+ h('input', {
1405
+ className: 'pp-input pp-mono',
1406
+ type: 'text',
1407
+ autoFocus: true,
1408
+ value,
1409
+ placeholder: t('net.placeholder'),
1410
+ disabled: busy,
1411
+ onInput: (event) => onChange(event.target.value),
1412
+ onKeyDown: (event) => {
1413
+ if (event.key === 'Enter' && !busy && value.trim()) onInstall();
1414
+ },
1415
+ style: { width: '100%', boxSizing: 'border-box', fontSize: 12.5, padding: '9px 11px' },
1416
+ }),
1417
+
1418
+ // 折叠的示例(对齐自带弹窗的「安装引导和示例」)
1419
+ h(
1420
+ 'div',
1421
+ { style: { margin: '10px 0 0' } },
1422
+ h(
1423
+ 'button',
1424
+ {
1425
+ className: 'pp-btn sm',
1426
+ style: { padding: '2px 0', background: 'none', border: 'none', color: 'var(--dsw-alias-label-secondary,#6b7280)' },
1427
+ onClick: () => setShowExamples((v) => !v),
1428
+ },
1429
+ `${showExamples ? '▾' : '▸'} ${t('net.examples')}`,
1430
+ ),
1431
+ showExamples
1432
+ ? h(
1433
+ 'div',
1434
+ { className: 'pp-hint', style: { marginTop: 6, lineHeight: 1.9 } },
1435
+ h('div', null, h('code', null, 'https://github.com/用户/仓库'), ` ${t('net.ex1')}`),
1436
+ h('div', null, h('code', null, 'https://github.com/用户/仓库/releases/tag/v1.0.0'), ` ${t('net.ex2')}`),
1437
+ h('div', null, h('code', null, 'https://example.com/backup.zip'), ` ${t('net.ex3')}`),
1438
+ h('div', null, h('code', null, 'dsh-plugin-pack'), ` ${t('net.ex4')}`),
1439
+ )
1440
+ : null,
1441
+ ),
1442
+
1443
+ // 提示块(对齐自带的黄色提示)
1444
+ h(
1445
+ 'div',
1446
+ {
1447
+ style: {
1448
+ marginTop: 12,
1449
+ padding: '10px 12px',
1450
+ borderRadius: 10,
1451
+ background: 'color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 8%,transparent)',
1452
+ border: '.5px solid color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 35%,transparent)',
1453
+ fontSize: 12,
1454
+ lineHeight: 1.7,
1455
+ color: 'var(--dsw-alias-label-secondary,#6b7280)',
1456
+ },
1457
+ },
1458
+ h('div', { style: { fontWeight: 600, marginBottom: 2 } }, `⚠ ${t('net.warnTitle')}`),
1459
+ h('div', null, t('net.warnBody')),
1460
+ ),
1461
+
1462
+ // 结果区:成功 / 不是整合包 / 其它错误
1463
+ result
1464
+ ? h(
1465
+ 'div',
1466
+ { style: { marginTop: 12 } },
1467
+ result.error
1468
+ ? isNotBundle
1469
+ ? h(
1470
+ 'div',
1471
+ { className: 'pp-not-bundle' },
1472
+ h('div', { className: 'pp-not-bundle-title' }, t('net.notBundle')),
1473
+ h('div', { className: 'pp-not-bundle-detail' }, String(result.error)),
1474
+ )
1475
+ : h(Alert, { kind: 'err' }, String(result.error))
1476
+ : h(
1477
+ 'div',
1478
+ { className: 'pp-hint' },
1479
+ `✓ ${t('net.done')} · ${result.from ?? ''} · ${result.name ?? ''} · ${
1480
+ result.bytes ? (result.bytes / 1048576).toFixed(1) + ' MiB' : ''
1481
+ } · ${result.plugins?.length ?? 0} ${t('plugins')}`,
1482
+ ),
1483
+ )
1484
+ : null,
1485
+
1486
+ // 大按钮(对齐自带的整宽「安装」)
1487
+ h(
1488
+ 'button',
1489
+ {
1490
+ className: 'pp-btn primary',
1491
+ style: { width: '100%', marginTop: 14, padding: '10px 12px', boxSizing: 'border-box' },
1492
+ disabled: busy || !value.trim(),
1493
+ onClick: onInstall,
1494
+ },
1495
+ busy ? h(Spinner, { label: phase || t('net.running') }) : t('net.install'),
1496
+ ),
1497
+ ),
1498
+ );
1499
+ }
1500
+
1272
1501
  function ConflictDialog({ pending, onConfirm, onCancel, t }) {
1273
1502
  const conflicts = pending?.conflicts ?? [];
1274
1503
  /** 初始全部选「覆盖」——多数人是想升级到新版本。 */
@@ -1438,6 +1667,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1438
1667
  import: t('jobs.kindImport'),
1439
1668
  'import-plugin': t('jobs.kindImportPlugin'),
1440
1669
  rollback: t('jobs.kindRollback'),
1670
+ fetch: t('jobs.kindFetch'),
1441
1671
  })[kind] ?? kind;
1442
1672
 
1443
1673
  const statusColor = (status) =>
@@ -1588,6 +1818,12 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1588
1818
  const pendingJobId = useRef(null);
1589
1819
  /** 导入进度阶段文案(读取中 / 上传中 / 检查冲突…),空串表示没在跑。 */
1590
1820
  const [phase, setPhase] = useState('');
1821
+ /** 网络安装:地址输入框的内容。 */
1822
+ const [netSource, setNetSource] = useState('');
1823
+ /** 网络安装的结果/错误。 */
1824
+ const [netResult, setNetResult] = useState(null);
1825
+ /** 「添加整合包」弹窗是否打开。 */
1826
+ const [netOpen, setNetOpen] = useState(false);
1591
1827
  /** 后台任务列表:切走画面回来靠它恢复 */
1592
1828
  const [jobs, setJobs] = useState([]);
1593
1829
  /** 体积上限(MiB),0 = 不限制。默认跟着服务端配置走。 */
@@ -1872,6 +2108,101 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1872
2108
  }
1873
2109
  };
1874
2110
 
2111
+ /**
2112
+ * 从网络来源安装整合包(GitHub 仓库/Release、npm 包、任意直链)。
2113
+ *
2114
+ * 流程:下载 → 服务端校验"确实是整合包" → 有冲突先弹窗 → 确认后导入。
2115
+ * 校验不过会明确报「不是整合包」,不会静默失败。
2116
+ */
2117
+ const runNetworkInstall = async () => {
2118
+ const source = netSource.trim();
2119
+ if (!source) {
2120
+ setNetResult({ error: '请填一个地址,比如 https://github.com/用户/仓库 或 npm 包名' });
2121
+ return;
2122
+ }
2123
+ setBusy(true);
2124
+ setNetResult(null);
2125
+ setImportResult(null);
2126
+ setPhase(t('import.downloading'));
2127
+ try {
2128
+ const fetched = await fetchBundle({ source });
2129
+ if (!fetched?.ok) {
2130
+ setNetResult({ error: fetched?.error ?? '下载失败' });
2131
+ return;
2132
+ }
2133
+ setPhase(t('import.checking'));
2134
+
2135
+ /* 下载到的可能是两种:
2136
+ * kind='plugin' → npm 包 / 单插件 tgz,走 tgz 导入
2137
+ * kind='bundle' → 整合包 zip,走整合包导入(可能要先问冲突) */
2138
+ if (fetched.kind === 'plugin') {
2139
+ const result = await doImport({
2140
+ file: fetched.file,
2141
+ dryRun: false,
2142
+ mode: 'merge',
2143
+ linkNodeModules: true,
2144
+ });
2145
+ setNetResult({
2146
+ ok: true,
2147
+ kind: 'plugin',
2148
+ file: fetched.file,
2149
+ from: fetched.from,
2150
+ name: fetched.name,
2151
+ bytes: fetched.bytes,
2152
+ plugins: fetched.plugins,
2153
+ });
2154
+ setImportResult(result);
2155
+ await load();
2156
+ await refreshJobs();
2157
+ // 装完关掉弹窗,回到列表看结果
2158
+ setNetOpen(false);
2159
+ return;
2160
+ }
2161
+
2162
+ // 下载好的包按普通导入走一遍:先 dryRun 看有没有重名冲突
2163
+ const preview = await doImport({ file: fetched.file, dryRun: true });
2164
+ const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
2165
+ if (conflicts.length) {
2166
+ pendingJobId.current = preview?.jobId ?? null;
2167
+ setPendingImport({
2168
+ kind: 'bundle',
2169
+ jobId: preview?.jobId ?? null,
2170
+ file: fetched.file,
2171
+ conflicts,
2172
+ plugins: preview.plugins ?? [],
2173
+ });
2174
+ // 交给冲突弹窗接管,先关掉来源弹窗
2175
+ setNetOpen(false);
2176
+ return;
2177
+ }
2178
+
2179
+ const result = await doImport({
2180
+ file: fetched.file,
2181
+ dryRun: false,
2182
+ mode: 'merge',
2183
+ restoreData: true,
2184
+ linkNodeModules: true,
2185
+ });
2186
+ setNetResult({
2187
+ ok: true,
2188
+ file: fetched.file,
2189
+ from: fetched.from,
2190
+ name: fetched.name,
2191
+ bytes: fetched.bytes,
2192
+ plugins: fetched.plugins,
2193
+ });
2194
+ setImportResult(result);
2195
+ await load();
2196
+ await refreshJobs();
2197
+ } catch (err) {
2198
+ const code = err?.code ? String(err.code) : null;
2199
+ setNetResult({ error: String(err?.message ?? err), code });
2200
+ } finally {
2201
+ setPhase('');
2202
+ setBusy(false);
2203
+ }
2204
+ };
2205
+
1875
2206
  /** 用户在冲突弹窗里做了选择:带着 decisions 真正导入。 */
1876
2207
  const confirmImport = async (decisions) => {
1877
2208
  const pending = pendingImport;
@@ -2219,7 +2550,37 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
2219
2550
  const importPage = h(
2220
2551
  'div',
2221
2552
  null,
2553
+ /* 右上角「添加整合包」按钮 —— 和 DSH 自带的「添加插件」同位置同风格。
2554
+ * 点开是一个弹窗,在里面填地址、点安装。 */
2555
+ h(
2556
+ 'div',
2557
+ {
2558
+ style: {
2559
+ display: 'flex',
2560
+ alignItems: 'center',
2561
+ justifyContent: 'space-between',
2562
+ gap: 12,
2563
+ marginBottom: 12,
2564
+ },
2565
+ },
2566
+ h('div', { style: { fontSize: 14, fontWeight: 600 } }, t('import.pageTitle')),
2567
+ h(
2568
+ 'button',
2569
+ {
2570
+ className: 'pp-btn',
2571
+ style: { display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none' },
2572
+ onClick: () => {
2573
+ setNetResult(null);
2574
+ setNetOpen(true);
2575
+ },
2576
+ },
2577
+ h('span', { style: { fontSize: 15, lineHeight: 1 } }, '+'),
2578
+ t('net.button'),
2579
+ ),
2580
+ ),
2581
+
2222
2582
  h(DropZone, { onFile: runDrop, busy, dropRef: lastDrop, phase }),
2583
+
2223
2584
  // 后台任务:切走画面回来能看到刚才那次导入的结果
2224
2585
  h(JobList, { jobs, onClear: clearJobHistory, t }),
2225
2586
 
@@ -2510,6 +2871,19 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
2510
2871
  setOut(looksLikeFile || picked.endsWith('\\') || picked.endsWith('/') ? picked : `${picked}\\`);
2511
2872
  },
2512
2873
  }),
2874
+ /* 「添加整合包」弹窗:带来源输入 + 校验提示 + 整宽安装按钮,
2875
+ * 布局对齐 DSH 自带的「添加插件」弹窗。 */
2876
+ h(AddBundleDialog, {
2877
+ open: netOpen,
2878
+ value: netSource,
2879
+ onChange: setNetSource,
2880
+ result: netResult,
2881
+ busy,
2882
+ phase,
2883
+ onInstall: runNetworkInstall,
2884
+ onClose: () => setNetOpen(false),
2885
+ t,
2886
+ }),
2513
2887
  // 重名冲突弹窗:叠在最上层
2514
2888
  h(ConflictDialog, {
2515
2889
  pending: pendingImport,
@@ -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
- runJob,
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',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-pack",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "DeepSeek Harness 插件整合包:一键把当前 profile 的第三方插件连依赖和配置导出成 zip(内部每个插件是标准 npm tgz),换机/换 profile 拖进去就能还原。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",