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

13 KiB
Raw Permalink Blame History

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

  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:

{ "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_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

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.