first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
# Binary entry — `suaveplan-electrobun.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single-file binary that wires `process.argv` into the pure `runCli`
dispatcher and translates the resolved exit code into a real
`process.exit` call. All business logic lives in `src/cli/cli.ts`; this
file is the only place that talks to the process boundary, which makes
the rest of the package fully unit-testable.
The binary registers the `@suaveplan/cli` prompt primitives as the
`Prompter` so the interactive `create` flow works against a real TTY
when run by hand. Tests that exercise the CLI never load this file —
they call `runCli` directly with a scripted prompter and a recording
runner.
## Features
- One-line wiring from `process.argv` to `runCli`.
- Real TTY prompts via `@suaveplan/cli/prompt/prompt`.
- Exit code translation: `runCli` resolves `{ code }`, the binary calls
`process.exit(code)`.
- Unexpected exceptions print to stderr and exit with `code: 2`.
## Usage: Basic
```bash
suaveplan-electrobun create my-app --skip-prompts --name my-app
suaveplan-electrobun dev --cwd ./my-app
suaveplan-electrobun build --cwd ./my-app --target mac --update
suaveplan-electrobun package --cwd ./my-app --target mac
suaveplan-electrobun sign --cwd ./my-app --identity "Dev ID Application: …"
```
## Usage: Advanced
```bash
# Use the layered config file at $HOME/.suaveplan-electrobun.json
# combined with project-level overrides
suaveplan-electrobun create --name override-app --skip-prompts ./out
```
## API Reference
- The binary takes no programmatic API. It accepts the argv shape
documented in `src/cli/cli.ts::HELP_TEXT`.
## Implementation Notes
The binary is excluded from the coverage thresholds in
`vitest.config.ts` because its only logic is `runCli(...).then(...,
...)` — the unit tests for `runCli` cover every reachable branch, and
the only paths that are unique to the binary are the `process.exit`
calls themselves. Excluding the file from coverage keeps the gate
honest without forcing a contrived "spawn a subprocess and assert on
exit code" test that would not catch anything the unit tests miss.
The binary uses `process.argv.slice(2)` rather than `process.argv` so
the node executable path and script path are stripped before the
parser sees them. The dispatcher's parser treats every remaining token
as a user-supplied flag, subcommand, or positional argument.

View File

@@ -0,0 +1,100 @@
# CLI — `cli.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the five command surfaces — `createCommand`, `devCommand`,
`buildCommand`, `packageCommand`, `signCommand` — plus the
`runCli(argv, deps?)` dispatcher that the published binary delegates to.
Every command is a pure function: it validates its option struct against
the matching Zod schema, runs the orchestrated work, and returns a typed
report. `runCli` never calls `process.exit`; the only exit-call lives in
`src/bin/suaveplan-electrobun.ts`, so the entire CLI is unit-testable
without a child process.
`createCommand` is the scaffolder entry: it loads the layered config,
applies CLI overrides, optionally runs the prompt flow, then hands off
to `generate`. The other four commands are runner-only — they probe the
upstream `electrobun` binary, raise `ELECTROBUN.BINARY_UNAVAILABLE` when
missing, spawn the binary with the canonical argv, and return a
`CommandReport` carrying exit code, stdout, stderr, and duration.
## Features
- Five command functions, each with Zod-validated options and structured
reports.
- Pure `runCli(argv)` dispatcher with `--help` / `-h` short-circuit and
closed-set flag parsing.
- Long-flag `--key=value` and `--key value` forms supported uniformly.
- Unknown flags / subcommands raise `ELECTROBUN.UNKNOWN_FLAG`.
- Missing required values raise `ELECTROBUN.INVALID_OPTIONS`.
- `timeRunner` wrapper increments invocation + duration histograms per
call and increments failure counters on `ElectrobunError` paths.
## Usage: Basic
```ts
import { runCli } from "@suaveplan/electrobun/cli";
const { code } = await runCli(process.argv.slice(2));
process.exit(code);
```
## Usage: Advanced
```ts
import { buildCommand } from "@suaveplan/electrobun/cli";
import type { CommandRunner } from "@suaveplan/electrobun/types";
const customRunner: CommandRunner = async (input) => {
// ship the spawn to a remote CI worker
return await postToWorker(input);
};
const report = await buildCommand(
{ cwd: "/proj", target: "linux", update: true },
{ runner: customRunner }
);
```
## API Reference
- `createCommand(opts: CreateCommandOptions, deps: CommandDeps & {
prompter: Prompter }): Promise<GenerateReport>`.
- `devCommand(opts: DevCommandOptions, deps?: { runner? }):
Promise<CommandReport>`.
- `buildCommand(opts: BuildCommandOptions, deps?: { runner? }):
Promise<CommandReport>`.
- `packageCommand(opts: PackageCommandOptions, deps?: { runner? }):
Promise<CommandReport>`.
- `signCommand(opts: SignCommandOptions, deps?: { runner? }):
Promise<CommandReport>`.
- `runCli(argv: readonly string[], deps?: RunCliOptions): Promise<{
code: number }>`.
- `HELP_TEXT: string` — the full help banner.
## Implementation Notes
The arg parser uses a closed `ALL_KNOWN_FLAGS` set. New flags must be
added to the set before the parser will accept them; an unknown flag
fails the run with `ELECTROBUN.UNKNOWN_FLAG`, which keeps subtle typos
from being silently swallowed. The `--help` and `-h` short forms are
intercepted before subcommand dispatch so they work uniformly across
every subcommand.
`runCli` catches every `ElectrobunError` and folds it into `code: 1`
with a one-line stderr message. Any other thrown value re-propagates,
on the principle that the runtime should not eat unexpected exceptions
— if something other than a typed error escapes the call, the bin
wrapper surfaces it with `code: 2`.
The `timeRunner` helper increments `electrobun_runner_invocations_total`
on entry, records the duration histogram on every exit (success or
failure), and records `electrobun_runner_failures_total` when the thrown
`ElectrobunError` carries a numeric `details.exitCode`. The failure
counter labels the exit code with `-1` when no numeric exit-code is
available — for instance when the probe stage raises
`BINARY_UNAVAILABLE` — so dashboards can still attribute the failure
to the right command without inventing fake exit codes.

