first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
# @suaveplan/installer - Plugin host + lifecycle for project boilerplating and asset installation: detect → prompts → plan → apply → postInstall, transactional apply with rollback, conflict policies, manifest discovery.
## Summary
Plugin host for project boilerplating and asset installation. Pipelines
plugins through `detect → prompts → plan → apply → postInstall` with
transactional rollback, conflict resolution, and dry-run support.
<!-- begin:auto-docs -->
## Documentation tree
- [bin](./src/bin/suaveplan-install.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [suaveplan-install](./src/bin/suaveplan-install.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [disk-fs](./src/fs/disk-fs.md) — Disk-backed implementation of `MutableInstallerFs` used by the `suaveplan-install` bin and any host-side code that want…
- [installer-fs](./src/fs/installer-fs.md) — Re-exports the read-only `InstallerFs` interface from the types module and declares its mutating extension `MutableInst…
- [memory-fs](./src/fs/memory-fs.md) — In-memory implementation of `MutableInstallerFs`. Used by the testing harness (`runPlugin` constructs one if the caller…
- [host](./src/host/host.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - conflict — Resolves the per-op `conflict` policy on…
- [apply](./src/host/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - conflict — Resolves the per-op `conflict` policy on…
- [apply](./src/host/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - conflict — Resolves the per-op `conflict` policy on…
- [conflict](./src/host/apply/conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./src/host/apply/json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./src/host/apply/text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [detect](./src/host/detect.md) — Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The plugin reports what is already present in the…
- [host](./src/host/host.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - conflict — Resolves the per-op `conflict` policy on…
- [plan](./src/host/plan.md) — Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]` against `FileOpsSchema` before letting them f…
- [post-install](./src/host/post-install.md) — Runs the plugin's optional `postInstall` stage with abort propagation. This is where plugins do follow-up work that doe…
- [prompts](./src/host/prompts.md) — Runs the plugin's optional `prompts` stage and validates the answers against the schema the plugin returns. This is whe…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [discover](./src/manifest/discover.md) — Walks the consumer's `node_modules` tree and returns every installer plugin matching the naming conventions: first-part…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [installer-fs.test-helpers](./src/testing/installer-fs.test-helpers.md) — Convenience helpers for assertions against the in-memory installer fs in plugin tests. `createMemoryFs(seed)` is a thin…
- [run-plugin](./src/testing/run-plugin.md) — End-to-end test harness for plugins. `runPlugin(plugin, options)` wires up an in-memory filesystem, a scripted CLI, a n…
- [script-cli](./src/testing/script-cli.md) — Non-TTY `InstallerCli` driven by pre-recorded answers. `ScriptCli` is the default CLI used by `runPlugin` — tests scrip…
- [error-codes](./src/error-codes.md) — Defines the canonical error-code registry for `@suaveplan/installer` and the `InstallerError` class that carries those…
<!-- end:auto-docs --> - [suaveplan-install](./src/bin/suaveplan-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [disk-fs](./src/fs/disk-fs.md) — Disk-backed implementation of `MutableInstallerFs` used by the `suaveplan-install` bin and any host-side code that want…
- [installer-fs](./src/fs/installer-fs.md) — Re-exports the read-only `InstallerFs` interface from the types module and declares its mutating extension `MutableInst…
- [memory-fs](./src/fs/memory-fs.md) — In-memory implementation of `MutableInstallerFs`. Used by the testing harness (`runPlugin` constructs one if the caller…
- [host](./src/host/host.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [apply](./src/host/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [apply](./src/host/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [conflict](./src/host/apply/conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./src/host/apply/json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./src/host/apply/text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [detect](./src/host/detect.md) — Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The plugin reports what is already present in the…
- [host](./src/host/host.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [plan](./src/host/plan.md) — Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]` against `FileOpsSchema` before letting them f…
- [post-install](./src/host/post-install.md) — Runs the plugin's optional `postInstall` stage with abort propagation. This is where plugins do follow-up work that doe…
- [prompts](./src/host/prompts.md) — Runs the plugin's optional `prompts` stage and validates the answers against the schema the plugin returns. This is whe…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [discover](./src/manifest/discover.md) — Walks the consumer's `node_modules` tree and returns every installer plugin matching the naming conventions: first-part…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [installer-fs.test-helpers](./src/testing/installer-fs.test-helpers.md) — Convenience helpers for assertions against the in-memory installer fs in plugin tests. `createMemoryFs(seed)` is a thin…
- [run-plugin](./src/testing/run-plugin.md) — End-to-end test harness for plugins. `runPlugin(plugin, options)` wires up an in-memory filesystem, a scripted CLI, a n…
- [script-cli](./src/testing/script-cli.md) — Non-TTY `InstallerCli` driven by pre-recorded answers. `ScriptCli` is the default CLI used by `runPlugin` — tests scrip…
- [error-codes](./src/error-codes.md) — Defines the canonical error-code registry for `@suaveplan/installer` and the `InstallerError` class that carries those…
- [suaveplan-install](./src/bin/suaveplan-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [disk-fs](./src/fs/disk-fs.md) — Disk-backed implementation of `MutableInstallerFs` used by the `suaveplan-install` bin and any host-side code that want…
- [installer-fs](./src/fs/installer-fs.md) — Re-exports the read-only `InstallerFs` interface from the types module and declares its mutating extension `MutableInst…
- [memory-fs](./src/fs/memory-fs.md) — In-memory implementation of `MutableInstallerFs`. Used by the testing harness (`runPlugin` constructs one if the caller…
- [host](./src/host/host.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [apply](./src/host/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [apply](./src/host/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [conflict](./src/host/apply/conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./src/host/apply/json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./src/host/apply/text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [detect](./src/host/detect.md) — Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The plugin reports what is already present in the…
- [host](./src/host/host.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [plan](./src/host/plan.md) — Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]` against `FileOpsSchema` before letting them f…
- [post-install](./src/host/post-install.md) — Runs the plugin's optional `postInstall` stage with abort propagation. This is where plugins do follow-up work that doe…
- [prompts](./src/host/prompts.md) — Runs the plugin's optional `prompts` stage and validates the answers against the schema the plugin returns. This is whe…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [discover](./src/manifest/discover.md) — Walks the consumer's `node_modules` tree and returns every installer plugin matching the naming conventions: first-part…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [installer-fs.test-helpers](./src/testing/installer-fs.test-helpers.md) — Convenience helpers for assertions against the in-memory installer fs in plugin tests. `createMemoryFs(seed)` is a thin…
- [run-plugin](./src/testing/run-plugin.md) — End-to-end test harness for plugins. `runPlugin(plugin, options)` wires up an in-memory filesystem, a scripted CLI, a n…
- [script-cli](./src/testing/script-cli.md) — Non-TTY `InstallerCli` driven by pre-recorded answers. `ScriptCli` is the default CLI used by `runPlugin` — tests scrip…
- [error-codes](./src/error-codes.md) — Defines the canonical error-code registry for `@suaveplan/installer` and the `InstallerError` class that carries those…
- [suaveplan-install](./src/bin/suaveplan-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [disk-fs](./src/fs/disk-fs.md) — Disk-backed implementation of `MutableInstallerFs` used by the `suaveplan-install` bin and any host-side code that want…
- [installer-fs](./src/fs/installer-fs.md) — Re-exports the read-only `InstallerFs` interface from the types module and declares its mutating extension `MutableInst…
- [memory-fs](./src/fs/memory-fs.md) — In-memory implementation of `MutableInstallerFs`. Used by the testing harness (`runPlugin` constructs one if the caller…
- [host](./src/host/host.md) — <!-- begin:auto-docs --> _(no submodules)_- apply — <!-- begin:auto-docs --> _(no submodules)_…
- [apply](./src/host/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [apply](./src/host/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [conflict](./src/host/apply/conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./src/host/apply/json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./src/host/apply/text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [detect](./src/host/detect.md) — Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The plugin reports what is already present in the…
- [host](./src/host/host.md) — <!-- begin:auto-docs --> _(no submodules)_- apply — <!-- begin:auto-docs --> _(no submodules)_…
- [plan](./src/host/plan.md) — Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]` against `FileOpsSchema` before letting them f…
- [post-install](./src/host/post-install.md) — Runs the plugin's optional `postInstall` stage with abort propagation. This is where plugins do follow-up work that doe…
- [prompts](./src/host/prompts.md) — Runs the plugin's optional `prompts` stage and validates the answers against the schema the plugin returns. This is whe…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [discover](./src/manifest/discover.md) — Walks the consumer's `node_modules` tree and returns every installer plugin matching the naming conventions: first-part…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [installer-fs.test-helpers](./src/testing/installer-fs.test-helpers.md) — Convenience helpers for assertions against the in-memory installer fs in plugin tests. `createMemoryFs(seed)` is a thin…
- [run-plugin](./src/testing/run-plugin.md) — End-to-end test harness for plugins. `runPlugin(plugin, options)` wires up an in-memory filesystem, a scripted CLI, a n…
- [script-cli](./src/testing/script-cli.md) — Non-TTY `InstallerCli` driven by pre-recorded answers. `ScriptCli` is the default CLI used by `runPlugin` — tests scrip…
- [error-codes](./src/error-codes.md) — Defines the canonical error-code registry for `@suaveplan/installer` and the `InstallerError` class that carries those…
- [suaveplan-install](./src/bin/suaveplan-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [disk-fs](./src/fs/disk-fs.md) — Disk-backed implementation of `MutableInstallerFs` used by the `suaveplan-install` bin and any host-side code that want…
- [installer-fs](./src/fs/installer-fs.md) — Re-exports the read-only `InstallerFs` interface from the types module and declares its mutating extension `MutableInst…
- [memory-fs](./src/fs/memory-fs.md) — In-memory implementation of `MutableInstallerFs`. Used by the testing harness (`runPlugin` constructs one if the caller…
- [host](./src/host/host.md) — <!-- begin:auto-docs --> _(no submodules)_ - [apply](./src/host/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_ - [apply](./src/host/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_ - [conflict](./src/host/apply/conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./src/host/apply/json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./src/host/apply/text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [detect](./src/host/detect.md) — Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The plugin reports what is already present in the…
- [host](./src/host/host.md) — <!-- begin:auto-docs --> _(no submodules)_ - [plan](./src/host/plan.md) — Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]` against `FileOpsSchema` before letting them f…
- [post-install](./src/host/post-install.md) — Runs the plugin's optional `postInstall` stage with abort propagation. This is where plugins do follow-up work that doe…
- [prompts](./src/host/prompts.md) — Runs the plugin's optional `prompts` stage and validates the answers against the schema the plugin returns. This is whe…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_ - [discover](./src/manifest/discover.md) — Walks the consumer's `node_modules` tree and returns every installer plugin matching the naming conventions: first-part…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [installer-fs.test-helpers](./src/testing/installer-fs.test-helpers.md) — Convenience helpers for assertions against the in-memory installer fs in plugin tests. `createMemoryFs(seed)` is a thin…
- [run-plugin](./src/testing/run-plugin.md) — End-to-end test harness for plugins. `runPlugin(plugin, options)` wires up an in-memory filesystem, a scripted CLI, a n…
- [script-cli](./src/testing/script-cli.md) — Non-TTY `InstallerCli` driven by pre-recorded answers. `ScriptCli` is the default CLI used by `runPlugin` — tests scrip…
- [error-codes](./src/error-codes.md) — Defines the canonical error-code registry for `@suaveplan/installer` and the `InstallerError` class that carries those…
## Quickstart
```bash
# Add a plugin to your project as a dev dep, then run the installer
bun add -D @suaveplan/install-agent-skills
npx @suaveplan/installer add agent-skills
```
## Programmatic use
```ts
import {
Host,
DiskFs,
type InstallerContext,
} from "@suaveplan/installer";
const fs = new DiskFs({ cwd: process.cwd() });
const host = new Host(ctx, fs);
const result = await host.run(myPlugin, {
dryRun: false,
force: false,
noInteractive: true,
});
console.log(result.summary);
```
## Testing harness
```ts
import { runPlugin, ScriptCli } from "@suaveplan/installer";
const result = await runPlugin(myPlugin, {
answers: { name: "agent-skills" },
});
expect(result.summary.written).toBeGreaterThan(0);
```
## Subpath exports
- `@suaveplan/installer` — main barrel.
- `@suaveplan/installer/types` — types-only.
- `@suaveplan/installer/errors` — `INSTALLER_CODES`, `InstallerError`.
- `@suaveplan/installer/host` — `Host`, stage runners, apply/conflict
helpers.
- `@suaveplan/installer/fs` — `DiskFs`, `MemoryFs`, `MutableInstallerFs`.
- `@suaveplan/installer/manifest` — `discoverPlugins`, `findPlugin`,
`loadManifest`, `validateManifest`.
- `@suaveplan/installer/testing` — `runPlugin`, `ScriptCli`,
`assertWrote`, `createMemoryFs`.
## Module documentation
Every implementation file ships with a sibling `.md` covering Purpose,
Features, Usage examples, API Reference, and Implementation Notes. See
`src/host/host.md`, `src/host/apply/apply.md`, `src/manifest/discover.md`
for the load-bearing modules.
## License
MIT

View File

@@ -0,0 +1,71 @@
# `suaveplan-install`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The CLI entry point for `@suaveplan/installer`. Exposes two commands:
`add <plugin>` (discovers, loads, and runs a plugin against the current
project) and `list` (enumerates installed plugins by manifest discovery).
The bin wires up a `DiskFs`, a non-interactive `InstallerCli`, a default
template engine, and the host pipeline. SIGINT/SIGTERM handlers connect
to an `AbortController` so Ctrl-C cleanly aborts mid-run with rollback.
## Features
- `add <plugin>` — discovers + dynamically imports the plugin entry,
builds an `InstallerContext`, runs the host, prints the resulting
summary as JSON.
- `list` — enumerates discovered plugins, printing
`<id>\t<version>\t<package-name>` per line for shell-friendly
pipelines.
- Flags supported on `add`: `--dry-run`, `--force`, `--no-interactive`,
`--only <names>`, `--exclude <names>`, `--answers <file>`.
- SIGINT/SIGTERM → abort — long-running plugins terminate cleanly.
- Wraps the entire `app.run` in OTel span `installer.cli` so trace
collectors capture top-level CLI activity.
## Basic Example
```bash
# Run from a project that has `@suaveplan/install-agent-skills` installed
npx @suaveplan/installer add agent-skills
```
## Advanced Example
```bash
# Dry-run with a curated subset and pre-recorded answers
npx @suaveplan/installer add agent-skills \
--dry-run \
--only crypto,events,logger \
--exclude legacy \
--answers ./install-answers.json
# Force overwrite existing files non-interactively
npx @suaveplan/installer add agent-skills --force --no-interactive
# List discovered plugins for shell scripting
npx @suaveplan/installer list | awk '{print $1}'
```
## API Reference
- `main(argv: ReadonlyArray<string>): Promise<number>` — programmatic
entry, used by both the bin and tests.
- `runAdd(pluginId, flags): Promise<void>` — direct invocation of the
add path; useful for embedding the installer in another tool.
- `runList(): Promise<void>` — direct invocation of the list path.
- Exits 0 on success, 1 on uncaught error.
## Implementation Notes
The bin uses `@suaveplan/cli` for argv parsing — same toolkit used by
the rest of the platform tier. Plugin entries are imported via dynamic
`import(entryPath)`, where `entryPath` is the absolute path resolved
from the discovered manifest's `entry` field. Both `default` and a
named `plugin` export are recognised so plugins can pick whichever style
they prefer. Answers files are JSON; YAML/TOML support would need an
opt-in dep and is deliberately out of scope. The OTel span wraps the
entire run so any spans inside `installer.<stage>` nest underneath it.

View File

@@ -0,0 +1,79 @@
# `error-codes`
## Purpose
Defines the canonical error-code registry for `@suaveplan/installer` and the
`InstallerError` class that carries those codes. Every failure path inside
the host — manifest validation, plugin discovery, prompt validation,
plan validation, conflict resolution, apply, and rollback — surfaces as an
`InstallerError` with a literal-typed `code` drawn from this registry. That
contract makes failures machine-classifiable: callers can write exhaustive
`switch` statements on `code` and add new codes only by extending the
registry, never by string-matching free-form messages.
## Features
- Frozen registry built via `defineCodes("INSTALLER", [...])` from
`@suaveplan/error`, so `Object.isFrozen(INSTALLER_CODES)` is `true` at
module load time.
- All codes are namespaced as `INSTALLER.<REASON>` strings.
- `InstallerError` extends `SuaveplanError<InstallerErrorCode>` so
`instanceof` plus `.code` narrowing remains exhaustive.
- Public widened constructor — callers can `throw new InstallerError(code, key, options)` directly without going through a factory.
- Stable code surface: nine reasons (`PLUGIN_NOT_FOUND`, `PLUGIN_LOAD_FAILED`,
`PLUGIN_INVALID_MANIFEST`, `PROMPT_VALIDATION_FAILED`, `PLAN_FAILED`,
`APPLY_CONFLICT`, `APPLY_FAILED`, `ROLLBACK_FAILED`, `ABORTED`).
## Basic Example
```ts
import { INSTALLER_CODES, InstallerError } from "@suaveplan/installer";
throw new InstallerError(
INSTALLER_CODES.PLUGIN_NOT_FOUND,
"installer.plugin.not_found",
{ context: { id: "agent-skills" } }
);
```
## Advanced Example
```ts
import { InstallerError } from "@suaveplan/installer";
function classify(err: unknown): string {
if (!(err instanceof InstallerError)) return "unknown";
switch (err.code) {
case "INSTALLER.PLUGIN_NOT_FOUND":
return "missing-plugin";
case "INSTALLER.APPLY_CONFLICT":
return "conflict";
case "INSTALLER.ROLLBACK_FAILED":
return "fatal-rollback";
default:
return "other";
}
}
```
## API Reference
- `INSTALLER_CODES` — frozen `{ [K]: "INSTALLER.K" }` map. Read-only at
runtime and at the type level.
- `type InstallerErrorCode` — union of every `INSTALLER.*` literal in the
registry.
- `class InstallerError extends SuaveplanError<InstallerErrorCode>` —
carries `code`, `message`, optional `cause`, `context`, `timestamp`, and
`traceId` from the base class.
## Implementation Notes
`defineCodes` freezes the returned object so consumers cannot mutate the
registry — this matches the conformance assertion in
`error-codes.spec-conformance.test.ts` (`INSTALLER-09`). The
`InstallerError` constructor sets `this.name = "InstallerError"` because
`SuaveplanError`'s default `new.target.name` is reset by some bundlers and
the explicit assignment guarantees stable serialised names. Every throw
inside this package should pick a code from this registry; throwing a bare
`Error` from internal code is treated as a regression by the conformance
suite.

View File

@@ -0,0 +1,66 @@
# `errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Re-export shim for the `./errors` subpath. Exposes `INSTALLER_CODES`,
`InstallerError`, and the `InstallerErrorCode` type from the canonical
`error-codes.ts` so callers who import via the `./errors` package subpath
get the same surface as `import { InstallerError } from "@suaveplan/installer"`. This keeps the spec's `./errors` subpath
documented in one place even though the implementation lives under
`error-codes.ts`.
## Features
- Stable subpath surface: every consumer who imports `@suaveplan/installer/errors` gets the same trio.
- No runtime cost — pure re-export, tree-shaken by Vite when unused.
- Keeps the canonical implementation in `error-codes.ts` so test, doc,
and conformance suites all point at the same file.
## Basic Example
```ts
import {
INSTALLER_CODES,
InstallerError,
} from "@suaveplan/installer/errors";
throw new InstallerError(
INSTALLER_CODES.ABORTED,
"installer.aborted"
);
```
## Advanced Example
```ts
import type { InstallerErrorCode } from "@suaveplan/installer/errors";
import { InstallerError } from "@suaveplan/installer/errors";
function classify(err: unknown): InstallerErrorCode | "other" {
return err instanceof InstallerError ? err.code : "other";
}
```
## API Reference
- `INSTALLER_CODES` — re-export of the frozen registry.
- `class InstallerError extends SuaveplanError<InstallerErrorCode>`.
- `type InstallerErrorCode` — union of every `INSTALLER.*` literal.
## Implementation Notes
The re-export pattern is deliberate — the spec's `./errors` subpath maps
to a sub-folder so future error utilities (e.g. an OpenTelemetry
formatter, a redaction helper, an error-code-to-exit-code mapping for
the bin) can land in `errors/` without breaking the import path. Today
the file is a one-line shim; once additional helpers move in, this
barrel becomes the canonical bundle. The same trio is also re-exported
from the package root (`@suaveplan/installer`), so callers can pick
whichever subpath best matches their import boundary policy. Subpath
imports are useful when consuming code wants narrower tree-shaking or
when a downstream lint rule restricts non-error utilities from being
imported alongside error types. Bundlers see the re-export as a no-op
and emit no extra modules in the dist output.

View File

@@ -0,0 +1,83 @@
# `disk-fs`
## Purpose
Disk-backed implementation of `MutableInstallerFs` used by the
`suaveplan-install` bin and any host-side code that wants to write to a
real project directory. All reads and writes resolve relative to a single
configurable `cwd`, so an installer run cannot accidentally escape the
target project's root via absolute paths supplied by a third-party plugin.
## Features
- Constructor accepts an explicit `cwd` (defaults to `process.cwd()`) and
an optional `AbortSignal` — every async method short-circuits with an
`AbortError` once the signal aborts.
- `writeFile` and `rename` automatically create intermediate directories
via `mkdir({ recursive: true })`.
- Absolute paths supplied by callers (e.g. `/foo`) are resolved as
*relative* to the configured `cwd`, never escaping it. This is the same
policy `MemoryFs` uses, keeping the two backends interchangeable for
tests vs. production.
- `readFile` returns a true `Uint8Array` view (not a Node `Buffer`), so
consumers that target browsers and servers behave identically.
## Basic Example
```ts
import { DiskFs } from "@suaveplan/installer";
const fs = new DiskFs({ cwd: process.cwd() });
const bytes = await fs.readFile("package.json");
const pkg = JSON.parse(new TextDecoder().decode(bytes)) as {
name: string;
};
console.log(pkg.name);
```
## Advanced Example
```ts
import { DiskFs } from "@suaveplan/installer";
const ac = new AbortController();
process.once("SIGINT", () => ac.abort());
const fs = new DiskFs({ cwd: process.cwd(), signal: ac.signal });
await fs.mkdir(".suaveplan/skills");
await fs.writeJson(".suaveplan/skills/index.json", {
schemaVersion: 1,
skills: [],
});
await fs.writeFile(
".suaveplan/skills/echo/SKILL.md",
new TextEncoder().encode("# echo\n"),
0o644
);
```
## API Reference
- `new DiskFs(options?: { cwd?, signal? })`
- `pathOf(...segments): string` — resolves relative to `cwd`.
- `exists(path): Promise<boolean>` — never throws; missing path returns
`false`.
- `stat(path): Promise<{ size, isFile, isDir }>`
- `readFile(path): Promise<Uint8Array>`
- `readJson<T>(path): Promise<T>`
- `listDir(path): Promise<string[]>` — sorted ascending.
- `writeFile(path, contents, mode?): Promise<void>`
- `deleteFile(path): Promise<void>` — no-op when absent (`force: true`).
- `mkdir(path): Promise<void>` — recursive.
- `rename(from, to): Promise<void>`
- `writeJson(path, value): Promise<void>`
## Implementation Notes
Internally, paths starting with `/` are stripped of the leading slash and
re-resolved against `cwd` so they cannot escape. This is critical: a third
party plugin that planned `{ kind: "write", path: "/etc/passwd" }` would
otherwise overwrite a system file. With the strip-and-resolve policy, the
write lands at `<cwd>/etc/passwd` instead. Consumers who really want to
write outside `cwd` must use `node:fs/promises` directly — the installer
does not provide an escape hatch on purpose.

View File

@@ -0,0 +1,76 @@
# `installer-fs`
## Purpose
Re-exports the read-only `InstallerFs` interface from the types module and
declares its mutating extension `MutableInstallerFs`. Plugins receive a
read-only `InstallerFs` through the context — they can describe writes via
`FileOp[]` but cannot perform them directly. The host uses
`MutableInstallerFs` internally to materialise ops with rollback. Keeping
the read/write split at the interface level is what makes `--dry-run`
trivially correct: a dry-run never resolves the mutating subset.
## Features
- Single source of truth for filesystem capability boundaries inside the
installer.
- `MutableInstallerFs` extends `InstallerFs` with `writeFile`, `deleteFile`,
`mkdir`, `rename`, and `writeJson`.
- Both `MemoryFs` and `DiskFs` implement `MutableInstallerFs`, but only the
read-only view is ever handed to plugin code.
## Basic Example
```ts
import { MemoryFs, type InstallerFs } from "@suaveplan/installer";
async function describe(fs: InstallerFs): Promise<number> {
const entries = await fs.listDir("/");
return entries.length;
}
const fs = new MemoryFs({ seed: { "/a.txt": "hi" } });
await describe(fs);
```
## Advanced Example
```ts
import {
type MutableInstallerFs,
MemoryFs,
} from "@suaveplan/installer";
async function backupThenWrite(
fs: MutableInstallerFs,
path: string,
next: Uint8Array
): Promise<void> {
if (await fs.exists(path)) {
const prior = await fs.readFile(path);
await fs.writeFile(`${path}.bak`, prior);
}
await fs.writeFile(path, next);
}
await backupThenWrite(new MemoryFs(), "/cfg.json", new Uint8Array([123, 125]));
```
## API Reference
- `interface InstallerFs` (re-export from `types/installer.types.ts`):
`exists`, `readFile`, `readJson`, `stat`, `listDir`, `pathOf`.
- `interface MutableInstallerFs extends InstallerFs`:
`writeFile(path, contents, mode?)`, `deleteFile(path)`, `mkdir(path)`,
`rename(from, to)`, `writeJson(path, value)`.
## Implementation Notes
`MutableInstallerFs` is exposed publicly through `./fs` so authors who want
to write a custom backing store (e.g. an S3-backed installer fs) have a
single contract to satisfy. Implementations must honour any `AbortSignal`
they were constructed with — both in-tree implementations call a shared
`ensureNotAborted(signal)` helper at the start of every async operation.
The interface deliberately has no `chmod` or `chown` operations: per-file
mode is carried on the `WriteOp` itself, and ownership is left to the
host filesystem.

View File

@@ -0,0 +1,82 @@
# `memory-fs`
## Purpose
In-memory implementation of `MutableInstallerFs`. Used by the testing
harness (`runPlugin` constructs one if the caller doesn't supply one) and
by anything that needs deterministic, side-effect-free filesystem
behaviour: integration tests, dry-run inspection of large plugins,
fixture seeding for replays. POSIX-style paths only; Windows-style
backslashes are normalised to forward slashes on entry.
## Features
- Seedable from a `Record<string, string | Uint8Array>` map at
construction time — strings are UTF-8 encoded automatically.
- Tracks directories as first-class entries, so `listDir` returns
deterministic, sorted results without sniffing files for shared
prefixes.
- `writeFile`/`mkdir` create intermediate directories implicitly.
- `deleteFile` refuses to remove a non-empty directory (`ENOTEMPTY`); empty
dirs delete cleanly.
- `snapshot()` returns a `{ path: Uint8Array }` map of every file — handy
for end-of-test assertions.
- Honours an optional `AbortSignal` — every async call short-circuits
with an `AbortError` once the signal aborts.
## Basic Example
```ts
import { MemoryFs } from "@suaveplan/installer";
const fs = new MemoryFs({ seed: { "/README.md": "# hi\n" } });
const bytes = await fs.readFile("/README.md");
console.log(new TextDecoder().decode(bytes));
```
## Advanced Example
```ts
import { MemoryFs } from "@suaveplan/installer";
const ac = new AbortController();
const fs = new MemoryFs({ signal: ac.signal });
await fs.mkdir("/.suaveplan/skills");
await fs.writeJson("/.suaveplan/skills/index.json", { skills: [] });
await fs.writeFile(
"/.suaveplan/skills/echo/SKILL.md",
new TextEncoder().encode("# echo\n")
);
const list = await fs.listDir("/.suaveplan/skills");
console.log(list); // ["echo", "index.json"]
ac.abort();
await fs.writeFile("/late.txt", new Uint8Array()); // throws AbortError
```
## API Reference
- `new MemoryFs(options?: { seed?, signal? })`
- `pathOf(...segments): string`
- `exists(path): Promise<boolean>`
- `stat(path): Promise<{ size, isFile, isDir }>`
- `readFile(path): Promise<Uint8Array>`
- `readJson<T>(path): Promise<T>`
- `listDir(path): Promise<string[]>`
- `writeFile(path, contents, mode?): Promise<void>`
- `deleteFile(path): Promise<void>`
- `mkdir(path): Promise<void>`
- `rename(from, to): Promise<void>`
- `writeJson(path, value): Promise<void>`
- `snapshot(): Record<string, Uint8Array>`
## Implementation Notes
Path normalisation collapses repeated slashes, strips trailing slashes
(except at the root), and canonicalises backslashes. Seeding paths whose
parents collide with an already-seeded *file* throws `ENOTDIR` — this is
intentional and mirrors POSIX behaviour. The internal `entries` map is
ordered by insertion; `listDir` re-sorts on every call so results are
stable regardless of write order.

View File

@@ -0,0 +1,120 @@
# `apply`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - conflict — Resolves the per-op `conflict` policy on…
- [conflict](./conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- conflict — Resolves the per-op `conflict` policy on…
- [conflict](./conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- conflict — Resolves the per-op `conflict` policy on…
- [conflict](./conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- conflict — Resolves the per-op `conflict` policy on…
- [conflict](./conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [conflict](./conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
## Purpose
Materialises a `FileOp[]` against a `MutableInstallerFs` with rollback
semantics. This is the only mutating stage in the installer pipeline.
Each successful op records an `undo` entry on a journal; if any op fails,
the journal replays in LIFO order to reverse already-applied work. Apply
is wrapped in an OTel span (`installer.apply`) and is the only place in
the host where `INSTALLER.APPLY_*` and `INSTALLER.ROLLBACK_FAILED` codes
originate.
## Features
- Per-op conflict resolution via `resolveConflict` — the host consults
each op's `conflict` policy before mutating the fs.
- Transactional rollback in reverse order — an apply failure undoes
everything that succeeded earlier in the run.
- Distinct codes: `APPLY_CONFLICT` for policy violations, `APPLY_FAILED`
for plugin/fs exceptions, `ROLLBACK_FAILED` when undo itself throws,
`ABORTED` when the run was cancelled mid-apply.
- `dry-run` is honoured by the host _before_ apply runs, so apply itself
always writes — no internal `if (dryRun) skip` branches to test.
- Per-op summary (`written`/`patched`/`skipped`/`conflicts`) for the host
to return.
## Basic Example
```ts
import { applyOps, MemoryFs } from "@suaveplan/installer";
const fs = new MemoryFs();
const summary = await applyOps({
ops: [
{
kind: "write",
path: "/hello.txt",
contents: new TextEncoder().encode("hi"),
},
],
ctx,
fs,
pluginId: "demo",
pluginVersion: "0.1.0",
});
console.log(summary); // { written: 1, patched: 0, skipped: 0, conflicts: 0 }
```
## Advanced Example
```ts
import { applyOps, InstallerError } from "@suaveplan/installer";
try {
await applyOps({ ops, ctx, fs, pluginId, pluginVersion });
} catch (err) {
if (err instanceof InstallerError) {
switch (err.code) {
case "INSTALLER.APPLY_CONFLICT":
console.error("Pre-existing files conflict; re-run with --force");
break;
case "INSTALLER.ROLLBACK_FAILED":
console.error(
"Apply failed AND rollback failed — fs may be in a partial state",
err.context?.["original"]
);
break;
default:
throw err;
}
}
}
```
## API Reference
- `applyOps(options: ApplyOptions): Promise<PlanSummary>`
- `interface ApplyOptions` —
`{ ops, ctx, fs, pluginId, pluginVersion }`.
- Errors: `INSTALLER.APPLY_CONFLICT`, `INSTALLER.APPLY_FAILED`,
`INSTALLER.ROLLBACK_FAILED`, `INSTALLER.ABORTED`.
## Implementation Notes
Each op's undo is captured into the journal _before_ the next op runs, so
even if abort fires mid-op the partial state is recoverable. `mkdir` ops
are short-circuited by `resolveConflict` for already-existing directories
(idempotent default), so we don't need a redundant `if (exists) return`
inside apply. The patch ops (`patch-json`, `patch-text`) read the prior
content (when the file exists) so undo can restore byte-for-byte; when
the file didn't exist, undo is a `deleteFile`. The `ROLLBACK_FAILED`
path attaches the original apply error as `context.original` so the
caller can still report root cause even when the recovery path failed.

View File

@@ -0,0 +1,80 @@
# `conflict`
## Purpose
Resolves the per-op `conflict` policy on a target that already exists.
Five policies are supported: `error` (raise `APPLY_CONFLICT`), `overwrite`
(replace), `skip-if-exists` (drop the op silently and record), `merge`
(structured merge for patch ops; raise for raw writes), and `prompt`
(ask the user via `InstallerCli.confirm`, defaulting to `error` semantics
in `--no-interactive` mode). The host's `--force` flag collapses every
policy to `overwrite`.
## Features
- Returns a `ConflictDecision` (`apply | skip | merge`) so the apply
function knows whether to proceed, drop, or perform a structured merge.
- `mkdir` is treated idempotently: re-creating an existing directory is
always a `skip` regardless of the declared policy. This matches the
"directories are containers" mental model.
- `prompt` is only honoured when `noInteractive === false`; otherwise it
hard-fails with `APPLY_CONFLICT`.
- `merge` on a raw `write` op raises `APPLY_CONFLICT` — text bytes can't
be structurally merged, only patch ops can.
## Basic Example
```ts
import { resolveConflict } from "@suaveplan/installer";
const decision = await resolveConflict({
op: {
kind: "write",
path: "/x.txt",
contents: new Uint8Array(),
conflict: "skip-if-exists",
},
exists: true,
ctx: { force: false, noInteractive: true, cli },
});
console.log(decision); // "skip"
```
## Advanced Example
```ts
import { resolveConflict } from "@suaveplan/installer";
const decisions = await Promise.all(
plan.ops.map(async (op) => ({
op,
decision: await resolveConflict({
op,
exists: await fs.exists(op.kind === "rename" ? op.to : op.path),
ctx: { force: cli.flags.force, noInteractive: cli.flags.noInteractive, cli: scriptCli },
}),
}))
);
const summary = decisions.reduce(
(acc, d) => ({ ...acc, [d.decision]: (acc[d.decision] ?? 0) + 1 }),
{} as Record<string, number>
);
```
## API Reference
- `resolveConflict(options: ResolveConflictOptions): Promise<ConflictDecision>`
- `interface ResolveConflictOptions` —
`{ op, exists, ctx: { force, noInteractive, cli } }`.
- `type ConflictDecision = "apply" | "skip" | "merge"`.
- Errors: `INSTALLER.APPLY_CONFLICT` for `error` policy on existing,
`merge` on raw write, or rejected/no-interactive prompt.
## Implementation Notes
The function returns synchronously when the target doesn't exist —
`exists === false` always means "apply", short-circuiting before any
prompt logic. The default policy when an op omits `conflict` is `error`,
chosen so plugins are forced to opt into destructive behaviour. The
prompt path uses `cli.confirm(...)` with a `false` default so accidentally
hitting Enter rejects the overwrite — safer-by-default ergonomics.

View File

@@ -0,0 +1,70 @@
# `json-merge`
## Purpose
Implements the deep-merge semantics used by `patch-json` ops with
`conflict: "merge"`. Match the conventions of `@suaveplan/config`: plain
objects merge key-by-key; arrays default to **replace** (use
`arrays: "concat"` to opt into concat behaviour); `undefined` keys in the
patch are skipped so existing values win. The merge is **pure** — input
objects are never mutated.
## Features
- Replace-by-default array semantics — surprising-by-default array merges
(concat) are an opt-in.
- Skips `undefined` patch keys — `{ a: undefined }` does not erase
`existing.a`.
- Treats class instances as scalars — only objects with `Object.prototype`
or null prototype are recursively merged.
- Concat-on-array-mismatch falls back to replace — concat is only
meaningful when both sides are arrays.
- Returns a brand new object tree, leaving inputs untouched.
## Basic Example
```ts
import { applyJsonMerge } from "@suaveplan/installer";
const next = applyJsonMerge(
{ a: 1, nested: { x: 1 } },
{ value: { b: 2, nested: { y: 2 } } }
);
console.log(next); // { a: 1, b: 2, nested: { x: 1, y: 2 } }
```
## Advanced Example
```ts
import { applyJsonMerge } from "@suaveplan/installer";
// Replace arrays (default) — overwrites the list:
applyJsonMerge({ tags: ["old"] }, { value: { tags: ["new"] } });
// → { tags: ["new"] }
// Concat arrays:
applyJsonMerge(
{ tags: ["old"] },
{ value: { tags: ["new"] }, arrays: "concat" }
);
// → { tags: ["old", "new"] }
// Skip undefined:
applyJsonMerge({ a: 1 }, { value: { a: undefined, b: 2 } });
// → { a: 1, b: 2 }
```
## API Reference
- `applyJsonMerge(existing: unknown, merge: JsonMerge): unknown`
- `interface JsonMerge` — `{ value: unknown, arrays?: "replace" | "concat" }`.
## Implementation Notes
The "is plain object" check is a prototype-equality test (`=== Object.prototype || === null`) rather than `typeof === "object"`. This
keeps class instances opaque so the merge doesn't accidentally rebuild
something like a `Date` into a plain object. Concat array semantics fall
back to replace when the base side isn't an array — concatenating into a
scalar would lose the original value, so replace is the safer default
there. Arrays in the patch are always cloned (`[...patch]`) so callers
can safely mutate the returned value without affecting the input.

View File

@@ -0,0 +1,76 @@
# `text-patch`
## Purpose
Applies an ordered sequence of `TextEdit`s to a string. Used by
`patch-text` ops to make targeted insertions/deletions/replacements
against existing text files (e.g. injecting a line into `.gitignore`,
appending a section to a README, removing a deprecated import). The
trailing-newline policy of the input is preserved so the patcher doesn't
silently change line-count semantics.
## Features
- Four edit modes: `insert-before`, `insert-after`, `replace`, `delete` —
declared per-edit via `TextEdit.mode`.
- String anchors (literal substring) and `RegExp` anchors both supported.
- Trailing newline preserved across the entire edit sequence — input
ending in `\n` produces output ending in `\n`.
- Sequential application — later edits see the effect of earlier ones,
so a chain like `replace "foo" → "FOO"` then `insert-after "FOO"` works.
- Missing-anchor failure throws synchronously — the caller (apply) wraps
this in `INSTALLER.APPLY_FAILED` so the host's transaction rolls back.
## Basic Example
```ts
import { applyTextEdits } from "@suaveplan/installer";
const out = applyTextEdits("hello world", [
{ anchor: "world", mode: "replace", content: "everyone" },
]);
console.log(out); // "hello everyone"
```
## Advanced Example
```ts
import { applyTextEdits } from "@suaveplan/installer";
const original = "node_modules\ndist\n";
const updated = applyTextEdits(original, [
{
anchor: /^/,
mode: "insert-before",
content: "# Auto-generated by suaveplan-install\n",
},
{
anchor: "dist",
mode: "insert-after",
content: "\n.suaveplan/",
},
]);
console.log(updated);
// # Auto-generated by suaveplan-install
// node_modules
// dist
// .suaveplan/
```
## API Reference
- `applyTextEdits(source: string, edits: ReadonlyArray<TextEdit>): string`
- `interface TextEdit` — `{ anchor: string | RegExp, mode, content? }`.
- Throws `Error("text-patch: anchor not found: ...")` when an anchor
doesn't match — apply catches and wraps in `APPLY_FAILED`.
## Implementation Notes
The trailing newline is sliced off before edits run and reattached after
— this avoids edits whose anchor contains the literal `\n` from
inadvertently producing a doubled newline at end-of-file. Anchor
matching uses `String.prototype.match(regex)` (rather than `regex.exec`)
so the anchor regex is treated as immutable across calls — there's no
shared state if a regex with `g` is reused. Edits with `undefined`
content default to empty string so `replace` without content effectively
*deletes* the matched anchor.

View File

@@ -0,0 +1,78 @@
# `detect`
## Purpose
Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The
plugin reports what is already present in the target — existing skill
directories, conflicting files, configuration to migrate — so the host
can show that information to the user before prompts run, and so plugins
can tailor their plans accordingly. Detect is read-only by contract: it
inspects `ctx.fs` but never mutates state.
## Features
- Wraps the plugin call in `installer.detect` span carrying `plugin.id`,
`plugin.version`, and `dry_run` so traces correlate with apply.
- No-op when the plugin omits `detect` — returns `{}` without opening a
span, so plugins that don't need detection pay zero observability
overhead.
- Surfaces plugin-thrown errors unchanged — the host's outer `try/catch`
is responsible for wrapping these in `INSTALLER.PLAN_FAILED` if they
occur post-detect, but detect-stage exceptions propagate raw so plugins
can model their own detection-time error codes.
## Basic Example
```ts
import { runDetect } from "@suaveplan/installer";
const result = await runDetect(myPlugin, ctx);
if (result.alreadyInstalled) {
ctx.cli.write("Plugin is already installed");
}
```
## Advanced Example
```ts
import type { InstallerPlugin } from "@suaveplan/installer";
const plugin: InstallerPlugin<undefined, void> = {
id: "auditor",
version: "0.1.0",
detect: async (ctx) => {
const skillsDir = ctx.fs.pathOf(ctx.cwd, ".suaveplan/skills");
const exists = await ctx.fs.exists(skillsDir);
if (!exists) {
return {
alreadyInstalled: false,
notes: ["fresh target"],
};
}
const entries = await ctx.fs.listDir(skillsDir);
return {
alreadyInstalled: true,
conflicts: entries.filter((e) => e !== "index.json"),
notes: [`${entries.length} entries already present`],
};
},
plan: async () => [],
apply: async () => undefined,
};
```
## API Reference
- `runDetect<TAnswers, THandle>(plugin, ctx): Promise<DetectResult>`
- `DetectResult` — `{ alreadyInstalled?: boolean, conflicts?: string[], notes?: string[] }`. All fields optional.
## Implementation Notes
The span is opened only when `plugin.detect` exists; this is a small
optimisation but it matters when the host is called in a tight loop
(e.g. `list` discovers many plugins). The default empty `DetectResult`
(`{}`) ensures downstream stages never see `undefined` and lets the
`alreadyInstalled` boolean be used in CLI rendering without an extra
guard. Detect doesn't receive answers — those don't exist yet — so any
configuration-dependent detection must happen lazily during plan
instead.

View File

@@ -0,0 +1,147 @@
# `host`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - conflict — Resolves the per-op `conflict` policy on…
- [apply](./apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [conflict](./apply/conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./apply/json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./apply/text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [detect](./detect.md) — Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The plugin reports what is already present in the…
- [host](./host.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [plan](./plan.md) — Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]` against `FileOpsSchema` before letting them f…
- [post-install](./post-install.md) — Runs the plugin's optional `postInstall` stage with abort propagation. This is where plugins do follow-up work that doe…
- [prompts](./prompts.md) — Runs the plugin's optional `prompts` stage and validates the answers against the schema the plugin returns. This is whe…
- conflict — Resolves the per-op `conflict` policy on…
- [apply](./apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [conflict](./apply/conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./apply/json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./apply/text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [detect](./detect.md) — Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The plugin reports what is already present in the…
- [host](./host.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [plan](./plan.md) — Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]` against `FileOpsSchema` before letting them f…
- [post-install](./post-install.md) — Runs the plugin's optional `postInstall` stage with abort propagation. This is where plugins do follow-up work that doe…
- [prompts](./prompts.md) — Runs the plugin's optional `prompts` stage and validates the answers against the schema the plugin returns. This is whe…
- conflict — Resolves the per-op `conflict` policy on…
- [apply](./apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [conflict](./apply/conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./apply/json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./apply/text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [detect](./detect.md) — Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The plugin reports what is already present in the…
- [host](./host.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [plan](./plan.md) — Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]` against `FileOpsSchema` before letting them f…
- [post-install](./post-install.md) — Runs the plugin's optional `postInstall` stage with abort propagation. This is where plugins do follow-up work that doe…
- [prompts](./prompts.md) — Runs the plugin's optional `prompts` stage and validates the answers against the schema the plugin returns. This is whe…
- conflict — Resolves the per-op `conflict` policy on…
- [apply](./apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- conflict — Resolves the per-op `conflict` policy on…
- [conflict](./apply/conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./apply/json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./apply/text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [detect](./detect.md) — Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The plugin reports what is already present in the…
- [host](./host.md) — <!-- begin:auto-docs --> _(no submodules)_- apply — <!-- begin:auto-docs --> _(no submodules)_…
- [plan](./plan.md) — Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]` against `FileOpsSchema` before letting them f…
- [post-install](./post-install.md) — Runs the plugin's optional `postInstall` stage with abort propagation. This is where plugins do follow-up work that doe…
- [prompts](./prompts.md) — Runs the plugin's optional `prompts` stage and validates the answers against the schema the plugin returns. This is whe…
- [apply](./apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./apply/conflict.md) — Resolves the per-op `conflict` policy on a target that already exists. Five policies are supported: `error` (raise `APP…
- [json-merge](./apply/json-merge.md) — Implements the deep-merge semantics used by `patch-json` ops with `conflict: "merge"`. Match the conventions of `@suave…
- [text-patch](./apply/text-patch.md) — Applies an ordered sequence of `TextEdit`s to a string. Used by `patch-text` ops to make targeted insertions/deletions/…
- [detect](./detect.md) — Runs a plugin's optional `detect` stage inside an OpenTelemetry span. The plugin reports what is already present in the…
- [host](./host.md) — <!-- begin:auto-docs --> _(no submodules)_- [plan](./plan.md) — Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]` against `FileOpsSchema` before letting them f…
- [post-install](./post-install.md) — Runs the plugin's optional `postInstall` stage with abort propagation. This is where plugins do follow-up work that doe…
- [prompts](./prompts.md) — Runs the plugin's optional `prompts` stage and validates the answers against the schema the plugin returns. This is whe…
## Purpose
The plugin host. `Host.run(plugin, options)` executes the strict pipeline
`detect → prompts → plan → apply → postInstall`, with abort propagation,
`--dry-run` short-circuiting, and `only`/`exclude` filtering. Each stage is
wrapped in an OpenTelemetry span (`installer.<stage>`) carrying
`plugin.id`, `plugin.version`, and `dry_run` attributes — that
instrumentation is what lets operators correlate apply failures with the
specific plugin and version that produced them.
## Features
- Strict ordering — optional stages (`detect`, `prompts`, `postInstall`)
are skipped without affecting the order of mandatory stages (`plan`,
`apply`).
- Abort checks at every stage boundary — if the signal aborts mid-run, the
host raises `INSTALLER.ABORTED` and apply rolls back any partial work.
- `--dry-run` — when set, the host returns the plan ops without invoking
apply or postInstall.
- `only`/`exclude` — substring filters applied to the op target path
(`op.to` for renames, `op.path` otherwise) before apply runs.
- `answersFromFile` channel — used by the bin's `--answers <file>` flag to
feed pre-recorded answers into prompt validation without going
interactive.
## Basic Example
```ts
import { Host, MemoryFs } from "@suaveplan/installer";
const fs = new MemoryFs();
const host = new Host(
{
cwd: "/",
fs,
cli: { confirm: async () => false, prompt: async () => "", write: () => undefined },
signal: new AbortController().signal,
logger: {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
templates: { render: (s) => s },
dryRun: false,
force: false,
noInteractive: true,
},
fs
);
const result = await host.run(myPlugin);
console.log(result.summary);
```
## Advanced Example
```ts
import { Host } from "@suaveplan/installer";
const ac = new AbortController();
const host = new Host({ ...ctx, signal: ac.signal, dryRun: true }, fs);
setTimeout(() => ac.abort(), 5_000);
const dryResult = await host.run(myPlugin, {
only: ["crypto", "events"],
exclude: ["legacy"],
});
console.log(`would apply ${dryResult.ops.length} ops`);
```
## API Reference
- `class Host` — constructed with `(ctx: InstallerContext, fs: MutableInstallerFs)`.
- `Host.run<TAnswers, THandle>(plugin, options): Promise<HostRunResult<THandle>>`
- `interface HostRunOptions<TAnswers> extends RunOptions<TAnswers>` —
adds `answersFromFile?: unknown` for the bin.
- `interface HostRunResult<THandle>` — `{ detect, ops, summary, handle, dryRun }`.
## Implementation Notes
`filterOps` runs _after_ plan but _before_ apply, so the conformance
guarantee that "every emitted op respects its conflict policy on re-run"
applies to the post-filter set. `--dry-run` returns a synthetic summary
(`skipped = ops.length`) so dashboards see a coherent counter even on
hypothetical runs. Abort checks are placed at every awaitable boundary
(after detect, after prompts, before apply) so a long-running prompt
schema can be cancelled cleanly. `applyOps` itself owns the rollback
journal — the host's only responsibility post-apply is to wire up
`postInstall` with the resulting handle.

View File

@@ -0,0 +1,72 @@
# `plan`
## Purpose
Invokes `plugin.plan(answers, ctx)` and validates the returned `FileOp[]`
against `FileOpsSchema` before letting them flow into apply. This is the
quality gate that prevents a buggy plugin from feeding malformed ops into
the host's transactional apply path. Both plugin-thrown errors and
schema-validation failures surface as `INSTALLER.PLAN_FAILED` so callers
have a single code to switch on.
## Features
- Schema validation via `FileOpsSchema` — every op kind, path, contents
type, and conflict policy is checked at the boundary.
- Wrapped in OTel span `installer.plan` carrying `plugin.id`,
`plugin.version`, and `dry_run`.
- Distinguishes `plan` failures (plugin threw) from invalid-shape failures
(plan returned bad ops) only via `messageKey` — both surface as the
same code, which keeps consumer error handlers simple.
- Original error chained via `cause` so the underlying Zod error or
plugin exception is recoverable for logging.
## Basic Example
```ts
import { runPlan } from "@suaveplan/installer";
const ops = await runPlan(myPlugin, answers, ctx);
console.log(`plan returned ${ops.length} ops`);
```
## Advanced Example
```ts
import { InstallerError, runPlan } from "@suaveplan/installer";
try {
const ops = await runPlan(myPlugin, answers, ctx);
return ops;
} catch (err) {
if (
err instanceof InstallerError &&
err.code === "INSTALLER.PLAN_FAILED"
) {
const issues = err.context?.["issues"];
if (issues) {
console.error("Plugin returned invalid ops:", issues);
} else {
console.error("Plugin plan threw:", err.cause);
}
}
throw err;
}
```
## API Reference
- `runPlan<TAnswers, THandle>(plugin, answers, ctx): Promise<ReadonlyArray<FileOp>>`
- Errors: `INSTALLER.PLAN_FAILED` with either:
- `installer.plan.invalid_ops` and `context.issues` for schema failures
- `installer.plan.threw` and `cause` set to the plugin's exception
## Implementation Notes
Validation runs *after* the plugin returns, so a plugin can build ops
incrementally without seeing intermediate validation errors. The schema
also fixes the common mistake of returning a single op instead of an
array — `FileOpsSchema` requires `Array<FileOp>` so that gets caught at
the boundary instead of crashing later in apply. If the plugin already
threw an `InstallerError`, we re-throw it directly rather than wrapping
twice — this preserves `cause` chains all the way to the top.

View File

@@ -0,0 +1,66 @@
# `post-install`
## Purpose
Runs the plugin's optional `postInstall` stage with abort propagation.
This is where plugins do follow-up work that doesn't fit the declarative
`FileOp` model: printing summaries, registering shell completions, kicking
off a `bun install`, sending events to a remote registry. The host hands
the plugin's typed `THandle` (returned from `apply`) plus the live
`InstallerContext` so postInstall can reach the same fs, cli, and
template engine that planning used.
## Features
- No-op when the plugin omits `postInstall` — zero overhead, no span.
- Wrapped in OTel span `installer.postInstall` so the trace covers
follow-up work.
- Aborts before invoking the plugin if the signal has already fired —
guards against late-arriving abort signals racing against postInstall.
- Surfaces plugin exceptions unchanged so plugins can model their own
postInstall error codes (e.g. "could not register shell completion").
## Basic Example
```ts
import { runPostInstall } from "@suaveplan/installer";
await runPostInstall(myPlugin, handle, ctx);
```
## Advanced Example
```ts
import type { InstallerPlugin } from "@suaveplan/installer";
const plugin: InstallerPlugin<undefined, { installed: number }> = {
id: "demo",
version: "0.1.0",
plan: async () => [],
apply: async (ops) => ({ installed: ops.length }),
postInstall: async (handle, ctx) => {
ctx.cli.write(
`\n✓ installed ${handle.installed} files into ${ctx.cwd}`
);
ctx.cli.write(
"Run `bun install` to refresh dependencies, then commit."
);
},
};
```
## API Reference
- `runPostInstall<TAnswers, THandle>(plugin, handle, ctx): Promise<void>`
- Errors: `INSTALLER.ABORTED` when the signal has fired before invocation;
any plugin-thrown error otherwise.
## Implementation Notes
The abort guard runs inside the OTel span so even short-circuited runs
produce a trace event for visibility. PostInstall is intentionally the
last stage and sees the result handle from apply — this matches the
"declare files, perform side-effects after" pattern used by tools like
`yo` and `create-react-app`. Plugins that need read access to the fresh
filesystem (e.g. to verify what apply produced) can read it back via
`ctx.fs` here without re-running plan.

View File

@@ -0,0 +1,87 @@
# `prompts`
## Purpose
Runs the plugin's optional `prompts` stage and validates the answers
against the schema the plugin returns. This is where `--answers <file>`
flows in (via `answersFromFile`) and where `--no-interactive` is enforced.
Validation failures surface as `INSTALLER.PROMPT_VALIDATION_FAILED` with
the underlying Zod (or other compatible) error attached as `cause` and
field-level issues attached as `context.issues`.
## Features
- Accepts both explicit `answers` (programmatic harness) and
`answersFromFile` (loaded from a JSON file by the bin).
- Fails fast under `noInteractive` when a plugin demands answers that
weren't pre-supplied — prevents silent prompts in CI.
- No-op when the plugin omits `prompts` — explicit answers pass through,
otherwise an empty `{}` is supplied to plan. Plugins that need answers
must declare a schema.
- Wrapped in OTel span `installer.prompts` so prompt validation timing is
observable.
## Basic Example
```ts
import { runPrompts } from "@suaveplan/installer";
import z from "zod";
const schema = z.object({ name: z.string().min(1) });
const plugin = {
id: "demo",
version: "0.1.0",
prompts: () => schema,
plan: async () => [],
apply: async () => undefined,
};
const answers = await runPrompts({
plugin,
ctx,
explicitAnswers: { name: "alice" },
});
```
## Advanced Example
```ts
import { runPrompts, InstallerError } from "@suaveplan/installer";
try {
const answers = await runPrompts({
plugin,
ctx,
answersFromFile: JSON.parse(await fileContent),
});
// ... pass to plan
} catch (err) {
if (
err instanceof InstallerError &&
err.code === "INSTALLER.PROMPT_VALIDATION_FAILED"
) {
const issues = err.context?.["issues"];
console.error("Invalid answers:", issues);
process.exit(2);
}
throw err;
}
```
## API Reference
- `runPrompts<TAnswers>(options): Promise<TAnswers>`
- `interface RunPromptsOptions<TAnswers>` —
`{ plugin, ctx, explicitAnswers?, answersFromFile? }`.
- Errors: `INSTALLER.PROMPT_VALIDATION_FAILED` for missing answers,
validation failures, or non-interactive without answers.
## Implementation Notes
`explicitAnswers` is preferred over `answersFromFile` when both are
supplied — the testing harness uses `explicitAnswers` and we want it to
take precedence when callers also pass a file. `safeParse` is used
instead of `parse` so the host can attach the Zod issues as structured
context rather than letting them bubble as raw `ZodError`. The schema
returned by `plugin.prompts` is treated structurally (`PromptSchema<T>`),
not nominally — any object with `parse` and `safeParse` is acceptable,
which lets plugins swap validators without breaking the host contract.

View File

@@ -0,0 +1,78 @@
# `discover`
## Purpose
Walks the consumer's `node_modules` tree and returns every installer
plugin matching the naming conventions: first-party
`@suaveplan/install-*` and third-party `@<scope>/suaveplan-install-*`.
This is how the `suaveplan-install` bin and any programmatic caller
enumerate plugins without forcing the consumer to maintain a registry.
## Features
- Walks both **hoisted** layouts (`<cwd>/node_modules`) and **pnpm-style**
nested layouts (`<cwd>/node_modules/.pnpm/*/node_modules`).
- Dedupes plugins by `id` so a package appearing in both the direct and
pnpm layouts surfaces once.
- `findPlugin(cwd, id)` raises `INSTALLER.PLUGIN_NOT_FOUND` carrying the
requested id when no installed package reports a matching manifest.
- Manifest load failures during discovery surface as
`INSTALLER.PLUGIN_LOAD_FAILED` with the underlying validation error as
`cause` and the package name in `context.packageName`.
- Sorted output — plugins are returned alphabetically by id, so CLI
output is stable across runs.
## Basic Example
```ts
import { discoverPlugins } from "@suaveplan/installer";
const plugins = await discoverPlugins(process.cwd());
for (const p of plugins) {
console.log(`${p.id}@${p.manifest.version}`);
}
```
## Advanced Example
```ts
import { findPlugin, InstallerError } from "@suaveplan/installer";
try {
const plugin = await findPlugin(process.cwd(), "agent-skills");
const entry = `${plugin.packagePath}/${plugin.manifest.entry}`;
const mod = (await import(entry)) as { default: unknown };
// ... mount mod.default in the host
} catch (err) {
if (
err instanceof InstallerError &&
err.code === "INSTALLER.PLUGIN_NOT_FOUND"
) {
console.error("Run `bun add -D @suaveplan/install-agent-skills` first");
process.exit(2);
}
throw err;
}
```
## API Reference
- `discoverPlugins(cwd: string): Promise<ReadonlyArray<DiscoveredPlugin>>`
— returns every match, deduped and sorted by `id`.
- `findPlugin(cwd: string, id: string): Promise<DiscoveredPlugin>` —
filters `discoverPlugins` and raises `INSTALLER.PLUGIN_NOT_FOUND` when
empty.
- `interface DiscoveredPlugin` — `{ id, packageName, packagePath, manifest }`.
## Implementation Notes
The walker only descends into `@scope/...` directories at the top level —
non-scoped packages cannot match either convention by construction
(`@suaveplan/install-*` requires the `@` prefix, and the third-party
infix `/suaveplan-install-` requires a slash). Hidden entries
(`node_modules/.bin`, `.pnpm`, `.cache`, …) are skipped. `listScopedDirs`
swallows `readdir` failures so a degenerate `@scope` entry (a file
masquerading as a directory) doesn't kill discovery for unrelated
packages. The pnpm walk reads `node_modules/.pnpm/*/node_modules` and
recursively collects from each — this matches pnpm's actual on-disk
layout where every dependency has its own scoped subtree.

View File

@@ -0,0 +1,91 @@
# `manifest`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Loads and validates plugin manifest files. Every Suaveplan installer plugin
ships a `manifest.json` next to its entry module; this module exposes
`loadManifest(packagePath)` and `validateManifest(raw)` which both surface
schema violations as `InstallerError(INSTALLER.PLUGIN_INVALID_MANIFEST)`
with the underlying Zod error attached as `cause`. The host calls
`loadManifest` once per plugin during discovery; the result is cached on
the `DiscoveredPlugin` record returned to callers.
## Features
- Backed by `PluginManifestSchema` (Zod) which enforces required fields:
`id`, semver `version`, non-empty `description`, relative `entry`,
`engines: Record<string, string>`, and `assets: string[]`.
- Optional `peerInstallers: string[]` for plugins that depend on other
installers being present at runtime.
- Distinguishes parse errors from schema errors but emits the same code
(`PLUGIN_INVALID_MANIFEST`) so consumers don't have to handle three
variants.
- Pure validator — `validateManifest(raw)` is synchronous and side-effect
free, so plugins can be validated before any disk I/O happens.
## Basic Example
```ts
import { loadManifest } from "@suaveplan/installer";
const manifest = await loadManifest(
"/path/to/node_modules/@suaveplan/install-agent-skills"
);
console.log(manifest.id, manifest.version);
```
## Advanced Example
```ts
import {
InstallerError,
loadManifest,
validateManifest,
} from "@suaveplan/installer";
async function safeLoad(path: string): Promise<unknown> {
try {
return await loadManifest(path);
} catch (err) {
if (
err instanceof InstallerError &&
err.code === "INSTALLER.PLUGIN_INVALID_MANIFEST"
) {
console.warn(`skipping ${path}: bad manifest`);
return null;
}
throw err;
}
}
// Standalone validation — no disk I/O:
const valid = validateManifest({
id: "demo",
version: "0.1.0",
description: "demo plugin",
entry: "dist/index.js",
engines: { node: ">=20" },
assets: [],
});
```
## API Reference
- `loadManifest(packagePath: string): Promise<PluginManifest>` —
reads `<packagePath>/manifest.json`, parses, and validates.
- `validateManifest(raw: unknown): PluginManifest` — pure schema check.
- `PluginManifest` — inferred type:
`{ id, version, description, entry, engines, assets, peerInstallers? }`.
## Implementation Notes
The schema uses a strict semver regex (`MAJOR.MINOR.PATCH` with optional
`-prerelease+build`) rather than `z.string()` so prerelease ergonomics are
explicit. Failures from `JSON.parse` are caught and re-thrown as
`PLUGIN_INVALID_MANIFEST`, not as raw `SyntaxError`, so callers always
have a typed `InstallerError` to switch on. The Zod error issues are
attached via `context.issues` for downstream error reporters that want
field-level breakdowns without re-walking the cause chain.

View File

@@ -0,0 +1,63 @@
# `installer-fs.test-helpers`
## Purpose
Convenience helpers for assertions against the in-memory installer fs in
plugin tests. `createMemoryFs(seed)` is a thin wrapper that returns a
fresh `MemoryFs` (with optional seed) so tests avoid the constructor
boilerplate. `assertWrote(fs, path, predicate)` reads the file at `path`,
decodes UTF-8, and matches the contents against the predicate (string
equality or callback) — failing assertions throw with a clear message
suitable for any test framework.
## Features
- Zero-dependency UTF-8 decoder over the file body — works in any test
runner (vitest, bun-test, node:test).
- Two predicate forms: bare string for equality, or callback for partial
/ regex / structural checks.
- Distinct error messages for missing file vs. predicate mismatch so
failures point straight at the right line.
## Basic Example
```ts
import { assertWrote, createMemoryFs } from "@suaveplan/installer";
const fs = createMemoryFs({ "/index.json": '{"version":1}' });
await assertWrote(fs, "/index.json", '{"version":1}');
```
## Advanced Example
```ts
import { assertWrote, createMemoryFs, runPlugin } from "@suaveplan/installer";
const fs = createMemoryFs();
await runPlugin(myPlugin, { fs, answers: { name: "echo" } });
await assertWrote(fs, "/.suaveplan/skills/echo/SKILL.md", (text) =>
text.startsWith("# echo")
);
await assertWrote(fs, "/.suaveplan/skills/index.json", (text) => {
const idx = JSON.parse(text) as { skills: Array<{ id: string }> };
return idx.skills.some((s) => s.id === "echo");
});
```
## API Reference
- `createMemoryFs(seed?: Record<string, string | Uint8Array>): MemoryFs`
- `assertWrote(fs: MemoryFs, path: string, predicate: string | ((text: string) => boolean)): Promise<void>`
- Failure modes: missing file → throws `Error("assertWrote: missing file at <path>")`; predicate fail → throws `Error("assertWrote: predicate failed for <path>")`.
## Implementation Notes
The helpers are deliberately minimal — they match the patterns in
`@suaveplan/testing/runner` (which also keeps zero workspace deps).
`assertWrote` always decodes as UTF-8; binary assertions should compare
bytes via `MemoryFs.snapshot()` directly. The string-predicate form is a
common case (docs, JSON snapshots) and is sugar for
`(text) => text === expected` so failures still produce the same
"predicate failed" message — tests should add their own context if they
need finer error reporting.

View File

@@ -0,0 +1,71 @@
# `run-plugin`
## Purpose
End-to-end test harness for plugins. `runPlugin(plugin, options)` wires up
an in-memory filesystem, a scripted CLI, a no-op logger, and a basic
template engine, then drives the plugin through the full
`detect → prompts → plan → apply → postInstall` pipeline. Returns the
filesystem and the recorded CLI interaction so tests can assert on what
was written and what was prompted. Zero real disk I/O.
## Features
- Sensible defaults: in-memory `MemoryFs`, scripted `ScriptCli`, no-op
logger, `{{var}}`-style template engine — all overridable.
- Returns `{ ops, summary, handle, dryRun, fs, cli }` so tests can
inspect the plan, the resulting fs, and the prompt history.
- Same `RunOptions` surface as `Host.run`: `dryRun`, `force`,
`noInteractive`, `only`, `exclude`, `signal`.
- Defaults `noInteractive` to `true` so test runs never block on a
prompt that wasn't scripted.
## Basic Example
```ts
import { runPlugin } from "@suaveplan/installer";
const result = await runPlugin(myPlugin, {
answers: { targetDir: "/.suaveplan/skills" },
});
expect(result.summary.written).toBe(5);
```
## Advanced Example
```ts
import { runPlugin, ScriptCli } from "@suaveplan/installer";
const cli = new ScriptCli({ confirm: [true, false] });
const ac = new AbortController();
const result = await runPlugin(myPlugin, {
cli,
signal: ac.signal,
answers: { skills: ["echo", "crypto"] },
only: ["crypto"],
force: false,
});
expect(cli.record.confirms).toEqual([
{ message: "Overwrite existing file?", answer: true },
]);
expect(result.ops.every((op) => op.path.includes("crypto"))).toBe(true);
```
## API Reference
- `runPlugin<TAnswers, THandle>(plugin, options): Promise<RunPluginResult<THandle>>`
- `interface RunPluginOptions<TAnswers>` —
`{ answers?, fs?, cli?, cwd?, logger?, templates?, signal?, dryRun?, force?, noInteractive?, only?, exclude? }`.
- `interface RunPluginResult<THandle> extends HostRunResult<THandle>`
— adds `fs: MemoryFs` and `cli: InstallerCli`.
## Implementation Notes
The default template engine handles only `{{varName}}` substitution and
returns empty string for unknown vars — that's enough for plugin tests
that don't care about templates and avoids adding a dependency on
`@suaveplan/templates` to the test surface. The default `cwd` is `/`
(POSIX root in `MemoryFs`'s namespace) so paths in plans can be written
literally. Tests that need a different `cwd` should pass it explicitly;
both `DiskFs` and `MemoryFs` honour the value identically.

View File

@@ -0,0 +1,70 @@
# `script-cli`
## Purpose
Non-TTY `InstallerCli` driven by pre-recorded answers. `ScriptCli` is the
default CLI used by `runPlugin` — tests script `confirm`/`prompt`
responses via the constructor and read back the interaction history via
the `record` getter to assert on what the host actually asked. This makes
prompt-conformance tests fully deterministic.
## Features
- Separate queues for `confirm` (booleans) and `prompt` (strings) so
tests don't have to interleave them by call-order.
- Falls back to `defaultValue` (and then to `false`/`""`) when the queue
is exhausted — handy for tests that don't care about every prompt.
- `record.confirms`, `record.prompts`, `record.written` give read-only
views of every interaction since construction.
- `write(line)` captures emitted output instead of printing to stdout, so
tests can assert on user-facing messages without log capture.
## Basic Example
```ts
import { ScriptCli } from "@suaveplan/installer";
const cli = new ScriptCli({ confirm: [true], prompt: ["alice"] });
console.log(await cli.confirm("Continue?", false)); // true
console.log(await cli.prompt("Name?", "guest")); // "alice"
```
## Advanced Example
```ts
import { runPlugin, ScriptCli } from "@suaveplan/installer";
const cli = new ScriptCli({
confirm: [true, false, true],
prompt: ["agent-skills", "/.suaveplan"],
});
await runPlugin(myPlugin, { cli, answers: undefined, noInteractive: false });
// All scripted answers consumed:
expect(cli.record.confirms.length).toBe(3);
expect(cli.record.prompts.map((p) => p.answer)).toEqual([
"agent-skills",
"/.suaveplan",
]);
```
## API Reference
- `new ScriptCli(answers?: { confirm?: boolean[], prompt?: string[] })`
- `confirm(message, defaultValue?): Promise<boolean>` — pops next
scripted boolean or returns the default.
- `prompt(message, defaultValue?): Promise<string>` — pops next scripted
string or returns the default.
- `write(line): void` — captures into `record.written`.
- `record` getter — `{ confirms, prompts, written }` with full history.
## Implementation Notes
The internal queues are mutable arrays that `shift()` on each call so
order is preserved. When a scripted value is `undefined` (queue empty),
the fallback chain is `defaultValue ?? false/""` — explicit `defaultValue`
wins over the generic falsy. The `record` getter returns the live
internal arrays — tests should treat them as read-only. Write captures
plain strings (no formatting, no ANSI) so assertions stay
implementation-agnostic.

View File

@@ -0,0 +1,79 @@
# `file-op.types`
## Purpose
Defines the discriminated `FileOp` union — the unit currency between a
plugin's `plan` step and the host's `apply` step. A plan returns `FileOp[]`;
the host validates, conflict-resolves, and materialises each one inside a
transaction. Keeping the op shape declarative (no closures, no callbacks)
is what enables `--dry-run`, structured-conflict resolution, and rollback.
## Features
- Six op kinds: `write`, `patch-json`, `patch-text`, `mkdir`, `delete`,
`rename`. Each carries a `kind` discriminator for exhaustive switching.
- Per-op `conflict?: ConflictPolicy` — `error | overwrite | skip-if-exists | merge | prompt`. Default is `error`; the host's `--force`
flag collapses everything to `overwrite`.
- Patch ops carry their structured intent: `JsonMerge` (deep-merge with
array semantics) for `patch-json`, `TextEdit[]` (anchor + mode) for
`patch-text`.
- Bytes-first `write` — contents are `Uint8Array` so binary assets travel
unchanged through the pipeline.
## Basic Example
```ts
import type { FileOp } from "@suaveplan/installer";
const op: FileOp = {
kind: "write",
path: "/.suaveplan/skills/echo/SKILL.md",
contents: new TextEncoder().encode("# echo\n"),
conflict: "skip-if-exists",
};
```
## Advanced Example
```ts
import type { FileOp } from "@suaveplan/installer";
const ops: FileOp[] = [
{ kind: "mkdir", path: "/.suaveplan/skills" },
{
kind: "patch-json",
path: "/.suaveplan/skills/index.json",
merge: { value: { skills: [{ id: "echo", version: "1.0.0" }] } },
conflict: "merge",
},
{
kind: "patch-text",
path: "/.gitignore",
edits: [
{ anchor: "node_modules", mode: "insert-after", content: "\n.suaveplan/" },
],
conflict: "merge",
},
];
```
## API Reference
- `WriteOp` — `kind: "write"`, `path`, `contents: Uint8Array`, optional
`mode?: number`.
- `PatchJsonOp` — `kind: "patch-json"`, `path`, `merge: JsonMerge`.
- `PatchTextOp` — `kind: "patch-text"`, `path`, `edits: ReadonlyArray<TextEdit>`.
- `MkdirOp` — `kind: "mkdir"`, `path`.
- `DeleteOp` — `kind: "delete"`, `path`.
- `RenameOp` — `kind: "rename"`, `from`, `to`.
- `BaseFileOp` — common `conflict?: ConflictPolicy` field.
- `ConflictPolicy` — `"error" | "overwrite" | "skip-if-exists" | "merge" | "prompt"`.
- `FileOp` — discriminated union of the six kinds above.
## Implementation Notes
The discriminator is `kind`, never `type`, to avoid clashes with TS's
internal type-narrowing keyword. `rename` uses `from`/`to` instead of
`path`/`newPath` so `pathOf(op)` (in `host/apply/conflict.ts` and
`host/host.ts`) can pick `op.to` for conflict resolution and filtering.
Mutating ops always reference the new resting place, never the source.

View File

@@ -0,0 +1,94 @@
# `installer.types`
## Purpose
Defines the runtime-free contract surface for `@suaveplan/installer`: the
interfaces a plugin author implements (`InstallerPlugin`), the runtime the
host gives plugins (`InstallerContext`, `InstallerFs`, `InstallerCli`,
`TemplateEngine`), and the data structures the host produces
(`DetectResult`, `PromptSchema<T>`, `JsonMerge`, `TextEdit`, `PlanSummary`).
Every other module in the package ultimately speaks one of these
interfaces. By keeping types in their own module we can change internal
representations without rippling across consumers.
## Features
- `InstallerPlugin<TAnswers, THandle>` carries generics end-to-end so
`prompts → plan → apply → postInstall` is type-safe with no `any`.
- `PromptSchema<T>` is a Zod-compatible structural type: any object with
`parse` and `safeParse` is acceptable, so plugins can swap validators.
- `InstallerFs` is read-only; `MutableInstallerFs` (in `fs/installer-fs.ts`)
extends it with the mutating methods used internally by `apply`.
- `InstallerContext` includes the resolved `dryRun`, `force`, and
`noInteractive` flags so plugins do not have to re-thread them.
## Basic Example
```ts
import type {
FileOp,
InstallerContext,
InstallerPlugin,
} from "@suaveplan/installer";
const myPlugin: InstallerPlugin<{ name: string }, void> = {
id: "demo",
version: "0.1.0",
plan: async (answers, _ctx): Promise<ReadonlyArray<FileOp>> => [
{
kind: "write",
path: `/${answers.name}.md`,
contents: new TextEncoder().encode(`hi ${answers.name}\n`),
},
],
apply: async () => undefined,
};
```
## Advanced Example
```ts
import type {
DetectResult,
InstallerContext,
InstallerPlugin,
} from "@suaveplan/installer";
interface Answers { dir: string }
interface Handle { count: number }
const advanced: InstallerPlugin<Answers, Handle> = {
id: "advanced",
version: "1.0.0",
detect: async (ctx: InstallerContext): Promise<DetectResult> => ({
alreadyInstalled: await ctx.fs.exists(`${ctx.cwd}/.suaveplan`),
notes: ["scanned"],
}),
plan: async () => [],
apply: async (ops) => ({ count: ops.length }),
postInstall: async (handle, ctx) => {
ctx.cli.write(`installed ${handle.count} files`);
},
};
```
## API Reference
- `InstallerContext` — `cwd`, `logger`, `fs`, `cli`, `templates`, `signal`,
`dryRun`, `force`, `noInteractive`.
- `InstallerPlugin<TAnswers, THandle>` — `id`, `version`, optional
`detect`/`prompts`/`postInstall`, mandatory `plan` + `apply`.
- `InstallerFs` — read API: `exists`, `readFile`, `readJson`, `stat`,
`listDir`, `pathOf`.
- `InstallerCli` — `confirm`, `prompt`, `write`.
- `PromptSchema<T>` — `parse(value): T` and
`safeParse(value): { success, data | error.issues }`.
- `DetectResult`, `JsonMerge`, `TextEdit`, `PlanSummary`, `RunOptions` —
see source for field-level shapes.
## Implementation Notes
Types here ship with **zero runtime cost**. Schemas live in `schemas/` and
are imported only at the host's validation boundary. `PromptSchema<T>` is
intentionally structural rather than nominal so existing Zod schemas plug
in without an adapter layer.