Files
suaveplan/openspec/archive/2026-05-25-enterprise-electrobun/specs/electrobun/spec.md
2026-05-27 23:29:53 +01:00

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_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: <tmpDir> }, { 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<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") 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<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:

  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 <target> [--update]; package → electrobun package --target <target>; sign → electrobun sign --identity <identity> [--entitlements <path>] [--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"