@dsh-pm/installer

August 13, 2026 · View on GitHub

Install, update, remove, and list dsh plugins — a thin, honest layer over the official dsh plugin flow.

@dsh-pm/installer is the mutation core of dsh pm, the plugin manager DSH should have shipped with (dsh-plugin-manager). It never hand-edits a profile's cordis.yml: every install, update, and removal drives the documented dsh plugin --profile <p> add|remove mechanism through one injected run seam, and records its own side-band state in a StateStore (~/.dsh/pm/plugins.json) so pm can list, update-check, and audit what it installed. If the official flow changes, we change one adapter — not four packages.

you ──▶ installer ──run(argv)──▶ dsh plugin --profile <p> add|remove   (the documented flow)
            │                                                              ▲ never edits cordis.yml
            └──store.upsert/remove──▶ ~/.dsh/pm/plugins.json   (side-band state, atomic writes)
  • Depends on zero DSH runtime packages — all @dsh-pm/core imports are type-only.
  • All four methods are unit-tested against a fake run, a fake registry, and a temp state file — the installer never shells out on its own.
  • Idempotent by construction: re-installing an already-present plugin is a no-op update, never a duplicate row; a failed profile install rolls back its own partial state row.

Install

# from the dsh-plugin-manager monorepo (Session 3 package)
pnpm --filter @dsh-pm/installer add @dsh-pm/core@workspace:*

Monorepo context

This repository is the standalone mirror of packages/installer in the dsh-plugin-manager monorepo. Inside the monorepo, @dsh-pm/core is a workspace:* dependency (Session 1's frozen contract package); this standalone repo instead ships the vendored frozen contract (src/core-contract.d.ts), so it builds, typechecks, and tests with only typescript + @types/node. Runtime behavior is identical: every @dsh-pm/core import is type-only, so the built lib never requires it.

Standalone repo: https://github.com/Jesse-njx/dsh-plugin-manager-installer. The package is consumed by the cli package (dsh pm …); end users install plugins with dsh pm install <name>, not by touching this library directly.

API

Exported surface is exactly the frozen spec §5.3 contract:

import type { InstalledPlugin, ProfileTarget, RegistryClient, StateStore } from '@dsh-pm/core'

export interface UpdateReport {
  name: string
  status: 'updated' | 'up-to-date' | 'error'
  from?: string; to?: string; error?: string
}

export interface Installer {
  install(name: string, target?: ProfileTarget): Promise<InstalledPlugin[]>
  uninstall(name: string): Promise<void>
  update(name?: string): Promise<UpdateReport[]>
  list(): Promise<InstalledPlugin[]>
}

// dependency-injected so installer never constructs a registry or store itself:
export function createInstaller(deps: {
  registry: RegistryClient
  store: StateStore
  config: { profiles: string[]; gitInstall: { depth: number; build: boolean } }
  run: (argv: string[]) => Promise<{ code: number; stdout: string; stderr: string }>
}): Installer

InstallerDeps is also exported for convenience. All errors are PmError-shaped (code + detail; codes: SOURCE_UNRESOLVED, INSTALL_FAILED, …) — never raw strings.

Semantics

install(name, target?)

  1. Resolve the source (spec §3.3): @scope/pkg → npm · owner/repo / github:owner/repo → GitHub · anything else resolves through the injected RegistryClient, picking the entry's npmName ?? repoUrl. Unresolvable → SOURCE_UNRESOLVED.
  2. Per profile in target?.profiles (default config.profiles, deduped): run dsh plugin --profile <p> add <ref> where <ref> is the npm name or github:owner/repo (DSH handles clone/build). gitInstall (--depth=<n>, --build/--no-build) is passed through for GitHub installs where the CLI supports it; npm installs get no git flags.
  3. Record one InstalledPlugin row per (plugin, profile): enabled: true, installedAt now, lastChecked: null, version = the registry's latestVersion at install time ('unknown' when the registry cannot tell).

Idempotency: re-installing the same (name, profile) with the same ref and version is a no-op — the add is skipped, the row is untouched, and no duplicate is ever written (upsert keys on (name, profile)). A reinstall of an older version is an update: add runs again and the row's version/installedAt refresh. Rollback: a failed add writes nothing for that profile; a prior row survives a failed reinstall untouched.

uninstall(name)

For every profile the state store says the plugin is installed into, run dsh plugin --profile <p> remove <name>, then delete the row. Unknown names are a no-op. A failed remove throws INSTALL_FAILED and keeps the row — the store is the source of truth for what pm installed.

update(name?)

For each installed plugin (or just name): resolve latestVersion via the registry (npm dist-tag / GitHub default-branch HEAD), compare with the installed version (semver numerically; anything else — git HEAD shas — by inequality), and re-run install into the same profiles when newer. update() with no argument updates everything. Always stamps lastChecked, whether or not an update fires, and returns one UpdateReport per plugin — never throws for a per-plugin failure.

list()

Reads the state store and returns rows grouped per profile (sorted by profile, then name), including the enabled state.

Design notes

  • The run seam is the only exit to the machine. It is a function, not a hard-coded execa call, so unit tests pass a fake and installer carries zero DSH runtime dependencies — a change in the official dsh plugin CLI is one adapter, not a rewrite.
  • Files, not databases. State is a plain, human-auditable, rm-able JSON file at ~/.dsh/pm/plugins.json, written atomically (write-temp + rename) and validated against the core state schema. StateStore is an interface in @dsh-pm/core, so this choice is swappable later without touching consumers.
  • The store is not the whole truth. It records what pm installed; a plugin a user added by hand is surfaced by dsh pm doctor as unmanaged — never clobbered here.
  • Temporary ambient contract. src/core-contract.d.ts mirrors the frozen @dsh-pm/core §5.1 shapes so this package typechecks before Session 1 builds core's lib/. It becomes dead the moment core's real types resolve — delete it then.

Development

pnpm --filter @dsh-pm/installer test        # node --test, 42 unit tests, no shelling
pnpm --filter @dsh-pm/installer typecheck   # tsc --noEmit (src + test)
pnpm --filter @dsh-pm/installer build       # tsc → lib/index.js + lib/types/index.d.ts

Known limitations (v0.1)

  • remove targets the plugin name (the state row has no cordis entry id), matching how InstalledPlugin is frozen; if the official remove needs the entry id, that is a core-contract change.
  • gitInstall flags assume the installed DSH supports them; an adapter that does not know them should strip them in its run implementation.
  • No dependency-graph resolution between plugins, no rollback history beyond re-install, no auto-update daemon — doctor reports drift, the user runs update.