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.
- package/lib/adapters/plugins.d.ts +16 -0
- package/lib/adapters/plugins.js +131 -4
- package/lib/client.js +480 -480
- 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 +10 -0
- package/lib/core/messages.js +20 -0
- package/lib/core/plugin-cli.d.ts +26 -0
- package/lib/core/plugin-cli.js +62 -2
- package/lib/core/types.d.ts +10 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/schema/types.d.ts +14 -0
- package/package.json +1 -1
- package/src/adapters/plugins.test.ts +162 -0
- package/src/adapters/plugins.ts +138 -4
- package/src/core/analyzer.ts +3 -1
- package/src/core/backup.ts +16 -1
- package/src/core/messages.ts +20 -0
- package/src/core/plugin-cli.fs.test.ts +65 -3
- package/src/core/plugin-cli.ts +55 -2
- package/src/core/types.ts +10 -0
- package/src/index.ts +1 -1
- package/src/schema/types.ts +14 -0
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,7 +22,7 @@ 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';
|
|
@@ -321,6 +321,17 @@ function unitsOf(
|
|
|
321
321
|
return units;
|
|
322
322
|
}
|
|
323
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
|
+
|
|
324
335
|
export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
325
336
|
readonly id = 'plugins' as const;
|
|
326
337
|
// 元数据唯一来源 = 注册表(t31):不再与 ui/export-flow.ts 的导出目录各写一份
|
|
@@ -389,6 +400,9 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
389
400
|
isBundle: p.isBundle ?? false,
|
|
390
401
|
inBundles: p.inBundles ?? [],
|
|
391
402
|
enabled: p.enabled,
|
|
403
|
+
// issue #95:只在 dsh.profile.bundles 里声明的层(源机无依赖声明)必须带上这个标记,
|
|
404
|
+
// 导入端据此走「只声明」而不是 npm 安装。缺省不写,普通依赖的字节与改造前逐字一致。
|
|
405
|
+
...(p.bundlesOnly === true ? { bundlesOnly: true } : {}),
|
|
392
406
|
});
|
|
393
407
|
}
|
|
394
408
|
} catch (err) {
|
|
@@ -577,6 +591,81 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
577
591
|
return set;
|
|
578
592
|
}
|
|
579
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
|
+
|
|
580
669
|
async analyzeImport(data: PluginsSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
581
670
|
const msg = ctx.msg;
|
|
582
671
|
const items: PlanItem[] = [];
|
|
@@ -667,8 +756,42 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
667
756
|
|
|
668
757
|
// 插件:包名唯一键;同版本 Skip / 未装 Install / 版本不同 Conflict
|
|
669
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;
|
|
670
763
|
for (const p of data.plugins) {
|
|
671
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
|
+
}
|
|
672
795
|
const tp = targetInstalled.find((t) => t.name === p.name);
|
|
673
796
|
// T1:该插件在备份内自带 tarball(本地源 link:/file:)→ 目标机无需原始路径即可安装。
|
|
674
797
|
const hasTarball = tarballOf(data, p.name) !== undefined;
|
|
@@ -802,10 +925,16 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
802
925
|
// 拖垮已成功导入的其余配置——与 workspaces 的「目标不可达 → warning」同款语义。
|
|
803
926
|
if (item.id.startsWith('plugin:')) {
|
|
804
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
|
+
}
|
|
805
935
|
try {
|
|
806
936
|
// 非 registry 来源(github:/file: 等)按来源 spec 安装,registry 包按裸包名装 npm 最新版
|
|
807
|
-
|
|
808
|
-
let spec = data?.plugins.find((p) => p.name === name)?.spec;
|
|
937
|
+
let spec = entry?.spec;
|
|
809
938
|
|
|
810
939
|
// T1:备份内自带 tarball → 解包到 homeDir 内的本地插件缓存目录,把 spec 重写为绝对路径。
|
|
811
940
|
// 这是「换机后本地插件不再丢失」的关键:原始绝对路径在目标机不存在,解包出的 tgz 一定存在。
|
|
@@ -881,6 +1010,11 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
881
1010
|
if (typeof name !== 'string' || name === '') {
|
|
882
1011
|
issues.push({ path: `plugins[${i}].name`, message: msg('adapter.validate.requiredField', { subject: 'plugins[].name' }), severity: 'error' });
|
|
883
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
|
+
}
|
|
884
1018
|
}
|
|
885
1019
|
}
|
|
886
1020
|
if (section.patch !== undefined && !Array.isArray(section.patch)) {
|
package/src/core/analyzer.ts
CHANGED
|
@@ -1806,9 +1806,11 @@ function applyTombstoneFilter(
|
|
|
1806
1806
|
/** PlanItem → 墓碑键({kind,id})映射;非「将写入」的条目(Skip 等)与配置类项返回 null 不过滤 */
|
|
1807
1807
|
function tombstoneKeyForItem(item: PlanItem): { kind: TombstoneKind; id: string } | null {
|
|
1808
1808
|
// 插件本体(plugin:<pkg>):Install / Update / Conflict 均视为「将写入该插件」→ 命中墓碑剔除;
|
|
1809
|
+
// issue #95 的 Create = 「把 host 自带的层声明进 dsh.profile.bundles」,同样是写入该插件,
|
|
1810
|
+
// 必须一起被墓碑剔除(用户删过的插件不得被一份旧备份重新声明回来)。
|
|
1809
1811
|
// patch: 行与 plugins:pnpm-workspace 是配置类项,不按插件墓碑过滤(分区级墓碑可覆盖)。
|
|
1810
1812
|
if (item.adapter === 'plugins' && item.id.startsWith('plugin:')) {
|
|
1811
|
-
if (item.kind !== 'Install' && item.kind !== 'Update' && item.kind !== 'Conflict') return null;
|
|
1813
|
+
if (item.kind !== 'Install' && item.kind !== 'Update' && item.kind !== 'Conflict' && item.kind !== 'Create') return null;
|
|
1812
1814
|
return { kind: 'plugin', id: item.id.slice('plugin:'.length) };
|
|
1813
1815
|
}
|
|
1814
1816
|
// 文件类分区条目(id = <分区>:<相对路径>):skills → skill 墓碑;其余文件类(agentPresets/
|
package/src/core/backup.ts
CHANGED
|
@@ -164,6 +164,16 @@ for (const id of SECTION_IDS) {
|
|
|
164
164
|
*/
|
|
165
165
|
export const PLUGIN_PATCH_REF_PREFIX = 'patchFile:'
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* plugins 分区中「profile 的 package.json」这一文件目标的 ref(issue #95)。
|
|
169
|
+
*
|
|
170
|
+
* 声明 host 自带的 bundle 层(`dsh.profile.bundles`)就是写这个文件,因此它必须与
|
|
171
|
+
* `pnpm-workspace.yaml` 同级:进导入前快照 → 回滚能整文件还原,否则一次导入留下的声明
|
|
172
|
+
* 撤不掉(照抄 \`patchFile:\` 的做法,用一个符号 ref 而不是真实文件名——ref 空间里还有
|
|
173
|
+
* 任意 patch 行 id,靠形状判断会撞车)。
|
|
174
|
+
*/
|
|
175
|
+
export const PLUGIN_PROFILE_MANIFEST_REF = 'profileManifest'
|
|
176
|
+
|
|
167
177
|
/** profile 目录绝对路径(plugins 分区内文件类目标的基准) */
|
|
168
178
|
function profileDirOf(ctx: HostContext): string {
|
|
169
179
|
const profile = ctx.profile !== undefined && ctx.profile !== '' ? ctx.profile : 'web';
|
|
@@ -216,6 +226,10 @@ export function resolveFileTarget(ctx: HostContext, adapter: SectionId, ref: str
|
|
|
216
226
|
if (adapter === 'plugins' && ref.startsWith(PLUGIN_PATCH_REF_PREFIX)) {
|
|
217
227
|
return path.join(profileDirOf(ctx), ref.slice(PLUGIN_PATCH_REF_PREFIX.length));
|
|
218
228
|
}
|
|
229
|
+
// issue #95:声明 host 自带 bundle 层 = 写 profile 的 package.json(快照/回滚必须覆盖它)
|
|
230
|
+
if (adapter === 'plugins' && ref === PLUGIN_PROFILE_MANIFEST_REF) {
|
|
231
|
+
return path.join(profileDirOf(ctx), 'package.json');
|
|
232
|
+
}
|
|
219
233
|
const base = FILE_BASES[adapter] ?? '';
|
|
220
234
|
return path.join(ctx.homeDir, base, ref);
|
|
221
235
|
}
|
|
@@ -236,6 +250,7 @@ export function resolveFileTargetRel(adapter: SectionId, ref: string, profile?:
|
|
|
236
250
|
if (adapter === 'plugins') {
|
|
237
251
|
const profileDir = `profiles/${profile !== undefined && profile !== '' ? profile : 'web'}`;
|
|
238
252
|
if (ref === 'pnpm-workspace.yaml') return normalizePath(`${profileDir}/pnpm-workspace.yaml`);
|
|
253
|
+
if (ref === PLUGIN_PROFILE_MANIFEST_REF) return normalizePath(`${profileDir}/package.json`);
|
|
239
254
|
if (ref.startsWith(PLUGIN_PATCH_REF_PREFIX)) {
|
|
240
255
|
return normalizePath(`${profileDir}/${ref.slice(PLUGIN_PATCH_REF_PREFIX.length)}`);
|
|
241
256
|
}
|
|
@@ -316,7 +331,7 @@ async function engineSnapshotEntry(ctx: HostContext, target: SnapshotTarget): Pr
|
|
|
316
331
|
case 'plugins':
|
|
317
332
|
case 'prompts': {
|
|
318
333
|
// plugins 分区的 pnpm-workspace.yaml / patch 文件 → 整文件快照(file 类,回滚可整文件还原)
|
|
319
|
-
if (target.adapter === 'plugins' && (target.ref === 'pnpm-workspace.yaml' || target.ref.startsWith(PLUGIN_PATCH_REF_PREFIX))) {
|
|
334
|
+
if (target.adapter === 'plugins' && (target.ref === 'pnpm-workspace.yaml' || target.ref === PLUGIN_PROFILE_MANIFEST_REF || target.ref.startsWith(PLUGIN_PATCH_REF_PREFIX))) {
|
|
320
335
|
const abs = resolveFileTarget(ctx, target.adapter, target.ref);
|
|
321
336
|
if (!(await ctx.fs.exists(abs))) {
|
|
322
337
|
return { kind: 'file', adapter: target.adapter, ref: target.ref, before: null, existed: false };
|
package/src/core/messages.ts
CHANGED
|
@@ -265,6 +265,15 @@ export const zh = {
|
|
|
265
265
|
'adapter.pluginInstallOk': '插件 {name} 已安装{suffix}',
|
|
266
266
|
'adapter.pluginUpdateTriggered': '插件 {name} 已触发更新(npm 最新版){suffix}',
|
|
267
267
|
'adapter.pluginRestartSuffix': ',重启 dsh 后生效',
|
|
268
|
+
'adapter.pluginHostBundleDeclare': '声明插件层 {name}(由 DSH 自带提供,不安装)',
|
|
269
|
+
'adapter.pluginHostBundleSame': '插件层 {name} 已在目标机声明(由 DSH 自带提供)',
|
|
270
|
+
'adapter.pluginHostBundleDeclared': '已声明插件层 {name},重启 dsh 后生效',
|
|
271
|
+
'adapter.pluginHostBundleDetail': '该层在源机只出现在 dsh.profile.bundles、dependencies 里没有它,由 DSH 从自己的安装位置解析;导入只声明,不装 npm 版本',
|
|
272
|
+
'adapter.pluginHostBundleNoManifest': '无法声明插件层 {name}:目标机 profile 缺少 package.json',
|
|
273
|
+
'adapter.pluginHostBundleManifestUnreadable': '无法声明插件层 {name}:profile 清单读不到或形态不符({reason})',
|
|
274
|
+
'adapter.pluginHostBundleWriteFailed': '声明插件层 {name} 失败:{reason}',
|
|
275
|
+
'adapter.pluginHostBundleBadJson': 'package.json 不是合法 JSON',
|
|
276
|
+
'adapter.pluginHostBundleBadShape': 'dsh.profile.bundles 不是字符串数组',
|
|
268
277
|
'adapter.pwMissing': '导入数据缺少 pnpm-workspace.yaml 内容',
|
|
269
278
|
'adapter.pluginInstallFailed': '插件 {name} 安装失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)',
|
|
270
279
|
'adapter.pluginUpdateFailed': '插件 {name} 更新失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)',
|
|
@@ -340,6 +349,7 @@ export const zh = {
|
|
|
340
349
|
'adapter.validate.requiredField': '缺少 {subject}',
|
|
341
350
|
'adapter.validate.sourceNamespace': '缺少来源 namespace',
|
|
342
351
|
'adapter.validate.number': '{subject} 必须是数字',
|
|
352
|
+
'adapter.validate.boolean': '{subject} 必须是布尔值',
|
|
343
353
|
'adapter.validate.nonEmptyRef': '{subject} 记录必须含非空 ref',
|
|
344
354
|
'adapter.validate.credentialRef': '凭据记录必须含非空 ref',
|
|
345
355
|
'adapter.validate.fileRelativePath': '文件记录必须含非空 relativePath',
|
|
@@ -715,6 +725,15 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
715
725
|
'adapter.pluginInstallOk': 'Plugin {name} installed{suffix}',
|
|
716
726
|
'adapter.pluginUpdateTriggered': 'Plugin {name} update triggered (npm latest){suffix}',
|
|
717
727
|
'adapter.pluginRestartSuffix': '; takes effect after restarting dsh',
|
|
728
|
+
'adapter.pluginHostBundleDeclare': 'Declare plugin layer {name} (provided by DSH itself; not installed)',
|
|
729
|
+
'adapter.pluginHostBundleSame': 'Plugin layer {name} is already declared on the target (provided by DSH itself)',
|
|
730
|
+
'adapter.pluginHostBundleDeclared': 'Plugin layer {name} declared; takes effect after restarting dsh',
|
|
731
|
+
'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',
|
|
732
|
+
'adapter.pluginHostBundleNoManifest': 'Cannot declare plugin layer {name}: the target profile has no package.json',
|
|
733
|
+
'adapter.pluginHostBundleManifestUnreadable': 'Cannot declare plugin layer {name}: the profile manifest is unreadable or has an unexpected shape ({reason})',
|
|
734
|
+
'adapter.pluginHostBundleWriteFailed': 'Failed to declare plugin layer {name}: {reason}',
|
|
735
|
+
'adapter.pluginHostBundleBadJson': 'package.json is not valid JSON',
|
|
736
|
+
'adapter.pluginHostBundleBadShape': 'dsh.profile.bundles is not an array of strings',
|
|
718
737
|
'adapter.pwMissing': 'Import data is missing pnpm-workspace.yaml content',
|
|
719
738
|
'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)',
|
|
720
739
|
'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)',
|
|
@@ -790,6 +809,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
790
809
|
'adapter.validate.requiredField': 'Missing {subject}',
|
|
791
810
|
'adapter.validate.sourceNamespace': 'Missing source namespace',
|
|
792
811
|
'adapter.validate.number': '{subject} must be a number',
|
|
812
|
+
'adapter.validate.boolean': '{subject} must be a boolean',
|
|
793
813
|
'adapter.validate.nonEmptyRef': '{subject} records must have a non-empty ref',
|
|
794
814
|
'adapter.validate.credentialRef': 'Credential records must have a non-empty ref',
|
|
795
815
|
'adapter.validate.fileRelativePath': 'File records must have a non-empty relativePath',
|
|
@@ -13,8 +13,8 @@ import { tmpdir } from 'node:os';
|
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
-
listInstalledPlugins, readInstalled, readInstalledVersion,
|
|
17
|
-
resolveProfileDir,
|
|
16
|
+
isBundlesOnlyEntry, listInstalledPlugins, mergeBundleDeclaration, readInstalled, readInstalledVersion,
|
|
17
|
+
reconcileBundles, resolveProfileDir,
|
|
18
18
|
} from './plugin-cli.ts';
|
|
19
19
|
import { enterRescueMode } from './boot-rescue.ts';
|
|
20
20
|
import type { PluginInfo } from './types.ts';
|
|
@@ -146,7 +146,9 @@ test('issue #28: 仅由 dsh.profile.bundles 声明的层必须出现在已装清
|
|
|
146
146
|
assert.equal(bundleOnly?.version, '0.3.1', '版本取 node_modules 落盘版本');
|
|
147
147
|
assert.equal(bundleOnly?.isBundle, true);
|
|
148
148
|
assert.deepEqual(bundleOnly?.inBundles, ['@linxin666/dsh-web-ui-all']);
|
|
149
|
-
assert.equal(bundleOnly?.spec, undefined, '无依赖声明 → 无 spec
|
|
149
|
+
assert.equal(bundleOnly?.spec, undefined, '无依赖声明 → 无 spec');
|
|
150
|
+
// issue #95:本机**有**落盘副本的手工层不是「host 自带层」,标记不得出现(保持安装通道行为)
|
|
151
|
+
assert.equal(bundleOnly?.bundlesOnly, undefined, '本机有副本 → 不标记 bundlesOnly');
|
|
150
152
|
// 从未落盘的 bundles 条目:仍列出(它确实是启动时会挂载的层),版本空串不抛
|
|
151
153
|
assert.equal(list.find((p) => p.name === 'stale-pkg')?.version, '');
|
|
152
154
|
// in-box bundle 依然不出现(无论来自 dependencies 还是 bundles)
|
|
@@ -234,3 +236,63 @@ test('reconcileBundles:救援模式下必须停手(不得把收窄掉的用
|
|
|
234
236
|
cleanup();
|
|
235
237
|
}
|
|
236
238
|
});
|
|
239
|
+
|
|
240
|
+
test('issue #95: 只在 bundles 里声明、且本机没有落盘副本的层必须标记 bundlesOnly', () => {
|
|
241
|
+
const { homeDir, profileDir, cleanup } = makeTempProfile(
|
|
242
|
+
{ 'pkg-a': '1.0.0' },
|
|
243
|
+
['@deepseek-ai/dsh-experimental-session-search', '@linxin666/dsh-web-ui-all'],
|
|
244
|
+
);
|
|
245
|
+
try {
|
|
246
|
+
writeInstalledPkg(profileDir, 'pkg-a', '1.0.0');
|
|
247
|
+
writeInstalledPkg(profileDir, '@linxin666/dsh-web-ui-all', '0.3.1', 'patch.yml');
|
|
248
|
+
|
|
249
|
+
const list = listInstalledPlugins(homeDir, 'web');
|
|
250
|
+
const hostLayer = list.find((p) => p.name === '@deepseek-ai/dsh-experimental-session-search');
|
|
251
|
+
assert.equal(hostLayer?.version, '', '本 profile 没装它 → 版本空串');
|
|
252
|
+
assert.equal(hostLayer?.bundlesOnly, true, '无依赖 + 无落盘副本 ⇒ 只能由 DSH 自己提供');
|
|
253
|
+
assert.equal(list.find((p) => p.name === '@linxin666/dsh-web-ui-all')?.bundlesOnly, undefined, '有落盘副本 → 保持旧行为');
|
|
254
|
+
assert.equal(list.find((p) => p.name === 'pkg-a')?.bundlesOnly, undefined, '普通依赖不得带标记');
|
|
255
|
+
} finally {
|
|
256
|
+
cleanup();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('issue #95: isBundlesOnlyEntry —— 显式字段优先,旧备份按形状推断', () => {
|
|
261
|
+
assert.equal(isBundlesOnlyEntry({ bundlesOnly: true, spec: '^1.0.0', version: '1.0.0' }), true, '显式字段优先');
|
|
262
|
+
assert.equal(isBundlesOnlyEntry({ bundlesOnly: false, version: '' }), false, '显式 false 不得被形状覆盖');
|
|
263
|
+
assert.equal(isBundlesOnlyEntry({ version: '' }), true, '旧备份:无 spec 且无已装版本');
|
|
264
|
+
assert.equal(isBundlesOnlyEntry({ spec: '^1.0.0', version: '' }), false, '有依赖声明 → 普通依赖(哪怕本机没装全)');
|
|
265
|
+
assert.equal(isBundlesOnlyEntry({ version: '0.3.1' }), false, '本机有落盘副本 → 手工层');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('issue #95: mergeBundleDeclaration 只追加一个名字;形状不符一律拒绝', () => {
|
|
269
|
+
const base = `${JSON.stringify({ name: 'dsh-profile-web', dependencies: { a: '1.0.0' }, dsh: { profile: { bundles: ['x'] } } }, null, 2)}\n`;
|
|
270
|
+
const merged = mergeBundleDeclaration(base, 'y');
|
|
271
|
+
assert.equal(merged?.changed, true);
|
|
272
|
+
const parsed = JSON.parse(merged!.text) as {
|
|
273
|
+
dependencies: Record<string, string>;
|
|
274
|
+
dsh: { profile: { bundles: string[] } };
|
|
275
|
+
};
|
|
276
|
+
assert.deepEqual(parsed.dsh.profile.bundles, ['x', 'y'], '名字追加在尾部');
|
|
277
|
+
assert.deepEqual(parsed.dependencies, { a: '1.0.0' }, '其余字段逐字保留');
|
|
278
|
+
assert.equal(mergeBundleDeclaration(merged!.text, 'y')?.changed, false, '幂等:重复声明不空写');
|
|
279
|
+
|
|
280
|
+
assert.equal(mergeBundleDeclaration('{', 'y'), null, 'JSON 坏 → null(绝不重建清单)');
|
|
281
|
+
assert.equal(mergeBundleDeclaration('[]', 'y'), null, '顶层非对象 → null');
|
|
282
|
+
assert.equal(mergeBundleDeclaration(JSON.stringify({ name: 'p' }), 'y'), null, '缺 dsh.profile → null');
|
|
283
|
+
assert.equal(
|
|
284
|
+
mergeBundleDeclaration(JSON.stringify({ dsh: { profile: { bundles: 'x' } } }), 'y'),
|
|
285
|
+
null,
|
|
286
|
+
'bundles 非数组 → null',
|
|
287
|
+
);
|
|
288
|
+
assert.equal(
|
|
289
|
+
mergeBundleDeclaration(JSON.stringify({ dsh: { profile: { bundles: ['x', 1] } } }), 'y'),
|
|
290
|
+
null,
|
|
291
|
+
'bundles 含非字符串 → null',
|
|
292
|
+
);
|
|
293
|
+
assert.equal(
|
|
294
|
+
mergeBundleDeclaration(JSON.stringify({ dsh: { profile: {} } }), 'y'),
|
|
295
|
+
null,
|
|
296
|
+
'bundles 字段缺省 → null(非空 bundles 会替换默认插件栈,绝不凭空造)',
|
|
297
|
+
);
|
|
298
|
+
});
|
package/src/core/plugin-cli.ts
CHANGED
|
@@ -340,19 +340,72 @@ export function listInstalledPlugins(homeDir: string, profile: string): PluginIn
|
|
|
340
340
|
for (const name of Array.isArray(declaredBundles) ? declaredBundles : []) {
|
|
341
341
|
if (INBOX_BUNDLES.has(name)) continue
|
|
342
342
|
if (Object.prototype.hasOwnProperty.call(installed, name)) continue // 已在依赖清单里
|
|
343
|
+
const installedVersion = readInstalledVersion(dir, name) ?? ''
|
|
343
344
|
out.push({
|
|
344
345
|
name,
|
|
345
|
-
version:
|
|
346
|
+
version: installedVersion,
|
|
346
347
|
enabled: true,
|
|
347
348
|
isBundle: true,
|
|
348
349
|
// 直接声明的层自己就是 profile 层
|
|
349
350
|
inBundles: [name],
|
|
350
|
-
//
|
|
351
|
+
// issue #95:无依赖声明 + **本 profile 也没有它的落盘副本** ⇒ 这个包只可能由 DSH 从自己的
|
|
352
|
+
// 安装位置解析。导入端只能把它声明进目标机的 bundles,绝不按裸包名走 npm(那会把
|
|
353
|
+
// dist-tag latest 的副本装进 profile,与 host 版本线脱节)。
|
|
354
|
+
// 本地**有**落盘副本的手工层不在此列:那种层得靠安装通道才能在目标机上存在,保持旧行为。
|
|
355
|
+
...(installedVersion === '' ? { bundlesOnly: true } : {}),
|
|
351
356
|
})
|
|
352
357
|
}
|
|
353
358
|
return out
|
|
354
359
|
}
|
|
355
360
|
|
|
361
|
+
/**
|
|
362
|
+
* 「只在 `dsh.profile.bundles` 里声明、`dependencies` 里没有该包」的条目判定(issue #95)。
|
|
363
|
+
*
|
|
364
|
+
* 为什么要一个共享判据:导出端的显式字段(`bundlesOnly`)只存在于新备份;旧备份只能按形状
|
|
365
|
+
* 推断——**有依赖 spec 的绝不是**(那是普通依赖,哪怕本机没装全);**没有 spec 且读不到已装版本**
|
|
366
|
+
* 的才是(本 profile 里没有任何东西提供它,DSH 只能从自己的安装位置解析,issue #95)。
|
|
367
|
+
*/
|
|
368
|
+
export function isBundlesOnlyEntry(entry: { bundlesOnly?: unknown; spec?: unknown; version?: unknown }): boolean {
|
|
369
|
+
if (entry.bundlesOnly === true) return true
|
|
370
|
+
if (entry.bundlesOnly !== undefined) return false
|
|
371
|
+
const spec = typeof entry.spec === 'string' ? entry.spec : ''
|
|
372
|
+
const version = typeof entry.version === 'string' ? entry.version : ''
|
|
373
|
+
return spec === '' && version === ''
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 把 `name` 声明进 profile package.json 的 `dsh.profile.bundles`(issue #95)。
|
|
378
|
+
*
|
|
379
|
+
* 只做「追加一个名字」:其余字段逐字保留,重复声明返回 `changed: false`(幂等,不空写文件)。
|
|
380
|
+
* 形状不可认(JSON 坏 / `dsh.profile` 非对象 / `bundles` 非字符串数组)→ 返回 null:
|
|
381
|
+
* 调用方必须**如实报告**并放弃这次声明,绝不重建一份清单出来(那会抹掉 profile 的其它字段)。
|
|
382
|
+
*
|
|
383
|
+
* `bundles` 字段**缺省也拒绝**:DSH 的语义是「bundles 非空即替换默认插件栈」,凭空造一个只含
|
|
384
|
+
* 一个名字的数组 = 悄悄改掉目标机的装配语义(应当由 DSH 自己写这个字段)。
|
|
385
|
+
*/
|
|
386
|
+
export function mergeBundleDeclaration(pkgText: string, name: string): { text: string; changed: boolean } | null {
|
|
387
|
+
let parsed: unknown
|
|
388
|
+
try {
|
|
389
|
+
parsed = JSON.parse(pkgText)
|
|
390
|
+
} catch {
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
394
|
+
const pkg = parsed as Record<string, unknown>
|
|
395
|
+
const dsh = pkg.dsh
|
|
396
|
+
if (dsh === null || typeof dsh !== 'object' || Array.isArray(dsh)) return null
|
|
397
|
+
const profile = (dsh as Record<string, unknown>).profile
|
|
398
|
+
if (profile === null || typeof profile !== 'object' || Array.isArray(profile)) return null
|
|
399
|
+
const raw = (profile as Record<string, unknown>).bundles
|
|
400
|
+
if (raw === undefined) return null
|
|
401
|
+
if (!Array.isArray(raw) || raw.some((entry) => typeof entry !== 'string')) return null
|
|
402
|
+
const bundles = raw as string[]
|
|
403
|
+
if (bundles.includes(name)) return { text: pkgText, changed: false }
|
|
404
|
+
bundles.push(name)
|
|
405
|
+
;(profile as Record<string, unknown>).bundles = bundles
|
|
406
|
+
return { text: `${JSON.stringify(pkg, null, 2)}\n`, changed: true }
|
|
407
|
+
}
|
|
408
|
+
|
|
356
409
|
/* --------------------------------------------------------------- CLI 通道 */
|
|
357
410
|
|
|
358
411
|
/** 15 分钟默认上限(慢网络 + git 安装),可用环境变量覆盖(CI/测试)。 */
|
package/src/core/types.ts
CHANGED
|
@@ -234,6 +234,16 @@ export interface PluginInfo {
|
|
|
234
234
|
inBundles?: string[];
|
|
235
235
|
/** 声明依赖 spec(profile package.json dependencies 原样);文件视图来自 readInstalled */
|
|
236
236
|
spec?: string;
|
|
237
|
+
/**
|
|
238
|
+
* 只在 `dsh.profile.bundles` 里声明、`dependencies` 里没有该包,且本 profile 的
|
|
239
|
+
* `node_modules` 里也没有它的落盘副本(issue #95)。
|
|
240
|
+
*
|
|
241
|
+
* DSH 对这类层从**自己的安装位置**解析(`boot-safety.ts` 的「@deepseek-ai/* 由 DSH 从它自己的
|
|
242
|
+
* 安装位置解析」与 `RESCUE_KEEP_BUNDLE_PREFIXES` 同一口径),因此导入端**不得**把它当
|
|
243
|
+
* registry 依赖去 `dsh plugin add`:那会把 npm dist-tag `latest` 的副本装进目标 profile,
|
|
244
|
+
* 与 host 的版本线脱节(issue #95 的真机故障)。只能把名字声明进目标机的 bundles。
|
|
245
|
+
*/
|
|
246
|
+
bundlesOnly?: boolean;
|
|
237
247
|
}
|
|
238
248
|
|
|
239
249
|
export interface PluginsFacade {
|
package/src/index.ts
CHANGED
|
@@ -181,7 +181,7 @@ export const name = 'config-manager'
|
|
|
181
181
|
export const inject = ['settings', 'credentials']
|
|
182
182
|
|
|
183
183
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
184
|
-
export const PLUGIN_VERSION = '0.1.
|
|
184
|
+
export const PLUGIN_VERSION = '0.1.78'
|
|
185
185
|
|
|
186
186
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
187
187
|
const PLUGIN_NAME = 'dsh-config-manager'
|
package/src/schema/types.ts
CHANGED
|
@@ -113,6 +113,20 @@ export interface PluginEntry {
|
|
|
113
113
|
* 否则按裸包名走 npm 最新版(官方机制)。
|
|
114
114
|
*/
|
|
115
115
|
spec?: string;
|
|
116
|
+
/**
|
|
117
|
+
* 该层只在 profile 的 `dsh.profile.bundles` 里声明、`dependencies` 里没有它,且该 profile 的
|
|
118
|
+
* `node_modules` 里也**没有**它的落盘副本(issue #95,导出侧写入)。
|
|
119
|
+
*
|
|
120
|
+
* 语义:这不是一个「装在本 profile 里的包」,而是由 DSH 从**自己的安装位置**解析的层
|
|
121
|
+
* (与 `boot-safety.ts` / `RESCUE_KEEP_BUNDLE_PREFIXES` 同口径)。导入端对这类条目
|
|
122
|
+
* **只把名字声明进目标机的 `dsh.profile.bundles`,绝不走 npm 安装通道**——按裸包名安装会让
|
|
123
|
+
* 目标机拿到 `dist-tags.latest` 的副本(2026-10 真机:host 0.2.1-rc.2 + 包 0.2.1-alpha.2
|
|
124
|
+
* → session-controller 一直 pending → 新建会话 service-unavailable)。
|
|
125
|
+
*
|
|
126
|
+
* 缺省(旧备份没有该字段)= 由 `spec` / `version` 形状推断,见
|
|
127
|
+
* `src/core/plugin-cli.ts` 的 `isBundlesOnlyEntry`。
|
|
128
|
+
*/
|
|
129
|
+
bundlesOnly?: boolean;
|
|
116
130
|
isBundle: boolean;
|
|
117
131
|
inBundles: string[];
|
|
118
132
|
enabled: boolean;
|