dsh-plugin-pack 0.0.0-stage → 1.0.3
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/LICENSE +21 -0
- package/README.md +537 -3
- package/bin/dsh-plugin-pack.js +200 -0
- package/client.js +2337 -0
- package/cordis.patch.yml +24 -0
- package/hotload.js +230 -0
- package/lib/export.js +452 -0
- package/lib/home.js +75 -0
- package/lib/import-plugin.js +356 -0
- package/lib/import-tgz.js +115 -0
- package/lib/import.js +831 -0
- package/lib/inbox.js +194 -0
- package/lib/index.js +725 -0
- package/lib/jobs.js +208 -0
- package/lib/patch-text.js +672 -0
- package/lib/scan.js +488 -0
- package/lib/server.js +926 -0
- package/lib/tar.js +241 -0
- package/lib/util.js +333 -0
- package/lib/zip-stream.js +166 -0
- package/lib/zip.js +514 -0
- package/package.json +72 -6
package/lib/export.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
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
|
+
// 但 `xxx.tgz\` 这种是误操作(把文件当目录)——
|
|
378
|
+
// 去掉分隔符后如果像个带扩展名的文件名,就按文件处理,别再拼一层。
|
|
379
|
+
if (/[\\/]$/.test(text)) {
|
|
380
|
+
const trimmed = text.replace(/[\\/]+$/, '');
|
|
381
|
+
if (/\.[a-z0-9]{2,8}$/i.test(path.basename(trimmed))) {
|
|
382
|
+
return path.resolve(trimmed);
|
|
383
|
+
}
|
|
384
|
+
return path.join(target, fallbackName);
|
|
385
|
+
}
|
|
386
|
+
if (stat?.isFile()) return target;
|
|
387
|
+
// 已经带了本类型该有的扩展名就原样;否则补上
|
|
388
|
+
if (target.toLowerCase().endsWith(ext)) return target;
|
|
389
|
+
// 用户明确写成了另一种扩展名(比如单插件却写了 .zip)→ 尊重用户选择
|
|
390
|
+
if (kind === 'plugin' && target.toLowerCase().endsWith('.zip')) return target;
|
|
391
|
+
if (kind === 'bundle' && target.toLowerCase().endsWith('.tgz')) return target;
|
|
392
|
+
return `${target}${ext}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** 文件名里安全的包名(scoped 包把 / 换成 --)。 */
|
|
396
|
+
function slugForFile(name) {
|
|
397
|
+
return (
|
|
398
|
+
String(name ?? '')
|
|
399
|
+
.replace(/^@/, '')
|
|
400
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
|
|
401
|
+
.replace(/\s+/g, '-')
|
|
402
|
+
.slice(0, 80) || 'plugin'
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 读 dsh 版本(有就读,没有就空)。 */
|
|
407
|
+
function readDshVersion(home) {
|
|
408
|
+
try {
|
|
409
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(home, 'version.json'), 'utf8'));
|
|
410
|
+
return pkg.version ?? null;
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 包内 README。 */
|
|
417
|
+
function renderReadme(manifest, report, createdAt) {
|
|
418
|
+
const lines = [
|
|
419
|
+
'dsh-plugin-pack 整合包(v2 布局)',
|
|
420
|
+
'===============================',
|
|
421
|
+
'',
|
|
422
|
+
`生成时间: ${createdAt.toISOString()}`,
|
|
423
|
+
`来源 profile: ${manifest.source.profile}`,
|
|
424
|
+
`来源目录: ${manifest.source.profileDir}`,
|
|
425
|
+
`插件数量: ${report.length}`,
|
|
426
|
+
'',
|
|
427
|
+
'包内结构:',
|
|
428
|
+
' manifest.json 插件清单与配置',
|
|
429
|
+
' packages/<包名>-<版本>.tgz 每个插件一个 npm tarball',
|
|
430
|
+
' data/... 可选,插件数据目录',
|
|
431
|
+
'',
|
|
432
|
+
'每个 .tgz 都是标准的 npm 包(package/ 前缀),可以单独取出来用:',
|
|
433
|
+
' npm i ./packages/<包名>-<版本>.tgz',
|
|
434
|
+
'',
|
|
435
|
+
'插件清单:',
|
|
436
|
+
...report.map(
|
|
437
|
+
(item) =>
|
|
438
|
+
` - ${item.name}@${item.version} ${humanBytes(item.bytes)} / 依赖 ${item.deps} 个${item.tgz ? ` → ${item.tgz}` : ''}`,
|
|
439
|
+
),
|
|
440
|
+
'',
|
|
441
|
+
'导入方式(任选一种):',
|
|
442
|
+
' 1. dsh 里拖进「插件 → 导入」的框',
|
|
443
|
+
' 2. 命令行: dsh-plugin-pack import <这个 zip 的路径>',
|
|
444
|
+
' 3. 让 agent 调用: plugin_pack_import({ file: "<路径>" })',
|
|
445
|
+
'',
|
|
446
|
+
'整包和单个 .tgz 都能导入:拖 zip 是装整批,拖 tgz 是只装那一个。',
|
|
447
|
+
'导入会先把 package.json 和 cordis.patch.yml 备份到 profile 的 .plugin-backups/ 下,',
|
|
448
|
+
'然后把插件落到 local-plugins/ 并写回 profile 配置。导入完成后重启 dsh 生效。',
|
|
449
|
+
'',
|
|
450
|
+
];
|
|
451
|
+
return `${lines.join('\n')}\n`;
|
|
452
|
+
}
|
package/lib/home.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路径与运行环境解析。
|
|
3
|
+
*
|
|
4
|
+
* dsh 在运行时注入 DSH_HOME / DSH_PROFILE / DSH_PROFILE_DIR,
|
|
5
|
+
* 但 CLI 直接跑时这些变量可能不存在,所以要有和宿主一致的兜底。
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-plugin-pack/home
|
|
8
|
+
*/
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
/** DSH 主目录。 */
|
|
13
|
+
export function dshHome(env = process.env) {
|
|
14
|
+
const raw = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : '';
|
|
15
|
+
return raw ? path.resolve(raw) : path.join(os.homedir(), '.dsh');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 当前 profile 名。 */
|
|
19
|
+
export function profileName(env = process.env) {
|
|
20
|
+
const raw = typeof env.DSH_PROFILE === 'string' ? env.DSH_PROFILE.trim() : '';
|
|
21
|
+
return raw || 'desktop';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 当前 profile 目录。 */
|
|
25
|
+
export function profileDir(env = process.env) {
|
|
26
|
+
const raw = typeof env.DSH_PROFILE_DIR === 'string' ? env.DSH_PROFILE_DIR.trim() : '';
|
|
27
|
+
return raw ? path.resolve(raw) : path.join(dshHome(env), 'profiles', profileName(env));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 把任意包名/目录名变成可安全落进 zip 的段名。 */
|
|
31
|
+
export function safeSegment(value) {
|
|
32
|
+
const text = String(value ?? '').trim();
|
|
33
|
+
const cleaned = text
|
|
34
|
+
.replace(/\\/g, '/')
|
|
35
|
+
.replace(/^\/+|\/+$/g, '')
|
|
36
|
+
.replace(/[<>:"|?*\u0000-\u001f]/g, '_')
|
|
37
|
+
.replace(/\.\./g, '__');
|
|
38
|
+
return cleaned || 'plugin';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 包名 → 单层目录名。scoped 包(@local/x)会被压成 `@local__x`,
|
|
43
|
+
* 这样 zip 里的 `packages/<段>/` 永远只有一层,解包不会踩到路径歧义。
|
|
44
|
+
*/
|
|
45
|
+
export function packSegment(name) {
|
|
46
|
+
return safeSegment(String(name ?? '').replace(/\//g, '__'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `20261026-145723` 形式的时间戳。 */
|
|
50
|
+
export function stamp(date = new Date()) {
|
|
51
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
52
|
+
return [
|
|
53
|
+
date.getFullYear(),
|
|
54
|
+
pad(date.getMonth() + 1),
|
|
55
|
+
pad(date.getDate()),
|
|
56
|
+
'-',
|
|
57
|
+
pad(date.getHours()),
|
|
58
|
+
pad(date.getMinutes()),
|
|
59
|
+
pad(date.getSeconds()),
|
|
60
|
+
].join('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 人类可读体积。 */
|
|
64
|
+
export function humanBytes(bytes) {
|
|
65
|
+
const n = Number(bytes) || 0;
|
|
66
|
+
if (n < 1024) return `${n} B`;
|
|
67
|
+
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
|
|
68
|
+
let value = n / 1024;
|
|
69
|
+
let unit = 0;
|
|
70
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
71
|
+
value /= 1024;
|
|
72
|
+
unit += 1;
|
|
73
|
+
}
|
|
74
|
+
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unit]}`;
|
|
75
|
+
}
|