Initial Commit
This commit is contained in:
362
openspec/changes/complete-installer/design.md
Normal file
362
openspec/changes/complete-installer/design.md
Normal 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.
|
||||
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).
|
||||
597
openspec/changes/complete-installer/specs/installer/spec.md
Normal file
597
openspec/changes/complete-installer/specs/installer/spec.md
Normal 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"`
|
||||
|
||||
---
|
||||
245
openspec/changes/complete-installer/tasks.md
Normal file
245
openspec/changes/complete-installer/tasks.md
Normal 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.
|
||||
Reference in New Issue
Block a user