Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,98 @@
{
"name": "@suaveplan/installer",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Plugin host + lifecycle for project boilerplating and asset installation: detect → prompts → plan → apply → postInstall, transactional apply with rollback, conflict policies, manifest discovery.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"suaveplan-install": "./dist/bin/suaveplan-install.js"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
},
"./host": {
"import": {
"types": "./dist/host/host.d.ts",
"default": "./dist/host/host.js"
}
},
"./fs": {
"import": {
"types": "./dist/fs/index.d.ts",
"default": "./dist/fs/index.js"
}
},
"./manifest": {
"import": {
"types": "./dist/manifest/index.d.ts",
"default": "./dist/manifest/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/cli": "workspace:^",
"@suaveplan/templates": "workspace:^",
"@suaveplan/validation": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"zod": "catalog:forms",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

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

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { trace } from "@opentelemetry/api";
import { cli } from "@suaveplan/cli";
import type { LoggerLike } from "@suaveplan/types";
import { DiskFs } from "../fs/disk-fs.js";
import { Host } from "../host/host.js";
import { discoverPlugins, findPlugin } from "../manifest/discover.js";
import type {
InstallerCli,
InstallerContext,
InstallerPlugin,
TemplateEngine,
} from "../types/installer.types.js";
const decoder = new TextDecoder();
const stdoutCli: InstallerCli = {
confirm: async (message, defaultValue = false) => {
process.stderr.write(
`[non-interactive] ${message} → ${String(defaultValue)}\n`
);
return defaultValue;
},
prompt: async (message, defaultValue = "") => {
process.stderr.write(
`[non-interactive] ${message} → ${defaultValue}\n`
);
return defaultValue;
},
write: (line) => {
process.stdout.write(`${line}\n`);
},
};
const echoTemplates: TemplateEngine = {
render: (template, context) =>
template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key: string) => {
const value = (context as Record<string, unknown>)[key];
return value === undefined ? "" : String(value);
}),
};
const noopLogger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
interface AddFlags {
readonly dryRun: boolean;
readonly force: boolean;
readonly noInteractive: boolean;
readonly only?: ReadonlyArray<string>;
readonly exclude?: ReadonlyArray<string>;
readonly answersFile?: string;
}
async function loadAnswersFile(path: string | undefined): Promise<unknown> {
if (!path) return undefined;
const raw = decoder.decode(await readFile(path));
return JSON.parse(raw);
}
export async function runAdd(pluginId: string, flags: AddFlags): Promise<void> {
const cwd = process.cwd();
const controller = new AbortController();
const onSignal = (): void => controller.abort();
process.once("SIGINT", onSignal);
process.once("SIGTERM", onSignal);
const fs = new DiskFs({ cwd, signal: controller.signal });
const ctx: InstallerContext = {
cwd,
fs,
cli: stdoutCli,
signal: controller.signal,
logger: noopLogger,
templates: echoTemplates,
dryRun: flags.dryRun,
force: flags.force,
noInteractive: flags.noInteractive,
};
const discovered = await findPlugin(cwd, pluginId);
const entryPath = `${discovered.packagePath}/${discovered.manifest.entry}`;
const mod = (await import(entryPath)) as Record<string, unknown>;
const pluginValue = (mod["default"] ?? mod["plugin"]) as
| InstallerPlugin<unknown, unknown>
| undefined;
if (!pluginValue) {
throw new Error(
`plugin ${pluginId} entry ${entryPath} did not export a default or named 'plugin'`
);
}
const answers = await loadAnswersFile(flags.answersFile);
const host = new Host(ctx, fs);
const result = await host.run(pluginValue, {
...(answers !== undefined ? { answers } : {}),
dryRun: flags.dryRun,
force: flags.force,
noInteractive: flags.noInteractive,
...(flags.only ? { only: flags.only } : {}),
...(flags.exclude ? { exclude: flags.exclude } : {}),
});
process.stdout.write(`${JSON.stringify(result.summary)}\n`);
}
export async function runList(): Promise<void> {
const found = await discoverPlugins(process.cwd());
for (const plugin of found) {
process.stdout.write(
`${plugin.id}\t${plugin.manifest.version}\t${plugin.packageName}\n`
);
}
}
function parseList(value: unknown): ReadonlyArray<string> | undefined {
if (typeof value !== "string" || value.length === 0) return undefined;
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
export async function main(argv: ReadonlyArray<string>): Promise<number> {
const tracer = trace.getTracer("@suaveplan/installer", "0.1.0");
return tracer.startActiveSpan("installer.cli", async (span) => {
try {
const app = cli("suaveplan-install", "0.1.0").description(
"Install Suaveplan plugins into the current project"
);
app.command("add <plugin>")
.description("Install a plugin into the current project")
.option("--dry-run", "print the plan and exit")
.option(
"--force",
"collapse all conflict policies to overwrite"
)
.option("--no-interactive", "fail on any prompt")
.option("--only <names>", "comma-separated allowlist")
.option("--exclude <names>", "comma-separated denylist")
.option("--answers <file>", "JSON answers file")
.action(async (ctx) => {
const opts = ctx.options;
await runAdd(ctx.args["plugin"] as string, {
dryRun: Boolean(opts["dry-run"] ?? opts["dryRun"]),
force: Boolean(opts["force"]),
noInteractive: Boolean(
opts["no-interactive"] ?? opts["noInteractive"]
),
...(parseList(opts["only"])
? {
only: parseList(
opts["only"]
) as ReadonlyArray<string>,
}
: {}),
...(parseList(opts["exclude"])
? {
exclude: parseList(
opts["exclude"]
) as ReadonlyArray<string>,
}
: {}),
...(typeof opts["answers"] === "string"
? { answersFile: opts["answers"] }
: {}),
});
});
app.command("list")
.description("List installed plugins")
.action(async () => {
await runList();
});
await app.run([...argv]);
return 0;
} finally {
span.end();
}
});
}
if (typeof require !== "undefined" && require.main === module) {
void main(process.argv.slice(2)).then(
(code) => process.exit(code),
(err) => {
process.stderr.write(`${(err as Error).message}\n`);
process.exit(1);
}
);
}

View File

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

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { INSTALLER_CODES, InstallerError } from "./error-codes.js";
describe("INSTALLER-09 — error-code registry is namespaced and frozen", () => {
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(INSTALLER_CODES)).toBe(true);
});
it("every code value starts with INSTALLER.", { timeout: 5000 }, () => {
for (const value of Object.values(INSTALLER_CODES)) {
expect(value.startsWith("INSTALLER.")).toBe(true);
}
});
it("contains required minimum set", { timeout: 5000 }, () => {
const required = [
"PLUGIN_NOT_FOUND",
"PLUGIN_LOAD_FAILED",
"PLUGIN_INVALID_MANIFEST",
"PROMPT_VALIDATION_FAILED",
"PLAN_FAILED",
"APPLY_CONFLICT",
"APPLY_FAILED",
"ROLLBACK_FAILED",
"ABORTED",
] as const;
for (const key of required) {
expect(INSTALLER_CODES[key]).toBe(`INSTALLER.${key}`);
}
});
it("InstallerError carries the literal code", { timeout: 5000 }, () => {
const err = new InstallerError(
INSTALLER_CODES.PLUGIN_NOT_FOUND,
"test.message"
);
expect(err.code).toBe("INSTALLER.PLUGIN_NOT_FOUND");
expect(err).toBeInstanceOf(InstallerError);
});
});

View File

@@ -0,0 +1,42 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Frozen registry of every code emitted by `@suaveplan/installer`.
*
* Each value is namespaced as `INSTALLER.<REASON>` and the registry itself
* is frozen at module-load time so consumers can rely on `instanceof` plus
* literal-typed `code` for exhaustive switches.
*/
export const INSTALLER_CODES = defineCodes("INSTALLER", [
"PLUGIN_NOT_FOUND",
"PLUGIN_LOAD_FAILED",
"PLUGIN_INVALID_MANIFEST",
"PROMPT_VALIDATION_FAILED",
"PLAN_FAILED",
"APPLY_CONFLICT",
"APPLY_FAILED",
"ROLLBACK_FAILED",
"ABORTED",
] as const);
export type InstallerErrorCode =
(typeof INSTALLER_CODES)[keyof typeof INSTALLER_CODES];
/**
* Typed error for every `@suaveplan/installer` failure path.
*
* Extends `SuaveplanError` with a literal code drawn from
* `INSTALLER_CODES`, so consumers can `instanceof InstallerError` and then
* narrow on `.code` without losing exhaustiveness.
*/
export class InstallerError extends SuaveplanError<InstallerErrorCode> {
public constructor(
code: InstallerErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "InstallerError";
}
}

View File

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

View File

@@ -0,0 +1,5 @@
export {
INSTALLER_CODES,
InstallerError,
type InstallerErrorCode,
} from "../error-codes.js";

View File

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

View File

@@ -0,0 +1,134 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { DiskFs } from "./disk-fs.js";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "installer-disk-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
describe("DiskFs", () => {
it("default cwd is process.cwd", { timeout: 5000 }, () => {
const fs = new DiskFs();
expect(fs.pathOf("a")).toContain("a");
});
it("exists returns true for present file", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "f"), "x");
expect(await fs.exists("f")).toBe(true);
});
it("exists returns false on missing", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
expect(await fs.exists("missing")).toBe(false);
});
it("readFile returns Uint8Array", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "f"), "hi");
const bytes = await fs.readFile("f");
expect(decoder.decode(bytes)).toBe("hi");
});
it("readJson decodes JSON", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "x.json"), '{"a":1}');
expect(await fs.readJson<{ a: number }>("x.json")).toEqual({ a: 1 });
});
it("stat reports file stats", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "f"), "abc");
const s = await fs.stat("f");
expect(s.isFile).toBe(true);
expect(s.size).toBe(3);
});
it("listDir lists entries sorted", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await mkdir(join(root, "d"));
await writeFile(join(root, "d", "b"), "x");
await writeFile(join(root, "d", "a"), "y");
expect(await fs.listDir("d")).toEqual(["a", "b"]);
});
it("writeFile creates intermediate dirs", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await fs.writeFile("nested/deep/file", encoder.encode("ok"));
expect(await fs.exists("nested/deep/file")).toBe(true);
});
it("writeFile honours mode", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await fs.writeFile("f", encoder.encode("ok"), 0o600);
expect(await fs.exists("f")).toBe(true);
});
it("deleteFile removes file", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "f"), "x");
await fs.deleteFile("f");
expect(await fs.exists("f")).toBe(false);
});
it("deleteFile on missing is no-op (force)", {
timeout: 5000,
}, async () => {
const fs = new DiskFs({ cwd: root });
await expect(fs.deleteFile("nope")).resolves.toBeUndefined();
});
it("mkdir creates dirs recursively", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await fs.mkdir("a/b/c");
expect(await fs.exists("a/b/c")).toBe(true);
});
it("rename moves file", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "f"), "x");
await fs.rename("f", "g");
expect(await fs.exists("g")).toBe(true);
});
it("writeJson writes pretty", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await fs.writeJson("x.json", { a: 1 });
const text = decoder.decode(await fs.readFile("x.json"));
expect(text).toBe('{\n "a": 1\n}\n');
});
it("aborted signal throws", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
const fs = new DiskFs({ cwd: root, signal: ac.signal });
await expect(fs.writeFile("x", encoder.encode("y"))).rejects.toThrow(
/aborted/i
);
});
it("absolute paths are resolved relative to cwd", {
timeout: 5000,
}, async () => {
const fs = new DiskFs({ cwd: root });
await fs.writeFile("/inner/f", encoder.encode("ok"));
expect(await fs.exists("inner/f")).toBe(true);
});
});

View File

@@ -0,0 +1,126 @@
import {
mkdir,
readdir,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import type { MutableInstallerFs } from "./installer-fs.js";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new DOMException("Operation aborted", "AbortError");
}
}
export interface DiskFsOptions {
readonly cwd?: string;
readonly signal?: AbortSignal;
}
/**
* Disk-backed implementation of `MutableInstallerFs`.
*
* All paths are resolved against `options.cwd` (or `process.cwd()`).
* Every async operation checks `options.signal` so the host's abort flow
* can interrupt mid-apply.
*/
export class DiskFs implements MutableInstallerFs {
private readonly cwd: string;
private readonly signal: AbortSignal | undefined;
public constructor(options: DiskFsOptions = {}) {
this.cwd = options.cwd ?? process.cwd();
this.signal = options.signal;
}
public pathOf(...segments: string[]): string {
return resolve(this.cwd, ...segments);
}
public async exists(path: string): Promise<boolean> {
ensureNotAborted(this.signal);
try {
await stat(this.resolve(path));
return true;
} catch {
return false;
}
}
public async stat(
path: string
): Promise<{ size: number; isFile: boolean; isDir: boolean }> {
ensureNotAborted(this.signal);
const s = await stat(this.resolve(path));
return { size: s.size, isFile: s.isFile(), isDir: s.isDirectory() };
}
public async readFile(path: string): Promise<Uint8Array> {
ensureNotAborted(this.signal);
const buf = await readFile(this.resolve(path));
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
}
public async readJson<T = unknown>(path: string): Promise<T> {
const bytes = await this.readFile(path);
return JSON.parse(decoder.decode(bytes)) as T;
}
public async listDir(path: string): Promise<string[]> {
ensureNotAborted(this.signal);
const entries = await readdir(this.resolve(path));
return entries.sort();
}
public async writeFile(
path: string,
contents: Uint8Array,
mode?: number
): Promise<void> {
ensureNotAborted(this.signal);
const target = this.resolve(path);
await mkdir(dirname(target), { recursive: true });
await writeFile(
target,
contents,
mode === undefined ? undefined : { mode }
);
}
public async deleteFile(path: string): Promise<void> {
ensureNotAborted(this.signal);
await rm(this.resolve(path), { force: true });
}
public async mkdir(path: string): Promise<void> {
ensureNotAborted(this.signal);
await mkdir(this.resolve(path), { recursive: true });
}
public async rename(from: string, to: string): Promise<void> {
ensureNotAborted(this.signal);
await mkdir(dirname(this.resolve(to)), { recursive: true });
await rename(this.resolve(from), this.resolve(to));
}
public async writeJson(path: string, value: unknown): Promise<void> {
await this.writeFile(
path,
encoder.encode(`${JSON.stringify(value, null, 2)}\n`)
);
}
private resolve(path: string): string {
return resolve(
this.cwd,
path.startsWith("/") ? path.slice(1) : join(path)
);
}
}

