@textui/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/app.d.ts +114 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +372 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +16 -0
- package/dist/commands.d.ts +11 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +428 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/registry.d.ts +81 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +226 -0
- package/package.json +61 -0
- package/src/app.ts +486 -0
- package/src/bin.ts +5 -0
- package/src/cli.ts +18 -0
- package/src/commands.ts +502 -0
- package/src/index.ts +4 -0
- package/src/registry.ts +321 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { stringWidth } from '@textui/core';
|
|
2
|
+
import type { TerminalInput, TerminalOutput } from '@textui/terminal';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CLI application primitives.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately small: commands, options, help, and prompts. The point is not
|
|
8
|
+
* to replace a full CLI ecosystem - it is that an application built on TextUI
|
|
9
|
+
* should be able to combine plain commands with interactive screens without
|
|
10
|
+
* reaching for a second framework and a second idea of what an argument is.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type OptionType = 'string' | 'number' | 'boolean';
|
|
14
|
+
|
|
15
|
+
export interface OptionSpec {
|
|
16
|
+
name: string;
|
|
17
|
+
type?: OptionType;
|
|
18
|
+
/** Single-letter alias: `-w`. */
|
|
19
|
+
short?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
default?: unknown;
|
|
22
|
+
required?: boolean;
|
|
23
|
+
/** Accept the flag more than once, collecting values. */
|
|
24
|
+
multiple?: boolean;
|
|
25
|
+
choices?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ArgumentSpec {
|
|
29
|
+
name: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
required?: boolean;
|
|
32
|
+
/** Swallow the rest of the command line. */
|
|
33
|
+
variadic?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ParsedArgs {
|
|
37
|
+
options: Record<string, unknown>;
|
|
38
|
+
positionals: string[];
|
|
39
|
+
/** Everything after a bare `--`. */
|
|
40
|
+
rest: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CliCommand {
|
|
44
|
+
name: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
aliases?: string[];
|
|
47
|
+
arguments?: ArgumentSpec[];
|
|
48
|
+
options?: OptionSpec[];
|
|
49
|
+
/** Subcommands. A command with children may still have a handler. */
|
|
50
|
+
commands?: CliCommand[];
|
|
51
|
+
examples?: string[];
|
|
52
|
+
hidden?: boolean;
|
|
53
|
+
run?(args: ParsedArgs, cli: Cli): Promise<number | void> | number | void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CliOptions {
|
|
57
|
+
name: string;
|
|
58
|
+
version?: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
commands: CliCommand[];
|
|
61
|
+
/** Options accepted before the command name. */
|
|
62
|
+
globalOptions?: OptionSpec[];
|
|
63
|
+
/** Where output goes. Swappable so tests can capture it. */
|
|
64
|
+
write?(text: string): void;
|
|
65
|
+
writeError?(text: string): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class CliError extends Error {
|
|
69
|
+
constructor(message: string, readonly exitCode = 1) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = 'CliError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const HELP_OPTION: OptionSpec = { name: 'help', short: 'h', type: 'boolean', description: 'Show this help' };
|
|
76
|
+
const VERSION_OPTION: OptionSpec = { name: 'version', short: 'v', type: 'boolean', description: 'Print the version' };
|
|
77
|
+
|
|
78
|
+
export class Cli {
|
|
79
|
+
readonly name: string;
|
|
80
|
+
readonly version: string;
|
|
81
|
+
|
|
82
|
+
constructor(private options: CliOptions) {
|
|
83
|
+
this.name = options.name;
|
|
84
|
+
this.version = options.version ?? '0.0.0';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
write(text: string): void {
|
|
88
|
+
(this.options.write ?? ((t: string) => process.stdout.write(t)))(text);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
writeError(text: string): void {
|
|
92
|
+
(this.options.writeError ?? ((t: string) => process.stderr.write(t)))(text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Run and return an exit code. Never throws for a user-facing problem. */
|
|
96
|
+
async run(argv: string[]): Promise<number> {
|
|
97
|
+
try {
|
|
98
|
+
return await this.dispatch(argv);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (err instanceof CliError) {
|
|
101
|
+
this.writeError(`${this.name}: ${err.message}\n`);
|
|
102
|
+
return err.exitCode;
|
|
103
|
+
}
|
|
104
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
105
|
+
this.writeError(`${this.name}: ${message}\n`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async dispatch(argv: string[]): Promise<number> {
|
|
111
|
+
const path: CliCommand[] = [];
|
|
112
|
+
let commands = this.options.commands;
|
|
113
|
+
let index = 0;
|
|
114
|
+
|
|
115
|
+
while (index < argv.length) {
|
|
116
|
+
const token = argv[index] as string;
|
|
117
|
+
if (token.startsWith('-')) break;
|
|
118
|
+
const found = commands.find(
|
|
119
|
+
(c) => c.name === token || (c.aliases ?? []).includes(token),
|
|
120
|
+
);
|
|
121
|
+
if (!found) break;
|
|
122
|
+
path.push(found);
|
|
123
|
+
commands = found.commands ?? [];
|
|
124
|
+
index++;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const command = path[path.length - 1];
|
|
128
|
+
const rest = argv.slice(index);
|
|
129
|
+
|
|
130
|
+
// Help and version are answered before anything is validated, because a
|
|
131
|
+
// user asking for help has by definition not got the arguments right yet.
|
|
132
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
133
|
+
this.write(this.help(path));
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
if (path.length === 0 && (rest.includes('--version') || rest.includes('-v'))) {
|
|
137
|
+
this.write(`${this.version}\n`);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
if (!command) {
|
|
141
|
+
this.write(this.help([]));
|
|
142
|
+
return argv.length === 0 ? 0 : 1;
|
|
143
|
+
}
|
|
144
|
+
if (!command.run) {
|
|
145
|
+
this.write(this.help(path));
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const specs = [
|
|
150
|
+
...(this.options.globalOptions ?? []),
|
|
151
|
+
...(command.options ?? []),
|
|
152
|
+
HELP_OPTION,
|
|
153
|
+
];
|
|
154
|
+
const parsed = parseArgs(rest, specs);
|
|
155
|
+
validate(command, parsed);
|
|
156
|
+
|
|
157
|
+
const code = await command.run(parsed, this);
|
|
158
|
+
return typeof code === 'number' ? code : 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
help(path: CliCommand[] = []): string {
|
|
162
|
+
const command = path[path.length - 1];
|
|
163
|
+
const lines: string[] = [];
|
|
164
|
+
|
|
165
|
+
if (!command) {
|
|
166
|
+
lines.push(this.options.description ?? `${this.name} ${this.version}`);
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push(`Usage: ${this.name} <command> [options]`);
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push('Commands:');
|
|
171
|
+
lines.push(...describeCommands(this.options.commands));
|
|
172
|
+
if (this.options.globalOptions?.length) {
|
|
173
|
+
lines.push('');
|
|
174
|
+
lines.push('Options:');
|
|
175
|
+
lines.push(...describeOptions([...this.options.globalOptions, HELP_OPTION, VERSION_OPTION]));
|
|
176
|
+
}
|
|
177
|
+
lines.push('');
|
|
178
|
+
lines.push(`Run \`${this.name} <command> --help\` for details.`);
|
|
179
|
+
return lines.join('\n') + '\n';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const names = path.map((c) => c.name).join(' ');
|
|
183
|
+
const args = (command.arguments ?? [])
|
|
184
|
+
.map((a) => (a.required ? `<${a.name}${a.variadic ? '...' : ''}>` : `[${a.name}${a.variadic ? '...' : ''}]`))
|
|
185
|
+
.join(' ');
|
|
186
|
+
|
|
187
|
+
if (command.description) {
|
|
188
|
+
lines.push(command.description);
|
|
189
|
+
lines.push('');
|
|
190
|
+
}
|
|
191
|
+
lines.push(`Usage: ${this.name} ${names}${args ? ` ${args}` : ''} [options]`);
|
|
192
|
+
|
|
193
|
+
if (command.arguments?.length) {
|
|
194
|
+
lines.push('');
|
|
195
|
+
lines.push('Arguments:');
|
|
196
|
+
const width = Math.max(...command.arguments.map((a) => stringWidth(a.name)));
|
|
197
|
+
for (const arg of command.arguments) {
|
|
198
|
+
lines.push(` ${arg.name.padEnd(width)} ${arg.description ?? ''}`.trimEnd());
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (command.commands?.length) {
|
|
203
|
+
lines.push('');
|
|
204
|
+
lines.push('Commands:');
|
|
205
|
+
lines.push(...describeCommands(command.commands));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const options = [...(command.options ?? []), HELP_OPTION];
|
|
209
|
+
lines.push('');
|
|
210
|
+
lines.push('Options:');
|
|
211
|
+
lines.push(...describeOptions(options));
|
|
212
|
+
|
|
213
|
+
if (command.examples?.length) {
|
|
214
|
+
lines.push('');
|
|
215
|
+
lines.push('Examples:');
|
|
216
|
+
for (const example of command.examples) lines.push(` ${example}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return lines.join('\n') + '\n';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function describeCommands(commands: CliCommand[]): string[] {
|
|
224
|
+
const visible = commands.filter((c) => !c.hidden);
|
|
225
|
+
if (visible.length === 0) return [' (none)'];
|
|
226
|
+
const width = Math.max(...visible.map((c) => stringWidth(c.name)));
|
|
227
|
+
return visible.map((c) => ` ${c.name.padEnd(width)} ${c.description ?? ''}`.trimEnd());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function describeOptions(options: OptionSpec[]): string[] {
|
|
231
|
+
const labels = options.map((o) => {
|
|
232
|
+
const short = o.short ? `-${o.short}, ` : ' ';
|
|
233
|
+
const value = o.type === 'boolean' || o.type === undefined ? '' : ` <${o.type}>`;
|
|
234
|
+
return `${short}--${o.name}${value}`;
|
|
235
|
+
});
|
|
236
|
+
const width = Math.max(...labels.map(stringWidth));
|
|
237
|
+
return options.map((o, i) => {
|
|
238
|
+
const extra: string[] = [];
|
|
239
|
+
if (o.choices) extra.push(`(${o.choices.join(' | ')})`);
|
|
240
|
+
if (o.default !== undefined) extra.push(`[default: ${String(o.default)}]`);
|
|
241
|
+
const suffix = extra.length ? ` ${extra.join(' ')}` : '';
|
|
242
|
+
return ` ${(labels[i] as string).padEnd(width)} ${o.description ?? ''}${suffix}`.trimEnd();
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Parse a command line.
|
|
248
|
+
*
|
|
249
|
+
* Supports `--flag`, `--no-flag`, `--key value`, `--key=value`, `-k value`,
|
|
250
|
+
* bundled short flags `-abc`, and `--` to stop parsing. Unknown options are an
|
|
251
|
+
* error rather than a positional, because a typo silently becoming a filename
|
|
252
|
+
* is how a CLI deletes the wrong thing.
|
|
253
|
+
*/
|
|
254
|
+
export function parseArgs(argv: string[], specs: OptionSpec[]): ParsedArgs {
|
|
255
|
+
const byName = new Map(specs.map((s) => [s.name, s]));
|
|
256
|
+
const byShort = new Map(specs.filter((s) => s.short).map((s) => [s.short as string, s]));
|
|
257
|
+
|
|
258
|
+
const options: Record<string, unknown> = {};
|
|
259
|
+
const positionals: string[] = [];
|
|
260
|
+
const rest: string[] = [];
|
|
261
|
+
|
|
262
|
+
for (const spec of specs) {
|
|
263
|
+
if (spec.default !== undefined) options[spec.name] = spec.default;
|
|
264
|
+
else if (spec.type === 'boolean') options[spec.name] = false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const assign = (spec: OptionSpec, raw: string | boolean): void => {
|
|
268
|
+
const value = coerce(spec, raw);
|
|
269
|
+
if (spec.choices && typeof value === 'string' && !spec.choices.includes(value)) {
|
|
270
|
+
throw new CliError(
|
|
271
|
+
`--${spec.name} must be one of: ${spec.choices.join(', ')} (got "${value}")`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (spec.multiple) {
|
|
275
|
+
const list = Array.isArray(options[spec.name]) ? (options[spec.name] as unknown[]) : [];
|
|
276
|
+
options[spec.name] = [...list, value];
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
options[spec.name] = value;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
let i = 0;
|
|
283
|
+
while (i < argv.length) {
|
|
284
|
+
const token = argv[i] as string;
|
|
285
|
+
|
|
286
|
+
if (token === '--') {
|
|
287
|
+
rest.push(...argv.slice(i + 1));
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (token.startsWith('--')) {
|
|
292
|
+
const body = token.slice(2);
|
|
293
|
+
const eq = body.indexOf('=');
|
|
294
|
+
const name = eq === -1 ? body : body.slice(0, eq);
|
|
295
|
+
const inline = eq === -1 ? undefined : body.slice(eq + 1);
|
|
296
|
+
|
|
297
|
+
if (name.startsWith('no-') && byName.has(name.slice(3))) {
|
|
298
|
+
assign(byName.get(name.slice(3)) as OptionSpec, false);
|
|
299
|
+
i++;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const spec = byName.get(name);
|
|
304
|
+
if (!spec) throw new CliError(`unknown option --${name}`);
|
|
305
|
+
|
|
306
|
+
if (spec.type === 'boolean' || spec.type === undefined) {
|
|
307
|
+
assign(spec, inline === undefined ? true : inline !== 'false');
|
|
308
|
+
i++;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const value = inline ?? argv[i + 1];
|
|
313
|
+
if (value === undefined) throw new CliError(`--${name} needs a value`);
|
|
314
|
+
assign(spec, value);
|
|
315
|
+
i += inline === undefined ? 2 : 1;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (token.startsWith('-') && token.length > 1) {
|
|
320
|
+
const letters = [...token.slice(1)];
|
|
321
|
+
for (let j = 0; j < letters.length; j++) {
|
|
322
|
+
const letter = letters[j] as string;
|
|
323
|
+
const spec = byShort.get(letter);
|
|
324
|
+
if (!spec) throw new CliError(`unknown option -${letter}`);
|
|
325
|
+
|
|
326
|
+
if (spec.type === 'boolean' || spec.type === undefined) {
|
|
327
|
+
assign(spec, true);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// A value-taking short flag consumes the rest of the bundle, or the
|
|
331
|
+
// next argument: `-w80` and `-w 80` both work.
|
|
332
|
+
const inline = letters.slice(j + 1).join('');
|
|
333
|
+
const value = inline !== '' ? inline : argv[++i];
|
|
334
|
+
if (value === undefined) throw new CliError(`-${letter} needs a value`);
|
|
335
|
+
assign(spec, value);
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
i++;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
positionals.push(token);
|
|
343
|
+
i++;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { options, positionals, rest };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function coerce(spec: OptionSpec, raw: string | boolean): unknown {
|
|
350
|
+
if (typeof raw === 'boolean') return raw;
|
|
351
|
+
if (spec.type === 'number') {
|
|
352
|
+
const n = Number(raw);
|
|
353
|
+
if (Number.isNaN(n)) throw new CliError(`--${spec.name} must be a number (got "${raw}")`);
|
|
354
|
+
return n;
|
|
355
|
+
}
|
|
356
|
+
if (spec.type === 'boolean') return raw !== 'false';
|
|
357
|
+
return raw;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function validate(command: CliCommand, parsed: ParsedArgs): void {
|
|
361
|
+
for (const spec of command.options ?? []) {
|
|
362
|
+
if (spec.required && parsed.options[spec.name] === undefined) {
|
|
363
|
+
throw new CliError(`--${spec.name} is required`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const required = (command.arguments ?? []).filter((a) => a.required);
|
|
367
|
+
if (parsed.positionals.length < required.length) {
|
|
368
|
+
const missing = required.slice(parsed.positionals.length).map((a) => a.name);
|
|
369
|
+
throw new CliError(`missing argument: ${missing.join(', ')}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ------------------------------------------------------------------ prompts
|
|
374
|
+
|
|
375
|
+
export interface PromptOptions {
|
|
376
|
+
message: string;
|
|
377
|
+
default?: string;
|
|
378
|
+
/** Hide what is typed. */
|
|
379
|
+
mask?: boolean;
|
|
380
|
+
validate?(value: string): string | null;
|
|
381
|
+
input?: TerminalInput;
|
|
382
|
+
output?: TerminalOutput;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A line prompt for non-interactive stretches of a CLI.
|
|
387
|
+
*
|
|
388
|
+
* This is deliberately not a TUI: mixing a full-screen renderer into a prompt
|
|
389
|
+
* means a resize or a stray escape sequence can eat the answer. When the input
|
|
390
|
+
* is not a terminal it returns the default rather than hanging, which is what
|
|
391
|
+
* makes a CLI usable from a script.
|
|
392
|
+
*/
|
|
393
|
+
export async function promptLine(options: PromptOptions): Promise<string> {
|
|
394
|
+
const input = options.input ?? process.stdin;
|
|
395
|
+
const output = options.output ?? process.stdout;
|
|
396
|
+
|
|
397
|
+
const suffix = options.default ? ` (${options.default})` : '';
|
|
398
|
+
if (!input.isTTY) return options.default ?? '';
|
|
399
|
+
|
|
400
|
+
for (;;) {
|
|
401
|
+
output.write(`${options.message}${suffix}: `);
|
|
402
|
+
const answer = await readLine(input, options.mask ? output : undefined);
|
|
403
|
+
const value = answer.trim() === '' ? options.default ?? '' : answer.trim();
|
|
404
|
+
|
|
405
|
+
const problem = options.validate?.(value);
|
|
406
|
+
if (!problem) {
|
|
407
|
+
if (options.mask) output.write('\n');
|
|
408
|
+
return value;
|
|
409
|
+
}
|
|
410
|
+
output.write(` ${problem}\n`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function promptConfirm(
|
|
415
|
+
message: string,
|
|
416
|
+
options: { default?: boolean; input?: TerminalInput; output?: TerminalOutput } = {},
|
|
417
|
+
): Promise<boolean> {
|
|
418
|
+
const fallback = options.default ?? false;
|
|
419
|
+
const answer = await promptLine({
|
|
420
|
+
message: `${message} ${fallback ? '[Y/n]' : '[y/N]'}`,
|
|
421
|
+
default: fallback ? 'y' : 'n',
|
|
422
|
+
input: options.input,
|
|
423
|
+
output: options.output,
|
|
424
|
+
});
|
|
425
|
+
return /^y(es)?$/i.test(answer);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function promptSelect(
|
|
429
|
+
message: string,
|
|
430
|
+
choices: { value: string; label: string }[],
|
|
431
|
+
options: { input?: TerminalInput; output?: TerminalOutput } = {},
|
|
432
|
+
): Promise<string> {
|
|
433
|
+
const output = options.output ?? process.stdout;
|
|
434
|
+
output.write(`${message}\n`);
|
|
435
|
+
choices.forEach((choice, i) => output.write(` ${i + 1}) ${choice.label}\n`));
|
|
436
|
+
|
|
437
|
+
const answer = await promptLine({
|
|
438
|
+
message: `Choose 1-${choices.length}`,
|
|
439
|
+
default: '1',
|
|
440
|
+
input: options.input,
|
|
441
|
+
output,
|
|
442
|
+
validate: (value: string) => {
|
|
443
|
+
const n = Number(value);
|
|
444
|
+
return Number.isInteger(n) && n >= 1 && n <= choices.length ? null : 'Out of range';
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
return (choices[Number(answer) - 1] as { value: string }).value;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const CTRL_C = String.fromCharCode(3);
|
|
451
|
+
const BACKSPACE = String.fromCharCode(8);
|
|
452
|
+
const DEL = String.fromCharCode(127);
|
|
453
|
+
|
|
454
|
+
function readLine(input: TerminalInput, maskTo?: TerminalOutput): Promise<string> {
|
|
455
|
+
return new Promise((resolve) => {
|
|
456
|
+
let buffer = '';
|
|
457
|
+
const wasRaw = input.isRaw;
|
|
458
|
+
|
|
459
|
+
const finish = (): void => {
|
|
460
|
+
input.off('data', onData);
|
|
461
|
+
if (maskTo && wasRaw === false && input.setRawMode) input.setRawMode(false);
|
|
462
|
+
input.pause();
|
|
463
|
+
resolve(buffer);
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const onData = (chunk: Buffer | string): void => {
|
|
467
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
468
|
+
for (const char of text) {
|
|
469
|
+
if (char === '\n' || char === '\r') return finish();
|
|
470
|
+
// ctrl+c during a prompt has to exit, not be typed into the answer.
|
|
471
|
+
if (char === CTRL_C) process.exit(130);
|
|
472
|
+
if (char === DEL || char === BACKSPACE) {
|
|
473
|
+
buffer = buffer.slice(0, -1);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
buffer += char;
|
|
477
|
+
if (maskTo) maskTo.write('*');
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
if (maskTo && input.setRawMode) input.setRawMode(true);
|
|
482
|
+
input.setEncoding('utf8');
|
|
483
|
+
input.resume();
|
|
484
|
+
input.on('data', onData);
|
|
485
|
+
});
|
|
486
|
+
}
|
package/src/bin.ts
ADDED
package/src/cli.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Cli } from './app.js';
|
|
2
|
+
import { COMMANDS } from './commands.js';
|
|
3
|
+
|
|
4
|
+
export const VERSION = '0.1.0';
|
|
5
|
+
|
|
6
|
+
export function createCli(options: { write?(t: string): void; writeError?(t: string): void } = {}): Cli {
|
|
7
|
+
return new Cli({
|
|
8
|
+
name: 'textui',
|
|
9
|
+
version: VERSION,
|
|
10
|
+
description: 'TextUI - build terminal interfaces you own.',
|
|
11
|
+
commands: COMMANDS,
|
|
12
|
+
globalOptions: [
|
|
13
|
+
{ name: 'cwd', type: 'string', description: 'Run as if from this directory' },
|
|
14
|
+
],
|
|
15
|
+
write: options.write,
|
|
16
|
+
writeError: options.writeError,
|
|
17
|
+
});
|
|
18
|
+
}
|