|
| 1 | +import { Command } from "commander"; |
| 2 | +import { basename } from "node:path"; |
| 3 | +import { DEFAULT_FAILURE_EXIT_CODE } from "./constants.js"; |
| 4 | +import { loadModule } from "./load-module.js"; |
| 5 | +import { |
| 6 | + parseFunctionSignature, |
| 7 | + type ParsedFunctionSignature, |
| 8 | + type ParsedOptionField, |
| 9 | + type ParsedParameter, |
| 10 | +} from "./parse-function.js"; |
| 11 | +import { camelToKebab } from "./utils/camel-to-kebab.js"; |
| 12 | +import { formatResult } from "./utils/format-result.js"; |
| 13 | +import { inferOptionType } from "./utils/infer-option-type.js"; |
| 14 | + |
| 15 | +export interface BuildCliOptions { |
| 16 | + programName?: string; |
| 17 | +} |
| 18 | + |
| 19 | +const collectArrayValue = (value: string, previous: string[]): string[] => [...previous, value]; |
| 20 | + |
| 21 | +const applyOptionField = (subcommand: Command, field: ParsedOptionField): void => { |
| 22 | + const kebabName = camelToKebab(field.name); |
| 23 | + const inferred = inferOptionType(field.defaultLiteral); |
| 24 | + |
| 25 | + switch (inferred.commanderType) { |
| 26 | + case "boolean": |
| 27 | + subcommand.option(`--${kebabName}`, ""); |
| 28 | + return; |
| 29 | + case "negated-boolean": |
| 30 | + subcommand.option(`--no-${kebabName}`, ""); |
| 31 | + return; |
| 32 | + case "number": |
| 33 | + subcommand.option(`--${kebabName} <number>`, "", parseFloat, inferred.defaultValue); |
| 34 | + return; |
| 35 | + case "array": { |
| 36 | + const emptyArrayDefault: string[] = []; |
| 37 | + subcommand.option(`--${kebabName} <value>`, "", collectArrayValue, emptyArrayDefault); |
| 38 | + return; |
| 39 | + } |
| 40 | + case "required-string": |
| 41 | + subcommand.requiredOption(`--${kebabName} <value>`, ""); |
| 42 | + return; |
| 43 | + case "string": |
| 44 | + if (inferred.defaultValue !== undefined) { |
| 45 | + subcommand.option(`--${kebabName} <value>`, "", inferred.defaultValue); |
| 46 | + return; |
| 47 | + } |
| 48 | + subcommand.option(`--${kebabName} <value>`, ""); |
| 49 | + return; |
| 50 | + } |
| 51 | +}; |
| 52 | + |
| 53 | +const applyParameter = (subcommand: Command, parameter: ParsedParameter): void => { |
| 54 | + if (parameter.kind === "primitive") { |
| 55 | + const argumentSpec = parameter.hasDefault |
| 56 | + ? `[${camelToKebab(parameter.name)}]` |
| 57 | + : `<${camelToKebab(parameter.name)}>`; |
| 58 | + subcommand.argument(argumentSpec); |
| 59 | + return; |
| 60 | + } |
| 61 | + |
| 62 | + if (parameter.kind === "rest") { |
| 63 | + subcommand.argument(`[${camelToKebab(parameter.name)}...]`); |
| 64 | + return; |
| 65 | + } |
| 66 | + |
| 67 | + for (const field of parameter.optionFields ?? []) { |
| 68 | + applyOptionField(subcommand, field); |
| 69 | + } |
| 70 | +}; |
| 71 | + |
| 72 | +const isPlainObject = (value: unknown): value is Record<string, unknown> => |
| 73 | + typeof value === "object" && value !== null; |
| 74 | + |
| 75 | +const assembleCallArgs = ( |
| 76 | + signature: ParsedFunctionSignature, |
| 77 | + commanderArgs: unknown[], |
| 78 | +): unknown[] => { |
| 79 | + const positionalParameters = signature.parameters.filter( |
| 80 | + (parameter) => parameter.kind === "primitive" || parameter.kind === "rest", |
| 81 | + ); |
| 82 | + const positionalValues = commanderArgs.slice(0, positionalParameters.length); |
| 83 | + const optionsSlot = commanderArgs[positionalParameters.length]; |
| 84 | + const rawOptions: Record<string, unknown> = isPlainObject(optionsSlot) ? optionsSlot : {}; |
| 85 | + |
| 86 | + const callArgs: unknown[] = []; |
| 87 | + let positionalCursor = 0; |
| 88 | + |
| 89 | + for (const parameter of signature.parameters) { |
| 90 | + if (parameter.kind === "primitive") { |
| 91 | + callArgs.push(positionalValues[positionalCursor]); |
| 92 | + positionalCursor++; |
| 93 | + continue; |
| 94 | + } |
| 95 | + |
| 96 | + if (parameter.kind === "rest") { |
| 97 | + const restValue = positionalValues[positionalCursor]; |
| 98 | + positionalCursor++; |
| 99 | + if (Array.isArray(restValue)) { |
| 100 | + callArgs.push(...restValue); |
| 101 | + } else if (restValue !== undefined) { |
| 102 | + callArgs.push(restValue); |
| 103 | + } |
| 104 | + continue; |
| 105 | + } |
| 106 | + |
| 107 | + const optionsObject: Record<string, unknown> = {}; |
| 108 | + for (const field of parameter.optionFields ?? []) { |
| 109 | + if (field.name in rawOptions) { |
| 110 | + optionsObject[field.name] = rawOptions[field.name]; |
| 111 | + } |
| 112 | + } |
| 113 | + callArgs.push(optionsObject); |
| 114 | + } |
| 115 | + |
| 116 | + return callArgs; |
| 117 | +}; |
| 118 | + |
| 119 | +const buildActionHandler = |
| 120 | + (fn: (...args: unknown[]) => unknown, signature: ParsedFunctionSignature) => |
| 121 | + async (...commanderArgs: unknown[]): Promise<void> => { |
| 122 | + try { |
| 123 | + const callArgs = assembleCallArgs(signature, commanderArgs); |
| 124 | + const result = await fn(...callArgs); |
| 125 | + const formatted = formatResult(result); |
| 126 | + if (formatted !== null) process.stdout.write(formatted + "\n"); |
| 127 | + } catch (error) { |
| 128 | + const message = error instanceof Error ? error.message : String(error); |
| 129 | + process.stderr.write(message + "\n"); |
| 130 | + process.exit(DEFAULT_FAILURE_EXIT_CODE); |
| 131 | + } |
| 132 | + }; |
| 133 | + |
| 134 | +export const convertJsToCli = async ( |
| 135 | + modulePath: string, |
| 136 | + options: BuildCliOptions = {}, |
| 137 | +): Promise<Command> => { |
| 138 | + const loaded = await loadModule(modulePath); |
| 139 | + |
| 140 | + const program = new Command() |
| 141 | + .name(options.programName ?? basename(loaded.absolutePath)) |
| 142 | + .description(`CLI generated from ${loaded.modulePath}`); |
| 143 | + |
| 144 | + for (const functionExport of loaded.functionExports) { |
| 145 | + const signature = parseFunctionSignature(functionExport.fn, functionExport.exportName); |
| 146 | + const subcommand = program.command(functionExport.commandName); |
| 147 | + for (const parameter of signature.parameters) { |
| 148 | + applyParameter(subcommand, parameter); |
| 149 | + } |
| 150 | + subcommand.action(buildActionHandler(functionExport.fn, signature)); |
| 151 | + } |
| 152 | + |
| 153 | + return program; |
| 154 | +}; |
0 commit comments