dsh-config-manager 0.1.77 → 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.
@@ -72,6 +72,22 @@ export declare class PluginsAdapter implements ConfigAdapter<PluginsSection> {
72
72
  * 不做任何写入(写入在 applyItem 内、且先于 pnpm-workspace.yaml)。
73
73
  */
74
74
  private patchAvailability;
75
+ /**
76
+ * 读目标机 profile 的 `dsh.profile.bundles`(issue #95)。
77
+ *
78
+ * 相对路径与写入目标同源(`profiles/<profile>/package.json`),走 `ctx.target.fs` 而不是
79
+ * 宿主私有通道:与 pnpm-workspace.yaml / patch 文件同一条 I/O 纪律(受限、可被 MemFs 测试)。
80
+ */
81
+ private targetBundles;
82
+ /**
83
+ * 把 host 自带的层声明进目标机 profile 的 `dsh.profile.bundles`(issue #95)。
84
+ *
85
+ * 为什么**不走安装通道**:这类层的包由 DSH 从自己的安装位置解析;按裸包名 `dsh plugin add`
86
+ * 会把 npm `dist-tags.latest` 的副本装进 profile,与 host 的版本线脱节 —— 真机故障:
87
+ * host 0.2.1-rc.2 + 包 0.2.1-alpha.2 的隔离组契约对不上 → `session-controller` 一直 pending
88
+ * → 新建会话 service-unavailable。声明不了时只报非致命 warning(§34.17),不拖垮其余配置。
89
+ */
90
+ private declareHostBundle;
75
91
  analyzeImport(data: PluginsSection, ctx: ImportContext): Promise<PlanItem[]>;
76
92
  applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult>;
77
93
  validate(data: PluginsSection, msg?: MsgFunc): Promise<ValidationResult>;
@@ -13,14 +13,14 @@
13
13
  import { join } from 'node:path';
14
14
  import { isDeepStrictEqual } from 'node:util';
15
15
  import { sha256Hex } from '../utils/hashing.js';
16
- import { installSpecFor, resolveProcessProfileName } from '../core/plugin-cli.js';
16
+ import { installSpecFor, isBundlesOnlyEntry, mergeBundleDeclaration, resolveProcessProfileName } from '../core/plugin-cli.js';
17
17
  import { classifyPluginSpec, LOCAL_PLUGIN_DIR, resolveLocalPluginPath } from '../core/local-plugin-pack.js';
18
18
  import { msgOf, zhMsg } from '../core/messages.js';
19
19
  import { engineMsg } from './messages.js';
20
20
  import { stringifyJsonSafe } from '../utils/json.js';
21
21
  import { isPathSafe, normalizePath } from '../utils/paths.js';
22
22
  import { isENOENT } from '../utils/guards.js';
23
- import { PLUGIN_PATCH_REF_PREFIX } from '../core/backup.js';
23
+ import { PLUGIN_PATCH_REF_PREFIX, PLUGIN_PROFILE_MANIFEST_REF } from '../core/backup.js';
24
24
  import { readEffectivePatchLines, resolveWriteLayer, USER_PATCH_FILE } from '../core/patch-layers.js';
25
25
  import { parsePnpmPatchedDependencies, sanitizePnpmWorkspacePatches } from './pnpm-workspace.js';
26
26
  import { BUILTIN_PLUGIN_DATA_SOURCES, areasForSection } from '../core/plugin-data-registry.js';
@@ -351,6 +351,9 @@ export class PluginsAdapter {
351
351
  isBundle: p.isBundle ?? false,
352
352
  inBundles: p.inBundles ?? [],
353
353
  enabled: p.enabled,
354
+ // issue #95:只在 dsh.profile.bundles 里声明的层(源机无依赖声明)必须带上这个标记,
355
+ // 导入端据此走「只声明」而不是 npm 安装。缺省不写,普通依赖的字节与改造前逐字一致。
356
+ ...(p.bundlesOnly === true ? { bundlesOnly: true } : {}),
354
357
  });
355
358
  }
356
359
  }
@@ -543,6 +546,83 @@ export class PluginsAdapter {
543
546
  }
544
547
  return set;
545
548
  }
