# 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 ` | `installer.install(id, opts)` | `--dry-run --force --no-interactive --answers --only --exclude --registry --cwd --json` | `PLUGIN_NOT_FOUND`, `APPLY_*`, `DEPENDENCY_*` | | `reinstall ` | `installer.reinstall(id, opts)` | global + `--keep-state` | `REINSTALL_NOT_INSTALLED`, `APPLY_*` | | `upgrade ` | `installer.upgrade(id, opts)` | global + `--from --to --allow-dirty` | `UPGRADE_*` | | `uninstall ` | `installer.uninstall(id, opts)` | global + `--allow-dirty --purge` | `UNINSTALL_NOT_INSTALLED`, `UNINSTALL_DIRTY_TREE` | | `list` | `installer.list()` | `--json` | — | | `info ` | `installer.info(id)` | `--json` | `INFO_NOT_INSTALLED` | | `doctor` | `installer.doctor()` | `--json --fix` | — | | `pin ` | `installer.pin(id, ver)` | global | `PIN_VERSION_INVALID`, `PIN_NOT_INSTALLED` | | `unpin ` | `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 ` | `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; public reinstall(id: string, opts?: ReinstallOptions): Promise; public upgrade(id: string, opts?: UpgradeOptions): Promise; public uninstall(id: string, opts?: UninstallOptions): Promise; public list(): Promise>; public info(id: string): Promise; public doctor(opts?: DoctorOptions): Promise; public pin(id: string, version: string): Promise; public unpin(id: string): Promise; public freeze(): Promise; public update(opts?: UpdateOptions): Promise; public search(query: string, opts?: SearchOptions): Promise>; } ``` 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": { "": { "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": { "": { "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 { readonly id: string; readonly version: string; readonly capabilities?: ReadonlyArray; readonly dependsOn?: Readonly>; detect?(ctx: InstallerContext): Promise; prompts?(ctx: InstallerContext): PromptSchema; plan(answers: TAnswers, ctx: InstallerContext): Promise; apply(ops: FileOp[], ctx: InstallerContext): Promise; preInstall?(ctx: InstallerContext): Promise; postInstall?(handle: THandle, ctx: InstallerContext): Promise; preUpgrade?(from: string, to: string, ctx: InstallerContext): Promise; postUpgrade?(from: string, to: string, ctx: InstallerContext): Promise; preUninstall?(ctx: InstallerContext): Promise; postUninstall?(ctx: InstallerContext): Promise; } ``` Every lifecycle hook is optional and wrapped in an OTel span named `installer.` 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; 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${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; resolve(id: string, range?: string): Promise; fetch(resolved: ResolvedPlugin): Promise; // tarball bytes search(query: string, opts?: SearchOptions): Promise>; } ``` 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 ` 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 `/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.