dsh-config-manager 0.1.76 → 0.1.78
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/adapters/plugin-data-declared.d.ts +12 -0
- package/lib/adapters/plugin-data-declared.js +57 -0
- package/lib/adapters/plugin-data.d.ts +4 -6
- package/lib/adapters/plugin-data.js +8 -36
- package/lib/adapters/plugins.d.ts +16 -0
- package/lib/adapters/plugins.js +223 -16
- package/lib/client.d.ts +104 -9
- package/lib/client.js +1800 -718
- package/lib/core/analyzer.js +3 -1
- package/lib/core/backup.d.ts +9 -0
- package/lib/core/backup.js +16 -1
- package/lib/core/messages.d.ts +11 -0
- package/lib/core/messages.js +22 -0
- package/lib/core/plugin-cli.d.ts +26 -0
- package/lib/core/plugin-cli.js +62 -2
- package/lib/core/rollback-attribution.d.ts +81 -0
- package/lib/core/rollback-attribution.js +236 -0
- package/lib/core/rollback-merge.d.ts +22 -0
- package/lib/core/rollback-merge.js +203 -0
- package/lib/core/rollback-point.d.ts +114 -0
- package/lib/core/rollback-point.js +534 -17
- package/lib/core/types.d.ts +28 -0
- package/lib/index.d.ts +26 -3
- package/lib/index.js +129 -16
- package/lib/routes/recovery.js +11 -0
- package/lib/schema/section-registry.js +5 -5
- package/lib/schema/types.d.ts +14 -0
- package/lib/ui/badge-tone.d.ts +16 -0
- package/lib/ui/badge-tone.js +20 -0
- package/lib/ui/i18n.d.ts +4 -4
- package/lib/ui/i18n.js +8 -8
- package/lib/ui/rollback-view.d.ts +95 -0
- package/lib/ui/rollback-view.js +55 -0
- package/lib/ui/selection-model.d.ts +140 -1
- package/lib/ui/selection-model.js +241 -9
- package/package.json +1 -1
- package/src/adapters/plugin-data-declared.ts +60 -0
- package/src/adapters/plugin-data.ts +7 -33
- package/src/adapters/plugins.test.ts +162 -0
- package/src/adapters/plugins.ts +236 -17
- package/src/adapters/units.test.ts +26 -0
- package/src/client/chat/ChatEntryView.tsx +147 -46
- package/src/client/chat/chat-entry-view.test.ts +277 -1
- package/src/client/chat/chat-entry-view.ts +98 -4
- package/src/client/chat/chat-node-registration.ts +71 -0
- package/src/client/chat/chat-slots.ts +30 -12
- package/src/client/common/ContentPicker.tsx +581 -175
- package/src/client/common/host-primitives.ts +64 -1
- package/src/client/common/ui.tsx +17 -3
- package/src/client/config-manager.module.css +630 -397
- package/src/client/css-scale-guard.test.ts +41 -3
- package/src/client/css-token-guard.test.ts +10 -4
- package/src/client/export/ExportView.tsx +2 -0
- package/src/client/index.ts +15 -3
- package/src/client/locales.ts +54 -10
- package/src/client/recovery/RollbackCard.tsx +56 -2
- package/src/client/recovery/RollbackDiffPane.tsx +5 -0
- package/src/client/recovery/RollbackFileChanges.tsx +46 -2
- package/src/client/recovery/recovery-locales.ts +22 -0
- package/src/client/recovery/rollback-file-changes.test.ts +1 -1
- package/src/core/analyzer.ts +3 -1
- package/src/core/backup.ts +16 -1
- package/src/core/messages.ts +22 -0
- package/src/core/plugin-cli.fs.test.ts +65 -3
- package/src/core/plugin-cli.ts +55 -2
- package/src/core/rollback-attribution.test.ts +120 -0
- package/src/core/rollback-attribution.ts +267 -0
- package/src/core/rollback-merge.test.ts +133 -0
- package/src/core/rollback-merge.ts +213 -0
- package/src/core/rollback-point.test.ts +412 -0
- package/src/core/rollback-point.ts +560 -20
- package/src/core/types.ts +29 -0
- package/src/index.facade.test.ts +60 -0
- package/src/index.ts +133 -19
- package/src/routes/recovery.ts +11 -0
- package/src/schema/section-registry.ts +5 -5
- package/src/schema/types.ts +14 -0
- package/src/ui/badge-tone.test.ts +23 -0
- package/src/ui/badge-tone.ts +27 -0
- package/src/ui/i18n.ts +8 -8
- package/src/ui/rollback-view.test.ts +27 -0
- package/src/ui/rollback-view.ts +139 -0
- package/src/ui/selection-model.test.ts +240 -2
- package/src/ui/selection-model.ts +349 -1
package/src/adapters/plugins.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { isDeepStrictEqual } from 'node:util';
|
|
15
15
|
import { sha256Hex } from '../utils/hashing.ts';
|
|
16
|
-
import { installSpecFor, resolveProcessProfileName } from '../core/plugin-cli.ts';
|
|
16
|
+
import { installSpecFor, isBundlesOnlyEntry, mergeBundleDeclaration, resolveProcessProfileName } from '../core/plugin-cli.ts';
|
|
17
17
|
import { classifyPluginSpec, LOCAL_PLUGIN_DIR, resolveLocalPluginPath } from '../core/local-plugin-pack.ts';
|
|
18
18
|
import type { PackLocalPluginsResult } from '../core/local-plugin-pack.ts';
|
|
19
19
|
import { msgOf, zhMsg } from '../core/messages.ts';
|
|
@@ -22,14 +22,17 @@ import { engineMsg } from './messages.ts';
|
|
|
22
22
|
import { stringifyJsonSafe } from '../utils/json.ts';
|
|
23
23
|
import { isPathSafe, normalizePath } from '../utils/paths.ts';
|
|
24
24
|
import { isENOENT } from '../utils/guards.ts';
|
|
25
|
-
import { PLUGIN_PATCH_REF_PREFIX } from '../core/backup.ts';
|
|
25
|
+
import { PLUGIN_PATCH_REF_PREFIX, PLUGIN_PROFILE_MANIFEST_REF } from '../core/backup.ts';
|
|
26
26
|
import { readEffectivePatchLines, resolveWriteLayer, USER_PATCH_FILE } from '../core/patch-layers.ts';
|
|
27
27
|
import { parsePnpmPatchedDependencies, sanitizePnpmWorkspacePatches } from './pnpm-workspace.ts';
|
|
28
28
|
import type { LocalPluginTarball, PatchLine, PluginEntry, PluginsSection, PnpmPatchFile } from '../schema/types.ts';
|
|
29
29
|
import type {
|
|
30
30
|
ApplyResult, ConfigAdapter, ExportOptions, ExportSection, ExportUnit, HostContext,
|
|
31
|
-
ImportContext, PlanItem, SectionPreview, ValidationResult,
|
|
31
|
+
ImportContext, PlanItem, PluginAreaRef, SectionPreview, ValidationResult,
|
|
32
32
|
} from '../core/types.ts';
|
|
33
|
+
import { BUILTIN_PLUGIN_DATA_SOURCES, areasForSection } from '../core/plugin-data-registry.ts';
|
|
34
|
+
import type { PluginDataSource } from '../core/plugin-data-registry.ts';
|
|
35
|
+
import { declaredProfileName, scanDeclaredDataSources } from './plugin-data-declared.ts';
|
|
33
36
|
import { sectionMeta } from '../schema/section-registry.ts';
|
|
34
37
|
import { validateJsonSection } from './json-section.ts';
|
|
35
38
|
|
|
@@ -156,6 +159,68 @@ function entriesOfRaw(raw: unknown): { config?: unknown }[] {
|
|
|
156
159
|
*/
|
|
157
160
|
const previewTarballBytes = new WeakMap<object, Map<string, number>>();
|
|
158
161
|
|
|
162
|
+
/**
|
|
163
|
+
* 「插件包名 → 它声明的数据区域」映射(只有 \`plugin:<包名>\` 单元用;导出选择器「按插件看」的树骨架)。
|
|
164
|
+
*
|
|
165
|
+
* 为什么挂 WeakMap 而不是给 PluginsSection 加字段:这是**展示用旁路信息** —— 既不是备份格式的
|
|
166
|
+
* 一部分(schema 校验与市场防线都不该看见它),也与「导出真的带什么」无关;挂在载荷对象上随对象
|
|
167
|
+
* 一起回收,载荷本身逐字节不变(与 previewTarballBytes 同一套理由)。
|
|
168
|
+
*/
|
|
169
|
+
const relatedAreasByData = new WeakMap<object, Map<string, PluginAreaRef[]>>();
|
|
170
|
+
|
|
171
|
+
/** 内置来源 + 自述声明的扫描缓存(TTL 内的同一 home/profile 只扫一次;/export-preview 一趟会问两次) */
|
|
172
|
+
const declaredSourcesMemo: { key: string; at: number; sources: readonly PluginDataSource[] } =
|
|
173
|
+
{ key: '', at: 0, sources: [] };
|
|
174
|
+
const DECLARED_MEMO_TTL_MS = 5_000;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 本机可归属的插件数据来源 = 内置来源表 + 已安装插件自述声明。
|
|
178
|
+
*
|
|
179
|
+
* 缓存键必须带 homeDir + profile:同一台机器换 profile 就是另一批已安装插件,拿旧声明画树
|
|
180
|
+
* 会让用户看到不属于当前 profile 的数据区域。
|
|
181
|
+
*/
|
|
182
|
+
async function pluginDataSources(ctx: HostContext): Promise<readonly PluginDataSource[]> {
|
|
183
|
+
const key = ctx.homeDir + '|' + declaredProfileName(ctx.profile);
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
if (declaredSourcesMemo.key === key && now - declaredSourcesMemo.at < DECLARED_MEMO_TTL_MS) {
|
|
186
|
+
return declaredSourcesMemo.sources;
|
|
187
|
+
}
|
|
188
|
+
const sources = [...BUILTIN_PLUGIN_DATA_SOURCES, ...await scanDeclaredDataSources(ctx)];
|
|
189
|
+
declaredSourcesMemo.key = key;
|
|
190
|
+
declaredSourcesMemo.at = now;
|
|
191
|
+
declaredSourcesMemo.sources = sources;
|
|
192
|
+
return sources;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 包名 → 该插件的数据区域引用。
|
|
197
|
+
*
|
|
198
|
+
* 口径与 pluginData / pluginAssets 的收集面**同源**(同一个 areasForSection):role=config 进
|
|
199
|
+
* pluginData、role=assets 进 pluginAssets;role=cache 的区域不属于任何分区,因此**绝不进树**
|
|
200
|
+
* (缓存是可再生产物,列出来只会诱导用户把它带走)。
|
|
201
|
+
*
|
|
202
|
+
* 只产出「注册表说它应该在哪儿」,不代表磁盘上真有 —— 界面必须把两者分开渲染。
|
|
203
|
+
*/
|
|
204
|
+
async function relatedAreasOf(ctx: HostContext): Promise<Map<string, PluginAreaRef[]>> {
|
|
205
|
+
const sources = await pluginDataSources(ctx);
|
|
206
|
+
const map = new Map<string, PluginAreaRef[]>();
|
|
207
|
+
for (const source of sources) {
|
|
208
|
+
const refs: PluginAreaRef[] = [];
|
|
209
|
+
for (const section of ['pluginData', 'pluginAssets'] as const) {
|
|
210
|
+
for (const entry of areasForSection([source], section)) {
|
|
211
|
+
refs.push({ section, relPath: entry.area.relPath, role: entry.area.role === 'assets' ? 'assets' : 'config' });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (refs.length === 0) continue;
|
|
215
|
+
for (const pkg of source.packages) {
|
|
216
|
+
const cur = map.get(pkg);
|
|
217
|
+
if (cur === undefined) map.set(pkg, [...refs]);
|
|
218
|
+
else cur.push(...refs);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return map;
|
|
222
|
+
}
|
|
223
|
+
|
|
159
224
|
/**
|
|
160
225
|
* 零 spawn 推导本地源插件的体积(预览专用)。
|
|
161
226
|
*
|
|
@@ -208,7 +273,11 @@ async function measureLocalSources(plugins: PluginEntry[], ctx: HostContext): Pr
|
|
|
208
273
|
* `tarballBytes` = 预览路径登记到的本地源体积(见 measureLocalSources,0 = 体积未知但仍随包携带);
|
|
209
274
|
* 缺省时体积与文件数完全按导出产物里的 base64 计算 —— 与改造前逐字一致。
|
|
210
275
|
*/
|
|
211
|
-
function unitsOf(
|
|
276
|
+
function unitsOf(
|
|
277
|
+
data: PluginsSection,
|
|
278
|
+
tarballBytes?: Map<string, number>,
|
|
279
|
+
related?: Map<string, PluginAreaRef[]>,
|
|
280
|
+
): ExportUnit[] {
|
|
212
281
|
const wsId = 'plugins:pnpm-workspace';
|
|
213
282
|
const patchFileIds = (data.patchFiles ?? []).map((pf) => `plugins:patch:${normalizePath(pf.relativePath)}`);
|
|
214
283
|
const group = [wsId, ...patchFileIds];
|
|
@@ -216,6 +285,7 @@ function unitsOf(data: PluginsSection, tarballBytes?: Map<string, number>): Expo
|
|
|
216
285
|
for (const pl of data.plugins) {
|
|
217
286
|
const tarball = tarballOf(data, pl.name);
|
|
218
287
|
const measured = tarballBytes?.get(pl.name);
|
|
288
|
+
const areas = related?.get(pl.name);
|
|
219
289
|
units.push({
|
|
220
290
|
id: `plugin:${pl.name}`,
|
|
221
291
|
label: pl.name,
|
|
@@ -223,6 +293,8 @@ function unitsOf(data: PluginsSection, tarballBytes?: Map<string, number>): Expo
|
|
|
223
293
|
// base64 长度 → 原始字节数(仅用于展示体积,不求精确);预览路径没有字节,用 stat 量到的源体积
|
|
224
294
|
sizeBytes: tarball !== undefined ? Math.floor((tarball.base64.length * 3) / 4) : measured ?? 0,
|
|
225
295
|
...(tarball !== undefined || measured !== undefined ? { fileCount: 1 } : {}),
|
|
296
|
+
// 「按插件看」的树骨架:该插件声明的数据区域(零数据目录 I/O)
|
|
297
|
+
...(areas !== undefined && areas.length > 0 ? { relatedAreas: areas } : {}),
|
|
226
298
|
});
|
|
227
299
|
}
|
|
228
300
|
for (const line of data.patch) {
|
|
@@ -249,6 +321,17 @@ function unitsOf(data: PluginsSection, tarballBytes?: Map<string, number>): Expo
|
|
|
249
321
|
return units;
|
|
250
322
|
}
|
|
251
323
|
|
|
324
|
+
/**
|
|
325
|
+
* 目标机 profile 的 `dsh.profile.bundles` 视图(issue #95)。
|
|
326
|
+
*
|
|
327
|
+
* 三态**必须分开**:`absent`(没有 package.json)、`unreadable`(读失败 / JSON 坏 / 形状不符)、
|
|
328
|
+
* `ok`。把后两者当成「没有」会直接去写一份清单,抹掉 profile 的其它字段。
|
|
329
|
+
*/
|
|
330
|
+
type TargetBundleView =
|
|
331
|
+
| { state: 'ok'; names: Set<string>; text: string }
|
|
332
|
+
| { state: 'absent' }
|
|
333
|
+
| { state: 'unreadable'; reason: string };
|
|
334
|
+
|
|
252
335
|
export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
253
336
|
readonly id = 'plugins' as const;
|
|
254
337
|
// 元数据唯一来源 = 注册表(t31):不再与 ui/export-flow.ts 的导出目录各写一份
|
|
@@ -291,7 +374,7 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
291
374
|
section: collected,
|
|
292
375
|
// 与 src/index.ts 对 JSON 分区现算体积的口径一致(utf8 字节数)
|
|
293
376
|
sizeBytes: new TextEncoder().encode(stringifyJsonSafe(data)).length,
|
|
294
|
-
items: unitsOf(data, tarballBytes),
|
|
377
|
+
items: unitsOf(data, tarballBytes, relatedAreasByData.get(data)),
|
|
295
378
|
};
|
|
296
379
|
}
|
|
297
380
|
|
|
@@ -317,6 +400,9 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
317
400
|
isBundle: p.isBundle ?? false,
|
|
318
401
|
inBundles: p.inBundles ?? [],
|
|
319
402
|
enabled: p.enabled,
|
|
403
|
+
// issue #95:只在 dsh.profile.bundles 里声明的层(源机无依赖声明)必须带上这个标记,
|
|
404
|
+
// 导入端据此走「只声明」而不是 npm 安装。缺省不写,普通依赖的字节与改造前逐字一致。
|
|
405
|
+
...(p.bundlesOnly === true ? { bundlesOnly: true } : {}),
|
|
320
406
|
});
|
|
321
407
|
}
|
|
322
408
|
} catch (err) {
|
|
@@ -432,16 +518,28 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
432
518
|
const alive = new Set(keptPlugins.map((pl) => pl.name));
|
|
433
519
|
keptTarballs = localTarballs?.filter((t) => alive.has(t.packageName));
|
|
434
520
|
}
|
|
521
|
+
const data: PluginsSection = {
|
|
522
|
+
version: 1,
|
|
523
|
+
plugins: keptPlugins,
|
|
524
|
+
patch: keptPatch,
|
|
525
|
+
pnpmWorkspace: keptWorkspace,
|
|
526
|
+
...(keptTarballs !== undefined ? { localTarballs: keptTarballs } : {}),
|
|
527
|
+
...(keptPatchFiles !== undefined ? { patchFiles: keptPatchFiles } : {}),
|
|
528
|
+
};
|
|
529
|
+
/**
|
|
530
|
+
* 「按插件看」的树骨架(导出选择器):登记每个插件声明的数据区域。
|
|
531
|
+
* 只登记「至少有一个区域」的插件 —— 其余插件单元保持无该字段,界面据此不显示空壳节点。
|
|
532
|
+
* 归属信息是**附加**信息:扫描失败不得影响导出/预览本身(与 pluginData 同一纪律)。
|
|
533
|
+
*/
|
|
534
|
+
try {
|
|
535
|
+
const related = await relatedAreasOf(ctx);
|
|
536
|
+
if (related.size > 0) relatedAreasByData.set(data, related);
|
|
537
|
+
} catch {
|
|
538
|
+
// 读不到声明就不登记(界面按「没有已知关联」渲染,绝不猜)
|
|
539
|
+
}
|
|
435
540
|
return {
|
|
436
541
|
sectionId: 'plugins',
|
|
437
|
-
data
|
|
438
|
-
version: 1,
|
|
439
|
-
plugins: keptPlugins,
|
|
440
|
-
patch: keptPatch,
|
|
441
|
-
pnpmWorkspace: keptWorkspace,
|
|
442
|
-
...(keptTarballs !== undefined ? { localTarballs: keptTarballs } : {}),
|
|
443
|
-
...(keptPatchFiles !== undefined ? { patchFiles: keptPatchFiles } : {}),
|
|
444
|
-
},
|
|
542
|
+
data,
|
|
445
543
|
counts: {
|
|
446
544
|
plugins: keptPlugins.length,
|
|
447
545
|
patchLines: keptPatch.length,
|
|
@@ -461,8 +559,9 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
461
559
|
* 由选择模型保证勾选/取消同步。
|
|
462
560
|
*/
|
|
463
561
|
listUnits(section: ExportSection<PluginsSection>): ExportUnit[] {
|
|
464
|
-
// 预览产物没有 tarball 字节,体积来自 measureLocalSources 挂上的统计(导出产物走 base64
|
|
465
|
-
|
|
562
|
+
// 预览产物没有 tarball 字节,体积来自 measureLocalSources 挂上的统计(导出产物走 base64 分支);
|
|
563
|
+
// 「按插件看」的数据区域骨架同样由 collect() 登记在载荷对象上(两条路径逐字一致,units.test 钉住)
|
|
564
|
+
return unitsOf(section.data, previewTarballBytes.get(section.data), relatedAreasByData.get(section.data));
|
|
466
565
|
}
|
|
467
566
|
|
|
468
567
|
/**
|
|
@@ -492,6 +591,81 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
492
591
|
return set;
|
|
493
592
|
}
|
|
494
593
|
|
|
594
|
+
/**
|
|
595
|
+
* 读目标机 profile 的 `dsh.profile.bundles`(issue #95)。
|
|
596
|
+
*
|
|
597
|
+
* 相对路径与写入目标同源(`profiles/<profile>/package.json`),走 `ctx.target.fs` 而不是
|
|
598
|
+
* 宿主私有通道:与 pnpm-workspace.yaml / patch 文件同一条 I/O 纪律(受限、可被 MemFs 测试)。
|
|
599
|
+
*/
|
|
600
|
+
private async targetBundles(ctx: ImportContext): Promise<TargetBundleView> {
|
|
601
|
+
const rel = `${PNPM_PROFILE_DIR(ctx.target.profile)}/package.json`;
|
|
602
|
+
try {
|
|
603
|
+
if (!(await ctx.target.fs.exists(rel))) return { state: 'absent' };
|
|
604
|
+
const text = new TextDecoder().decode(await ctx.target.fs.readFile(rel));
|
|
605
|
+
let parsed: unknown;
|
|
606
|
+
try {
|
|
607
|
+
parsed = JSON.parse(text);
|
|
608
|
+
} catch {
|
|
609
|
+
return { state: 'unreadable', reason: ctx.msg('adapter.pluginHostBundleBadJson') };
|
|
610
|
+
}
|
|
611
|
+
const bundles = (parsed as { dsh?: { profile?: { bundles?: unknown } } } | null)?.dsh?.profile?.bundles;
|
|
612
|
+
if (!Array.isArray(bundles) || bundles.some((entry) => typeof entry !== 'string')) {
|
|
613
|
+
return { state: 'unreadable', reason: ctx.msg('adapter.pluginHostBundleBadShape') };
|
|
614
|
+
}
|
|
615
|
+
return { state: 'ok', names: new Set(bundles as string[]), text };
|
|
616
|
+
} catch (err) {
|
|
617
|
+
return { state: 'unreadable', reason: err instanceof Error ? err.message : String(err) };
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* 把 host 自带的层声明进目标机 profile 的 `dsh.profile.bundles`(issue #95)。
|
|
623
|
+
*
|
|
624
|
+
* 为什么**不走安装通道**:这类层的包由 DSH 从自己的安装位置解析;按裸包名 `dsh plugin add`
|
|
625
|
+
* 会把 npm `dist-tags.latest` 的副本装进 profile,与 host 的版本线脱节 —— 真机故障:
|
|
626
|
+
* host 0.2.1-rc.2 + 包 0.2.1-alpha.2 的隔离组契约对不上 → `session-controller` 一直 pending
|
|
627
|
+
* → 新建会话 service-unavailable。声明不了时只报非致命 warning(§34.17),不拖垮其余配置。
|
|
628
|
+
*/
|
|
629
|
+
private async declareHostBundle(name: string, ctx: ImportContext): Promise<ApplyResult> {
|
|
630
|
+
const msg = ctx.msg;
|
|
631
|
+
const rel = `${PNPM_PROFILE_DIR(ctx.target.profile)}/package.json`;
|
|
632
|
+
const view = await this.targetBundles(ctx);
|
|
633
|
+
if (view.state === 'absent') {
|
|
634
|
+
return { ok: false, warning: true, message: msg('adapter.pluginHostBundleNoManifest', { name }) };
|
|
635
|
+
}
|
|
636
|
+
if (view.state === 'unreadable') {
|
|
637
|
+
return {
|
|
638
|
+
ok: false,
|
|
639
|
+
warning: true,
|
|
640
|
+
message: msg('adapter.pluginHostBundleManifestUnreadable', { name, reason: view.reason }),
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
if (view.names.has(name)) {
|
|
644
|
+
return { ok: true, needsRestart: false, message: msg('adapter.pluginHostBundleSame', { name }) };
|
|
645
|
+
}
|
|
646
|
+
const merged = mergeBundleDeclaration(view.text, name);
|
|
647
|
+
if (merged === null) {
|
|
648
|
+
return {
|
|
649
|
+
ok: false,
|
|
650
|
+
warning: true,
|
|
651
|
+
message: msg('adapter.pluginHostBundleManifestUnreadable', {
|
|
652
|
+
name,
|
|
653
|
+
reason: msg('adapter.pluginHostBundleBadShape'),
|
|
654
|
+
}),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
await ctx.target.fs.writeFile(rel, new TextEncoder().encode(merged.text));
|
|
659
|
+
} catch (err) {
|
|
660
|
+
return {
|
|
661
|
+
ok: false,
|
|
662
|
+
warning: true,
|
|
663
|
+
message: msg('adapter.pluginHostBundleWriteFailed', { name, reason: err instanceof Error ? err.message : String(err) }),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
return { ok: true, needsRestart: true, message: msg('adapter.pluginHostBundleDeclared', { name }) };
|
|
667
|
+
}
|
|
668
|
+
|
|
495
669
|
async analyzeImport(data: PluginsSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
496
670
|
const msg = ctx.msg;
|
|
497
671
|
const items: PlanItem[] = [];
|
|
@@ -582,8 +756,42 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
582
756
|
|
|
583
757
|
// 插件:包名唯一键;同版本 Skip / 未装 Install / 版本不同 Conflict
|
|
584
758
|
const targetInstalled = await ctx.target.plugins.listInstalled();
|
|
759
|
+
// issue #95:只在 dsh.profile.bundles 里声明的层(源机没有依赖声明)**不装 npm 包** ——
|
|
760
|
+
// 它的名字只声明进目标机的 bundles,由 DSH 从自己的安装位置解析。只在真出现这类条目时才读
|
|
761
|
+
// 目标清单(其余场景零额外 I/O,行为与改造前逐字一致)。
|
|
762
|
+
const bundleView = data.plugins.some((p) => isBundlesOnlyEntry(p)) ? await this.targetBundles(ctx) : null;
|
|
585
763
|
for (const p of data.plugins) {
|
|
586
764
|
const id = `plugin:${p.name}`;
|
|
765
|
+
if (isBundlesOnlyEntry(p)) {
|
|
766
|
+
if (bundleView !== null && bundleView.state === 'ok' && bundleView.names.has(p.name)) {
|
|
767
|
+
items.push({
|
|
768
|
+
id, kind: 'Skip', adapter: 'plugins',
|
|
769
|
+
description: msg('adapter.pluginHostBundleSame', { name: p.name }),
|
|
770
|
+
detail: msg('adapter.pluginHostBundleDetail'),
|
|
771
|
+
severity: 'info',
|
|
772
|
+
});
|
|
773
|
+
} else if (bundleView !== null && bundleView.state === 'ok') {
|
|
774
|
+
items.push({
|
|
775
|
+
id, kind: 'Create', adapter: 'plugins',
|
|
776
|
+
description: msg('adapter.pluginHostBundleDeclare', { name: p.name }),
|
|
777
|
+
detail: msg('adapter.pluginHostBundleDetail'),
|
|
778
|
+
severity: 'info',
|
|
779
|
+
target: { adapter: 'plugins', ref: PLUGIN_PROFILE_MANIFEST_REF },
|
|
780
|
+
});
|
|
781
|
+
} else {
|
|
782
|
+
// 清单缺 / 读不到 / 形态不符:**不猜也不重建**,如实告警(非致命,其余分区照常导入)
|
|
783
|
+
items.push({
|
|
784
|
+
id,
|
|
785
|
+
kind: 'Warning',
|
|
786
|
+
adapter: 'plugins',
|
|
787
|
+
description: bundleView === null || bundleView.state === 'absent'
|
|
788
|
+
? msg('adapter.pluginHostBundleNoManifest', { name: p.name })
|
|
789
|
+
: msg('adapter.pluginHostBundleManifestUnreadable', { name: p.name, reason: bundleView.reason }),
|
|
790
|
+
severity: 'warning',
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
587
795
|
const tp = targetInstalled.find((t) => t.name === p.name);
|
|
588
796
|
// T1:该插件在备份内自带 tarball(本地源 link:/file:)→ 目标机无需原始路径即可安装。
|
|
589
797
|
const hasTarball = tarballOf(data, p.name) !== undefined;
|
|
@@ -717,10 +925,16 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
717
925
|
// 拖垮已成功导入的其余配置——与 workspaces 的「目标不可达 → warning」同款语义。
|
|
718
926
|
if (item.id.startsWith('plugin:')) {
|
|
719
927
|
const name = item.id.replace(/^plugin:/, '');
|
|
928
|
+
const data = ctx.sections.get('plugins') as PluginsSection | undefined;
|
|
929
|
+
// issue #95:host 自带的层(源机只在 dsh.profile.bundles 声明、没有依赖声明)走「只声明」,
|
|
930
|
+
// 绝不进 npm 安装通道(按裸包名装 = 把 dist-tag latest 的副本塞进 profile)。
|
|
931
|
+
const entry = data?.plugins.find((p) => p.name === name);
|
|
932
|
+
if (entry !== undefined && isBundlesOnlyEntry(entry)) {
|
|
933
|
+
return await this.declareHostBundle(name, ctx);
|
|
934
|
+
}
|
|
720
935
|
try {
|
|
721
936
|
// 非 registry 来源(github:/file: 等)按来源 spec 安装,registry 包按裸包名装 npm 最新版
|
|
722
|
-
|
|
723
|
-
let spec = data?.plugins.find((p) => p.name === name)?.spec;
|
|
937
|
+
let spec = entry?.spec;
|
|
724
938
|
|
|
725
939
|
// T1:备份内自带 tarball → 解包到 homeDir 内的本地插件缓存目录,把 spec 重写为绝对路径。
|
|
726
940
|
// 这是「换机后本地插件不再丢失」的关键:原始绝对路径在目标机不存在,解包出的 tgz 一定存在。
|
|
@@ -796,6 +1010,11 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
796
1010
|
if (typeof name !== 'string' || name === '') {
|
|
797
1011
|
issues.push({ path: `plugins[${i}].name`, message: msg('adapter.validate.requiredField', { subject: 'plugins[].name' }), severity: 'error' });
|
|
798
1012
|
}
|
|
1013
|
+
// issue #95:`bundlesOnly` 决定走不走 npm 安装通道,写错类型必须拦下(缺省合法)
|
|
1014
|
+
const bundlesOnly = (p as { bundlesOnly?: unknown }).bundlesOnly;
|
|
1015
|
+
if (bundlesOnly !== undefined && typeof bundlesOnly !== 'boolean') {
|
|
1016
|
+
issues.push({ path: `plugins[${i}].bundlesOnly`, message: msg('adapter.validate.boolean', { subject: 'plugins[].bundlesOnly' }), severity: 'error' });
|
|
1017
|
+
}
|
|
799
1018
|
}
|
|
800
1019
|
}
|
|
801
1020
|
if (section.patch !== undefined && !Array.isArray(section.patch)) {
|
|
@@ -147,6 +147,32 @@ test('plugins:按包名过滤,cordis 补丁行独立成单元', async () =>
|
|
|
147
147
|
assert.deepEqual(filtered.data.patch, [], '未勾选的补丁行不得被带出');
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
test('plugins:「按插件看」的树骨架 —— 单元带上它声明的数据区域(内置来源表,缓存永不进树)', async () => {
|
|
151
|
+
const ctx = makeContext('win32', HOME);
|
|
152
|
+
// 内置来源表:dsh-mnemon → runtime(config) + data/documents(assets);
|
|
153
|
+
// dsh-plugin-wallpaper-engine → 数据目录(config) + uploads(assets) + cache(**不属于任何分区**)
|
|
154
|
+
ctx.plugins.installed.set('dsh-mnemon', { name: 'dsh-mnemon', version: '1.0.0', enabled: true });
|
|
155
|
+
ctx.plugins.installed.set('dsh-plugin-wallpaper-engine', {
|
|
156
|
+
name: 'dsh-plugin-wallpaper-engine', version: '1.0.0', enabled: true,
|
|
157
|
+
});
|
|
158
|
+
ctx.plugins.installed.set('pkg-a', { name: 'pkg-a', version: '1.0.0', enabled: true });
|
|
159
|
+
const adapter = new PluginsAdapter();
|
|
160
|
+
|
|
161
|
+
const previewed = await adapter.preview(ctx, { includeSecrets: false });
|
|
162
|
+
assert.deepEqual(previewed.items.find((u) => u.id === 'plugin:dsh-mnemon')?.relatedAreas, [
|
|
163
|
+
{ section: 'pluginData', relPath: '.mnemon/runtime', role: 'config' },
|
|
164
|
+
{ section: 'pluginAssets', relPath: '.mnemon/data', role: 'assets' },
|
|
165
|
+
{ section: 'pluginAssets', relPath: '.mnemon/documents', role: 'assets' },
|
|
166
|
+
]);
|
|
167
|
+
const we = previewed.items.find((u) => u.id === 'plugin:dsh-plugin-wallpaper-engine')?.relatedAreas ?? [];
|
|
168
|
+
assert.deepEqual(we.map((r) => r.relPath), ['.dsh-wallpaper-engine', '.dsh-wallpaper-engine/uploads'],
|
|
169
|
+
'cache 区域可再生产物、不属于任何分区,绝不进树');
|
|
170
|
+
// 没有声明的插件不得凭空带上区域(不猜)
|
|
171
|
+
assert.equal(previewed.items.find((u) => u.id === 'plugin:pkg-a')?.relatedAreas, undefined);
|
|
172
|
+
// 预览与导出两条路径的单元清单必须逐项相同(本文件既有纪律)
|
|
173
|
+
assert.deepEqual(previewed.items, adapter.listUnits(previewed.section));
|
|
174
|
+
});
|
|
175
|
+
|
|
150
176
|
test('plugins:pnpm-workspace.yaml ↔ patch 文件是原子组(lockedWith 双向声明)', () => {
|
|
151
177
|
const adapter = new PluginsAdapter();
|
|
152
178
|
const section: ExportSection<PluginsSection> = {
|