View File

@@ -0,0 +1,82 @@
# Config loader — `config.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`loadConfig(cliArgs, deps?)` builds an `ElectrobunConfig` by layering
hard-coded defaults, an optional on-disk `.suaveplan-electrobun.json`,
and CLI argument overrides on top of one another. The loader sits on
`@suaveplan/config`'s five-layer pipeline (defaults / file / object /
env / CLI), wiring three of the five for the scaffolder's needs.
The `--name`, `--id`, `--icon`, and `--version` CLI flags map to the
`name`, `identifier`, `iconSource`, and `version` fields respectively.
Defaults fill `version="0.0.1"`, `features=[]`,
`networkingComplexity="basic"`, and `r3fModules=[]` so callers can rely
on the final shape having those fields populated even when no file or
flag supplies them.
The `ConfigDiscoveryAdapter` seam (defined in `src/fs/fs.ts`) controls
where the loader looks for the config file. Tests inject an in-memory
adapter; production code uses the default `nodeConfigDiscoveryAdapter`
that probes `process.cwd()` then `homedir()` via real `node:fs` calls.
## Features
- Three-layer build: defaults → file → CLI.
- Synchronous (no async lift required by callers).
- Wraps malformed JSON as `ELECTROBUN.CONFIG_PARSE_FAILED`.
- Pluggable file discovery through `ConfigDiscoveryAdapter`.
- Exported `CLI_MAPPING` constant so consumers can introspect the flag
set (used by the `--help` text builder).
## Usage: Basic
```ts
import { loadConfig } from "@suaveplan/electrobun/config";
const cfg = loadConfig(process.argv.slice(2));
console.log(cfg.name ?? "(no name set)");
```
## Usage: Advanced
```ts
import { loadConfig } from "@suaveplan/electrobun/config";
import type { ConfigDiscoveryAdapter } from "@suaveplan/electrobun/fs";
const inMemory: ConfigDiscoveryAdapter = {
cwd: () => "/proj",
homedir: () => "/home/user",
exists: (p) => p === "/proj/.suaveplan-electrobun.json",
readFile: () => JSON.stringify({ name: "ci-app", features: ["api"] }),
};
const cfg = loadConfig(["--icon", "/icons/app.png"], {
discovery: inMemory,
});
// cfg.name === "ci-app"; cfg.iconSource === "/icons/app.png"
```
## API Reference
- `loadConfig(cliArgs: readonly string[] = [], deps?: { discovery?:
ConfigDiscoveryAdapter }): ElectrobunConfig`.
- `HARDCODED_DEFAULTS: ElectrobunConfig` — frozen defaults applied at
the bottom of the layer stack.
- `CLI_MAPPING: Record<string, string>` — exact flag-to-field mapping
used by the CLI layer.
## Implementation Notes
`@suaveplan/config`'s `cliLayer` takes mapping keys without the leading
`--`; the constants here mirror that contract verbatim. `coerceValue`
inside the cli layer parses booleans, numbers, and JSON literals, so a
caller can pass `--features '["api","i18n"]'` and have the array land
on the right field without bespoke wiring.
The file layer is skipped entirely when `findConfigFile` returns
`undefined`. This keeps the layer stack short for the common "no config
file" case and avoids a no-op fileLayer reload subscription.

View File

