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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user