Initial Commit

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

View File

@@ -0,0 +1,362 @@
# Design: complete-installer
Non-normative design for the full installer surface. Normative requirements
live in `specs/installer/spec.md`.
## Command matrix
| Verb | Library method | Flags | Throws |
|---|---|---|---|
| `install <plugin>` | `installer.install(id, opts)` | `--dry-run --force --no-interactive --answers --only --exclude --registry --cwd --json` | `PLUGIN_NOT_FOUND`, `APPLY_*`, `DEPENDENCY_*` |
| `reinstall <plugin>` | `installer.reinstall(id, opts)` | global + `--keep-state` | `REINSTALL_NOT_INSTALLED`, `APPLY_*` |
| `upgrade <plugin>` | `installer.upgrade(id, opts)` | global + `--from --to --allow-dirty` | `UPGRADE_*` |
| `uninstall <plugin>` | `installer.uninstall(id, opts)` | global + `--allow-dirty --purge` | `UNINSTALL_NOT_INSTALLED`, `UNINSTALL_DIRTY_TREE` |
| `list` | `installer.list()` | `--json` | — |
| `info <plugin>` | `installer.info(id)` | `--json` | `INFO_NOT_INSTALLED` |
| `doctor` | `installer.doctor()` | `--json --fix` | — |
| `pin <plugin> <version>` | `installer.pin(id, ver)` | global | `PIN_VERSION_INVALID`, `PIN_NOT_INSTALLED` |
| `unpin <plugin>` | `installer.unpin(id)` | global | `PIN_NOT_PINNED` |
| `freeze` | `installer.freeze()` | global | `LOCKFILE_WRITE_FAILED` |
| `update --all` | `installer.update(opts)` | global + `--all --pinned-skip` | `UPDATE_PARTIAL_FAILURE` |
| `search <query>` | `installer.search(q, opts)` | `--registry --limit --json` | `REGISTRY_UNREACHABLE` |
`add` and `remove` remain as aliases of `install` and `uninstall` for
backwards compatibility.
## Library API shape
```ts
import type { InstallerContext } from "./types/installer.types.js";
export interface InstallerOptions {
readonly ctx: InstallerContext;
readonly registry?: RegistrySource; // defaults to verdaccio
readonly state?: StateBackend; // defaults to disk JSON
readonly lockfile?: LockfileBackend; // defaults to disk JSON
readonly events?: EventBusLike; // defaults to no-op
}
export class Installer {
public constructor(opts: InstallerOptions);
public install(id: string, opts?: InstallOptions): Promise<InstallReport>;
public reinstall(id: string, opts?: ReinstallOptions): Promise<InstallReport>;
public upgrade(id: string, opts?: UpgradeOptions): Promise<UpgradeReport>;
public uninstall(id: string, opts?: UninstallOptions): Promise<UninstallReport>;
public list(): Promise<ReadonlyArray<InstalledPluginInfo>>;
public info(id: string): Promise<PluginInfoReport>;
public doctor(opts?: DoctorOptions): Promise<DoctorReport>;
public pin(id: string, version: string): Promise<void>;
public unpin(id: string): Promise<void>;
public freeze(): Promise<LockfileReport>;
public update(opts?: UpdateOptions): Promise<UpdateReport>;
public search(query: string, opts?: SearchOptions): Promise<ReadonlyArray<SearchHit>>;
}
```
Every method returns a typed report; no method returns `void` for
operations that mutate state. Every `*Options`, `*Report`, and `*Info`
type is named (no inline shapes) per AGENTS.md §11.
## State-file schema
```jsonc
{
"$schema": "installer-state/v1",
"plugins": {
"<pluginId>": {
"version": "0.7.0",
"installedAt": "2026-05-17T12:34:56Z",
"targetDir": ".suaveplan/skills",
"registry": "verdaccio",
"capabilities": ["skills"],
"dependsOn": { "@suaveplan/install-base": "^0.1.0" },
"fileHashes": {
"skills/crypto/SKILL.md": "blake3:..."
}
}
}
}
```
Loader Zod-validates the document; failure → `INSTALLER.STATE_INVALID`.
## Lockfile schema
```jsonc
{
"$schema": "installer-lock/v1",
"generatedAt": "2026-05-17T12:34:56Z",
"plugins": {
"<pluginId>": {
"resolved": "0.7.0",
"pinned": false,
"registry": "verdaccio",
"integrity": "blake3-...",
"dependsOn": { "@suaveplan/install-base": "0.1.0" }
}
}
}
```
Serialised with sorted keys and `\n` line endings so byte-identical lockfiles
result from identical inputs (deterministic for CI diffing).
## Plugin-host API extensions
```ts
export interface InstallerPlugin<TAnswers, THandle = void> {
readonly id: string;
readonly version: string;
readonly capabilities?: ReadonlyArray<string>;
readonly dependsOn?: Readonly<Record<string, string>>;
detect?(ctx: InstallerContext): Promise<DetectResult>;
prompts?(ctx: InstallerContext): PromptSchema<TAnswers>;
plan(answers: TAnswers, ctx: InstallerContext): Promise<FileOp[]>;
apply(ops: FileOp[], ctx: InstallerContext): Promise<THandle>;
preInstall?(ctx: InstallerContext): Promise<void>;
postInstall?(handle: THandle, ctx: InstallerContext): Promise<void>;
preUpgrade?(from: string, to: string, ctx: InstallerContext): Promise<void>;
postUpgrade?(from: string, to: string, ctx: InstallerContext): Promise<void>;
preUninstall?(ctx: InstallerContext): Promise<void>;
postUninstall?(ctx: InstallerContext): Promise<void>;
}
```
Every lifecycle hook is optional and wrapped in an OTel span named
`installer.<hook>` carrying `plugin.id` and `plugin.version`. Throws from
hooks roll back the transaction.
## Dependency resolution
1. Build the plugin graph from `dependsOn` declarations in every installed
plugin's state entry plus the target plugin's manifest.
2. Run Tarjan's algorithm for cycle detection — cycle →
`INSTALLER.DEPENDENCY_CYCLE`.
3. Topological-sort the DAG; install / upgrade in dependency order
(leaves first), uninstall in reverse (consumers first).
4. For each dependency edge, check the installed version satisfies the
declared semver range — unsatisfied → `INSTALLER.DEPENDENCY_UNSATISFIED`
with `context: { dependent, dependency, required, installed? }`.
## Rollback journal format
`.suaveplan/installer-journal.log` — append-only, one JSON object per line:
```jsonc
{ "ts": "...", "txn": "uuid", "phase": "begin", "op": "install", "plugin": "..." }
{ "ts": "...", "txn": "uuid", "phase": "stage", "path": "..." }
{ "ts": "...", "txn": "uuid", "phase": "commit" }
{ "ts": "...", "txn": "uuid", "phase": "abort", "cause": "..." }
```
Every line is fsync'd before the next phase advances. On startup the
journal is replayed: a `begin` without a matching `commit`/`abort` means
the previous run crashed; `doctor` reports it and offers `--fix` to
rollback the staged tmp dir.
## Migration script contract
```ts
export type MigrationScript = (ctx: MigrationContext) => Promise<void | FileOp[]>;
export interface MigrationContext {
readonly fs: MutableInstallerFs; // rooted at staged tmp dir
readonly logger: LoggerLike;
readonly signal: AbortSignal;
readonly from: string;
readonly to: string;
}
export type MigrationTable = Readonly<Record<`${string}->${string}`, MigrationScript>>;
```
Selector finds the shortest contiguous chain of edges from `from` to `to`
via BFS over the table keys. No path → `INSTALLER.MIGRATION_PATH_UNAVAILABLE`.
## Registry source interface
```ts
export interface RegistrySource {
readonly name: string;
auth?(): Promise<void>;
resolve(id: string, range?: string): Promise<ResolvedPlugin>;
fetch(resolved: ResolvedPlugin): Promise<Uint8Array>; // tarball bytes
search(query: string, opts?: SearchOptions): Promise<ReadonlyArray<SearchHit>>;
}
```
Built-ins live under `src/registry/`:
- `verdaccio/verdaccio.ts` — `node_modules` walk + Verdaccio HTTP for
`search` and version listing (existing `discoverPlugins` reused).
- `oci/oci.ts` — `oci-distribution` HTTP, `OCI_TOKEN` auth.
- `git/git.ts` — `git clone --depth 1 <url> <ref>` then treat checkout as
the plugin package.
- `local-tarball/local-tarball.ts` — `tar -x` an on-disk `.tgz`.
Each source ships its own `*.spec-conformance.test.ts`.
## Error codes (full registry)
Existing (preserved verbatim from `INSTALLER-09`):
`PLUGIN_NOT_FOUND`, `PLUGIN_LOAD_FAILED`, `PLUGIN_INVALID_MANIFEST`,
`PROMPT_VALIDATION_FAILED`, `PLAN_FAILED`, `APPLY_CONFLICT`,
`APPLY_FAILED`, `ROLLBACK_FAILED`, `ABORTED`.
Upgrade (from superseded change, carried forward):
`UPGRADE_NOT_INSTALLED`, `UPGRADE_VERSION_NOT_FOUND`,
`UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`, `UPGRADE_STATE_INVALID`,
`UPGRADE_MIGRATION_FAILED`, `UPGRADE_LOCKFILE_MISSING`.
New in this change:
`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`.
Total: 9 (existing) + 7 (upgrade) + 18 (new) = **34 codes**, all
namespaced `INSTALLER.*`, registry frozen via `defineCodes`.
## File layout
```text
packages/tooling/installer/src/
installer/
installer.ts
installer.md
installer.spec-conformance.test.ts
install.ts
install.md
reinstall.ts
reinstall.md
uninstall.ts
uninstall.md
info.ts
info.md
list.ts
list.md
pin.ts
pin.md
unpin.ts
unpin.md
freeze.ts
freeze.md
update.ts
update.md
search.ts
search.md
doctor.ts
doctor.md
doctor.spec-conformance.test.ts
dependency-graph.ts
dependency-graph.md
index.ts
state/
state.types.ts
state.schemas.ts
state-file.ts
state-file.md
state-file.spec-conformance.test.ts
hash-tree.ts
hash-tree.md
journal.ts
journal.md
journal.spec-conformance.test.ts
lockfile.ts
lockfile.md
lockfile.spec-conformance.test.ts
index.ts
registry/
registry.types.ts
registry.schemas.ts
verdaccio/
verdaccio.ts
verdaccio.md
verdaccio.spec-conformance.test.ts
oci/
oci.ts
oci.md
oci.spec-conformance.test.ts
git/
git.ts
git.md
git.spec-conformance.test.ts
local-tarball/
local-tarball.ts
local-tarball.md
local-tarball.spec-conformance.test.ts
index.ts
host/
upgrade/
upgrade.ts
upgrade.md
upgrade.spec-conformance.test.ts
preflight.ts
preflight.md
preflight.spec-conformance.test.ts
dirty-tree.spec-conformance.test.ts
diff.ts
diff.md
apply-diff.spec-conformance.test.ts
migrations.ts
migrations.types.ts
migrations.md
migrations.spec-conformance.test.ts
index.ts
hooks/
hooks.ts
hooks.md
hooks.spec-conformance.test.ts
index.ts
testing/
fixtures/
plugins/
base-plugin.ts
dependent-plugin.ts
cyclic-a-plugin.ts
cyclic-b-plugin.ts
upgrade-fixture-plugin.ts
registry/
verdaccio-container.ts
bin/
suaveplan-install.ts (extended)
suaveplan-install.spec-conformance.test.ts
```
Every implementation `.ts` has a sibling `.md`. Every `<feature>/index.ts`
uses explicit named re-exports (AGENTS.md §6a).
## Decisions and trade-offs
- **One change, no narrow follow-ups.** The superseded upgrade change
proved that narrow-scoped sub-changes generate review churn and leave
the package incoherent. This change ships the whole surface in one
archive so the package's identity is stable from genesis-1.0.0 forward.
- **Lockfile + state file are separate documents.** State is the
reality of what is on disk; lockfile is the contract of what should be
on disk. Mixing them couples drift detection to resolution policy.
- **BLAKE3 over SHA-256.** `@suaveplan/crypto-hash` ships both; BLAKE3 is
~3× faster on small files and parallelisable. No tamper-proof guarantee
needed — this is change detection.
- **Journal is append-only newline-JSON, not a binary log.** Operators
can `cat`/`grep` it during incident response; the parser is one
`for await line of readline.createInterface(fs.createReadStream(...))`.
- **Testcontainers Verdaccio for tests.** CLAUDE.md rule 2 forbids
mocks; the Verdaccio container starts in `<10s` and ships a tiny
fixture registry of 4 plugins. Per-test publish/unpublish is supported.
- **`Installer` wraps `Host`, does not replace it.** Existing `Host.run`
callers (the v0.0 surface) keep working; new multi-plugin operations
go through `Installer`.
- **No network resolution by default.** `verdaccio` source defaults to
the existing `node_modules` walk; opting into `oci` / `git` /
`local-tarball` is explicit. Air-gapped CI stays air-gapped.

