Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
# @suaveplan/electrobun - Electrobun desktop-app generator + orchestrator: scaffolds projects, generates icons, drives dev/build/package/sign commands via an injectable runner. Typed ELECTROBUN_CODES, Zod-validated options, OTel self-telemetry.
Electrobun desktop-app generator and orchestrator for the SuavePlan
ecosystem. Scaffolds a fresh Electrobun project from a layered config +
prompt flow, drives the upstream `electrobun` binary for dev / build /
package / sign, and emits structured reports + OpenTelemetry metrics for
every operation.
<!-- begin:auto-docs -->
## Documentation tree
- [bin](./src/bin/suaveplan-electrobun.md) — Single-file binary that wires `process.argv` into the pure `runCli` dispatcher and translates the resolved exit code in…
- [suaveplan-electrobun](./src/bin/suaveplan-electrobun.md) — Single-file binary that wires `process.argv` into the pure `runCli` dispatcher and translates the resolved exit code in…
- [cli](./src/cli/cli.md) — Owns the five command surfaces — `createCommand`, `devCommand`, `buildCommand`, `packageCommand`, `signCommand` — plus…
- [cli](./src/cli/cli.md) — Owns the five command surfaces — `createCommand`, `devCommand`, `buildCommand`, `packageCommand`, `signCommand` — plus…
- [config](./src/config/config.md) — `loadConfig(cliArgs, deps?)` builds an `ElectrobunConfig` by layering hard-coded defaults, an optional on-disk `.suavep…
- [config](./src/config/config.md) — `loadConfig(cliArgs, deps?)` builds an `ElectrobunConfig` by layering hard-coded defaults, an optional on-disk `.suavep…
- [error-codes](./src/error-codes/error-codes.md) — Owns the stable `ELECTROBUN.*` error-code namespace and the `ElectrobunError` class that every public entry point throw…
- [error-codes](./src/error-codes/error-codes.md) — Owns the stable `ELECTROBUN.*` error-code namespace and the `ElectrobunError` class that every public entry point throw…
- [fs](./src/fs/fs.md) — Owns the `FsAdapter` seam and the synchronous `ConfigDiscoveryAdapter` used by the layered-config loader. Every IO-touc…
- [fs](./src/fs/fs.md) — Owns the `FsAdapter` seam and the synchronous `ConfigDiscoveryAdapter` used by the layered-config loader. Every IO-touc…
- [generator](./src/generator/generator.md) — Owns the two-call surface that scaffolds an Electrobun project on disk. `collectFiles(options, overrides?)` is pure — i…
- [generator](./src/generator/generator.md) — Owns the two-call surface that scaffolds an Electrobun project on disk. `collectFiles(options, overrides?)` is pure — i…
- [icons](./src/icons/icons.md) — Wraps the upstream `iconz` library behind an `IconRendererAdapter` seam so the generator can run the icon-generation pi…
- [icons](./src/icons/icons.md) — Wraps the upstream `iconz` library behind an `IconRendererAdapter` seam so the generator can run the icon-generation pi…
- [prompts](./src/prompts/prompts.md) — Owns the interactive scaffolder that backs the CLI's `create` command. `runPrompts(config, outputDir, prompter)` walks…
- [prompts](./src/prompts/prompts.md) — Owns the interactive scaffolder that backs the CLI's `create` command. `runPrompts(config, outputDir, prompter)` walks…
- [runner](./src/runner/runner.md) — Owns the `CommandRunner` seam that every electrobun shell-out flows through, plus the `probeElectrobun` and `invokeElec…
- [runner](./src/runner/runner.md) — Owns the `CommandRunner` seam that every electrobun shell-out flows through, plus the `probeElectrobun` and `invokeElec…
- [schemas](./src/schemas/schemas.md) — Owns the Zod schemas that gate every public boundary of `@suaveplan/electrobun`. Each interface in `src/types/electrobu…
- [schemas](./src/schemas/schemas.md) — Owns the Zod schemas that gate every public boundary of `@suaveplan/electrobun`. Each interface in `src/types/electrobu…
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry surface for `@suaveplan/electrobun`. Every public entry point that does observable work (project…
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry surface for `@suaveplan/electrobun`. Every public entry point that does observable work (project…
- [templates](./src/templates/templates.md) — Owns every renderer that produces a project-level file in the scaffolded output: `electrobun.config.ts`, `package.json`…
- [features](./src/templates/features.md) — Owns every renderer that produces a per-feature file in the scaffolded output: `i18n`, `networking` (basic + full), `ap…
- [templates](./src/templates/templates.md) — Owns every renderer that produces a project-level file in the scaffolded output: `electrobun.config.ts`, `package.json`…
<!-- end:auto-docs -->
## Features
- **Five-command CLI**: `create`, `dev`, `build`, `package`, `sign`.
- **Layered config**: defaults + `.suaveplan-electrobun.json` + CLI args
via `@suaveplan/config`.
- **Injectable seams**: `FsAdapter`, `CommandRunner`,
`IconRendererAdapter`, `Prompter` — every IO path is testable without
`vi.mock`.
- **Zod-validated options** at every public boundary; bad input fails as
`ELECTROBUN.INVALID_OPTIONS`.
- **Eleven typed error codes** under the `ELECTROBUN.*` namespace.
- **OpenTelemetry**: five counters + two histograms on the
`@suaveplan/electrobun` meter.
- **iconz integration** for `.icns` / `.ico` / `.png` icon generation.
- **Auto-update bundling** via the `--update` flag on `build`.
- **Code-signing pipeline** via `sign --identity --entitlements
--notarize` (the wrapper shapes argv; signing certificates remain in
the user's keychain).
- **Eight feature renderers** (`i18n`, `networking` × basic/full, `api`,
`api-schemas`, `storage`, `auth`, `form-builder`, `r3f` with five
modules).
## Installation
```bash
bun add @suaveplan/electrobun
```
The package ships a `suaveplan-electrobun` binary:
```bash
suaveplan-electrobun --help
```
## Usage
### Scaffold a new project
```bash
suaveplan-electrobun create my-app --skip-prompts \
--name my-app --id com.example.my-app --icon ./assets/icon.png
```
### Interactive scaffold
```bash
suaveplan-electrobun create ./my-app
```
### Dev / build / package / sign
```bash
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 "Developer ID Application: ..." \
--entitlements ./entitlements.plist --notarize
```
### Programmatic use
```ts
import {
buildCommand,
createCommand,
generate,
runCli,
} from "@suaveplan/electrobun";
const report = await generate({
name: "my-app",
identifier: "com.example.my-app",
description: "A demo",
version: "0.1.0",
outputDir: "./my-app",
features: ["i18n", "api"],
networkingComplexity: "basic",
r3fModules: [],
});
```
## Subpath exports
| Subpath | Surface |
|---|---|
| `@suaveplan/electrobun` | Full barrel |
| `@suaveplan/electrobun/cli` | Five command functions + `runCli` |
| `@suaveplan/electrobun/config` | `loadConfig`, defaults |
| `@suaveplan/electrobun/error-codes` | `ELECTROBUN_CODES`, `ElectrobunError` |
| `@suaveplan/electrobun/fs` | `FsAdapter`, `nodeFsAdapter`, file helpers |
| `@suaveplan/electrobun/generator` | `collectFiles`, `generate` |
| `@suaveplan/electrobun/icons` | `generateIcons`, `iconzRendererAdapter` |
| `@suaveplan/electrobun/prompts` | `runPrompts` |
| `@suaveplan/electrobun/runner` | `CommandRunner`, `probeElectrobun`, `invokeElectrobun` |
| `@suaveplan/electrobun/schemas` | Zod schemas + `parseOrThrow` |
| `@suaveplan/electrobun/telemetry` | OTel counters + histograms |
| `@suaveplan/electrobun/templates` | Project + feature renderers |
| `@suaveplan/electrobun/types` | Shared types + `FEATURE_ORDER` |
## Error codes
The full `ELECTROBUN.*` registry:
`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`.
## License
MIT — see the repository root.

View File

@@ -0,0 +1,137 @@
{
"name": "@suaveplan/electrobun",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Electrobun desktop-app generator + orchestrator: scaffolds projects, generates icons, drives dev/build/package/sign commands via an injectable runner. Typed ELECTROBUN_CODES, Zod-validated options, OTel self-telemetry.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"suaveplan-electrobun": "./dist/bin/suaveplan-electrobun.js"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
},
"./fs": {
"import": {
"types": "./dist/fs/index.d.ts",
"default": "./dist/fs/index.js"
}
},
"./runner": {
"import": {
"types": "./dist/runner/index.d.ts",
"default": "./dist/runner/index.js"
}
},
"./config": {
"import": {
"types": "./dist/config/index.d.ts",
"default": "./dist/config/index.js"
}
},
"./templates": {
"import": {
"types": "./dist/templates/index.d.ts",
"default": "./dist/templates/index.js"
}
},
"./icons": {
"import": {
"types": "./dist/icons/index.d.ts",
"default": "./dist/icons/index.js"
}
},
"./generator": {
"import": {
"types": "./dist/generator/index.d.ts",
"default": "./dist/generator/index.js"
}
},
"./prompts": {
"import": {
"types": "./dist/prompts/index.d.ts",
"default": "./dist/prompts/index.js"
}
},
"./cli": {
"import": {
"types": "./dist/cli/index.d.ts",
"default": "./dist/cli/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/cli": "workspace:^",
"@suaveplan/config": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/templates": "workspace:^",
"@suaveplan/types": "workspace:^",
"iconz": "catalog:cli",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

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,42 @@
#!/usr/bin/env node
import {
confirm,
multiselect,
select,
text,
} from "@suaveplan/cli/prompt/prompt";
import { runCli } from "../cli/cli.js";
import type { Prompter } from "../types/electrobun.types.js";
const cliPrompter: Prompter = {
text: (opts) => text(opts),
confirm: (opts) => confirm(opts),
select: <T extends string>(opts: {
message: string;
choices: ReadonlyArray<{ value: T; label: string }>;
default?: T;
}): Promise<T> =>
select<T>({
message: opts.message,
choices: [...opts.choices],
...(opts.default === undefined ? {} : { default: opts.default }),
}),
multiselect: <T extends string>(opts: {
message: string;
choices: ReadonlyArray<{ value: T; label: string }>;
}): Promise<readonly T[]> =>
multiselect<T>({
message: opts.message,
choices: [...opts.choices],
}) as Promise<readonly T[]>,
};
runCli(process.argv.slice(2), { prompter: cliPrompter }).then(
({ code }) => {
process.exit(code);
},
(err) => {
process.stderr.write(`${String(err)}\n`);
process.exit(2);
}
);

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,905 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import type {
CommandInput,
CommandResult,
CommandRunner,
Prompter,
} from "../types/electrobun.types.js";
import {
buildCommand,
createCommand,
devCommand,
packageCommand,
runCli,
signCommand,
} from "./cli.js";
interface Recording {
runner: CommandRunner;
calls: CommandInput[];
}
function recordingRunner(
results: ReadonlyArray<Partial<CommandResult>>
): Recording {
const calls: CommandInput[] = [];
let i = 0;
const fn: CommandRunner = async (input) => {
calls.push(input);
const r = results[i] ?? results[results.length - 1] ?? {};
i += 1;
return {
exitCode: r.exitCode ?? 0,
stdout: r.stdout ?? "1.0.0",
stderr: r.stderr ?? "",
durationMs: r.durationMs ?? 1,
};
};
return { runner: fn, calls };
}
function probeOk(stdout = "1.0.0"): Partial<CommandResult> {
return { exitCode: 0, stdout };
}
function silentPrompter(): Prompter {
return {
text: async ({ default: d }) => d ?? "demo",
confirm: async () => true,
select: async <T extends string>({
default: d,
choices,
}: {
default?: T;
choices: ReadonlyArray<{ value: T }>;
}): Promise<T> => (d ?? choices[0]!.value) as T,
multiselect: async <T extends string>(): Promise<readonly T[]> =>
[] as readonly T[],
};
}
describe("devCommand / buildCommand / packageCommand / signCommand", () => {
it("devCommand validates options", { timeout: 5000 }, async () => {
try {
await devCommand({ cwd: "" });
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.INVALID_OPTIONS
);
}
});
it("buildCommand passes --update when options.update is true", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await buildCommand(
{ cwd: "/tmp", target: "mac", update: true },
{ runner: r.runner }
);
// first call = probe, second = build
const build = r.calls[1];
expect(build?.args).toContain("--update");
});
it("buildCommand omits --update by default", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await buildCommand(
{ cwd: "/tmp", target: "mac" },
{ runner: r.runner }
);
const build = r.calls[1];
expect(build?.args.includes("--update")).toBe(false);
});
it("packageCommand emits the expected argv", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await packageCommand(
{ cwd: "/tmp", target: "linux", extraArgs: ["--verbose"] },
{ runner: r.runner }
);
expect(r.calls[1]?.args).toEqual([
"electrobun",
"package",
"--target",
"linux",
"--verbose",
]);
});
it("signCommand forwards identity, entitlements, and notarize", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await signCommand(
{
cwd: "/tmp",
identity: "Dev ID",
entitlements: "/e.plist",
notarize: true,
},
{ runner: r.runner }
);
const args = r.calls[1]?.args ?? [];
expect(args).toContain("--identity");
expect(args).toContain("Dev ID");
expect(args).toContain("--entitlements");
expect(args).toContain("/e.plist");
expect(args).toContain("--notarize");
});
it("subcommands use nodeCommandRunner default when no runner supplied", {
timeout: 30000,
}, async () => {
// No deps.runner → real spawn path. Outcome depends on whether
// bunx electrobun resolves on this host; either branch (success
// or ElectrobunError) is acceptable — the test only exercises
// the default-runner fallback.
const settle = async <T>(p: Promise<T>): Promise<unknown> => {
try {
return await p;
} catch (err) {
return err;
}
};
await settle(signCommand({ cwd: "/tmp", identity: "Dev ID" }));
await settle(devCommand({ cwd: "/tmp" }));
await settle(buildCommand({ cwd: "/tmp", target: "mac" }));
await settle(packageCommand({ cwd: "/tmp", target: "mac" }));
});
it("signCommand forwards AbortSignal to runner", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const ctl = new AbortController();
await signCommand(
{ cwd: "/tmp", identity: "Dev ID", signal: ctl.signal },
{ runner: r.runner }
);
expect(r.calls[1]?.signal).toBe(ctl.signal);
});
it("buildCommand forwards AbortSignal to runner", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const ctl = new AbortController();
await buildCommand(
{ cwd: "/tmp", target: "mac", signal: ctl.signal },
{ runner: r.runner }
);
expect(r.calls[1]?.signal).toBe(ctl.signal);
});
it("packageCommand forwards AbortSignal to runner", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const ctl = new AbortController();
await packageCommand(
{ cwd: "/tmp", target: "mac", signal: ctl.signal },
{ runner: r.runner }
);
expect(r.calls[1]?.signal).toBe(ctl.signal);
});
it("devCommand forwards AbortSignal to runner", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const ctl = new AbortController();
await devCommand(
{ cwd: "/tmp", signal: ctl.signal },
{ runner: r.runner }
);
expect(r.calls[1]?.signal).toBe(ctl.signal);
});
it("signCommand without entitlements does not pass the flag", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await signCommand(
{ cwd: "/tmp", identity: "Dev ID" },
{ runner: r.runner }
);
const args = r.calls[1]?.args ?? [];
expect(args.includes("--entitlements")).toBe(false);
});
it("non-zero exit raises RUNNER_FAILED", { timeout: 5000 }, async () => {
const r = recordingRunner([probeOk(), { exitCode: 2, stderr: "fail" }]);
try {
await devCommand({ cwd: "/tmp" }, { runner: r.runner });
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.RUNNER_FAILED
);
expect((err as ElectrobunError).details["exitCode"]).toBe(2);
}
});
it("missing binary raises BINARY_UNAVAILABLE", {
timeout: 5000,
}, async () => {
const r = recordingRunner([{ exitCode: 127 }]);
try {
await buildCommand(
{ cwd: "/tmp", target: "mac" },
{ runner: r.runner }
);
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.BINARY_UNAVAILABLE
);
}
});
it("devCommand forwards extraArgs", { timeout: 5000 }, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await devCommand(
{ cwd: "/tmp", extraArgs: ["--watch"] },
{ runner: r.runner }
);
expect(r.calls[1]?.args).toContain("--watch");
});
it("devCommand records duration even on failure", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 5, stderr: "boom" }]);
await expect(
devCommand({ cwd: "/tmp" }, { runner: r.runner })
).rejects.toBeInstanceOf(ElectrobunError);
});
it("non-Electrobun error inside timeRunner is re-thrown", {
timeout: 5000,
}, async () => {
const r: CommandRunner = async () => {
throw new Error("network down");
};
await expect(
devCommand({ cwd: "/tmp" }, { runner: r })
).rejects.toBeInstanceOf(Error);
});
});
describe("runCli", () => {
it("--help returns code 0", { timeout: 5000 }, async () => {
const captured: string[] = [];
const result = await runCli(["--help"], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 0 });
expect(captured[0]).toContain("Usage");
});
it("runCli with default `out` argument still resolves", {
timeout: 5000,
}, async () => {
// Capture the console.log fallback by stubbing it for one call.
const orig = console.log;
const captured: string[] = [];
console.log = (line: string) => captured.push(String(line));
try {
const result = await runCli(["--help"]);
expect(result.code).toBe(0);
expect(captured.join("\n")).toContain("Usage");
} finally {
console.log = orig;
}
});
it("-h short flag returns code 0", { timeout: 5000 }, async () => {
const captured: string[] = [];
const result = await runCli(["-h"], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 0 });
});
it("no subcommand prints help and returns code 0", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli([], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 0 });
expect(captured[0]).toContain("Usage");
});
it("unknown subcommand raises UNKNOWN_FLAG", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli(["zzz"], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 1 });
expect(captured.join("\n")).toContain("ELECTROBUN.UNKNOWN_FLAG");
});
it("unknown long flag raises UNKNOWN_FLAG", { timeout: 5000 }, async () => {
const captured: string[] = [];
const result = await runCli(["dev", "--zzz"], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 1 });
expect(captured.join("\n")).toContain("ELECTROBUN.UNKNOWN_FLAG");
});
it("unknown short flag raises UNKNOWN_FLAG", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli(["dev", "-z"], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 1 });
});
it("dev subcommand dispatches and reports", { timeout: 5000 }, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const captured: string[] = [];
const result = await runCli(["dev", "--cwd", "/tmp"], {
out: (l) => captured.push(l),
runner: r.runner,
});
expect(result).toEqual({ code: 0 });
expect(captured.join("\n")).toContain("dev exited 0");
});
it("build subcommand parses --target= and --update", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const captured: string[] = [];
const result = await runCli(
["build", "--cwd=/tmp", "--target=mac", "--update"],
{ out: (l) => captured.push(l), runner: r.runner }
);
expect(result).toEqual({ code: 0 });
expect(r.calls[1]?.args).toContain("--update");
});
it("package subcommand dispatches", { timeout: 5000 }, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const result = await runCli(
["package", "--cwd", "/tmp", "--target", "windows"],
{ runner: r.runner, out: () => undefined }
);
expect(result.code).toBe(0);
expect(r.calls[1]?.args).toContain("windows");
});
it("sign subcommand dispatches with entitlements", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const result = await runCli(
[
"sign",
"--cwd",
"/tmp",
"--identity",
"Dev ID",
"--entitlements",
"/e.plist",
"--notarize",
],
{ runner: r.runner, out: () => undefined }
);
expect(result.code).toBe(0);
});
it("missing --cwd raises INVALID_OPTIONS", { timeout: 5000 }, async () => {
const captured: string[] = [];
const result = await runCli(["dev"], {
out: (l) => captured.push(l),
runner: recordingRunner([probeOk()]).runner,
});
expect(result.code).toBe(1);
expect(captured.join("\n")).toContain("ELECTROBUN.INVALID_OPTIONS");
});
it("build without --target raises INVALID_OPTIONS", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli(["build", "--cwd", "/tmp"], {
out: (l) => captured.push(l),
runner: recordingRunner([probeOk()]).runner,
});
expect(result.code).toBe(1);
});
it("create requires a prompter when not skipping prompts", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli(["create", "/tmp"], {
out: (l) => captured.push(l),
});
expect(result.code).toBe(1);
expect(captured.join("\n")).toContain("ELECTROBUN.INVALID_OPTIONS");
});
it("create subcommand dispatches with skip-prompts + overrides", {
timeout: 5000,
}, async () => {
const writes: string[] = [];
const fs = {
async readFile() {
return "";
},
async writeFile(path: string) {
writes.push(path);
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const captured: string[] = [];
const result = await runCli(
[
"create",
"/scratch/dest",
"--skip-prompts",
"--name",
"x",
"--id",
"com.x.y",
"--app-version",
"1.0.0",
],
{
out: (l) => captured.push(l),
prompter: silentPrompter(),
fs,
}
);
expect(result.code).toBe(0);
expect(writes.length).toBeGreaterThan(0);
expect(captured.join("\n")).toContain("Created project");
});
it("create with --icon forwards iconOverride", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const renderer = {
async render() {
return { files: ["app.icns"] };
},
};
const result = await runCli(
[
"create",
"/scratch/dest",
"--skip-prompts",
"--name",
"x",
"--id",
"com.x.y",
"--icon",
"/icons/source.png",
],
{
out: () => undefined,
prompter: silentPrompter(),
fs,
renderer,
fileExists: () => true,
}
);
expect(result.code).toBe(0);
});
it("runCli catches ElectrobunError from a subcommand and folds to code 1", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 9, stderr: "boom" }]);
const captured: string[] = [];
const result = await runCli(["dev", "--cwd", "/tmp"], {
out: (l) => captured.push(l),
runner: r.runner,
});
expect(result.code).toBe(1);
expect(captured.join("\n")).toContain("ELECTROBUN.RUNNER_FAILED");
});
it("parseArgs surfaces UNKNOWN_FLAG out to stdout", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli(["create", "/tmp", "--zzz"], {
out: (l) => captured.push(l),
});
expect(result.code).toBe(1);
expect(captured.join("\n")).toContain("ELECTROBUN.UNKNOWN_FLAG");
});
it("create without positional directory uses defaults", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const result = await runCli(["create", "--skip-prompts"], {
out: () => undefined,
prompter: silentPrompter(),
fs,
});
expect(result.code).toBe(0);
});
it("create through runCli without fs override succeeds", {
timeout: 30000,
}, async () => {
// Real fs path — write into a real tmp dir.
const real = mkdtempSync(join(tmpdir(), "electrobun-runcli-create-"));
try {
const result = await runCli(
[
"create",
join(real, "out"),
"--skip-prompts",
"--name",
"x",
"--id",
"com.x.y",
],
{
out: () => undefined,
prompter: silentPrompter(),
}
);
expect(result.code).toBe(0);
} finally {
rmSync(real, { recursive: true, force: true });
}
});
it("sign with notarize-only (no entitlements) covers branch", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const result = await runCli(
["sign", "--cwd", "/tmp", "--identity", "Dev ID", "--notarize"],
{ runner: r.runner, out: () => undefined }
);
expect(result.code).toBe(0);
});
it("runCli re-throws non-ElectrobunError from dispatch", {
timeout: 5000,
}, async () => {
const failingRunner: CommandRunner = async () => {
throw new Error("network");
};
await expect(
runCli(["dev", "--cwd", "/tmp"], {
runner: failingRunner,
out: () => undefined,
})
).rejects.toBeInstanceOf(Error);
});
});
describe("createCommand", () => {
it("skip-prompts uses overrides and defaults", {
timeout: 5000,
}, async () => {
const written: string[] = [];
const fs = {
async readFile() {
return "";
},
async writeFile(path: string) {
written.push(path);
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const renderer = {
async render() {
return { files: ["app.icns", "app.ico"] };
},
};
const report = await createCommand(
{
directory: "/scratch/dest",
skipPrompts: true,
nameOverride: "skip-app",
identifierOverride: "com.skip.app",
iconOverride: "/icons/skip.png",
versionOverride: "2.3.4",
},
{
fs,
prompter: silentPrompter(),
renderer,
fileExists: () => true,
}
);
expect(report.outputDir).toBe("/scratch/dest");
expect(written.length).toBeGreaterThan(0);
});
it("interactive path runs the prompt flow", { timeout: 5000 }, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const report = await createCommand(
{
directory: "/scratch/dest",
nameOverride: "x",
identifierOverride: "com.x.y",
versionOverride: "1.1.1",
},
{ fs, prompter: silentPrompter() }
);
expect(report.outputDir).toBeDefined();
});
it("skip-prompts honours a config file with full set of fields", {
timeout: 5000,
}, async () => {
const fsAdapter = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const cfg = {
name: "cfg-name",
identifier: "com.cfg.id",
description: "cfg-desc",
version: "9.9.9",
features: ["i18n"],
networkingComplexity: "full",
r3fModules: ["camera"],
};
const discovery = {
cwd: () => "/cwd",
homedir: () => "/home",
exists: (p: string) => p === "/cwd/.suaveplan-electrobun.json",
readFile: () => JSON.stringify(cfg),
};
const report = await createCommand(
{ skipPrompts: true, discovery },
{ fs: fsAdapter, prompter: silentPrompter() }
);
expect(report.outputDir).toBe("cfg-name");
});
it("interactive path with all overrides set", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const renderer = {
async render() {
return { files: ["app.icns"] };
},
};
const report = await createCommand(
{
directory: "/scratch/y",
nameOverride: "n",
identifierOverride: "com.n.y",
iconOverride: "/icons/x.png",
versionOverride: "3.2.1",
},
{
fs,
prompter: silentPrompter(),
renderer,
fileExists: () => true,
}
);
expect(report.outputDir).toBeDefined();
});
it("interactive path with no overrides", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const report = await createCommand(
{ directory: "/scratch/y" },
{ fs, prompter: silentPrompter() }
);
expect(report.outputDir).toBeDefined();
});
it("skip-prompts with a full config carries every field through ?? lefts", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const cfg = {
name: "cfg-skip-name",
identifier: "com.cfg.skip",
description: "cfg-skip-desc",
version: "8.8.8",
iconSource: "/cfg/icon.png",
features: ["i18n"],
networkingComplexity: "full",
r3fModules: ["camera"],
};
const discovery = {
cwd: () => "/cwd",
homedir: () => "/home",
exists: (p: string) => p === "/cwd/.suaveplan-electrobun.json",
readFile: () => JSON.stringify(cfg),
};
const renderer = {
async render() {
return { files: ["app.icns"] };
},
};
const report = await createCommand(
{ skipPrompts: true, discovery },
{ fs, prompter: silentPrompter(), renderer, fileExists: () => true }
);
expect(report.outputDir).toBe("cfg-skip-name");
});
it("skip-prompts uses bare default outputDir when none supplied", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const report = await createCommand(
{ skipPrompts: true },
{ fs, prompter: silentPrompter() }
);
expect(report.outputDir).toBe("my-electrobun-app");
});
});

View File

@@ -0,0 +1,537 @@
import { loadConfig } from "../config/config.js";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import { nodeFsAdapter } from "../fs/fs.js";
import { generate } from "../generator/generator.js";
import { runPrompts } from "../prompts/prompts.js";
import { invokeElectrobun, nodeCommandRunner } from "../runner/runner.js";
import {
BuildCommandOptionsSchema,
DevCommandOptionsSchema,
PackageCommandOptionsSchema,
ProjectOptionsSchema,
parseOrThrow,
SignCommandOptionsSchema,
} from "../schemas/schemas.js";
import {
recordRunnerDuration,
recordRunnerFailure,
recordRunnerInvocation,
} from "../telemetry/telemetry.js";
import type {
BuildCommandOptions,
CommandDeps,
CommandReport,
CommandRunner,
DevCommandOptions,
ElectrobunConfig,
FeatureModule,
GenerateReport,
PackageCommandOptions,
ProjectOptions,
Prompter,
R3fModule,
SignCommandOptions,
} from "../types/electrobun.types.js";
export const HELP_TEXT = `Usage: suaveplan-electrobun <command> [options]
Commands:
create [dir] Scaffold a new Electrobun project
dev Run \`electrobun dev\` in the project at --cwd
build Build for --target=mac|linux|windows
package Package the build for --target
sign Sign the packaged build with --identity
Global flags:
--help, -h Print this message
--skip-prompts Use config/defaults without prompting (create)
--cwd <path> Working directory (dev/build/package/sign)
--target <triple> Build target (build/package): mac|linux|windows
--update Include update bundle (build)
--identity <name> Signing identity (sign)
--entitlements <path> Entitlements plist (sign)
--notarize Submit for notarization (sign)
--name, --id, --icon, --version Override the matching create field
`;
export interface RunCliOptions extends CommandDeps {
readonly env?: Readonly<Record<string, string>>;
}
interface ParsedArgs {
readonly subcommand: string | undefined;
readonly positional: readonly string[];
readonly flags: Record<string, string | true>;
}
const ALL_KNOWN_FLAGS = new Set<string>([
"help",
"h",
"skip-prompts",
"cwd",
"target",
"update",
"identity",
"entitlements",
"notarize",
"name",
"id",
"icon",
"version",
"app-version",
]);
function parseArgs(argv: readonly string[]): ParsedArgs {
let subcommand: string | undefined;
const positional: string[] = [];
const flags: Record<string, string | true> = {};
for (let i = 0; i < argv.length; i += 1) {
const raw = argv[i] as string;
if (raw === "--help" || raw === "-h") {
flags["help"] = true;
continue;
}
if (raw.startsWith("--")) {
const eq = raw.indexOf("=");
const key = eq === -1 ? raw.slice(2) : raw.slice(2, eq);
if (!ALL_KNOWN_FLAGS.has(key)) {
throw new ElectrobunError(
ELECTROBUN_CODES.UNKNOWN_FLAG,
`Unknown flag: --${key}`,
{ details: { flag: key } }
);
}
if (eq !== -1) {
flags[key] = raw.slice(eq + 1);
continue;
}
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
flags[key] = true;
continue;
}
flags[key] = next;
i += 1;
continue;
}
if (raw.startsWith("-")) {
throw new ElectrobunError(
ELECTROBUN_CODES.UNKNOWN_FLAG,
`Unknown flag: ${raw}`,
{ details: { flag: raw } }
);
}
if (subcommand === undefined) {
subcommand = raw;
continue;
}
positional.push(raw);
}
return { subcommand, positional, flags };
}
async function timeRunner<T>(
command: "dev" | "build" | "package" | "sign",
fn: () => Promise<T>
): Promise<T> {
const started = Date.now();
recordRunnerInvocation(command);
try {
const result = await fn();
recordRunnerDuration(command, Date.now() - started);
return result;
} catch (err) {
recordRunnerDuration(command, Date.now() - started);
if (err instanceof ElectrobunError) {
const exitCode = err.details["exitCode"];
if (typeof exitCode === "number") {
recordRunnerFailure(command, exitCode);
} else {
recordRunnerFailure(command, -1);
}
}
throw err;
}
}
function reportFrom(
command: "dev" | "build" | "package" | "sign",
result: {
exitCode: number;
stdout: string;
stderr: string;
durationMs: number;
}
): CommandReport {
return {
command,
exitCode: result.exitCode,
durationMs: result.durationMs,
stdout: result.stdout,
stderr: result.stderr,
};
}
/** Run `electrobun dev` in `options.cwd`. */
export async function devCommand(
options: DevCommandOptions,
deps: { runner?: CommandRunner } = {}
): Promise<CommandReport> {
const parsed = parseOrThrow(
DevCommandOptionsSchema,
options,
"DevCommandOptions"
);
const runner = deps.runner ?? nodeCommandRunner;
return timeRunner("dev", async () => {
const result = await invokeElectrobun({
runner,
cwd: parsed.cwd,
args: ["dev", ...(parsed.extraArgs ?? [])],
...(options.signal === undefined ? {} : { signal: options.signal }),
command: "dev",
});
return reportFrom("dev", result);
});
}
/** Run `electrobun build --target <target> [--update]` in `options.cwd`. */
export async function buildCommand(
options: BuildCommandOptions,
deps: { runner?: CommandRunner } = {}
): Promise<CommandReport> {
const parsed = parseOrThrow(
BuildCommandOptionsSchema,
options,
"BuildCommandOptions"
);
const runner = deps.runner ?? nodeCommandRunner;
const args = [
"build",
"--target",
parsed.target,
...(parsed.update ? ["--update"] : []),
...(parsed.extraArgs ?? []),
];
return timeRunner("build", async () => {
const result = await invokeElectrobun({
runner,
cwd: parsed.cwd,
args,
...(options.signal === undefined ? {} : { signal: options.signal }),
command: "build",
});
return reportFrom("build", result);
});
}
/** Run `electrobun package --target <target>` in `options.cwd`. */
export async function packageCommand(
options: PackageCommandOptions,
deps: { runner?: CommandRunner } = {}
): Promise<CommandReport> {
const parsed = parseOrThrow(
PackageCommandOptionsSchema,
options,
"PackageCommandOptions"
);
const runner = deps.runner ?? nodeCommandRunner;
const args = [
"package",
"--target",
parsed.target,
...(parsed.extraArgs ?? []),
];
return timeRunner("package", async () => {
const result = await invokeElectrobun({
runner,
cwd: parsed.cwd,
args,
...(options.signal === undefined ? {} : { signal: options.signal }),
command: "package",
});
return reportFrom("package", result);
});
}
/** Run `electrobun sign --identity <id> [...]` in `options.cwd`. */
export async function signCommand(
options: SignCommandOptions,
deps: { runner?: CommandRunner } = {}
): Promise<CommandReport> {
const parsed = parseOrThrow(
SignCommandOptionsSchema,
options,
"SignCommandOptions"
);
const runner = deps.runner ?? nodeCommandRunner;
const args = [
"sign",
"--identity",
parsed.identity,
...(parsed.entitlements ? ["--entitlements", parsed.entitlements] : []),
...(parsed.notarize ? ["--notarize"] : []),
...(parsed.extraArgs ?? []),
];
return timeRunner("sign", async () => {
const result = await invokeElectrobun({
runner,
cwd: parsed.cwd,
args,
...(options.signal === undefined ? {} : { signal: options.signal }),
command: "sign",
});
return reportFrom("sign", result);
});
}
export interface CreateCommandOptions {
readonly directory?: string;
readonly skipPrompts?: boolean;
readonly nameOverride?: string;
readonly identifierOverride?: string;
readonly iconOverride?: string;
readonly versionOverride?: string;
readonly cliArgs?: readonly string[];
/**
* Optional discovery adapter for the layered-config file lookup. Tests
* inject an in-memory adapter; production passes the default
* `nodeConfigDiscoveryAdapter` implicitly.
*/
readonly discovery?: import("../fs/fs.js").ConfigDiscoveryAdapter;
}
/**
* Scaffold a new project. When `skipPrompts` is true the function uses
* the layered config + overrides directly; otherwise it drives the
* interactive prompt flow before handing off to `generate`.
*/
export async function createCommand(
options: CreateCommandOptions,
deps: CommandDeps & { prompter: Prompter }
): Promise<GenerateReport> {
const cliArgs = options.cliArgs ?? [];
const config = loadConfig(
cliArgs,
options.discovery ? { discovery: options.discovery } : {}
);
let projectOptions: ProjectOptions;
if (options.skipPrompts) {
const name = options.nameOverride ?? config.name ?? "my-electrobun-app";
// `config` is always shaped by `HARDCODED_DEFAULTS`, so the
// primitive defaults (version, features, networkingComplexity,
// r3fModules) are guaranteed to be defined; only `name`,
// `identifier`, `description`, and `iconSource` can be absent.
const candidate: ProjectOptions = {
name,
identifier:
options.identifierOverride ??
config.identifier ??
`com.example.${name}`,
description:
config.description ?? "An Electrobun desktop application",
version: options.versionOverride ?? (config.version as string),
outputDir: options.directory ?? config.name ?? "my-electrobun-app",
iconSource: options.iconOverride ?? config.iconSource,
features: config.features as readonly FeatureModule[],
networkingComplexity:
config.networkingComplexity as ProjectOptions["networkingComplexity"],
r3fModules: config.r3fModules as readonly R3fModule[],
};
projectOptions = parseOrThrow(
ProjectOptionsSchema,
candidate,
"ProjectOptions (skip-prompts)"
);
} else {
const enrichedConfig: ElectrobunConfig = {
...config,
...(options.nameOverride === undefined
? {}
: { name: options.nameOverride }),
...(options.identifierOverride === undefined
? {}
: { identifier: options.identifierOverride }),
...(options.iconOverride === undefined
? {}
: { iconSource: options.iconOverride }),
...(options.versionOverride === undefined
? {}
: { version: options.versionOverride }),
};
projectOptions = await runPrompts(
enrichedConfig,
options.directory,
deps.prompter
);
}
return generate(projectOptions, {
...(deps.fs ? { fs: deps.fs } : {}),
...(deps.renderer ? { renderer: deps.renderer } : {}),
...(deps.fileExists ? { fileExists: deps.fileExists } : {}),
});
}
function ensureString(value: string | true | undefined, flag: string): string {
if (typeof value === "string") {
return value;
}
throw new ElectrobunError(
ELECTROBUN_CODES.INVALID_OPTIONS,
`--${flag} requires a value`,
{ details: { flag } }
);
}
/**
* Pure CLI dispatcher. Parses argv, runs the matching subcommand, and
* returns the resolved exit code. Never calls `process.exit`; only the
* binary entry owns the exit.
*/
export async function runCli(
argv: readonly string[],
options: RunCliOptions = {}
): Promise<{ code: number }> {
const out = options.out ?? ((line: string) => console.log(line));
let parsed: ParsedArgs;
try {
parsed = parseArgs(argv);
} catch (err) {
// parseArgs only throws ElectrobunError(UNKNOWN_FLAG).
const ee = err as ElectrobunError;
out(`${ee.code}: ${ee.message}`);
return { code: 1 };
}
if (parsed.flags["help"]) {
out(HELP_TEXT);
return { code: 0 };
}
if (parsed.subcommand === undefined) {
out(HELP_TEXT);
return { code: 0 };
}
const runner = options.runner ?? nodeCommandRunner;
const fs = options.fs ?? nodeFsAdapter;
try {
switch (parsed.subcommand) {
case "create": {
if (!options.prompter) {
throw new ElectrobunError(
ELECTROBUN_CODES.INVALID_OPTIONS,
"create requires a Prompter dependency",
{ details: { command: "create" } }
);
}
const directory = parsed.positional[0];
const report = await createCommand(
{
...(directory === undefined ? {} : { directory }),
skipPrompts: parsed.flags["skip-prompts"] === true,
...(typeof parsed.flags["name"] === "string"
? { nameOverride: parsed.flags["name"] }
: {}),
...(typeof parsed.flags["id"] === "string"
? { identifierOverride: parsed.flags["id"] }
: {}),
...(typeof parsed.flags["icon"] === "string"
? { iconOverride: parsed.flags["icon"] }
: {}),
...(typeof parsed.flags["app-version"] === "string"
? { versionOverride: parsed.flags["app-version"] }
: {}),
cliArgs: [...argv],
},
{
prompter: options.prompter,
...(options.fs ? { fs } : {}),
...(options.renderer
? { renderer: options.renderer }
: {}),
...(options.fileExists
? { fileExists: options.fileExists }
: {}),
}
);
out(
`Created project at ${report.outputDir} (${report.filesCreated.length} files, ${report.durationMs}ms)`
);
return { code: 0 };
}
case "dev": {
const report = await devCommand(
{
cwd: ensureString(parsed.flags["cwd"], "cwd"),
},
{ runner }
);
out(`dev exited ${report.exitCode} in ${report.durationMs}ms`);
return { code: 0 };
}
case "build": {
const target = ensureString(parsed.flags["target"], "target");
const report = await buildCommand(
{
cwd: ensureString(parsed.flags["cwd"], "cwd"),
target: target as BuildCommandOptions["target"],
update: parsed.flags["update"] === true,
},
{ runner }
);
out(
`build exited ${report.exitCode} in ${report.durationMs}ms`
);
return { code: 0 };
}
case "package": {
const target = ensureString(parsed.flags["target"], "target");
const report = await packageCommand(
{
cwd: ensureString(parsed.flags["cwd"], "cwd"),
target: target as PackageCommandOptions["target"],
},
{ runner }
);
out(
`package exited ${report.exitCode} in ${report.durationMs}ms`
);
return { code: 0 };
}
case "sign": {
const report = await signCommand(
{
cwd: ensureString(parsed.flags["cwd"], "cwd"),
identity: ensureString(
parsed.flags["identity"],
"identity"
),
...(typeof parsed.flags["entitlements"] === "string"
? { entitlements: parsed.flags["entitlements"] }
: {}),
notarize: parsed.flags["notarize"] === true,
},
{ runner }
);
out(`sign exited ${report.exitCode} in ${report.durationMs}ms`);
return { code: 0 };
}
default: {
const err = new ElectrobunError(
ELECTROBUN_CODES.UNKNOWN_FLAG,
`Unknown subcommand: ${parsed.subcommand}`,
{ details: { subcommand: parsed.subcommand } }
);
out(`${err.code}: ${err.message}`);
return { code: 1 };
}
}
} catch (err) {
if (err instanceof ElectrobunError) {
out(`${err.code}: ${err.message}`);
return { code: 1 };
}
throw err;
}
}

View File

@@ -0,0 +1,10 @@
export type { CreateCommandOptions, RunCliOptions } from "./cli.js";
export {
buildCommand,
createCommand,
devCommand,
HELP_TEXT,
packageCommand,
runCli,
signCommand,
} from "./cli.js";

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,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import type { ConfigDiscoveryAdapter } from "../fs/fs.js";
import { HARDCODED_DEFAULTS, loadConfig } from "./config.js";
function discoveryWith(files: Record<string, string>): ConfigDiscoveryAdapter {
return {
cwd: () => "/cwd",
homedir: () => "/home",
exists: (p) => p in files,
readFile: (p) => files[p] ?? "",
};
}
describe("loadConfig", () => {
it("loadConfig defaults cliArgs to empty array", { timeout: 5000 }, () => {
// The default discovery looks at process.cwd() + homedir(); we
// accept whatever it finds there — the test only exercises the
// default-arg branch.
const cfg = loadConfig();
expect(typeof cfg).toBe("object");
});
it("returns defaults when no file or args", { timeout: 5000 }, () => {
const cfg = loadConfig([], { discovery: discoveryWith({}) });
expect(cfg.version).toBe(HARDCODED_DEFAULTS.version);
expect(cfg.networkingComplexity).toBe("basic");
expect(cfg.features).toEqual([]);
});
it("reads name from a cwd file", { timeout: 5000 }, () => {
const cfg = loadConfig([], {
discovery: discoveryWith({
"/cwd/.suaveplan-electrobun.json": '{"name":"file-app"}',
}),
});
expect(cfg.name).toBe("file-app");
});
it("CLI args override file values", { timeout: 5000 }, () => {
const cfg = loadConfig(["--name", "cli-app"], {
discovery: discoveryWith({
"/cwd/.suaveplan-electrobun.json": '{"name":"file-app"}',
}),
});
expect(cfg.name).toBe("cli-app");
});
it("every CLI mapping reaches the right config path", {
timeout: 5000,
}, () => {
const cfg = loadConfig(
[
"--name",
"n",
"--id",
"com.x",
"--icon",
"/i.png",
"--version",
"9.9.9",
],
{ discovery: discoveryWith({}) }
);
expect(cfg.name).toBe("n");
expect(cfg.identifier).toBe("com.x");
expect(cfg.iconSource).toBe("/i.png");
expect(cfg.version).toBe("9.9.9");
});
it("malformed file raises CONFIG_PARSE_FAILED", { timeout: 5000 }, () => {
try {
loadConfig([], {
discovery: discoveryWith({
"/cwd/.suaveplan-electrobun.json": "{not json",
}),
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ElectrobunError);
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.CONFIG_PARSE_FAILED
);
}
});
});

View File

@@ -0,0 +1,59 @@
import {
type ConfigData,
cliLayer,
createConfig,
defaultsLayer,
fileLayer,
} from "@suaveplan/config";
import type { ConfigDiscoveryAdapter } from "../fs/fs.js";
import { findConfigFile } from "../fs/fs.js";
import type { ElectrobunConfig } from "../types/electrobun.types.js";
const HARDCODED_DEFAULTS: ElectrobunConfig = {
version: "0.0.1",
features: [],
networkingComplexity: "basic",
r3fModules: [],
};
const CLI_MAPPING: Record<string, string> = {
name: "name",
id: "identifier",
icon: "iconSource",
version: "version",
};
/**
* Build an `ElectrobunConfig` by layering hard-coded defaults, an optional
* `.suaveplan-electrobun.json` file, and CLI argument overrides.
*
* The layering order (lowest priority first) is:
* 1. defaults
* 2. file (if present)
* 3. CLI args (`--name`, `--id`, `--icon`, `--version`)
*
* @param cliArgs - Raw argv slice (defaults to an empty array).
* @param deps - Optional injectable `ConfigDiscoveryAdapter` for file lookup.
*/
export function loadConfig(
cliArgs: readonly string[] = [],
deps: { discovery?: ConfigDiscoveryAdapter } = {}
): ElectrobunConfig {
const fileContent = findConfigFile(deps.discovery);
const layers = [
defaultsLayer(HARDCODED_DEFAULTS as unknown as ConfigData),
...(fileContent
? [fileLayer({ content: fileContent as unknown as ConfigData })]
: []),
cliLayer({
args: [...cliArgs],
mapping: CLI_MAPPING,
}),
];
const store = createConfig({ layers });
return store.toJSON() as ElectrobunConfig;
}
export { CLI_MAPPING, HARDCODED_DEFAULTS };

View File

@@ -0,0 +1 @@
export { CLI_MAPPING, HARDCODED_DEFAULTS, loadConfig } from "./config.js";

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,45 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ELECTROBUN_CODES, ElectrobunError } from "./error-codes.js";
describe("ELECTROBUN_CODES registry", () => {
it("registry exposes the full code set", { timeout: 5000 }, () => {
const values = Object.values(ELECTROBUN_CODES).sort();
expect(values).toEqual(
[
"ELECTROBUN.INVALID_OPTIONS",
"ELECTROBUN.OUTPUT_NOT_EMPTY",
"ELECTROBUN.CONFIG_PARSE_FAILED",
"ELECTROBUN.ICON_SOURCE_MISSING",
"ELECTROBUN.ICON_RENDER_FAILED",
"ELECTROBUN.TEMPLATE_RENDER_FAILED",
"ELECTROBUN.FS_WRITE_FAILED",
"ELECTROBUN.BINARY_UNAVAILABLE",
"ELECTROBUN.RUNNER_FAILED",
"ELECTROBUN.PROMPT_ABORTED",
"ELECTROBUN.UNKNOWN_FLAG",
].sort()
);
});
it("ElectrobunError carries code and details", { timeout: 5000 }, () => {
const err = new ElectrobunError(
ELECTROBUN_CODES.INVALID_OPTIONS,
"bad",
{ details: { field: "x" }, cause: new Error("root") }
);
expect(err.code).toBe("ELECTROBUN.INVALID_OPTIONS");
expect(err.details["field"]).toBe("x");
expect(err.name).toBe("ElectrobunError");
expect(err instanceof SuaveplanError).toBe(true);
expect(err.cause).toBeInstanceOf(Error);
});
it("ElectrobunError without options carries empty details", {
timeout: 5000,
}, () => {
const err = new ElectrobunError(ELECTROBUN_CODES.RUNNER_FAILED, "boom");
expect(err.details).toEqual({});
expect(err.cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,54 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/electrobun`.
*
* Codes follow `ELECTROBUN.<REASON>` and survive RPC / worker boundaries
* because they are plain strings produced by `defineCodes`. Consumers
* SHOULD discriminate on `error.code` instead of `instanceof` so the
* discriminator works across serialised-error surfaces.
*/
export const ELECTROBUN_CODES = defineCodes("ELECTROBUN", [
"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",
] as const);
export type ElectrobunCode =
(typeof ELECTROBUN_CODES)[keyof typeof ELECTROBUN_CODES];
/** Options accepted by the {@link ElectrobunError} constructor. */
export interface ElectrobunErrorOptions {
readonly cause?: unknown;
readonly details?: Record<string, unknown>;
}
/**
* Typed error class for every `@suaveplan/electrobun` failure path. Carries
* the registered code, a human-readable message, and an optional structured
* `details` payload through the `SuaveplanError` `context` field.
*/
export class ElectrobunError extends SuaveplanError<ElectrobunCode> {
public readonly details: Record<string, unknown>;
public constructor(
code: ElectrobunCode,
message: string,
options?: ElectrobunErrorOptions
) {
super(code, message, {
...(options?.cause === undefined ? {} : { cause: options.cause }),
context: options?.details ?? {},
});
this.name = "ElectrobunError";
this.details = options?.details ?? {};
}
}

View File

@@ -0,0 +1,5 @@
export type {
ElectrobunCode,
ElectrobunErrorOptions,
} from "./error-codes.js";
export { ELECTROBUN_CODES, ElectrobunError } from "./error-codes.js";

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,192 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import type {
ConfigDiscoveryAdapter,
FsAdapter,
GeneratedFile,
} from "../types/electrobun.types.js";
import {
directoryExists,
directoryIsEmpty,
ensureDirectory,
findConfigFile,
nodeConfigDiscoveryAdapter,
nodeFsAdapter,
writeFiles,
} from "./fs.js";
let tmp: string;
function fakeDiscovery(
files: Record<string, string | "throw" | "bad-json">,
overrides: { cwd?: string; homedir?: string } = {}
): ConfigDiscoveryAdapter & { reads: string[] } {
const reads: string[] = [];
return {
cwd: () => overrides.cwd ?? "/cwd",
homedir: () => overrides.homedir ?? "/home",
exists: (path) => path in files,
readFile: (path) => {
reads.push(path);
const value = files[path];
if (value === "throw") {
throw new Error("EACCES");
}
if (value === "bad-json") {
return "{not json";
}
return value as string;
},
reads,
};
}
describe("fs helpers", () => {
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "electrobun-fs-test-"));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("nodeConfigDiscoveryAdapter round-trips a config file", {
timeout: 5000,
}, () => {
const path = join(tmp, ".suaveplan-electrobun.json");
require("node:fs").writeFileSync(path, '{"name":"disk-app"}');
expect(nodeConfigDiscoveryAdapter.exists(path)).toBe(true);
expect(nodeConfigDiscoveryAdapter.readFile(path)).toBe(
'{"name":"disk-app"}'
);
expect(typeof nodeConfigDiscoveryAdapter.cwd()).toBe("string");
expect(typeof nodeConfigDiscoveryAdapter.homedir()).toBe("string");
});
it("nodeFsAdapter round-trips a file", { timeout: 5000 }, async () => {
const p = join(tmp, "a.txt");
await nodeFsAdapter.writeFile(p, "x");
expect(await nodeFsAdapter.readFile(p)).toBe("x");
const stats = await nodeFsAdapter.stat(tmp);
expect(stats.isDirectory()).toBe(true);
const entries = await nodeFsAdapter.readdir(tmp);
expect(entries).toContain("a.txt");
});
it("ensureDirectory creates nested dirs", { timeout: 5000 }, async () => {
const nested = join(tmp, "a", "b", "c");
await ensureDirectory(nested);
expect(await directoryExists(nested)).toBe(true);
});
it("directoryExists returns false for missing dir", {
timeout: 5000,
}, async () => {
expect(await directoryExists(join(tmp, "missing"))).toBe(false);
});
it("directoryIsEmpty true for missing dir", { timeout: 5000 }, async () => {
expect(await directoryIsEmpty(join(tmp, "missing"))).toBe(true);
});
it("directoryIsEmpty false when populated", { timeout: 5000 }, async () => {
const dir = join(tmp, "x");
await ensureDirectory(dir);
await nodeFsAdapter.writeFile(join(dir, "y"), "z");
expect(await directoryIsEmpty(dir)).toBe(false);
});
it("writeFiles writes nested paths", { timeout: 5000 }, async () => {
const files: GeneratedFile[] = [
{ path: "a.txt", content: "1" },
{ path: "nested/b.txt", content: "2" },
];
await writeFiles(tmp, files);
expect(readFileSync(join(tmp, "a.txt"), "utf-8")).toBe("1");
expect(readFileSync(join(tmp, "nested/b.txt"), "utf-8")).toBe("2");
});
it("writeFiles surfaces FS_WRITE_FAILED on adapter throw", {
timeout: 5000,
}, async () => {
const throwingFs: FsAdapter = {
...nodeFsAdapter,
writeFile: async () => {
throw new Error("disk full");
},
};
await expect(
writeFiles(tmp, [{ path: "a.txt", content: "x" }], throwingFs)
).rejects.toMatchObject({
code: ELECTROBUN_CODES.FS_WRITE_FAILED,
});
});
it("findConfigFile returns undefined when nothing present", {
timeout: 5000,
}, () => {
const discovery = fakeDiscovery({});
expect(findConfigFile(discovery)).toBeUndefined();
});
it("findConfigFile reads cwd file when present", { timeout: 5000 }, () => {
const discovery = fakeDiscovery({
"/cwd/.suaveplan-electrobun.json": '{"name":"from-cwd"}',
});
expect(findConfigFile(discovery)).toEqual({ name: "from-cwd" });
});
it("findConfigFile falls back to homedir file", { timeout: 5000 }, () => {
const discovery = fakeDiscovery({
"/home/.suaveplan-electrobun.json": '{"name":"from-home"}',
});
expect(findConfigFile(discovery)).toEqual({
name: "from-home",
});
});
it("findConfigFile wraps malformed JSON as CONFIG_PARSE_FAILED", {
timeout: 5000,
}, () => {
const discovery = fakeDiscovery({
"/cwd/.suaveplan-electrobun.json": "bad-json",
});
try {
findConfigFile(discovery);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ElectrobunError);
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.CONFIG_PARSE_FAILED
);
}
});
it("findConfigFile wraps read failure as CONFIG_PARSE_FAILED", {
timeout: 5000,
}, () => {
const discovery = fakeDiscovery({
"/cwd/.suaveplan-electrobun.json": "throw",
});
try {
findConfigFile(discovery);
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.CONFIG_PARSE_FAILED
);
}
});
});

View File

@@ -0,0 +1,171 @@
import { existsSync, readFileSync } from "node:fs";
import {
mkdir as fsMkdir,
readdir as fsReaddir,
readFile as fsReadFile,
stat as fsStat,
writeFile as fsWriteFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import type {
ElectrobunConfig,
FsAdapter,
GeneratedFile,
} from "../types/electrobun.types.js";
export const CONFIG_FILENAME = ".suaveplan-electrobun.json";
/**
* Synchronous discovery helpers used by `findConfigFile`. Kept as a
* pluggable contract so tests can drive the layered-config loader with
* an in-memory file map instead of a real temp directory.
*/
export interface ConfigDiscoveryAdapter {
exists(path: string): boolean;
readFile(path: string): string;
cwd(): string;
homedir(): string;
}
/** Default discovery adapter backed by `node:fs` + `os`. */
export const nodeConfigDiscoveryAdapter: ConfigDiscoveryAdapter = {
exists: (path) => existsSync(path),
readFile: (path) => readFileSync(path, "utf-8"),
cwd: () => process.cwd(),
homedir: () => homedir(),
};
/**
* Default async `FsAdapter` backed by `node:fs/promises`. Round-trips
* strings as UTF-8 and uses the platform's native `stat` implementation.
*/
export const nodeFsAdapter: FsAdapter = {
readFile: async (path) => fsReadFile(path, "utf-8"),
writeFile: async (path, content) => {
await fsWriteFile(path, content, "utf-8");
},
mkdir: async (path, opts) => {
await fsMkdir(path, { recursive: opts.recursive });
},
stat: async (path) => fsStat(path),
readdir: async (path) => fsReaddir(path),
};
/**
* Ensure a directory exists. Recursive by default — never raises on a
* pre-existing directory.
*/
export async function ensureDirectory(
dir: string,
fs: FsAdapter = nodeFsAdapter
): Promise<void> {
await fs.mkdir(dir, { recursive: true });
}
/** True when `dir` exists and is a directory. */
export async function directoryExists(
dir: string,
fs: FsAdapter = nodeFsAdapter
): Promise<boolean> {
try {
const stats = await fs.stat(dir);
return stats.isDirectory();
} catch {
return false;
}
}
/**
* True when `dir` either does not exist or exists with zero entries.
* Mirrors upstream behaviour: a non-existent directory is "empty enough"
* to scaffold into.
*/
export async function directoryIsEmpty(
dir: string,
fs: FsAdapter = nodeFsAdapter
): Promise<boolean> {
try {
const entries = await fs.readdir(dir);
return entries.length === 0;
} catch {
return true;
}
}
/**
* Write a list of files to disk under `outputDir`, creating missing parent
* directories along the way. Every write goes through the `FsAdapter`
* seam; a thrown adapter call is wrapped as `ELECTROBUN.FS_WRITE_FAILED`.
*/
export async function writeFiles(
outputDir: string,
files: readonly GeneratedFile[],
fs: FsAdapter = nodeFsAdapter
): Promise<void> {
for (const file of files) {
const fullPath = join(outputDir, file.path);
const dir = dirname(fullPath);
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(fullPath, file.content);
} catch (cause) {
throw new ElectrobunError(
ELECTROBUN_CODES.FS_WRITE_FAILED,
`failed to write ${fullPath}`,
{
cause,
details: {
path: fullPath,
outputDir,
},
}
);
}
}
}
/**
* Locate `.suaveplan-electrobun.json` in `cwd` or the user's home dir.
* Returns the parsed config, or `undefined` when no file is present.
* Malformed JSON raises `ELECTROBUN.CONFIG_PARSE_FAILED`. The discovery
* pass is synchronous so `loadConfig` can stay synchronous and feed the
* CLI arg-parser directly.
*/
export function findConfigFile(
discovery: ConfigDiscoveryAdapter = nodeConfigDiscoveryAdapter
): ElectrobunConfig | undefined {
const candidates = [
join(discovery.cwd(), CONFIG_FILENAME),
join(discovery.homedir(), CONFIG_FILENAME),
];
for (const candidate of candidates) {
if (!discovery.exists(candidate)) {
continue;
}
let raw: string;
try {
raw = discovery.readFile(candidate);
} catch (cause) {
throw new ElectrobunError(
ELECTROBUN_CODES.CONFIG_PARSE_FAILED,
`failed to read ${candidate}`,
{ cause, details: { path: candidate } }
);
}
try {
return JSON.parse(raw) as ElectrobunConfig;
} catch (cause) {
throw new ElectrobunError(
ELECTROBUN_CODES.CONFIG_PARSE_FAILED,
`failed to parse ${candidate}`,
{ cause, details: { path: candidate } }
);
}
}
return undefined;
}

View File

@@ -0,0 +1,11 @@
export type { ConfigDiscoveryAdapter } from "./fs.js";
export {
CONFIG_FILENAME,
directoryExists,
directoryIsEmpty,
ensureDirectory,
findConfigFile,
nodeConfigDiscoveryAdapter,
nodeFsAdapter,
writeFiles,
} from "./fs.js";

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,324 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
ELECTROBUN_CODES,
type ElectrobunError,
} from "../error-codes/error-codes.js";
import type {
FsAdapter,
IconRendererAdapter,
ProjectOptions,
} from "../types/electrobun.types.js";
import { collectFiles, generate } from "./generator.js";
function makeOptions(overrides: Partial<ProjectOptions> = {}): ProjectOptions {
return {
name: "test-app",
identifier: "com.test.app",
description: "A test application",
version: "1.0.0",
outputDir: "/tmp/test",
features: [],
networkingComplexity: "basic",
r3fModules: [],
...overrides,
};
}
interface RecordingFs extends FsAdapter {
readonly writes: Array<{ path: string; content: string }>;
readonly mkdirs: string[];
}
function recordingFs(): RecordingFs {
const writes: Array<{ path: string; content: string }> = [];
const mkdirs: string[] = [];
return {
writes,
mkdirs,
async readFile() {
return "";
},
async writeFile(path: string, content: string) {
writes.push({ path, content });
},
async mkdir(path: string) {
mkdirs.push(path);
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
}
let tmp: string;
describe("collectFiles", () => {
it("minimal options emit the nine core files", { timeout: 5000 }, () => {
const files = collectFiles(makeOptions());
const paths = files.map((f) => f.path);
expect(paths).toEqual([
"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",
]);
});
it("all eight features emit all feature paths", { timeout: 5000 }, () => {
const files = collectFiles(
makeOptions({
features: [
"i18n",
"networking",
"api",
"api-schemas",
"storage",
"auth",
"form-builder",
"r3f",
],
r3fModules: ["camera"],
})
);
const paths = files.map((f) => f.path);
for (const p of [
"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",
]) {
expect(paths).toContain(p);
}
});
it("feature order is canonical regardless of input order", {
timeout: 5000,
}, () => {
const files = collectFiles(
makeOptions({ features: ["r3f", "i18n", "api"] })
);
const featurePaths = files
.map((f) => f.path)
.filter((p) => p.startsWith("src/features/"));
expect(featurePaths).toEqual([
"src/features/i18n.ts",
"src/features/api.ts",
"src/features/r3f.tsx",
]);
});
it("collectFiles raises INVALID_OPTIONS on empty name", {
timeout: 5000,
}, () => {
try {
collectFiles(makeOptions({ name: "" }));
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.INVALID_OPTIONS
);
}
});
});
describe("generate", () => {
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "electrobun-gen-"));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("writes all core files to disk", { timeout: 30000 }, async () => {
const outputDir = join(tmp, "my-app");
const report = await generate(makeOptions({ outputDir }));
expect(report.outputDir).toBe(outputDir);
expect(report.filesCreated.length).toBeGreaterThanOrEqual(10);
expect(existsSync(join(outputDir, "electrobun.config.ts"))).toBe(true);
expect(existsSync(join(outputDir, "package.json"))).toBe(true);
expect(existsSync(join(outputDir, ".suaveplan-electrobun.json"))).toBe(
true
);
});
it("generate writes every file from collectFiles", {
timeout: 5000,
}, async () => {
const fs = recordingFs();
const opts = makeOptions({
outputDir: "/scratch",
features: ["i18n"],
});
await generate(opts, { fs });
const filesWritten = fs.writes.map((w) =>
w.path.replace("/scratch/", "")
);
for (const p of [
"electrobun.config.ts",
"package.json",
"src/features/i18n.ts",
".suaveplan-electrobun.json",
]) {
expect(filesWritten).toContain(p);
}
});
it("generate skips icons when iconSource is absent", {
timeout: 5000,
}, async () => {
const fs = recordingFs();
const report = await generate(makeOptions({ outputDir: "/scratch" }), {
fs,
});
expect(report.icons).toBeUndefined();
});
it("generate runs icons when iconSource is set", {
timeout: 5000,
}, async () => {
const fs = recordingFs();
let rendererCalls = 0;
const renderer: IconRendererAdapter = {
async render() {
rendererCalls += 1;
return { files: ["app.icns", "app.ico"] };
},
};
const report = await generate(
makeOptions({
outputDir: "/scratch",
iconSource: "/in.png",
}),
{
fs,
renderer,
fileExists: () => true,
}
);
expect(rendererCalls).toBe(1);
expect(report.icons?.generated).toBe(true);
});
it("generates valid package.json on disk", { timeout: 30000 }, async () => {
const outputDir = join(tmp, "pkg-test");
await generate(
makeOptions({
outputDir,
features: ["api", "storage"],
})
);
const pkg = JSON.parse(
readFileSync(join(outputDir, "package.json"), "utf-8")
) as {
name: string;
dependencies: Record<string, string>;
};
expect(pkg.name).toBe("test-app");
expect(pkg.dependencies["@suaveplan/api"]).toBeDefined();
expect(pkg.dependencies["@suaveplan/storage"]).toBeDefined();
});
it("generator wraps a project-renderer throw as TEMPLATE_RENDER_FAILED", {
timeout: 5000,
}, () => {
try {
collectFiles(makeOptions(), {
renderProjectFile: (path) => {
if (path === "package.json") {
throw new Error("boom");
}
return "x";
},
});
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.TEMPLATE_RENDER_FAILED
);
expect((err as ElectrobunError).details["path"]).toBe(
"package.json"
);
}
});
it("generator wraps a feature-renderer throw as TEMPLATE_RENDER_FAILED", {
timeout: 5000,
}, () => {
try {
collectFiles(makeOptions({ features: ["i18n"] }), {
renderFeatureFile: () => {
throw new Error("feature boom");
},
});
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.TEMPLATE_RENDER_FAILED
);
expect((err as ElectrobunError).details["feature"]).toBe("i18n");
}
});
it("generate uses real existsSync when no fileExists supplied", {
timeout: 30000,
}, async () => {
const outputDir = join(tmp, "real-exists");
// A non-existent iconSource path makes generateIcons raise
// ICON_SOURCE_MISSING via the default existsSync fallback.
await expect(
generate(
makeOptions({
outputDir,
iconSource: "/this/does/not/exist/either",
})
)
).rejects.toMatchObject({
code: ELECTROBUN_CODES.ICON_SOURCE_MISSING,
});
});
it("renderProjectFile override is used when supplied", {
timeout: 5000,
}, () => {
const files = collectFiles(makeOptions(), {
renderProjectFile: (path, _opts, canonical) =>
path === "README.md" ? "MARKER" : canonical(_opts),
});
const readme = files.find((f) => f.path === "README.md");
expect(readme?.content).toBe("MARKER");
});
it("renderFeatureFile override is used when supplied", {
timeout: 5000,
}, () => {
const files = collectFiles(makeOptions({ features: ["api"] }), {
renderFeatureFile: (_f, _opts, canonical) => {
canonical(_opts); // hit the canonical path for coverage
return "FEATURE_MARKER";
},
});
const api = files.find((f) => f.path === "src/features/api.ts");
expect(api?.content).toBe("FEATURE_MARKER");
});
});

View File

@@ -0,0 +1,208 @@
import { existsSync } from "node:fs";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import { ensureDirectory, nodeFsAdapter, writeFiles } from "../fs/fs.js";
import { generateIcons } from "../icons/icons.js";
import { ProjectOptionsSchema, parseOrThrow } from "../schemas/schemas.js";
import {
recordGenerateDuration,
recordProject,
} from "../telemetry/telemetry.js";
import { featurePath, renderFeature } from "../templates/features.js";
import {
renderElectrobunConfig,
renderMainProcess,
renderMainviewCss,
renderMainviewEntry,
renderMainviewHtml,
renderPackageJson,
renderReadme,
renderSharedTypes,
renderTsconfig,
} from "../templates/templates.js";
import type {
FsAdapter,
GeneratedFile,
GenerateReport,
IconRendererAdapter,
IconReport,
ProjectOptions,
} from "../types/electrobun.types.js";
import { FEATURE_ORDER } from "../types/electrobun.types.js";
const CORE_FILE_LIST: ReadonlyArray<{
path: string;
render: (options: ProjectOptions) => string;
}> = [
{ path: "electrobun.config.ts", render: renderElectrobunConfig },
{ path: "package.json", render: renderPackageJson },
{ path: "tsconfig.json", render: () => renderTsconfig() },
{ path: "src/bun/index.ts", render: renderMainProcess },
{ path: "src/mainview/index.html", render: renderMainviewHtml },
{ path: "src/mainview/index.css", render: () => renderMainviewCss() },
{ path: "src/mainview/index.tsx", render: renderMainviewEntry },
{ path: "src/shared/types.ts", render: renderSharedTypes },
{ path: "README.md", render: renderReadme },
];
export interface GenerateDeps {
readonly fs?: FsAdapter;
readonly renderer?: IconRendererAdapter;
readonly fileExists?: (path: string) => boolean;
readonly now?: () => number;
readonly overrides?: CollectOverrides;
}
/**
* Overrides accepted by {@link collectFiles}. Tests pass a faulty renderer
* here to exercise the `ELECTROBUN.TEMPLATE_RENDER_FAILED` wrapper; the
* default is "use the canonical renderer table verbatim".
*/
export interface CollectOverrides {
readonly renderProjectFile?: (
path: string,
options: ProjectOptions,
canonical: (options: ProjectOptions) => string
) => string;
readonly renderFeatureFile?: (
feature: string,
options: ProjectOptions,
canonical: (options: ProjectOptions) => string
) => string;
}
/**
* Build the list of files the generator will emit for `options`. The
* order is deterministic: nine core files first, then one file per
* selected feature in the canonical `FEATURE_ORDER` (independent of the
* caller's input order).
*/
export function collectFiles(
options: ProjectOptions,
overrides: CollectOverrides = {}
): GeneratedFile[] {
const parsed = parseOrThrow(
ProjectOptionsSchema,
options,
"ProjectOptions"
);
const renderProject = overrides.renderProjectFile;
const renderFeatureOverride = overrides.renderFeatureFile;
const files: GeneratedFile[] = CORE_FILE_LIST.map((entry) => {
try {
const content = renderProject
? renderProject(entry.path, parsed, entry.render)
: entry.render(parsed);
return { path: entry.path, content };
} catch (cause) {
throw new ElectrobunError(
ELECTROBUN_CODES.TEMPLATE_RENDER_FAILED,
`failed to render ${entry.path}`,
{ cause, details: { path: entry.path } }
);
}
});
const selected = new Set(parsed.features);
for (const feature of FEATURE_ORDER) {
if (!selected.has(feature)) {
continue;
}
try {
const canonical = (opts: ProjectOptions): string =>
renderFeature(feature, opts);
const content = renderFeatureOverride
? renderFeatureOverride(feature, parsed, canonical)
: canonical(parsed);
files.push({ path: featurePath(feature), content });
} catch (cause) {
throw new ElectrobunError(
ELECTROBUN_CODES.TEMPLATE_RENDER_FAILED,
`failed to render feature ${feature}`,
{ cause, details: { feature } }
);
}
}
return files;
}
/**
* Scaffold a new Electrobun project under `options.outputDir`. Writes
* every file from `collectFiles(options)`, then the
* `.suaveplan-electrobun.json` snapshot, then optionally runs the icon
* pipeline when `options.iconSource` is set.
*
* Returns a `GenerateReport`; the histogram
* `electrobun_generate_duration_ms` receives one observation per call,
* and `electrobun_projects_generated_total` increments once per call.
*/
export async function generate(
options: ProjectOptions,
deps: GenerateDeps = {}
): Promise<GenerateReport> {
const parsed = parseOrThrow(
ProjectOptionsSchema,
options,
"ProjectOptions"
);
const fs = deps.fs ?? nodeFsAdapter;
const now = deps.now ?? Date.now;
const started = now();
await ensureDirectory(parsed.outputDir, fs);
const files = collectFiles(parsed, deps.overrides ?? {});
await writeFiles(parsed.outputDir, files, fs);
const configSnapshot = JSON.stringify(
{
name: parsed.name,
identifier: parsed.identifier,
description: parsed.description,
version: parsed.version,
features: parsed.features,
networkingComplexity: parsed.networkingComplexity,
r3fModules: parsed.r3fModules,
},
null,
2
);
await writeFiles(
parsed.outputDir,
[
{
path: ".suaveplan-electrobun.json",
content: `${configSnapshot}\n`,
},
],
fs
);
let icons: IconReport | undefined;
if (parsed.iconSource) {
icons = await generateIcons(
{ inputPath: parsed.iconSource, outputDir: parsed.outputDir },
{
...(deps.renderer === undefined
? {}
: { renderer: deps.renderer }),
fileExists: deps.fileExists ?? ((p) => existsSync(p)),
}
);
}
const durationMs = now() - started;
recordProject(parsed.features.length);
recordGenerateDuration(durationMs, parsed.features.length);
return {
outputDir: parsed.outputDir,
filesCreated: [
...files.map((f) => f.path),
".suaveplan-electrobun.json",
],
durationMs,
icons,
};
}

View File

@@ -0,0 +1,2 @@
export type { CollectOverrides, GenerateDeps } from "./generator.js";
export { collectFiles, generate } from "./generator.js";

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,157 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import type { IconRendererAdapter } from "../types/electrobun.types.js";
import { generateIcons, iconzRendererAdapter } from "./icons.js";
const ONE_PX_PNG = Buffer.from(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489000000017352474200aece1ce90000000d49444154789c63600100000005000159c8fdcf0000000049454e44ae426082",
"hex"
);
const fileExistsAlways = () => true;
const fileExistsNever = () => false;
const recordingRenderer = (): IconRendererAdapter & {
last?: Parameters<IconRendererAdapter["render"]>[0];
} => {
const adapter = {
last: undefined as unknown,
async render(input: Parameters<IconRendererAdapter["render"]>[0]) {
adapter.last = input;
return {
files: ["app.icns", "app.ico", "icon-16.png", "icon-32.png"],
};
},
};
return adapter as never;
};
const throwingRenderer: IconRendererAdapter = {
async render() {
throw new Error("imagemagick missing");
},
};
describe("generateIcons", () => {
it("validates options against the schema", { timeout: 5000 }, async () => {
try {
await generateIcons(
{ inputPath: "", outputDir: "" },
{ renderer: recordingRenderer(), fileExists: fileExistsAlways }
);
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.INVALID_OPTIONS
);
}
});
it("missing source raises ICON_SOURCE_MISSING", {
timeout: 5000,
}, async () => {
try {
await generateIcons(
{ inputPath: "/does/not/exist", outputDir: "/tmp/a" },
{ fileExists: fileExistsNever }
);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ElectrobunError);
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.ICON_SOURCE_MISSING
);
}
});
it("returns IconReport with files on success", {
timeout: 5000,
}, async () => {
const renderer = recordingRenderer();
const report = await generateIcons(
{ inputPath: "/in.png", outputDir: "/tmp/out" },
{ renderer, fileExists: fileExistsAlways }
);
expect(report.generated).toBe(true);
expect(report.files).toContain("icons/app.icns");
expect(report.files).toContain("icons/app.ico");
const last = (renderer as { last: { output: string } }).last;
expect(last.output).toBe("/tmp/out/icons");
});
it("renderer throw is wrapped as ICON_RENDER_FAILED", {
timeout: 5000,
}, async () => {
const report = await generateIcons(
{ inputPath: "/in.png", outputDir: "/tmp/out" },
{ renderer: throwingRenderer, fileExists: fileExistsAlways }
);
expect(report.generated).toBe(false);
expect(report.files).toEqual([]);
expect(report.error).toContain("imagemagick missing");
});
it("renderer non-Error throw is stringified", {
timeout: 5000,
}, async () => {
const adapter: IconRendererAdapter = {
async render() {
throw "stringly-typed";
},
};
const report = await generateIcons(
{ inputPath: "/in.png", outputDir: "/tmp/out" },
{ renderer: adapter, fileExists: fileExistsAlways }
);
expect(report.error).toContain("stringly-typed");
});
it("generateIcons falls through to the default iconz adapter", {
timeout: 30000,
}, async () => {
const tmp = mkdtempSync(join(tmpdir(), "electrobun-iconz-fb-"));
try {
const input = join(tmp, "source.png");
writeFileSync(input, ONE_PX_PNG);
const report = await generateIcons({
inputPath: input,
outputDir: tmp,
});
expect(report.generated).toBe(true);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("iconzRendererAdapter renders icons from a real PNG", {
timeout: 30000,
}, async () => {
const tmp = mkdtempSync(join(tmpdir(), "electrobun-iconz-"));
try {
const input = join(tmp, "source.png");
writeFileSync(input, ONE_PX_PNG);
const result = await iconzRendererAdapter.render({
input,
output: join(tmp, "icons"),
icons: {
icns: { name: "app", sizes: [16, 32] },
ico: { name: "app", sizes: [16, 32] },
png: {
name: "icon-{{dims}}",
sizes: [16, 32],
},
},
});
expect(Array.isArray(result.files)).toBe(true);
expect(result.files.length).toBeGreaterThan(0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,143 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import {
GenerateIconsOptionsSchema,
parseOrThrow,
} from "../schemas/schemas.js";
import { recordIcons } from "../telemetry/telemetry.js";
import type {
IconRendererAdapter,
IconRendererInput,
IconReport,
} from "../types/electrobun.types.js";
const ICNS_SIZES = [16, 32, 64, 128, 256, 512, 1024] as const;
const ICO_SIZES = [16, 32, 48, 64, 128, 256] as const;
const PNG_SIZES = [16, 32, 48, 64, 128, 256, 512, 1024] as const;
interface IconzModule {
createIconz(input: {
input: string;
output: string;
icons: Record<
string,
{
type: string;
name: string;
sizes: readonly number[];
folder?: string;
}
>;
}): { generate(): Promise<unknown> };
}
/**
* Default icon renderer backed by the `iconz` package. Loads the upstream
* library lazily so test environments that swap in a recording adapter
* never pay the import cost.
*/
export const iconzRendererAdapter: IconRendererAdapter = {
async render(input: IconRendererInput) {
const mod = (await import("iconz")) as unknown as IconzModule;
const iconz = mod.createIconz({
input: input.input,
output: input.output,
icons: {
appIcns: {
type: "icns",
name: input.icons.icns.name,
sizes: input.icons.icns.sizes,
},
appIco: {
type: "ico",
name: input.icons.ico.name,
sizes: input.icons.ico.sizes,
},
pngIcons: {
type: "png",
name: input.icons.png.name,
sizes: input.icons.png.sizes,
folder: ".",
},
},
});
await iconz.generate();
const files = [
`${input.icons.icns.name}.icns`,
`${input.icons.ico.name}.ico`,
...input.icons.png.sizes.map(
(size) =>
`${input.icons.png.name.replace("{{dims}}", String(size))}.png`
),
];
return { files };
},
};
export interface GenerateIconsOptions {
readonly inputPath: string;
readonly outputDir: string;
}
export interface GenerateIconsDeps {
readonly renderer?: IconRendererAdapter;
readonly fileExists?: (path: string) => boolean;
}
/**
* Run the icon pipeline against the user's input image. The default
* renderer is `iconz`; tests may inject a recording fake to assert the
* `IconRendererInput` shape. Returns an `IconReport`; raises
* `ELECTROBUN.ICON_SOURCE_MISSING` when the input path does not exist,
* and wraps renderer throws as `ELECTROBUN.ICON_RENDER_FAILED` (the
* report's `error` field carries the underlying message).
*/
export async function generateIcons(
options: GenerateIconsOptions,
deps: GenerateIconsDeps = {}
): Promise<IconReport> {
const parsed = parseOrThrow(
GenerateIconsOptionsSchema,
options,
"GenerateIconsOptions"
);
const fileExists = deps.fileExists ?? ((p: string) => existsSync(p));
if (!fileExists(parsed.inputPath)) {
recordIcons("failed");
throw new ElectrobunError(
ELECTROBUN_CODES.ICON_SOURCE_MISSING,
`icon source not found: ${parsed.inputPath}`,
{ details: { inputPath: parsed.inputPath } }
);
}
const renderer = deps.renderer ?? iconzRendererAdapter;
const iconsDir = join(parsed.outputDir, "icons");
try {
const { files } = await renderer.render({
input: parsed.inputPath,
output: iconsDir,
icons: {
icns: { name: "app", sizes: ICNS_SIZES },
ico: { name: "app", sizes: ICO_SIZES },
png: { name: "icon-{{dims}}", sizes: PNG_SIZES },
},
});
recordIcons("generated");
return {
generated: true,
files: files.map((f) => `icons/${f}`),
};
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
recordIcons("failed");
return {
generated: false,
files: [],
error: `Failed to generate icons: ${message}`,
};
}
}

View File

@@ -0,0 +1,2 @@
export type { GenerateIconsDeps, GenerateIconsOptions } from "./icons.js";
export { generateIcons, iconzRendererAdapter } from "./icons.js";

View File

@@ -0,0 +1,142 @@
export type {
CreateCommandOptions,
RunCliOptions,
} from "./cli/cli.js";
export {
buildCommand,
createCommand,
devCommand,
HELP_TEXT,
packageCommand,
runCli,
signCommand,
} from "./cli/cli.js";
export {
CLI_MAPPING,
HARDCODED_DEFAULTS,
loadConfig,
} from "./config/config.js";
export type {
ElectrobunCode,
ElectrobunErrorOptions,
} from "./error-codes/error-codes.js";
export {
ELECTROBUN_CODES,
ElectrobunError,
} from "./error-codes/error-codes.js";
export type { ConfigDiscoveryAdapter } from "./fs/fs.js";
export {
CONFIG_FILENAME,
directoryExists,
directoryIsEmpty,
ensureDirectory,
findConfigFile,
nodeConfigDiscoveryAdapter,
nodeFsAdapter,
writeFiles,
} from "./fs/fs.js";
export type {
CollectOverrides,
GenerateDeps,
} from "./generator/generator.js";
export { collectFiles, generate } from "./generator/generator.js";
export type {
GenerateIconsDeps,
GenerateIconsOptions,
} from "./icons/icons.js";
export { generateIcons, iconzRendererAdapter } from "./icons/icons.js";
export { runPrompts } from "./prompts/prompts.js";
export {
invokeElectrobun,
nodeCommandRunner,
probeElectrobun,
} from "./runner/runner.js";
export {
BuildCommandOptionsSchema,
BuildTargetSchema,
DevCommandOptionsSchema,
ElectrobunConfigSchema,
FeatureModuleSchema,
GenerateIconsOptionsSchema,
NetworkingComplexitySchema,
PackageCommandOptionsSchema,
ProjectOptionsSchema,
parseOrThrow,
R3fModuleSchema,
SignCommandOptionsSchema,
} from "./schemas/schemas.js";
export type {
IconOutcome,
PromptOutcome,
RunnerCommand,
} from "./telemetry/telemetry.js";
export {
generateDurationHistogram,
iconsGeneratedCounter,
projectsGeneratedCounter,
promptsCounter,
recordGenerateDuration,
recordIcons,
recordProject,
recordPromptOutcome,
recordRunnerDuration,
recordRunnerFailure,
recordRunnerInvocation,
runnerDurationHistogram,
runnerFailuresCounter,
runnerInvocationsCounter,
} from "./telemetry/telemetry.js";
export {
featurePath,
renderApiFeature,
renderApiSchemasFeature,
renderAuthFeature,
renderFeature,
renderFormBuilderFeature,
renderI18nFeature,
renderNetworkFeature,
renderNetworkFullFeature,
renderR3fFeature,
renderStorageFeature,
} from "./templates/features.js";
export type { ProjectFileKind } from "./templates/templates.js";
export {
FEATURE_LABELS,
R3F_MODULE_LABELS,
renderElectrobunConfig,
renderMainProcess,
renderMainviewCss,
renderMainviewEntry,
renderMainviewHtml,
renderPackageJson,
renderProjectFile,
renderReadme,
renderSharedTypes,
renderTsconfig,
} from "./templates/templates.js";
export type {
BuildCommandOptions,
BuildTarget,
CommandDeps,
CommandInput,
CommandReport,
CommandResult,
CommandRunner,
DevCommandOptions,
ElectrobunConfig,
FeatureModule,
FsAdapter,
GeneratedFile,
GenerateReport,
IconRendererAdapter,
IconRendererInput,
IconRendererResult,
IconReport,
NetworkingComplexity,
PackageCommandOptions,
ProjectOptions,
Prompter,
R3fModule,
SignCommandOptions,
} from "./types/electrobun.types.js";
export { FEATURE_ORDER } from "./types/electrobun.types.js";

View File

@@ -0,0 +1 @@
export { runPrompts } from "./prompts.js";

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,209 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ELECTROBUN_CODES,
type ElectrobunError,
} from "../error-codes/error-codes.js";
import { ProjectOptionsSchema } from "../schemas/schemas.js";
import type {
ElectrobunConfig,
FeatureModule,
NetworkingComplexity,
Prompter,
R3fModule,
} from "../types/electrobun.types.js";
import { runPrompts } from "./prompts.js";
interface ScriptedAnswers {
name?: string;
identifier?: string;
description?: string;
version?: string;
iconSource?: string;
features?: readonly FeatureModule[];
networkingComplexity?: NetworkingComplexity;
r3fModules?: readonly R3fModule[];
proceed?: boolean;
}
function scriptedPrompter(answers: ScriptedAnswers = {}): Prompter {
let textCount = 0;
const textKeys: ReadonlyArray<keyof ScriptedAnswers> = [
"name",
"identifier",
"description",
"version",
"iconSource",
];
const selectQueue: NetworkingComplexity[] = [];
if (answers.networkingComplexity !== undefined) {
selectQueue.push(answers.networkingComplexity);
}
const multiselectQueue: ReadonlyArray<readonly string[]> = [
(answers.features ?? []) as readonly string[],
(answers.r3fModules ?? []) as readonly string[],
];
let multiselectIdx = 0;
return {
text: async ({ default: d }) => {
const key = textKeys[textCount] as keyof ScriptedAnswers;
textCount += 1;
const value = answers[key];
if (typeof value === "string") {
return value;
}
return d ?? "";
},
confirm: async ({ default: d }) => answers.proceed ?? d ?? true,
select: async <T extends string>({
default: d,
}: {
default?: T;
}): Promise<T> => (selectQueue.shift() ?? d ?? "basic") as T,
multiselect: async <T extends string>(): Promise<readonly T[]> => {
const next = multiselectQueue[multiselectIdx] ?? [];
multiselectIdx += 1;
return next as readonly T[];
},
};
}
const emptyConfig: ElectrobunConfig = {};
describe("runPrompts", () => {
it("runPrompts assembles a complete ProjectOptions", {
timeout: 5000,
}, async () => {
const result = await runPrompts(
emptyConfig,
"/tmp/out",
scriptedPrompter({
name: "demo",
identifier: "com.example.demo",
description: "demo app",
version: "1.2.3",
iconSource: "",
features: ["i18n"],
proceed: true,
})
);
expect(ProjectOptionsSchema.safeParse(result).success).toBe(true);
expect(result.name).toBe("demo");
expect(result.iconSource).toBeUndefined();
expect(result.outputDir).toBe("/tmp/out");
expect(result.networkingComplexity).toBe("basic");
expect(result.r3fModules).toEqual([]);
});
it("runPrompts uses name as outputDir when not supplied", {
timeout: 5000,
}, async () => {
const result = await runPrompts(
emptyConfig,
undefined,
scriptedPrompter({
name: "auto-dir",
proceed: true,
})
);
expect(result.outputDir).toBe("auto-dir");
});
it("runPrompts pulls defaults from the supplied config", {
timeout: 5000,
}, async () => {
const result = await runPrompts(
{
name: "cfg-app",
identifier: "com.cfg.app",
description: "from cfg",
version: "9.9.9",
iconSource: "/cfg.png",
networkingComplexity: "full",
},
undefined,
scriptedPrompter({ proceed: true })
);
expect(result.name).toBe("cfg-app");
expect(result.iconSource).toBe("/cfg.png");
});
it("runPrompts asks for networking complexity when feature selected", {
timeout: 5000,
}, async () => {
const result = await runPrompts(
emptyConfig,
"/tmp",
scriptedPrompter({
name: "n",
features: ["networking"],
networkingComplexity: "full",
proceed: true,
})
);
expect(result.networkingComplexity).toBe("full");
});
it("runPrompts asks for r3f modules when feature selected", {
timeout: 5000,
}, async () => {
const result = await runPrompts(
emptyConfig,
"/tmp",
scriptedPrompter({
name: "n",
features: ["r3f"],
r3fModules: ["camera", "physics"],
proceed: true,
})
);
expect(result.r3fModules).toEqual(["camera", "physics"]);
});
it("negative confirmation aborts the prompt", {
timeout: 5000,
}, async () => {
try {
await runPrompts(
emptyConfig,
"/tmp",
scriptedPrompter({
name: "n",
proceed: false,
})
);
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.PROMPT_ABORTED
);
}
});
it("deriveIdentifier defaults to com.example.<slug>", {
timeout: 5000,
}, async () => {
const result = await runPrompts(
emptyConfig,
"/tmp",
scriptedPrompter({
name: "Spaced Name!",
proceed: true,
})
);
expect(result.identifier).toBe("com.example.spaced-name");
});
it("deriveIdentifier falls back to com.example.app on all-symbols name", {
timeout: 5000,
}, async () => {
const result = await runPrompts(
emptyConfig,
"/tmp",
scriptedPrompter({
name: "!!!",
proceed: true,
})
);
expect(result.identifier).toBe("com.example.app");
});
});

View File

@@ -0,0 +1,118 @@
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import { recordPromptOutcome } from "../telemetry/telemetry.js";
import { FEATURE_LABELS, R3F_MODULE_LABELS } from "../templates/templates.js";
import type {
ElectrobunConfig,
FeatureModule,
NetworkingComplexity,
ProjectOptions,
Prompter,
R3fModule,
} from "../types/electrobun.types.js";
function deriveIdentifier(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
return `com.example.${slug.length > 0 ? slug : "app"}`;
}
const FEATURE_CHOICES: ReadonlyArray<{ value: FeatureModule; label: string }> =
(
Object.entries(FEATURE_LABELS) as ReadonlyArray<[FeatureModule, string]>
).map(([value, label]) => ({ value, label }));
const R3F_CHOICES: ReadonlyArray<{ value: R3fModule; label: string }> = (
Object.entries(R3F_MODULE_LABELS) as ReadonlyArray<[R3fModule, string]>
).map(([value, label]) => ({ value, label }));
const NETWORK_CHOICES: ReadonlyArray<{
value: NetworkingComplexity;
label: string;
}> = [
{ value: "basic", label: "Basic discovery + messaging" },
{
value: "full",
label: "Full mesh with state sync, vector clocks, and roles",
},
];
/**
* Drive the interactive scaffolder via the injectable `Prompter`. Defaults
* carry through from the layered config when present, so the user can
* accept the existing answers by hitting return. A negative `proceed`
* confirmation surfaces as `ELECTROBUN.PROMPT_ABORTED`.
*/
export async function runPrompts(
config: ElectrobunConfig,
outputDir: string | undefined,
prompter: Prompter
): Promise<ProjectOptions> {
const name = await prompter.text({
message: "App name:",
default: config.name ?? "my-electrobun-app",
});
const identifier = await prompter.text({
message: "App identifier (e.g. com.company.app):",
default: config.identifier ?? deriveIdentifier(name),
});
const description = await prompter.text({
message: "Description:",
default: config.description ?? "An Electrobun desktop application",
});
const version = await prompter.text({
message: "Version:",
default: config.version ?? "0.0.1",
});
const iconSource = await prompter.text({
message: "Icon source image path (leave empty to skip):",
default: config.iconSource ?? "",
});
const features = await prompter.multiselect<FeatureModule>({
message: "Select feature modules to include:",
choices: FEATURE_CHOICES,
});
let networkingComplexity: NetworkingComplexity = "basic";
if (features.includes("networking")) {
networkingComplexity = await prompter.select<NetworkingComplexity>({
message: "Networking complexity:",
choices: NETWORK_CHOICES,
default: config.networkingComplexity ?? "basic",
});
}
let r3fModules: readonly R3fModule[] = [];
if (features.includes("r3f")) {
r3fModules = await prompter.multiselect<R3fModule>({
message: "Select R3F modules:",
choices: R3F_CHOICES,
});
}
const resolvedDir = outputDir ?? name;
const proceed = await prompter.confirm({
message: `Create "${name}" in ./${resolvedDir}?`,
default: true,
});
if (!proceed) {
recordPromptOutcome("aborted");
throw new ElectrobunError(
ELECTROBUN_CODES.PROMPT_ABORTED,
"user aborted the prompt"
);
}
recordPromptOutcome("completed");
return {
name,
identifier,
description,
version,
outputDir: resolvedDir,
iconSource: iconSource.length > 0 ? iconSource : undefined,
features,
networkingComplexity,
r3fModules,
};
}

View File

@@ -0,0 +1,5 @@
export {
invokeElectrobun,
nodeCommandRunner,
probeElectrobun,
} from "./runner.js";

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,253 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import type {
CommandInput,
CommandResult,
CommandRunner,
} from "../types/electrobun.types.js";
import {
invokeElectrobun,
nodeCommandRunner,
probeElectrobun,
} from "./runner.js";
function fakeRunner(result: Partial<CommandResult>): CommandRunner & {
calls: CommandInput[];
} {
const calls: CommandInput[] = [];
const fn: CommandRunner = async (input) => {
calls.push(input);
return {
exitCode: result.exitCode ?? 0,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
durationMs: result.durationMs ?? 1,
};
};
return Object.assign(fn, { calls });
}
describe("runner", () => {
it("probeElectrobun returns available with version on success", {
timeout: 5000,
}, async () => {
const runner = fakeRunner({ exitCode: 0, stdout: "1.2.3\n" });
const result = await probeElectrobun(runner);
expect(result.available).toBe(true);
expect(result.version).toBe("1.2.3");
});
it("probeElectrobun returns unavailable on non-zero exit", {
timeout: 5000,
}, async () => {
const runner = fakeRunner({ exitCode: 127, stdout: "" });
const result = await probeElectrobun(runner);
expect(result.available).toBe(false);
});
it("probeElectrobun returns available without version when stdout has no triple", {
timeout: 5000,
}, async () => {
const runner = fakeRunner({ exitCode: 0, stdout: "ok" });
const result = await probeElectrobun(runner);
expect(result.available).toBe(true);
expect(result.version).toBeUndefined();
});
it("probeElectrobun parses version from stderr fallback", {
timeout: 5000,
}, async () => {
const runner = fakeRunner({
exitCode: 0,
stdout: "",
stderr: "4.5.6",
});
const result = await probeElectrobun(runner);
expect(result.version).toBe("4.5.6");
});
it("invokeElectrobun raises BINARY_UNAVAILABLE when probe fails", {
timeout: 5000,
}, async () => {
const runner = fakeRunner({ exitCode: 127 });
try {
await invokeElectrobun({
runner,
cwd: "/tmp",
args: ["build"],
command: "build",
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ElectrobunError);
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.BINARY_UNAVAILABLE
);
}
});
it("invokeElectrobun raises RUNNER_FAILED on non-zero exit", {
timeout: 5000,
}, async () => {
const exitCodes: number[] = [0, 2];
const runner: CommandRunner = async () => ({
exitCode: exitCodes.shift() ?? 0,
stdout: "1.0.0",
stderr: "boom",
durationMs: 1,
});
try {
await invokeElectrobun({
runner,
cwd: "/tmp",
args: ["build", "--target", "mac"],
command: "build",
});
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.RUNNER_FAILED
);
expect((err as ElectrobunError).details["exitCode"]).toBe(2);
expect((err as ElectrobunError).details["command"]).toBe("build");
}
});
it("invokeElectrobun returns result on success", {
timeout: 5000,
}, async () => {
const runner: CommandRunner = async () => ({
exitCode: 0,
stdout: "1.0.0",
stderr: "",
durationMs: 1,
});
const result = await invokeElectrobun({
runner,
cwd: "/tmp",
args: ["dev"],
command: "dev",
});
expect(result.exitCode).toBe(0);
});
it("invokeElectrobun forwards AbortSignal through CommandInput", {
timeout: 5000,
}, async () => {
const seen: AbortSignal[] = [];
let probeCount = 0;
const runner: CommandRunner = async (input) => {
if (input.signal) {
seen.push(input.signal);
}
probeCount += 1;
return {
exitCode: 0,
stdout: probeCount === 1 ? "1.0.0" : "",
stderr: "",
durationMs: 1,
};
};
const ctl = new AbortController();
await invokeElectrobun({
runner,
cwd: "/tmp",
args: ["dev"],
command: "dev",
signal: ctl.signal,
});
expect(seen.length).toBe(1);
});
it("nodeCommandRunner spawns the host runtime and reports exit 0", {
timeout: 5000,
}, async () => {
// Use process.execPath instead of "node" — under turbo+bun,
// PATH includes a bun "node" shim that rejects --version.
const result = await nodeCommandRunner({
command: process.execPath,
args: ["--version"],
});
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toMatch(
/^(v?\d+\.\d+\.\d+|.*\d+\.\d+\.\d+)/
);
});
it("nodeCommandRunner reports non-zero exit", {
timeout: 5000,
}, async () => {
const result = await nodeCommandRunner({
command: "node",
args: ["-e", "process.exit(7)"],
});
expect(result.exitCode).toBe(7);
});
it("nodeCommandRunner rejects on spawn error", {
timeout: 5000,
}, async () => {
await expect(
nodeCommandRunner({
command: "this-binary-should-not-exist-anywhere-12345",
args: [],
})
).rejects.toBeInstanceOf(Error);
});
it("nodeCommandRunner honours an aborted signal", {
timeout: 5000,
}, async () => {
const ctl = new AbortController();
const started = Date.now();
const promise = nodeCommandRunner({
command: "node",
args: [
"-e",
"setInterval(()=>{},1000); setTimeout(()=>process.exit(0),10000)",
],
signal: ctl.signal,
});
setTimeout(() => ctl.abort(), 25);
const result = await promise;
const elapsed = Date.now() - started;
// The child must terminate quickly after abort — within seconds, not
// its scheduled 10s lifespan.
expect(elapsed).toBeLessThan(3000);
expect(result.durationMs).toBeLessThan(3000);
});
it("nodeCommandRunner passes env overrides through", {
timeout: 5000,
}, async () => {
const result = await nodeCommandRunner({
command: "node",
args: ["-e", "process.stdout.write(process.env.ELECTROBUN_TEST_X)"],
env: { ELECTROBUN_TEST_X: "abc" },
});
expect(result.stdout).toBe("abc");
});
it("probeElectrobun uses nodeCommandRunner default when no runner supplied", {
timeout: 10000,
}, async () => {
// The real `bunx electrobun --version` probe may or may not
// succeed depending on the host; the test only asserts that
// the default-runner path returns a structured result.
const result = await probeElectrobun();
expect(typeof result.available).toBe("boolean");
});
it("nodeCommandRunner captures stderr from the child", {
timeout: 5000,
}, async () => {
const result = await nodeCommandRunner({
command: "node",
args: ["-e", "process.stderr.write('err-output'); process.exit(0)"],
});
expect(result.stderr).toContain("err-output");
});
});

View File

@@ -0,0 +1,127 @@
import { spawn } from "node:child_process";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import type {
CommandInput,
CommandResult,
CommandRunner,
} from "../types/electrobun.types.js";
/**
* Default `CommandRunner` backed by `node:child_process.spawn`. Buffers
* stdout / stderr as UTF-8 strings and records the wall-clock duration.
*/
export const nodeCommandRunner: CommandRunner = async (input) => {
const started = Date.now();
return new Promise<CommandResult>((resolve, reject) => {
const child = spawn(input.command, [...input.args], {
cwd: input.cwd,
env: input.env
? ({ ...process.env, ...input.env } as NodeJS.ProcessEnv)
: (process.env as NodeJS.ProcessEnv),
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf-8");
child.stderr?.setEncoding("utf-8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk: string) => {
stderr += chunk;
});
const onAbort = (): void => {
child.kill("SIGTERM");
};
input.signal?.addEventListener("abort", onAbort, { once: true });
child.on("error", (err) => {
input.signal?.removeEventListener("abort", onAbort);
reject(err);
});
child.on("close", (code) => {
input.signal?.removeEventListener("abort", onAbort);
resolve({
exitCode: code ?? 0,
stdout,
stderr,
durationMs: Date.now() - started,
});
});
});
};
const VERSION_RE = /(\d+\.\d+\.\d+)/;
/** Probe for the `electrobun` binary via `bunx electrobun --version`. */
export async function probeElectrobun(
runner: CommandRunner = nodeCommandRunner,
cwd?: string
): Promise<{ available: boolean; version?: string }> {
const result = await runner({
command: "bunx",
args: ["electrobun", "--version"],
...(cwd === undefined ? {} : { cwd }),
});
if (result.exitCode !== 0) {
return { available: false };
}
const match =
VERSION_RE.exec(result.stdout) ?? VERSION_RE.exec(result.stderr);
if (match?.[1]) {
return { available: true, version: match[1] };
}
return { available: true };
}
/**
* Run the electrobun binary with the supplied subcommand args. Raises
* `ELECTROBUN.BINARY_UNAVAILABLE` when the probe fails and
* `ELECTROBUN.RUNNER_FAILED` on a non-zero exit.
*/
export async function invokeElectrobun(input: {
runner: CommandRunner;
cwd: string;
args: readonly string[];
signal?: AbortSignal;
command: "dev" | "build" | "package" | "sign";
}): Promise<CommandResult> {
const probe = await probeElectrobun(input.runner, input.cwd);
if (!probe.available) {
throw new ElectrobunError(
ELECTROBUN_CODES.BINARY_UNAVAILABLE,
"electrobun binary unavailable",
{
details: {
cwd: input.cwd,
remediation:
"Install electrobun: `bun add -D electrobun` in the project root",
},
}
);
}
const runInput: CommandInput = {
command: "bunx",
args: ["electrobun", ...input.args],
cwd: input.cwd,
...(input.signal === undefined ? {} : { signal: input.signal }),
};
const result = await input.runner(runInput);
if (result.exitCode !== 0) {
throw new ElectrobunError(
ELECTROBUN_CODES.RUNNER_FAILED,
`electrobun ${input.command} exited ${result.exitCode}`,
{
details: {
command: input.command,
exitCode: result.exitCode,
stderr: result.stderr,
args: [...input.args],
},
}
);
}
return result;
}

View File

@@ -0,0 +1,14 @@
export {
BuildCommandOptionsSchema,
BuildTargetSchema,
DevCommandOptionsSchema,
ElectrobunConfigSchema,
FeatureModuleSchema,
GenerateIconsOptionsSchema,
NetworkingComplexitySchema,
PackageCommandOptionsSchema,
ProjectOptionsSchema,
parseOrThrow,
R3fModuleSchema,
SignCommandOptionsSchema,
} from "./schemas.js";

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,97 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import type { ProjectOptions } from "../types/electrobun.types.js";
import {
BuildCommandOptionsSchema,
ProjectOptionsSchema,
parseOrThrow,
SignCommandOptionsSchema,
} from "./schemas.js";
function makeOptions(overrides: Partial<ProjectOptions> = {}): ProjectOptions {
return {
name: "test-app",
identifier: "com.test.app",
description: "An app",
version: "1.0.0",
outputDir: "/tmp/x",
features: [],
networkingComplexity: "basic",
r3fModules: [],
...overrides,
};
}
describe("schemas", () => {
it("ProjectOptionsSchema rejects empty name", { timeout: 5000 }, () => {
const result = ProjectOptionsSchema.safeParse(
makeOptions({ name: "" })
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((i) => i.path[0] === "name")).toBe(
true
);
}
});
it("ProjectOptionsSchema accepts valid options", { timeout: 5000 }, () => {
const result = ProjectOptionsSchema.safeParse(
makeOptions({ features: ["i18n"], r3fModules: ["camera"] })
);
expect(result.success).toBe(true);
});
it("BuildCommandOptionsSchema enforces target enum", {
timeout: 5000,
}, () => {
const result = BuildCommandOptionsSchema.safeParse({
cwd: "/tmp",
target: "bsd",
});
expect(result.success).toBe(false);
});
it("SignCommandOptionsSchema requires identity", { timeout: 5000 }, () => {
const result = SignCommandOptionsSchema.safeParse({
cwd: "/tmp",
identity: "",
});
expect(result.success).toBe(false);
});
it("parseOrThrow raises ELECTROBUN.INVALID_OPTIONS on bad input", {
timeout: 5000,
}, () => {
try {
parseOrThrow(
ProjectOptionsSchema,
makeOptions({ name: "" }),
"ProjectOptions"
);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ElectrobunError);
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.INVALID_OPTIONS
);
expect(
Array.isArray((err as ElectrobunError).details["issues"])
).toBe(true);
}
});
it("parseOrThrow returns parsed value on success", {
timeout: 5000,
}, () => {
const parsed = parseOrThrow(
ProjectOptionsSchema,
makeOptions(),
"ProjectOptions"
);
expect(parsed.name).toBe("test-app");
});
});

View File

@@ -0,0 +1,114 @@
import z from "zod";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
const FEATURE_VALUES = [
"i18n",
"networking",
"api",
"api-schemas",
"storage",
"auth",
"form-builder",
"r3f",
] as const;
const NETWORKING_VALUES = ["basic", "full"] as const;
const R3F_VALUES = [
"camera",
"physics",
"character",
"particles",
"pathfinding",
] as const;
const TARGET_VALUES = ["mac", "linux", "windows"] as const;
export const FeatureModuleSchema = z.enum(FEATURE_VALUES);
export const NetworkingComplexitySchema = z.enum(NETWORKING_VALUES);
export const R3fModuleSchema = z.enum(R3F_VALUES);
export const BuildTargetSchema = z.enum(TARGET_VALUES);
const NonEmptyString = z.string().min(1);
export const ProjectOptionsSchema = z.object({
name: NonEmptyString,
identifier: NonEmptyString,
description: z.string(),
version: NonEmptyString,
outputDir: NonEmptyString,
iconSource: z.string().optional(),
features: z.array(FeatureModuleSchema).readonly(),
networkingComplexity: NetworkingComplexitySchema,
r3fModules: z.array(R3fModuleSchema).readonly(),
});
export const ElectrobunConfigSchema = z.object({
name: z.string().optional(),
identifier: z.string().optional(),
description: z.string().optional(),
version: z.string().optional(),
iconSource: z.string().optional(),
features: z.array(FeatureModuleSchema).optional(),
networkingComplexity: NetworkingComplexitySchema.optional(),
r3fModules: z.array(R3fModuleSchema).optional(),
});
export const GenerateIconsOptionsSchema = z.object({
inputPath: NonEmptyString,
outputDir: NonEmptyString,
});
export const DevCommandOptionsSchema = z.object({
cwd: NonEmptyString,
extraArgs: z.array(z.string()).optional(),
});
export const BuildCommandOptionsSchema = z.object({
cwd: NonEmptyString,
target: BuildTargetSchema,
update: z.boolean().optional(),
extraArgs: z.array(z.string()).optional(),
});
export const PackageCommandOptionsSchema = z.object({
cwd: NonEmptyString,
target: BuildTargetSchema,
extraArgs: z.array(z.string()).optional(),
});
export const SignCommandOptionsSchema = z.object({
cwd: NonEmptyString,
identity: NonEmptyString,
entitlements: z.string().optional(),
notarize: z.boolean().optional(),
extraArgs: z.array(z.string()).optional(),
});
/**
* Parse a value against a Zod schema and raise an `ElectrobunError` with
* `ELECTROBUN.INVALID_OPTIONS` when the parse fails. The Zod issue list is
* carried on `details.issues` so callers can drive diagnostics without
* re-parsing the message.
*/
export function parseOrThrow<T>(
schema: z.ZodType<T>,
value: unknown,
context: string
): T {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
}
throw new ElectrobunError(
ELECTROBUN_CODES.INVALID_OPTIONS,
`${context}: invalid options`,
{
details: {
context,
issues: result.error.issues,
},
}
);
}

View File

@@ -0,0 +1,21 @@
export type {
IconOutcome,
PromptOutcome,
RunnerCommand,
} from "./telemetry.js";
export {
generateDurationHistogram,
iconsGeneratedCounter,
projectsGeneratedCounter,
promptsCounter,
recordGenerateDuration,
recordIcons,
recordProject,
recordPromptOutcome,
recordRunnerDuration,
recordRunnerFailure,
recordRunnerInvocation,
runnerDurationHistogram,
runnerFailuresCounter,
runnerInvocationsCounter,
} from "./telemetry.js";

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,123 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
recordGenerateDuration,
recordIcons,
recordProject,
recordPromptOutcome,
recordRunnerDuration,
recordRunnerFailure,
recordRunnerInvocation,
} from "./telemetry.js";
interface DataPoint {
readonly attributes: Record<string, string>;
readonly value: number;
}
interface MetricSample {
readonly name: string;
readonly points: readonly DataPoint[];
}
interface CollectedMetric {
descriptor: { name: string };
dataPoints: ReadonlyArray<{
attributes: Record<string, string>;
value?: number | { sum?: number; count?: number };
}>;
}
function dataPointValue(p: {
value?: number | { sum?: number; count?: number };
}): number {
if (typeof p.value === "number") {
return p.value;
}
if (
p.value &&
typeof p.value === "object" &&
typeof p.value.sum === "number"
) {
return p.value.sum;
}
return 0;
}
interface CollectedResource {
scopeMetrics: ReadonlyArray<{ metrics: readonly CollectedMetric[] }>;
}
async function collect(): Promise<readonly MetricSample[]> {
const flushed =
(await flushTestMetrics()) as ReadonlyArray<CollectedResource>;
const result: MetricSample[] = [];
for (const m of flushed) {
for (const scope of m.scopeMetrics) {
for (const metric of scope.metrics) {
result.push({
name: metric.descriptor.name,
points: metric.dataPoints.map((p) => ({
attributes: p.attributes,
value: dataPointValue(p),
})),
});
}
}
}
return result;
}
describe("telemetry", () => {
afterEach(async () => {
await flushTestMetrics();
});
it("recordRunnerInvocation increments the counter once per call", {
timeout: 5000,
}, async () => {
recordRunnerInvocation("build");
const samples = await collect();
const sample = samples.find(
(s) => s.name === "electrobun_runner_invocations_total"
);
expect(sample).toBeDefined();
const point = sample?.points.find(
(p) => p.attributes["command"] === "build"
);
expect(point?.value).toBe(1);
});
it("recordGenerateDuration writes to the histogram", {
timeout: 5000,
}, async () => {
recordGenerateDuration(123, 2);
const samples = await collect();
const sample = samples.find(
(s) => s.name === "electrobun_generate_duration_ms"
);
expect(sample).toBeDefined();
const point = sample?.points.find(
(p) => p.attributes["features_count"] === "2"
);
expect(point?.value).toBeGreaterThanOrEqual(123);
});
it("every helper records its instrument", { timeout: 5000 }, async () => {
recordProject(3);
recordIcons("generated");
recordIcons("skipped");
recordIcons("failed");
recordRunnerFailure("dev", 2);
recordPromptOutcome("completed");
recordPromptOutcome("aborted");
recordRunnerDuration("package", 42);
const samples = await collect();
const names = samples.map((s) => s.name);
expect(names).toContain("electrobun_projects_generated_total");
expect(names).toContain("electrobun_icons_generated_total");
expect(names).toContain("electrobun_runner_failures_total");
expect(names).toContain("electrobun_prompts_total");
expect(names).toContain("electrobun_runner_duration_ms");
});
});

View File

@@ -0,0 +1,120 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/electrobun";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
* first access. When no provider is registered the OTel API returns no-op
* instruments, so the helpers below are safe to call from production code
* that does not opt into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
export type RunnerCommand = "dev" | "build" | "package" | "sign" | "probe";
export type PromptOutcome = "completed" | "aborted";
export type IconOutcome = "generated" | "skipped" | "failed";
/** Counter incremented per `generate()` call. */
export const projectsGeneratedCounter: Counter = meter.createCounter(
"electrobun_projects_generated_total",
{
description: "Project scaffolds completed by `generate()`",
}
);
/** Counter incremented per icon-pipeline outcome. */
export const iconsGeneratedCounter: Counter = meter.createCounter(
"electrobun_icons_generated_total",
{
description: "Icon-pipeline outcomes (generated, skipped, failed)",
}
);
/** Counter incremented per orchestrated runner invocation. */
export const runnerInvocationsCounter: Counter = meter.createCounter(
"electrobun_runner_invocations_total",
{
description: "Electrobun subprocess invocations, tagged with command",
}
);
/** Counter incremented per runner failure. */
export const runnerFailuresCounter: Counter = meter.createCounter(
"electrobun_runner_failures_total",
{
description:
"Electrobun subprocess failures, tagged with command + exit",
}
);
/** Counter incremented per interactive prompt outcome. */
export const promptsCounter: Counter = meter.createCounter(
"electrobun_prompts_total",
{
description: "Interactive prompt outcomes (completed, aborted)",
}
);
/** Histogram of `generate()` durations in milliseconds. */
export const generateDurationHistogram: Histogram = meter.createHistogram(
"electrobun_generate_duration_ms",
{
description: "Project-scaffold durations in milliseconds",
unit: "ms",
}
);
/** Histogram of runner durations in milliseconds. */
export const runnerDurationHistogram: Histogram = meter.createHistogram(
"electrobun_runner_duration_ms",
{
description: "Electrobun subprocess durations in milliseconds",
unit: "ms",
}
);
/** Record a single project generation. */
export function recordProject(featuresCount: number): void {
projectsGeneratedCounter.add(1, { features_count: String(featuresCount) });
}
/** Record a single icon-pipeline outcome. */
export function recordIcons(outcome: IconOutcome): void {
iconsGeneratedCounter.add(1, { outcome });
}
/** Record a single runner invocation. */
export function recordRunnerInvocation(command: RunnerCommand): void {
runnerInvocationsCounter.add(1, { command });
}
/** Record a single runner failure. */
export function recordRunnerFailure(
command: RunnerCommand,
exitCode: number
): void {
runnerFailuresCounter.add(1, {
command,
exit_code: String(exitCode),
});
}
/** Record a single prompt outcome. */
export function recordPromptOutcome(outcome: PromptOutcome): void {
promptsCounter.add(1, { outcome });
}
/** Record a `generate()` duration in milliseconds. */
export function recordGenerateDuration(
ms: number,
featuresCount: number
): void {
generateDurationHistogram.record(ms, {
features_count: String(featuresCount),
});
}
/** Record a runner duration in milliseconds. */
export function recordRunnerDuration(command: RunnerCommand, ms: number): void {
runnerDurationHistogram.record(ms, { command });
}

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,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ProjectOptions } from "../types/electrobun.types.js";
import {
featurePath,
renderApiFeature,
renderApiSchemasFeature,
renderAuthFeature,
renderFeature,
renderFormBuilderFeature,
renderI18nFeature,
renderNetworkFeature,
renderNetworkFullFeature,
renderR3fFeature,
renderStorageFeature,
} from "./features.js";
function makeOptions(overrides: Partial<ProjectOptions> = {}): ProjectOptions {
return {
name: "demo",
identifier: "com.demo.app",
description: "A demo",
version: "0.1.0",
outputDir: "/tmp/d",
features: [],
networkingComplexity: "basic",
r3fModules: [],
...overrides,
};
}
describe("feature templates", () => {
it("renderI18nFeature emits MessageCatalog", { timeout: 5000 }, () => {
expect(renderI18nFeature()).toContain("MessageCatalog");
});
it("renderNetworkFeature emits basic node", { timeout: 5000 }, () => {
const out = renderNetworkFeature();
expect(out).toContain("NetworkNode");
expect(out).not.toContain("vectorClocks");
});
it("renderNetworkFullFeature emits state + governance", {
timeout: 5000,
}, () => {
const out = renderNetworkFullFeature();
expect(out).toContain("vectorClocks");
expect(out).toContain("governance");
});
it("renderApiFeature emits defineApp", { timeout: 5000 }, () => {
expect(renderApiFeature()).toContain("defineApp");
});
it("renderApiSchemasFeature emits UserSchema", { timeout: 5000 }, () => {
expect(renderApiSchemasFeature()).toContain("UserSchema");
});
it("renderStorageFeature emits createStorage", { timeout: 5000 }, () => {
expect(renderStorageFeature()).toContain("createStorage");
});
it("renderAuthFeature emits createAuth", { timeout: 5000 }, () => {
expect(renderAuthFeature()).toContain("createAuth");
});
it("renderFormBuilderFeature emits FormBuilder", { timeout: 5000 }, () => {
expect(renderFormBuilderFeature()).toContain("FormBuilder");
});
it("renderR3fFeature without modules emits minimal scene", {
timeout: 5000,
}, () => {
const out = renderR3fFeature(makeOptions());
expect(out).toContain("RotatingBox");
expect(out).not.toContain("Sparkles");
expect(out).not.toContain("@react-three/rapier");
});
it("r3f renderer emits the requested modules", { timeout: 5000 }, () => {
const out = renderR3fFeature(
makeOptions({
r3fModules: ["physics", "particles", "camera"],
})
);
expect(out).toContain("@react-three/rapier");
expect(out).toContain("Sparkles");
expect(out).toContain("AdaptiveDpr");
});
it("networking feature complexity selects the correct renderer", {
timeout: 5000,
}, () => {
const out = renderFeature(
"networking",
makeOptions({ networkingComplexity: "full" })
);
expect(out).toContain("state:");
expect(out).toContain("governance:");
});
it("renderFeature basic networking when complexity=basic", {
timeout: 5000,
}, () => {
const out = renderFeature(
"networking",
makeOptions({ networkingComplexity: "basic" })
);
expect(out).not.toContain("governance");
});
it("renderFeature dispatches every feature", { timeout: 5000 }, () => {
const opts = makeOptions({ r3fModules: ["camera"] });
for (const f of [
"i18n",
"networking",
"api",
"api-schemas",
"storage",
"auth",
"form-builder",
"r3f",
] as const) {
const out = renderFeature(f, opts);
expect(typeof out).toBe("string");
expect(out.length).toBeGreaterThan(0);
}
});
it("featurePath maps each feature to a path", { timeout: 5000 }, () => {
expect(featurePath("i18n")).toBe("src/features/i18n.ts");
expect(featurePath("networking")).toBe("src/features/network.ts");
expect(featurePath("api")).toBe("src/features/api.ts");
expect(featurePath("api-schemas")).toBe("src/features/schemas.ts");
expect(featurePath("storage")).toBe("src/features/storage.ts");
expect(featurePath("auth")).toBe("src/features/auth.ts");
expect(featurePath("form-builder")).toBe(
"src/features/form-builder.ts"
);
expect(featurePath("r3f")).toBe("src/features/r3f.tsx");
});
});

View File

@@ -0,0 +1,493 @@
import type {
FeatureModule,
ProjectOptions,
} from "../types/electrobun.types.js";
/** Render the i18n feature module. */
export function renderI18nFeature(): string {
return `import { MessageCatalog } from "@suaveplan/i18n";
const messages = {
en: {
greeting: "Hello, {name}!",
welcome: "Welcome to the app",
settings: "Settings",
language: "Language",
},
es: {
greeting: "Hola, {name}!",
welcome: "Bienvenido a la aplicación",
settings: "Configuración",
language: "Idioma",
},
fr: {
greeting: "Bonjour, {name} !",
welcome: "Bienvenue dans l'application",
settings: "Paramètres",
language: "Langue",
},
};
export const catalog = new MessageCatalog({
defaultLocale: "en",
fallbackLocale: "en",
messages,
});
export function setLocale(locale: string): void {
catalog.setLocale(locale);
}
export function t(key: string, params?: Record<string, string>): string {
return catalog.get(key, params);
}
`;
}
/** Render the basic-networking feature module. */
export function renderNetworkFeature(): string {
return `import { NetworkNode } from "@suaveplan/network";
let node: NetworkNode | null = null;
export async function initializeNetwork(): Promise<NetworkNode> {
node = new NetworkNode({
transport: "websocket",
discovery: {
enabled: true,
interval: 5000,
},
});
node.on("peer:connected", (peerId: string) => {
console.log(\`Peer connected: \${peerId}\`);
});
node.on("peer:disconnected", (peerId: string) => {
console.log(\`Peer disconnected: \${peerId}\`);
});
node.on("message", (message: { from: string; data: unknown }) => {
console.log(\`Message from \${message.from}:\`, message.data);
});
await node.start();
console.log("Network node started");
return node;
}
export function getNode(): NetworkNode | null {
return node;
}
export async function broadcast(data: unknown): Promise<void> {
if (node) {
await node.broadcast(data);
}
}
export async function shutdownNetwork(): Promise<void> {
if (node) {
await node.stop();
node = null;
}
}
`;
}
/** Render the full-mesh networking feature module. */
export function renderNetworkFullFeature(): string {
return `import { NetworkNode } from "@suaveplan/network";
let node: NetworkNode | null = null;
export async function initializeNetwork(): Promise<NetworkNode> {
node = new NetworkNode({
transport: "websocket",
discovery: {
enabled: true,
interval: 5000,
},
state: {
hierarchical: true,
vectorClocks: true,
conflictResolution: "last-write-wins",
},
governance: {
roles: ["leader", "follower"],
electionStrategy: "capability-based",
autoPromote: true,
},
});
node.on("peer:connected", (peerId: string) => {
console.log(\`Peer connected: \${peerId}\`);
});
node.on("peer:disconnected", (peerId: string) => {
console.log(\`Peer disconnected: \${peerId}\`);
});
node.on("message", (message: { from: string; data: unknown }) => {
console.log(\`Message from \${message.from}:\`, message.data);
});
node.on("state:sync", (state: unknown) => {
console.log("State synchronized:", state);
});
node.on("role:changed", (role: string) => {
console.log(\`Role changed to: \${role}\`);
});
await node.start();
console.log("Network node started (full mesh mode)");
return node;
}
export function getNode(): NetworkNode | null {
return node;
}
export async function broadcast(data: unknown): Promise<void> {
if (node) {
await node.broadcast(data);
}
}
export function getState<T>(key: string): T | undefined {
return node?.getState<T>(key);
}
export function setState<T>(key: string, value: T): void {
node?.setState(key, value);
}
export async function shutdownNetwork(): Promise<void> {
if (node) {
await node.stop();
node = null;
}
}
`;
}
/** Render the api feature module. */
export function renderApiFeature(): string {
return `import { defineApp } from "@suaveplan/api";
const app = defineApp({
name: "app-api",
port: 3001,
cors: {
origin: "*",
credentials: true,
},
});
app.route("GET", "/api/health", (c) => {
return c.json({ status: "ok", timestamp: Date.now() });
});
app.route("GET", "/api/info", (c) => {
return c.json({
name: "Electrobun App API",
version: "1.0.0",
});
});
export async function startApiServer(): Promise<void> {
await app.start();
console.log("API server started on port 3001");
}
export { app };
`;
}
/** Render the api-schemas feature module. */
export function renderApiSchemasFeature(): string {
return `import { z } from "@suaveplan/validation";
export const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string().min(1),
createdAt: z.string().datetime(),
});
export const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true,
});
export const MessageSchema = z.object({
id: z.string(),
from: z.string(),
to: z.string(),
content: z.string().min(1),
timestamp: z.string().datetime(),
});
export const PaginatedResponseSchema = <T extends z.ZodType>(itemSchema: T) =>
z.object({
items: z.array(itemSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
});
export type User = z.infer<typeof UserSchema>;
export type CreateUser = z.infer<typeof CreateUserSchema>;
export type Message = z.infer<typeof MessageSchema>;
`;
}
/** Render the storage feature module. */
export function renderStorageFeature(): string {
return `import { createStorage } from "@suaveplan/storage";
export const appStorage = createStorage({
driver: "file",
options: {
directory: "./data",
namespace: "app",
},
});
export async function saveData<T>(key: string, value: T): Promise<void> {
await appStorage.set(key, value);
}
export async function loadData<T>(key: string): Promise<T | undefined> {
return appStorage.get<T>(key);
}
export async function removeData(key: string): Promise<void> {
await appStorage.delete(key);
}
export async function listKeys(pattern?: string): Promise<string[]> {
return appStorage.keys(pattern ?? "*");
}
`;
}
/** Render the auth feature module. */
export function renderAuthFeature(): string {
return `import { createAuth } from "@suaveplan/auth";
const auth = createAuth({
strategy: "jwt",
tokenExpiry: "24h",
refreshExpiry: "7d",
});
export interface AuthSession {
userId: string;
token: string;
expiresAt: number;
}
let currentSession: AuthSession | null = null;
export async function login(
email: string,
password: string,
): Promise<AuthSession> {
const result = await auth.authenticate({ email, password });
currentSession = {
userId: result.userId,
token: result.token,
expiresAt: result.expiresAt,
};
return currentSession;
}
export function getSession(): AuthSession | null {
return currentSession;
}
export function isAuthenticated(): boolean {
if (!currentSession) return false;
return currentSession.expiresAt > Date.now();
}
export async function logout(): Promise<void> {
if (currentSession) {
await auth.revoke(currentSession.token);
currentSession = null;
}
}
export { auth };
`;
}
/** Render the form-builder feature module. */
export function renderFormBuilderFeature(): string {
return `import { FormBuilder } from "@suaveplan/form-builder";
export const contactForm = new FormBuilder()
.section("personal", "Personal Information")
.field({
name: "firstName",
type: "text",
label: "First Name",
required: true,
validation: { minLength: 1, maxLength: 50 },
})
.field({
name: "lastName",
type: "text",
label: "Last Name",
required: true,
validation: { minLength: 1, maxLength: 50 },
})
.field({
name: "email",
type: "email",
label: "Email Address",
required: true,
})
.section("details", "Details")
.field({
name: "subject",
type: "select",
label: "Subject",
options: [
{ value: "general", label: "General Inquiry" },
{ value: "support", label: "Technical Support" },
{ value: "feedback", label: "Feedback" },
],
})
.field({
name: "message",
type: "textarea",
label: "Message",
required: true,
validation: { minLength: 10, maxLength: 1000 },
})
.build();
`;
}
/** Render the r3f feature module — output depends on selected r3fModules. */
export function renderR3fFeature(options: ProjectOptions): string {
const imports: string[] = [
'import React, { useRef } from "react";',
'import { Canvas, useFrame } from "@react-three/fiber";',
'import { OrbitControls } from "@react-three/drei";',
];
const r3fMods = options.r3fModules;
const extraComponents: string[] = [];
if (r3fMods.includes("camera")) {
imports.push(
'import { AdaptiveDpr, AdaptiveEvents } from "@react-three/drei";'
);
extraComponents.push(" <AdaptiveDpr pixelated />");
extraComponents.push(" <AdaptiveEvents />");
}
if (r3fMods.includes("physics")) {
imports.push(
'import { Physics, RigidBody } from "@react-three/rapier";'
);
}
if (r3fMods.includes("particles")) {
imports.push('import { Sparkles } from "@react-three/drei";');
extraComponents.push(
" <Sparkles count={100} scale={4} size={2} speed={0.4} />"
);
}
const meshContent = r3fMods.includes("physics")
? ` <Physics>
<RigidBody>
<RotatingBox />
</RigidBody>
<RigidBody type="fixed" position={[0, -2, 0]}>
<mesh>
<boxGeometry args={[10, 0.2, 10]} />
<meshStandardMaterial color="#334155" />
</mesh>
</RigidBody>
</Physics>`
: " <RotatingBox />";
return `${imports.join("\n")}
function RotatingBox() {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((_, delta) => {
if (meshRef.current) {
meshRef.current.rotation.x += delta * 0.5;
meshRef.current.rotation.y += delta * 0.3;
}
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#3b82f6" />
</mesh>
);
}
export function Scene() {
return (
<Canvas camera={{ position: [3, 3, 3], fov: 50 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} />
${meshContent}
${extraComponents.join("\n")}
<OrbitControls />
</Canvas>
);
}
`;
}
/**
* Render any feature module. The `networking` branch chooses between basic
* and full implementations based on `options.networkingComplexity`.
*/
export function renderFeature(
feature: FeatureModule,
options: ProjectOptions
): string {
switch (feature) {
case "i18n":
return renderI18nFeature();
case "networking":
return options.networkingComplexity === "full"
? renderNetworkFullFeature()
: renderNetworkFeature();
case "api":
return renderApiFeature();
case "api-schemas":
return renderApiSchemasFeature();
case "storage":
return renderStorageFeature();
case "auth":
return renderAuthFeature();
case "form-builder":
return renderFormBuilderFeature();
case "r3f":
return renderR3fFeature(options);
}
}
/** Filesystem path for a feature's output file. */
export function featurePath(feature: FeatureModule): string {
if (feature === "networking") {
return "src/features/network.ts";
}
if (feature === "api-schemas") {
return "src/features/schemas.ts";
}
if (feature === "r3f") {
return "src/features/r3f.tsx";
}
return `src/features/${feature}.ts`;
}

View File

@@ -0,0 +1,28 @@
export {
featurePath,
renderApiFeature,
renderApiSchemasFeature,
renderAuthFeature,
renderFeature,
renderFormBuilderFeature,
renderI18nFeature,
renderNetworkFeature,
renderNetworkFullFeature,
renderR3fFeature,
renderStorageFeature,
} from "./features.js";
export type { ProjectFileKind } from "./templates.js";
export {
FEATURE_LABELS,
R3F_MODULE_LABELS,
renderElectrobunConfig,
renderMainProcess,
renderMainviewCss,
renderMainviewEntry,
renderMainviewHtml,
renderPackageJson,
renderProjectFile,
renderReadme,
renderSharedTypes,
renderTsconfig,
} from "./templates.js";

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,199 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ProjectOptions } from "../types/electrobun.types.js";
import {
FEATURE_LABELS,
R3F_MODULE_LABELS,
renderElectrobunConfig,
renderMainProcess,
renderMainviewCss,
renderMainviewEntry,
renderMainviewHtml,
renderPackageJson,
renderProjectFile,
renderReadme,
renderSharedTypes,
renderTsconfig,
} from "./templates.js";
function makeOptions(overrides: Partial<ProjectOptions> = {}): ProjectOptions {
return {
name: "demo",
identifier: "com.demo.app",
description: "A demo",
version: "0.1.0",
outputDir: "/tmp/d",
features: [],
networkingComplexity: "basic",
r3fModules: [],
...overrides,
};
}
describe("project templates", () => {
it("renderProjectFile output is byte-stable", { timeout: 5000 }, () => {
const a = renderProjectFile("package-json", makeOptions());
const b = renderProjectFile("package-json", makeOptions());
expect(a).toBe(b);
});
it("renderElectrobunConfig includes icon when iconSource present", {
timeout: 5000,
}, () => {
const out = renderElectrobunConfig(
makeOptions({ iconSource: "/i.png" })
);
expect(out).toContain("icons/app.icns");
expect(out).toContain("icons/app.ico");
expect(out).toContain("icons/icon-256.png");
});
it("renderElectrobunConfig omits icon when iconSource missing", {
timeout: 5000,
}, () => {
const out = renderElectrobunConfig(makeOptions());
expect(out).not.toContain("app.icns");
});
it("renderMainProcess wires api + networking init", {
timeout: 5000,
}, () => {
const out = renderMainProcess(
makeOptions({ features: ["api", "networking"] })
);
expect(out).toContain("startApiServer");
expect(out).toContain("initializeNetwork");
});
it("renderMainProcess no setup lines when no flags", {
timeout: 5000,
}, () => {
const out = renderMainProcess(makeOptions());
expect(out).not.toContain("startApiServer");
expect(out).not.toContain("initializeNetwork");
});
it("renderPackageJson assembles feature deps + r3f mods", {
timeout: 5000,
}, () => {
const out = renderPackageJson(
makeOptions({
features: ["i18n", "r3f", "networking", "auth"],
networkingComplexity: "full",
r3fModules: ["physics", "pathfinding"],
})
);
const parsed = JSON.parse(out) as {
dependencies: Record<string, string>;
};
expect(parsed.dependencies["@suaveplan/i18n"]).toBe("latest");
expect(parsed.dependencies["@suaveplan/network-governance"]).toBe(
"latest"
);
expect(parsed.dependencies["@suaveplan/r3f-physics-rapier"]).toBe(
"latest"
);
expect(parsed.dependencies["@suaveplan/r3f-pathfinding"]).toBe(
"latest"
);
expect(parsed.dependencies["@suaveplan/auth"]).toBe("latest");
});
it("renderTsconfig is valid JSON", { timeout: 5000 }, () => {
const parsed = JSON.parse(renderTsconfig()) as {
compilerOptions: { target: string };
};
expect(parsed.compilerOptions.target).toBe("ES2022");
});
it("renderReadme includes feature labels when set", {
timeout: 5000,
}, () => {
const out = renderReadme(makeOptions({ features: ["i18n", "auth"] }));
expect(out).toContain(FEATURE_LABELS.i18n);
expect(out).toContain(FEATURE_LABELS.auth);
});
it("renderReadme prints `None selected` when no features", {
timeout: 5000,
}, () => {
const out = renderReadme(makeOptions());
expect(out).toContain("None selected");
});
it("renderMainviewHtml carries project name", { timeout: 5000 }, () => {
const out = renderMainviewHtml(makeOptions({ name: "Spectre" }));
expect(out).toContain("<title>Spectre</title>");
});
it("renderMainviewCss returns the CSS skeleton", { timeout: 5000 }, () => {
const out = renderMainviewCss();
expect(out).toContain("--color-primary: #3b82f6");
});
it("renderMainviewEntry imports i18n when feature selected", {
timeout: 5000,
}, () => {
const out = renderMainviewEntry(
makeOptions({
features: ["i18n", "r3f", "storage", "form-builder"],
})
);
expect(out).toContain("catalog");
expect(out).toContain("Scene");
expect(out).toContain("appStorage");
expect(out).toContain("contactForm");
expect(out).toContain('catalog.get("greeting"');
});
it("renderMainviewEntry uses Welcome when no i18n", {
timeout: 5000,
}, () => {
const out = renderMainviewEntry(makeOptions());
expect(out).toContain("Welcome");
});
it("renderSharedTypes adds PeerMessage for networking", {
timeout: 5000,
}, () => {
const out = renderSharedTypes(
makeOptions({ features: ["networking"] })
);
expect(out).toContain("PeerMessage");
});
it("renderSharedTypes omits PeerMessage without networking", {
timeout: 5000,
}, () => {
const out = renderSharedTypes(makeOptions());
expect(out).not.toContain("PeerMessage");
});
it("renderProjectFile dispatches every kind", { timeout: 5000 }, () => {
const opts = makeOptions();
for (const kind of [
"electrobun-config",
"main-process",
"package-json",
"tsconfig",
"readme",
"mainview-html",
"mainview-css",
"mainview-entry",
"shared-types",
] as const) {
const out = renderProjectFile(kind, opts);
expect(typeof out).toBe("string");
expect(out.length).toBeGreaterThan(0);
}
});
it("R3F_MODULE_LABELS covers every module", { timeout: 5000 }, () => {
expect(Object.keys(R3F_MODULE_LABELS).sort()).toEqual([
"camera",
"character",
"particles",
"pathfinding",
"physics",
]);
});
});

View File

@@ -0,0 +1,472 @@
import type {
FeatureModule,
ProjectOptions,
} from "../types/electrobun.types.js";
/**
* Map of feature labels for prompt rendering and README emission. Stable
* across runs — consumers can rely on the key set matching `FeatureModule`.
*/
export const FEATURE_LABELS: Record<FeatureModule, string> = {
i18n: "Internationalization (i18n)",
networking: "Networking / P2P Mesh",
api: "API Server (embedded Hono)",
"api-schemas": "API Schemas (Zod validation)",
storage: "Storage (multi-backend)",
auth: "Authentication",
"form-builder": "Form Builder",
r3f: "R3F / 3D Graphics",
};
/** Map of R3F module labels for prompt rendering. */
export const R3F_MODULE_LABELS = {
camera: "Camera & Performance",
physics: "Physics (Rapier)",
character: "Character & Animation",
particles: "Particles & Effects",
pathfinding: "Pathfinding",
} as const;
/** Render the generated `electrobun.config.ts`. */
export function renderElectrobunConfig(options: ProjectOptions): string {
const hasIcons = Boolean(options.iconSource);
return `import type { ElectrobunConfig } from "electrobun";
const config: ElectrobunConfig = {
app: {
name: "${options.name}",
identifier: "${options.identifier}",
version: "${options.version}",
},
build: {
views: {
mainview: {
entrypoint: "src/mainview/index.ts",
},
},
copy: {
"src/mainview/index.html": "views/mainview/index.html",
"src/mainview/index.css": "views/mainview/index.css",
},
mac: {
bundleCEF: false,${hasIcons ? '\n icon: "icons/app.icns",' : ""}
},
linux: {
bundleCEF: false,${hasIcons ? '\n icon: "icons/icon-256.png",' : ""}
},
windows: {
bundleCEF: false,${hasIcons ? '\n icon: "icons/app.ico",' : ""}
},
},
};
export default config;
`;
}
/** Render the generated `src/bun/index.ts` main process entry. */
export function renderMainProcess(options: ProjectOptions): string {
const hasApi = options.features.includes("api");
const hasNetworking = options.features.includes("networking");
const imports: string[] = [
'import { BrowserWindow } from "electrobun/bun";',
];
const setupLines: string[] = [];
if (hasApi) {
imports.push('import { startApiServer } from "../features/api.js";');
setupLines.push("await startApiServer();");
}
if (hasNetworking) {
imports.push(
'import { initializeNetwork } from "../features/network.js";'
);
setupLines.push("await initializeNetwork();");
}
const setupBlock =
setupLines.length > 0
? `\n${setupLines.map((l) => ` ${l}`).join("\n")}\n`
: "";
return `${imports.join("\n")}
async function main() {
console.log("Starting ${options.name}...");
${setupBlock}
const mainWindow = new BrowserWindow({
title: "${options.name}",
url: "views://mainview/index.html",
width: 1024,
height: 768,
x: 200,
y: 200,
});
}
main();
`;
}
/** Render the generated `src/mainview/index.html`. */
export function renderMainviewHtml(options: ProjectOptions): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${options.name}</title>
<link rel="stylesheet" href="./index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.js"></script>
</body>
</html>
`;
}
/** Render the generated `src/mainview/index.css`. */
export function renderMainviewCss(): string {
return `*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-primary: #3b82f6;
--color-text: #f8fafc;
--color-text-muted: #94a3b8;
--radius: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#root {
width: 100%;
max-width: 640px;
padding: 2rem;
}
.app-header {
text-align: center;
margin-bottom: 2rem;
}
.app-header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.app-header p {
color: var(--color-text-muted);
}
.card {
background: var(--color-surface);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
.btn {
background: var(--color-primary);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
font-size: 1rem;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.9;
}
`;
}
/** Render the generated `src/mainview/index.tsx`. */
export function renderMainviewEntry(options: ProjectOptions): string {
const hasI18n = options.features.includes("i18n");
const hasR3f = options.features.includes("r3f");
const hasStorage = options.features.includes("storage");
const hasFormBuilder = options.features.includes("form-builder");
const imports: string[] = [
'import React from "react";',
'import { createRoot } from "react-dom/client";',
];
if (hasI18n) {
imports.push('import { catalog } from "../features/i18n.js";');
}
if (hasR3f) {
imports.push('import { Scene } from "../features/r3f.js";');
}
if (hasStorage) {
imports.push('import { appStorage } from "../features/storage.js";');
}
if (hasFormBuilder) {
imports.push(
'import { contactForm } from "../features/form-builder.js";'
);
}
const i18nGreeting =
"${" + 'catalog.get("greeting", { name: "World" })' + "}";
const greeting = hasI18n ? i18nGreeting : "Welcome";
const r3fSection = hasR3f
? `
<div className="card" style={{ height: 300 }}>
<Scene />
</div>`
: "";
return `${imports.join("\n")}
function App() {
return (
<div>
<header className="app-header">
<h1>${options.name}</h1>
<p>${options.description}</p>
</header>
<div className="card">
<h2>${greeting}</h2>
<p>Your Electrobun app is running.</p>
</div>${r3fSection}
</div>
);
}
const root = createRoot(document.getElementById("root")!);
root.render(<App />);
`;
}
/** Render the generated `src/shared/types.ts`. */
export function renderSharedTypes(options: ProjectOptions): string {
const sections: string[] = [
`export interface AppConfig {
name: string;
version: string;
identifier: string;
}
export const APP_CONFIG: AppConfig = {
name: "${options.name}",
version: "${options.version}",
identifier: "${options.identifier}",
};`,
];
if (options.features.includes("networking")) {
sections.push(`
export interface PeerMessage {
type: string;
payload: unknown;
timestamp: number;
}`);
}
return `${sections.join("\n")}\n`;
}
/** Render the generated `tsconfig.json`. */
export function renderTsconfig(): string {
const config = {
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "Bundler",
jsx: "react-jsx",
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
resolveJsonModule: true,
declaration: true,
declarationMap: true,
outDir: "./dist",
rootDir: "./src",
},
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: ["node_modules", "dist"],
};
return `${JSON.stringify(config, null, 2)}\n`;
}
/** Render the generated `README.md`. */
export function renderReadme(options: ProjectOptions): string {
const featuresList =
options.features.length > 0
? options.features.map((f) => `- ${FEATURE_LABELS[f]}`).join("\n")
: "- None selected";
return `# ${options.name}
${options.description}
## Getting Started
\`\`\`bash
bun install
bun run dev
\`\`\`
## Build
\`\`\`bash
bun run build
\`\`\`
## Features
${featuresList}
## Project Structure
\`\`\`
src/
bun/ Main process (Bun runtime)
mainview/ UI (React + Tailwind)
features/ Feature modules
shared/ Shared types and utilities
\`\`\`
Built with [Electrobun](https://electrobun.dev) and [SuavePlan](https://suaveplan.com).
`;
}
const FEATURE_DEPS: Record<
FeatureModule,
readonly { pkg: string; version: string }[]
> = {
i18n: [{ pkg: "@suaveplan/i18n", version: "latest" }],
networking: [
{ pkg: "@suaveplan/network", version: "latest" },
{ pkg: "@suaveplan/network-websockets", version: "latest" },
],
api: [
{ pkg: "@suaveplan/api", version: "latest" },
{ pkg: "@suaveplan/api-shared", version: "latest" },
],
"api-schemas": [
{ pkg: "@suaveplan/api-schemas", version: "latest" },
{ pkg: "@suaveplan/validation", version: "latest" },
],
storage: [{ pkg: "@suaveplan/storage", version: "latest" }],
auth: [
{ pkg: "@suaveplan/auth", version: "latest" },
{ pkg: "@suaveplan/jwt-tenant", version: "latest" },
],
"form-builder": [{ pkg: "@suaveplan/form-builder", version: "latest" }],
r3f: [
{ pkg: "@suaveplan/r3f", version: "latest" },
{ pkg: "@react-three/fiber", version: "latest" },
{ pkg: "@react-three/drei", version: "latest" },
{ pkg: "three", version: "latest" },
],
};
const R3F_MODULE_DEPS = {
camera: ["@suaveplan/r3f-camera", "@suaveplan/r3f-performance"] as const,
physics: ["@suaveplan/r3f-physics-rapier"] as const,
character: [
"@suaveplan/r3f-character",
"@suaveplan/r3f-animation",
] as const,
particles: ["@suaveplan/r3f-particles"] as const,
pathfinding: ["@suaveplan/r3f-pathfinding"] as const,
} as const;
/** Render the generated `package.json`. */
export function renderPackageJson(options: ProjectOptions): string {
const featureDeps: Record<string, string> = {};
for (const feature of options.features) {
for (const { pkg, version } of FEATURE_DEPS[feature]) {
featureDeps[pkg] = version;
}
if (
feature === "networking" &&
options.networkingComplexity === "full"
) {
featureDeps["@suaveplan/network-governance"] = "latest";
}
if (feature === "r3f") {
for (const mod of options.r3fModules) {
for (const dep of R3F_MODULE_DEPS[mod]) {
featureDeps[dep] = "latest";
}
}
}
}
const pkg = {
name: options.name,
version: options.version,
description: options.description,
type: "module",
scripts: {
start: "electrobun dev",
dev: "electrobun dev --watch",
build: "electrobun build",
},
dependencies: {
electrobun: "latest",
react: "latest",
"react-dom": "latest",
...featureDeps,
},
devDependencies: {
"@types/bun": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
typescript: "latest",
},
};
return `${JSON.stringify(pkg, null, 2)}\n`;
}
export type ProjectFileKind =
| "electrobun-config"
| "main-process"
| "package-json"
| "tsconfig"
| "readme"
| "mainview-html"
| "mainview-css"
| "mainview-entry"
| "shared-types";
/** Render any project-level file by its kind. */
export function renderProjectFile(
kind: ProjectFileKind,
options: ProjectOptions
): string {
switch (kind) {
case "electrobun-config":
return renderElectrobunConfig(options);
case "main-process":
return renderMainProcess(options);
case "package-json":
return renderPackageJson(options);
case "tsconfig":
return renderTsconfig();
case "readme":
return renderReadme(options);
case "mainview-html":
return renderMainviewHtml(options);
case "mainview-css":
return renderMainviewCss();
case "mainview-entry":
return renderMainviewEntry(options);
case "shared-types":
return renderSharedTypes(options);
}
}

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.

View File

@@ -0,0 +1,208 @@
/**
* Shared type surface for `@suaveplan/electrobun`.
*
* Every public option object, every adapter contract, and every report shape
* lives here. Concrete Zod schemas mirror these types in {@link
* "../schemas/schemas.ts"} so the boundary parses on every public call.
*/
/** A feature module the user can opt into when scaffolding a project. */
export type FeatureModule =
| "i18n"
| "networking"
| "api"
| "api-schemas"
| "storage"
| "auth"
| "form-builder"
| "r3f";
/** Networking complexity for the `networking` feature. */
export type NetworkingComplexity = "basic" | "full";
/** R3F module the user can opt into when the `r3f` feature is selected. */
export type R3fModule =
| "camera"
| "physics"
| "character"
| "particles"
| "pathfinding";
/** Build target for the cross-platform packager. */
export type BuildTarget = "mac" | "linux" | "windows";
/** Canonical feature-render order (independent of caller input). */
export const FEATURE_ORDER: readonly FeatureModule[] = [
"i18n",
"networking",
"api",
"api-schemas",
"storage",
"auth",
"form-builder",
"r3f",
];
/** Full project options consumed by the generator. */
export interface ProjectOptions {
readonly name: string;
readonly identifier: string;
readonly description: string;
readonly version: string;
readonly outputDir: string;
readonly iconSource?: string | undefined;
readonly features: readonly FeatureModule[];
readonly networkingComplexity: NetworkingComplexity;
readonly r3fModules: readonly R3fModule[];
}
/** Partial / on-disk config carrying defaults overrides. */
export interface ElectrobunConfig {
readonly name?: string;
readonly identifier?: string;
readonly description?: string;
readonly version?: string;
readonly iconSource?: string;
readonly features?: readonly FeatureModule[];
readonly networkingComplexity?: NetworkingComplexity;
readonly r3fModules?: readonly R3fModule[];
}
/** A file the generator will write to disk. */
export interface GeneratedFile {
readonly path: string;
readonly content: string;
}
/** Minimal filesystem contract used by every IO-touching helper. */
export interface FsAdapter {
readFile(path: string): Promise<string>;
writeFile(path: string, content: string): Promise<void>;
mkdir(path: string, opts: { recursive: boolean }): Promise<void>;
stat(path: string): Promise<{ isDirectory(): boolean }>;
readdir(path: string): Promise<readonly string[]>;
}
/** Input shape accepted by every `CommandRunner` implementation. */
export interface CommandInput {
readonly command: string;
readonly args: readonly string[];
readonly cwd?: string;
readonly env?: Readonly<Record<string, string>>;
readonly signal?: AbortSignal;
}
/** Result shape returned by every `CommandRunner`. */
export interface CommandResult {
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
readonly durationMs: number;
}
/** Injectable command runner; default = `nodeCommandRunner` (spawn-based). */
export type CommandRunner = (input: CommandInput) => Promise<CommandResult>;
/** Icon descriptor passed to the renderer. */
export interface IconRendererInput {
readonly input: string;
readonly output: string;
readonly icons: {
readonly icns: { name: string; sizes: readonly number[] };
readonly ico: { name: string; sizes: readonly number[] };
readonly png: { name: string; sizes: readonly number[] };
};
}
/** Result returned by the icon renderer adapter. */
export interface IconRendererResult {
readonly files: readonly string[];
}
/** Injectable icon renderer contract; default = `iconzRendererAdapter`. */
export interface IconRendererAdapter {
render(input: IconRendererInput): Promise<IconRendererResult>;
}
/** Prompter contract — five primitives covering every interactive call. */
export interface Prompter {
text(opts: { message: string; default?: string }): Promise<string>;
confirm(opts: { message: string; default?: boolean }): Promise<boolean>;
select<T extends string>(opts: {
message: string;
choices: ReadonlyArray<{ value: T; label: string }>;
default?: T;
}): Promise<T>;
multiselect<T extends string>(opts: {
message: string;
choices: ReadonlyArray<{ value: T; label: string }>;
}): Promise<readonly T[]>;
}
/** Report returned by `generateIcons`. */
export interface IconReport {
readonly generated: boolean;
readonly files: readonly string[];
readonly error?: string;
}
/** Report returned by `generate`. */
export interface GenerateReport {
readonly outputDir: string;
readonly filesCreated: readonly string[];
readonly durationMs: number;
readonly icons: IconReport | undefined;
}
/** Options accepted by `devCommand`. */
export interface DevCommandOptions {
readonly cwd: string;
readonly extraArgs?: readonly string[];
readonly signal?: AbortSignal;
}
/** Options accepted by `buildCommand`. */
export interface BuildCommandOptions {
readonly cwd: string;
readonly target: BuildTarget;
readonly update?: boolean;
readonly extraArgs?: readonly string[];
readonly signal?: AbortSignal;
}
/** Options accepted by `packageCommand`. */
export interface PackageCommandOptions {
readonly cwd: string;
readonly target: BuildTarget;
readonly extraArgs?: readonly string[];
readonly signal?: AbortSignal;
}
/** Options accepted by `signCommand`. */
export interface SignCommandOptions {
readonly cwd: string;
readonly identity: string;
readonly entitlements?: string;
readonly notarize?: boolean;
readonly extraArgs?: readonly string[];
readonly signal?: AbortSignal;
}
/** Generic command-orchestration report. */
export interface CommandReport {
readonly command: "dev" | "build" | "package" | "sign";
readonly exitCode: number;
readonly durationMs: number;
readonly stdout: string;
readonly stderr: string;
}
/** Dependencies bag accepted by `runCli` and every subcommand. */
export interface CommandDeps {
readonly fs?: FsAdapter;
readonly runner?: CommandRunner;
readonly renderer?: IconRendererAdapter;
readonly prompter?: Prompter;
readonly out?: (line: string) => void;
readonly fileExists?: (path: string) => boolean;
}

View File

@@ -0,0 +1,26 @@
export type {
BuildCommandOptions,
BuildTarget,
CommandDeps,
CommandInput,
CommandReport,
CommandResult,
CommandRunner,
DevCommandOptions,
ElectrobunConfig,
FeatureModule,
FsAdapter,
GeneratedFile,
GenerateReport,
IconRendererAdapter,
IconRendererInput,
IconRendererResult,
IconReport,
NetworkingComplexity,
PackageCommandOptions,
ProjectOptions,
Prompter,
R3fModule,
SignCommandOptions,
} from "./electrobun.types.js";
export { FEATURE_ORDER } from "./electrobun.types.js";

View File

@@ -0,0 +1,68 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(__dirname, "..");
const packageJson = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
) as {
sideEffects: unknown;
bin: Record<string, string>;
exports: Record<string, unknown>;
};
const DOCUMENTED_SUBPATHS = [
"error-codes",
"telemetry",
"types",
"schemas",
"fs",
"runner",
"config",
"templates",
"icons",
"generator",
"prompts",
"cli",
] as const;
describe("package shape", () => {
it("package.json declares the documented subpath exports", {
timeout: 5000,
}, () => {
const subpathKeys = Object.keys(packageJson.exports)
.filter((k) => k.startsWith("./") && k !== "./package.json")
.map((k) => k.replace(/^\.\//, ""));
for (const sub of DOCUMENTED_SUBPATHS) {
expect(subpathKeys).toContain(sub);
}
});
it("bin entry resolves to the published binary", { timeout: 5000 }, () => {
expect(packageJson.bin["suaveplan-electrobun"]).toBe(
"./dist/bin/suaveplan-electrobun.js"
);
});
it("sideEffects is false", { timeout: 5000 }, () => {
expect(packageJson.sideEffects).toBe(false);
});
it("every feature folder has a matching subpath export", {
timeout: 5000,
}, () => {
const srcDir = join(PKG_ROOT, "src");
const featureFolders = readdirSync(srcDir, {
withFileTypes: true,
})
.filter((e) => e.isDirectory() && e.name !== "bin")
.map((e) => e.name)
.sort();
const subpathKeys = Object.keys(packageJson.exports)
.filter((k) => k.startsWith("./") && k !== "./package.json")
.map((k) => k.replace(/^\.\//, ""));
for (const folder of featureFolders) {
expect(subpathKeys).toContain(folder);
}
});
});

View File

@@ -0,0 +1,34 @@
/**
* Test setup for @suaveplan/electrobun.
*
* Registers a real in-memory OpenTelemetry MeterProvider BEFORE source
* modules import so the global meter resolved on first use binds to it.
* The electrobun package is server-tier and uses node:fs / node:child_process —
* no DOM shims required.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,63 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir:
"../../../../node_modules/.vite/packages/devtools-tooling-electrobun",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"bin/suaveplan-electrobun":
"src/bin/suaveplan-electrobun.ts",
"error-codes/index": "src/error-codes/index.ts",
"telemetry/index": "src/telemetry/index.ts",
"types/index": "src/types/index.ts",
"schemas/index": "src/schemas/index.ts",
"fs/index": "src/fs/index.ts",
"runner/index": "src/runner/index.ts",
"config/index": "src/config/index.ts",
"templates/index": "src/templates/index.ts",
"icons/index": "src/icons/index.ts",
"generator/index": "src/generator/index.ts",
"prompts/index": "src/prompts/index.ts",
"cli/index": "src/cli/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/cli",
/^@suaveplan\/cli\//,
"@suaveplan/config",
"@suaveplan/error",
"@suaveplan/templates",
"@suaveplan/types",
"iconz",
/^iconz\//,
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
// Multiple test files spawn real child processes (node, bunx electrobun);
// running them in parallel under heavy turbo load causes flake on WSL2.
fileParallelism: false,
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"src/bin/**",
"src/types/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
hookTimeout: 5000,
},
});