Initial Commit
This commit is contained in:
23
packages/devtools/tooling/README.md
Normal file
23
packages/devtools/tooling/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# `tooling`
|
||||
|
||||
CLI frameworks, installers, and build tooling.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `tooling`
|
||||
|
||||
11 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cli`](./cli/README.md) | Enterprise-grade CLI framework for Node.js. Composable fluent API with subcommand routing, option parsing, OTel tracing, and a full set of terminal UI primitiv… |
|
||||
| [`@suaveplan/electrobun`](./electrobun/README.md) | Electrobun desktop-app generator and orchestrator for the SuavePlan ecosystem. Scaffolds a fresh Electrobun project from a layered config + prompt flow, drives… |
|
||||
| [`@suaveplan/install-agent-skills`](./install-agent-skills/README.md) | Installer plugin that bundles every Suaveplan skill (`SKILL.md` + assets) and installs them into a target project via `@suaveplan/installer`. |
|
||||
| [`@suaveplan/installer`](./installer/README.md) | Plugin host for project boilerplating and asset installation. Pipelines plugins through `detect → prompts → plan → apply → postInstall` with transactional roll… |
|
||||
| [`@suaveplan/linter`](./linter/README.md) | Unified markdown + biome linting pipeline for Suaveplan: a remark-based markdown fixer covering MD012/022/025/029/031/032/035/036/040/044, an injectable `Comma… |
|
||||
| [`@suaveplan/mcp`](./mcp/README.md) | Universal Model Context Protocol (MCP) server + client with a pluggable transport stack, JSON-RPC dispatcher, AbortSignal cancellation, progress notificat… |
|
||||
| [`@suaveplan/overview`](./overview/README.md) | Machine-readable Suaveplan project manifest: typed PackageRecord / SuavePlanManifest / WorkspaceGraph, Zod schemas, tier inference, filesystem discovery (server sub-path), graph utilities (topo, cycle detection, transitive closure, leaves, roots), plugin/category/tier roll-ups, six OVERVIEW.* error codes, three OTel counters + one histogram. |
|
||||
| [`@suaveplan/profiler`](./profiler/README.md) | Universal runtime performance profiler. |
|
||||
| [`@suaveplan/readline`](./readline/README.md) | AsyncGenerator line reader and interactive prompt interface for Node.js streams, file paths, and Web Streams. |
|
||||
| [`@suaveplan/sandbox`](./sandbox/README.md) | Server-tier sandboxed code execution for `@suaveplan` plugin hosts and agent runtimes. Ships four `SandboxEngine` implementations (a `node:vm` default, real `n… |
|
||||
| [`@suaveplan/test-vite`](./test-vite/README.md) | Vite + Vitest configuration factories and validators for Suaveplan: `createViteLibraryConfig`, `createViteAppConfig`, `createVitestConfig`, `createVitestBrowse… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/devtools/tooling/_intro.md
Normal file
1
packages/devtools/tooling/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
CLI frameworks, installers, and build tooling.
|
||||
202
packages/devtools/tooling/cli/README.md
Normal file
202
packages/devtools/tooling/cli/README.md
Normal file
File diff suppressed because one or more lines are too long
118
packages/devtools/tooling/cli/package.json
Normal file
118
packages/devtools/tooling/cli/package.json
Normal file
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"name": "@suaveplan/cli",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Zero-dependency CLI toolkit: argument parser, command router, ANSI styling, output formatting, spinner, progress bar, interactive prompts",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors/errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/errors.d.ts",
|
||||
"default": "./dist/errors/errors.js"
|
||||
}
|
||||
},
|
||||
"./parser/parser": {
|
||||
"import": {
|
||||
"types": "./dist/parser/parser.d.ts",
|
||||
"default": "./dist/parser/parser.js"
|
||||
}
|
||||
},
|
||||
"./command/command": {
|
||||
"import": {
|
||||
"types": "./dist/command/command.d.ts",
|
||||
"default": "./dist/command/command.js"
|
||||
}
|
||||
},
|
||||
"./app/app": {
|
||||
"import": {
|
||||
"types": "./dist/app/app.d.ts",
|
||||
"default": "./dist/app/app.js"
|
||||
}
|
||||
},
|
||||
"./style/style": {
|
||||
"import": {
|
||||
"types": "./dist/style/style.d.ts",
|
||||
"default": "./dist/style/style.js"
|
||||
}
|
||||
},
|
||||
"./output/format/format": {
|
||||
"import": {
|
||||
"types": "./dist/output/format/format.d.ts",
|
||||
"default": "./dist/output/format/format.js"
|
||||
}
|
||||
},
|
||||
"./output/table/table": {
|
||||
"import": {
|
||||
"types": "./dist/output/table/table.d.ts",
|
||||
"default": "./dist/output/table/table.js"
|
||||
}
|
||||
},
|
||||
"./spinner/spinner": {
|
||||
"import": {
|
||||
"types": "./dist/spinner/spinner.d.ts",
|
||||
"default": "./dist/spinner/spinner.js"
|
||||
}
|
||||
},
|
||||
"./progress/progress": {
|
||||
"import": {
|
||||
"types": "./dist/progress/progress.d.ts",
|
||||
"default": "./dist/progress/progress.js"
|
||||
}
|
||||
},
|
||||
"./prompt/prompt": {
|
||||
"import": {
|
||||
"types": "./dist/prompt/prompt.d.ts",
|
||||
"default": "./dist/prompt/prompt.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 .",
|
||||
"bench": "bun run src/__benchmarks__/parser-throughput.bench.ts && bun run src/__benchmarks__/app-run.bench.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/readline": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"zod": "catalog:forms",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
120
packages/devtools/tooling/cli/src/app/app.md
Normal file
120
packages/devtools/tooling/cli/src/app/app.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# app
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`App` is the top-level entry point for a `@suaveplan/cli` application. It wraps a root `Command` and adds application-level concerns: version printing, help generation, `onError` handling, OTel span instrumentation on `run()`, and `AbortSignal` support. The `cli()` factory function creates an `App` with a name and optional version string in a single expression.
|
||||
|
||||
`App` delegates all routing and parsing to the underlying `Command` hierarchy. Its primary responsibility is to intercept `--version`, `--help`, and abort signals before dispatching, and to wrap any thrown error with `CliError(CLI_CODES.APP.RUN_FAILED)` when an `onError` handler is registered.
|
||||
|
||||
## Features
|
||||
|
||||
- Fluent builder: `.description()`, `.option()`, `.argument()`, `.action()`, `.command()`, `.example()`
|
||||
- `run(argv?, options?)` dispatches to the root command; defaults `argv` to `process.argv.slice(2)`
|
||||
- `--version` / `-V` prints the version string (if set) and exits cleanly
|
||||
- `--help` / `-h` prints `helpText()` and exits cleanly
|
||||
- `options.signal` (AbortSignal) — throws `CliError(CLI_CODES.APP.ABORTED)` immediately when already aborted
|
||||
- `onError` handler — catches thrown errors, wraps non-`CliError` in `CliError(CLI_CODES.APP.RUN_FAILED)`, and calls the handler instead of re-throwing
|
||||
- OTel span on every `run()` call: span name `cli.app.run`, attribute `cli.app` set to the app name; `SpanStatusCode.ERROR` set on failure
|
||||
- `cli(name, version?)` factory for single-expression app creation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { cli } from "@suaveplan/cli/app/app";
|
||||
|
||||
const app = cli("deploy", "1.0.0")
|
||||
.description("Deployment toolkit");
|
||||
|
||||
app.command("up")
|
||||
.description("Bring services up")
|
||||
.action(async () => {
|
||||
console.log("Services are up");
|
||||
});
|
||||
|
||||
await app.run();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { App } from "@suaveplan/cli/app/app";
|
||||
|
||||
const app = new App({
|
||||
name: "myapp",
|
||||
version: "2.1.0",
|
||||
description: "My enterprise CLI",
|
||||
writer: (text) => process.stdout.write(text + "\n"),
|
||||
onError: (err) => {
|
||||
console.error(`Error [${err.code}]: ${err.message}`);
|
||||
process.exit(1);
|
||||
},
|
||||
});
|
||||
|
||||
app
|
||||
.option("--verbose", "Enable verbose output")
|
||||
.action(async (ctx) => {
|
||||
if (ctx.options.verbose) console.log("Verbose mode on");
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
process.on("SIGINT", () => controller.abort());
|
||||
|
||||
await app.run(process.argv.slice(2), { signal: controller.signal });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new App(config: AppConfig)`
|
||||
|
||||
Creates a new app. Config fields: `name` (required), `version`, `description`, `enableHelp` (default true), `enableVersion` (default true), `onError`, `writer`.
|
||||
|
||||
### `app.name: string`
|
||||
|
||||
The app's name as provided in config.
|
||||
|
||||
### `app.version: string | undefined`
|
||||
|
||||
The app's version string, or `undefined` if not set.
|
||||
|
||||
### `app.description(text: string): this`
|
||||
|
||||
Sets the app description shown in help text.
|
||||
|
||||
### `app.option(flags, description?, defaultValue?): this`
|
||||
|
||||
Registers a root-level option.
|
||||
|
||||
### `app.argument(spec, description?): this`
|
||||
|
||||
Registers a root-level positional argument.
|
||||
|
||||
### `app.action(fn: ActionFn): this`
|
||||
|
||||
Sets the root action handler.
|
||||
|
||||
### `app.command(name: string): Command`
|
||||
|
||||
Registers and returns a named sub-command.
|
||||
|
||||
### `app.example(text: string): this`
|
||||
|
||||
Adds an example to the help output.
|
||||
|
||||
### `app.helpText(): string`
|
||||
|
||||
Returns formatted multi-line help text for the root command.
|
||||
|
||||
### `app.run(argv?: string[], options?: { signal?: AbortSignal }): Promise<void>`
|
||||
|
||||
Executes the app. If `signal` is already aborted, throws `CliError(ABORTED)` immediately. Intercepts `--version` and `--help` before routing. Delegates to the root `Command.execute()`.
|
||||
|
||||
### `cli(name: string, version?: string): App`
|
||||
|
||||
Factory function. Creates an `App` with `name` and optional `version`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `writer` config field injects the output sink, defaulting to `process.stdout.write`. This enables full testability without TTY or process coupling. The `onError` hook receives a `CliError` — if the thrown value was a plain `Error`, it is wrapped in `CliError(RUN_FAILED)` before being handed to the hook, so callers always receive a structured error with a stable code.
|
||||
9
packages/devtools/tooling/cli/src/app/app.schemas.ts
Normal file
9
packages/devtools/tooling/cli/src/app/app.schemas.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const AppConfigSchema = z.object({
|
||||
name: z.string(),
|
||||
version: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
enableHelp: z.boolean().optional(),
|
||||
enableVersion: z.boolean().optional(),
|
||||
});
|
||||
252
packages/devtools/tooling/cli/src/app/app.test.ts
Normal file
252
packages/devtools/tooling/cli/src/app/app.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CLI_CODES } from "../errors/errors.js";
|
||||
import { App, cli } from "./app.js";
|
||||
|
||||
function makeApp(writer: (s: string) => void = () => {}): App {
|
||||
return new App({ name: "test-app", version: "1.2.3", writer });
|
||||
}
|
||||
|
||||
describe("App.run()", { timeout: 5000 }, () => {
|
||||
it("prints version with --version", { timeout: 5000 }, async () => {
|
||||
const output: string[] = [];
|
||||
const app = makeApp((s) => output.push(s));
|
||||
await app.run(["--version"]);
|
||||
expect(output.join("")).toContain("1.2.3");
|
||||
});
|
||||
|
||||
it("prints help with --help", { timeout: 5000 }, async () => {
|
||||
const output: string[] = [];
|
||||
const app = makeApp((s) => output.push(s));
|
||||
await app.run(["--help"]);
|
||||
expect(output.join("")).toContain("Usage");
|
||||
});
|
||||
|
||||
it("routes to registered command", { timeout: 5000 }, async () => {
|
||||
const called: string[] = [];
|
||||
const app = makeApp();
|
||||
app.command("build").action(async () => {
|
||||
called.push("build");
|
||||
});
|
||||
await app.run(["build"]);
|
||||
expect(called).toEqual(["build"]);
|
||||
});
|
||||
|
||||
it("throws CliError(ABORTED) when AbortSignal fires before execution", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const app = makeApp();
|
||||
app.action(async () => {});
|
||||
await expect(
|
||||
app.run([], { signal: controller.signal })
|
||||
).rejects.toMatchObject({
|
||||
code: CLI_CODES.APP.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onError with CliError on execution failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const errors: Error[] = [];
|
||||
const app = new App({
|
||||
name: "app",
|
||||
onError: (e) => errors.push(e),
|
||||
});
|
||||
app.action(async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
await app.run([]);
|
||||
expect(errors.length).toBe(1);
|
||||
});
|
||||
|
||||
it("OTel span does not throw (no-op tracer)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = makeApp();
|
||||
app.action(async () => {});
|
||||
await expect(app.run([])).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("rethrows error when no onError handler", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = makeApp();
|
||||
app.action(async () => {
|
||||
throw new Error("unhandled");
|
||||
});
|
||||
await expect(app.run([])).rejects.toThrow("unhandled");
|
||||
});
|
||||
|
||||
it("wraps error in CliError(RUN_FAILED) when passing to onError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const errors: Error[] = [];
|
||||
const app = new App({
|
||||
name: "app",
|
||||
onError: (e) => errors.push(e),
|
||||
});
|
||||
app.action(async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
await app.run([]);
|
||||
expect(errors[0]).toMatchObject({ code: CLI_CODES.APP.RUN_FAILED });
|
||||
});
|
||||
});
|
||||
|
||||
describe("App constructor edge cases", { timeout: 5000 }, () => {
|
||||
it("uses process.stdout when no writer provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new App({ name: "test", version: "1.0.0" });
|
||||
// Should not throw — uses default stdout writer
|
||||
await app.run(["--version"]);
|
||||
});
|
||||
|
||||
it("sets description when provided in config", { timeout: 5000 }, () => {
|
||||
const app = new App({ name: "app", description: "My tool" });
|
||||
expect(app.helpText()).toContain("My tool");
|
||||
});
|
||||
|
||||
it("run() without argv defaults to process.argv.slice(2)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new App({ name: "app", writer: () => {} });
|
||||
app.action(async () => {});
|
||||
// process.argv.slice(2) in test env is likely [] or test runner args — just shouldn't throw
|
||||
await expect(app.run()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("--version without version string does not print version", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const output: string[] = [];
|
||||
const errors: unknown[] = [];
|
||||
const app = new App({
|
||||
name: "app",
|
||||
writer: (s) => output.push(s),
|
||||
onError: (e) => errors.push(e),
|
||||
});
|
||||
app.action(async () => {});
|
||||
await app.run(["--version"]);
|
||||
expect(output).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("enableVersion:false passes --version to action", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const output: string[] = [];
|
||||
const errors: unknown[] = [];
|
||||
const app = new App({
|
||||
name: "app",
|
||||
version: "1.0.0",
|
||||
enableVersion: false,
|
||||
writer: (s) => output.push(s),
|
||||
onError: (e) => errors.push(e),
|
||||
});
|
||||
app.action(async () => {});
|
||||
await app.run(["--version"]);
|
||||
expect(output).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("enableHelp:false passes --help to action", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const output: string[] = [];
|
||||
const errors: unknown[] = [];
|
||||
const app = new App({
|
||||
name: "app",
|
||||
enableHelp: false,
|
||||
writer: (s) => output.push(s),
|
||||
onError: (e) => errors.push(e),
|
||||
});
|
||||
app.action(async () => {});
|
||||
await app.run(["--help"]);
|
||||
expect(output).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("onError receives error from failed action", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const errors: Error[] = [];
|
||||
const app = new App({ name: "app", onError: (e) => errors.push(e) });
|
||||
app.action(async () => {
|
||||
throw new Error("action error");
|
||||
});
|
||||
await app.run([]);
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0]).toMatchObject({ code: CLI_CODES.APP.RUN_FAILED });
|
||||
});
|
||||
|
||||
it("onError wraps non-Error throw as CliError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const errors: Error[] = [];
|
||||
const app = new App({ name: "app", onError: (e) => errors.push(e) });
|
||||
(app as unknown as { _root: { _action: unknown } })._root._action =
|
||||
async () => {
|
||||
throw "string error";
|
||||
};
|
||||
await app.run([]);
|
||||
expect(errors[0]).toMatchObject({ code: CLI_CODES.APP.RUN_FAILED });
|
||||
});
|
||||
});
|
||||
|
||||
describe("App builder methods", { timeout: 5000 }, () => {
|
||||
it("description() sets description on root command", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const app = makeApp();
|
||||
app.description("My tool");
|
||||
expect(app.helpText()).toContain("My tool");
|
||||
});
|
||||
|
||||
it("option() adds global option", { timeout: 5000 }, async () => {
|
||||
let captured: unknown;
|
||||
const app = makeApp();
|
||||
app.option("--dry-run, -d", "dry run mode");
|
||||
app.action(async (ctx) => {
|
||||
captured = ctx.options["dryRun"];
|
||||
});
|
||||
await app.run(["--dry-run"]);
|
||||
expect(captured).toBe(true);
|
||||
});
|
||||
|
||||
it("argument() adds positional argument", { timeout: 5000 }, async () => {
|
||||
let captured: unknown;
|
||||
const app = makeApp();
|
||||
app.argument("<file>", "file to process");
|
||||
app.action(async (ctx) => {
|
||||
captured = ctx.args["file"];
|
||||
});
|
||||
await app.run(["myfile.txt"]);
|
||||
expect(captured).toBe("myfile.txt");
|
||||
});
|
||||
|
||||
it("example() shows up in help text", { timeout: 5000 }, () => {
|
||||
const output: string[] = [];
|
||||
const app = new App({ name: "app", writer: (s) => output.push(s) });
|
||||
app.example("app build --verbose");
|
||||
app.run(["--help"]);
|
||||
return new Promise<void>((r) =>
|
||||
setTimeout(() => {
|
||||
expect(output.join("")).toContain("app build --verbose");
|
||||
r();
|
||||
}, 50)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cli() factory", { timeout: 5000 }, () => {
|
||||
it("creates App with name and version", { timeout: 5000 }, () => {
|
||||
const app = cli("myapp", "2.0.0");
|
||||
expect(app.name).toBe("myapp");
|
||||
expect(app.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("creates App with name only", { timeout: 5000 }, () => {
|
||||
const app = cli("myapp");
|
||||
expect(app.name).toBe("myapp");
|
||||
expect(app.version).toBeUndefined();
|
||||
});
|
||||
});
|
||||
129
packages/devtools/tooling/cli/src/app/app.ts
Normal file
129
packages/devtools/tooling/cli/src/app/app.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { Command } from "../command/command.js";
|
||||
import type { ActionFn } from "../command/command.types.js";
|
||||
import { CLI_CODES, CliError } from "../errors/errors.js";
|
||||
import type { AppConfig } from "./app.types.js";
|
||||
|
||||
const tracer = trace.getTracer("@suaveplan/cli", "0.1.0");
|
||||
|
||||
export class App {
|
||||
private _root: Command;
|
||||
private _version: string | undefined;
|
||||
private _config: AppConfig;
|
||||
private _write: (text: string) => void;
|
||||
|
||||
constructor(config: AppConfig) {
|
||||
this._config = config;
|
||||
this._version = config.version;
|
||||
this._write = config.writer ?? ((t) => process.stdout.write(`${t}\n`));
|
||||
this._root = new Command(config.name);
|
||||
if (config.description) this._root.description(config.description);
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this._config.name;
|
||||
}
|
||||
|
||||
get version(): string | undefined {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
description(text: string): this {
|
||||
this._root.description(text);
|
||||
return this;
|
||||
}
|
||||
|
||||
option(flags: string, desc?: string, defaultValue?: unknown): this {
|
||||
this._root.option(flags, desc, defaultValue);
|
||||
return this;
|
||||
}
|
||||
|
||||
argument(spec: string, desc?: string): this {
|
||||
this._root.argument(spec, desc);
|
||||
return this;
|
||||
}
|
||||
|
||||
action(fn: ActionFn): this {
|
||||
this._root.action(fn);
|
||||
return this;
|
||||
}
|
||||
|
||||
command(name: string): Command {
|
||||
return this._root.command(name);
|
||||
}
|
||||
|
||||
example(text: string): this {
|
||||
this._root.example(text);
|
||||
return this;
|
||||
}
|
||||
|
||||
helpText(): string {
|
||||
return this._root.helpText();
|
||||
}
|
||||
|
||||
async run(
|
||||
argv?: string[],
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<void> {
|
||||
const args = argv ?? process.argv.slice(2);
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw new CliError(CLI_CODES.APP.ABORTED, "cli.app.aborted");
|
||||
}
|
||||
|
||||
const span = tracer.startSpan("cli.app.run", {
|
||||
attributes: { "cli.app": this._config.name },
|
||||
});
|
||||
|
||||
try {
|
||||
// Handle --version
|
||||
const enableVersion = this._config.enableVersion !== false;
|
||||
if (
|
||||
enableVersion &&
|
||||
(args[0] === "--version" || args[0] === "-V") &&
|
||||
this._version
|
||||
) {
|
||||
this._write(this._version);
|
||||
span.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle --help
|
||||
const enableHelp = this._config.enableHelp !== false;
|
||||
if (enableHelp && (args[0] === "--help" || args[0] === "-h")) {
|
||||
this._write(this.helpText());
|
||||
span.end();
|
||||
return;
|
||||
}
|
||||
|
||||
await this._root.execute(args);
|
||||
span.end();
|
||||
} catch (err) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
span.end();
|
||||
|
||||
if (this._config.onError) {
|
||||
const cliErr =
|
||||
err instanceof CliError
|
||||
? err
|
||||
: new CliError(
|
||||
CLI_CODES.APP.RUN_FAILED,
|
||||
"cli.app.run_failed",
|
||||
{
|
||||
cause:
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error(String(err)),
|
||||
}
|
||||
);
|
||||
this._config.onError(cliErr);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cli(name: string, version?: string): App {
|
||||
return new App(version !== undefined ? { name, version } : { name });
|
||||
}
|
||||
9
packages/devtools/tooling/cli/src/app/app.types.ts
Normal file
9
packages/devtools/tooling/cli/src/app/app.types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface AppConfig {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
enableHelp?: boolean;
|
||||
enableVersion?: boolean;
|
||||
onError?: (err: Error) => void;
|
||||
writer?: (text: string) => void;
|
||||
}
|
||||
3
packages/devtools/tooling/cli/src/app/index.ts
Normal file
3
packages/devtools/tooling/cli/src/app/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { App, cli } from "./app.js";
|
||||
export { AppConfigSchema } from "./app.schemas.js";
|
||||
export type { AppConfig } from "./app.types.js";
|
||||
122
packages/devtools/tooling/cli/src/command/command.md
Normal file
122
packages/devtools/tooling/cli/src/command/command.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# command
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`Command` is the core routing primitive for `@suaveplan/cli`. It models a single CLI command that can carry options, positional arguments, and an action handler, or act as a router that delegates to named sub-commands. Every `App` is backed by a root `Command`.
|
||||
|
||||
The class exposes a fluent builder API: chain `.description()`, `.option()`, `.argument()`, `.command()`, and `.action()` calls before calling `.execute()` at runtime. Sub-commands are registered via `.command(name)` which returns the child `Command`, enabling identical fluent chaining for nested hierarchies.
|
||||
|
||||
## Features
|
||||
|
||||
- Fluent builder: `.description()`, `.option()`, `.argument()`, `.action()`, `.command()`, `.alias()`, `.hidden()`, `.example()`
|
||||
- Sub-command routing: first non-flag token in `argv` is matched against registered names and aliases
|
||||
- Option parsing delegated to `parseArgs` — inherits full flag semantics (boolean, string, number, `--no-*`, short flags, `=` syntax, `--` rest)
|
||||
- OTel span on every `execute()` call: span name `cli.command.execute`, attribute `cli.command` set to the full qualified name
|
||||
- `CliError(CLI_CODES.COMMAND.NO_ACTION)` when no action is registered and no sub-command is found
|
||||
- `CliError(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND)` when an unknown first token is provided and sub-commands are registered
|
||||
- Automatic `--help` / `-h` handling: prints `helpText()` and returns without error
|
||||
- `helpText(parentName?)` generates a formatted usage block with options and sub-command listing
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Command } from "@suaveplan/cli/command/command";
|
||||
|
||||
const cmd = new Command("greet")
|
||||
.description("Greet a user")
|
||||
.option("--name <name>", "Name to greet", "World")
|
||||
.action(async (ctx) => {
|
||||
console.log(`Hello, ${ctx.options.name}!`);
|
||||
});
|
||||
|
||||
await cmd.execute(["--name", "Alice"]);
|
||||
// → Hello, Alice!
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Command } from "@suaveplan/cli/command/command";
|
||||
|
||||
const root = new Command("deploy")
|
||||
.description("Deployment toolkit");
|
||||
|
||||
root
|
||||
.command("up")
|
||||
.description("Bring services up")
|
||||
.option("--env <env>", "Target environment", "staging")
|
||||
.action(async (ctx) => {
|
||||
console.log(`Deploying to ${ctx.options.env}`);
|
||||
});
|
||||
|
||||
root
|
||||
.command("down")
|
||||
.alias("d")
|
||||
.description("Tear services down")
|
||||
.action(async () => {
|
||||
console.log("Tearing down...");
|
||||
});
|
||||
|
||||
// Route: "deploy up --env production"
|
||||
await root.execute(["up", "--env", "production"]);
|
||||
|
||||
// Route by alias: "deploy d"
|
||||
await root.execute(["d"]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new Command(name: string)`
|
||||
|
||||
Creates a new command with the given name.
|
||||
|
||||
### `.description(text: string): this`
|
||||
|
||||
Sets the human-readable description shown in help text.
|
||||
|
||||
### `.option(flags: string, description?: string, defaultValue?: unknown): this`
|
||||
|
||||
Registers an option. `flags` follows `parseOptionFlags` syntax: `"--name <value>"`, `"-v, --verbose"`.
|
||||
|
||||
### `.argument(spec: string, description?: string): this`
|
||||
|
||||
Registers a positional argument. `spec` follows `parseArgumentSpec` syntax: `"<file>"`, `"[file]"`, `"<files...>"`.
|
||||
|
||||
### `.action(fn: ActionFn): this`
|
||||
|
||||
Sets the action handler. `fn` receives an `ActionContext` with `options`, `args` (positionals), `rest`, and `command` info.
|
||||
|
||||
### `.command(name: string): Command`
|
||||
|
||||
Registers a named sub-command and returns it for further chaining.
|
||||
|
||||
### `.alias(name: string): this`
|
||||
|
||||
Adds an alias for this command, recognised during routing.
|
||||
|
||||
### `.hidden(): this`
|
||||
|
||||
Marks the command as hidden — it is excluded from help text listings.
|
||||
|
||||
### `.example(text: string): this`
|
||||
|
||||
Adds an example line shown in help text.
|
||||
|
||||
### `.helpText(parentName?: string): string`
|
||||
|
||||
Returns formatted multi-line help text including usage, options, sub-commands, and examples.
|
||||
|
||||
### `.getParserConfig(): ParserConfig`
|
||||
|
||||
Returns the `ParserConfig` derived from registered options and arguments. Used internally by `execute()`.
|
||||
|
||||
### `.execute(argv: string[], parentName?: string): Promise<void>`
|
||||
|
||||
Parses `argv`, routes to sub-commands or calls the action. Wraps the execution in an OTel span. Throws `CliError` on routing or parse failures.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Sub-command alias resolution scans all registered sub-commands for an `_aliases` entry matching the first token. An OTel span covers the full `execute()` lifecycle including sub-command delegation — the span status is set to `ERROR` if any exception propagates. The `--help` / `-h` shortcut is handled before sub-command routing and before the action call, so it always works even if no action is registered.
|
||||
@@ -0,0 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const CommandInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
fullName: z.string(),
|
||||
});
|
||||
204
packages/devtools/tooling/cli/src/command/command.test.ts
Normal file
204
packages/devtools/tooling/cli/src/command/command.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CLI_CODES, CliError } from "../errors/errors.js";
|
||||
import { Command } from "./command.js";
|
||||
|
||||
describe("Command — fluent API", { timeout: 5000 }, () => {
|
||||
it("sets description", { timeout: 5000 }, () => {
|
||||
const cmd = new Command("test").description("A test command");
|
||||
expect(cmd.descriptionText).toBe("A test command");
|
||||
});
|
||||
|
||||
it("registers and routes to subcommand", { timeout: 5000 }, async () => {
|
||||
const called: string[] = [];
|
||||
const parent = new Command("app");
|
||||
parent.command("sub").action(async () => {
|
||||
called.push("sub");
|
||||
});
|
||||
await parent.execute(["sub"]);
|
||||
expect(called).toEqual(["sub"]);
|
||||
});
|
||||
|
||||
it("routes to subcommand by alias", { timeout: 5000 }, async () => {
|
||||
const called: string[] = [];
|
||||
const parent = new Command("app");
|
||||
const sub = parent.command("subcommand");
|
||||
sub.alias("s").action(async () => {
|
||||
called.push("aliased");
|
||||
});
|
||||
await parent.execute(["s"]);
|
||||
expect(called).toEqual(["aliased"]);
|
||||
});
|
||||
|
||||
it("throws CliError(NO_ACTION) when no action defined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cmd = new Command("empty");
|
||||
await expect(cmd.execute([])).rejects.toMatchObject({
|
||||
code: CLI_CODES.COMMAND.NO_ACTION,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws CliError(UNKNOWN_SUBCOMMAND) when subcommand not found but subcommands exist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cmd = new Command("app");
|
||||
cmd.command("sub").action(async () => {});
|
||||
await expect(cmd.execute(["nope"])).rejects.toMatchObject({
|
||||
code: CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls action with parsed context", { timeout: 5000 }, async () => {
|
||||
let capturedOpts: Record<string, unknown> = {};
|
||||
const cmd = new Command("greet")
|
||||
.option("--name <n>", "Name")
|
||||
.action(async (ctx) => {
|
||||
capturedOpts = ctx.options;
|
||||
});
|
||||
await cmd.execute(["--name", "world"]);
|
||||
expect(capturedOpts.name).toBe("world");
|
||||
});
|
||||
|
||||
it("hides command from help", { timeout: 5000 }, () => {
|
||||
const cmd = new Command("hidden").hidden();
|
||||
expect(cmd.isHidden).toBe(true);
|
||||
});
|
||||
|
||||
it("generates help text with usage line", { timeout: 5000 }, () => {
|
||||
const cmd = new Command("build").description("Build the project");
|
||||
const help = cmd.helpText();
|
||||
expect(help).toContain("Usage: build");
|
||||
});
|
||||
|
||||
it("OTel span does not throw (no-op tracer)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cmd = new Command("noop").action(async () => {});
|
||||
await expect(cmd.execute([])).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("prints help text when --help passed", { timeout: 5000 }, async () => {
|
||||
const cmd = new Command("mycmd").description("My command");
|
||||
cmd.action(async () => {});
|
||||
// Should not throw
|
||||
await expect(cmd.execute(["--help"])).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("shows help when no argv and subcommands exist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const parent = new Command("app");
|
||||
parent.command("sub").action(async () => {});
|
||||
await expect(parent.execute([])).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("helpText includes variadic required argument", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = new Command("cmd").argument("<files...>", "files");
|
||||
expect(cmd.helpText()).toContain("<files...>");
|
||||
});
|
||||
|
||||
it("helpText includes variadic optional argument", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = new Command("cmd").argument("[files...]", "files");
|
||||
expect(cmd.helpText()).toContain("[files...]");
|
||||
});
|
||||
|
||||
it("helpText includes option with value hint and short flag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = new Command("cmd").option(
|
||||
"--output <file>, -o",
|
||||
"output file"
|
||||
);
|
||||
const help = cmd.helpText();
|
||||
expect(help).toContain("-o,");
|
||||
expect(help).toContain("<output>");
|
||||
});
|
||||
|
||||
it("helpText option without short flag uses padding", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = new Command("cmd").option("--verbose", "verbose output");
|
||||
const help = cmd.helpText();
|
||||
expect(help).toContain("--verbose");
|
||||
});
|
||||
|
||||
it("helpText shows visible subcommands and hides hidden ones", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parent = new Command("app");
|
||||
parent
|
||||
.command("visible")
|
||||
.description("shown")
|
||||
.action(async () => {});
|
||||
parent
|
||||
.command("secret")
|
||||
.hidden()
|
||||
.action(async () => {});
|
||||
const help = parent.helpText();
|
||||
expect(help).toContain("visible");
|
||||
expect(help).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("helpText shows examples section", { timeout: 5000 }, () => {
|
||||
const cmd = new Command("cmd")
|
||||
.example("cmd --foo bar")
|
||||
.action(async () => {});
|
||||
expect(cmd.helpText()).toContain("cmd --foo bar");
|
||||
});
|
||||
|
||||
it("helpText includes non-variadic required argument <name>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = new Command("cmd").argument("<output>", "output file");
|
||||
expect(cmd.helpText()).toContain("<output>");
|
||||
});
|
||||
|
||||
it("helpText includes non-variadic optional argument [name]", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = new Command("cmd").argument("[output]", "output file");
|
||||
expect(cmd.helpText()).toContain("[output]");
|
||||
});
|
||||
|
||||
it("helpText includes option without description", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = new Command("cmd").option("--quiet");
|
||||
expect(cmd.helpText()).toContain("--quiet");
|
||||
});
|
||||
|
||||
it("helpText includes parent name when provided", { timeout: 5000 }, () => {
|
||||
const cmd = new Command("sub").description("A sub command");
|
||||
expect(cmd.helpText("myapp")).toContain("myapp sub");
|
||||
});
|
||||
|
||||
it("alias() is no-op when command has subcommands", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const parent = new Command("app");
|
||||
const sub = parent.command("sub");
|
||||
sub.command("nested").action(async () => {});
|
||||
// alias() on a command with subcommands should be a no-op
|
||||
sub.alias("s");
|
||||
// Routing via "s" should fail since alias wasn't registered
|
||||
await expect(parent.execute(["sub", "nested"])).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("rethrows errors with span ERROR status (no-op tracer)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cmd = new Command("fail").action(async () => {
|
||||
throw new CliError(
|
||||
CLI_CODES.COMMAND.NO_ACTION,
|
||||
"cli.command.no_action"
|
||||
);
|
||||
});
|
||||
await expect(cmd.execute([])).rejects.toMatchObject({
|
||||
code: CLI_CODES.COMMAND.NO_ACTION,
|
||||
});
|
||||
});
|
||||
});
|
||||
219
packages/devtools/tooling/cli/src/command/command.ts
Normal file
219
packages/devtools/tooling/cli/src/command/command.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { CLI_CODES, CliError } from "../errors/errors.js";
|
||||
import {
|
||||
parseArgs,
|
||||
parseArgumentSpec,
|
||||
parseOptionFlags,
|
||||
} from "../parser/parser.js";
|
||||
import type {
|
||||
ArgumentConfig,
|
||||
OptionConfig,
|
||||
ParserConfig,
|
||||
} from "../parser/parser.types.js";
|
||||
import type { ActionContext, ActionFn } from "./command.types.js";
|
||||
|
||||
export { parseArgumentSpec, parseOptionFlags } from "../parser/parser.js";
|
||||
|
||||
const tracer = trace.getTracer("@suaveplan/cli", "0.1.0");
|
||||
|
||||
export class Command {
|
||||
private _description = "";
|
||||
private _action: ActionFn | undefined;
|
||||
private _options: Record<string, OptionConfig> = {};
|
||||
private _arguments: ArgumentConfig[] = [];
|
||||
private _subcommands: Map<string, Command> = new Map();
|
||||
private _aliases: Map<string, string> = new Map();
|
||||
private _hidden = false;
|
||||
private _examples: string[] = [];
|
||||
|
||||
constructor(public readonly name: string) {}
|
||||
|
||||
get descriptionText(): string {
|
||||
return this._description;
|
||||
}
|
||||
|
||||
get isHidden(): boolean {
|
||||
return this._hidden;
|
||||
}
|
||||
|
||||
description(text: string): this {
|
||||
this._description = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
option(flags: string, desc?: string, defaultValue?: unknown): this {
|
||||
const { name, config } = parseOptionFlags(flags, desc, defaultValue);
|
||||
this._options[name] = config;
|
||||
return this;
|
||||
}
|
||||
|
||||
argument(spec: string, desc?: string): this {
|
||||
this._arguments.push(parseArgumentSpec(spec, desc));
|
||||
return this;
|
||||
}
|
||||
|
||||
action(fn: ActionFn): this {
|
||||
this._action = fn;
|
||||
return this;
|
||||
}
|
||||
|
||||
command(name: string): Command {
|
||||
const sub = new Command(name);
|
||||
this._subcommands.set(name, sub);
|
||||
return sub;
|
||||
}
|
||||
|
||||
alias(name: string): this {
|
||||
if (this._subcommands.size === 0 && this.name) {
|
||||
this._aliases.set(name, this.name);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
hidden(): this {
|
||||
this._hidden = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
example(text: string): this {
|
||||
this._examples.push(text);
|
||||
return this;
|
||||
}
|
||||
|
||||
getParserConfig(): ParserConfig {
|
||||
return {
|
||||
options: this._options,
|
||||
arguments: this._arguments,
|
||||
};
|
||||
}
|
||||
|
||||
helpText(parentName?: string): string {
|
||||
const fullName = parentName ? `${parentName} ${this.name}` : this.name;
|
||||
const lines: string[] = [];
|
||||
|
||||
if (this._description) lines.push(this._description, "");
|
||||
|
||||
lines.push(
|
||||
`Usage: ${fullName}${this._subcommands.size > 0 ? " <command>" : ""} [options]`
|
||||
);
|
||||
|
||||
if (this._arguments.length > 0) {
|
||||
const argStr = this._arguments
|
||||
.map((a) =>
|
||||
a.variadic
|
||||
? a.required
|
||||
? `<${a.name}...>`
|
||||
: `[${a.name}...]`
|
||||
: a.required
|
||||
? `<${a.name}>`
|
||||
: `[${a.name}]`
|
||||
)
|
||||
.join(" ");
|
||||
lines[lines.length - 1] += ` ${argStr}`;
|
||||
}
|
||||
|
||||
if (Object.keys(this._options).length > 0) {
|
||||
lines.push("", "Options:");
|
||||
for (const [name, cfg] of Object.entries(this._options)) {
|
||||
const short = cfg.short ? `-${cfg.short}, ` : " ";
|
||||
const valueHint =
|
||||
cfg.type && cfg.type !== "boolean" ? ` <${name}>` : "";
|
||||
lines.push(
|
||||
` ${short}--${name}${valueHint}${cfg.description ? ` ${cfg.description}` : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._subcommands.size > 0) {
|
||||
lines.push("", "Commands:");
|
||||
for (const [, sub] of this._subcommands) {
|
||||
if (!sub.isHidden) {
|
||||
lines.push(` ${sub.name.padEnd(16)}${sub._description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this._examples.length > 0) {
|
||||
lines.push("", "Examples:");
|
||||
for (const ex of this._examples) lines.push(` ${ex}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async execute(argv: string[], parentName?: string): Promise<void> {
|
||||
const fullName = parentName ? `${parentName} ${this.name}` : this.name;
|
||||
const span = tracer.startSpan("cli.command.execute", {
|
||||
attributes: { "cli.command": fullName },
|
||||
});
|
||||
|
||||
try {
|
||||
// Check for help flag
|
||||
if (argv[0] === "--help" || argv[0] === "-h") {
|
||||
console.log(this.helpText(parentName));
|
||||
span.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Route to subcommand if first token is a non-flag
|
||||
const first = argv[0];
|
||||
if (first && !first.startsWith("-")) {
|
||||
// Check direct match
|
||||
let sub = this._subcommands.get(first);
|
||||
// Check alias
|
||||
if (!sub) {
|
||||
for (const [, cmd] of this._subcommands) {
|
||||
if ((cmd as Command)._aliases.has(first)) {
|
||||
sub = cmd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sub) {
|
||||
await sub.execute(argv.slice(1), fullName);
|
||||
span.end();
|
||||
return;
|
||||
}
|
||||
if (this._subcommands.size > 0) {
|
||||
throw new CliError(
|
||||
CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND,
|
||||
"cli.command.unknown_subcommand",
|
||||
{ context: { subcommand: first, command: fullName } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._action) {
|
||||
if (this._subcommands.size > 0 && !first) {
|
||||
console.log(this.helpText(parentName));
|
||||
span.end();
|
||||
return;
|
||||
}
|
||||
throw new CliError(
|
||||
CLI_CODES.COMMAND.NO_ACTION,
|
||||
"cli.command.no_action",
|
||||
{ context: { command: fullName } }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseArgs(argv, this.getParserConfig());
|
||||
const ctx: ActionContext = {
|
||||
args: parsed.positionals,
|
||||
options: parsed.options,
|
||||
rest: parsed.rest,
|
||||
command: {
|
||||
name: this.name,
|
||||
description: this._description,
|
||||
fullName,
|
||||
},
|
||||
};
|
||||
|
||||
await this._action(ctx);
|
||||
span.end();
|
||||
} catch (err) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
span.end();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/devtools/tooling/cli/src/command/command.types.ts
Normal file
14
packages/devtools/tooling/cli/src/command/command.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface CommandInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
fullName: string;
|
||||
}
|
||||
|
||||
export interface ActionContext {
|
||||
args: Record<string, unknown>;
|
||||
options: Record<string, unknown>;
|
||||
rest: string[];
|
||||
command: CommandInfo;
|
||||
}
|
||||
|
||||
export type ActionFn = (ctx: ActionContext) => void | Promise<void>;
|
||||
3
packages/devtools/tooling/cli/src/command/index.ts
Normal file
3
packages/devtools/tooling/cli/src/command/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Command, parseArgumentSpec, parseOptionFlags } from "./command.js";
|
||||
export { CommandInfoSchema } from "./command.schemas.js";
|
||||
export type { ActionContext, ActionFn, CommandInfo } from "./command.types.js";
|
||||
171
packages/devtools/tooling/cli/src/errors/errors.md
Normal file
171
packages/devtools/tooling/cli/src/errors/errors.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `errors` module defines the complete error taxonomy for `@suaveplan/cli`. It
|
||||
provides a stable, machine-readable code registry (`CLI_CODES`) and a strongly-typed
|
||||
error class (`CliError`) so that every failure surface in the CLI framework is
|
||||
discriminable at runtime without string-matching on messages. Callers can switch
|
||||
exhaustively on `err.code`, log structured records with stable identifiers, and
|
||||
forward typed errors across process boundaries without losing fidelity.
|
||||
|
||||
## Features
|
||||
|
||||
- **Three sub-namespaces** — `CLI.PARSE.*` covers argument/option parsing failures;
|
||||
`CLI.COMMAND.*` covers command-dispatch failures; `CLI.APP.*` covers top-level
|
||||
application lifecycle failures.
|
||||
- **Nine stable error codes** — each code is a dot-delimited string constant produced
|
||||
by `defineCodes()` from `@suaveplan/error`, guaranteeing uniqueness and structural
|
||||
consistency across the codebase.
|
||||
- **`CliCode` union type** — narrows the full set of valid codes for use in generic
|
||||
`SuaveplanError<CliCode>` type parameters and exhaustive switch statements.
|
||||
- **`CliError` class** — extends `SuaveplanError` with a public constructor, cause
|
||||
chaining, and structured `context` attachment for diagnostic metadata.
|
||||
- **Zero runtime overhead** — the `CLI_CODES` object is `as const`, so all string
|
||||
values are inlined at the call site by the TypeScript compiler.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CliError, CLI_CODES } from "@suaveplan/cli/errors/errors";
|
||||
|
||||
function assertKnownOption(flag: string, known: Set<string>): void {
|
||||
if (!known.has(flag)) {
|
||||
throw new CliError(
|
||||
CLI_CODES.PARSE.UNKNOWN_OPTION,
|
||||
"cli.parse.unknown_option"
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CliError, CLI_CODES } from "@suaveplan/cli/errors/errors";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
async function runApp(signal: AbortSignal): Promise<void> {
|
||||
try {
|
||||
await doWork(signal);
|
||||
} catch (err) {
|
||||
if (signal.aborted) {
|
||||
throw new CliError(CLI_CODES.APP.ABORTED, "cli.app.aborted", {
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
context: { reason: signal.reason },
|
||||
});
|
||||
}
|
||||
throw new CliError(CLI_CODES.APP.RUN_FAILED, "cli.app.run_failed", {
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(err: unknown): void {
|
||||
if (err instanceof CliError) {
|
||||
// Narrow on the stable code — no message-string matching needed.
|
||||
switch (err.code) {
|
||||
case CLI_CODES.APP.ABORTED:
|
||||
process.exitCode = 130; // standard SIGINT exit code
|
||||
break;
|
||||
case CLI_CODES.PARSE.UNKNOWN_OPTION:
|
||||
console.error(`Unknown option: ${String(err.context?.["option"])}`);
|
||||
process.exitCode = 1;
|
||||
break;
|
||||
default:
|
||||
console.error(err.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CLI_CODES`
|
||||
|
||||
```ts
|
||||
const CLI_CODES: {
|
||||
PARSE: {
|
||||
UNKNOWN_OPTION: "CLI.PARSE.UNKNOWN_OPTION";
|
||||
MISSING_VALUE: "CLI.PARSE.MISSING_VALUE";
|
||||
INVALID_VALUE: "CLI.PARSE.INVALID_VALUE";
|
||||
MISSING_ARGUMENT: "CLI.PARSE.MISSING_ARGUMENT";
|
||||
MISSING_REQUIRED_OPTION: "CLI.PARSE.MISSING_REQUIRED_OPTION";
|
||||
};
|
||||
COMMAND: {
|
||||
NO_ACTION: "CLI.COMMAND.NO_ACTION";
|
||||
UNKNOWN_SUBCOMMAND: "CLI.COMMAND.UNKNOWN_SUBCOMMAND";
|
||||
};
|
||||
APP: {
|
||||
RUN_FAILED: "CLI.APP.RUN_FAILED";
|
||||
ABORTED: "CLI.APP.ABORTED";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Immutable registry of all error codes used by `@suaveplan/cli`. Each value is a
|
||||
dot-delimited string in the form `<NAMESPACE>.<SUBCATEGORY>.<NAME>`.
|
||||
|
||||
### `CliCode`
|
||||
|
||||
```ts
|
||||
type CliCode =
|
||||
| "CLI.PARSE.UNKNOWN_OPTION"
|
||||
| "CLI.PARSE.MISSING_VALUE"
|
||||
| "CLI.PARSE.INVALID_VALUE"
|
||||
| "CLI.PARSE.MISSING_ARGUMENT"
|
||||
| "CLI.PARSE.MISSING_REQUIRED_OPTION"
|
||||
| "CLI.COMMAND.NO_ACTION"
|
||||
| "CLI.COMMAND.UNKNOWN_SUBCOMMAND"
|
||||
| "CLI.APP.RUN_FAILED"
|
||||
| "CLI.APP.ABORTED";
|
||||
```
|
||||
|
||||
Union of all values in `CLI_CODES`. Use as the type parameter for
|
||||
`SuaveplanError<CliCode>` or in exhaustive switch statements.
|
||||
|
||||
### `CliError`
|
||||
|
||||
```ts
|
||||
class CliError extends SuaveplanError<CliCode> {
|
||||
public constructor(
|
||||
code: CliCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
): CliError;
|
||||
}
|
||||
```
|
||||
|
||||
Typed error for all `@suaveplan/cli` failures.
|
||||
|
||||
- **`code`** — one of the `CliCode` values; accessible as `err.code` via the base
|
||||
class.
|
||||
- **`messageKey`** — i18n message key passed to the base class for message resolver
|
||||
integration.
|
||||
- **`options.cause`** — optional upstream `Error` for cause chaining; accessible as
|
||||
`err.cause`.
|
||||
- **`options.context`** — optional `Record<string, unknown>` with structured
|
||||
diagnostic metadata; accessible as `err.context`.
|
||||
|
||||
Throws: nothing — this is an Error constructor.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`CLI_CODES` is built from three separate `defineCodes()` calls rather than one flat
|
||||
call so that each sub-namespace is isolated. This mirrors the pattern established in
|
||||
`@suaveplan/readline` and keeps future code-set additions within a namespace
|
||||
non-breaking for callers who already destructure `CLI_CODES.PARSE`.
|
||||
|
||||
The `CliError` constructor comment (`biome-ignore`) documents why the constructor is
|
||||
not "useless" from Biome's perspective: the base `SuaveplanError` constructor is
|
||||
`protected`, so widening it to `public` here is a meaningful override even though
|
||||
the body only calls `super()`. This is the standard pattern across all
|
||||
`@suaveplan/*` packages.
|
||||
|
||||
No runtime dependencies beyond `@suaveplan/error` are introduced. The `defineCodes`
|
||||
helper produces plain string literals, so the entire module tree-shakes to near
|
||||
zero bytes when errors are not thrown on a given code path.
|
||||
56
packages/devtools/tooling/cli/src/errors/errors.test.ts
Normal file
56
packages/devtools/tooling/cli/src/errors/errors.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CLI_CODES, CliError } from "./errors.js";
|
||||
|
||||
describe("CLI_CODES", { timeout: 5000 }, () => {
|
||||
it("has all expected PARSE codes", { timeout: 5000 }, () => {
|
||||
expect(CLI_CODES.PARSE.UNKNOWN_OPTION).toBe("CLI.PARSE.UNKNOWN_OPTION");
|
||||
expect(CLI_CODES.PARSE.MISSING_VALUE).toBe("CLI.PARSE.MISSING_VALUE");
|
||||
expect(CLI_CODES.PARSE.INVALID_VALUE).toBe("CLI.PARSE.INVALID_VALUE");
|
||||
expect(CLI_CODES.PARSE.MISSING_ARGUMENT).toBe(
|
||||
"CLI.PARSE.MISSING_ARGUMENT"
|
||||
);
|
||||
expect(CLI_CODES.PARSE.MISSING_REQUIRED_OPTION).toBe(
|
||||
"CLI.PARSE.MISSING_REQUIRED_OPTION"
|
||||
);
|
||||
});
|
||||
|
||||
it("has all expected COMMAND codes", { timeout: 5000 }, () => {
|
||||
expect(CLI_CODES.COMMAND.NO_ACTION).toBe("CLI.COMMAND.NO_ACTION");
|
||||
expect(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND).toBe(
|
||||
"CLI.COMMAND.UNKNOWN_SUBCOMMAND"
|
||||
);
|
||||
});
|
||||
|
||||
it("has all expected APP codes", { timeout: 5000 }, () => {
|
||||
expect(CLI_CODES.APP.RUN_FAILED).toBe("CLI.APP.RUN_FAILED");
|
||||
expect(CLI_CODES.APP.ABORTED).toBe("CLI.APP.ABORTED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CliError", { timeout: 5000 }, () => {
|
||||
it("is an Error with the given code", { timeout: 5000 }, () => {
|
||||
const err = new CliError(
|
||||
CLI_CODES.PARSE.UNKNOWN_OPTION,
|
||||
"cli.parse.unknown_option"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe("CLI.PARSE.UNKNOWN_OPTION");
|
||||
});
|
||||
|
||||
it("chains cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new CliError(CLI_CODES.APP.ABORTED, "cli.app.aborted", {
|
||||
cause,
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("attaches context", { timeout: 5000 }, () => {
|
||||
const err = new CliError(
|
||||
CLI_CODES.PARSE.UNKNOWN_OPTION,
|
||||
"cli.parse.unknown_option",
|
||||
{ context: { option: "foo" } }
|
||||
);
|
||||
expect(err.context).toEqual({ option: "foo" });
|
||||
});
|
||||
});
|
||||
70
packages/devtools/tooling/cli/src/errors/errors.ts
Normal file
70
packages/devtools/tooling/cli/src/errors/errors.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
const PARSE_CODES = defineCodes("CLI.PARSE", [
|
||||
"UNKNOWN_OPTION",
|
||||
"MISSING_VALUE",
|
||||
"INVALID_VALUE",
|
||||
"MISSING_ARGUMENT",
|
||||
"MISSING_REQUIRED_OPTION",
|
||||
] as const);
|
||||
|
||||
const COMMAND_CODES = defineCodes("CLI.COMMAND", [
|
||||
"NO_ACTION",
|
||||
"UNKNOWN_SUBCOMMAND",
|
||||
] as const);
|
||||
|
||||
const APP_CODES = defineCodes("CLI.APP", ["RUN_FAILED", "ABORTED"] as const);
|
||||
|
||||
/**
|
||||
* Error code registry for `@suaveplan/cli`, organised by sub-namespace.
|
||||
*
|
||||
* - `CLI.PARSE.UNKNOWN_OPTION` — an unrecognised option flag was supplied
|
||||
* - `CLI.PARSE.MISSING_VALUE` — an option that requires a value received none
|
||||
* - `CLI.PARSE.INVALID_VALUE` — an option value failed type/constraint validation
|
||||
* - `CLI.PARSE.MISSING_ARGUMENT` — a required positional argument was not provided
|
||||
* - `CLI.PARSE.MISSING_REQUIRED_OPTION` — a required option flag was omitted entirely
|
||||
* - `CLI.COMMAND.NO_ACTION` — a command matched but has no handler registered
|
||||
* - `CLI.COMMAND.UNKNOWN_SUBCOMMAND` — an unrecognised subcommand name was supplied
|
||||
* - `CLI.APP.RUN_FAILED` — top-level application run threw an unexpected error
|
||||
* - `CLI.APP.ABORTED` — the CLI run was cancelled via AbortSignal or SIGINT
|
||||
*/
|
||||
export const CLI_CODES = {
|
||||
PARSE: PARSE_CODES,
|
||||
COMMAND: COMMAND_CODES,
|
||||
APP: APP_CODES,
|
||||
} as const;
|
||||
|
||||
export type CliCode =
|
||||
| (typeof PARSE_CODES)[keyof typeof PARSE_CODES]
|
||||
| (typeof COMMAND_CODES)[keyof typeof COMMAND_CODES]
|
||||
| (typeof APP_CODES)[keyof typeof APP_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/cli` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` with a strongly-typed `code` drawn from
|
||||
* `CLI_CODES`, giving callers narrow `instanceof` discrimination and
|
||||
* a stable machine-readable code for logging and alerting.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { CliError, CLI_CODES } from "@suaveplan/cli/errors/errors";
|
||||
*
|
||||
* throw new CliError(
|
||||
* CLI_CODES.PARSE.UNKNOWN_OPTION,
|
||||
* "cli.parse.unknown_option",
|
||||
* { cause: originalError, context: { option: "--foo" } }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class CliError extends SuaveplanError<CliCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||||
public constructor(
|
||||
code: CliCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
5
packages/devtools/tooling/cli/src/errors/index.ts
Normal file
5
packages/devtools/tooling/cli/src/errors/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { CliCode } from "./errors.js";
|
||||
export {
|
||||
CLI_CODES,
|
||||
CliError,
|
||||
} from "./errors.js";
|
||||
115
packages/devtools/tooling/cli/src/index.ts
Normal file
115
packages/devtools/tooling/cli/src/index.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// App
|
||||
export { App, cli } from "./app/app.js";
|
||||
export { AppConfigSchema } from "./app/app.schemas.js";
|
||||
export type { AppConfig } from "./app/app.types.js";
|
||||
// Command
|
||||
export {
|
||||
Command,
|
||||
parseArgumentSpec,
|
||||
parseOptionFlags,
|
||||
} from "./command/command.js";
|
||||
export { CommandInfoSchema } from "./command/command.schemas.js";
|
||||
export type {
|
||||
ActionContext,
|
||||
ActionFn,
|
||||
CommandInfo,
|
||||
} from "./command/command.types.js";
|
||||
export type { CliCode } from "./errors/errors.js";
|
||||
// Errors
|
||||
export { CLI_CODES, CliError } from "./errors/errors.js";
|
||||
// Output — format
|
||||
export {
|
||||
columns,
|
||||
divider,
|
||||
heading,
|
||||
indent,
|
||||
json,
|
||||
keyValue,
|
||||
list,
|
||||
tree,
|
||||
} from "./output/format/format.js";
|
||||
export { FormatOptionsSchema } from "./output/format/format.schemas.js";
|
||||
export type {
|
||||
JsonOptions,
|
||||
KeyValueOptions,
|
||||
ListOptions,
|
||||
TreeNode,
|
||||
TreeOptions,
|
||||
} from "./output/format/format.types.js";
|
||||
// Output — table
|
||||
export { table } from "./output/table/table.js";
|
||||
export {
|
||||
AlignmentSchema,
|
||||
TableOptionsSchema,
|
||||
} from "./output/table/table.schemas.js";
|
||||
export type { Alignment, TableOptions } from "./output/table/table.types.js";
|
||||
// Parser
|
||||
export { parseArgs } from "./parser/parser.js";
|
||||
export {
|
||||
ArgumentConfigSchema,
|
||||
OptionConfigSchema,
|
||||
OptionTypeSchema,
|
||||
ParsedArgsSchema,
|
||||
ParserConfigSchema,
|
||||
} from "./parser/parser.schemas.js";
|
||||
export type {
|
||||
ArgumentConfig,
|
||||
OptionConfig,
|
||||
OptionType,
|
||||
ParsedArgs,
|
||||
ParserConfig,
|
||||
} from "./parser/parser.types.js";
|
||||
// Progress
|
||||
export { createProgress } from "./progress/progress.js";
|
||||
export { ProgressOptionsSchema } from "./progress/progress.schemas.js";
|
||||
export type {
|
||||
ProgressInstance,
|
||||
ProgressOptions,
|
||||
} from "./progress/progress.types.js";
|
||||
// Prompt
|
||||
export {
|
||||
confirm,
|
||||
createDefaultReader,
|
||||
multiselect,
|
||||
password,
|
||||
select,
|
||||
text,
|
||||
} from "./prompt/prompt.js";
|
||||
export {
|
||||
ConfirmPromptOptionsSchema,
|
||||
SelectChoiceSchema,
|
||||
TextPromptOptionsSchema,
|
||||
} from "./prompt/prompt.schemas.js";
|
||||
export type {
|
||||
ConfirmPromptOptions,
|
||||
InputReader,
|
||||
MultiSelectPromptOptions,
|
||||
PasswordPromptOptions,
|
||||
SelectChoice,
|
||||
SelectPromptOptions,
|
||||
TextPromptOptions,
|
||||
} from "./prompt/prompt.types.js";
|
||||
// Spinner
|
||||
export { createSpinner } from "./spinner/spinner.js";
|
||||
export { SpinnerOptionsSchema } from "./spinner/spinner.schemas.js";
|
||||
export type {
|
||||
SpinnerInstance,
|
||||
SpinnerOptions,
|
||||
WritableStreamLike,
|
||||
} from "./spinner/spinner.types.js";
|
||||
// Style
|
||||
export {
|
||||
createStyler,
|
||||
getStyleNames,
|
||||
isColorSupported,
|
||||
stripAnsi,
|
||||
style,
|
||||
} from "./style/style.js";
|
||||
export { StyleOptionsSchema } from "./style/style.schemas.js";
|
||||
export type {
|
||||
BgColor,
|
||||
Color,
|
||||
Modifier,
|
||||
StyleCode,
|
||||
Styler,
|
||||
} from "./style/style.types.js";
|
||||
93
packages/devtools/tooling/cli/src/output/format/format.md
Normal file
93
packages/devtools/tooling/cli/src/output/format/format.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# output/format
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`format` provides a collection of pure string-formatting utilities for structured CLI output. All functions take plain data and return formatted strings — no side effects, no I/O. Callers are responsible for printing the result. This design makes every function trivially testable and composable.
|
||||
|
||||
The module covers the most common CLI output patterns: key-value pairs, bulleted lists, directory-style trees, inline JSON, multi-column alignment, section headings, horizontal dividers, and indented blocks.
|
||||
|
||||
## Features
|
||||
|
||||
- `keyValue(obj, opts?)` — formats a record as aligned `key: value` pairs
|
||||
- `list(items, opts?)` — formats an array as a bulleted list
|
||||
- `tree(node, opts?)` — renders a recursive tree with `├─` / `└─` connectors
|
||||
- `json(value, opts?)` — `JSON.stringify` wrapper with configurable indent
|
||||
- `columns(rows, separator?)` — right-pads cells so columns align across rows
|
||||
- `heading(text, level?)` — box-style heading with `═` (level 1) or `─` (level 2) borders
|
||||
- `divider(char?, width?)` — repeated character line
|
||||
- `indent(text, spaces?)` — prefixes every line with spaces
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { keyValue, list, heading } from "@suaveplan/cli/output/format/format";
|
||||
|
||||
console.log(heading("Build Report"));
|
||||
console.log(keyValue({ status: "success", files: 42, duration: "1.2s" }));
|
||||
console.log(list(["Compiled main.ts", "Emitted dist/", "Linked types"]));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { tree, columns, json, indent } from "@suaveplan/cli/output/format/format";
|
||||
|
||||
const structure = {
|
||||
label: "src/",
|
||||
children: [
|
||||
{ label: "app/", children: [{ label: "app.ts" }, { label: "app.test.ts" }] },
|
||||
{ label: "utils/", children: [{ label: "format.ts" }] },
|
||||
],
|
||||
};
|
||||
|
||||
console.log(tree(structure));
|
||||
|
||||
const rows = [
|
||||
["alice", "admin", "2024-01-01"],
|
||||
["bob", "user", "2024-03-15"],
|
||||
];
|
||||
console.log(columns([["Name", "Role", "Joined"], ...rows]));
|
||||
|
||||
console.log(indent(json({ ok: true, count: 7 }), 4));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `keyValue(obj: Record<string, unknown>, opts?: KeyValueOptions): string`
|
||||
|
||||
Formats each entry as `key: value`. Options: `separator` (default `": "`), `indent` (spaces before each line, default 0).
|
||||
|
||||
### `list(items: string[], opts?: ListOptions): string`
|
||||
|
||||
Formats each item with a bullet marker. Options: `bullet` (default `"•"`), `indent` (default 0).
|
||||
|
||||
### `tree(node: TreeNode, opts?: TreeOptions, _prefix?: string): string`
|
||||
|
||||
Renders a nested tree with connector lines. Options: `indent` (spaces per nesting level, default 2).
|
||||
|
||||
### `json(value: unknown, opts?: JsonOptions): string`
|
||||
|
||||
Returns `JSON.stringify(value, null, indent)`. Options: `indent` (default 2).
|
||||
|
||||
### `columns(rows: string[][], separator?: string): string`
|
||||
|
||||
Pads each cell in each column to the column's maximum width, then joins with `separator` (default `" "`). Returns empty string for empty input.
|
||||
|
||||
### `heading(text: string, level?: number): string`
|
||||
|
||||
Returns a three-line block: border, centered text, border. Level 1 uses `═`, level 2 uses `─`, others use `-`.
|
||||
|
||||
### `divider(char?: string, width?: number): string`
|
||||
|
||||
Returns `char.repeat(width)`. Defaults: `"─"` and `40`.
|
||||
|
||||
### `indent(text: string, spaces?: number): string`
|
||||
|
||||
Prepends `spaces` (default 2) spaces to every line of `text`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All functions are stateless and have no dependencies. `tree` recurses depth-first, tracking a prefix string for connector characters at each level. `columns` computes widths in a single pass over all rows, then renders in a second pass. The `_prefix` parameter of `tree` is internal — external callers should omit it.
|
||||
@@ -0,0 +1,26 @@
|
||||
import z from "zod";
|
||||
|
||||
export const KeyValueOptionsSchema = z.object({
|
||||
separator: z.string().optional(),
|
||||
indent: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const ListOptionsSchema = z.object({
|
||||
bullet: z.string().optional(),
|
||||
indent: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const TreeOptionsSchema = z.object({
|
||||
indent: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const JsonOptionsSchema = z.object({
|
||||
indent: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const FormatOptionsSchema = z.object({
|
||||
keyValue: KeyValueOptionsSchema.optional(),
|
||||
list: ListOptionsSchema.optional(),
|
||||
tree: TreeOptionsSchema.optional(),
|
||||
json: JsonOptionsSchema.optional(),
|
||||
});
|
||||
188
packages/devtools/tooling/cli/src/output/format/format.test.ts
Normal file
188
packages/devtools/tooling/cli/src/output/format/format.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
columns,
|
||||
divider,
|
||||
heading,
|
||||
indent,
|
||||
json,
|
||||
keyValue,
|
||||
list,
|
||||
tree,
|
||||
} from "./format.js";
|
||||
|
||||
describe("keyValue", { timeout: 5000 }, () => {
|
||||
it("contains key and value", { timeout: 5000 }, () => {
|
||||
const out = keyValue({ a: 1, b: 2 });
|
||||
expect(out).toContain("a");
|
||||
expect(out).toContain("1");
|
||||
});
|
||||
|
||||
it("uses custom separator", { timeout: 5000 }, () => {
|
||||
expect(keyValue({ x: "y" }, { separator: " = " })).toContain("x = y");
|
||||
});
|
||||
|
||||
it("applies indent", { timeout: 5000 }, () => {
|
||||
const out = keyValue({ k: "v" }, { indent: 4 });
|
||||
expect(out.startsWith(" k")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", { timeout: 5000 }, () => {
|
||||
it("contains bullet markers", { timeout: 5000 }, () => {
|
||||
const out = list(["x", "y"]);
|
||||
expect(out).toContain("• x");
|
||||
expect(out).toContain("• y");
|
||||
});
|
||||
|
||||
it("uses custom bullet", { timeout: 5000 }, () => {
|
||||
expect(list(["a"], { bullet: "-" })).toContain("- a");
|
||||
});
|
||||
|
||||
it("empty list returns empty string", { timeout: 5000 }, () => {
|
||||
expect(list([])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tree", { timeout: 5000 }, () => {
|
||||
it("contains root label", { timeout: 5000 }, () => {
|
||||
expect(tree({ label: "root" })).toContain("root");
|
||||
});
|
||||
|
||||
it("contains child labels", { timeout: 5000 }, () => {
|
||||
const out = tree({ label: "root", children: [{ label: "child" }] });
|
||||
expect(out).toContain("root");
|
||||
expect(out).toContain("child");
|
||||
});
|
||||
|
||||
it("handles nested children", { timeout: 5000 }, () => {
|
||||
const out = tree({
|
||||
label: "a",
|
||||
children: [{ label: "b", children: [{ label: "c" }] }],
|
||||
});
|
||||
expect(out).toContain("a");
|
||||
expect(out).toContain("b");
|
||||
expect(out).toContain("c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("json", { timeout: 5000 }, () => {
|
||||
it("formats json with indent 2", { timeout: 5000 }, () => {
|
||||
expect(json({ x: 1 })).toBe('{\n "x": 1\n}');
|
||||
});
|
||||
|
||||
it("uses custom indent", { timeout: 5000 }, () => {
|
||||
expect(json({ a: 1 }, { indent: 4 })).toContain(" ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("columns", { timeout: 5000 }, () => {
|
||||
it("returns empty string for no rows", { timeout: 5000 }, () => {
|
||||
expect(columns([])).toBe("");
|
||||
});
|
||||
|
||||
it("aligns columns", { timeout: 5000 }, () => {
|
||||
const out = columns([
|
||||
["a", "bb"],
|
||||
["ccc", "d"],
|
||||
]);
|
||||
const lines = out.split("\n");
|
||||
expect(lines.length).toBe(2);
|
||||
});
|
||||
|
||||
it("column width >= max cell content width (property)", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.array(fc.string({ maxLength: 20 }), {
|
||||
minLength: 1,
|
||||
maxLength: 4,
|
||||
}),
|
||||
{ minLength: 1, maxLength: 10 }
|
||||
),
|
||||
(rows) => {
|
||||
const out = columns(rows);
|
||||
const lines = out.split("\n");
|
||||
expect(lines.length).toBe(rows.length);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tree — multi-child (├ connector)", { timeout: 5000 }, () => {
|
||||
it("uses ├ for non-last children", { timeout: 5000 }, () => {
|
||||
const out = tree({
|
||||
label: "root",
|
||||
children: [{ label: "a" }, { label: "b" }],
|
||||
});
|
||||
expect(out).toContain("├");
|
||||
expect(out).toContain("└");
|
||||
});
|
||||
|
||||
it("uses │ prefix for non-last children with nested subtree", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = tree({
|
||||
label: "root",
|
||||
children: [
|
||||
{ label: "first", children: [{ label: "child" }] },
|
||||
{ label: "last" },
|
||||
],
|
||||
});
|
||||
expect(out).toContain("│");
|
||||
expect(out).toContain("first");
|
||||
expect(out).toContain("child");
|
||||
});
|
||||
});
|
||||
|
||||
describe("heading", { timeout: 5000 }, () => {
|
||||
it("contains text", { timeout: 5000 }, () => {
|
||||
expect(heading("Title", 1)).toContain("Title");
|
||||
});
|
||||
|
||||
it("uses default level 1 when no level arg given", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(heading("Hello")).toContain("═");
|
||||
});
|
||||
|
||||
it("uses ═ for level 1", { timeout: 5000 }, () => {
|
||||
expect(heading("X", 1)).toContain("═");
|
||||
});
|
||||
|
||||
it("uses ─ for level 2", { timeout: 5000 }, () => {
|
||||
expect(heading("X", 2)).toContain("─");
|
||||
});
|
||||
|
||||
it("uses - for level 3+", { timeout: 5000 }, () => {
|
||||
expect(heading("X", 3)).toContain("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("divider defaults", { timeout: 5000 }, () => {
|
||||
it("uses ─ and width 40 by default", { timeout: 5000 }, () => {
|
||||
expect(divider()).toBe("─".repeat(40));
|
||||
});
|
||||
});
|
||||
|
||||
describe("indent defaults", { timeout: 5000 }, () => {
|
||||
it("uses 2 spaces by default", { timeout: 5000 }, () => {
|
||||
expect(indent("x")).toBe(" x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("divider", { timeout: 5000 }, () => {
|
||||
it("returns repeated char", { timeout: 5000 }, () => {
|
||||
expect(divider("=", 5)).toBe("=====");
|
||||
});
|
||||
});
|
||||
|
||||
describe("indent", { timeout: 5000 }, () => {
|
||||
it("indents each line", { timeout: 5000 }, () => {
|
||||
const out = indent("a\nb", 2);
|
||||
expect(out).toBe(" a\n b");
|
||||
});
|
||||
});
|
||||
82
packages/devtools/tooling/cli/src/output/format/format.ts
Normal file
82
packages/devtools/tooling/cli/src/output/format/format.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
JsonOptions,
|
||||
KeyValueOptions,
|
||||
ListOptions,
|
||||
TreeNode,
|
||||
TreeOptions,
|
||||
} from "./format.types.js";
|
||||
|
||||
export function keyValue(
|
||||
obj: Record<string, unknown>,
|
||||
opts?: KeyValueOptions
|
||||
): string {
|
||||
const sep = opts?.separator ?? ": ";
|
||||
const pad = " ".repeat(opts?.indent ?? 0);
|
||||
return Object.entries(obj)
|
||||
.map(([k, v]) => `${pad}${k}${sep}${String(v)}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function list(items: string[], opts?: ListOptions): string {
|
||||
const bullet = opts?.bullet ?? "•";
|
||||
const pad = " ".repeat(opts?.indent ?? 0);
|
||||
return items.map((item) => `${pad}${bullet} ${item}`).join("\n");
|
||||
}
|
||||
|
||||
export function tree(node: TreeNode, opts?: TreeOptions, _prefix = ""): string {
|
||||
const indent = opts?.indent ?? 2;
|
||||
const lines: string[] = [node.label];
|
||||
if (node.children) {
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const child = node.children[i]!;
|
||||
const isLast = i === node.children.length - 1;
|
||||
const connector = isLast ? "└" : "├";
|
||||
const childPrefix = _prefix + " ".repeat(indent);
|
||||
const childLines = tree(child, opts, childPrefix).split("\n");
|
||||
lines.push(`${_prefix}${connector}─ ${childLines[0]}`);
|
||||
for (let j = 1; j < childLines.length; j++) {
|
||||
lines.push(`${_prefix}${isLast ? " " : "│"} ${childLines[j]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function json(value: unknown, opts?: JsonOptions): string {
|
||||
return JSON.stringify(value, null, opts?.indent ?? 2);
|
||||
}
|
||||
|
||||
export function columns(rows: string[][], separator = " "): string {
|
||||
if (rows.length === 0) return "";
|
||||
const colCount = Math.max(...rows.map((r) => r.length));
|
||||
const widths = Array.from({ length: colCount }, (_, c) =>
|
||||
Math.max(...rows.map((r) => (r[c] ?? "").length))
|
||||
);
|
||||
return rows
|
||||
.map((row) =>
|
||||
row
|
||||
.map((cell, c) =>
|
||||
c < row.length - 1 ? cell.padEnd(widths[c]!) : cell
|
||||
)
|
||||
.join(separator)
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function heading(text: string, level = 1): string {
|
||||
const char = level === 1 ? "═" : level === 2 ? "─" : "-";
|
||||
const line = char.repeat(text.length + 4);
|
||||
return `${line}\n ${text} \n${line}`;
|
||||
}
|
||||
|
||||
export function divider(char = "─", width = 40): string {
|
||||
return char.repeat(width);
|
||||
}
|
||||
|
||||
export function indent(text: string, spaces = 2): string {
|
||||
const pad = " ".repeat(spaces);
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => `${pad}${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface KeyValueOptions {
|
||||
separator?: string;
|
||||
indent?: number;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
bullet?: string;
|
||||
indent?: number;
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
label: string;
|
||||
children?: TreeNode[];
|
||||
}
|
||||
|
||||
export interface TreeOptions {
|
||||
indent?: number;
|
||||
}
|
||||
|
||||
export interface JsonOptions {
|
||||
indent?: number;
|
||||
}
|
||||
24
packages/devtools/tooling/cli/src/output/format/index.ts
Normal file
24
packages/devtools/tooling/cli/src/output/format/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
columns,
|
||||
divider,
|
||||
heading,
|
||||
indent,
|
||||
json,
|
||||
keyValue,
|
||||
list,
|
||||
tree,
|
||||
} from "./format.js";
|
||||
export {
|
||||
FormatOptionsSchema,
|
||||
JsonOptionsSchema,
|
||||
KeyValueOptionsSchema,
|
||||
ListOptionsSchema,
|
||||
TreeOptionsSchema,
|
||||
} from "./format.schemas.js";
|
||||
export type {
|
||||
JsonOptions,
|
||||
KeyValueOptions,
|
||||
ListOptions,
|
||||
TreeNode,
|
||||
TreeOptions,
|
||||
} from "./format.types.js";
|
||||
3
packages/devtools/tooling/cli/src/output/table/index.ts
Normal file
3
packages/devtools/tooling/cli/src/output/table/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { table } from "./table.js";
|
||||
export { AlignmentSchema, TableOptionsSchema } from "./table.schemas.js";
|
||||
export type { Alignment, TableOptions } from "./table.types.js";
|
||||
81
packages/devtools/tooling/cli/src/output/table/table.md
Normal file
81
packages/devtools/tooling/cli/src/output/table/table.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# output/table
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`table` renders structured data as a formatted ASCII table with header row, optional borders, and configurable column alignment. It computes column widths from the full data set — header and all rows — ensuring that every column is at least as wide as its widest cell. The result is a plain multi-line string ready for console output or piping.
|
||||
|
||||
The function is pure: it takes a `TableOptions` object and returns a `string`. No state, no I/O, no mutations.
|
||||
|
||||
## Features
|
||||
|
||||
- Automatic column width calculation: each column is as wide as its widest cell across all rows
|
||||
- Three alignment modes per column: `"left"` (default), `"right"`, `"center"`
|
||||
- Border mode (default on): header and data rows separated by `─┼─` divider
|
||||
- Border-off mode: columns joined with two spaces
|
||||
- Handles empty rows gracefully — returns header-only output
|
||||
- Property-tested: column widths always `>=` max cell content width
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { table } from "@suaveplan/cli/output/table/table";
|
||||
|
||||
const out = table({
|
||||
headers: ["Name", "Role", "Since"],
|
||||
rows: [
|
||||
["Alice", "Admin", "2022"],
|
||||
["Bob", "User", "2023"],
|
||||
],
|
||||
});
|
||||
console.log(out);
|
||||
// Name │ Role │ Since
|
||||
// ──────┼───────┼──────
|
||||
// Alice │ Admin │ 2022
|
||||
// Bob │ User │ 2023
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { table } from "@suaveplan/cli/output/table/table";
|
||||
|
||||
const out = table({
|
||||
headers: ["Package", "Version", "Size"],
|
||||
rows: [
|
||||
["@suaveplan/error", "0.1.0", "12 kB"],
|
||||
["@suaveplan/cli", "0.1.0", "48 kB"],
|
||||
],
|
||||
alignment: ["left", "center", "right"],
|
||||
border: true,
|
||||
});
|
||||
console.log(out);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `table(opts: TableOptions): string`
|
||||
|
||||
Renders the table and returns the formatted string.
|
||||
|
||||
**`opts.headers: string[]`** — column header labels. Determines the number of columns.
|
||||
|
||||
**`opts.rows: string[][]`** — data rows. Each row is an array of string cells. Extra cells beyond `headers.length` are ignored; missing cells are treated as empty.
|
||||
|
||||
**`opts.alignment?: Alignment[]`** — per-column alignment (`"left"` | `"right"` | `"center"`). Defaults to `"left"` for any column not specified.
|
||||
|
||||
**`opts.border?: boolean`** — whether to render `│` column separators and `─┼─` divider line. Default `true`.
|
||||
|
||||
### `Alignment`
|
||||
|
||||
`"left" | "right" | "center"` — the alignment mode for a table column.
|
||||
|
||||
### `TableOptions`
|
||||
|
||||
The full options type. Corresponds to `TableOptionsSchema` for Zod validation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Column widths are computed with a single `Math.max` pass over `[headers, ...rows]`. Cells are padded with `String.prototype.padEnd` (left/center) or `String.prototype.padStart` (right via the `align` helper). The divider line joins `"─".repeat(width)` segments with `"─┼─"`. When `border` is false, the divider is omitted and segments are joined with `" "` instead of `" │ "`. Trailing whitespace on each row is trimmed to keep output clean.
|
||||
@@ -0,0 +1,10 @@
|
||||
import z from "zod";
|
||||
|
||||
export const AlignmentSchema = z.enum(["left", "right", "center"]);
|
||||
|
||||
export const TableOptionsSchema = z.object({
|
||||
headers: z.array(z.string()),
|
||||
rows: z.array(z.array(z.string())),
|
||||
alignment: z.array(AlignmentSchema).optional(),
|
||||
border: z.boolean().optional(),
|
||||
});
|
||||
89
packages/devtools/tooling/cli/src/output/table/table.test.ts
Normal file
89
packages/devtools/tooling/cli/src/output/table/table.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { table } from "./table.js";
|
||||
|
||||
describe("table", { timeout: 5000 }, () => {
|
||||
it("renders header row", { timeout: 5000 }, () => {
|
||||
const out = table({ headers: ["Name", "Age"], rows: [] });
|
||||
expect(out).toContain("Name");
|
||||
expect(out).toContain("Age");
|
||||
});
|
||||
|
||||
it("renders data rows", { timeout: 5000 }, () => {
|
||||
const out = table({
|
||||
headers: ["Name", "Age"],
|
||||
rows: [
|
||||
["Alice", "30"],
|
||||
["Bob", "25"],
|
||||
],
|
||||
});
|
||||
expect(out).toContain("Alice");
|
||||
expect(out).toContain("Bob");
|
||||
});
|
||||
|
||||
it("handles empty rows", { timeout: 5000 }, () => {
|
||||
const out = table({ headers: ["Col"], rows: [] });
|
||||
expect(out).toContain("Col");
|
||||
});
|
||||
|
||||
it("aligns columns to max width (property)", { timeout: 30000 }, () => {
|
||||
const printable = fc
|
||||
.stringMatching(/^[a-zA-Z0-9_-]+$/)
|
||||
.filter((s) => s.length >= 1 && s.length <= 15);
|
||||
const cell = fc
|
||||
.stringMatching(/^[a-zA-Z0-9 _-]*$/)
|
||||
.filter((s) => s.length <= 15);
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(printable, { minLength: 1, maxLength: 4 }),
|
||||
fc.array(fc.array(cell, { minLength: 1, maxLength: 4 }), {
|
||||
maxLength: 5,
|
||||
}),
|
||||
(headers, rows) => {
|
||||
const out = table({
|
||||
headers,
|
||||
rows: rows.map((r) => r.slice(0, headers.length)),
|
||||
});
|
||||
const lines = out.split("\n");
|
||||
expect(lines.length).toBeGreaterThanOrEqual(1);
|
||||
for (const h of headers) {
|
||||
expect(lines[0]).toContain(h);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("right-aligns numbers", { timeout: 5000 }, () => {
|
||||
const out = table({
|
||||
headers: ["Name", "Score"],
|
||||
rows: [["Alice", "100"]],
|
||||
alignment: ["left", "right"],
|
||||
});
|
||||
expect(out).toContain("Alice");
|
||||
expect(out).toContain("100");
|
||||
});
|
||||
|
||||
it("center-aligns when specified", { timeout: 5000 }, () => {
|
||||
const out = table({
|
||||
headers: ["Col"],
|
||||
rows: [["x"]],
|
||||
alignment: ["center"],
|
||||
});
|
||||
expect(out).toContain("Col");
|
||||
});
|
||||
|
||||
it("border:false uses double-space separator and no divider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = table({
|
||||
headers: ["Name", "Score"],
|
||||
rows: [["Alice", "100"]],
|
||||
border: false,
|
||||
});
|
||||
expect(out).not.toContain("│");
|
||||
expect(out).not.toContain("─┼─");
|
||||
expect(out).toContain("Name");
|
||||
expect(out).toContain("Alice");
|
||||
});
|
||||
});
|
||||
42
packages/devtools/tooling/cli/src/output/table/table.ts
Normal file
42
packages/devtools/tooling/cli/src/output/table/table.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Alignment, TableOptions } from "./table.types.js";
|
||||
|
||||
function align(text: string, width: number, alignment: Alignment): string {
|
||||
const pad = width - text.length;
|
||||
if (pad <= 0) return text;
|
||||
if (alignment === "right") return " ".repeat(pad) + text;
|
||||
if (alignment === "center") {
|
||||
const left = Math.floor(pad / 2);
|
||||
const right = pad - left;
|
||||
return " ".repeat(left) + text + " ".repeat(right);
|
||||
}
|
||||
return text + " ".repeat(pad);
|
||||
}
|
||||
|
||||
export function table(opts: TableOptions): string {
|
||||
const { headers, rows, alignment = [], border = true } = opts;
|
||||
const colCount = headers.length;
|
||||
const allRows = [headers, ...rows];
|
||||
|
||||
const widths = Array.from({ length: colCount }, (_, c) =>
|
||||
Math.max(...allRows.map((r) => (r[c] ?? "").length))
|
||||
);
|
||||
|
||||
const sep = border ? " │ " : " ";
|
||||
const rowToStr = (row: string[], _isHeader = false): string =>
|
||||
row
|
||||
.slice(0, colCount)
|
||||
.map((cell, c) => align(cell, widths[c]!, alignment[c] ?? "left"))
|
||||
.join(sep)
|
||||
.trimEnd();
|
||||
|
||||
const divLine = border ? widths.map((w) => "─".repeat(w)).join("─┼─") : "";
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(rowToStr(headers, true));
|
||||
if (border) lines.push(divLine);
|
||||
for (const row of rows) {
|
||||
lines.push(rowToStr(row));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type Alignment = "left" | "right" | "center";
|
||||
|
||||
export interface TableOptions {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
alignment?: Alignment[];
|
||||
border?: boolean;
|
||||
}
|
||||
14
packages/devtools/tooling/cli/src/parser/index.ts
Normal file
14
packages/devtools/tooling/cli/src/parser/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { parseArgs, parseArgumentSpec, parseOptionFlags } from "./parser.js";
|
||||
export {
|
||||
ArgumentConfigSchema,
|
||||
OptionConfigSchema,
|
||||
ParsedArgsSchema,
|
||||
ParserConfigSchema,
|
||||
} from "./parser.schemas.js";
|
||||
export type {
|
||||
ArgumentConfig,
|
||||
OptionConfig,
|
||||
OptionType,
|
||||
ParsedArgs,
|
||||
ParserConfig,
|
||||
} from "./parser.types.js";
|
||||
92
packages/devtools/tooling/cli/src/parser/parser.md
Normal file
92
packages/devtools/tooling/cli/src/parser/parser.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# parser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `parser` module turns a raw `argv` token array into a structured `ParsedArgs` object with typed options, named positional arguments, and a `rest` passthrough channel. It is the low-level argument-parsing engine that higher-level command definitions build on, designed to model the behaviour CLI users expect from mature tools like `git`, `npm`, and `cargo`.
|
||||
|
||||
The module is deliberately imperative and framework-free: no reflection, no decorators, no hidden state. Every behaviour — type coercion, `--no-*` negation, short-flag resolution, choice validation, required-option checking — is enforced in a single linear pass over `argv`, and every failure mode raises a `CliError` with a stable code drawn from `CLI_CODES.PARSE.*` so callers can branch on machine-readable codes instead of message strings.
|
||||
|
||||
## Features
|
||||
|
||||
- Boolean flags (`--verbose`) and negation (`--no-verbose`)
|
||||
- String/number/array options via `--key value`, `--key=value`, or short-flag (`-v`) forms
|
||||
- Short-flag mapping via `short: "v"` on the option config
|
||||
- Passthrough `rest` channel after `--`
|
||||
- Variadic positionals (`<files...>`) collected as arrays
|
||||
- Required positionals and required options with dedicated error codes
|
||||
- Choice validation with `INVALID_VALUE`
|
||||
- `allowUnknown: true` to silently ignore unrecognised flags
|
||||
- Defaults for options and positionals
|
||||
- Helper parsers for flag specs (`parseOptionFlags`) and positional specs (`parseArgumentSpec`)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseArgs } from "@suaveplan/cli/parser";
|
||||
|
||||
const result = parseArgs(["--verbose", "src/file.ts"], {
|
||||
options: { verbose: { type: "boolean" } },
|
||||
arguments: [{ name: "file", required: true, variadic: false }],
|
||||
});
|
||||
|
||||
console.log(result.options.verbose); // true
|
||||
console.log(result.positionals.file); // "src/file.ts"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseArgs, parseOptionFlags, parseArgumentSpec } from "@suaveplan/cli/parser";
|
||||
|
||||
const flag = parseOptionFlags("-o, --output <file>", "Output file");
|
||||
const posArg = parseArgumentSpec("<sources...>", "Source files");
|
||||
|
||||
const result = parseArgs(
|
||||
["-o", "out.txt", "--format=json", "a.ts", "b.ts", "--", "--not-a-flag"],
|
||||
{
|
||||
options: {
|
||||
[flag.name]: flag.config,
|
||||
format: { type: "string", choices: ["json", "text"], default: "text" },
|
||||
retries: { type: "number", default: 3 },
|
||||
},
|
||||
arguments: [posArg],
|
||||
}
|
||||
);
|
||||
|
||||
// result.options.output === "out.txt"
|
||||
// result.options.format === "json"
|
||||
// result.positionals.sources === ["a.ts", "b.ts"]
|
||||
// result.rest === ["--not-a-flag"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseArgs(argv, config?)`
|
||||
|
||||
- `argv: string[]` — raw CLI tokens (without the node/bun prefix)
|
||||
- `config?: ParserConfig` — optional options/arguments definitions, `allowUnknown`, `stopEarly`
|
||||
- Returns `ParsedArgs` with `options`, `positionals`, and `rest`
|
||||
- Throws `CliError` with codes `UNKNOWN_OPTION`, `MISSING_VALUE`, `INVALID_VALUE`, `MISSING_ARGUMENT`, `MISSING_REQUIRED_OPTION`
|
||||
|
||||
### `parseOptionFlags(flags, description?, defaultValue?)`
|
||||
|
||||
- `flags: string` — commander-style spec, e.g. `"-v, --verbose"` or `"--output <file>"`
|
||||
- Returns `{ name, config }` where `name` is the camelCased long-flag name
|
||||
- Throws `CliError(UNKNOWN_OPTION)` if no long flag is provided
|
||||
|
||||
### `parseArgumentSpec(spec, description?)`
|
||||
|
||||
- `spec: string` — `"<name>"`, `"[name]"`, `"<name...>"`, or `"[name...]"`
|
||||
- Returns an `ArgumentConfig` with `required`, `variadic`, `name`, and optional `description`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Single-pass state machine: tokens are consumed left-to-right with a small `restMode` flag that flips when `--` is encountered. There are no lookaheads beyond peeking at `argv[i + 1]` to decide whether a flag's value is the next token.
|
||||
- `--no-*` handling strips the literal `--no-` prefix (not the camelCased form) and then camelCases the remainder, so `--no-dry-run` correctly maps to the `dryRun` option.
|
||||
- Values are only consumed as the next token when that token does not start with `-`, preventing `--foo --bar` from stealing `--bar` as the value of `--foo`.
|
||||
- `number` type uses `Number(raw)` and rejects `NaN` so strings like `"abc"` raise `INVALID_VALUE` rather than silently becoming `NaN`.
|
||||
- All errors are `CliError` instances carrying `context` metadata (option name, offending value, allowed choices) so UIs and logs can render actionable diagnostics without re-parsing message strings.
|
||||
- Schemas mirror the TypeScript types exactly and are exported separately so callers can validate untrusted `ParserConfig` inputs at the boundary without pulling Zod into hot paths.
|
||||
38
packages/devtools/tooling/cli/src/parser/parser.schemas.ts
Normal file
38
packages/devtools/tooling/cli/src/parser/parser.schemas.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import z from "zod";
|
||||
|
||||
export const OptionTypeSchema = z.enum([
|
||||
"string",
|
||||
"boolean",
|
||||
"number",
|
||||
"array",
|
||||
]);
|
||||
|
||||
export const OptionConfigSchema = z.object({
|
||||
type: OptionTypeSchema.optional(),
|
||||
short: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
default: z.unknown().optional(),
|
||||
required: z.boolean().optional(),
|
||||
choices: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ArgumentConfigSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean(),
|
||||
variadic: z.boolean(),
|
||||
default: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const ParserConfigSchema = z.object({
|
||||
options: z.record(z.string(), OptionConfigSchema).optional(),
|
||||
arguments: z.array(ArgumentConfigSchema).optional(),
|
||||
allowUnknown: z.boolean().optional(),
|
||||
stopEarly: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ParsedArgsSchema = z.object({
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
positionals: z.record(z.string(), z.unknown()),
|
||||
rest: z.array(z.string()),
|
||||
});
|
||||
320
packages/devtools/tooling/cli/src/parser/parser.test.ts
Normal file
320
packages/devtools/tooling/cli/src/parser/parser.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { CLI_CODES, CliError } from "../errors/errors.js";
|
||||
import { parseArgs, parseArgumentSpec, parseOptionFlags } from "./parser.js";
|
||||
|
||||
describe("parseArgs", { timeout: 5000 }, () => {
|
||||
it("parses a boolean flag", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["--verbose"], {
|
||||
options: { verbose: { type: "boolean" } },
|
||||
});
|
||||
expect(result.options.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it("parses --no-* to set boolean false", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["--no-verbose"], {
|
||||
options: { verbose: { type: "boolean", default: true } },
|
||||
});
|
||||
expect(result.options.verbose).toBe(false);
|
||||
});
|
||||
|
||||
it("parses a string option with = syntax", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["--name=world"], {
|
||||
options: { name: { type: "string" } },
|
||||
});
|
||||
expect(result.options.name).toBe("world");
|
||||
});
|
||||
|
||||
it("parses a string option as next token", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["--output", "file.txt"], {
|
||||
options: { output: { type: "string" } },
|
||||
});
|
||||
expect(result.options.output).toBe("file.txt");
|
||||
});
|
||||
|
||||
it("parses a number option", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["--count", "42"], {
|
||||
options: { count: { type: "number" } },
|
||||
});
|
||||
expect(result.options.count).toBe(42);
|
||||
});
|
||||
|
||||
it("parses short flags", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["-v"], {
|
||||
options: { verbose: { type: "boolean", short: "v" } },
|
||||
});
|
||||
expect(result.options.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it("captures rest after --", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["--", "foo", "bar"]);
|
||||
expect(result.rest).toEqual(["foo", "bar"]);
|
||||
});
|
||||
|
||||
it("throws CliError(UNKNOWN_OPTION) for unknown flags", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => parseArgs(["--unknown"])).toThrow(CliError);
|
||||
expect(() => parseArgs(["--unknown"])).toThrow(
|
||||
expect.objectContaining({ code: CLI_CODES.PARSE.UNKNOWN_OPTION })
|
||||
);
|
||||
});
|
||||
|
||||
it("throws CliError(MISSING_VALUE) when value missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseArgs(["--name"], { options: { name: { type: "string" } } })
|
||||
).toThrow(
|
||||
expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_VALUE })
|
||||
);
|
||||
});
|
||||
|
||||
it("throws CliError(MISSING_ARGUMENT) for missing required positional", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseArgs([], {
|
||||
arguments: [{ name: "file", required: true, variadic: false }],
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_ARGUMENT })
|
||||
);
|
||||
});
|
||||
|
||||
it("parses variadic positional as array", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["a", "b", "c"], {
|
||||
arguments: [{ name: "files", required: true, variadic: true }],
|
||||
});
|
||||
expect(result.positionals.files).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("throws CliError(INVALID_VALUE) when value not in choices", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseArgs(["--format", "xml"], {
|
||||
options: {
|
||||
format: { type: "string", choices: ["json", "text"] },
|
||||
},
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({ code: CLI_CODES.PARSE.INVALID_VALUE })
|
||||
);
|
||||
});
|
||||
|
||||
it("applies defaults for missing options", { timeout: 5000 }, () => {
|
||||
const result = parseArgs([], {
|
||||
options: { verbose: { type: "boolean", default: false } },
|
||||
});
|
||||
expect(result.options.verbose).toBe(false);
|
||||
});
|
||||
|
||||
it("throws MISSING_REQUIRED_OPTION when required option is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseArgs([], {
|
||||
options: { token: { type: "string", required: true } },
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: CLI_CODES.PARSE.MISSING_REQUIRED_OPTION,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_VALUE for non-numeric number option", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseArgs(["--count", "abc"], {
|
||||
options: { count: { type: "number" } },
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({ code: CLI_CODES.PARSE.INVALID_VALUE })
|
||||
);
|
||||
});
|
||||
|
||||
it("parses non-flag tokens as positionals", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["src/file.ts"], {
|
||||
arguments: [{ name: "file", required: false, variadic: false }],
|
||||
});
|
||||
expect(result.positionals.file).toBe("src/file.ts");
|
||||
});
|
||||
|
||||
it("allowUnknown: true silently ignores unknown flags", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseArgs(["--unknown-flag"], { allowUnknown: true })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws UNKNOWN_OPTION for unknown short flag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => parseArgs(["-z"])).toThrow(
|
||||
expect.objectContaining({ code: CLI_CODES.PARSE.UNKNOWN_OPTION })
|
||||
);
|
||||
});
|
||||
|
||||
it("allowUnknown ignores unknown short flag", { timeout: 5000 }, () => {
|
||||
expect(() => parseArgs(["-z"], { allowUnknown: true })).not.toThrow();
|
||||
});
|
||||
|
||||
it("parses short flag with string value via next token", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseArgs(["-o", "out.txt"], {
|
||||
options: { output: { type: "string", short: "o" } },
|
||||
});
|
||||
expect(result.options.output).toBe("out.txt");
|
||||
});
|
||||
|
||||
it("throws MISSING_VALUE for short flag without value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseArgs(["-o"], {
|
||||
options: { output: { type: "string", short: "o" } },
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_VALUE })
|
||||
);
|
||||
});
|
||||
|
||||
it("uses default value for missing optional positional argument", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseArgs([], {
|
||||
arguments: [
|
||||
{
|
||||
name: "env",
|
||||
required: false,
|
||||
variadic: false,
|
||||
default: "development",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.positionals.env).toBe("development");
|
||||
});
|
||||
|
||||
it("treats --no-flag as unknown when base flag not defined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => parseArgs(["--no-unknown"])).toThrow(
|
||||
expect.objectContaining({ code: CLI_CODES.PARSE.UNKNOWN_OPTION })
|
||||
);
|
||||
});
|
||||
|
||||
it("boolean short flag without explicit type defaults to boolean", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseArgs(["-v"], {
|
||||
options: { verbose: { short: "v" } },
|
||||
});
|
||||
expect(result.options.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it("optional positional absent with no default is not in positionals", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseArgs([], {
|
||||
arguments: [{ name: "output", required: false, variadic: false }],
|
||||
});
|
||||
expect(result.positionals["output"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOptionFlags", { timeout: 5000 }, () => {
|
||||
it("extracts long flag name", { timeout: 5000 }, () => {
|
||||
const { name } = parseOptionFlags("--verbose");
|
||||
expect(name).toBe("verbose");
|
||||
});
|
||||
|
||||
it("extracts short flag", { timeout: 5000 }, () => {
|
||||
const { config } = parseOptionFlags("-v, --verbose");
|
||||
expect(config.short).toBe("v");
|
||||
});
|
||||
|
||||
it("marks as string type when value placeholder present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { config } = parseOptionFlags("--output <file>");
|
||||
expect(config.type).toBe("string");
|
||||
});
|
||||
|
||||
it("throws when no long flag provided", { timeout: 5000 }, () => {
|
||||
expect(() => parseOptionFlags("-v")).toThrow(CliError);
|
||||
});
|
||||
|
||||
it("sets description", { timeout: 5000 }, () => {
|
||||
const { config } = parseOptionFlags("--verbose", "Enable verbose mode");
|
||||
expect(config.description).toBe("Enable verbose mode");
|
||||
});
|
||||
|
||||
it("marks as string type for optional value placeholder [file]", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { config } = parseOptionFlags("--output [file]");
|
||||
expect(config.type).toBe("string");
|
||||
});
|
||||
|
||||
it("sets default value in config", { timeout: 5000 }, () => {
|
||||
const { config } = parseOptionFlags("--count", "count", 42);
|
||||
expect(config.default).toBe(42);
|
||||
});
|
||||
|
||||
it("ignores unrecognized parts in flags string", { timeout: 5000 }, () => {
|
||||
const { name } = parseOptionFlags("--name VALUE");
|
||||
expect(name).toBe("name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseArgumentSpec", { timeout: 5000 }, () => {
|
||||
it("marks required for <name>", { timeout: 5000 }, () => {
|
||||
expect(parseArgumentSpec("<file>").required).toBe(true);
|
||||
});
|
||||
|
||||
it("marks optional for [name]", { timeout: 5000 }, () => {
|
||||
expect(parseArgumentSpec("[file]").required).toBe(false);
|
||||
});
|
||||
|
||||
it("marks variadic for <files...>", { timeout: 5000 }, () => {
|
||||
expect(parseArgumentSpec("<files...>").variadic).toBe(true);
|
||||
});
|
||||
|
||||
it("sets name without brackets", { timeout: 5000 }, () => {
|
||||
expect(parseArgumentSpec("<file>").name).toBe("file");
|
||||
});
|
||||
|
||||
it("sets description", { timeout: 5000 }, () => {
|
||||
expect(parseArgumentSpec("<file>", "The input file").description).toBe(
|
||||
"The input file"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseArgs property tests", { timeout: 30000 }, () => {
|
||||
it("never throws with allowUnknown: true", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.oneof(
|
||||
fc.constant("--foo"),
|
||||
fc.constant("--bar=baz"),
|
||||
fc.constant("-x"),
|
||||
fc.string({ maxLength: 10 })
|
||||
),
|
||||
{ maxLength: 10 }
|
||||
),
|
||||
(argv) => {
|
||||
expect(() =>
|
||||
parseArgs(argv, { allowUnknown: true })
|
||||
).not.toThrow();
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
266
packages/devtools/tooling/cli/src/parser/parser.ts
Normal file
266
packages/devtools/tooling/cli/src/parser/parser.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { CLI_CODES, CliError } from "../errors/errors.js";
|
||||
import type {
|
||||
ArgumentConfig,
|
||||
OptionConfig,
|
||||
ParsedArgs,
|
||||
ParserConfig,
|
||||
} from "./parser.types.js";
|
||||
|
||||
function toCamelCase(str: string): string {
|
||||
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function parseOptionFlags(
|
||||
flags: string,
|
||||
description?: string,
|
||||
defaultValue?: unknown
|
||||
): { name: string; config: OptionConfig } {
|
||||
const parts = flags.split(/[\s,]+/).filter(Boolean);
|
||||
let longFlag: string | undefined;
|
||||
let shortFlag: string | undefined;
|
||||
let hasValue = false;
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.startsWith("--")) {
|
||||
longFlag = part.slice(2);
|
||||
} else if (part.startsWith("-") && part.length === 2) {
|
||||
shortFlag = part.slice(1);
|
||||
} else if (part.startsWith("<") || part.startsWith("[")) {
|
||||
hasValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (/<[^>]+>|\[[^\]]+\]/.test(flags)) {
|
||||
hasValue = true;
|
||||
}
|
||||
|
||||
if (!longFlag) {
|
||||
throw new CliError(
|
||||
CLI_CODES.PARSE.UNKNOWN_OPTION,
|
||||
"cli.parse.unknown_option",
|
||||
{ context: { flags } }
|
||||
);
|
||||
}
|
||||
|
||||
const name = toCamelCase(longFlag);
|
||||
const config: OptionConfig = {};
|
||||
if (hasValue) config.type = "string";
|
||||
if (shortFlag) config.short = shortFlag;
|
||||
if (description) config.description = description;
|
||||
if (defaultValue !== undefined) config.default = defaultValue;
|
||||
|
||||
return { name, config };
|
||||
}
|
||||
|
||||
export function parseArgumentSpec(
|
||||
spec: string,
|
||||
description?: string
|
||||
): ArgumentConfig {
|
||||
const required = spec.startsWith("<");
|
||||
const inner = spec.slice(1, -1);
|
||||
const variadic = inner.endsWith("...");
|
||||
const name = variadic ? inner.slice(0, -3) : inner;
|
||||
return {
|
||||
name,
|
||||
required,
|
||||
variadic,
|
||||
...(description ? { description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[], config?: ParserConfig): ParsedArgs {
|
||||
const optionDefs = config?.options ?? {};
|
||||
const argDefs = config?.arguments ?? [];
|
||||
const allowUnknown = config?.allowUnknown ?? false;
|
||||
|
||||
const shortMap: Record<string, string> = {};
|
||||
for (const [name, def] of Object.entries(optionDefs)) {
|
||||
if (def.short) shortMap[def.short] = name;
|
||||
}
|
||||
|
||||
const options: Record<string, unknown> = {};
|
||||
for (const [name, def] of Object.entries(optionDefs)) {
|
||||
if (def.default !== undefined) options[name] = def.default;
|
||||
}
|
||||
|
||||
const positionals: string[] = [];
|
||||
const rest: string[] = [];
|
||||
let i = 0;
|
||||
let restMode = false;
|
||||
|
||||
while (i < argv.length) {
|
||||
const arg = argv[i]!;
|
||||
|
||||
if (restMode) {
|
||||
rest.push(arg);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--") {
|
||||
restMode = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--")) {
|
||||
const eqIdx = arg.indexOf("=");
|
||||
let key: string;
|
||||
let inlineValue: string | undefined;
|
||||
|
||||
if (eqIdx !== -1) {
|
||||
key = toCamelCase(arg.slice(2, eqIdx));
|
||||
inlineValue = arg.slice(eqIdx + 1);
|
||||
} else {
|
||||
key = toCamelCase(arg.slice(2));
|
||||
}
|
||||
|
||||
// Handle --no-* boolean negation
|
||||
if (arg.startsWith("--no-")) {
|
||||
const rawKey = arg.slice(5);
|
||||
const camelRaw = toCamelCase(rawKey);
|
||||
if (camelRaw in optionDefs) {
|
||||
options[camelRaw] = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const def = optionDefs[key];
|
||||
if (!def) {
|
||||
if (allowUnknown) {
|
||||
if (
|
||||
inlineValue === undefined &&
|
||||
i + 1 < argv.length &&
|
||||
!argv[i + 1]!.startsWith("-")
|
||||
) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw new CliError(
|
||||
CLI_CODES.PARSE.UNKNOWN_OPTION,
|
||||
"cli.parse.unknown_option",
|
||||
{ context: { option: key } }
|
||||
);
|
||||
}
|
||||
|
||||
const type = def.type ?? "boolean";
|
||||
|
||||
if (type === "boolean") {
|
||||
options[key] = true;
|
||||
i++;
|
||||
} else {
|
||||
let raw: string;
|
||||
if (inlineValue !== undefined) {
|
||||
raw = inlineValue;
|
||||
i++;
|
||||
} else if (
|
||||
i + 1 < argv.length &&
|
||||
!argv[i + 1]!.startsWith("-")
|
||||
) {
|
||||
raw = argv[i + 1]!;
|
||||
i += 2;
|
||||
} else {
|
||||
throw new CliError(
|
||||
CLI_CODES.PARSE.MISSING_VALUE,
|
||||
"cli.parse.missing_value",
|
||||
{ context: { option: key } }
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "number") {
|
||||
const num = Number(raw);
|
||||
if (Number.isNaN(num)) {
|
||||
throw new CliError(
|
||||
CLI_CODES.PARSE.INVALID_VALUE,
|
||||
"cli.parse.invalid_value",
|
||||
{ context: { option: key, value: raw } }
|
||||
);
|
||||
}
|
||||
options[key] = num;
|
||||
} else if (def.choices && !def.choices.includes(raw)) {
|
||||
throw new CliError(
|
||||
CLI_CODES.PARSE.INVALID_VALUE,
|
||||
"cli.parse.invalid_value",
|
||||
{
|
||||
context: {
|
||||
option: key,
|
||||
value: raw,
|
||||
choices: def.choices,
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
options[key] = raw;
|
||||
}
|
||||
}
|
||||
} else if (arg.startsWith("-") && arg.length === 2) {
|
||||
const shortKey = arg.slice(1);
|
||||
const longName = shortMap[shortKey];
|
||||
if (!longName) {
|
||||
if (allowUnknown) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
throw new CliError(
|
||||
CLI_CODES.PARSE.UNKNOWN_OPTION,
|
||||
"cli.parse.unknown_option",
|
||||
{ context: { option: shortKey } }
|
||||
);
|
||||
}
|
||||
const def = optionDefs[longName]!;
|
||||
const type = def.type ?? "boolean";
|
||||
if (type === "boolean") {
|
||||
options[longName] = true;
|
||||
i++;
|
||||
} else {
|
||||
if (i + 1 >= argv.length) {
|
||||
throw new CliError(
|
||||
CLI_CODES.PARSE.MISSING_VALUE,
|
||||
"cli.parse.missing_value",
|
||||
{ context: { option: longName } }
|
||||
);
|
||||
}
|
||||
options[longName] = argv[i + 1];
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
const namedPositionals: Record<string, unknown> = {};
|
||||
let posIdx = 0;
|
||||
for (const def of argDefs) {
|
||||
if (def.variadic) {
|
||||
namedPositionals[def.name] = positionals.slice(posIdx);
|
||||
posIdx = positionals.length;
|
||||
} else if (posIdx < positionals.length) {
|
||||
namedPositionals[def.name] = positionals[posIdx++];
|
||||
} else if (def.default !== undefined) {
|
||||
namedPositionals[def.name] = def.default;
|
||||
} else if (def.required) {
|
||||
throw new CliError(
|
||||
CLI_CODES.PARSE.MISSING_ARGUMENT,
|
||||
"cli.parse.missing_argument",
|
||||
{ context: { argument: def.name } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, def] of Object.entries(optionDefs)) {
|
||||
if (def.required && !(name in options)) {
|
||||
throw new CliError(
|
||||
CLI_CODES.PARSE.MISSING_REQUIRED_OPTION,
|
||||
"cli.parse.missing_required_option",
|
||||
{ context: { option: name } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { options, positionals: namedPositionals, rest };
|
||||
}
|
||||
31
packages/devtools/tooling/cli/src/parser/parser.types.ts
Normal file
31
packages/devtools/tooling/cli/src/parser/parser.types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type OptionType = "string" | "boolean" | "number" | "array";
|
||||
|
||||
export interface OptionConfig {
|
||||
type?: OptionType;
|
||||
short?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
export interface ArgumentConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
variadic: boolean;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
export interface ParserConfig {
|
||||
options?: Record<string, OptionConfig>;
|
||||
arguments?: ArgumentConfig[];
|
||||
allowUnknown?: boolean;
|
||||
stopEarly?: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedArgs {
|
||||
options: Record<string, unknown>;
|
||||
positionals: Record<string, unknown>;
|
||||
rest: string[];
|
||||
}
|
||||
7
packages/devtools/tooling/cli/src/progress/index.ts
Normal file
7
packages/devtools/tooling/cli/src/progress/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { createProgress } from "./progress.js";
|
||||
export { ProgressOptionsSchema } from "./progress.schemas.js";
|
||||
export type {
|
||||
ProgressInstance,
|
||||
ProgressOptions,
|
||||
WritableStreamLike,
|
||||
} from "./progress.types.js";
|
||||
92
packages/devtools/tooling/cli/src/progress/progress.md
Normal file
92
packages/devtools/tooling/cli/src/progress/progress.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# progress
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createProgress` renders a fixed-width ASCII progress bar for tracking long-running operations with a known total. It writes bar updates to any `WritableStreamLike` output — TTY or non-TTY — using in-place line clearing on TTY terminals and newline-separated lines on non-TTY streams (CI, pipes). The bar updates are driven by explicit `update(current)` calls from the caller; no internal timer is used.
|
||||
|
||||
The instance is `AbortSignal`-aware: aborting the signal silently makes all subsequent `update()` calls no-ops, allowing calling code to cancel a long operation without crashing.
|
||||
|
||||
## Features
|
||||
|
||||
- Fixed-width block bar with configurable `width` (default 40 characters) using `█` fill and `░` empty characters
|
||||
- Percentage and `current/total` count displayed after the bar: `[████████████░░░░░░░░] 60% (60/100)`
|
||||
- TTY mode: clears the current line and re-renders in-place via `clearLine` / `cursorTo`
|
||||
- Non-TTY mode: writes each update on its own line for readable CI output
|
||||
- `render()` returns the bar string without writing to the stream — useful for embedding in larger output
|
||||
- `complete()` forces 100% and appends a newline; idempotent (safe to call multiple times)
|
||||
- `AbortSignal` support: abort event listener marks the instance done; pre-fired signal respected via guard check in `update()`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createProgress } from "@suaveplan/cli/progress/progress";
|
||||
|
||||
const p = createProgress({ total: 100 });
|
||||
|
||||
for (let i = 0; i <= 100; i++) {
|
||||
p.update(i);
|
||||
await sleep(20);
|
||||
}
|
||||
|
||||
p.complete();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createProgress } from "@suaveplan/cli/progress/progress";
|
||||
import { Writable } from "node:stream";
|
||||
|
||||
const controller = new AbortController();
|
||||
const p = createProgress({
|
||||
total: files.length,
|
||||
width: 60,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let processed = 0;
|
||||
for (const file of files) {
|
||||
if (controller.signal.aborted) break;
|
||||
await processFile(file);
|
||||
p.update(++processed);
|
||||
}
|
||||
|
||||
p.complete();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createProgress(opts: ProgressOptions): ProgressInstance`
|
||||
|
||||
Creates a progress bar instance. No output is written until `update()` or `complete()` is called.
|
||||
|
||||
**`opts.total: number`** — the maximum value (100%). Must be positive.
|
||||
|
||||
**`opts.width?: number`** — width of the bar in characters (default `40`).
|
||||
|
||||
**`opts.stream?: WritableStreamLike`** — output target (default `process.stdout`). TTY in-place rendering requires `clearLine` and `cursorTo` methods plus `isTTY === true`.
|
||||
|
||||
**`opts.signal?: AbortSignal`** — when aborted, future `update()` calls become no-ops and the bar stops advancing.
|
||||
|
||||
### `ProgressInstance`
|
||||
|
||||
**`update(current: number): void`** — updates the displayed progress to `current`. Values are clamped to `[0, total]`. No-op if `complete()` was already called or the signal was aborted.
|
||||
|
||||
**`complete(): void`** — writes a 100% bar with a trailing newline and marks the instance done. Idempotent.
|
||||
|
||||
**`render(): string`** — returns the current bar string without writing to the stream.
|
||||
|
||||
### `ProgressOptionsSchema`
|
||||
|
||||
Zod schema for `ProgressOptions` — validates `total` (positive number) and `width` (optional positive number). Excludes `stream` and `signal`.
|
||||
|
||||
### `WritableStreamLike`
|
||||
|
||||
Re-exported from `../spinner/spinner.types.js`. Any object with `write(text: string): void` satisfies this interface; `clearLine`, `cursorTo`, and `isTTY` are optional and enable TTY mode when present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Progress percentage is computed as `Math.min(1, current / total)` to handle over-total updates gracefully. The filled block count is `Math.round(pct * width)` — rounding avoids a full-bar display at 99% on small widths. The `complete()` guard (`done` flag) prevents double-writes when called multiple times. The `AbortSignal` listener uses `{ once: true }` so it self-removes after the first event.
|
||||
@@ -0,0 +1,6 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ProgressOptionsSchema = z.object({
|
||||
total: z.number().positive(),
|
||||
width: z.number().positive().optional(),
|
||||
});
|
||||
145
packages/devtools/tooling/cli/src/progress/progress.test.ts
Normal file
145
packages/devtools/tooling/cli/src/progress/progress.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Writable } from "node:stream";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createProgress } from "./progress.js";
|
||||
|
||||
type TtyWritable = Writable & {
|
||||
isTTY: boolean;
|
||||
clearLine: (dir: number) => void;
|
||||
cursorTo: (x: number) => void;
|
||||
};
|
||||
|
||||
function makeStream(): { stream: TtyWritable; output: () => string } {
|
||||
const chunks: string[] = [];
|
||||
const stream = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
chunks.push(chunk.toString());
|
||||
cb();
|
||||
},
|
||||
}) as TtyWritable;
|
||||
stream.isTTY = false;
|
||||
stream.clearLine = () => {};
|
||||
stream.cursorTo = () => {};
|
||||
return { stream, output: () => chunks.join("") };
|
||||
}
|
||||
|
||||
function makeTtyStream(): {
|
||||
stream: TtyWritable;
|
||||
output: () => string;
|
||||
cleared: () => number;
|
||||
} {
|
||||
const chunks: string[] = [];
|
||||
let clearCount = 0;
|
||||
const stream = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
chunks.push(chunk.toString());
|
||||
cb();
|
||||
},
|
||||
}) as TtyWritable;
|
||||
stream.isTTY = true;
|
||||
stream.clearLine = () => {
|
||||
clearCount++;
|
||||
};
|
||||
stream.cursorTo = () => {};
|
||||
return { stream, output: () => chunks.join(""), cleared: () => clearCount };
|
||||
}
|
||||
|
||||
describe("createProgress", { timeout: 5000 }, () => {
|
||||
it("render() returns a bar string for 0%", () => {
|
||||
const { stream } = makeStream();
|
||||
const p = createProgress({ total: 100, stream });
|
||||
expect(p.render()).toContain("0%");
|
||||
expect(p.render()).toContain("0/100");
|
||||
});
|
||||
|
||||
it("update() advances the bar and writes to stream", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const p = createProgress({ total: 100, stream });
|
||||
p.update(50);
|
||||
expect(output()).toContain("50%");
|
||||
expect(output()).toContain("50/100");
|
||||
});
|
||||
|
||||
it("update() clamps to total", () => {
|
||||
const { stream } = makeStream();
|
||||
const p = createProgress({ total: 100, stream });
|
||||
p.update(200);
|
||||
expect(p.render()).toContain("100%");
|
||||
});
|
||||
|
||||
it("update() clamps to 0 for negative values", () => {
|
||||
const { stream } = makeStream();
|
||||
const p = createProgress({ total: 100, stream });
|
||||
p.update(-10);
|
||||
expect(p.render()).toContain("0%");
|
||||
});
|
||||
|
||||
it("complete() writes 100% bar with newline", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const p = createProgress({ total: 100, stream });
|
||||
p.update(50);
|
||||
p.complete();
|
||||
expect(output()).toContain("100%");
|
||||
expect(output()).toContain("100/100");
|
||||
});
|
||||
|
||||
it("complete() is idempotent", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const p = createProgress({ total: 10, stream });
|
||||
p.complete();
|
||||
p.complete();
|
||||
const count = output().split("100%").length - 1;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("update() is no-op after complete()", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const p = createProgress({ total: 10, stream });
|
||||
p.complete();
|
||||
const before = output();
|
||||
p.update(5);
|
||||
expect(output()).toBe(before);
|
||||
});
|
||||
|
||||
it("respects custom width", () => {
|
||||
const { stream } = makeStream();
|
||||
const p = createProgress({ total: 100, width: 20, stream });
|
||||
const bar = p.render();
|
||||
const match = bar.match(/\[([█░]+)\]/);
|
||||
expect(match?.[1]?.length).toBe(20);
|
||||
});
|
||||
|
||||
it("aborts via AbortSignal — update becomes no-op", async () => {
|
||||
const { stream, output } = makeStream();
|
||||
const controller = new AbortController();
|
||||
const p = createProgress({
|
||||
total: 100,
|
||||
stream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
p.update(30);
|
||||
controller.abort();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const before = output();
|
||||
p.update(60);
|
||||
expect(output()).toBe(before);
|
||||
});
|
||||
|
||||
it("uses TTY clear on update", () => {
|
||||
const { stream, cleared } = makeTtyStream();
|
||||
const p = createProgress({ total: 100, stream });
|
||||
p.update(10);
|
||||
expect(cleared()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses TTY clear on complete", () => {
|
||||
const { stream, cleared } = makeTtyStream();
|
||||
const p = createProgress({ total: 100, stream });
|
||||
p.complete();
|
||||
expect(cleared()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("defaults stream to process.stdout when omitted", () => {
|
||||
const p = createProgress({ total: 100 });
|
||||
expect(p.render()).toContain("0%");
|
||||
});
|
||||
});
|
||||
77
packages/devtools/tooling/cli/src/progress/progress.ts
Normal file
77
packages/devtools/tooling/cli/src/progress/progress.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
ProgressInstance,
|
||||
ProgressOptions,
|
||||
WritableStreamLike,
|
||||
} from "./progress.types.js";
|
||||
|
||||
const DEFAULT_WIDTH = 40;
|
||||
const FILL_CHAR = "█";
|
||||
const EMPTY_CHAR = "░";
|
||||
|
||||
export function createProgress(opts: ProgressOptions): ProgressInstance {
|
||||
const { total } = opts;
|
||||
const width = opts.width ?? DEFAULT_WIDTH;
|
||||
const stream: WritableStreamLike = opts.stream ?? process.stdout;
|
||||
const isTTY = stream.isTTY ?? false;
|
||||
|
||||
let current = 0;
|
||||
let done = false;
|
||||
|
||||
function buildBar(value: number): string {
|
||||
const pct = Math.min(1, value / total);
|
||||
const filled = Math.round(pct * width);
|
||||
const empty = width - filled;
|
||||
const bar = FILL_CHAR.repeat(filled) + EMPTY_CHAR.repeat(empty);
|
||||
const percent = Math.round(pct * 100);
|
||||
return `[${bar}] ${percent}% (${value}/${total})`;
|
||||
}
|
||||
|
||||
function write(value: number): void {
|
||||
const line = buildBar(value);
|
||||
if (isTTY && stream.clearLine && stream.cursorTo) {
|
||||
stream.clearLine(0);
|
||||
stream.cursorTo(0);
|
||||
stream.write(line);
|
||||
} else {
|
||||
stream.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const instance: ProgressInstance = {
|
||||
render(): string {
|
||||
return buildBar(current);
|
||||
},
|
||||
|
||||
update(value: number): void {
|
||||
if (opts.signal?.aborted || done) return;
|
||||
current = Math.min(total, Math.max(0, value));
|
||||
write(current);
|
||||
},
|
||||
|
||||
complete(): void {
|
||||
if (done) return;
|
||||
done = true;
|
||||
current = total;
|
||||
const line = buildBar(total);
|
||||
if (isTTY && stream.clearLine && stream.cursorTo) {
|
||||
stream.clearLine(0);
|
||||
stream.cursorTo(0);
|
||||
stream.write(`${line}\n`);
|
||||
} else {
|
||||
stream.write(`${line}\n`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (opts.signal) {
|
||||
opts.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
done = true;
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
16
packages/devtools/tooling/cli/src/progress/progress.types.ts
Normal file
16
packages/devtools/tooling/cli/src/progress/progress.types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { WritableStreamLike } from "../spinner/spinner.types.js";
|
||||
|
||||
export type { WritableStreamLike };
|
||||
|
||||
export interface ProgressOptions {
|
||||
total: number;
|
||||
width?: number;
|
||||
stream?: WritableStreamLike;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ProgressInstance {
|
||||
update(current: number): void;
|
||||
complete(): void;
|
||||
render(): string;
|
||||
}
|
||||
22
packages/devtools/tooling/cli/src/prompt/index.ts
Normal file
22
packages/devtools/tooling/cli/src/prompt/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
confirm,
|
||||
createDefaultReader,
|
||||
multiselect,
|
||||
password,
|
||||
select,
|
||||
text,
|
||||
} from "./prompt.js";
|
||||
export {
|
||||
ConfirmPromptOptionsSchema,
|
||||
SelectChoiceSchema,
|
||||
TextPromptOptionsSchema,
|
||||
} from "./prompt.schemas.js";
|
||||
export type {
|
||||
ConfirmPromptOptions,
|
||||
InputReader,
|
||||
MultiSelectPromptOptions,
|
||||
PasswordPromptOptions,
|
||||
SelectChoice,
|
||||
SelectPromptOptions,
|
||||
TextPromptOptions,
|
||||
} from "./prompt.types.js";
|
||||
95
packages/devtools/tooling/cli/src/prompt/prompt.md
Normal file
95
packages/devtools/tooling/cli/src/prompt/prompt.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# prompt
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`prompt` provides a collection of pure, injectable interactive prompt functions for CLI applications: `text`, `password`, `confirm`, `select`, and `multiselect`. All five functions accept an `InputReader` as their last argument for testing and embedding — callers supply any object with `question(prompt): Promise<string>` and `close(): void`. In production, `createDefaultReader()` builds an `InputReader` backed by `@suaveplan/readline/interface`, which in turn reads from `process.stdin`.
|
||||
|
||||
Prompt functions are asynchronous, loop-safe, and validation-aware. They re-prompt on invalid input rather than throwing, keeping the user experience coherent across all terminal types.
|
||||
|
||||
## Features
|
||||
|
||||
- `text(opts, reader?)` — free-form text input with optional default and validation callback
|
||||
- `password(opts, reader?)` — masked input (structurally identical to text; masking is delegated to the terminal emulator or TTY mode)
|
||||
- `confirm(opts, reader?)` — yes/no boolean with optional default; accepts `y`, `yes`, `n`, `no` (case-insensitive)
|
||||
- `select(opts, reader?)` — numbered single-choice list; re-prompts on out-of-range input
|
||||
- `multiselect(opts, reader?)` — comma-separated multi-choice with configurable `min`/`max` and deduplication
|
||||
- `createDefaultReader()` — async factory returning an `InputReader` wired to `process.stdin` via `@suaveplan/readline`
|
||||
- All functions accept both a plain `string` message or a full options object for brevity
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { confirm, select, text } from "@suaveplan/cli/prompt/prompt";
|
||||
|
||||
const name = await text("Your name?");
|
||||
const confirmed = await confirm({ message: "Continue?", default: true });
|
||||
const role = await select({ message: "Pick a role:", choices: ["admin", "user", "guest"] });
|
||||
|
||||
console.log(name, confirmed, role);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { multiselect, text } from "@suaveplan/cli/prompt/prompt";
|
||||
import type { InputReader } from "@suaveplan/cli/prompt/prompt";
|
||||
|
||||
// Inject a reader for testing
|
||||
function mockReader(lines: string[]): InputReader {
|
||||
let i = 0;
|
||||
return { question: async () => lines[i++] ?? "", close: () => {} };
|
||||
}
|
||||
|
||||
const username = await text(
|
||||
{ message: "Username?", validate: (v) => v.length >= 3 || "at least 3 chars" },
|
||||
mockReader(["ab", "alice"]),
|
||||
);
|
||||
|
||||
const features = await multiselect(
|
||||
{ message: "Enable features:", choices: ["logging", "metrics", "tracing"], min: 1 },
|
||||
mockReader(["1,2"]),
|
||||
);
|
||||
|
||||
console.log(username, features);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `text(opts: TextPromptOptions | string, reader?: InputReader): Promise<string>`
|
||||
|
||||
Prompts for free-form text. When `opts` is a string it is used as the `message`. Returns the user's input or `opts.default` if the input is empty and a default is set. Re-prompts if `opts.validate` returns an error string.
|
||||
|
||||
### `password(opts: PasswordPromptOptions | string, reader?: InputReader): Promise<string>`
|
||||
|
||||
Identical interface to `text` but semantically for secret input. The caller is responsible for disabling echo on the actual terminal if needed.
|
||||
|
||||
### `confirm(opts: ConfirmPromptOptions | string, reader?: InputReader): Promise<boolean>`
|
||||
|
||||
Prompts for a yes/no answer. Accepts `y`, `yes`, `n`, `no` (case-insensitive). Returns `opts.default` when the user submits an empty line and a default is configured. Re-prompts on unrecognized input.
|
||||
|
||||
### `select<T>(opts: SelectPromptOptions<T>, reader?: InputReader): Promise<T>`
|
||||
|
||||
Displays a numbered list of choices and returns the `value` of the selected entry. Choices may be plain strings (label = value) or `SelectChoice<T>` objects. Re-prompts when the entered number is out of range.
|
||||
|
||||
### `multiselect<T>(opts: MultiSelectPromptOptions<T>, reader?: InputReader): Promise<T[]>`
|
||||
|
||||
Displays a numbered list and accepts comma-separated indices. Deduplicates repeated indices. Enforces `opts.min` / `opts.max` bounds and re-prompts when any index is invalid or the count is out of bounds.
|
||||
|
||||
### `createDefaultReader(): Promise<InputReader>`
|
||||
|
||||
Async factory that lazily imports `@suaveplan/readline/interface` and wires it to `process.stdin` / `process.stdout`. Returns an `InputReader`. The caller is responsible for calling `close()` when done.
|
||||
|
||||
### `InputReader`
|
||||
|
||||
Injectable interface: `{ question(prompt: string): Promise<string>; close(): void; }`. Pass a mock for tests.
|
||||
|
||||
### `TextPromptOptionsSchema`, `ConfirmPromptOptionsSchema`, `SelectChoiceSchema`
|
||||
|
||||
Zod schemas for the corresponding option types — useful for config-file driven prompts.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All prompt functions manage the reader lifecycle when no reader is injected: they call `createDefaultReader()` in a `try/finally` block that calls `close()` after the prompt resolves. When a reader is injected, the caller owns the lifecycle. Validation callbacks return either `true` (pass) or an error string (fail); the error is written to `process.stderr` before re-prompting. The `multiselect` function considers any out-of-range index a hard error and re-prompts rather than silently discarding it.
|
||||
17
packages/devtools/tooling/cli/src/prompt/prompt.schemas.ts
Normal file
17
packages/devtools/tooling/cli/src/prompt/prompt.schemas.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import z from "zod";
|
||||
|
||||
export const TextPromptOptionsSchema = z.object({
|
||||
message: z.string(),
|
||||
default: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ConfirmPromptOptionsSchema = z.object({
|
||||
message: z.string(),
|
||||
default: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const SelectChoiceSchema = z.object({
|
||||
label: z.string(),
|
||||
value: z.unknown(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
201
packages/devtools/tooling/cli/src/prompt/prompt.test.ts
Normal file
201
packages/devtools/tooling/cli/src/prompt/prompt.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
_setReaderFactory,
|
||||
confirm,
|
||||
createDefaultReader,
|
||||
multiselect,
|
||||
password,
|
||||
select,
|
||||
text,
|
||||
} from "./prompt.js";
|
||||
import type { InputReader } from "./prompt.types.js";
|
||||
|
||||
function makeReader(lines: string[]): InputReader {
|
||||
let idx = 0;
|
||||
return {
|
||||
question: async (_prompt: string) => lines[idx++] ?? "",
|
||||
close: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("withReader — no reader provided path", { timeout: 5000 }, () => {
|
||||
it("creates and closes reader when no reader arg given", async () => {
|
||||
let closed = false;
|
||||
const fakeReader: InputReader = {
|
||||
question: async () => "Alice",
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
const restore = _setReaderFactory(async () => fakeReader);
|
||||
try {
|
||||
const result = await text("Name?");
|
||||
expect(result).toBe("Alice");
|
||||
expect(closed).toBe(true);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDefaultReader()", { timeout: 5000 }, () => {
|
||||
it("returns an InputReader with question and close methods", async () => {
|
||||
const reader = await createDefaultReader();
|
||||
expect(typeof reader.question).toBe("function");
|
||||
expect(typeof reader.close).toBe("function");
|
||||
// Call question() and close immediately to exercise the function body;
|
||||
// race with a timeout so the test never blocks on stdin
|
||||
const result = await Promise.race([
|
||||
reader.question("?").catch(() => "closed"),
|
||||
new Promise<string>((r) => setTimeout(() => r("timeout"), 50)),
|
||||
]);
|
||||
reader.close();
|
||||
expect(typeof result).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("text()", { timeout: 5000 }, () => {
|
||||
it("returns user input", async () => {
|
||||
expect(await text("Name?", makeReader(["Alice"]))).toBe("Alice");
|
||||
});
|
||||
|
||||
it("returns default when input is empty", async () => {
|
||||
expect(
|
||||
await text({ message: "Name?", default: "Bob" }, makeReader([""]))
|
||||
).toBe("Bob");
|
||||
});
|
||||
|
||||
it("re-prompts when validation fails then passes", async () => {
|
||||
const result = await text(
|
||||
{
|
||||
message: "Age?",
|
||||
validate: (v) => (Number(v) > 0 ? true : "must be positive"),
|
||||
},
|
||||
makeReader(["-1", "25"])
|
||||
);
|
||||
expect(result).toBe("25");
|
||||
});
|
||||
|
||||
it("returns answer when no default and empty input", async () => {
|
||||
expect(await text({ message: "Name?" }, makeReader([""]))).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("password()", { timeout: 5000 }, () => {
|
||||
it("returns user input", async () => {
|
||||
expect(await password("Pass?", makeReader(["secret"]))).toBe("secret");
|
||||
});
|
||||
|
||||
it("re-prompts when validation fails then passes", async () => {
|
||||
const result = await password(
|
||||
{
|
||||
message: "Pass?",
|
||||
validate: (v) => (v.length >= 6 ? true : "too short"),
|
||||
},
|
||||
makeReader(["abc", "longpass"])
|
||||
);
|
||||
expect(result).toBe("longpass");
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirm()", { timeout: 5000 }, () => {
|
||||
it("returns true for 'y'", async () => {
|
||||
expect(await confirm("Continue?", makeReader(["y"]))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 'yes'", async () => {
|
||||
expect(await confirm("Continue?", makeReader(["yes"]))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for 'n'", async () => {
|
||||
expect(await confirm("Continue?", makeReader(["n"]))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for 'no'", async () => {
|
||||
expect(await confirm("Continue?", makeReader(["no"]))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns default true when empty", async () => {
|
||||
expect(
|
||||
await confirm({ message: "Go?", default: true }, makeReader([""]))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns default false when empty", async () => {
|
||||
expect(
|
||||
await confirm({ message: "Go?", default: false }, makeReader([""]))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("re-prompts on invalid input then accepts valid", async () => {
|
||||
expect(await confirm("Go?", makeReader(["maybe", "y"]))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("select()", { timeout: 5000 }, () => {
|
||||
it("returns the selected choice value", async () => {
|
||||
const result = await select(
|
||||
{ message: "Pick:", choices: ["a", "b", "c"] },
|
||||
makeReader(["2"])
|
||||
);
|
||||
expect(result).toBe("b");
|
||||
});
|
||||
|
||||
it("re-prompts on invalid selection", async () => {
|
||||
const result = await select(
|
||||
{ message: "Pick:", choices: ["x", "y"] },
|
||||
makeReader(["99", "1"])
|
||||
);
|
||||
expect(result).toBe("x");
|
||||
});
|
||||
|
||||
it("handles object choices", async () => {
|
||||
const result = await select(
|
||||
{ message: "Pick:", choices: [{ label: "Option A", value: 42 }] },
|
||||
makeReader(["1"])
|
||||
);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiselect()", { timeout: 5000 }, () => {
|
||||
it("returns selected values", async () => {
|
||||
const result = await multiselect(
|
||||
{ message: "Pick:", choices: ["a", "b", "c"] },
|
||||
makeReader(["1,3"])
|
||||
);
|
||||
expect(result).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
it("enforces min selection", async () => {
|
||||
const result = await multiselect(
|
||||
{ message: "Pick:", choices: ["a", "b", "c"], min: 2 },
|
||||
makeReader(["1", "1,2"])
|
||||
);
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("enforces max selection", async () => {
|
||||
const result = await multiselect(
|
||||
{ message: "Pick:", choices: ["a", "b", "c"], max: 1 },
|
||||
makeReader(["1,2", "1"])
|
||||
);
|
||||
expect(result.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("deduplicates repeated indices", async () => {
|
||||
const result = await multiselect(
|
||||
{ message: "Pick:", choices: ["a", "b"] },
|
||||
makeReader(["1,1,1"])
|
||||
);
|
||||
expect(result).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("re-prompts on invalid indices", async () => {
|
||||
const result = await multiselect(
|
||||
{ message: "Pick:", choices: ["a", "b"] },
|
||||
makeReader(["99", "1"])
|
||||
);
|
||||
expect(result).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
189
packages/devtools/tooling/cli/src/prompt/prompt.ts
Normal file
189
packages/devtools/tooling/cli/src/prompt/prompt.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
ConfirmPromptOptions,
|
||||
InputReader,
|
||||
MultiSelectPromptOptions,
|
||||
PasswordPromptOptions,
|
||||
SelectChoice,
|
||||
SelectPromptOptions,
|
||||
TextPromptOptions,
|
||||
} from "./prompt.types.js";
|
||||
|
||||
function normalizeChoice<T>(choice: SelectChoice<T> | string): SelectChoice<T> {
|
||||
if (typeof choice === "string")
|
||||
return { label: choice, value: choice as unknown as T };
|
||||
return choice;
|
||||
}
|
||||
|
||||
export async function createDefaultReader(): Promise<InputReader> {
|
||||
const { createInterface } = await import(
|
||||
"@suaveplan/readline/interface/interface"
|
||||
);
|
||||
const { stdin, stdout } = process;
|
||||
const input = stdin as Parameters<typeof createInterface>[0]["input"];
|
||||
const output = stdout as NonNullable<
|
||||
Parameters<typeof createInterface>[0]["output"]
|
||||
>;
|
||||
const rl = createInterface({ input, output });
|
||||
return {
|
||||
question: (prompt: string) => rl.question(prompt),
|
||||
close: () => rl.close(),
|
||||
};
|
||||
}
|
||||
|
||||
let _readerFactory: () => Promise<InputReader> = createDefaultReader;
|
||||
|
||||
/** @internal Override the default reader factory (for testing). */
|
||||
export function _setReaderFactory(
|
||||
factory: () => Promise<InputReader>
|
||||
): () => void {
|
||||
_readerFactory = factory;
|
||||
return () => {
|
||||
_readerFactory = createDefaultReader;
|
||||
};
|
||||
}
|
||||
|
||||
async function withReader<T>(
|
||||
reader: InputReader | undefined,
|
||||
fn: (rl: InputReader) => Promise<T>
|
||||
): Promise<T> {
|
||||
if (reader) return fn(reader);
|
||||
const rl = await _readerFactory();
|
||||
try {
|
||||
return await fn(rl);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function text(
|
||||
opts: TextPromptOptions | string,
|
||||
reader?: InputReader
|
||||
): Promise<string> {
|
||||
const options: TextPromptOptions =
|
||||
typeof opts === "string" ? { message: opts } : opts;
|
||||
return withReader(reader, async (rl) => {
|
||||
while (true) {
|
||||
const answer = await rl.question(`${options.message} `);
|
||||
const value =
|
||||
answer === "" && options.default !== undefined
|
||||
? options.default
|
||||
: answer;
|
||||
if (options.validate) {
|
||||
const result = options.validate(value);
|
||||
if (result !== true) {
|
||||
process.stderr.write(`${result}\n`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function password(
|
||||
opts: PasswordPromptOptions | string,
|
||||
reader?: InputReader
|
||||
): Promise<string> {
|
||||
const options: PasswordPromptOptions =
|
||||
typeof opts === "string" ? { message: opts } : opts;
|
||||
return withReader(reader, async (rl) => {
|
||||
while (true) {
|
||||
const value = await rl.question(`${options.message} `);
|
||||
if (options.validate) {
|
||||
const result = options.validate(value);
|
||||
if (result !== true) {
|
||||
process.stderr.write(`${result}\n`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirm(
|
||||
opts: ConfirmPromptOptions | string,
|
||||
reader?: InputReader
|
||||
): Promise<boolean> {
|
||||
const options: ConfirmPromptOptions =
|
||||
typeof opts === "string" ? { message: opts } : opts;
|
||||
return withReader(reader, async (rl) => {
|
||||
while (true) {
|
||||
const hint =
|
||||
options.default === true
|
||||
? " [Y/n]"
|
||||
: options.default === false
|
||||
? " [y/N]"
|
||||
: " [y/n]";
|
||||
const answer = await rl.question(`${options.message}${hint} `);
|
||||
if (answer === "" && options.default !== undefined)
|
||||
return options.default;
|
||||
if (/^y(es)?$/i.test(answer)) return true;
|
||||
if (/^no?$/i.test(answer)) return false;
|
||||
process.stderr.write("Please enter y or n.\n");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function select<T = string>(
|
||||
opts: SelectPromptOptions<T>,
|
||||
reader?: InputReader
|
||||
): Promise<T> {
|
||||
const choices = opts.choices.map(normalizeChoice<T>);
|
||||
return withReader(reader, async (rl) => {
|
||||
while (true) {
|
||||
const list = choices
|
||||
.map((c, i) => ` ${i + 1}) ${c.label}`)
|
||||
.join("\n");
|
||||
const answer = await rl.question(
|
||||
`${opts.message}\n${list}\nChoice: `
|
||||
);
|
||||
const index = Number.parseInt(answer, 10);
|
||||
if (!Number.isNaN(index) && index >= 1 && index <= choices.length) {
|
||||
return choices[index - 1]!.value;
|
||||
}
|
||||
process.stderr.write(
|
||||
`Please enter a number between 1 and ${choices.length}.\n`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function multiselect<T = string>(
|
||||
opts: MultiSelectPromptOptions<T>,
|
||||
reader?: InputReader
|
||||
): Promise<T[]> {
|
||||
const choices = opts.choices.map(normalizeChoice<T>);
|
||||
const min = opts.min ?? 0;
|
||||
const max = opts.max ?? choices.length;
|
||||
return withReader(reader, async (rl) => {
|
||||
while (true) {
|
||||
const list = choices
|
||||
.map((c, i) => ` ${i + 1}) ${c.label}`)
|
||||
.join("\n");
|
||||
const answer = await rl.question(
|
||||
`${opts.message}\n${list}\nChoices (comma-separated): `
|
||||
);
|
||||
const parsed = answer
|
||||
.split(",")
|
||||
.map((s) => Number.parseInt(s.trim(), 10));
|
||||
const valid = parsed.filter(
|
||||
(n) => !Number.isNaN(n) && n >= 1 && n <= choices.length
|
||||
);
|
||||
if (valid.length !== parsed.length) {
|
||||
process.stderr.write(
|
||||
`Please enter numbers between 1 and ${choices.length}.\n`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const unique = [...new Set(valid)];
|
||||
if (unique.length < min || unique.length > max) {
|
||||
process.stderr.write(
|
||||
`Please select between ${min} and ${max} items.\n`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return unique.map((i) => choices[i - 1]!.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
39
packages/devtools/tooling/cli/src/prompt/prompt.types.ts
Normal file
39
packages/devtools/tooling/cli/src/prompt/prompt.types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface InputReader {
|
||||
question(prompt: string): Promise<string>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface TextPromptOptions {
|
||||
message: string;
|
||||
default?: string;
|
||||
validate?: (value: string) => true | string;
|
||||
}
|
||||
|
||||
export interface PasswordPromptOptions {
|
||||
message: string;
|
||||
validate?: (value: string) => true | string;
|
||||
}
|
||||
|
||||
export interface ConfirmPromptOptions {
|
||||
message: string;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectChoice<T = string> {
|
||||
label: string;
|
||||
value: T;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SelectPromptOptions<T = string> {
|
||||
message: string;
|
||||
choices: Array<SelectChoice<T> | string>;
|
||||
default?: T;
|
||||
}
|
||||
|
||||
export interface MultiSelectPromptOptions<T = string> {
|
||||
message: string;
|
||||
choices: Array<SelectChoice<T> | string>;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
7
packages/devtools/tooling/cli/src/spinner/index.ts
Normal file
7
packages/devtools/tooling/cli/src/spinner/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { createSpinner } from "./spinner.js";
|
||||
export { SpinnerOptionsSchema } from "./spinner.schemas.js";
|
||||
export type {
|
||||
SpinnerInstance,
|
||||
SpinnerOptions,
|
||||
WritableStreamLike,
|
||||
} from "./spinner.types.js";
|
||||
99
packages/devtools/tooling/cli/src/spinner/spinner.md
Normal file
99
packages/devtools/tooling/cli/src/spinner/spinner.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# spinner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createSpinner` renders an animated terminal spinner for long-running operations. It writes a spinning frame animation to any `WritableStreamLike` output — TTY or non-TTY — and exposes a chainable API to start, stop, succeed, fail, or warn when the operation resolves. The spinner integrates with `AbortSignal` so it can be cancelled programmatically without leaking timers.
|
||||
|
||||
The spinner is entirely synchronous after creation. No promises are used internally — the animation is driven by a `setInterval` timer that is always cancelled on `stop()`.
|
||||
|
||||
## Features
|
||||
|
||||
- Animated frame loop with configurable frames array and interval (default 80ms, 10-frame braille spinner)
|
||||
- Chainable API: `start().succeed()`, `start().fail()`, `start().warn()`, `start().stop()`
|
||||
- TTY mode: clears current line and re-renders in-place via `clearLine`/`cursorTo`
|
||||
- Non-TTY mode (CI/pipes): writes each frame on its own line — no escape codes
|
||||
- `AbortSignal` support: live abort event listener (`"abort"`) stops the spinner; pre-fired abort prevents `start()` entirely
|
||||
- `succeed` / `fail` / `warn` write a terminal symbol (`✔` / `✖` / `⚠`) followed by the text
|
||||
- `update(text)` changes the displayed text without restarting the animation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createSpinner } from "@suaveplan/cli/spinner/spinner";
|
||||
|
||||
const sp = createSpinner({ text: "Fetching data…" });
|
||||
sp.start();
|
||||
await fetchData();
|
||||
sp.succeed("Done!");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createSpinner } from "@suaveplan/cli/spinner/spinner";
|
||||
|
||||
const controller = new AbortController();
|
||||
const sp = createSpinner({
|
||||
text: "Building…",
|
||||
frames: ["-", "\\", "|", "/"],
|
||||
interval: 120,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
sp.start();
|
||||
setTimeout(() => controller.abort(), 5000); // cancel after 5 s
|
||||
|
||||
try {
|
||||
await runBuild();
|
||||
sp.succeed("Build complete");
|
||||
} catch {
|
||||
sp.fail("Build failed");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createSpinner(opts?: SpinnerOptions): SpinnerInstance`
|
||||
|
||||
Creates and returns a spinner instance. No animation starts until `start()` is called.
|
||||
|
||||
**`opts.text?: string`** — initial text displayed next to the spinner frame (default `""`).
|
||||
|
||||
**`opts.frames?: string[]`** — animation frame sequence (default: 10-step braille pattern).
|
||||
|
||||
**`opts.interval?: number`** — milliseconds between frame advances (default `80`).
|
||||
|
||||
**`opts.stream?: WritableStreamLike`** — output target (default `process.stdout`). Any object with a `write()` method is accepted; TTY-specific methods (`clearLine`, `cursorTo`) are optional.
|
||||
|
||||
**`opts.signal?: AbortSignal`** — when aborted, calls `stop()` immediately. If already aborted at construction time, `start()` becomes a no-op.
|
||||
|
||||
### `SpinnerInstance`
|
||||
|
||||
**`isSpinning: boolean`** — `true` between `start()` and `stop()`.
|
||||
|
||||
**`start(text?: string): SpinnerInstance`** — begins animation. If a `text` argument is provided, updates the displayed text before starting. No-op if already spinning or if the signal is pre-aborted.
|
||||
|
||||
**`stop(): SpinnerInstance`** — stops animation and clears the line (TTY only). No-op if not spinning.
|
||||
|
||||
**`succeed(text?: string): SpinnerInstance`** — calls `stop()` then writes `✔ <text>` (falls back to current text).
|
||||
|
||||
**`fail(text?: string): SpinnerInstance`** — calls `stop()` then writes `✖ <text>`.
|
||||
|
||||
**`warn(text?: string): SpinnerInstance`** — calls `stop()` then writes `⚠ <text>`.
|
||||
|
||||
**`update(text: string): SpinnerInstance`** — replaces the displayed text without interrupting animation.
|
||||
|
||||
### `SpinnerOptionsSchema`
|
||||
|
||||
Zod schema for `SpinnerOptions` — validates `text`, `frames`, and `interval` (excludes `stream` and `signal` which are not Zod-serialisable).
|
||||
|
||||
### `WritableStreamLike`
|
||||
|
||||
Minimal interface for the `stream` option: any object with `write(text: string): void`. The optional `clearLine`, `cursorTo`, and `isTTY` members enable TTY in-place rendering when present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The TTY branch detects `stream.isTTY === true` at construction time and uses `clearLine(0)` + `cursorTo(0)` + `write(frame + " " + text)` on each tick. The non-TTY branch appends a newline to each frame write so CI logs remain readable. The `setInterval` is stored and cleared in `stop()` to prevent timer leaks; calling `stop()` when not spinning is a safe no-op. The `AbortSignal` listener uses `{ once: true }` so it self-removes after the first abort event.
|
||||
@@ -0,0 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const SpinnerOptionsSchema = z.object({
|
||||
text: z.string().optional(),
|
||||
frames: z.array(z.string()).optional(),
|
||||
interval: z.number().positive().optional(),
|
||||
});
|
||||
263
packages/devtools/tooling/cli/src/spinner/spinner.test.ts
Normal file
263
packages/devtools/tooling/cli/src/spinner/spinner.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { Writable } from "node:stream";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createSpinner } from "./spinner.js";
|
||||
|
||||
type TtyWritable = Writable & {
|
||||
isTTY: boolean;
|
||||
clearLine: (dir: number) => void;
|
||||
cursorTo: (x: number) => void;
|
||||
};
|
||||
|
||||
function makeStream(): { stream: TtyWritable; output: () => string } {
|
||||
const chunks: string[] = [];
|
||||
const stream = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
chunks.push(chunk.toString());
|
||||
cb();
|
||||
},
|
||||
}) as TtyWritable;
|
||||
stream.isTTY = false;
|
||||
stream.clearLine = () => {};
|
||||
stream.cursorTo = () => {};
|
||||
return { stream, output: () => chunks.join("") };
|
||||
}
|
||||
|
||||
function makeTtyStream(): {
|
||||
stream: TtyWritable;
|
||||
output: () => string;
|
||||
cleared: () => number;
|
||||
} {
|
||||
const chunks: string[] = [];
|
||||
let clearCount = 0;
|
||||
const stream = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
chunks.push(chunk.toString());
|
||||
cb();
|
||||
},
|
||||
}) as TtyWritable;
|
||||
stream.isTTY = true;
|
||||
stream.clearLine = () => {
|
||||
clearCount++;
|
||||
};
|
||||
stream.cursorTo = () => {};
|
||||
return { stream, output: () => chunks.join(""), cleared: () => clearCount };
|
||||
}
|
||||
|
||||
describe("createSpinner", { timeout: 5000 }, () => {
|
||||
it("starts and is spinning", () => {
|
||||
const { stream } = makeStream();
|
||||
const sp = createSpinner({ text: "Loading", stream });
|
||||
sp.start();
|
||||
expect(sp.isSpinning).toBe(true);
|
||||
sp.stop();
|
||||
});
|
||||
|
||||
it("is not spinning after stop()", () => {
|
||||
const { stream } = makeStream();
|
||||
const sp = createSpinner({ text: "x", stream });
|
||||
sp.start().stop();
|
||||
expect(sp.isSpinning).toBe(false);
|
||||
});
|
||||
|
||||
it("writes to non-TTY stream on start", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const sp = createSpinner({ text: "Working", stream });
|
||||
sp.start();
|
||||
expect(output()).toContain("Working");
|
||||
sp.stop();
|
||||
});
|
||||
|
||||
it("succeed writes success symbol", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const sp = createSpinner({ text: "done", stream });
|
||||
sp.start().succeed("Completed");
|
||||
expect(output()).toContain("Completed");
|
||||
expect(output()).toContain("✔");
|
||||
});
|
||||
|
||||
it("fail writes fail symbol", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const sp = createSpinner({ text: "task", stream });
|
||||
sp.start().fail("Failed");
|
||||
expect(output()).toContain("Failed");
|
||||
expect(output()).toContain("✖");
|
||||
});
|
||||
|
||||
it("warn writes warn symbol", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const sp = createSpinner({ text: "task", stream });
|
||||
sp.start().warn("Warning");
|
||||
expect(output()).toContain("Warning");
|
||||
expect(output()).toContain("⚠");
|
||||
});
|
||||
|
||||
it("update changes the spinner text", () => {
|
||||
const { stream } = makeStream();
|
||||
const sp = createSpinner({ text: "old", stream });
|
||||
sp.update("new");
|
||||
sp.start();
|
||||
sp.stop();
|
||||
expect(sp.isSpinning).toBe(false);
|
||||
});
|
||||
|
||||
it("aborts via AbortSignal", async () => {
|
||||
const { stream } = makeStream();
|
||||
const controller = new AbortController();
|
||||
const sp = createSpinner({
|
||||
text: "work",
|
||||
stream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
sp.start();
|
||||
controller.abort();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(sp.isSpinning).toBe(false);
|
||||
});
|
||||
|
||||
it("start() is a no-op when signal is already aborted", () => {
|
||||
const { stream } = makeStream();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const sp = createSpinner({
|
||||
text: "work",
|
||||
stream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
sp.start();
|
||||
expect(sp.isSpinning).toBe(false);
|
||||
});
|
||||
|
||||
it("stop() is a no-op when not spinning", () => {
|
||||
const { stream } = makeStream();
|
||||
const sp = createSpinner({ text: "x", stream });
|
||||
expect(() => sp.stop()).not.toThrow();
|
||||
expect(sp.isSpinning).toBe(false);
|
||||
});
|
||||
|
||||
it("start() is a no-op when already spinning", () => {
|
||||
const { stream } = makeStream();
|
||||
const sp = createSpinner({ text: "x", stream });
|
||||
sp.start();
|
||||
sp.start();
|
||||
expect(sp.isSpinning).toBe(true);
|
||||
sp.stop();
|
||||
});
|
||||
|
||||
it("clears line on TTY stop", () => {
|
||||
const { stream, cleared } = makeTtyStream();
|
||||
const sp = createSpinner({ text: "tty", stream });
|
||||
sp.start();
|
||||
sp.stop();
|
||||
expect(cleared()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders TTY frame on start", () => {
|
||||
const { stream, output } = makeTtyStream();
|
||||
const sp = createSpinner({ text: "tty-work", stream });
|
||||
sp.start();
|
||||
expect(output()).toContain("tty-work");
|
||||
sp.stop();
|
||||
});
|
||||
|
||||
it("succeed/fail/warn use current text when no arg given", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const sp = createSpinner({ text: "mytask", stream });
|
||||
sp.start().succeed();
|
||||
expect(output()).toContain("mytask");
|
||||
});
|
||||
|
||||
it("interval callback advances the frame", async () => {
|
||||
const { stream, output } = makeTtyStream();
|
||||
const sp = createSpinner({
|
||||
text: "tick",
|
||||
stream,
|
||||
interval: 1,
|
||||
frames: [">", "-"],
|
||||
});
|
||||
sp.start();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
sp.stop();
|
||||
expect(output().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("start() with text arg updates text", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const sp = createSpinner({ stream });
|
||||
sp.start("dynamic text");
|
||||
expect(output()).toContain("dynamic text");
|
||||
sp.stop();
|
||||
});
|
||||
|
||||
it("fail() without arg uses current text", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const sp = createSpinner({ text: "job", stream });
|
||||
sp.start().fail();
|
||||
expect(output()).toContain("job");
|
||||
expect(output()).toContain("✖");
|
||||
});
|
||||
|
||||
it("warn() without arg uses current text", () => {
|
||||
const { stream, output } = makeStream();
|
||||
const sp = createSpinner({ text: "job", stream });
|
||||
sp.start().warn();
|
||||
expect(output()).toContain("job");
|
||||
expect(output()).toContain("⚠");
|
||||
});
|
||||
|
||||
it("uses process.stdout when stream is omitted", () => {
|
||||
const sp = createSpinner({ text: "test" });
|
||||
expect(sp.isSpinning).toBe(false);
|
||||
});
|
||||
|
||||
it("isTTY defaults to false when stream has no isTTY prop", () => {
|
||||
const chunks: string[] = [];
|
||||
const stream = {
|
||||
write: (t: string) => {
|
||||
chunks.push(t);
|
||||
},
|
||||
};
|
||||
const sp = createSpinner({ text: "test", stream });
|
||||
sp.start();
|
||||
expect(chunks.join("")).toContain("test");
|
||||
sp.stop();
|
||||
});
|
||||
|
||||
it("uses default opts when called with no arguments", () => {
|
||||
const sp = createSpinner();
|
||||
expect(sp.isSpinning).toBe(false);
|
||||
});
|
||||
|
||||
it("clearLine is no-op when isTTY=true but clearLine/cursorTo absent", () => {
|
||||
const chunks: string[] = [];
|
||||
const stream = {
|
||||
write: (t: string) => {
|
||||
chunks.push(t);
|
||||
},
|
||||
isTTY: true,
|
||||
};
|
||||
const sp = createSpinner({ text: "task", stream });
|
||||
sp.start();
|
||||
sp.stop();
|
||||
expect(sp.isSpinning).toBe(false);
|
||||
});
|
||||
|
||||
it("interval callback on non-TTY stream does not renderFrame", async () => {
|
||||
const chunks: string[] = [];
|
||||
const stream = {
|
||||
write: (t: string) => {
|
||||
chunks.push(t);
|
||||
},
|
||||
};
|
||||
const sp = createSpinner({
|
||||
text: "x",
|
||||
stream,
|
||||
interval: 1,
|
||||
frames: ["a", "b"],
|
||||
});
|
||||
sp.start();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
sp.stop();
|
||||
// Only initial frame written (non-TTY interval does nothing)
|
||||
expect(chunks.length).toBe(1);
|
||||
});
|
||||
});
|
||||
111
packages/devtools/tooling/cli/src/spinner/spinner.ts
Normal file
111
packages/devtools/tooling/cli/src/spinner/spinner.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type {
|
||||
SpinnerInstance,
|
||||
SpinnerOptions,
|
||||
WritableStreamLike,
|
||||
} from "./spinner.types.js";
|
||||
|
||||
const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
const DEFAULT_INTERVAL = 80;
|
||||
|
||||
function clearLine(stream: WritableStreamLike): void {
|
||||
if (stream.isTTY && stream.clearLine && stream.cursorTo) {
|
||||
stream.clearLine(0);
|
||||
stream.cursorTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFrame(
|
||||
stream: WritableStreamLike,
|
||||
frame: string,
|
||||
text: string,
|
||||
isTTY: boolean
|
||||
): void {
|
||||
if (isTTY && stream.clearLine && stream.cursorTo) {
|
||||
stream.clearLine(0);
|
||||
stream.cursorTo(0);
|
||||
stream.write(`${frame} ${text}`);
|
||||
} else {
|
||||
stream.write(`${frame} ${text}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSpinner(opts: SpinnerOptions = {}): SpinnerInstance {
|
||||
const frames = opts.frames ?? DEFAULT_FRAMES;
|
||||
const interval = opts.interval ?? DEFAULT_INTERVAL;
|
||||
const stream: WritableStreamLike = opts.stream ?? process.stdout;
|
||||
const isTTY = stream.isTTY ?? false;
|
||||
|
||||
let text = opts.text ?? "";
|
||||
let spinning = false;
|
||||
let frameIndex = 0;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const instance: SpinnerInstance = {
|
||||
get isSpinning() {
|
||||
return spinning;
|
||||
},
|
||||
|
||||
start(newText?: string): SpinnerInstance {
|
||||
if (opts.signal?.aborted) return instance;
|
||||
if (spinning) return instance;
|
||||
if (newText !== undefined) text = newText;
|
||||
spinning = true;
|
||||
renderFrame(
|
||||
stream,
|
||||
frames[frameIndex % frames.length]!,
|
||||
text,
|
||||
isTTY
|
||||
);
|
||||
timer = setInterval(() => {
|
||||
frameIndex = (frameIndex + 1) % frames.length;
|
||||
if (isTTY)
|
||||
renderFrame(stream, frames[frameIndex]!, text, isTTY);
|
||||
}, interval);
|
||||
return instance;
|
||||
},
|
||||
|
||||
stop(): SpinnerInstance {
|
||||
if (!spinning) return instance;
|
||||
spinning = false;
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
if (isTTY) clearLine(stream);
|
||||
return instance;
|
||||
},
|
||||
|
||||
succeed(newText?: string): SpinnerInstance {
|
||||
instance.stop();
|
||||
stream.write(`✔ ${newText ?? text}\n`);
|
||||
return instance;
|
||||
},
|
||||
|
||||
fail(newText?: string): SpinnerInstance {
|
||||
instance.stop();
|
||||
stream.write(`✖ ${newText ?? text}\n`);
|
||||
return instance;
|
||||
},
|
||||
|
||||
warn(newText?: string): SpinnerInstance {
|
||||
instance.stop();
|
||||
stream.write(`⚠ ${newText ?? text}\n`);
|
||||
return instance;
|
||||
},
|
||||
|
||||
update(newText: string): SpinnerInstance {
|
||||
text = newText;
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
|
||||
if (opts.signal) {
|
||||
opts.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
instance.stop();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
24
packages/devtools/tooling/cli/src/spinner/spinner.types.ts
Normal file
24
packages/devtools/tooling/cli/src/spinner/spinner.types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface WritableStreamLike {
|
||||
write(text: string): void;
|
||||
clearLine?(dir: number): void;
|
||||
cursorTo?(x: number): void;
|
||||
isTTY?: boolean;
|
||||
}
|
||||
|
||||
export interface SpinnerOptions {
|
||||
text?: string;
|
||||
frames?: string[];
|
||||
interval?: number;
|
||||
stream?: WritableStreamLike;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SpinnerInstance {
|
||||
readonly isSpinning: boolean;
|
||||
start(text?: string): SpinnerInstance;
|
||||
stop(): SpinnerInstance;
|
||||
succeed(text?: string): SpinnerInstance;
|
||||
fail(text?: string): SpinnerInstance;
|
||||
warn(text?: string): SpinnerInstance;
|
||||
update(text: string): SpinnerInstance;
|
||||
}
|
||||
15
packages/devtools/tooling/cli/src/style/index.ts
Normal file
15
packages/devtools/tooling/cli/src/style/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
createStyler,
|
||||
getStyleNames,
|
||||
isColorSupported,
|
||||
stripAnsi,
|
||||
style,
|
||||
} from "./style.js";
|
||||
export { StyleCodeSchema, StyleOptionsSchema } from "./style.schemas.js";
|
||||
export type {
|
||||
BgColor,
|
||||
Color,
|
||||
Modifier,
|
||||
StyleCode,
|
||||
Styler,
|
||||
} from "./style.types.js";
|
||||
82
packages/devtools/tooling/cli/src/style/style.md
Normal file
82
packages/devtools/tooling/cli/src/style/style.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# style
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`style` provides terminal ANSI colour and text-decoration utilities for `@suaveplan/cli`. It wraps text in ANSI escape sequences for modifiers (bold, dim, italic, underline, strikethrough) and colours (foreground and background). The module is colour-support-aware: when the terminal does not support colour, all styling is bypassed and plain text is returned unchanged.
|
||||
|
||||
The design uses a `Proxy`-based fluent builder that allows infinite chaining of style codes — `style.bold.red.underline("text")` — without allocating intermediate strings until the final call.
|
||||
|
||||
## Features
|
||||
|
||||
- 24 style codes: 6 modifiers, 9 foreground colours, 1 gray, 8 background colours
|
||||
- `createStyler(enabled?)` — factory for a fresh styler instance, injectable colour flag for testing
|
||||
- `style` — singleton styler wired to `isColorSupported()` at module load time
|
||||
- `stripAnsi(text)` — removes all ANSI escape sequences from a string
|
||||
- `isColorSupported()` — returns `true` when stdout is a TTY or `FORCE_COLOR` is set
|
||||
- `getStyleNames()` — returns the list of all valid style code names
|
||||
- Proxy-based chaining: `style.bold.red.underline("x")` composes all codes in a single call
|
||||
- Disabled mode: `createStyler(false)` returns identity function — no allocation overhead
|
||||
- Zero runtime dependencies outside the package
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { style } from "@suaveplan/cli/style/style";
|
||||
|
||||
console.log(style.bold("Build complete"));
|
||||
console.log(style.red("Error occurred"));
|
||||
console.log(style.green.bold("✓ Tests passed"));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createStyler, isColorSupported, stripAnsi } from "@suaveplan/cli/style/style";
|
||||
|
||||
// Inject colour support for testing
|
||||
const s = createStyler(process.env.CI !== "true");
|
||||
|
||||
function label(text: string, type: "info" | "warn" | "error"): string {
|
||||
if (type === "error") return s.bold.red(`[ERROR] ${text}`);
|
||||
if (type === "warn") return s.yellow(`[WARN] ${text}`);
|
||||
return s.cyan(`[INFO] ${text}`);
|
||||
}
|
||||
|
||||
console.log(label("Connection timed out", "error"));
|
||||
|
||||
// Strip for log files
|
||||
const plain = stripAnsi(label("hello", "info"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createStyler(enabled?: boolean): Styler`
|
||||
|
||||
Creates a new styler instance. `enabled` defaults to `isColorSupported()`. Returns a `Styler` (Proxy) that composes ANSI codes on chained property access and applies them on call.
|
||||
|
||||
### `style: Styler`
|
||||
|
||||
Pre-built singleton styler bound to the current terminal's colour support. Use this for production output.
|
||||
|
||||
### `stripAnsi(text: string): string`
|
||||
|
||||
Strips all ANSI escape sequences from `text`. Safe for log files and length calculations.
|
||||
|
||||
### `isColorSupported(): boolean`
|
||||
|
||||
Returns `true` when `process.stdout.isTTY` is true or `FORCE_COLOR` env var is set.
|
||||
|
||||
### `getStyleNames(): StyleCode[]`
|
||||
|
||||
Returns the array of all known style code names in order.
|
||||
|
||||
### `Styler`
|
||||
|
||||
A callable type with all style codes as chainable properties. Calling it with a string applies all accumulated codes and returns the styled string.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `Proxy` trap intercepts property access on the styler function itself, returning a new `Proxy`-wrapped function with the accessed code appended to the accumulated stack. This avoids the class-per-style-combination explosion of naive implementations. Opening and closing ANSI codes are concatenated from the stack at call time. The `reset` code (`\x1b[0m`) is handled like any other code — callers can chain `.reset` to reset all styles.
|
||||
31
packages/devtools/tooling/cli/src/style/style.schemas.ts
Normal file
31
packages/devtools/tooling/cli/src/style/style.schemas.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import z from "zod";
|
||||
|
||||
export const StyleCodeSchema = z.enum([
|
||||
"bold",
|
||||
"dim",
|
||||
"italic",
|
||||
"underline",
|
||||
"strikethrough",
|
||||
"reset",
|
||||
"black",
|
||||
"red",
|
||||
"green",
|
||||
"yellow",
|
||||
"blue",
|
||||
"magenta",
|
||||
"cyan",
|
||||
"white",
|
||||
"gray",
|
||||
"bgBlack",
|
||||
"bgRed",
|
||||
"bgGreen",
|
||||
"bgYellow",
|
||||
"bgBlue",
|
||||
"bgMagenta",
|
||||
"bgCyan",
|
||||
"bgWhite",
|
||||
]);
|
||||
|
||||
export const StyleOptionsSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
108
packages/devtools/tooling/cli/src/style/style.test.ts
Normal file
108
packages/devtools/tooling/cli/src/style/style.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
createStyler,
|
||||
getStyleNames,
|
||||
isColorSupported,
|
||||
stripAnsi,
|
||||
} from "./style.js";
|
||||
|
||||
describe("stripAnsi", { timeout: 5000 }, () => {
|
||||
it("strips ANSI escape codes", { timeout: 5000 }, () => {
|
||||
expect(stripAnsi("\x1b[31mred\x1b[39m")).toBe("red");
|
||||
});
|
||||
|
||||
it("returns plain text unchanged", { timeout: 5000 }, () => {
|
||||
expect(stripAnsi("hello")).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isColorSupported", { timeout: 5000 }, () => {
|
||||
it("returns a boolean", { timeout: 5000 }, () => {
|
||||
expect(typeof isColorSupported()).toBe("boolean");
|
||||
});
|
||||
|
||||
it("returns true when isTTY is true", { timeout: 5000 }, () => {
|
||||
const saved = process.stdout.isTTY;
|
||||
process.stdout.isTTY = true;
|
||||
expect(isColorSupported()).toBe(true);
|
||||
process.stdout.isTTY = saved;
|
||||
});
|
||||
|
||||
it("returns true when FORCE_COLOR is set", { timeout: 5000 }, () => {
|
||||
const saved = process.env["FORCE_COLOR"];
|
||||
process.env["FORCE_COLOR"] = "1";
|
||||
try {
|
||||
expect(isColorSupported()).toBe(true);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env["FORCE_COLOR"];
|
||||
else process.env["FORCE_COLOR"] = saved;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStyleNames", { timeout: 5000 }, () => {
|
||||
it("returns non-empty array", { timeout: 5000 }, () => {
|
||||
const names = getStyleNames();
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
expect(names).toContain("bold");
|
||||
expect(names).toContain("red");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStyler", { timeout: 5000 }, () => {
|
||||
it("returns a callable styler", { timeout: 5000 }, () => {
|
||||
const s = createStyler(true);
|
||||
expect(typeof s).toBe("function");
|
||||
});
|
||||
|
||||
it("wraps text with ANSI codes when enabled", { timeout: 5000 }, () => {
|
||||
const s = createStyler(true);
|
||||
const result = s.red("hello");
|
||||
expect(result).toContain("\x1b[");
|
||||
expect(result).toContain("hello");
|
||||
});
|
||||
|
||||
it("returns plain text when disabled", { timeout: 5000 }, () => {
|
||||
const s = createStyler(false);
|
||||
expect(s.bold.red("text")).toBe("text");
|
||||
});
|
||||
|
||||
it("chains styles", { timeout: 5000 }, () => {
|
||||
const s = createStyler(true);
|
||||
const result = s.bold.red("hi");
|
||||
expect(stripAnsi(result)).toBe("hi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStyler — proxy unknown prop", { timeout: 5000 }, () => {
|
||||
it("returns undefined for unknown style property", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = createStyler(true);
|
||||
// Access a property that is not a valid style code
|
||||
expect((s as Record<string, unknown>)["notAStyle"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStyler — default parameter", { timeout: 5000 }, () => {
|
||||
it("uses isColorSupported() as default when called without args", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = createStyler(); // triggers default parameter branch
|
||||
expect(typeof s).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStyler property tests", { timeout: 30000 }, () => {
|
||||
it("stripAnsi(style.bold.red(s)) === s for all strings", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const s = createStyler(true);
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (str) => {
|
||||
expect(stripAnsi(s.bold.red(str))).toBe(str);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
73
packages/devtools/tooling/cli/src/style/style.ts
Normal file
73
packages/devtools/tooling/cli/src/style/style.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { StyleCode, Styler } from "./style.types.js";
|
||||
|
||||
const CODES: Record<StyleCode, [number, number]> = {
|
||||
reset: [0, 0],
|
||||
bold: [1, 22],
|
||||
dim: [2, 22],
|
||||
italic: [3, 23],
|
||||
underline: [4, 24],
|
||||
strikethrough: [9, 29],
|
||||
black: [30, 39],
|
||||
red: [31, 39],
|
||||
green: [32, 39],
|
||||
yellow: [33, 39],
|
||||
blue: [34, 39],
|
||||
magenta: [35, 39],
|
||||
cyan: [36, 39],
|
||||
white: [37, 39],
|
||||
gray: [90, 39],
|
||||
bgBlack: [40, 49],
|
||||
bgRed: [41, 49],
|
||||
bgGreen: [42, 49],
|
||||
bgYellow: [43, 49],
|
||||
bgBlue: [44, 49],
|
||||
bgMagenta: [45, 49],
|
||||
bgCyan: [46, 49],
|
||||
bgWhite: [47, 49],
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is intentional for ANSI stripping
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
export function stripAnsi(text: string): string {
|
||||
return text.replace(ANSI_RE, "");
|
||||
}
|
||||
|
||||
export function isColorSupported(): boolean {
|
||||
return (
|
||||
process.stdout?.isTTY === true ||
|
||||
process.env["FORCE_COLOR"] !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function getStyleNames(): StyleCode[] {
|
||||
return Object.keys(CODES) as StyleCode[];
|
||||
}
|
||||
|
||||
export function createStyler(enabled = isColorSupported()): Styler {
|
||||
function makeStyler(appliedCodes: StyleCode[]): Styler {
|
||||
const fn = (text: string): string => {
|
||||
if (!enabled) return text;
|
||||
let open = "";
|
||||
let close = "";
|
||||
for (const code of appliedCodes) {
|
||||
const [o, c] = CODES[code];
|
||||
open += `\x1b[${o}m`;
|
||||
close = `\x1b[${c}m${close}`;
|
||||
}
|
||||
return `${open}${text}${close}`;
|
||||
};
|
||||
|
||||
return new Proxy(fn as Styler, {
|
||||
get(_target, prop: string) {
|
||||
if (prop in CODES) {
|
||||
return makeStyler([...appliedCodes, prop as StyleCode]);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeStyler([]);
|
||||
}
|
||||
|
||||
export const style: Styler = createStyler(isColorSupported());
|
||||
54
packages/devtools/tooling/cli/src/style/style.types.ts
Normal file
54
packages/devtools/tooling/cli/src/style/style.types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type Modifier =
|
||||
| "bold"
|
||||
| "dim"
|
||||
| "italic"
|
||||
| "underline"
|
||||
| "strikethrough"
|
||||
| "reset";
|
||||
export type Color =
|
||||
| "black"
|
||||
| "red"
|
||||
| "green"
|
||||
| "yellow"
|
||||
| "blue"
|
||||
| "magenta"
|
||||
| "cyan"
|
||||
| "white"
|
||||
| "gray";
|
||||
export type BgColor =
|
||||
| "bgBlack"
|
||||
| "bgRed"
|
||||
| "bgGreen"
|
||||
| "bgYellow"
|
||||
| "bgBlue"
|
||||
| "bgMagenta"
|
||||
| "bgCyan"
|
||||
| "bgWhite";
|
||||
export type StyleCode = Modifier | Color | BgColor;
|
||||
|
||||
export interface Styler {
|
||||
(text: string): string;
|
||||
bold: Styler;
|
||||
dim: Styler;
|
||||
italic: Styler;
|
||||
underline: Styler;
|
||||
strikethrough: Styler;
|
||||
reset: Styler;
|
||||
black: Styler;
|
||||
red: Styler;
|
||||
green: Styler;
|
||||
yellow: Styler;
|
||||
blue: Styler;
|
||||
magenta: Styler;
|
||||
cyan: Styler;
|
||||
white: Styler;
|
||||
gray: Styler;
|
||||
bgBlack: Styler;
|
||||
bgRed: Styler;
|
||||
bgGreen: Styler;
|
||||
bgYellow: Styler;
|
||||
bgBlue: Styler;
|
||||
bgMagenta: Styler;
|
||||
bgCyan: Styler;
|
||||
bgWhite: Styler;
|
||||
}
|
||||
1
packages/devtools/tooling/cli/tests/setup/polyfills.ts
Normal file
1
packages/devtools/tooling/cli/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/devtools/tooling/cli/tsconfig.lib.json
Normal file
12
packages/devtools/tooling/cli/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"]
|
||||
}
|
||||
39
packages/devtools/tooling/cli/vite.config.ts
Normal file
39
packages/devtools/tooling/cli/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/cli",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/readline",
|
||||
/^@suaveplan\/readline\//,
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/devtools/tooling/cli/vitest.config.ts
Normal file
31
packages/devtools/tooling/cli/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user