Initial Commit
This commit is contained in:
171
packages/devtools/tooling/installer/README.md
Normal file
171
packages/devtools/tooling/installer/README.md
Normal 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
|
||||
|
||||
98
packages/devtools/tooling/installer/package.json
Normal file
98
packages/devtools/tooling/installer/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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.
|
||||
191
packages/devtools/tooling/installer/src/bin/suaveplan-install.ts
Normal file
191
packages/devtools/tooling/installer/src/bin/suaveplan-install.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
79
packages/devtools/tooling/installer/src/error-codes.md
Normal file
79
packages/devtools/tooling/installer/src/error-codes.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
42
packages/devtools/tooling/installer/src/error-codes.ts
Normal file
42
packages/devtools/tooling/installer/src/error-codes.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
66
packages/devtools/tooling/installer/src/errors/errors.md
Normal file
66
packages/devtools/tooling/installer/src/errors/errors.md
Normal 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.
|
||||
5
packages/devtools/tooling/installer/src/errors/errors.ts
Normal file
5
packages/devtools/tooling/installer/src/errors/errors.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
INSTALLER_CODES,
|
||||
InstallerError,
|
||||
type InstallerErrorCode,
|
||||
} from "../error-codes.js";
|
||||
83
packages/devtools/tooling/installer/src/fs/disk-fs.md
Normal file
83
packages/devtools/tooling/installer/src/fs/disk-fs.md
Normal 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.
|
||||
134
packages/devtools/tooling/installer/src/fs/disk-fs.test.ts
Normal file
134
packages/devtools/tooling/installer/src/fs/disk-fs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
126
packages/devtools/tooling/installer/src/fs/disk-fs.ts
Normal file
126
packages/devtools/tooling/installer/src/fs/disk-fs.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
6
packages/devtools/tooling/installer/src/fs/index.ts
Normal file
6
packages/devtools/tooling/installer/src/fs/index.ts
Normal 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";
|
||||
76
packages/devtools/tooling/installer/src/fs/installer-fs.md
Normal file
76
packages/devtools/tooling/installer/src/fs/installer-fs.md
Normal 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.
|
||||
15
packages/devtools/tooling/installer/src/fs/installer-fs.ts
Normal file
15
packages/devtools/tooling/installer/src/fs/installer-fs.ts
Normal 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";
|
||||
82
packages/devtools/tooling/installer/src/fs/memory-fs.md
Normal file
82
packages/devtools/tooling/installer/src/fs/memory-fs.md
Normal 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.
|
||||
155
packages/devtools/tooling/installer/src/fs/memory-fs.test.ts
Normal file
155
packages/devtools/tooling/installer/src/fs/memory-fs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
217
packages/devtools/tooling/installer/src/fs/memory-fs.ts
Normal file
217
packages/devtools/tooling/installer/src/fs/memory-fs.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
packages/devtools/tooling/installer/src/host/apply/apply.md
Normal file
120
packages/devtools/tooling/installer/src/host/apply/apply.md
Normal 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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
439
packages/devtools/tooling/installer/src/host/apply/apply.test.ts
Normal file
439
packages/devtools/tooling/installer/src/host/apply/apply.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
218
packages/devtools/tooling/installer/src/host/apply/apply.ts
Normal file
218
packages/devtools/tooling/installer/src/host/apply/apply.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# `conflict`
|
||||
|
||||
## Purpose
|
||||
|
||||
Resolves the per-op `conflict` policy on a target that already exists.
|
||||
Five policies are supported: `error` (raise `APPLY_CONFLICT`), `overwrite`
|
||||
(replace), `skip-if-exists` (drop the op silently and record), `merge`
|
||||
(structured merge for patch ops; raise for raw writes), and `prompt`
|
||||
(ask the user via `InstallerCli.confirm`, defaulting to `error` semantics
|
||||
in `--no-interactive` mode). The host's `--force` flag collapses every
|
||||
policy to `overwrite`.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns a `ConflictDecision` (`apply | skip | merge`) so the apply
|
||||
function knows whether to proceed, drop, or perform a structured merge.
|
||||
- `mkdir` is treated idempotently: re-creating an existing directory is
|
||||
always a `skip` regardless of the declared policy. This matches the
|
||||
"directories are containers" mental model.
|
||||
- `prompt` is only honoured when `noInteractive === false`; otherwise it
|
||||
hard-fails with `APPLY_CONFLICT`.
|
||||
- `merge` on a raw `write` op raises `APPLY_CONFLICT` — text bytes can't
|
||||
be structurally merged, only patch ops can.
|
||||
|
||||
## Basic Example
|
||||
|
||||
```ts
|
||||
import { resolveConflict } from "@suaveplan/installer";
|
||||
|
||||
const decision = await resolveConflict({
|
||||
op: {
|
||||
kind: "write",
|
||||
path: "/x.txt",
|
||||
contents: new Uint8Array(),
|
||||
conflict: "skip-if-exists",
|
||||
},
|
||||
exists: true,
|
||||
ctx: { force: false, noInteractive: true, cli },
|
||||
});
|
||||
console.log(decision); // "skip"
|
||||
```
|
||||
|
||||
## Advanced Example
|
||||
|
||||
```ts
|
||||
import { resolveConflict } from "@suaveplan/installer";
|
||||
|
||||
const decisions = await Promise.all(
|
||||
plan.ops.map(async (op) => ({
|
||||
op,
|
||||
decision: await resolveConflict({
|
||||
op,
|
||||
exists: await fs.exists(op.kind === "rename" ? op.to : op.path),
|
||||
ctx: { force: cli.flags.force, noInteractive: cli.flags.noInteractive, cli: scriptCli },
|
||||
}),
|
||||
}))
|
||||
);
|
||||
const summary = decisions.reduce(
|
||||
(acc, d) => ({ ...acc, [d.decision]: (acc[d.decision] ?? 0) + 1 }),
|
||||
{} as Record<string, number>
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `resolveConflict(options: ResolveConflictOptions): Promise<ConflictDecision>`
|
||||
- `interface ResolveConflictOptions` —
|
||||
`{ op, exists, ctx: { force, noInteractive, cli } }`.
|
||||
- `type ConflictDecision = "apply" | "skip" | "merge"`.
|
||||
- Errors: `INSTALLER.APPLY_CONFLICT` for `error` policy on existing,
|
||||
`merge` on raw write, or rejected/no-interactive prompt.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function returns synchronously when the target doesn't exist —
|
||||
`exists === false` always means "apply", short-circuiting before any
|
||||
prompt logic. The default policy when an op omits `conflict` is `error`,
|
||||
chosen so plugins are forced to opt into destructive behaviour. The
|
||||
prompt path uses `cli.confirm(...)` with a `false` default so accidentally
|
||||
hitting Enter rejects the overwrite — safer-by-default ergonomics.
|
||||
@@ -0,0 +1,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) } }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# `json-merge`
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the deep-merge semantics used by `patch-json` ops with
|
||||
`conflict: "merge"`. Match the conventions of `@suaveplan/config`: plain
|
||||
objects merge key-by-key; arrays default to **replace** (use
|
||||
`arrays: "concat"` to opt into concat behaviour); `undefined` keys in the
|
||||
patch are skipped so existing values win. The merge is **pure** — input
|
||||
objects are never mutated.
|
||||
|
||||
## Features
|
||||
|
||||
- Replace-by-default array semantics — surprising-by-default array merges
|
||||
(concat) are an opt-in.
|
||||
- Skips `undefined` patch keys — `{ a: undefined }` does not erase
|
||||
`existing.a`.
|
||||
- Treats class instances as scalars — only objects with `Object.prototype`
|
||||
or null prototype are recursively merged.
|
||||
- Concat-on-array-mismatch falls back to replace — concat is only
|
||||
meaningful when both sides are arrays.
|
||||
- Returns a brand new object tree, leaving inputs untouched.
|
||||
|
||||
## Basic Example
|
||||
|
||||
```ts
|
||||
import { applyJsonMerge } from "@suaveplan/installer";
|
||||
|
||||
const next = applyJsonMerge(
|
||||
{ a: 1, nested: { x: 1 } },
|
||||
{ value: { b: 2, nested: { y: 2 } } }
|
||||
);
|
||||
console.log(next); // { a: 1, b: 2, nested: { x: 1, y: 2 } }
|
||||
```
|
||||
|
||||
## Advanced Example
|
||||
|
||||
```ts
|
||||
import { applyJsonMerge } from "@suaveplan/installer";
|
||||
|
||||
// Replace arrays (default) — overwrites the list:
|
||||
applyJsonMerge({ tags: ["old"] }, { value: { tags: ["new"] } });
|
||||
// → { tags: ["new"] }
|
||||
|
||||
// Concat arrays:
|
||||
applyJsonMerge(
|
||||
{ tags: ["old"] },
|
||||
{ value: { tags: ["new"] }, arrays: "concat" }
|
||||
);
|
||||
// → { tags: ["old", "new"] }
|
||||
|
||||
// Skip undefined:
|
||||
applyJsonMerge({ a: 1 }, { value: { a: undefined, b: 2 } });
|
||||
// → { a: 1, b: 2 }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `applyJsonMerge(existing: unknown, merge: JsonMerge): unknown`
|
||||
- `interface JsonMerge` — `{ value: unknown, arrays?: "replace" | "concat" }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The "is plain object" check is a prototype-equality test (`=== Object.prototype || === null`) rather than `typeof === "object"`. This
|
||||
keeps class instances opaque so the merge doesn't accidentally rebuild
|
||||
something like a `Date` into a plain object. Concat array semantics fall
|
||||
back to replace when the base side isn't an array — concatenating into a
|
||||
scalar would lose the original value, so replace is the safer default
|
||||
there. Arrays in the patch are always cloned (`[...patch]`) so callers
|
||||
can safely mutate the returned value without affecting the input.
|
||||
@@ -0,0 +1,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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# `text-patch`
|
||||
|
||||
## Purpose
|
||||
|
||||
Applies an ordered sequence of `TextEdit`s to a string. Used by
|
||||
`patch-text` ops to make targeted insertions/deletions/replacements
|
||||
against existing text files (e.g. injecting a line into `.gitignore`,
|
||||
appending a section to a README, removing a deprecated import). The
|
||||
trailing-newline policy of the input is preserved so the patcher doesn't
|
||||
silently change line-count semantics.
|
||||
|
||||
## Features
|
||||
|
||||
- Four edit modes: `insert-before`, `insert-after`, `replace`, `delete` —
|
||||
declared per-edit via `TextEdit.mode`.
|
||||
- String anchors (literal substring) and `RegExp` anchors both supported.
|
||||
- Trailing newline preserved across the entire edit sequence — input
|
||||
ending in `\n` produces output ending in `\n`.
|
||||
- Sequential application — later edits see the effect of earlier ones,
|
||||
so a chain like `replace "foo" → "FOO"` then `insert-after "FOO"` works.
|
||||
- Missing-anchor failure throws synchronously — the caller (apply) wraps
|
||||
this in `INSTALLER.APPLY_FAILED` so the host's transaction rolls back.
|
||||
|
||||
## Basic Example
|
||||
|
||||
```ts
|
||||
import { applyTextEdits } from "@suaveplan/installer";
|
||||
|
||||
const out = applyTextEdits("hello world", [
|
||||
{ anchor: "world", mode: "replace", content: "everyone" },
|
||||
]);
|
||||
console.log(out); // "hello everyone"
|
||||
```
|
||||
|
||||
## Advanced Example
|
||||
|
||||
```ts
|
||||
import { applyTextEdits } from "@suaveplan/installer";
|
||||
|
||||
const original = "node_modules\ndist\n";
|
||||
const updated = applyTextEdits(original, [
|
||||
{
|
||||
anchor: /^/,
|
||||
mode: "insert-before",
|
||||
content: "# Auto-generated by suaveplan-install\n",
|
||||
},
|
||||
{
|
||||
anchor: "dist",
|
||||
mode: "insert-after",
|
||||
content: "\n.suaveplan/",
|
||||
},
|
||||
]);
|
||||
console.log(updated);
|
||||
// # Auto-generated by suaveplan-install
|
||||
// node_modules
|
||||
// dist
|
||||
// .suaveplan/
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `applyTextEdits(source: string, edits: ReadonlyArray<TextEdit>): string`
|
||||
- `interface TextEdit` — `{ anchor: string | RegExp, mode, content? }`.
|
||||
- Throws `Error("text-patch: anchor not found: ...")` when an anchor
|
||||
doesn't match — apply catches and wraps in `APPLY_FAILED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The trailing newline is sliced off before edits run and reattached after
|
||||
— this avoids edits whose anchor contains the literal `\n` from
|
||||
inadvertently producing a doubled newline at end-of-file. Anchor
|
||||
matching uses `String.prototype.match(regex)` (rather than `regex.exec`)
|
||||
so the anchor regex is treated as immutable across calls — there's no
|
||||
shared state if a regex with `g` is reused. Edits with `undefined`
|
||||
content default to empty string so `replace` without content effectively
|
||||
*deletes* the matched anchor.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
78
packages/devtools/tooling/installer/src/host/detect.md
Normal file
78
packages/devtools/tooling/installer/src/host/detect.md
Normal 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.
|
||||
37
packages/devtools/tooling/installer/src/host/detect.ts
Normal file
37
packages/devtools/tooling/installer/src/host/detect.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
147
packages/devtools/tooling/installer/src/host/host.md
Normal file
147
packages/devtools/tooling/installer/src/host/host.md
Normal 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.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
239
packages/devtools/tooling/installer/src/host/host.test.ts
Normal file
239
packages/devtools/tooling/installer/src/host/host.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
125
packages/devtools/tooling/installer/src/host/host.ts
Normal file
125
packages/devtools/tooling/installer/src/host/host.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
17
packages/devtools/tooling/installer/src/host/index.ts
Normal file
17
packages/devtools/tooling/installer/src/host/index.ts
Normal 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";
|
||||
72
packages/devtools/tooling/installer/src/host/plan.md
Normal file
72
packages/devtools/tooling/installer/src/host/plan.md
Normal 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.
|
||||
62
packages/devtools/tooling/installer/src/host/plan.ts
Normal file
62
packages/devtools/tooling/installer/src/host/plan.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
66
packages/devtools/tooling/installer/src/host/post-install.md
Normal file
66
packages/devtools/tooling/installer/src/host/post-install.md
Normal 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.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
44
packages/devtools/tooling/installer/src/host/post-install.ts
Normal file
44
packages/devtools/tooling/installer/src/host/post-install.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
87
packages/devtools/tooling/installer/src/host/prompts.md
Normal file
87
packages/devtools/tooling/installer/src/host/prompts.md
Normal 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.
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
77
packages/devtools/tooling/installer/src/host/prompts.ts
Normal file
77
packages/devtools/tooling/installer/src/host/prompts.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
73
packages/devtools/tooling/installer/src/index.ts
Normal file
73
packages/devtools/tooling/installer/src/index.ts
Normal 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";
|
||||
78
packages/devtools/tooling/installer/src/manifest/discover.md
Normal file
78
packages/devtools/tooling/installer/src/manifest/discover.md
Normal 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.
|
||||
124
packages/devtools/tooling/installer/src/manifest/discover.ts
Normal file
124
packages/devtools/tooling/installer/src/manifest/discover.ts
Normal 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;
|
||||
}
|
||||
10
packages/devtools/tooling/installer/src/manifest/index.ts
Normal file
10
packages/devtools/tooling/installer/src/manifest/index.ts
Normal 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";
|
||||
91
packages/devtools/tooling/installer/src/manifest/manifest.md
Normal file
91
packages/devtools/tooling/installer/src/manifest/manifest.md
Normal 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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
59
packages/devtools/tooling/installer/src/manifest/manifest.ts
Normal file
59
packages/devtools/tooling/installer/src/manifest/manifest.ts
Normal 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);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
10
packages/devtools/tooling/installer/src/schemas/index.ts
Normal file
10
packages/devtools/tooling/installer/src/schemas/index.ts
Normal 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";
|
||||
@@ -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>;
|
||||
15
packages/devtools/tooling/installer/src/testing/index.ts
Normal file
15
packages/devtools/tooling/installer/src/testing/index.ts
Normal 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";
|
||||
@@ -0,0 +1,63 @@
|
||||
# `installer-fs.test-helpers`
|
||||
|
||||
## Purpose
|
||||
|
||||
Convenience helpers for assertions against the in-memory installer fs in
|
||||
plugin tests. `createMemoryFs(seed)` is a thin wrapper that returns a
|
||||
fresh `MemoryFs` (with optional seed) so tests avoid the constructor
|
||||
boilerplate. `assertWrote(fs, path, predicate)` reads the file at `path`,
|
||||
decodes UTF-8, and matches the contents against the predicate (string
|
||||
equality or callback) — failing assertions throw with a clear message
|
||||
suitable for any test framework.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero-dependency UTF-8 decoder over the file body — works in any test
|
||||
runner (vitest, bun-test, node:test).
|
||||
- Two predicate forms: bare string for equality, or callback for partial
|
||||
/ regex / structural checks.
|
||||
- Distinct error messages for missing file vs. predicate mismatch so
|
||||
failures point straight at the right line.
|
||||
|
||||
## Basic Example
|
||||
|
||||
```ts
|
||||
import { assertWrote, createMemoryFs } from "@suaveplan/installer";
|
||||
|
||||
const fs = createMemoryFs({ "/index.json": '{"version":1}' });
|
||||
await assertWrote(fs, "/index.json", '{"version":1}');
|
||||
```
|
||||
|
||||
## Advanced Example
|
||||
|
||||
```ts
|
||||
import { assertWrote, createMemoryFs, runPlugin } from "@suaveplan/installer";
|
||||
|
||||
const fs = createMemoryFs();
|
||||
await runPlugin(myPlugin, { fs, answers: { name: "echo" } });
|
||||
|
||||
await assertWrote(fs, "/.suaveplan/skills/echo/SKILL.md", (text) =>
|
||||
text.startsWith("# echo")
|
||||
);
|
||||
await assertWrote(fs, "/.suaveplan/skills/index.json", (text) => {
|
||||
const idx = JSON.parse(text) as { skills: Array<{ id: string }> };
|
||||
return idx.skills.some((s) => s.id === "echo");
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createMemoryFs(seed?: Record<string, string | Uint8Array>): MemoryFs`
|
||||
- `assertWrote(fs: MemoryFs, path: string, predicate: string | ((text: string) => boolean)): Promise<void>`
|
||||
- Failure modes: missing file → throws `Error("assertWrote: missing file at <path>")`; predicate fail → throws `Error("assertWrote: predicate failed for <path>")`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helpers are deliberately minimal — they match the patterns in
|
||||
`@suaveplan/testing/runner` (which also keeps zero workspace deps).
|
||||
`assertWrote` always decodes as UTF-8; binary assertions should compare
|
||||
bytes via `MemoryFs.snapshot()` directly. The string-predicate form is a
|
||||
common case (docs, JSON snapshots) and is sugar for
|
||||
`(text) => text === expected` so failures still produce the same
|
||||
"predicate failed" message — tests should add their own context if they
|
||||
need finer error reporting.
|
||||
@@ -0,0 +1,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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# `run-plugin`
|
||||
|
||||
## Purpose
|
||||
|
||||
End-to-end test harness for plugins. `runPlugin(plugin, options)` wires up
|
||||
an in-memory filesystem, a scripted CLI, a no-op logger, and a basic
|
||||
template engine, then drives the plugin through the full
|
||||
`detect → prompts → plan → apply → postInstall` pipeline. Returns the
|
||||
filesystem and the recorded CLI interaction so tests can assert on what
|
||||
was written and what was prompted. Zero real disk I/O.
|
||||
|
||||
## Features
|
||||
|
||||
- Sensible defaults: in-memory `MemoryFs`, scripted `ScriptCli`, no-op
|
||||
logger, `{{var}}`-style template engine — all overridable.
|
||||
- Returns `{ ops, summary, handle, dryRun, fs, cli }` so tests can
|
||||
inspect the plan, the resulting fs, and the prompt history.
|
||||
- Same `RunOptions` surface as `Host.run`: `dryRun`, `force`,
|
||||
`noInteractive`, `only`, `exclude`, `signal`.
|
||||
- Defaults `noInteractive` to `true` so test runs never block on a
|
||||
prompt that wasn't scripted.
|
||||
|
||||
## Basic Example
|
||||
|
||||
```ts
|
||||
import { runPlugin } from "@suaveplan/installer";
|
||||
|
||||
const result = await runPlugin(myPlugin, {
|
||||
answers: { targetDir: "/.suaveplan/skills" },
|
||||
});
|
||||
expect(result.summary.written).toBe(5);
|
||||
```
|
||||
|
||||
## Advanced Example
|
||||
|
||||
```ts
|
||||
import { runPlugin, ScriptCli } from "@suaveplan/installer";
|
||||
|
||||
const cli = new ScriptCli({ confirm: [true, false] });
|
||||
const ac = new AbortController();
|
||||
const result = await runPlugin(myPlugin, {
|
||||
cli,
|
||||
signal: ac.signal,
|
||||
answers: { skills: ["echo", "crypto"] },
|
||||
only: ["crypto"],
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(cli.record.confirms).toEqual([
|
||||
{ message: "Overwrite existing file?", answer: true },
|
||||
]);
|
||||
expect(result.ops.every((op) => op.path.includes("crypto"))).toBe(true);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runPlugin<TAnswers, THandle>(plugin, options): Promise<RunPluginResult<THandle>>`
|
||||
- `interface RunPluginOptions<TAnswers>` —
|
||||
`{ answers?, fs?, cli?, cwd?, logger?, templates?, signal?, dryRun?, force?, noInteractive?, only?, exclude? }`.
|
||||
- `interface RunPluginResult<THandle> extends HostRunResult<THandle>`
|
||||
— adds `fs: MemoryFs` and `cli: InstallerCli`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default template engine handles only `{{varName}}` substitution and
|
||||
returns empty string for unknown vars — that's enough for plugin tests
|
||||
that don't care about templates and avoids adding a dependency on
|
||||
`@suaveplan/templates` to the test surface. The default `cwd` is `/`
|
||||
(POSIX root in `MemoryFs`'s namespace) so paths in plans can be written
|
||||
literally. Tests that need a different `cwd` should pass it explicitly;
|
||||
both `DiskFs` and `MemoryFs` honour the value identically.
|
||||
@@ -0,0 +1,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("/");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# `script-cli`
|
||||
|
||||
## Purpose
|
||||
|
||||
Non-TTY `InstallerCli` driven by pre-recorded answers. `ScriptCli` is the
|
||||
default CLI used by `runPlugin` — tests script `confirm`/`prompt`
|
||||
responses via the constructor and read back the interaction history via
|
||||
the `record` getter to assert on what the host actually asked. This makes
|
||||
prompt-conformance tests fully deterministic.
|
||||
|
||||
## Features
|
||||
|
||||
- Separate queues for `confirm` (booleans) and `prompt` (strings) so
|
||||
tests don't have to interleave them by call-order.
|
||||
- Falls back to `defaultValue` (and then to `false`/`""`) when the queue
|
||||
is exhausted — handy for tests that don't care about every prompt.
|
||||
- `record.confirms`, `record.prompts`, `record.written` give read-only
|
||||
views of every interaction since construction.
|
||||
- `write(line)` captures emitted output instead of printing to stdout, so
|
||||
tests can assert on user-facing messages without log capture.
|
||||
|
||||
## Basic Example
|
||||
|
||||
```ts
|
||||
import { ScriptCli } from "@suaveplan/installer";
|
||||
|
||||
const cli = new ScriptCli({ confirm: [true], prompt: ["alice"] });
|
||||
console.log(await cli.confirm("Continue?", false)); // true
|
||||
console.log(await cli.prompt("Name?", "guest")); // "alice"
|
||||
```
|
||||
|
||||
## Advanced Example
|
||||
|
||||
```ts
|
||||
import { runPlugin, ScriptCli } from "@suaveplan/installer";
|
||||
|
||||
const cli = new ScriptCli({
|
||||
confirm: [true, false, true],
|
||||
prompt: ["agent-skills", "/.suaveplan"],
|
||||
});
|
||||
|
||||
await runPlugin(myPlugin, { cli, answers: undefined, noInteractive: false });
|
||||
|
||||
// All scripted answers consumed:
|
||||
expect(cli.record.confirms.length).toBe(3);
|
||||
expect(cli.record.prompts.map((p) => p.answer)).toEqual([
|
||||
"agent-skills",
|
||||
"/.suaveplan",
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ScriptCli(answers?: { confirm?: boolean[], prompt?: string[] })`
|
||||
- `confirm(message, defaultValue?): Promise<boolean>` — pops next
|
||||
scripted boolean or returns the default.
|
||||
- `prompt(message, defaultValue?): Promise<string>` — pops next scripted
|
||||
string or returns the default.
|
||||
- `write(line): void` — captures into `record.written`.
|
||||
- `record` getter — `{ confirms, prompts, written }` with full history.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal queues are mutable arrays that `shift()` on each call so
|
||||
order is preserved. When a scripted value is `undefined` (queue empty),
|
||||
the fallback chain is `defaultValue ?? false/""` — explicit `defaultValue`
|
||||
wins over the generic falsy. The `record` getter returns the live
|
||||
internal arrays — tests should treat them as read-only. Write captures
|
||||
plain strings (no formatting, no ANSI) so assertions stay
|
||||
implementation-agnostic.
|
||||
@@ -0,0 +1,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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# `file-op.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the discriminated `FileOp` union — the unit currency between a
|
||||
plugin's `plan` step and the host's `apply` step. A plan returns `FileOp[]`;
|
||||
the host validates, conflict-resolves, and materialises each one inside a
|
||||
transaction. Keeping the op shape declarative (no closures, no callbacks)
|
||||
is what enables `--dry-run`, structured-conflict resolution, and rollback.
|
||||
|
||||
## Features
|
||||
|
||||
- Six op kinds: `write`, `patch-json`, `patch-text`, `mkdir`, `delete`,
|
||||
`rename`. Each carries a `kind` discriminator for exhaustive switching.
|
||||
- Per-op `conflict?: ConflictPolicy` — `error | overwrite | skip-if-exists | merge | prompt`. Default is `error`; the host's `--force`
|
||||
flag collapses everything to `overwrite`.
|
||||
- Patch ops carry their structured intent: `JsonMerge` (deep-merge with
|
||||
array semantics) for `patch-json`, `TextEdit[]` (anchor + mode) for
|
||||
`patch-text`.
|
||||
- Bytes-first `write` — contents are `Uint8Array` so binary assets travel
|
||||
unchanged through the pipeline.
|
||||
|
||||
## Basic Example
|
||||
|
||||
```ts
|
||||
import type { FileOp } from "@suaveplan/installer";
|
||||
|
||||
const op: FileOp = {
|
||||
kind: "write",
|
||||
path: "/.suaveplan/skills/echo/SKILL.md",
|
||||
contents: new TextEncoder().encode("# echo\n"),
|
||||
conflict: "skip-if-exists",
|
||||
};
|
||||
```
|
||||
|
||||
## Advanced Example
|
||||
|
||||
```ts
|
||||
import type { FileOp } from "@suaveplan/installer";
|
||||
|
||||
const ops: FileOp[] = [
|
||||
{ kind: "mkdir", path: "/.suaveplan/skills" },
|
||||
{
|
||||
kind: "patch-json",
|
||||
path: "/.suaveplan/skills/index.json",
|
||||
merge: { value: { skills: [{ id: "echo", version: "1.0.0" }] } },
|
||||
conflict: "merge",
|
||||
},
|
||||
{
|
||||
kind: "patch-text",
|
||||
path: "/.gitignore",
|
||||
edits: [
|
||||
{ anchor: "node_modules", mode: "insert-after", content: "\n.suaveplan/" },
|
||||
],
|
||||
conflict: "merge",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WriteOp` — `kind: "write"`, `path`, `contents: Uint8Array`, optional
|
||||
`mode?: number`.
|
||||
- `PatchJsonOp` — `kind: "patch-json"`, `path`, `merge: JsonMerge`.
|
||||
- `PatchTextOp` — `kind: "patch-text"`, `path`, `edits: ReadonlyArray<TextEdit>`.
|
||||
- `MkdirOp` — `kind: "mkdir"`, `path`.
|
||||
- `DeleteOp` — `kind: "delete"`, `path`.
|
||||
- `RenameOp` — `kind: "rename"`, `from`, `to`.
|
||||
- `BaseFileOp` — common `conflict?: ConflictPolicy` field.
|
||||
- `ConflictPolicy` — `"error" | "overwrite" | "skip-if-exists" | "merge" | "prompt"`.
|
||||
- `FileOp` — discriminated union of the six kinds above.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The discriminator is `kind`, never `type`, to avoid clashes with TS's
|
||||
internal type-narrowing keyword. `rename` uses `from`/`to` instead of
|
||||
`path`/`newPath` so `pathOf(op)` (in `host/apply/conflict.ts` and
|
||||
`host/host.ts`) can pick `op.to` for conflict resolution and filtering.
|
||||
Mutating ops always reference the new resting place, never the source.
|
||||
@@ -0,0 +1,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;
|
||||
24
packages/devtools/tooling/installer/src/types/index.ts
Normal file
24
packages/devtools/tooling/installer/src/types/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/devtools/tooling/installer/tsconfig.lib.json
Normal file
12
packages/devtools/tooling/installer/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
55
packages/devtools/tooling/installer/vite.config.ts
Normal file
55
packages/devtools/tooling/installer/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/devtools/tooling/installer/vitest.config.ts
Normal file
31
packages/devtools/tooling/installer/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user