58 lines
5.5 KiB
Markdown
58 lines
5.5 KiB
Markdown
# 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`).
|