Files
suaveplan/openspec/changes/complete-installer/proposal.md
2026-05-27 23:29:53 +01:00

15 KiB

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 <plugin> Resolve, plan, transactionally apply, persist state. Replaces add as the primary verb; add remains as an alias to preserve the v0.0 contract.
reinstall <plugin> Force-rerun a clean install against the recorded target version, restoring drift to canonical state.
upgrade <plugin> Diff installed → target, run migration chain, transactionally apply, atomically persist new state.
uninstall <plugin> 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 <plugin> 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 <plugin> <version> Record a pinned version in the lockfile; refuse upgrades past it.
unpin <plugin> 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> Query the active registry source for matching plugin packages.

Every verb shares the global flags --dry-run, --force, --no-interactive, --answers <file>, --cwd <dir>, --registry <name>, --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<pluginId, semverRange>. 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 "<from>-><to>". 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.<method> 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 <plugin>")…). 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:

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).