Files
suaveplan/openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/proposal.md
2026-05-27 23:29:53 +01:00

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 from node_modules), --allow-dirty (skip dirty-tree guard — destructive, opt-in only).
  • Host.upgrade(plugin, options) public entry point alongside Host.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 MigrationContext and MigrationScript contract under @suaveplan/installer/migrations for 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 with INSTALLER.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 existing runPlugin harness gains an upgradePlugin sibling so its conformance tests cover both flows. The plugin itself receives a tiny optional upgrade.ts exporting 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. upgrade only moves forward. A separate add-installer-downgrade change can extend the diff engine in reverse; the migration contract is intentionally directional (from/to versions) so downgrade is a different scope.
  • Cross-plugin coordination. Upgrading install-agent-skills does not trigger an upgrade of a hypothetical install-web-server. Each plugin is upgraded independently; peerInstallers field 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-01 Lifecycle order → src/host/upgrade/upgrade.spec-conformance.test.ts::"runs detect → resolve → diff → migrate → apply → postInstall in order"
  • INSTALLER-UPGRADE-02 Idempotent same-version no-op → src/host/upgrade/upgrade.spec-conformance.test.ts::"exits with UPGRADE_SAME_VERSION when installed equals target"
  • INSTALLER-UPGRADE-03 Dirty-tree guard → src/host/upgrade/dirty-tree.spec-conformance.test.ts::"throws UPGRADE_DIRTY_TREE when state-file hash diverges from disk"
  • INSTALLER-UPGRADE-04 Lockfile 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-05 Transactional diff application → src/host/upgrade/apply-diff.spec-conformance.test.ts::"rolls back snapshot on staged-rename failure"
  • INSTALLER-UPGRADE-06 Migration script contract → src/host/upgrade/migrations.spec-conformance.test.ts::"runs upgrade.ts scripts in semver order between from and to"
  • INSTALLER-UPGRADE-07 Migration failure rollback → src/host/upgrade/migrations.spec-conformance.test.ts::"rolls back transaction and surfaces UPGRADE_MIGRATION_FAILED on script throw"
  • INSTALLER-UPGRADE-08 State-file integrity → src/state/state-file.spec-conformance.test.ts::"atomically writes installer-state.json only on successful commit"
  • INSTALLER-UPGRADE-09 Error-code registry namespacing → src/error-codes.spec-conformance.test.ts::"upgrade codes are frozen and namespaced INSTALLER.*"
  • INSTALLER-UPGRADE-10 Version-not-found surface → src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_VERSION_NOT_FOUND when plugin not in node_modules"
  • INSTALLER-UPGRADE-11 Not-installed surface → src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_NOT_INSTALLED when no state-file entry exists"
  • INSTALLER-UPGRADE-12 CLI 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/installer green.
  • bunx turbo test:coverage --filter=@suaveplan/installer 100% istanbul on every new module under src/host/upgrade/, src/state/, and the upgrade-specific additions to src/bin/suaveplan-install.ts.
  • Sibling .md for every new implementation .ts per openspec/AGENTS.md §4.
  • openspec validate add-installer-upgrade --strict green.
  • Spec delta folded into openspec/specs/installer/spec.md on archive.