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/lib/import.js ADDED
@@ -0,0 +1,831 @@
1
+ /**
2
+ * 导入:解整合包 → 备份 → 落到 local-plugins → 合并 profile 配置 → 建链接。
3
+ *
4
+ * 设计原则:
5
+ * - 任何写操作之前先备份 package.json / cordis.patch.yml / 被覆盖的插件目录;
6
+ * - 所有 zip 条目路径都过 safeJoin,不允许穿出目标目录;
7
+ * - dryRun 只返回计划,一个字节都不写。
8
+ *
9
+ * @module dsh-plugin-pack/import
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ import { readZipMeta, extractEntry, safeJoin } from './zip.js';
15
+ import { readTgz } from './tar.js';
16
+ import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName as resolveProfileName, packSegment, stamp, humanBytes } from './home.js';
17
+ import { readJson, readText, writeJson, writeText, ensureDir, isDir, isFile, realOrSelf, copyTree, removeTree } from './util.js';
18
+ import { existingIds, rowId, appendInsertBlock, splitTopLevel, yamlScalar } from './patch-text.js';
19
+ import { isAbsoluteSpec, clearScanCache } from './scan.js';
20
+
21
+ export const PACK_FORMAT = 'dsh-plugin-pack@2';
22
+ /** 兼容旧的散文件布局。 */
23
+ export const PACK_FORMAT_V1 = 'dsh-plugin-pack@1';
24
+
25
+ /**
26
+ * 把整合包里的一个插件 tgz 解到目标目录。
27
+ *
28
+ * npm tarball 的内容都在 package/ 下,解出来要去掉这一层;
29
+ * 也兼容没有 package/ 前缀的手工包。所有路径都防穿越。
30
+ *
31
+ * @param {Buffer} zipBuffer 整个整合包
32
+ * @param {object|null} tgzEntry 该插件在 zip 里的条目(readZipMeta 的记录)
33
+ * @param {string} dest 目标目录
34
+ * @returns {number} 写出的文件数
35
+ */
36
+ function extractTarballInto(zipBuffer, tgzEntry, dest) {
37
+ if (!tgzEntry) {
38
+ const error = new Error('整合包里找不到这个插件的 .tgz');
39
+ error.code = 'BAD_PACK';
40
+ throw error;
41
+ }
42
+ const tgzBuffer = extractEntry(zipBuffer, tgzEntry);
43
+ const items = readTgz(tgzBuffer);
44
+ if (!items.length) {
45
+ const error = new Error(`插件 tgz 是空的: ${tgzEntry.name}`);
46
+ error.code = 'BAD_PACK';
47
+ throw error;
48
+ }
49
+
50
+ const hasPrefix = items.every((item) => item.name.startsWith('package/'));
51
+ const base = path.resolve(dest);
52
+ let written = 0;
53
+
54
+ for (const item of items) {
55
+ let rel = String(item.name).replace(/\\/g, '/');
56
+ if (hasPrefix) rel = rel.slice('package/'.length);
57
+ rel = rel.replace(/^\/+/, '');
58
+ if (!rel) continue;
59
+ const segments = rel.split('/').filter(Boolean);
60
+ if (segments.some((seg) => seg === '..' || seg === '.')) continue;
61
+ const target = path.join(base, ...segments);
62
+ if (!path.resolve(target).startsWith(base)) continue;
63
+ ensureDir(path.dirname(target));
64
+ fs.writeFileSync(target, item.data);
65
+ written += 1;
66
+ }
67
+ return written;
68
+ }
69
+
70
+ /**
71
+ * 读一个整合包,返回清单和体积统计(不写盘)。
72
+ *
73
+ * 用 readZipMeta 只解析中央目录,再单独解 manifest.json 一条,
74
+ * 几百 MB 的包也只占 manifest 的内存。
75
+ *
76
+ * @param {string} file
77
+ * @returns {object}
78
+ */
79
+ export function inspectBundle(file) {
80
+ const target = path.resolve(file);
81
+ if (!isFile(target)) {
82
+ const error = new Error(`整合包不存在: ${target}`);
83
+ error.code = 'NOT_FOUND';
84
+ throw error;
85
+ }
86
+ const buffer = fs.readFileSync(target);
87
+
88
+ // 先确认这真的是个 zip。用户可能拖进来任何文件(改名的 rar、半下载的包等),
89
+ // readZipMeta 会抛「找不到 EOCD」,那种原始报错看不懂。
90
+ let records;
91
+ try {
92
+ records = readZipMeta(buffer);
93
+ } catch (zipError) {
94
+ const error = new Error(
95
+ `zip 不是整合包:这个文件不是有效的 zip 压缩包(${String(zipError?.message ?? zipError).slice(0, 80)})。` +
96
+ `请确认它是 dsh-plugin-pack 导出的 .zip 或单个插件的 .tgz。`,
97
+ );
98
+ error.code = 'NOT_A_ZIP';
99
+ throw error;
100
+ }
101
+ let bufferRef = buffer;
102
+
103
+ const manifestRecord = records.find((record) => record.name === 'manifest.json');
104
+ if (!manifestRecord) {
105
+ // 是 zip,但不是整合包(普通的压缩包、别的工具导出的包)
106
+ const error = new Error(
107
+ `zip 不是整合包:这个 zip 里没有 manifest.json(根目录下共 ${records.length} 个文件)。` +
108
+ `它可能是普通的压缩包,或者是别的工具打的包,不是 dsh-plugin-pack 导出的整合包。`,
109
+ );
110
+ error.code = 'NOT_A_BUNDLE';
111
+ throw error;
112
+ }
113
+ let manifest;
114
+ try {
115
+ manifest = JSON.parse(extractEntry(bufferRef, manifestRecord).toString('utf8'));
116
+ } catch (error) {
117
+ const wrapped = new Error(
118
+ `zip 不是整合包:manifest.json 无法解析(${error?.message ?? error}),文件可能损坏。`,
119
+ );
120
+ wrapped.code = 'BAD_MANIFEST';
121
+ throw wrapped;
122
+ }
123
+ // 同时接受 v1(散文件)与 v2(内部 tgz)
124
+ const KNOWN_FORMATS = new Set(['dsh-plugin-pack@1', 'dsh-plugin-pack@2']);
125
+ if (!KNOWN_FORMATS.has(String(manifest?.format))) {
126
+ const error = new Error(
127
+ `zip 不是整合包:manifest.json 的格式标识是「${manifest?.format ?? '(缺失)'}」,` +
128
+ `不是 dsh-plugin-pack 的 @1 或 @2。`,
129
+ );
130
+ error.code = 'BAD_FORMAT';
131
+ throw error;
132
+ }
133
+
134
+ // 两种布局都认:
135
+ // v1(旧):packages/<段>/<文件...> —— 散文件
136
+ // v2(新):packages/<包名>-<版本>.tgz —— 每个插件一个 npm tarball
137
+ const layout = manifest.layout === 'tarballs' || String(manifest.format) === 'dsh-plugin-pack@2' ? 'tarballs' : 'files';
138
+
139
+ const packages = new Map(); // segment -> [{ rel, entry }](v1 用)
140
+ const tarballs = new Map(); // segment -> record(v2 用)
141
+ const data = new Map();
142
+
143
+ for (const record of records) {
144
+ if (record.name.startsWith('packages/')) {
145
+ const rest = record.name.slice('packages/'.length);
146
+ if (layout === 'tarballs') {
147
+ // packages/<name>-<version>.tgz 或 packages/<name>.tgz
148
+ if (!/\.(tgz|tar\.gz)$/i.test(rest)) continue;
149
+ const base = rest.replace(/\.(tgz|tar\.gz)$/i, '');
150
+ tarballs.set(base, record);
151
+ continue;
152
+ }
153
+ const slash = rest.indexOf('/');
154
+ if (slash < 0) continue;
155
+ const segment = rest.slice(0, slash);
156
+ const rel = rest.slice(slash + 1);
157
+ if (!rel) continue;
158
+ if (!packages.has(segment)) packages.set(segment, []);
159
+ packages.get(segment).push({ rel, entry: record });
160
+ continue;
161
+ }
162
+ if (record.name.startsWith('data/')) {
163
+ const rest = record.name.slice('data/'.length);
164
+ const slash = rest.indexOf('/');
165
+ if (slash < 0) continue;
166
+ const segment = rest.slice(0, slash);
167
+ const rel = rest.slice(slash + 1);
168
+ if (!rel) continue;
169
+ if (!data.has(segment)) data.set(segment, []);
170
+ data.get(segment).push({ rel, entry: record });
171
+ }
172
+ }
173
+
174
+ const plugins = (Array.isArray(manifest.plugins) ? manifest.plugins : []).map((plugin) => {
175
+ const segment = packSegment(plugin.name);
176
+ const files = packages.get(segment) ?? [];
177
+
178
+ // v2:按 manifest 里记的 tgz 路径找,找不到就按「包名-版本」猜
179
+ let tgzRecord = null;
180
+ if (layout === 'tarballs') {
181
+ const declared = typeof plugin.tgz === 'string' ? plugin.tgz.replace(/^packages\//, '').replace(/\.(tgz|tar\.gz)$/i, '') : null;
182
+ if (declared && tarballs.has(declared)) tgzRecord = tarballs.get(declared);
183
+ else tgzRecord = tarballs.get(`${segment}-${plugin.version}`) ?? tarballs.get(segment) ?? null;
184
+ }
185
+
186
+ const hasPackageJson = layout === 'tarballs' ? Boolean(tgzRecord) : files.some((item) => item.rel === 'package.json');
187
+
188
+ return {
189
+ name: plugin.name,
190
+ version: plugin.version,
191
+ kind: plugin.kind,
192
+ segment,
193
+ layout,
194
+ tgz: tgzRecord ? tgzRecord.name : null,
195
+ tgzEntry: tgzRecord,
196
+ files: layout === 'tarballs' ? (tgzRecord ? 1 : 0) : files.length,
197
+ bytes: layout === 'tarballs' ? (tgzRecord ? tgzRecord.size : 0) : files.reduce((sum, item) => sum + item.entry.size, 0),
198
+ dataFiles: (data.get(segment) ?? []).length,
199
+ patchRows: Array.isArray(plugin.patchRows) ? plugin.patchRows.length : 0,
200
+ insertRows: Array.isArray(plugin.insertRows) ? plugin.insertRows.length : 0,
201
+ deps: Array.isArray(plugin.deps) ? plugin.deps.length : 0,
202
+ hasPackageJson,
203
+ manifest: plugin,
204
+ };
205
+ });
206
+
207
+ return {
208
+ file: target,
209
+ bytes: buffer.length,
210
+ createdAt: manifest.createdAt,
211
+ source: manifest.source ?? {},
212
+ manifest,
213
+ plugins,
214
+ packages,
215
+ data,
216
+ records,
217
+ get buffer() {
218
+ return bufferRef;
219
+ },
220
+ entryCount: records.length,
221
+ warnings: [
222
+ ...(Array.isArray(manifest.warnings) ? manifest.warnings : []),
223
+ ...plugins.filter((plugin) => !plugin.hasPackageJson).map((plugin) => `${plugin.name}: 包里没有 package.json`),
224
+ ],
225
+ };
226
+ }
227
+
228
+ /**
229
+ * 导入整合包。
230
+ *
231
+ * @param {object} options
232
+ * @param {string} options.file
233
+ * @param {boolean} [options.dryRun]
234
+ * @param {'merge'|'skip'} [options.mode]
235
+ * @param {boolean} [options.overwritePatchConfig]
236
+ * @param {boolean} [options.restoreData]
237
+ * @param {boolean} [options.linkNodeModules]
238
+ * @param {string[]|null} [options.only]
239
+ * @returns {object}
240
+ */
241
+ export function importBundle(options = {}) {
242
+ const env = options.env ?? process.env;
243
+ const home = options.dshHome ? path.resolve(options.dshHome) : resolveDshHome(env);
244
+ const profile = options.profileDir ? path.resolve(options.profileDir) : resolveProfileDir(env);
245
+ const profileLabel = options.profileName ?? resolveProfileName(env);
246
+ const mode = options.mode === 'skip' ? 'skip' : 'merge';
247
+ const dryRun = Boolean(options.dryRun);
248
+ /** 进度回调:后台任务管理器用它显示"进行到哪一步"。 */
249
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
250
+ const overwritePatchConfig = Boolean(options.overwritePatchConfig);
251
+ const restoreData = options.restoreData !== false;
252
+ const linkNodeModules = options.linkNodeModules !== false;
253
+
254
+ /* 逐插件决策:{ "<包名>": "overwrite" | "skip" }。
255
+ * 优先级最高 —— 界面弹窗后按用户对每个插件的选择传进来。
256
+ * 没列到的插件退回全局 mode(merge=覆盖 / skip=跳过)。 */
257
+ const decisions = options.decisions && typeof options.decisions === 'object' ? options.decisions : {};
258
+
259
+ const decisionFor = (name) => {
260
+ const picked = decisions[name];
261
+ if (picked === 'overwrite' || picked === 'skip') return picked;
262
+ return mode === 'skip' ? 'skip' : 'overwrite';
263
+ };
264
+
265
+ const pack = inspectBundle(options.file);
266
+ const only = Array.isArray(options.only) && options.only.length ? new Set(options.only) : null;
267
+ const chosen = pack.plugins.filter((plugin) => !only || only.has(plugin.name));
268
+ if (!chosen.length) {
269
+ const error = new Error('整合包里没有要导入的插件(only 过滤后为空)');
270
+ error.code = 'EMPTY_SELECTION';
271
+ throw error;
272
+ }
273
+
274
+ const localPluginsDir = path.join(profile, 'local-plugins');
275
+ const pkgFile = path.join(profile, 'package.json');
276
+ const patchFile = path.join(profile, 'cordis.patch.yml');
277
+ const pkg = readJson(pkgFile, { name: `dsh-profile-${profileLabel}`, private: true, dependencies: {}, dsh: { profile: { bundles: [] } } });
278
+ const bundles = Array.isArray(pkg?.dsh?.profile?.bundles) ? [...pkg.dsh.profile.bundles] : [];
279
+ const dependencies = pkg?.dependencies && typeof pkg.dependencies === 'object' ? { ...pkg.dependencies } : {};
280
+ const patchText = readText(patchFile, '') ?? '';
281
+ const knownIds = existingIds(patchText);
282
+
283
+ const plan = [];
284
+ const warnings = [...pack.warnings];
285
+ for (const plugin of chosen) {
286
+ const dest = path.join(localPluginsDir, plugin.segment);
287
+ const nodeLink = path.join(profile, 'node_modules', ...String(plugin.name).split('/'));
288
+ const exists = isDir(dest);
289
+ const linkState = describeLink(nodeLink, dest);
290
+ // 路径插件:只在 cordis.patch.yml 里用绝对路径 insert、没进 bundles 的(如 pwsh7-preference)。
291
+ // 导入后 name 重写成新位置的绝对文件路径,不进 bundles / dependencies / 链接。
292
+ const sources = Array.isArray(plugin.manifest?.sources) ? plugin.manifest.sources : [];
293
+ const pathPlugin = sources.includes('patch-path') && !plugin.manifest?.inBundles;
294
+ const specifier = pathPlugin
295
+ ? `${profile}/local-plugins/${plugin.segment}/${plugin.manifest?.entry || 'index.js'}`.replace(/\\/g, '/')
296
+ : plugin.name;
297
+ plan.push({
298
+ name: plugin.name,
299
+ version: plugin.version,
300
+ segment: plugin.segment,
301
+ files: plugin.files,
302
+ bytes: plugin.bytes,
303
+ dest,
304
+ exists,
305
+ action: exists ? decisionFor(plugin.name) : 'create',
306
+ nodeLink,
307
+ linkState,
308
+ pathPlugin,
309
+ specifier,
310
+ inBundles: bundles.includes(plugin.name),
311
+ dependency: dependencies[plugin.name] ?? null,
312
+ patchRows: plugin.patchRows,
313
+ insertRows: plugin.insertRows,
314
+ dataFiles: plugin.dataFiles,
315
+ plugin,
316
+ });
317
+ }
318
+
319
+ const patchPlan = planPatch({ plan, patchText, knownIds, overwritePatchConfig, warnings });
320
+ const bundleAdds = plan.filter((item) => !item.pathPlugin && !item.inBundles).map((item) => item.name);
321
+
322
+ /* 与已装插件的冲突清单。
323
+ * 界面拿它弹窗问"覆盖还是跳过",再把决定塞回 decisions。
324
+ * dryRun 也返回它 —— 这正是界面判断"要不要弹窗"的依据。 */
325
+ const conflicts = plan
326
+ .filter((item) => item.exists)
327
+ .map((item) => {
328
+ const installed = readJson(path.join(item.dest, 'package.json'), null);
329
+ return {
330
+ name: item.name,
331
+ version: item.version,
332
+ installedVersion: installed?.version ?? null,
333
+ dest: item.dest,
334
+ bytes: item.bytes,
335
+ // 同一个版本还是不同版本,界面可以据此给更明确的提示
336
+ sameVersion: installed?.version === item.version,
337
+ };
338
+ });
339
+
340
+ if (dryRun) {
341
+ return {
342
+ ok: true,
343
+ dryRun: true,
344
+ file: pack.file,
345
+ profile,
346
+ profileLabel,
347
+ mode,
348
+ plugins: plan.map(({ patchRows, insertRows, plugin, ...rest }) => rest),
349
+ // 冲突清单:界面据此弹窗问覆盖还是跳过
350
+ conflicts,
351
+ bundlesToAdd: bundleAdds,
352
+ patch: patchPlan.summary,
353
+ warnings,
354
+ restartRequired: true,
355
+ };
356
+ }
357
+
358
+ // ---- 真正开始写 ----
359
+ onProgress(`读取整合包(${pack.plugins.length} 个插件)`, 5);
360
+ const stampText = stamp();
361
+ const backupRoot = uniqueBackupDir(path.join(profile, '.plugin-backups', `plugin-pack-${stampText}`));
362
+ ensureDir(backupRoot);
363
+ const record = {
364
+ format: 'dsh-plugin-pack-backup@1',
365
+ stamp: stampText,
366
+ createdAt: new Date().toISOString(),
367
+ source: pack.file,
368
+ profile,
369
+ profileLabel,
370
+ addedDirs: [],
371
+ replacedDirs: [],
372
+ restoredDirs: [],
373
+ dataDirs: [],
374
+ links: [],
375
+ removedLinks: [],
376
+ };
377
+
378
+ if (isFile(pkgFile)) {
379
+ ensureDir(backupRoot);
380
+ fs.copyFileSync(pkgFile, path.join(backupRoot, 'package.json'));
381
+ }
382
+ if (isFile(patchFile)) {
383
+ ensureDir(backupRoot);
384
+ fs.copyFileSync(patchFile, path.join(backupRoot, 'cordis.patch.yml'));
385
+ }
386
+
387
+ const applied = [];
388
+ const totalPlugins = plan.length;
389
+ for (let index = 0; index < plan.length; index += 1) {
390
+ const item = plan[index];
391
+ const basePercent = 10 + Math.round((index / Math.max(1, totalPlugins)) * 70);
392
+ if (item.action === 'skip') {
393
+ onProgress(`跳过 ${item.name}(已存在)`, basePercent);
394
+ applied.push({ name: item.name, action: 'skipped', reason: '目标已存在,mode=skip' });
395
+ continue;
396
+ }
397
+ onProgress(`解包 ${item.name}`, basePercent);
398
+ if (item.exists) {
399
+ const backupDir = path.join(backupRoot, 'local-plugins', item.segment);
400
+ copyTree(item.dest, backupDir);
401
+ record.replacedDirs.push({ dest: item.dest, backup: backupDir });
402
+ removeTree(item.dest);
403
+ }
404
+ ensureDir(item.dest);
405
+
406
+ // v2:每个插件一个 tgz,解包到目标目录(去掉 package/ 前缀)
407
+ const pluginInfo = chosen.find((entry) => entry.segment === item.segment);
408
+ let written = 0;
409
+ if (pack.manifest?.layout === 'tarballs' || pack.manifest?.format === 'dsh-plugin-pack@2') {
410
+ written = extractTarballInto(pack.buffer, pluginInfo?.tgzEntry, item.dest);
411
+ } else {
412
+ // v1:散文件逐条写
413
+ const files = pack.packages.get(item.segment) ?? [];
414
+ for (const file of files) {
415
+ const target = safeJoin(item.dest, file.rel);
416
+ ensureDir(path.dirname(target));
417
+ fs.writeFileSync(target, extractEntry(pack.buffer, file.entry));
418
+ written += 1;
419
+ }
420
+ }
421
+
422
+ if (!item.exists) record.addedDirs.push(item.dest);
423
+ applied.push({ name: item.name, action: item.action, files: written, dest: item.dest });
424
+ }
425
+
426
+ // 数据目录
427
+ if (restoreData) {
428
+ for (const plugin of chosen) {
429
+ const dataFiles = pack.data.get(plugin.segment) ?? [];
430
+ for (const file of dataFiles) {
431
+ const [top, ...rest] = file.rel.split('/');
432
+ if (!top || !rest.length) continue;
433
+ const destRoot = path.join(home, top);
434
+ const target = safeJoin(destRoot, rest.join('/'));
435
+ if (!isFile(target)) {
436
+ ensureDir(path.dirname(target));
437
+ fs.writeFileSync(target, extractEntry(pack.buffer, file.entry));
438
+ record.dataDirs.push(target);
439
+ }
440
+ }
441
+ }
442
+ }
443
+
444
+ // profile/package.json
445
+ onProgress('写回 profile 配置', 85);
446
+ pkg.dependencies = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
447
+ pkg.dsh = pkg.dsh && typeof pkg.dsh === 'object' ? pkg.dsh : {};
448
+ pkg.dsh.profile = pkg.dsh.profile && typeof pkg.dsh.profile === 'object' ? pkg.dsh.profile : {};
449
+ pkg.dsh.profile.bundles = Array.isArray(pkg.dsh.profile.bundles) ? pkg.dsh.profile.bundles : [];
450
+ for (const item of plan) {
451
+ if (item.action === 'skip' || item.pathPlugin) continue;
452
+ pkg.dependencies[item.name] = `link:./local-plugins/${item.segment}`;
453
+ if (!pkg.dsh.profile.bundles.includes(item.name)) pkg.dsh.profile.bundles.push(item.name);
454
+ }
455
+ writeJson(pkgFile, pkg);
456
+
457
+ // cordis.patch.yml
458
+ let nextPatch = patchText;
459
+ if (patchPlan.topRows.length) nextPatch = appendTopRows(nextPatch, patchPlan.topRows, `plugin-pack ${stampText}`);
460
+ if (patchPlan.insertRows.length) {
461
+ nextPatch = appendInsertBlock(nextPatch, dedentRows(patchPlan.insertRows), `plugin-pack ${stampText}`).text;
462
+ }
463
+ if (nextPatch !== patchText) writeText(patchFile, nextPatch);
464
+
465
+ // node_modules 链接
466
+ onProgress('建立 node_modules 链接', 92);
467
+ const links = [];
468
+ if (linkNodeModules) {
469
+ for (const item of plan) {
470
+ if (item.action === 'skip' || item.pathPlugin) continue;
471
+ const result = ensurePackageLink(profile, item.name, item.dest, { backupRoot, record });
472
+ links.push({ name: item.name, ...result });
473
+ }
474
+ }
475
+
476
+ record.links = links;
477
+ writeJson(path.join(backupRoot, 'record.json'), record);
478
+ // 导入改了 profile 配置和插件目录,扫描缓存必须失效
479
+ clearScanCache();
480
+ onProgress(`完成:${applied.length} 个插件已处理`, 100);
481
+
482
+ return {
483
+ ok: true,
484
+ file: pack.file,
485
+ profile,
486
+ profileLabel,
487
+ backup: backupRoot,
488
+ mode,
489
+ plugins: applied,
490
+ conflicts,
491
+ decisions: Object.fromEntries(plan.filter((item) => item.exists).map((item) => [item.name, item.action])),
492
+ links,
493
+ bundlesAdded: bundleAdds,
494
+ patch: patchPlan.summary,
495
+ dataRestored: record.dataDirs.length,
496
+ warnings,
497
+ restartRequired: true,
498
+ };
499
+ }
500
+
501
+ /** 计算 patch 的增补计划(不写盘)。 */
502
+ function planPatch({ plan, patchText, knownIds, overwritePatchConfig, warnings }) {
503
+ const topRows = [];
504
+ const insertRows = [];
505
+ const skipped = [];
506
+ const ids = new Set(knownIds);
507
+ const entries = splitTopLevel(patchText);
508
+ const byId = new Map(entries.filter((entry) => entry.kind === 'row' && entry.id).map((entry) => [entry.id, entry]));
509
+
510
+ for (const item of plan) {
511
+ for (const raw of item.plugin?.manifest?.patchRows ?? []) {
512
+ const id = rowId(raw);
513
+ if (!id) continue;
514
+ if (ids.has(id)) {
515
+ if (overwritePatchConfig && byId.has(id)) {
516
+ skipped.push({ id, reason: '已存在,overwritePatchConfig=true 时按原行保留(避免破坏用户手改)' });
517
+ } else {
518
+ skipped.push({ id, reason: 'id 已存在,跳过' });
519
+ }
520
+ continue;
521
+ }
522
+ ids.add(id);
523
+ topRows.push(rewriteRowName(raw, item.specifier));
524
+ }
525
+ for (const raw of item.plugin?.manifest?.insertRows ?? []) {
526
+ const id = rowId(raw);
527
+ if (!id) continue;
528
+ if (ids.has(id)) {
529
+ skipped.push({ id, reason: 'id 已存在,跳过' });
530
+ continue;
531
+ }
532
+ ids.add(id);
533
+ insertRows.push(rewriteRowName(raw, item.specifier));
534
+ }
535
+ }
536
+
537
+ if (skipped.length) {
538
+ warnings.push(`patch 有 ${skipped.length} 条已存在,未重复写入:${skipped.map((item) => item.id).join(', ')}`);
539
+ }
540
+
541
+ return {
542
+ topRows,
543
+ insertRows,
544
+ summary: {
545
+ topRows: topRows.length,
546
+ insertRows: insertRows.length,
547
+ skipped,
548
+ },
549
+ };
550
+ }
551
+
552
+ /** 把 row 里写死的绝对路径 name 改成新位置(包名或新的绝对路径,按 YAML 规则安全化)。 */
553
+ function rewriteRowName(raw, specifier) {
554
+ return String(raw).replace(
555
+ /(^|[ \t])name:([ \t]*)(['"]?)([^'"\r\n]+)\3/g,
556
+ (match, lead, gap, quote, value) => {
557
+ const trimmed = value.trim();
558
+ if (!isAbsoluteSpec(trimmed)) return match;
559
+ return `${lead}name:${gap}${yamlScalar(specifier)}`;
560
+ },
561
+ );
562
+ }
563
+
564
+ /** 顶层 row 追加。 */
565
+ function appendTopRows(text, rows, comment) {
566
+ const eol = String(text).includes('\r\n') ? '\r\n' : '\n';
567
+ let out = String(text ?? '');
568
+ if (out && !out.endsWith('\n')) out += eol;
569
+ if (out && !out.endsWith(`${eol}${eol}`)) out += eol;
570
+ out += `# >>> dsh-plugin-pack ${comment}${eol}`;
571
+ for (const row of rows) {
572
+ const body = String(row).replace(/\r?\n$/, '').replace(/\r?\n/g, eol);
573
+ out += `${body}${eol}`;
574
+ }
575
+ return out;
576
+ }
577
+
578
+ /**
579
+ * 把 insert 子条目原文(4 空格缩进)还原成顶层行形态(0 缩进),
580
+ * 再交给 appendIndentBlock 统一加 4 —— 否则会双重缩进,
581
+ * splitInsertChildren(只认 4 空格)就收集不到它的 id,导致重复导入。
582
+ */
583
+ function dedentRows(rows) {
584
+ return rows.map((raw) =>
585
+ String(raw)
586
+ .split(/\r?\n/)
587
+ .map((line) => (line.startsWith(' ') ? line.slice(4) : line))
588
+ .join('\n'),
589
+ );
590
+ }
591
+
592
+ /** 描述目标 node_modules 位置当前是什么状态。 */
593
+ function describeLink(linkPath, dest) {
594
+ try {
595
+ const stat = fs.lstatSync(linkPath);
596
+ if (stat.isSymbolicLink()) {
597
+ const real = realOrSelf(linkPath);
598
+ return real === realOrSelf(dest) ? 'linked-to-dest' : `linked-elsewhere:${real}`;
599
+ }
600
+ if (stat.isDirectory()) return 'directory';
601
+ return 'file';
602
+ } catch {
603
+ return 'missing';
604
+ }
605
+ }
606
+
607
+ /** 建立 node_modules/<name> → local-plugins/<segment> 的链接。 */
608
+ function ensurePackageLink(profile, name, dest, { backupRoot, record }) {
609
+ const linkPath = path.join(profile, 'node_modules', ...String(name).split('/'));
610
+ const state = describeLink(linkPath, dest);
611
+ if (state === 'linked-to-dest') return { status: 'already', path: linkPath };
612
+ try {
613
+ if (state === 'directory' || state === 'file') {
614
+ const backup = path.join(backupRoot, 'node_modules', packSegment(name));
615
+ copyTree(linkPath, backup);
616
+ record.replacedDirs.push({ dest: linkPath, backup });
617
+ removeTree(linkPath);
618
+ } else if (state.startsWith('linked-elsewhere')) {
619
+ // 原链接(pnpm 建的)删之前把目标记下来,回滚时要原样重建。
620
+ const previousTarget = fs.readlinkSync(linkPath);
621
+ record.removedLinks.push({ path: linkPath, target: previousTarget });
622
+ fs.unlinkSync(linkPath);
623
+ }
624
+ } catch (error) {
625
+ return { status: 'failed', path: linkPath, error: `清理旧位置失败: ${error?.message ?? error}` };
626
+ }
627
+ try {
628
+ ensureDir(path.dirname(linkPath));
629
+ fs.symlinkSync(dest, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
630
+ return { status: 'linked', path: linkPath };
631
+ } catch (error) {
632
+ return {
633
+ status: 'failed',
634
+ path: linkPath,
635
+ error: `建链接失败: ${error?.message ?? error}。可手动跑 pnpm install 让 link: 依赖生效`,
636
+ };
637
+ }
638
+ }
639
+
640
+ /** 备份目录撞名(同一秒内多次导入)时追加序号,不覆盖旧备份。 */
641
+ function uniqueBackupDir(base) {
642
+ if (!isDir(base) && !isFile(base)) return base;
643
+ for (let index = 2; index < 100; index += 1) {
644
+ const candidate = `${base}-${index}`;
645
+ if (!isDir(candidate) && !isFile(candidate)) return candidate;
646
+ }
647
+ return `${base}-${Date.now()}`;
648
+ }
649
+
650
+ /** 按记录重建一条链接;junction 优先(Windows 不需要特权),失败退回 symlink。 */
651
+ function relink(recorded) {
652
+ try {
653
+ try {
654
+ fs.lstatSync(recorded.path);
655
+ fs.unlinkSync(recorded.path);
656
+ } catch {
657
+ /* 已经不在了 */
658
+ }
659
+ if (process.platform === 'win32') {
660
+ try {
661
+ fs.symlinkSync(recorded.target, recorded.path, 'junction');
662
+ return;
663
+ } catch {
664
+ /* 退回 symlink */
665
+ }
666
+ }
667
+ fs.symlinkSync(recorded.target, recorded.path, 'dir');
668
+ } catch {
669
+ /* 重建失败:留报告里 */
670
+ }
671
+ }
672
+
673
+ /**
674
+ * 回滚最近一次(或指定一次)导入。
675
+ *
676
+ * @param {object} [options]
677
+ * @param {string} [options.backup] 备份目录名或绝对路径
678
+ * @returns {object}
679
+ */
680
+ export function rollbackImport(options = {}) {
681
+ const env = options.env ?? process.env;
682
+ const profile = options.profileDir ? path.resolve(options.profileDir) : resolveProfileDir(env);
683
+ const backupRoot = resolveBackupDir(profile, options.backup);
684
+ const record = readJson(path.join(backupRoot, 'record.json'), null);
685
+ if (!record) {
686
+ const error = new Error(`备份里没有 record.json,无法安全回滚: ${backupRoot}`);
687
+ error.code = 'BAD_BACKUP';
688
+ throw error;
689
+ }
690
+
691
+ const restored = [];
692
+ const removed = [];
693
+ const relinked = [];
694
+
695
+ const backupPkg = path.join(backupRoot, 'package.json');
696
+ const backupPatch = path.join(backupRoot, 'cordis.patch.yml');
697
+ if (isFile(backupPkg)) {
698
+ fs.copyFileSync(backupPkg, path.join(profile, 'package.json'));
699
+ restored.push('package.json');
700
+ }
701
+ if (isFile(backupPatch)) {
702
+ fs.copyFileSync(backupPatch, path.join(profile, 'cordis.patch.yml'));
703
+ restored.push('cordis.patch.yml');
704
+ }
705
+
706
+ for (const dest of record.addedDirs ?? []) {
707
+ removeTree(dest);
708
+ removed.push(dest);
709
+ }
710
+ for (const item of record.replacedDirs ?? []) {
711
+ if (!isDir(item.backup)) continue;
712
+ removeTree(item.dest);
713
+ copyTree(item.backup, item.dest);
714
+ restored.push(item.dest);
715
+ }
716
+ // 先删掉本次导入创建的链接,再重建被替换的原链接(同一路径,顺序不能反)。
717
+ for (const link of record.links ?? []) {
718
+ if (link?.status === 'linked' && link.path) {
719
+ try {
720
+ fs.lstatSync(link.path);
721
+ fs.unlinkSync(link.path);
722
+ relinked.push(link.path);
723
+ } catch {
724
+ /* 已经不在了 */
725
+ }
726
+ }
727
+ }
728
+ for (const recorded of record.removedLinks ?? []) {
729
+ // 兼容旧格式(纯字符串)与新格式({ path, target })
730
+ const info = typeof recorded === 'string' ? { path: recorded, target: null } : recorded;
731
+ if (!info?.path) continue;
732
+ if (info.target) {
733
+ relink(info);
734
+ restored.push(`${info.path} -> ${info.target}`);
735
+ }
736
+ }
737
+ for (const file of record.dataDirs ?? []) {
738
+ removeTree(file);
739
+ removed.push(file);
740
+ }
741
+
742
+ // 标记该备份已消费:重命名加 .done,之后「最新备份」指向更早的、还没回滚过的那次。
743
+ try {
744
+ const done = `${backupRoot}.done`;
745
+ fs.renameSync(backupRoot, done);
746
+ return {
747
+ ok: true,
748
+ backup: backupRoot,
749
+ backupArchived: done,
750
+ restored,
751
+ removed,
752
+ unlinked: relinked,
753
+ restartRequired: true,
754
+ };
755
+ } catch {
756
+ /* 重命名失败不影响回滚结果 */
757
+ }
758
+
759
+ // 回滚改了配置和插件目录,扫描缓存失效
760
+ clearScanCache();
761
+
762
+ return {
763
+ ok: true,
764
+ backup: backupRoot,
765
+ restored,
766
+ removed,
767
+ unlinked: relinked,
768
+ restartRequired: true,
769
+ };
770
+ }
771
+
772
+ /** 找备份目录:给了就用,没给就取最新(跳过已消费的 .done)。 */
773
+ export function resolveBackupDir(profile, raw) {
774
+ const root = path.join(profile, '.plugin-backups');
775
+ if (raw && String(raw).trim()) {
776
+ const value = String(raw).trim();
777
+ const direct = path.isAbsolute(value) ? value : path.join(root, value);
778
+ if (isDir(direct)) return direct;
779
+ if (isDir(`${direct}.done`)) {
780
+ const error = new Error(`该备份已经回滚过了: ${direct}`);
781
+ error.code = 'ALREADY_DONE';
782
+ throw error;
783
+ }
784
+ const error = new Error(`备份目录不存在: ${direct}`);
785
+ error.code = 'NOT_FOUND';
786
+ throw error;
787
+ }
788
+ let names = [];
789
+ try {
790
+ names = fs
791
+ .readdirSync(root, { withFileTypes: true })
792
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('plugin-pack-') && !entry.name.endsWith('.done'))
793
+ .map((entry) => entry.name)
794
+ .sort();
795
+ } catch {
796
+ names = [];
797
+ }
798
+ if (!names.length) {
799
+ const error = new Error(`没有找到任何还没回滚过的 plugin-pack 备份: ${root}`);
800
+ error.code = 'NOT_FOUND';
801
+ throw error;
802
+ }
803
+ return path.join(root, names[names.length - 1]);
804
+ }
805
+
806
+ /** 列出所有可回滚的备份(已消费的 .done 不算)。 */
807
+ export function listBackups(profileDir) {
808
+ const root = path.join(profileDir, '.plugin-backups');
809
+ let names = [];
810
+ try {
811
+ names = fs
812
+ .readdirSync(root, { withFileTypes: true })
813
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('plugin-pack-') && !entry.name.endsWith('.done'))
814
+ .map((entry) => entry.name)
815
+ .sort()
816
+ .reverse();
817
+ } catch {
818
+ return [];
819
+ }
820
+ return names.map((name) => {
821
+ const dir = path.join(root, name);
822
+ const record = readJson(path.join(dir, 'record.json'), null);
823
+ return {
824
+ name,
825
+ dir,
826
+ createdAt: record?.createdAt ?? null,
827
+ source: record?.source ?? null,
828
+ plugins: (record?.addedDirs ?? []).length + (record?.replacedDirs ?? []).length,
829
+ };
830
+ });
831
+ }