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/lib/core/analyzer.js
CHANGED
|
@@ -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
|
}
|
package/lib/core/backup.d.ts
CHANGED
|
@@ -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
|
*
|
package/lib/core/backup.js
CHANGED
|
@@ -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 };
|
package/lib/core/messages.d.ts
CHANGED
|
@@ -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';
|
package/lib/core/messages.js
CHANGED
|
@@ -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',
|
package/lib/core/plugin-cli.d.ts
CHANGED
|
@@ -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 只在需要覆写子进程环境时出现)。 */
|
package/lib/core/plugin-cli.js
CHANGED
|
@@ -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:
|
|
333
|
+
version: installedVersion,
|
|
333
334
|
enabled: true,
|
|
334
335
|
isBundle: true,
|
|
335
336
|
// 直接声明的层自己就是 profile 层
|
|
336
337
|
inBundles: [name],
|
|
337
|
-
//
|
|
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;
|
package/lib/core/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
/**
|
package/lib/schema/types.d.ts
CHANGED
|
@@ -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.
|
|
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
|
+
});
|