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/LICENSE +21 -0
- package/README.md +537 -3
- package/bin/dsh-plugin-pack.js +200 -0
- package/client.js +2320 -0
- package/cordis.patch.yml +24 -0
- package/hotload.js +230 -0
- package/lib/export.js +444 -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/scan.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扫描当前 profile 里所有「第三方插件」,以及它们各自的依赖闭包、
|
|
3
|
+
* 在 cordis.patch.yml 里的配置行、可选的插件数据目录。
|
|
4
|
+
*
|
|
5
|
+
* 这里不碰网络、不写文件,只读。
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-plugin-pack/scan
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName as resolveProfileName } from './home.js';
|
|
14
|
+
import { readJson, readText, realOrSelf, isDir, isFile, measureTree, measureTreeAsync, SKIP_DIRS, fileFilter } from './util.js';
|
|
15
|
+
import { splitTopLevel, splitInsertChildren, entryMatchesPlugin } from './patch-text.js';
|
|
16
|
+
|
|
17
|
+
/** 官方包前缀:宿主自带,不进整合包。 */
|
|
18
|
+
const OFFICIAL_PREFIX = '@deepseek-ai/';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 扫描结果缓存。
|
|
22
|
+
*
|
|
23
|
+
* 一次完整扫描要 3 秒(dsh-ops 9920 个文件、lark-plus 1033 个,每个都要 stat)。
|
|
24
|
+
* 界面一打开就调 status,等于每次干等 3 秒 —— 这就是"加载不出来"的主因。
|
|
25
|
+
* 缓存 60 秒,配置文件 mtime 变了就失效。
|
|
26
|
+
*
|
|
27
|
+
* @type {Map<string, { at: number, value: object, pkgMtime: number, patchMtime: number }>}
|
|
28
|
+
*/
|
|
29
|
+
const scanCache = new Map();
|
|
30
|
+
const SCAN_TTL_MS = 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** 清空缓存(导入/导出/回滚后调用,保证下次读到新的体积)。 */
|
|
33
|
+
export function clearScanCache() {
|
|
34
|
+
scanCache.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 这些包永远由宿主提供,即便出现在 dependencies 里也不打包。 */
|
|
38
|
+
const HOST_PROVIDED = new Set([
|
|
39
|
+
'react',
|
|
40
|
+
'react-dom',
|
|
41
|
+
'@deepseek-ai/cordis',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** 一个包是不是官方/宿主提供。 */
|
|
45
|
+
export function isOfficial(name) {
|
|
46
|
+
return typeof name === 'string' && (name.startsWith(OFFICIAL_PREFIX) || HOST_PROVIDED.has(name));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 路径插件(patch 里写死绝对路径)的判断。 */
|
|
50
|
+
export function isAbsoluteSpec(value) {
|
|
51
|
+
return typeof value === 'string' && (/^[A-Za-z]:[\\/]/.test(value) || value.startsWith('/'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 扫描 profile。
|
|
56
|
+
*
|
|
57
|
+
* @param {object} [options]
|
|
58
|
+
* @param {string} [options.profileDir]
|
|
59
|
+
* @param {string} [options.dshHome]
|
|
60
|
+
* @param {string[]|null} [options.include] 只导出这些包名
|
|
61
|
+
* @param {string[]} [options.exclude] 排除这些包名
|
|
62
|
+
* @param {boolean} [options.includeData] 是否收录插件数据目录
|
|
63
|
+
* @param {boolean} [options.includeSecrets] 是否收录 .env / *.pem 等
|
|
64
|
+
* @param {number} [options.maxBytes] 超过就抛错
|
|
65
|
+
* @param {(root: string, options: object) => Promise<{bytes:number,files:number}>} [options.measure]
|
|
66
|
+
* 测目录体积的实现。默认用异步版(不阻塞事件循环);测试可注入同步版。
|
|
67
|
+
* @param {boolean} [options.fresh] 跳过缓存强制重扫(导出等需要精确体积的场景)
|
|
68
|
+
* @returns {Promise<object>} 扫描结果
|
|
69
|
+
*/
|
|
70
|
+
export async function scanProfile(options = {}) {
|
|
71
|
+
const env = options.env ?? process.env;
|
|
72
|
+
const home = options.dshHome ? path.resolve(options.dshHome) : resolveDshHome(env);
|
|
73
|
+
const dir = options.profileDir ? path.resolve(options.profileDir) : resolveProfileDir(env);
|
|
74
|
+
const name = options.profileName ?? resolveProfileName(env);
|
|
75
|
+
const includeSecrets = Boolean(options.includeSecrets);
|
|
76
|
+
const includeData = Boolean(options.includeData);
|
|
77
|
+
const warnings = [];
|
|
78
|
+
|
|
79
|
+
const pkgFile = path.join(dir, 'package.json');
|
|
80
|
+
const patchFile = path.join(dir, 'cordis.patch.yml');
|
|
81
|
+
|
|
82
|
+
/* 缓存:一次完整扫描要 3 秒(dsh-ops 9920 个文件、lark-plus 1033 个,
|
|
83
|
+
* 每个都要 stat)。界面一打开就调 status,等于每次干等 3 秒。
|
|
84
|
+
* 缓存 60 秒,且配置文件 mtime 一变就失效。 */
|
|
85
|
+
const cacheKey = [dir, includeSecrets ? 's' : '', includeData ? 'd' : '', (options.include ?? []).join('|'), (options.exclude ?? []).join('|')].join('\0');
|
|
86
|
+
if (!options.fresh) {
|
|
87
|
+
const cached = scanCache.get(cacheKey);
|
|
88
|
+
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
|
+
}
|
|
95
|
+
if (stampOk) return cached.value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const pkg = readJson(pkgFile, null);
|
|
100
|
+
if (!pkg) warnings.push(`没有找到 profile 的 package.json: ${pkgFile}`);
|
|
101
|
+
|
|
102
|
+
const bundles = Array.isArray(pkg?.dsh?.profile?.bundles) ? [...pkg.dsh.profile.bundles] : [];
|
|
103
|
+
const dependencies = pkg?.dependencies && typeof pkg.dependencies === 'object' ? { ...pkg.dependencies } : {};
|
|
104
|
+
|
|
105
|
+
const patchText = readText(patchFile, '') ?? '';
|
|
106
|
+
const topEntries = splitTopLevel(patchText);
|
|
107
|
+
|
|
108
|
+
/** @type {Map<string, object>} key 用真实目录,避免同一插件多路命中。 */
|
|
109
|
+
const found = new Map();
|
|
110
|
+
|
|
111
|
+
const addCandidate = (candidate) => {
|
|
112
|
+
const rawDir = candidate.dir ? path.resolve(candidate.dir) : null;
|
|
113
|
+
if (!rawDir || !isDir(rawDir)) return;
|
|
114
|
+
const realDir = realOrSelf(rawDir);
|
|
115
|
+
const key = realDir.toLowerCase();
|
|
116
|
+
const existing = found.get(key);
|
|
117
|
+
if (existing) {
|
|
118
|
+
existing.sources.add(candidate.source);
|
|
119
|
+
if (candidate.inBundles) {
|
|
120
|
+
existing.inBundles = true;
|
|
121
|
+
existing.bundleIndex = candidate.bundleIndex ?? existing.bundleIndex;
|
|
122
|
+
}
|
|
123
|
+
if (candidate.link && !existing.link) existing.link = candidate.link;
|
|
124
|
+
if (candidate.declaredName && !existing.declaredName) existing.declaredName = candidate.declaredName;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
found.set(key, {
|
|
128
|
+
dir: realDir,
|
|
129
|
+
declaredName: candidate.declaredName ?? null,
|
|
130
|
+
link: candidate.link ?? null,
|
|
131
|
+
inBundles: Boolean(candidate.inBundles),
|
|
132
|
+
bundleIndex: candidate.bundleIndex ?? -1,
|
|
133
|
+
sources: new Set([candidate.source]),
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// 1) bundles 里声明的插件
|
|
138
|
+
bundles.forEach((bundle, index) => {
|
|
139
|
+
if (typeof bundle !== 'string' || isOfficial(bundle)) return;
|
|
140
|
+
const resolved = resolvePackageDir(bundle, dir, dependencies);
|
|
141
|
+
if (!resolved) {
|
|
142
|
+
warnings.push(`bundles 里的 ${bundle} 没有安装到 ${dir}/node_modules,已跳过`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
addCandidate({ dir: resolved, declaredName: bundle, link: dependencies[bundle] ?? null, inBundles: true, bundleIndex: index, source: 'bundles' });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// 2) dependencies 里声明但没进 bundles 的(可能是插件,也可能只是工具库)
|
|
149
|
+
for (const [depName, spec] of Object.entries(dependencies)) {
|
|
150
|
+
if (isOfficial(depName)) continue;
|
|
151
|
+
if ([...found.values()].some((item) => item.declaredName === depName)) continue;
|
|
152
|
+
const resolved = resolvePackageDir(depName, dir, dependencies);
|
|
153
|
+
if (!resolved) continue;
|
|
154
|
+
addCandidate({ dir: resolved, declaredName: depName, link: spec, inBundles: false, source: 'dependencies' });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 3) local-plugins 下手工放的
|
|
158
|
+
const localPluginsDir = path.join(dir, 'local-plugins');
|
|
159
|
+
if (isDir(localPluginsDir)) {
|
|
160
|
+
for (const entry of fs.readdirSync(localPluginsDir, { withFileTypes: true })) {
|
|
161
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
162
|
+
if (entry.name.startsWith('.')) continue;
|
|
163
|
+
addCandidate({ dir: path.join(localPluginsDir, entry.name), declaredName: null, inBundles: false, source: 'local-plugins' });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 4) cordis.patch.yml 里用绝对路径 insert 的(如 pwsh7-preference)
|
|
168
|
+
for (const entry of topEntries) {
|
|
169
|
+
for (const value of entry.names) {
|
|
170
|
+
if (!isAbsoluteSpec(value)) continue;
|
|
171
|
+
const target = isFile(value) ? path.dirname(value) : value;
|
|
172
|
+
addCandidate({ dir: target, declaredName: null, inBundles: false, source: 'patch-path' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const plugins = [];
|
|
177
|
+
for (const item of found.values()) {
|
|
178
|
+
const plugin = await buildPlugin(item, {
|
|
179
|
+
profileDir: dir,
|
|
180
|
+
dshHome: home,
|
|
181
|
+
topEntries,
|
|
182
|
+
includeSecrets,
|
|
183
|
+
includeData,
|
|
184
|
+
warnings,
|
|
185
|
+
measure: options.measure,
|
|
186
|
+
});
|
|
187
|
+
if (!plugin) continue;
|
|
188
|
+
// dependencies 来源可能是纯工具库:既不是 dsh-* 命名,也没有 dsh 字段,
|
|
189
|
+
// 也没进 bundles / 没被 patch 引用 —— 标记出来但默认不选中。
|
|
190
|
+
const looksLikePlugin =
|
|
191
|
+
plugin.inBundles ||
|
|
192
|
+
plugin.name.startsWith('dsh-') ||
|
|
193
|
+
plugin.hasPatch ||
|
|
194
|
+
Boolean(plugin.client) ||
|
|
195
|
+
plugin.patchRows.length > 0 ||
|
|
196
|
+
plugin.insertRows.length > 0 ||
|
|
197
|
+
item.sources.has('local-plugins') ||
|
|
198
|
+
item.sources.has('patch-path');
|
|
199
|
+
if (!looksLikePlugin) plugin.skipped = 'not-plugin';
|
|
200
|
+
plugins.push(plugin);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 排序:bundles 顺序优先,其余按名字
|
|
204
|
+
plugins.sort((a, b) => {
|
|
205
|
+
if (a.bundleIndex !== b.bundleIndex) {
|
|
206
|
+
if (a.bundleIndex < 0) return 1;
|
|
207
|
+
if (b.bundleIndex < 0) return -1;
|
|
208
|
+
return a.bundleIndex - b.bundleIndex;
|
|
209
|
+
}
|
|
210
|
+
return a.name.localeCompare(b.name);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// include 的三种语义要分清:
|
|
214
|
+
// 未传 / 非数组 → null,表示"不过滤",导出全部
|
|
215
|
+
// 空数组 → 空 Set,表示"一个都不要"(导出端会因此报 NO_PLUGINS)
|
|
216
|
+
// 有内容的数组 → 只导出这些
|
|
217
|
+
// 以前空数组被当成"不过滤",导致界面上误传 [] 时会把全部插件都打进去。
|
|
218
|
+
const include = Array.isArray(options.include) ? new Set(options.include) : null;
|
|
219
|
+
const exclude = new Set(Array.isArray(options.exclude) ? options.exclude : []);
|
|
220
|
+
const selected = plugins.filter((plugin) => {
|
|
221
|
+
if (include && !include.has(plugin.name)) return false;
|
|
222
|
+
if (exclude.has(plugin.name)) return false;
|
|
223
|
+
if (plugin.skipped === 'not-plugin' && !include) return false;
|
|
224
|
+
return true;
|
|
225
|
+
});
|
|
226
|
+
for (const plugin of plugins) {
|
|
227
|
+
if (!selected.includes(plugin) && !plugin.skipped) plugin.skipped = 'filtered';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const totals = selected.reduce(
|
|
231
|
+
(acc, plugin) => {
|
|
232
|
+
acc.bytes += plugin.bytes;
|
|
233
|
+
acc.files += plugin.fileCount;
|
|
234
|
+
return acc;
|
|
235
|
+
},
|
|
236
|
+
{ bytes: 0, files: 0 },
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const result = {
|
|
240
|
+
format: 'dsh-plugin-pack@1',
|
|
241
|
+
profile: { name, dir, dshHome: home, bundles, dependencies, patchFile },
|
|
242
|
+
plugins,
|
|
243
|
+
selected,
|
|
244
|
+
totals,
|
|
245
|
+
warnings,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// 写入缓存(记下两份配置的 mtime,之后据此判断失效)
|
|
249
|
+
try {
|
|
250
|
+
scanCache.set(cacheKey, {
|
|
251
|
+
at: Date.now(),
|
|
252
|
+
value: result,
|
|
253
|
+
pkgMtime: fs.statSync(pkgFile).mtimeMs,
|
|
254
|
+
patchMtime: fs.statSync(patchFile).mtimeMs,
|
|
255
|
+
});
|
|
256
|
+
} catch {
|
|
257
|
+
/* 缓存可有可无 */
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 在 profile 的解析上下文里找一个包目录。 */
|
|
264
|
+
export function resolvePackageDir(name, profileDir, dependencies = {}) {
|
|
265
|
+
if (!name) return null;
|
|
266
|
+
const segments = name.split('/');
|
|
267
|
+
const direct = path.join(profileDir, 'node_modules', ...segments);
|
|
268
|
+
if (isFile(path.join(direct, 'package.json'))) return realOrSelf(direct);
|
|
269
|
+
|
|
270
|
+
const spec = dependencies[name];
|
|
271
|
+
if (typeof spec === 'string' && (spec.startsWith('link:') || spec.startsWith('file:'))) {
|
|
272
|
+
const target = path.resolve(profileDir, spec.slice(spec.indexOf(':') + 1));
|
|
273
|
+
if (isDir(target) && isFile(path.join(target, 'package.json'))) return realOrSelf(target);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const require = createRequire(path.join(profileDir, 'package.json'));
|
|
278
|
+
let current = path.dirname(require.resolve(name));
|
|
279
|
+
while (current && current !== path.dirname(current)) {
|
|
280
|
+
if (isFile(path.join(current, 'package.json'))) return realOrSelf(current);
|
|
281
|
+
current = path.dirname(current);
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
/* ESM-only 包 require.resolve 会失败,走上面的目录探测 */
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 从一个包目录向上找 node_modules/<dep>。 */
|
|
290
|
+
function resolveDepDir(fromDir, depName, profileDir) {
|
|
291
|
+
const segments = depName.split('/');
|
|
292
|
+
let current = fromDir;
|
|
293
|
+
for (let i = 0; i < 12; i += 1) {
|
|
294
|
+
const candidate = path.join(current, 'node_modules', ...segments);
|
|
295
|
+
if (isFile(path.join(candidate, 'package.json'))) return realOrSelf(candidate);
|
|
296
|
+
const parent = path.dirname(current);
|
|
297
|
+
if (parent === current) break;
|
|
298
|
+
current = parent;
|
|
299
|
+
}
|
|
300
|
+
const fallback = path.join(profileDir, 'node_modules', ...segments);
|
|
301
|
+
if (isFile(path.join(fallback, 'package.json'))) return realOrSelf(fallback);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 解析包入口(相对包目录)。 */
|
|
306
|
+
export function resolveEntry(dir, meta) {
|
|
307
|
+
const candidates = [];
|
|
308
|
+
const exp = meta?.exports;
|
|
309
|
+
if (typeof exp === 'string') candidates.push(exp);
|
|
310
|
+
else if (exp && typeof exp === 'object') {
|
|
311
|
+
const dot = exp['.'] ?? exp;
|
|
312
|
+
if (typeof dot === 'string') candidates.push(dot);
|
|
313
|
+
else if (dot && typeof dot === 'object') {
|
|
314
|
+
for (const key of ['import', 'node', 'default', 'require']) {
|
|
315
|
+
if (typeof dot[key] === 'string') {
|
|
316
|
+
candidates.push(dot[key]);
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (typeof meta?.main === 'string') candidates.push(meta.main);
|
|
323
|
+
candidates.push('index.js', 'index.mjs', 'index.cjs', 'lib/index.js', 'dist/index.js');
|
|
324
|
+
for (const candidate of candidates) {
|
|
325
|
+
const target = path.resolve(dir, candidate);
|
|
326
|
+
if (isFile(target)) return path.relative(dir, target).replace(/\\/g, '/');
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 依赖闭包(不含 peer / dev / @deepseek-ai)。异步版。 */
|
|
332
|
+
async function collectDepsAsync(rootDir, meta, { profileDir, includeSecrets, warnings, measure }) {
|
|
333
|
+
const out = [];
|
|
334
|
+
const seen = new Set();
|
|
335
|
+
const byName = new Map();
|
|
336
|
+
const keepFile = fileFilter({ includeSecrets });
|
|
337
|
+
const queue = [{ dir: rootDir, meta, depth: 0 }];
|
|
338
|
+
while (queue.length) {
|
|
339
|
+
const current = queue.shift();
|
|
340
|
+
if (current.depth > 32) continue;
|
|
341
|
+
const pkg = current.meta ?? readJson(path.join(current.dir, 'package.json'), null);
|
|
342
|
+
if (!pkg) continue;
|
|
343
|
+
const required = Object.keys(pkg.dependencies ?? {});
|
|
344
|
+
const optional = Object.keys(pkg.optionalDependencies ?? {});
|
|
345
|
+
const peer = pkg.peerDependencies ?? {};
|
|
346
|
+
for (const depName of [...required, ...optional]) {
|
|
347
|
+
if (isOfficial(depName) || depName.startsWith('@types/')) continue;
|
|
348
|
+
if (Object.hasOwn(peer, depName)) continue;
|
|
349
|
+
const isOptional = optional.includes(depName) && !required.includes(depName);
|
|
350
|
+
const depDir = resolveDepDir(current.dir, depName, profileDir);
|
|
351
|
+
if (!depDir) {
|
|
352
|
+
if (!isOptional) warnings.push(`依赖没装上,已跳过: ${depName}(来自 ${path.basename(current.dir)})`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (seen.has(depDir)) continue;
|
|
356
|
+
seen.add(depDir);
|
|
357
|
+
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
|
+
});
|
|
362
|
+
const record = {
|
|
363
|
+
name: depName,
|
|
364
|
+
version: depMeta.version ?? '0.0.0',
|
|
365
|
+
dir: depDir,
|
|
366
|
+
bytes: size.bytes,
|
|
367
|
+
files: size.files,
|
|
368
|
+
};
|
|
369
|
+
if (byName.has(depName)) {
|
|
370
|
+
warnings.push(`依赖 ${depName} 出现多个版本,只保留先遇到的那个(${byName.get(depName).version} 优先于 ${record.version})`);
|
|
371
|
+
} else {
|
|
372
|
+
byName.set(depName, record);
|
|
373
|
+
out.push(record);
|
|
374
|
+
}
|
|
375
|
+
queue.push({ dir: depDir, meta: depMeta, depth: current.depth + 1 });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 从 patch 里挑出属于该插件的行。 */
|
|
382
|
+
function matchPatchRows(topEntries, plugin) {
|
|
383
|
+
const patchRows = [];
|
|
384
|
+
const insertRows = [];
|
|
385
|
+
const target = { name: plugin.name, dirs: [plugin.dir, path.join(plugin.dir, 'index.js')] };
|
|
386
|
+
for (const entry of topEntries) {
|
|
387
|
+
if (entry.kind === 'insert') {
|
|
388
|
+
for (const child of splitInsertChildren(entry.raw)) {
|
|
389
|
+
if (entryMatchesPlugin({ ...child, kind: 'row' }, target)) insertRows.push(child.raw);
|
|
390
|
+
}
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (entry.kind !== 'row') continue;
|
|
394
|
+
if (entryMatchesPlugin(entry, target)) patchRows.push(entry.raw);
|
|
395
|
+
}
|
|
396
|
+
return { patchRows, insertRows };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** 插件数据目录候选:$DSH_HOME/<name>、$DSH_HOME/<name 去 dsh- 前缀>、package.json 的 dsh.dataDirs。 */
|
|
400
|
+
function findDataDirs(plugin, dshHome, meta) {
|
|
401
|
+
const names = new Set();
|
|
402
|
+
const bare = plugin.name.startsWith('dsh-') ? plugin.name.slice(4) : plugin.name;
|
|
403
|
+
names.add(plugin.name);
|
|
404
|
+
names.add(bare);
|
|
405
|
+
for (const extra of Array.isArray(meta?.dsh?.dataDirs) ? meta.dsh.dataDirs : []) {
|
|
406
|
+
if (typeof extra === 'string' && extra.trim()) names.add(extra.trim());
|
|
407
|
+
}
|
|
408
|
+
const out = [];
|
|
409
|
+
for (const candidate of names) {
|
|
410
|
+
if (candidate.includes('/') || candidate.includes('\\') || candidate === '..') continue;
|
|
411
|
+
const full = path.join(dshHome, candidate);
|
|
412
|
+
if (!isDir(full)) continue;
|
|
413
|
+
if (out.some((item) => item.path === full)) continue;
|
|
414
|
+
out.push({ name: candidate, path: full });
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** 组装单个插件的扫描记录。 */
|
|
420
|
+
/**
|
|
421
|
+
* 组装单个插件的扫描记录(异步)。
|
|
422
|
+
*
|
|
423
|
+
* @param {object} item
|
|
424
|
+
* @param {object} context
|
|
425
|
+
* @param {(root: string, options: object) => Promise<{bytes:number,files:number}>} context.measure
|
|
426
|
+
* 测目录体积的函数。默认用异步版,避免大目录把事件循环卡住。
|
|
427
|
+
*/
|
|
428
|
+
async function buildPlugin(item, { profileDir, dshHome, topEntries, includeSecrets, includeData, warnings, measure = measureTreeAsync }) {
|
|
429
|
+
const metaFile = path.join(item.dir, 'package.json');
|
|
430
|
+
const meta = readJson(metaFile, null);
|
|
431
|
+
const name = meta?.name ?? item.declaredName ?? path.basename(item.dir);
|
|
432
|
+
if (!name) return null;
|
|
433
|
+
|
|
434
|
+
const pluginWarnings = [];
|
|
435
|
+
const entry = resolveEntry(item.dir, meta);
|
|
436
|
+
if (!meta && !entry) {
|
|
437
|
+
pluginWarnings.push('既没有 package.json 也找不到入口文件,可能不是插件目录');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const skipFileKeep = fileFilter({ includeSecrets });
|
|
441
|
+
const mainSize = await measure(item.dir, {
|
|
442
|
+
skipDirs: new Set([...SKIP_DIRS, 'node_modules']),
|
|
443
|
+
keepFile: skipFileKeep,
|
|
444
|
+
});
|
|
445
|
+
const deps = await collectDepsAsync(item.dir, meta, { profileDir, includeSecrets, warnings: pluginWarnings, measure });
|
|
446
|
+
|
|
447
|
+
const patchInfo = matchPatchRows(topEntries, { name, dir: item.dir });
|
|
448
|
+
|
|
449
|
+
const dataCandidates = findDataDirs({ name }, dshHome, meta);
|
|
450
|
+
const dataDirs = [];
|
|
451
|
+
if (includeData) {
|
|
452
|
+
for (const candidate of dataCandidates) {
|
|
453
|
+
const size = await measure(candidate.path, { skipDirs: SKIP_DIRS, keepFile: skipFileKeep });
|
|
454
|
+
dataDirs.push({ ...candidate, bytes: size.bytes, files: size.files });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const bytes = mainSize.bytes + deps.reduce((sum, dep) => sum + dep.bytes, 0) + dataDirs.reduce((sum, data) => sum + data.bytes, 0);
|
|
459
|
+
const fileCount = mainSize.files + deps.reduce((sum, dep) => sum + dep.files, 0) + dataDirs.reduce((sum, data) => sum + data.files, 0);
|
|
460
|
+
|
|
461
|
+
if (!entry && meta) pluginWarnings.push('找不到入口文件,导入后可能加载失败');
|
|
462
|
+
if (pluginWarnings.length) warnings.push(...pluginWarnings.map((text) => `${name}: ${text}`));
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
name,
|
|
466
|
+
version: meta?.version ?? '0.0.0',
|
|
467
|
+
description: typeof meta?.description === 'string' ? meta.description : '',
|
|
468
|
+
kind: meta ? 'package' : 'path',
|
|
469
|
+
dir: item.dir,
|
|
470
|
+
link: item.link,
|
|
471
|
+
declaredName: item.declaredName,
|
|
472
|
+
inBundles: item.inBundles,
|
|
473
|
+
bundleIndex: item.bundleIndex,
|
|
474
|
+
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,
|
|
477
|
+
client: meta?.dsh?.client ?? null,
|
|
478
|
+
entry,
|
|
479
|
+
deps,
|
|
480
|
+
patchRows: patchInfo.patchRows,
|
|
481
|
+
insertRows: patchInfo.insertRows,
|
|
482
|
+
dataCandidates: dataCandidates.map((candidate) => candidate.name),
|
|
483
|
+
dataDirs,
|
|
484
|
+
bytes,
|
|
485
|
+
fileCount,
|
|
486
|
+
warnings: pluginWarnings,
|
|
487
|
+
};
|
|
488
|
+
}
|