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.
@@ -1601,9 +1601,11 @@ function applyTombstoneFilter(items, tombstones) {
1601
1601
  /** PlanItem → 墓碑键({kind,id})映射;非「将写入」的条目(Skip 等)与配置类项返回 null 不过滤 */
1602
1602
  function tombstoneKeyForItem(item) {
1603
1603
  // 插件本体(plugin:<pkg>):Install / Update / Conflict 均视为「将写入该插件」→ 命中墓碑剔除;
1604
+ // issue #95 的 Create = 「把 host 自带的层声明进 dsh.profile.bundles」,同样是写入该插件,
1605
+ // 必须一起被墓碑剔除(用户删过的插件不得被一份旧备份重新声明回来)。
1604
1606
  // patch: 行与 plugins:pnpm-workspace 是配置类项,不按插件墓碑过滤(分区级墓碑可覆盖)。
1605
1607
  if (item.adapter === 'plugins' && item.id.startsWith('plugin:')) {
1606
- if (item.kind !== 'Install' && item.kind !== 'Update' && item.kind !== 'Conflict')
1608
+ if (item.kind !== 'Install' && item.kind !== 'Update' && item.kind !== 'Conflict' && item.kind !== 'Create')
1607
1609
  return null;
1608
1610
  return { kind: 'plugin', id: item.id.slice('plugin:'.length) };
1609
1611
  }
@@ -38,6 +38,15 @@ export declare function planItemWritesTarget(item: PlanItem, exec?: PlanItemExec
38
38
  * 行 id 是任意字符串,无法靠形状判断它是不是一个路径。
39
39
  */
40
40
  export declare const PLUGIN_PATCH_REF_PREFIX = "patchFile:";
41
+ /**
42
+ * plugins 分区中「profile 的 package.json」这一文件目标的 ref(issue #95)。
43
+ *
44
+ * 声明 host 自带的 bundle 层(`dsh.profile.bundles`)就是写这个文件,因此它必须与
45
+ * `pnpm-workspace.yaml` 同级:进导入前快照 → 回滚能整文件还原,否则一次导入留下的声明
46
+ * 撤不掉(照抄 \`patchFile:\` 的做法,用一个符号 ref 而不是真实文件名——ref 空间里还有
47
+ * 任意 patch 行 id,靠形状判断会撞车)。
48
+ */
49
+ export declare const PLUGIN_PROFILE_MANIFEST_REF = "profileManifest";
41
50
  /**
42
51
  * 以**系统 home 为根**的文件类分区(ref 是系统 home 相对路径)。
43
52
  *
@@ -118,6 +118,15 @@ for (const id of SECTION_IDS) {
118
118
  * 行 id 是任意字符串,无法靠形状判断它是不是一个路径。
119
119
  */
120
120
  export const PLUGIN_PATCH_REF_PREFIX = 'patchFile:';
121
+ /**
122
+ * plugins 分区中「profile 的 package.json」这一文件目标的 ref(issue #95)。
123
+ *
124
+ * 声明 host 自带的 bundle 层(`dsh.profile.bundles`)就是写这个文件,因此它必须与
125
+ * `pnpm-workspace.yaml` 同级:进导入前快照 → 回滚能整文件还原,否则一次导入留下的声明
126
+ * 撤不掉(照抄 \`patchFile:\` 的做法,用一个符号 ref 而不是真实文件名——ref 空间里还有
127
+ * 任意 patch 行 id,靠形状判断会撞车)。
128
+ */
129
+ export const PLUGIN_PROFILE_MANIFEST_REF = 'profileManifest';
121
130
  /** profile 目录绝对路径(plugins 分区内文件类目标的基准) */
122
131
  function profileDirOf(ctx) {
123
132
  const profile = ctx.profile !== undefined && ctx.profile !== '' ? ctx.profile : 'web';
@@ -167,6 +176,10 @@ export function resolveFileTarget(ctx, adapter, ref) {
167
176
  if (adapter === 'plugins' && ref.startsWith(PLUGIN_PATCH_REF_PREFIX)) {
168
177
  return path.join(profileDirOf(ctx), ref.slice(PLUGIN_PATCH_REF_PREFIX.length));
169
178
  }
179
+ // issue #95:声明 host 自带 bundle 层 = 写 profile 的 package.json(快照/回滚必须覆盖它)
180
+ if (adapter === 'plugins' && ref === PLUGIN_PROFILE_MANIFEST_REF) {
181
+ return path.join(profileDirOf(ctx), 'package.json');
182
+ }
170
183
  const base = FILE_BASES[adapter] ?? '';
171
184
  return path.join(ctx.homeDir, base, ref);
172
185
  }
@@ -188,6 +201,8 @@ export function resolveFileTargetRel(adapter, ref, profile) {
188
201
  const profileDir = `profiles/${profile !== undefined && profile !== '' ? profile : 'web'}`;
189
202
  if (ref === 'pnpm-workspace.yaml')
190
203
  return normalizePath(`${profileDir}/pnpm-workspace.yaml`);
204
+ if (ref === PLUGIN_PROFILE_MANIFEST_REF)
205
+ return normalizePath(`${profileDir}/package.json`);
191
206
  if (ref.startsWith(PLUGIN_PATCH_REF_PREFIX)) {
192
207
  return normalizePath(`${profileDir}/${ref.slice(PLUGIN_PATCH_REF_PREFIX.length)}`);
193
208
  }
@@ -257,7 +272,7 @@ async function engineSnapshotEntry(ctx, target) {
257
272
  case 'plugins':
258
273
  case 'prompts': {
259
274
  // plugins 分区的 pnpm-workspace.yaml / patch 文件 → 整文件快照(file 类,回滚可整文件还原)
260
- if (target.adapter === 'plugins' && (target.ref === 'pnpm-workspace.yaml' || target.ref.startsWith(PLUGIN_PATCH_REF_PREFIX))) {
275
+ if (target.adapter === 'plugins' && (target.ref === 'pnpm-workspace.yaml' || target.ref === PLUGIN_PROFILE_MANIFEST_REF || target.ref.startsWith(PLUGIN_PATCH_REF_PREFIX))) {
261
276
  const abs = resolveFileTarget(ctx, target.adapter, target.ref);
262
277
  if (!(await ctx.fs.exists(abs))) {
263
278
  return { kind: 'file', adapter: target.adapter, ref: target.ref, before: null, existed: false };
@@ -247,6 +247,15 @@ export declare const zh: {
247
247
  readonly 'adapter.pluginInstallOk': '插件 {name} 已安装{suffix}';
248
248
  readonly 'adapter.pluginUpdateTriggered': '插件 {name} 已触发更新(npm 最新版){suffix}';
249
249
  readonly 'adapter.pluginRestartSuffix': ',重启 dsh 后生效';
250
+ readonly 'adapter.pluginHostBundleDeclare': '声明插件层 {name}(由 DSH 自带提供,不安装)';
251
+ readonly 'adapter.pluginHostBundleSame': '插件层 {name} 已在目标机声明(由 DSH 自带提供)';
252
+ readonly 'adapter.pluginHostBundleDeclared': '已声明插件层 {name},重启 dsh 后生效';
253
+ readonly 'adapter.pluginHostBundleDetail': '该层在源机只出现在 dsh.profile.bundles、dependencies 里没有它,由 DSH 从自己的安装位置解析;导入只声明,不装 npm 版本';
254
+ readonly 'adapter.pluginHostBundleNoManifest': '无法声明插件层 {name}:目标机 profile 缺少 package.json';
255
+ readonly 'adapter.pluginHostBundleManifestUnreadable': '无法声明插件层 {name}:profile 清单读不到或形态不符({reason})';
256
+ readonly 'adapter.pluginHostBundleWriteFailed': '声明插件层 {name} 失败:{reason}';
257
+ readonly 'adapter.pluginHostBundleBadJson': 'package.json 不是合法 JSON';
258
+ readonly 'adapter.pluginHostBundleBadShape': 'dsh.profile.bundles 不是字符串数组';
250
259
  readonly 'adapter.pwMissing': '导入数据缺少 pnpm-workspace.yaml 内容';
251
260
  readonly 'adapter.pluginInstallFailed': '插件 {name} 安装失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)';
252
261
  readonly 'adapter.pluginUpdateFailed': '插件 {name} 更新失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)';
@@ -318,6 +327,7 @@ export declare const zh: {
318
327
  readonly 'adapter.validate.requiredField': '缺少 {subject}';
319
328
  readonly 'adapter.validate.sourceNamespace': '缺少来源 namespace';
320
329
  readonly 'adapter.validate.number': '{subject} 必须是数字';
330
+ readonly 'adapter.validate.boolean': '{subject} 必须是布尔值';
321
331
  readonly 'adapter.validate.nonEmptyRef': '{subject} 记录必须含非空 ref';
322
332
  readonly 'adapter.validate.credentialRef': '凭据记录必须含非空 ref';
323
333
  readonly 'adapter.validate.fileRelativePath': '文件记录必须含非空 relativePath';
@@ -238,6 +238,15 @@ export const zh = {
238
238
  'adapter.pluginInstallOk': '插件 {name} 已安装{suffix}',
239
239
  'adapter.pluginUpdateTriggered': '插件 {name} 已触发更新(npm 最新版){suffix}',
240
240
  'adapter.pluginRestartSuffix': ',重启 dsh 后生效',
241
+ 'adapter.pluginHostBundleDeclare': '声明插件层 {name}(由 DSH 自带提供,不安装)',
242
+ 'adapter.pluginHostBundleSame': '插件层 {name} 已在目标机声明(由 DSH 自带提供)',
243
+ 'adapter.pluginHostBundleDeclared': '已声明插件层 {name},重启 dsh 后生效',
244
+ 'adapter.pluginHostBundleDetail': '该层在源机只出现在 dsh.profile.bundles、dependencies 里没有它,由 DSH 从自己的安装位置解析;导入只声明,不装 npm 版本',
245
+ 'adapter.pluginHostBundleNoManifest': '无法声明插件层 {name}:目标机 profile 缺少 package.json',
246
+ 'adapter.pluginHostBundleManifestUnreadable': '无法声明插件层 {name}:profile 清单读不到或形态不符({reason})',
247
+ 'adapter.pluginHostBundleWriteFailed': '声明插件层 {name} 失败:{reason}',
248
+ 'adapter.pluginHostBundleBadJson': 'package.json 不是合法 JSON',
249
+ 'adapter.pluginHostBundleBadShape': 'dsh.profile.bundles 不是字符串数组',
241
250
  'adapter.pwMissing': '导入数据缺少 pnpm-workspace.yaml 内容',
242
251
  'adapter.pluginInstallFailed': '插件 {name} 安装失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)',
243
252
  'adapter.pluginUpdateFailed': '插件 {name} 更新失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)',
@@ -311,6 +320,7 @@ export const zh = {
311
320
  'adapter.validate.requiredField': '缺少 {subject}',
312
321
  'adapter.validate.sourceNamespace': '缺少来源 namespace',
313
322
  'adapter.validate.number': '{subject} 必须是数字',
323
+ 'adapter.validate.boolean': '{subject} 必须是布尔值',
314
324
  'adapter.validate.nonEmptyRef': '{subject} 记录必须含非空 ref',
315
325
  'adapter.validate.credentialRef': '凭据记录必须含非空 ref',
316
326
  'adapter.validate.fileRelativePath': '文件记录必须含非空 relativePath',
@@ -676,6 +686,15 @@ export const en = {
676
686
  'adapter.pluginInstallOk': 'Plugin {name} installed{suffix}',
677
687
  'adapter.pluginUpdateTriggered': 'Plugin {name} update triggered (npm latest){suffix}',
678
688
  'adapter.pluginRestartSuffix': '; takes effect after restarting dsh',
689
+ 'adapter.pluginHostBundleDeclare': 'Declare plugin layer {name} (provided by DSH itself; not installed)',
690
+ 'adapter.pluginHostBundleSame': 'Plugin layer {name} is already declared on the target (provided by DSH itself)',
691
+ 'adapter.pluginHostBundleDeclared': 'Plugin layer {name} declared; takes effect after restarting dsh',
692
+ 'adapter.pluginHostBundleDetail': 'On the source this layer only appears in dsh.profile.bundles (no dependency declaration) and is resolved by DSH from its own installation; the import only declares it and installs no npm version',
693
+ 'adapter.pluginHostBundleNoManifest': 'Cannot declare plugin layer {name}: the target profile has no package.json',
694
+ 'adapter.pluginHostBundleManifestUnreadable': 'Cannot declare plugin layer {name}: the profile manifest is unreadable or has an unexpected shape ({reason})',
695
+ 'adapter.pluginHostBundleWriteFailed': 'Failed to declare plugin layer {name}: {reason}',
696
+ 'adapter.pluginHostBundleBadJson': 'package.json is not valid JSON',
697
+ 'adapter.pluginHostBundleBadShape': 'dsh.profile.bundles is not an array of strings',
679
698
  'adapter.pwMissing': 'Import data is missing pnpm-workspace.yaml content',
680
699
  'adapter.pluginInstallFailed': 'Plugin {name} install failed: {msg}. You can install it manually: dsh plugin --profile {profile} add {name} (the rest of the config was imported; fix dependencies and retry from the settings page, or install by hand)',
681
700
  'adapter.pluginUpdateFailed': 'Plugin {name} update failed: {msg}. You can install it manually: dsh plugin --profile {profile} add {name} (the rest of the config was imported; fix dependencies and retry from the settings page, or install by hand)',
@@ -749,6 +768,7 @@ export const en = {
749
768
  'adapter.validate.requiredField': 'Missing {subject}',
750
769
  'adapter.validate.sourceNamespace': 'Missing source namespace',
751
770
  'adapter.validate.number': '{subject} must be a number',
771
+ 'adapter.validate.boolean': '{subject} must be a boolean',
752
772
  'adapter.validate.nonEmptyRef': '{subject} records must have a non-empty ref',
753
773
  'adapter.validate.credentialRef': 'Credential records must have a non-empty ref',
754
774
  'adapter.validate.fileRelativePath': 'File records must have a non-empty relativePath',
@@ -118,6 +118,32 @@ export declare function reconcileBundles(profileDir: string): boolean;
118
118
  export declare function cleanupAbortedInstall(profileDir: string, pkg: string): void;
119
119
  /** 实时读 profile 已装插件清单(先 reconcile bundles,再逐依赖取版本/bundle 属性)。 */
120
120
  export declare function listInstalledPlugins(homeDir: string, profile: string): PluginInfo[];
121
+ /**
122
+ * 「只在 `dsh.profile.bundles` 里声明、`dependencies` 里没有该包」的条目判定(issue #95)。
123
+ *
124
+ * 为什么要一个共享判据:导出端的显式字段(`bundlesOnly`)只存在于新备份;旧备份只能按形状
125
+ * 推断——**有依赖 spec 的绝不是**(那是普通依赖,哪怕本机没装全);**没有 spec 且读不到已装版本**
126
+ * 的才是(本 profile 里没有任何东西提供它,DSH 只能从自己的安装位置解析,issue #95)。
127
+ */
128
+ export declare function isBundlesOnlyEntry(entry: {
129
+ bundlesOnly?: unknown;
130
+ spec?: unknown;
131
+ version?: unknown;
132
+ }): boolean;
133
+ /**
134
+ * 把 `name` 声明进 profile package.json 的 `dsh.profile.bundles`(issue #95)。
135
+ *
136
+ * 只做「追加一个名字」:其余字段逐字保留,重复声明返回 `changed: false`(幂等,不空写文件)。
137
+ * 形状不可认(JSON 坏 / `dsh.profile` 非对象 / `bundles` 非字符串数组)→ 返回 null:
138
+ * 调用方必须**如实报告**并放弃这次声明,绝不重建一份清单出来(那会抹掉 profile 的其它字段)。
139
+ *
140
+ * `bundles` 字段**缺省也拒绝**:DSH 的语义是「bundles 非空即替换默认插件栈」,凭空造一个只含
141
+ * 一个名字的数组 = 悄悄改掉目标机的装配语义(应当由 DSH 自己写这个字段)。
142
+ */
143
+ export declare function mergeBundleDeclaration(pkgText: string, name: string): {
144
+ text: string;
145
+ changed: boolean;
146
+ } | null;
121
147
  /** 安装目标:非 registry spec 按 spec 安装;其余按裸包名(npm 最新版)。 */
122
148
  export declare function installSpecFor(pkg: string, spec?: string): string;
123
149
  /** 一次 CLI 调用的启动描述(env 只在需要覆写子进程环境时出现)。 */
@@ -327,18 +327,78 @@ export function listInstalledPlugins(homeDir, profile) {
327
327
  continue;
328
328
  if (Object.prototype.hasOwnProperty.call(installed, name))
329
329
  continue; // 已在依赖清单里
330
+ const installedVersion = readInstalledVersion(dir, name) ?? '';
330
331
  out.push({
331
332
  name,
332
- version: readInstalledVersion(dir, name) ?? '',
333
+ version: installedVersion,
333
334
  enabled: true,
334
335
  isBundle: true,
335
336
  // 直接声明的层自己就是 profile 层
336
337
  inBundles: [name],
337
- // 无依赖声明 → 无 spec:导入时按裸包名安装(与 registry 依赖同语义)
338
+ // issue #95:无依赖声明 + **本 profile 也没有它的落盘副本** ⇒ 这个包只可能由 DSH 从自己的
339
+ // 安装位置解析。导入端只能把它声明进目标机的 bundles,绝不按裸包名走 npm(那会把
340
+ // dist-tag latest 的副本装进 profile,与 host 版本线脱节)。
341
+ // 本地**有**落盘副本的手工层不在此列:那种层得靠安装通道才能在目标机上存在,保持旧行为。
342
+ ...(installedVersion === '' ? { bundlesOnly: true } : {}),
338
343
  });
339
344
  }
340
345
  return out;
341
346
  }
347
+ /**
348
+ * 「只在 `dsh.profile.bundles` 里声明、`dependencies` 里没有该包」的条目判定(issue #95)。
349
+ *
350
+ * 为什么要一个共享判据:导出端的显式字段(`bundlesOnly`)只存在于新备份;旧备份只能按形状
351
+ * 推断——**有依赖 spec 的绝不是**(那是普通依赖,哪怕本机没装全);**没有 spec 且读不到已装版本**
352
+ * 的才是(本 profile 里没有任何东西提供它,DSH 只能从自己的安装位置解析,issue #95)。
353
+ */
354
+ export function isBundlesOnlyEntry(entry) {
355
+ if (entry.bundlesOnly === true)
356
+ return true;
357
+ if (entry.bundlesOnly !== undefined)
358
+ return false;
359
+ const spec = typeof entry.spec === 'string' ? entry.spec : '';
360
+ const version = typeof entry.version === 'string' ? entry.version : '';
361
+ return spec === '' && version === '';
362
+ }
363
+ /**
364
+ * 把 `name` 声明进 profile package.json 的 `dsh.profile.bundles`(issue #95)。
365
+ *
366
+ * 只做「追加一个名字」:其余字段逐字保留,重复声明返回 `changed: false`(幂等,不空写文件)。
367
+ * 形状不可认(JSON 坏 / `dsh.profile` 非对象 / `bundles` 非字符串数组)→ 返回 null:
368
+ * 调用方必须**如实报告**并放弃这次声明,绝不重建一份清单出来(那会抹掉 profile 的其它字段)。
369
+ *
370
+ * `bundles` 字段**缺省也拒绝**:DSH 的语义是「bundles 非空即替换默认插件栈」,凭空造一个只含
371
+ * 一个名字的数组 = 悄悄改掉目标机的装配语义(应当由 DSH 自己写这个字段)。
372
+ */
373
+ export function mergeBundleDeclaration(pkgText, name) {
374
+ let parsed;
375
+ try {
376
+ parsed = JSON.parse(pkgText);
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
382
+ return null;
383
+ const pkg = parsed;
384
+ const dsh = pkg.dsh;
385
+ if (dsh === null || typeof dsh !== 'object' || Array.isArray(dsh))
386
+ return null;
387
+ const profile = dsh.profile;
388
+ if (profile === null || typeof profile !== 'object' || Array.isArray(profile))
389
+ return null;
390
+ const raw = profile.bundles;
391
+ if (raw === undefined)
392
+ return null;
393
+ if (!Array.isArray(raw) || raw.some((entry) => typeof entry !== 'string'))
394
+ return null;
395
+ const bundles = raw;
396
+ if (bundles.includes(name))
397
+ return { text: pkgText, changed: false };
398
+ bundles.push(name);
399
+ profile.bundles = bundles;
400
+ return { text: `${JSON.stringify(pkg, null, 2)}\n`, changed: true };
401
+ }
342
402
  /* --------------------------------------------------------------- CLI 通道 */
343
403
  /** 15 分钟默认上限(慢网络 + git 安装),可用环境变量覆盖(CI/测试)。 */
344
404
  const INSTALL_TIMEOUT_MS = Number(process.env.DSH_CONFIG_MANAGER_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000;
@@ -240,6 +240,16 @@ export interface PluginInfo {
240
240
  inBundles?: string[];
241
241
  /** 声明依赖 spec(profile package.json dependencies 原样);文件视图来自 readInstalled */
242
242
  spec?: string;
243
+ /**
244
+ * 只在 `dsh.profile.bundles` 里声明、`dependencies` 里没有该包,且本 profile 的
245
+ * `node_modules` 里也没有它的落盘副本(issue #95)。
246
+ *
247
+ * DSH 对这类层从**自己的安装位置**解析(`boot-safety.ts` 的「@deepseek-ai/* 由 DSH 从它自己的
248
+ * 安装位置解析」与 `RESCUE_KEEP_BUNDLE_PREFIXES` 同一口径),因此导入端**不得**把它当
249
+ * registry 依赖去 `dsh plugin add`:那会把 npm dist-tag `latest` 的副本装进目标 profile,
250
+ * 与 host 的版本线脱节(issue #95 的真机故障)。只能把名字声明进目标机的 bundles。
251
+ */
252
+ bundlesOnly?: boolean;
243
253
  }
244
254
  export interface PluginsFacade {
245
255
  listInstalled(): Promise<PluginInfo[]>;
package/lib/index.d.ts CHANGED
@@ -78,7 +78,7 @@ export declare const name = "config-manager";
78
78
  /** Services required before the engine can mount (present in every profile). */
79
79
  export declare const inject: string[];
80
80
  /** Plugin version, kept in sync with package.json ("version"). */
81
- export declare const PLUGIN_VERSION = "0.1.77";
81
+ export declare const PLUGIN_VERSION = "0.1.78";
82
82
  /**
83
83
  * Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
84
84
  * 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
package/lib/index.js CHANGED
@@ -138,7 +138,7 @@ export const name = 'config-manager';
138
138
  /** Services required before the engine can mount (present in every profile). */
139
139
  export const inject = ['settings', 'credentials'];
140
140
  /** Plugin version, kept in sync with package.json ("version"). */
141
- export const PLUGIN_VERSION = '0.1.77';
141
+ export const PLUGIN_VERSION = '0.1.78';
142
142
  /** Plugin own package name — excluded from its own exported plugins list. */
143
143
  const PLUGIN_NAME = 'dsh-config-manager';
144
144
  /**
@@ -111,6 +111,20 @@ export interface PluginEntry {
111
111
  * 否则按裸包名走 npm 最新版(官方机制)。
112
112
  */
113
113
  spec?: string;
114
+ /**
115
+ * 该层只在 profile 的 `dsh.profile.bundles` 里声明、`dependencies` 里没有它,且该 profile 的
116
+ * `node_modules` 里也**没有**它的落盘副本(issue #95,导出侧写入)。
117
+ *
118
+ * 语义:这不是一个「装在本 profile 里的包」,而是由 DSH 从**自己的安装位置**解析的层
119
+ * (与 `boot-safety.ts` / `RESCUE_KEEP_BUNDLE_PREFIXES` 同口径)。导入端对这类条目
120
+ * **只把名字声明进目标机的 `dsh.profile.bundles`,绝不走 npm 安装通道**——按裸包名安装会让
121
+ * 目标机拿到 `dist-tags.latest` 的副本(2026-10 真机:host 0.2.1-rc.2 + 包 0.2.1-alpha.2
122
+ * → session-controller 一直 pending → 新建会话 service-unavailable)。
123
+ *
124
+ * 缺省(旧备份没有该字段)= 由 `spec` / `version` 形状推断,见
125
+ * `src/core/plugin-cli.ts` 的 `isBundlesOnlyEntry`。
126
+ */
127
+ bundlesOnly?: boolean;
114
128
  isBundle: boolean;
115
129
  inBundles: string[];
116
130
  enabled: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-config-manager",
3
- "version": "0.1.77",
3
+ "version": "0.1.78",
4
4
  "license": "MIT",
5
5
  "description": "Backup, restore, export, import, migrate and sync your DeepSeek Harness (DSH) configuration: settings, plugins, MCP servers, skills, agent presets and workspaces. One-click migration to another machine. DSH 配置一键备份、恢复、导出、导入、迁移与同步插件。",
6
6
  "icon": "./icon.svg",
@@ -6,6 +6,8 @@ import test from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
7
  import { PluginsAdapter } from './plugins.ts';
8
8
  import { makeContext, makeImportContext } from './test-helpers.ts';
9
+ import { PLUGIN_PROFILE_MANIFEST_REF } from '../core/backup.ts';
10
+ import type { PluginsSection } from '../schema/types.ts';
9
11
  import type { PlanItem } from '../core/types.ts';
10
12
 
11
13
  test('issue #35:patch 文件随分区迁移;目标缺失的 patchedDependencies 声明导入时剔除', async () => {
@@ -567,3 +569,163 @@ test('t81 / t23 口径:patch 文件存在性探测读失败(非 ENOENT)必
567
569
  'ENOENT 仍应视为「不存在」(保留既有剔除语义与可见告警)',
568
570
  );
569
571
  });
572
+
573
+ /**
574
+ * issue #95:`@deepseek-ai/*` 的实验层在 DSH 里只写进 dsh.profile.bundles(包由 DSH 自己的
575
+ * 安装位置解析,profile 的 dependencies / node_modules 里都没有它)。导入端把它当 registry 包去
576
+ * `dsh plugin add`,会把 npm `dist-tags.latest` 的副本装进 profile —— 真机故障:
577
+ * host 0.2.1-rc.2 + 包 0.2.1-alpha.2 的隔离组契约对不上 → `session-controller` 一直 pending
578
+ * → 新建会话报 service-unavailable。
579
+ * 正确行为:只把名字声明进目标机的 dsh.profile.bundles,绝不走安装通道。
580
+ */
581
+ test('issue #95:host 自带的 bundle 层只声明 bundles,不装 npm 包', async () => {
582
+ const src = makeContext('win32', 'C:\\Users\\alice', 'web');
583
+ src.plugins.installed.set('@deepseek-ai/dsh-experimental-session-search', {
584
+ name: '@deepseek-ai/dsh-experimental-session-search',
585
+ version: '',
586
+ enabled: true,
587
+ isBundle: true,
588
+ inBundles: ['@deepseek-ai/dsh-experimental-session-search'],
589
+ bundlesOnly: true,
590
+ });
591
+ src.plugins.installed.set('dshmarket', { name: 'dshmarket', version: '1.0.3', enabled: true, spec: '^1.0.3' });
592
+
593
+ const adapter = new PluginsAdapter();
594
+ const exported = await adapter.export(src, { includeSecrets: false });
595
+ const layerName = '@deepseek-ai/dsh-experimental-session-search';
596
+ assert.equal(
597
+ exported.data.plugins.find((p) => p.name === layerName)?.bundlesOnly,
598
+ true,
599
+ '导出必须带上 bundlesOnly 标记',
600
+ );
601
+ assert.equal(
602
+ exported.data.plugins.find((p) => p.name === 'dshmarket')?.bundlesOnly,
603
+ undefined,
604
+ '普通依赖不得带该标记(旧备份字节形态不变)',
605
+ );
606
+
607
+ const dst = makeContext('linux', '/home/bob', 'web');
608
+ const pkgRel = 'profiles/web/package.json';
609
+ await dst.fs.writeFile(pkgRel, new TextEncoder().encode(`${JSON.stringify({
610
+ name: 'dsh-profile-web',
611
+ private: true,
612
+ dependencies: {},
613
+ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
614
+ }, null, 2)}\n`));
615
+ const sections = new Map([['plugins', exported.data]]);
616
+
617
+ const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
618
+ const item = items.find((i) => i.id === `plugin:${layerName}`);
619
+ assert.equal(item?.kind, 'Create', '缺声明 → Create(绝不是 Install)');
620
+ assert.deepEqual(
621
+ item?.target,
622
+ { adapter: 'plugins', ref: PLUGIN_PROFILE_MANIFEST_REF },
623
+ '写入目标必须进导入前快照(回滚能撤掉这次声明)',
624
+ );
625
+ assert.equal(items.find((i) => i.id === 'plugin:dshmarket')?.kind, 'Install', '普通依赖照旧走安装通道');
626
+
627
+ const r = await adapter.applyItem(item!, makeImportContext(dst, sections));
628
+ assert.equal(r.ok, true);
629
+ const written = JSON.parse(new TextDecoder().decode(dst.fs.files.get('/home/bob/profiles/web/package.json')!)) as {
630
+ dependencies: Record<string, string>;
631
+ dsh: { profile: { bundles: string[] } };
632
+ };
633
+ assert.deepEqual(
634
+ written.dsh.profile.bundles,
635
+ ['@deepseek-ai/dsh-base', layerName],
636
+ '只追加一层,原声明保序',
637
+ );
638
+ assert.deepEqual(written.dependencies, {}, '不得凭空写依赖行');
639
+ assert.equal(dst.plugins.installed.has(layerName), false, '绝不调用 npm 安装通道');
640
+
641
+ // 幂等:再分析一次 → Skip(不重复写、不重复报 Create)
642
+ const again = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
643
+ assert.equal(again.find((i) => i.id === item!.id)?.kind, 'Skip');
644
+ });
645
+
646
+ test('issue #95:旧备份(没有 bundlesOnly 字段)按形状识别,绝不误伤普通依赖', async () => {
647
+ const adapter = new PluginsAdapter();
648
+ const layerName = '@deepseek-ai/dsh-experimental-auto-review';
649
+ const data: PluginsSection = {
650
+ version: 1,
651
+ plugins: [
652
+ { name: layerName, version: '', isBundle: true, inBundles: [layerName], enabled: true },
653
+ { name: 'broken-dep', version: '', spec: '^2.0.0', isBundle: false, inBundles: [], enabled: true },
654
+ ],
655
+ patch: [],
656
+ };
657
+ const dst = makeContext('linux', '/home/bob', 'web');
658
+ await dst.fs.writeFile(
659
+ 'profiles/web/package.json',
660
+ new TextEncoder().encode(`${JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: [] } } }, null, 2)}\n`),
661
+ );
662
+ const sections = new Map([['plugins', data]]);
663
+ const items = await adapter.analyzeImport(data, makeImportContext(dst, sections));
664
+ assert.equal(
665
+ items.find((i) => i.id === `plugin:${layerName}`)?.kind,
666
+ 'Create',
667
+ '无 spec 且无已装版本 = host 自带层 → 只声明',
668
+ );
669
+ assert.equal(
670
+ items.find((i) => i.id === 'plugin:broken-dep')?.kind,
671
+ 'Install',
672
+ '有依赖声明 → 照旧安装(哪怕本机没装全,也绝不静默跳过)',
673
+ );
674
+
675
+ // 应用后:名字进了 bundles,且没有为该层调用安装通道
676
+ const r = await adapter.applyItem(
677
+ items.find((i) => i.id === `plugin:${layerName}`)!,
678
+ makeImportContext(dst, sections),
679
+ );
680
+ assert.equal(r.ok, true);
681
+ assert.equal(dst.plugins.installed.has(layerName), false);
682
+ const written = JSON.parse(new TextDecoder().decode(dst.fs.files.get('/home/bob/profiles/web/package.json')!)) as {
683
+ dsh: { profile: { bundles: string[] } };
684
+ };
685
+ assert.deepEqual(written.dsh.profile.bundles, [layerName]);
686
+ });
687
+
688
+ test('issue #95:目标清单缺失 / JSON 坏 / 形态不符 → 只告警,绝不重建清单', async () => {
689
+ const adapter = new PluginsAdapter();
690
+ const data: PluginsSection = {
691
+ version: 1,
692
+ plugins: [{ name: 'host-layer', version: '', isBundle: true, inBundles: ['host-layer'], enabled: true, bundlesOnly: true }],
693
+ patch: [],
694
+ };
695
+ // ① 目标机没有 package.json
696
+ const a = makeContext('linux', '/home/a', 'web');
697
+ const sectionsA = new Map([['plugins', data]]);
698
+ const itemsA = await adapter.analyzeImport(data, makeImportContext(a, sectionsA));
699
+ assert.equal(itemsA[0]?.kind, 'Warning', '没有清单 → 告警(不是 Install,也不是静默跳过)');
700
+ assert.match(itemsA[0]?.description ?? '', /package\.json/);
701
+ const applyA = await adapter.applyItem(itemsA[0]!, makeImportContext(a, sectionsA));
702
+ assert.equal(applyA.ok, false);
703
+ assert.equal(applyA.warning, true, '声明不了只记非致命 warning(§34.17)');
704
+ assert.equal(await a.fs.exists('profiles/web/package.json'), false, '绝不凭空造一份清单');
705
+
706
+ // ② JSON 坏:原文必须逐字不动
707
+ const b = makeContext('linux', '/home/b', 'web');
708
+ await b.fs.writeFile('profiles/web/package.json', new TextEncoder().encode('{'));
709
+ const sectionsB = new Map([['plugins', data]]);
710
+ const itemsB = await adapter.analyzeImport(data, makeImportContext(b, sectionsB));
711
+ assert.equal(itemsB[0]?.kind, 'Warning');
712
+ assert.match(itemsB[0]?.description ?? '', /JSON|形态/);
713
+ const applyB = await adapter.applyItem(itemsB[0]!, makeImportContext(b, sectionsB));
714
+ assert.equal(applyB.ok, false);
715
+ assert.equal(
716
+ new TextDecoder().decode(b.fs.files.get('/home/b/profiles/web/package.json')!),
717
+ '{',
718
+ '坏清单绝不被覆写',
719
+ );
720
+
721
+ // ③ dsh.profile.bundles 不是字符串数组
722
+ const c = makeContext('linux', '/home/c', 'web');
723
+ await c.fs.writeFile(
724
+ 'profiles/web/package.json',
725
+ new TextEncoder().encode(JSON.stringify({ dsh: { profile: { bundles: 'x' } } })),
726
+ );
727
+ const sectionsC = new Map([['plugins', data]]);
728
+ const itemsC = await adapter.analyzeImport(data, makeImportContext(c, sectionsC));
729
+ assert.equal(itemsC[0]?.kind, 'Warning');
730
+ assert.match(itemsC[0]?.description ?? '', /形态/);
731
+ });