feat(plugin-inventory): offline bundle and pnpm plugin install for the desktop

Adds an install surface to the plugin-inventory gateway: availableBundles lists
the curated offline-installable optional bundles (AVAILABLE_BUNDLES); install
composes an offline bundle into the profile's dsh.profile.bundles, or for a
registry spec runs pnpm against the writable profile via the bundled Node and a
vendored pnpm (gated behind the dshAllowPluginInstall context flag, set only by
the desktop boot); uninstall removes a bundle layer. The reconcile logic from
`dsh plugin add` moves into app-boot as shared helpers. The desktop vendored
pnpm into the harness and sets the allow-install env; the plugin-list SPA gains
an installable-bundles section. Tests cover the guard, install helpers, and the
SPA section at 100% host coverage.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 18:46:39 +08:00
parent 8f1c764614
commit 6cd7c5a590
25 changed files with 929 additions and 110 deletions
@@ -266,6 +266,56 @@
white-space: nowrap;
}
.install {
margin-bottom: 1.25rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--dsh-border);
}
.install h3 {
margin: 0 0 0.5rem;
font-size: 13px;
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;
}
.installName {
overflow-wrap: anywhere;
font-family: var(--ds-font-family-code);
font-size: 12px;
}
.installAction {
flex: none;
}
.installRestart {
margin: 0 0 0.5rem;
color: var(--dsw-alias-label-secondary);
font-size: 12px;
}
.installError {
margin: 0 0 0.5rem;
color: var(--dsw-alias-danger-text);
font-size: 12px;
}
@media (prefers-reduced-motion: no-preference) {
.chevron {
transition: transform 140ms var(--ds-ease-in-out);
@@ -1,5 +1,10 @@
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
import type {
AvailableBundlesSnapshot,
InstallResult,
InstallSpec,
PluginInventorySnapshot,
} from '@deepseek-ai/dsh-api-remotes/client'
import {
IconChevronDownOutline14,
IconSearchOutline16,
@@ -14,6 +19,12 @@ export interface PluginInventorySettingsTabInjected {
list: () => Promise<PluginInventorySnapshot>
/** Toggle one plugin entry on or off; persists across a restart. */
setEnabled: (entryId: PluginInventoryEntry['entryId'], enabled: boolean) => Promise<void>
/** List the offline-installable optional bundles. */
availableBundles: () => Promise<AvailableBundlesSnapshot>
/** Install a bundle or registry plugin; the host persists the change. */
install: (spec: InstallSpec) => Promise<InstallResult>
/** Un-compose an offline optional bundle. */
uninstall: (name: string) => Promise<InstallResult>
}
type PluginInventoryEntry = PluginInventorySnapshot['entries'][number]
@@ -62,14 +73,24 @@ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean
.some(value => value.toLocaleLowerCase().includes(normalizedQuery))
}
/** Render the current Loader inventory with per-plugin enable/disable. */
export function PluginInventorySettingsTab({ list, setEnabled, t }: PluginInventorySettingsTabProps): ReactNode {
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, install, uninstall, t,
}: PluginInventorySettingsTabProps): ReactNode {
const catalogId = useId()
const [request, setRequest] = useState(0)
const [query, setQuery] = useState('')
const [expanded, setExpanded] = useState<PluginInventoryEntry['entryId'] | null>(null)
const [state, setState] = useState<ViewState>({ status: 'loading' })
const [busy, setBusy] = useState<PluginInventoryEntry['entryId'] | null>(null)
const [bundles, setBundles] = useState<BundleState>({ status: 'loading' })
const [installBusy, setInstallBusy] = useState<string | null>(null)
const [installNote, setInstallNote] = useState<{ kind: 'restart' | 'error'; text: string } | null>(null)
useEffect(() => {
let current = true
@@ -80,14 +101,40 @@ export function PluginInventorySettingsTab({ list, setEnabled, t }: PluginInvent
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
setBusy(entryId)
void setEnabled(entryId, enabled).then(
() => setRequest(value => value + 1),
() => { setRequest(value => value + 1) },
() => { /* the next list reflects the unchanged state */ },
).finally(() => setBusy(null))
).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)
setInstallNote(null)
const action = mode === 'install'
? install({ type: 'bundle', name })
: uninstall(name)
void action.then(
() => {
setInstallNote({ kind: 'restart', text: t('restartRequired') })
setRequest(value => value + 1)
},
() => { setInstallNote({ kind: 'error', text: t('installFailed') }) },
).finally(() => { setInstallBusy(null) })
}
const normalizedQuery = query.trim().toLocaleLowerCase()
@@ -207,32 +254,59 @@ export function PluginInventorySettingsTab({ list, setEnabled, t }: PluginInvent
</div>
) : null}
{state.status === 'ready' ? (
<div className={css.catalog}>
<label className={css.search}>
<IconSearchOutline16 aria-hidden="true" />
<span className={css.visuallyHidden}>{t('search')}</span>
<input
type="search"
value={query}
placeholder={t('search')}
aria-label={t('search')}
onChange={(event) => { setQuery(event.currentTarget.value) }}
/>
</label>
<div className={css.catalogHeading}>
<h3>{t('catalog')}</h3>
<span data-plugin-count={filteredEntries.length}>{filteredEntries.length}</span>
</div>
{state.snapshot.entries.length === 0 ? <p className={css.status}>{t('empty')}</p> : null}
{state.snapshot.entries.length > 0 && filteredEntries.length === 0
? <p className={css.status}>{t('emptySearch')}</p>
: null}
{filteredEntries.length > 0 ? (
<ul className={css.cards}>
{filteredEntries.map(card)}
</ul>
<>
{bundles.status === 'ready' && bundles.snapshot.available.length > 0 ? (
<section className={css.install} aria-label={t('available')}>
<h3>{t('available')}</h3>
{installNote !== null
? <p className={installNote.kind === 'error' ? css.installError : css.installRestart} role="status">{installNote.text}</p>
: null}
<ul className={css.installList}>
{bundles.snapshot.available.map(bundle => (
<li key={bundle.name} className={css.installRow}>
<span className={css.installName}>{bundle.name}</span>
<button
type="button"
className={css.installAction}
disabled={installBusy !== null}
onClick={() => { mutateBundle(bundle.name, bundle.installed ? 'uninstall' : 'install') }}
>
{installBusy === bundle.name
? t('installing')
: bundle.installed ? t('uninstall') : t('install')}
</button>
</li>
))}
</ul>
</section>
) : null}
</div>
<div className={css.catalog}>
<label className={css.search}>
<IconSearchOutline16 aria-hidden="true" />
<span className={css.visuallyHidden}>{t('search')}</span>
<input
type="search"
value={query}
placeholder={t('search')}
aria-label={t('search')}
onChange={(event) => { setQuery(event.currentTarget.value) }}
/>
</label>
<div className={css.catalogHeading}>
<h3>{t('catalog')}</h3>
<span data-plugin-count={filteredEntries.length}>{filteredEntries.length}</span>
</div>
{state.snapshot.entries.length === 0 ? <p className={css.status}>{t('empty')}</p> : null}
{state.snapshot.entries.length > 0 && filteredEntries.length === 0
? <p className={css.status}>{t('emptySearch')}</p>
: null}
{filteredEntries.length > 0 ? (
<ul className={css.cards}>
{filteredEntries.map(card)}
</ul>
) : null}
</div>
</>
) : null}
</div>
)
@@ -40,7 +40,30 @@ export function apply(ctx: ClientContext): void {
throw new Error(`pluginInventory.setEnabled failed: ${result.error.code}: ${result.error.message}`)
}
}
const injected = (): PluginInventorySettingsTabInjected => ({ list, setEnabled })
const availableBundles: PluginInventorySettingsTabInjected['availableBundles'] = async () => {
const result = await ctx.remote.pluginInventory.availableBundles()
if (!result.ok) {
throw new Error(`pluginInventory.availableBundles failed: ${result.error.code}: ${result.error.message}`)
}
return result.value
}
const install: PluginInventorySettingsTabInjected['install'] = async (spec) => {
const result = await ctx.remote.pluginInventory.install(spec)
if (!result.ok) {
throw new Error(`pluginInventory.install failed: ${result.error.code}: ${result.error.message}`)
}
return result.value
}
const uninstall: PluginInventorySettingsTabInjected['uninstall'] = async (name) => {
const result = await ctx.remote.pluginInventory.uninstall(name)
if (!result.ok) {
throw new Error(`pluginInventory.uninstall failed: ${result.error.code}: ${result.error.message}`)
}
return result.value
}
const injected = (): PluginInventorySettingsTabInjected => ({
list, setEnabled, availableBundles, install, uninstall,
})
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
@@ -24,6 +24,12 @@ export const zh = {
disable: '停用',
toggling: '切换中…',
required: '应用必需插件,不可切换',
available: '可安装插件',
install: '安装',
uninstall: '卸载',
installing: '处理中…',
restartRequired: '已应用,重启后生效',
installFailed: '安装失败',
} satisfies Record<string, string>
/** Plugin inventory locale key union. */
@@ -53,4 +59,10 @@ export const en = {
disable: 'Disable',
toggling: 'Toggling…',
required: 'Required by the app; cannot be toggled',
available: 'Installable plugins',
install: 'Install',
uninstall: 'Uninstall',
installing: 'Working…',
restartRequired: 'Applied; restart to activate',
installFailed: 'Install failed',
} satisfies Record<PluginInventoryLocaleKey, string>
@@ -13,10 +13,18 @@ afterEach(cleanup)
type Snapshot = Awaited<ReturnType<PluginInventorySettingsTabInjected['list']>>
const t = ((key: PluginInventoryLocaleKey): string => en[key]) as PluginInventorySettingsTabProps['t']
function props(list: PluginInventorySettingsTabInjected['list']): PluginInventorySettingsTabProps {
function props(
list: PluginInventorySettingsTabInjected['list'],
bundles: PluginInventorySettingsTabInjected['availableBundles'] = async () => ({ available: [] }),
install: PluginInventorySettingsTabInjected['install'] = async () => ({ ok: true as const, restartRequired: true }),
uninstall: PluginInventorySettingsTabInjected['uninstall'] = async () => ({ ok: true as const, restartRequired: true }),
): PluginInventorySettingsTabProps {
return {
t,
list,
availableBundles: bundles,
install,
uninstall,
} as PluginInventorySettingsTabProps
}
@@ -153,4 +161,29 @@ describe('PluginInventorySettingsTab', () => {
pendingFailure.unmount()
await act(async () => { deferredFailure.reject(new Error('late failure')) })
})
it('renders the installable bundles with install and uninstall actions', async () => {
const install = vi.fn<PluginInventorySettingsTabInjected['install']>(async () => ({ ok: true, restartRequired: true }))
const uninstall = vi.fn<PluginInventorySettingsTabInjected['uninstall']>(async () => ({ ok: true, restartRequired: true }))
const bundles: PluginInventorySettingsTabInjected['availableBundles'] = async () => ({
available: [
{ name: '@deepseek-ai/dsh-new-bundle', installed: false },
{ name: '@deepseek-ai/dsh-installed-bundle', installed: true },
],
})
render(<PluginInventorySettingsTab {...props(async () => SNAPSHOT, bundles, install, uninstall)} />)
expect(await screen.findByText(en.available)).toBeTruthy()
expect(screen.getByText('@deepseek-ai/dsh-new-bundle')).toBeTruthy()
expect(screen.getByText('@deepseek-ai/dsh-installed-bundle')).toBeTruthy()
// Install a not-installed bundle.
fireEvent.click(screen.getByRole('button', { name: en.install }))
expect(install).toHaveBeenCalledWith({ type: 'bundle', name: '@deepseek-ai/dsh-new-bundle' })
expect(await screen.findByText(en.restartRequired)).toBeTruthy()
// Uninstall an installed bundle.
fireEvent.click(screen.getByRole('button', { name: en.uninstall }))
expect(uninstall).toHaveBeenCalledWith('@deepseek-ai/dsh-installed-bundle')
})
})