# Capability Spec Delta: installer (upgrade flow) **Package**: `@suaveplan/installer` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. > Test files are planned paths inside `packages/tooling/installer/`. ## ADDED Requirements ### Requirement: Upgrade lifecycle order `Host.upgrade(plugin, options)` SHALL execute upgrade stages in the strict order `loadState → resolveTarget → preflight → hashTree → planFrom → planTo → diff → materialise → applyOps → runMigrations → commitState`. Optional stages (`detect`, `prompts`, `postInstall` from the install lifecycle) SHALL still be honoured before `loadState` and after `commitState` respectively, preserving plugin composability. Each stage SHALL be wrapped in an OpenTelemetry span named `installer.upgrade.` whose parent span `installer.upgrade` carries attributes `plugin.id`, `plugin.version.from`, `plugin.version.to`, and `dry_run`. #### Scenario: pipeline executes in the documented order - **WHEN** `Host.upgrade(fixturePlugin, { from: "1.0.0", to: "2.0.0" })` runs against an installed `1.0.0` fixture and a `2.0.0` bundle in `node_modules` - **THEN** the OTel exporter records spans in the order `loadState`, `resolveTarget`, `preflight`, `hashTree`, `planFrom`, `planTo`, `diff`, `materialise`, `applyOps`, `runMigrations`, `commitState`, all parented to one `installer.upgrade` span carrying `plugin.id`, `plugin.version.from`, `plugin.version.to`, and `dry_run`. **Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"runs detect → resolve → diff → migrate → apply → postInstall in order"` --- ### Requirement: Same-version upgrade is idempotent `Host.upgrade` SHALL short-circuit when the installed version recorded in the state file equals the target version resolved from `node_modules`, and surface `InstallerError(INSTALLER.UPGRADE_SAME_VERSION)`. Callers MAY treat this code as a success indicator. Re-running the same upgrade after a successful commit SHALL therefore always exit via this short-circuit. The short-circuit SHALL NOT fire when `--force` was supplied. #### Scenario: re-running upgrade after success exits with UPGRADE_SAME_VERSION - **WHEN** `Host.upgrade` is invoked twice in succession against the same fixture plugin with no `--force` flag - **THEN** the first invocation MUST commit and write the state file, and the second invocation MUST throw `InstallerError` with code `INSTALLER.UPGRADE_SAME_VERSION` and MUST NOT stage any file **Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"exits with UPGRADE_SAME_VERSION when installed equals target"` --- ### Requirement: Dirty-tree pre-flight guard Before staging any file op, `Host.upgrade` SHALL hash every path recorded in the state file's `fileHashes` and compare to the recorded value. Any mismatched hash SHALL surface as `InstallerError(INSTALLER.UPGRADE_DIRTY_TREE, { context: { paths: string[] } })` unless `--allow-dirty` or `--force` was supplied. The error context SHALL enumerate every divergent path so the operator can reconcile manually. #### Scenario: divergent on-disk hash blocks the upgrade - **GIVEN** a state file recording `skills/crypto/SKILL.md` at hash `H1` - **WHEN** the file on disk hashes to `H2` and `Host.upgrade` runs without `--allow-dirty` or `--force` - **THEN** an `InstallerError` with code `INSTALLER.UPGRADE_DIRTY_TREE` is thrown whose `context.paths` includes `skills/crypto/SKILL.md` **Verified by:** `src/host/upgrade/dirty-tree.spec-conformance.test.ts::"throws UPGRADE_DIRTY_TREE when state-file hash diverges from disk"` --- ### Requirement: Lockfile presence is required `Host.upgrade` SHALL refuse to run unless at least one of `bun.lockb`, `bun.lock`, `package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock` is present in `cwd`. Missing lockfile SHALL surface as `InstallerError(INSTALLER.UPGRADE_LOCKFILE_MISSING)`. This gate exists to prevent upgrades against a tree whose dependency graph is unpinned, where the resolved target version could shift between runs. #### Scenario: missing lockfile blocks the upgrade - **WHEN** `Host.upgrade` runs in a `cwd` with no lockfile of any supported format - **THEN** an `InstallerError` with code `INSTALLER.UPGRADE_LOCKFILE_MISSING` is thrown before any file is hashed **Verified by:** `src/host/upgrade/preflight.spec-conformance.test.ts::"throws UPGRADE_LOCKFILE_MISSING when no bun.lockb / package-lock.json / pnpm-lock.yaml exists"` --- ### Requirement: Transactional diff application with rollback `Host.upgrade` SHALL materialise the computed diff (`added`, `removed`, `changed`) into the existing `FileOp` union and feed it through `Host.applyOps`, reusing the staged-tmp + atomic-rename transaction described in the canonical `INSTALLER-04` requirement. Removed files SHALL be moved aside into `/.deleted/` rather than unlinked, so rollback can restore them. Any failure during staging or commit SHALL trigger a reverse-order rollback that unlinks staged files and renames the moved-aside files back into place. Rollback failures SHALL surface as `InstallerError(INSTALLER.ROLLBACK_FAILED, { cause })`. #### Scenario: rename failure rolls the tree back - **GIVEN** a fixture plugin whose final `rename` of `a-renamed.txt` is injected to throw EIO - **WHEN** `Host.upgrade` runs against an installed `1.0.0` tree containing `a.txt` - **THEN** `a.txt` exists at its original content after the throw and `a-renamed.txt` does not exist, and the throw surfaces as `InstallerError` with code `INSTALLER.APPLY_FAILED` (or `INSTALLER.ROLLBACK_FAILED` if the restore also fails) carrying the EIO as `cause` **Verified by:** `src/host/upgrade/apply-diff.spec-conformance.test.ts::"rolls back snapshot on staged-rename failure"` --- ### Requirement: Migration scripts run in semver order `Host.upgrade` SHALL, when a plugin ships an `upgrade.ts` exporting a `MigrationTable`, load the table, select the shortest contiguous chain of edges from `from` to `to`, and execute each `MigrationScript` in order inside the same transaction as the diff apply. Each script SHALL receive a `MigrationContext` carrying the same `fs` (rooted at the staged tmp dir), the same `AbortSignal`, the host `logger`, and the `from`/`to` semver strings for the current edge. #### Scenario: chained migrations execute from-to-target in order - **GIVEN** a plugin with edges `1.0.0->1.1.0` and `1.1.0->2.0.0` - **WHEN** `Host.upgrade` runs from `1.0.0` to `2.0.0` - **THEN** both scripts MUST run, the `1.0.0->1.1.0` script MUST run first, both MUST receive an `fs` rooted at the same staged tmp directory, and both MUST observe the same `AbortSignal` instance **Verified by:** `src/host/upgrade/migrations.spec-conformance.test.ts::"runs upgrade.ts scripts in semver order between from and to"` --- ### Requirement: Migration failure rolls back the transaction A `MigrationScript` throw SHALL roll back every staged op (including any ops emitted by earlier migration scripts in the same chain) and SHALL surface as `InstallerError(INSTALLER.UPGRADE_MIGRATION_FAILED, { cause })`. The state file SHALL NOT be written when a migration throws. Re-running the upgrade after a migration failure SHALL therefore see the original installed version in the state file and start the chain from the beginning. #### Scenario: throwing migration leaves tree at pre-upgrade state - **GIVEN** a plugin whose `1.0.0->2.0.0` migration throws on invocation - **WHEN** `Host.upgrade` runs from `1.0.0` to `2.0.0` - **THEN** the throw surfaces as `InstallerError` with code `INSTALLER.UPGRADE_MIGRATION_FAILED`, the live tree is bit-identical to its pre-upgrade content, and the state file still records version `1.0.0` **Verified by:** `src/host/upgrade/migrations.spec-conformance.test.ts::"rolls back transaction and surfaces UPGRADE_MIGRATION_FAILED on script throw"` --- ### Requirement: State file is written atomically on commit only `Host.upgrade` SHALL persist the new `installer-state.json` entry only after every diff op and migration script has committed successfully. The write SHALL use the write-fsync-rename pattern: serialise to `/.suaveplan/installer-state.json.tmp-`, `fsync` the descriptor, then `rename` over the live path. Reads SHALL Zod-validate the loaded state; validation failures SHALL surface as `InstallerError(INSTALLER.UPGRADE_STATE_INVALID, { cause })`. #### Scenario: state file appears only after successful commit - **WHEN** `Host.upgrade` aborts mid-apply (any failure) - **THEN** the file at `.suaveplan/installer-state.json` is unchanged from its pre-upgrade content (either still recording the old version or still absent), and no `.tmp-*` sibling remains on disk **Verified by:** `src/state/state-file.spec-conformance.test.ts::"atomically writes installer-state.json only on successful commit"` --- ### Requirement: Upgrade error codes are namespaced and frozen The `INSTALLER_CODES` registry SHALL include `UPGRADE_NOT_INSTALLED`, `UPGRADE_VERSION_NOT_FOUND`, `UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`, `UPGRADE_STATE_INVALID`, `UPGRADE_MIGRATION_FAILED`, and `UPGRADE_LOCKFILE_MISSING` in addition to the existing nine codes. The registry SHALL remain frozen (`Object.isFrozen(INSTALLER_CODES) === true`). Every new value SHALL start with `INSTALLER.`. `InstallerError` SHALL remain the only error class thrown by the upgrade path. #### Scenario: registry contains the upgrade codes and stays frozen - **WHEN** the registry is inspected at module-load time - **THEN** `INSTALLER_CODES.UPGRADE_NOT_INSTALLED` equals `"INSTALLER.UPGRADE_NOT_INSTALLED"`, every other upgrade code is present with the same `INSTALLER.` prefix, and `Object.isFrozen(INSTALLER_CODES)` returns `true` **Verified by:** `src/error-codes.spec-conformance.test.ts::"upgrade codes are frozen and namespaced INSTALLER.*"` --- ### Requirement: Missing target version surfaces a typed error `Host.upgrade` SHALL surface `InstallerError(INSTALLER.UPGRADE_VERSION_NOT_FOUND, { context: { pluginId, requested } })` when the requested target version (whether supplied via `--to` or resolved from `node_modules`) does not exist. The error SHALL be thrown before any pre-flight check runs so callers can distinguish "plugin not installed at target" from "plugin tree dirty". #### Scenario: requesting an unresolvable target version fails early - **GIVEN** an installed plugin at `1.0.0` and `node_modules` containing only `1.0.0` - **WHEN** `Host.upgrade(plugin, { to: "9.9.9" })` runs - **THEN** an `InstallerError` with code `INSTALLER.UPGRADE_VERSION_NOT_FOUND` MUST be thrown and the `context.requested` field MUST equal `"9.9.9"` **Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_VERSION_NOT_FOUND when plugin not in node_modules"` --- ### Requirement: Upgrading an uninstalled plugin surfaces a typed error `Host.upgrade` SHALL surface `InstallerError(INSTALLER.UPGRADE_NOT_INSTALLED, { context: { pluginId } })` when no entry for the requested `pluginId` exists in the state file. The error SHALL be thrown before any version resolution runs so the operator gets the most specific guidance ("run `add` first") without network or filesystem cost. #### Scenario: upgrade against empty state file fails fast - **GIVEN** an empty `.suaveplan/installer-state.json` (no `plugins` entry for the requested id) - **WHEN** `Host.upgrade(plugin)` runs - **THEN** an `InstallerError` with code `INSTALLER.UPGRADE_NOT_INSTALLED` MUST be thrown before `resolveTargetVersion` is called **Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_NOT_INSTALLED when no state-file entry exists"` --- ### Requirement: CLI verb forwards every upgrade flag `suaveplan-install upgrade ` SHALL parse and forward the flags `--dry-run`, `--force`, `--no-interactive`, `--answers `, `--from `, `--to `, and `--allow-dirty` to `Host.upgrade`. The verb SHALL share the same `InstallerContext` construction path as `add`, so the underlying `fs`, `logger`, `cli`, `templates`, and `signal` are identical. `--dry-run` SHALL print the computed diff (counts of added/removed/changed plus the migration chain) and exit with code 0 before any staging occurs. #### Scenario: each upgrade flag reaches Host.upgrade - **WHEN** `main(["upgrade", "agent-skills", "--dry-run", "--force", "--allow-dirty", "--from", "1.0.0", "--to", "2.0.0", "--answers", "a.json"])` runs with a recording `Host.upgrade` stub - **THEN** the stub records one call whose options object contains `{ dryRun: true, force: true, allowDirty: true, from: "1.0.0", to: "2.0.0", answers: }` **Verified by:** `src/bin/suaveplan-install.spec-conformance.test.ts::"upgrade verb forwards --dry-run, --force, --allow-dirty, --from, --to to Host.upgrade"`