Initial Commit
This commit is contained in:
151
packages/devtools/tooling/electrobun/README.md
Normal file
151
packages/devtools/tooling/electrobun/README.md
Normal 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.
|
||||
|
||||
137
packages/devtools/tooling/electrobun/package.json
Normal file
137
packages/devtools/tooling/electrobun/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
);
|
||||
100
packages/devtools/tooling/electrobun/src/cli/cli.md
Normal file
100
packages/devtools/tooling/electrobun/src/cli/cli.md
Normal 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.
|
||||
905
packages/devtools/tooling/electrobun/src/cli/cli.test.ts
Normal file
905
packages/devtools/tooling/electrobun/src/cli/cli.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
537
packages/devtools/tooling/electrobun/src/cli/cli.ts
Normal file
537
packages/devtools/tooling/electrobun/src/cli/cli.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
packages/devtools/tooling/electrobun/src/cli/index.ts
Normal file
10
packages/devtools/tooling/electrobun/src/cli/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type { CreateCommandOptions, RunCliOptions } from "./cli.js";
|
||||
export {
|
||||
buildCommand,
|
||||
createCommand,
|
||||
devCommand,
|
||||
HELP_TEXT,
|
||||
packageCommand,
|
||||
runCli,
|
||||
signCommand,
|
||||
} from "./cli.js";
|
||||
82
packages/devtools/tooling/electrobun/src/config/config.md
Normal file
82
packages/devtools/tooling/electrobun/src/config/config.md
Normal 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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
59
packages/devtools/tooling/electrobun/src/config/config.ts
Normal file
59
packages/devtools/tooling/electrobun/src/config/config.ts
Normal 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 };
|
||||
1
packages/devtools/tooling/electrobun/src/config/index.ts
Normal file
1
packages/devtools/tooling/electrobun/src/config/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CLI_MAPPING, HARDCODED_DEFAULTS, loadConfig } from "./config.js";
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
ElectrobunCode,
|
||||
ElectrobunErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export { ELECTROBUN_CODES, ElectrobunError } from "./error-codes.js";
|
||||
104
packages/devtools/tooling/electrobun/src/fs/fs.md
Normal file
104
packages/devtools/tooling/electrobun/src/fs/fs.md
Normal 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.
|
||||
192
packages/devtools/tooling/electrobun/src/fs/fs.test.ts
Normal file
192
packages/devtools/tooling/electrobun/src/fs/fs.test.ts
Normal 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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
171
packages/devtools/tooling/electrobun/src/fs/fs.ts
Normal file
171
packages/devtools/tooling/electrobun/src/fs/fs.ts
Normal 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;
|
||||
}
|
||||
11
packages/devtools/tooling/electrobun/src/fs/index.ts
Normal file
11
packages/devtools/tooling/electrobun/src/fs/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type { ConfigDiscoveryAdapter } from "./fs.js";
|
||||
export {
|
||||
CONFIG_FILENAME,
|
||||
directoryExists,
|
||||
directoryIsEmpty,
|
||||
ensureDirectory,
|
||||
findConfigFile,
|
||||
nodeConfigDiscoveryAdapter,
|
||||
nodeFsAdapter,
|
||||
writeFiles,
|
||||
} from "./fs.js";
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
208
packages/devtools/tooling/electrobun/src/generator/generator.ts
Normal file
208
packages/devtools/tooling/electrobun/src/generator/generator.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CollectOverrides, GenerateDeps } from "./generator.js";
|
||||
export { collectFiles, generate } from "./generator.js";
|
||||
91
packages/devtools/tooling/electrobun/src/icons/icons.md
Normal file
91
packages/devtools/tooling/electrobun/src/icons/icons.md
Normal 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.
|
||||
157
packages/devtools/tooling/electrobun/src/icons/icons.test.ts
Normal file
157
packages/devtools/tooling/electrobun/src/icons/icons.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
143
packages/devtools/tooling/electrobun/src/icons/icons.ts
Normal file
143
packages/devtools/tooling/electrobun/src/icons/icons.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
2
packages/devtools/tooling/electrobun/src/icons/index.ts
Normal file
2
packages/devtools/tooling/electrobun/src/icons/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { GenerateIconsDeps, GenerateIconsOptions } from "./icons.js";
|
||||
export { generateIcons, iconzRendererAdapter } from "./icons.js";
|
||||
142
packages/devtools/tooling/electrobun/src/index.ts
Normal file
142
packages/devtools/tooling/electrobun/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { runPrompts } from "./prompts.js";
|
||||
96
packages/devtools/tooling/electrobun/src/prompts/prompts.md
Normal file
96
packages/devtools/tooling/electrobun/src/prompts/prompts.md
Normal 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.
|
||||
209
packages/devtools/tooling/electrobun/src/prompts/prompts.test.ts
Normal file
209
packages/devtools/tooling/electrobun/src/prompts/prompts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
118
packages/devtools/tooling/electrobun/src/prompts/prompts.ts
Normal file
118
packages/devtools/tooling/electrobun/src/prompts/prompts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
5
packages/devtools/tooling/electrobun/src/runner/index.ts
Normal file
5
packages/devtools/tooling/electrobun/src/runner/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
invokeElectrobun,
|
||||
nodeCommandRunner,
|
||||
probeElectrobun,
|
||||
} from "./runner.js";
|
||||
97
packages/devtools/tooling/electrobun/src/runner/runner.md
Normal file
97
packages/devtools/tooling/electrobun/src/runner/runner.md
Normal 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.
|
||||
253
packages/devtools/tooling/electrobun/src/runner/runner.test.ts
Normal file
253
packages/devtools/tooling/electrobun/src/runner/runner.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
127
packages/devtools/tooling/electrobun/src/runner/runner.ts
Normal file
127
packages/devtools/tooling/electrobun/src/runner/runner.ts
Normal 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;
|
||||
}
|
||||
14
packages/devtools/tooling/electrobun/src/schemas/index.ts
Normal file
14
packages/devtools/tooling/electrobun/src/schemas/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
BuildCommandOptionsSchema,
|
||||
BuildTargetSchema,
|
||||
DevCommandOptionsSchema,
|
||||
ElectrobunConfigSchema,
|
||||
FeatureModuleSchema,
|
||||
GenerateIconsOptionsSchema,
|
||||
NetworkingComplexitySchema,
|
||||
PackageCommandOptionsSchema,
|
||||
ProjectOptionsSchema,
|
||||
parseOrThrow,
|
||||
R3fModuleSchema,
|
||||
SignCommandOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
104
packages/devtools/tooling/electrobun/src/schemas/schemas.md
Normal file
104
packages/devtools/tooling/electrobun/src/schemas/schemas.md
Normal 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`.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
114
packages/devtools/tooling/electrobun/src/schemas/schemas.ts
Normal file
114
packages/devtools/tooling/electrobun/src/schemas/schemas.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
21
packages/devtools/tooling/electrobun/src/telemetry/index.ts
Normal file
21
packages/devtools/tooling/electrobun/src/telemetry/index.ts
Normal 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";
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
120
packages/devtools/tooling/electrobun/src/telemetry/telemetry.ts
Normal file
120
packages/devtools/tooling/electrobun/src/telemetry/telemetry.ts
Normal 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 });
|
||||
}
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
493
packages/devtools/tooling/electrobun/src/templates/features.ts
Normal file
493
packages/devtools/tooling/electrobun/src/templates/features.ts
Normal 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`;
|
||||
}
|
||||
28
packages/devtools/tooling/electrobun/src/templates/index.ts
Normal file
28
packages/devtools/tooling/electrobun/src/templates/index.ts
Normal 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";
|
||||
107
packages/devtools/tooling/electrobun/src/templates/templates.md
Normal file
107
packages/devtools/tooling/electrobun/src/templates/templates.md
Normal 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.
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
472
packages/devtools/tooling/electrobun/src/templates/templates.ts
Normal file
472
packages/devtools/tooling/electrobun/src/templates/templates.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
26
packages/devtools/tooling/electrobun/src/types/index.ts
Normal file
26
packages/devtools/tooling/electrobun/src/types/index.ts
Normal 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";
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/devtools/tooling/electrobun/tsconfig.lib.json
Normal file
12
packages/devtools/tooling/electrobun/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
63
packages/devtools/tooling/electrobun/vite.config.ts
Normal file
63
packages/devtools/tooling/electrobun/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
37
packages/devtools/tooling/electrobun/vitest.config.ts
Normal file
37
packages/devtools/tooling/electrobun/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user