Initial Commit

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

View File

@@ -0,0 +1,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.

View 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(),
});

View 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();
});
});

View 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 });
}

View 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;
}

View File

@@ -0,0 +1,3 @@
export { App, cli } from "./app.js";
export { AppConfigSchema } from "./app.schemas.js";
export type { AppConfig } from "./app.types.js";