View File

@@ -0,0 +1,6 @@
export { DiskFs, type DiskFsOptions } from "./disk-fs.js";
export type {
InstallerFs,
MutableInstallerFs,
} from "./installer-fs.js";
export { MemoryFs, type MemoryFsOptions } from "./memory-fs.js";

View File

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

View File

@@ -0,0 +1,15 @@
import type { InstallerFs } from "../types/installer.types.js";
/**
* Mutating extension to {@link InstallerFs} used internally by the host's
* apply stage. Plugins only see the read-only `InstallerFs`.
*/
export interface MutableInstallerFs extends InstallerFs {
writeFile(path: string, contents: Uint8Array, mode?: number): Promise<void>;
deleteFile(path: string): Promise<void>;
mkdir(path: string): Promise<void>;
rename(from: string, to: string): Promise<void>;
writeJson(path: string, value: unknown): Promise<void>;
}
export type { InstallerFs } from "../types/installer.types.js";

View File

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

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryFs } from "./memory-fs.js";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
describe("MemoryFs", () => {
it("seeds with strings and Uint8Array", { timeout: 5000 }, async () => {
const fs = new MemoryFs({
seed: { "/a.txt": "hi", "/b.bin": encoder.encode("x") },
});
expect(decoder.decode(await fs.readFile("/a.txt"))).toBe("hi");
expect(decoder.decode(await fs.readFile("/b.bin"))).toBe("x");
});
it("pathOf joins segments", { timeout: 5000 }, () => {
const fs = new MemoryFs();
expect(fs.pathOf()).toBe("/");
expect(fs.pathOf("a", "b")).toBe("a/b");
expect(fs.pathOf("a\\b", "c")).toBe("a/b/c");
});
it("readJson decodes JSON", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/x.json": '{"a":1}' } });
expect(await fs.readJson<{ a: number }>("/x.json")).toEqual({ a: 1 });
});
it("stat for files and dirs", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/d/f.txt": "x" } });
const file = await fs.stat("/d/f.txt");
expect(file).toEqual({ size: 1, isFile: true, isDir: false });
const dir = await fs.stat("/d");
expect(dir).toEqual({ size: 0, isFile: false, isDir: true });
});
it("stat throws on missing", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await expect(fs.stat("/nope")).rejects.toThrow(/ENOENT/);
});
it("readFile throws on missing", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await expect(fs.readFile("/nope")).rejects.toThrow(/ENOENT/);
});
it("listDir lists immediate children sorted", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({
seed: { "/d/b.txt": "b", "/d/a.txt": "a", "/d/sub/x.txt": "x" },
});
expect(await fs.listDir("/d")).toEqual(["a.txt", "b.txt", "sub"]);
expect(await fs.listDir("/")).toContain("d");
});
it("listDir on file throws", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/f.txt": "x" } });
await expect(fs.listDir("/f.txt")).rejects.toThrow(/ENOTDIR/);
});
it("writeFile + readFile + mode", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.writeFile("/a.txt", encoder.encode("hi"), 0o600);
expect(decoder.decode(await fs.readFile("/a.txt"))).toBe("hi");
});
it("deleteFile removes file", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/x.txt": "x" } });
await fs.deleteFile("/x.txt");
expect(await fs.exists("/x.txt")).toBe(false);
});
it("deleteFile on missing is no-op", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await expect(fs.deleteFile("/nope")).resolves.toBeUndefined();
});
it("deleteFile rejects non-empty directory", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({ seed: { "/d/x.txt": "x" } });
await expect(fs.deleteFile("/d")).rejects.toThrow(/ENOTEMPTY/);
});
it("deleteFile removes empty directory", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.mkdir("/d");
await expect(fs.deleteFile("/d")).resolves.toBeUndefined();
});
it("rename moves file", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/a.txt": "x" } });
await fs.rename("/a.txt", "/b.txt");
expect(await fs.exists("/a.txt")).toBe(false);
expect(await fs.exists("/b.txt")).toBe(true);
});
it("rename throws on missing source", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await expect(fs.rename("/no", "/yes")).rejects.toThrow(/ENOENT/);
});
it("writeJson writes pretty JSON", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.writeJson("/x.json", { a: 1 });
expect(decoder.decode(await fs.readFile("/x.json"))).toBe(
'{\n "a": 1\n}\n'
);
});
it("snapshot returns file map", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/a.txt": "a", "/b.txt": "b" } });
const snap = fs.snapshot();
expect(Object.keys(snap).sort()).toEqual(["/a.txt", "/b.txt"]);
});
it("seeding a path that conflicts with existing file throws", {
timeout: 5000,
}, () => {
expect(
() =>
new MemoryFs({
seed: { "/a.txt": "x", "/a.txt/inner": "y" },
})
).toThrow(/ENOTDIR/);
});
it("aborted signal makes operations throw", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
const fs = new MemoryFs({ signal: ac.signal });
await expect(fs.writeFile("/x", encoder.encode("y"))).rejects.toThrow(
/aborted/i
);
});
it("mkdir creates intermediate dirs", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.mkdir("/a/b/c");
expect(await fs.exists("/a")).toBe(true);
expect(await fs.exists("/a/b/c")).toBe(true);
});
it("mkdir at root keeps root", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.mkdir("/");
expect(await fs.exists("/")).toBe(true);
});
it("normalize strips trailing slash", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.mkdir("/a/b/");
expect(await fs.exists("/a/b")).toBe(true);
});
});

View File

@@ -0,0 +1,217 @@
import type { MutableInstallerFs } from "./installer-fs.js";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
interface MemoryDirEntry {
readonly kind: "dir";
}
interface MemoryFileEntry {
readonly kind: "file";
contents: Uint8Array;
mode?: number;
}
type MemoryEntry = MemoryDirEntry | MemoryFileEntry;
function normalize(p: string): string {
const trimmed = p.replace(/\\/g, "/").replace(/\/+/g, "/");
if (trimmed.length > 1 && trimmed.endsWith("/")) {
return trimmed.slice(0, -1);
}
return trimmed;
}
function dirname(p: string): string {
const norm = normalize(p);
const idx = norm.lastIndexOf("/");
if (idx <= 0) return "/";
return norm.slice(0, idx);
}
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new DOMException("Operation aborted", "AbortError");
}
}
export interface MemoryFsOptions {
readonly seed?: Record<string, string | Uint8Array>;
readonly signal?: AbortSignal;
}
/**
* In-memory `InstallerFs` used by the testing harness and by `apply`'s
* staged-tmp transaction. Strictly POSIX-style paths; directories are
* tracked as first-class entries so `listDir` returns deterministic
* results.
*/
export class MemoryFs implements MutableInstallerFs {
private readonly entries = new Map<string, MemoryEntry>();
private readonly signal: AbortSignal | undefined;
public constructor(options: MemoryFsOptions = {}) {
this.entries.set("/", { kind: "dir" });
this.signal = options.signal;
if (options.seed) {
for (const [path, value] of Object.entries(options.seed)) {
const bytes =
typeof value === "string" ? encoder.encode(value) : value;
this.seedFile(path, bytes);
}
}
}
public pathOf(...segments: string[]): string {
if (segments.length === 0) return "/";
const joined = segments
.map((s) => s.replace(/\\/g, "/"))
.join("/")
.replace(/\/+/g, "/");
return normalize(joined);
}
public async exists(path: string): Promise<boolean> {
ensureNotAborted(this.signal);
return this.entries.has(normalize(path));
}
public async stat(
path: string
): Promise<{ size: number; isFile: boolean; isDir: boolean }> {
ensureNotAborted(this.signal);
const entry = this.entries.get(normalize(path));
if (!entry) {
throw new Error(`ENOENT: ${path}`);
}
if (entry.kind === "file") {
return {
size: entry.contents.byteLength,
isFile: true,
isDir: false,
};
}
return { size: 0, isFile: false, isDir: true };
}
public async readFile(path: string): Promise<Uint8Array> {
ensureNotAborted(this.signal);
const entry = this.entries.get(normalize(path));
if (!entry || entry.kind !== "file") {
throw new Error(`ENOENT: ${path}`);
}
return new Uint8Array(entry.contents);
}
public async readJson<T = unknown>(path: string): Promise<T> {
const bytes = await this.readFile(path);
return JSON.parse(decoder.decode(bytes)) as T;
}
public async listDir(path: string): Promise<string[]> {
ensureNotAborted(this.signal);
const norm = normalize(path);
const entry = this.entries.get(norm);
if (!entry || entry.kind !== "dir") {
throw new Error(`ENOTDIR: ${path}`);
}
const prefix = norm === "/" ? "/" : `${norm}/`;
const out = new Set<string>();
for (const key of this.entries.keys()) {
if (key === norm) continue;
if (!key.startsWith(prefix)) continue;
const rest = key.slice(prefix.length);
const slash = rest.indexOf("/");
out.add(slash === -1 ? rest : rest.slice(0, slash));
}
return Array.from(out).sort();
}
public async writeFile(
path: string,
contents: Uint8Array,
mode?: number
): Promise<void> {
ensureNotAborted(this.signal);
const norm = normalize(path);
this.ensureDirChain(dirname(norm));
this.entries.set(norm, {
kind: "file",
contents: new Uint8Array(contents),
...(mode === undefined ? {} : { mode }),
});
}
public async deleteFile(path: string): Promise<void> {
ensureNotAborted(this.signal);
const norm = normalize(path);
const entry = this.entries.get(norm);
if (!entry) return;
if (entry.kind === "dir") {
const children = await this.listDir(norm);
if (children.length > 0) {
throw new Error(`ENOTEMPTY: ${path}`);
}
}
this.entries.delete(norm);
}
public async mkdir(path: string): Promise<void> {
ensureNotAborted(this.signal);
this.ensureDirChain(normalize(path));
}
public async rename(from: string, to: string): Promise<void> {
ensureNotAborted(this.signal);
const f = normalize(from);
const t = normalize(to);
const src = this.entries.get(f);
if (!src) {
throw new Error(`ENOENT: ${from}`);
}
this.ensureDirChain(dirname(t));
this.entries.set(t, src);
this.entries.delete(f);
}
public async writeJson(path: string, value: unknown): Promise<void> {
await this.writeFile(
path,
encoder.encode(`${JSON.stringify(value, null, 2)}\n`)
);
}
public snapshot(): Record<string, Uint8Array> {
const out: Record<string, Uint8Array> = {};
for (const [path, entry] of this.entries) {
if (entry.kind === "file") {
out[path] = new Uint8Array(entry.contents);
}
}
return out;
}
private seedFile(path: string, contents: Uint8Array): void {
const norm = normalize(path);
this.ensureDirChain(dirname(norm));
this.entries.set(norm, { kind: "file", contents });
}
private ensureDirChain(path: string): void {
const norm = normalize(path);
if (norm === "/" || norm === "") {
this.entries.set("/", { kind: "dir" });
return;
}
const parts = norm.split("/").filter(Boolean);
let cursor = "";
for (const part of parts) {
cursor = `${cursor}/${part}`;
const existing = this.entries.get(cursor);
if (!existing) {
this.entries.set(cursor, { kind: "dir" });
} else if (existing.kind === "file") {
throw new Error(`ENOTDIR: ${cursor}`);
}
}
}
}

View File

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

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryFs } from "../../fs/memory-fs.js";
import { runPlugin } from "../../testing/run-plugin.js";
import type { FileOp } from "../../types/file-op.types.js";
import type { InstallerPlugin } from "../../types/installer.types.js";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function bytes(s: string): Uint8Array {
return encoder.encode(s);
}
describe("INSTALLER-04 — transactional apply with rollback", () => {
it("rolls back files in reverse order on apply failure", {
timeout: 5000,
}, async () => {
const failing: FileOp = {
kind: "rename",
from: "/missing.txt",
to: "/elsewhere.txt",
};
const plugin: InstallerPlugin<undefined, void> = {
id: "tx",
version: "0.1.0",
plan: async () => [
{ kind: "write", path: "/a.txt", contents: bytes("a") },
{ kind: "write", path: "/b.txt", contents: bytes("b") },
failing,
],
apply: async () => undefined,
};
const fs = new MemoryFs();
await expect(runPlugin(plugin, { fs })).rejects.toMatchObject({
code: "INSTALLER.APPLY_FAILED",
});
expect(await fs.exists("/a.txt")).toBe(false);
expect(await fs.exists("/b.txt")).toBe(false);
});
it("dry-run prints plan without writing", { timeout: 5000 }, async () => {
const plugin: InstallerPlugin<undefined, void> = {
id: "dry",
version: "0.1.0",
plan: async () => [
{ kind: "write", path: "/a.txt", contents: bytes("a") },
],
apply: async () => undefined,
};
const fs = new MemoryFs();
const result = await runPlugin(plugin, { fs, dryRun: true });
expect(result.dryRun).toBe(true);
expect(await fs.exists("/a.txt")).toBe(false);
});
});
describe("INSTALLER-05 — conflict policy resolution", () => {
it("error policy raises APPLY_CONFLICT on existing target", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({ seed: { "/x.txt": "prev" } });
const plugin: InstallerPlugin<undefined, void> = {
id: "c",
version: "0.1.0",
plan: async () => [
{
kind: "write",
path: "/x.txt",
contents: bytes("new"),
conflict: "error",
},
],
apply: async () => undefined,
};
await expect(runPlugin(plugin, { fs })).rejects.toMatchObject({
code: "INSTALLER.APPLY_CONFLICT",
});
});
it("merge policy on patch-json deep-merges", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({
seed: { "/idx.json": JSON.stringify({ a: 1, nested: { x: 1 } }) },
});
const plugin: InstallerPlugin<undefined, void> = {
id: "merge",
version: "0.1.0",
plan: async () => [
{
kind: "patch-json",
path: "/idx.json",
merge: { value: { b: 2, nested: { y: 2 } } },
conflict: "merge",
},
],
apply: async () => undefined,
};
await runPlugin(plugin, { fs });
const result = JSON.parse(
decoder.decode(await fs.readFile("/idx.json"))
) as {
a: number;
b: number;
nested: { x: number; y: number };
};
expect(result).toEqual({ a: 1, b: 2, nested: { x: 1, y: 2 } });
});
it("merge policy on raw write raises APPLY_CONFLICT", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({ seed: { "/x.txt": "old" } });
const plugin: InstallerPlugin<undefined, void> = {
id: "bad",
version: "0.1.0",
plan: async () => [
{
kind: "write",
path: "/x.txt",
contents: bytes("new"),
conflict: "merge",
},
],
apply: async () => undefined,
};
await expect(runPlugin(plugin, { fs })).rejects.toMatchObject({
code: "INSTALLER.APPLY_CONFLICT",
});
});
it("skip-if-exists drops conflicting ops", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/x.txt": "old" } });
const plugin: InstallerPlugin<undefined, void> = {
id: "skip",
version: "0.1.0",
plan: async () => [
{
kind: "write",
path: "/x.txt",
contents: bytes("new"),
conflict: "skip-if-exists",
},
],
apply: async () => undefined,
};
const result = await runPlugin(plugin, { fs });
expect(result.summary.skipped).toBe(1);
expect(decoder.decode(await fs.readFile("/x.txt"))).toBe("old");
});
it("overwrite policy replaces existing", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/x.txt": "old" } });
const plugin: InstallerPlugin<undefined, void> = {
id: "ow",
version: "0.1.0",
plan: async () => [
{
kind: "write",
path: "/x.txt",
contents: bytes("new"),
conflict: "overwrite",
},
],
apply: async () => undefined,
};
await runPlugin(plugin, { fs });
expect(decoder.decode(await fs.readFile("/x.txt"))).toBe("new");
});
});

