Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user