View File

@@ -0,0 +1,298 @@
# OpenSpec Change: complete-installer
- **Change ID**: complete-installer
- **Package**: `@suaveplan/installer`
- **Status**: PROPOSED
- **Date**: 2026-05-17
- **Design**: ./design.md
- **Spec delta**: ./specs/installer/spec.md
- **Supersedes**: `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/` (rolled into this change as the upgrade sub-surface).
## Why
The ported installer (archive `2026-04-28-installer-port`) shipped only `add`
and `list`. A narrow upgrade follow-up was drafted but then superseded
(`_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/`) precisely
because the package's identity demands a complete package-manager-style
surface. Today `@suaveplan/installer` cannot:
- bring an existing install up to a newer version (`upgrade`, `update --all`),
- remove an install cleanly (`uninstall`),
- re-do an install deterministically (`reinstall`),
- pin / unpin / freeze versions for reproducible CI,
- enumerate or inspect installed plugins beyond a flat `list`,
- detect drift, broken symlinks, dependency cycles, or registry connectivity
problems (`doctor`),
- search a registry for available plugins (`search`),
- run plugins programmatically through a typed library API (only the CLI
bin is exposed),
- coordinate between plugins that depend on one another (no dependency
resolution between plugins),
- run plugin-authored migration scripts across version boundaries,
- pull from anything other than a hand-rolled `node_modules` walk (no
pluggable registry — Verdaccio / OCI / git / local-tarball).
Every one of those gaps is core to what the word "installer" means. Genesis
ships enterprise-grade packages, not minimum-viable scaffolds (CLAUDE.md §3,
AGENTS.md §2b). This change closes the whole gap in one OpenSpec change so
the package stops drifting between narrow follow-ups.
## What Changes
A single proposal landing every command the package's identity implies plus
the supporting subsystems. **No deferrals beyond the explicit "Out of scope"
list below.**
### CLI surface (every verb ships in this change)
| Verb | Purpose |
|---|---|
| `install <plugin>` | Resolve, plan, transactionally apply, persist state. Replaces `add` as the primary verb; `add` remains as an alias to preserve the v0.0 contract. |
| `reinstall <plugin>` | Force-rerun a clean install against the recorded target version, restoring drift to canonical state. |
| `upgrade <plugin>` | Diff installed → target, run migration chain, transactionally apply, atomically persist new state. |
| `uninstall <plugin>` | Reverse the install: delete every file recorded in the state entry, run plugin `preUninstall`/`postUninstall`, drop the state entry. |
| `list` | Enumerate installed plugins with version, install date, target dir, drift status. |
| `info <plugin>` | Print detailed manifest + state entry + dependency graph for a single plugin. |
| `doctor` | Health check: stale state, broken symlinks, version mismatches, registry connectivity, dependency cycles, unpinned deps, missing lockfile. |
| `pin <plugin> <version>` | Record a pinned version in the lockfile; refuse upgrades past it. |
| `unpin <plugin>` | Remove a pin. |
| `freeze` | Snapshot every installed plugin's resolved version into `.suaveplan/installer-lock.json`. |
| `update --all` | Bulk upgrade every installed plugin to its current resolved target (respecting pins). |
| `search <query>` | Query the active registry source for matching plugin packages. |
Every verb shares the global flags `--dry-run`, `--force`, `--no-interactive`,
`--answers <file>`, `--cwd <dir>`, `--registry <name>`, `--json`. Verb-specific
flags are enumerated in `design.md`.
### Library API
`Installer` class exposes the same operations programmatically so other tools
(e.g. `create-suaveplan`, in-process bootstrap scripts, the `bin`) call one
surface:
- `installer.install(pluginId, opts)`
- `installer.reinstall(pluginId, opts)`
- `installer.upgrade(pluginId, opts)`
- `installer.uninstall(pluginId, opts)`
- `installer.list()`
- `installer.info(pluginId)`
- `installer.doctor()`
- `installer.pin(pluginId, version)`
- `installer.unpin(pluginId)`
- `installer.freeze()`
- `installer.update(opts)`
- `installer.search(query)`
The existing `Host` class becomes the per-plugin orchestration seam beneath
`Installer`; the CLI bin becomes a thin shell that constructs an
`InstallerContext` and forwards every flag to the library method.
### Plugin host capabilities
- Lifecycle hooks (every hook optional, every hook async, every hook receives
`InstallerContext`): `preInstall`, `postInstall`, `preUpgrade`,
`postUpgrade`, `preUninstall`, `postUninstall`.
- Capability declaration in the manifest (`capabilities: string[]`) so
plugins advertise what they provide and `info` / `doctor` can reason
about them.
- Inter-plugin dependency resolution: a plugin manifest MAY declare
`dependsOn: Record<pluginId, semverRange>`. The installer resolves the
topological order before any apply step and refuses cyclic graphs with
`INSTALLER.DEPENDENCY_CYCLE`.
- Version constraints honoured: a plugin declaring `peerInstallers` or
`dependsOn` with a semver range MUST find a satisfying installed
version, or the run fails with `INSTALLER.DEPENDENCY_UNSATISFIED`.
### Manifest / state / lockfile
- `installer-state.json` — per-plugin state entry with `pluginId`,
`version`, `installedAt`, `targetDir`, `fileHashes` (BLAKE3 via
`@suaveplan/crypto-hash`).
- `installer-lock.json` — deterministic JSON lockfile with sorted keys,
resolved versions, integrity digests, registry source per entry,
populated by `install`, `upgrade`, `freeze`.
- Integrity hashing: every file written records its BLAKE3 digest in the
state entry; drift detection compares on-disk hash vs recorded hash.
### Migration scripts
Plugins MAY ship `upgrade.ts` exporting a `MigrationTable` keyed
`"<from>-><to>"`. The runner selects the shortest contiguous chain, runs
each script inside the same staged-tmp transaction as the diff apply, and
rolls back on throw. Scripts execute in a sandbox whose `fs` is rooted at
the staged tmp directory; outside-tmp filesystem writes are prevented by
the `MutableInstallerFs` boundary.
### Registry sources
Pluggable `RegistrySource` interface with four built-in implementations:
| Source | Use |
|---|---|
| `verdaccio` (default) | Resolve via `node_modules` after `bun add`; matches existing behaviour. |
| `oci` | Pull plugin tarball from an OCI artifact registry (`ghcr.io`, `Harbor`). |
| `git` | Clone a git ref and treat the checkout as the plugin package. |
| `local-tarball` | Install from a local `.tgz` path — air-gapped use. |
Auth via `NPM_TOKEN` (Verdaccio) and `OCI_TOKEN` (OCI); each source
implements its own `auth()` hook.
### Atomicity + rollback
- Snapshot-before-mutate via move-aside journal (cf. archived design.md §"Atomicity model").
- Transactional apply: staged-tmp + atomic rename per file (existing
`INSTALLER-04` model, extended to `uninstall` and `upgrade`).
- Automatic rollback on any apply / migration / state-write failure.
- Journaled state for crash recovery: `installer-journal.log` records
txn-start / txn-commit / txn-abort lines fsync'd between phases so
`doctor` can detect interrupted runs and offer recovery.
### Diagnostics
`doctor` runs every check below in parallel, returns a `DoctorReport`
listing failures with severity + remediation hint:
- stale state entries (plugin uninstalled out-of-band),
- broken symlinks under any recorded `targetDir`,
- version mismatch between state and `node_modules`,
- registry connectivity (ping per configured registry),
- dependency cycles in the installed plugin graph,
- missing or stale lockfile,
- interrupted transactions (journal mid-state).
### Telemetry
Every public library method opens an OTel span named
`installer.<method>` carrying `plugin.id`, `plugin.version`, `dry_run`,
and (where relevant) `plugin.version.from` / `plugin.version.to`.
Every state transition emits a typed event on the injected
`EventBusLike`: `installer.installed`, `installer.upgraded`,
`installer.reinstalled`, `installer.uninstalled`, `installer.pinned`,
`installer.unpinned`, `installer.frozen`, `installer.doctor.completed`.
### Error codes
Full `INSTALLER.*` registry via `defineCodes`, extending the existing nine
codes (preserved) with one code per failure mode introduced by the new
surface. Enumeration lives in `design.md` §"Error codes"; the registry
remains frozen and namespaced `INSTALLER.*`.
### Tests
100% istanbul coverage gated by `bunx turbo test:coverage`. Real services
only (CLAUDE.md rule 2): a testcontainers Verdaccio fixture lives under
`src/testing/fixtures/registry/` and powers every install / upgrade /
search test. Fixture plugins under `src/testing/fixtures/plugins/` exercise
every dependency / migration / lifecycle-hook branch.
## Source mapping
| Source / reference | What it tells us |
|---|---|
| `openspec/archive/2026-04-28-installer-port/proposal.md` | Original port: lifecycle, conflict policies, manifest, `INSTALLER_CODES`, testing harness — all reused. |
| `openspec/archive/2026-04-28-installer-port/specs/installer/spec.md` (folded as `openspec/specs/installer/spec.md`) | Canonical `INSTALLER-01`…`INSTALLER-09` requirements: preserved verbatim and extended, never replaced. |
| `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/proposal.md` | Upgrade flow design (state file, dirty-tree gate, lockfile preflight, migration scripts). Rolled into this proposal §"What Changes". |
| `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/design.md` | Atomicity model (move-aside journal, BLAKE3 hashing, snapshot strategy). Reused as the foundation for `upgrade`, `uninstall`, and `reinstall`. |
| `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/specs/installer/spec.md` | Upgrade requirements (`INSTALLER-UPGRADE-01`…`12`). Carried into this change's delta and supplemented with `install`/`uninstall`/`reinstall`/`doctor`/`pin`/`unpin`/`freeze`/`update`/`search`/`info` requirements. |
| `packages/tooling/installer/src/bin/suaveplan-install.ts` | Existing CLI shape (`app.command("add <plugin>")…`). New verbs attach the same way; `runAdd` becomes `runInstall` with `add` aliased. |
| `packages/tooling/installer/src/host/host.ts` | `Host` orchestration class — kept as the per-plugin seam; wrapped by the new `Installer` class for the multi-plugin operations. |
| `packages/tooling/installer/src/host/apply/apply.ts` | Staged-tmp + atomic-rename transaction. Reused unchanged by `upgrade`, `uninstall`, `reinstall`. |
| `packages/tooling/installer/src/manifest/discover.ts` | `discoverPlugins` + `findPlugin` — extended to consult the active `RegistrySource` rather than only `node_modules`. |
| `packages/tooling/installer/src/error-codes.ts` | Existing frozen registry — appended to, never replaced. |
| `packages/tooling/installer/src/fs/installer-fs.ts` | `MutableInstallerFs` boundary — reused as the sandbox surface for migration scripts. |
| `packages/tooling/install-agent-skills/src/plugin.ts` | Sibling plugin: gains `preInstall`/`postUninstall` hooks and an empty `upgrade.ts` table so every new lifecycle / migration code path is exercised by a real consumer. |
(No `/mnt/s/web/suaveplan/enterprise/installer.md` exists on main — the
installer is genesis-original. No `/mnt/s/web/suaveplan/packages/installer/`
exists. Authoritative design intent lives in the archived port + the
superseded upgrade change, both cited above.)
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/installer` to enumerate. Current
dependents:
- `@suaveplan/install-agent-skills` — [x] tests run / [x] docs reviewed /
[ ] no action needed. The skills plugin gains optional `preInstall`,
`postUninstall`, and an empty `upgrade.ts` migration table so every new
lifecycle and migration-loader code path is exercised by a real consumer.
Its conformance harness extends from `runPlugin` to also cover
`upgradePlugin`, `uninstallPlugin`, and `reinstallPlugin`.
No other dependents currently consume `@suaveplan/installer`.
## Migration
This change introduces consumer-visible behaviour changes for any
caller that was using the v0.0 surface directly:
- `add` CLI verb is retained as an alias of `install`; existing scripts
keep working.
- `Host.run` is retained verbatim; `Installer.install` is the new
multi-plugin orchestrator that internally calls `Host.run` per plugin.
- The state file is new — first `install` against an existing tree
silently records hashes; subsequent operations gate on them.
- `INSTALLER.*` registry gains codes; consumers asserting on the existing
nine codes are unaffected (registry only grows).
Search command for downstream callers:
```bash
grep -rln "@suaveplan/installer" packages/ | grep -v "/dist/\|/coverage/\|/installer/"
```
Recommended replacement when migrating from `Host.run` directly to the
multi-plugin surface: instantiate `new Installer({ ctx })` and call
`installer.install(pluginId, opts)` — it handles dependency resolution,
state persistence, and lockfile updates that `Host.run` does not.
## Out of scope
Only the items genuinely outside installer identity:
- **Publishing plugins to a registry.** That is `bun scripts/publish.ts`
(the monorepo's publish pipeline); the installer consumes registries,
it does not write to them.
- **`create-suaveplan` thin wrapper bin** that bundles installer + a
curated default plugin set. Packaging concern; future change.
- **Background / scheduled auto-upgrade daemon.** Operator-initiated only;
schedulers are out of scope (use cron + `update --all`).
- **Cross-registry mirroring / proxy.** The installer reads from one active
`RegistrySource` per run; mirroring is the registry's concern.
- **Downgrade.** The diff engine is directional (`from`/`to` semver); a
downgrade requires the reverse migration chain and is a separate
proposal once a real consumer needs it.
## Verification
Every requirement in `specs/installer/spec.md` carries a `**Verified by:**`
line. Conformance tests live under:
- `src/installer/installer.spec-conformance.test.ts` — multi-plugin
orchestration (`install`, `reinstall`, `uninstall`, `update`, `list`,
`info`, `pin`, `unpin`, `freeze`, `search`).
- `src/installer/doctor.spec-conformance.test.ts` — every diagnostic check.
- `src/host/upgrade/*.spec-conformance.test.ts` — upgrade pipeline,
dirty-tree, preflight, diff, migrations.
- `src/state/state-file.spec-conformance.test.ts` — atomic write, schema.
- `src/state/journal.spec-conformance.test.ts` — crash-recovery journal.
- `src/state/lockfile.spec-conformance.test.ts` — deterministic lockfile.
- `src/registry/*.spec-conformance.test.ts` — every registry source.
- `src/bin/suaveplan-install.spec-conformance.test.ts` — every CLI verb
forwards every flag.
- `src/error-codes.spec-conformance.test.ts` — full frozen registry.
## Acceptance criteria
- `bunx turbo lint typecheck test --filter=@suaveplan/installer` green.
- `bunx turbo test:coverage --filter=@suaveplan/installer` 100% istanbul
on every module under `src/installer/`, `src/host/upgrade/`,
`src/state/`, `src/registry/`, and the additions to `src/bin/`.
- Sibling `.md` for every new implementation `.ts` per AGENTS.md §4.
- `openspec validate complete-installer --strict` green.
- Spec delta folded into `openspec/specs/installer/spec.md` on archive.
- `bunx turbo test --filter=...@suaveplan/installer` green (dependent
sweep validates `@suaveplan/install-agent-skills` against the new
surface).

View File

@@ -0,0 +1,597 @@
<!-- 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"`
---

View File

@@ -0,0 +1,245 @@
# Tasks: complete-installer
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Error codes
- [ ] 1.1 Extend `src/error-codes.ts` `defineCodes("INSTALLER", [...])` tuple
with every code listed in `design.md` §"Error codes" (existing nine,
seven upgrade codes from the superseded change, eighteen new codes).
- [ ] 1.2 Update `src/error-codes.md` to enumerate every code with one-line
semantic description.
- [ ] 1.3 Rewrite `src/error-codes.spec-conformance.test.ts` to assert every
code value is present, frozen, and `INSTALLER.`-prefixed
(`MODIFIED: registry contains every complete-installer code`).
## 2. State + lockfile + journal (`src/state/`)
- [ ] 2.1 `src/state/state.types.ts` — `InstallerState`, `InstalledPlugin`,
`FileHash`, named option / report types.
- [ ] 2.2 `src/state/state.schemas.ts` — Zod schemas validating the on-disk
shape.
- [ ] 2.3 `src/state/state-file.ts` — `loadState`, `writeState` (atomic
write → fsync → rename), `updatePlugin`, `removePlugin`. Throws
`INSTALLER.STATE_INVALID` on schema failure.
- [ ] 2.4 `src/state/state-file.spec-conformance.test.ts` covering atomic
write and load-validation paths.
- [ ] 2.5 `src/state/hash-tree.ts` — `hashTree(fs, dir)` via BLAKE3
(`@suaveplan/crypto-hash`).
- [ ] 2.6 `src/state/journal.ts` — append-only newline-JSON journal with
`beginTxn`, `stage`, `commit`, `abort`, `replay`. Throws
`INSTALLER.JOURNAL_REPLAY_FAILED` on malformed lines.
- [ ] 2.7 `src/state/journal.spec-conformance.test.ts`.
- [ ] 2.8 `src/state/lockfile.ts` — `loadLockfile`, `writeLockfile`
(deterministic sorted keys, integrity digest). Throws
`INSTALLER.LOCKFILE_INVALID` / `INSTALLER.LOCKFILE_WRITE_FAILED`.
- [ ] 2.9 `src/state/lockfile.spec-conformance.test.ts` (byte-identical
round-trip).
- [ ] 2.10 Sibling `.md` for every new file.
## 3. Registry sources (`src/registry/`)
- [ ] 3.1 `src/registry/registry.types.ts` — `RegistrySource` interface,
`ResolvedPlugin`, `SearchHit`, `SearchOptions`.
- [ ] 3.2 `src/registry/registry.schemas.ts` — Zod boundary schemas.
- [ ] 3.3 `src/registry/verdaccio/verdaccio.ts` — default source, reuses
`discoverPlugins`; adds `search` + version listing over HTTP.
- [ ] 3.4 `src/registry/oci/oci.ts` — OCI tarball pull, `OCI_TOKEN`.
- [ ] 3.5 `src/registry/git/git.ts` — `git clone --depth 1`.
- [ ] 3.6 `src/registry/local-tarball/local-tarball.ts` — tar-extract.
- [ ] 3.7 `src/registry/index.ts` — explicit named re-exports.
- [ ] 3.8 `*.spec-conformance.test.ts` per source (4 files).
- [ ] 3.9 `src/testing/fixtures/registry/verdaccio-container.ts` —
testcontainers Verdaccio fixture + fixture plugin catalogue.
- [ ] 3.10 Sibling `.md` for every new file.
## 4. Plugin host extensions (`src/host/hooks/`, `src/host/upgrade/`)
- [ ] 4.1 `src/host/hooks/hooks.ts` — invoke `preInstall`/`postInstall`/
`preUpgrade`/`postUpgrade`/`preUninstall`/`postUninstall` with OTel
spans + rollback-on-throw.
- [ ] 4.2 `src/host/hooks/hooks.spec-conformance.test.ts`.
- [ ] 4.3 Extend `InstallerPlugin` interface in
`src/types/installer.types.ts` with `capabilities`, `dependsOn`,
and the six new hook fields.
- [ ] 4.4 `src/host/upgrade/upgrade.ts` — full pipeline per spec.
- [ ] 4.5 `src/host/upgrade/preflight.ts` — lockfile + dirty-tree gates.
- [ ] 4.6 `src/host/upgrade/diff.ts` + `materialise` helpers.
- [ ] 4.7 `src/host/upgrade/migrations.ts` + `migrations.types.ts` —
`MigrationContext`, `MigrationTable`, `loadMigrationTable`,
`selectMigrations` (BFS), `runMigrations` (transactional).
- [ ] 4.8 Upgrade `*.spec-conformance.test.ts` files (`upgrade`,
`preflight`, `dirty-tree`, `apply-diff`, `migrations`).
- [ ] 4.9 Sibling `.md` for every new file.
## 5. Installer class + commands (`src/installer/`)
- [ ] 5.1 `src/installer/installer.ts` — `Installer` class wrapping
`Host`, exposing every documented method. ≤300 lines: factor each
command into its own file.
- [ ] 5.2 `src/installer/install.ts` — `install` orchestrator: resolve
registry → discover plugin → resolve deps → host run → write state
→ write lockfile.
- [ ] 5.3 `src/installer/reinstall.ts` — force-reapply at recorded
version. Throws `REINSTALL_NOT_INSTALLED`.
- [ ] 5.4 `src/installer/uninstall.ts` — gate dirty-tree, run hooks,
delete recorded files transactionally, drop state + lockfile
entry. Throws `UNINSTALL_NOT_INSTALLED`, `UNINSTALL_DIRTY_TREE`.
- [ ] 5.5 `src/installer/list.ts` — enumerate with drift status.
- [ ] 5.6 `src/installer/info.ts` — manifest + state + transitive
dependency graph. Throws `INFO_NOT_INSTALLED`.
- [ ] 5.7 `src/installer/pin.ts` — set `pinned: true, resolved`.
- [ ] 5.8 `src/installer/unpin.ts` — clear `pinned` flag.
- [ ] 5.9 `src/installer/freeze.ts` — deterministic lockfile snapshot.
- [ ] 5.10 `src/installer/update.ts` — `--all` bulk upgrade with
partial-failure aggregation. Throws `UPDATE_PARTIAL_FAILURE`.
- [ ] 5.11 `src/installer/search.ts` — delegate to active registry.
Throws `REGISTRY_UNREACHABLE`.
- [ ] 5.12 `src/installer/doctor.ts` — every diagnostic check in
parallel; `--fix` for journal rollback only.
- [ ] 5.13 `src/installer/dependency-graph.ts` — Tarjan cycle detect +
topological sort. Throws `DEPENDENCY_CYCLE`,
`DEPENDENCY_UNSATISFIED`.
- [ ] 5.14 Conformance tests:
- [ ] 5.14.1 `src/installer/installer.spec-conformance.test.ts`
(install, reinstall, uninstall, list, info, pin/unpin, freeze,
update, search, dependency-cycle, OTel span, event-bus).
- [ ] 5.14.2 `src/installer/doctor.spec-conformance.test.ts`.
- [ ] 5.15 OTel span instrumentation on every public method:
`installer.<method>` with documented attributes + error recording.
- [ ] 5.16 Typed event-bus emission on every state-mutating method.
- [ ] 5.17 `src/installer/index.ts` — explicit named re-exports.
- [ ] 5.18 Sibling `.md` for every new file.
## 6. CLI bin (`src/bin/`)
- [ ] 6.1 Extend `src/bin/suaveplan-install.ts` to register every verb:
`install`, `reinstall`, `upgrade`, `uninstall`, `list`, `info`,
`doctor`, `pin`, `unpin`, `freeze`, `update`, `search`. Keep `add`
as an alias of `install`.
- [ ] 6.2 Parse every documented global flag (`--dry-run`, `--force`,
`--no-interactive`, `--answers <file>`, `--cwd <dir>`,
`--registry <name>`, `--json`) and verb-specific flags from
design.md.
- [ ] 6.3 `--json` output path: emit the report object verbatim.
- [ ] 6.4 `src/bin/suaveplan-install.spec-conformance.test.ts`
asserting every verb forwards every flag (recording `Installer`
stub).
- [ ] 6.5 Update `src/bin/suaveplan-install.md`.
## 7. Type / schema additions (`src/types/`, `src/schemas/`)
- [ ] 7.1 Extend `src/types/installer.types.ts` with `InstallerOptions`,
`InstallReport`, `UpgradeReport`, `UninstallReport`,
`InstalledPluginInfo`, `PluginInfoReport`, `DoctorReport`,
`DoctorFinding`, `LockfileReport`, `UpdateReport`, `SearchHit`,
`SearchOptions`, lifecycle hook signatures, dependency types.
- [ ] 7.2 Extend `src/schemas/` with Zod validators for every new
report / option type.
- [ ] 7.3 No inline shapes anywhere (AGENTS.md §11).
## 8. Subpath exports + barrels
- [ ] 8.1 `package.json` `exports` map gains one subpath per new feature
module: `./installer/installer`, `./installer/install`,
`./installer/reinstall`, `./installer/uninstall`, `./installer/list`,
`./installer/info`, `./installer/pin`, `./installer/unpin`,
`./installer/freeze`, `./installer/update`, `./installer/search`,
`./installer/doctor`, `./installer/dependency-graph`,
`./state/state-file`, `./state/journal`, `./state/lockfile`,
`./state/hash-tree`, `./registry`, `./registry/verdaccio`,
`./registry/oci`, `./registry/git`, `./registry/local-tarball`,
`./host/upgrade`, `./host/hooks`, `./migrations`.
- [ ] 8.2 Every `<feature>/index.ts` uses explicit named re-exports
(AGENTS.md §6a — no `export *`).
- [ ] 8.3 Package-root `src/index.ts` re-exports the `Installer` class
plus every named report type for the top-level surface.
## 9. Testing fixtures (`src/testing/fixtures/`)
- [ ] 9.1 `src/testing/fixtures/plugins/base-plugin.ts` — simple plugin
no dependencies, exercises the install / uninstall path.
- [ ] 9.2 `src/testing/fixtures/plugins/dependent-plugin.ts` —
depends on `base-plugin`, exercises topological resolution.
- [ ] 9.3 `src/testing/fixtures/plugins/cyclic-a-plugin.ts` +
`cyclic-b-plugin.ts` — exercise `DEPENDENCY_CYCLE`.
- [ ] 9.4 `src/testing/fixtures/plugins/upgrade-fixture-plugin.ts` —
`1.0.0` and `2.0.0` variants with one migration script.
- [ ] 9.5 `src/testing/fixtures/plugins/hooks-fixture-plugin.ts` —
every lifecycle hook implemented for hook-order tests.
## 10. Docs
- [ ] 10.1 Every new implementation `.ts` has a sibling `.md` meeting
AGENTS.md §4 (≥200 words, Purpose / Features / Basic / Advanced /
API Reference / Implementation Notes, both examples compile).
- [ ] 10.2 Update root `packages/tooling/installer/README.md` to
enumerate every verb, link to per-module docs in the **Modules**
section, and show a quickstart for `install`, `upgrade`,
`uninstall`, `doctor`, `search`.
- [ ] 10.3 No `TODO`, `FIXME`, `@deprecated`, marketing language, or
stub-doc placeholders anywhere (AGENTS.md §2b).
## 11. Dependent sweep + impact
- [ ] 11.1 Add empty `upgrade.ts` `MigrationTable` export to
`@suaveplan/install-agent-skills` and wire it through the
manifest so the migration-loader path runs even with zero
migrations.
- [ ] 11.2 Add optional `preInstall` and `postUninstall` hooks to
`@suaveplan/install-agent-skills` so hooks-fixture path is
exercised by a real consumer.
- [ ] 11.3 Extend `install-agent-skills` conformance harness with
`upgradePlugin`, `uninstallPlugin`, `reinstallPlugin` helpers
mirroring `runPlugin`.
- [ ] 11.4 `bunx turbo test --filter=...@suaveplan/installer` green.
## 12. Pre-archive gates (AGENTS.md §13)
- [ ] 12.1 `bunx turbo lint typecheck test --filter=@suaveplan/installer`
green.
- [ ] 12.2 `bunx turbo test:coverage --filter=@suaveplan/installer`
100% istanbul on every new module.
- [ ] 12.3 `bun run check` (biome) green.
- [ ] 12.4 Banned-pattern scan (`@deprecated|TODO|FIXME|ts-ignore|
ts-expect-error|.skip|.only`) zero output in new files.
- [ ] 12.5 Test-runner-import scan (`from "vitest"` / `from "bun:test"`)
zero output in new files (AGENTS.md §7).
- [ ] 12.6 Barrel-shape scan (no `export *` in feature `index.ts` files)
zero output (AGENTS.md §6a).
- [ ] 12.7 Missing `.md` scan zero output.
- [ ] 12.8 Subpath export count matches feature-module count.
- [ ] 12.9 Bare-version scan zero output (`catalog:` / `workspace:^`).
- [ ] 12.10 File-size scan: every `*.ts` ≤ 300 lines, every `*.test.ts`
≤ 500 lines.
- [ ] 12.11 `bunx turbo build --filter=@suaveplan/installer` green.
## 13. Root-doc refresh + markdownlint (AGENTS.md §13b)
- [ ] 13.1 `bun scripts/folder-reorg/generate-manifest.ts` to refresh
`MANIFEST.md`.
- [ ] 13.2 `bun scripts/generate-missing.ts` to refresh `MISSING.yml`.
- [ ] 13.3 Update `STATUS.md` installer row (already Completed; refresh
surface description).
- [ ] 13.4 Update `SUMMARY.md` installer paragraph to enumerate every
verb + library surface.
- [ ] 13.5 `bunx markdownlint-cli2 --fix` against touched markdown,
resolve any unfixable violations by hand.
## 14. Archive (AGENTS.md §14)
- [ ] 14.1 Fold `specs/installer/spec.md` delta into
`openspec/specs/installer/spec.md` (modified `INSTALLER-09`
replaces the existing one verbatim; every ADDED requirement
appends).
- [ ] 14.2 Delete the superseded folder
`openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/`
since its content is now subsumed.
- [ ] 14.3 Move `openspec/changes/complete-installer/` →
`openspec/archive/<yyyy-mm-dd>-complete-installer/`.
- [ ] 14.4 `openspec validate complete-installer --strict` green prior
to archive.