View File

@@ -0,0 +1,439 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import { MemoryFs } from "../../fs/memory-fs.js";
import { ScriptCli } from "../../testing/script-cli.js";
import type { FileOp } from "../../types/file-op.types.js";
import type { InstallerContext } from "../../types/installer.types.js";
import { applyOps } from "./apply.js";
import { resolveConflict } from "./conflict.js";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const noopLogger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeCtx(
fs: MemoryFs,
overrides: Partial<InstallerContext> = {}
): InstallerContext {
return {
cwd: "/",
fs,
cli: new ScriptCli(),
signal: new AbortController().signal,
logger: noopLogger,
templates: { render: (t) => t },
dryRun: false,
force: false,
noInteractive: true,
...overrides,
};
}
describe("applyOps additional paths", () => {
it("rolls back successful ops in reverse on failure", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ops: FileOp[] = [
{ kind: "write", path: "/a.txt", contents: encoder.encode("a") },
{ kind: "write", path: "/b.txt", contents: encoder.encode("b") },
{ kind: "rename", from: "/missing", to: "/elsewhere" },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_FAILED" });
expect(await fs.exists("/a.txt")).toBe(false);
expect(await fs.exists("/b.txt")).toBe(false);
});
it("delete op records prior content for rollback", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({
seed: { "/a.txt": "before", "/b.txt": "before-b" },
});
const ops: FileOp[] = [
{ kind: "delete", path: "/a.txt", conflict: "overwrite" },
{ kind: "rename", from: "/missing", to: "/elsewhere" },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_FAILED" });
// Rolled back: /a.txt restored
expect(await fs.exists("/a.txt")).toBe(true);
});
it("delete on missing increments skipped", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
const summary = await applyOps({
ops: [{ kind: "delete", path: "/nope" }],
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
});
expect(summary.skipped).toBe(1);
});
it("mkdir on existing dir is no-op", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.mkdir("/d");
const summary = await applyOps({
ops: [{ kind: "mkdir", path: "/d" }],
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
});
expect(summary.written).toBe(0);
});
it("patch-text appends to existing file", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/x.txt": "hi" } });
const ops: FileOp[] = [
{
kind: "patch-text",
path: "/x.txt",
edits: [{ anchor: "hi", mode: "insert-after", content: "!" }],
conflict: "merge",
},
];
await applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
});
expect(decoder.decode(await fs.readFile("/x.txt"))).toBe("hi!");
});
it("patch-json without prior writes whole value", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ops: FileOp[] = [
{
kind: "patch-json",
path: "/x.json",
merge: { value: { a: 1 } },
},
];
await applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
});
expect(
JSON.parse(decoder.decode(await fs.readFile("/x.json"))) as {
a: number;
}
).toEqual({ a: 1 });
});
it("rollback failure surfaces ROLLBACK_FAILED", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
fs.deleteFile = async () => {
throw new Error("rollback boom");
};
const ops: FileOp[] = [
{ kind: "write", path: "/a.txt", contents: encoder.encode("a") },
{ kind: "rename", from: "/missing", to: "/elsewhere" },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.ROLLBACK_FAILED" });
});
it("aborted signal mid-apply raises ABORTED", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ac = new AbortController();
ac.abort();
const ops: FileOp[] = [
{ kind: "write", path: "/a.txt", contents: encoder.encode("a") },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs, { signal: ac.signal }),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.ABORTED" });
});
it("mkdir rollback removes the freshly-created directory", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ops: FileOp[] = [
{ kind: "mkdir", path: "/a" },
{ kind: "rename", from: "/missing", to: "/elsewhere" },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_FAILED" });
expect(await fs.exists("/a")).toBe(false);
});
it("mkdir on fresh path creates and journals undo", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const summary = await applyOps({
ops: [{ kind: "mkdir", path: "/freshdir" }],
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
});
expect(summary.written).toBe(1);
expect(await fs.exists("/freshdir")).toBe(true);
});
it("patch-json rollback restores prior content", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({ seed: { "/x.json": '{"a":1}' } });
const ops: FileOp[] = [
{
kind: "patch-json",
path: "/x.json",
merge: { value: { b: 2 } },
conflict: "merge",
},
{ kind: "rename", from: "/missing", to: "/elsewhere" },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_FAILED" });
expect(
JSON.parse(decoder.decode(await fs.readFile("/x.json"))) as {
a: number;
}
).toEqual({ a: 1 });
});
it("write rollback restores prior file when overwriting", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({ seed: { "/x.txt": "before" } });
const ops: FileOp[] = [
{
kind: "write",
path: "/x.txt",
contents: encoder.encode("after"),
conflict: "overwrite",
},
{ kind: "rename", from: "/missing", to: "/elsewhere" },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_FAILED" });
expect(decoder.decode(await fs.readFile("/x.txt"))).toBe("before");
});
it("patch-json rollback (no prior) deletes staged file", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ops: FileOp[] = [
{
kind: "patch-json",
path: "/x.json",
merge: { value: { a: 1 } },
},
{ kind: "rename", from: "/missing", to: "/elsewhere" },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_FAILED" });
expect(await fs.exists("/x.json")).toBe(false);
});
it("patch-text rollback (no prior) deletes staged file", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ops: FileOp[] = [
{
kind: "patch-text",
path: "/new.txt",
edits: [
{ anchor: /^/, mode: "insert-before", content: "first" },
],
},
{ kind: "rename", from: "/missing", to: "/elsewhere" },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_FAILED" });
expect(await fs.exists("/new.txt")).toBe(false);
});
it("patch-text rollback restores prior content", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({ seed: { "/x.txt": "before" } });
const ops: FileOp[] = [
{
kind: "patch-text",
path: "/x.txt",
edits: [
{ anchor: "before", mode: "replace", content: "after" },
],
conflict: "merge",
},
{ kind: "rename", from: "/missing", to: "/elsewhere" },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_FAILED" });
expect(decoder.decode(await fs.readFile("/x.txt"))).toBe("before");
});
it("rename failing rolls back rename only", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/a.txt": "x" } });
const ops: FileOp[] = [
{ kind: "rename", from: "/a.txt", to: "/b.txt" },
{ kind: "rename", from: "/nonexistent", to: "/dst" },
];
await expect(
applyOps({
ops,
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_FAILED" });
expect(await fs.exists("/a.txt")).toBe(true);
expect(await fs.exists("/b.txt")).toBe(false);
});
});
describe("resolveConflict pathOf rename", () => {
it("rename op surfaces target in conflict context", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({ seed: { "/dst.txt": "x", "/src.txt": "y" } });
await expect(
applyOps({
ops: [
{
kind: "rename",
from: "/src.txt",
to: "/dst.txt",
conflict: "merge",
},
],
ctx: makeCtx(fs),
fs,
pluginId: "p",
pluginVersion: "0",
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_CONFLICT" });
});
});
describe("resolveConflict prompt path", () => {
it("interactive prompt accepted → overwrite", {
timeout: 5000,
}, async () => {
const cli = new ScriptCli({ confirm: [true] });
const decision = await resolveConflict({
op: {
kind: "write",
path: "/x",
contents: encoder.encode("y"),
conflict: "prompt",
},
exists: true,
ctx: { force: false, noInteractive: false, cli },
});
expect(decision).toBe("apply");
});
it("interactive prompt rejected → error", { timeout: 5000 }, async () => {
const cli = new ScriptCli({ confirm: [false] });
await expect(
resolveConflict({
op: {
kind: "write",
path: "/x",
contents: encoder.encode("y"),
conflict: "prompt",
},
exists: true,
ctx: { force: false, noInteractive: false, cli },
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_CONFLICT" });
});
});

View File

@@ -0,0 +1,218 @@
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { InstallerError } from "../../error-codes.js";
import type { MutableInstallerFs } from "../../fs/installer-fs.js";
import type { FileOp } from "../../types/file-op.types.js";
import type {
InstallerContext,
PlanSummary,
} from "../../types/installer.types.js";
import { resolveConflict } from "./conflict.js";
import { applyJsonMerge } from "./json-merge.js";
import { applyTextEdits } from "./text-patch.js";
const tracer = trace.getTracer("@suaveplan/installer", "0.1.0");
const decoder = new TextDecoder();
const encoder = new TextEncoder();
interface JournalEntry {
readonly undo: () => Promise<void>;
}
function pathOf(op: FileOp): string {
return op.kind === "rename" ? op.to : op.path;
}
function ensureNotAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw new InstallerError("INSTALLER.ABORTED", "installer.aborted", {
cause: signal.reason as Error | undefined,
});
}
}
export interface ApplyOptions {
readonly ops: ReadonlyArray<FileOp>;
readonly ctx: InstallerContext;
readonly fs: MutableInstallerFs;
readonly pluginId: string;
readonly pluginVersion: string;
}
/**
* Materialise `FileOp[]` against `fs` with rollback semantics.
*
* Each op records an `undo` action onto the journal as soon as it succeeds
* so any subsequent failure can reverse the apply in LIFO order. `--dry-run`
* is honoured by the host before this stage runs (see `host.ts`).
*/
export async function applyOps(options: ApplyOptions): Promise<PlanSummary> {
return tracer.startActiveSpan(
"installer.apply",
{
attributes: {
"plugin.id": options.pluginId,
"plugin.version": options.pluginVersion,
dry_run: options.ctx.dryRun,
},
},
async (span) => {
const journal: JournalEntry[] = [];
const summary: {
written: number;
patched: number;
skipped: number;
conflicts: number;
} = {
written: 0,
patched: 0,
skipped: 0,
conflicts: 0,
};
try {
for (const op of options.ops) {
ensureNotAborted(options.ctx.signal);
await applyOne(
op,
options.fs,
options.ctx,
journal,
summary
);
}
span.setStatus({ code: SpanStatusCode.OK });
return summary;
} catch (cause) {
summary.conflicts += 1;
await rollback(journal).catch((rbCause) => {
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
throw new InstallerError(
"INSTALLER.ROLLBACK_FAILED",
"installer.rollback.failed",
{
cause: rbCause as Error,
context: { original: String(cause) },
}
);
});
span.setStatus({ code: SpanStatusCode.ERROR });
if (cause instanceof InstallerError) throw cause;
throw new InstallerError(
"INSTALLER.APPLY_FAILED",
"installer.apply.failed",
{ cause: cause as Error }
);
} finally {
span.end();
}
}
);
}
async function applyOne(
op: FileOp,
fs: MutableInstallerFs,
ctx: InstallerContext,
journal: JournalEntry[],
summary: {
written: number;
patched: number;
skipped: number;
conflicts: number;
}
): Promise<void> {
const targetPath = pathOf(op);
const exists = await fs.exists(targetPath);
const decision = await resolveConflict({
op,
exists,
ctx: {
force: ctx.force,
noInteractive: ctx.noInteractive,
cli: ctx.cli,
},
});
if (decision === "skip") {
summary.skipped += 1;
return;
}
if (op.kind === "mkdir") {
// resolveConflict already short-circuits when the directory exists,
// so by the time we reach here the path is fresh.
await fs.mkdir(op.path);
journal.push({ undo: () => fs.deleteFile(op.path) });
summary.written += 1;
return;
}
if (op.kind === "delete") {
if (!exists) {
summary.skipped += 1;
return;
}
const prior = await fs.readFile(op.path);
await fs.deleteFile(op.path);
journal.push({ undo: () => fs.writeFile(op.path, prior) });
summary.written += 1;
return;
}
if (op.kind === "rename") {
await fs.rename(op.from, op.to);
journal.push({ undo: () => fs.rename(op.to, op.from) });
summary.written += 1;
return;
}
const prior = exists ? await fs.readFile(op.path) : undefined;
if (op.kind === "write") {
await fs.writeFile(op.path, op.contents, op.mode);
journal.push({
undo: () =>
prior === undefined
? fs.deleteFile(op.path)
: fs.writeFile(op.path, prior),
});
summary.written += 1;
return;
}
if (op.kind === "patch-json") {
let next: unknown;
if (decision === "merge" && prior !== undefined) {
const existing = JSON.parse(decoder.decode(prior)) as unknown;
next = applyJsonMerge(existing, op.merge);
} else {
next = applyJsonMerge(undefined, op.merge);
}
await fs.writeJson(op.path, next);
journal.push({
undo: () =>
prior === undefined
? fs.deleteFile(op.path)
: fs.writeFile(op.path, prior),
});
summary.patched += 1;
return;
}
// patch-text
const baseText = prior === undefined ? "" : decoder.decode(prior);
const next = applyTextEdits(baseText, op.edits);
await fs.writeFile(op.path, encoder.encode(next));
journal.push({
undo: () =>
prior === undefined
? fs.deleteFile(op.path)
: fs.writeFile(op.path, prior),
});
summary.patched += 1;
}
async function rollback(journal: ReadonlyArray<JournalEntry>): Promise<void> {
for (let i = journal.length - 1; i >= 0; i -= 1) {
await (journal[i] as JournalEntry).undo();
}
}

View File

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

View File

@@ -0,0 +1,79 @@
import { InstallerError } from "../../error-codes.js";
import type { ConflictPolicy, FileOp } from "../../types/file-op.types.js";
import type {
InstallerCli,
InstallerContext,
} from "../../types/installer.types.js";
export type ConflictDecision = "apply" | "skip" | "merge";
const DEFAULT_POLICY: ConflictPolicy = "error";
export interface ResolveConflictOptions {
readonly op: FileOp;
readonly exists: boolean;
readonly ctx: Pick<InstallerContext, "force" | "noInteractive" | "cli">;
}
function pathOf(op: FileOp): string {
return op.kind === "rename" ? op.to : op.path;
}
function effectivePolicy(op: FileOp, force: boolean): ConflictPolicy {
if (force) return "overwrite";
return op.conflict ?? DEFAULT_POLICY;
}
async function askPrompt(
op: FileOp,
cli: InstallerCli,
noInteractive: boolean
): Promise<ConflictPolicy> {
if (noInteractive) return "error";
const ok = await cli.confirm(
`[installer] ${op.kind} ${pathOf(op)} already exists — overwrite?`,
false
);
return ok ? "overwrite" : "error";
}
/**
* Resolve a conflict according to {@link ConflictPolicy} semantics.
* Returns `apply` (proceed normally), `skip` (drop the op silently and
* record it), or `merge` (callers must perform a structured merge for
* patch ops; raw `write` ops with policy `merge` raise APPLY_CONFLICT).
*/
export async function resolveConflict(
options: ResolveConflictOptions
): Promise<ConflictDecision> {
const { op, exists, ctx } = options;
if (!exists) return "apply";
// Idempotent default: re-applying mkdir for an existing directory is a
// silent no-op regardless of conflict policy. Tests treat "already a
// directory" as the success state for mkdir, not a conflict.
if (op.kind === "mkdir") return "skip";
let policy = effectivePolicy(op, ctx.force);
if (policy === "prompt") {
policy = await askPrompt(op, ctx.cli, ctx.noInteractive);
}
if (policy === "skip-if-exists") return "skip";
if (policy === "overwrite") return "apply";
if (policy === "merge") {
if (op.kind === "patch-json" || op.kind === "patch-text") {
return "merge";
}
throw new InstallerError(
"INSTALLER.APPLY_CONFLICT",
"installer.apply.merge_unsupported",
{ context: { kind: op.kind, path: pathOf(op) } }
);
}
throw new InstallerError(
"INSTALLER.APPLY_CONFLICT",
"installer.apply.conflict",
{ context: { kind: op.kind, path: pathOf(op) } }
);
}

View File

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

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { applyJsonMerge } from "./json-merge.js";
describe("applyJsonMerge", () => {
it("replaces arrays by default", { timeout: 5000 }, () => {
expect(applyJsonMerge({ a: [1, 2] }, { value: { a: [3] } })).toEqual({
a: [3],
});
});
it("concats arrays when arrays=concat", { timeout: 5000 }, () => {
expect(
applyJsonMerge(
{ a: [1, 2] },
{ value: { a: [3] }, arrays: "concat" }
)
).toEqual({ a: [1, 2, 3] });
});
it("concat falls back to replace when base is not array", {
timeout: 5000,
}, () => {
expect(
applyJsonMerge({ a: 1 }, { value: { a: [3] }, arrays: "concat" })
).toEqual({ a: [3] });
});
it("returns base when patch is undefined", { timeout: 5000 }, () => {
expect(applyJsonMerge({ a: 1 }, { value: undefined })).toEqual({
a: 1,
});
});
it("scalar patch replaces base", { timeout: 5000 }, () => {
expect(applyJsonMerge({ a: 1 }, { value: 42 })).toBe(42);
});
it("merges into empty when base is not object", { timeout: 5000 }, () => {
expect(applyJsonMerge(undefined, { value: { a: 1 } })).toEqual({
a: 1,
});
});
it("skips undefined keys in patch", { timeout: 5000 }, () => {
expect(
applyJsonMerge({ a: 1 }, { value: { a: undefined, b: 2 } })
).toEqual({ a: 1, b: 2 });
});
it("rejects non-plain-object prototypes", { timeout: 5000 }, () => {
class X {
public a = 1;
}
const base = new X();
const result = applyJsonMerge(base, { value: { a: 99 } });
expect(result).toEqual({ a: 99 });
});
});

View File

@@ -0,0 +1,45 @@
import type { JsonMerge } from "../../types/installer.types.js";
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
/**
* Deep-merge `merge.value` into `existing`, matching the semantics of
* `@suaveplan/config`:
*
* - Plain objects merge key-by-key.
* - Arrays default to **replace** (not concat) — set `merge.arrays = "concat"`
* to opt into concat semantics.
* - `undefined` keys in the patch are skipped (existing wins).
* - Scalar/array on either side replaces.
*/
export function applyJsonMerge(existing: unknown, merge: JsonMerge): unknown {
return mergeValue(existing, merge.value, merge.arrays ?? "replace");
}
function mergeValue(
base: unknown,
patch: unknown,
arrays: "replace" | "concat"
): unknown {
if (patch === undefined) return base;
if (Array.isArray(patch)) {
if (arrays === "concat" && Array.isArray(base)) {
return [...base, ...patch];
}
return [...patch];
}
if (isPlainObject(patch)) {
const baseObj = isPlainObject(base) ? base : {};
const out: Record<string, unknown> = { ...baseObj };
for (const [k, v] of Object.entries(patch)) {
if (v === undefined) continue;
out[k] = mergeValue(out[k], v, arrays);
}
return out;
}
return patch;
}

View File

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

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { applyTextEdits } from "./text-patch.js";
describe("applyTextEdits", () => {
it("inserts before string anchor", { timeout: 5000 }, () => {
expect(
applyTextEdits("hello world", [
{ anchor: "world", mode: "insert-before", content: "big " },
])
).toBe("hello big world");
});
it("inserts after regex anchor", { timeout: 5000 }, () => {
expect(
applyTextEdits("foo", [
{ anchor: /foo/, mode: "insert-after", content: "bar" },
])
).toBe("foobar");
});
it("replaces matched anchor", { timeout: 5000 }, () => {
expect(
applyTextEdits("a b c", [
{ anchor: "b", mode: "replace", content: "B" },
])
).toBe("a B c");
});
it("delete drops the anchor text", { timeout: 5000 }, () => {
expect(
applyTextEdits("a b c", [{ anchor: "b ", mode: "delete" }])
).toBe("a c");
});
it("preserves trailing newline", { timeout: 5000 }, () => {
expect(
applyTextEdits("hi\n", [
{ anchor: "hi", mode: "insert-after", content: "!" },
])
).toBe("hi!\n");
});
it("preserves no-newline policy", { timeout: 5000 }, () => {
expect(
applyTextEdits("hi", [
{ anchor: "hi", mode: "insert-after", content: "!" },
])
).toBe("hi!");
});
it("applies edits sequentially", { timeout: 5000 }, () => {
expect(
applyTextEdits("abc", [
{ anchor: "b", mode: "replace", content: "B" },
{ anchor: "B", mode: "insert-after", content: "X" },
])
).toBe("aBXc");
});
it("missing string anchor throws", { timeout: 5000 }, () => {
expect(() =>
applyTextEdits("abc", [{ anchor: "z", mode: "delete" }])
).toThrow();
});
it("missing regex anchor throws", { timeout: 5000 }, () => {
expect(() =>
applyTextEdits("abc", [{ anchor: /z+/, mode: "delete" }])
).toThrow();
});
it("undefined content is treated as empty", { timeout: 5000 }, () => {
expect(applyTextEdits("abc", [{ anchor: "b", mode: "replace" }])).toBe(
"ac"
);
});
it("regex without index returns null", { timeout: 5000 }, () => {
// Empty regex matches at position 0 — exercises the index-defined branch
expect(
applyTextEdits("abc", [
{ anchor: /^/, mode: "insert-after", content: "X" },
])
).toBe("Xabc");
});
it("insert-before with undefined content is empty", {
timeout: 5000,
}, () => {
expect(
applyTextEdits("ab", [{ anchor: "a", mode: "insert-before" }])
).toBe("ab");
});
it("insert-after with undefined content is empty", {
timeout: 5000,
}, () => {
expect(
applyTextEdits("ab", [{ anchor: "a", mode: "insert-after" }])
).toBe("ab");
});
});

View File

@@ -0,0 +1,51 @@
import type { TextEdit } from "../../types/installer.types.js";
function findAnchor(
source: string,
anchor: string | RegExp
): { start: number; end: number } | null {
if (typeof anchor === "string") {
const idx = source.indexOf(anchor);
return idx === -1 ? null : { start: idx, end: idx + anchor.length };
}
const m = source.match(anchor);
if (!m || typeof m.index !== "number") return null;
return { start: m.index, end: m.index + m[0].length };
}
/**
* Apply an ordered sequence of `TextEdit`s to `source`.
*
* The trailing-newline policy of `source` is preserved: if the input ends
* with `\n`, the result ends with `\n` (and vice-versa). Edits are applied
* left-to-right against the most-recently-produced text, so anchors in
* later edits see the effect of earlier ones.
*/
export function applyTextEdits(
source: string,
edits: ReadonlyArray<TextEdit>
): string {
const trailingNewline = source.endsWith("\n");
let body = trailingNewline ? source.slice(0, -1) : source;
for (const edit of edits) {
const found = findAnchor(body, edit.anchor);
if (!found) {
throw new Error(
`text-patch: anchor not found: ${String(edit.anchor)}`
);
}
const before = body.slice(0, found.start);
const matched = body.slice(found.start, found.end);
const after = body.slice(found.end);
if (edit.mode === "insert-before") {
body = `${before}${edit.content ?? ""}${matched}${after}`;
} else if (edit.mode === "insert-after") {
body = `${before}${matched}${edit.content ?? ""}${after}`;
} else if (edit.mode === "replace") {
body = `${before}${edit.content ?? ""}${after}`;
} else {
body = `${before}${after}`;
}
}
return trailingNewline ? `${body}\n` : body;
}

View File

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

View File

@@ -0,0 +1,37 @@
import { trace } from "@opentelemetry/api";
import type {
DetectResult,
InstallerContext,
InstallerPlugin,
} from "../types/installer.types.js";
const tracer = trace.getTracer("@suaveplan/installer", "0.1.0");
/**
* Run the plugin's optional `detect` stage inside an OTel span. When the
* plugin omits `detect`, returns an empty `DetectResult` without opening a
* span.
*/
export async function runDetect<TAnswers, THandle>(
plugin: InstallerPlugin<TAnswers, THandle>,
ctx: InstallerContext
): Promise<DetectResult> {
if (!plugin.detect) return {};
return tracer.startActiveSpan(
"installer.detect",
{
attributes: {
"plugin.id": plugin.id,
"plugin.version": plugin.version,
dry_run: ctx.dryRun,
},
},
async (span) => {
try {
return await plugin.detect!(ctx);
} finally {
span.end();
}
}
);
}

View File

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

View File

@@ -0,0 +1,154 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { runPlugin } from "../testing/run-plugin.js";
import type { FileOp } from "../types/file-op.types.js";
import type { InstallerPlugin } from "../types/installer.types.js";
const encoder = new TextEncoder();
function bytes(str: string): Uint8Array {
return encoder.encode(str);
}
describe("INSTALLER-01 — lifecycle order", () => {
it("invokes detect → prompts → plan → apply → postInstall in strict order", {
timeout: 5000,
}, async () => {
const log: string[] = [];
const plugin: InstallerPlugin<undefined, { applied: number }> = {
id: "lifecycle",
version: "0.1.0",
detect: async () => {
log.push("detect");
return {};
},
plan: async () => {
log.push("plan");
return [];
},
apply: async (ops) => {
log.push("apply");
return { applied: ops.length };
},
postInstall: async (handle) => {
log.push(`post:${handle.applied}`);
},
};
const result = await runPlugin(plugin);
expect(log).toEqual(["detect", "plan", "apply", "post:0"]);
expect(result.handle).toEqual({ applied: 0 });
});
it("skips optional stages without breaking order", {
timeout: 5000,
}, async () => {
const log: string[] = [];
const plugin: InstallerPlugin<undefined, void> = {
id: "minimal",
version: "0.1.0",
plan: async () => {
log.push("plan");
return [];
},
apply: async () => {
log.push("apply");
},
};
await runPlugin(plugin);
expect(log).toEqual(["plan", "apply"]);
});
});
describe("INSTALLER-02 — idempotent re-run with conflict policy", () => {
function makeWritePlugin(
policy: "skip-if-exists" | "overwrite" | "error" | undefined
): InstallerPlugin<undefined, void> {
return {
id: "rerun",
version: "0.1.0",
plan: async () => {
const op: FileOp = {
kind: "write",
path: "/dst/file.txt",
contents: bytes("hello"),
...(policy ? { conflict: policy } : {}),
};
return [op];
},
apply: async () => undefined,
};
}
it("default policy is error on second run", { timeout: 5000 }, async () => {
const first = await runPlugin(makeWritePlugin(undefined));
await expect(
runPlugin(makeWritePlugin(undefined), { fs: first.fs })
).rejects.toMatchObject({ code: "INSTALLER.APPLY_CONFLICT" });
});
it("--force collapses to overwrite", { timeout: 5000 }, async () => {
const first = await runPlugin(makeWritePlugin(undefined));
const second = await runPlugin(makeWritePlugin(undefined), {
fs: first.fs,
force: true,
});
expect(second.summary.written).toBe(1);
});
it("skip-if-exists drops op silently", { timeout: 5000 }, async () => {
const first = await runPlugin(makeWritePlugin("skip-if-exists"));
const second = await runPlugin(makeWritePlugin("skip-if-exists"), {
fs: first.fs,
});
expect(second.summary.skipped).toBe(1);
});
it("--no-interactive turns prompt policies into error", {
timeout: 5000,
}, async () => {
const first = await runPlugin(
makeWritePlugin("prompt") as InstallerPlugin<undefined, void>
);
await expect(
runPlugin(makeWritePlugin("prompt"), {
fs: first.fs,
noInteractive: true,
})
).rejects.toMatchObject({ code: "INSTALLER.APPLY_CONFLICT" });
});
});
describe("INSTALLER-03 — abort propagation", () => {
it("aborted-before-run raises ABORTED", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
const plugin: InstallerPlugin<undefined, void> = {
id: "x",
version: "0.1.0",
plan: async () => [],
apply: async () => undefined,
};
await expect(
runPlugin(plugin, { signal: ac.signal })
).rejects.toMatchObject({ code: "INSTALLER.ABORTED" });
});
it("aborted-mid-apply rolls back and raises ABORTED", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
const plugin: InstallerPlugin<undefined, void> = {
id: "x2",
version: "0.1.0",
plan: async () => [
{ kind: "write", path: "/a.txt", contents: bytes("a") },
{ kind: "write", path: "/b.txt", contents: bytes("b") },
],
apply: async () => undefined,
};
// Abort after plan, before apply runs the second op
queueMicrotask(() => ac.abort());
await expect(
runPlugin(plugin, { signal: ac.signal })
).rejects.toMatchObject({ code: "INSTALLER.ABORTED" });
});
});

View File

@@ -0,0 +1,239 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import { MemoryFs } from "../fs/memory-fs.js";
import { ScriptCli } from "../testing/script-cli.js";
import type { FileOp } from "../types/file-op.types.js";
import type {
InstallerContext,
InstallerPlugin,
} from "../types/installer.types.js";
import { Host } from "./host.js";
const encoder = new TextEncoder();
const noopLogger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeCtx(overrides: Partial<InstallerContext> = {}): InstallerContext {
const fs = (overrides.fs as MemoryFs | undefined) ?? new MemoryFs();
const ac = new AbortController();
return {
cwd: "/",
fs,
cli: new ScriptCli(),
signal: overrides.signal ?? ac.signal,
logger: noopLogger,
templates: { render: (t) => t },
dryRun: overrides.dryRun ?? false,
force: overrides.force ?? false,
noInteractive: overrides.noInteractive ?? true,
...overrides,
};
}
describe("Host filterOps", () => {
function makeWritePlugin(
paths: string[]
): InstallerPlugin<undefined, void> {
return {
id: "p",
version: "0.1.0",
plan: async () =>
paths.map(
(p): FileOp => ({
kind: "write",
path: p,
contents: encoder.encode("x"),
})
),
apply: async () => undefined,
};
}
it("only filter retains matching paths", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
const ctx = makeCtx({ fs });
const host = new Host(ctx, fs);
const result = await host.run(
makeWritePlugin(["/a.txt", "/b.txt", "/c.txt"]),
{ only: ["a", "b"] }
);
expect(result.ops.length).toBe(2);
});
it("exclude filter drops matching paths", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
const ctx = makeCtx({ fs });
const host = new Host(ctx, fs);
const result = await host.run(
makeWritePlugin(["/a.txt", "/b.txt", "/c.txt"]),
{ exclude: ["b"] }
);
expect(result.ops.length).toBe(2);
});
it("only and exclude both apply (only first, exclude second)", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ctx = makeCtx({ fs });
const host = new Host(ctx, fs);
const result = await host.run(
makeWritePlugin(["/a.txt", "/b.txt", "/c.txt"]),
{ only: ["a", "b", "c"], exclude: ["b"] }
);
expect(result.ops.length).toBe(2);
});
it("filterOps reads rename target via op.to", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({ seed: { "/x.txt": "x", "/y.txt": "y" } });
const ctx = makeCtx({ fs, force: true });
const host = new Host(ctx, fs);
const plugin: InstallerPlugin<undefined, void> = {
id: "rename",
version: "0.1.0",
plan: async () => [
{ kind: "rename", from: "/x.txt", to: "/keep.txt" },
{ kind: "rename", from: "/y.txt", to: "/drop.txt" },
],
apply: async () => undefined,
};
const result = await host.run(plugin, { only: ["keep"] });
expect(result.ops.length).toBe(1);
});
it("empty filters keep everything", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
const ctx = makeCtx({ fs });
const host = new Host(ctx, fs);
const result = await host.run(makeWritePlugin(["/a.txt", "/b.txt"]), {
only: [],
exclude: [],
});
expect(result.ops.length).toBe(2);
});
});
describe("Host abort points", () => {
it("abort between detect and prompts surfaces ABORTED", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ac = new AbortController();
const ctx = makeCtx({ fs, signal: ac.signal });
const host = new Host(ctx, fs);
const plugin: InstallerPlugin<undefined, void> = {
id: "p",
version: "0.1.0",
detect: async () => {
ac.abort();
return {};
},
plan: async () => [],
apply: async () => undefined,
};
await expect(host.run(plugin)).rejects.toMatchObject({
code: "INSTALLER.ABORTED",
});
});
it("abort between prompts and plan surfaces ABORTED", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ac = new AbortController();
const ctx = makeCtx({ fs, signal: ac.signal });
const host = new Host(ctx, fs);
const plugin: InstallerPlugin<{ ok: number }, void> = {
id: "p",
version: "0.1.0",
prompts: () =>
({
parse: (v: unknown) => v as { ok: number },
safeParse: (v: unknown) => {
ac.abort();
return { success: true, data: v as { ok: number } };
},
}) as never,
plan: async () => {
throw new Error("should not run after abort");
},
apply: async () => undefined,
};
await expect(
host.run(plugin, { answers: { ok: 1 } })
).rejects.toMatchObject({ code: "INSTALLER.ABORTED" });
});
});
describe("Host plan errors", () => {
it("plugin plan throwing wraps in PLAN_FAILED", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ctx = makeCtx({ fs });
const host = new Host(ctx, fs);
const plugin: InstallerPlugin<undefined, void> = {
id: "p",
version: "0.1.0",
plan: async () => {
throw new Error("nope");
},
apply: async () => undefined,
};
await expect(host.run(plugin)).rejects.toMatchObject({
code: "INSTALLER.PLAN_FAILED",
});
});
it("answersFromFile is forwarded to runPrompts", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ctx = makeCtx({ fs });
const host = new Host(ctx, fs);
let observed: unknown;
const plugin: InstallerPlugin<{ x: number }, void> = {
id: "p",
version: "0.1.0",
prompts: () => ({
parse: (v: unknown) => v as { x: number },
safeParse: (v: unknown) => ({
success: true as const,
data: v as { x: number },
}),
}),
plan: async (answers) => {
observed = answers;
return [];
},
apply: async () => undefined,
};
await host.run(plugin, { answersFromFile: { x: 1 } });
expect(observed).toEqual({ x: 1 });
});
it("plugin plan returning invalid ops surfaces PLAN_FAILED", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs();
const ctx = makeCtx({ fs });
const host = new Host(ctx, fs);
const plugin = {
id: "p",
version: "0.1.0",
plan: async () =>
[{ kind: "bogus" }] as unknown as ReadonlyArray<FileOp>,
apply: async () => undefined,
} as unknown as InstallerPlugin<undefined, void>;
await expect(host.run(plugin)).rejects.toMatchObject({
code: "INSTALLER.PLAN_FAILED",
});
});
});

