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

5.5 KiB

Installer — Spec

Requirements

INSTALLER-01 — Lifecycle order

Host.run(plugin, options) MUST execute plugin stages in the strict order detect → prompts → plan → apply → postInstall. Optional stages (detect, prompts, postInstall) MUST be skipped when not declared on the plugin without affecting the order of the remaining stages. Each stage MUST be wrapped in an OpenTelemetry span named installer.<stage> carrying attributes plugin.id, plugin.version, and dry_run.

Verified by: src/host/host.spec-conformance.test.ts (INSTALLER-01).

INSTALLER-02 — Idempotent re-run with conflict policy

Running the same plugin twice against the same target MUST be safe. On the second run, every emitted FileOp MUST be resolved against its declared conflict policy (error | overwrite | skip-if-exists | merge | prompt). The default conflict policy MUST be error. The --force flag MUST collapse all policies to overwrite. The --no-interactive flag MUST cause prompt policies to fail with INSTALLER.APPLY_CONFLICT instead of blocking.

Verified by: src/host/host.spec-conformance.test.ts (INSTALLER-02), src/host/apply/apply.spec-conformance.test.ts (INSTALLER-05).

INSTALLER-03 — Abort propagation

InstallerContext.signal: AbortSignal MUST be threaded through every stage. When the signal aborts mid-run, the host MUST stop the current stage at the next async boundary, roll back any partially applied ops, and surface InstallerError(INSTALLER.ABORTED). Plugins receiving the signal MUST honour it; the host MUST NOT silently swallow AbortError.

Verified by: src/host/host.spec-conformance.test.ts (INSTALLER-03).

INSTALLER-04 — Transactional apply with rollback

apply MUST materialise FileOp[] through a staged-tmp + atomic-rename strategy: every write and patch-* op writes to a sibling tmp path under the target directory, then renames into place only after every op in the plan has staged successfully. Any failure during staging MUST trigger a reverse-order rollback that deletes the staged tmp files and restores any pre-existing files moved aside. Rollback failures MUST surface as InstallerError(INSTALLER.ROLLBACK_FAILED, { cause }) carrying the original apply error as nested cause. --dry-run MUST print the plan and return before any tmp files are written.

Verified by: src/host/apply/apply.spec-conformance.test.ts (INSTALLER-04).

INSTALLER-05 — Conflict policy resolution

For each FileOp whose target path already exists, the host MUST consult the op's conflict field and: error → throw INSTALLER.APPLY_CONFLICT; overwrite → replace; skip-if-exists → drop the op silently and record it in the plan summary; merge → apply JsonMerge for patch-json and TextEdit[] for patch-text (and is rejected for raw write ops with INSTALLER.APPLY_CONFLICT); prompt → ask via InstallerCli.confirm, defaulting to error semantics on rejection or in --no-interactive mode.

Verified by: src/host/apply/apply.spec-conformance.test.ts (INSTALLER-05).

INSTALLER-06 — Prompt validation

Host.runPrompts(plugin, ctx) MUST Zod-validate the answers produced by plugin.prompts?.() before passing them to plan. Validation failures MUST surface as InstallerError(INSTALLER.PROMPT_VALIDATION_FAILED, { cause: zodIssue }). When --answers <file> is supplied, the host MUST load the file, validate against the same schema, and bypass interactive prompting entirely. --no-interactive MUST cause any plugin that demands additional prompts (beyond what --answers supplied) to fail with INSTALLER.PROMPT_VALIDATION_FAILED.

Verified by: src/host/prompts.spec-conformance.test.ts (INSTALLER-06).

INSTALLER-07 — Manifest schema is enforced at load time

loadManifest(packagePath) MUST resolve the plugin package's manifest.json and validate it against PluginManifestSchema. Required fields: id (string), version (semver), description (string), entry (relative module path), engines (Record<string, string>), assets (string[]). Optional fields: peerInstallers (string[]). Validation failures MUST surface as InstallerError(INSTALLER.PLUGIN_INVALID_MANIFEST, { cause }).

Verified by: src/manifest/manifest.spec-conformance.test.ts (INSTALLER-07).

INSTALLER-08 — Plugin discovery from consumer node_modules

discoverPlugins(cwd) MUST resolve packages matching @suaveplan/install-* (first-party) and @<scope>/suaveplan-install-* (third-party convention) from the consumer's node_modules tree, including hoisted and pnpm-style nested layouts. Missing plugins MUST surface as InstallerError(INSTALLER.PLUGIN_NOT_FOUND) carrying the requested id. Load failures (require/import throws, malformed exports) MUST surface as InstallerError(INSTALLER.PLUGIN_LOAD_FAILED, { cause }).

Verified by: src/manifest/manifest.spec-conformance.test.ts (INSTALLER-08).

INSTALLER-09 — Error-code registry is namespaced and frozen

INSTALLER_CODES MUST be declared via defineCodes("INSTALLER", [...]) from @suaveplan/error. Object.isFrozen(INSTALLER_CODES) MUST be true. Every code value MUST start with INSTALLER.. InstallerError MUST extend SuaveplanError<InstallerErrorCode>. The registry MUST contain at minimum: PLUGIN_NOT_FOUND, PLUGIN_LOAD_FAILED, PLUGIN_INVALID_MANIFEST, PROMPT_VALIDATION_FAILED, PLAN_FAILED, APPLY_CONFLICT, APPLY_FAILED, ROLLBACK_FAILED, ABORTED.

Verified by: src/error-codes.spec-conformance.test.ts (INSTALLER-09).