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,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).