19 KiB
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<ElectrobunCode> and carry a details: Record<string, unknown>
field for structured diagnostics.
Scenario: registry exposes the full code set
- WHEN the consumer imports
ELECTROBUN_CODESand readsObject.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", andinstanceof 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
ElectrobunErrorwithcode === "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
pathsequals 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: <tmpDir> }, { fs })runs - THEN
fshas received onewriteFilecall per file incollectFiles(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.iconsisundefined
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
ElectrobunErrorwithcode === "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 === falseANDerrorcontains 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
ElectrobunErrorwithcode === "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:ANDgovernance:
Scenario: r3f renderer emits the requested modules
- WHEN
renderFeature("r3f", { ...opts, r3fModules: ["physics","particles"] }) - THEN the output contains
@react-three/rapierANDSparkles
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<string>, writeFile(path, content): Promise<void>,
mkdir(path, opts): Promise<void>, stat(path): Promise<{ isDirectory(): boolean }>,
readdir(path): Promise<readonly string[]>. 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")thenawait 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
ElectrobunErrorwithcode === "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<CommandResult> 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:
- validate its option schema (raising
ELECTROBUN.INVALID_OPTIONSon failure); - probe the electrobun binary (raising
ELECTROBUN.BINARY_UNAVAILABLEwhen absent); - spawn the binary with the canonical argv shape (
dev→electrobun dev;build→electrobun build --target <target> [--update];package→electrobun package --target <target>;sign→electrobun sign --identity <identity> [--entitlements <path>] [--notarize]); - classify a non-zero exit as
ELECTROBUN.RUNNER_FAILEDwithdetails.command,details.exitCode, anddetails.stderr; - record one observation in
electrobun_runner_duration_msand one increment inelectrobun_runner_invocations_totalper call; - return a
*Reportcarrying{ 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
ElectrobunErrorwithcode === "ELECTROBUN.RUNNER_FAILED"is thrown ANDdetails.exitCodeis preserved
Scenario: missing binary raises BINARY_UNAVAILABLE
- WHEN
buildCommand({ cwd, target: "mac" }, { runner: missingBinaryRunner }) - THEN an
ElectrobunErrorwithcode === "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
ElectrobunErrorwithcode === "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 }ANDcapturerecorded 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
MeterProviderreports exactly one data point onelectrobun_runner_invocations_totalwithcommand="build"
Scenario: recordGenerateDuration writes to the histogram
- WHEN
recordGenerateDuration(123, 2) - THEN the in-memory
MeterProviderreports one observation of123onelectrobun_generate_duration_mswithfeatures_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"