Files
2026-05-27 23:29:53 +01:00

13 KiB

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.<stage> 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 <tmp>/.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 <cwd>/.suaveplan/installer-state.json.tmp-<txn>, 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 <plugin> SHALL parse and forward the flags --dry-run, --force, --no-interactive, --answers <file>, --from <version>, --to <version>, 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: <parsed JSON> }

Verified by: src/bin/suaveplan-install.spec-conformance.test.ts::"upgrade verb forwards --dry-run, --force, --allow-dirty, --from, --to to Host.upgrade"