598 lines
27 KiB
Markdown
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"`
|
|
|
|
---
|