Files
2026-05-27 23:29:53 +01:00

363 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.