@sdxc/cli 0.0.0-pre.1 → 0.0.0-stage

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.
Files changed (55) hide show
  1. package/README.md +2 -363
  2. package/package.json +5 -40
  3. package/LICENSE.md +0 -21
  4. package/dist/argument.d.ts +0 -120
  5. package/dist/argument.js +0 -104
  6. package/dist/cli.d.ts +0 -68
  7. package/dist/cli.js +0 -186
  8. package/dist/command.d.ts +0 -139
  9. package/dist/command.js +0 -79
  10. package/dist/context.d.ts +0 -138
  11. package/dist/context.js +0 -175
  12. package/dist/errors.d.ts +0 -67
  13. package/dist/errors.js +0 -82
  14. package/dist/exec.d.ts +0 -83
  15. package/dist/exec.js +0 -136
  16. package/dist/flag.d.ts +0 -175
  17. package/dist/flag.js +0 -91
  18. package/dist/handler.d.ts +0 -73
  19. package/dist/handler.js +0 -30
  20. package/dist/help.d.ts +0 -18
  21. package/dist/help.js +0 -141
  22. package/dist/index.d.ts +0 -35
  23. package/dist/index.js +0 -20
  24. package/dist/interactions.d.ts +0 -27
  25. package/dist/interactions.js +0 -373
  26. package/dist/keys.d.ts +0 -49
  27. package/dist/keys.js +0 -146
  28. package/dist/lazy.d.ts +0 -29
  29. package/dist/lazy.js +0 -23
  30. package/dist/middleware.d.ts +0 -78
  31. package/dist/middleware.js +0 -9
  32. package/dist/parse.d.ts +0 -38
  33. package/dist/parse.js +0 -218
  34. package/dist/prompt.d.ts +0 -153
  35. package/dist/prompt.js +0 -53
  36. package/dist/resolve.d.ts +0 -27
  37. package/dist/resolve.js +0 -294
  38. package/dist/schema.d.ts +0 -27
  39. package/dist/schema.js +0 -34
  40. package/dist/screen.d.ts +0 -42
  41. package/dist/screen.js +0 -114
  42. package/dist/session.d.ts +0 -84
  43. package/dist/session.js +0 -219
  44. package/dist/style.d.ts +0 -50
  45. package/dist/style.js +0 -72
  46. package/dist/suggest.d.ts +0 -15
  47. package/dist/suggest.js +0 -50
  48. package/dist/task.d.ts +0 -47
  49. package/dist/task.js +0 -213
  50. package/dist/terminal.d.ts +0 -21
  51. package/dist/terminal.js +0 -101
  52. package/dist/test.d.ts +0 -89
  53. package/dist/test.js +0 -140
  54. package/dist/tree.d.ts +0 -58
  55. package/dist/tree.js +0 -97
