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.
@@ -0,0 +1,24 @@
1
+ # dsh-plugin-pack bundle patch
2
+ #
3
+ # 只插一行:插件本体。
4
+ # - 宿主侧:工具 plugin_pack_*、/pack 命令、/plugin-pack/* HTTP 路由(lib/index.js + lib/server.js)
5
+ # - 客户端:插件页顶部「导入 / 导出」两个标签页 + 插件行详情 + 设置页(client.js)
6
+ # - 热加载:hotload.js,改完 client 代码或 package.json 不必重启(/pack hotload 或界面按钮)
7
+ - insert:
8
+ - id: dsh-plugin-pack
9
+ name: dsh-plugin-pack
10
+ config:
11
+ enabled: true
12
+ # 空字符串 = $DSH_HOME/exports
13
+ exportDir: ''
14
+ # 默认不打包插件自己的数据目录(可能含 token)
15
+ includeData: false
16
+ # 默认剔除 .env / *.pem / credentials.json 之类
17
+ includeSecrets: false
18
+ # 体积上限,按**未压缩**字节算(压缩比约 1:3)。
19
+ # 默认 2 GiB 原始 ≈ 600~700 MiB 成品,够全量备份;界面里也能改,0 = 不限制。
20
+ maxBytes: 2147483648
21
+ # 导入时是否覆盖已存在的同 id patch 行
22
+ overwritePatchConfig: false
23
+ # 启动时自动把自己的客户端半身热加载一次(失败只记日志,不阻断)
24
+ selfHotload: true
package/hotload.js ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * 客户端 bundle 热加载。
3
+ *
4
+ * 背景(读宿主 dsh-client-modules 源码得出的事实):
5
+ *
6
+ * 1. 宿主启动时扫描 Loader 行 → 读 package.json → 判定 `dsh.client.platform === 'web'`
7
+ * 且 `exports["./client"]` 存在 → 才把包入表,之后 `/plugins/??<id>/client.js&rev=<hash>` 才有内容。
8
+ * 2. 判定结果(包括"不是客户端包"这种否定结论)缓存在 `pkgMeta: Map<sourceKey, meta|null>` 里,
9
+ * **没有任何失效机制** —— `resolveMeta()` 命中缓存就直接返回,从不重读 package.json。
10
+ * 这就是为什么改了 package.json 也看不到界面,必须重启。
11
+ * 3. 唯一能把改动送进图表的是 `rebuilt(id)`,但它只在 `table` 里已有这个包时才有效。
12
+ * 4. `dirty: Set<entryName>` + `flush(onError)` 是重扫入口,`processOne` → `resolveSource`
13
+ * → `resolveMeta` 会**再走一次**判定 —— 只要把缓存删掉,重扫就会读到新值。
14
+ *
15
+ * 所以热加载 = 删缓存 + 标记 dirty + flush,再对已入表的包走 rebuilt。
16
+ * 全部从宿主进程内部做,不需要重启。
17
+ *
18
+ * @module dsh-plugin-pack/hotload
19
+ */
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ /** 宿主服务名。 */
24
+ const SERVICE = 'clientModules';
25
+
26
+ /**
27
+ * 从 ctx 里取出 clientModules 服务实例。
28
+ *
29
+ * 优先 ctx.get(),其次直接取属性 —— 不同 cordis 版本暴露方式略有差别。
30
+ *
31
+ * @param {object} ctx
32
+ * @returns {object|null}
33
+ */
34
+ export function getRegistry(ctx) {
35
+ if (!ctx) return null;
36
+ try {
37
+ const viaGet = typeof ctx.get === 'function' ? ctx.get(SERVICE) : undefined;
38
+ if (viaGet) return viaGet;
39
+ } catch {
40
+ /* 服务不存在时 cordis 会抛 */
41
+ }
42
+ try {
43
+ const direct = ctx[SERVICE];
44
+ if (direct) return direct;
45
+ } catch {
46
+ /* 需要 inject 才能取属性 */
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /** 一个 Loader 行的 name 是否属于这个包。 */
52
+ function isRowOf(loaderName, packageName, packageDir) {
53
+ if (loaderName === packageName) return true;
54
+ // 路径形态的行(patch 里写死绝对路径的插件)
55
+ const normalized = String(loaderName).replace(/\\/g, '/');
56
+ const lowered = normalized.toLowerCase();
57
+ const dir = String(packageDir).replace(/\\/g, '/').toLowerCase();
58
+ if (!lowered.includes('/') && !lowered.includes(':')) return false;
59
+ return lowered === dir || lowered.startsWith(`${dir}/`);
60
+ }
61
+
62
+ /**
63
+ * 收集 registry 里所有和这个包相关的 sourceKey。
64
+ *
65
+ * pkgMeta 的 key 形如 `<baseUrl>\0<loaderName>`,所以要按 loaderName 匹配。
66
+ *
67
+ * @param {object} registry
68
+ * @param {string} packageName
69
+ * @param {string} packageDir
70
+ * @returns {{ sourceKeys: string[], loaderNames: string[] }}
71
+ */
72
+ export function keysForPackage(registry, packageName, packageDir) {
73
+ const sourceKeys = [];
74
+ const loaderNames = new Set();
75
+ const meta = registry?.pkgMeta;
76
+ if (!(meta instanceof Map)) return { sourceKeys, loaderNames };
77
+
78
+ for (const key of meta.keys()) {
79
+ const raw = String(key);
80
+ const at = raw.indexOf('\0');
81
+ const loaderName = at >= 0 ? raw.slice(at + 1) : raw;
82
+ if (isRowOf(loaderName, packageName, packageDir)) {
83
+ sourceKeys.push(key);
84
+ loaderNames.add(loaderName);
85
+ }
86
+ }
87
+ return { sourceKeys, loaderNames: [...loaderNames] };
88
+ }
89
+
90
+ /**
91
+ * 让宿主重新扫描一个包,把改动送进客户端图表。
92
+ *
93
+ * @param {object} ctx cordis 上下文
94
+ * @param {object} options
95
+ * @param {string} options.packageName 包名(Loader 行 name 常见取值)
96
+ * @param {string} options.packageDir 包目录绝对路径(用于匹配路径形态的 Loader 行)
97
+ * @param {(msg: string) => void} [options.log]
98
+ * @returns {object} 结果
99
+ */
100
+ export function reloadClientPackage(ctx, options = {}) {
101
+ const log = typeof options.log === 'function' ? options.log : () => {};
102
+ const packageName = String(options.packageName ?? '').trim();
103
+ const packageDir = String(options.packageDir ?? '').trim();
104
+ if (!packageName) return { ok: false, error: '需要 packageName' };
105
+
106
+ const registry = getRegistry(ctx);
107
+ if (!registry) {
108
+ return {
109
+ ok: false,
110
+ error: `宿主没有 ${SERVICE} 服务(可能不是 Web/桌面端,或版本不支持)`,
111
+ };
112
+ }
113
+
114
+ const steps = [];
115
+ const { sourceKeys, loaderNames } = keysForPackage(registry, packageName, packageDir);
116
+ steps.push(`匹配到 ${sourceKeys.length} 条缓存 / ${loaderNames.length} 个 Loader 行`);
117
+
118
+ // 1) 删掉否定缓存,让 resolveMeta 重读 package.json
119
+ if (registry.pkgMeta instanceof Map) {
120
+ for (const key of sourceKeys) registry.pkgMeta.delete(key);
121
+ steps.push(`已清除 pkgMeta 缓存 ${sourceKeys.length} 条`);
122
+ }
123
+
124
+ // 2) 标记 dirty:processOne 会重新走 resolveSource → resolveMeta
125
+ const names = loaderNames.length ? loaderNames : [packageName];
126
+ if (!(registry.dirty instanceof Set)) {
127
+ return { ok: false, error: 'registry.dirty 不是 Set,宿主版本不兼容' };
128
+ }
129
+ for (const name of names) registry.dirty.add(name);
130
+ steps.push(`已标记 dirty: ${names.join(', ')}`);
131
+
132
+ // 3) 触发重扫
133
+ const failures = [];
134
+ if (typeof registry.flush !== 'function') {
135
+ return { ok: false, error: 'registry.flush 不可用,宿主版本不兼容' };
136
+ }
137
+ registry.flush((error) => failures.push(String(error?.message ?? error)));
138
+ steps.push(`flush 完成,失败 ${failures.length} 条`);
139
+
140
+ // 4) 若已入表,走 rebuilt 让浏览器拿到新 rev
141
+ const inTable = registry.table instanceof Map ? registry.table.has(packageName) : false;
142
+ let rev = null;
143
+ if (inTable && typeof registry.rebuilt === 'function') {
144
+ rev = registry.rebuilt(packageName) ?? null;
145
+ steps.push(`rebuilt → rev=${rev}`);
146
+ } else {
147
+ steps.push('未入表(重扫可能失败,或 Loader 行不属于本包)');
148
+ }
149
+
150
+ for (const step of steps) log(step);
151
+
152
+ return {
153
+ ok: inTable,
154
+ packageName,
155
+ inTable,
156
+ rev,
157
+ clearedCaches: sourceKeys.length,
158
+ loaderNames: names,
159
+ failures,
160
+ steps,
161
+ error: inTable ? null : '重扫后仍不在图表里:检查 dsh.client.platform 是否为 web、exports["./client"] 是否存在、Loader 行 name 是否为包名',
162
+ };
163
+ }
164
+
165
+ /**
166
+ * 只读检查:这个包现在在宿主的客户端图表里吗?
167
+ *
168
+ * @param {object} ctx
169
+ * @param {string} packageName
170
+ * @returns {object}
171
+ */
172
+ export function clientStatus(ctx, packageName) {
173
+ const registry = getRegistry(ctx);
174
+ if (!registry) return { available: false, reason: `没有 ${SERVICE} 服务` };
175
+ const inTable = registry.table instanceof Map ? registry.table.has(packageName) : false;
176
+ const record = inTable ? registry.table.get(packageName) : null;
177
+ const composed = registry.composed ?? null;
178
+ const rows = Array.isArray(composed?.entries) ? composed.entries : [];
179
+ const row = rows.find((item) => item.id === packageName) ?? null;
180
+ return {
181
+ available: true,
182
+ inTable,
183
+ rev: record?.entry?.rev ?? row?.rev ?? null,
184
+ clientPath: record?.meta?.clientPath ?? null,
185
+ inject: record?.meta?.inject ?? null,
186
+ immediately: record?.meta?.immediately ?? null,
187
+ bootstrapIds: composed?.bootstrapIds ?? [],
188
+ entries: rows.map((item) => item.id),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * 直接读包自己的声明,判断"该不该"被宿主接受(不依赖宿主)。
194
+ *
195
+ * @param {string} packageDir
196
+ * @returns {object}
197
+ */
198
+ export function localVerdict(packageDir) {
199
+ const pkgFile = path.join(packageDir, 'package.json');
200
+ let pkg;
201
+ try {
202
+ pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
203
+ } catch (error) {
204
+ return { ok: false, error: `读 package.json 失败: ${error?.message ?? error}` };
205
+ }
206
+ const decl = pkg.dsh?.client;
207
+ if (!decl) return { ok: false, error: '没有 dsh.client 声明', name: pkg.name, version: pkg.version };
208
+ if (decl.platform !== 'web') return { ok: false, error: `platform=${decl.platform},需要 web`, name: pkg.name };
209
+
210
+ let rel = pkg.exports?.['./client'];
211
+ if (rel && typeof rel === 'object') rel = rel.default ?? rel.import ?? rel.require;
212
+ if (typeof rel !== 'string') {
213
+ return { ok: false, error: 'exports 里没有 "./client"', name: pkg.name };
214
+ }
215
+ const clientPath = path.join(packageDir, rel.replace(/^\.\//, ''));
216
+ if (!fs.existsSync(clientPath)) {
217
+ return { ok: false, error: `client bundle 不存在: ${clientPath}`, name: pkg.name };
218
+ }
219
+ const stat = fs.statSync(clientPath);
220
+ return {
221
+ ok: true,
222
+ name: pkg.name,
223
+ version: pkg.version,
224
+ clientPath,
225
+ bytes: stat.size,
226
+ mtimeMs: stat.mtimeMs,
227
+ inject: decl.inject ?? [],
228
+ immediately: decl.immediately === true,
229
+ };
230
+ }
package/lib/export.js ADDED
@@ -0,0 +1,444 @@
1
+ /**
2
+ * 导出:把扫描到的插件连依赖打成 zip 整合包。
3
+ *
4
+ * 包内布局:
5
+ * manifest.json
6
+ * README.txt
7
+ * packages/<插件名>/... 插件本体
8
+ * packages/<插件名>/node_modules/<依赖>/ 扁平化的运行时依赖
9
+ * data/<插件名>/<目录>/... 可选:插件数据
10
+ *
11
+ * @module dsh-plugin-pack/export
12
+ */
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ import { scanProfile } from './scan.js';
18
+ import { writeZipStream } from './zip-stream.js';
19
+ import { writeTgzStream } from './tar.js';
20
+ import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName as resolveProfileName, packSegment, stamp, humanBytes } from './home.js';
21
+ import { walkTree, walkTreeAsync, SKIP_DIRS, fileFilter, ensureDir, removeTree } from './util.js';
22
+
23
+ /** 新整合包格式:内部每个插件是一个 .tgz。 */
24
+ export const PACK_FORMAT_V2 = 'dsh-plugin-pack@2';
25
+ /** 旧格式:内部是散文件。 */
26
+ export const PACK_FORMAT_V1 = 'dsh-plugin-pack@1';
27
+ /** 写出的格式用 v2;导入端同时认 v1 和 v2。 */
28
+ export const PACK_FORMAT = PACK_FORMAT_V2;
29
+ export const GENERATOR = { name: 'dsh-plugin-pack', version: '1.0.0' };
30
+
31
+ /**
32
+ * 生成整合包。
33
+ *
34
+ * @param {object} [options]
35
+ * @param {string} [options.out] 输出文件;目录则自动命名
36
+ * @param {string[]|null} [options.include]
37
+ * @param {string[]} [options.exclude]
38
+ * @param {boolean} [options.includeData]
39
+ * @param {boolean} [options.includeSecrets]
40
+ * @param {number} [options.maxBytes]
41
+ * @param {boolean} [options.dryRun]
42
+ * @param {(message: string) => void} [options.onProgress]
43
+ * @returns {Promise<object>} 导出报告
44
+ */
45
+ export async function exportBundle(options = {}) {
46
+ const env = options.env ?? process.env;
47
+ const home = options.dshHome ? path.resolve(options.dshHome) : resolveDshHome(env);
48
+ const profile = options.profileDir ? path.resolve(options.profileDir) : resolveProfileDir(env);
49
+ const profileLabel = options.profileName ?? resolveProfileName(env);
50
+ const includeSecrets = Boolean(options.includeSecrets);
51
+ // maxBytes:显式传 0 = 不限制;没传才用默认值。
52
+ //
53
+ // 注意这个上限是按**未压缩**字节算的(写入时才知道真实压缩比),
54
+ // 而典型压缩比约 1:3 —— 实测 479 MiB 原始内容产出 166 MiB 的 zip。
55
+ // 所以默认给 2 GiB 原始体积(约 600~700 MiB 成品),
56
+ // 默认 512 MiB 会把正常的全量备份直接挡掉。
57
+ const maxBytes =
58
+ options.maxBytes === 0
59
+ ? Number.POSITIVE_INFINITY
60
+ : Number.isFinite(options.maxBytes) && options.maxBytes > 0
61
+ ? Math.floor(options.maxBytes)
62
+ : 2 * 1024 * 1024 * 1024;
63
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
64
+ const warnings = [];
65
+
66
+ const scan = await scanProfile({
67
+ env,
68
+ dshHome: home,
69
+ profileDir: profile,
70
+ profileName: profileLabel,
71
+ include: options.include ?? null,
72
+ exclude: options.exclude ?? [],
73
+ includeData: Boolean(options.includeData),
74
+ includeSecrets,
75
+ maxBytes,
76
+ // 导出要精确体积,不能用可能过期的缓存
77
+ fresh: true,
78
+ });
79
+ warnings.push(...scan.warnings);
80
+
81
+ const selected = scan.selected;
82
+ if (!selected.length) {
83
+ const error = new Error('没有可导出的插件:当前 profile 里没扫到第三方插件,或者 include/exclude 把它们全过滤掉了');
84
+ error.code = 'NO_PLUGINS';
85
+ throw error;
86
+ }
87
+
88
+ const createdAt = new Date();
89
+ const stampText = stamp(createdAt);
90
+ const manifest = {
91
+ format: PACK_FORMAT,
92
+ createdAt: createdAt.toISOString(),
93
+ generator: GENERATOR,
94
+ // v2:packages/ 下每个插件是一个独立 .tgz
95
+ layout: 'tarballs',
96
+ source: {
97
+ profile: profileLabel,
98
+ profileDir: profile,
99
+ dshHome: home,
100
+ dshVersion: readDshVersion(home),
101
+ platform: process.platform,
102
+ arch: process.arch,
103
+ node: process.version,
104
+ },
105
+ plugins: selected.map((plugin) => ({
106
+ name: plugin.name,
107
+ version: plugin.version,
108
+ // 打包完成后会回填成 packages/<包名>-<版本>.tgz
109
+ tgz: null,
110
+ description: plugin.description,
111
+ kind: plugin.kind,
112
+ sources: plugin.sources,
113
+ declaredName: plugin.declaredName,
114
+ link: plugin.link,
115
+ inBundles: plugin.inBundles,
116
+ bundleIndex: plugin.bundleIndex,
117
+ hasPatch: plugin.hasPatch,
118
+ patchRel: plugin.patchRel,
119
+ client: plugin.client,
120
+ entry: plugin.entry,
121
+ deps: plugin.deps.map((dep) => ({ name: dep.name, version: dep.version, bytes: dep.bytes })),
122
+ patchRows: plugin.patchRows,
123
+ insertRows: plugin.insertRows,
124
+ dataDirs: plugin.dataDirs.map((data) => ({ name: data.name, bytes: data.bytes, files: data.files })),
125
+ bytes: plugin.bytes,
126
+ fileCount: plugin.fileCount,
127
+ warnings: plugin.warnings,
128
+ })),
129
+ profile: {
130
+ bundles: scan.profile.bundles,
131
+ dependencies: scan.profile.dependencies,
132
+ },
133
+ warnings,
134
+ };
135
+
136
+ /** @type {Array<{name: string, file?: string, data?: Buffer|string}>} */
137
+ const entries = [];
138
+ let planned = 0;
139
+ const report = [];
140
+
141
+ /**
142
+ * 把一个插件打成 npm tarball 的 Buffer(异步)。
143
+ *
144
+ * 单个插件导出直接用这个结果;整合包则把它当成 zip 里的一个条目。
145
+ * 两者共用同一段逻辑,保证"单导出的 tgz"和"整合包里的 tgz"完全一致。
146
+ *
147
+ * @param {object} plugin scan 结果里的一个插件
148
+ * @returns {Promise<{ buffer: Buffer, bytes: number, files: number, deps: number, tgzName: string }>}
149
+ */
150
+ const buildPluginTarball = async (plugin) => {
151
+ const keepFile = fileFilter({ includeSecrets });
152
+ const skipDirs = new Set([...SKIP_DIRS, 'node_modules']);
153
+ /** @type {Array<{name: string}>} */
154
+ const tgzEntries = [];
155
+
156
+ const addToTgz = async (prefix, root) => {
157
+ let files = 0;
158
+ let bytes = 0;
159
+ for await (const item of walkTreeAsync(root, { skipDirs, keepFile })) {
160
+ planned += item.size;
161
+ if (planned > maxBytes) {
162
+ const error = new Error(
163
+ `整合包超过体积上限 ${humanBytes(maxBytes)}(已到 ${humanBytes(planned)})。` +
164
+ `在导出页把「体积上限」调大、点「按当前选择自动调大」,或改成「不限制」;也可以只勾选部分插件。`,
165
+ );
166
+ error.code = 'TOO_LARGE';
167
+ throw error;
168
+ }
169
+ // npm tarball 的路径一律在 package/ 下
170
+ tgzEntries.push({ name: `package/${prefix ? `${prefix}/` : ''}${item.rel}`, file: item.path });
171
+ files += 1;
172
+ bytes += item.size;
173
+ }
174
+ return { files, bytes };
175
+ };
176
+
177
+ const main = await addToTgz('', plugin.dir);
178
+ for (const dep of plugin.deps) {
179
+ // 依赖目录必须用**真实包名结构**:@electron/get 要落成 node_modules/@electron/get/,
180
+ // 不能压成 @electron__get —— 那样 Node 的模块解析找不到它(ERR_MODULE_NOT_FOUND)。
181
+ // packSegment 只适合做"单层目录名"(如 local-plugins 下的插件目录),不能用在 node_modules。
182
+ await addToTgz(`node_modules/${dep.name}`, dep.dir);
183
+ }
184
+
185
+ // 写进临时文件再读回 —— writeTgzStream 是写盘接口,这里借一次
186
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'dsh-pack-tgz-'));
187
+ const tmpFile = path.join(tmpDir, 'plugin.tgz');
188
+ try {
189
+ const result = await writeTgzStream(tgzEntries, tmpFile, { maxBytes: maxBytes * 4 });
190
+ const buffer = await fs.promises.readFile(tmpFile);
191
+ return {
192
+ buffer,
193
+ bytes: result.bytes,
194
+ files: main.files,
195
+ deps: plugin.deps.length,
196
+ tgzName: `${packSegment(plugin.name)}-${plugin.version}.tgz`,
197
+ };
198
+ } finally {
199
+ removeTree(tmpDir);
200
+ }
201
+ };
202
+
203
+ for (let index = 0; index < selected.length; index += 1) {
204
+ const plugin = selected[index];
205
+ const pluginSegment = packSegment(plugin.name);
206
+ const tarball = await buildPluginTarball(plugin);
207
+ onProgress(`已打包 ${plugin.name} → ${tarball.tgzName}(${humanBytes(tarball.bytes)})`);
208
+
209
+ // 整合包内每个插件是一个独立的 .tgz,放在 packages/ 下
210
+ const tgzPath = `packages/${tarball.tgzName}`;
211
+ entries.push({ name: tgzPath, data: tarball.buffer });
212
+ // 回填 manifest 里的路径,导入端据此定位
213
+ manifest.plugins[index].tgz = tgzPath;
214
+ manifest.plugins[index].tgzBytes = tarball.bytes;
215
+
216
+ for (const data of plugin.dataDirs) {
217
+ const keepFile = fileFilter({ includeSecrets });
218
+ const skipDirs = new Set([...SKIP_DIRS, 'node_modules']);
219
+ for await (const item of walkTreeAsync(data.path, { skipDirs, keepFile })) {
220
+ planned += item.size;
221
+ if (planned > maxBytes) {
222
+ const error = new Error(`整合包超过体积上限 ${humanBytes(maxBytes)}(已到 ${humanBytes(planned)})。`);
223
+ error.code = 'TOO_LARGE';
224
+ throw error;
225
+ }
226
+ entries.push({ name: `data/${pluginSegment}/${packSegment(data.name)}/${item.rel}`, file: item.path });
227
+ }
228
+ }
229
+
230
+ report.push({
231
+ name: plugin.name,
232
+ version: plugin.version,
233
+ kind: plugin.kind,
234
+ files: tarball.files,
235
+ bytes: tarball.bytes,
236
+ deps: tarball.deps,
237
+ patchRows: plugin.patchRows.length,
238
+ insertRows: plugin.insertRows.length,
239
+ dataDirs: plugin.dataDirs.length,
240
+ tgz: `packages/${tarball.tgzName}`,
241
+ });
242
+
243
+ // 单个插件导出:直接把这个 tarball 当成品
244
+ if (selected.length === 1) {
245
+ const outFile = resolveOutFile(options.out, home, profileLabel, stampText, {
246
+ kind: 'plugin',
247
+ pluginName: plugin.name,
248
+ });
249
+ if (options.dryRun) {
250
+ return {
251
+ ok: true,
252
+ dryRun: true,
253
+ kind: 'plugin',
254
+ file: outFile,
255
+ bytes: tarball.bytes,
256
+ entries: tarball.files,
257
+ plugins: report,
258
+ warnings,
259
+ manifest,
260
+ };
261
+ }
262
+ ensureDir(path.dirname(outFile));
263
+ await fs.promises.writeFile(outFile, tarball.buffer);
264
+ onProgress(`已写出 ${outFile}(${humanBytes(tarball.bytes)})`);
265
+ return {
266
+ ok: true,
267
+ kind: 'plugin',
268
+ file: outFile,
269
+ bytes: tarball.bytes,
270
+ rawBytes: planned,
271
+ entries: tarball.files,
272
+ plugins: report,
273
+ warnings,
274
+ manifest,
275
+ };
276
+ }
277
+ }
278
+
279
+ /* 走到这里一定是多个插件 → 整合包(.zip)。
280
+ * 单个插件已在上面循环里直接返回 .tgz 了。
281
+ *
282
+ * 整合包里的每个插件是一个独立的 .tgz(packages/<包名>-<版本>.tgz),
283
+ * 这样:
284
+ * - 包内结构对人是可读的(一眼看出有哪些插件、各自多大)
285
+ * - 导入时可以只解自己要的那几个
286
+ * - 单个 tgz 抽出来就能直接 npm i,与单独导出的文件完全等价
287
+ */
288
+ const outFile = resolveOutFile(options.out, home, profileLabel, stampText, { kind: 'bundle' });
289
+
290
+ const readme = renderReadme(manifest, report, createdAt);
291
+ const head = [
292
+ { name: 'manifest.json', data: `${JSON.stringify(manifest, null, 2)}\n` },
293
+ { name: 'README.txt', data: readme },
294
+ ];
295
+
296
+ if (options.dryRun) {
297
+ return {
298
+ ok: true,
299
+ dryRun: true,
300
+ kind: 'bundle',
301
+ file: outFile,
302
+ bytes: head.reduce((sum, entry) => sum + Buffer.byteLength(entry.data), 0) + planned,
303
+ entries: entries.length + head.length,
304
+ plugins: report,
305
+ warnings,
306
+ manifest,
307
+ };
308
+ }
309
+
310
+ ensureDir(path.dirname(outFile));
311
+
312
+ let progressBytes = 0;
313
+ const result = await writeZipStream([...head, ...entries], outFile, {
314
+ maxBytes: maxBytes * 4, // 压缩前的原始总量上限
315
+ onEntry: (name, bytes) => {
316
+ progressBytes += bytes;
317
+ if (progressBytes > 0 && progressBytes % (32 * 1024 * 1024) < bytes) {
318
+ onProgress(`已写入 ${humanBytes(progressBytes)} / ${humanBytes(planned)}`);
319
+ }
320
+ },
321
+ });
322
+ onProgress(`已写出 ${outFile}(${humanBytes(result.bytes)})`);
323
+
324
+ return {
325
+ ok: true,
326
+ kind: 'bundle',
327
+ file: outFile,
328
+ bytes: result.bytes,
329
+ rawBytes: planned,
330
+ entries: result.entries,
331
+ plugins: report,
332
+ warnings,
333
+ manifest,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * 解析输出路径;传目录时自动生成文件名。
339
+ *
340
+ * 三条规则按顺序:
341
+ * 1. 空 → $DSH_HOME/exports/<默认名>
342
+ * 2. 是已存在的目录,或字符串以分隔符结尾 → 当成目录,拼默认文件名
343
+ * 3. 已带正确扩展名 → 原样;否则补扩展名
344
+ *
345
+ * 扩展名跟着导出类型走:
346
+ * - 单个插件 → .tgz(npm tarball,包名化文件名)
347
+ * - 多个插件 → .zip(整合包)
348
+ *
349
+ * @param {string} raw
350
+ * @param {string} home
351
+ * @param {string} profileLabel
352
+ * @param {string} stampText
353
+ * @param {{ kind?: 'plugin'|'bundle', pluginName?: string }} [options]
354
+ */
355
+ export function resolveOutFile(raw, home, profileLabel, stampText, options = {}) {
356
+ const kind = options.kind === 'plugin' ? 'plugin' : 'bundle';
357
+ const ext = kind === 'plugin' ? '.tgz' : '.zip';
358
+ const fallbackName =
359
+ kind === 'plugin'
360
+ ? // npm tarball 惯例用「包名-版本」或「包名-时间戳」,这里用包名 + 时间戳便于区分
361
+ `${slugForFile(options.pluginName ?? 'plugin')}-${stampText}.tgz`
362
+ : `dsh-plugin-pack-${profileLabel}-${stampText}.zip`;
363
+
364
+ if (!raw || !String(raw).trim()) {
365
+ return path.join(home, 'exports', fallbackName);
366
+ }
367
+ const text = String(raw).trim();
368
+ const target = path.resolve(text);
369
+ let stat = null;
370
+ try {
371
+ stat = fs.statSync(target);
372
+ } catch {
373
+ stat = null;
374
+ }
375
+ if (stat?.isDirectory()) return path.join(target, fallbackName);
376
+ // 末尾带分隔符:用户明确指了"这个目录",即使它还不存在
377
+ if (/[\\/]$/.test(text)) return path.join(target, fallbackName);
378
+ if (stat?.isFile()) return target;
379
+ // 已经带了本类型该有的扩展名就原样;否则补上
380
+ if (target.toLowerCase().endsWith(ext)) return target;
381
+ // 用户明确写成了另一种扩展名(比如单插件却写了 .zip)→ 尊重用户选择
382
+ if (kind === 'plugin' && target.toLowerCase().endsWith('.zip')) return target;
383
+ if (kind === 'bundle' && target.toLowerCase().endsWith('.tgz')) return target;
384
+ return `${target}${ext}`;
385
+ }
386
+
387
+ /** 文件名里安全的包名(scoped 包把 / 换成 --)。 */
388
+ function slugForFile(name) {
389
+ return (
390
+ String(name ?? '')
391
+ .replace(/^@/, '')
392
+ .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
393
+ .replace(/\s+/g, '-')
394
+ .slice(0, 80) || 'plugin'
395
+ );
396
+ }
397
+
398
+ /** 读 dsh 版本(有就读,没有就空)。 */
399
+ function readDshVersion(home) {
400
+ try {
401
+ const pkg = JSON.parse(fs.readFileSync(path.join(home, 'version.json'), 'utf8'));
402
+ return pkg.version ?? null;
403
+ } catch {
404
+ return null;
405
+ }
406
+ }
407
+
408
+ /** 包内 README。 */
409
+ function renderReadme(manifest, report, createdAt) {
410
+ const lines = [
411
+ 'dsh-plugin-pack 整合包(v2 布局)',
412
+ '===============================',
413
+ '',
414
+ `生成时间: ${createdAt.toISOString()}`,
415
+ `来源 profile: ${manifest.source.profile}`,
416
+ `来源目录: ${manifest.source.profileDir}`,
417
+ `插件数量: ${report.length}`,
418
+ '',
419
+ '包内结构:',
420
+ ' manifest.json 插件清单与配置',
421
+ ' packages/<包名>-<版本>.tgz 每个插件一个 npm tarball',
422
+ ' data/... 可选,插件数据目录',
423
+ '',
424
+ '每个 .tgz 都是标准的 npm 包(package/ 前缀),可以单独取出来用:',
425
+ ' npm i ./packages/<包名>-<版本>.tgz',
426
+ '',
427
+ '插件清单:',
428
+ ...report.map(
429
+ (item) =>
430
+ ` - ${item.name}@${item.version} ${humanBytes(item.bytes)} / 依赖 ${item.deps} 个${item.tgz ? ` → ${item.tgz}` : ''}`,
431
+ ),
432
+ '',
433
+ '导入方式(任选一种):',
434
+ ' 1. dsh 里拖进「插件 → 导入」的框',
435
+ ' 2. 命令行: dsh-plugin-pack import <这个 zip 的路径>',
436
+ ' 3. 让 agent 调用: plugin_pack_import({ file: "<路径>" })',
437
+ '',
438
+ '整包和单个 .tgz 都能导入:拖 zip 是装整批,拖 tgz 是只装那一个。',
439
+ '导入会先把 package.json 和 cordis.patch.yml 备份到 profile 的 .plugin-backups/ 下,',
440
+ '然后把插件落到 local-plugins/ 并写回 profile 配置。导入完成后重启 dsh 生效。',
441
+ '',
442
+ ];
443
+ return `${lines.join('\n')}\n`;
444
+ }