@@ -0,0 +1,93 @@
# Error codes — `error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the stable `ELECTROBUN.*` error-code namespace and the
`ElectrobunError` class that every public entry point throws on failure.
The registry is produced by `@suaveplan/error`'s `defineCodes("ELECTROBUN",
[...])` so each code is a plain `ELECTROBUN.<REASON>` string that survives
serialisation across worker / RPC / log-shipping boundaries — consumers
discriminate on `error.code` instead of `instanceof`, which means
incidents land in a single switch statement regardless of where the
failure originated.
The eleven codes cover every observable failure path in the package:
boundary validation, output-directory collision, malformed config files,
missing or unrenderable icon sources, template renderer crashes, fs
write failures, an unavailable `electrobun` binary, a non-zero exit from
the orchestrated runner, a user-aborted prompt, and an unknown CLI flag
or subcommand. Each code is paired with at least one call site exercised
by the package's tests; the conformance gate fails the build if
any code becomes unreachable.
## Features
- `ELECTROBUN_CODES` frozen-string registry of eleven codes.
- `ElectrobunError` extending `SuaveplanError<ElectrobunCode>`.
- `details: Record<string, unknown>` payload preserved through
`SuaveplanError.context`.
- `cause` chaining for native error wrappers without losing the original
stack.
## Usage: Basic
```ts
import { ELECTROBUN_CODES, ElectrobunError } from "@suaveplan/electrobun/error-codes";
throw new ElectrobunError(
ELECTROBUN_CODES.INVALID_OPTIONS,
"name must be a non-empty string"
);
```
## Usage: Advanced
```ts
import { ELECTROBUN_CODES, ElectrobunError } from "@suaveplan/electrobun/error-codes";
try {
await runSomething();
} catch (cause) {
throw new ElectrobunError(
ELECTROBUN_CODES.RUNNER_FAILED,
"electrobun build exited 2",
{
cause,
details: {
command: "build",
exitCode: 2,
stderr: "missing entitlements",
},
}
);
}
```
## API Reference
- `ELECTROBUN_CODES: Readonly<{...}>` — frozen registry of code strings.
Every value is `"ELECTROBUN." + <REASON>`.
- `ElectrobunCode = typeof ELECTROBUN_CODES[keyof typeof
ELECTROBUN_CODES]` — the literal-union code type.
- `class ElectrobunError extends SuaveplanError<ElectrobunCode>` with
fields `code`, `message`, `details`, and `cause`.
- `ElectrobunErrorOptions = { cause?: unknown; details?: Record<string,
unknown> }`.
## Implementation Notes
The constructor forwards `details` through `SuaveplanError.context` so the
serialised wire format (the `error.toJSON()` shape produced by
`@suaveplan/error`) carries the diagnostic payload alongside the code.
Consumers reading the wire format pull `code` and `context` and never
need to deserialise an `Error` subclass.
`cause` is only set when the caller supplies it — passing `undefined`
elides the field rather than leaving an explicit `cause: undefined` that
would defeat downstream cause-chain walkers. The same pattern is used
across `@suaveplan/linter` and `@suaveplan/installer`; keeping the shape
identical reduces the cognitive load of moving between packages.

View File

@@ -0,0 +1,104 @@
# Filesystem adapters — `fs.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the `FsAdapter` seam and the synchronous `ConfigDiscoveryAdapter`
used by the layered-config loader. Every IO-touching helper in the
package routes through one of these contracts so tests can stand in a
fake adapter without resorting to `vi.mock`, and so production code can
swap in a real-fs adapter wired through a test container or a mounted
overlay when the situation calls for it.
The default `nodeFsAdapter` round-trips strings as UTF-8 through
`node:fs/promises`. The default `nodeConfigDiscoveryAdapter` uses
`node:fs`'s sync `existsSync` / `readFileSync` plus the `node:os`
`homedir` and `process.cwd()` calls — sync paths are limited to the
single one-off discovery pass during `loadConfig`, and every other write
path is async through the adapter seam.
## Features
- `nodeFsAdapter` — full `FsAdapter` implementation backed by
`node:fs/promises`.
- `ensureDirectory` / `directoryExists` / `directoryIsEmpty` — defensive
directory helpers that never throw on a missing parent.
- `writeFiles(outputDir, files, fs?)` — writes a generated file list,
creating parents along the way; wraps adapter throws as
`ELECTROBUN.FS_WRITE_FAILED`.
- `nodeConfigDiscoveryAdapter` + `findConfigFile(discovery?)` — synchronous
config-file discovery in `cwd` then `homedir`; wraps malformed JSON or
read failures as `ELECTROBUN.CONFIG_PARSE_FAILED`.
## Usage: Basic
```ts
import {
ensureDirectory,
nodeFsAdapter,
writeFiles,
} from "@suaveplan/electrobun/fs";
await ensureDirectory("/tmp/my-app");
await writeFiles(
"/tmp/my-app",
[{ path: "README.md", content: "# hi" }],
nodeFsAdapter
);
```
## Usage: Advanced
```ts
import type { FsAdapter } from "@suaveplan/electrobun/types";
import { writeFiles } from "@suaveplan/electrobun/fs";
const recording: Array<{ path: string; content: string }> = [];
const recorder: FsAdapter = {
readFile: async () => "",
writeFile: async (path, content) => {
recording.push({ path, content });
},
mkdir: async () => undefined,
stat: async () => ({ isDirectory: () => true }),
readdir: async () => [],
};
await writeFiles("/out", [{ path: "a.txt", content: "x" }], recorder);
// recording carries the spawn-equivalent log of every write
```
## API Reference
- `FsAdapter` — `readFile`, `writeFile`, `mkdir({ recursive })`, `stat`,
`readdir`.
- `nodeFsAdapter: FsAdapter`.
- `ensureDirectory(dir, fs?): Promise<void>`.
- `directoryExists(dir, fs?): Promise<boolean>`.
- `directoryIsEmpty(dir, fs?): Promise<boolean>` — `true` for missing
dirs as well.
- `writeFiles(outputDir, files, fs?): Promise<void>` — throws
`ElectrobunError(ELECTROBUN.FS_WRITE_FAILED)` on adapter failure.
- `ConfigDiscoveryAdapter` — `exists`, `readFile`, `cwd`, `homedir`.
- `nodeConfigDiscoveryAdapter: ConfigDiscoveryAdapter`.
- `findConfigFile(discovery?): ElectrobunConfig | undefined` — throws
`ElectrobunError(ELECTROBUN.CONFIG_PARSE_FAILED)` on malformed JSON or
unreadable file.
- `CONFIG_FILENAME = ".suaveplan-electrobun.json"`.
## Implementation Notes
`directoryIsEmpty` deliberately returns `true` for a missing directory
because the caller's intent is "can we scaffold into this path?" — a
non-existent path is just as scaffold-friendly as an empty one. This
mirrors the upstream behaviour and keeps the `create` command from
spuriously refusing to scaffold into a fresh directory.
`findConfigFile` is the only synchronous path in the package because it
runs once before the asynchronous CLI command dispatch; making it async
would force the entire CLI shim to be async without a benefit, and would
move the test boundary deeper into the call chain. The
`ConfigDiscoveryAdapter` seam keeps the test surface clean while
preserving the synchronous default.

View File

