From 3e0eca752c721395e43378cafbc7eab23d69f59f Mon Sep 17 00:00:00 2001 From: Pine Date: Fri, 14 Aug 2026 13:53:24 +0800 Subject: [PATCH] feat(plugin-inventory): add setEnabled Remote that toggles and persists a plugin pluginInventory/setEnabled calls ctx.loader.update({disabled}) for a live effect and writes an explicit disabled override into the profile's user patch layer so the choice survives a restart. The patch row id is the bare entry options.id, not the group-prefixed tree id. Co-Authored-By: Claude --- packages/host/plugin-inventory/package.json | 1 + packages/host/plugin-inventory/src/index.ts | 27 +++++++++++ packages/host/plugin-inventory/src/persist.ts | 45 +++++++++++++++++++ .../plugin-inventory/tests/inventory.spec.ts | 17 ++++++- .../plugin-inventory/tests/persist.spec.ts | 38 ++++++++++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 packages/host/plugin-inventory/src/persist.ts create mode 100644 packages/host/plugin-inventory/tests/persist.spec.ts diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 0f020b372c..0b13096e8b 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -49,6 +49,7 @@ ], "license": "MIT", "dependencies": { + "js-yaml": "^4.2.0", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index ff5394c564..cf07c03f63 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -2,9 +2,11 @@ import type { Context, FiberState } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/cordis-plugin-loader' +import { fileURLToPath } from 'node:url' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. import type {} from 'zod' +import { persistPluginDisabled } from './persist.ts' import type { PluginEntryId, PluginFiberPhase, @@ -67,6 +69,31 @@ export class PluginInventoryGateway extends TypertRemoteService { } return { entries } } + + /** + * Toggle one plugin entry on or off. Applies the change live through the + * Loader (disposing or re-starting the plugin's fiber) and persists an + * explicit `disabled` override into the profile's user patch layer so the + * choice survives a restart. A plugin enabled by a bundle patch must carry + * the `disabled: false` override too, or the bundle's default would win on + * the next reload. + * @param entryId - the loader tree entry id (as `list` reports it). + * @param enabled - the desired effective state. + * @returns a confirmation; the caller re-lists to observe the new phase. + */ + @Remote('setEnabled') + async setEnabled(entryId: PluginEntryId, enabled: boolean): Promise<{ ok: true }> { + const entry = this.ctx.loader.resolve(entryId) + if (entry === undefined) { + throw new Error(`plugin entry ${String(entryId)} not found`) + } + const rowId = entry.options.id + await this.ctx.loader.update(entryId, { disabled: !enabled }) + if (this.ctx.baseUrl !== undefined) { + persistPluginDisabled(fileURLToPath(this.ctx.baseUrl), rowId, !enabled) + } + return { ok: true } + } } export default PluginInventoryGateway diff --git a/packages/host/plugin-inventory/src/persist.ts b/packages/host/plugin-inventory/src/persist.ts new file mode 100644 index 0000000000..6ea00f387a --- /dev/null +++ b/packages/host/plugin-inventory/src/persist.ts @@ -0,0 +1,45 @@ +/** + * Persist a plugin's enable/disable override into a profile's user patch layer. + * + * A runtime `ctx.loader.update(id, { disabled })` toggles the plugin live but, + * for a row enabled by a bundle patch, writes only the fully-patched tree — the + * patch layer re-applies on the next read and the toggle does not survive a + * restart. Durable control therefore writes a `- id: disabled: true` + * override into the profile's `cordis.patch.yml` (the last-applied user layer). + * @module @deepseek-ai/dsh-plugin-inventory/persist + */ + +import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import yaml from 'js-yaml' + +/** One loader patch row (the shape the patch file's array carries). */ +interface PatchRow { + id?: string + disabled?: boolean +} + +/** + * Upsert a `disabled` override for one plugin row in a profile patch. The state + * is always written explicitly (`disabled: true` to disable, `false` to + * re-enable over a bundle-default disable), never removed: dropping the row + * would fall back to the bundle's own `disabled` default rather than the + * user's choice. Writes atomically. + * @param profileDir - the profile directory holding `cordis.patch.yml`. + * @param rowId - the bare loader row id (entry's `options.id`, not the group-prefixed tree id). + * @param disabled - the persisted disabled state to record. + * @returns the absolute patch path written. + */ +export function persistPluginDisabled(profileDir: string, rowId: string, disabled: boolean): string { + const patchPath = join(profileDir, 'cordis.patch.yml') + const existing = existsSync(patchPath) + ? yaml.load(readFileSync(patchPath, 'utf8')) + : [] + const rows: PatchRow[] = Array.isArray(existing) ? existing.filter((row): row is PatchRow => row !== null && typeof row === 'object') : [] + const kept = rows.filter(row => row.id !== rowId) + const text = yaml.dump([...kept, { id: rowId, disabled }], { lineWidth: -1 }) + const tmp = `${patchPath}.tmp` + writeFileSync(tmp, text, 'utf8') + renameSync(tmp, patchPath) + return patchPath +} diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index cd43c492a8..18fa77b524 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context, type Plugin } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' -import PluginInventoryGateway from '../src/index.ts' +import PluginInventoryGateway, { type PluginEntryId } from '../src/index.ts' const contexts: Context[] = [] @@ -39,9 +39,24 @@ describe('PluginInventoryGateway', () => { }) expect(remoteMethods(inventory)).toEqual([ { method: 'list', invocation: { kind: 'direct' } }, + { method: 'setEnabled', invocation: { kind: 'direct' } }, ]) }) + it('setEnabled toggles the Loader entry live', async () => { + const { ctx, inventory } = await harness() + const id = await ctx.loader.create({ name: 'cordis:active' }) as PluginEntryId + await inventory.setEnabled(id, false) + expect(inventory.list().entries.find(entry => entry.entryId === id)).toEqual({ + entryId: id, + moduleName: 'cordis:active', + enabled: false, + fiberPhase: null, + }) + await inventory.setEnabled(id, true) + expect(inventory.list().entries.find(entry => entry.entryId === id)?.enabled).toBe(true) + }) + it('projects current non-group Loader entries without a second cache', async () => { const { ctx, inventory } = await harness() const activeId = await ctx.loader.create({ name: 'cordis:active' }) diff --git a/packages/host/plugin-inventory/tests/persist.spec.ts b/packages/host/plugin-inventory/tests/persist.spec.ts new file mode 100644 index 0000000000..625887414b --- /dev/null +++ b/packages/host/plugin-inventory/tests/persist.spec.ts @@ -0,0 +1,38 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { persistPluginDisabled } from '../src/persist.ts' + +/** A scratch profile dir with a given starting patch. */ +function profile(initial: string): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-plugin-persist-')) + writeFileSync(join(dir, 'cordis.patch.yml'), initial) + return dir +} + +describe('persistPluginDisabled', () => { + it('appends a disabled override to an empty patch', () => { + const dir = profile('[]\n') + persistPluginDisabled(dir, 'image-recognition-http', true) + const text = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8') + expect(text).toContain('image-recognition-http') + expect(text).toContain('disabled: true') + }) + + it('writes disabled: false to override a bundle-default disable', () => { + const dir = profile('- id: tool-web\n disabled: true\n') + persistPluginDisabled(dir, 'tool-web', false) + const text = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8') + expect(text).toContain('tool-web') + expect(text).toContain('disabled: false') + }) + + it('replaces an existing override for the same row instead of duplicating', () => { + const dir = profile('- id: image-recognition-http\n disabled: true\n') + persistPluginDisabled(dir, 'image-recognition-http', false) + const text = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8') + expect(text.match(/image-recognition-http/g)).toHaveLength(1) + expect(text).not.toContain('disabled: true') + }) +})