Initial Commit
This commit is contained in:
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";
|
||||
Reference in New Issue
Block a user