9.1 KiB
OpenSpec Change: add-installer-upgrade
- Change ID: add-installer-upgrade
- Package:
@suaveplan/installer - Status: PROPOSED
- Date: 2026-05-17
- Design: ./design.md
- Spec delta: ./specs/installer/spec.md
Why
openspec/archive/2026-04-28-installer-port/proposal.md §"Out of scope" deferred
the upgrade flow:
Plugin upgrade flow (
upgrade <plugin>to bring an existing install up to current bundle version) is a follow-up — initial scope is install + reinstall (idempotent re-run with conflict policy).
The installer therefore ships add <plugin> and list but has no answer for a
user who installed @suaveplan/install-agent-skills@0.5.0 six months ago and
wants the changes that landed in 0.7.0. Today their only options are
add agent-skills --force (which overwrites local edits without diffing) or a
manual rm -rf (which destroys their in-flight skill edits). Both are unsafe.
The add path is also unable to run plugin-authored migration scripts, so any
breaking layout change in a plugin bundle (e.g. skills/<id>/SKILL.md →
skills/<id>/<id>.md) silently leaves stale files behind that the user has to
hand-prune.
What Changes
Add a third top-level installer command, suaveplan-install upgrade <plugin>,
that resolves the installed plugin version from the on-disk state file,
resolves the target version from node_modules, computes the file-level
diff between the two bundles (added / removed / changed), and applies it
transactionally with the same staged-tmp + rollback strategy apply already
uses for add. Pre-flight gates protect against in-flight edits (dirty target
tree) and missing lockfiles. Plugins MAY ship an optional upgrade.ts
migration script per from-version → to-version pair; the host invokes them in
order inside the same transaction.
Surface added
suaveplan-install upgrade <plugin>CLI verb with flags--dry-run,--force,--no-interactive,--answers <file>,--from <version>(override detected),--to <version>(target; defaults to whatever resolves fromnode_modules),--allow-dirty(skip dirty-tree guard — destructive, opt-in only).Host.upgrade(plugin, options)public entry point alongsideHost.run.- A persisted state file (
.suaveplan/installer-state.json) capturing{ pluginId, version, installedAt, fileHashes }per installed plugin so diff and dirty-tree detection are deterministic on subsequent upgrades. - A
MigrationContextandMigrationScriptcontract under@suaveplan/installer/migrationsfor plugin-authored scripts. - 7 new
INSTALLER.*codes:UPGRADE_NOT_INSTALLED,UPGRADE_VERSION_NOT_FOUND,UPGRADE_SAME_VERSION,UPGRADE_DIRTY_TREE,UPGRADE_STATE_INVALID,UPGRADE_MIGRATION_FAILED,UPGRADE_LOCKFILE_MISSING.
Behaviour guarantees
- Re-running
upgrade <plugin>when installed == target is a no-op exiting withINSTALLER.UPGRADE_SAME_VERSION(treated as success under--dry-run). - Failure at any point (diff compute, migration script throw, atomic rename error) rolls the target tree back to the pre-upgrade snapshot. Partial state is never persisted.
- The state file is updated atomically (
write → fsync → rename) only after the transaction commits.
Source mapping
| Source / archive reference | What it tells us |
|---|---|
openspec/archive/2026-04-28-installer-port/proposal.md §"Out of scope" bullet 2 |
The deferred upgrade flow is the scope of this change. |
openspec/archive/2026-04-28-installer-port/proposal.md §"Architecture → Lifecycle" |
Reuse the existing detect → prompts → plan → apply → postInstall pipeline; upgrade adds a diff-and-migrate stage between plan and apply. |
openspec/specs/installer/spec.md INSTALLER-04 (transactional apply) |
Upgrade MUST reuse the same staged-tmp + rollback machinery — no parallel transaction model. |
openspec/specs/installer/spec.md INSTALLER-05 (conflict policy) |
Per-file conflict policy already exists; upgrade extends it with a "user-modified-since-install" case detected via the state-file hashes. |
openspec/specs/installer/spec.md INSTALLER-09 (frozen registry) |
New codes MUST extend defineCodes("INSTALLER", [...]) — no parallel registry. |
packages/tooling/installer/src/bin/suaveplan-install.ts |
Existing CLI shape: cli().command("add <plugin>")…. The new verb attaches identically: .command("upgrade <plugin>"). |
packages/tooling/installer/src/host/host.ts |
Host class is the orchestration seam; Host.upgrade slots in alongside Host.run. |
packages/tooling/installer/src/host/apply/apply.ts |
Staged-tmp transaction implementation is reused as-is; upgrade composes it. |
packages/tooling/installer/src/manifest/discover.ts |
Version resolution from node_modules already exists for add; upgrade reuses findPlugin(cwd, pluginId). |
packages/tooling/installer/src/error-codes.ts |
New codes APPEND to the existing tuple; the registry stays frozen. |
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 is the canonical upgrade target; its existingrunPluginharness gains anupgradePluginsibling so its conformance tests cover both flows. The plugin itself receives a tiny optionalupgrade.tsexporting an empty migration table (no breaking layout changes yet); this proves the migration contract loads even when the table is empty.
No other dependents currently consume @suaveplan/installer.
Out of scope
- Downgrade.
upgradeonly moves forward. A separateadd-installer-downgradechange can extend the diff engine in reverse; the migration contract is intentionally directional (from/toversions) so downgrade is a different scope. - Cross-plugin coordination. Upgrading
install-agent-skillsdoes not trigger an upgrade of a hypotheticalinstall-web-server. Each plugin is upgraded independently;peerInstallersfield warnings are a future packaging change. - Background / scheduled upgrade daemon. Upgrade is operator-initiated via the CLI; there is no auto-update behaviour.
- Network resolution. The "target version" is whatever resolves from
node_modules; the installer never talks to npm directly. Bumping the installed package version remains the user's package-manager step.
Verification
Every requirement in specs/installer/spec.md carries a **Verified by:**
line pointing to a conformance test in src/host/upgrade/ or
src/state/. The full set:
INSTALLER-UPGRADE-01Lifecycle order →src/host/upgrade/upgrade.spec-conformance.test.ts::"runs detect → resolve → diff → migrate → apply → postInstall in order"INSTALLER-UPGRADE-02Idempotent same-version no-op →src/host/upgrade/upgrade.spec-conformance.test.ts::"exits with UPGRADE_SAME_VERSION when installed equals target"INSTALLER-UPGRADE-03Dirty-tree guard →src/host/upgrade/dirty-tree.spec-conformance.test.ts::"throws UPGRADE_DIRTY_TREE when state-file hash diverges from disk"INSTALLER-UPGRADE-04Lockfile pre-flight →src/host/upgrade/preflight.spec-conformance.test.ts::"throws UPGRADE_LOCKFILE_MISSING when no bun.lockb / package-lock.json / pnpm-lock.yaml exists"INSTALLER-UPGRADE-05Transactional diff application →src/host/upgrade/apply-diff.spec-conformance.test.ts::"rolls back snapshot on staged-rename failure"INSTALLER-UPGRADE-06Migration script contract →src/host/upgrade/migrations.spec-conformance.test.ts::"runs upgrade.ts scripts in semver order between from and to"INSTALLER-UPGRADE-07Migration failure rollback →src/host/upgrade/migrations.spec-conformance.test.ts::"rolls back transaction and surfaces UPGRADE_MIGRATION_FAILED on script throw"INSTALLER-UPGRADE-08State-file integrity →src/state/state-file.spec-conformance.test.ts::"atomically writes installer-state.json only on successful commit"INSTALLER-UPGRADE-09Error-code registry namespacing →src/error-codes.spec-conformance.test.ts::"upgrade codes are frozen and namespaced INSTALLER.*"INSTALLER-UPGRADE-10Version-not-found surface →src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_VERSION_NOT_FOUND when plugin not in node_modules"INSTALLER-UPGRADE-11Not-installed surface →src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_NOT_INSTALLED when no state-file entry exists"INSTALLER-UPGRADE-12CLI flag wiring →src/bin/suaveplan-install.spec-conformance.test.ts::"upgrade verb forwards --dry-run, --force, --allow-dirty, --from, --to to Host.upgrade"
Acceptance criteria
bunx turbo lint typecheck test --filter=@suaveplan/installergreen.bunx turbo test:coverage --filter=@suaveplan/installer100% istanbul on every new module undersrc/host/upgrade/,src/state/, and the upgrade-specific additions tosrc/bin/suaveplan-install.ts.- Sibling
.mdfor every new implementation.tsperopenspec/AGENTS.md§4. openspec validate add-installer-upgrade --strictgreen.- Spec delta folded into
openspec/specs/installer/spec.mdon archive.