# 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 ` 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 ` 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//SKILL.md` → `skills//.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 `, 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 ` CLI verb with flags `--dry-run`, `--force`, `--no-interactive`, `--answers `, `--from ` (override detected), `--to ` (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 ` 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 ")…`. The new verb attaches identically: `.command("upgrade ")`. | | `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.