Initial Commit
This commit is contained in:
298
openspec/changes/complete-installer/proposal.md
Normal file
298
openspec/changes/complete-installer/proposal.md
Normal 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).
|
||||
Reference in New Issue
Block a user