From 5a107fdef5b34fe8a29581ee1b51b00409673e2f Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 13 Oct 2026 00:50:54 +0800 Subject: [PATCH] fix(plugin-manager): keep anchors and comment placement when pruning switches --- packages/boot/app-boot/src/index.ts | 2 +- packages/boot/app-boot/src/profile-context.ts | 14 ++++++++-- packages/boot/plugin-manager/README.i18n.yaml | 4 +-- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- packages/boot/plugin-manager/src/index.ts | 5 ++-- packages/boot/plugin-manager/src/patch.ts | 23 +++++++++------- .../boot/plugin-manager/tests/patch.spec.ts | 26 +++++++++++++++++-- 8 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index f5b08a4596..282519fbbd 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -17,7 +17,7 @@ import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from ' import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts' +export { readBundlePatches, readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts' export { sanitizeProfile } from './profile-sanitize.ts' export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts' export { diff --git a/packages/boot/app-boot/src/profile-context.ts b/packages/boot/app-boot/src/profile-context.ts index 328b543e00..e2ebfad5ce 100644 --- a/packages/boot/app-boot/src/profile-context.ts +++ b/packages/boot/app-boot/src/profile-context.ts @@ -54,6 +54,17 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b return { id: TELEMETRY_ROW_ID, disabled: true } } +/** Read the selected bundles' patches, which apply before the profile patch file. + * @param binName Diagnostic prefix for malformed or missing configuration. + * @param context Data supplied by the profile launcher. + * @param initialProfile Already loaded startup profile; omitted reads the current files. + * @returns Bundle patches in application order. + */ +export function readBundlePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] { + const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false }) + return profile.layers.flatMap(layer => layer.patches) +} + /** Read current bundle and user layers with the launch-time overlays. * @param binName Diagnostic prefix for malformed or missing configuration. * @param context Data supplied by the profile launcher. @@ -61,9 +72,8 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b * @returns Detached ordered patches; this function does not update the Loader. */ export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] { - const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false }) const patches = structuredClone([ - ...profile.layers.flatMap(layer => layer.patches), + ...readBundlePatches(binName, context, initialProfile), ...(initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? []), ...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []), ...context.overlays, diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 430a5e215b..c95d399d99 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: b0c68b304e1b0040 - zh: af0f4b659971d91d + en: d7dc0c0500c9d381 + zh: afd5faeaef84c714 /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 89c58803e3..1c3ca5f62e 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -37,7 +37,7 @@ For a deployment without agent presets, enable the tool in the profile patch. Pr disabled: false ``` -A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. An override holding only `id` and `disabled` is removed, and none is appended, when the bundle layers and the remaining profile patches already give the entry the requested state, so the profile patch records only departures from the bundles. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. +A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. An override holding only `id` and `disabled` is removed, and none is appended, when the bundle layers and the remaining profile patches already give the entry the requested state, so the profile patch records only departures from the bundles. Home and invocation patches are left out of that comparison; they apply after the profile patch and do not decide what it records. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 8e6b7ec952..1442c3c351 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" disabled: false ``` -插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。如果组合包层和其余 profile patch 已使条目处于所请求的状态,仅含 `id` 与 `disabled` 的覆盖项会被移除,也不会追加覆盖项,因此 profile patch 只记录与组合包不同的设置。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 +插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。如果组合包层和其余 profile patch 已使条目处于所请求的状态,仅含 `id` 与 `disabled` 的覆盖项会被移除,也不会追加覆盖项,因此 profile patch 只记录与组合包不同的设置。该比较不计入 home 和单次启动 patch;它们在 profile patch 之后应用,不决定 profile patch 记录什么。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index c0066f38b8..274c9bb7f1 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -13,7 +13,7 @@ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, - reconcileProfilePatches, readProfilePatches, loadProfileDirectory, OPTIONAL_BUNDLES, bundlePatchPaths, + reconcileProfilePatches, readProfilePatches, readBundlePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, @@ -455,8 +455,7 @@ export class PluginManager extends TypertRemoteService { const row = (await this.listPlugins()).find(item => item.entryId === id) if (row === undefined) throw new ManagementFailure('unknown-plugin') if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason) - const { layers } = loadProfileDirectory('dsh', this.profile.dir, this.profile.installAnchor, { userLayer: false }) - await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled, layers.flatMap(layer => layer.patches)) + await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled, readBundlePatches('dsh', this.profile)) result.warnings = await this.reload(enabled ? [row.patchId] : []) const current = (await this.listPlugins()).find(item => item.entryId === id) return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined diff --git a/packages/boot/plugin-manager/src/patch.ts b/packages/boot/plugin-manager/src/patch.ts index 2906a4eaad..148b17f783 100644 --- a/packages/boot/plugin-manager/src/patch.ts +++ b/packages/boot/plugin-manager/src/patch.ts @@ -1,4 +1,4 @@ -/** Comment-preserving profile plugin enablement edits. */ +/** Comment-preserving profile plugin enablement edits and the entry flattening they compare against. */ import { readFile } from 'node:fs/promises' import { isMap, isNode, isScalar, isSeq, parseDocument } from 'yaml' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' @@ -15,8 +15,8 @@ export function flatten(rows: EntryOptions[]): EntryOptions[] { } /** Replace the last matching override or append one after existing insertions. An override that holds only `id` and - * `disabled` is removed, or not appended, when the bundle layers and the remaining profile patches already give the - * entry the requested enablement. + * `disabled`, without anchors, is removed, or not appended, when the bundle layers and the remaining profile patches + * already give the entry the requested literal `disabled` value; home patches and launch overlays are not consulted. * @param filename Current profile patch file. * @param id Unique composition entry id. * @param name Module name used to match name-qualified overrides. @@ -47,19 +47,24 @@ export async function writePluginEnabled( const expectedName = document.getIn([index, 'name']) return !expectedName || expectedName === name }) - const inherits = (index: number): boolean => { - const row = flatten(composeEntries([[...layers, ...patches.toSpliced(index, 1)]])).find(entry => entry.id === id) + // Compares the entry's own literal `disabled`; a `!!js` value never matches, so its override is kept. + const implied = (index: number): boolean => { + const row = flatten(composeEntries([[...layers, ...patches.toSpliced(index, 1)]])).findLast(entry => entry.id === id) return row !== undefined && (row.disabled ?? false) === !enabled } if (isMap(target)) { const index = items.indexOf(target) - const onlySwitch = target.items.every(pair => isScalar(pair.key) && (pair.key.value === 'id' || pair.key.value === 'disabled')) - if (onlySwitch && inherits(index)) { + // A removed anchor would leave later aliases unresolved. + const onlySwitch = target.anchor === undefined && target.items.every(pair => isScalar(pair.key) && isScalar(pair.value) + && pair.key.anchor === undefined && pair.value.anchor === undefined && (pair.key.value === 'id' || pair.key.value === 'disabled')) + if (onlySwitch && implied(index)) { document.deleteIn([index]) - // The comment above the removed override can be the file header, so it moves to the following node. + // The comment above the removed override can be the file header, so it moves to the following item, above an + // emptied sequence, or after the remaining items. const comment = target.commentBefore const next = items[index] if (comment && isNode(next)) next.commentBefore = next.commentBefore ? `${comment}\n${next.commentBefore}` : comment + else if (comment && items.length === 0) document.contents.commentBefore = comment else if (comment) document.comment = document.comment ? `${comment}\n${document.comment}` : comment } else if (document.getIn([index, 'disabled']) === !enabled) { return false @@ -67,7 +72,7 @@ export async function writePluginEnabled( document.setIn([index, 'disabled'], !enabled) } } else { - if (inherits(items.length)) return false + if (implied(items.length)) return false document.add({ id, disabled: !enabled }) } await writeFileAtomic(filename, String(document), { mode: 0o600 }) diff --git a/packages/boot/plugin-manager/tests/patch.spec.ts b/packages/boot/plugin-manager/tests/patch.spec.ts index fc313a1398..4a80f4cfdc 100644 --- a/packages/boot/plugin-manager/tests/patch.spec.ts +++ b/packages/boot/plugin-manager/tests/patch.spec.ts @@ -114,14 +114,20 @@ it('keeps the comment above a removed switch and prunes a switch the composition const only = await fixture('# header\n- id: tool\n disabled: true\n') expect(await writePluginEnabled(only, 'tool', 'package', true, layers)).toBe(true) expect(loadOptionalPatches('test', only)).toEqual([]) - expect(await readFile(only, 'utf8')).toContain('# header') + expect(await readFile(only, 'utf8')).toBe('# header\n[]\n') + const last = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n') + expect(await writePluginEnabled(last, 'tool', 'package', true, layers)).toBe(true) + expect(await readFile(last, 'utf8')).toBe('- id: other\n disabled: true\n\n# about tool\n') + const lastWithTail = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n# tail\n') + expect(await writePluginEnabled(lastWithTail, 'tool', 'package', true, layers)).toBe(true) + expect(await readFile(lastWithTail, 'utf8')).toBe('- id: other\n disabled: true\n\n# about tool\n# tail\n') const trailing = await fixture('- id: tool\n disabled: true # removed\n# tail\n') expect(await writePluginEnabled(trailing, 'tool', 'package', true, layers)).toBe(true) expect(loadOptionalPatches('test', trailing)).toEqual([]) expect(await readFile(trailing, 'utf8')).toContain('# tail') const both = await fixture('# header\n- id: tool\n disabled: true\n# tail\n') expect(await writePluginEnabled(both, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(both, 'utf8')).toMatch(/# header[\s\S]*# tail/) + expect(await readFile(both, 'utf8')).toBe('# header\n[]\n\n# tail\n') }) it('treats insertions in the profile patch as part of the inherited composition', async () => { @@ -129,3 +135,19 @@ it('treats insertions in the profile patch as part of the inherited composition' expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(false) expect(loadOptionalPatches('test', file)).toEqual([{ insert: [{ id: 'tool', name: 'package' }] }]) }) + +it.each([ + ['a value', '- id: &plugin tool\n disabled: true\n- id: other\n config: { target: *plugin }\n', { target: 'tool' }], + ['the entry', '- &plugin { id: tool, disabled: true }\n- id: other\n config: { target: *plugin }\n', { target: { id: 'tool', disabled: false } }], + ['a key', '- id: tool\n &key disabled: true\n- id: other\n config: { *key : 1 }\n', { disabled: 1 }], +])('keeps a switch whose anchor on %s other entries reference', async (_, text, config) => { + const file = await fixture(text) + expect(await writePluginEnabled(file, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package' }] }])).toBe(true) + expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', disabled: false }, { id: 'other', config }]) +}) + +it('keeps a switch whose value is an alias', async () => { + const file = await fixture('- id: other\n config: { flag: &flag true }\n- id: tool\n disabled: *flag\n') + expect(await writePluginEnabled(file, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package' }] }])).toBe(true) + expect(loadOptionalPatches('test', file)).toEqual([{ id: 'other', config: { flag: true } }, { id: 'tool', disabled: false }]) +})