549
+ /**
550
+ * 读目标机 profile 的 `dsh.profile.bundles`(issue #95)。
551
+ *
552
+ * 相对路径与写入目标同源(`profiles/<profile>/package.json`),走 `ctx.target.fs` 而不是
553
+ * 宿主私有通道:与 pnpm-workspace.yaml / patch 文件同一条 I/O 纪律(受限、可被 MemFs 测试)。
554
+ */
555
+ async targetBundles(ctx) {
556
+ const rel = `${PNPM_PROFILE_DIR(ctx.target.profile)}/package.json`;
557
+ try {
558
+ if (!(await ctx.target.fs.exists(rel)))
559
+ return { state: 'absent' };
560
+ const text = new TextDecoder().decode(await ctx.target.fs.readFile(rel));
561
+ let parsed;
562
+ try {
563
+ parsed = JSON.parse(text);
564
+ }
565
+ catch {
566
+ return { state: 'unreadable', reason: ctx.msg('adapter.pluginHostBundleBadJson') };
567
+ }
568
+ const bundles = parsed?.dsh?.profile?.bundles;
569
+ if (!Array.isArray(bundles) || bundles.some((entry) => typeof entry !== 'string')) {
570
+ return { state: 'unreadable', reason: ctx.msg('adapter.pluginHostBundleBadShape') };
571
+ }
572
+ return { state: 'ok', names: new Set(bundles), text };
573
+ }
574
+ catch (err) {
575
+ return { state: 'unreadable', reason: err instanceof Error ? err.message : String(err) };
576
+ }
577
+ }
578
+ /**
579
+ * 把 host 自带的层声明进目标机 profile 的 `dsh.profile.bundles`(issue #95)。
580
+ *
581
+ * 为什么**不走安装通道**:这类层的包由 DSH 从自己的安装位置解析;按裸包名 `dsh plugin add`
582
+ * 会把 npm `dist-tags.latest` 的副本装进 profile,与 host 的版本线脱节 —— 真机故障:
583
+ * host 0.2.1-rc.2 + 包 0.2.1-alpha.2 的隔离组契约对不上 → `session-controller` 一直 pending
584
+ * → 新建会话 service-unavailable。声明不了时只报非致命 warning(§34.17),不拖垮其余配置。
585
+ */
586
+ async declareHostBundle(name, ctx) {
587
+ const msg = ctx.msg;
588
+ const rel = `${PNPM_PROFILE_DIR(ctx.target.profile)}/package.json`;
589
+ const view = await this.targetBundles(ctx);
590
+ if (view.state === 'absent') {
591
+ return { ok: false, warning: true, message: msg('adapter.pluginHostBundleNoManifest', { name }) };
592
+ }
593
+ if (view.state === 'unreadable') {
594
+ return {
595
+ ok: false,
596
+ warning: true,
597
+ message: msg('adapter.pluginHostBundleManifestUnreadable', { name, reason: view.reason }),
598
+ };
599
+ }
600
+ if (view.names.has(name)) {
601
+ return { ok: true, needsRestart: false, message: msg('adapter.pluginHostBundleSame', { name }) };
602
+ }
603
+ const merged = mergeBundleDeclaration(view.text, name);
604
+ if (merged === null) {
605
+ return {
606
+ ok: false,
607
+ warning: true,
608
+ message: msg('adapter.pluginHostBundleManifestUnreadable', {
609
+ name,
610
+ reason: msg('adapter.pluginHostBundleBadShape'),
611
+ }),
612
+ };
613
+ }
614
+ try {
615
+ await ctx.target.fs.writeFile(rel, new TextEncoder().encode(merged.text));
616
+ }
617
+ catch (err) {
618
+ return {
619
+ ok: false,
620
+ warning: true,
621
+ message: msg('adapter.pluginHostBundleWriteFailed', { name, reason: err instanceof Error ? err.message : String(err) }),
622
+ };
623
+ }
624
+ return { ok: true, needsRestart: true, message: msg('adapter.pluginHostBundleDeclared', { name }) };
625
+ }
546
626
  async analyzeImport(data, ctx) {
547
627
  const msg = ctx.msg;
548
628
  const items = [];
@@ -634,8 +714,44 @@ export class PluginsAdapter {
634
714
  }
635
715
  // 插件:包名唯一键;同版本 Skip / 未装 Install / 版本不同 Conflict
636
716
  const targetInstalled = await ctx.target.plugins.listInstalled();
717
+ // issue #95:只在 dsh.profile.bundles 里声明的层(源机没有依赖声明)**不装 npm 包** ——
718
+ // 它的名字只声明进目标机的 bundles,由 DSH 从自己的安装位置解析。只在真出现这类条目时才读
719
+ // 目标清单(其余场景零额外 I/O,行为与改造前逐字一致)。
720
+ const bundleView = data.plugins.some((p) => isBundlesOnlyEntry(p)) ? await this.targetBundles(ctx) : null;
637
721
  for (const p of data.plugins) {
638
722
  const id = `plugin:${p.name}`;
723
+ if (isBundlesOnlyEntry(p)) {
724
+ if (bundleView !== null && bundleView.state === 'ok' && bundleView.names.has(p.name)) {
725
+ items.push({
726
+ id, kind: 'Skip', adapter: 'plugins',
727
+ description: msg('adapter.pluginHostBundleSame', { name: p.name }),
728
+ detail: msg('adapter.pluginHostBundleDetail'),
729
+ severity: 'info',
730
+ });
731
+ }
732
+ else if (bundleView !== null && bundleView.state === 'ok') {
733
+ items.push({
734
+ id, kind: 'Create', adapter: 'plugins',
735
+ description: msg('adapter.pluginHostBundleDeclare', { name: p.name }),
736
+ detail: msg('adapter.pluginHostBundleDetail'),
737
+ severity: 'info',
738
+ target: { adapter: 'plugins', ref: PLUGIN_PROFILE_MANIFEST_REF },
739
+ });
740
+ }
741
+ else {
742
+ // 清单缺 / 读不到 / 形态不符:**不猜也不重建**,如实告警(非致命,其余分区照常导入)
743
+ items.push({
744
+ id,
745
+ kind: 'Warning',
746
+ adapter: 'plugins',
747
+ description: bundleView === null || bundleView.state === 'absent'
748
+ ? msg('adapter.pluginHostBundleNoManifest', { name: p.name })
749
+ : msg('adapter.pluginHostBundleManifestUnreadable', { name: p.name, reason: bundleView.reason }),
750
+ severity: 'warning',
751
+ });
752
+ }
753
+ continue;
754
+ }
639
755
  const tp = targetInstalled.find((t) => t.name === p.name);
640
756
  // T1:该插件在备份内自带 tarball(本地源 link:/file:)→ 目标机无需原始路径即可安装。
641
757
  const hasTarball = tarballOf(data, p.name) !== undefined;
@@ -777,10 +893,16 @@ export class PluginsAdapter {
777
893
  // 拖垮已成功导入的其余配置——与 workspaces 的「目标不可达 → warning」同款语义。
778
894
  if (item.id.startsWith('plugin:')) {
779
895
  const name = item.id.replace(/^plugin:/, '');
896
+ const data = ctx.sections.get('plugins');
897
+ // issue #95:host 自带的层(源机只在 dsh.profile.bundles 声明、没有依赖声明)走「只声明」,
898
+ // 绝不进 npm 安装通道(按裸包名装 = 把 dist-tag latest 的副本塞进 profile)。
899
+ const entry = data?.plugins.find((p) => p.name === name);
900
+ if (entry !== undefined && isBundlesOnlyEntry(entry)) {
901
+ return await this.declareHostBundle(name, ctx);
902
+ }
780
903
  try {
781
904
  // 非 registry 来源(github:/file: 等)按来源 spec 安装,registry 包按裸包名装 npm 最新版
782
- const data = ctx.sections.get('plugins');
783
- let spec = data?.plugins.find((p) => p.name === name)?.spec;
905
+ let spec = entry?.spec;
784
906
  // T1:备份内自带 tarball → 解包到 homeDir 内的本地插件缓存目录,把 spec 重写为绝对路径。
785
907
  // 这是「换机后本地插件不再丢失」的关键:原始绝对路径在目标机不存在,解包出的 tgz 一定存在。
786
908
  const tarball = data !== undefined ? tarballOf(data, name) : undefined;
@@ -860,6 +982,11 @@ export class PluginsAdapter {
860
982
  if (typeof name !== 'string' || name === '') {
861
983
  issues.push({ path: `plugins[${i}].name`, message: msg('adapter.validate.requiredField', { subject: 'plugins[].name' }), severity: 'error' });
862
984
  }
985
+ // issue #95:`bundlesOnly` 决定走不走 npm 安装通道,写错类型必须拦下(缺省合法)
986
+ const bundlesOnly = p.bundlesOnly;
987
+ if (bundlesOnly !== undefined && typeof bundlesOnly !== 'boolean') {
988
+ issues.push({ path: `plugins[${i}].bundlesOnly`, message: msg('adapter.validate.boolean', { subject: 'plugins[].bundlesOnly' }), severity: 'error' });
989
+ }
863
990
  }
864
991
  }
865
992
  if (section.patch !== undefined && !Array.isArray(section.patch)) {