@@ -0,0 +1,96 @@
# Generator — `generator.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the two-call surface that scaffolds an Electrobun project on disk.
`collectFiles(options, overrides?)` is pure — it returns the
`GeneratedFile[]` list for a given `ProjectOptions` without touching the
filesystem. `generate(options, deps?)` writes the file list through the
`FsAdapter` seam, snapshots the `.suaveplan-electrobun.json` config, and
optionally runs the icon pipeline when `options.iconSource` is set.
The generator is the only place that knows the canonical file order: the
nine core files come first (`electrobun.config.ts`, `package.json`,
`tsconfig.json`, the main-process Bun entry, three mainview files, the
shared types module, the README), then one file per selected feature in
the `FEATURE_ORDER` (`i18n`, `networking`, `api`, `api-schemas`,
`storage`, `auth`, `form-builder`, `r3f`). Input order does not matter
— the iterator walks `FEATURE_ORDER` and includes the feature only when
it is present in the input set.
The `overrides` argument lets tests substitute a faulty renderer to
exercise the `ELECTROBUN.TEMPLATE_RENDER_FAILED` wrapper without forcing
the production renderer table to throw. Production callers never set
overrides.
## Features
- Deterministic file ordering across runs.
- Zod-validated input via `ProjectOptionsSchema`.
- Wraps every renderer throw as `ELECTROBUN.TEMPLATE_RENDER_FAILED`.
- Optional icon pipeline gated by `options.iconSource`.
- OTel: `recordProject(featuresCount)` increment +
`recordGenerateDuration(ms, featuresCount)` observation per call.
- `CollectOverrides` seam for test-only renderer substitution.
## Usage: Basic
```ts
import { generate } from "@suaveplan/electrobun/generator";
const report = await generate({
name: "my-app",
identifier: "com.example.my-app",
description: "demo",
version: "0.1.0",
outputDir: "/tmp/my-app",
features: ["i18n", "api"],
networkingComplexity: "basic",
r3fModules: [],
});
console.log(`Wrote ${report.filesCreated.length} files in ${report.durationMs}ms`);
```
## Usage: Advanced
```ts
import { collectFiles } from "@suaveplan/electrobun/generator";
import type { FsAdapter } from "@suaveplan/electrobun/types";
import { writeFiles } from "@suaveplan/electrobun/fs";
const fs: FsAdapter = /* in-memory adapter */;
const files = collectFiles(opts);
await writeFiles(opts.outputDir, files, fs);
```
## API Reference
- `collectFiles(options: ProjectOptions, overrides?: CollectOverrides):
GeneratedFile[]`.
- `generate(options: ProjectOptions, deps?: GenerateDeps):
Promise<GenerateReport>`.
- `GenerateDeps = { fs?, renderer?, fileExists?, now?, overrides? }`.
- `CollectOverrides = { renderProjectFile?, renderFeatureFile? }` — the
test seam.
## Implementation Notes
`collectFiles` validates with Zod first, then iterates the file list.
Each renderer is wrapped in a try/catch that converts native errors into
`ElectrobunError(ELECTROBUN.TEMPLATE_RENDER_FAILED)` carrying the
faulty path (or feature name) in `details`. The wrap is essential
because a renderer failure surfaces as a string-typed crash at the call
site that consumed `collectFiles`; the typed code lets the caller branch
on "template bug" vs "Zod bug" vs "fs bug" without parsing messages.
The icon-pipeline integration is gated by a truthy
`parsed.iconSource` rather than `!= undefined`, so an empty-string
`iconSource` (a common prompt default) skips icon generation entirely.
This matches the upstream behaviour: the prompt layer collapses an
empty answer to `undefined` before passing it down, but the generator
defends against the empty-string case too in case a config file
materialises one.

View File

@@ -0,0 +1,91 @@
# Icon pipeline — `icons.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps the upstream `iconz` library behind an `IconRendererAdapter` seam
so the generator can run the icon-generation pipeline with a real
renderer in production and with a deterministic fake in tests. The
adapter contract is intentionally narrow — `render(input)` takes a
single descriptor and returns a `{ files }` payload — so swapping in a
different implementation (for instance, a server-side image-magick
worker) costs one struct.
`generateIcons({ inputPath, outputDir }, deps?)` validates its input
against the Zod schema, probes for the source file's existence, calls
the renderer, and returns an `IconReport` carrying either the produced
file list or the error message that the renderer threw. The function
never throws on a renderer failure — it returns the report with
`generated: false` and an `error` string so callers can surface the
warning without aborting the rest of the scaffold. A missing source
file IS a hard failure (raises `ELECTROBUN.ICON_SOURCE_MISSING`)
because the caller's intent was specifically "generate icons from this
path", not "skip icons silently."
## Features
- `iconzRendererAdapter` default backed by lazy `iconz` import.
- Boundary validation against `GenerateIconsOptionsSchema`.
- Hard `ELECTROBUN.ICON_SOURCE_MISSING` on a missing source path.
- Soft `IconReport.error` wrapping for renderer throws.
- Canonical icon descriptor (icns 16/32/.../1024, ico 16/.../256, png
16/.../1024) — pinned across runs.
- OTel `recordIcons("generated"|"skipped"|"failed")` increments per
outcome.
## Usage: Basic
```ts
import { generateIcons } from "@suaveplan/electrobun/icons";
const report = await generateIcons({
inputPath: "./assets/source.png",
outputDir: "./out/my-app",
});
if (report.error) {
console.warn(report.error);
}
```
## Usage: Advanced
```ts
import type { IconRendererAdapter } from "@suaveplan/electrobun/types";
import { generateIcons } from "@suaveplan/electrobun/icons";
const recording: IconRendererAdapter = {
async render(input) {
console.log("would emit:", input.icons);
return { files: ["app.icns", "app.ico"] };
},
};
await generateIcons(
{ inputPath: "./a.png", outputDir: "./out" },
{ renderer: recording, fileExists: () => true }
);
```
## API Reference
- `generateIcons(options, deps?): Promise<IconReport>`.
- `GenerateIconsOptions = { inputPath, outputDir }`.
- `GenerateIconsDeps = { renderer?, fileExists? }`.
- `iconzRendererAdapter: IconRendererAdapter` — default `iconz`-backed
renderer.
## Implementation Notes
The renderer is loaded via dynamic `import("iconz")` rather than a
top-level static import so the cost is paid only on the first real
icon-render call. Test environments that always inject a fake adapter
never trigger the import, which keeps the test suite fast and free of
native-dependency side effects.
The `IconReport.files` array is built from the renderer's reply rather
than from a hard-coded list so an adapter that produces a different
file set (for example, a stripped-down adapter for CI) reports honestly.
The recorder fake in `icons.test.ts` returns a minimal list to keep the
assertions tight.

View File