@@ -1,21 +0,0 @@
1
- /**
2
- * A virtual terminal: an output stream that applies the cursor movement, erasing and wrapping a
3
- * real terminal does, so a test reads the screen a person would see instead of the escape
4
- * sequences that drew it.
5
- *
6
- * @author [Sergio Xalambrí](https://sergiodxa.com)
7
- * @copyright Sergio Xalambrí 2026
8
- */
9
- import type { Output } from "./screen.js";
10
- /** A screen that keeps every row written to it, scrolling never discarding one. */
11
- export declare class VirtualTerminal implements Output {
12
- #private;
13
- readonly isTTY = true;
14
- readonly columns: number;
15
- /** @param columns - The width text wraps at */
16
- constructor(columns: number);
17
- /** Applies `text` as a terminal would. */
18
- write(text: string): boolean;
19
- /** The screen as text: each row without trailing spaces, trailing empty rows dropped. */
20
- screen(): string;
21
- }
package/dist/terminal.js DELETED
@@ -1,101 +0,0 @@
1
- /**
2
- * A virtual terminal: an output stream that applies the cursor movement, erasing and wrapping a
3
- * real terminal does, so a test reads the screen a person would see instead of the escape
4
- * sequences that drew it.
5
- *
6
- * @author [Sergio Xalambrí](https://sergiodxa.com)
7
- * @copyright Sergio Xalambrí 2026
8
- */
9
- /** A CSI escape sequence: parameters and the final letter. */
10
- // oxlint-disable-next-line eslint/no-control-regex -- CSI sequences start with the ESC control character
11
- const CSI = /^\u001B\[([0-9;?]*)([A-Za-z])/;
12
- /** A screen that keeps every row written to it, scrolling never discarding one. */
13
- export class VirtualTerminal {
14
- isTTY = true;
15
- columns;
16
- #rows = [[]];
17
- #row = 0;
18
- #column = 0;
19
- /** @param columns - The width text wraps at */
20
- constructor(columns) {
21
- this.columns = columns;
22
- }
23
- /** Applies `text` as a terminal would. */
24
- write(text) {
25
- let index = 0;
26
- while (index < text.length) {
27
- let rest = text.slice(index);
28
- let sequence = CSI.exec(rest);
29
- if (sequence !== null) {
30
- this.#control(sequence[1], sequence[2]);
31
- index += sequence[0].length;
32
- continue;
33
- }
34
- let character = String.fromCodePoint(rest.codePointAt(0));
35
- index += character.length;
36
- if (character === "\r")
37
- this.#column = 0;
38
- else if (character === "\n")
39
- this.#moveTo(this.#row + 1, 0);
40
- else if (character === "\u001B")
41
- continue;
42
- else
43
- this.#print(character);
44
- }
45
- return true;
46
- }
47
- /** The screen as text: each row without trailing spaces, trailing empty rows dropped. */
48
- screen() {
49
- let lines = this.#rows.map((row) => row.join("").replace(/\s+$/, ""));
50
- while (lines.length > 0 && lines.at(-1) === "")
51
- lines.pop();
52
- return lines.join("\n");
53
- }
54
- /** Prints one character, wrapping first when the previous one filled the row. */
55
- #print(character) {
56
- if (this.#column >= this.columns)
57
- this.#moveTo(this.#row + 1, 0);
58
- let row = this.#rows[this.#row];
59
- while (row.length < this.#column)
60
- row.push(" ");
61
- row[this.#column] = character;
62
- this.#column += 1;
63
- }
64
- #moveTo(row, column) {
65
- this.#row = Math.max(0, row);
66
- this.#column = Math.max(0, column);
67
- while (this.#rows.length <= this.#row)
68
- this.#rows.push([]);
69
- }
70
- /** Applies one CSI sequence; styling and cursor visibility change nothing on the text. */
71
- #control(parameters, command) {
72
- let count = parameters === "" ? 1 : Number.parseInt(parameters, 10) || 1;
73
- let row = this.#rows[this.#row];
74
- switch (command) {
75
- case "A":
76
- this.#moveTo(this.#row - count, this.#column);
77
- break;
78
- case "B":
79
- this.#moveTo(this.#row + count, this.#column);
80
- break;
81
- case "C":
82
- this.#column = Math.min(this.columns, this.#column + count);
83
- break;
84
- case "D":
85
- this.#column = Math.max(0, this.#column - count);
86
- break;
87
- case "G":
88
- this.#column = count - 1;
89
- break;
90
- case "K":
91
- row.length = Math.min(row.length, this.#column);
92
- break;
93
- case "J":
94
- row.length = Math.min(row.length, this.#column);
95
- this.#rows.length = this.#row + 1;
96
- break;
97
- default:
98
- break;
99
- }
100
- }
101
- }
package/dist/test.d.ts DELETED
@@ -1,89 +0,0 @@
1
- /**
2
- * `@sdxc/cli/test`: runs a CLI in a test the way a terminal does, collecting both streams,
3
- * answering prompts from a script, recording every child process against a table of replies,
4
- * and optionally drawing prompts on a virtual terminal driven by key presses.
5
- *
6
- * @author [Sergio Xalambrí](https://sergiodxa.com)
7
- * @copyright Sergio Xalambrí 2026
8
- */
9
- import type { CLI } from "./cli.js";
10
- import type { Group } from "./command.js";
11
- import type { Exec, Stdio } from "./exec.js";
12
- import type { AnyCommandMiddleware } from "./middleware.js";
13
- import type { Env } from "./style.js";
14
- /** What a recorded command answers: exit 0 and no output unless given. */
15
- export interface ExecReply {
16
- /** @default 0 */
17
- code?: number;
18
- stdout?: string;
19
- stderr?: string;
20
- }
21
- /** One command the run executed. */
22
- export interface ExecCall {
23
- command: string[];
24
- cwd: string;
25
- stdio: Stdio;
26
- }
27
- /** How `invoke` runs the CLI. */
28
- export interface InvokeOptions {
29
- /** @default process.cwd() */
30
- cwd?: string;
31
- /** The whole environment the run sees. @default {} */
32
- env?: Env;
33
- /**
34
- * Replies by command, keyed as a shell user writes it (`"git init"`), or an `Exec` that runs
35
- * them. A command with no reply fails, naming itself, so nothing runs by accident.
36
- * @default {}
37
- */
38
- exec?: Readonly<Record<string, ExecReply>> | Exec;
39
- /**
40
- * Prompt answers: an object answers by the flag or argument a prompt fills (its key or flag
41
- * name) or a handler prompt's `name`; an array answers prompts in the order they are asked.
42
- * A prompt with no answer fails the run, naming it.
43
- */
44
- answers?: Readonly<Record<string, unknown>> | readonly unknown[];
45
- /**
46
- * Whether stdin is a terminal. With `false`, nothing is asked: defaults apply, and a
47
- * required input with a prompt is a usage error.
48
- * @default true
49
- */
50
- interactive?: boolean;
51
- /** Draws prompts and tasks on a virtual terminal this wide; prompts then read `keys`. */
52
- tty?: {
53
- columns?: number;
54
- };
55
- /**
56
- * Key presses for `tty`: `"enter"`, `"down"`, `"space"`, `"ctrl+c"`, a character, or longer
57
- * text typed one character at a time.
58
- */
59
- keys?: readonly string[];
60
- /** Aborting it stops the run the way Ctrl+C does. */
61
- signal?: AbortSignal;
62
- /**
63
- * Text piped into the run, which `ctx.stdin.text()` reads. Piped stdin is no terminal, so
64
- * `interactive` then defaults to `false`.
65
- */
66
- stdin?: string;
67
- }
68
- /** What a run printed and how it ended. */
69
- export interface Invocation {
70
- code: number;
71
- stdout: string;
72
- /** What stderr shows: plain text, or the virtual terminal's final screen with `tty`. */
73
- stderr: string;
74
- /** Every command `ctx.exec` ran, in order. */
75
- exec: ExecCall[];
76
- /** With `tty`, the screen after each key press. */
77
- screens: string[];
78
- }
79
- /**
80
- * Runs `cli` with `argv` and answers what it printed.
81
- *
82
- * @example
83
- * let run = await invoke(cli, ["create", "app"], { answers: { template: "app", git: false } });
84
- * expect(run.code).toBe(0);
85
- * @example
86
- * let run = await invoke(cli, ["create"], { tty: { columns: 60 }, keys: ["my-app", "enter", "down", "enter"] });
87
- * run.screens[0]; // the prompt after "my-app" was typed
88
- */
89
- export declare function invoke<Commands extends Group, Chain extends readonly AnyCommandMiddleware[]>(cli: CLI<Commands, Chain>, argv: readonly string[], options?: InvokeOptions): Promise<Invocation>;
package/dist/test.js DELETED
@@ -1,140 +0,0 @@
1
- /**
2
- * `@sdxc/cli/test`: runs a CLI in a test the way a terminal does, collecting both streams,
3
- * answering prompts from a script, recording every child process against a table of replies,
4
- * and optionally drawing prompts on a virtual terminal driven by key presses.
5
- *
6
- * @author [Sergio Xalambrí](https://sergiodxa.com)
7
- * @copyright Sergio Xalambrí 2026
8
- */
9
- import { EventEmitter } from "node:events";
10
- import { failure, success } from "@sdxc/result";
11
- import { runScripted } from "./cli.js";
12
- import { displayCommand, ExecError } from "./exec.js";
13
- import { keysFromScript } from "./keys.js";
14
- import { VirtualTerminal } from "./terminal.js";
15
- /** A stream that keeps what is written to it. */
16
- class Capture {
17
- text = "";
18
- isTTY = false;
19
- write(text) {
20
- this.text += text;
21
- return true;
22
- }
23
- }
24
- /** An exec answering from `replies` and recording each call. */
25
- function tableExec(replies, calls) {
26
- return async (command, request) => {
27
- calls.push({ command: [...command], cwd: request.cwd, stdio: request.stdio });
28
- let shown = displayCommand(command);
29
- let reply = replies[shown];
30
- if (reply === undefined) {
31
- return failure(new ExecError(`invoke() has no reply for ${shown}`, { command, code: null }));
32
- }
33
- let stdout = reply.stdout ?? "";
34
- let stderr = reply.stderr ?? "";
35
- if (request.stdio === "inherit") {
36
- if (stdout !== "")
37
- request.stdout.write(stdout);
38
- if (stderr !== "")
39
- request.stderr.write(stderr);
40
- }
41
- else if (request.stdio === "stderr") {
42
- if (stdout !== "")
43
- request.stderr.write(stdout);
44
- if (stderr !== "")
45
- request.stderr.write(stderr);
46
- }
47
- let collected = request.stdio === "collect"
48
- ? { stdout, stderr, output: stdout + stderr }
49
- : { stdout: "", stderr: "", output: "" };
50
- let code = reply.code ?? 0;
51
- if (code !== 0) {
52
- return failure(new ExecError(`${shown} exited with ${code}`, { command, code, ...collected }));
53
- }
54
- return success({ code, ...collected });
55
- };
56
- }
57
- /**
58
- * A stdin that reports being a terminal or not. Read, a pipe produces `text` and ends, empty
59
- * when there is none; a terminal produces nothing.
60
- */
61
- function scriptedInput(isTTY, text) {
62
- let emitter = new EventEmitter();
63
- let sent = false;
64
- return Object.assign(emitter, {
65
- isTTY,
66
- resume: () => {
67
- if (isTTY || sent)
68
- return;
69
- sent = true;
70
- queueMicrotask(() => {
71
- if (text !== undefined && text !== "")
72
- emitter.emit("data", text);
73
- emitter.emit("end");
74
- });
75
- },
76
- pause: () => undefined,
77
- });
78
- }
79
- /**
80
- * Runs `cli` with `argv` and answers what it printed.
81
- *
82
- * @example
83
- * let run = await invoke(cli, ["create", "app"], { answers: { template: "app", git: false } });
84
- * expect(run.code).toBe(0);
85
- * @example
86
- * let run = await invoke(cli, ["create"], { tty: { columns: 60 }, keys: ["my-app", "enter", "down", "enter"] });
87
- * run.screens[0]; // the prompt after "my-app" was typed
88
- */
89
- export async function invoke(cli, argv, options = {}) {
90
- let stdout = new Capture();
91
- let terminal = options.tty === undefined ? undefined : new VirtualTerminal(options.tty.columns ?? 80);
92
- let capture = new Capture();
93
- let stderr = terminal ?? capture;
94
- let calls = [];
95
- let exec = typeof options.exec === "function"
96
- ? recording(options.exec, calls)
97
- : tableExec(options.exec ?? {}, calls);
98
- let screens = [];
99
- let keyboard;
100
- let pending = false;
101
- if (terminal !== undefined) {
102
- let keys = keysFromScript(options.keys ?? []);
103
- keyboard = () => ({
104
- read() {
105
- if (pending)
106
- screens.push(terminal.screen());
107
- let key = keys.shift();
108
- pending = key !== undefined;
109
- return Promise.resolve(key ?? null);
110
- },
111
- close: () => undefined,
112
- });
113
- }
114
- let interactive = options.interactive ?? options.stdin === undefined;
115
- let code = await runScripted(cli, argv, {
116
- cwd: options.cwd ?? process.cwd(),
117
- env: options.env ?? {},
118
- stdin: scriptedInput(interactive, options.stdin),
119
- stdout,
120
- stderr,
121
- exec,
122
- signal: options.signal ?? new AbortController().signal,
123
- }, { answers: options.answers, keyboard, interactive });
124
- if (pending && terminal !== undefined)
125
- screens.push(terminal.screen());
126
- return {
127
- code,
128
- stdout: stdout.text,
129
- stderr: terminal === undefined ? capture.text : terminal.screen(),
130
- exec: calls,
131
- screens,
132
- };
133
- }
134
- /** Records each call before handing it to `exec`. */
135
- function recording(exec, calls) {
136
- return (command, request) => {
137
- calls.push({ command: [...command], cwd: request.cwd, stdio: request.stdio });
138
- return exec(command, request);
139
- };
140
- }
package/dist/tree.d.ts DELETED
@@ -1,58 +0,0 @@
1
- /**
2
- * The indexed contract a CLI parses against: every command and group with its path, flag
3
- * lookup by long name, alias and short alias, and which commands own each flag name. Built once
4
- * by `createCLI`, so a contract mistake fails at startup instead of on the run that reaches it.
5
- *
6
- * @author [Sergio Xalambrí](https://sergiodxa.com)
7
- * @copyright Sergio Xalambrí 2026
8
- */
9
- import type { ArgumentSpec } from "./argument.js";
10
- import type { Group, Node } from "./command.js";
11
- import type { FlagSpec } from "./flag.js";
12
- /** One declared flag with the property its handler reads it as. */
13
- export interface FlagEntry {
14
- key: string;
15
- spec: FlagSpec;
16
- }
17
- /** One declared argument with the property its handler reads it as. */
18
- export interface ArgumentEntry {
19
- key: string;
20
- spec: ArgumentSpec;
21
- }
22
- /** One command or group in the indexed contract. */
23
- export interface Entry {
24
- node: Node;
25
- /** The names typed after the program to reach it; empty for the root. */
26
- path: readonly string[];
27
- /** The program name and the path, as the help and every error print it. */
28
- title: string;
29
- parent: Entry | undefined;
30
- /** Members by name; empty for a command. */
31
- children: ReadonlyMap<string, Entry>;
32
- arguments: readonly ArgumentEntry[];
33
- flags: readonly FlagEntry[];
34
- /** Flags by long name and by alias. */
35
- longs: ReadonlyMap<string, FlagEntry>;
36
- shorts: ReadonlyMap<string, FlagEntry>;
37
- /** Its handler runs tasks, so the help lists `--no-progress`. */
38
- tasks: boolean;
39
- /** It declares `argument.rest()`, so every token after its name reaches it unparsed. */
40
- rest: boolean;
41
- }
42
- /** The whole indexed contract. */
43
- export interface Tree {
44
- root: Entry;
45
- entries: ReadonlyMap<Node, Entry>;
46
- /** The commands declaring each long name or alias, for naming them in a usage error. */
47
- owners: ReadonlyMap<string, readonly Entry[]>;
48
- }
49
- /**
50
- * Indexes `root` under the program `name`.
51
- *
52
- * @throws Error When a command or group appears twice in the tree, a flag takes a reserved name
53
- * (`help`, `version`, `progress`, `-h`) or a name starting with `no-`, two flags of one command
54
- * share a name, a variadic argument is not the last one, or a command with `argument.rest()`
55
- * declares flags or another argument. Each is a contract mistake that would make a command
56
- * unreachable or ambiguous.
57
- */
58
- export declare function buildTree(name: string, root: Group): Tree;
package/dist/tree.js DELETED
@@ -1,97 +0,0 @@
1
- /**
2
- * The indexed contract a CLI parses against: every command and group with its path, flag
3
- * lookup by long name, alias and short alias, and which commands own each flag name. Built once
4
- * by `createCLI`, so a contract mistake fails at startup instead of on the run that reaches it.
5
- *
6
- * @author [Sergio Xalambrí](https://sergiodxa.com)
7
- * @copyright Sergio Xalambrí 2026
8
- */
9
- import { argumentSpec } from "./argument.js";
10
- import { groupSpec, isGroup } from "./command.js";
11
- import { flagSpec } from "./flag.js";
12
- /** Long names every command answers to, so no flag may take them. */
13
- const RESERVED_NAMES = new Set(["help", "version", "progress"]);
14
- /** Short names every command answers to. */
15
- const RESERVED_SHORTS = new Set(["h"]);
16
- /**
17
- * Indexes `root` under the program `name`.
18
- *
19
- * @throws Error When a command or group appears twice in the tree, a flag takes a reserved name
20
- * (`help`, `version`, `progress`, `-h`) or a name starting with `no-`, two flags of one command
21
- * share a name, a variadic argument is not the last one, or a command with `argument.rest()`
22
- * declares flags or another argument. Each is a contract mistake that would make a command
23
- * unreachable or ambiguous.
24
- */
25
- export function buildTree(name, root) {
26
- let entries = new Map();
27
- let owners = new Map();
28
- /** Indexes one node and everything under it. */
29
- function visit(node, path, parent) {
30
- if (entries.has(node)) {
31
- throw new Error(`${[name, ...path].join(" ")} reuses a command already in the tree`);
32
- }
33
- let title = [name, ...path].join(" ");
34
- let children = new Map();
35
- let flags = [];
36
- let longs = new Map();
37
- let shorts = new Map();
38
- let args = [];
39
- let declaration = isGroup(node) ? groupSpec(node) : node;
40
- for (let [key, declared] of Object.entries(declaration.flags)) {
41
- let flag = { key, spec: flagSpec(declared) };
42
- for (let long of [flag.spec.name, ...flag.spec.aliases]) {
43
- if (RESERVED_NAMES.has(long) || long.startsWith("no-")) {
44
- throw new Error(`${title}: --${long} is reserved for every command`);
45
- }
46
- if (longs.has(long))
47
- throw new Error(`${title}: two flags are named --${long}`);
48
- longs.set(long, flag);
49
- }
50
- let short = flag.spec.short;
51
- if (short !== undefined) {
52
- if (short.length !== 1 || RESERVED_SHORTS.has(short)) {
53
- throw new Error(`${title}: -${short} must be one character other than h`);
54
- }
55
- if (shorts.has(short))
56
- throw new Error(`${title}: two flags are named -${short}`);
57
- shorts.set(short, flag);
58
- }
59
- flags.push(flag);
60
- }
61
- let entriesOfArguments = Object.entries(declaration.arguments);
62
- entriesOfArguments.forEach(([key, declared], index) => {
63
- let spec = argumentSpec(declared);
64
- if (spec.variadic && index < entriesOfArguments.length - 1) {
65
- throw new Error(`${title}: the variadic argument <${key}> must be the last one`);
66
- }
67
- args.push({ key, spec });
68
- });
69
- let rest = args.some((arg) => arg.spec.rest);
70
- if (rest && (isGroup(node) || flags.length > 0 || args.length !== 1)) {
71
- throw new Error(`${title}: argument.rest() is a command's only argument, with no flags`);
72
- }
73
- let entry = {
74
- node,
75
- path,
76
- title,
77
- parent,
78
- children,
79
- arguments: args,
80
- flags,
81
- longs,
82
- shorts,
83
- tasks: declaration.tasks,
84
- rest,
85
- };
86
- entries.set(node, entry);
87
- for (let long of longs.keys())
88
- owners.set(long, [...(owners.get(long) ?? []), entry]);
89
- if (isGroup(node)) {
90
- for (let [member, child] of Object.entries(groupSpec(node).members)) {
91
- children.set(member, visit(child, [...path, member], entry));
92
- }
93
- }
94
- return entry;
95
- }
96
- return { root: visit(root, [], undefined), entries, owners };
97
- }