@rhythmjs/cli 0.0.16 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,49 +1,82 @@
1
1
  # @rhythmjs/cli
2
2
 
3
- The command-line layer of Rhythm, the Bun-native backend framework: CLI command routing on top of the `@rhythmjs/rhythm` kernel. `RhythmCli` matches commands against argv positional tokens with a simple linear scan (appropriate for the handful-to-dozens of commands a real CLI has), supports prefixes and nested command groups, and mounts flat into a parent `Rhythm` app via `.use(cli.middleware())`, so an unmatched command correctly falls through to whatever's registered after it.
3
+ The command-line layer of Rhythm, the Bun-native backend framework: command routing on top of the `@rhythmjs/rhythm` kernel. `RhythmCli` matches commands against the leading words of argv with [rou3](https://github.com/h3js/rou3), supports nested command groups (`db migrate`), optional and catch-all params, and runs per-command middleware chains.
4
4
 
5
- `RhythmCli` is not an app and does not extend `Rhythm`; it is a controller that compiles commands and middleware down to a single middleware (`.middleware()`). It shares the core middleware contract (`compose`, `Middleware`, `derive`; `next()` takes no arguments, extend the context with `derive()`), but has no startup `context` or `register()`, and it can't be served on its own: a `Rhythm` app is always the host that owns the lifecycle.
5
+ `RhythmCli` is a `Pipeline`, like `Rhythm`: it has request-time `use()` middleware and commands, but no startup phase. It runs on its own through `toCliHandler(cli)`, or mounts into a `Rhythm` app with `mount(cli)` when you want startup work (`register`, `decorate`, `include`) around it.
6
6
 
7
7
  ## Example
8
8
 
9
9
  ```ts
10
- import { Rhythm } from "@rhythmjs/rhythm";
11
10
  import { RhythmCli } from "@rhythmjs/cli";
11
+ import { withParsedArgv } from "@rhythmjs/cli/argv";
12
12
  import { toCliHandler } from "@rhythmjs/cli/run";
13
- import type { RhythmCliContext } from "@rhythmjs/cli/context";
14
13
 
15
- const cli = new RhythmCli().command("deploy :environment", (ctx) => {
16
- ctx.response.print(`deploying to ${ctx.args.environment}`);
17
- });
14
+ const cli = new RhythmCli({ name: "tool" })
15
+ .use(withParsedArgv())
16
+ .cmd("greet :name?", (ctx) => {
17
+ const message = `hello ${ctx.params.name ?? "world"}`;
18
+ ctx.log(ctx.flags.shout ? message.toUpperCase() : message);
19
+ })
20
+ .cmd(
21
+ "deploy :env",
22
+ (ctx, next) => {
23
+ if (!["staging", "production"].includes(ctx.params.env))
24
+ return ctx.fail(`unknown environment '${ctx.params.env}'`);
25
+ return next();
26
+ },
27
+ (ctx) => ctx.log(`deployed to ${ctx.params.env}`),
28
+ );
18
29
 
19
- const app = new Rhythm<RhythmCliContext>().use(cli.middleware());
20
- process.exitCode = await toCliHandler(app)(process.argv.slice(2));
30
+ process.exitCode = await toCliHandler(cli)(Bun.argv.slice(2));
21
31
  ```
22
32
 
23
- A fuller runnable version, including nested command groups and interactive prompts, is at [`examples/cli`](../../examples/cli).
24
-
25
33
  ## Concepts
26
34
 
27
- - **`ctx.response`**: `print(line)`, `printError(line)`, `exit(code)`, all chainable. Flushed to the console and returned as the process exit code by the adapter.
28
- - **`ctx.args`**: captured `:name` command tokens, added once a command matches.
29
- - **`ctx.flags`**: parsed `--foo` / `--foo=bar` / `-f` options. Schema-less: a bare `--foo` is boolean `true`, `--foo bar` takes the next token as its value unless the token itself looks like a flag, the same default behavior as `minimist`.
30
- - **`ctx.stdin`**: a `ReadableStream`, or `null` when stdin is a TTY (nothing piped in).
31
- - **Interactive prompts**: `createPrompt()` gives `text()`/`confirm()`/`select()`/`multiSelect()` (the latter two support an `allowCustom` option that adds a "type your own" choice). Wire it in via `app.context.prompt = ...` on the host `Rhythm` app; see the example.
32
- - **Prefixes compose across nesting**: a child cli mounted into a prefixed parent via `.use(child)` gets the parent's prefix segments joined onto every one of its commands, at any nesting depth. Mounting copies the child's commands and middleware at that moment; commands added to the child afterwards don't appear in the parent, and the child keeps working standalone.
33
- - **Registration order is execution order**: a `.use()` middleware wraps only the commands registered after it, and runs only when one of them matches the argv (so a guard never answers `help` or another cli's commands); a mounted cli (`.use(child.middleware())`) always runs; commands registered before it are untouched, and a matched command that doesn't call `next()` returns without reaching anything registered later. An unmatched command falls through, entry by entry, to the outer `next()`.
34
- - **Conditional middleware**: `.use(fn, condition)` also needs `condition(ctx)` to return true (sync or async); the second callback only narrows, so a middleware registered after every command still never runs.
35
- - **A cli is a controller, not a module**: it has no startup `context` or `register()`, and it cannot be `register()`ed into a `Rhythm` app either; `register()` composes `Rhythm` modules only. A cli mounts into an app exactly one way: koa-style, via `.use(cli.middleware())`.
35
+ - **Commands.** `cmd(command, ...handlers)` takes a space-separated command (`"db migrate :name"`). Words are literal unless they start with `:`. Handlers are `(ctx, next)` middleware and form a chain; the first may be `derive()`, which widens the context type for the handlers after it. Matching uses the words before the first flag, so `deploy staging --force` matches `deploy :env`.
36
+ - **Params.** `:name` is required, `:name?` optional (absent when not given), `:name+` / `:name*` / `**:name` capture the remaining words as a `string[]` (`*` and `**` alone land in `params["0"]`). Values are URI-decoded. Params are typed from the command string: `:name` is `string`, `:name?` is `string | undefined`, catch-alls are `string[]`, and reading a name that is not in the command is a compile error.
37
+ - **Strict context.** The context only has what was added: `ctx.nope` is a compile error, `ctx.flags` only exists after `withParsedArgv()`, `ctx.prompt` only after `withPrompt()`, and `derive()` widens it for the handlers after it. A command-level `derive` only affects that command. `toCliHandler(app)` rejects, at compile time, an app that requires input the runner cannot supply.
38
+ - **Request-time middleware.** `cli.use(middleware)` wraps commands and runs only when a command matched, so a guard never answers an unknown command. `derive()` extends the context type.
39
+ - **Running and exit codes.** `toCliHandler(app, io?)` returns `(argv) => Promise<number>`: the exit code. It reports an unmatched command as `error: unknown command 'x'` (exit code 2, or `no command given`), and a thrown error as an error line (exit code 1). `app` can be a `RhythmCli`, or a `Rhythm` app that mounts one. Register `cmd("", handler)` to handle the no-argument case yourself.
40
+ - **Output.** The context carries `argv`, `stdout`, `stderr`, `exitCode`, and the helpers `log(...)` (stdout), `error(...)` (stderr) and `fail(message, code = 1)` (prints `error: message` to stderr and sets the exit code). Pass `io: { stdout, stderr }` to `toCliHandler` to capture output, e.g. in tests.
41
+ - **Named failures.** `new RhythmCli({ name })` labels failures when mounted: `mounted cli "tool" failed` with the original error as `cause`.
42
+
43
+ ## Opt-in extensions
44
+
45
+ Nothing is added to the context unless you ask for it:
46
+
47
+ - **`withParsedArgv()`** (`@rhythmjs/cli/argv`) adds `ctx.flags` and `ctx.positionals`. Parsing is schema-less: `--foo=bar` and `--foo bar` give a string, a bare `--foo` (or one followed by another flag) is `true`, `-f` works the same way, and everything after `--` is positional. Like `minimist`, `--force now` consumes `now` as the value. `parseArgv(argv)` is the same parser as a plain function.
48
+ - **`withPrompt()`** (`@rhythmjs/cli/prompt`) adds `ctx.prompt` with `text()`, `confirm()`, `select()` and `multiSelect()` (the last two accept `allowCustom` to add a "type your own" choice) and closes its input when the command finishes. By default it reads lines through Bun's async-iterable `console`, only when you first ask; pass `() => ({ ask, write, close? })` to supply your own IO. `createPrompt(io)` is the same thing without the middleware.
49
+
50
+ ```ts
51
+ const cli = new RhythmCli().use(withPrompt()).cmd("init", async (ctx) => {
52
+ const name = await ctx.prompt.text("Project name?", { default: "my-app" });
53
+ if (await ctx.prompt.confirm("Install dependencies?", { default: true })) ctx.log(`installing for ${name}`);
54
+ });
55
+ ```
56
+
57
+ ## With a Rhythm app
58
+
59
+ ```ts
60
+ import { Rhythm, decorate, mount } from "@rhythmjs/rhythm";
61
+
62
+ const app = new Rhythm({ name: "tool" })
63
+ .register(decorate(async () => ({ config: await loadConfig() })))
64
+ .use(mount(cli));
65
+
66
+ process.exitCode = await toCliHandler(app)(Bun.argv.slice(2));
67
+ ```
36
68
 
37
69
  ## API
38
70
 
39
- - `new RhythmCli(options?)`: `options.prefix` (space-separated, e.g. `"remote"`).
40
- - `.command(path, ...handlers)`: register a command; `path` is space-separated and may contain `:param` tokens (e.g. `"deploy :environment"`). A trailing `:param?` is optional (e.g. `"new :name?"`): `ctx.args.param` is left out when the token is absent. Optional params must come last. A trailing `**` is a catch-all, like the router: it captures the remaining positionals into `ctx.args._`, joined by spaces, and is absent when there are none (e.g. `"run :script **"`). Order is required, then optional, then at most one catch-all.
41
- - `.use(fn)`: plain middleware. `.use(child)`: mount a nested `RhythmCli` (prefixes compose).
42
- - `.middleware()`: this CLI as a plain middleware, for mounting into a `Rhythm` app via `.use()`; the cli's only way onto a runtime. Note: mounting a _cli_ into a _cli_ must use `.use(child)`, not `.use(child.commands())`, because an opaque middleware can't have the parent's prefix applied to its commands.
43
- - `.entries`: a read-only snapshot of registered middlewares and commands, in order. `.middleware()` is tagged with the cli as its source, so a parent module lists it in `sources`.
44
- - `toCliHandler(app)`: bridges a `Rhythm` app to `(argv: string[]) => Promise<number>`.
45
- - `createPrompt()`: a `{ text, confirm, select, multiSelect }` prompt reading lines through Bun's async-iterable `console`, for use with `app.context` on the host app.
71
+ - `new RhythmCli<I, D>(options?)`: `I` is context the CLI needs from the parent (checked by `mount()`), `D` is what its own `derive()` calls add; both are usually inferred. `options.name` labels failures (`type` defaults to `"cli"`).
72
+ - `.cmd(command, ...handlers)`: register a command.
73
+ - `.use(middleware)`: command-level middleware; `derive()` extends the context type.
74
+ - `.callback()`: the CLI as a `(ctx) => Promise<ctx>` function, used by `mount()` and `toCliHandler()`.
75
+ - `.sources` / `.parent` / `.options`: inherited from `Pipeline`.
76
+ - `createCliContext(argv, io?)` (`@rhythmjs/cli/context`): the base context with `log`, `error`, `fail` and `exitCode`.
77
+ - `toCliHandler(app, io?)` (`@rhythmjs/cli/run`): bridges a CLI or `Rhythm` app to `(argv) => Promise<number>`.
78
+ - `parseArgv(argv)`, `withParsedArgv()` (`@rhythmjs/cli/argv`); `createPrompt(io)`, `createStdioPromptIO()`, `withPrompt(createIO?)` (`@rhythmjs/cli/prompt`).
46
79
 
47
80
  ## Running on Bun
48
81
 
49
- `@rhythmjs/cli/run` is the runtime half, coupled to Bun on purpose: `toCliHandler(app)` reads piped input through `Bun.stdin.stream()` (a TTY leaves `ctx.stdin` null), and `createPrompt()` reads answer lines through Bun's async-iterable `console`.
82
+ The runtime half is coupled to Bun on purpose: prompts read through Bun's async-iterable `console`, and nothing is adapted for other runtimes.
package/dist/argv.d.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import { type ExtensionMiddleware } from "@rhythmjs/rhythm";
1
2
  export interface ParsedArgv {
2
3
  positionals: string[];
3
4
  flags: Record<string, string | boolean>;
4
5
  }
5
6
  export declare function parseArgv(argv: readonly string[]): ParsedArgv;
7
+ export declare function withParsedArgv(): ExtensionMiddleware<{
8
+ argv: readonly string[];
9
+ }, ParsedArgv>;
package/dist/argv.js CHANGED
@@ -1,7 +1,55 @@
1
1
  // @bun
2
- import {
3
- parseArgv2
4
- } from "./rhythm-cli-qdf1cds9.js";
2
+ // src/argv.ts
3
+ import { derive } from "@rhythmjs/rhythm";
4
+ function parseArgv(argv) {
5
+ const positionals = [];
6
+ const flags = {};
7
+ let rawMode = false;
8
+ for (let i = 0;i < argv.length; i++) {
9
+ const token = argv[i];
10
+ if (rawMode) {
11
+ positionals.push(token);
12
+ continue;
13
+ }
14
+ if (token === "--") {
15
+ rawMode = true;
16
+ continue;
17
+ }
18
+ if (token.startsWith("--")) {
19
+ const eqIndex = token.indexOf("=");
20
+ if (eqIndex !== -1) {
21
+ flags[token.slice(2, eqIndex)] = token.slice(eqIndex + 1);
22
+ continue;
23
+ }
24
+ const name = token.slice(2);
25
+ const next = argv[i + 1];
26
+ if (next !== undefined && !next.startsWith("-")) {
27
+ flags[name] = next;
28
+ i++;
29
+ } else {
30
+ flags[name] = true;
31
+ }
32
+ continue;
33
+ }
34
+ if (token.startsWith("-") && token.length > 1) {
35
+ const name = token.slice(1);
36
+ const next = argv[i + 1];
37
+ if (next !== undefined && !next.startsWith("-")) {
38
+ flags[name] = next;
39
+ i++;
40
+ } else {
41
+ flags[name] = true;
42
+ }
43
+ continue;
44
+ }
45
+ positionals.push(token);
46
+ }
47
+ return { positionals, flags };
48
+ }
49
+ function withParsedArgv() {
50
+ return derive((ctx) => parseArgv(ctx.argv));
51
+ }
5
52
  export {
6
- parseArgv2 as parseArgv
53
+ parseArgv,
54
+ withParsedArgv
7
55
  };
package/dist/context.d.ts CHANGED
@@ -1,14 +1,17 @@
1
- export declare class RhythmCliResponse {
2
- exitCode: number;
3
- stdout: string[];
4
- stderr: string[];
5
- print(line: string): this;
6
- printError(line: string): this;
7
- exit(code: number): this;
8
- }
1
+ export type CliWriter = {
2
+ write(text: string): unknown;
3
+ };
4
+ export type CliIO = {
5
+ stdout: CliWriter;
6
+ stderr: CliWriter;
7
+ };
9
8
  export interface RhythmCliContext {
10
- readonly argv: readonly string[];
11
- readonly flags: Readonly<Record<string, string | boolean>>;
12
- readonly stdin: ReadableStream<Uint8Array> | null;
13
- readonly response: RhythmCliResponse;
9
+ readonly argv: string[];
10
+ readonly stdout: CliWriter;
11
+ readonly stderr: CliWriter;
12
+ exitCode: number;
13
+ log(...parts: unknown[]): void;
14
+ error(...parts: unknown[]): void;
15
+ fail(this: RhythmCliContext, message: string, code?: number): void;
14
16
  }
17
+ export declare function createCliContext(argv: string[], io?: CliIO): RhythmCliContext;
package/dist/context.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  import {
3
- RhythmCliResponse2
4
- } from "./rhythm-cli-abdgd67x.js";
3
+ createCliContext2
4
+ } from "./rhythm-cli-3w09y8wk.js";
5
5
  export {
6
- RhythmCliResponse2 as RhythmCliResponse
6
+ createCliContext2 as createCliContext
7
7
  };
package/dist/prompt.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ExtensionMiddleware } from "@rhythmjs/rhythm";
1
2
  export interface RhythmPrompt {
2
3
  text(message: string, options?: {
3
4
  default?: string;
@@ -21,3 +22,7 @@ export declare function createPrompt(io: RhythmPromptIO): {
21
22
  prompt: RhythmPrompt;
22
23
  close: () => void;
23
24
  };
25
+ export declare function createStdioPromptIO(): RhythmPromptIO;
26
+ export declare function withPrompt(createIO?: () => RhythmPromptIO): ExtensionMiddleware<{}, {
27
+ prompt: RhythmPrompt;
28
+ }>;
package/dist/prompt.js CHANGED
@@ -1,7 +1,104 @@
1
1
  // @bun
2
- import {
3
- createPrompt2
4
- } from "./rhythm-cli-28bavedc.js";
2
+ // src/prompt.ts
3
+ var CUSTOM_LABEL = "Other (type your own)";
4
+ function createPrompt(io) {
5
+ const { ask, write } = io;
6
+ const text = async (message, options) => {
7
+ const suffix = options?.default ? ` (${options.default})` : "";
8
+ const answer = (await ask(`${message}${suffix} `)).trim();
9
+ return answer || options?.default || "";
10
+ };
11
+ const confirm = async (message, options) => {
12
+ const suffix = options?.default === undefined ? "(y/n)" : options.default ? "(Y/n)" : "(y/N)";
13
+ while (true) {
14
+ const answer = (await ask(`${message} ${suffix} `)).trim().toLowerCase();
15
+ if (!answer && options?.default !== undefined)
16
+ return options.default;
17
+ if (answer === "y" || answer === "yes")
18
+ return true;
19
+ if (answer === "n" || answer === "no")
20
+ return false;
21
+ write(`Please answer y or n.
22
+ `);
23
+ }
24
+ };
25
+ function listChoices(message, choices, allowCustom) {
26
+ write(`${message}
27
+ `);
28
+ choices.forEach((choice, i) => write(` ${i + 1}) ${choice}
29
+ `));
30
+ const customIndex = allowCustom ? choices.length + 1 : -1;
31
+ if (allowCustom)
32
+ write(` ${customIndex}) ${CUSTOM_LABEL}
33
+ `);
34
+ return customIndex;
35
+ }
36
+ const select = async (message, choices, options) => {
37
+ const customIndex = listChoices(message, choices, options?.allowCustom);
38
+ while (true) {
39
+ const index = Number(await ask("Enter number: "));
40
+ if (Number.isInteger(index) && index >= 1 && index <= choices.length)
41
+ return choices[index - 1];
42
+ if (index === customIndex)
43
+ return text("Enter value:");
44
+ write(`Invalid choice, try again.
45
+ `);
46
+ }
47
+ };
48
+ const multiSelect = async (message, choices, options) => {
49
+ const customIndex = listChoices(message, choices, options?.allowCustom);
50
+ const maxIndex = options?.allowCustom ? customIndex : choices.length;
51
+ write(`(comma-separated numbers, e.g. "1,3")
52
+ `);
53
+ while (true) {
54
+ const raw = await ask("Enter numbers: ");
55
+ const indices = raw.split(",").map((part) => Number(part.trim())).filter((n) => n !== 0 || raw.trim() === "0");
56
+ const valid = indices.length > 0 && indices.every((n) => Number.isInteger(n) && n >= 1 && n <= maxIndex);
57
+ if (!valid) {
58
+ write(`Invalid choices, try again.
59
+ `);
60
+ continue;
61
+ }
62
+ const results = [];
63
+ for (const n of indices) {
64
+ results.push(n === customIndex ? await text("Enter value:") : choices[n - 1]);
65
+ }
66
+ return results;
67
+ }
68
+ };
69
+ return { prompt: { text, confirm, select, multiSelect }, close: () => io.close?.() };
70
+ }
71
+ function createStdioPromptIO() {
72
+ let lines;
73
+ return {
74
+ ask: async (query) => {
75
+ process.stdout.write(query);
76
+ lines ??= console[Symbol.asyncIterator]();
77
+ const { value } = await lines.next();
78
+ return value ?? "";
79
+ },
80
+ write: (text) => {
81
+ process.stdout.write(text);
82
+ },
83
+ close: () => {
84
+ lines?.return?.();
85
+ }
86
+ };
87
+ }
88
+ function withPrompt(createIO = createStdioPromptIO) {
89
+ const middleware = async (ctx, next) => {
90
+ const { prompt, close } = createPrompt(createIO());
91
+ ctx.prompt = prompt;
92
+ try {
93
+ await next();
94
+ } finally {
95
+ close();
96
+ }
97
+ };
98
+ return middleware;
99
+ }
5
100
  export {
6
- createPrompt2 as createPrompt
101
+ createPrompt,
102
+ createStdioPromptIO,
103
+ withPrompt
7
104
  };
@@ -0,0 +1,26 @@
1
+ // @bun
2
+ // src/context.ts
3
+ import { format } from "util";
4
+ function createCliContext2(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
5
+ return {
6
+ argv,
7
+ stdout: io.stdout,
8
+ stderr: io.stderr,
9
+ exitCode: 0,
10
+ log(...parts) {
11
+ io.stdout.write(`${format(...parts)}
12
+ `);
13
+ },
14
+ error(...parts) {
15
+ io.stderr.write(`${format(...parts)}
16
+ `);
17
+ },
18
+ fail(message, code = 1) {
19
+ io.stderr.write(`error: ${message}
20
+ `);
21
+ this.exitCode = code;
22
+ }
23
+ };
24
+ }
25
+
26
+ export { createCliContext2 };
@@ -1,26 +1,32 @@
1
- import type { Condition, DeriveMiddleware, Middleware } from "@rhythmjs/rhythm/types";
1
+ import { type InferRouteParams } from "rou3";
2
+ import { Pipeline, type ExtensionMiddleware, type Middleware, type Next, type PipelineOptions } from "@rhythmjs/rhythm";
2
3
  import type { RhythmCliContext } from "./context";
3
- export interface RhythmCliCommandContext {
4
- readonly args: Readonly<Record<string, string>>;
5
- }
6
- export interface RhythmCliOptions {
7
- prefix?: string;
8
- }
9
- export type CliEntry = {
10
- readonly kind: "middleware";
11
- readonly fn: Middleware<any>;
12
- } | {
13
- readonly kind: "command";
14
- readonly segments: readonly string[];
15
- readonly handlers: readonly Middleware<any>[];
4
+ export type CliContext<T extends object> = T & RhythmCliContext;
5
+ export type UseContext<T extends object> = CliContext<T> & {
6
+ readonly params: Record<string, string | string[]>;
7
+ };
8
+ type ToPath<Command extends string> = Command extends `${infer Word} ${infer Rest}` ? `${Word}/${ToPath<Rest>}` : Command;
9
+ type CatchAllWord<Word extends string> = Word extends `**:${infer Name}` ? Name : Word extends `:${infer Name}+` ? Name : Word extends `:${infer Name}*` ? Name : Word extends "*" | "**" ? "0" : never;
10
+ type CatchAllKey<Command extends string> = Command extends `${infer Word} ${infer Rest}` ? CatchAllWord<Word> | CatchAllKey<Rest> : CatchAllWord<Command>;
11
+ type Flatten<T> = {
12
+ [K in keyof T]: T[K];
13
+ } & {};
14
+ export type CommandParams<C extends string> = Flatten<Omit<InferRouteParams<`/${ToPath<C>}`>, CatchAllKey<C> | "_"> & {
15
+ [K in CatchAllKey<C>]: string[];
16
+ }>;
17
+ export type CommandContext<T extends object, C extends string> = CliContext<T> & {
18
+ params: CommandParams<C>;
16
19
  };
17
- export declare class RhythmCli<TContext extends RhythmCliContext = RhythmCliContext, TInput extends RhythmCliContext = TContext> {
20
+ export type CommandHandler<T extends object, C extends string> = (ctx: CommandContext<T, C>, next: Next) => unknown | Promise<unknown>;
21
+ export type CommandHandlers<T extends object, C extends string> = [CommandHandler<T, C>, ...CommandHandler<T, C>[]];
22
+ export declare class RhythmCli<I extends object = {}, D extends object = {}> extends Pipeline<UseContext<I & D>> {
18
23
  #private;
19
- constructor(options?: RhythmCliOptions);
20
- get entries(): readonly CliEntry[];
21
- use<TExtra extends object>(fn: DeriveMiddleware<TContext, TExtra>): RhythmCli<TContext & TExtra, TInput>;
22
- use(fn: Middleware<TContext>, condition?: Condition<TContext>): this;
23
- command<TExtra extends object>(path: string, middleware: DeriveMiddleware<TContext & RhythmCliCommandContext, TExtra>, ...handlers: Middleware<TContext & RhythmCliCommandContext & TExtra>[]): this;
24
- command(path: string, ...handlers: Middleware<TContext & RhythmCliCommandContext>[]): this;
25
- middleware(): Middleware<TInput>;
24
+ readonly "~input"?: I;
25
+ constructor(options?: PipelineOptions);
26
+ use<U extends object>(middleware: ExtensionMiddleware<UseContext<I & D>, U>): RhythmCli<I, D & U>;
27
+ use(middleware: Middleware<UseContext<I & D>>): this;
28
+ cmd<C extends string, U extends object>(command: C, middleware: ExtensionMiddleware<CommandContext<I & D, C>, U>, ...handlers: CommandHandler<I & D & U, C>[]): this;
29
+ cmd<C extends string>(command: C, ...handlers: CommandHandlers<I & D, C>): this;
30
+ callback(): (input: CliContext<I>) => Promise<CliContext<I & D>>;
26
31
  }
32
+ export {};
@@ -1,154 +1,77 @@
1
1
  // @bun
2
- import {
3
- parseArgv2
4
- } from "./rhythm-cli-qdf1cds9.js";
5
-
6
2
  // src/rhythm-cli.ts
7
- import { compose, gate } from "@rhythmjs/rhythm/compose";
8
- import { sourceOf, withSource } from "@rhythmjs/rhythm/source";
9
- function toSegments(command) {
10
- return command.trim().split(/\s+/).filter(Boolean);
11
- }
12
- var isOptional = (segment) => segment.startsWith(":") && segment.endsWith("?");
13
- var isCatchAll = (segment) => segment === "**";
14
- function matchCommand(pattern, positionals) {
15
- const catchAll = isCatchAll(pattern.at(-1));
16
- const fixed = catchAll ? pattern.slice(0, -1) : pattern;
17
- const required = fixed.filter((segment) => !isOptional(segment)).length;
18
- if (positionals.length < required || !catchAll && positionals.length > fixed.length)
19
- return null;
20
- const params = {};
21
- for (let i = 0;i < fixed.length; i++) {
22
- const segment = fixed[i];
23
- const token = positionals[i];
24
- if (segment.startsWith(":")) {
25
- if (token !== undefined)
26
- params[segment.slice(1, isOptional(segment) ? -1 : undefined)] = token;
27
- } else if (segment !== token) {
28
- return null;
29
- }
30
- }
31
- if (catchAll && positionals.length > fixed.length) {
32
- params._ = positionals.slice(fixed.length).join(" ");
3
+ import { addRoute, createRouter, findRoute } from "rou3";
4
+ import {
5
+ Pipeline,
6
+ compose
7
+ } from "@rhythmjs/rhythm";
8
+ var isFlag = (token) => token.startsWith("-") && token !== "-";
9
+ var lookupPath = (words) => `/${words.map(encodeURIComponent).join("/")}`;
10
+ function catchAllKey(words) {
11
+ for (const word of words) {
12
+ const named = /^\*\*:(\w+)$/.exec(word) ?? /^:(\w+)[+*]$/.exec(word);
13
+ if (named)
14
+ return named[1];
15
+ if (word === "*" || word === "**")
16
+ return "0";
33
17
  }
34
- return params;
18
+ return;
35
19
  }
36
- function assertValidPattern(path, segments) {
37
- const rank = (segment) => isCatchAll(segment) ? 2 : isOptional(segment) ? 1 : 0;
38
- for (let i = 1;i < segments.length; i++) {
39
- if (rank(segments[i]) < rank(segments[i - 1])) {
40
- throw new TypeError(`optional params and a catch-all must come last, in that order, in command "${path}"`);
20
+ function decodeParams(params, catchAll) {
21
+ const decoded = {};
22
+ try {
23
+ for (const [key, value] of Object.entries(params ?? {})) {
24
+ if (key === "_" && catchAll === "0")
25
+ continue;
26
+ decoded[key] = key === catchAll ? value.split("/").filter(Boolean).map(decodeURIComponent) : decodeURIComponent(value);
41
27
  }
42
- }
43
- if (segments.filter(isCatchAll).length > 1) {
44
- throw new TypeError(`command "${path}" has more than one catch-all`);
45
- }
46
- }
47
- function mountedCommands(cli, out, seen = new Set) {
48
- if (seen.has(cli))
28
+ } catch {
49
29
  return;
50
- seen.add(cli);
51
- for (const entry of cli.entries) {
52
- if (entry.kind === "command")
53
- out.push({ segments: [...entry.segments] });
54
- else {
55
- const child = sourceOf(entry.fn);
56
- if (child instanceof RhythmCli)
57
- mountedCommands(child, out, seen);
58
- }
59
30
  }
31
+ if (catchAll !== undefined)
32
+ decoded[catchAll] ??= [];
33
+ return decoded;
60
34
  }
61
35
 
62
- class RhythmCli {
63
- #options;
64
- #entries = [];
65
- constructor(options = {}) {
66
- this.#options = options;
67
- }
68
- get entries() {
69
- return [...this.#entries];
70
- }
71
- get #prefixSegments() {
72
- return this.#options.prefix ? toSegments(this.#options.prefix) : [];
36
+ class RhythmCli extends Pipeline {
37
+ #routes = createRouter();
38
+ constructor(options) {
39
+ super({ type: "cli", ...options });
73
40
  }
74
- use(fn, condition) {
75
- if (typeof fn !== "function")
76
- throw new TypeError("middleware must be a function!");
77
- if (condition !== undefined && typeof condition !== "function")
78
- throw new TypeError("condition must be a function!");
79
- const source = sourceOf(fn);
80
- const wrapped = condition ? gate(fn, condition) : fn;
81
- this.#entries.push({ kind: "middleware", fn: condition && source ? withSource(wrapped, source) : wrapped });
82
- return this;
41
+ use(middleware) {
42
+ return super.use(middleware);
83
43
  }
84
- command(path, ...handlers) {
85
- const segments = toSegments(path);
86
- assertValidPattern(path, segments);
87
- this.#entries.push({ kind: "command", segments: [...this.#prefixSegments, ...segments], handlers });
44
+ cmd(command, ...handlers) {
45
+ const chain = compose(handlers);
46
+ const words = command.trim().split(/\s+/).filter(Boolean);
47
+ addRoute(this.#routes, "", `/${words.join("/")}`, {
48
+ run: (ctx) => chain(ctx),
49
+ catchAll: catchAllKey(words)
50
+ });
88
51
  return this;
89
52
  }
90
- #compile() {
91
- const groups = [];
92
- const reaches = (ctx, from) => {
93
- const { positionals } = parseArgv2(ctx.argv);
94
- for (let g = from;g < groups.length; g++) {
95
- if (groups[g].some((command) => matchCommand(command.segments, positionals)))
96
- return true;
97
- }
98
- return false;
99
- };
100
- const dispatchFor = (compiled) => {
101
- return async (ctx, next) => {
102
- const { positionals } = parseArgv2(ctx.argv);
103
- for (const command of compiled) {
104
- const args = matchCommand(command.segments, positionals);
105
- if (!args)
106
- continue;
107
- await command.dispatch({ ...ctx, args }, next);
108
- return;
109
- }
110
- await next();
111
- };
53
+ callback() {
54
+ const run = this.chain();
55
+ return async (input) => {
56
+ const ctx = input;
57
+ const match = this.#match(ctx);
58
+ if (!match)
59
+ return ctx;
60
+ Object.assign(ctx, { params: match.params });
61
+ await run(ctx, () => match.route.run(ctx));
62
+ return ctx;
112
63
  };
113
- const stack = [];
114
- let i = 0;
115
- while (i < this.#entries.length) {
116
- const entry = this.#entries[i];
117
- if (entry.kind === "middleware") {
118
- const { fn } = entry;
119
- const source = sourceOf(fn);
120
- if (source) {
121
- if (source instanceof RhythmCli) {
122
- const commands = [];
123
- mountedCommands(source, commands);
124
- groups.push(commands);
125
- }
126
- stack.push(fn);
127
- } else {
128
- const from = groups.length;
129
- stack.push((ctx, next) => reaches(ctx, from) ? fn(ctx, next) : next());
130
- }
131
- i++;
132
- continue;
133
- }
134
- const compiled = [];
135
- while (i < this.#entries.length) {
136
- const command = this.#entries[i];
137
- if (command.kind !== "command")
138
- break;
139
- compiled.push({ segments: command.segments, dispatch: compose(command.handlers) });
140
- i++;
141
- }
142
- groups.push(compiled);
143
- stack.push(dispatchFor(compiled));
144
- }
145
- return compose(stack);
146
64
  }
147
- middleware() {
148
- const fn = this.#compile();
149
- return withSource(async (ctx, next) => {
150
- await fn(ctx, next);
151
- }, this);
65
+ #match(ctx) {
66
+ const end = ctx.argv.findIndex(isFlag);
67
+ const words = end === -1 ? ctx.argv : ctx.argv.slice(0, end);
68
+ const found = findRoute(this.#routes, "", lookupPath(words));
69
+ if (!found)
70
+ return;
71
+ const params = decodeParams(found.params, found.data.catchAll);
72
+ if (!params)
73
+ return;
74
+ return { route: found.data, params };
152
75
  }
153
76
  }
154
77
  export {
package/dist/run.d.ts CHANGED
@@ -1,8 +1,3 @@
1
- import type { Rhythm } from "@rhythmjs/rhythm";
2
- import { type RhythmPrompt } from "./prompt";
3
- import { type RhythmCliContext } from "./context";
4
- export declare function toCliHandler<TContext extends RhythmCliContext>(app: Rhythm<RhythmCliContext, any, TContext>): (argv: string[]) => Promise<number>;
5
- export declare function createPrompt(): {
6
- prompt: RhythmPrompt;
7
- close: () => void;
8
- };
1
+ import type { Mountable } from "@rhythmjs/rhythm";
2
+ import { type CliIO, type RhythmCliContext } from "./context";
3
+ export declare function toCliHandler<I extends object = any>(app: Mountable<I> & (RhythmCliContext extends I ? unknown : never), io?: CliIO): (argv: string[]) => Promise<number>;
package/dist/run.js CHANGED
@@ -1,48 +1,26 @@
1
1
  // @bun
2
2
  import {
3
- parseArgv2
4
- } from "./rhythm-cli-qdf1cds9.js";
5
- import {
6
- createPrompt2
7
- } from "./rhythm-cli-28bavedc.js";
8
- import {
9
- RhythmCliResponse2
10
- } from "./rhythm-cli-abdgd67x.js";
3
+ createCliContext2
4
+ } from "./rhythm-cli-3w09y8wk.js";
11
5
 
12
6
  // src/run.ts
13
- function toCliHandler(app) {
7
+ function toCliHandler(app, io) {
14
8
  const run = app.callback();
15
9
  return async (argv) => {
16
- const { flags } = parseArgv2(argv);
17
- const stdin = process.stdin.isTTY ? null : Bun.stdin.stream();
18
- const ctx = await run({ argv, flags, stdin, response: new RhythmCliResponse2 });
19
- for (const line of ctx.response.stdout)
20
- console.log(line);
21
- for (const line of ctx.response.stderr)
22
- console.error(line);
23
- return ctx.response.exitCode;
24
- };
25
- }
26
- function defaultPromptIO() {
27
- const lines = console[Symbol.asyncIterator]();
28
- return {
29
- ask: async (query) => {
30
- process.stdout.write(query);
31
- const { value } = await lines.next();
32
- return value ?? "";
33
- },
34
- write: (text) => {
35
- process.stdout.write(text);
36
- },
37
- close: () => {
38
- lines.return?.();
10
+ const base = createCliContext2(argv, io);
11
+ try {
12
+ const result = await run(base);
13
+ if (result?.params === undefined) {
14
+ base.fail(argv.length ? `unknown command '${argv[0]}'` : "no command given", 2);
15
+ return base.exitCode;
16
+ }
17
+ return result.exitCode ?? base.exitCode;
18
+ } catch (error) {
19
+ base.fail(error instanceof Error ? error.message : String(error));
20
+ return base.exitCode;
39
21
  }
40
22
  };
41
23
  }
42
- function createPrompt() {
43
- return createPrompt2(defaultPromptIO());
44
- }
45
24
  export {
46
- createPrompt,
47
25
  toCliHandler
48
26
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rhythmjs/cli",
3
- "version": "0.0.16",
4
- "description": "Command-line argument parsing, prompts, and command routing for the Rhythm middleware kernel, running on Bun.",
3
+ "version": "0.0.17",
4
+ "description": "Command-line routing for Rhythm: RhythmCli commands, argv parsing, prompts and a Bun runner.",
5
5
  "homepage": "https://rhythm.js.org/cli",
6
6
  "license": "ISC",
7
7
  "repository": {
@@ -35,21 +35,14 @@
35
35
  "types": "./dist/run.d.ts",
36
36
  "default": "./dist/run.js"
37
37
  },
38
- "./adapters/context": {
39
- "types": "./dist/context.d.ts",
40
- "default": "./dist/context.js"
41
- },
42
- "./adapters/bun": {
43
- "types": "./dist/run.d.ts",
44
- "default": "./dist/run.js"
45
- },
46
38
  "./package.json": "./package.json"
47
39
  },
48
40
  "publishConfig": {
49
41
  "access": "public"
50
42
  },
51
43
  "dependencies": {
52
- "@rhythmjs/rhythm": "0.0.16"
44
+ "@rhythmjs/rhythm": "0.0.17",
45
+ "rou3": "^1.0.0"
53
46
  },
54
47
  "devDependencies": {
55
48
  "@types/bun": "^1.4.2",
@@ -60,7 +53,7 @@
60
53
  "bun": ">=1.2.0"
61
54
  },
62
55
  "scripts": {
63
- "build": "bun build src/rhythm-cli.ts src/argv.ts src/prompt.ts src/context.ts src/run.ts --outdir dist --root src --format esm --target bun --packages external --splitting && tsc -p tsconfig.build.json",
56
+ "build": "rm -rf dist && bun build src/rhythm-cli.ts src/argv.ts src/prompt.ts src/context.ts src/run.ts --outdir dist --root src --format esm --target bun --packages external --splitting && tsc -p tsconfig.build.json",
64
57
  "typecheck": "tsc --noEmit",
65
58
  "test": "bun test"
66
59
  }
@@ -1,72 +0,0 @@
1
- // @bun
2
- // src/prompt.ts
3
- var CUSTOM_LABEL = "Other (type your own)";
4
- function createPrompt2(io) {
5
- const { ask, write } = io;
6
- const text = async (message, options) => {
7
- const suffix = options?.default ? ` (${options.default})` : "";
8
- const answer = (await ask(`${message}${suffix} `)).trim();
9
- return answer || options?.default || "";
10
- };
11
- const confirm = async (message, options) => {
12
- const suffix = options?.default === undefined ? "(y/n)" : options.default ? "(Y/n)" : "(y/N)";
13
- while (true) {
14
- const answer = (await ask(`${message} ${suffix} `)).trim().toLowerCase();
15
- if (!answer && options?.default !== undefined)
16
- return options.default;
17
- if (answer === "y" || answer === "yes")
18
- return true;
19
- if (answer === "n" || answer === "no")
20
- return false;
21
- write(`Please answer y or n.
22
- `);
23
- }
24
- };
25
- function listChoices(message, choices, allowCustom) {
26
- write(`${message}
27
- `);
28
- choices.forEach((choice, i) => write(` ${i + 1}) ${choice}
29
- `));
30
- const customIndex = allowCustom ? choices.length + 1 : -1;
31
- if (allowCustom)
32
- write(` ${customIndex}) ${CUSTOM_LABEL}
33
- `);
34
- return customIndex;
35
- }
36
- const select = async (message, choices, options) => {
37
- const customIndex = listChoices(message, choices, options?.allowCustom);
38
- while (true) {
39
- const index = Number(await ask("Enter number: "));
40
- if (Number.isInteger(index) && index >= 1 && index <= choices.length)
41
- return choices[index - 1];
42
- if (index === customIndex)
43
- return text("Enter value:");
44
- write(`Invalid choice, try again.
45
- `);
46
- }
47
- };
48
- const multiSelect = async (message, choices, options) => {
49
- const customIndex = listChoices(message, choices, options?.allowCustom);
50
- const maxIndex = options?.allowCustom ? customIndex : choices.length;
51
- write(`(comma-separated numbers, e.g. "1,3")
52
- `);
53
- while (true) {
54
- const raw = await ask("Enter numbers: ");
55
- const indices = raw.split(",").map((part) => Number(part.trim())).filter((n) => n !== 0 || raw.trim() === "0");
56
- const valid = indices.length > 0 && indices.every((n) => Number.isInteger(n) && n >= 1 && n <= maxIndex);
57
- if (!valid) {
58
- write(`Invalid choices, try again.
59
- `);
60
- continue;
61
- }
62
- const results = [];
63
- for (const n of indices) {
64
- results.push(n === customIndex ? await text("Enter value:") : choices[n - 1]);
65
- }
66
- return results;
67
- }
68
- };
69
- return { prompt: { text, confirm, select, multiSelect }, close: () => io.close?.() };
70
- }
71
-
72
- export { createPrompt2 };
@@ -1,21 +0,0 @@
1
- // @bun
2
- // src/context.ts
3
- class RhythmCliResponse2 {
4
- exitCode = 0;
5
- stdout = [];
6
- stderr = [];
7
- print(line) {
8
- this.stdout.push(line);
9
- return this;
10
- }
11
- printError(line) {
12
- this.stderr.push(line);
13
- return this;
14
- }
15
- exit(code) {
16
- this.exitCode = code;
17
- return this;
18
- }
19
- }
20
-
21
- export { RhythmCliResponse2 };
@@ -1,49 +0,0 @@
1
- // @bun
2
- // src/argv.ts
3
- function parseArgv2(argv) {
4
- const positionals = [];
5
- const flags = {};
6
- let rawMode = false;
7
- for (let i = 0;i < argv.length; i++) {
8
- const token = argv[i];
9
- if (rawMode) {
10
- positionals.push(token);
11
- continue;
12
- }
13
- if (token === "--") {
14
- rawMode = true;
15
- continue;
16
- }
17
- if (token.startsWith("--")) {
18
- const eqIndex = token.indexOf("=");
19
- if (eqIndex !== -1) {
20
- flags[token.slice(2, eqIndex)] = token.slice(eqIndex + 1);
21
- continue;
22
- }
23
- const name = token.slice(2);
24
- const next = argv[i + 1];
25
- if (next !== undefined && !next.startsWith("-")) {
26
- flags[name] = next;
27
- i++;
28
- } else {
29
- flags[name] = true;
30
- }
31
- continue;
32
- }
33
- if (token.startsWith("-") && token.length > 1) {
34
- const name = token.slice(1);
35
- const next = argv[i + 1];
36
- if (next !== undefined && !next.startsWith("-")) {
37
- flags[name] = next;
38
- i++;
39
- } else {
40
- flags[name] = true;
41
- }
42
- continue;
43
- }
44
- positionals.push(token);
45
- }
46
- return { positionals, flags };
47
- }
48
-
49
- export { parseArgv2 };