@@ -0,0 +1,96 @@
# Prompts — `prompts.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the interactive scaffolder that backs the CLI's `create` command.
`runPrompts(config, outputDir, prompter)` walks the user through nine
questions (name, identifier, description, version, icon source,
features, networking complexity when networking is selected, R3F modules
when R3F is selected, and a final confirm) and returns a fully-populated
`ProjectOptions` ready to feed the generator.
Every interaction flows through the injectable `Prompter` contract; tests
drive the function with a scripted prompter that returns canned answers,
production consumes the `@suaveplan/cli` prompt façade. The
`Prompter.text`, `confirm`, `select<T>`, and `multiselect<T>` methods are
the four primitives every prompt rests on — keeping the surface narrow
means swapping in a non-TTY prompter (for example a GUI front-end)
costs four methods.
A negative `proceed` answer raises `ELECTROBUN.PROMPT_ABORTED` and bumps
the `electrobun_prompts_total{outcome="aborted"}` counter; a completed
run bumps the `outcome="completed"` slot.
## Features
- Closed `Prompter` contract for swapping front-ends.
- Defaults pulled from the supplied `ElectrobunConfig` (lifted from
`loadConfig`).
- `networkingComplexity` and `r3fModules` are conditional on the
matching feature being selected.
- `deriveIdentifier` falls back to `com.example.<slug>` when the user
declines to set an explicit identifier.
- OTel `recordPromptOutcome` per call.
## Usage: Basic
```ts
import { runPrompts } from "@suaveplan/electrobun/prompts";
import { confirm, multiselect, select, text } from "@suaveplan/cli/prompt/prompt";
import type { Prompter } from "@suaveplan/electrobun/types";
const cliPrompter: Prompter = {
text,
confirm,
select,
multiselect,
};
const opts = await runPrompts({}, undefined, cliPrompter);
```
## Usage: Advanced
```ts
import type { Prompter } from "@suaveplan/electrobun/types";
const recorded: string[] = [];
const dryPrompter: Prompter = {
async text({ message, default: d }) {
recorded.push(message);
return d ?? "";
},
async confirm({ default: d }) {
return d ?? true;
},
async select({ choices, default: d }) {
return (d ?? choices[0]!.value) as never;
},
async multiselect() {
return [] as never;
},
};
await runPrompts(cfg, "/tmp/x", dryPrompter);
```
## API Reference
- `runPrompts(config: ElectrobunConfig, outputDir: string | undefined,
prompter: Prompter): Promise<ProjectOptions>`.
## Implementation Notes
The feature-list and R3F-module choices are derived from
`FEATURE_LABELS` / `R3F_MODULE_LABELS` so the human-readable text stays
in lockstep with the closed `FeatureModule` / `R3fModule` union types.
A new feature joining the union appears automatically in the prompt
choices once the labels constant is updated.
`iconSource` is collapsed from an empty string to `undefined` before
being returned. This matches the generator's expectation that an
absent icon source means "skip the icon pipeline entirely"; without the
collapse, the generator would call into the icon module with an empty
path and then have to re-handle the empty case.

View File

@@ -0,0 +1,97 @@
# Command runner — `runner.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the `CommandRunner` seam that every electrobun shell-out flows
through, plus the `probeElectrobun` and `invokeElectrobun` helpers that
the four orchestration subcommands use. The seam exists so tests can
substitute a recording fake without `vi.mock`, and so consumers running
under sandboxed environments can plug in their own spawn primitive
(for example a remote-runner that ships the spawn off to a CI worker).
The default `nodeCommandRunner` wraps `node:child_process.spawn` directly;
it buffers stdout / stderr as UTF-8 strings, records the wall-clock
duration, and propagates an optional `AbortSignal` through a `SIGTERM`
kill on abort. The wrapper does not pass the command through a shell, so
argument-injection through unsanitised user input is structurally
impossible.
`probeElectrobun` runs `bunx electrobun --version` and reports
`{ available, version? }`. `invokeElectrobun` probes first, raises
`ELECTROBUN.BINARY_UNAVAILABLE` on miss, and otherwise spawns the
subcommand with the supplied argv; a non-zero exit lands as
`ELECTROBUN.RUNNER_FAILED` carrying `command`, `exitCode`, `stderr`, and
the original argv on `details`.
## Features
- `nodeCommandRunner` — default async runner backed by
`node:child_process.spawn`.
- `probeElectrobun(runner?, cwd?)` — boolean probe + optional version
string parsed from stdout / stderr.
- `invokeElectrobun({ runner, cwd, args, command, signal? })` —
probe-then-spawn orchestrator with full error classification.
## Usage: Basic
```ts
import { invokeElectrobun, nodeCommandRunner } from "@suaveplan/electrobun/runner";
const result = await invokeElectrobun({
runner: nodeCommandRunner,
cwd: "/tmp/my-app",
args: ["build", "--target", "mac"],
command: "build",
});
console.log(`build exited ${result.exitCode} in ${result.durationMs}ms`);
```
## Usage: Advanced
```ts
import type { CommandRunner } from "@suaveplan/electrobun/types";
import { invokeElectrobun } from "@suaveplan/electrobun/runner";
const recording: Array<{ command: string; args: readonly string[] }> = [];
const recorder: CommandRunner = async (input) => {
recording.push({ command: input.command, args: input.args });
return { exitCode: 0, stdout: "1.2.3", stderr: "", durationMs: 1 };
};
await invokeElectrobun({
runner: recorder,
cwd: "/tmp/x",
args: ["sign", "--identity", "Dev ID"],
command: "sign",
});
```
## API Reference
- `nodeCommandRunner: CommandRunner` — `spawn`-backed default.
- `probeElectrobun(runner?: CommandRunner, cwd?: string):
Promise<{ available: boolean; version?: string }>`.
- `invokeElectrobun(input: { runner, cwd, args, command, signal? }):
Promise<CommandResult>` — throws `ElectrobunError(BINARY_UNAVAILABLE
| RUNNER_FAILED)`.
## Implementation Notes
`spawn` is used rather than `exec` so the `command` and `args` arrays
never pass through a shell. The wrapper sets `stdio: ["ignore", "pipe",
"pipe"]` to detach stdin (the orchestrated commands run non-interactively)
and to surface stdout / stderr buffers for diagnostics.
The version regex `(\d+\.\d+\.\d+)` is intentionally permissive — it
covers `1.2.3`, `v1.2.3`, and `electrobun 1.2.3 (some flag)` outputs.
When the binary changes its `--version` format the probe still reports
`available: true` without a version, which is enough to clear the
`BINARY_UNAVAILABLE` gate.
`AbortSignal` is forwarded via an `addEventListener("abort", …,
{ once: true })` that issues `SIGTERM` to the child. The listener is
removed on `close` / `error` to avoid leaking memory when many
subprocesses share a single signal.

View File

