Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,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.