13 KiB
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
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
{
"$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
{
"$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
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
- Build the plugin graph from
dependsOndeclarations in every installed plugin's state entry plus the target plugin's manifest. - Run Tarjan's algorithm for cycle detection — cycle →
INSTALLER.DEPENDENCY_CYCLE. - Topological-sort the DAG; install / upgrade in dependency order (leaves first), uninstall in reverse (consumers first).
- For each dependency edge, check the installed version satisfies the
declared semver range — unsatisfied →
INSTALLER.DEPENDENCY_UNSATISFIEDwithcontext: { dependent, dependency, required, installed? }.
Rollback journal format
.suaveplan/installer-journal.log — append-only, one JSON object per line:
{ "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
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
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_moduleswalk + Verdaccio HTTP forsearchand version listing (existingdiscoverPluginsreused).oci/oci.ts—oci-distributionHTTP,OCI_TOKENauth.git/git.ts—git clone --depth 1 <url> <ref>then treat checkout as the plugin package.local-tarball/local-tarball.ts—tar -xan 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
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-hashships 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/grepit during incident response; the parser is onefor await line of readline.createInterface(fs.createReadStream(...)). - Testcontainers Verdaccio for tests. CLAUDE.md rule 2 forbids
mocks; the Verdaccio container starts in
<10sand ships a tiny fixture registry of 4 plugins. Per-test publish/unpublish is supported. InstallerwrapsHost, does not replace it. ExistingHost.runcallers (the v0.0 surface) keep working; new multi-plugin operations go throughInstaller.- No network resolution by default.
verdacciosource defaults to the existingnode_moduleswalk; opting intooci/git/local-tarballis explicit. Air-gapped CI stays air-gapped.