View File

@@ -0,0 +1,125 @@
import { InstallerError } from "../error-codes.js";
import type { MutableInstallerFs } from "../fs/installer-fs.js";
import type { FileOp } from "../types/file-op.types.js";
import type {
DetectResult,
InstallerContext,
InstallerPlugin,
PlanSummary,
RunOptions,
} from "../types/installer.types.js";
import { applyOps } from "./apply/apply.js";
import { runDetect } from "./detect.js";
import { runPlan } from "./plan.js";
import { runPostInstall } from "./post-install.js";
import { runPrompts } from "./prompts.js";
export interface HostRunOptions<TAnswers> extends RunOptions<TAnswers> {
readonly answersFromFile?: unknown;
}
export interface HostRunResult<THandle> {
readonly detect: DetectResult;
readonly ops: ReadonlyArray<FileOp>;
readonly summary: PlanSummary;
readonly handle: THandle | undefined;
readonly dryRun: boolean;
}
function filterOps(
ops: ReadonlyArray<FileOp>,
only: ReadonlyArray<string> | undefined,
exclude: ReadonlyArray<string> | undefined
): ReadonlyArray<FileOp> {
if ((!only || only.length === 0) && (!exclude || exclude.length === 0)) {
return ops;
}
const onlySet = only && only.length > 0 ? new Set(only) : undefined;
const excludeSet =
exclude && exclude.length > 0 ? new Set(exclude) : undefined;
return ops.filter((op) => {
const path = op.kind === "rename" ? op.to : op.path;
if (
onlySet &&
!Array.from(onlySet).some((token) => path.includes(token))
) {
return false;
}
if (
excludeSet &&
Array.from(excludeSet).some((token) => path.includes(token))
) {
return false;
}
return true;
});
}
/**
* Plugin host — orchestrates detect → prompts → plan → apply → postInstall
* with strict ordering, abort propagation, and `--dry-run` support.
*/
export class Host {
private readonly fs: MutableInstallerFs;
public constructor(
private readonly ctx: InstallerContext,
fs: MutableInstallerFs
) {
this.fs = fs;
}
public async run<TAnswers, THandle>(
plugin: InstallerPlugin<TAnswers, THandle>,
options: HostRunOptions<TAnswers> = {}
): Promise<HostRunResult<THandle>> {
if (this.ctx.signal.aborted) {
throw new InstallerError("INSTALLER.ABORTED", "installer.aborted");
}
const detect = await runDetect(plugin, this.ctx);
if (this.ctx.signal.aborted) {
throw new InstallerError("INSTALLER.ABORTED", "installer.aborted");
}
const answers = await runPrompts({
plugin,
ctx: this.ctx,
...(options.answers !== undefined
? { explicitAnswers: options.answers }
: {}),
...(options.answersFromFile !== undefined
? { answersFromFile: options.answersFromFile }
: {}),
});
if (this.ctx.signal.aborted) {
throw new InstallerError("INSTALLER.ABORTED", "installer.aborted");
}
const rawOps = await runPlan(plugin, answers, this.ctx);
const ops = filterOps(rawOps, options.only, options.exclude);
if (this.ctx.dryRun) {
return {
detect,
ops,
summary: {
written: 0,
patched: 0,
skipped: ops.length,
conflicts: 0,
},
handle: undefined,
dryRun: true,
};
}
const summary = await applyOps({
ops,
ctx: this.ctx,
fs: this.fs,
pluginId: plugin.id,
pluginVersion: plugin.version,
});
const handle = await plugin.apply(ops, this.ctx);
await runPostInstall(plugin, handle, this.ctx);
return { detect, ops, summary, handle, dryRun: false };
}
}

