# OpenSpec Change: complete-installer - **Change ID**: complete-installer - **Package**: `@suaveplan/installer` - **Status**: PROPOSED - **Date**: 2026-05-17 - **Design**: ./design.md - **Spec delta**: ./specs/installer/spec.md - **Supersedes**: `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/` (rolled into this change as the upgrade sub-surface). ## Why The ported installer (archive `2026-04-28-installer-port`) shipped only `add` and `list`. A narrow upgrade follow-up was drafted but then superseded (`_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/`) precisely because the package's identity demands a complete package-manager-style surface. Today `@suaveplan/installer` cannot: - bring an existing install up to a newer version (`upgrade`, `update --all`), - remove an install cleanly (`uninstall`), - re-do an install deterministically (`reinstall`), - pin / unpin / freeze versions for reproducible CI, - enumerate or inspect installed plugins beyond a flat `list`, - detect drift, broken symlinks, dependency cycles, or registry connectivity problems (`doctor`), - search a registry for available plugins (`search`), - run plugins programmatically through a typed library API (only the CLI bin is exposed), - coordinate between plugins that depend on one another (no dependency resolution between plugins), - run plugin-authored migration scripts across version boundaries, - pull from anything other than a hand-rolled `node_modules` walk (no pluggable registry — Verdaccio / OCI / git / local-tarball). Every one of those gaps is core to what the word "installer" means. Genesis ships enterprise-grade packages, not minimum-viable scaffolds (CLAUDE.md §3, AGENTS.md §2b). This change closes the whole gap in one OpenSpec change so the package stops drifting between narrow follow-ups. ## What Changes A single proposal landing every command the package's identity implies plus the supporting subsystems. **No deferrals beyond the explicit "Out of scope" list below.** ### CLI surface (every verb ships in this change) | Verb | Purpose | |---|---| | `install ` | Resolve, plan, transactionally apply, persist state. Replaces `add` as the primary verb; `add` remains as an alias to preserve the v0.0 contract. | | `reinstall ` | Force-rerun a clean install against the recorded target version, restoring drift to canonical state. | | `upgrade ` | Diff installed → target, run migration chain, transactionally apply, atomically persist new state. | | `uninstall ` | Reverse the install: delete every file recorded in the state entry, run plugin `preUninstall`/`postUninstall`, drop the state entry. | | `list` | Enumerate installed plugins with version, install date, target dir, drift status. | | `info ` | Print detailed manifest + state entry + dependency graph for a single plugin. | | `doctor` | Health check: stale state, broken symlinks, version mismatches, registry connectivity, dependency cycles, unpinned deps, missing lockfile. | | `pin ` | Record a pinned version in the lockfile; refuse upgrades past it. | | `unpin ` | Remove a pin. | | `freeze` | Snapshot every installed plugin's resolved version into `.suaveplan/installer-lock.json`. | | `update --all` | Bulk upgrade every installed plugin to its current resolved target (respecting pins). | | `search ` | Query the active registry source for matching plugin packages. | Every verb shares the global flags `--dry-run`, `--force`, `--no-interactive`, `--answers `, `--cwd `, `--registry `, `--json`. Verb-specific flags are enumerated in `design.md`. ### Library API `Installer` class exposes the same operations programmatically so other tools (e.g. `create-suaveplan`, in-process bootstrap scripts, the `bin`) call one surface: - `installer.install(pluginId, opts)` - `installer.reinstall(pluginId, opts)` - `installer.upgrade(pluginId, opts)` - `installer.uninstall(pluginId, opts)` - `installer.list()` - `installer.info(pluginId)` - `installer.doctor()` - `installer.pin(pluginId, version)` - `installer.unpin(pluginId)` - `installer.freeze()` - `installer.update(opts)` - `installer.search(query)` The existing `Host` class becomes the per-plugin orchestration seam beneath `Installer`; the CLI bin becomes a thin shell that constructs an `InstallerContext` and forwards every flag to the library method. ### Plugin host capabilities - Lifecycle hooks (every hook optional, every hook async, every hook receives `InstallerContext`): `preInstall`, `postInstall`, `preUpgrade`, `postUpgrade`, `preUninstall`, `postUninstall`. - Capability declaration in the manifest (`capabilities: string[]`) so plugins advertise what they provide and `info` / `doctor` can reason about them. - Inter-plugin dependency resolution: a plugin manifest MAY declare `dependsOn: Record`. The installer resolves the topological order before any apply step and refuses cyclic graphs with `INSTALLER.DEPENDENCY_CYCLE`. - Version constraints honoured: a plugin declaring `peerInstallers` or `dependsOn` with a semver range MUST find a satisfying installed version, or the run fails with `INSTALLER.DEPENDENCY_UNSATISFIED`. ### Manifest / state / lockfile - `installer-state.json` — per-plugin state entry with `pluginId`, `version`, `installedAt`, `targetDir`, `fileHashes` (BLAKE3 via `@suaveplan/crypto-hash`). - `installer-lock.json` — deterministic JSON lockfile with sorted keys, resolved versions, integrity digests, registry source per entry, populated by `install`, `upgrade`, `freeze`. - Integrity hashing: every file written records its BLAKE3 digest in the state entry; drift detection compares on-disk hash vs recorded hash. ### Migration scripts Plugins MAY ship `upgrade.ts` exporting a `MigrationTable` keyed `"->"`. The runner selects the shortest contiguous chain, runs each script inside the same staged-tmp transaction as the diff apply, and rolls back on throw. Scripts execute in a sandbox whose `fs` is rooted at the staged tmp directory; outside-tmp filesystem writes are prevented by the `MutableInstallerFs` boundary. ### Registry sources Pluggable `RegistrySource` interface with four built-in implementations: | Source | Use | |---|---| | `verdaccio` (default) | Resolve via `node_modules` after `bun add`; matches existing behaviour. | | `oci` | Pull plugin tarball from an OCI artifact registry (`ghcr.io`, `Harbor`). | | `git` | Clone a git ref and treat the checkout as the plugin package. | | `local-tarball` | Install from a local `.tgz` path — air-gapped use. | Auth via `NPM_TOKEN` (Verdaccio) and `OCI_TOKEN` (OCI); each source implements its own `auth()` hook. ### Atomicity + rollback - Snapshot-before-mutate via move-aside journal (cf. archived design.md §"Atomicity model"). - Transactional apply: staged-tmp + atomic rename per file (existing `INSTALLER-04` model, extended to `uninstall` and `upgrade`). - Automatic rollback on any apply / migration / state-write failure. - Journaled state for crash recovery: `installer-journal.log` records txn-start / txn-commit / txn-abort lines fsync'd between phases so `doctor` can detect interrupted runs and offer recovery. ### Diagnostics `doctor` runs every check below in parallel, returns a `DoctorReport` listing failures with severity + remediation hint: - stale state entries (plugin uninstalled out-of-band), - broken symlinks under any recorded `targetDir`, - version mismatch between state and `node_modules`, - registry connectivity (ping per configured registry), - dependency cycles in the installed plugin graph, - missing or stale lockfile, - interrupted transactions (journal mid-state). ### Telemetry Every public library method opens an OTel span named `installer.` carrying `plugin.id`, `plugin.version`, `dry_run`, and (where relevant) `plugin.version.from` / `plugin.version.to`. Every state transition emits a typed event on the injected `EventBusLike`: `installer.installed`, `installer.upgraded`, `installer.reinstalled`, `installer.uninstalled`, `installer.pinned`, `installer.unpinned`, `installer.frozen`, `installer.doctor.completed`. ### Error codes Full `INSTALLER.*` registry via `defineCodes`, extending the existing nine codes (preserved) with one code per failure mode introduced by the new surface. Enumeration lives in `design.md` §"Error codes"; the registry remains frozen and namespaced `INSTALLER.*`. ### Tests 100% istanbul coverage gated by `bunx turbo test:coverage`. Real services only (CLAUDE.md rule 2): a testcontainers Verdaccio fixture lives under `src/testing/fixtures/registry/` and powers every install / upgrade / search test. Fixture plugins under `src/testing/fixtures/plugins/` exercise every dependency / migration / lifecycle-hook branch. ## Source mapping | Source / reference | What it tells us | |---|---| | `openspec/archive/2026-04-28-installer-port/proposal.md` | Original port: lifecycle, conflict policies, manifest, `INSTALLER_CODES`, testing harness — all reused. | | `openspec/archive/2026-04-28-installer-port/specs/installer/spec.md` (folded as `openspec/specs/installer/spec.md`) | Canonical `INSTALLER-01`…`INSTALLER-09` requirements: preserved verbatim and extended, never replaced. | | `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/proposal.md` | Upgrade flow design (state file, dirty-tree gate, lockfile preflight, migration scripts). Rolled into this proposal §"What Changes". | | `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/design.md` | Atomicity model (move-aside journal, BLAKE3 hashing, snapshot strategy). Reused as the foundation for `upgrade`, `uninstall`, and `reinstall`. | | `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/specs/installer/spec.md` | Upgrade requirements (`INSTALLER-UPGRADE-01`…`12`). Carried into this change's delta and supplemented with `install`/`uninstall`/`reinstall`/`doctor`/`pin`/`unpin`/`freeze`/`update`/`search`/`info` requirements. | | `packages/tooling/installer/src/bin/suaveplan-install.ts` | Existing CLI shape (`app.command("add ")…`). New verbs attach the same way; `runAdd` becomes `runInstall` with `add` aliased. | | `packages/tooling/installer/src/host/host.ts` | `Host` orchestration class — kept as the per-plugin seam; wrapped by the new `Installer` class for the multi-plugin operations. | | `packages/tooling/installer/src/host/apply/apply.ts` | Staged-tmp + atomic-rename transaction. Reused unchanged by `upgrade`, `uninstall`, `reinstall`. | | `packages/tooling/installer/src/manifest/discover.ts` | `discoverPlugins` + `findPlugin` — extended to consult the active `RegistrySource` rather than only `node_modules`. | | `packages/tooling/installer/src/error-codes.ts` | Existing frozen registry — appended to, never replaced. | | `packages/tooling/installer/src/fs/installer-fs.ts` | `MutableInstallerFs` boundary — reused as the sandbox surface for migration scripts. | | `packages/tooling/install-agent-skills/src/plugin.ts` | Sibling plugin: gains `preInstall`/`postUninstall` hooks and an empty `upgrade.ts` table so every new lifecycle / migration code path is exercised by a real consumer. | (No `/mnt/s/web/suaveplan/enterprise/installer.md` exists on main — the installer is genesis-original. No `/mnt/s/web/suaveplan/packages/installer/` exists. Authoritative design intent lives in the archived port + the superseded upgrade change, both cited above.) ## Impact ### Affected dependents Run `bunx turbo ls --filter=...@suaveplan/installer` to enumerate. Current dependents: - `@suaveplan/install-agent-skills` — [x] tests run / [x] docs reviewed / [ ] no action needed. The skills plugin gains optional `preInstall`, `postUninstall`, and an empty `upgrade.ts` migration table so every new lifecycle and migration-loader code path is exercised by a real consumer. Its conformance harness extends from `runPlugin` to also cover `upgradePlugin`, `uninstallPlugin`, and `reinstallPlugin`. No other dependents currently consume `@suaveplan/installer`. ## Migration This change introduces consumer-visible behaviour changes for any caller that was using the v0.0 surface directly: - `add` CLI verb is retained as an alias of `install`; existing scripts keep working. - `Host.run` is retained verbatim; `Installer.install` is the new multi-plugin orchestrator that internally calls `Host.run` per plugin. - The state file is new — first `install` against an existing tree silently records hashes; subsequent operations gate on them. - `INSTALLER.*` registry gains codes; consumers asserting on the existing nine codes are unaffected (registry only grows). Search command for downstream callers: ```bash grep -rln "@suaveplan/installer" packages/ | grep -v "/dist/\|/coverage/\|/installer/" ``` Recommended replacement when migrating from `Host.run` directly to the multi-plugin surface: instantiate `new Installer({ ctx })` and call `installer.install(pluginId, opts)` — it handles dependency resolution, state persistence, and lockfile updates that `Host.run` does not. ## Out of scope Only the items genuinely outside installer identity: - **Publishing plugins to a registry.** That is `bun scripts/publish.ts` (the monorepo's publish pipeline); the installer consumes registries, it does not write to them. - **`create-suaveplan` thin wrapper bin** that bundles installer + a curated default plugin set. Packaging concern; future change. - **Background / scheduled auto-upgrade daemon.** Operator-initiated only; schedulers are out of scope (use cron + `update --all`). - **Cross-registry mirroring / proxy.** The installer reads from one active `RegistrySource` per run; mirroring is the registry's concern. - **Downgrade.** The diff engine is directional (`from`/`to` semver); a downgrade requires the reverse migration chain and is a separate proposal once a real consumer needs it. ## Verification Every requirement in `specs/installer/spec.md` carries a `**Verified by:**` line. Conformance tests live under: - `src/installer/installer.spec-conformance.test.ts` — multi-plugin orchestration (`install`, `reinstall`, `uninstall`, `update`, `list`, `info`, `pin`, `unpin`, `freeze`, `search`). - `src/installer/doctor.spec-conformance.test.ts` — every diagnostic check. - `src/host/upgrade/*.spec-conformance.test.ts` — upgrade pipeline, dirty-tree, preflight, diff, migrations. - `src/state/state-file.spec-conformance.test.ts` — atomic write, schema. - `src/state/journal.spec-conformance.test.ts` — crash-recovery journal. - `src/state/lockfile.spec-conformance.test.ts` — deterministic lockfile. - `src/registry/*.spec-conformance.test.ts` — every registry source. - `src/bin/suaveplan-install.spec-conformance.test.ts` — every CLI verb forwards every flag. - `src/error-codes.spec-conformance.test.ts` — full frozen registry. ## Acceptance criteria - `bunx turbo lint typecheck test --filter=@suaveplan/installer` green. - `bunx turbo test:coverage --filter=@suaveplan/installer` 100% istanbul on every module under `src/installer/`, `src/host/upgrade/`, `src/state/`, `src/registry/`, and the additions to `src/bin/`. - Sibling `.md` for every new implementation `.ts` per AGENTS.md §4. - `openspec validate complete-installer --strict` green. - Spec delta folded into `openspec/specs/installer/spec.md` on archive. - `bunx turbo test --filter=...@suaveplan/installer` green (dependent sweep validates `@suaveplan/install-agent-skills` against the new surface).