@sdxc/cli 0.0.0-stage → 2026.10.16

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/LICENSE.md +21 -0
  2. package/README.md +363 -2
  3. package/dist/argument.d.ts +120 -0
  4. package/dist/argument.js +104 -0
  5. package/dist/cli.d.ts +68 -0
  6. package/dist/cli.js +186 -0
  7. package/dist/command.d.ts +139 -0
  8. package/dist/command.js +79 -0
  9. package/dist/context.d.ts +138 -0
  10. package/dist/context.js +175 -0
  11. package/dist/errors.d.ts +67 -0
  12. package/dist/errors.js +82 -0
  13. package/dist/exec.d.ts +83 -0
  14. package/dist/exec.js +136 -0
  15. package/dist/flag.d.ts +175 -0
  16. package/dist/flag.js +91 -0
  17. package/dist/handler.d.ts +73 -0
  18. package/dist/handler.js +30 -0
  19. package/dist/help.d.ts +18 -0
  20. package/dist/help.js +141 -0
  21. package/dist/index.d.ts +35 -0
  22. package/dist/index.js +20 -0
  23. package/dist/interactions.d.ts +27 -0
  24. package/dist/interactions.js +373 -0
  25. package/dist/keys.d.ts +49 -0
  26. package/dist/keys.js +146 -0
  27. package/dist/lazy.d.ts +29 -0
  28. package/dist/lazy.js +23 -0
  29. package/dist/middleware.d.ts +78 -0
  30. package/dist/middleware.js +9 -0
  31. package/dist/parse.d.ts +38 -0
  32. package/dist/parse.js +218 -0
  33. package/dist/prompt.d.ts +153 -0
  34. package/dist/prompt.js +53 -0
  35. package/dist/resolve.d.ts +27 -0
  36. package/dist/resolve.js +294 -0
  37. package/dist/schema.d.ts +27 -0
  38. package/dist/schema.js +34 -0
  39. package/dist/screen.d.ts +42 -0
  40. package/dist/screen.js +114 -0
  41. package/dist/session.d.ts +84 -0
  42. package/dist/session.js +219 -0
  43. package/dist/style.d.ts +50 -0
  44. package/dist/style.js +72 -0
  45. package/dist/suggest.d.ts +15 -0
  46. package/dist/suggest.js +50 -0
  47. package/dist/task.d.ts +47 -0
  48. package/dist/task.js +213 -0
  49. package/dist/terminal.d.ts +21 -0
  50. package/dist/terminal.js +101 -0
  51. package/dist/test.d.ts +89 -0
  52. package/dist/test.js +140 -0
  53. package/dist/tree.d.ts +58 -0
  54. package/dist/tree.js +97 -0
  55. package/package.json +40 -5
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The context a command's middleware and handler receive: its parsed arguments and flags, the
3
+ * process it runs in (working directory, environment, output, prompts, tasks, child processes)
4
+ * and the values middleware published, readable by key or as installed properties.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * One command's run. A test builds one through `invoke()` from `@sdxc/cli/test`; a middleware
11
+ * publishes services with `set()`, which a handler reads as `ctx.<property>`.
12
+ *
13
+ * @example ctx.set(Database, connect(ctx.env.DATABASE_URL), { property: "db" });
14
+ */
15
+ export class CommandContext {
16
+ /**
17
+ * The arguments the run was given, exactly as `cli.run` received them: the command's names,
18
+ * flags and values before parsing, for logging a run or starting it again.
19
+ */
20
+ argv;
21
+ /** The command's names after the program, `["ui", "add"]` for `sdxc ui add`. */
22
+ path;
23
+ /** The positional arguments, parsed, prompted for, or defaulted. */
24
+ arguments;
25
+ /** The flags, parsed, prompted for, or defaulted. */
26
+ flags;
27
+ cwd;
28
+ env;
29
+ /** Styling for stdout; `style.stderr` styles stderr, each off for a stream that is not a terminal. */
30
+ style;
31
+ /** Aborts on Ctrl+C. */
32
+ signal;
33
+ /**
34
+ * Questions a handler asks itself. Each takes a `default`, which answers without a
35
+ * terminal, so the command stays runnable in CI.
36
+ */
37
+ prompt;
38
+ /**
39
+ * Runs async work under a spinner, answering a `Result`; the label is optional.
40
+ *
41
+ * @example let posts = await ctx.task("Fetching posts", (task) => fetch(url, { signal: task.signal }));
42
+ */
43
+ task;
44
+ /**
45
+ * What the command was piped. `stdin.isTTY` is true at a terminal, where `text()` would wait
46
+ * for the user to end input, so a handler reads it only when `isTTY` is false.
47
+ *
48
+ * @example let sql = ctx.flags.execute ?? (ctx.stdin.isTTY ? undefined : await ctx.stdin.text());
49
+ */
50
+ stdin;
51
+ #session;
52
+ #values = new Map();
53
+ #execEnv = {};
54
+ /** @param init - The parsed command and the run it belongs to */
55
+ constructor(init) {
56
+ this.argv = init.argv;
57
+ this.path = init.path;
58
+ this.arguments = init.arguments;
59
+ this.flags = init.flags;
60
+ this.cwd = init.session.cwd;
61
+ this.env = init.session.env;
62
+ this.style = init.session.style;
63
+ this.signal = init.session.signal;
64
+ this.prompt = init.prompt;
65
+ this.task = init.session.tasks.task;
66
+ this.stdin = createStdin(init.session.stdin);
67
+ this.#session = init.session;
68
+ }
69
+ /** Writes to stdout, above any running task. */
70
+ stdout = (text) => {
71
+ this.#session.screen.write(this.#session.stdout, text);
72
+ };
73
+ /** Writes to stderr, above any running task. */
74
+ stderr = (text) => {
75
+ this.#session.screen.write(this.#session.stderr, text);
76
+ };
77
+ /**
78
+ * Runs a program with no shell, answering its exit code and output, or an `ExecError` when
79
+ * it fails to start or exits non-zero. `"inherit"` and `"stderr"` output pauses any spinner
80
+ * until the child exits.
81
+ *
82
+ * @param command - The program and its arguments, passed through exactly
83
+ * @example let status = await ctx.exec(["git", "status", "--short"], { stdio: "collect" });
84
+ */
85
+ exec(command, options) {
86
+ return this.#session.exec(command, {
87
+ ...options,
88
+ env: { ...this.#execEnv, ...options?.env },
89
+ });
90
+ }
91
+ /**
92
+ * Adds variables every child `exec` starts with, over the run's `env` and under a call's own
93
+ * `options.env`, so a middleware hands children what it knows (a trace parent) and a caller
94
+ * still overrides it. An `undefined` value removes the variable from children.
95
+ *
96
+ * @param variables - Merged over what earlier calls added
97
+ * @example ctx.setExecEnv({ TRACEPARENT: toTraceParent(trace) });
98
+ */
99
+ setExecEnv(variables) {
100
+ this.#execEnv = { ...this.#execEnv, ...variables };
101
+ }
102
+ /**
103
+ * Reads a value some middleware published.
104
+ *
105
+ * @param key - The context key to read
106
+ * @returns Its value, the key's default, or `undefined`
107
+ */
108
+ get(key) {
109
+ if (this.#values.has(key))
110
+ return this.#values.get(key);
111
+ return key.defaultValue;
112
+ }
113
+ /**
114
+ * Reads a value an earlier middleware must have published. A middleware sees the bare
115
+ * context, without the properties earlier ones installed, so this is how it reads them
116
+ * without an `undefined` it has no answer for; a missing value fails the run with exit 1.
117
+ *
118
+ * @param key - The context key to read
119
+ * @throws Error When nothing published a value for the key
120
+ * @example let db = ctx.require(Database);
121
+ */
122
+ require(key) {
123
+ let value = this.get(key);
124
+ if (value === undefined || value === null) {
125
+ throw new Error(`${this.path.join(" ")} read a context key that no middleware published`);
126
+ }
127
+ return value;
128
+ }
129
+ /**
130
+ * Whether a value has been published for a key.
131
+ *
132
+ * @param key - The context key to check
133
+ */
134
+ has(key) {
135
+ return this.#values.has(key);
136
+ }
137
+ /**
138
+ * Publishes a value for a key, optionally installing it as a read-only property so handlers
139
+ * read `ctx.db` rather than `ctx.get(Database)`.
140
+ *
141
+ * @param key - The context key to write
142
+ * @param value - The value to publish
143
+ * @param options - The property name to install it under
144
+ */
145
+ set(key, value, options) {
146
+ this.#values.set(key, value);
147
+ if (options === undefined)
148
+ return;
149
+ Object.defineProperty(this, options.property, {
150
+ value,
151
+ configurable: true,
152
+ enumerable: true,
153
+ writable: false,
154
+ });
155
+ }
156
+ }
157
+ /** A reader over `input` that reads it to the end at most once. */
158
+ function createStdin(input) {
159
+ let reading;
160
+ return {
161
+ isTTY: input.isTTY === true,
162
+ text() {
163
+ reading ??= new Promise((resolve, reject) => {
164
+ let chunks = [];
165
+ input.on("data", (chunk) => {
166
+ chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
167
+ });
168
+ input.once("end", () => resolve(chunks.join("")));
169
+ input.once("error", reject);
170
+ input.resume();
171
+ });
172
+ return reading;
173
+ },
174
+ };
175
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The failures a command answers and the exit codes they map to: a usage failure prints the
3
+ * command's help and exits 2, a cancelled one exits 130 in silence, an exit failure picks its
4
+ * own code, and any other failure prints its message and exits 1.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Failure, Success } from "@sdxc/result";
10
+ /** The exit code each outcome of a run maps to, shared by every command line built here. */
11
+ export declare const EXIT_CODES: {
12
+ readonly success: 0;
13
+ readonly failure: 1;
14
+ readonly usage: 2;
15
+ readonly cancelled: 130;
16
+ };
17
+ /** A command line the user wrote wrong; the run prints the message and the command's help. */
18
+ export declare class UsageError extends Error {
19
+ name: string;
20
+ }
21
+ /**
22
+ * The user stopped the run, with Ctrl+C or Escape at a prompt or Ctrl+C during a task. The run
23
+ * exits 130 and writes nothing more, since the user already knows why it stopped.
24
+ */
25
+ export declare class CancelledError extends Error {
26
+ name: string;
27
+ /** @param message - Read only by code that logs the failure; the run itself prints nothing */
28
+ constructor(message?: string);
29
+ }
30
+ /**
31
+ * A failure that picks its own exit code. An empty message prints nothing, for a command that
32
+ * already reported what went wrong, such as a test runner that printed its failed tests.
33
+ */
34
+ export declare class ExitError extends Error {
35
+ name: string;
36
+ /** The process exit code the run answers. */
37
+ code: number;
38
+ /**
39
+ * @param code - The exit code
40
+ * @param message - What the run prints on stderr, nothing when empty
41
+ */
42
+ constructor(code: number, message?: string);
43
+ }
44
+ /**
45
+ * A successful outcome; a command that has nothing to hand back calls it with no argument.
46
+ *
47
+ * @example return success();
48
+ */
49
+ export declare function success(): Success<undefined>;
50
+ export declare function success<T>(data: T): Success<T>;
51
+ /**
52
+ * A usage failure: the run prints `message`, then the help of the command that answered it, and
53
+ * exits 2.
54
+ *
55
+ * @example if (ctx.flags.prompt && !ctx.flags.from) return usage("--prompt needs --from");
56
+ */
57
+ export declare function usage(message: string): Failure<UsageError>;
58
+ /** A cancelled failure, which exits 130 without printing anything. */
59
+ export declare function cancelled(): Failure<CancelledError>;
60
+ /**
61
+ * A failure that exits with `code`, printing `message` when one is given.
62
+ *
63
+ * @example return run.failed > 0 ? exit(1) : success();
64
+ */
65
+ export declare function exit(code: number, message?: string): Failure<ExitError>;
66
+ /** The exit code a run's failure maps to. */
67
+ export declare function exitCodeOf(error: Error): number;
package/dist/errors.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The failures a command answers and the exit codes they map to: a usage failure prints the
3
+ * command's help and exits 2, a cancelled one exits 130 in silence, an exit failure picks its
4
+ * own code, and any other failure prints its message and exits 1.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { failure, success as succeed } from "@sdxc/result";
10
+ /** The exit code each outcome of a run maps to, shared by every command line built here. */
11
+ export const EXIT_CODES = {
12
+ success: 0,
13
+ failure: 1,
14
+ usage: 2,
15
+ cancelled: 130,
16
+ };
17
+ /** A command line the user wrote wrong; the run prints the message and the command's help. */
18
+ export class UsageError extends Error {
19
+ name = "UsageError";
20
+ }
21
+ /**
22
+ * The user stopped the run, with Ctrl+C or Escape at a prompt or Ctrl+C during a task. The run
23
+ * exits 130 and writes nothing more, since the user already knows why it stopped.
24
+ */
25
+ export class CancelledError extends Error {
26
+ name = "CancelledError";
27
+ /** @param message - Read only by code that logs the failure; the run itself prints nothing */
28
+ constructor(message = "Cancelled") {
29
+ super(message);
30
+ }
31
+ }
32
+ /**
33
+ * A failure that picks its own exit code. An empty message prints nothing, for a command that
34
+ * already reported what went wrong, such as a test runner that printed its failed tests.
35
+ */
36
+ export class ExitError extends Error {
37
+ name = "ExitError";
38
+ /** The process exit code the run answers. */
39
+ code;
40
+ /**
41
+ * @param code - The exit code
42
+ * @param message - What the run prints on stderr, nothing when empty
43
+ */
44
+ constructor(code, message = "") {
45
+ super(message);
46
+ this.code = code;
47
+ }
48
+ }
49
+ export function success(data) {
50
+ return succeed(data);
51
+ }
52
+ /**
53
+ * A usage failure: the run prints `message`, then the help of the command that answered it, and
54
+ * exits 2.
55
+ *
56
+ * @example if (ctx.flags.prompt && !ctx.flags.from) return usage("--prompt needs --from");
57
+ */
58
+ export function usage(message) {
59
+ return failure(new UsageError(message));
60
+ }
61
+ /** A cancelled failure, which exits 130 without printing anything. */
62
+ export function cancelled() {
63
+ return failure(new CancelledError());
64
+ }
65
+ /**
66
+ * A failure that exits with `code`, printing `message` when one is given.
67
+ *
68
+ * @example return run.failed > 0 ? exit(1) : success();
69
+ */
70
+ export function exit(code, message) {
71
+ return failure(new ExitError(code, message));
72
+ }
73
+ /** The exit code a run's failure maps to. */
74
+ export function exitCodeOf(error) {
75
+ if (error instanceof UsageError)
76
+ return EXIT_CODES.usage;
77
+ if (error instanceof CancelledError)
78
+ return EXIT_CODES.cancelled;
79
+ if (error instanceof ExitError)
80
+ return error.code;
81
+ return EXIT_CODES.failure;
82
+ }
package/dist/exec.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Child processes without a shell: arguments reach the program exactly as given, so nothing in
3
+ * them is expanded or split. The answer is a `Result` carrying the exit code and what the child
4
+ * printed, and an aborted signal stops the child and answers a cancelled failure.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { Output } from "./screen.js";
11
+ import type { Env } from "./style.js";
12
+ import { CancelledError } from "./errors.js";
13
+ /**
14
+ * Where a child's output goes: `"inherit"` to the CLI's own streams, `"ignore"` nowhere,
15
+ * `"collect"` into the answer only, and `"stderr"` sends the child's stdout to the CLI's stderr,
16
+ * so a command whose stdout is piped keeps it clean while an install prints.
17
+ */
18
+ export type Stdio = "inherit" | "ignore" | "collect" | "stderr";
19
+ /** How `ctx.exec` runs one command. */
20
+ export interface ExecOptions {
21
+ /** @default the command's working directory */
22
+ cwd?: string;
23
+ /** Variables set on top of the command's environment. */
24
+ env?: Env;
25
+ /** @default "inherit" */
26
+ stdio?: Stdio;
27
+ /** Stops the child. @default the run's signal, which aborts on Ctrl+C */
28
+ signal?: AbortSignal;
29
+ /** Text written to the child's stdin, which is otherwise closed (or the terminal for "inherit"). */
30
+ input?: string;
31
+ }
32
+ /** Everything an {@link Exec} receives, with every default filled in. */
33
+ export interface ExecRequest {
34
+ cwd: string;
35
+ env: Env;
36
+ stdio: Stdio;
37
+ signal: AbortSignal;
38
+ input: string | undefined;
39
+ /** The CLI's streams, for `"inherit"` and `"stderr"` output. */
40
+ stdout: Output;
41
+ stderr: Output;
42
+ }
43
+ /** A child that exited 0. */
44
+ export interface ExecOutput {
45
+ code: number;
46
+ /** What the child printed on stdout, empty unless it was collected. */
47
+ stdout: string;
48
+ stderr: string;
49
+ /** Both streams in the order they arrived. */
50
+ output: string;
51
+ }
52
+ /** A child that could not start or exited with a code other than 0. */
53
+ export declare class ExecError extends Error {
54
+ name: string;
55
+ /** The command as given. */
56
+ command: readonly string[];
57
+ /** The exit code, or `null` when the child never started or ended on a signal. */
58
+ code: number | null;
59
+ stdout: string;
60
+ stderr: string;
61
+ output: string;
62
+ /**
63
+ * @param message - What went wrong
64
+ * @param details - The command and what it printed
65
+ */
66
+ constructor(message: string, details: {
67
+ command: readonly string[];
68
+ code: number | null;
69
+ stdout?: string;
70
+ stderr?: string;
71
+ output?: string;
72
+ cause?: unknown;
73
+ });
74
+ }
75
+ /** Runs one command; `cli.run`'s `exec` option replaces it, as the test harness does. */
76
+ export type Exec = (command: readonly string[], request: ExecRequest) => Promise<Result<ExecOutput, ExecError | CancelledError>>;
77
+ /** The command as a shell user would read it, for messages. */
78
+ export declare function displayCommand(command: readonly string[]): string;
79
+ /**
80
+ * Spawns `command` with `node:child_process`. Output bound for the process's own streams is
81
+ * inherited directly; output bound for any other stream is piped and forwarded to it.
82
+ */
83
+ export declare const spawnExec: Exec;
package/dist/exec.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Child processes without a shell: arguments reach the program exactly as given, so nothing in
3
+ * them is expanded or split. The answer is a `Result` carrying the exit code and what the child
4
+ * printed, and an aborted signal stops the child and answers a cancelled failure.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { failure, success } from "@sdxc/result";
11
+ import { CancelledError } from "./errors.js";
12
+ /** A child that could not start or exited with a code other than 0. */
13
+ export class ExecError extends Error {
14
+ name = "ExecError";
15
+ /** The command as given. */
16
+ command;
17
+ /** The exit code, or `null` when the child never started or ended on a signal. */
18
+ code;
19
+ stdout;
20
+ stderr;
21
+ output;
22
+ /**
23
+ * @param message - What went wrong
24
+ * @param details - The command and what it printed
25
+ */
26
+ constructor(message, details) {
27
+ super(message, { cause: details.cause });
28
+ this.command = details.command;
29
+ this.code = details.code;
30
+ this.stdout = details.stdout ?? "";
31
+ this.stderr = details.stderr ?? "";
32
+ this.output = details.output ?? "";
33
+ }
34
+ }
35
+ /** The command as a shell user would read it, for messages. */
36
+ export function displayCommand(command) {
37
+ return command
38
+ .map((part) => (/^[\w./:=@-]+$/.test(part) ? part : JSON.stringify(part)))
39
+ .join(" ");
40
+ }
41
+ /**
42
+ * Spawns `command` with `node:child_process`. Output bound for the process's own streams is
43
+ * inherited directly; output bound for any other stream is piped and forwarded to it.
44
+ */
45
+ export const spawnExec = (command, request) => new Promise((resolve) => {
46
+ let settled = false;
47
+ let settle = (outcome) => {
48
+ if (settled)
49
+ return;
50
+ settled = true;
51
+ resolve(outcome);
52
+ };
53
+ let [program, ...args] = command;
54
+ if (program === undefined) {
55
+ settle(failure(new ExecError("exec needs a command", { command, code: null })));
56
+ return;
57
+ }
58
+ if (request.signal.aborted) {
59
+ settle(failure(new CancelledError()));
60
+ return;
61
+ }
62
+ let shown = displayCommand(command);
63
+ let direct = (target, stream) => target === stream;
64
+ let outTarget = request.stdio === "inherit"
65
+ ? request.stdout
66
+ : request.stdio === "stderr"
67
+ ? request.stderr
68
+ : null;
69
+ let errTarget = request.stdio === "inherit" || request.stdio === "stderr" ? request.stderr : null;
70
+ let stdout = request.stdio === "ignore"
71
+ ? "ignore"
72
+ : outTarget === null
73
+ ? "pipe"
74
+ : direct(outTarget, process.stdout)
75
+ ? "inherit"
76
+ : direct(outTarget, process.stderr)
77
+ ? 2
78
+ : "pipe";
79
+ let stderr = request.stdio === "ignore"
80
+ ? "ignore"
81
+ : errTarget !== null && direct(errTarget, process.stderr)
82
+ ? "inherit"
83
+ : "pipe";
84
+ let stdin = request.input !== undefined ? "pipe" : request.stdio === "inherit" ? "inherit" : "ignore";
85
+ let child = spawn(program, args, {
86
+ cwd: request.cwd,
87
+ env: { ...request.env },
88
+ shell: false,
89
+ stdio: [stdin, stdout, stderr],
90
+ });
91
+ let collected = { stdout: "", stderr: "", output: "" };
92
+ child.stdout?.on("data", (chunk) => {
93
+ let text = chunk.toString();
94
+ if (outTarget === null) {
95
+ collected.stdout += text;
96
+ collected.output += text;
97
+ }
98
+ else
99
+ outTarget.write(text);
100
+ });
101
+ child.stderr?.on("data", (chunk) => {
102
+ let text = chunk.toString();
103
+ if (errTarget === null) {
104
+ collected.stderr += text;
105
+ collected.output += text;
106
+ }
107
+ else
108
+ errTarget.write(text);
109
+ });
110
+ if (request.input !== undefined)
111
+ child.stdin?.end(request.input);
112
+ let abort = () => child.kill("SIGTERM");
113
+ request.signal.addEventListener("abort", abort, { once: true });
114
+ child.once("error", (error) => {
115
+ request.signal.removeEventListener("abort", abort);
116
+ settle(failure(new ExecError(`${shown} could not start: ${error.message}`, {
117
+ command,
118
+ code: null,
119
+ cause: error,
120
+ ...collected,
121
+ })));
122
+ });
123
+ child.once("close", (code, signal) => {
124
+ request.signal.removeEventListener("abort", abort);
125
+ if (request.signal.aborted) {
126
+ settle(failure(new CancelledError()));
127
+ return;
128
+ }
129
+ if (code === 0) {
130
+ settle(success({ code, ...collected }));
131
+ return;
132
+ }
133
+ let reason = code === null ? `was stopped by ${signal ?? "a signal"}` : `exited with ${code}`;
134
+ settle(failure(new ExecError(`${shown} ${reason}`, { command, code, ...collected })));
135
+ });
136
+ });