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.
|
||||
Reference in New Issue
Block a user