View File

@@ -0,0 +1,17 @@
export { type ApplyOptions, applyOps } from "./apply/apply.js";
export {
type ConflictDecision,
type ResolveConflictOptions,
resolveConflict,
} from "./apply/conflict.js";
export { applyJsonMerge } from "./apply/json-merge.js";
export { applyTextEdits } from "./apply/text-patch.js";
export { runDetect } from "./detect.js";
export {
Host,
type HostRunOptions,
type HostRunResult,
} from "./host.js";
export { runPlan } from "./plan.js";
export { runPostInstall } from "./post-install.js";
export { type RunPromptsOptions, runPrompts } from "./prompts.js";

View File

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

View File

@@ -0,0 +1,62 @@
import { trace } from "@opentelemetry/api";
import { SuaveplanError } from "@suaveplan/error";
import { InstallerError } from "../error-codes.js";
import { FileOpsSchema } from "../schemas/file-op.schemas.js";
import type { FileOp } from "../types/file-op.types.js";
import type {
InstallerContext,
InstallerPlugin,
} from "../types/installer.types.js";
const tracer = trace.getTracer("@suaveplan/installer", "0.1.0");
/**
* Invoke `plugin.plan(answers, ctx)` and validate the returned `FileOp[]`.
* Validation failure surfaces as `INSTALLER.PLAN_FAILED` so plan-level bugs
* never leak invalid op shapes into apply.
*/
export async function runPlan<TAnswers, THandle>(
plugin: InstallerPlugin<TAnswers, THandle>,
answers: TAnswers,
ctx: InstallerContext
): Promise<ReadonlyArray<FileOp>> {
return tracer.startActiveSpan(
"installer.plan",
{
attributes: {
"plugin.id": plugin.id,
"plugin.version": plugin.version,
dry_run: ctx.dryRun,
},
},
async (span) => {
try {
const ops = await plugin.plan(answers, ctx);
const parsed = FileOpsSchema.safeParse(ops);
if (!parsed.success) {
throw new InstallerError(
"INSTALLER.PLAN_FAILED",
"installer.plan.invalid_ops",
{
cause: parsed.error as unknown as Error,
context: { issues: parsed.error.issues },
}
);
}
return ops;
} catch (cause) {
// Re-throw any SuaveplanError (cross-package codes like
// INSTALL_SKILLS.UNKNOWN_SKILL) unchanged; only wrap
// anonymous failures.
if (cause instanceof SuaveplanError) throw cause;
throw new InstallerError(
"INSTALLER.PLAN_FAILED",
"installer.plan.threw",
{ cause: cause as Error }
);
} finally {
span.end();
}
}
);
}

