dsh-plugin-pack 0.0.0-stage → 1.0.2

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/lib/index.js ADDED
@@ -0,0 +1,725 @@
1
+ /**
2
+ * dsh-plugin-pack 入口:注册 5 个工具 + /pack 命令。
3
+ *
4
+ * 所有能力都在本文件里组装,子模块只做纯函数/文件操作,方便单测和 CLI 复用。
5
+ *
6
+ * @module dsh-plugin-pack
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import { scanProfile } from './scan.js';
12
+ import { exportBundle, resolveOutFile } from './export.js';
13
+ import { importBundle, inspectBundle, rollbackImport, listBackups } from './import.js';
14
+ import { mountRoutes } from './server.js';
15
+ import { reloadClientPackage, clientStatus, localVerdict } from '../hotload.js';
16
+ import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName as resolveProfileName, humanBytes } from './home.js';
17
+ import { isFile } from './util.js';
18
+
19
+ export const name = 'dsh-plugin-pack';
20
+ export const inject = ['tools'];
21
+
22
+ /** 本包所在目录(判断 Loader 行是否属于自己)。 */
23
+ const SELF_DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..');
24
+
25
+ export const DEFAULTS = {
26
+ enabled: true,
27
+ exportDir: '',
28
+ includeData: false,
29
+ includeSecrets: false,
30
+ // 按未压缩字节算;压缩比约 1:3,2 GiB 原始 ≈ 600~700 MiB 成品
31
+ maxBytes: 2 * 1024 * 1024 * 1024,
32
+ overwritePatchConfig: false,
33
+ selfHotload: true,
34
+ };
35
+
36
+ const TOOL_OUTPUT = {
37
+ schema: {
38
+ type: 'object',
39
+ additionalProperties: false,
40
+ properties: { text: { type: 'string' } },
41
+ },
42
+ render: (_args, value) => [{ type: 'text', text: String(value?.text ?? '') }],
43
+ };
44
+
45
+ /** 统一错误转文本,工具不抛。 */
46
+ function fail(error) {
47
+ const message = error?.message ?? String(error);
48
+ return { text: `ERROR: ${message}` };
49
+ }
50
+
51
+ /** 按包名找包目录:先 current profile 的 node_modules,再本包自己的真实目录。 */
52
+ function resolvePackageDirFor(packageName, local) {
53
+ const segments = String(packageName).split('/');
54
+ const candidates = [
55
+ path.join(local.profileDir, 'node_modules', ...segments),
56
+ path.join(local.profileDir, 'local-plugins', ...segments),
57
+ SELF_DIR,
58
+ ];
59
+ for (const candidate of candidates) {
60
+ try {
61
+ if (isFile(path.join(candidate, 'package.json'))) return candidate;
62
+ } catch {
63
+ /* 下一个 */
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** 当前上下文。 */
70
+ function context(config) {
71
+ const env = process.env;
72
+ return {
73
+ env,
74
+ home: resolveDshHome(env),
75
+ profileDir: resolveProfileDir(env),
76
+ profileName: resolveProfileName(env),
77
+ config,
78
+ };
79
+ }
80
+
81
+ /** 宿主 cordis 上下文(给热加载用)。 */
82
+ let pluginCtx = null;
83
+
84
+ /** 渲染插件清单。 */
85
+ export function renderList(scan, ctx, backups = []) {
86
+ const lines = [];
87
+ lines.push(`profile: ${scan.profile.name} (${scan.profile.dir})`);
88
+ lines.push(`DSH_HOME: ${scan.profile.dshHome}`);
89
+ lines.push(`扫到插件 ${scan.plugins.length} 个,可导出 ${scan.selected.length} 个,合计 ${humanBytes(scan.totals.bytes)} / ${scan.totals.files} 文件`);
90
+ lines.push('');
91
+ const header = ['', '插件', '版本', '类型', '依赖', 'patch', '体积', '来源'];
92
+ const rows = scan.plugins.map((plugin, index) => [
93
+ plugin.skipped ? ' ' : '•',
94
+ plugin.name,
95
+ plugin.version,
96
+ plugin.kind,
97
+ `${plugin.deps.length}`,
98
+ `${plugin.patchRows.length}+${plugin.insertRows.length}`,
99
+ humanBytes(plugin.bytes),
100
+ plugin.inBundles ? 'bundles' : plugin.sources.join('/'),
101
+ ]);
102
+ lines.push(renderTable([header, ...rows]));
103
+ const dataCandidates = scan.plugins.filter((plugin) => plugin.dataCandidates.length);
104
+ if (dataCandidates.length) {
105
+ lines.push('');
106
+ lines.push('可选数据目录(默认不打包,导出时加 includeData=true):');
107
+ for (const plugin of dataCandidates) {
108
+ lines.push(` - ${plugin.name}: ${plugin.dataCandidates.join(', ')}`);
109
+ }
110
+ }
111
+ if (scan.warnings.length) {
112
+ lines.push('');
113
+ lines.push(`警告 ${scan.warnings.length} 条:`);
114
+ for (const warning of scan.warnings.slice(0, 20)) lines.push(` ! ${warning}`);
115
+ if (scan.warnings.length > 20) lines.push(` ... 其余 ${scan.warnings.length - 20} 条略`);
116
+ }
117
+ lines.push('');
118
+ lines.push(`备份目录: ${path.join(ctx.profileDir, '.plugin-backups')}`);
119
+ if (backups.length) {
120
+ lines.push(`可回滚备份 ${backups.length} 个(最新在前):`);
121
+ for (const backup of backups.slice(0, 5)) {
122
+ lines.push(` - ${backup.name} ${backup.createdAt ?? ''} ${backup.plugins} 项`);
123
+ }
124
+ } else {
125
+ lines.push('还没有导入备份。');
126
+ }
127
+ return lines.join('\n');
128
+ }
129
+
130
+ /** 极简表格对齐(按显示宽度近似,中文算 2)。 */
131
+ function renderTable(rows) {
132
+ const width = (text) => [...String(text)].reduce((sum, char) => sum + (char.charCodeAt(0) > 0x2e80 ? 2 : 1), 0);
133
+ const columns = Math.max(...rows.map((row) => row.length));
134
+ const widths = new Array(columns).fill(0);
135
+ for (const row of rows) {
136
+ for (let i = 0; i < columns; i += 1) widths[i] = Math.max(widths[i], width(row[i] ?? ''));
137
+ }
138
+ return rows
139
+ .map((row) =>
140
+ row
141
+ .map((cell, index) => {
142
+ const text = String(cell ?? '');
143
+ return text + ' '.repeat(Math.max(0, widths[index] - width(text)));
144
+ })
145
+ .join(' ')
146
+ .replace(/\s+$/, ''),
147
+ )
148
+ .join('\n');
149
+ }
150
+
151
+ /** 渲染导出结果。 */
152
+ export function renderExport(result) {
153
+ const lines = [];
154
+ if (result.dryRun) {
155
+ lines.push(`[dry-run] 将导出 ${result.plugins.length} 个插件到 ${result.file}`);
156
+ } else {
157
+ lines.push(`✓ 整合包已生成: ${result.file}`);
158
+ lines.push(` 体积 ${humanBytes(result.bytes)}(未压缩 ${humanBytes(result.rawBytes)}),${result.entries} 个条目`);
159
+ }
160
+ lines.push('');
161
+ lines.push(renderTable([
162
+ ['插件', '版本', '文件', '体积', '依赖', 'patch'],
163
+ ...result.plugins.map((plugin) => [
164
+ plugin.name,
165
+ plugin.version,
166
+ `${plugin.files}`,
167
+ humanBytes(plugin.bytes),
168
+ `${plugin.deps}`,
169
+ `${plugin.patchRows}+${plugin.insertRows}`,
170
+ ]),
171
+ ]));
172
+ if (result.warnings?.length) {
173
+ lines.push('');
174
+ lines.push(`警告 ${result.warnings.length} 条:`);
175
+ for (const warning of result.warnings.slice(0, 20)) lines.push(` ! ${warning}`);
176
+ }
177
+ if (!result.dryRun) {
178
+ lines.push('');
179
+ lines.push('在另一台机器/另一个 profile 上导入:');
180
+ lines.push(` /pack import "${result.file}"`);
181
+ }
182
+ return lines.join('\n');
183
+ }
184
+
185
+ /** 渲染导入结果。 */
186
+ export function renderImport(result) {
187
+ const lines = [];
188
+ if (result.dryRun) {
189
+ lines.push(`[dry-run] 准备导入 ${result.plugins.length} 个插件到 ${result.profile}`);
190
+ lines.push(` 模式: ${result.mode}`);
191
+ lines.push('');
192
+ lines.push(renderTable([
193
+ ['插件', '版本', '动作', '文件', '体积', '链接状态'],
194
+ ...result.plugins.map((plugin) => [
195
+ plugin.name,
196
+ plugin.version,
197
+ plugin.action,
198
+ `${plugin.files}`,
199
+ humanBytes(plugin.bytes),
200
+ plugin.linkState,
201
+ ]),
202
+ ]));
203
+ lines.push('');
204
+ lines.push(`patch 将写入: 顶层 ${result.patch.topRows} 条, insert ${result.patch.insertRows} 条`);
205
+ if (result.bundlesToAdd.length) lines.push(`bundles 将新增: ${result.bundlesToAdd.join(', ')}`);
206
+ lines.push('(dry-run 没有写任何文件)');
207
+ } else {
208
+ lines.push(`✓ 导入完成,profile: ${result.profileLabel}`);
209
+ lines.push(` 备份: ${result.backup}`);
210
+ lines.push('');
211
+ lines.push(renderTable([
212
+ ['插件', '动作', '文件'],
213
+ ...result.plugins.map((plugin) => [plugin.name, plugin.action, `${plugin.files ?? ''}`]),
214
+ ]));
215
+ lines.push('');
216
+ lines.push(`patch 写入: 顶层 ${result.patch.topRows} 条, insert ${result.patch.insertRows} 条`);
217
+ if (result.bundlesAdded.length) lines.push(`bundles 新增: ${result.bundlesAdded.join(', ')}`);
218
+ if (result.dataRestored) lines.push(`数据文件还原: ${result.dataRestored} 个`);
219
+ const badLinks = (result.links ?? []).filter((link) => link.status === 'failed');
220
+ if (badLinks.length) {
221
+ lines.push('');
222
+ lines.push(`链接失败 ${badLinks.length} 个(可手动 pnpm install 补齐):`);
223
+ for (const link of badLinks) lines.push(` ! ${link.name}: ${link.error}`);
224
+ }
225
+ lines.push('');
226
+ lines.push('**重启 dsh 生效。** 回滚: /pack rollback');
227
+ }
228
+ if (result.warnings?.length) {
229
+ lines.push('');
230
+ lines.push(`警告 ${result.warnings.length} 条:`);
231
+ for (const warning of result.warnings.slice(0, 20)) lines.push(` ! ${warning}`);
232
+ }
233
+ return lines.join('\n');
234
+ }
235
+
236
+ /** 渲染 inspect。 */
237
+ export function renderInspect(info) {
238
+ const lines = [];
239
+ lines.push(`整合包: ${info.file}`);
240
+ lines.push(`体积: ${humanBytes(info.bytes)} 条目: ${info.entryCount}`);
241
+ lines.push(`生成: ${info.createdAt ?? '?'} 来源 profile: ${info.source?.profile ?? '?'}`);
242
+ lines.push(`来源目录: ${info.source?.profileDir ?? '?'}`);
243
+ lines.push('');
244
+ lines.push(renderTable([
245
+ ['插件', '版本', '文件', '体积', '依赖', 'patch', 'package.json'],
246
+ ...info.plugins.map((plugin) => [
247
+ plugin.name,
248
+ plugin.version,
249
+ `${plugin.files}`,
250
+ humanBytes(plugin.bytes),
251
+ `${plugin.deps}`,
252
+ `${plugin.patchRows}+${plugin.insertRows}`,
253
+ plugin.hasPackageJson ? 'ok' : '缺失',
254
+ ]),
255
+ ]));
256
+ if (info.warnings?.length) {
257
+ lines.push('');
258
+ for (const warning of info.warnings.slice(0, 20)) lines.push(` ! ${warning}`);
259
+ }
260
+ return lines.join('\n');
261
+ }
262
+
263
+ /** 解析 `/pack` 的文本参数。 */
264
+ export function parseArgs(rawInput) {
265
+ const tokens = String(rawInput ?? '').match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
266
+ const flags = new Map();
267
+ const positional = [];
268
+ for (const token of tokens) {
269
+ if (token.startsWith('--')) {
270
+ const [key, ...rest] = token.slice(2).split('=');
271
+ flags.set(key, rest.length ? rest.join('=') : true);
272
+ continue;
273
+ }
274
+ if (token.startsWith('-') && token.length > 1 && !path.isAbsolute(token)) {
275
+ flags.set(token.slice(1), true);
276
+ continue;
277
+ }
278
+ positional.push(token.replace(/^"(.*)"$/s, '$1').replace(/^'(.*)'$/s, '$1'));
279
+ }
280
+ return { positional, flags };
281
+ }
282
+
283
+ /** 处理 /pack 命令。 */
284
+ export async function handlePackCommand(rawInput, config) {
285
+ const { positional, flags } = parseArgs(rawInput);
286
+ const sub = (positional[0] ?? 'list').toLowerCase();
287
+ const ctx = context(config);
288
+ try {
289
+ switch (sub) {
290
+ case 'list':
291
+ case 'ls':
292
+ case 'status': {
293
+ const scan = await scanProfile({
294
+ includeData: Boolean(flags.get('data')),
295
+ includeSecrets: Boolean(flags.get('secrets')),
296
+ });
297
+ return { kind: 'success', text: renderList(scan, ctx, listBackups(ctx.profileDir)) };
298
+ }
299
+ case 'export':
300
+ case 'save':
301
+ case 'e': {
302
+ const out = positional[1];
303
+ const include = typeof flags.get('include') === 'string' ? String(flags.get('include')).split(',').map((item) => item.trim()).filter(Boolean) : null;
304
+ const exclude = typeof flags.get('exclude') === 'string' ? String(flags.get('exclude')).split(',').map((item) => item.trim()).filter(Boolean) : [];
305
+ const result = await exportBundle({
306
+ out: out ?? (config.exportDir || path.join(ctx.home, 'exports')),
307
+ include,
308
+ exclude,
309
+ includeData: Boolean(flags.get('data')) || config.includeData,
310
+ includeSecrets: Boolean(flags.get('secrets')) || config.includeSecrets,
311
+ maxBytes: config.maxBytes,
312
+ dryRun: Boolean(flags.get('dry')) || Boolean(flags.get('dry-run')),
313
+ });
314
+ return { kind: 'success', text: renderExport(result) };
315
+ }
316
+ case 'inspect':
317
+ case 'info':
318
+ case 'i': {
319
+ const file = positional[1];
320
+ if (!file) return { kind: 'error', text: '用法: /pack inspect <zip 路径>' };
321
+ return { kind: 'success', text: renderInspect(inspectBundle(file)) };
322
+ }
323
+ case 'import':
324
+ case 'load': {
325
+ const file = positional[1];
326
+ if (!file) return { kind: 'error', text: '用法: /pack import <zip 路径> [--dry] [--skip] [--no-data] [--no-link]' };
327
+ const result = importBundle({
328
+ file,
329
+ dryRun: Boolean(flags.get('dry')) || Boolean(flags.get('dry-run')),
330
+ mode: flags.get('skip') ? 'skip' : 'merge',
331
+ overwritePatchConfig: Boolean(flags.get('overwrite')) || config.overwritePatchConfig,
332
+ restoreData: !flags.get('no-data'),
333
+ linkNodeModules: !flags.get('no-link'),
334
+ });
335
+ return { kind: 'success', text: renderImport(result) };
336
+ }
337
+ case 'rollback':
338
+ case 'undo': {
339
+ const backup = positional[1];
340
+ const result = rollbackImport({ backup });
341
+ return {
342
+ kind: 'success',
343
+ text: `✓ 已回滚到 ${result.backup}\n恢复: ${result.restored.join(', ') || '(无)'}\n删除: ${result.removed.length} 项\n解链: ${result.unlinked.length} 项\n**重启 dsh 生效。**`,
344
+ };
345
+ }
346
+ case 'backups':
347
+ case 'b': {
348
+ const backups = listBackups(ctx.profileDir);
349
+ if (!backups.length) return { kind: 'success', text: '还没有导入备份。' };
350
+ return {
351
+ kind: 'success',
352
+ text: backups.map((item) => `${item.name} ${item.createdAt ?? ''} ${item.plugins} 项`).join('\n'),
353
+ };
354
+ }
355
+ case 'hotload':
356
+ case 'reload': {
357
+ const packageName = positional[1] || name;
358
+ const packageDir = resolvePackageDirFor(packageName, ctx);
359
+ if (!packageDir) return { kind: 'error', text: `找不到包目录: ${packageName}` };
360
+ const verdict = localVerdict(packageDir);
361
+ const steps = [];
362
+ const result = reloadClientPackage(pluginCtx, {
363
+ packageName,
364
+ packageDir,
365
+ log: (message) => steps.push(` · ${message}`),
366
+ });
367
+ return {
368
+ kind: result.ok ? 'success' : 'error',
369
+ text: [
370
+ verdict.ok ? `声明检查: ✓ ${verdict.clientPath}` : `声明检查: ✗ ${verdict.error}`,
371
+ ...steps,
372
+ result.ok ? `✓ 已热加载,rev=${result.rev}。刷新页面(Ctrl+F5)即可。` : `✗ ${result.error}`,
373
+ ].join('\n'),
374
+ };
375
+ }
376
+ case 'client':
377
+ case 'cs': {
378
+ const packageName = positional[1] || name;
379
+ const status = clientStatus(pluginCtx, packageName);
380
+ if (!status.available) return { kind: 'error', text: status.reason };
381
+ return {
382
+ kind: 'success',
383
+ text: [
384
+ `${packageName}: ${status.inTable ? '✓ 在图表里' : '✗ 不在图表里'}`,
385
+ `rev: ${status.rev ?? '(无)'}`,
386
+ `bundle: ${status.clientPath ?? '(无)'}`,
387
+ `图表内 ${status.entries.length} 个包`,
388
+ ].join('\n'),
389
+ };
390
+ }
391
+ case 'help':
392
+ case 'h':
393
+ default:
394
+ return {
395
+ kind: 'success',
396
+ text: [
397
+ 'dsh-plugin-pack — 插件整合包一键导出/导入',
398
+ '',
399
+ '/pack list 列出当前 profile 的第三方插件与备份',
400
+ '/pack export [路径] [选项] 导出整合包',
401
+ ' --include=a,b 只导出这些包',
402
+ ' --exclude=a,b 排除这些包',
403
+ ' --data 连插件数据目录一起打包',
404
+ ' --secrets 连 .env / *.pem 一起打包(默认剔除)',
405
+ ' --dry 只算体积,不写文件',
406
+ '/pack inspect <zip> 看包里有什么',
407
+ '/pack import <zip> [选项] 导入',
408
+ ' --dry 预演,不写任何文件',
409
+ ' --skip 目标已存在就跳过',
410
+ ' --no-data 不还原数据目录',
411
+ ' --no-link 不建 node_modules 链接',
412
+ ' --overwrite 覆盖同 id 的 patch 行',
413
+ '/pack rollback [备份名] 回滚最近一次导入',
414
+ '/pack backups 列出备份',
415
+ '',
416
+ `默认导出目录: ${path.join(ctx.home, 'exports')}`,
417
+ ].join('\n'),
418
+ };
419
+ }
420
+ } catch (error) {
421
+ return { kind: 'error', text: `ERROR: ${error?.message ?? error}` };
422
+ }
423
+ }
424
+
425
+ /** cordis 插件入口。 */
426
+ export function apply(ctx, config = {}) {
427
+ const cfg = { ...DEFAULTS, ...(config ?? {}) };
428
+ if (!cfg.enabled) return;
429
+
430
+ pluginCtx = ctx;
431
+
432
+ /* 启动时自己热加载一次客户端半身。
433
+ * 宿主把"这个包不是客户端包"的判定缓存到重启,而我们经常是在装好之后才补
434
+ * exports/plugins.row.config 的 —— 自愈一次比让用户重启强。
435
+ * 失败不阻断启动:热加载只是优化,服务端工具不受影响。 */
436
+ if (cfg.selfHotload !== false) {
437
+ try {
438
+ const delayed = setTimeout(() => {
439
+ try {
440
+ const verdict = localVerdict(SELF_DIR);
441
+ if (!verdict.ok) {
442
+ console.warn(`[dsh-plugin-pack] 客户端声明不完整,跳过自热加载: ${verdict.error}`);
443
+ return;
444
+ }
445
+ const state = clientStatus(ctx, name);
446
+ if (state.available && state.inTable) return;
447
+ const result = reloadClientPackage(ctx, { packageName: name, packageDir: SELF_DIR });
448
+ if (result.ok) console.log(`[dsh-plugin-pack] 客户端半身已热加载 rev=${result.rev}`);
449
+ else console.warn(`[dsh-plugin-pack] 自热加载未成功: ${result.error}`);
450
+ } catch (error) {
451
+ console.warn('[dsh-plugin-pack] 自热加载异常:', String(error?.message ?? error));
452
+ }
453
+ }, 1200);
454
+ delayed.unref?.();
455
+ ctx.effect(() => () => clearTimeout(delayed), 'dsh-plugin-pack: self hotload');
456
+ } catch {
457
+ /* 没有 effect 的上下文:跳过 */
458
+ }
459
+ }
460
+
461
+ // 客户端界面用的 HTTP 桥。
462
+ try {
463
+ mountRoutes(ctx, cfg);
464
+ } catch (error) {
465
+ console.warn('[dsh-plugin-pack] 路由挂载失败:', String(error?.message ?? error));
466
+ }
467
+
468
+ const commands = ctx.get?.('commands');
469
+ if (commands?.register) {
470
+ commands.register({
471
+ name: 'pack',
472
+ description: '插件整合包:一键导出/导入当前 profile 的全部第三方插件(list/export/inspect/import/rollback)',
473
+ input: { hint: 'list | export [路径] | inspect <zip> | import <zip> | rollback | backups | help' },
474
+ handler: async (invocation) => handlePackCommand(invocation?.rawInput ?? '', cfg),
475
+ });
476
+ }
477
+
478
+ if (!ctx.tools) return;
479
+
480
+ ctx.tools.register({
481
+ name: 'plugin_pack_list',
482
+ description:
483
+ '列出当前 dsh profile 里所有可导出的第三方插件(含依赖数、patch 行数、体积)、可选的数据目录,以及历史导入备份。做整合包之前先调这个。',
484
+ parameters: {
485
+ type: 'object',
486
+ additionalProperties: false,
487
+ properties: {
488
+ includeData: { type: 'boolean', description: '是否把插件数据目录也算进体积,默认 false' },
489
+ includeSecrets: { type: 'boolean', description: '是否把 .env / *.pem 等凭证文件也算进去,默认 false' },
490
+ },
491
+ },
492
+ output: TOOL_OUTPUT,
493
+ async execute(args) {
494
+ try {
495
+ const local = context(cfg);
496
+ const scan = await scanProfile({
497
+ includeData: Boolean(args?.includeData),
498
+ includeSecrets: Boolean(args?.includeSecrets),
499
+ });
500
+ return { text: renderList(scan, local, listBackups(local.profileDir)) };
501
+ } catch (error) {
502
+ return fail(error);
503
+ }
504
+ },
505
+ });
506
+
507
+ ctx.tools.register({
508
+ name: 'plugin_pack_export',
509
+ description:
510
+ '把当前 profile 的第三方插件连运行时依赖一键打成 zip 整合包(默认写到 $DSH_HOME/exports)。包内自带 manifest.json,可在另一台机器/另一个 profile 用 plugin_pack_import 还原。',
511
+ parameters: {
512
+ type: 'object',
513
+ additionalProperties: false,
514
+ properties: {
515
+ out: { type: 'string', description: '输出 zip 路径;给目录则自动命名;留空用 $DSH_HOME/exports' },
516
+ include: { type: 'array', items: { type: 'string' }, description: '只导出这些包名' },
517
+ exclude: { type: 'array', items: { type: 'string' }, description: '排除这些包名' },
518
+ includeData: { type: 'boolean', description: '连插件数据目录($DSH_HOME/<插件名>)一起打包,默认 false' },
519
+ includeSecrets: { type: 'boolean', description: '连 .env / *.pem / credentials.json 一起打包,默认 false' },
520
+ maxBytes: { type: 'number', description: '体积上限,默认 536870912(512 MiB)' },
521
+ dryRun: { type: 'boolean', description: '只统计体积,不写文件' },
522
+ },
523
+ },
524
+ output: TOOL_OUTPUT,
525
+ async execute(args) {
526
+ try {
527
+ const local = context(cfg);
528
+ const result = await exportBundle({
529
+ out: args?.out || cfg.exportDir || path.join(local.home, 'exports'),
530
+ include: Array.isArray(args?.include) ? args.include : null,
531
+ exclude: Array.isArray(args?.exclude) ? args.exclude : [],
532
+ includeData: Boolean(args?.includeData) || cfg.includeData,
533
+ includeSecrets: Boolean(args?.includeSecrets) || cfg.includeSecrets,
534
+ maxBytes: Number(args?.maxBytes) || cfg.maxBytes,
535
+ dryRun: Boolean(args?.dryRun),
536
+ });
537
+ return { text: renderExport(result) };
538
+ } catch (error) {
539
+ return fail(error);
540
+ }
541
+ },
542
+ });
543
+
544
+ ctx.tools.register({
545
+ name: 'plugin_pack_inspect',
546
+ description: '读取一个整合包 zip 的 manifest,列出里面的插件、版本、体积、依赖数、patch 行,以及包本身的警告。导入前先看这个。',
547
+ parameters: {
548
+ type: 'object',
549
+ additionalProperties: false,
550
+ properties: {
551
+ file: { type: 'string', description: '整合包 zip 路径' },
552
+ },
553
+ required: ['file'],
554
+ },
555
+ output: TOOL_OUTPUT,
556
+ async execute(args) {
557
+ try {
558
+ const file = args?.file;
559
+ if (!file) return { text: 'ERROR: 需要 file 参数(zip 路径)' };
560
+ return { text: renderInspect(inspectBundle(file)) };
561
+ } catch (error) {
562
+ return fail(error);
563
+ }
564
+ },
565
+ });
566
+
567
+ ctx.tools.register({
568
+ name: 'plugin_pack_import',
569
+ description:
570
+ '导入一个插件整合包:备份现有 package.json / cordis.patch.yml,把插件落到 profile/local-plugins,合并 bundles 与 patch 行,并建立 node_modules 链接。导入后需要重启 dsh。先跑 dryRun 看计划。',
571
+ parameters: {
572
+ type: 'object',
573
+ additionalProperties: false,
574
+ properties: {
575
+ file: { type: 'string', description: '整合包 zip 路径' },
576
+ dryRun: { type: 'boolean', description: '只返回计划,不写任何文件' },
577
+ mode: { type: 'string', enum: ['merge', 'skip'], description: '目标已存在时覆盖还是跳过,默认 merge' },
578
+ overwritePatchConfig: { type: 'boolean', description: '同 id 的 patch 行是否覆盖,默认 false' },
579
+ restoreData: { type: 'boolean', description: '是否还原包里的数据目录,默认 true' },
580
+ linkNodeModules: { type: 'boolean', description: '是否建立 node_modules 链接,默认 true' },
581
+ only: { type: 'array', items: { type: 'string' }, description: '只导入这些包名' },
582
+ },
583
+ required: ['file'],
584
+ },
585
+ output: TOOL_OUTPUT,
586
+ async execute(args) {
587
+ try {
588
+ const file = args?.file;
589
+ if (!file) return { text: 'ERROR: 需要 file 参数(zip 路径)' };
590
+ const result = importBundle({
591
+ file,
592
+ dryRun: Boolean(args?.dryRun),
593
+ mode: args?.mode === 'skip' ? 'skip' : 'merge',
594
+ overwritePatchConfig: Boolean(args?.overwritePatchConfig) || cfg.overwritePatchConfig,
595
+ restoreData: args?.restoreData !== false,
596
+ linkNodeModules: args?.linkNodeModules !== false,
597
+ only: Array.isArray(args?.only) ? args.only : null,
598
+ });
599
+ return { text: renderImport(result) };
600
+ } catch (error) {
601
+ return fail(error);
602
+ }
603
+ },
604
+ });
605
+
606
+ ctx.tools.register({
607
+ name: 'plugin_pack_rollback',
608
+ description: '回滚最近一次(或指定一次)plugin_pack_import:恢复 package.json、cordis.patch.yml、被覆盖的插件目录,删掉本次新增的插件与链接。回滚后需要重启 dsh。',
609
+ parameters: {
610
+ type: 'object',
611
+ additionalProperties: false,
612
+ properties: {
613
+ backup: { type: 'string', description: '备份目录名(如 plugin-pack-20261026-145723)或绝对路径;留空回滚最新一次' },
614
+ },
615
+ },
616
+ output: TOOL_OUTPUT,
617
+ async execute(args) {
618
+ try {
619
+ const result = rollbackImport({ backup: args?.backup });
620
+ return {
621
+ text: [
622
+ `✓ 已回滚到 ${result.backup}`,
623
+ `恢复: ${result.restored.join(', ') || '(无)'}`,
624
+ `删除: ${result.removed.length} 项`,
625
+ `解链: ${result.unlinked.length} 项`,
626
+ '**重启 dsh 生效。**',
627
+ ].join('\n'),
628
+ };
629
+ } catch (error) {
630
+ return fail(error);
631
+ }
632
+ },
633
+ });
634
+
635
+ /* 热加载:不用重启就让客户端界面生效。
636
+ * 原理见 ../hotload.js 的注释:宿主把"不是客户端包"的判定缓存在 pkgMeta 里,
637
+ * 清掉缓存 + 标记 dirty + flush,就能让它重扫当前磁盘上的 package.json。 */
638
+ ctx.tools.register({
639
+ name: 'plugin_pack_hotload',
640
+ description:
641
+ '不重启 dsh,让插件的客户端界面立即生效:清掉宿主对"这个包不是客户端包"的缓存,强制重扫 package.json 与 client bundle,并通过 rebuilt 把新 rev 推给浏览器。改完 client.js 或 package.json 后调它。',
642
+ parameters: {
643
+ type: 'object',
644
+ additionalProperties: false,
645
+ properties: {
646
+ package: { type: 'string', description: '包名,默认 dsh-plugin-pack 自己;也可以传别的插件包名' },
647
+ dir: { type: 'string', description: '包目录绝对路径;省略时按 profile 的 node_modules 解析' },
648
+ },
649
+ },
650
+ output: TOOL_OUTPUT,
651
+ async execute(args) {
652
+ try {
653
+ const local = context(cfg);
654
+ const packageName = args?.package?.trim() || name;
655
+ const packageDir = args?.dir?.trim() || resolvePackageDirFor(packageName, local);
656
+ if (!packageDir) return { text: `ERROR: 找不到包目录: ${packageName}` };
657
+
658
+ const verdict = localVerdict(packageDir);
659
+ const lines = [];
660
+ lines.push(`包: ${packageName}`);
661
+ lines.push(`目录: ${packageDir}`);
662
+ lines.push(
663
+ verdict.ok
664
+ ? `声明检查: ✓ platform=web, client=${verdict.clientPath} (${verdict.bytes} B)`
665
+ : `声明检查: ✗ ${verdict.error}`,
666
+ );
667
+
668
+ const result = reloadClientPackage(ctx, {
669
+ packageName,
670
+ packageDir,
671
+ log: (message) => lines.push(` · ${message}`),
672
+ });
673
+ lines.push('');
674
+ lines.push(result.ok ? `✓ 已进入客户端图表,rev=${result.rev}` : `✗ ${result.error}`);
675
+ if (result.failures?.length) {
676
+ lines.push(`宿主报错 ${result.failures.length} 条:`);
677
+ for (const failure of result.failures.slice(0, 6)) lines.push(` ! ${failure}`);
678
+ }
679
+ lines.push('');
680
+ lines.push('浏览器端:刷新页面(Ctrl+F5)即可看到界面,不必重启 dsh。');
681
+ return { text: lines.join('\n') };
682
+ } catch (error) {
683
+ return fail(error);
684
+ }
685
+ },
686
+ });
687
+
688
+ ctx.tools.register({
689
+ name: 'plugin_pack_client_status',
690
+ description: '查一个插件的客户端界面在宿主图表里的状态:是否已入表、rev、bundle 路径、inject、以及是否在 bootstrap 批次里。排查"插件装上了但界面不显示"时先调这个。',
691
+ parameters: {
692
+ type: 'object',
693
+ additionalProperties: false,
694
+ properties: {
695
+ package: { type: 'string', description: '包名,默认 dsh-plugin-pack 自己' },
696
+ },
697
+ },
698
+ output: TOOL_OUTPUT,
699
+ async execute(args) {
700
+ try {
701
+ const packageName = args?.package?.trim() || name;
702
+ const status = clientStatus(ctx, packageName);
703
+ if (!status.available) return { text: `ERROR: ${status.reason}` };
704
+ const lines = [
705
+ `包: ${packageName}`,
706
+ `在图表里: ${status.inTable ? '✓ 是' : '✗ 否'}`,
707
+ `rev: ${status.rev ?? '(无)'}`,
708
+ `client bundle: ${status.clientPath ?? '(无)'}`,
709
+ `inject: ${Array.isArray(status.inject) ? status.inject.join(', ') || '(空)' : '(无)'}`,
710
+ `immediately: ${status.immediately === true ? 'true' : 'false'}`,
711
+ ];
712
+ if (status.inTable && !status.bootstrapIds.includes(packageName)) {
713
+ lines.push('注: 不在 bootstrap 批次里,界面会按需加载(首次进入相关页面时才拉取)。');
714
+ }
715
+ lines.push('');
716
+ lines.push(`图表内全部包 (${status.entries.length}): ${status.entries.join(', ')}`);
717
+ return { text: lines.join('\n') };
718
+ } catch (error) {
719
+ return fail(error);
720
+ }
721
+ },
722
+ });
723
+ }
724
+
725
+ export default { name, inject, apply };