From e18cfbfb933ac9c5cf25c140fb4125eec9d62dee Mon Sep 17 00:00:00 2001 From: Pine Date: Fri, 14 Aug 2026 20:45:00 +0800 Subject: [PATCH] feat(plugin-inventory): live plugin activation, mirror install, and toggle lists Installing a plugin now activates it without a restart: the CLI boot provides a dshReloadProfile handle that re-runs the profile composition and applies it to the running root Include, and the install/uninstall Remotes recompose live when the handle is present (restartRequired: false). Registry installs try the ordered INSTALL_REGISTRIES mirrors with the official npm registry as the final fallback, erroring only when every source is unreachable. The enable/disable guard splits into a REQUIRED_PLUGINS blacklist and a USER_TOGGLEABLE_PLUGINS whitelist (default toggleable) generated from the running plugin list, and the offline optional-bundle catalog is emptied (default bundles are not installable/uninstallable). The plugin-list tab becomes a registry install form and shows immediate-activation instead of a restart notice. Co-Authored-By: Claude --- apps/cli/src/profile-boot.ts | 30 +++- .../PluginInventorySettingsTab.module.css | 19 +-- .../src/client/PluginInventorySettingsTab.tsx | 111 +++++------- .../src/client/index.ts | 16 +- .../src/client/locales.ts | 18 +- .../tests/components.client.spec.tsx | 38 ++--- .../host/plugin-inventory/README.i18n.yaml | 4 +- packages/host/plugin-inventory/README.md | 2 +- packages/host/plugin-inventory/README.zh.md | 2 +- packages/host/plugin-inventory/src/bundles.ts | 13 +- packages/host/plugin-inventory/src/index.ts | 44 ++++- packages/host/plugin-inventory/src/install.ts | 49 +++++- .../host/plugin-inventory/src/required.ts | 160 ++++++++++++++++-- .../plugin-inventory/tests/install.spec.ts | 84 ++++++++- .../plugin-inventory/tests/inventory.spec.ts | 73 +++++--- .../plugin-inventory/tests/required.spec.ts | 19 ++- 16 files changed, 486 insertions(+), 196 deletions(-) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index df9c6276d8..f16af6d8dc 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -245,16 +245,40 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con ]) // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. + /** + * Recompose the running tree from the current profile manifest. Re-runs the + * composition (so a plugin just installed into `dsh.profile.bundles` joins the + * patch stack) and applies it to the root Include live, activating new plugins + * without a restart. Called by the plugin-inventory install path. + */ + const reloadProfile = async (): Promise => { + const ctx = app.current + if (ctx === undefined || ctx.fiber.state !== FiberState.ACTIVE || ctx.get('loader') === undefined) return + const fresh = composeProfile(options.profile, options.patchFiles) + const patches = structuredClone([ + ...fresh.bundlePatches, + ...loadOptionalPatches(NAME, fresh.profile.patchPath) ?? [], + ...loadOptionalPatches(NAME, homePatchPath()) ?? [], + ...fresh.overlays, + ]) + // resolve() throws for an unknown id; the include entry is always present. + const entry = ctx.loader.resolve('include') + const { patches: _ignored, ...includeConfig } = entry.options.config as Record & { patches?: unknown } + await entry.update({ config: { ...includeConfig, patches } }) + } const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { app.current = hostCtx // Before any config-tree entry mounts, so plugins resolve all launch-time // environment values from the same immutable provenance snapshot. hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment) - // Plugin install facts: the installation anchor for resolving bundles, and - // whether registry (pnpm) install is permitted. Both default off; the - // desktop boot opts into install via DSH_ALLOW_PLUGIN_INSTALL. + // Plugin install facts: the installation anchor for resolving bundles, + // whether registry (pnpm) install is permitted, and a live-recomposition + // handle that activates an installed plugin without a restart. Anchor and + // reload default off/absent; the desktop boot opts into install via + // DSH_ALLOW_PLUGIN_INSTALL. hostCtx.provide('dshInstallAnchor', INSTALL_ANCHOR) hostCtx.provide('dshAllowPluginInstall', process.env.DSH_ALLOW_PLUGIN_INSTALL === '1') + hostCtx.provide('dshReloadProfile', reloadProfile) // The command line and bounded exit request are launcher facts available // to every app plugin that injects the argument snapshot. provideCmdline(hostCtx, { diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index cbe7afea67..48e79bde19 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -278,26 +278,15 @@ font-weight: 600; } -.installList { - display: flex; - flex-direction: column; - gap: 0.375rem; - margin: 0; - padding: 0; - list-style: none; -} - .installRow { display: flex; align-items: center; - justify-content: space-between; - gap: 0.75rem; + gap: 0.5rem; } -.installName { - overflow-wrap: anywhere; - font-family: var(--ds-font-family-code); - font-size: 12px; +.installInput { + flex: 1; + min-width: 0; } .installAction { diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index bec0f7f1f1..c0b2096249 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -1,10 +1,5 @@ import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' -import type { - AvailableBundlesSnapshot, - InstallResult, - InstallSpec, - PluginInventorySnapshot, -} from '@deepseek-ai/dsh-api-remotes/client' +import type { InstallResult, InstallSpec, PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client' import { IconChevronDownOutline14, IconSearchOutline16, @@ -19,12 +14,8 @@ export interface PluginInventorySettingsTabInjected { list: () => Promise /** Toggle one plugin entry on or off; persists across a restart. */ setEnabled: (entryId: PluginInventoryEntry['entryId'], enabled: boolean) => Promise - /** List the offline-installable optional bundles. */ - availableBundles: () => Promise - /** Install a bundle or registry plugin; the host persists the change. */ + /** Install a registry plugin by package name; the host persists the change. */ installPlugin: (spec: InstallSpec) => Promise - /** Un-compose an offline optional bundle. */ - uninstall: (name: string) => Promise } type PluginInventoryEntry = PluginInventorySnapshot['entries'][number] @@ -73,14 +64,9 @@ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean .some(value => value.toLocaleLowerCase().includes(normalizedQuery)) } -type BundleState = - | { readonly status: 'loading' } - | { readonly status: 'ready'; readonly snapshot: AvailableBundlesSnapshot } - | { readonly status: 'error' } - /** Render the current Loader inventory with per-plugin enable/disable and install. */ export function PluginInventorySettingsTab({ - list, setEnabled, availableBundles, installPlugin, uninstall, t, + list, setEnabled, installPlugin, t, }: PluginInventorySettingsTabProps): ReactNode { const catalogId = useId() const [request, setRequest] = useState(0) @@ -88,8 +74,8 @@ export function PluginInventorySettingsTab({ const [expanded, setExpanded] = useState(null) const [state, setState] = useState({ status: 'loading' }) const [busy, setBusy] = useState(null) - const [bundles, setBundles] = useState({ status: 'loading' }) - const [installBusy, setInstallBusy] = useState(null) + const [spec, setSpec] = useState('') + const [installBusy, setInstallBusy] = useState(false) const [installNote, setInstallNote] = useState<{ kind: 'restart' | 'error'; text: string } | null>(null) useEffect(() => { @@ -101,15 +87,6 @@ export function PluginInventorySettingsTab({ return () => { current = false } }, [list, request]) - useEffect(() => { - let current = true - void Promise.resolve().then(() => availableBundles()).then( - (snapshot) => { if (current) setBundles({ status: 'ready', snapshot }) }, - () => { if (current) setBundles({ status: 'error' }) }, - ) - return () => { current = false } - }, [availableBundles, request]) - /** Toggle one entry then re-read the inventory. */ const toggle = (entryId: PluginInventoryEntry['entryId'], enabled: boolean): void => { if (busy !== null) return @@ -120,21 +97,25 @@ export function PluginInventorySettingsTab({ ).finally(() => { setBusy(null) }) } - /** Install or uninstall an optional bundle, then re-read the catalog. */ - const mutateBundle = (name: string, mode: 'install' | 'uninstall'): void => { - if (installBusy !== null) return - setInstallBusy(name) + /** Install a plugin by package name via the registry, then re-read. */ + const installRegistry = (): void => { + const trimmed = spec.trim() + if (trimmed.length === 0 || installBusy) return + setInstallBusy(true) setInstallNote(null) - const action = mode === 'install' - ? installPlugin({ type: 'bundle', name }) - : uninstall(name) - void action.then( - () => { - setInstallNote({ kind: 'restart', text: t('restartRequired') }) + void installPlugin({ type: 'registry', spec: trimmed }).then( + (result) => { + // A live recompose activates the plugin immediately; only fall back to a + // restart notice when no reload handle exists. + setInstallNote({ kind: 'restart', text: t(result.restartRequired ? 'restartRequired' : 'installed') }) + setSpec('') setRequest(value => value + 1) }, - () => { setInstallNote({ kind: 'error', text: t('installFailed') }) }, - ).finally(() => { setInstallBusy(null) }) + (error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + setInstallNote({ kind: 'error', text: `${t('installFailed')}: ${detail}` }) + }, + ).finally(() => { setInstallBusy(false) }) } const normalizedQuery = query.trim().toLocaleLowerCase() @@ -255,31 +236,31 @@ export function PluginInventorySettingsTab({ ) : null} {state.status === 'ready' ? ( <> - {bundles.status === 'ready' && bundles.snapshot.available.length > 0 ? ( -
-

{t('available')}

- {installNote !== null - ?

{installNote.text}

- : null} -
    - {bundles.snapshot.available.map(bundle => ( -
  • - {bundle.name} - -
  • - ))} -
-
- ) : null} +
+

{t('installPlugin')}

+
+ { setSpec(event.currentTarget.value) }} + onKeyDown={(event) => { if (event.key === 'Enter') installRegistry() }} + /> + +
+ {installNote !== null + ?

{installNote.text}

+ : null} +