View File

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

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import { MemoryFs } from "../fs/memory-fs.js";
import { ScriptCli } from "../testing/script-cli.js";
import type {
InstallerContext,
InstallerPlugin,
} from "../types/installer.types.js";
import { runPostInstall } from "./post-install.js";
const noopLogger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function ctx(signal: AbortSignal): InstallerContext {
return {
cwd: "/",
fs: new MemoryFs(),
cli: new ScriptCli(),
signal,
logger: noopLogger,
templates: { render: (t) => t },
dryRun: false,
force: false,
noInteractive: true,
};
}
describe("runPostInstall", () => {
it("no-op when plugin has no postInstall", { timeout: 5000 }, async () => {
const ac = new AbortController();
const plugin: InstallerPlugin<undefined, void> = {
id: "p",
version: "0.1.0",
plan: async () => [],
apply: async () => undefined,
};
await expect(
runPostInstall(plugin, undefined, ctx(ac.signal))
).resolves.toBeUndefined();
});
it("invokes postInstall with handle", { timeout: 5000 }, async () => {
const ac = new AbortController();
let observed: number | undefined;
const plugin: InstallerPlugin<undefined, number> = {
id: "p",
version: "0.1.0",
plan: async () => [],
apply: async () => 42,
postInstall: async (handle) => {
observed = handle;
},
};
await runPostInstall(plugin, 42, ctx(ac.signal));
expect(observed).toBe(42);
});
it("aborted signal raises ABORTED before invoking postInstall", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort();
const plugin: InstallerPlugin<undefined, void> = {
id: "p",
version: "0.1.0",
plan: async () => [],
apply: async () => undefined,
postInstall: async () => {
throw new Error("must not run");
},
};
await expect(
runPostInstall(plugin, undefined, ctx(ac.signal))
).rejects.toMatchObject({ code: "INSTALLER.ABORTED" });
});
});

View File

@@ -0,0 +1,44 @@
import { trace } from "@opentelemetry/api";
import { InstallerError } from "../error-codes.js";
import type {
InstallerContext,
InstallerPlugin,
} from "../types/installer.types.js";
const tracer = trace.getTracer("@suaveplan/installer", "0.1.0");
/**
* Run the plugin's optional `postInstall` stage with abort propagation.
* AbortError instances bubble as `INSTALLER.ABORTED`; everything else as
* the original error so the host can record a failed run.
*/
export async function runPostInstall<TAnswers, THandle>(
plugin: InstallerPlugin<TAnswers, THandle>,
handle: THandle,
ctx: InstallerContext
): Promise<void> {
if (!plugin.postInstall) return;
return tracer.startActiveSpan(
"installer.postInstall",
{
attributes: {
"plugin.id": plugin.id,
"plugin.version": plugin.version,
dry_run: ctx.dryRun,
},
},
async (span) => {
try {
if (ctx.signal.aborted) {
throw new InstallerError(
"INSTALLER.ABORTED",
"installer.aborted"
);
}
await plugin.postInstall!(handle, ctx);
} finally {
span.end();
}
}
);
}

View File

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

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { runPlugin } from "../testing/run-plugin.js";
import type { InstallerPlugin } from "../types/installer.types.js";
interface Answers {
name: string;
}
function makePlugin(): InstallerPlugin<Answers, void> {
return {
id: "p",
version: "0.1.0",
prompts: () =>
z.object({
name: z.string().min(1),
}) as never as import("../types/installer.types.js").PromptSchema<Answers>,
plan: async () => [],
apply: async () => undefined,
};
}
describe("INSTALLER-06 — prompt validation", () => {
it("validates explicit answers via Zod", { timeout: 5000 }, async () => {
const result = await runPlugin(makePlugin(), {
answers: { name: "ok" },
});
expect(result.ops).toEqual([]);
});
it("rejects invalid answers with PROMPT_VALIDATION_FAILED", {
timeout: 5000,
}, async () => {
await expect(
runPlugin(makePlugin(), {
// @ts-expect-error — deliberately invalid for the validation path
answers: { name: "" },
})
).rejects.toMatchObject({ code: "INSTALLER.PROMPT_VALIDATION_FAILED" });
});
it("missing answers + noInteractive fails fast", {
timeout: 5000,
}, async () => {
await expect(
runPlugin(makePlugin(), { noInteractive: true })
).rejects.toMatchObject({
code: "INSTALLER.PROMPT_VALIDATION_FAILED",
});
});
it("plugin without prompts skips validation", {
timeout: 5000,
}, async () => {
const noPromptPlugin: InstallerPlugin<undefined, void> = {
id: "p2",
version: "0.1.0",
plan: async () => [],
apply: async () => undefined,
};
const result = await runPlugin(noPromptPlugin);
expect(result.ops).toEqual([]);
});
it("plugin without prompts but with explicit answers passes them through", {
timeout: 5000,
}, async () => {
let observed: unknown;
const plugin: InstallerPlugin<{ x: number }, void> = {
id: "p3",
version: "0.1.0",
plan: async (answers) => {
observed = answers;
return [];
},
apply: async () => undefined,
};
await runPlugin(plugin, { answers: { x: 1 } });
expect(observed).toEqual({ x: 1 });
});
it("missing answers without --no-interactive also fails fast", {
timeout: 5000,
}, async () => {
const plugin: InstallerPlugin<Answers, void> = makePlugin();
await expect(
runPlugin(plugin, { noInteractive: false })
).rejects.toMatchObject({
code: "INSTALLER.PROMPT_VALIDATION_FAILED",
});
});
});

View File

@@ -0,0 +1,77 @@
import { trace } from "@opentelemetry/api";
import { InstallerError } from "../error-codes.js";
import type {
InstallerContext,
InstallerPlugin,
} from "../types/installer.types.js";
const tracer = trace.getTracer("@suaveplan/installer", "0.1.0");
export interface RunPromptsOptions<TAnswers> {
readonly plugin: InstallerPlugin<TAnswers, unknown>;
readonly ctx: InstallerContext;
readonly explicitAnswers?: TAnswers;
readonly answersFromFile?: unknown;
}
/**
* Run the plugin's optional `prompts` stage. If the plugin declares a
* schema and answers are available (via `explicitAnswers` or
* `answersFromFile`), validate them. When the plugin demands additional
* answers but `--no-interactive` is set and none were supplied, fail with
* `INSTALLER.PROMPT_VALIDATION_FAILED`.
*/
export async function runPrompts<TAnswers>(
options: RunPromptsOptions<TAnswers>
): Promise<TAnswers> {
const { plugin, ctx, explicitAnswers, answersFromFile } = options;
return tracer.startActiveSpan(
"installer.prompts",
{
attributes: {
"plugin.id": plugin.id,
"plugin.version": plugin.version,
dry_run: ctx.dryRun,
},
},
async (span) => {
try {
if (!plugin.prompts) {
if (explicitAnswers !== undefined) return explicitAnswers;
return {} as TAnswers;
}
const schema = plugin.prompts(ctx);
const candidate =
explicitAnswers !== undefined
? explicitAnswers
: answersFromFile;
if (candidate === undefined) {
if (ctx.noInteractive) {
throw new InstallerError(
"INSTALLER.PROMPT_VALIDATION_FAILED",
"installer.prompts.no_answers_in_no_interactive"
);
}
throw new InstallerError(
"INSTALLER.PROMPT_VALIDATION_FAILED",
"installer.prompts.missing_answers"
);
}
const result = schema.safeParse(candidate);
if (!result.success) {
throw new InstallerError(
"INSTALLER.PROMPT_VALIDATION_FAILED",
"installer.prompts.invalid",
{
cause: result.error as unknown as Error,
context: { issues: result.error.issues },
}
);
}
return result.data;
} finally {
span.end();
}
}
);
}

View File

@@ -0,0 +1,73 @@
// Errors
export {
INSTALLER_CODES,
InstallerError,
type InstallerErrorCode,
} from "./error-codes.js";
// FS
export {
DiskFs,
type DiskFsOptions,
MemoryFs,
type MemoryFsOptions,
type MutableInstallerFs,
} from "./fs/index.js";
// Host
export {
type ApplyOptions,
applyJsonMerge,
applyOps,
applyTextEdits,
type ConflictDecision,
Host,
type HostRunOptions,
type HostRunResult,
type ResolveConflictOptions,
type RunPromptsOptions,
resolveConflict,
runDetect,
runPlan,
runPostInstall,
runPrompts,
} from "./host/index.js";
// Manifest
export {
type DiscoveredPlugin,
discoverPlugins,
findPlugin,
loadManifest,
validateManifest,
} from "./manifest/index.js";
// Schemas
export {
type CliFlags,
CliFlagsSchema,
ConflictPolicySchema,
FileOpSchema,
FileOpsSchema,
type PluginManifest,
PluginManifestSchema,
} from "./schemas/index.js";
// Types
export type {
BaseFileOp,
ConflictPolicy,
DeleteOp,
DetectResult,
FileOp,
InstallerCli,
InstallerContext,
InstallerFs,
InstallerPlugin,
JsonMerge,
MkdirOp,
PatchJsonOp,
PatchTextOp,
PlanSummary,
PromptSchema,
RenameOp,
RunOptions,
TemplateEngine,
TextEdit,
WriteOp,
} from "./types/index.js";

View File

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

View File

