diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 4a366a9a5d..ed75c89f07 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -18,78 +18,13 @@ import { initProfile, PROFILE_TEMPLATES, readProfileManifest, - resolveBundleDir, + reconcileProfileBundles, resolveProfileDir, - writeProfileManifest, - type ProfileManifest, } from '@deepseek-ai/dsh-app-boot' import { INSTALL_ANCHOR } from './profile-boot.ts' const NAME = 'dsh' -/** - * Whether a resolved dependency exports a profile patch, i.e. is a bundle. - * @param packageName - the dependency's package name. - * @param profileDir - the profile directory (resolution anchor). - * @returns true when the package manifest declares `dsh.bundle`. - */ -function exportsPatch(packageName: string, profileDir: string): boolean { - let dir: string - try { - dir = resolveBundleDir(NAME, packageName, INSTALL_ANCHOR, profileDir) - } catch { - return false // pnpm reported success yet the package is unresolvable — treat as plain - } - const manifest = readProfileManifest(NAME, dir) - return manifest.dsh?.bundle?.patch !== undefined -} - -/** - * Reconcile `dsh.profile.bundles` against the installed state: pnpm has - * already written the real installed names (so a git/path/tarball/alias spec - * on the command line reconciles by its true package name) and materialized - * the packages. A dependency that resolves to a `dsh.bundle`-declaring - * package joins the layer stack (appended in dependency order); a - * dependency-listed name that no longer does — removed, or the installed - * version dropped the declaration — leaves it. In-box bundles from the - * profile template are not dependencies and are never touched. Warns once - * per newly-added bundle-less dependency (a plain library is fine; the - * warning is orientation). - */ -function reconcilePlugins(before: ProfileManifest, profileDir: string): void { - const after = readProfileManifest(NAME, profileDir) - const beforeDeps = new Set(Object.keys(before.dependencies ?? {})) - const dependencies = Object.keys(after.dependencies ?? {}) - const plugins = after.dsh?.profile?.bundles ?? [] - let changed = false - for (const packageName of dependencies) { - const isBundle = exportsPatch(packageName, profileDir) - if (isBundle && !plugins.includes(packageName)) { - plugins.push(packageName) - changed = true - } else if (!isBundle && !beforeDeps.has(packageName)) { - process.stderr.write( - `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer ` - + '(a later update that gains one activates it automatically)\n', - ) - } - } - const dependencySet = new Set(dependencies) - for (const packageName of [...plugins]) { - // Only dependency-managed entries are subject to removal; template - // bundles (dsh-base and friends) are not dependencies. - const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName) - const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir) - if (wasDependency && !stillBundle) { - plugins.splice(plugins.indexOf(packageName), 1) - changed = true - } - } - if (!changed) return - after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } } - writeProfileManifest(profileDir, after) -} - /** * Rewrite relative filesystem specs against the user's invoking directory. * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin` @@ -141,7 +76,7 @@ export function runPlugin(profile: string, args: readonly string[]): number { } const exitCode = result.status ?? 1 if (exitCode === 0) { - reconcilePlugins(before, dir) + reconcileProfileBundles(NAME, before, dir, INSTALL_ANCHOR) } else { // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH // one; the profile owns it, and the commonest failure here is pnpm ≥10 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 19c4abb245..df9c6276d8 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -250,6 +250,11 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // 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. + hostCtx.provide('dshInstallAnchor', INSTALL_ANCHOR) + hostCtx.provide('dshAllowPluginInstall', process.env.DSH_ALLOW_PLUGIN_INSTALL === '1') // 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/apps/desktop/scripts/build-harness.mjs b/apps/desktop/scripts/build-harness.mjs index 3da63d92c7..2e954f66d5 100644 --- a/apps/desktop/scripts/build-harness.mjs +++ b/apps/desktop/scripts/build-harness.mjs @@ -43,4 +43,15 @@ if (!existsSync(nodeBin)) throw new Error(`node executable not found: ${nodeBin} cpSync(nodeBin, join(out, 'bin/node')) chmodSync(join(out, 'bin/node'), 0o755) +// Vendor pnpm so the packaged app can install third-party plugins without pnpm +// on the target machine. npm ships with Node, so use it on the build machine +// (which has network); a failure only disables registry install, never the +// offline bundle install, so warn rather than abort. +try { + execFileSync('npm', ['install', '--prefix', join(out, 'pnpm'), 'pnpm@11.7.0'], { stdio: 'pipe' }) + console.log('vendored pnpm into harness') +} catch { + console.warn('could not vendor pnpm; registry plugin install will be unavailable in the packaged app') +} + console.log(`assembled self-contained harness at ${out}`) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3bd219dc57..b2b7877446 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -177,7 +177,8 @@ function startSession(): void { // Run the harness from its own root so relative path resolution is stable. cwd: harness, stdio: ['ignore', 'pipe', 'inherit'], - env: { ...process.env, DSH_TELEMETRY_DISABLED: '1' }, + // The desktop is the trusted surface that opts into plugin install. + env: { ...process.env, DSH_TELEMETRY_DISABLED: '1', DSH_ALLOW_PLUGIN_INSTALL: '1' }, }) let settled = false let buffered = '' diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 210ec0bf80..7b7f27b9f3 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -9,7 +9,13 @@ import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol' -export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' +export type { + AvailableBundle, + AvailableBundlesSnapshot, + InstallResult, + InstallSpec, + PluginInventorySnapshot, +} from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 9be66947bb..0c0986de5d 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -33,11 +33,13 @@ export { DEFAULT_PROFILE_BUNDLES, healProfilesModuleFallback, initProfile, + isBundlePackage, loadProfile, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, PROFILES_DIR, readProfileManifest, + reconcileProfileBundles, resolveBundleDir, resolveProfileDir, writeProfileManifest, diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 45d835eef7..78485592ef 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -354,6 +354,76 @@ export function resolveBundleDir( ) } +/** + * Whether a resolved dependency package declares a profile patch, i.e. is a + * bundle. An unresolvable package is treated as plain (pnpm may have reported + * success before the package is actually installable). + * @param binName - the diagnostic prefix on thrown errors. + * @param packageName - the dependency's package name. + * @param profileDir - the profile directory (resolution anchor). + * @param installAnchor - absolute path of a file inside the dsh app package. + * @returns true when the package manifest declares `dsh.bundle`. + */ +export function isBundlePackage( + binName: string, packageName: string, profileDir: string, installAnchor: string, +): boolean { + let dir: string + try { + dir = resolveBundleDir(binName, packageName, installAnchor, profileDir) + } catch { + return false + } + return readProfileManifest(binName, dir).dsh?.bundle?.patch !== undefined +} + +/** + * Reconcile `dsh.profile.bundles` against the installed state after a package + * manager run: pnpm has written the real installed names and materialized the + * packages. A dependency that resolves to a `dsh.bundle`-declaring package + * joins the layer stack (appended in dependency order); a dependency-listed + * name that no longer does leaves it. In-box template bundles are not + * dependencies and are never touched. Writes the manifest only when the list + * changed. + * @param binName - the diagnostic prefix on thrown errors. + * @param before - the manifest read before the package manager ran. + * @param profileDir - the profile directory. + * @param installAnchor - absolute path of a file inside the dsh app package. + */ +export function reconcileProfileBundles( + binName: string, before: ProfileManifest, profileDir: string, installAnchor: string, +): void { + const after = readProfileManifest(binName, profileDir) + const beforeDeps = new Set(Object.keys(before.dependencies ?? {})) + const dependencies = Object.keys(after.dependencies ?? {}) + const plugins = after.dsh?.profile?.bundles ?? [] + let changed = false + for (const packageName of dependencies) { + const isBundle = isBundlePackage(binName, packageName, profileDir, installAnchor) + if (isBundle && !plugins.includes(packageName)) { + plugins.push(packageName) + changed = true + } else if (!isBundle && !beforeDeps.has(packageName)) { + process.stderr.write( + `${binName}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer ` + + '(a later update that gains one activates it automatically)\n', + ) + } + } + const dependencySet = new Set(dependencies) + for (const packageName of [...plugins]) { + const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName) + const stillBundle = dependencySet.has(packageName) && isBundlePackage(binName, packageName, profileDir, installAnchor) + if (wasDependency && !stillBundle) { + plugins.splice(plugins.indexOf(packageName), 1) + changed = true + } + } + if (changed) { + after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } } + writeProfileManifest(profileDir, after) + } +} + /** * Load a profile: resolve every `dsh.profile.bundles` entry to its patch * layer and parse the profile's own patch file. A listed bundle without a 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 b2e54b5bc7..cbe7afea67 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 @@ -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); 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 db6b3057a5..a6cb3d90b8 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -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 /** 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: (spec: InstallSpec) => Promise + /** Un-compose an offline optional bundle. */ + uninstall: (name: string) => Promise } 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(null) const [state, setState] = useState({ status: 'loading' }) const [busy, setBusy] = useState(null) + const [bundles, setBundles] = useState({ status: 'loading' }) + const [installBusy, setInstallBusy] = useState(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 ) : null} {state.status === 'ready' ? ( -
- -
-

{t('catalog')}

- {filteredEntries.length} -
- {state.snapshot.entries.length === 0 ?

{t('empty')}

: null} - {state.snapshot.entries.length > 0 && filteredEntries.length === 0 - ?

{t('emptySearch')}

- : null} - {filteredEntries.length > 0 ? ( -
    - {filteredEntries.map(card)} -
+ <> + {bundles.status === 'ready' && bundles.snapshot.available.length > 0 ? ( +
+

{t('available')}

+ {installNote !== null + ?

{installNote.text}

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

{t('catalog')}

+ {filteredEntries.length} +
+ {state.snapshot.entries.length === 0 ?

{t('empty')}

: null} + {state.snapshot.entries.length > 0 && filteredEntries.length === 0 + ?

{t('emptySearch')}

+ : null} + {filteredEntries.length > 0 ? ( +
    + {filteredEntries.map(card)} +
+ ) : null} +
+ ) : null} ) diff --git a/packages/client/ui-settings-plugin-inventory/src/client/index.ts b/packages/client/ui-settings-plugin-inventory/src/client/index.ts index 8034101a33..1e51421529 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/index.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/index.ts @@ -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', diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 6377860c59..0fd3a8233c 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -24,6 +24,12 @@ export const zh = { disable: '停用', toggling: '切换中…', required: '应用必需插件,不可切换', + available: '可安装插件', + install: '安装', + uninstall: '卸载', + installing: '处理中…', + restartRequired: '已应用,重启后生效', + installFailed: '安装失败', } satisfies Record /** 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 diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index cdf5cd9310..019177ed84 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -13,10 +13,18 @@ afterEach(cleanup) type Snapshot = Awaited> 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(async () => ({ ok: true, restartRequired: true })) + const uninstall = vi.fn(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( 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') + }) }) diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 90c94cdf69..2fcc1ee62b 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/plugin-inventory/README.md -README.md: a91c02df5e7a57db89c9a63af6c4355573b843da -README.zh.md: 261db0b92cf025606b7fc18c5754d54f8cd34e48 +README.md: 3d899ac7404acd16047efce09e4c6d0dd0508db2 +README.zh.md: ceb69499e3b2209c58d37e9c6d4b18cc599d7dc5 diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index 7d6fc26610..3d899ac740 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -6,7 +6,9 @@ Host projection of the current Cordis Loader tree with per-plugin enable/disable The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, or event stream. `setEnabled` toggles one entry live through `ctx.loader.update` and persists an explicit `disabled` override into the profile's user patch layer so the choice survives a restart (a bundle-default disable needs the `disabled: false` override to stick). -Every entry carries a `protected` flag. The guard is default-open: every plugin is toggleable unless its module name is in the small `REQUIRED_PLUGINS` set in `src/required.ts` — the load-bearing core (the entry tree, the Remote RPC spine, the session and agent spines) that must never be disabled. `setEnabled` refuses to disable a required plugin and, after enabling, verifies the fiber becomes active (reverting a dependency-missing enable). The Web plugin-list tab renders one flat list of every entry: each shows its real enabled state, a toggleable plugin carries an enable or disable button (so a bundle-default-disabled plugin can be re-enabled), and a required plugin shows only a read-only note. Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. +Every entry carries a `protected` flag. The guard in `src/required.ts` is default-open with two code-editable lists: `REQUIRED_PLUGINS` (the blacklist of load-bearing core that must never be disabled — the entry tree, the Remote RPC spine, the session and agent spines) and `USER_TOGGLEABLE_PLUGINS` (the whitelist, which overrides the blacklist for an explicitly toggleable plugin); a plugin on neither list is toggleable by default. The full dependency-derived taxonomy of the shipped base bundle is in [`docs/plugin-system.md`](../../../docs/plugin-system.md). `setEnabled` refuses to disable a required plugin and, after enabling, verifies the fiber becomes active (reverting a dependency-missing enable). The Web plugin-list tab renders one flat list of every entry: each shows its real enabled state, a toggleable plugin carries an enable or disable button (so a bundle-default-disabled plugin can be re-enabled), and a required plugin shows only a read-only note. + +The gateway also manages installation through `availableBundles`/`install`/`uninstall`. `availableBundles` lists the curated offline-installable optional bundles in `src/bundles.ts` (`AVAILABLE_BUNDLES`), each marked installed when present in the profile's `dsh.profile.bundles`. `install` composes an offline bundle into that list (no network), or for a registry spec runs pnpm against the writable profile directory via the bundled Node and vendored pnpm — a registry install is gated behind the `dshAllowPluginInstall` context flag, which only the desktop boot sets. These writes persist the profile manifest and require a restart to take effect. Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index 5111081db8..ceb69499e3 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -6,7 +6,9 @@ 阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型或事件流。`setEnabled` 通过 `ctx.loader.update` 实时切换单条条目,并把显式 `disabled` 覆盖写进 profile 的用户补丁层,使选择在重启后保留(bundle 默认禁用的行需要 `disabled: false` 覆盖才能保持启用)。 -每条条目带 `protected` 标记。守卫默认开放:除 `src/required.ts` 中 `REQUIRED_PLUGINS` 这个小型集合(入口树、Remote RPC 主干、session 与 agent 主干等必须保留的核心)外,所有插件都可切换。`setEnabled` 拒绝停用必需插件;启用后会校验 fiber 变为 active(依赖缺失的启用会回滚)。Web 插件列表 tab 渲染单一扁平列表,包含所有条目:每项按真实启用状态显示,可切换插件带"启用"或"停用"按钮(这样 bundle 默认禁用的插件也能重新启用),必需插件只显示只读说明。公开 payload 类型位于 `./types`,Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 +每条条目带 `protected` 标记。`src/required.ts` 中的守卫默认开放,含两个可由代码编辑的名单:`REQUIRED_PLUGINS`(黑名单,即不可停用的承重核心——入口树、Remote RPC 主干、session 与 agent 主干)与 `USER_TOGGLEABLE_PLUGINS`(白名单,覆盖黑名单、对显式可开关的插件生效);未列入任何名单的插件默认可切换。随包 base bundle 的完整依赖图分类见 [`docs/plugin-system.md`](../../../docs/plugin-system.md)。`setEnabled` 拒绝停用必需插件;启用后会校验 fiber 变为 active(依赖缺失的启用会回滚)。Web 插件列表 tab 渲染单一扁平列表,包含所有条目:每项按真实启用状态显示,可切换插件带"启用"或"停用"按钮(这样 bundle 默认禁用的插件也能重新启用),必需插件只显示只读说明。 + +网关还通过 `availableBundles`/`install`/`uninstall` 管理安装。`availableBundles` 列出 `src/bundles.ts`(`AVAILABLE_BUNDLES`)中策展的离线可安装可选 bundle,每项在存在于 profile 的 `dsh.profile.bundles` 时标记为已安装。`install` 把离线 bundle 组合进该列表(无需网络);对 registry spec 则用内置 Node 与 vendored pnpm 在可写的 profile 目录运行 pnpm——registry 安装受 `dshAllowPluginInstall` 上下文标志门禁,仅桌面启动会开启。这些写入持久化 profile 清单,需重启生效。公开 payload 类型位于 `./types`,Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 0b13096e8b..f007f5d3fe 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -54,6 +54,7 @@ }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", @@ -61,6 +62,7 @@ }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/host/plugin-inventory/src/bundles.ts b/packages/host/plugin-inventory/src/bundles.ts new file mode 100644 index 0000000000..c3c5d53398 --- /dev/null +++ b/packages/host/plugin-inventory/src/bundles.ts @@ -0,0 +1,19 @@ +/** + * The curated catalog of optional bundles a deployment may install offline. + * + * A bundle here is an npm package that is already resolvable from the running + * installation (it is shipped in the harness or declared as a dependency of the + * app), so "installing" it only means composing it into the profile's + * `dsh.profile.bundles` — no network or package manager required. Add a new + * shipped optional bundle to {@link AVAILABLE_BUNDLES} to make it installable + * from the plugin list. + * @module @deepseek-ai/dsh-plugin-inventory/bundles + */ + +/** + * Bundle package names a deployment may compose offline. Keep this to bundles + * that are guaranteed resolvable from the installation anchor. + */ +export const AVAILABLE_BUNDLES: readonly string[] = [ + '@deepseek-ai/dsh-image-recognition-bundle', +] diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index 3addeb9e90..db93423b18 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -3,12 +3,18 @@ import type { Context, FiberState } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/cordis-plugin-loader' import { fileURLToPath } from 'node:url' +import { readProfileManifest, type ProfileManifest } from '@deepseek-ai/dsh-app-boot' 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 { persistPluginDisabled } from './persist.ts' import { isRequiredPlugin } from './required.ts' import type { + AvailableBundlesSnapshot, + InstallResult, + InstallSpec, PluginEntryId, PluginFiberPhase, PluginInventoryEntry, @@ -16,6 +22,8 @@ import type { } from './types.ts' export type * from './types.ts' +export { AVAILABLE_BUNDLES } from './bundles.ts' +export type { AvailableBundle, AvailableBundlesSnapshot, InstallResult, InstallSpec } from './types.ts' /** Brand an existing Loader-tree entry id at the owning boundary. */ function pluginEntryId(value: string): PluginEntryId { @@ -85,10 +93,8 @@ export class PluginInventoryGateway extends TypertRemoteService { */ @Remote('setEnabled') async setEnabled(entryId: PluginEntryId, enabled: boolean): Promise<{ ok: true }> { + // resolve() throws for an unknown id, so the entry is always defined. const entry = this.ctx.loader.resolve(entryId) - if (entry === undefined) { - throw new Error(`plugin entry ${String(entryId)} not found`) - } // Disabling a required system plugin tears the process down; refuse it. // Re-enabling a disabled plugin is what this surface is for. if (!enabled && isRequiredPlugin(entry.options.name)) { @@ -107,6 +113,92 @@ export class PluginInventoryGateway extends TypertRemoteService { } return { ok: true } } + + /** + * List the offline-installable optional bundles, each marked installed when + * it is already composed in the profile's `dsh.profile.bundles`. + * @returns the curated bundle catalog with installed state. + */ + @Remote('availableBundles') + availableBundles(): AvailableBundlesSnapshot { + const profileDir = this.profileDir() + const installed = new Set(this.profileManifest(profileDir)?.dsh?.profile?.bundles ?? []) + return { + available: AVAILABLE_BUNDLES.map(name => ({ name, installed: installed.has(name) })), + } + } + + /** + * Install a plugin. A `bundle` spec composes an offline optional bundle into + * the profile's bundle layer list (no network); a `registry` spec runs pnpm + * against the writable profile directory via the bundled Node and vendored + * pnpm, which requires the `dshAllowPluginInstall` context flag (set only by + * the desktop boot). Persists the manifest; the running tree recomposes at + * the next boot. + * @param spec - the bundle name or registry package spec to install. + * @returns a confirmation; `restartRequired` tells the caller to restart. + */ + @Remote('install') + install(spec: InstallSpec): 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') { + composeOfflineBundle('dsh', profileDir, anchor, spec.name) + return { ok: true, restartRequired: true } + } + if (this.ctx.get('dshAllowPluginInstall') !== true) { + throw new Error('dsh: plugin install is not permitted in this runtime') + } + const pnpmCjs = resolvePnpm(process.execPath) + if (pnpmCjs === undefined) { + throw new Error('dsh: bundled pnpm is unavailable in this runtime') + } + // The reconcile below re-reads the manifest, so a missing file fails there; + // this snapshot fallback is only reached in that same unreachable-to-succeed case. + /* v8 ignore next */ + const before = this.profileManifest(profileDir) ?? { dependencies: {} } + runPnpmInstall({ + binName: 'dsh', + profileDir, + installAnchor: anchor, + nodeBin: process.execPath, + pnpmCjs, + spec: spec.spec, + before, + }) + return { ok: true, restartRequired: true } + } + + /** + * Un-compose an offline optional bundle from the profile's bundle layer list. + * @param name - the bundle package name to remove. + * @returns a confirmation; `restartRequired` tells the caller to restart. + */ + @Remote('uninstall') + uninstall(name: string): InstallResult { + uninstallBundle('dsh', this.profileDir(), name) + return { ok: true, restartRequired: true } + } + + /** The profile directory the Loader anchors on (its `baseUrl`). */ + private profileDir(): string { + if (this.ctx.baseUrl === undefined) { + throw new Error('dsh: plugin management requires a profile directory') + } + return fileURLToPath(this.ctx.baseUrl) + } + + /** Read the profile manifest, or undefined when absent. */ + private profileManifest(profileDir: string): ProfileManifest | undefined { + try { + return readProfileManifest('dsh', profileDir) + } catch { + return undefined + } + } } export default PluginInventoryGateway diff --git a/packages/host/plugin-inventory/src/install.ts b/packages/host/plugin-inventory/src/install.ts new file mode 100644 index 0000000000..a0d9bd6aea --- /dev/null +++ b/packages/host/plugin-inventory/src/install.ts @@ -0,0 +1,121 @@ +/** + * Pure helpers behind the plugin-inventory install/uninstall Remotes. + * + * Offline bundle install composes an already-shipped optional bundle into the + * profile's `dsh.profile.bundles` — a writable profile-manifest mutation with + * no network or package manager. Registry install runs pnpm against the + * writable profile directory via a bundled Node + vendored pnpm, then + * reconciles the bundle layer list exactly as `dsh plugin add` does. All + * functions are dependency-free of Cordis so they unit-test without a context. + * @module @deepseek-ai/dsh-plugin-inventory/install + */ + +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { + readProfileManifest, + reconcileProfileBundles, + resolveBundleDir, + writeProfileManifest, + type ProfileManifest, +} from '@deepseek-ai/dsh-app-boot' + +/** + * Compose an offline optional bundle into the profile's bundle layer list. + * Validates that the bundle resolves from the installation anchor (so a bad + * name fails loud instead of silently corrupting the manifest), then appends + * the name when absent. Persists only — the running tree is recomposed at the + * next boot. + * @param binName - the diagnostic prefix on thrown errors. + * @param profileDir - the writable profile directory. + * @param installAnchor - absolute path of a file inside the dsh app package. + * @param name - the bundle package name to compose. + */ +export function composeOfflineBundle( + binName: string, profileDir: string, installAnchor: string, name: string, +): void { + resolveBundleDir(binName, name, installAnchor, profileDir) + const manifest = readProfileManifest(binName, profileDir) + const bundles = manifest.dsh?.profile?.bundles ?? [] + if (bundles.includes(name)) return + manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles: [...bundles, name] } } + writeProfileManifest(profileDir, manifest) +} + +/** + * Remove an optional bundle from the profile's bundle layer list. Leaves the + * installed dependency (if any) in place; a later `dsh plugin remove` handles + * the package itself. + * @param binName - the diagnostic prefix on thrown errors. + * @param profileDir - the writable profile directory. + * @param name - the bundle package name to un-compose. + */ +export function uninstallBundle(binName: string, profileDir: string, name: string): void { + const manifest = readProfileManifest(binName, profileDir) + const bundles = (manifest.dsh?.profile?.bundles ?? []).filter(bundle => bundle !== name) + manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } } + writeProfileManifest(profileDir, manifest) +} + +/** Options for one pnpm install run. */ +export interface PnpmInstallOptions { + readonly binName: string + readonly profileDir: string + readonly installAnchor: string + /** Absolute path of the Node executable to run pnpm with (the bundled node). */ + readonly nodeBin: string + /** Absolute path of the pnpm CLI entry (pnpm.cjs). */ + readonly pnpmCjs: string + /** The package specifier to install. */ + readonly spec: string + /** The profile manifest read before the install, for reconciliation. */ + readonly before: ProfileManifest +} + +/** + * Run `pnpm add ` in the profile directory via a bundled Node, then + * reconcile `dsh.profile.bundles` against the installed state. Throws when the + * package manager fails (nonzero exit or spawn error). + * @param options - the run options. + */ +export function runPnpmInstall(options: PnpmInstallOptions): void { + const { binName, profileDir, installAnchor, nodeBin, pnpmCjs, spec, before } = options + const result = spawn(nodeBin, [pnpmCjs, 'add', spec], profileDir) + if (result.exitCode !== 0) { + throw new Error(`${binName}: pnpm install failed with exit code ${result.exitCode}`) + } + reconcileProfileBundles(binName, before, profileDir, installAnchor) +} + +/** Spawn one synchronous child and return its exit code (0 on success). */ +function spawn(command: string, args: readonly string[], cwd: string): { exitCode: number } { + const result = spawnSync(command, args, { + cwd, + stdio: 'inherit', + shell: process.platform === 'win32', + }) + if (result.error !== undefined) { + throw result.error + } + // On a successful spawn status is always a number; null coincides with the + // spawn-error path above, so the fallback is unreachable. + /* v8 ignore next */ + return { exitCode: result.status ?? 1 } +} + +/** + * Locate the pnpm CLI bundled into the harness. Honors a `DSH_PNPM` override, + * then looks for the vendored pnpm beside the bundled Node's harness root. + * @param nodeBin - the bundled Node executable path (`process.execPath`). + * @param env - the process environment. + * @returns the pnpm.cjs path, or undefined when none is vendored. + */ +export function resolvePnpm(nodeBin: string, env: NodeJS.ProcessEnv = process.env): string | undefined { + if (env.DSH_PNPM) return env.DSH_PNPM + // nodeBin is harness/bin/node in the packaged app, so the harness root is + // one level up from bin/; pnpm is vendored under harness/pnpm/. + const harnessRoot = resolve(dirname(nodeBin), '..') + const candidate = join(harnessRoot, 'pnpm', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs') + return existsSync(candidate) ? candidate : undefined +} diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index 8ef4bc61a8..496c5afba5 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -28,3 +28,28 @@ export interface PluginInventoryEntry { export interface PluginInventorySnapshot { readonly entries: readonly PluginInventoryEntry[] } + +/** One optional bundle in the offline-installable catalog. */ +export interface AvailableBundle { + /** The bundle's npm package name. */ + readonly name: string + /** Whether the bundle is already composed in the profile. */ + readonly installed: boolean +} + +/** Catalog snapshot returned by the available-bundles Remote. */ +export interface AvailableBundlesSnapshot { + readonly available: readonly AvailableBundle[] +} + +/** What a plugin-install request targets. */ +export type InstallSpec = + | { readonly type: 'bundle'; readonly name: string } + | { readonly type: 'registry'; readonly spec: string } + +/** Result of an install/uninstall request. */ +export interface InstallResult { + readonly ok: true + /** Whether the app must restart for the change to take effect. */ + readonly restartRequired: boolean +} diff --git a/packages/host/plugin-inventory/tests/install.spec.ts b/packages/host/plugin-inventory/tests/install.spec.ts new file mode 100644 index 0000000000..c71c9a53ac --- /dev/null +++ b/packages/host/plugin-inventory/tests/install.spec.ts @@ -0,0 +1,146 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' +import { composeOfflineBundle, resolvePnpm, runPnpmInstall, uninstallBundle } from '../src/install.ts' + +const dirs: string[] = [] + +function makeProfile(bundles: string[] = []): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-install-')) + dirs.push(dir) + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'dsh-profile-test', dsh: { profile: { bundles } } }, undefined, 2), + ) + return dir +} + +/** Make a bundle resolvable from a profile dir's node_modules. */ +function makeBundle(dir: string, name: string): void { + const pkgDir = join(dir, 'node_modules', name) + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ + name, + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(pkgDir, 'cordis.patch.yml'), '[]\n') +} + +/** A fake pnpm CLI that exits with the given code. */ +function makeFakePnpm(dir: string, exitCode: number): string { + const file = join(dir, `pnpm-${exitCode}.cjs`) + writeFileSync(file, `process.exit(${exitCode})\n`) + return file +} + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +describe('composeOfflineBundle', () => { + it('appends a resolvable bundle and stays idempotent', () => { + const dir = makeProfile() + makeBundle(dir, 'example-bundle') + composeOfflineBundle('dsh', dir, join(dir, 'package.json'), 'example-bundle') + expect(readProfileManifest('dsh', dir).dsh?.profile?.bundles).toEqual(['example-bundle']) + composeOfflineBundle('dsh', dir, join(dir, 'package.json'), 'example-bundle') + expect(readProfileManifest('dsh', dir).dsh?.profile?.bundles).toEqual(['example-bundle']) + }) + + it('fails loud for an unresolvable bundle', () => { + const dir = makeProfile() + expect(() => { composeOfflineBundle('dsh', dir, join(dir, 'package.json'), 'missing') }).toThrow(/cannot resolve/) + }) +}) + +describe('uninstallBundle', () => { + it('removes a bundle from the layer list', () => { + const dir = makeProfile(['example-bundle']) + uninstallBundle('dsh', dir, 'example-bundle') + expect(readProfileManifest('dsh', dir).dsh?.profile?.bundles).toEqual([]) + }) +}) + +describe('runPnpmInstall', () => { + it('reconciles without throwing on pnpm success', () => { + const dir = makeProfile() + const before = readProfileManifest('dsh', dir) + const pnpm = makeFakePnpm(dir, 0) + runPnpmInstall({ + binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), + nodeBin: process.execPath, pnpmCjs: pnpm, spec: 'example', before, + }) + }) + + it('throws when pnpm fails', () => { + const dir = makeProfile() + const before = readProfileManifest('dsh', dir) + const pnpm = makeFakePnpm(dir, 1) + expect(() => { runPnpmInstall({ + binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), + nodeBin: process.execPath, pnpmCjs: pnpm, spec: 'example', before, + }) }).toThrow(/pnpm install failed/) + }) +}) + +describe('composeOfflineBundle with a bare manifest', () => { + it('initializes an absent bundle list', () => { + const dir = makeProfile() + rmSync(join(dir, 'package.json')) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test' })) + makeBundle(dir, 'example-bundle') + composeOfflineBundle('dsh', dir, join(dir, 'package.json'), 'example-bundle') + expect(readProfileManifest('dsh', dir).dsh?.profile?.bundles).toEqual(['example-bundle']) + }) +}) + +describe('uninstallBundle with a non-present bundle', () => { + it('leaves the list unchanged', () => { + const dir = makeProfile(['other']) + uninstallBundle('dsh', dir, 'absent') + expect(readProfileManifest('dsh', dir).dsh?.profile?.bundles).toEqual(['other']) + }) + + it('handles an absent bundle list', () => { + const dir = makeProfile() + rmSync(join(dir, 'package.json')) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test' })) + uninstallBundle('dsh', dir, 'absent') + expect(readProfileManifest('dsh', dir).dsh?.profile?.bundles).toEqual([]) + }) +}) + +describe('runPnpmInstall spawn failure', () => { + it('throws the spawn error when node cannot start', () => { + const dir = makeProfile() + const before = readProfileManifest('dsh', dir) + expect(() => { runPnpmInstall({ + binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), + nodeBin: '/nonexistent/node', pnpmCjs: '/x/pnpm.cjs', spec: 'x', before, + }) }).toThrow() + }) +}) + +describe('resolvePnpm', () => { + it('honors a DSH_PNPM override', () => { + expect(resolvePnpm('/x/bin/node', { DSH_PNPM: '/vendored/pnpm.cjs' })).toBe('/vendored/pnpm.cjs') + }) + + it('derives the vendored pnpm beside the node harness root and misses when absent', () => { + expect(resolvePnpm('/nonexistent/bin/node', {})).toBeUndefined() + }) + + it('finds a vendored pnpm beside the node harness root', () => { + const dir = makeProfile() + const harnessRoot = join(dir, 'harness') + const pnpmDir = join(harnessRoot, 'pnpm', 'node_modules', 'pnpm', 'bin') + mkdirSync(pnpmDir, { recursive: true }) + writeFileSync(join(pnpmDir, 'pnpm.cjs'), '') + const nodeBin = join(harnessRoot, 'bin', 'node') + mkdirSync(join(harnessRoot, 'bin'), { recursive: true }) + writeFileSync(nodeBin, '') + expect(resolvePnpm(nodeBin, {})).toBe(join(pnpmDir, 'pnpm.cjs')) + }) +}) diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 72f802f41b..6bb43e78d3 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -1,6 +1,11 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { Context, type Plugin } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' import PluginInventoryGateway, { type PluginEntryId } from '../src/index.ts' @@ -34,7 +39,7 @@ async function harness(): Promise<{ } describe('PluginInventoryGateway', () => { - it('publishes one direct list method under the pluginInventory namespace', async () => { + it('publishes direct methods under the pluginInventory namespace', async () => { const { inventory } = await harness() expect(inventory.typertRemote).toMatchObject({ serviceKey: 'pluginInventory', @@ -43,6 +48,9 @@ describe('PluginInventoryGateway', () => { expect(remoteMethods(inventory)).toEqual([ { method: 'list', invocation: { kind: 'direct' } }, { method: 'setEnabled', invocation: { kind: 'direct' } }, + { method: 'availableBundles', invocation: { kind: 'direct' } }, + { method: 'install', invocation: { kind: 'direct' } }, + { method: 'uninstall', invocation: { kind: 'direct' } }, ]) }) @@ -117,4 +125,161 @@ describe('PluginInventoryGateway', () => { await ctx.loader.remove(pendingId) expect(inventory.list().entries.some(entry => entry.entryId === pendingId)).toBe(false) }) + + it('availableBundles reports installed state from the profile manifest', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(dir + '/').href + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-test', + dsh: { profile: { bundles: ['@deepseek-ai/dsh-image-recognition-bundle'] } }, + })) + try { + const snapshot = inventory.availableBundles() + expect(snapshot.available).toEqual([ + { name: '@deepseek-ai/dsh-image-recognition-bundle', installed: true }, + ]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('install requires an install anchor and gating for registry specs', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', dsh: { profile: { bundles: [] } } })) + try { + expect(() => inventory.install({ type: 'bundle', name: 'x' })).toThrow(/install anchor is unavailable/) + ctx.provide('dshInstallAnchor', join(dir, 'package.json')) + expect(() => inventory.install({ type: 'registry', spec: 'x' })).toThrow(/not permitted/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('uninstall removes a bundle from the profile manifest', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-test', + dsh: { profile: { bundles: ['@deepseek-ai/dsh-image-recognition-bundle'] } }, + })) + try { + expect(inventory.uninstall('@deepseek-ai/dsh-image-recognition-bundle').restartRequired).toBe(true) + expect(inventory.availableBundles().available[0]!.installed).toBe(false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('composes an offline bundle when the anchor is available', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', dsh: { profile: { bundles: [] } } })) + mkdirSync(join(dir, 'node_modules', 'b'), { recursive: true }) + writeFileSync(join(dir, 'node_modules', 'b', 'package.json'), JSON.stringify({ name: 'b', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + writeFileSync(join(dir, 'node_modules', 'b', 'cordis.patch.yml'), '[]\n') + ctx.provide('dshInstallAnchor', join(dir, 'package.json')) + try { + inventory.install({ type: 'bundle', name: 'b' }) + expect(readProfileManifest('dsh', dir).dsh?.profile?.bundles).toEqual(['b']) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('installs a registry spec via bundled pnpm when permitted', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', dsh: { profile: { bundles: [] } } })) + const fakePnpm = join(dir, 'pnpm.cjs') + writeFileSync(fakePnpm, 'process.exit(0)\n') + process.env.DSH_PNPM = fakePnpm + try { + ctx.provide('dshInstallAnchor', join(dir, 'package.json')) + ctx.provide('dshAllowPluginInstall', true) + expect(inventory.install({ type: 'registry', spec: 'some-pkg' }).restartRequired).toBe(true) + } finally { + delete process.env.DSH_PNPM + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('fails loud without a profile directory', async () => { + const { inventory } = await harness() + expect(() => inventory.availableBundles()).toThrow(/profile directory/) + }) + + it('treats a missing profile manifest as no installed bundles', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + try { + expect(inventory.availableBundles().available[0]!.installed).toBe(false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('setEnabled fails loud for an unknown entry', async () => { + const { inventory } = await harness() + await expect(inventory.setEnabled('missing' as PluginEntryId, false)).rejects.toThrow(/cannot resolve entry missing/) + }) + + it('setEnabled persists the override when anchored to a profile directory', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + try { + const id = await ctx.loader.create({ name: 'cordis:user-toggleable' }) as PluginEntryId + await inventory.setEnabled(id, false) + expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('disabled: true') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('setEnabled reverts an enable whose fiber cannot activate', async () => { + const { ctx, inventory } = await harness() + const id = await ctx.loader.create({ name: 'cordis:pending' }) as PluginEntryId + await expect(inventory.setEnabled(id, true)).rejects.toThrow(/could not start/) + expect(inventory.list().entries.find(entry => entry.entryId === id)?.enabled).toBe(false) + }) + + it('registry install fails loud when bundled pnpm is absent', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', dsh: { profile: { bundles: [] } } })) + delete process.env.DSH_PNPM + try { + ctx.provide('dshInstallAnchor', join(dir, 'package.json')) + ctx.provide('dshAllowPluginInstall', true) + expect(() => inventory.install({ type: 'registry', spec: 'x' })).toThrow(/bundled pnpm is unavailable/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('registry install fails loud when the profile manifest is missing', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + const fakePnpm = join(dir, 'pnpm.cjs') + writeFileSync(fakePnpm, 'process.exit(0)\n') + process.env.DSH_PNPM = fakePnpm + try { + ctx.provide('dshInstallAnchor', join(dir, 'package.json')) + ctx.provide('dshAllowPluginInstall', true) + expect(() => inventory.install({ type: 'registry', spec: 'x' })).toThrow(/failed to read profile manifest/) + } finally { + delete process.env.DSH_PNPM + rmSync(dir, { recursive: true, force: true }) + } + }) }) diff --git a/packages/host/plugin-inventory/tests/persist.spec.ts b/packages/host/plugin-inventory/tests/persist.spec.ts index 625887414b..f79d1c4a39 100644 --- a/packages/host/plugin-inventory/tests/persist.spec.ts +++ b/packages/host/plugin-inventory/tests/persist.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -35,4 +35,21 @@ describe('persistPluginDisabled', () => { expect(text.match(/image-recognition-http/g)).toHaveLength(1) expect(text).not.toContain('disabled: true') }) + + it('creates the patch file when it does not yet exist', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-plugin-persist-')) + 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') + rmSync(dir, { recursive: true, force: true }) + }) + + it('treats a non-array patch file as empty', () => { + const dir = profile('not-an-array\n') + persistPluginDisabled(dir, 'image-recognition-http', true) + const text = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8') + expect(text).toContain('image-recognition-http') + expect(text).not.toContain('not-an-array') + }) }) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json index 5bd45b3f3c..76f562879a 100644 --- a/packages/host/plugin-inventory/tsconfig.json +++ b/packages/host/plugin-inventory/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/loader" }, + { + "path": "../../boot/app-boot" + }, { "path": "../../util/brand" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9bb1c25b7..a346f46d40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4972,6 +4972,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand