Initial Commit
This commit is contained in:
172
openspec/archive/2026-05-25-enterprise-electrobun/design.md
Normal file
172
openspec/archive/2026-05-25-enterprise-electrobun/design.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Design — @suaveplan/electrobun (genesis port)
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
┌───────────────────────────────────────────┐
|
||||
│ src/bin/suaveplan-electrobun │
|
||||
│ (process.argv → exit code) │
|
||||
└───────────────────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────┐
|
||||
│ src/cli/cli.ts │
|
||||
│ runCli(argv) → { code } │
|
||||
│ createCommand / devCommand / buildCommand│
|
||||
│ packageCommand / signCommand │
|
||||
└───┬──────┬──────┬──────┬──────┬───────────┘
|
||||
│ │ │ │ │
|
||||
┌─────────┘ │ │ │ └──────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
generator/ runner/ runner/ runner/ runner/
|
||||
├─ collectFiles electrobun electrobun electrobun electrobun
|
||||
├─ generate dev … build … package … sign …
|
||||
│ ├─ templates/
|
||||
│ ├─ icons/
|
||||
│ ├─ fs/ (FsAdapter)
|
||||
│ └─ config/
|
||||
▼
|
||||
templates/ (project + feature renderers)
|
||||
```
|
||||
|
||||
Every IO-touching primitive accepts an injectable adapter:
|
||||
|
||||
| Adapter | Default | Used by |
|
||||
|---|---|---|
|
||||
| `FsAdapter` | `nodeFsAdapter` (real `node:fs`) | `generator`, `config`, `prompts`, `icons` |
|
||||
| `CommandRunner` | `nodeCommandRunner` (real `child_process.spawn`) | `runner`, `dev/build/package/sign` |
|
||||
| `IconRendererAdapter` | `iconzRendererAdapter` (real `iconz`) | `icons` |
|
||||
| `Prompter` | `@suaveplan/cli` prompt façade | `prompts` |
|
||||
|
||||
This is the same seam pattern `@suaveplan/linter` uses for biome / fs / glob; the
|
||||
fakes used in tests are real-behaviour fakes (temp dirs, deterministic command
|
||||
output), not `vi.mock` stubs.
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/devtools/tooling/electrobun/
|
||||
package.json
|
||||
tsconfig.lib.json
|
||||
vite.config.ts
|
||||
vitest.config.ts
|
||||
README.md
|
||||
tests/
|
||||
setup/polyfills.ts
|
||||
package-shape.test.ts
|
||||
src/
|
||||
index.ts
|
||||
bin/
|
||||
suaveplan-electrobun.ts
|
||||
suaveplan-electrobun.md
|
||||
error-codes/
|
||||
error-codes.ts
|
||||
error-codes.test.ts
|
||||
error-codes.md
|
||||
index.ts
|
||||
telemetry/
|
||||
telemetry.ts
|
||||
telemetry.test.ts
|
||||
telemetry.md
|
||||
index.ts
|
||||
types/
|
||||
electrobun.types.ts
|
||||
electrobun.types.md
|
||||
index.ts
|
||||
schemas/
|
||||
schemas.ts
|
||||
schemas.test.ts
|
||||
schemas.md
|
||||
index.ts
|
||||
fs/
|
||||
fs.ts
|
||||
fs.test.ts
|
||||
fs.md
|
||||
index.ts
|
||||
runner/
|
||||
runner.ts
|
||||
runner.test.ts
|
||||
runner.md
|
||||
index.ts
|
||||
config/
|
||||
config.ts
|
||||
config.test.ts
|
||||
config.md
|
||||
index.ts
|
||||
templates/
|
||||
templates.ts
|
||||
templates.test.ts
|
||||
templates.md
|
||||
features.ts
|
||||
features.test.ts
|
||||
features.md
|
||||
index.ts
|
||||
icons/
|
||||
icons.ts
|
||||
icons.test.ts
|
||||
icons.md
|
||||
index.ts
|
||||
generator/
|
||||
generator.ts
|
||||
generator.test.ts
|
||||
generator.md
|
||||
index.ts
|
||||
prompts/
|
||||
prompts.ts
|
||||
prompts.test.ts
|
||||
prompts.md
|
||||
index.ts
|
||||
cli/
|
||||
cli.ts
|
||||
cli.test.ts
|
||||
cli.md
|
||||
index.ts
|
||||
```
|
||||
|
||||
## Error codes
|
||||
|
||||
`defineCodes("ELECTROBUN", […])` with exactly:
|
||||
|
||||
- `INVALID_OPTIONS` — Zod boundary failure.
|
||||
- `OUTPUT_NOT_EMPTY` — refusing to overwrite a non-empty target directory.
|
||||
- `CONFIG_PARSE_FAILED` — `.suaveplan-electrobun.json` exists but is malformed.
|
||||
- `ICON_SOURCE_MISSING` — `iconSource` path does not exist.
|
||||
- `ICON_RENDER_FAILED` — `iconz` raised.
|
||||
- `TEMPLATE_RENDER_FAILED` — a renderer threw.
|
||||
- `FS_WRITE_FAILED` — `writeFiles` could not commit.
|
||||
- `BINARY_UNAVAILABLE` — `bunx electrobun --version` returns non-zero.
|
||||
- `RUNNER_FAILED` — orchestrated command (dev/build/package/sign) returned non-zero.
|
||||
- `PROMPT_ABORTED` — user cancelled the interactive prompt.
|
||||
- `UNKNOWN_FLAG` — `runCli` saw an argv it does not recognise.
|
||||
|
||||
## OTel surface
|
||||
|
||||
- `electrobun_projects_generated_total` (counter; tagged `features_count`).
|
||||
- `electrobun_icons_generated_total` (counter; tagged `outcome=generated|skipped|failed`).
|
||||
- `electrobun_runner_invocations_total` (counter; tagged `command=dev|build|package|sign|probe`).
|
||||
- `electrobun_runner_failures_total` (counter; tagged `command`, `exit_code`).
|
||||
- `electrobun_prompts_total` (counter; tagged `outcome=completed|aborted`).
|
||||
- `electrobun_generate_duration_ms` (histogram; tagged `features_count`).
|
||||
- `electrobun_runner_duration_ms` (histogram; tagged `command`).
|
||||
|
||||
## Test strategy
|
||||
|
||||
Tests run under Vitest via `@suaveplan/testing/runner`. Everything IO-touching uses
|
||||
a real `mkdtemp` temp directory and the real `nodeFsAdapter` (no `vi.mock`). The
|
||||
runner surface is exercised with a real-behaviour fake `CommandRunner` that records
|
||||
the spawn args and returns a canned `CommandResult`; one integration scenario
|
||||
exercises the real `nodeCommandRunner` against `node --version` to prove the seam
|
||||
default works end-to-end. The icon renderer is exercised with a real-behaviour
|
||||
fake `IconRendererAdapter` that asserts the option shape passed to `iconz`; the
|
||||
real `iconz` path is a one-line dynamic-import call covered by the default
|
||||
adapter's smoke test.
|
||||
|
||||
## Why `runner` is the right level of abstraction
|
||||
|
||||
Electrobun's dev/build/package/sign pipeline is implemented inside the
|
||||
`electrobun` binary that ships with the user's project. Re-implementing it here
|
||||
would duplicate the upstream and silently drift. Instead the wrapper shapes the
|
||||
argv, runs the binary through the `CommandRunner` seam, parses stdout/exit code,
|
||||
and produces a structured `*Report` that callers (CI, IDE extensions) can act on.
|
||||
This keeps the package thin enough to test exhaustively while still adding real
|
||||
value over directly calling `bunx electrobun build`.
|
||||
118
openspec/archive/2026-05-25-enterprise-electrobun/proposal.md
Normal file
118
openspec/archive/2026-05-25-enterprise-electrobun/proposal.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Proposal — Migrate @suaveplan/electrobun to genesis
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/electrobun/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Upstream `@suaveplan/electrobun` (`/mnt/s/web/suaveplan/packages/universal/electrobun/`)
|
||||
is a thin scaffolder: it prompts the user for an app name and a feature list, runs an
|
||||
`iconz`-backed icon pipeline, and emits a fixed-template Electrobun project to disk.
|
||||
The genesis `enterprise/electrobun.md` audit highlights five structural gaps:
|
||||
|
||||
1. every failure path is either an `Error` literal or a `console.log`, so consumers
|
||||
cannot discriminate between "icon source missing" and "rollup chain blew up";
|
||||
2. no OpenTelemetry surface for what is the entry point new contributors hit first;
|
||||
3. the generator is hard-wired to `node:fs` with no seam for tests or for embedding
|
||||
in CI;
|
||||
4. only the `create` step is covered — operators have nothing for `dev` (watch +
|
||||
restart), `build` (cross-platform packaging), `package` (release-bundle assembly),
|
||||
`sign` (notarization + entitlements), or the auto-update pipeline; and
|
||||
5. the `renderXxxFeature()` template functions are inlined-string blobs with no
|
||||
validation of the render input, so a stale `ProjectOptions` shape silently
|
||||
produces a broken project.
|
||||
|
||||
Genesis cleans up the contract: every failure raises a typed `ElectrobunError`
|
||||
carrying a stable `ELECTROBUN.*` code; every command and every render-helper takes
|
||||
an injectable seam (`FsAdapter`, `CommandRunner`, `IconRendererAdapter`,
|
||||
`Prompter`); every public option is Zod-validated; the CLI grows from one command
|
||||
(`create`) to five (`create`, `dev`, `build`, `package`, `sign`); and an
|
||||
OTel surface (5 counters + 2 histograms) makes the package observable in CI.
|
||||
|
||||
## What
|
||||
|
||||
Land `@suaveplan/electrobun@0.1.0` at `packages/devtools/tooling/electrobun/` with the
|
||||
following feature modules (one folder per feature, sibling `.md` per implementation
|
||||
file; no `electrobun-` prefix inside the package):
|
||||
|
||||
- `error-codes/` — `ELECTROBUN_CODES = defineCodes("ELECTROBUN", [...])` and
|
||||
`ElectrobunError`.
|
||||
- `telemetry/` — OTel counters + histograms (`projects_generated_total`,
|
||||
`icons_generated_total`, `runner_invocations_total`, `runner_failures_total`,
|
||||
`prompts_total`, `generate_duration_ms`, `runner_duration_ms`).
|
||||
- `types/` — shared interfaces (`ProjectOptions`, `ElectrobunConfig`, `GeneratedFile`,
|
||||
`FsAdapter`, `CommandRunner`, `CommandInput`, `CommandResult`,
|
||||
`IconRendererAdapter`, `Prompter`, every `*Report` / `*Options`).
|
||||
- `schemas/` — Zod schemas for every public type; both schema and inferred type
|
||||
exported.
|
||||
- `fs/` — `nodeFsAdapter` and seam-driven `ensureDirectory`, `directoryExists`,
|
||||
`directoryIsEmpty`, `writeFiles`, `findConfigFile`.
|
||||
- `runner/` — `CommandRunner` contract + `nodeCommandRunner` default (spawn-based).
|
||||
- `config/` — `loadConfig(cliArgs, opts?)` consuming the
|
||||
`@suaveplan/config` 5-layer pipeline (defaults / file / CLI) with the
|
||||
`FsAdapter` injected for file discovery.
|
||||
- `templates/` — render helpers for every project file
|
||||
(`electrobun-config`, `main-process`, `package-json`, `tsconfig`, `readme`,
|
||||
`mainview-html`, `mainview-css`, `mainview-entry`, `shared-types`) plus
|
||||
`templates/features/` for every feature renderer (`i18n`, `network`,
|
||||
`network-full`, `api`, `api-schemas`, `storage`, `auth`, `form-builder`, `r3f`).
|
||||
- `generator/` — `collectFiles(options)`, `generate(options, opts?)` —
|
||||
orchestrates the renderer table, writes via `FsAdapter`, returns a
|
||||
`GenerateReport`.
|
||||
- `icons/` — `generateIcons(opts, deps?)` — wraps `iconz` via the
|
||||
`IconRendererAdapter` seam.
|
||||
- `prompts/` — `runPrompts(config, outputDir, prompter?)`.
|
||||
- `cli/` — five pure functions (`createCommand`, `devCommand`, `buildCommand`,
|
||||
`packageCommand`, `signCommand`) and `runCli(argv, opts?)`. Each returns a
|
||||
`*Report`; none call `process.exit`.
|
||||
- `bin/` — `suaveplan-electrobun` binary wiring `runCli` through
|
||||
`process.argv.slice(2)`.
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| `ProjectOptions` / `ElectrobunConfig` types | upstream | yes | Preserved; now Zod-validated. |
|
||||
| `FEATURE_LABELS`, `R3F_MODULE_LABELS` | upstream | yes | Preserved. |
|
||||
| `FeatureModule`, `NetworkingComplexity`, `R3fModule` unions | upstream | yes | Preserved verbatim. |
|
||||
| `loadConfig(cliArgs)` layered loader | upstream | yes | Now consumes `@suaveplan/config`'s 5-layer pipeline; `findConfigFile` is `FsAdapter`-driven. |
|
||||
| `directoryExists` / `directoryIsEmpty` / `ensureDirectory` / `writeFiles` | upstream | yes | Preserved; injectable `FsAdapter`. |
|
||||
| `collectFiles(options)` core file list | upstream | yes | Preserved with deterministic ordering. |
|
||||
| `generate(options)` orchestrator | upstream | enhanced | Returns `GenerateReport`; emits OTel histogram. |
|
||||
| `generateIcons(input, outputDir)` | upstream | enhanced | Takes an `IconRendererAdapter` seam; default = real `iconz`. Returns `IconReport`. |
|
||||
| Project file renderers (9) | upstream | yes | Same outputs, deterministic. |
|
||||
| Feature renderers (8) | upstream | yes | Same outputs. |
|
||||
| Interactive `create` command | upstream | yes | Preserved; Zod-validated; `--skip-prompts` honoured. |
|
||||
| `dev` / `build` / `package` / `sign` commands | none | added | Orchestrate the upstream `electrobun` binary via `CommandRunner`; report shape + error classification. |
|
||||
| Auto-update bundling | none | added | Surfaced via `buildOptions.update` → `electrobun build --update` argv. |
|
||||
| Code-signing options | none | added | `signCommand` exposes identity / entitlements / notarization; the wrapper shapes args + classifies errors. |
|
||||
| Error-code registry | none | added | `ELECTROBUN.*` namespace, eleven codes. |
|
||||
| OTel self-telemetry | none | added | 5 counters + 2 histograms. |
|
||||
| Zod-validated options | none | added | Every public option is Zod-validated. |
|
||||
| Subpath exports per feature | none | added | One subpath export per feature module. |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/electrobun` to enumerate. Net-new package on
|
||||
genesis — no current dependents.
|
||||
|
||||
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/electrobun`).
|
||||
|
||||
## Risks
|
||||
|
||||
- **`iconz` ESM resolution.** Upstream loads `iconz/presets` dynamically. The
|
||||
genesis port keeps the dynamic-import path behind the `IconRendererAdapter` seam
|
||||
so tests can swap in a fake renderer without paying the `iconz` cost.
|
||||
- **`electrobun` binary availability.** `dev`/`build`/`package`/`sign` shell out to
|
||||
the user's local `electrobun` binary (installed in the generated project's
|
||||
`node_modules`). The wrapper probes for it via the `CommandRunner` seam and
|
||||
raises `ELECTROBUN.BINARY_UNAVAILABLE` with a remediation hint when missing.
|
||||
- **Signing requires real certs.** The `sign` command's test surface asserts only
|
||||
the argument-shape and error-classification contract; the real signing pipeline
|
||||
runs against the user's keychain / Apple-developer-id / Windows-PFX and is
|
||||
exercised by an opt-in integration scenario that probes the runner.
|
||||
- **Cross-platform packaging.** `electrobun build` produces platform-specific
|
||||
outputs; the wrapper records the requested target triple (`mac` / `linux` /
|
||||
`windows`) in the `PackageReport` so CI can branch on it.
|
||||
@@ -0,0 +1,391 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: electrobun
|
||||
|
||||
**Package**: `@suaveplan/electrobun`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ELECTROBUN_CODES registry MUST enumerate every typed failure path
|
||||
|
||||
The package MUST export `ELECTROBUN_CODES = defineCodes("ELECTROBUN", [...])` exposing
|
||||
exactly the following codes: `INVALID_OPTIONS`, `OUTPUT_NOT_EMPTY`,
|
||||
`CONFIG_PARSE_FAILED`, `ICON_SOURCE_MISSING`, `ICON_RENDER_FAILED`,
|
||||
`TEMPLATE_RENDER_FAILED`, `FS_WRITE_FAILED`, `BINARY_UNAVAILABLE`, `RUNNER_FAILED`,
|
||||
`PROMPT_ABORTED`, `UNKNOWN_FLAG`. Every code MUST be reachable from a call site
|
||||
exercised by a test in `src/`. The exported `ElectrobunError` MUST extend
|
||||
`SuaveplanError<ElectrobunCode>` and carry a `details: Record<string, unknown>`
|
||||
field for structured diagnostics.
|
||||
|
||||
#### Scenario: registry exposes the full code set
|
||||
|
||||
- **WHEN** the consumer imports `ELECTROBUN_CODES` and reads `Object.values(ELECTROBUN_CODES)`
|
||||
- **THEN** the result contains exactly the eleven declared codes (no more, no less)
|
||||
|
||||
#### Scenario: ElectrobunError carries code and details
|
||||
|
||||
- **WHEN** `new ElectrobunError(ELECTROBUN_CODES.INVALID_OPTIONS, "bad", { details: { field: "x" } })`
|
||||
- **THEN** the instance has `code === "ELECTROBUN.INVALID_OPTIONS"`,
|
||||
`details.field === "x"`, and `instanceof SuaveplanError === true`
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"registry exposes the full code set"` AND `src/error-codes/error-codes.test.ts::"ElectrobunError carries code and details"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Public options MUST be Zod-validated at the boundary
|
||||
|
||||
Every public entry point (`generate`, `collectFiles`, `generateIcons`, `loadConfig`,
|
||||
`runPrompts`, `createCommand`, `devCommand`, `buildCommand`, `packageCommand`,
|
||||
`signCommand`, `runCli`) MUST validate its option object against an exported Zod
|
||||
schema. A schema parse failure MUST surface as `ElectrobunError` with code
|
||||
`ELECTROBUN.INVALID_OPTIONS` and a `details.issues` array carrying the Zod issue
|
||||
list.
|
||||
|
||||
#### Scenario: ProjectOptionsSchema rejects empty name
|
||||
|
||||
- **WHEN** `ProjectOptionsSchema.safeParse({ ...validOptions, name: "" })` runs
|
||||
- **THEN** the parse fails with at least one issue on path `["name"]`
|
||||
|
||||
#### Scenario: collectFiles raises INVALID_OPTIONS on bad input
|
||||
|
||||
- **WHEN** `collectFiles({ name: "", identifier: "x", description: "y", version: "0.0.1", outputDir: "/tmp/a", features: [], networkingComplexity: "basic", r3fModules: [] })` runs
|
||||
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.INVALID_OPTIONS"` is thrown
|
||||
|
||||
**Verified by:** `src/schemas/schemas.test.ts::"ProjectOptionsSchema rejects empty name"` AND `src/generator/generator.test.ts::"collectFiles raises INVALID_OPTIONS on empty name"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: collectFiles MUST return the deterministic core file list
|
||||
|
||||
`collectFiles(options)` MUST return a `GeneratedFile[]` containing — in order — the
|
||||
nine core paths `electrobun.config.ts`, `package.json`, `tsconfig.json`,
|
||||
`src/bun/index.ts`, `src/mainview/index.html`, `src/mainview/index.css`,
|
||||
`src/mainview/index.tsx`, `src/shared/types.ts`, `README.md`, followed by one file
|
||||
per selected feature in the canonical order `i18n`, `networking`, `api`,
|
||||
`api-schemas`, `storage`, `auth`, `form-builder`, `r3f`. Feature ordering MUST
|
||||
not depend on `options.features` insertion order.
|
||||
|
||||
#### Scenario: minimal options emit the nine core files
|
||||
|
||||
- **WHEN** `collectFiles({ ...validOptions, features: [] })` runs
|
||||
- **THEN** the returned `paths` equals the nine-element core list
|
||||
|
||||
#### Scenario: all eight features emit all feature paths
|
||||
|
||||
- **WHEN** `collectFiles({ ...validOptions, features: ["i18n","networking","api","api-schemas","storage","auth","form-builder","r3f"], r3fModules: ["camera"] })`
|
||||
- **THEN** the returned paths contain `src/features/i18n.ts`,
|
||||
`src/features/network.ts`, `src/features/api.ts`,
|
||||
`src/features/schemas.ts`, `src/features/storage.ts`,
|
||||
`src/features/auth.ts`, `src/features/form-builder.ts`,
|
||||
`src/features/r3f.tsx`
|
||||
|
||||
#### Scenario: feature order is canonical regardless of input order
|
||||
|
||||
- **WHEN** `collectFiles({ ...validOptions, features: ["r3f","i18n","api"] })`
|
||||
- **THEN** the feature paths in the returned list appear in the order
|
||||
`src/features/i18n.ts`, `src/features/api.ts`, `src/features/r3f.tsx`
|
||||
|
||||
**Verified by:** `src/generator/generator.test.ts::"minimal options emit the nine core files"` AND `src/generator/generator.test.ts::"all eight features emit all feature paths"` AND `src/generator/generator.test.ts::"feature order is canonical regardless of input order"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: generate MUST write files via the injectable FsAdapter
|
||||
|
||||
`generate(options, deps?)` MUST resolve `deps.fs ?? nodeFsAdapter`, write every
|
||||
file from `collectFiles(options)` to `options.outputDir` via that adapter, write
|
||||
the `.suaveplan-electrobun.json` config snapshot, optionally run the icon pipeline
|
||||
when `options.iconSource` is set, and return a `GenerateReport` carrying
|
||||
`{ outputDir, filesCreated, durationMs, icons }` where `icons` is the icon
|
||||
report (or `undefined` when no icon source was supplied). The histogram
|
||||
`electrobun_generate_duration_ms` MUST receive one observation per call.
|
||||
|
||||
#### Scenario: generate writes every file from collectFiles
|
||||
|
||||
- **WHEN** `generate({ ...validOptions, outputDir: <tmpDir> }, { fs })` runs
|
||||
- **THEN** `fs` has received one `writeFile` call per file in `collectFiles(options)`,
|
||||
plus one for `.suaveplan-electrobun.json`
|
||||
|
||||
#### Scenario: generate skips icons when iconSource is absent
|
||||
|
||||
- **WHEN** `generate({ ...validOptions, iconSource: undefined }, deps)` runs
|
||||
- **THEN** the returned `report.icons` is `undefined`
|
||||
|
||||
**Verified by:** `src/generator/generator.test.ts::"generate writes every file from collectFiles"` AND `src/generator/generator.test.ts::"generate skips icons when iconSource is absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: generateIcons MUST run the icon pipeline through the IconRendererAdapter
|
||||
|
||||
`generateIcons({ inputPath, outputDir }, deps?)` MUST resolve
|
||||
`deps.renderer ?? iconzRendererAdapter`, invoke `renderer.render({ input,
|
||||
output, icons })` with the canonical icon descriptor (icns / ico / png set), and
|
||||
return an `IconReport` of shape `{ generated: boolean; files: readonly string[];
|
||||
error?: string }`. A missing input path MUST surface as
|
||||
`ELECTROBUN.ICON_SOURCE_MISSING`. A renderer throw MUST surface as
|
||||
`ELECTROBUN.ICON_RENDER_FAILED` with the message preserved in `details.message`.
|
||||
|
||||
#### Scenario: missing source raises ICON_SOURCE_MISSING
|
||||
|
||||
- **WHEN** `generateIcons({ inputPath: "/does/not/exist", outputDir: "/tmp/a" })`
|
||||
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.ICON_SOURCE_MISSING"`
|
||||
is thrown
|
||||
|
||||
#### Scenario: renderer throw is wrapped as ICON_RENDER_FAILED
|
||||
|
||||
- **WHEN** `generateIcons({ inputPath, outputDir }, { renderer: throwingRenderer })`
|
||||
- **THEN** the returned report has `generated === false` AND `error` contains
|
||||
the underlying message
|
||||
|
||||
**Verified by:** `src/icons/icons.test.ts::"missing source raises ICON_SOURCE_MISSING"` AND `src/icons/icons.test.ts::"renderer throw is wrapped as ICON_RENDER_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: loadConfig MUST stack defaults, file, and CLI layers
|
||||
|
||||
`loadConfig(cliArgs, deps?)` MUST resolve `deps.fs ?? nodeFsAdapter`, locate
|
||||
`.suaveplan-electrobun.json` in `cwd` or `home` via the adapter, and build an
|
||||
`ElectrobunConfig` through `@suaveplan/config`'s `createConfig`/`defaultsLayer` +
|
||||
`fileLayer` + `cliLayer` pipeline. CLI args MUST map `--name → name`,
|
||||
`--id → identifier`, `--icon → iconSource`, `--version → version`. Defaults MUST
|
||||
fill `version="0.0.1"`, `features=[]`, `networkingComplexity="basic"`,
|
||||
`r3fModules=[]`. A malformed config file MUST surface as
|
||||
`ELECTROBUN.CONFIG_PARSE_FAILED`.
|
||||
|
||||
#### Scenario: CLI args override file values
|
||||
|
||||
- **WHEN** `loadConfig(["--name", "cli-app"], { fs: fsWithFile({ name: "file-app" }) })`
|
||||
- **THEN** the returned config has `name === "cli-app"`
|
||||
|
||||
#### Scenario: malformed file raises CONFIG_PARSE_FAILED
|
||||
|
||||
- **WHEN** `loadConfig([], { fs: fsWithCorruptFile() })`
|
||||
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.CONFIG_PARSE_FAILED"`
|
||||
is thrown
|
||||
|
||||
**Verified by:** `src/config/config.test.ts::"CLI args override file values"` AND `src/config/config.test.ts::"malformed file raises CONFIG_PARSE_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Templates MUST render every project and feature file deterministically
|
||||
|
||||
The `renderProjectFile(kind, options)` and `renderFeature(feature, options)`
|
||||
functions MUST cover the full upstream set: `electrobun-config`, `main-process`,
|
||||
`package-json`, `tsconfig`, `readme`, `mainview-html`, `mainview-css`,
|
||||
`mainview-entry`, `shared-types` (project) and `i18n`, `network`, `network-full`,
|
||||
`api`, `api-schemas`, `storage`, `auth`, `form-builder`, `r3f` (feature). For a
|
||||
fixed `ProjectOptions` input, each renderer MUST produce a byte-identical output
|
||||
across runs.
|
||||
|
||||
#### Scenario: renderProjectFile output is byte-stable
|
||||
|
||||
- **WHEN** `renderProjectFile("package-json", validOptions)` runs twice
|
||||
- **THEN** both invocations return identical strings
|
||||
|
||||
#### Scenario: networking feature complexity selects the correct renderer
|
||||
|
||||
- **WHEN** `renderFeature("networking", { ...opts, networkingComplexity: "full" })`
|
||||
- **THEN** the output contains `state:` AND `governance:`
|
||||
|
||||
#### Scenario: r3f renderer emits the requested modules
|
||||
|
||||
- **WHEN** `renderFeature("r3f", { ...opts, r3fModules: ["physics","particles"] })`
|
||||
- **THEN** the output contains `@react-three/rapier` AND `Sparkles`
|
||||
|
||||
**Verified by:** `src/templates/templates.test.ts::"renderProjectFile output is byte-stable"` AND `src/templates/features.test.ts::"networking feature complexity selects the correct renderer"` AND `src/templates/features.test.ts::"r3f renderer emits the requested modules"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: FsAdapter MUST expose every operation the generator needs
|
||||
|
||||
The exported `FsAdapter` contract MUST expose at minimum
|
||||
`readFile(path): Promise<string>`, `writeFile(path, content): Promise<void>`,
|
||||
`mkdir(path, opts): Promise<void>`, `stat(path): Promise<{ isDirectory(): boolean }>`,
|
||||
`readdir(path): Promise<readonly string[]>`. The `nodeFsAdapter` default MUST be
|
||||
backed by `node:fs/promises` and MUST round-trip a real file under a real
|
||||
`mkdtemp` directory. A `writeFile` failure MUST surface as
|
||||
`ELECTROBUN.FS_WRITE_FAILED`.
|
||||
|
||||
#### Scenario: nodeFsAdapter round-trips a file
|
||||
|
||||
- **WHEN** `await fs.writeFile(p, "x")` then `await fs.readFile(p)`
|
||||
- **THEN** the read returns `"x"`
|
||||
|
||||
#### Scenario: writeFiles surfaces FS_WRITE_FAILED on adapter throw
|
||||
|
||||
- **WHEN** `writeFiles(outputDir, files, { fs: throwingFs })` runs
|
||||
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.FS_WRITE_FAILED"`
|
||||
is thrown
|
||||
|
||||
**Verified by:** `src/fs/fs.test.ts::"nodeFsAdapter round-trips a file"` AND `src/fs/fs.test.ts::"writeFiles surfaces FS_WRITE_FAILED on adapter throw"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CommandRunner MUST shape every electrobun shell-out
|
||||
|
||||
The exported `CommandRunner` contract MUST be
|
||||
`(input: CommandInput) => Promise<CommandResult>` where `CommandInput` carries
|
||||
`{ command, args, cwd?, env?, signal? }` and `CommandResult` carries
|
||||
`{ exitCode, stdout, stderr, durationMs }`. `probeElectrobun(runner)` MUST
|
||||
invoke the runner with `command="bunx"` and `args=["electrobun","--version"]`
|
||||
and return `{ available: boolean; version?: string }`. A non-zero exit MUST
|
||||
classify as `available === false`. The `nodeCommandRunner` default MUST spawn
|
||||
via `node:child_process.spawn` and resolve stdout / stderr as UTF-8 strings.
|
||||
|
||||
#### Scenario: probeElectrobun returns available with version on success
|
||||
|
||||
- **WHEN** `probeElectrobun(runnerReturning({ exitCode: 0, stdout: "1.2.3\n" }))`
|
||||
- **THEN** the result is `{ available: true, version: "1.2.3" }`
|
||||
|
||||
#### Scenario: probeElectrobun returns unavailable on non-zero exit
|
||||
|
||||
- **WHEN** `probeElectrobun(runnerReturning({ exitCode: 127, stdout: "" }))`
|
||||
- **THEN** the result is `{ available: false }`
|
||||
|
||||
**Verified by:** `src/runner/runner.test.ts::"probeElectrobun returns available with version on success"` AND `src/runner/runner.test.ts::"probeElectrobun returns unavailable on non-zero exit"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: dev / build / package / sign commands MUST orchestrate the binary
|
||||
|
||||
Each of `devCommand`, `buildCommand`, `packageCommand`, `signCommand` MUST:
|
||||
|
||||
1. validate its option schema (raising `ELECTROBUN.INVALID_OPTIONS` on failure);
|
||||
2. probe the electrobun binary (raising `ELECTROBUN.BINARY_UNAVAILABLE` when
|
||||
absent);
|
||||
3. spawn the binary with the canonical argv shape (`dev` → `electrobun dev`;
|
||||
`build` → `electrobun build --target <target> [--update]`; `package` →
|
||||
`electrobun package --target <target>`; `sign` → `electrobun sign --identity
|
||||
<identity> [--entitlements <path>] [--notarize]`);
|
||||
4. classify a non-zero exit as `ELECTROBUN.RUNNER_FAILED` with `details.command`,
|
||||
`details.exitCode`, and `details.stderr`;
|
||||
5. record one observation in `electrobun_runner_duration_ms` and one increment
|
||||
in `electrobun_runner_invocations_total` per call;
|
||||
6. return a `*Report` carrying `{ command, exitCode, durationMs, stdout, stderr }`.
|
||||
|
||||
#### Scenario: buildCommand passes --update when options.update is true
|
||||
|
||||
- **WHEN** `buildCommand({ cwd, target: "mac", update: true }, { runner: recordingRunner })`
|
||||
- **THEN** the recorded args include `"--update"`
|
||||
|
||||
#### Scenario: signCommand forwards identity, entitlements, and notarize
|
||||
|
||||
- **WHEN** `signCommand({ cwd, identity: "Dev ID", entitlements: "/e.plist", notarize: true }, deps)`
|
||||
- **THEN** the recorded args contain `"--identity"`, `"Dev ID"`,
|
||||
`"--entitlements"`, `"/e.plist"`, `"--notarize"`
|
||||
|
||||
#### Scenario: non-zero exit raises RUNNER_FAILED
|
||||
|
||||
- **WHEN** `devCommand({ cwd }, { runner: failingRunner })` runs
|
||||
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.RUNNER_FAILED"` is
|
||||
thrown AND `details.exitCode` is preserved
|
||||
|
||||
#### Scenario: missing binary raises BINARY_UNAVAILABLE
|
||||
|
||||
- **WHEN** `buildCommand({ cwd, target: "mac" }, { runner: missingBinaryRunner })`
|
||||
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.BINARY_UNAVAILABLE"`
|
||||
is thrown
|
||||
|
||||
**Verified by:** `src/cli/cli.test.ts::"buildCommand passes --update when options.update is true"` AND `src/cli/cli.test.ts::"signCommand forwards identity, entitlements, and notarize"` AND `src/cli/cli.test.ts::"non-zero exit raises RUNNER_FAILED"` AND `src/cli/cli.test.ts::"missing binary raises BINARY_UNAVAILABLE"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: runPrompts MUST drive the interactive scaffolder via the injectable Prompter
|
||||
|
||||
`runPrompts(config, outputDir, prompter?)` MUST prompt for `name`, `identifier`,
|
||||
`description`, `version`, `iconSource`, `features`, `networkingComplexity` (only
|
||||
when `features` includes `"networking"`), `r3fModules` (only when `features`
|
||||
includes `"r3f"`), and a `proceed` confirmation. A negative confirmation MUST
|
||||
surface as `ELECTROBUN.PROMPT_ABORTED`. The function MUST default
|
||||
`networkingComplexity` to `"basic"` and `r3fModules` to `[]` when the gating
|
||||
feature is not selected.
|
||||
|
||||
#### Scenario: runPrompts assembles a complete ProjectOptions
|
||||
|
||||
- **WHEN** `runPrompts(emptyConfig, undefined, scriptedPrompter)` runs
|
||||
- **THEN** the returned object satisfies `ProjectOptionsSchema.safeParse(result).success === true`
|
||||
|
||||
#### Scenario: negative confirmation aborts the prompt
|
||||
|
||||
- **WHEN** `runPrompts(emptyConfig, undefined, scriptedPrompter({ proceed: false }))`
|
||||
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.PROMPT_ABORTED"`
|
||||
is thrown
|
||||
|
||||
**Verified by:** `src/prompts/prompts.test.ts::"runPrompts assembles a complete ProjectOptions"` AND `src/prompts/prompts.test.ts::"negative confirmation aborts the prompt"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: runCli MUST dispatch every subcommand and never call process.exit
|
||||
|
||||
`runCli(argv, opts?)` MUST recognise the subcommands `create`, `dev`, `build`,
|
||||
`package`, `sign`, and `--help` / `-h`. Unknown subcommands or unknown flags
|
||||
MUST raise `ELECTROBUN.UNKNOWN_FLAG`. The function MUST return
|
||||
`{ code: 0 }` on success and `{ code: 1 }` on a handled
|
||||
`ElectrobunError`. No code path inside `runCli` MUST call
|
||||
`process.exit`; only the binary entry MUST own the exit.
|
||||
|
||||
#### Scenario: --help returns code 0
|
||||
|
||||
- **WHEN** `runCli(["--help"], { out: capture })` runs
|
||||
- **THEN** the result is `{ code: 0 }` AND `capture` recorded a help string
|
||||
|
||||
#### Scenario: unknown subcommand raises UNKNOWN_FLAG
|
||||
|
||||
- **WHEN** `runCli(["zzz"])` runs
|
||||
- **THEN** the result is `{ code: 1 }` AND the captured stderr contains
|
||||
`"ELECTROBUN.UNKNOWN_FLAG"`
|
||||
|
||||
**Verified by:** `src/cli/cli.test.ts::"--help returns code 0"` AND `src/cli/cli.test.ts::"unknown subcommand raises UNKNOWN_FLAG"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Telemetry MUST emit the documented OTel surface
|
||||
|
||||
The package MUST expose `electrobun_projects_generated_total`,
|
||||
`electrobun_icons_generated_total`, `electrobun_runner_invocations_total`,
|
||||
`electrobun_runner_failures_total`, `electrobun_prompts_total` counters and
|
||||
`electrobun_generate_duration_ms`, `electrobun_runner_duration_ms` histograms,
|
||||
each on the `@suaveplan/electrobun` meter, and MUST provide `recordProject`,
|
||||
`recordIcons`, `recordRunnerInvocation`, `recordRunnerFailure`,
|
||||
`recordPromptOutcome`, `recordGenerateDuration`, `recordRunnerDuration` helpers
|
||||
that increment / observe with the documented label keys.
|
||||
|
||||
#### Scenario: recordRunnerInvocation increments the counter once per call
|
||||
|
||||
- **WHEN** `recordRunnerInvocation("build")`
|
||||
- **THEN** the in-memory `MeterProvider` reports exactly one data point on
|
||||
`electrobun_runner_invocations_total` with `command="build"`
|
||||
|
||||
#### Scenario: recordGenerateDuration writes to the histogram
|
||||
|
||||
- **WHEN** `recordGenerateDuration(123, 2)`
|
||||
- **THEN** the in-memory `MeterProvider` reports one observation of `123`
|
||||
on `electrobun_generate_duration_ms` with `features_count="2"`
|
||||
|
||||
**Verified by:** `src/telemetry/telemetry.test.ts::"recordRunnerInvocation increments the counter once per call"` AND `src/telemetry/telemetry.test.ts::"recordGenerateDuration writes to the histogram"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Package MUST be server-tier with per-feature subpath exports
|
||||
|
||||
The `package.json` MUST declare `"type": "module"`, `"sideEffects": false`,
|
||||
`bin: { "suaveplan-electrobun": "./dist/bin/suaveplan-electrobun.js" }`, and
|
||||
subpath exports for `.`, `./error-codes`, `./telemetry`, `./types`, `./schemas`,
|
||||
`./fs`, `./runner`, `./config`, `./templates`, `./icons`, `./generator`,
|
||||
`./prompts`, `./cli`. Internal deps MUST use `workspace:^`; external deps MUST
|
||||
use `catalog:*` references (`zod` → `forms`, `@opentelemetry/api` → `observability`,
|
||||
`tslib` → `build`, `iconz` → `cli`). No bare version pins.
|
||||
|
||||
#### Scenario: package.json declares the documented subpath exports
|
||||
|
||||
- **WHEN** the consumer reads `Object.keys(pkg.exports).filter(k => k.startsWith("./") && k !== "./package.json")`
|
||||
- **THEN** the result contains all thirteen documented subpaths
|
||||
|
||||
#### Scenario: bin entry resolves to the published binary
|
||||
|
||||
- **WHEN** the consumer reads `pkg.bin["suaveplan-electrobun"]`
|
||||
- **THEN** the value is `"./dist/bin/suaveplan-electrobun.js"`
|
||||
|
||||
**Verified by:** `tests/package-shape.test.ts::"package.json declares the documented subpath exports"` AND `tests/package-shape.test.ts::"bin entry resolves to the published binary"`
|
||||
33
openspec/archive/2026-05-25-enterprise-electrobun/tasks.md
Normal file
33
openspec/archive/2026-05-25-enterprise-electrobun/tasks.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Tasks — Migrate @suaveplan/electrobun
|
||||
|
||||
1. [x] Author proposal.md, design.md, spec delta.
|
||||
2. [x] Add `iconz` to the `cli` catalog in the root `package.json`.
|
||||
3. [x] Scaffold package at `packages/devtools/tooling/electrobun/` with `package.json`
|
||||
(v0.1.0, subpath exports per feature), `tsconfig.lib.json`, `vite.config.ts`,
|
||||
`vitest.config.ts`, `README.md`, `tests/setup/polyfills.ts`.
|
||||
4. [x] Implement `src/error-codes/error-codes.ts` with
|
||||
`defineCodes("ELECTROBUN", [...])` and `ElectrobunError`.
|
||||
5. [x] Implement `src/telemetry/telemetry.ts` (5 counters + 2 histograms).
|
||||
6. [x] Implement `src/types/electrobun.types.ts` with shared interfaces.
|
||||
7. [x] Implement `src/schemas/schemas.ts` with Zod schemas for every public type.
|
||||
8. [x] Implement `src/fs/fs.ts` (`nodeFsAdapter`, `findConfigFile`, helpers).
|
||||
9. [x] Implement `src/runner/runner.ts` (`CommandRunner`, `nodeCommandRunner`,
|
||||
`probeElectrobun`).
|
||||
10. [x] Implement `src/config/config.ts` (`loadConfig`).
|
||||
11. [x] Implement `src/templates/templates.ts` — every project + feature renderer.
|
||||
12. [x] Implement `src/icons/icons.ts` (`generateIcons` via `IconRendererAdapter`).
|
||||
13. [x] Implement `src/generator/generator.ts` (`collectFiles`, `generate`).
|
||||
14. [x] Implement `src/prompts/prompts.ts` (`runPrompts` with `Prompter` seam).
|
||||
15. [x] Implement `src/cli/cli.ts` — five command functions plus `runCli`.
|
||||
16. [x] Implement `src/bin/suaveplan-electrobun.ts` — single binary entry.
|
||||
17. [x] Sibling `.md` for every `.ts` (six required sections, ≥200 words,
|
||||
real-import basic + advanced examples).
|
||||
18. [x] `.test.ts` files exercising every spec scenario; runner imports from
|
||||
`@suaveplan/testing/runner`.
|
||||
19. [x] `bunx turbo lint typecheck test build --filter=@suaveplan/electrobun` — green.
|
||||
20. [x] `bunx turbo test:coverage --filter=@suaveplan/electrobun` — 100/100/100/100.
|
||||
21. [x] Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`.
|
||||
22. [x] Flip `STATUS.md` row from `–` to `✅` with one-line description.
|
||||
23. [x] Fold `specs/electrobun/spec.md` into `openspec/specs/electrobun/spec.md`.
|
||||
24. [x] Archive `openspec/changes/enterprise-electrobun/` →
|
||||
`openspec/archive/2026-05-25-enterprise-electrobun/`.
|
||||
Reference in New Issue
Block a user