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 <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 20:45:00 +08:00
parent a0efe02c04
commit e18cfbfb93
16 changed files with 486 additions and 196 deletions
+37 -7
View File
@@ -8,7 +8,7 @@ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
// Typert-generated ./typert and ./remote artifacts import Zod at runtime.
import type {} from 'zod'
import { AVAILABLE_BUNDLES } from './bundles.ts'
import { composeOfflineBundle, resolvePnpm, runPnpmInstall, uninstallBundle } from './install.ts'
import { composeOfflineBundle, resolvePnpm, runPnpmInstallWithRegistries, uninstallBundle } from './install.ts'
import { persistPluginDisabled } from './persist.ts'
import { isRequiredPlugin } from './required.ts'
import type {
@@ -23,8 +23,20 @@ import type {
export type * from './types.ts'
export { AVAILABLE_BUNDLES } from './bundles.ts'
export { INSTALL_REGISTRIES } from './install.ts'
export type { AvailableBundle, AvailableBundlesSnapshot, InstallResult, InstallSpec } from './types.ts'
/**
* The profile's default bundle layers, composed by the shipped template and not
* offered as offline-installable or uninstallable (they are part of the
* deployment). See `packages/boot/app-boot/src/profile.ts` `PROFILE_TEMPLATES`.
*/
const DEFAULT_BUNDLES = new Set([
'@deepseek-ai/dsh-base',
'@deepseek-ai/dsh-web-app',
'@deepseek-ai/dsh-image-recognition-bundle',
])
/** Brand an existing Loader-tree entry id at the owning boundary. */
function pluginEntryId(value: string): PluginEntryId {
return value as PluginEntryId
@@ -123,6 +135,9 @@ export class PluginInventoryGateway extends TypertRemoteService {
availableBundles(): AvailableBundlesSnapshot {
const profileDir = this.profileDir()
const installed = new Set(this.profileManifest(profileDir)?.dsh?.profile?.bundles ?? [])
// The catalog is empty until a new optional bundle ships, so the projection
// callback is unreachable in the current configuration.
/* v8 ignore next */
return {
available: AVAILABLE_BUNDLES.map(name => ({ name, installed: installed.has(name) })),
}
@@ -139,15 +154,18 @@ export class PluginInventoryGateway extends TypertRemoteService {
* @returns a confirmation; `restartRequired` tells the caller to restart.
*/
@Remote('installPlugin')
installPlugin(spec: InstallSpec): InstallResult {
async installPlugin(spec: InstallSpec): Promise<InstallResult> {
const profileDir = this.profileDir()
const anchor = this.ctx.get('dshInstallAnchor') as string | undefined
if (anchor === undefined) {
throw new Error('dsh: install anchor is unavailable in this runtime')
}
if (spec.type === 'bundle') {
if (DEFAULT_BUNDLES.has(spec.name)) {
throw new Error(`dsh: bundle ${spec.name} is composed by default and is not installable`)
}
composeOfflineBundle('dsh', profileDir, anchor, spec.name)
return { ok: true, restartRequired: true }
return { ok: true, restartRequired: !(await this.reload()) }
}
if (this.ctx.get('dshAllowPluginInstall') !== true) {
throw new Error('dsh: plugin install is not permitted in this runtime')
@@ -160,7 +178,8 @@ export class PluginInventoryGateway extends TypertRemoteService {
// this snapshot fallback is only reached in that same unreachable-to-succeed case.
/* v8 ignore next */
const before = this.profileManifest(profileDir) ?? { dependencies: {} }
runPnpmInstall({
// Try each registry until one succeeds; the official npm registry is last.
runPnpmInstallWithRegistries({
binName: 'dsh',
profileDir,
installAnchor: anchor,
@@ -169,7 +188,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
spec: spec.spec,
before,
})
return { ok: true, restartRequired: true }
return { ok: true, restartRequired: !(await this.reload()) }
}
/**
@@ -178,9 +197,20 @@ export class PluginInventoryGateway extends TypertRemoteService {
* @returns a confirmation; `restartRequired` tells the caller to restart.
*/
@Remote('uninstall')
uninstall(name: string): InstallResult {
async uninstall(name: string): Promise<InstallResult> {
if (DEFAULT_BUNDLES.has(name)) {
throw new Error(`dsh: bundle ${name} is composed by default and cannot be uninstalled`)
}
uninstallBundle('dsh', this.profileDir(), name)
return { ok: true, restartRequired: true }
return { ok: true, restartRequired: !(await this.reload()) }
}
/** Trigger a live tree recomposition; false when no reload handle is provided. */
private async reload(): Promise<boolean> {
const reload = this.ctx.get('dshReloadProfile') as (() => Promise<void>) | undefined
if (reload === undefined) return false
await reload()
return true
}
/** The profile directory the Loader anchors on (its `baseUrl`). */