Initial Commit
This commit is contained in:
205
openspec/archive/2026-04-28-installer-port/proposal.md
Normal file
205
openspec/archive/2026-04-28-installer-port/proposal.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# OpenSpec Change: installer-port
|
||||
|
||||
- **Change ID**: installer-port
|
||||
- **Packages**: `@suaveplan/installer` (new), `@suaveplan/install-agent-skills` (new)
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-04-28
|
||||
|
||||
## Summary
|
||||
|
||||
Introduce `@suaveplan/installer`, a thin plugin host for project boilerplating and asset installation, and `@suaveplan/install-agent-skills`, the inaugural plugin/bundle that installs the full Suaveplan agent-skills set into a target project from npm — no repo clone required.
|
||||
|
||||
The installer is a generic, modular pipeline (`detect → prompts → plan → apply → postInstall`) built on top of three primitives that already exist on genesis: `@suaveplan/cli` (UX), `@suaveplan/templates` (rendering), `@suaveplan/validation` (Zod prompt schemas). Plugins are vertical (`install-agent-skills`, future `install-web-server`, `install-spa`, …) and each ships as its own npm package consumable via `npx @suaveplan/installer add <plugin>`.
|
||||
|
||||
## Why
|
||||
|
||||
Skills today require users to clone the repo to copy files out — a known pain point. We also want a unified path for boilerplating web servers, SPAs, desktop apps, mobile apps, etc., without each generator reinventing argv parsing, prompts, dry-run, conflict resolution, and rollback.
|
||||
|
||||
A plugin host solves both. The lifecycle (detect/prompts/plan/apply/postInstall) is identical for skills and for boilerplate; the rendering substrate (`@suaveplan/templates`) is identical; the UX substrate (`@suaveplan/cli`) is identical. Without a host, every generator duplicates ~600 lines of orchestration.
|
||||
|
||||
We ship the host together with one real plugin (`install-agent-skills`) so the contract is validated by a non-trivial consumer on day one — not a paper interface.
|
||||
|
||||
## Why a single bundle for skills
|
||||
|
||||
Earlier sketch proposed one npm package per skill. Rejected: ~90 skills × separate packages multiplies publish pipelines, changelogs, and resolution churn. Users almost always want "the suaveplan skill set" or a curated subset, not a cherry-pick. `install-agent-skills` ships **all skills as a single versioned bundle** with internal selectivity via `--only` / `--exclude` flags. If a future skill grows heavy assets (large datasets, models) that bloat the bundle for users who don't need them, that one peels out as `@suaveplan/skill-<heavy>` — bundle remains the default.
|
||||
|
||||
## Why `templates` is not a plugin
|
||||
|
||||
Plugins are vertical (skills, web-server, spa) — what the user chose to install. `@suaveplan/templates` is a rendering primitive every plugin uses internally. Modelling it as a plugin would invert the dependency: plugins would have to opt into rendering. Wrong shape. Templates stays an internal primitive of `installer` and of every plugin that needs interpolation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Layers
|
||||
|
||||
```
|
||||
@suaveplan/cli ← argv, prompts, spinner, output (existing)
|
||||
@suaveplan/templates ← interpolation (existing)
|
||||
@suaveplan/validation ← Zod prompt schemas (existing)
|
||||
@suaveplan/error ← structured errors (existing)
|
||||
│
|
||||
▼
|
||||
@suaveplan/installer ← plugin host + lifecycle (this change)
|
||||
│
|
||||
├── @suaveplan/install-agent-skills (this change)
|
||||
├── @suaveplan/install-web-server (future)
|
||||
├── @suaveplan/install-spa (future)
|
||||
└── @suaveplan/install-<vertical> (future)
|
||||
```
|
||||
|
||||
### Plugin contract
|
||||
|
||||
The host's only public surface is the plugin contract:
|
||||
|
||||
```ts
|
||||
export interface InstallerContext {
|
||||
readonly cwd: string;
|
||||
readonly logger: LoggerLike;
|
||||
readonly fs: InstallerFs; // virtual filesystem (dry-run safe)
|
||||
readonly templates: TemplateEngine; // shared template engine instance
|
||||
readonly cli: InstallerCli; // prompts, spinner, output
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
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<FileOp[]>;
|
||||
apply(ops: FileOp[], ctx: InstallerContext): Promise<THandle>;
|
||||
postInstall?(handle: THandle, ctx: InstallerContext): Promise<void>;
|
||||
}
|
||||
|
||||
export type FileOp =
|
||||
| { kind: "write"; path: string; contents: Uint8Array; mode?: number }
|
||||
| { kind: "patch-json"; path: string; merge: JsonMerge }
|
||||
| { kind: "patch-text"; path: string; edits: TextEdit[] }
|
||||
| { kind: "mkdir"; path: string }
|
||||
| { kind: "delete"; path: string }
|
||||
| { kind: "rename"; from: string; to: string };
|
||||
```
|
||||
|
||||
Generics (`TAnswers`, `THandle`) flow end-to-end so `prompts` returns typed answers and `apply` returns a typed handle the plugin can hand to `postInstall`. No `any`.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. **detect** — plugin reports what is already present (e.g. existing `agent-skills/` directory, conflicting files).
|
||||
2. **prompts** — Zod-validated answer schema; `cli` renders prompts in TTY, accepts `--answers <file>` non-interactively.
|
||||
3. **plan** — pure function: produces `FileOp[]` against the virtual fs. No real writes yet.
|
||||
4. **apply** — host materialises ops to disk inside a transaction (staged-tmp + atomic rename per file; rollback on failure). `--dry-run` prints the plan and exits before this step.
|
||||
5. **postInstall** — plugin runs follow-up commands (e.g. `bun install`, registers entries, prints next-steps).
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Each `FileOp` declares an explicit conflict policy: `error | overwrite | skip-if-exists | merge | prompt`. The default is `error`. `--force` collapses to `overwrite`. `--no-interactive` rejects `prompt` ops.
|
||||
|
||||
### Manifest
|
||||
|
||||
Every plugin exports a `manifest.json` next to its entry. Schema (`PluginManifestSchema`) is owned by `@suaveplan/installer` and validated at load time. The manifest declares: `id`, `version`, `description`, `engines`, `peerInstallers`, `assets` (paths inside the plugin package), and the entry module.
|
||||
|
||||
## `@suaveplan/install-agent-skills`
|
||||
|
||||
The first vertical plugin. Ships every Suaveplan skill (`SKILL.md` + co-located assets) bundled inside the package under `assets/skills/<name>/`. The plugin's `plan` step:
|
||||
|
||||
1. Reads its bundled manifest (`assets/skills/manifest.json`).
|
||||
2. Filters by `--only <names>` / `--exclude <names>` if supplied; defaults to **all** skills.
|
||||
3. Detects target project's existing skill directory (`.suaveplan/skills/` by default; configurable).
|
||||
4. Emits `FileOp[]`: `mkdir` for each skill folder, `write` for each file, `patch-json` for the project's skill index file (creates if missing).
|
||||
5. `postInstall` prints the count installed and next-step guidance.
|
||||
|
||||
Bundle format (inside the published package):
|
||||
|
||||
```
|
||||
assets/skills/
|
||||
manifest.json # { skills: [{ id, version, dir, files }] }
|
||||
crypto/
|
||||
SKILL.md
|
||||
examples/...
|
||||
events/
|
||||
SKILL.md
|
||||
…
|
||||
```
|
||||
|
||||
Versioning aligns with the monorepo: one bump per release, one changelog entry covering all skill changes.
|
||||
|
||||
## What changes
|
||||
|
||||
### 1. Two new packages
|
||||
|
||||
- `packages/platform/installer/` → `@suaveplan/installer@0.0.0`
|
||||
- `packages/platform/install-agent-skills/` → `@suaveplan/install-agent-skills@0.0.0`
|
||||
|
||||
(`platform` plugin matches `@suaveplan/cli` and `@suaveplan/readline`, the other CLI-tier packages.)
|
||||
|
||||
### 2. Workspace deps
|
||||
|
||||
`@suaveplan/installer`:
|
||||
- `@suaveplan/cli`, `@suaveplan/templates`, `@suaveplan/validation`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/logger`, `@opentelemetry/api`, `zod`. All `workspace:^` for internal, `catalog:` for external.
|
||||
|
||||
`@suaveplan/install-agent-skills`:
|
||||
- `@suaveplan/installer`, `@suaveplan/error`, `@suaveplan/types`, `zod`. All `workspace:^`.
|
||||
|
||||
### 3. Subpath exports
|
||||
|
||||
`@suaveplan/installer`: `.`, `./types`, `./errors`, `./host`, `./fs`, `./manifest`, `./testing` (test doubles for `InstallerContext`).
|
||||
|
||||
`@suaveplan/install-agent-skills`: `.`, `./manifest`. The `./manifest` subpath exposes the bundled skills index for tooling that wants to enumerate without invoking the installer.
|
||||
|
||||
### 4. CLI bin
|
||||
|
||||
`@suaveplan/installer` ships a `suaveplan-install` bin (also exposed as `npx @suaveplan/installer …`):
|
||||
|
||||
```bash
|
||||
npx @suaveplan/installer add agent-skills # default: all skills
|
||||
npx @suaveplan/installer add agent-skills --only crypto,events
|
||||
npx @suaveplan/installer add agent-skills --exclude legacy
|
||||
npx @suaveplan/installer add agent-skills --dry-run
|
||||
npx @suaveplan/installer add agent-skills --answers ./answers.json
|
||||
npx @suaveplan/installer list # discovered plugins
|
||||
```
|
||||
|
||||
Plugins are discovered by package-name convention (`@suaveplan/install-*` and `@<scope>/suaveplan-install-*` for third parties). Installer resolves them through the consumer's `node_modules` — same model as `eslint-plugin-*`.
|
||||
|
||||
### 5. Error codes
|
||||
|
||||
`INSTALLER` namespace via `defineCodes("INSTALLER", [...])`:
|
||||
|
||||
- `INSTALLER.PLUGIN_NOT_FOUND`
|
||||
- `INSTALLER.PLUGIN_LOAD_FAILED`
|
||||
- `INSTALLER.PLUGIN_INVALID_MANIFEST`
|
||||
- `INSTALLER.PROMPT_VALIDATION_FAILED`
|
||||
- `INSTALLER.PLAN_FAILED`
|
||||
- `INSTALLER.APPLY_CONFLICT`
|
||||
- `INSTALLER.APPLY_FAILED`
|
||||
- `INSTALLER.ROLLBACK_FAILED`
|
||||
- `INSTALLER.ABORTED`
|
||||
|
||||
`INSTALL_SKILLS` namespace for the skills plugin:
|
||||
|
||||
- `INSTALL_SKILLS.MANIFEST_INVALID`
|
||||
- `INSTALL_SKILLS.UNKNOWN_SKILL`
|
||||
- `INSTALL_SKILLS.TARGET_DIR_INVALID`
|
||||
|
||||
### 6. Observability
|
||||
|
||||
Every host stage (`detect`, `prompts`, `plan`, `apply`, `postInstall`) is wrapped in an OTel span (`installer.<stage>`) with attributes `plugin.id`, `plugin.version`, `dry_run`. `apply` records counters for files written / patched / skipped / conflicts.
|
||||
|
||||
### 7. Testing
|
||||
|
||||
- `@suaveplan/installer/testing` exports an in-memory `InstallerFs`, a no-TTY `InstallerCli` that scripts answers, and a `runPlugin(plugin, { answers })` harness for integration-style plugin tests with zero real disk I/O.
|
||||
- `@suaveplan/install-agent-skills` uses the harness to verify the bundled manifest plans correctly under `--only` / `--exclude` filters.
|
||||
|
||||
## Out of scope (future changes)
|
||||
|
||||
- Future verticals (`install-web-server`, `install-spa`, `install-desktop-app`, `install-mobile-app`) ship as separate change-ids once the host is stable.
|
||||
- Plugin upgrade flow (`upgrade <plugin>` to bring an existing install up to current bundle version) is a follow-up — initial scope is install + reinstall (idempotent re-run with conflict policy).
|
||||
- A `create-suaveplan` thin wrapper bin that bundles installer + a curated default plugin set is a future packaging-only change; not introduced here.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `bunx turbo lint typecheck test --filter=@suaveplan/installer` and `--filter=@suaveplan/install-agent-skills` green.
|
||||
- `bunx turbo test:coverage` ≥ 100 % istanbul on touched modules in both packages.
|
||||
- Sibling `.md` for every implementation `.ts` (`openspec/AGENTS.md` §4).
|
||||
- Spec deltas folded into `openspec/specs/installer/spec.md` and `openspec/specs/install-agent-skills/spec.md` on archive.
|
||||
- `INSTALLER-01`…`INSTALLER-09` and `INSTALL_SKILLS-01`…`INSTALL_SKILLS-05` have `**Verified by:**` lines pointing to `*.spec-conformance.test.ts`.
|
||||
- End-to-end test: `runPlugin(agentSkillsPlugin, { answers: { skills: "all" } })` against an in-memory fs writes the full skill set and reports zero conflicts on a fresh target.
|
||||
Reference in New Issue
Block a user