363 lines
13 KiB
Markdown
363 lines
13 KiB
Markdown
# 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.
|