27 KiB
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 insidepackages/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>", andObject.isFrozen(INSTALLER_CODES)returnstrue
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
Installerclass is imported from@suaveplan/installerand instantiated with a validInstallerOptions - THEN the instance exposes every method listed above and each
method's return value is the documented
*Reportinterface, 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.installruns against a fixture plugin and the apply phase aborts mid-way - THEN neither
installer-state.jsonnorinstaller-lock.jsonis 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
InstallerErrorwith codeINSTALLER.REINSTALL_NOT_INSTALLEDis 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.txtandb.txtrecorded 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.mdat hashH1and the on-disk file hashing toH2 - WHEN
Installer.list()runs - THEN the returned
InstalledPluginInfofor that plugin hasdrift === "modified"andmodifiedPathsincludes"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
Adepending onB, andBdepending onC, all installed - WHEN
Installer.info("A")runs - THEN
report.dependencyGraphcontains{ 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:
- stale state entries (state references a plugin no longer present in any registry);
- broken symlinks anywhere under a recorded
targetDir; - version mismatch between state and
node_modules; - registry connectivity (ping each configured registry source);
- dependency cycles in the installed plugin graph;
- missing lockfile;
- interrupted transactions (journal
beginwithoutcommit/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
beginline with no matchingcommitorabort - WHEN
Installer.doctor()runs - THEN the report contains a
DoctorFindingwithcode === "INSTALLER.JOURNAL_REPLAY_FAILED",severity === "error", and a non-emptyremediationstring
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.0andnode_modulescontaining2.0.0 - WHEN
Installer.upgrade(pluginId)runs without--force - THEN an
InstallerErroris thrown whose code is one ofINSTALLER.UPGRADE_VERSION_NOT_FOUNDorINSTALLER.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.jsonis 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
InstallerErrorwith codeINSTALLER.UPDATE_PARTIAL_FAILUREis thrown carrying bothfailuresandsuccessesincontext
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
searchthrows a network error andInstaller.search("agent")runs - THEN an
InstallerErrorwith codeINSTALLER.REGISTRY_UNREACHABLEis thrown whosecauseis 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
preInstallthrows - WHEN
Installer.install(pluginId)runs - THEN no file is staged or written, the state file is unchanged,
and an
InstallerErroris thrown carrying the original throw ascause
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
AdeclaringdependsOn: { B: "^1" }and pluginBdeclaringdependsOn: { A: "^1" } - WHEN
Installer.install("A")runs with both plugins discoverable - THEN an
InstallerErrorwith codeINSTALLER.DEPENDENCY_CYCLEis thrown andcontext.cycleincludes 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
writeStateinvocation whose finalrenameis injected to throwEIO - 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 threestagelines and nocommit/abortfor txnX - WHEN the journal is replayed
- THEN a
DoctorFindingis produced for txnXwith codeINSTALLER.JOURNAL_REPLAY_FAILEDandremediationreferencing 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
InstallerErrorwith codeINSTALLER.LOCKFILE_INVALIDis thrown whosecauseis 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
registryfield 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.0and1.1.0->2.0.0 - WHEN
Host.upgraderuns from1.0.0to2.0.0 - THEN both scripts run in order, both receive an
fsrooted at the same staged tmp dir, and both 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: 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.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: 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 recordingInstaller.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>, 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
Installerinstance constructed with a recordingEventBusLike - WHEN
Installer.install(pluginId)succeeds - THEN the recorder captures exactly one event with topic
"installer.installed"whose payload satisfies the registered Zod schema and includespluginId,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 includeplugin.idequal topluginId,plugin.versionequal to the resolved version, anddry_runequal to the option value, and whoseendTimeis 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
verdaccioregistry starts - THEN the Verdaccio container boots within the integration-test
timeout, the fixture catalogue is published, and
RegistrySource.resolve("fixture-plugin")returns aResolvedPluginwhoseversionmatches the published version
Verified by: src/registry/verdaccio/verdaccio.spec-conformance.test.ts::"testcontainers Verdaccio publishes the fixture catalogue and resolves fixture-plugin"