feat(plugin): plugin marketplace, durable install persistence, skill manager

Add an in-app plugin marketplace fed by a remote web catalog: the host
plugin-inventory gateway gains marketplaceList/Install/Uninstall remotes,
a durable per-user install table reconciled against the actual profile, and
git/npm/tarball/bundle install paths with non-interactive git and vendored
pnpm. The Web Settings surface gains a sibling 插件市场 tab that lists the
catalog with recommended badges, repository links, and a combined sort
(recommended first, then catalog priority, then id).

Add a host + client skill manager: list local skills by direct filesystem
discovery, and install/uninstall/toggle/edit their SKILL.md from git/npm/
tarball/local sources in the writable user root.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-15 17:01:48 +08:00
parent 5f348f9b1c
commit a53769955b
57 changed files with 5168 additions and 129 deletions
+290 -29
View File
@@ -8,13 +8,35 @@ 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, runPnpmInstallWithRegistries, uninstallBundle } from './install.ts'
import {
composeOfflineBundle,
registryPackageName,
resolvePnpmCommand,
runPnpmInstall,
runPnpmInstallWithRegistries,
runPnpmRemove,
uninstallBundle,
writeAllowBuilds,
} from './install.ts'
import {
fetchMarketplaceCatalog,
fetchMarketplaceSpec,
MARKETPLACE_URL,
marketplaceBaseUrl,
marketplaceDataDir,
readInstallTable,
writeInstallTable,
} from './marketplace.ts'
import { persistPluginDisabled } from './persist.ts'
import { isRequiredPlugin } from './required.ts'
import type {
AvailableBundlesSnapshot,
InstallResult,
InstallSpec,
InstalledBundlesSnapshot,
InstalledMarketplacePlugin,
MarketplaceInstallSpec,
MarketplaceSnapshot,
PluginEntryId,
PluginFiberPhase,
PluginInventoryEntry,
@@ -23,8 +45,40 @@ 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'
export {
INSTALL_REGISTRIES,
parseBlockedBuilds,
registryPackageName,
resolvePnpmCommand,
runPnpmRemove,
writeAllowBuilds,
} from './install.ts'
export type { PnpmAddResult, ResolvedPnpmCommand } from './install.ts'
export {
CATALOG_FILE,
MARKETPLACE_URL,
fetchMarketplaceCatalog,
fetchMarketplaceSpec,
marketplaceBaseUrl,
marketplaceDataDir,
marketplaceSpecUrl,
readInstallTable,
writeInstallTable,
} from './marketplace.ts'
export type {
AvailableBundle,
AvailableBundlesSnapshot,
InstallResult,
InstallSpec,
InstalledBundle,
InstalledBundlesSnapshot,
InstalledMarketplacePlugin,
MarketplaceEntry,
MarketplaceInstallMethod,
MarketplaceInstallSpec,
MarketplacePluginMeta,
MarketplaceSnapshot,
} from './types.ts'
/**
* The profile's default bundle layers, composed by the shipped template and not
@@ -98,7 +152,10 @@ export class PluginInventoryGateway extends TypertRemoteService {
* 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.
* the next reload. A plugin that is PENDING on a dependency (a service
* another enabled plugin provides) is left enabled — the loader activates it
* once the dependency resolves; a plugin whose apply actually fails rejects
* here via the loader's own start error.
* @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.
@@ -113,13 +170,13 @@ export class PluginInventoryGateway extends TypertRemoteService {
throw new Error(`plugin ${String(entryId)} is required by the application and cannot be disabled`)
}
const rowId = entry.options.id
// update() throws when the plugin's apply/config fails (and restores the
// previous disabled state), so a resolved enable has either started or is
// legitimately PENDING on a dependency that another enabled plugin
// provides. PENDING is not a failure: reverting it here would stop
// interdependent plugins — typically several freshly installed ones —
// from being enabled together.
await this.ctx.loader.update(entryId, { disabled: !enabled })
// An enable whose injected services are unavailable would fail the next
// boot (the dependent never becomes active). Revert and refuse loudly.
if (enabled && entry.fiber !== undefined && entry.fiber.state !== FIBER_STATE.ACTIVE) {
await this.ctx.loader.update(entryId, { disabled: true })
throw new Error(`plugin ${String(entryId)} could not start; its dependencies are unavailable`)
}
if (this.ctx.baseUrl !== undefined) {
persistPluginDisabled(fileURLToPath(this.ctx.baseUrl), rowId, !enabled)
}
@@ -148,10 +205,19 @@ export class PluginInventoryGateway extends TypertRemoteService {
* 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.
* the desktop boot). A registry spec is any pnpm `add` specifier — a bare npm
* name, a tarball path/URL, or a git/GitHub URL — so every community install
* source shares this path.
*
* Build-script consent is two-phase. The first call (no `consentBuilds`)
* installs; when pnpm blocks dependency build scripts the host returns them
* as `pendingBuilds` instead of failing. The caller shows those to the user,
* and the retry carries the exact consented set in `consentBuilds`: the host
* runs `pnpm approve-builds` for those packages, then reinstalls. 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.
* @returns a confirmation; `pendingBuilds` pauses the install for consent,
* otherwise `restartRequired` tells the caller to restart.
*/
@Remote('installPlugin')
async installPlugin(spec: InstallSpec): Promise<InstallResult> {
@@ -170,41 +236,236 @@ export class PluginInventoryGateway extends TypertRemoteService {
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')
// Prefer the pnpm vendored into the packaged harness; fall back to pnpm on
// PATH so a development checkout (which has no vendored pnpm) can install.
const pnpm = resolvePnpmCommand(process.execPath)
if (pnpm === undefined) {
throw new Error('dsh: bundled pnpm is unavailable in this runtime and pnpm is not on PATH')
}
// 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: {} }
// Try each registry until one succeeds; the official npm registry is last.
runPnpmInstallWithRegistries({
binName: 'dsh',
profileDir,
installAnchor: anchor,
nodeBin: process.execPath,
pnpmCjs,
spec: spec.spec,
before,
})
// A consenting retry allows exactly the packages the user confirmed in the
// profile's pnpm-workspace.yaml (deterministic — no dependency on pnpm's
// pending-build state), then the add proceeds with them buildable.
if (spec.consentBuilds !== undefined && spec.consentBuilds.length > 0) {
writeAllowBuilds(profileDir, spec.consentBuilds)
}
// A registry-name spec participates in the registry fallback loop and the
// minimum-release-age exemption; a git, tarball, or path spec runs once.
const registryName = registryPackageName(spec.spec)
const result = registryName === undefined
? runPnpmInstall({
binName: 'dsh',
profileDir,
installAnchor: anchor,
nodeBin: pnpm.nodeBin,
pnpmCjs: pnpm.pnpmCjs,
spec: spec.spec,
before,
})
: runPnpmInstallWithRegistries({
binName: 'dsh',
profileDir,
installAnchor: anchor,
nodeBin: pnpm.nodeBin,
pnpmCjs: pnpm.pnpmCjs,
spec: spec.spec,
before,
minimumReleaseAgeExclude: registryName,
})
if (result.pendingBuilds !== undefined) {
return { ok: true, restartRequired: false, pendingBuilds: result.pendingBuilds }
}
return { ok: true, restartRequired: !(await this.reload()) }
}
/**
* Un-compose an offline optional bundle from the profile's bundle layer list.
* @param name - the bundle package name to remove.
* List the profile's user-installed plugin dependencies (the packages pnpm
* manages in the profile, excluding the in-box bundles, which ship with the
* installation and are never dependencies). These are the ones the user can
* uninstall.
* @returns the installed-dependency snapshot.
*/
@Remote('installedBundles')
installedBundles(): InstalledBundlesSnapshot {
const profileDir = this.profileDir()
const dependencies = this.profileManifest(profileDir)?.dependencies ?? {}
// In-box bundles (dsh-base & friends) are never profile dependencies, so the
// dependency list is exactly the set the user installed.
return { installed: Object.keys(dependencies).map(name => ({ name })) }
}
/**
* Uninstall a plugin. A user-installed dependency (managed by pnpm) is removed
* with `pnpm remove`, which drops both the dependency and any bundle layer it
* had declared — this requires the `dshAllowPluginInstall` context flag, set
* only by the desktop boot. An offline optional bundle that a profile composed
* is un-composed (no dependency to remove). In-box bundles are part of the
* installation and are refused.
* @param name - the plugin package name to remove.
* @returns a confirmation; `restartRequired` tells the caller to restart.
*/
@Remote('uninstall')
async uninstall(name: string): Promise<InstallResult> {
const profileDir = this.profileDir()
if (DEFAULT_BUNDLES.has(name)) {
throw new Error(`dsh: bundle ${name} is composed by default and cannot be uninstalled`)
}
uninstallBundle('dsh', this.profileDir(), name)
const before = this.profileManifest(profileDir)
if (before?.dependencies?.[name] !== undefined) {
// A user-installed dependency: remove it with pnpm, then reconcile (the
// reconcile drops the layer when the removed package had declared one).
const anchor = this.ctx.get('dshInstallAnchor') as string | undefined
if (anchor === undefined) {
throw new Error('dsh: install anchor is unavailable in this runtime')
}
if (this.ctx.get('dshAllowPluginInstall') !== true) {
throw new Error('dsh: plugin uninstall is not permitted in this runtime')
}
const pnpm = resolvePnpmCommand(process.execPath)
if (pnpm === undefined) {
throw new Error('dsh: bundled pnpm is unavailable in this runtime and pnpm is not on PATH')
}
runPnpmRemove({
binName: 'dsh',
profileDir,
installAnchor: anchor,
nodeBin: pnpm.nodeBin,
pnpmCjs: pnpm.pnpmCjs,
spec: name,
before,
})
return { ok: true, restartRequired: !(await this.reload()) }
}
// Not a profile dependency: an offline optional bundle the profile composed.
uninstallBundle('dsh', profileDir, name)
return { ok: true, restartRequired: !(await this.reload()) }
}
/**
* List the remote marketplace catalog, marking each entry installed. The
* durable install table records what the user installed; an entry is reported
* installed only when it is BOTH recorded there AND actually present in the
* current profile's dependencies (or bundle layers, for a bundle install) —
* so a plugin removed by another path or a reset profile is not shown as
* installed after a restart. When no profile is anchored, the table alone is
* the fallback. The catalog is fetched from the static web host; a transient
* network miss throws so the caller can surface a clean failure.
* @returns the marketplace catalog with installed state.
*/
@Remote('marketplaceList')
async marketplaceList(): Promise<MarketplaceSnapshot> {
const dataDir = marketplaceDataDir()
const table = readInstallTable(dataDir)
const entries = await fetchMarketplaceCatalog(MARKETPLACE_URL)
const profile = this.ctx.baseUrl !== undefined ? this.profileManifest(this.profileDir()) : undefined
const dependencies = profile?.dependencies ?? {}
const bundles = profile?.dsh?.profile?.bundles ?? []
return {
entries: entries.map((meta) => {
const row = table[meta.id]
const installed = row !== undefined && (
row.method === 'bundle'
? bundles.includes(row.spec)
: dependencies[row.dependency ?? row.spec] !== undefined
)
return { ...meta, installed }
}),
}
}
/**
* Install a marketplace plugin by id. Fetches the plugin's install spec from
* the catalog base (derived from the fixed catalog URL, never from catalog
* content), maps it onto the existing install path (git/npm/tarball via the
* registry Remote, bundle via the offline compose), and records the install
* in the durable table once it succeeds. A `pendingBuilds` result pauses for
* build consent exactly as a direct registry install does; the caller shows
* the blocked packages and re-invokes with the consented set.
* @param id - the marketplace plugin id.
* @param consentBuilds - the packages the user consented to run build scripts
* for, sent on the retry after a `pendingBuilds` result.
* @returns the underlying install result (restart notice or pending consent).
*/
@Remote('marketplaceInstall')
async marketplaceInstall(id: string, consentBuilds?: readonly string[]): Promise<InstallResult> {
const spec = await fetchMarketplaceSpec(marketplaceBaseUrl(MARKETPLACE_URL), id)
const result = spec.method === 'bundle'
? await this.installPlugin({ type: 'bundle', name: spec.spec })
: consentBuilds !== undefined && consentBuilds.length > 0
? await this.installPlugin({ type: 'registry', spec: spec.spec, consentBuilds })
: await this.installPlugin({ type: 'registry', spec: spec.spec })
// A paused install awaits consent; only a completed install is recorded.
if (result.pendingBuilds !== undefined) return result
this.recordMarketplaceInstall(id, spec)
return result
}
/**
* Uninstall a marketplace plugin by id. Resolves the profile dependency (or
* the bundle name) from the install table row, calls the existing uninstall
* path, and drops the table row once it succeeds.
* @param id - the marketplace plugin id.
* @returns the underlying uninstall result.
*/
@Remote('marketplaceUninstall')
async marketplaceUninstall(id: string): Promise<InstallResult> {
const dataDir = marketplaceDataDir()
const table = readInstallTable(dataDir)
const row = table[id]
if (row === undefined) throw new Error(`marketplace plugin ${id} is not installed`)
// A bundle install un-composes by its bundle name; registry installs remove
// the profile dependency, falling back to the spec when no explicit
// dependency name was recorded.
const result = await this.uninstall(row.method === 'bundle' ? row.spec : row.dependency ?? row.spec)
const next = { ...table }
Reflect.deleteProperty(next, id)
writeInstallTable(dataDir, next)
return result
}
/** Record a successful marketplace install in the durable install table. */
private recordMarketplaceInstall(id: string, spec: MarketplaceInstallSpec): void {
const dataDir = marketplaceDataDir()
const table = readInstallTable(dataDir)
// The web spec's `dependency` is a best-effort guess; for a git (or path)
// install the resolved package name is only known once pnpm runs, and it can
// carry a scope the guess omitted (e.g. `@dsh-external/dsh-visualize`). The
// marketplace's installed check matches the profile by this name, so record
// the real one — resolved from the just-updated profile — to keep the table
// in sync with the dependency the loader actually holds.
const dependency = this.resolveInstalledDependency(spec) ?? spec.dependency
const row: InstalledMarketplacePlugin = {
method: spec.method,
spec: spec.spec,
installedAt: new Date().toISOString(),
...(dependency !== undefined ? { dependency } : {}),
}
writeInstallTable(dataDir, { ...table, [id]: row })
}
/**
* Resolve the real profile dependency name for a just-completed install. An
* exact dependency-name match wins; otherwise a git/path spec installs under
* the package's own name, which surfaces as the dependency whose version range
* equals the spec.
* @param spec - the marketplace install spec that succeeded.
* @returns the profile dependency name, or undefined when unresolvable.
*/
private resolveInstalledDependency(spec: MarketplaceInstallSpec): string | undefined {
if (this.ctx.baseUrl === undefined) return undefined
const dependencies = this.profileManifest(this.profileDir())?.dependencies ?? {}
if (spec.dependency !== undefined && dependencies[spec.dependency] !== undefined) {
return spec.dependency
}
for (const [name, range] of Object.entries(dependencies)) {
if (range === spec.spec) return name
}
return undefined
}
/** 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