From e776e2b3d2bfc36c276f614842c4f11153ccbf27 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 13 Oct 2026 17:09:18 +0800 Subject: [PATCH] refactor(plugin-manager): pass the profile to writePluginEnabled --- packages/boot/plugin-manager/src/index.ts | 4 +- packages/boot/plugin-manager/src/patch.ts | 14 +- .../boot/plugin-manager/tests/patch.spec.ts | 179 +++++++++--------- 3 files changed, 102 insertions(+), 95 deletions(-) diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 274c9bb7f1..6434b0edfe 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, readBundlePatches, OPTIONAL_BUNDLES, bundlePatchPaths, + reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, @@ -455,7 +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) - await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled, readBundlePatches('dsh', this.profile)) + await writePluginEnabled(this.profile, row.patchId, row.moduleName, enabled) 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 148b17f783..0dfacb2de6 100644 --- a/packages/boot/plugin-manager/src/patch.ts +++ b/packages/boot/plugin-manager/src/patch.ts @@ -2,8 +2,7 @@ import { readFile } from 'node:fs/promises' import { isMap, isNode, isScalar, isSeq, parseDocument } from 'yaml' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { composeEntries, loadOptionalPatches } from '@deepseek-ai/dsh-app-boot' +import { composeEntries, loadOptionalPatches, readBundlePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' /** Flatten only the groups addressable by the profile's patch composer. @@ -17,16 +16,14 @@ 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`, 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 profile Profile whose patch file is edited and whose selected bundles supply the compared composition. * @param id Unique composition entry id. * @param name Module name used to match name-qualified overrides. * @param enabled Desired entry enablement. - * @param layers Bundle patches applied before the profile patch file. * @returns Whether the file changed. */ -export async function writePluginEnabled( - filename: string, id: string, name: string, enabled: boolean, layers: readonly PatchOptions[], -): Promise { +export async function writePluginEnabled(profile: ProfileContext, id: string, name: string, enabled: boolean): Promise { + const filename = profile.patchPath let text: string try { text = await readFile(filename, 'utf8') @@ -41,6 +38,7 @@ export async function writePluginEnabled( if (error !== undefined) throw error if (!isSeq(document.contents)) throw new Error('Profile patch must be a YAML sequence') const patches = loadOptionalPatches('dsh', filename) ?? [] + const bundlePatches = readBundlePatches('dsh', profile) const items = document.contents.items const target = items.findLast((item, index) => { if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert')) return false @@ -49,7 +47,7 @@ export async function writePluginEnabled( }) // 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) + const row = flatten(composeEntries([[...bundlePatches, ...patches.toSpliced(index, 1)]])).findLast(entry => entry.id === id) return row !== undefined && (row.disabled ?? false) === !enabled } if (isMap(target)) { diff --git a/packages/boot/plugin-manager/tests/patch.spec.ts b/packages/boot/plugin-manager/tests/patch.spec.ts index 4a80f4cfdc..9580da1e52 100644 --- a/packages/boot/plugin-manager/tests/patch.spec.ts +++ b/packages/boot/plugin-manager/tests/patch.spec.ts @@ -4,72 +4,83 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { expect, it, onTestFinished } from 'vitest' import { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' -import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot' +import { initProfile, loadOptionalPatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' import { writePluginEnabled } from '../src/patch.ts' -async function fixture(text?: string): Promise { - const dir = await mkdtemp(join(tmpdir(), 'manager-patch-')) - onTestFinished(() => rm(dir, { recursive: true, force: true })) - const file = join(dir, 'cordis.patch.yml') - if (text !== undefined) await writeFile(file, text) - return file +const TOOL = { id: 'tool', name: 'package' } + +/** Create a profile whose patch file holds `text` (none when omitted) and whose one selected bundle inserts `rows`. */ +async function fixture(text?: string, rows: object[] = []): Promise { + const home = await mkdtemp(join(tmpdir(), 'manager-patch-')) + onTestFinished(() => rm(home, { recursive: true, force: true })) + const dir = join(home, 'profile') + const installAnchor = join(home, 'package.json') + await writeFile(installAnchor, '{"name":"installation","dependencies":{}}') + initProfile(dir, ['bundle']) + const bundle = join(dir, 'node_modules', 'bundle') + await mkdir(bundle, { recursive: true }) + await writeFile(join(bundle, 'package.json'), JSON.stringify({ name: 'bundle', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + await writeFile(join(bundle, 'cordis.patch.yml'), JSON.stringify([{ insert: rows }])) + const patchPath = join(dir, 'cordis.patch.yml') + await (text === undefined ? rm(patchPath) : writeFile(patchPath, text)) + return { name: 'test', dir, patchPath, installAnchor, cwd: home, home, startedBundles: ['bundle'], overlays: [], telemetryDisabledEnv: undefined } } it('preserves comments, expressions, unrelated configuration and the last override', async () => { - const file = await fixture('# personal configuration\n- id: tool\n config:\n value: !!js process.platform\n- id: tool\n disabled: false # availability\n') - expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(true) - const text = await readFile(file, 'utf8') + const profile = await fixture('# personal configuration\n- id: tool\n config:\n value: !!js process.platform\n- id: tool\n disabled: false # availability\n') + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(true) + const text = await readFile(profile.patchPath, 'utf8') expect(text).toContain('# personal configuration') expect(text).toContain('!!js process.platform') expect(text).toContain('# availability') - expect(loadOptionalPatches('test', file)).toEqual([ + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([ { id: 'tool', config: { value: { __jsExpr: 'process.platform' } } }, { id: 'tool', disabled: true }, ]) - expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(false) - expect(await readFile(file, 'utf8')).toBe(text) - expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(true) + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(false) + expect(await readFile(profile.patchPath, 'utf8')).toBe(text) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) }) it('creates a missing patch file and appends after insertions', async () => { - const file = await fixture() - await writePluginEnabled(file, 'tool', 'package', false, []) - expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', disabled: true }]) - await writeFile(file, '- insert:\n - id: tool\n name: package\n') - await writePluginEnabled(file, 'tool', 'package', false, []) - expect(loadOptionalPatches('test', file)).toEqual([ + const profile = await fixture() + await writePluginEnabled(profile, 'tool', 'package', false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'tool', disabled: true }]) + await writeFile(profile.patchPath, '- insert:\n - id: tool\n name: package\n') + await writePluginEnabled(profile, 'tool', 'package', false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([ { insert: [{ id: 'tool', name: 'package' }] }, { id: 'tool', disabled: true }, ]) }) it.each(['- id: [broken', 'mapping: true\n'])('refuses malformed documents without overwriting %s', async (text) => { - const file = await fixture(text) - await expect(writePluginEnabled(file, 'tool', 'package', true, [])).rejects.toThrow() - expect(await readFile(file, 'utf8')).toBe(text) + const profile = await fixture(text) + await expect(writePluginEnabled(profile, 'tool', 'package', true)).rejects.toThrow() + expect(await readFile(profile.patchPath, 'utf8')).toBe(text) }) it('retains name-asserting overrides and appends an unambiguous switch', async () => { - const file = await fixture('- id: tool\n name: another-package\n disabled: false\n') - await writePluginEnabled(file, 'tool', 'package', false, []) - expect(loadOptionalPatches('test', file)).toEqual([ + const profile = await fixture('- id: tool\n name: another-package\n disabled: false\n') + await writePluginEnabled(profile, 'tool', 'package', false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([ { id: 'tool', name: 'another-package', disabled: false }, { id: 'tool', disabled: true }, ]) - expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(false) + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(false) }) it('reports read failures without replacing a directory with configuration', async () => { - const file = await fixture() - await mkdir(file) - await expect(writePluginEnabled(file, 'tool', 'package', true, [])).rejects.toThrow() + const profile = await fixture() + await mkdir(profile.patchPath) + await expect(writePluginEnabled(profile, 'tool', 'package', true)).rejects.toThrow() }) it('updates the last matching named override and leaves mismatched names untouched', async () => { - const file = await fixture('- id: tool\n disabled: true\n- id: tool\n name: package\n config:\n value: !!js process.platform\n disabled: true # availability\n- id: tool\n name: another-package\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(true) - const text = await readFile(file, 'utf8') + const profile = await fixture('- id: tool\n disabled: true\n- id: tool\n name: package\n config:\n value: !!js process.platform\n disabled: true # availability\n- id: tool\n name: another-package\n disabled: true\n') + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + const text = await readFile(profile.patchPath, 'utf8') expect(text).toContain('!!js process.platform') expect(text).toContain('# availability') - const patches = loadOptionalPatches('test', file) + const patches = loadOptionalPatches('test', profile.patchPath) expect(patches).toEqual([ { id: 'tool', disabled: true }, { id: 'tool', name: 'package', config: { value: { __jsExpr: 'process.platform' } }, disabled: false }, @@ -77,63 +88,61 @@ it('updates the last matching named override and leaves mismatched names untouch ]) expect(applyEntryPatches([{ id: 'tool', name: 'package' }], patches, () => {})) .toMatchObject([{ id: 'tool', name: 'package', disabled: false }]) - expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(false) - expect(await readFile(file, 'utf8')).toBe(text) - expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(true) - expect(loadOptionalPatches('test', file)).toHaveLength(3) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(false) + expect(await readFile(profile.patchPath, 'utf8')).toBe(text) + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toHaveLength(3) }) it('removes or omits a plain switch that restates the bundle layers', async () => { - const layers = [{ insert: [{ id: 'tool', name: 'package' }] }] - const file = await fixture('# personal configuration\n- id: other\n disabled: true\n- id: tool\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(file, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, layers)).toBe(false) - expect(await readFile(file, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', false, layers)).toBe(true) - expect(loadOptionalPatches('test', file)).toEqual([{ id: 'other', disabled: true }, { id: 'tool', disabled: true }]) + const profile = await fixture('# personal configuration\n- id: other\n disabled: true\n- id: tool\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(await readFile(profile.patchPath, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(false) + expect(await readFile(profile.patchPath, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'other', disabled: true }, { id: 'tool', disabled: true }]) }) it('keeps a switch that carries other settings or differs from the bundle layers', async () => { - const file = await fixture('- id: tool\n config: { value: 1 }\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package' }] }])).toBe(true) - expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', config: { value: 1 }, disabled: false }]) - const plain = await fixture('- id: tool\n disabled: true\n') - expect(await writePluginEnabled(plain, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package', disabled: true }] }])).toBe(true) - expect(loadOptionalPatches('test', plain)).toEqual([{ id: 'tool', disabled: false }]) + const profile = await fixture('- id: tool\n config: { value: 1 }\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'tool', config: { value: 1 }, disabled: false }]) + const plain = await fixture('- id: tool\n disabled: true\n', [{ ...TOOL, disabled: true }]) + expect(await writePluginEnabled(plain, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', plain.patchPath)).toEqual([{ id: 'tool', disabled: false }]) }) it('keeps the comment above a removed switch and prunes a switch the composition already implies', async () => { - const layers = [{ insert: [{ id: 'tool', name: 'package' }] }] - const file = await fixture('# header\n- id: tool\n disabled: false\n# other\n- id: other\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(file, 'utf8')).toBe('# header\n# other\n- id: other\n disabled: true\n') - const first = await fixture('# header\n- id: tool\n disabled: true\n- id: other\n disabled: true\n') - expect(await writePluginEnabled(first, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(first, 'utf8')).toBe('# header\n- id: other\n disabled: true\n') - 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')).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')).toBe('# header\n[]\n\n# tail\n') + const profile = await fixture('# header\n- id: tool\n disabled: false\n# other\n- id: other\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(await readFile(profile.patchPath, 'utf8')).toBe('# header\n# other\n- id: other\n disabled: true\n') + const first = await fixture('# header\n- id: tool\n disabled: true\n- id: other\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(first, 'tool', 'package', true)).toBe(true) + expect(await readFile(first.patchPath, 'utf8')).toBe('# header\n- id: other\n disabled: true\n') + const only = await fixture('# header\n- id: tool\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(only, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', only.patchPath)).toEqual([]) + expect(await readFile(only.patchPath, 'utf8')).toBe('# header\n[]\n') + const last = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(last, 'tool', 'package', true)).toBe(true) + expect(await readFile(last.patchPath, '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', [TOOL]) + expect(await writePluginEnabled(lastWithTail, 'tool', 'package', true)).toBe(true) + expect(await readFile(lastWithTail.patchPath, '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', [TOOL]) + expect(await writePluginEnabled(trailing, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', trailing.patchPath)).toEqual([]) + expect(await readFile(trailing.patchPath, 'utf8')).toContain('# tail') + const both = await fixture('# header\n- id: tool\n disabled: true\n# tail\n', [TOOL]) + expect(await writePluginEnabled(both, 'tool', 'package', true)).toBe(true) + expect(await readFile(both.patchPath, 'utf8')).toBe('# header\n[]\n\n# tail\n') }) it('treats insertions in the profile patch as part of the inherited composition', async () => { - const file = await fixture('- insert:\n - id: tool\n name: package\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(false) - expect(loadOptionalPatches('test', file)).toEqual([{ insert: [{ id: 'tool', name: 'package' }] }]) + const profile = await fixture('- insert:\n - id: tool\n name: package\n') + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ insert: [{ id: 'tool', name: 'package' }] }]) }) it.each([ @@ -141,13 +150,13 @@ it.each([ ['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 }]) + const profile = await fixture(text, [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).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 }]) + const profile = await fixture('- id: other\n config: { flag: &flag true }\n- id: tool\n disabled: *flag\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'other', config: { flag: true } }, { id: 'tool', disabled: false }]) })