Something went wrong. Try again.
A nicer S3 CLI
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153import { bindArgs, parse } from './args.ts';import type { CommandSpec } from './command.ts';import { Context, type Format, type Result, type Session } from './context.ts';import { setting } from './config.ts';import { codeOf, exitOf, EXIT, UsageError } from './errors.ts';import { helpJson, helpText } from './help.ts';import { errRed, render, setColour, stripAnsi } from './output.ts';import { isVerbose, setVerbose } from './observe.ts';import { cliRoot } from './commands/index.ts';import { version } from './version.ts';
// The whole program, as a function from argv to an exit code, so tests and// hosts can run commands without a subprocess. main.ts is the only place that// turns the code into an exit. The shell runs each line through `execute`// too, with its own tree and its own session, so a command behaves the same// typed in either.
export interface IO { stdout: (text: string) => void; stderr: (text: string) => void;}
export const defaultIO: IO = { stdout: (text) => process.stdout.write(text), stderr: (text) => process.stderr.write(text),};
export async function main(argv: string[], io: IO = defaultIO): Promise<number> { // What a shell runs on Tab: never fails loudly — an error is nothing to offer. if (argv[0] === '__complete') { try { const { respond } = await import('./completion.ts'); const lines = await respond(cliRoot, argv.slice(1)); if (lines.length) io.stdout(`${lines.join('\n')}\n`); } catch { // Nothing to offer. } return EXIT.ok; } if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-V')) { io.stdout(`s3cli ${version()}\n`); return EXIT.ok; } // Bare `s3cli`: the shell on a terminal; anywhere else, help — never a // read of a stdin that may never end. if (argv.length === 0) { if (process.stdin.isTTY && process.stdout.isTTY) { const { runShell } = await import('./shell.ts'); return await runShell('/', {}); } io.stdout(helpText([cliRoot])); return EXIT.ok; } return await execute(cliRoot, argv, { cwd: '/', shell: false }, io);}
/** Parse and run one command line against a tree, print what it returns, and say how it went. */export async function execute(root: CommandSpec, argv: string[], session: Session, io: IO = defaultIO): Promise<number> { const format = resolveFormat(argv); const json = format === 'json'; // -v is for this command: in the shell, the next one is as quiet as before. const wasVerbose = isVerbose(); try { const parsed = parse(root, argv); if (parsed.options['color'] !== undefined) setColour(Boolean(parsed.options['color'])); if (json) setColour(false); if (parsed.options['verbose']) setVerbose(true);
if (parsed.help || !parsed.command.run) { io.stdout(json ? `${JSON.stringify(helpJson(parsed.path), null, 2)}\n` : helpText(parsed.path)); return EXIT.ok; }
const ctx = new Context({ command: parsed.command, path: parsed.path, options: parsed.options, args: bindArgs(parsed.command, parsed.positionals), format, session, out: io.stdout, }); const outcome = await parsed.command.run(ctx) as Result | undefined; if (outcome) print(outcome, format, io); return outcome?.exit ?? EXIT.ok; } catch (error) { return report(error, json, argv.includes('--verbose') || argv.includes('-v'), session, io); } finally { setVerbose(wasVerbose); }}
const FORMATS: readonly Format[] = ['text', 'json'];
/** * How results are printed: the last of --json and --format on the line; else * the format setting; else text. Worked out before parsing, so that a command * line that does not parse still fails in the format asked for. */export function resolveFormat(argv: readonly string[], saved: () => string = () => setting('format')): Format { let chosen: Format | undefined; for (let at = 0; at < argv.length; at++) { const word = argv[at]!; if (word === '--') break; if (word === '--json') chosen = 'json'; else if (word.startsWith('--format=')) chosen = FORMATS.find((each) => each === word.slice('--format='.length)) ?? chosen; else if (word === '--format') chosen = FORMATS.find((each) => each === argv[at + 1]) ?? chosen; } if (chosen) return chosen; try { const value = saved(); return FORMATS.includes(value as Format) ? value as Format : 'text'; } catch { return 'text'; }}
function print(outcome: Result, format: Format, io: IO): void { if (format === 'json') { io.stdout(`${JSON.stringify(outcome.data ?? null, null, process.stdout.isTTY ? 2 : 0)}\n`); return; } const text = typeof outcome.text === 'function' ? outcome.text() : outcome.text; const shown = text ?? (outcome.data === undefined ? '' : render(outcome.data)); if (shown) io.stdout(shown.endsWith('\n') ? shown : `${shown}\n`);}
function report(error: unknown, json: boolean, verbose: boolean, session: Session, io: IO): number { // Ctrl-C in the shell: the command stops, and that is all there is to say. if (session.signal?.aborted) { io.stderr('cancelled\n'); return EXIT.error; } // --verbose shows where an unexpected error came from. const known = exitOf(error) !== EXIT.error || codeOf(error) !== 'InternalError'; if (verbose && error instanceof Error && !known) io.stderr(`${error.stack}\n`); const exit = exitOf(error); const code = codeOf(error); const message = error instanceof Error ? error.message : String(error); if (json) { const status = (error as { status?: number }).status; io.stdout(`${JSON.stringify({ error: { code, message: stripAnsi(message), exit, ...(status ? { status } : {}) } })}\n`); } if (error instanceof UsageError) { io.stderr(`${errRed('error:')} ${message}\n`); const name = [error.path[0]?.name, ...error.path.slice(1).map((command) => command.name)].filter(Boolean).join(' '); if (name) io.stderr(`Run '${name} --help' for usage.\n`); } else if (!json || process.stderr.isTTY) { io.stderr(`${errRed('error:')} ${message}\n`); } return exit;}