dsh-plugin-pack 1.0.9 → 1.0.11

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
@@ -103,6 +103,9 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
103
103
  'import.restoreData': '还原数据目录',
104
104
  'import.link': '建立 node_modules 链接',
105
105
  'import.done': '导入完成',
106
+ 'import.written': '已写入磁盘',
107
+ 'import.whyNotListed':
108
+ '文件、依赖和配置都已经写到 profile 里了。「已安装」列表是 dsh 启动时读取的,重启后新插件才会出现在那里 —— 现在没显示不代表没装上。',
106
109
  'import.restart': '重启 dsh 后生效',
107
110
  'import.empty': '先选一个 zip 文件',
108
111
  'import.source.bundle': '整合包',
@@ -286,6 +289,9 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
286
289
  'import.restoreData': 'Restore data dirs',
287
290
  'import.link': 'Link node_modules',
288
291
  'import.done': 'Import finished',
292
+ 'import.written': 'Written to disk',
293
+ 'import.whyNotListed':
294
+ 'Files, dependencies and config are already written into the profile. The "Installed" list is read by dsh at startup, so new plugins appear there only after a restart — not showing now does not mean it failed.',
289
295
  'import.restart': 'Restart dsh to apply',
290
296
  'import.empty': 'Choose a zip first',
291
297
  'import.source.bundle': 'Bundle',
@@ -2626,8 +2632,14 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
2626
2632
  { kind: 'ok' },
2627
2633
  importResult.dryRun
2628
2634
  ? `${t('import.dry')} · ${importResult.plugins?.length ?? 0} ${t('plugins')}`
2629
- : `${t('import.done')} · ${t('import.restart')}`,
2635
+ : `${t('import.written')} · ${importResult.plugins?.length ?? 0} ${t('plugins')} · ${t('import.restart')}`,
2630
2636
  ),
2637
+ /* 说清楚为什么"已安装"列表里还看不到:
2638
+ * 那个列表是 dsh 启动时读 profile 生成的,运行中不会自己刷新。
2639
+ * 文件现在确实已经在磁盘上了 —— 不说明这点用户会以为装失败。 */
2640
+ !importResult.dryRun
2641
+ ? h('div', { className: 'pp-hint', style: { margin: '6px 0 0' } }, t('import.whyNotListed'))
2642
+ : null,
2631
2643
  importResult.plugins?.length
