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_moduleswalk (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 andinfo/doctorcan 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 withINSTALLER.DEPENDENCY_CYCLE. - Version constraints honoured: a plugin declaring
peerInstallersordependsOnwith a semver range MUST find a satisfying installed version, or the run fails withINSTALLER.DEPENDENCY_UNSATISFIED.
Manifest / state / lockfile
installer-state.json— per-plugin state entry withpluginId,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 byinstall,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-04model, extended touninstallandupgrade). - Automatic rollback on any apply / migration / state-write failure.
- Journaled state for crash recovery:
installer-journal.logrecords txn-start / txn-commit / txn-abort lines fsync'd between phases sodoctorcan 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 optionalpreInstall,postUninstall, and an emptyupgrade.tsmigration table so every new lifecycle and migration-loader code path is exercised by a real consumer. Its conformance harness extends fromrunPluginto also coverupgradePlugin,uninstallPlugin, andreinstallPlugin.
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:
addCLI verb is retained as an alias ofinstall; existing scripts keep working.Host.runis retained verbatim;Installer.installis the new multi-plugin orchestrator that internally callsHost.runper plugin.- The state file is new — first
installagainst 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-suaveplanthin 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
RegistrySourceper run; mirroring is the registry's concern. - Downgrade. The diff engine is directional (
from/tosemver); 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/installergreen.bunx turbo test:coverage --filter=@suaveplan/installer100% istanbul on every module undersrc/installer/,src/host/upgrade/,src/state/,src/registry/, and the additions tosrc/bin/.- Sibling
.mdfor every new implementation.tsper AGENTS.md §4. openspec validate complete-installer --strictgreen.- Spec delta folded into
openspec/specs/installer/spec.mdon archive. bunx turbo test --filter=...@suaveplan/installergreen (dependent sweep validates@suaveplan/install-agent-skillsagainst the new surface).