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

598 lines
27 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: installer (complete-installer)
**Package**: `@suaveplan/installer`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> Planned test paths live inside `packages/tooling/installer/`.
## MODIFIED Requirements
### Requirement: Error-code registry is namespaced and frozen
`INSTALLER_CODES` MUST be declared via `defineCodes("INSTALLER", [...])` from
`@suaveplan/error`. `Object.isFrozen(INSTALLER_CODES)` MUST be `true`. Every
code value MUST start with `INSTALLER.`. `InstallerError` MUST extend
`SuaveplanError<InstallerErrorCode>`. The registry MUST contain — at minimum
— the original nine codes plus every code introduced by the complete-installer
surface: `PLUGIN_NOT_FOUND`, `PLUGIN_LOAD_FAILED`, `PLUGIN_INVALID_MANIFEST`,
`PROMPT_VALIDATION_FAILED`, `PLAN_FAILED`, `APPLY_CONFLICT`, `APPLY_FAILED`,
`ROLLBACK_FAILED`, `ABORTED`, `UPGRADE_NOT_INSTALLED`,
`UPGRADE_VERSION_NOT_FOUND`, `UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`,
`UPGRADE_STATE_INVALID`, `UPGRADE_MIGRATION_FAILED`,
`UPGRADE_LOCKFILE_MISSING`, `REINSTALL_NOT_INSTALLED`,
`UNINSTALL_NOT_INSTALLED`, `UNINSTALL_DIRTY_TREE`, `INFO_NOT_INSTALLED`,
`PIN_NOT_INSTALLED`, `PIN_NOT_PINNED`, `PIN_VERSION_INVALID`,
`LOCKFILE_WRITE_FAILED`, `LOCKFILE_INVALID`, `UPDATE_PARTIAL_FAILURE`,
`REGISTRY_UNREACHABLE`, `REGISTRY_AUTH_FAILED`, `REGISTRY_UNKNOWN`,
`DEPENDENCY_CYCLE`, `DEPENDENCY_UNSATISFIED`,
`MIGRATION_PATH_UNAVAILABLE`, `JOURNAL_REPLAY_FAILED`, `STATE_INVALID`.
#### Scenario: registry contains every code and remains frozen
- **WHEN** the registry is inspected at module-load time
- **THEN** each listed code is present as `INSTALLER_CODES.<NAME>` whose
string value equals `"INSTALLER.<NAME>"`, and
`Object.isFrozen(INSTALLER_CODES)` returns `true`
**Verified by:** `src/error-codes.spec-conformance.test.ts::"registry contains every complete-installer code and stays frozen"`
---
## ADDED Requirements
### Requirement: Installer library class exposes every operation
The package SHALL export an `Installer` class whose public methods are
`install`, `reinstall`, `upgrade`, `uninstall`, `list`, `info`, `doctor`,
`pin`, `unpin`, `freeze`, `update`, and `search`. Every method SHALL return
a typed report (never `void` for mutating operations) and SHALL accept a
typed options object whose shape is declared as a named interface (no
inline shapes — AGENTS.md §11). The class SHALL be the only public entry
point for multi-plugin operations; the existing `Host` class SHALL remain
the per-plugin orchestration seam wrapped by `Installer`.
#### Scenario: every documented method exists with the right shape
- **WHEN** the `Installer` class is imported from `@suaveplan/installer`
and instantiated with a valid `InstallerOptions`
- **THEN** the instance exposes every method listed above and each
method's return value is the documented `*Report` interface, validated
by its Zod schema
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"exposes every public method with the documented report shape"`
---
### Requirement: install command resolves, plans, applies, persists
`Installer.install(pluginId, opts)` SHALL execute the strict pipeline
`resolveRegistry → discoverPlugin → resolveDependencies →
runHostPipeline → writeState → writeLockfile`. The dependency-resolution
step SHALL topologically order any transitively-declared plugin
dependencies and install leaves first. State and lockfile writes SHALL
use the atomic `write → fsync → rename` pattern and SHALL occur only
after every transactional apply commits.
#### Scenario: install records state and lockfile only after commit
- **WHEN** `Installer.install` runs against a fixture plugin and the
apply phase aborts mid-way
- **THEN** neither `installer-state.json` nor `installer-lock.json` is
written, and no `.tmp-*` siblings remain on disk
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"install writes state and lockfile only after successful commit"`
---
### Requirement: reinstall reapplies recorded version against canonical state
`Installer.reinstall(pluginId, opts)` SHALL load the state entry, fail with
`INSTALLER.REINSTALL_NOT_INSTALLED` when no entry exists, and otherwise
re-run the install pipeline against the recorded version with conflict
policies collapsed to `overwrite`. The `--keep-state` flag SHALL preserve
the original `installedAt` timestamp; without it, `installedAt` SHALL
refresh.
#### Scenario: reinstall against an absent plugin throws typed error
- **WHEN** `Installer.reinstall("missing-plugin")` runs against an empty
state file
- **THEN** an `InstallerError` with code `INSTALLER.REINSTALL_NOT_INSTALLED`
is thrown and no apply phase runs
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"reinstall throws REINSTALL_NOT_INSTALLED when plugin not in state"`
---
### Requirement: uninstall reverses install and clears state entry
`Installer.uninstall(pluginId, opts)` SHALL load the state entry, fail
with `INSTALLER.UNINSTALL_NOT_INSTALLED` when absent, gate on a clean
target tree (failing with `INSTALLER.UNINSTALL_DIRTY_TREE` unless
`--allow-dirty` or `--force` is supplied), run any `preUninstall` hook,
delete every file recorded in `fileHashes` via the same staged-tmp +
atomic-rename transaction used by install (move-aside then unlink on
commit), run `postUninstall`, and remove the state entry on success. The
`--purge` flag SHALL additionally remove the plugin's `targetDir` if
empty after the unlinks.
#### Scenario: uninstall removes every recorded file and clears state
- **GIVEN** a plugin installed with files `a.txt` and `b.txt` recorded in
the state entry
- **WHEN** `Installer.uninstall(pluginId)` runs against a clean tree
- **THEN** both files no longer exist on disk, the state entry for the
plugin is removed, and the lockfile entry is removed
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"uninstall deletes recorded files and clears state plus lockfile entry"`
---
### Requirement: list enumerates installed plugins with drift status
`Installer.list()` SHALL return an array of `InstalledPluginInfo` objects
each carrying `pluginId`, `version`, `installedAt`, `targetDir`,
`registry`, `pinned`, and `drift` (`"clean" | "modified" | "missing"`).
Drift SHALL be computed by re-hashing every path recorded in the state
entry's `fileHashes` and comparing to the recorded BLAKE3 digest.
#### Scenario: list reports modified status when a recorded file differs
- **GIVEN** a state entry recording `skills/crypto/SKILL.md` at hash `H1`
and the on-disk file hashing to `H2`
- **WHEN** `Installer.list()` runs
- **THEN** the returned `InstalledPluginInfo` for that plugin has
`drift === "modified"` and `modifiedPaths` includes
`"skills/crypto/SKILL.md"`
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"list reports modified drift when file hash diverges from state"`
---
### Requirement: info returns manifest, state, and dependency graph
`Installer.info(pluginId)` SHALL fail with `INSTALLER.INFO_NOT_INSTALLED`
when the plugin is not in the state file. Otherwise it SHALL return a
`PluginInfoReport` carrying the resolved manifest, the state entry, the
direct and transitive dependency graph (as an adjacency map keyed by
plugin id), and the lockfile entry. The dependency graph SHALL be
computed by walking the installed plugins' `dependsOn` fields.
#### Scenario: info surfaces transitive dependency graph
- **GIVEN** a plugin `A` depending on `B`, and `B` depending on `C`,
all installed
- **WHEN** `Installer.info("A")` runs
- **THEN** `report.dependencyGraph` contains `{ A: ["B"], B: ["C"], C: [] }`
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"info returns transitive dependency graph for installed plugin"`
---
### Requirement: doctor runs every diagnostic check
`Installer.doctor()` SHALL execute every check below in parallel and
return a `DoctorReport` whose `findings` array contains one
`DoctorFinding` per detected issue with `severity` (`"info" | "warn" |
"error"`), `code` (an `INSTALLER.*` value), `pluginId?`, `path?`, and
`remediation` string:
1. stale state entries (state references a plugin no longer present in
any registry);
2. broken symlinks anywhere under a recorded `targetDir`;
3. version mismatch between state and `node_modules`;
4. registry connectivity (ping each configured registry source);
5. dependency cycles in the installed plugin graph;
6. missing lockfile;
7. interrupted transactions (journal `begin` without `commit` / `abort`).
When run with `--fix`, doctor SHALL attempt remediation only for the
interrupted-transaction class (rollback the staged tmp dir) and SHALL
leave every other class to the operator with the remediation hint.
#### Scenario: doctor reports interrupted transactions detected in the journal
- **GIVEN** a journal containing a `begin` line with no matching
`commit` or `abort`
- **WHEN** `Installer.doctor()` runs
- **THEN** the report contains a `DoctorFinding` with
`code === "INSTALLER.JOURNAL_REPLAY_FAILED"`, `severity === "error"`,
and a non-empty `remediation` string
**Verified by:** `src/installer/doctor.spec-conformance.test.ts::"doctor reports interrupted transactions from the journal"`
---
### Requirement: pin records pinned version and refuses upgrades past it
`Installer.pin(pluginId, version)` SHALL fail with
`INSTALLER.PIN_NOT_INSTALLED` when no state entry exists,
`INSTALLER.PIN_VERSION_INVALID` when the version is not valid semver, and
otherwise SHALL set `pinned: true` and `resolved: <version>` on the
plugin's lockfile entry. Any subsequent `Installer.upgrade` targeting a
version different from the pin SHALL refuse with
`INSTALLER.UPGRADE_VERSION_NOT_FOUND` unless `--force` is supplied.
#### Scenario: upgrading past a pin without force fails
- **GIVEN** a pinned plugin at `1.0.0` and `node_modules` containing
`2.0.0`
- **WHEN** `Installer.upgrade(pluginId)` runs without `--force`
- **THEN** an `InstallerError` is thrown whose code is one of
`INSTALLER.UPGRADE_VERSION_NOT_FOUND` or `INSTALLER.UPGRADE_SAME_VERSION`,
and the lockfile pin is preserved
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"pin blocks upgrade past the pinned version unless force is supplied"`
---
### Requirement: unpin removes a pin without other state change
`Installer.unpin(pluginId)` SHALL fail with `INSTALLER.PIN_NOT_PINNED`
when the lockfile entry is not currently pinned, and otherwise SHALL set
`pinned: false` on the entry without altering the `resolved` version, the
state file, or any other field.
#### Scenario: unpin clears the pin flag only
- **GIVEN** a lockfile entry with `pinned: true, resolved: "1.0.0"`
- **WHEN** `Installer.unpin(pluginId)` runs
- **THEN** the entry becomes `pinned: false, resolved: "1.0.0"` and no
other field changes
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"unpin clears the pin flag and leaves resolved version untouched"`
---
### Requirement: freeze snapshots every installed plugin into a deterministic lockfile
`Installer.freeze()` SHALL walk every entry in the state file, resolve
each plugin's current version from its `RegistrySource`, and write
`.suaveplan/installer-lock.json` with sorted keys, `\n` line endings, and
an `integrity` BLAKE3 digest per plugin. Two invocations against the
same inputs SHALL produce byte-identical lockfile content.
#### Scenario: two freeze runs produce byte-identical output
- **WHEN** `Installer.freeze()` runs twice in succession against the
same state file
- **THEN** the SHA-256 digest of `installer-lock.json` is identical
across the two runs
**Verified by:** `src/state/lockfile.spec-conformance.test.ts::"freeze produces a byte-identical lockfile across runs"`
---
### Requirement: update --all upgrades every installed plugin, skipping pins
`Installer.update(opts)` SHALL iterate every entry in the state file
in dependency order, run `Installer.upgrade` on each, and aggregate the
results into an `UpdateReport`. Pinned plugins SHALL be skipped when
`--pinned-skip` is set (the default). Partial failures (one plugin
fails, others succeed) SHALL surface as
`InstallerError(INSTALLER.UPDATE_PARTIAL_FAILURE)` whose `context.failures`
enumerates `{ pluginId, code, cause }` per failed plugin, and whose
`context.successes` enumerates the upgraded plugins. The aggregate error
SHALL NOT prevent the successful plugins' state or lockfile entries from
persisting.
#### Scenario: partial failure preserves successful upgrades
- **GIVEN** two installed plugins where the second's upgrade throws
- **WHEN** `Installer.update({ all: true })` runs
- **THEN** the first plugin's state and lockfile reflect the new
version, the second plugin's state and lockfile are unchanged, and
an `InstallerError` with code `INSTALLER.UPDATE_PARTIAL_FAILURE` is
thrown carrying both `failures` and `successes` in `context`
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"update --all upgrades each plugin and surfaces partial failures with both successes and failures"`
---
### Requirement: search queries the active registry source
`Installer.search(query, opts)` SHALL delegate to the active
`RegistrySource.search` method, return `SearchHit[]` (with `id`,
`version`, `description`, `registry`), and surface registry connectivity
failures as `InstallerError(INSTALLER.REGISTRY_UNREACHABLE, { cause })`.
The `--limit` flag SHALL cap the returned array length.
#### Scenario: search forwards query and surfaces registry failures
- **WHEN** the active registry source's `search` throws a network error
and `Installer.search("agent")` runs
- **THEN** an `InstallerError` with code `INSTALLER.REGISTRY_UNREACHABLE`
is thrown whose `cause` is the original network error
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"search surfaces registry connectivity failures as REGISTRY_UNREACHABLE"`
---
### Requirement: lifecycle hooks run in order and roll back on throw
`Host` SHALL invoke plugin lifecycle hooks in the strict order
`preInstall → detect → prompts → plan → apply → postInstall` for install,
`preUpgrade → diff → migrate → apply → postUpgrade` for upgrade, and
`preUninstall → applyDeletes → postUninstall` for uninstall. Each hook
SHALL be wrapped in an OpenTelemetry span named `installer.<hook>`
carrying `plugin.id` and `plugin.version`. A throw from any hook SHALL
roll back the active transaction and surface as `InstallerError` whose
`cause` is the original throw.
#### Scenario: preInstall throw rolls back without writing state
- **GIVEN** a fixture plugin whose `preInstall` throws
- **WHEN** `Installer.install(pluginId)` runs
- **THEN** no file is staged or written, the state file is unchanged,
and an `InstallerError` is thrown carrying the original throw as
`cause`
**Verified by:** `src/host/hooks/hooks.spec-conformance.test.ts::"preInstall throw rolls back and surfaces InstallerError with original cause"`
---
### Requirement: inter-plugin dependency resolution is topological and cycle-detecting
The installer SHALL build a dependency graph from every installed plugin's
`dependsOn` field plus the target plugin's manifest, detect cycles via
Tarjan's algorithm, and refuse cyclic graphs with
`InstallerError(INSTALLER.DEPENDENCY_CYCLE, { context: { cycle: string[] } })`.
For each dependency edge whose declared semver range is not satisfied by
the installed version, the installer SHALL throw
`InstallerError(INSTALLER.DEPENDENCY_UNSATISFIED, { context: { dependent, dependency, required, installed? } })`.
Resolution order SHALL be topological (leaves first for install/upgrade,
consumers first for uninstall).
#### Scenario: cyclic dependency graph is rejected
- **GIVEN** plugin `A` declaring `dependsOn: { B: "^1" }` and plugin `B`
declaring `dependsOn: { A: "^1" }`
- **WHEN** `Installer.install("A")` runs with both plugins discoverable
- **THEN** an `InstallerError` with code `INSTALLER.DEPENDENCY_CYCLE`
is thrown and `context.cycle` includes both `"A"` and `"B"`
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"install refuses cyclic plugin dependency graph with DEPENDENCY_CYCLE"`
---
### Requirement: state file uses atomic write-fsync-rename
`writeState(fs, cwd, state)` SHALL serialise the state document via Zod,
write to `.suaveplan/installer-state.json.tmp-<txn>`, `fsync` the
descriptor, and atomically `rename` over the live path. Reads SHALL Zod-
validate the loaded document; validation failures SHALL surface as
`InstallerError(INSTALLER.STATE_INVALID, { cause })`. No partial state
document SHALL ever be observable.
#### Scenario: aborted write leaves prior state intact
- **GIVEN** an existing valid state file and a `writeState` invocation
whose final `rename` is injected to throw `EIO`
- **WHEN** the write is attempted
- **THEN** the live state file is bit-identical to its pre-write content
and no `.tmp-*` sibling remains after the error surfaces
**Verified by:** `src/state/state-file.spec-conformance.test.ts::"state file write is atomic and leaves prior content intact on rename failure"`
---
### Requirement: rollback journal records every transaction phase
`.suaveplan/installer-journal.log` SHALL be append-only newline-delimited
JSON. Every transaction SHALL emit `begin`, one or more `stage`, then
exactly one of `commit` or `abort`. Every line SHALL be `fsync`'d before
the next phase advances. On startup, the journal SHALL be replayed: a
`begin` without a matching `commit` / `abort` SHALL be reported by
`doctor` as `INSTALLER.JOURNAL_REPLAY_FAILED` with a remediation pointing
to the staged tmp dir. Malformed journal lines SHALL surface as the same
code with `cause` set.
#### Scenario: interrupted transaction is detectable from the journal
- **GIVEN** a journal with `{ phase: "begin", txn: "X" }` followed by
three `stage` lines and no `commit` / `abort` for txn `X`
- **WHEN** the journal is replayed
- **THEN** a `DoctorFinding` is produced for txn `X` with code
`INSTALLER.JOURNAL_REPLAY_FAILED` and `remediation` referencing the
staged tmp directory
**Verified by:** `src/state/journal.spec-conformance.test.ts::"replay detects begin without commit and surfaces JOURNAL_REPLAY_FAILED"`
---
### Requirement: lockfile is deterministic and integrity-stamped
`installer-lock.json` SHALL be serialised with keys sorted
lexicographically at every level, `\n` line endings, and a final newline.
Every plugin entry SHALL carry a BLAKE3 `integrity` digest computed over
the concatenation of every `fileHashes` value in the corresponding state
entry. Reads SHALL Zod-validate the lockfile; failure SHALL surface as
`InstallerError(INSTALLER.LOCKFILE_INVALID, { cause })`. Write failures
SHALL surface as `INSTALLER.LOCKFILE_WRITE_FAILED`.
#### Scenario: lockfile load rejects malformed JSON with typed error
- **GIVEN** a lockfile on disk containing `"not a real document"`
- **WHEN** `loadLockfile(fs, cwd)` runs
- **THEN** an `InstallerError` with code `INSTALLER.LOCKFILE_INVALID` is
thrown whose `cause` is the Zod issue
**Verified by:** `src/state/lockfile.spec-conformance.test.ts::"load rejects malformed lockfile JSON with LOCKFILE_INVALID"`
---
### Requirement: registry source contract has four built-in implementations
The package SHALL export a `RegistrySource` interface with `name`,
optional `auth`, `resolve`, `fetch`, and `search` methods. Four built-in
implementations SHALL ship under `src/registry/`: `verdaccio` (default,
`node_modules` walk + Verdaccio HTTP), `oci` (OCI artifact pull),
`git` (clone + checkout), and `local-tarball` (extract a local `.tgz`).
Each implementation SHALL be selectable via the `--registry <name>` CLI
flag and the `registry` option on `InstallerOptions`. Unknown registry
names SHALL surface as `InstallerError(INSTALLER.REGISTRY_UNKNOWN)`.
Auth failures SHALL surface as
`InstallerError(INSTALLER.REGISTRY_AUTH_FAILED, { cause })`.
#### Scenario: every built-in registry resolves a fixture plugin
- **GIVEN** a fixture plugin published to a testcontainers Verdaccio
instance, the same fixture published as an OCI artifact, the same
fixture available at a git ref, and the same fixture as a local
`.tgz`
- **WHEN** `Installer.install("fixture-plugin")` runs once per
registry source via `--registry <verdaccio|oci|git|local-tarball>`
- **THEN** every run completes successfully and writes a state entry
whose `registry` field matches the requested source
**Verified by:** `src/registry/verdaccio/verdaccio.spec-conformance.test.ts::"resolve and fetch a fixture plugin from a testcontainers Verdaccio"`
---
### Requirement: migration scripts run in semver order inside the same transaction
`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` via BFS, and execute each `MigrationScript`
in order inside the same staged-tmp 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. Missing path
between `from` and `to` SHALL surface as
`INSTALLER.MIGRATION_PATH_UNAVAILABLE`. A script throw SHALL roll back
every staged op (including ops emitted by earlier scripts in the chain)
and surface as `INSTALLER.UPGRADE_MIGRATION_FAILED`.
#### Scenario: chained migrations execute from-to-target inside one transaction
- **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 run in order, both receive an `fs` rooted at
the same staged tmp dir, and both 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: upgrade lifecycle, dirty-tree, and preflight gates
`Host.upgrade(plugin, options)` SHALL execute stages in the order
`loadState → resolveTarget → preflight → hashTree → planFrom → planTo →
diff → materialise → applyOps → runMigrations → commitState`. The
`preflight` stage SHALL refuse to run unless a lockfile of one of the
supported formats (`bun.lockb`, `bun.lock`, `package-lock.json`,
`pnpm-lock.yaml`, `yarn.lock`) exists in `cwd`, failing with
`INSTALLER.UPGRADE_LOCKFILE_MISSING`. The `dirty-tree` check SHALL hash
every path in the state entry and compare to the recorded BLAKE3 digest;
any mismatch SHALL surface as `INSTALLER.UPGRADE_DIRTY_TREE` with
`context.paths` enumerating divergent paths, unless `--allow-dirty` or
`--force` was supplied. `installed === target` SHALL short-circuit with
`INSTALLER.UPGRADE_SAME_VERSION` unless `--force`.
#### 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: CLI verb forwards every flag for every command
`suaveplan-install` SHALL register every verb listed in the
proposal's command table, parse global flags (`--dry-run`, `--force`,
`--no-interactive`, `--answers <file>`, `--cwd <dir>`,
`--registry <name>`, `--json`) plus the verb-specific flags from the
design `design.md` command matrix, and forward every parsed flag to
the matching `Installer.<method>` call. `add` SHALL remain as an alias
of `install`. `--json` on any verb SHALL emit the operation's report
as a single JSON document on stdout instead of human-readable output.
#### Scenario: every verb forwards every documented flag
- **WHEN** `main(["upgrade", "agent-skills", "--dry-run", "--force", "--allow-dirty", "--from", "1.0.0", "--to", "2.0.0", "--answers", "a.json", "--registry", "verdaccio"])`
runs against a recording `Installer.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>, registry: "verdaccio" }`
**Verified by:** `src/bin/suaveplan-install.spec-conformance.test.ts::"every verb forwards every documented flag to the matching Installer method"`
---
### Requirement: event bus emits typed events for every state transition
Every public `Installer.*` method that mutates state SHALL publish a
typed event on the injected `EventBusLike`: `installer.installed`,
`installer.reinstalled`, `installer.upgraded`, `installer.uninstalled`,
`installer.pinned`, `installer.unpinned`, `installer.frozen`,
`installer.doctor.completed`. Each event payload SHALL be Zod-validated
at the publish boundary. When no event bus is injected, events SHALL
be silently dropped (no throw).
#### Scenario: successful install publishes installer.installed
- **GIVEN** an `Installer` instance constructed with a recording
`EventBusLike`
- **WHEN** `Installer.install(pluginId)` succeeds
- **THEN** the recorder captures exactly one event with topic
`"installer.installed"` whose payload satisfies the registered
Zod schema and includes `pluginId`, `version`, `installedAt`
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"install publishes installer.installed event with Zod-validated payload"`
---
### Requirement: OTel spans wrap every library method
Every public `Installer.*` method SHALL be wrapped in an OpenTelemetry
span named `installer.<method>` carrying attributes `plugin.id` (when
applicable), `plugin.version` (when applicable), `dry_run`, and
(for upgrade) `plugin.version.from`, `plugin.version.to`. The span SHALL
end before the method returns; thrown errors SHALL be recorded on the
span via `span.recordException` and the span status SHALL be set to
`ERROR`.
#### Scenario: install records span with correct attributes and ends before return
- **WHEN** `Installer.install(pluginId)` runs against an in-memory OTel
exporter
- **THEN** the exporter records one span named `"installer.install"`
whose attributes include `plugin.id` equal to `pluginId`,
`plugin.version` equal to the resolved version, and `dry_run` equal
to the option value, and whose `endTime` is set
**Verified by:** `src/installer/installer.spec-conformance.test.ts::"install opens and closes installer.install span with documented attributes"`
---
### Requirement: testcontainers-Verdaccio fixture powers every install / search test
The package SHALL ship a `src/testing/fixtures/registry/verdaccio-container.ts`
helper that boots a real Verdaccio instance via testcontainers, publishes
a small fixed catalogue of fixture plugins, and exposes a
`RegistrySource` pointing at it. Every install / upgrade / search /
update / search conformance test SHALL use this real container — no
mocks (CLAUDE.md rule 2). The helper SHALL implement the
`@suaveplan/testing-integration` `IntegrationFixture` contract so
container lifecycle is managed automatically.
#### Scenario: container fixture publishes and resolves the fixture catalogue
- **WHEN** the conformance test suite for `verdaccio` registry starts
- **THEN** the Verdaccio container boots within the integration-test
timeout, the fixture catalogue is published, and
`RegistrySource.resolve("fixture-plugin")` returns a `ResolvedPlugin`
whose `version` matches the published version
**Verified by:** `src/registry/verdaccio/verdaccio.spec-conformance.test.ts::"testcontainers Verdaccio publishes the fixture catalogue and resolves fixture-plugin"`
---