@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/coreimports 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?)
- Resolve the source (spec §3.3):
@scope/pkg→ npm ·owner/repo/github:owner/repo→ GitHub · anything else resolves through the injectedRegistryClient, picking the entry'snpmName ?? repoUrl. Unresolvable →SOURCE_UNRESOLVED. - Per profile in
target?.profiles(defaultconfig.profiles, deduped): rundsh plugin --profile <p> add <ref>where<ref>is the npm name orgithub: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. - Record one
InstalledPluginrow per (plugin, profile):enabled: true,installedAtnow,lastChecked: null, version = the registry'slatestVersionat 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
runseam is the only exit to the machine. It is a function, not a hard-codedexecacall, so unit tests pass a fake andinstallercarries zero DSH runtime dependencies — a change in the officialdsh pluginCLI 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.StateStoreis 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 doctoras unmanaged — never clobbered here. - Temporary ambient contract.
src/core-contract.d.tsmirrors the frozen@dsh-pm/core§5.1 shapes so this package typechecks before Session 1 builds core'slib/. 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)
removetargets the plugin name (the state row has no cordis entry id), matching howInstalledPluginis frozen; if the officialremoveneeds the entry id, that is a core-contract change.gitInstallflags assume the installed DSH supports them; an adapter that does not know them should strip them in itsrunimplementation.- No dependency-graph resolution between plugins, no rollback history beyond re-install, no auto-update
daemon —
doctorreports drift, the user runsupdate.