Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user