2632
2644
  ? h(
2633
2645
  'table',
@@ -417,8 +417,9 @@ export async function fetchBundleFrom({ source, home, onProgress = () => {} }) {
417
417
  report(`下载完成:${(data.length / 1048576).toFixed(1)} MiB`);
418
418
 
419
419
  // 落到 inbox(保留扩展名,后续按 .zip/.tgz 分流)
420
+ // origin='network':安装成功后可以自动删掉这份副本,不占用户磁盘
420
421
  pruneSafe(home);
421
- const saved = saveIncomingZip({ home, filename: name, data });
422
+ const saved = saveIncomingZip({ home, filename: name, data, origin: 'network' });
422
423
  report(`已保存到 ${saved.file}`);
423
424
 
424
425
  /* 校验阶段分两种:
@@ -16,7 +16,7 @@ import path from 'node:path';
16
16
  import { readJson, writeJson, readText, writeText, ensureDir, isDir, isFile, copyTree, removeTree } from './util.js';
17
17
  import { packSegment, stamp, humanBytes } from './home.js';
18
18
  import { existingIds, rowId, splitTopLevel, splitInsertChildren, entryMatchesPlugin, appendInsertBlock } from './patch-text.js';
19
- import { resolveEntry, isAbsoluteSpec } from './scan.js';
19
+ import { resolveEntry, isAbsoluteSpec, clearScanCache } from './scan.js';
20
20
 
21
21
  /** 从一个目录读出插件身份,判断它能不能装。 */
22
22
  export function describeLocalPlugin(dir) {
@@ -238,6 +238,8 @@ export function importLocalPlugin(options = {}) {
238
238
 
239
239
  record.links = link ? [link] : [];
240
240
  writeJson(path.join(backupRoot, 'record.json'), record);
241
+ // 装完必须让扫描缓存失效,否则界面还按旧结果报"未安装"
242
+ clearScanCache();
241
243
 
242
244
  return {
243
245
  ok: true,
package/lib/inbox.js CHANGED
@@ -20,6 +20,32 @@ import { ensureDir, removeTree } from './util.js';
20
20
  /** 单次落盘上限(默认 1 GiB,够大包也够防呆)。 */
21
21
  const MAX_BYTES = 1024 * 1024 * 1024;
22
22
 
23
+ /** 记录这份 inbox 副本来源的标记文件名。 */
24
+ const ORIGIN_MARK = '.origin';
25
+
26
+ /**
27
+ * 在 root 下建一个唯一的子目录:`<时间戳>-<4位随机>`。
28
+ *
29
+ * stamp() 只到秒,同一秒内多次落盘会撞名;加随机后缀既保持可读
30
+ * (按时间排序仍然有效),又能避免互相覆盖。
31
+ *
32
+ * @param {string} root
33
+ * @returns {string} 已创建的目录绝对路径
34
+ */
35
+ function uniqueDir(root) {
36
+ for (let attempt = 0; attempt < 20; attempt += 1) {
37
+ const suffix = Math.random().toString(16).slice(2, 6).padEnd(4, '0');
38
+ const dir = path.join(root, `${stamp()}-${suffix}`);
39
+ if (fs.existsSync(dir)) continue;
40
+ ensureDir(dir);
41
+ return dir;
42
+ }
43
+ // 极端情况下兜底:用纳秒精度的时间戳
44
+ const dir = path.join(root, `${stamp()}-${Date.now() % 100000}`);
45
+ ensureDir(dir);
46
+ return dir;
47
+ }
48
+
23
49
  /**
24
50
  * 从 multipart/form-data 里解析出文件条目。
25
51
  *
@@ -27,8 +53,7 @@ const MAX_BYTES = 1024 * 1024 * 1024;
27
53
  * 不引第三方库。
28
54
  *
29
55
  * @param {Buffer} body 整个请求体
30
- * @param {string} boundary boundary 字符串(不含前置 --)
31
- * @returns {Array<{ name: string, filename: string|null, data: Buffer }>}
56
+ * @param {string} boundary boundary 字符串(不含前置 --) * @returns {Array<{ name: string, filename: string|null, data: Buffer }>}
32
57
  */
33
58
  export function parseMultipart(body, boundary) {
34
59
  const delimiter = Buffer.from(`--${boundary}`);
@@ -87,9 +112,12 @@ export function boundaryOf(contentType) {
87
112
  * @param {string} options.home $DSH_HOME
88
113
  * @param {string} options.filename 原始文件名
89
114
  * @param {Buffer} options.data 内容
115
+ * @param {'upload'|'network'} [options.origin]
116
+ * 来源。`network` = 从 GitHub/npm 下载来的,装完可以自动删;
117
+ * `upload` = 用户从本地拖/选的,装完要留着(用户自己的文件)。
90
118
  * @returns {{ file: string, bytes: number }}
91
119
  */
92
- export function saveIncomingZip({ home, filename, data }) {
120
+ export function saveIncomingZip({ home, filename, data, origin = 'upload' }) {
93
121
  if (!data?.length) {
94
122
  const error = new Error('上传内容为空');
95
123
  error.code = 'BAD_UPLOAD';
@@ -101,8 +129,16 @@ export function saveIncomingZip({ home, filename, data }) {
101
129
  throw error;
102
130
  }
103
131
  const base = safeName(String(filename ?? '').trim() || `bundle-${stamp()}.zip`);
104
- const dir = path.join(home, 'plugin-pack', 'inbox', stamp());
105
- ensureDir(dir);
132
+ /* 目录名要唯一:stamp() 只到秒,同一秒内连拖两个包会落到同一个目录,
133
+ * 后一个会覆盖前一个的 .origin 标记(甚至同名时覆盖文件本身)。
134
+ * 加个短随机后缀避开。 */
135
+ const dir = uniqueDir(path.join(home, 'plugin-pack', 'inbox'));
136
+ // 记下来源:安装成功后据此决定要不要删掉这份落盘副本
137
+ try {
138
+ fs.writeFileSync(path.join(dir, ORIGIN_MARK), origin === 'network' ? 'network' : 'upload');
139
+ } catch {
140
+ /* 标记写不进去不影响导入 */
141
+ }
106
142
  // 保留原始扩展名:.zip = 整合包,.tgz/.tar.gz = 单个插件的 npm tarball
107
143
  const lower = base.toLowerCase();
108
144
  const hasKnownExt = lower.endsWith('.zip') || lower.endsWith('.tgz') || lower.endsWith('.tar.gz');
@@ -192,3 +228,47 @@ export function pruneInbox(home, days = 7) {
192
228
  }
193
229
  return removed;
194
230
  }
231
+
232
+ /**
233
+ * 读这份 inbox 副本的来源。
234
+ *
235
+ * @param {string} file inbox 里的文件绝对路径
236
+ * @returns {'upload'|'network'|null} 读不到标记时返回 null(老版本落盘的文件)
237
+ */
238
+ export function inboxOrigin(file) {
239
+ try {
240
+ const mark = path.join(path.dirname(String(file)), ORIGIN_MARK);
241
+ const text = fs.readFileSync(mark, 'utf8').trim();
242
+ return text === 'network' ? 'network' : 'upload';
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * 删掉 inbox 里的一份落盘副本(连它所在的时间戳目录一起删)。
250
+ *
251
+ * 只删 inbox 内部的目录:传进来的路径必须在 `<home>/plugin-pack/inbox` 之下,
252
+ * 否则直接拒绝 —— 免得哪天参数传错把用户自己的文件删了。
253
+ *
254
+ * @param {string} home $DSH_HOME
255
+ * @param {string} file inbox 里的文件路径
256
+ * @returns {boolean} 是否真的删了
257
+ */
258
+ export function removeInboxEntry(home, file) {
259
+ try {
260
+ const root = path.resolve(home, 'plugin-pack', 'inbox');
261
+ const target = path.resolve(String(file));
262
+ // 必须严格在 inbox 之下,且不能就是 inbox 本身
263
+ if (target === root || !target.startsWith(root + path.sep)) return false;
264
+ const dir = path.dirname(target);
265
+ // 目录必须是 inbox 的直接子目录(时间戳目录),不能是更外层
266
+ if (path.dirname(dir) !== root) return false;
267
+ // 目录本身要存在,否则不算"删掉了"
268
+ if (!fs.existsSync(dir)) return false;
269
+ removeTree(dir);
270
+ return true;
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
package/lib/index.js CHANGED
@@ -462,7 +462,25 @@ export function apply(ctx, config = {}) {
462
462
  try {
463
463
  mountRoutes(ctx, cfg);
464
464
  } catch (error) {
465
- console.warn('[dsh-plugin-pack] 路由挂载失败:', String(error?.message ?? error));
465
+ console.warn('[dsh-plugin-pack] 路由挂载失败:', String(error?.message || error));
466
+ }
467
+
468
+ /* 启动后台预热扫描缓存。
469
+ *
470
+ * 一次完整扫描要遍历上万文件(每个 stat 一次),冷启动要好几秒。
471
+ * 用户点开插件页时如果正好没缓存,就得干等 —— 这就是"加载插件时间很长"。
472
+ * 启动后延迟几秒(避开 dsh 自己的启动高峰)在后台扫一次,
473
+ * 之后界面打开直接命中缓存,毫秒级返回。
474
+ *
475
+ * 失败不阻断启动:最坏情况就是第一次打开慢一点。 */
476
+ try {
477
+ const warm = setTimeout(() => {
478
+ scanProfile({}).catch(() => {});
479
+ }, 5000);
480
+ warm.unref?.();
481
+ ctx.effect?.(() => () => clearTimeout(warm), 'dsh-plugin-pack: warm scan cache');
482
+ } catch {
483
+ /* 没有 effect 上下文时跳过 */
466
484
  }
467
485
 
468
486
  const commands = ctx.get?.('commands');
package/lib/scan.js CHANGED
@@ -27,13 +27,73 @@ const OFFICIAL_PREFIX = '@deepseek-ai/';
27
27
  * @type {Map<string, { at: number, value: object, pkgMtime: number, patchMtime: number }>}
28
28
  */
29
29
  const scanCache = new Map();
30
- const SCAN_TTL_MS = 60 * 1000;
30
+ /**
31
+ * 缓存有效期。
32
+ *
33
+ * 5 分钟:失效主要靠 mtime 判断(配置或插件目录一变就重扫),
34
+ * TTL 只是兜底防止极端情况下永远不刷新。这样界面打开是毫秒级,
35
+ * 装完插件下一轮也会立刻看到新状态。
36
+ */
37
+ const SCAN_TTL_MS = 5 * 60 * 1000;
38
+
39
+ /**
40
+ * 单个插件目录的体积缓存(key = 目录真实路径)。
41
+ *
42
+ * 整个扫描里最贵的是 measureTree:dsh-ops 那种上万文件的插件,
43
+ * 光 stat 就要一两秒。但插件没装没删的时候体积不会变,
44
+ * 按目录 mtime 缓存一下,重复扫描就从"秒级"掉到"毫秒级"。
45
+ *
46
+ * 注意:目录 mtime 只在增删直接子项时变化,深处文件被改写不会变。
47
+ * 所以导出(要精确体积)走 fresh=true 全量测量,只有界面展示用缓存。
48
+ *
49
+ * @type {Map<string, { mtimeMs: number, bytes: number, files: number }>}
50
+ */
51
+ const sizeCache = new Map();
31
52
 
32
- /** 清空缓存(导入/导出/回滚后调用,保证下次读到新的体积)。 */
53
+ /**
54
+ * 让扫描结果缓存失效(导入/导出/回滚后调用)。
55
+ *
56
+ * 只清「扫描结果」,不清「体积缓存」—— 体积缓存是按目录 mtime 自校验的:
57
+ * 刚装的那个插件目录 mtime 变了,下次自动重算;没动过的插件继续命中,
58
+ * 这样装完插件后的刷新仍然是快的(不用把上万个文件重新 stat 一遍)。
59
+ */
33
60
  export function clearScanCache() {
34
61
  scanCache.clear();
35
62
  }
36
63
 
64
+ /** 彻底清掉体积缓存(测试用;正式流程不必调,它自己会按 mtime 失效)。 */
65
+ export function clearSizeCache() {
66
+ sizeCache.clear();
67
+ }
68
+
69
+ /**
70
+ * 带缓存版本的 measureTree。
71
+ *
72
+ * 目录 mtime 没变就直接用上次的结果,避免重复遍历上万个文件。
73
+ * 缓存的是"某个目录的总字节/文件数",与调用方传的 skipDirs 无关 ——
74
+ * 所以只缓存参数固定的那几处调用(主目录、依赖、数据目录各自独立 key 前缀)。
75
+ *
76
+ * @param {string} root
77
+ * @param {object} options
78
+ * @param {string} variant 区分不同过滤参数('main' / 'dep' / 'data')
79
+ */
80
+ async function measureCached(root, options, variant) {
81
+ const key = `${variant}\0${root}`;
82
+ const mtimeMs = (() => {
83
+ try {
84
+ return fs.statSync(root).mtimeMs;
85
+ } catch {
86
+ return -1;
87
+ }
88
+ })();
89
+ const hit = sizeCache.get(key);
90
+ if (hit && hit.mtimeMs === mtimeMs && mtimeMs !== -1) return { bytes: hit.bytes, files: hit.files };
91
+
92
+ const measured = await measureTreeAsync(root, options);
93
+ if (mtimeMs !== -1) sizeCache.set(key, { mtimeMs, bytes: measured.bytes, files: measured.files });
94
+ return measured;
95
+ }
96
+
37
97
  /** 这些包永远由宿主提供,即便出现在 dependencies 里也不打包。 */
38
98
  const HOST_PROVIDED = new Set([
39
99
  'react',
@@ -78,20 +138,38 @@ export async function scanProfile(options = {}) {
78
138
 
79
139
  const pkgFile = path.join(dir, 'package.json');
80
140
  const patchFile = path.join(dir, 'cordis.patch.yml');
81
-
82
- /* 缓存:一次完整扫描要 3 秒(dsh-ops 9920 个文件、lark-plus 1033 个,
83
- * 每个都要 stat)。界面一打开就调 status,等于每次干等 3 秒。
84
- * 缓存 60 秒,且配置文件 mtime 一变就失效。 */
141
+ const localPluginsDir = path.join(dir, 'local-plugins');
142
+ const nodeModulesDir = path.join(dir, 'node_modules');
143
+
144
+ /* 缓存:一次完整扫描要好几秒(dsh-ops 上万文件,每个都要 stat)。
145
+ * 界面一打开就调 status,等于每次干等。
146
+ *
147
+ * 失效条件(任一):
148
+ * - 超过 TTL
149
+ * - package.json / cordis.patch.yml 变了
150
+ * - local-plugins 或 node_modules 目录 mtime 变了
151
+ * ← 这一条很关键:装完插件如果只往 local-plugins 放了目录、
152
+ * 而 package.json 因为某些原因没变,旧缓存会让界面显示"未安装"。
153
+ */
85
154
  const cacheKey = [dir, includeSecrets ? 's' : '', includeData ? 'd' : '', (options.include ?? []).join('|'), (options.exclude ?? []).join('|')].join('\0');
155
+
156
+ /** 读一个路径的 mtimeMs(不存在返回 -1),用于判断缓存是否还新鲜。 */
157
+ const mtimeOf = (target) => {
158
+ try {
159
+ return fs.statSync(target).mtimeMs;
160
+ } catch {
161
+ return -1;
162
+ }
163
+ };
164
+
86
165
  if (!options.fresh) {
87
166
  const cached = scanCache.get(cacheKey);
88
167
  if (cached && Date.now() - cached.at < SCAN_TTL_MS) {
89
- let stampOk = false;
90
- try {
91
- stampOk = fs.statSync(pkgFile).mtimeMs === cached.pkgMtime && fs.statSync(patchFile).mtimeMs === cached.patchMtime;
92
- } catch {
93
- stampOk = false;
94
- }
168
+ const stampOk =
169
+ mtimeOf(pkgFile) === cached.pkgMtime &&
170
+ mtimeOf(patchFile) === cached.patchMtime &&
171
+ mtimeOf(localPluginsDir) === cached.localMtime &&
172
+ mtimeOf(nodeModulesDir) === cached.modulesMtime;
95
173
  if (stampOk) return cached.value;
96
174
  }
97
175
  }
@@ -99,6 +177,14 @@ export async function scanProfile(options = {}) {
99
177
  const pkg = readJson(pkgFile, null);
100
178
  if (!pkg) warnings.push(`没有找到 profile 的 package.json: ${pkgFile}`);
101
179
 
180
+ /**
181
+ * 正式路径用的体积测量。
182
+ * fresh=true → 全量重算(导出要精确数字)
183
+ * 否则 → 命中目录 mtime 缓存(界面展示要快)
184
+ */
185
+ const effectiveMeasure = async (root, opts, variant = 'x') =>
186
+ options.fresh ? measureTreeAsync(root, opts) : measureCached(root, opts, variant);
187
+
102
188
  const bundles = Array.isArray(pkg?.dsh?.profile?.bundles) ? [...pkg.dsh.profile.bundles] : [];
103
189
  const dependencies = pkg?.dependencies && typeof pkg.dependencies === 'object' ? { ...pkg.dependencies } : {};
104
190
 
@@ -154,8 +240,7 @@ export async function scanProfile(options = {}) {
154
240
  addCandidate({ dir: resolved, declaredName: depName, link: spec, inBundles: false, source: 'dependencies' });
155
241
  }
156
242
 
157
- // 3) local-plugins 下手工放的
158
- const localPluginsDir = path.join(dir, 'local-plugins');
243
+ // 3) local-plugins 下手工放的(目录变量在上面缓存校验时已声明)
159
244
  if (isDir(localPluginsDir)) {
160
245
  for (const entry of fs.readdirSync(localPluginsDir, { withFileTypes: true })) {
161
246
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
@@ -182,7 +267,8 @@ export async function scanProfile(options = {}) {
182
267
  includeSecrets,
183
268
  includeData,
184
269
  warnings,
185
- measure: options.measure,
270
+ // 测试可注入自定义 measure;正式路径用带缓存的版本
271
+ measure: options.measure ?? effectiveMeasure,
186
272
  });
187
273
  if (!plugin) continue;
188
274
  // dependencies 来源可能是纯工具库:既不是 dsh-* 命名,也没有 dsh 字段,
@@ -245,13 +331,15 @@ export async function scanProfile(options = {}) {
245
331
  warnings,
246
332
  };
247
333
 
248
- // 写入缓存(记下两份配置的 mtime,之后据此判断失效)
334
+ // 写入缓存(记下配置与插件目录的 mtime,之后据此判断失效)
249
335
  try {
250
336
  scanCache.set(cacheKey, {
251
337
  at: Date.now(),
252
338
  value: result,
253
- pkgMtime: fs.statSync(pkgFile).mtimeMs,
254
- patchMtime: fs.statSync(patchFile).mtimeMs,
339
+ pkgMtime: mtimeOf(pkgFile),
340
+ patchMtime: mtimeOf(patchFile),
341
+ localMtime: mtimeOf(localPluginsDir),
342
+ modulesMtime: mtimeOf(nodeModulesDir),
255
343
  });
256
344
  } catch {
257
345
  /* 缓存可有可无 */
@@ -355,10 +443,14 @@ async function collectDepsAsync(rootDir, meta, { profileDir, includeSecrets, war
355
443
  if (seen.has(depDir)) continue;
356
444
  seen.add(depDir);
357
445
  const depMeta = readJson(path.join(depDir, 'package.json'), {});
358
- const size = await measure(depDir, {
359
- skipDirs: new Set([...SKIP_DIRS, 'node_modules']),
360
- keepFile,
361
- });
446
+ const size = await measure(
447
+ depDir,
448
+ {
449
+ skipDirs: new Set([...SKIP_DIRS, 'node_modules']),
450
+ keepFile,
451
+ },
452
+ 'dep',
453
+ );
362
454
  const record = {
363
455
  name: depName,
364
456
  version: depMeta.version ?? '0.0.0',
@@ -438,10 +530,14 @@ async function buildPlugin(item, { profileDir, dshHome, topEntries, includeSecre
438
530
  }
439
531
 
440
532
  const skipFileKeep = fileFilter({ includeSecrets });
441
- const mainSize = await measure(item.dir, {
442
- skipDirs: new Set([...SKIP_DIRS, 'node_modules']),
443
- keepFile: skipFileKeep,
444
- });
533
+ const mainSize = await measure(
534
+ item.dir,
535
+ {
536
+ skipDirs: new Set([...SKIP_DIRS, 'node_modules']),
537
+ keepFile: skipFileKeep,
538
+ },
539
+ 'main',
540
+ );
445
541
  const deps = await collectDepsAsync(item.dir, meta, { profileDir, includeSecrets, warnings: pluginWarnings, measure });
446
542
 
447
543
  const patchInfo = matchPatchRows(topEntries, { name, dir: item.dir });
@@ -450,7 +546,7 @@ async function buildPlugin(item, { profileDir, dshHome, topEntries, includeSecre
450
546
  const dataDirs = [];
451
547
  if (includeData) {
452
548
  for (const candidate of dataCandidates) {
453
- const size = await measure(candidate.path, { skipDirs: SKIP_DIRS, keepFile: skipFileKeep });
549
+ const size = await measure(candidate.path, { skipDirs: SKIP_DIRS, keepFile: skipFileKeep }, 'data');
454
550
  dataDirs.push({ ...candidate, bytes: size.bytes, files: size.files });
455
551
  }
456
552
  }
package/lib/server.js CHANGED
@@ -28,7 +28,15 @@ import { importBundle, inspectBundle, rollbackImport, listBackups } from './impo
28
28
  import { reloadClientPackage, clientStatus, localVerdict } from '../hotload.js';
29
29
  import { importLocalPlugin } from './import-plugin.js';
30
30
  import { importTarball, isTarball } from './import-tgz.js';
31
- import { parseMultipart, boundaryOf, saveIncomingZip, saveIncomingDir, pruneInbox } from './inbox.js';
31
+ import {
32
+ parseMultipart,
33
+ boundaryOf,
34
+ saveIncomingZip,
35
+ saveIncomingDir,
36
+ pruneInbox,
37
+ inboxOrigin,
38
+ removeInboxEntry,
39
+ } from './inbox.js';
32
40
  import { runJob, listJobs, getJob, runningJobs, clearFinished, openWaiting, getWaiting, listWaiting, cancelWaiting } from './jobs.js';
33
41
  import { fetchBundleFrom, parseSource } from './fetch-bundle.js';
34
42
  import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName as resolveProfileName, humanBytes } from './home.js';
@@ -80,6 +88,28 @@ function sendJson(response, status, value) {
80
88
  }
81
89
 
82
90
  /** 把异常翻译成 HTTP 状态;其余仍是 500。 */
91
+ /**
92
+ * 安装成功后清理 inbox 里的落盘副本。
93
+ *
94
+ * 只删「从网络下载」的那份;用户自己拖/选的本地文件永远保留 ——
95
+ * 那是他自己的文件,工具不该动它。
96
+ *
97
+ * @param {object} config
98
+ * @param {string} file 本次导入用的文件路径
99
+ * @returns {boolean} 是否真的删掉了
100
+ */
101
+ function autoCleanInbox(config, file) {
102
+ try {
103
+ if (!file) return false;
104
+ const home = contextOf(config).home;
105
+ // 只有网络下载的来源才删
106
+ if (inboxOrigin(file) !== 'network') return false;
107
+ return removeInboxEntry(home, file);
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
83
113
  function statusOf(error) {
84
114
  const explicit = Number(error?.status ?? error?.statusCode);
85
115
  if (Number.isFinite(explicit) && explicit >= 400 && explicit < 600) return explicit;
@@ -590,7 +620,9 @@ export function mountRoutes(ctx, config = {}) {
590
620
  meta: { file: body.file },
591
621
  task: (report) => doImport(report),
592
622
  });
593
- sendJson(response, 200, { ...result, jobId: id });
623
+ // 网络下载来的 tgz,装完同样清掉副本
624
+ const cleanedTgz = autoCleanInbox(config, body.file);
625
+ sendJson(response, 200, { ...result, jobId: id, ...(cleanedTgz ? { inboxCleaned: true } : {}) });
594
626
  return;
595
627
  }
596
628
  const dryRun = Boolean(body.dryRun);
@@ -641,7 +673,11 @@ export function mountRoutes(ctx, config = {}) {
641
673
  jobId: body.jobId ? String(body.jobId) : undefined,
642
674
  task: (report) => doImportBundle(report),
643
675
  });
644
- sendJson(response, 200, { ...result, jobId: id });
676
+ /* 装完清掉网络下载的副本:
677
+ * 从 GitHub/npm 下下来的包,装好后原件就没用了,留着白占磁盘。
678
+ * 但用户自己拖/选的本地文件(origin='upload')一律保留 —— 那是他的东西。 */
679
+ const cleaned = autoCleanInbox(config, body.file);
680
+ sendJson(response, 200, { ...result, jobId: id, ...(cleaned ? { inboxCleaned: true } : {}) });
645
681
  },
646
682
  },
647
683
  'plugin-pack: import',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-pack",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "DeepSeek Harness 插件整合包:一键把当前 profile 的第三方插件连依赖和配置导出成 zip(内部每个插件是标准 npm tgz),换机/换 profile 拖进去就能还原。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",