Initial Commit
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
Reference in New Issue
Block a user