import * as Console from "../../Console.ts" import * as Effect from "../../Effect.ts" import * as Option from "../../Option.ts" import * as Redacted from "../../Redacted.ts" import type * as Terminal from "../../Terminal.ts" import type * as CliError from "../CliError.ts" import type * as Command from "../Command.ts" import * as Param from "../Param.ts" import * as Primitive from "../Primitive.ts" import * as Prompt from "../Prompt.ts" import * as Ansi from "./ansi.ts" import { toImpl } from "./command.ts" export interface Options { readonly commandPath?: ReadonlyArray | undefined readonly prefix?: ReadonlyArray | undefined } export interface Result { readonly args: Array readonly displayArgs: Array } interface CommandLineArg { readonly value: string readonly displayValue: string } export const run: ( command: Command.Command.Any, options?: Options ) => Effect.Effect = Effect.fnUntraced( function*(command, options) { const commandPath = options?.commandPath ?? [command.name] const selected = getCommandAtPath(command, commandPath) const commandLine = (options?.prefix ?? commandPath).map((value) => commandLineArg(value)) const { colors } = yield* Prompt.Theme yield* logCurrentCommand(commandLine, colors) yield* promptCommand(selected, commandLine, selected === command ? "ROOT" : selected.name, colors) return { args: commandLine.map((arg) => arg.value), displayArgs: commandLine.map((arg) => arg.displayValue) } } ) const getCommandAtPath = ( command: Command.Command.Any, commandPath: ReadonlyArray ): Command.Command.Any => { let current = command for (const name of commandPath.slice(1)) { const child = current.subcommands .flatMap((group) => group.commands) .find((candidate) => candidate.name === name || candidate.alias === name) if (child === undefined) { break } current = child } return current } const promptCommand: ( command: Command.Command.Any, commandLine: Array, sectionName: string, colors: boolean ) => Effect.Effect = Effect.fnUntraced( function*(command, commandLine, sectionName, colors) { const impl = toImpl(command) const visibleSubcommands = command.subcommands.flatMap((group) => group.commands.filter((child) => !child.unlisted)) const config = visibleSubcommands.length === 0 ? impl.config : impl.contextConfig if (config.flags.length > 0) { yield* Console.log(renderSection(sectionName, "FLAGS", colors)) for (const param of config.flags) { commandLine.push(...yield* promptParam(param)) } if (config.arguments.length > 0 || visibleSubcommands.length > 0) { yield* logCurrentCommand(commandLine, colors) } } if (config.arguments.length > 0) { yield* Console.log(renderSection(sectionName, "ARGUMENTS", colors)) for (const param of config.arguments) { commandLine.push(...yield* promptParam(param)) } if (visibleSubcommands.length > 0) { yield* logCurrentCommand(commandLine, colors) } } if (visibleSubcommands.length === 0) { return } const child = yield* Prompt.run(Prompt.Select({ message: "Command", choices: visibleSubcommands.map((command) => ({ title: command.name, value: command, ...(command.shortDescription !== undefined ? { description: command.shortDescription } : command.description !== undefined ? { description: command.description } : {}) })) })) yield* Console.log() commandLine.push(commandLineArg(child.name)) if (hasWizardSteps(child)) { yield* logCurrentCommand(commandLine, colors) } yield* promptCommand(child, commandLine, child.name, colors) } ) const hasWizardSteps = (command: Command.Command.Any): boolean => { const hasVisibleSubcommands = command.subcommands.some((group) => group.commands.some((child) => !child.unlisted)) return hasVisibleSubcommands || toImpl(command).config.orderedParams.length > 0 } const promptParam = Effect.fnUntraced( function*(param: Param.Any): Effect.fn.Return< Array, CliError.CliError | Terminal.QuitError, Command.Environment > { const single = Param.getUnderlyingSingleOrThrow(param) const metadata = Param.getParamMetadata(param) if (metadata.isOptional) { const include = yield* Prompt.run(Prompt.Confirm({ message: `Set ${renderParamLabel(single)}?`, initial: false })) if (!include) { yield* Console.log() return [] } } const count = !metadata.isVariadic ? 1 : yield* Prompt.run(Prompt.Int({ message: `${renderParamLabel(single)} count`, default: Option.getOrElse(metadata.variadicMin, () => 0), min: Option.getOrElse(metadata.variadicMin, () => 0), ...(Option.isSome(metadata.variadicMax) ? { max: metadata.variadicMax.value } : {}) })) const values: Array = [] for (let i = 0; i < count; i++) { values.push(yield* promptSingle(single)) } const parsed = single.kind === Param.flagKind ? { flags: { [single.name]: values.map((arg) => arg.value) }, arguments: [] } : { flags: {}, arguments: values.map((arg) => arg.value) } yield* param.parse(parsed) yield* Console.log() if (single.kind === Param.argumentKind) { return values } // Inline option-looking values so the lexer cannot interpret them as flags or the -- delimiter. // Flags with an optional value only accept an inline value. return values.flatMap((value) => single.optionalValue !== undefined || (value.value.startsWith("-") && value.value.length > 1) ? [commandLineArg(`--${single.name}=${value.value}`, `--${single.name}=${value.displayValue}`)] : [commandLineArg(`--${single.name}`), value] ) } ) const promptSingle = ( single: Param.Single ): Effect.Effect => { const message = renderParamMessage(single) switch (single.primitiveType._tag) { case "Boolean": return Effect.map( Prompt.run(Prompt.Confirm({ message, label: { confirm: "true", deny: "false" }, placeholder: { defaultConfirm: "(T/f)", defaultDeny: "(t/F)" } })), (value) => commandLineArg(String(value)) ) case "Choice": { const choices = Primitive.getChoiceKeys(single.primitiveType) ?? [] return Effect.map( Prompt.run(Prompt.Select({ message, choices: choices.map((choice) => ({ title: choice, value: choice })) })), commandLineArg ) } case "Date": return Effect.map(Prompt.run(Prompt.Date({ message })), (date) => commandLineArg(date.toISOString())) case "Finite": return Effect.map(Prompt.run(Prompt.Number({ message })), (value) => commandLineArg(String(value))) case "Int": return Effect.map(Prompt.run(Prompt.Int({ message })), (value) => commandLineArg(String(value))) case "Redacted": return Effect.map( Prompt.run(Prompt.Password({ message })), (value) => commandLineArg(Redacted.value(value), "") ) default: return Effect.map(Prompt.run(Prompt.String({ message })), commandLineArg) } } const commandLineArg = (value: string, displayValue: string = value): CommandLineArg => ({ value, displayValue }) const formatName = (single: Param.Single): string => single.kind === Param.flagKind ? `--${single.name}` : single.name const renderParamMessage = (single: Param.Single): string => renderParamLabel(single) const renderParamLabel = (single: Param.Single): string => { const description = Option.getOrUndefined(single.description)?.trim() const label = single.kind === Param.flagKind && description !== undefined && description.length <= 32 ? description : humanize(single.name) return single.kind === Param.flagKind ? `${label} (${formatName(single)})` : label } const humanize = (name: string): string => { const words = name.split(/[-_]+/).filter((word) => word.length > 0) if (words.length === 0) return name return [words[0][0].toUpperCase() + words[0].slice(1), ...words.slice(1)].join(" ") } const logCurrentCommand = (commandLine: ReadonlyArray, colors: boolean): Effect.Effect => Console.log(renderCommandBlock("Current command", commandLine.map((arg) => arg.displayValue), colors, Ansi.magenta)) const renderSection = (commandName: string, section: string, colors: boolean): string => `${Ansi.annotate(colors, commandName.toUpperCase(), Ansi.bold, Ansi.cyanBright)} ${ Ansi.annotate(colors, "·", Ansi.blackBright) } ${Ansi.annotate(colors, section, Ansi.bold, Ansi.white)}` export const renderIntroduction = ( name: string, version: string, summary: string | undefined, colors: boolean ): string => { const title = `${Ansi.annotate(colors, name, Ansi.bold, Ansi.cyanBright)} ${ Ansi.annotate(colors, `v${version}`, Ansi.white) } ${Ansi.annotate(colors, "· Command wizard", Ansi.bold, Ansi.white)}` return [ title, ...(summary === undefined || summary.length === 0 ? [] : [summary]), Ansi.annotate(colors, "Build a command interactively. Press Ctrl+C to cancel.", Ansi.blackBright), "" ].join("\n") } export const renderCompletion = (commandLine: ReadonlyArray, colors: boolean): string => renderCommandBlock("Command ready", commandLine, colors, Ansi.cyanBright, Ansi.green) export const renderQuit = (colors: boolean): string => `\n${Ansi.annotate(colors, "Wizard cancelled.", Ansi.red)}` const renderCommandBlock = ( label: string, commandLine: ReadonlyArray, colors: boolean, commandColor: string, labelColor: string = Ansi.white ): string => { const lines = wrapCommand(commandLine) return [ Ansi.annotate(colors, label, Ansi.bold, labelColor), ...lines.map((line) => Ansi.annotate(colors, line, commandColor)), "" ].join("\n") } const wrapCommand = (commandLine: ReadonlyArray): Array => { const width = 88 const firstIndent = " $ " const continuationIndent = " " const args = commandLine.map(formatShellArg) const lines: Array = [] let current = firstIndent for (const arg of args) { const separator = current === firstIndent || current === continuationIndent ? "" : " " if (current.length + separator.length + arg.length > width && current !== firstIndent) { lines.push(`${current} \\`) current = `${continuationIndent}${arg}` } else { current += `${separator}${arg}` } } if (current !== firstIndent) { lines.push(current) } return lines } const formatShellArg = (arg: string): string => /^[A-Za-z0-9_./:@%+=,-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'"'"'`)}'`