Files
workdsh/docs/plugin-development.en.md
T

5.0 KiB

DSH Desktop Plugin Development

Understand the two plugin layers

A normal DSH plugin can provide Host services, commands, routes, bundles, or a Web Client. It should depend on upstream DSH contracts whenever possible so the same package can work in the CLI, an ordinary Web profile, and DSH Desktop.

Desktop adds two public Host services:

  • desktopProfiles: reads the active profile, discovers selectable profiles, and requests a safe profile switch.
  • desktopPnpm: runs pnpm in the active profile, or manages plugins through the official dsh plugin semantics.

These services live in the Host Cordis generation in Electron's main process. The renderer cannot read them directly; a plugin with browser UI should continue to use ordinary DSH Web routes, RPC, client metadata, services, and slots.

The complete types, lifecycle, and failure semantics are in dsh-plugin-desktop/docs/plugin-services.md. This page focuses on selection and the minimum safe patterns.

Desktop-only plugins

If a plugin only makes sense in Desktop, declare the services as required injections:

import type { Context } from '@deepseek-ai/cordis'
import type {} from 'dsh-plugin-desktop/profile-service'
import type { DesktopPnpmHandle } from 'dsh-plugin-desktop/pnpm'

export const name = 'example-desktop-plugin'
export const inject = ['desktopProfiles', 'desktopPnpm']

export function apply(ctx: Context): void {
  ctx.logger.info(`profile: ${ctx.desktopProfiles.current.name}`)
  let active: DesktopPnpmHandle | undefined

  // Connect this function to an explicit user action in the plugin UI.
  async function installExample(): Promise<void> {
    active = ctx.desktopPnpm.runPlugin(
      ['add', 'example-plugin'],
      process.cwd(),
    )
    await active.done
  }

  ctx.effect(() => {
    return async () => {
      active?.cancel()
      await active?.done.catch(() => {})
    }
  }, 'example plugin operation')
}

Production code should invoke package operations from an explicit user action, validate the target, read stdout/stderr, set its own timeout, and check both exitCode and signal. A generation allows only one desktopPnpm package operation at a time; dispose must cancel and await it.

Plugins that work in Desktop and ordinary DSH

When the same package must also run under ordinary dsh web, do not put Desktop services in the top-level required inject list. Inject ordinary dependencies first and dynamically detect Desktop:

export const inject = ['webServer', 'loader']

export function apply(ctx: Context, config: { profile?: string }): void {
  const profiles = ctx.get('desktopProfiles')
  if (profiles === undefined) {
    mountOrdinaryDshManager(ctx, config.profile ?? 'web')
    return
  }

  ctx.inject(['desktopPnpm'], (desktopPnpm) => {
    mountManager(ctx, {
      profile: profiles.current.name,
      profileDir: profiles.current.dir,
      runPlugin: (args, cwd, signal) => desktopPnpm.runPlugin(args, cwd, signal),
    })
  })
}

The ordinary DSH fallback remains the plugin's authoritative implementation. Do not infer the Desktop profile from process.argv, ctx.baseUrl, settings, or $DSH_HOME; in Desktop, use desktopProfiles.current.

run() versus runPlugin()

desktopPnpm.run(args) is a low-level pnpm operation with the active profile as its cwd. It does not promise DSH profile initialization, caller-relative file:/link: anchoring, or dsh.profile.bundles reconciliation.

desktopPnpm.runPlugin(args, invokingDir) runs packaged dsh plugin --profile <active> and preserves upstream plugin-management semantics. Use it for install, remove, update, and dependency repair:

desktopPnpm.runPlugin(['add', target], invokingDir, signal)
desktopPnpm.runPlugin(['remove', packageName], invokingDir, signal)
desktopPnpm.runPlugin(['update'], invokingDir, signal)
desktopPnpm.runPlugin(['install', '--no-frozen-lockfile'], invokingDir, signal)

Arguments are passed as argv. Do not concatenate shell strings or depend on Windows .cmd shims. The service settles only after the whole subprocess tree exits and terminates active operations during generation disposal.

APIs not to depend on

desktopRuntime, desktopPnpmBootstrap, Electron BrowserWindow, the tray registry, private Node helpers, ELECTRON_RUN_AS_NODE, and generated shims are Desktop internals. Their presence in declarations or runtime context does not make them third-party compatibility contracts.

Testing and release checks

At minimum, a plugin should cover:

  • Loading under ordinary DSH without Desktop services, or staying pending by design.
  • Matching the profile name and directory reported by Desktop to the user's actual selection.
  • Cancellation, non-zero exit, spawn failure, and generation teardown for package operations.
  • Restarting after a plugin change and seeing the bundle in the next Loader composition.

Read the architecture next, then use the package-level service contract as the API reference.