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 insidepackages/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 installed1.0.0fixture and a2.0.0bundle innode_modules - THEN the OTel exporter records spans in the order
loadState,resolveTarget,preflight,hashTree,planFrom,planTo,diff,materialise,applyOps,runMigrations,commitState, all parented to oneinstaller.upgradespan carryingplugin.id,plugin.version.from,plugin.version.to, anddry_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.upgradeis invoked twice in succession against the same fixture plugin with no--forceflag - THEN the first invocation MUST commit and write the state file, and
the second invocation MUST throw
InstallerErrorwith codeINSTALLER.UPGRADE_SAME_VERSIONand 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.mdat hashH1 - WHEN the file on disk hashes to
H2andHost.upgraderuns without--allow-dirtyor--force - THEN an
InstallerErrorwith codeINSTALLER.UPGRADE_DIRTY_TREEis thrown whosecontext.pathsincludesskills/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.upgraderuns in acwdwith no lockfile of any supported format - THEN an
InstallerErrorwith codeINSTALLER.UPGRADE_LOCKFILE_MISSINGis 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
renameofa-renamed.txtis injected to throw EIO - WHEN
Host.upgraderuns against an installed1.0.0tree containinga.txt - THEN
a.txtexists at its original content after the throw anda-renamed.txtdoes not exist, and the throw surfaces asInstallerErrorwith codeINSTALLER.APPLY_FAILED(orINSTALLER.ROLLBACK_FAILEDif the restore also fails) carrying the EIO ascause
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.0and1.1.0->2.0.0 - WHEN
Host.upgraderuns from1.0.0to2.0.0 - THEN both scripts MUST run, the
1.0.0->1.1.0script MUST run first, both MUST receive anfsrooted at the same staged tmp directory, and both MUST observe the sameAbortSignalinstance
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.0migration throws on invocation - WHEN
Host.upgraderuns from1.0.0to2.0.0 - THEN the throw surfaces as
InstallerErrorwith codeINSTALLER.UPGRADE_MIGRATION_FAILED, the live tree is bit-identical to its pre-upgrade content, and the state file still records version1.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.upgradeaborts mid-apply (any failure) - THEN the file at
.suaveplan/installer-state.jsonis 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_INSTALLEDequals"INSTALLER.UPGRADE_NOT_INSTALLED", every other upgrade code is present with the sameINSTALLER.prefix, andObject.isFrozen(INSTALLER_CODES)returnstrue
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.0andnode_modulescontaining only1.0.0 - WHEN
Host.upgrade(plugin, { to: "9.9.9" })runs - THEN an
InstallerErrorwith codeINSTALLER.UPGRADE_VERSION_NOT_FOUNDMUST be thrown and thecontext.requestedfield 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(nopluginsentry for the requested id) - WHEN
Host.upgrade(plugin)runs - THEN an
InstallerErrorwith codeINSTALLER.UPGRADE_NOT_INSTALLEDMUST be thrown beforeresolveTargetVersionis 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 recordingHost.upgradestub - 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"