Files
suaveplan/openspec/changes/complete-installer/specs/installer/spec.md
2026-05-27 23:29:53 +01:00

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