@@ -0,0 +1,124 @@
import { readdir, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { InstallerError } from "../error-codes.js";
import type { PluginManifest } from "../schemas/manifest.schemas.js";
import { loadManifest } from "./manifest.js";
const FIRST_PARTY_PREFIX = "@suaveplan/install-";
const THIRD_PARTY_INFIX = "/suaveplan-install-";
export interface DiscoveredPlugin {
readonly id: string;
readonly packageName: string;
readonly packagePath: string;
readonly manifest: PluginManifest;
}
async function isDir(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}
async function listScopedDirs(scopeDir: string): Promise<string[]> {
try {
return await readdir(scopeDir);
} catch {
return [];
}
}
function matchesConvention(packageName: string): boolean {
if (packageName.startsWith(FIRST_PARTY_PREFIX)) return true;
return packageName.includes(THIRD_PARTY_INFIX);
}
async function collectFromNodeModules(
nodeModules: string
): Promise<DiscoveredPlugin[]> {
if (!(await isDir(nodeModules))) return [];
const found: DiscoveredPlugin[] = [];
const entries = await readdir(nodeModules);
for (const entry of entries) {
if (entry.startsWith(".")) continue;
if (!entry.startsWith("@")) continue;
const entryPath = join(nodeModules, entry);
const scopedNames = await listScopedDirs(entryPath);
for (const scoped of scopedNames) {
const packageName = `${entry}/${scoped}`;
if (!matchesConvention(packageName)) continue;
const packagePath = join(entryPath, scoped);
const manifest = await loadManifest(packagePath).catch((cause) => {
throw new InstallerError(
"INSTALLER.PLUGIN_LOAD_FAILED",
"installer.plugin.load_failed",
{
cause: cause as Error,
context: { packageName },
}
);
});
found.push({
id: manifest.id,
packageName,
packagePath,
manifest,
});
}
}
return found;
}
/**
* Discover plugins matching the `@suaveplan/install-*` and
* `@<scope>/suaveplan-install-*` conventions in the consumer's node_modules
* tree. Walks both hoisted (`<cwd>/node_modules`) and pnpm-style nested
* layouts (`<cwd>/node_modules/.pnpm/*`).
*
* Throws `INSTALLER.PLUGIN_LOAD_FAILED` when a candidate package fails to
* load its manifest. Use `findPlugin(cwd, id)` for "must exist" lookups —
* that helper raises `INSTALLER.PLUGIN_NOT_FOUND` instead.
*/
export async function discoverPlugins(
cwd: string
): Promise<ReadonlyArray<DiscoveredPlugin>> {
const root = resolve(cwd);
const direct = await collectFromNodeModules(join(root, "node_modules"));
const pnpm = join(root, "node_modules", ".pnpm");
const pnpmEntries: DiscoveredPlugin[] = [];
if (await isDir(pnpm)) {
const entries = await readdir(pnpm);
for (const entry of entries) {
const inner = join(pnpm, entry, "node_modules");
const fromInner = await collectFromNodeModules(inner);
pnpmEntries.push(...fromInner);
}
}
const merged = new Map<string, DiscoveredPlugin>();
for (const plugin of [...direct, ...pnpmEntries]) {
if (!merged.has(plugin.id)) merged.set(plugin.id, plugin);
}
return Array.from(merged.values()).sort((a, b) => a.id.localeCompare(b.id));
}
/**
* Find a single plugin by `id`. Surfaces `INSTALLER.PLUGIN_NOT_FOUND` when
* no installed package reports a matching manifest.
*/
export async function findPlugin(
cwd: string,
id: string
): Promise<DiscoveredPlugin> {
const all = await discoverPlugins(cwd);
const match = all.find((p) => p.id === id);
if (!match) {
throw new InstallerError(
"INSTALLER.PLUGIN_NOT_FOUND",
"installer.plugin.not_found",
{ context: { id } }
);
}
return match;
}

View File

@@ -0,0 +1,10 @@
export {
type PluginManifest,
PluginManifestSchema,
} from "../schemas/manifest.schemas.js";
export {
type DiscoveredPlugin,
discoverPlugins,
findPlugin,
} from "./discover.js";
export { loadManifest, validateManifest } from "./manifest.js";

View File

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

View File

@@ -0,0 +1,349 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { InstallerError } from "../error-codes.js";
import { discoverPlugins, findPlugin } from "./discover.js";
import { loadManifest, validateManifest } from "./manifest.js";
let tempRoot: string;
beforeEach(async () => {
tempRoot = await mkdtemp(join(tmpdir(), "installer-conformance-"));
});
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
const validManifest = {
id: "agent-skills",
version: "0.1.0",
description: "test plugin",
entry: "dist/index.js",
engines: { node: ">=20" },
assets: ["assets/skills"],
};
describe("INSTALLER-07 — manifest schema enforced at load time", () => {
it("loadManifest validates required fields", {
timeout: 5000,
}, async () => {
const pkgDir = join(tempRoot, "pkg-a");
await mkdir(pkgDir, { recursive: true });
await writeFile(
join(pkgDir, "manifest.json"),
JSON.stringify(validManifest)
);
const m = await loadManifest(pkgDir);
expect(m.id).toBe("agent-skills");
});
it("rejects malformed manifests with PLUGIN_INVALID_MANIFEST", {
timeout: 5000,
}, async () => {
const pkgDir = join(tempRoot, "pkg-b");
await mkdir(pkgDir, { recursive: true });
await writeFile(
join(pkgDir, "manifest.json"),
JSON.stringify({ id: 1 })
);
await expect(loadManifest(pkgDir)).rejects.toMatchObject({
code: "INSTALLER.PLUGIN_INVALID_MANIFEST",
});
});
it("validateManifest rejects bad shapes", { timeout: 5000 }, () => {
expect(() => validateManifest({ id: "x" })).toThrow(InstallerError);
});
it("rejects unparseable JSON", { timeout: 5000 }, async () => {
const pkgDir = join(tempRoot, "pkg-bad");
await mkdir(pkgDir, { recursive: true });
await writeFile(join(pkgDir, "manifest.json"), "{not json");
await expect(loadManifest(pkgDir)).rejects.toMatchObject({
code: "INSTALLER.PLUGIN_INVALID_MANIFEST",
});
});
it("rejects missing manifest", { timeout: 5000 }, async () => {
const pkgDir = join(tempRoot, "pkg-none");
await mkdir(pkgDir, { recursive: true });
await expect(loadManifest(pkgDir)).rejects.toMatchObject({
code: "INSTALLER.PLUGIN_INVALID_MANIFEST",
});
});
});
describe("INSTALLER-08 — plugin discovery from consumer node_modules", () => {
it("finds @suaveplan/install-* in hoisted node_modules", {
timeout: 10000,
}, async () => {
const consumer = join(tempRoot, "consumer");
const pluginPath = join(
consumer,
"node_modules",
"@suaveplan",
"install-agent-skills"
);
await mkdir(pluginPath, { recursive: true });
await writeFile(
join(pluginPath, "manifest.json"),
JSON.stringify(validManifest)
);
const plugins = await discoverPlugins(consumer);
expect(plugins.map((p) => p.id)).toEqual(["agent-skills"]);
});
it("finds @<scope>/suaveplan-install-* third-party plugins", {
timeout: 10000,
}, async () => {
const consumer = join(tempRoot, "consumer-tp");
const pluginPath = join(
consumer,
"node_modules",
"@vendor",
"suaveplan-install-thing"
);
await mkdir(pluginPath, { recursive: true });
await writeFile(
join(pluginPath, "manifest.json"),
JSON.stringify({ ...validManifest, id: "thing" })
);
const plugins = await discoverPlugins(consumer);
expect(plugins.map((p) => p.id)).toEqual(["thing"]);
});
it("walks pnpm-style nested layouts", { timeout: 10000 }, async () => {
const consumer = join(tempRoot, "consumer-pnpm");
const pluginPath = join(
consumer,
"node_modules",
".pnpm",
"@suaveplan+install-agent-skills@0.0.0",
"node_modules",
"@suaveplan",
"install-agent-skills"
);
await mkdir(pluginPath, { recursive: true });
await writeFile(
join(pluginPath, "manifest.json"),
JSON.stringify(validManifest)
);
const plugins = await discoverPlugins(consumer);
expect(plugins.map((p) => p.id)).toEqual(["agent-skills"]);
});
it("findPlugin raises PLUGIN_NOT_FOUND when missing", {
timeout: 5000,
}, async () => {
const consumer = join(tempRoot, "empty");
await mkdir(consumer, { recursive: true });
await expect(findPlugin(consumer, "nonexistent")).rejects.toMatchObject(
{
code: "INSTALLER.PLUGIN_NOT_FOUND",
}
);
});
it("returns [] for missing node_modules", { timeout: 5000 }, async () => {
const consumer = join(tempRoot, "empty-2");
await mkdir(consumer, { recursive: true });
const plugins = await discoverPlugins(consumer);
expect(plugins).toEqual([]);
});
it("rejects matching package with malformed manifest as PLUGIN_LOAD_FAILED", {
timeout: 5000,
}, async () => {
const consumer = join(tempRoot, "consumer-bad");
const pluginPath = join(
consumer,
"node_modules",
"@suaveplan",
"install-broken"
);
await mkdir(pluginPath, { recursive: true });
await writeFile(
join(pluginPath, "manifest.json"),
JSON.stringify({ id: 1 })
);
await expect(discoverPlugins(consumer)).rejects.toMatchObject({
code: "INSTALLER.PLUGIN_LOAD_FAILED",
});
});
it("ignores non-scoped packages even with manifests", {
timeout: 5000,
}, async () => {
const consumer = join(tempRoot, "consumer-mixed");
const otherPath = join(consumer, "node_modules", "react");
await mkdir(otherPath, { recursive: true });
await writeFile(
join(otherPath, "manifest.json"),
JSON.stringify(validManifest)
);
const plugins = await discoverPlugins(consumer);
expect(plugins).toEqual([]);
});
it("ignores scoped non-convention packages", {
timeout: 5000,
}, async () => {
const consumer = join(tempRoot, "consumer-other-scope");
const otherPath = join(consumer, "node_modules", "@types", "node");
await mkdir(otherPath, { recursive: true });
await writeFile(
join(otherPath, "manifest.json"),
JSON.stringify(validManifest)
);
const plugins = await discoverPlugins(consumer);
expect(plugins).toEqual([]);
});
it("listScopedDirs swallows missing scope dirs", {
timeout: 5000,
}, async () => {
const consumer = join(tempRoot, "consumer-empty-scope");
// Create an @scope entry but no contents — readdir on a regular file
// should still resolve to []
const scope = join(consumer, "node_modules", "@empty");
await mkdir(scope, { recursive: true });
const plugins = await discoverPlugins(consumer);
expect(plugins).toEqual([]);
});
it("listScopedDirs swallows readdir errors when @scope is a file", {
timeout: 5000,
}, async () => {
const consumer = join(tempRoot, "consumer-broken-scope");
const nm = join(consumer, "node_modules");
await mkdir(nm, { recursive: true });
// Create a regular file under node_modules that *looks* like a scope
// — readdir() on it will throw ENOTDIR, which the helper swallows.
await writeFile(join(nm, "@filelike"), "not a directory");
const plugins = await discoverPlugins(consumer);
expect(plugins).toEqual([]);
});
it("dedupes plugins seen in both hoisted and pnpm layouts", {
timeout: 5000,
}, async () => {
const consumer = join(tempRoot, "consumer-dual");
const direct = join(
consumer,
"node_modules",
"@suaveplan",
"install-agent-skills"
);
await mkdir(direct, { recursive: true });
await writeFile(
join(direct, "manifest.json"),
JSON.stringify(validManifest)
);
const pnpmInner = join(
consumer,
"node_modules",
".pnpm",
"@suaveplan+install-agent-skills@0.0.0",
"node_modules",
"@suaveplan",
"install-agent-skills"
);
await mkdir(pnpmInner, { recursive: true });
await writeFile(
join(pnpmInner, "manifest.json"),
JSON.stringify(validManifest)
);
const plugins = await discoverPlugins(consumer);
expect(plugins.length).toBe(1);
});
it("findPlugin scans an existing list before raising", {
timeout: 5000,
}, async () => {
const consumer = join(tempRoot, "consumer-list");
const pluginPath = join(
consumer,
"node_modules",
"@suaveplan",
"install-agent-skills"
);
await mkdir(pluginPath, { recursive: true });
await writeFile(
join(pluginPath, "manifest.json"),
JSON.stringify(validManifest)
);
await expect(
findPlugin(consumer, "different-id")
).rejects.toMatchObject({
code: "INSTALLER.PLUGIN_NOT_FOUND",
});
});
it("sorts discovered plugins by id", {
timeout: 5000,
}, async () => {
const consumer = join(tempRoot, "consumer-many");
const skillsPath = join(
consumer,
"node_modules",
"@suaveplan",
"install-agent-skills"
);
const otherPath = join(
consumer,
"node_modules",
"@suaveplan",
"install-web-server"
);
await mkdir(skillsPath, { recursive: true });
await mkdir(otherPath, { recursive: true });
await writeFile(
join(skillsPath, "manifest.json"),
JSON.stringify(validManifest)
);
await writeFile(
join(otherPath, "manifest.json"),
JSON.stringify({ ...validManifest, id: "web-server" })
);
const plugins = await discoverPlugins(consumer);
expect(plugins.map((p) => p.id)).toEqual([
"agent-skills",
"web-server",
]);
});
it("findPlugin returns the matching DiscoveredPlugin", {
timeout: 5000,
}, async () => {
const consumer = join(tempRoot, "consumer-found");
const pluginPath = join(
consumer,
"node_modules",
"@suaveplan",
"install-agent-skills"
);
await mkdir(pluginPath, { recursive: true });
await writeFile(
join(pluginPath, "manifest.json"),
JSON.stringify(validManifest)
);
const found = await findPlugin(consumer, "agent-skills");
expect(found.id).toBe("agent-skills");
});
it("ignores hidden node_modules entries", { timeout: 5000 }, async () => {
const consumer = join(tempRoot, "consumer-hidden");
const hidden = join(consumer, "node_modules", ".bin");
await mkdir(hidden, { recursive: true });
const plugins = await discoverPlugins(consumer);
expect(plugins).toEqual([]);
});
});

View File

@@ -0,0 +1,59 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { InstallerError } from "../error-codes.js";
import {
type PluginManifest,
PluginManifestSchema,
} from "../schemas/manifest.schemas.js";
const decoder = new TextDecoder();
/**
* Validate a raw object against `PluginManifestSchema`.
* Throws `InstallerError(INSTALLER.PLUGIN_INVALID_MANIFEST)` with the Zod
* error attached as `cause` on failure.
*/
export function validateManifest(raw: unknown): PluginManifest {
const result = PluginManifestSchema.safeParse(raw);
if (!result.success) {
throw new InstallerError(
"INSTALLER.PLUGIN_INVALID_MANIFEST",
"installer.plugin.invalid_manifest",
{ cause: result.error, context: { issues: result.error.issues } }
);
}
return result.data;
}
/**
* Load `manifest.json` from a plugin package root and validate it.
* `INSTALLER.PLUGIN_INVALID_MANIFEST` covers both parse errors and schema
* violations.
*/
export async function loadManifest(
packagePath: string
): Promise<PluginManifest> {
const target = join(packagePath, "manifest.json");
let raw: string;
try {
const bytes = await readFile(target);
raw = decoder.decode(bytes);
} catch (cause) {
throw new InstallerError(
"INSTALLER.PLUGIN_INVALID_MANIFEST",
"installer.plugin.manifest_unreadable",
{ cause: cause as Error, context: { path: target } }
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (cause) {
throw new InstallerError(
"INSTALLER.PLUGIN_INVALID_MANIFEST",
"installer.plugin.manifest_unparseable",
{ cause: cause as Error, context: { path: target } }
);
}
return validateManifest(parsed);
}

View File

@@ -0,0 +1,13 @@
// schema: boundary-only
import z from "zod";
export const CliFlagsSchema = z.object({
dryRun: z.boolean().default(false),
force: z.boolean().default(false),
noInteractive: z.boolean().default(false),
only: z.array(z.string()).optional(),
exclude: z.array(z.string()).optional(),
answersFile: z.string().min(1).optional(),
});
export type CliFlags = z.infer<typeof CliFlagsSchema>;

View File

@@ -0,0 +1,79 @@
// schema: boundary-only
import z from "zod";
const conflictPolicy = z.enum([
"error",
"overwrite",
"skip-if-exists",
"merge",
"prompt",
]);
const writeOp = z.object({
kind: z.literal("write"),
path: z.string().min(1),
contents: z.instanceof(Uint8Array),
mode: z.number().int().nonnegative().optional(),
conflict: conflictPolicy.optional(),
});
const patchJsonOp = z.object({
kind: z.literal("patch-json"),
path: z.string().min(1),
merge: z.object({
value: z.unknown(),
arrays: z.enum(["replace", "concat"]).optional(),
}),
conflict: conflictPolicy.optional(),
});
const patchTextOp = z.object({
kind: z.literal("patch-text"),
path: z.string().min(1),
edits: z
.array(
z.object({
anchor: z.union([z.string(), z.instanceof(RegExp)]),
mode: z.enum([
"insert-before",
"insert-after",
"replace",
"delete",
]),
content: z.string().optional(),
})
)
.min(1),
conflict: conflictPolicy.optional(),
});
const mkdirOp = z.object({
kind: z.literal("mkdir"),
path: z.string().min(1),
conflict: conflictPolicy.optional(),
});
const deleteOp = z.object({
kind: z.literal("delete"),
path: z.string().min(1),
conflict: conflictPolicy.optional(),
});
const renameOp = z.object({
kind: z.literal("rename"),
from: z.string().min(1),
to: z.string().min(1),
conflict: conflictPolicy.optional(),
});
export const FileOpSchema = z.discriminatedUnion("kind", [
writeOp,
patchJsonOp,
patchTextOp,
mkdirOp,
deleteOp,
renameOp,
]);
export const FileOpsSchema = z.array(FileOpSchema);
export const ConflictPolicySchema = conflictPolicy;

View File

@@ -0,0 +1,10 @@
export { type CliFlags, CliFlagsSchema } from "./cli-flags.schemas.js";
export {
ConflictPolicySchema,
FileOpSchema,
FileOpsSchema,
} from "./file-op.schemas.js";
export {
type PluginManifest,
PluginManifestSchema,
} from "./manifest.schemas.js";

View File

@@ -0,0 +1,16 @@
// schema: boundary-only
import z from "zod";
const semverRe = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
export const PluginManifestSchema = z.object({
id: z.string().min(1),
version: z.string().regex(semverRe, "version must be semver"),
description: z.string().min(1),
entry: z.string().min(1),
engines: z.record(z.string(), z.string()),
assets: z.array(z.string()),
peerInstallers: z.array(z.string()).optional(),
});
export type PluginManifest = z.infer<typeof PluginManifestSchema>;

View File

@@ -0,0 +1,15 @@
export { MemoryFs } from "../fs/memory-fs.js";
export {
assertWrote,
createMemoryFs,
} from "./installer-fs.test-helpers.js";
export {
type RunPluginOptions,
type RunPluginResult,
runPlugin,
} from "./run-plugin.js";
export {
ScriptCli,
type ScriptedAnswers,
type ScriptedRecord,
} from "./script-cli.js";

View File

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

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { assertWrote, createMemoryFs } from "./installer-fs.test-helpers.js";
describe("createMemoryFs", () => {
it("returns an empty MemoryFs by default", { timeout: 5000 }, async () => {
const fs = createMemoryFs();
expect(await fs.exists("/anything")).toBe(false);
});
it("seeds when given a seed map", { timeout: 5000 }, async () => {
const fs = createMemoryFs({ "/a.txt": "hi" });
expect(await fs.exists("/a.txt")).toBe(true);
});
});
describe("assertWrote", () => {
it("passes when string predicate matches", { timeout: 5000 }, async () => {
const fs = createMemoryFs({ "/a.txt": "hi" });
await assertWrote(fs, "/a.txt", "hi");
});
it("passes when callback predicate matches", {
timeout: 5000,
}, async () => {
const fs = createMemoryFs({ "/a.txt": "hello" });
await assertWrote(fs, "/a.txt", (text) => text.startsWith("hello"));
});
it("throws when missing", { timeout: 5000 }, async () => {
const fs = createMemoryFs();
await expect(assertWrote(fs, "/missing", "_")).rejects.toThrow(
/missing/
);
});
it("throws when predicate fails", { timeout: 5000 }, async () => {
const fs = createMemoryFs({ "/a.txt": "hi" });
await expect(assertWrote(fs, "/a.txt", "bye")).rejects.toThrow(
/predicate/
);
});
});

View File

@@ -0,0 +1,30 @@
import { MemoryFs, type MemoryFsOptions } from "../fs/memory-fs.js";
const decoder = new TextDecoder();
export function createMemoryFs(seed?: MemoryFsOptions["seed"]): MemoryFs {
return seed ? new MemoryFs({ seed }) : new MemoryFs();
}
/**
* Assert a file exists in `fs` and that its decoded UTF-8 contents match
* `predicate` (string equality or callback). Throws on miss so test
* frameworks surface the failure location.
*/
export async function assertWrote(
fs: MemoryFs,
path: string,
predicate: string | ((contents: string) => boolean)
): Promise<void> {
const exists = await fs.exists(path);
if (!exists) {
throw new Error(`assertWrote: missing file at ${path}`);
}
const bytes = await fs.readFile(path);
const text = decoder.decode(bytes);
const ok =
typeof predicate === "string" ? text === predicate : predicate(text);
if (!ok) {
throw new Error(`assertWrote: predicate failed for ${path}`);
}
}

View File

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

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { InstallerPlugin } from "../types/installer.types.js";
import { runPlugin } from "./run-plugin.js";
describe("runPlugin defaults", () => {
it("default logger swallows all levels", { timeout: 5000 }, async () => {
const plugin: InstallerPlugin<undefined, void> = {
id: "log",
version: "0.1.0",
plan: async (_a, ctx) => {
ctx.logger.debug("d");
ctx.logger.info("i");
ctx.logger.warn("w");
ctx.logger.error("e");
return [];
},
apply: async () => undefined,
};
await expect(runPlugin(plugin)).resolves.toBeDefined();
});
it("default templates render simple variables", {
timeout: 5000,
}, async () => {
let rendered = "";
const plugin: InstallerPlugin<undefined, void> = {
id: "tpl",
version: "0.1.0",
plan: async (_a, ctx) => {
rendered = ctx.templates.render("hi {{ name }}!", {
name: "x",
});
return [];
},
apply: async () => undefined,
};
await runPlugin(plugin);
expect(rendered).toBe("hi x!");
});
it("default templates leave unknown vars empty", {
timeout: 5000,
}, async () => {
let rendered = "";
const plugin: InstallerPlugin<undefined, void> = {
id: "tpl2",
version: "0.1.0",
plan: async (_a, ctx) => {
rendered = ctx.templates.render("a={{a}} b={{b}}", { a: 1 });
return [];
},
apply: async () => undefined,
};
await runPlugin(plugin);
expect(rendered).toBe("a=1 b=");
});
it("default cwd is /", { timeout: 5000 }, async () => {
let observed = "";
const plugin: InstallerPlugin<undefined, void> = {
id: "cwd",
version: "0.1.0",
plan: async (_a, ctx) => {
observed = ctx.cwd;
return [];
},
apply: async () => undefined,
};
await runPlugin(plugin);
expect(observed).toBe("/");
});
});

View File

@@ -0,0 +1,80 @@
import type { LoggerLike } from "@suaveplan/types";
import { MemoryFs } from "../fs/memory-fs.js";
import { Host, type HostRunResult } from "../host/host.js";
import type {
InstallerCli,
InstallerContext,
InstallerPlugin,
TemplateEngine,
} from "../types/installer.types.js";
import { ScriptCli } from "./script-cli.js";
const noopLogger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const echoTemplates: TemplateEngine = {
render: (template, context) =>
template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key: string) => {
const value = (context as Record<string, unknown>)[key];
return value === undefined ? "" : String(value);
}),
};
export interface RunPluginOptions<TAnswers> {
readonly answers?: TAnswers;
readonly fs?: MemoryFs;
readonly cli?: InstallerCli;
readonly cwd?: string;
readonly logger?: LoggerLike;
readonly templates?: TemplateEngine;
readonly signal?: AbortSignal;
readonly dryRun?: boolean;
readonly force?: boolean;
readonly noInteractive?: boolean;
readonly only?: ReadonlyArray<string>;
readonly exclude?: ReadonlyArray<string>;
}
export interface RunPluginResult<THandle> extends HostRunResult<THandle> {
readonly fs: MemoryFs;
readonly cli: InstallerCli;
}
/**
* Run `plugin` end-to-end against an in-memory filesystem with scripted
* CLI answers. The returned `fs` and `cli` references let tests assert on
* the resulting filesystem and on the prompt history.
*/
export async function runPlugin<TAnswers, THandle>(
plugin: InstallerPlugin<TAnswers, THandle>,
options: RunPluginOptions<TAnswers> = {}
): Promise<RunPluginResult<THandle>> {
const fs = options.fs ?? new MemoryFs();
const cli = options.cli ?? new ScriptCli();
const signal = options.signal ?? new AbortController().signal;
const ctx: InstallerContext = {
cwd: options.cwd ?? "/",
fs,
cli,
signal,
logger: options.logger ?? noopLogger,
templates: options.templates ?? echoTemplates,
dryRun: options.dryRun ?? false,
force: options.force ?? false,
noInteractive: options.noInteractive ?? true,
};
const host = new Host(ctx, fs);
const result = await host.run(plugin, {
answers: options.answers as TAnswers,
dryRun: ctx.dryRun,
force: ctx.force,
noInteractive: ctx.noInteractive,
only: options.only ?? [],
exclude: options.exclude ?? [],
});
return { ...result, fs, cli };
}

View File

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

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ScriptCli } from "./script-cli.js";
describe("ScriptCli", () => {
it("returns scripted confirms in order", { timeout: 5000 }, async () => {
const cli = new ScriptCli({ confirm: [true, false] });
expect(await cli.confirm("a", false)).toBe(true);
expect(await cli.confirm("b", true)).toBe(false);
});
it("falls back to default when queue is empty", {
timeout: 5000,
}, async () => {
const cli = new ScriptCli();
expect(await cli.confirm("a", true)).toBe(true);
expect(await cli.confirm("b")).toBe(false);
});
it("prompt returns scripted values", { timeout: 5000 }, async () => {
const cli = new ScriptCli({ prompt: ["alice"] });
expect(await cli.prompt("name?", "default")).toBe("alice");
expect(await cli.prompt("again?", "fallback")).toBe("fallback");
expect(await cli.prompt("nodefault?")).toBe("");
});
it("write captures output", { timeout: 5000 }, () => {
const cli = new ScriptCli();
cli.write("hello");
cli.write("world");
expect(cli.record.written).toEqual(["hello", "world"]);
});
it("record captures interaction history", { timeout: 5000 }, async () => {
const cli = new ScriptCli({ confirm: [true], prompt: ["x"] });
await cli.confirm("c?", false);
await cli.prompt("p?", "_");
expect(cli.record.confirms).toEqual([{ message: "c?", answer: true }]);
expect(cli.record.prompts).toEqual([{ message: "p?", answer: "x" }]);
});
});

