# Capability Spec Delta: electrobun **Package**: `@suaveplan/electrobun` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: ELECTROBUN_CODES registry MUST enumerate every typed failure path The package MUST export `ELECTROBUN_CODES = defineCodes("ELECTROBUN", [...])` exposing exactly the following codes: `INVALID_OPTIONS`, `OUTPUT_NOT_EMPTY`, `CONFIG_PARSE_FAILED`, `ICON_SOURCE_MISSING`, `ICON_RENDER_FAILED`, `TEMPLATE_RENDER_FAILED`, `FS_WRITE_FAILED`, `BINARY_UNAVAILABLE`, `RUNNER_FAILED`, `PROMPT_ABORTED`, `UNKNOWN_FLAG`. Every code MUST be reachable from a call site exercised by a test in `src/`. The exported `ElectrobunError` MUST extend `SuaveplanError` and carry a `details: Record` field for structured diagnostics. #### Scenario: registry exposes the full code set - **WHEN** the consumer imports `ELECTROBUN_CODES` and reads `Object.values(ELECTROBUN_CODES)` - **THEN** the result contains exactly the eleven declared codes (no more, no less) #### Scenario: ElectrobunError carries code and details - **WHEN** `new ElectrobunError(ELECTROBUN_CODES.INVALID_OPTIONS, "bad", { details: { field: "x" } })` - **THEN** the instance has `code === "ELECTROBUN.INVALID_OPTIONS"`, `details.field === "x"`, and `instanceof SuaveplanError === true` **Verified by:** `src/error-codes/error-codes.test.ts::"registry exposes the full code set"` AND `src/error-codes/error-codes.test.ts::"ElectrobunError carries code and details"` --- ### Requirement: Public options MUST be Zod-validated at the boundary Every public entry point (`generate`, `collectFiles`, `generateIcons`, `loadConfig`, `runPrompts`, `createCommand`, `devCommand`, `buildCommand`, `packageCommand`, `signCommand`, `runCli`) MUST validate its option object against an exported Zod schema. A schema parse failure MUST surface as `ElectrobunError` with code `ELECTROBUN.INVALID_OPTIONS` and a `details.issues` array carrying the Zod issue list. #### Scenario: ProjectOptionsSchema rejects empty name - **WHEN** `ProjectOptionsSchema.safeParse({ ...validOptions, name: "" })` runs - **THEN** the parse fails with at least one issue on path `["name"]` #### Scenario: collectFiles raises INVALID_OPTIONS on bad input - **WHEN** `collectFiles({ name: "", identifier: "x", description: "y", version: "0.0.1", outputDir: "/tmp/a", features: [], networkingComplexity: "basic", r3fModules: [] })` runs - **THEN** an `ElectrobunError` with `code === "ELECTROBUN.INVALID_OPTIONS"` is thrown **Verified by:** `src/schemas/schemas.test.ts::"ProjectOptionsSchema rejects empty name"` AND `src/generator/generator.test.ts::"collectFiles raises INVALID_OPTIONS on empty name"` --- ### Requirement: collectFiles MUST return the deterministic core file list `collectFiles(options)` MUST return a `GeneratedFile[]` containing — in order — the nine core paths `electrobun.config.ts`, `package.json`, `tsconfig.json`, `src/bun/index.ts`, `src/mainview/index.html`, `src/mainview/index.css`, `src/mainview/index.tsx`, `src/shared/types.ts`, `README.md`, followed by one file per selected feature in the canonical order `i18n`, `networking`, `api`, `api-schemas`, `storage`, `auth`, `form-builder`, `r3f`. Feature ordering MUST not depend on `options.features` insertion order. #### Scenario: minimal options emit the nine core files - **WHEN** `collectFiles({ ...validOptions, features: [] })` runs - **THEN** the returned `paths` equals the nine-element core list #### Scenario: all eight features emit all feature paths - **WHEN** `collectFiles({ ...validOptions, features: ["i18n","networking","api","api-schemas","storage","auth","form-builder","r3f"], r3fModules: ["camera"] })` - **THEN** the returned paths contain `src/features/i18n.ts`, `src/features/network.ts`, `src/features/api.ts`, `src/features/schemas.ts`, `src/features/storage.ts`, `src/features/auth.ts`, `src/features/form-builder.ts`, `src/features/r3f.tsx` #### Scenario: feature order is canonical regardless of input order - **WHEN** `collectFiles({ ...validOptions, features: ["r3f","i18n","api"] })` - **THEN** the feature paths in the returned list appear in the order `src/features/i18n.ts`, `src/features/api.ts`, `src/features/r3f.tsx` **Verified by:** `src/generator/generator.test.ts::"minimal options emit the nine core files"` AND `src/generator/generator.test.ts::"all eight features emit all feature paths"` AND `src/generator/generator.test.ts::"feature order is canonical regardless of input order"` --- ### Requirement: generate MUST write files via the injectable FsAdapter `generate(options, deps?)` MUST resolve `deps.fs ?? nodeFsAdapter`, write every file from `collectFiles(options)` to `options.outputDir` via that adapter, write the `.suaveplan-electrobun.json` config snapshot, optionally run the icon pipeline when `options.iconSource` is set, and return a `GenerateReport` carrying `{ outputDir, filesCreated, durationMs, icons }` where `icons` is the icon report (or `undefined` when no icon source was supplied). The histogram `electrobun_generate_duration_ms` MUST receive one observation per call. #### Scenario: generate writes every file from collectFiles - **WHEN** `generate({ ...validOptions, outputDir: }, { fs })` runs - **THEN** `fs` has received one `writeFile` call per file in `collectFiles(options)`, plus one for `.suaveplan-electrobun.json` #### Scenario: generate skips icons when iconSource is absent - **WHEN** `generate({ ...validOptions, iconSource: undefined }, deps)` runs - **THEN** the returned `report.icons` is `undefined` **Verified by:** `src/generator/generator.test.ts::"generate writes every file from collectFiles"` AND `src/generator/generator.test.ts::"generate skips icons when iconSource is absent"` --- ### Requirement: generateIcons MUST run the icon pipeline through the IconRendererAdapter `generateIcons({ inputPath, outputDir }, deps?)` MUST resolve `deps.renderer ?? iconzRendererAdapter`, invoke `renderer.render({ input, output, icons })` with the canonical icon descriptor (icns / ico / png set), and return an `IconReport` of shape `{ generated: boolean; files: readonly string[]; error?: string }`. A missing input path MUST surface as `ELECTROBUN.ICON_SOURCE_MISSING`. A renderer throw MUST surface as `ELECTROBUN.ICON_RENDER_FAILED` with the message preserved in `details.message`. #### Scenario: missing source raises ICON_SOURCE_MISSING - **WHEN** `generateIcons({ inputPath: "/does/not/exist", outputDir: "/tmp/a" })` - **THEN** an `ElectrobunError` with `code === "ELECTROBUN.ICON_SOURCE_MISSING"` is thrown #### Scenario: renderer throw is wrapped as ICON_RENDER_FAILED - **WHEN** `generateIcons({ inputPath, outputDir }, { renderer: throwingRenderer })` - **THEN** the returned report has `generated === false` AND `error` contains the underlying message **Verified by:** `src/icons/icons.test.ts::"missing source raises ICON_SOURCE_MISSING"` AND `src/icons/icons.test.ts::"renderer throw is wrapped as ICON_RENDER_FAILED"` --- ### Requirement: loadConfig MUST stack defaults, file, and CLI layers `loadConfig(cliArgs, deps?)` MUST resolve `deps.fs ?? nodeFsAdapter`, locate `.suaveplan-electrobun.json` in `cwd` or `home` via the adapter, and build an `ElectrobunConfig` through `@suaveplan/config`'s `createConfig`/`defaultsLayer` + `fileLayer` + `cliLayer` pipeline. CLI args MUST map `--name → name`, `--id → identifier`, `--icon → iconSource`, `--version → version`. Defaults MUST fill `version="0.0.1"`, `features=[]`, `networkingComplexity="basic"`, `r3fModules=[]`. A malformed config file MUST surface as `ELECTROBUN.CONFIG_PARSE_FAILED`. #### Scenario: CLI args override file values - **WHEN** `loadConfig(["--name", "cli-app"], { fs: fsWithFile({ name: "file-app" }) })` - **THEN** the returned config has `name === "cli-app"` #### Scenario: malformed file raises CONFIG_PARSE_FAILED - **WHEN** `loadConfig([], { fs: fsWithCorruptFile() })` - **THEN** an `ElectrobunError` with `code === "ELECTROBUN.CONFIG_PARSE_FAILED"` is thrown **Verified by:** `src/config/config.test.ts::"CLI args override file values"` AND `src/config/config.test.ts::"malformed file raises CONFIG_PARSE_FAILED"` --- ### Requirement: Templates MUST render every project and feature file deterministically The `renderProjectFile(kind, options)` and `renderFeature(feature, options)` functions MUST cover the full upstream set: `electrobun-config`, `main-process`, `package-json`, `tsconfig`, `readme`, `mainview-html`, `mainview-css`, `mainview-entry`, `shared-types` (project) and `i18n`, `network`, `network-full`, `api`, `api-schemas`, `storage`, `auth`, `form-builder`, `r3f` (feature). For a fixed `ProjectOptions` input, each renderer MUST produce a byte-identical output across runs. #### Scenario: renderProjectFile output is byte-stable - **WHEN** `renderProjectFile("package-json", validOptions)` runs twice - **THEN** both invocations return identical strings #### Scenario: networking feature complexity selects the correct renderer - **WHEN** `renderFeature("networking", { ...opts, networkingComplexity: "full" })` - **THEN** the output contains `state:` AND `governance:` #### Scenario: r3f renderer emits the requested modules - **WHEN** `renderFeature("r3f", { ...opts, r3fModules: ["physics","particles"] })` - **THEN** the output contains `@react-three/rapier` AND `Sparkles` **Verified by:** `src/templates/templates.test.ts::"renderProjectFile output is byte-stable"` AND `src/templates/features.test.ts::"networking feature complexity selects the correct renderer"` AND `src/templates/features.test.ts::"r3f renderer emits the requested modules"` --- ### Requirement: FsAdapter MUST expose every operation the generator needs The exported `FsAdapter` contract MUST expose at minimum `readFile(path): Promise`, `writeFile(path, content): Promise`, `mkdir(path, opts): Promise`, `stat(path): Promise<{ isDirectory(): boolean }>`, `readdir(path): Promise`. The `nodeFsAdapter` default MUST be backed by `node:fs/promises` and MUST round-trip a real file under a real `mkdtemp` directory. A `writeFile` failure MUST surface as `ELECTROBUN.FS_WRITE_FAILED`. #### Scenario: nodeFsAdapter round-trips a file - **WHEN** `await fs.writeFile(p, "x")` then `await fs.readFile(p)` - **THEN** the read returns `"x"` #### Scenario: writeFiles surfaces FS_WRITE_FAILED on adapter throw - **WHEN** `writeFiles(outputDir, files, { fs: throwingFs })` runs - **THEN** an `ElectrobunError` with `code === "ELECTROBUN.FS_WRITE_FAILED"` is thrown **Verified by:** `src/fs/fs.test.ts::"nodeFsAdapter round-trips a file"` AND `src/fs/fs.test.ts::"writeFiles surfaces FS_WRITE_FAILED on adapter throw"` --- ### Requirement: CommandRunner MUST shape every electrobun shell-out The exported `CommandRunner` contract MUST be `(input: CommandInput) => Promise` where `CommandInput` carries `{ command, args, cwd?, env?, signal? }` and `CommandResult` carries `{ exitCode, stdout, stderr, durationMs }`. `probeElectrobun(runner)` MUST invoke the runner with `command="bunx"` and `args=["electrobun","--version"]` and return `{ available: boolean; version?: string }`. A non-zero exit MUST classify as `available === false`. The `nodeCommandRunner` default MUST spawn via `node:child_process.spawn` and resolve stdout / stderr as UTF-8 strings. #### Scenario: probeElectrobun returns available with version on success - **WHEN** `probeElectrobun(runnerReturning({ exitCode: 0, stdout: "1.2.3\n" }))` - **THEN** the result is `{ available: true, version: "1.2.3" }` #### Scenario: probeElectrobun returns unavailable on non-zero exit - **WHEN** `probeElectrobun(runnerReturning({ exitCode: 127, stdout: "" }))` - **THEN** the result is `{ available: false }` **Verified by:** `src/runner/runner.test.ts::"probeElectrobun returns available with version on success"` AND `src/runner/runner.test.ts::"probeElectrobun returns unavailable on non-zero exit"` --- ### Requirement: dev / build / package / sign commands MUST orchestrate the binary Each of `devCommand`, `buildCommand`, `packageCommand`, `signCommand` MUST: 1. validate its option schema (raising `ELECTROBUN.INVALID_OPTIONS` on failure); 2. probe the electrobun binary (raising `ELECTROBUN.BINARY_UNAVAILABLE` when absent); 3. spawn the binary with the canonical argv shape (`dev` → `electrobun dev`; `build` → `electrobun build --target [--update]`; `package` → `electrobun package --target `; `sign` → `electrobun sign --identity [--entitlements ] [--notarize]`); 4. classify a non-zero exit as `ELECTROBUN.RUNNER_FAILED` with `details.command`, `details.exitCode`, and `details.stderr`; 5. record one observation in `electrobun_runner_duration_ms` and one increment in `electrobun_runner_invocations_total` per call; 6. return a `*Report` carrying `{ command, exitCode, durationMs, stdout, stderr }`. #### Scenario: buildCommand passes --update when options.update is true - **WHEN** `buildCommand({ cwd, target: "mac", update: true }, { runner: recordingRunner })` - **THEN** the recorded args include `"--update"` #### Scenario: signCommand forwards identity, entitlements, and notarize - **WHEN** `signCommand({ cwd, identity: "Dev ID", entitlements: "/e.plist", notarize: true }, deps)` - **THEN** the recorded args contain `"--identity"`, `"Dev ID"`, `"--entitlements"`, `"/e.plist"`, `"--notarize"` #### Scenario: non-zero exit raises RUNNER_FAILED - **WHEN** `devCommand({ cwd }, { runner: failingRunner })` runs - **THEN** an `ElectrobunError` with `code === "ELECTROBUN.RUNNER_FAILED"` is thrown AND `details.exitCode` is preserved #### Scenario: missing binary raises BINARY_UNAVAILABLE - **WHEN** `buildCommand({ cwd, target: "mac" }, { runner: missingBinaryRunner })` - **THEN** an `ElectrobunError` with `code === "ELECTROBUN.BINARY_UNAVAILABLE"` is thrown **Verified by:** `src/cli/cli.test.ts::"buildCommand passes --update when options.update is true"` AND `src/cli/cli.test.ts::"signCommand forwards identity, entitlements, and notarize"` AND `src/cli/cli.test.ts::"non-zero exit raises RUNNER_FAILED"` AND `src/cli/cli.test.ts::"missing binary raises BINARY_UNAVAILABLE"` --- ### Requirement: runPrompts MUST drive the interactive scaffolder via the injectable Prompter `runPrompts(config, outputDir, prompter?)` MUST prompt for `name`, `identifier`, `description`, `version`, `iconSource`, `features`, `networkingComplexity` (only when `features` includes `"networking"`), `r3fModules` (only when `features` includes `"r3f"`), and a `proceed` confirmation. A negative confirmation MUST surface as `ELECTROBUN.PROMPT_ABORTED`. The function MUST default `networkingComplexity` to `"basic"` and `r3fModules` to `[]` when the gating feature is not selected. #### Scenario: runPrompts assembles a complete ProjectOptions - **WHEN** `runPrompts(emptyConfig, undefined, scriptedPrompter)` runs - **THEN** the returned object satisfies `ProjectOptionsSchema.safeParse(result).success === true` #### Scenario: negative confirmation aborts the prompt - **WHEN** `runPrompts(emptyConfig, undefined, scriptedPrompter({ proceed: false }))` - **THEN** an `ElectrobunError` with `code === "ELECTROBUN.PROMPT_ABORTED"` is thrown **Verified by:** `src/prompts/prompts.test.ts::"runPrompts assembles a complete ProjectOptions"` AND `src/prompts/prompts.test.ts::"negative confirmation aborts the prompt"` --- ### Requirement: runCli MUST dispatch every subcommand and never call process.exit `runCli(argv, opts?)` MUST recognise the subcommands `create`, `dev`, `build`, `package`, `sign`, and `--help` / `-h`. Unknown subcommands or unknown flags MUST raise `ELECTROBUN.UNKNOWN_FLAG`. The function MUST return `{ code: 0 }` on success and `{ code: 1 }` on a handled `ElectrobunError`. No code path inside `runCli` MUST call `process.exit`; only the binary entry MUST own the exit. #### Scenario: --help returns code 0 - **WHEN** `runCli(["--help"], { out: capture })` runs - **THEN** the result is `{ code: 0 }` AND `capture` recorded a help string #### Scenario: unknown subcommand raises UNKNOWN_FLAG - **WHEN** `runCli(["zzz"])` runs - **THEN** the result is `{ code: 1 }` AND the captured stderr contains `"ELECTROBUN.UNKNOWN_FLAG"` **Verified by:** `src/cli/cli.test.ts::"--help returns code 0"` AND `src/cli/cli.test.ts::"unknown subcommand raises UNKNOWN_FLAG"` --- ### Requirement: Telemetry MUST emit the documented OTel surface The package MUST expose `electrobun_projects_generated_total`, `electrobun_icons_generated_total`, `electrobun_runner_invocations_total`, `electrobun_runner_failures_total`, `electrobun_prompts_total` counters and `electrobun_generate_duration_ms`, `electrobun_runner_duration_ms` histograms, each on the `@suaveplan/electrobun` meter, and MUST provide `recordProject`, `recordIcons`, `recordRunnerInvocation`, `recordRunnerFailure`, `recordPromptOutcome`, `recordGenerateDuration`, `recordRunnerDuration` helpers that increment / observe with the documented label keys. #### Scenario: recordRunnerInvocation increments the counter once per call - **WHEN** `recordRunnerInvocation("build")` - **THEN** the in-memory `MeterProvider` reports exactly one data point on `electrobun_runner_invocations_total` with `command="build"` #### Scenario: recordGenerateDuration writes to the histogram - **WHEN** `recordGenerateDuration(123, 2)` - **THEN** the in-memory `MeterProvider` reports one observation of `123` on `electrobun_generate_duration_ms` with `features_count="2"` **Verified by:** `src/telemetry/telemetry.test.ts::"recordRunnerInvocation increments the counter once per call"` AND `src/telemetry/telemetry.test.ts::"recordGenerateDuration writes to the histogram"` --- ### Requirement: Package MUST be server-tier with per-feature subpath exports The `package.json` MUST declare `"type": "module"`, `"sideEffects": false`, `bin: { "suaveplan-electrobun": "./dist/bin/suaveplan-electrobun.js" }`, and subpath exports for `.`, `./error-codes`, `./telemetry`, `./types`, `./schemas`, `./fs`, `./runner`, `./config`, `./templates`, `./icons`, `./generator`, `./prompts`, `./cli`. Internal deps MUST use `workspace:^`; external deps MUST use `catalog:*` references (`zod` → `forms`, `@opentelemetry/api` → `observability`, `tslib` → `build`, `iconz` → `cli`). No bare version pins. #### Scenario: package.json declares the documented subpath exports - **WHEN** the consumer reads `Object.keys(pkg.exports).filter(k => k.startsWith("./") && k !== "./package.json")` - **THEN** the result contains all thirteen documented subpaths #### Scenario: bin entry resolves to the published binary - **WHEN** the consumer reads `pkg.bin["suaveplan-electrobun"]` - **THEN** the value is `"./dist/bin/suaveplan-electrobun.js"` **Verified by:** `tests/package-shape.test.ts::"package.json declares the documented subpath exports"` AND `tests/package-shape.test.ts::"bin entry resolves to the published binary"`