dsh-plugin-pack 1.0.10 → 1.0.12

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',
@@ -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) {
@@ -33,15 +33,20 @@ export function describeLocalPlugin(dir) {
33
33
  throw error;
34
34
  }
35
35
  const entry = resolveEntry(target, meta);
36
- const patchRel = typeof meta?.dsh?.bundle?.patch === 'string' ? meta.dsh.bundle.patch : null;
36
+ // dsh.bundle.patch 可能是字符串或数组(一个插件可声明多个 patch 文件)
37
+ const patchSpec = meta?.dsh?.bundle?.patch;
38
+ const patchRels = (Array.isArray(patchSpec) ? patchSpec : [patchSpec]).filter(
39
+ (rel) => typeof rel === 'string' && rel.trim(),
40
+ );
37
41
  return {
38
42
  dir: target,
39
43
  name: meta.name.trim(),
40
44
  version: meta.version ?? '0.0.0',
41
45
  description: typeof meta.description === 'string' ? meta.description : '',
42
46
  entry,
43
- hasPatch: patchRel ? isFile(path.join(target, patchRel)) : false,
44
- patchRel,
47
+ hasPatch: patchRels.some((rel) => isFile(path.join(target, rel))),
48
+ patchRel: patchRels[0] ?? null,
49
+ patchRels,
45
50
  client: meta.dsh?.client ?? null,
46
51
  deps: Object.keys(meta.dependencies ?? {}).filter((dep) => !dep.startsWith('@deepseek-ai/')),
47
52
  };
@@ -96,7 +101,11 @@ export function importLocalPlugin(options = {}) {
96
101
  const insertRows = [];
97
102
  const skippedRows = [];
98
103
  if (info.hasPatch) {
99
- const ownPatch = readText(path.join(info.dir, info.patchRel), '') ?? '';
104
+ // 多 patch 文件的插件要把每一份都读进来合并,只读第一个会丢配置
105
+ const ownPatch = (info.patchRels ?? [info.patchRel])
106
+ .filter(Boolean)
107
+ .map((rel) => readText(path.join(info.dir, rel), '') ?? '')
108
+ .join('\n');
100
109
  const target = { name: info.name, dirs: [info.dir] };
101
110
  for (const entry of splitTopLevel(ownPatch)) {
102
111
  if (entry.kind === 'insert') {
@@ -238,6 +247,8 @@ export function importLocalPlugin(options = {}) {
238
247
 
239
248
  record.links = link ? [link] : [];
240
249
  writeJson(path.join(backupRoot, 'record.json'), record);
250
+ // 装完必须让扫描缓存失效,否则界面还按旧结果报"未安装"
251
+ clearScanCache();
241
252
 
242
253
  return {
243
254
  ok: true,
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
  }
@@ -461,6 +557,17 @@ async function buildPlugin(item, { profileDir, dshHome, topEntries, includeSecre
461
557
  if (!entry && meta) pluginWarnings.push('找不到入口文件,导入后可能加载失败');
462
558
  if (pluginWarnings.length) warnings.push(...pluginWarnings.map((text) => `${name}: ${text}`));
463
559
 
560
+ /**
561
+ * `dsh.bundle.patch` 可能是字符串,也可能是数组
562
+ * (一个插件声明多个 patch 文件,实测 dsh-agentic-pet 就是数组)。
563
+ * 早先只当字符串处理,遇到数组会让 path.join 抛
564
+ * ERR_INVALID_ARG_TYPE: The "path" argument must be of type string。
565
+ */
566
+ const patchSpec = meta?.dsh?.bundle?.patch;
567
+ const patchRels = (Array.isArray(patchSpec) ? patchSpec : [patchSpec]).filter(
568
+ (rel) => typeof rel === 'string' && rel.trim(),
569
+ );
570
+
464
571
  return {
465
572
  name,
466
573
  version: meta?.version ?? '0.0.0',
@@ -472,8 +579,10 @@ async function buildPlugin(item, { profileDir, dshHome, topEntries, includeSecre
472
579
  inBundles: item.inBundles,
473
580
  bundleIndex: item.bundleIndex,
474
581
  sources: [...item.sources],
475
- hasPatch: Boolean(meta?.dsh?.bundle?.patch) && isFile(path.join(item.dir, meta.dsh.bundle.patch)),
476
- patchRel: typeof meta?.dsh?.bundle?.patch === 'string' ? meta.dsh.bundle.patch : null,
582
+ hasPatch: patchRels.some((rel) => isFile(path.join(item.dir, rel))),
583
+ // 主 patch 路径(多文件时取第一个,其余在 patchRels 里)
584
+ patchRel: patchRels[0] ?? null,
585
+ patchRels,
477
586
  client: meta?.dsh?.client ?? null,
478
587
  entry,
479
588
  deps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-pack",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "DeepSeek Harness 插件整合包:一键把当前 profile 的第三方插件连依赖和配置导出成 zip(内部每个插件是标准 npm tgz),换机/换 profile 拖进去就能还原。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",