View File

@@ -0,0 +1,62 @@
import type { InstallerCli } from "../types/installer.types.js";
export interface ScriptedAnswers {
readonly confirm?: ReadonlyArray<boolean>;
readonly prompt?: ReadonlyArray<string>;
}
export interface ScriptedRecord {
readonly confirms: ReadonlyArray<{ message: string; answer: boolean }>;
readonly prompts: ReadonlyArray<{ message: string; answer: string }>;
readonly written: ReadonlyArray<string>;
}
/**
* Non-TTY `InstallerCli` driven by pre-recorded answers. Exposes the
* recorded interaction history via `record` so tests can assert on what
* the host prompted for.
*/
export class ScriptCli implements InstallerCli {
private readonly confirmQueue: boolean[];
private readonly promptQueue: string[];
private readonly confirms: { message: string; answer: boolean }[] = [];
private readonly prompts: { message: string; answer: string }[] = [];
private readonly written: string[] = [];
public constructor(answers: ScriptedAnswers = {}) {
this.confirmQueue = [...(answers.confirm ?? [])];
this.promptQueue = [...(answers.prompt ?? [])];
}
public async confirm(
message: string,
defaultValue?: boolean
): Promise<boolean> {
const next = this.confirmQueue.shift();
const answer = next ?? defaultValue ?? false;
this.confirms.push({ message, answer });
return answer;
}
public async prompt(
message: string,
defaultValue?: string
): Promise<string> {
const next = this.promptQueue.shift();
const answer = next ?? defaultValue ?? "";
this.prompts.push({ message, answer });
return answer;
}
public write(line: string): void {
this.written.push(line);
}
public get record(): ScriptedRecord {
return {
confirms: this.confirms,
prompts: this.prompts,
written: this.written,
};
}
}

View File

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

View File

@@ -0,0 +1,55 @@
import type { JsonMerge, TextEdit } from "./installer.types.js";
export type ConflictPolicy =
| "error"
| "overwrite"
| "skip-if-exists"
| "merge"
| "prompt";
export interface BaseFileOp {
readonly conflict?: ConflictPolicy;
}
export interface WriteOp extends BaseFileOp {
readonly kind: "write";
readonly path: string;
readonly contents: Uint8Array;
readonly mode?: number;
}
export interface PatchJsonOp extends BaseFileOp {
readonly kind: "patch-json";
readonly path: string;
readonly merge: JsonMerge;
}
export interface PatchTextOp extends BaseFileOp {
readonly kind: "patch-text";
readonly path: string;
readonly edits: ReadonlyArray<TextEdit>;
}
export interface MkdirOp extends BaseFileOp {
readonly kind: "mkdir";
readonly path: string;
}
export interface DeleteOp extends BaseFileOp {
readonly kind: "delete";
readonly path: string;
}
export interface RenameOp extends BaseFileOp {
readonly kind: "rename";
readonly from: string;
readonly to: string;
}
export type FileOp =
| WriteOp
| PatchJsonOp
| PatchTextOp
| MkdirOp
| DeleteOp
| RenameOp;

View File

@@ -0,0 +1,24 @@
export type {
BaseFileOp,
ConflictPolicy,
DeleteOp,
FileOp,
MkdirOp,
PatchJsonOp,
PatchTextOp,
RenameOp,
WriteOp,
} from "./file-op.types.js";
export type {
DetectResult,
InstallerCli,
InstallerContext,
InstallerFs,
InstallerPlugin,
JsonMerge,
PlanSummary,
PromptSchema,
RunOptions,
TemplateEngine,
TextEdit,
} from "./installer.types.js";

View File

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

View File

@@ -0,0 +1,96 @@
import type { LoggerLike } from "@suaveplan/types";
import type { FileOp } from "./file-op.types.js";
export interface InstallerFs {
exists(path: string): Promise<boolean>;
readFile(path: string): Promise<Uint8Array>;
readJson<T = unknown>(path: string): Promise<T>;
stat(
path: string
): Promise<{ size: number; isFile: boolean; isDir: boolean }>;
listDir(path: string): Promise<string[]>;
pathOf(...segments: string[]): string;
}
export interface InstallerCli {
confirm(message: string, defaultValue?: boolean): Promise<boolean>;
prompt(message: string, defaultValue?: string): Promise<string>;
write(line: string): void;
}
export interface TemplateEngine {
render(template: string, context: Record<string, unknown>): string;
}
export interface InstallerContext {
readonly cwd: string;
readonly logger: LoggerLike;
readonly fs: InstallerFs;
readonly templates: TemplateEngine;
readonly cli: InstallerCli;
readonly signal: AbortSignal;
readonly dryRun: boolean;
readonly force: boolean;
readonly noInteractive: boolean;
}
export interface DetectResult {
readonly alreadyInstalled?: boolean;
readonly conflicts?: ReadonlyArray<string>;
readonly notes?: ReadonlyArray<string>;
}
export interface PromptSchema<T> {
parse(value: unknown): T;
safeParse(value: unknown):
| { success: true; data: T }
| {
success: false;
error: {
issues: ReadonlyArray<{
path: ReadonlyArray<string | number>;
message: string;
}>;
};
};
}
export interface JsonMerge {
readonly value: unknown;
readonly arrays?: "replace" | "concat";
}
export interface TextEdit {
readonly anchor: string | RegExp;
readonly mode: "insert-before" | "insert-after" | "replace" | "delete";
readonly content?: string;
}
export interface InstallerPlugin<TAnswers, THandle = void> {
readonly id: string;
readonly version: string;
detect?(ctx: InstallerContext): Promise<DetectResult>;
prompts?(ctx: InstallerContext): PromptSchema<TAnswers>;
plan(
answers: TAnswers,
ctx: InstallerContext
): Promise<ReadonlyArray<FileOp>>;
apply(ops: ReadonlyArray<FileOp>, ctx: InstallerContext): Promise<THandle>;
postInstall?(handle: THandle, ctx: InstallerContext): Promise<void>;
}
export interface RunOptions<TAnswers> {
readonly answers?: TAnswers;
readonly dryRun?: boolean;
readonly force?: boolean;
readonly noInteractive?: boolean;
readonly only?: ReadonlyArray<string>;
readonly exclude?: ReadonlyArray<string>;
}
export interface PlanSummary {
readonly written: number;
readonly patched: number;
readonly skipped: number;
readonly conflicts: number;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,55 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/installer",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"testing/index": "src/testing/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/cli",
/^@suaveplan\/cli\//,
"@suaveplan/templates",
/^@suaveplan\/templates\//,
"@suaveplan/validation",
/^@suaveplan\/validation\//,
"@suaveplan/error",
/^@suaveplan\/error\//,
"@suaveplan/types",
/^@suaveplan\/types\//,
"@suaveplan/events",
/^@suaveplan\/events\//,
"@suaveplan/logger",
/^@suaveplan\/logger\//,
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/bin/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});