Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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"`