import { 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 { // 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 { 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; }