@@ -0,0 +1,104 @@
# Schemas — `schemas.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the Zod schemas that gate every public boundary of
`@suaveplan/electrobun`. Each interface in `src/types/electrobun.types.ts`
has a matching schema here, and the `parseOrThrow` helper folds the
parse + throw pattern into one call so every entry point reads the same
way: validate, then operate on the inferred type. The schemas are the
runtime contract — when a downstream library passes a stale
`ProjectOptions` shape, the schema fails at the boundary rather than
silently producing a broken project.
The schemas use Zod 4's enum / object / readonly-array helpers to keep
the TypeScript inference cheap, and `parseOrThrow` always raises
`ElectrobunError(ELECTROBUN.INVALID_OPTIONS)` so consumers can branch on
`error.code === "ELECTROBUN.INVALID_OPTIONS"` regardless of which entry
point validated.
## Features
- Enum schemas (`FeatureModuleSchema`, `NetworkingComplexitySchema`,
`R3fModuleSchema`, `BuildTargetSchema`).
- Composite schemas (`ProjectOptionsSchema`, `ElectrobunConfigSchema`,
`GenerateIconsOptionsSchema`, `DevCommandOptionsSchema`,
`BuildCommandOptionsSchema`, `PackageCommandOptionsSchema`,
`SignCommandOptionsSchema`).
- `parseOrThrow(schema, value, context)` — single helper that converts a
Zod failure into an `ElectrobunError` carrying `details.issues`.
## Usage: Basic
```ts
import { ProjectOptionsSchema } from "@suaveplan/electrobun/schemas";
const parsed = ProjectOptionsSchema.safeParse(input);
if (!parsed.success) {
console.error(parsed.error.issues);
}
```
## Usage: Advanced
```ts
import {
ProjectOptionsSchema,
parseOrThrow,
} from "@suaveplan/electrobun/schemas";
import { ElectrobunError } from "@suaveplan/electrobun/error-codes";
try {
const validated = parseOrThrow(
ProjectOptionsSchema,
input,
"ProjectOptions"
);
await generate(validated);
} catch (err) {
if (err instanceof ElectrobunError) {
process.stderr.write(`${err.code}: ${err.message}\n`);
for (const issue of err.details["issues"] as Array<{
path: ReadonlyArray<string>;
message: string;
}>) {
process.stderr.write(` - ${issue.path.join(".")}: ${issue.message}\n`);
}
}
}
```
## API Reference
- `FeatureModuleSchema`, `NetworkingComplexitySchema`, `R3fModuleSchema`,
`BuildTargetSchema` — closed Zod enums.
- `ProjectOptionsSchema` — full `ProjectOptions` shape; rejects empty
`name`, `identifier`, `version`, `outputDir`.
- `ElectrobunConfigSchema` — partial config; every field optional.
- `GenerateIconsOptionsSchema` — `{ inputPath, outputDir }`.
- `DevCommandOptionsSchema`, `BuildCommandOptionsSchema`,
`PackageCommandOptionsSchema`, `SignCommandOptionsSchema` — per-command
option shapes.
- `parseOrThrow<T>(schema, value, context)` — returns the parsed value or
throws `ElectrobunError(ELECTROBUN.INVALID_OPTIONS, "${context}: invalid
options", { details: { context, issues } })`.
## Implementation Notes
`z.array(...).readonly()` produces a `readonly`-typed inference that
matches the `readonly`-array fields on `ProjectOptions`, so the consumer
gets a parsed object whose mutability matches the interface. This is
important because the generator iterates the feature list and would
otherwise need an explicit `Array.from(...)` to avoid a structural
mismatch.
`parseOrThrow` uses `safeParse` rather than `parse` so the Zod issue list
lands on `details.issues`. The thrown error carries the original `cause`
slot empty — the Zod issues are the cause, surfaced as structured data
rather than a child `Error`. Consumers who want to log a single line
should format `err.message`; consumers who want to drive remediation
should walk `details.issues`.

View File

@@ -0,0 +1,93 @@
# Telemetry — `telemetry.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry surface for `@suaveplan/electrobun`. Every public
entry point that does observable work (project scaffolding, icon
rendering, command orchestration, prompt completion) ends with a call
into one of the recorder helpers exported here. Tests register a real
in-memory `MeterProvider` in `tests/setup/polyfills.ts` before any source
module imports, so the global meter resolved on first use binds to it
and every increment / observation is captured for assertion.
When no provider is registered the OTel API returns no-op instruments,
so the helpers are safe to call from production code that opts out of
telemetry without a guard ladder. The instrument names follow the
`electrobun_*` snake-case convention that the rest of the tooling
family already uses, which keeps Prometheus / Loki dashboards consistent.
## Features
- Five counters (`projects_generated`, `icons_generated`,
`runner_invocations`, `runner_failures`, `prompts`).
- Two histograms (`generate_duration_ms`, `runner_duration_ms`).
- Eight recorder helpers, one per instrument plus the prompt outcome and
icon outcome convenience wrappers.
- Lazy-bound meter that picks up whichever `MeterProvider` was registered
globally at first use — no boot order constraints on the consumer.
## Usage: Basic
```ts
import { recordRunnerInvocation } from "@suaveplan/electrobun/telemetry";
recordRunnerInvocation("build");
```
## Usage: Advanced
```ts
import {
recordGenerateDuration,
recordRunnerDuration,
recordRunnerFailure,
} from "@suaveplan/electrobun/telemetry";
const started = performance.now();
try {
await runOrchestrator(opts);
recordRunnerDuration("build", performance.now() - started);
} catch (err) {
recordRunnerFailure("build", err?.details?.exitCode ?? 1);
throw err;
} finally {
recordGenerateDuration(performance.now() - started, opts.features.length);
}
```
## API Reference
- `recordProject(featuresCount: number): void` — `+1` on
`electrobun_projects_generated_total` with `features_count`.
- `recordIcons(outcome: "generated"|"skipped"|"failed"): void` — `+1` on
`electrobun_icons_generated_total`.
- `recordRunnerInvocation(command: RunnerCommand): void` — `+1` on
`electrobun_runner_invocations_total`.
- `recordRunnerFailure(command: RunnerCommand, exitCode: number): void`
— `+1` on `electrobun_runner_failures_total` with `command` and
`exit_code` labels.
- `recordPromptOutcome(outcome: "completed"|"aborted"): void` — `+1` on
`electrobun_prompts_total`.
- `recordGenerateDuration(ms: number, featuresCount: number): void` —
observation on `electrobun_generate_duration_ms` with `features_count`.
- `recordRunnerDuration(command: RunnerCommand, ms: number): void` —
observation on `electrobun_runner_duration_ms` with `command`.
## Implementation Notes
The label keys are pinned (`features_count`, `outcome`, `command`,
`exit_code`) and intentionally use snake_case so they line up with the
OpenTelemetry semantic-conventions style for tooling instruments. The
recorder helpers stringify numeric labels because the `MeterProvider`
attribute API accepts strings — letting callers pass numbers would force
every call site to remember the coercion.
The lazy `meter` binding means the package can be imported into a fresh
process and used immediately; if the consumer later registers a
`MeterProvider`, the next call picks up the new instruments without a
re-import. This matches the lifecycle of the `@suaveplan/opentelemetry`
SDK factory, which wires the provider once at process start.

View File

@@ -0,0 +1,85 @@
# Feature templates — `features.ts`
## Purpose
Owns every renderer that produces a per-feature file in the scaffolded
output: `i18n`, `networking` (basic + full), `api`, `api-schemas`,
`storage`, `auth`, `form-builder`, and `r3f`. The renderers are pure
functions over `ProjectOptions`; the `r3f` renderer additionally branches
on `options.r3fModules` to wire camera / physics / particles / character
/ pathfinding subsystems into the emitted scene.
The companion `templates.ts` module covers the project-level renderers;
the two together replace the upstream `src/templates/*.ts` and
`src/templates/features/*.ts` blob set. `renderFeature(feature,
options)` is the single dispatch entry the generator uses; individual
`render*Feature` helpers are exported for tools that want to render a
single feature in isolation (for example, a "show me the API skeleton"
preview pane in an IDE extension).
## Features
- Eight per-feature renderers — every closed `FeatureModule` value has a
dedicated implementation.
- `renderNetworkFeature` vs `renderNetworkFullFeature` selected by
`options.networkingComplexity`.
- `renderR3fFeature` composes the camera / physics / particles imports
+ scene fragments based on `options.r3fModules`.
- `featurePath(feature)` returns the canonical output path for each
feature (the generator uses this to assemble the file list).
## Usage: Basic
```ts
import { renderFeature, featurePath } from "@suaveplan/electrobun/templates";
const content = renderFeature("i18n", opts);
const path = featurePath("i18n");
// path === "src/features/i18n.ts"
```
## Usage: Advanced
```ts
import {
featurePath,
renderFeature,
} from "@suaveplan/electrobun/templates";
const featureFiles = opts.features.map((f) => ({
path: featurePath(f),
content: renderFeature(f, opts),
}));
```
## API Reference
- `renderI18nFeature()`, `renderNetworkFeature()`,
`renderNetworkFullFeature()`, `renderApiFeature()`,
`renderApiSchemasFeature()`, `renderStorageFeature()`,
`renderAuthFeature()`, `renderFormBuilderFeature()`,
`renderR3fFeature(options)`.
- `renderFeature(feature: FeatureModule, options: ProjectOptions):
string` — dispatch table.
- `featurePath(feature: FeatureModule): string`.
## Implementation Notes
The `r3f` renderer is the only one whose output is materially shaped by
`ProjectOptions` beyond the basic "the feature is enabled" toggle. The
imports and scene fragments are assembled by joining arrays of strings,
which makes the relative ordering deterministic and the diff-noise low
when a new R3F module is added: the test for "r3f renderer emits the
requested modules" pins the order so a refactor cannot silently
re-shuffle imports.
The `networking` renderer dispatches on `options.networkingComplexity`
rather than `options.networking` because the upstream contract used
`"basic"` / `"full"` to gate the full mesh layer. Keeping the same
contract means downstream electrobun projects already on the
`networkingComplexity` shape do not need to migrate.
`featurePath` lives here rather than in the generator because the
mapping is per-feature; the generator consumes it as a thin lookup. If
a future feature lands with a non-default output path, the change is
local to `features.ts`.

View File

@@ -0,0 +1,107 @@
# Project templates — `templates.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns every renderer that produces a project-level file in the scaffolded
output: `electrobun.config.ts`, `package.json`, `tsconfig.json`,
`README.md`, the main-process Bun entry, the mainview HTML / CSS / TSX,
and the shared types module. The renderers are pure functions over
`ProjectOptions` — given the same input they return byte-identical
strings, which is the property the generator's snapshot tests rely on.
The companion `features.ts` module covers the per-feature renderers; the
two files together replace the upstream `src/templates/*.ts` blob set.
Splitting project files from features keeps the dispatch table for each
small enough to inline at the call site, and keeps the test surface
shallow.
`renderProjectFile(kind, options)` is the single entry point the
generator uses. Direct exports of each `render*` helper are preserved
for consumers that want to drop a single template into a different
toolchain (for example, a `--scaffold-only` CI script that emits
`package.json` without the rest of the project).
## Features
- Nine project renderers covering every file the scaffolder writes.
- `renderProjectFile(kind, options)` dispatch table for the generator.
- `FEATURE_LABELS` / `R3F_MODULE_LABELS` constants used by the prompt
layer and by `renderReadme`.
- Deterministic output for a given `ProjectOptions` (byte-stable across
runs).
## Usage: Basic
```ts
import { renderPackageJson } from "@suaveplan/electrobun/templates";
const pkgJson = renderPackageJson({
name: "my-app",
identifier: "com.example.my-app",
description: "demo",
version: "0.1.0",
outputDir: "/tmp/x",
features: ["api"],
networkingComplexity: "basic",
r3fModules: [],
});
```
## Usage: Advanced
```ts
import { renderProjectFile } from "@suaveplan/electrobun/templates";
import { writeFiles } from "@suaveplan/electrobun/fs";
const kinds = [
"electrobun-config",
"package-json",
"tsconfig",
"main-process",
"mainview-html",
"mainview-css",
"mainview-entry",
"shared-types",
"readme",
] as const;
const opts = /* ... */;
const files = kinds.map((kind) => ({
path: pathFor(kind),
content: renderProjectFile(kind, opts),
}));
await writeFiles(opts.outputDir, files);
```
## API Reference
- `renderElectrobunConfig(options)`, `renderMainProcess(options)`,
`renderMainviewHtml(options)`, `renderMainviewCss()`,
`renderMainviewEntry(options)`, `renderSharedTypes(options)`,
`renderTsconfig()`, `renderReadme(options)`, `renderPackageJson(options)`.
- `renderProjectFile(kind: ProjectFileKind, options)` — dispatch table.
- `FEATURE_LABELS: Record<FeatureModule, string>`.
- `R3F_MODULE_LABELS: Record<R3fModule, string>`.
## Implementation Notes
Every renderer is total over its input — there is no fallback branch
that emits a placeholder. Bad `ProjectOptions` should be rejected by the
Zod schema in `src/schemas/schemas.ts` before reaching a renderer; if
the schema misses something the renderer will produce a string with
unexpected `undefined` interpolations, which the unit tests catch.
`renderPackageJson` keeps the dependency map in a per-feature table
(`FEATURE_DEPS`) so the renderer body stays small and the table can
diff cleanly when the suaveplan dep set evolves. R3F module dependencies
live in a parallel `R3F_MODULE_DEPS` table for the same reason.
`renderMainviewEntry` builds the i18n greeting via a literal template
fragment rather than a string-interpolation `${...}` so the source
emitted into the scaffolded project preserves the surrounding `${...}`
braces. The build step never touches these strings — they are emitted
verbatim — so the generated `.tsx` file compiles cleanly inside the
downstream project.

View File

@@ -0,0 +1,115 @@
# Types — `electrobun.types.ts`
## Purpose
Centralises every public interface used across the `@suaveplan/electrobun`
surface. The file is type-only — no runtime emit beyond the
`FEATURE_ORDER` `readonly` array that defines the canonical feature
render order. Keeping every shape in a single module means downstream
consumers (CI scripts, IDE extensions, the binary entry) only ever import
one symbol per concept instead of chasing them across feature folders.
The Zod schemas in `src/schemas/schemas.ts` mirror these shapes one-to-one
so the type system and the runtime validator never drift. Every public
function in the package parses its options against the schema and then
operates on the inferred TypeScript type.
## Features
- `ProjectOptions` / `ElectrobunConfig` — the user-facing scaffolder shape.
- `FeatureModule`, `NetworkingComplexity`, `R3fModule`, `BuildTarget` —
closed union types that mirror the upstream CLI choices.
- `FsAdapter`, `CommandRunner`, `IconRendererAdapter`, `Prompter` — the four
seams every IO-touching helper accepts.
- `*CommandOptions` (`Dev`, `Build`, `Package`, `Sign`) + `CommandReport`
for the four orchestration subcommands.
- `IconReport`, `GenerateReport` — structured returns for the generator
and icon pipeline.
- `CommandDeps` — the dependency bag accepted by `runCli` and every
subcommand.
## Usage: Basic
```ts
import type { ProjectOptions } from "@suaveplan/electrobun/types";
const opts: ProjectOptions = {
name: "my-app",
identifier: "com.example.my-app",
description: "A demo Electrobun app",
version: "0.1.0",
outputDir: "/tmp/my-app",
features: ["i18n", "api"],
networkingComplexity: "basic",
r3fModules: [],
};
```
## Usage: Advanced
```ts
import type {
CommandRunner,
FsAdapter,
Prompter,
} from "@suaveplan/electrobun/types";
const recordingRunner: CommandRunner = async (input) => ({
exitCode: 0,
stdout: "",
stderr: "",
durationMs: 1,
});
const fakeFs: FsAdapter = {
readFile: async () => "",
writeFile: async () => undefined,
mkdir: async () => undefined,
stat: async () => ({ isDirectory: () => true }),
readdir: async () => [],
};
const promptScript: Prompter = {
text: async ({ default: d }) => d ?? "",
confirm: async () => true,
select: async ({ default: d, choices }) =>
(d ?? choices[0]!.value) as never,
multiselect: async () => [] as never,
};
```
## API Reference
- `ProjectOptions` — required fields: `name`, `identifier`, `description`,
`version`, `outputDir`, `features`, `networkingComplexity`, `r3fModules`.
Optional: `iconSource`.
- `ElectrobunConfig` — every `ProjectOptions` field is optional; used as the
config-file shape.
- `GeneratedFile = { path: string; content: string }`.
- `FsAdapter` — `readFile`, `writeFile`, `mkdir({ recursive })`, `stat`,
`readdir` (all `Promise<…>`).
- `CommandInput = { command, args, cwd?, env?, signal? }`.
- `CommandResult = { exitCode, stdout, stderr, durationMs }`.
- `CommandRunner = (input) => Promise<CommandResult>`.
- `Prompter.text / confirm / select<T> / multiselect<T>`.
- `IconRendererInput.icons` carries `{ icns, ico, png }` descriptors.
- `IconReport = { generated, files, error? }`.
- `GenerateReport = { outputDir, filesCreated, durationMs, icons }`.
- `CommandReport = { command, exitCode, durationMs, stdout, stderr }`.
- `FEATURE_ORDER` — the canonical eight-feature render order.
## Implementation Notes
`FEATURE_ORDER` is a `readonly` array tuple so the generator can iterate
it without sorting on every call. The runtime contract is "if the consumer
asks for `r3f` before `i18n`, we render `i18n` first regardless." Keeping
the order in a single exported constant is the gate against drift between
the upstream CLI and the renderer ordering.
`FsAdapter.stat` returns `{ isDirectory(): boolean }` to match the
`node:fs.Stats` shape without forcing the consumer to import the whole
`Stats` class. The fakes used in tests return inline objects implementing
just this method, which keeps tests free of `Stats` instantiation.
Every option that the CLI exposes has both a `*Options` interface and a
matching Zod schema in `src/schemas/schemas.ts`; consumers should prefer
the schema for runtime validation and the interface for compile-time
typing.