UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

1,379 lines (1,217 loc) 46.6 kB
import * as fs from "node:fs/promises"; import { glob } from "node:fs/promises"; import { $atom, $env, $hook, $inject, $state, Alepha, type Static, type TObject, type TSchema, type TUnion, TypeBoxError, z, } from "alepha"; import { $logger, ConsoleColorProvider } from "alepha/logger"; import { CommandError } from "../errors/CommandError.ts"; import { Asker } from "../helpers/Asker.ts"; import { EnvUtils } from "../helpers/EnvUtils.ts"; import { Runner } from "../helpers/Runner.ts"; import { $command, type CommandHandlerArgs, type CommandPrimitive, } from "../primitives/$command.ts"; // --------------------------------------------------------------------------------------------------------------------- const envSchema = z.object({ CLI_NAME: z.text({ default: "cli", description: "Name of the CLI application.", }), CLI_DESCRIPTION: z.text({ default: "", description: "Description of the CLI application.", }), }); declare module "alepha" { interface Env extends Partial<Static<typeof envSchema>> {} } /** * CLI provider configuration atom */ export const cliOptions = $atom({ name: "alepha.command.cli.options", schema: z.object({ name: z.string().describe("Name of the CLI application.").optional(), description: z .string() .describe("Description of the CLI application.") .optional(), argv: z .array(z.string()) .describe("Command line arguments to parse.") .optional(), }), default: {}, }); export type CliProviderOptions = Static<typeof cliOptions.schema>; declare module "alepha" { interface State { [cliOptions.key]: CliProviderOptions; } } // --------------------------------------------------------------------------------------------------------------------- /** * CLI provider for parsing and executing commands. * * Handles: * - Command resolution (simple, nested, colon-notation) * - Flag and argument parsing * - Environment variable validation * - Help generation * - Pre/post command hooks * * @example * ```typescript * // Define a command * class MyCommands { * build = $command({ * name: "build", * description: "Build the project", * flags: z.object({ watch: z.boolean().optional() }), * handler: async ({ flags }) => { ... } * }); * } * * // CLI automatically discovers and executes commands * const alepha = Alepha.create().with(MyCommands); * ``` */ export class CliProvider { // ───────────────────────────────────────────────────────────────────────────── // Dependencies // ───────────────────────────────────────────────────────────────────────────── protected readonly env = $env(envSchema); protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected readonly color = $inject(ConsoleColorProvider); protected readonly runner = $inject(Runner); protected readonly asker = $inject(Asker); protected readonly envUtils = $inject(EnvUtils); protected readonly options = $state(cliOptions); // ───────────────────────────────────────────────────────────────────────────── // Configuration // ───────────────────────────────────────────────────────────────────────────── protected get name(): string { return this.options.name || this.env.CLI_NAME; } protected get description(): string { return this.options.description || this.env.CLI_DESCRIPTION; } protected get argv(): string[] { return ( this.options.argv || (typeof process !== "undefined" ? process.argv.slice(2) : []) ); } /** * Global flags available to all commands. */ protected readonly globalFlags = { help: { aliases: ["h", "help"], description: "Show this help message", schema: z.boolean(), }, verbose: { // No `-v` alias — it collides with `--version` on the root command. aliases: ["verbose"], description: "Verbose output: trace-level logs (framework internals) in pretty format. Task output already streams by default.", schema: z.boolean(), }, }; // ───────────────────────────────────────────────────────────────────────────── // Lifecycle // ───────────────────────────────────────────────────────────────────────────── /** * Main entry point - resolves and executes the command from process.argv. * This is the production execution path with full lifecycle support. */ protected readonly onReady = $hook({ on: "ready", handler: async () => { const argv = [...this.argv]; // Extract positional arguments (potential command path) const positionalArgs = argv.filter((arg) => !arg.startsWith("-")); // Resolve command using space-separated or colon-notation const { command, consumedArgs } = this.resolveCommand(positionalArgs); const globalFlags = this.parseFlags( argv, Object.entries(this.getAllGlobalFlags()).map(([key, value]) => ({ key, ...value, })), { strict: false }, // Don't throw for command-specific flags ); // `--verbose` → raise the logger to trace + pretty format via state // (read live by Logger). Task output already streams to the terminal; // this additionally surfaces the framework's own trace/debug logs. // // An agent session (Claude Code sets CLAUDECODE) implies verbose: the // compact `cli` format is tuned for a human watching a terminal, but an // agent benefits from the full module/context and internal logs — same // as if `--verbose` were passed. const verbose = globalFlags.verbose || !!this.alepha.env.CLAUDECODE; if (verbose) { this.alepha.store.set("alepha.logger.level", "trace"); this.alepha.store.set("alepha.logger.format", "pretty"); } if (globalFlags.help) { this.printHelp(command); return; } if (!command) { // Check if there's a root command (name === "") const rootCommand = this.findCommand(""); // If we have positional args but no matching command, show error const commandName = positionalArgs[0] ?? ""; if (commandName !== "" && !rootCommand?.options.args) { this.log.error(`Unknown command: '${commandName}'`); this.printHelp(); return; } // Execute root command if it exists if (rootCommand) { await this.executeCommand(rootCommand, argv, true); return; } // No command found and no root command return; } // Remove consumed command path args from argv for argument parsing const remainingArgv = this.removeConsumedArgs(argv, consumedArgs); // Since we've removed the command path, treat it like a root command for parsing await this.executeCommand(command, remainingArgv, true); }, }); /** * Execute a command with full lifecycle support. * * This is the production execution path that includes: * - Mode-based .env file loading * - Pre/post command hooks * - Runner session for pretty CLI output * - Alepha context wrapper for proper scoping * * @see run() for a lightweight test-only alternative */ protected async executeCommand( command: CommandPrimitive<TObject>, argv: string[], isRootCommand: boolean, ): Promise<void> { const root = process.cwd(); // Handle --mode flag if command has mode option enabled let modeValue: string | undefined; if (command.options.mode) { modeValue = this.parseModeFlag(argv); // Use default mode if not provided and mode is a string if (modeValue === undefined && typeof command.options.mode === "string") { modeValue = command.options.mode; } await this.loadModeEnv(root, modeValue); } const commandFlags = this.parseCommandFlags(argv, command.flags, { modeEnabled: !!command.options.mode, }); const commandArgs = this.parseCommandArgs( argv, command.options.args, isRootCommand, command.flags, ); const commandEnv = this.parseCommandEnv(command.env, command.name); await this.alepha.context.run(async () => { this.log.debug(`Executing command '${command.name}'...`, { flags: commandFlags, args: commandArgs, mode: modeValue, }); const runner = this.runner; // Start command session for pretty print runner.startCommand(this.name, command.name); const args = { flags: commandFlags, args: commandArgs, env: commandEnv, run: runner.run, ask: this.asker.ask, fs, glob, root, help: () => this.printHelp(command), mode: modeValue, }; // Execute pre-hooks const preHooks = this.findPreHooks(command.name); for (const hook of preHooks) { this.log.debug(`Executing pre-hook for '${command.name}'...`); await hook.options.handler(args as CommandHandlerArgs<TObject>); } // Execute main command await command.options.handler(args as CommandHandlerArgs<TObject>); // Execute post-hooks const postHooks = this.findPostHooks(command.name); for (const hook of postHooks) { this.log.debug(`Executing post-hook for '${command.name}'...`); await hook.options.handler(args as CommandHandlerArgs<TObject>); } runner.end(); this.log.debug(`Command '${command.name}' executed successfully.`); }); } /** * Remove consumed command path arguments from argv (keeps flags and remaining args). */ protected removeConsumedArgs( argv: string[], consumedArgs: string[], ): string[] { const result: string[] = []; let consumedIndex = 0; for (const arg of argv) { if (arg.startsWith("-")) { result.push(arg); } else if ( consumedIndex < consumedArgs.length && arg === consumedArgs[consumedIndex] ) { consumedIndex++; // Skip this arg, it's part of the command path } else { result.push(arg); } } return result; } /** * Resolve a command from positional arguments. * * Supports: * 1. Space-separated subcommands: `deploy vercel` -> finds deploy command, then vercel child * 2. Colon notation (backwards compat): `deploy:vercel` -> finds command with name "deploy:vercel" * 3. Simple commands: `build` -> finds command with name "build" */ protected resolveCommand(positionalArgs: string[]): { command: CommandPrimitive<TObject> | undefined; consumedArgs: string[]; } { if (positionalArgs.length === 0) { return { command: undefined, consumedArgs: [] }; } const firstArg = positionalArgs[0]; // First, try colon notation for backwards compatibility (e.g., "deploy:vercel") if (firstArg.includes(":")) { const command = this.findCommand(firstArg); if (command) { return { command, consumedArgs: [firstArg] }; } } // Try to find command with space-separated subcommand path // Only search top-level commands to avoid child commands shadowing // top-level ones (e.g., "platform > build" shadowing standalone "build") let currentCommand = this.findTopLevelCommand(firstArg); const consumedArgs: string[] = []; if (!currentCommand) { return { command: undefined, consumedArgs: [] }; } consumedArgs.push(firstArg); // Walk through remaining args to find nested subcommands for (let i = 1; i < positionalArgs.length; i++) { const arg = positionalArgs[i]; if (!currentCommand.hasChildren) { break; } const childCommand = currentCommand.findChild(arg); if (childCommand) { currentCommand = childCommand; consumedArgs.push(arg); } else { // No matching child, stop here break; } } return { command: currentCommand, consumedArgs }; } // ───────────────────────────────────────────────────────────────────────────── // Public API // ───────────────────────────────────────────────────────────────────────────── /** * Get all registered commands in the application. */ public get commands(): CommandPrimitive<any>[] { return this.alepha.primitives($command); } /** * Execute a command handler with given arguments. * * This is a **lightweight test helper** that directly invokes the command handler * without the full production lifecycle. It intentionally skips: * - Pre/post command hooks * - Runner session (pretty CLI output) * - Alepha context wrapper * - .env.{mode} file loading * * For production execution, the `onReady` hook uses `executeCommand()` which * provides the full lifecycle. Merging them would either make this method too * heavy for simple testing or require many optional parameters to toggle behaviors. * * @example * ```typescript * // In tests * const cli = alepha.inject(CliProvider); * const cmd = alepha.inject(InitCommand); * * await cli.run(cmd.init, "--agent --pm=yarn"); * await cli.run(cmd.init, { argv: "--agent", root: "/project" }); * ``` */ public async run<T extends TObject, A extends TSchema>( command: CommandPrimitive<T, A>, options: | string | string[] | { argv?: string | string[]; root?: string } = {}, ): Promise<void> { const opts = typeof options === "string" || Array.isArray(options) ? { argv: options } : options; const args = typeof opts.argv === "string" ? opts.argv.split(" ").filter(Boolean) : (opts.argv ?? []); const root = opts.root ?? process.cwd(); const commandFlags = this.parseCommandFlags(args, command.flags, { modeEnabled: !!command.options.mode, }); const commandArgs = this.parseCommandArgs( args, command.options.args, true, command.flags, ); const commandEnv = this.parseCommandEnv(command.env, command.name); let modeValue: string | undefined; if (command.options.mode) { modeValue = this.parseModeFlag(args); if (modeValue === undefined && typeof command.options.mode === "string") { modeValue = command.options.mode; } } await command.options.handler({ flags: commandFlags, args: commandArgs, env: commandEnv, run: this.runner.run, ask: this.asker.ask, fs, glob, root, help: () => this.printHelp(command), mode: modeValue, } as CommandHandlerArgs<T, A>); } // ───────────────────────────────────────────────────────────────────────────── // Command Resolution // ───────────────────────────────────────────────────────────────────────────── /** * Find a command by name or alias */ protected findCommand(name: string): CommandPrimitive<TObject> | undefined { return this.commands.findLast( (command) => command.name === name || command.aliases.includes(name), ); } /** * Find a top-level command by name or alias (excludes child commands) */ protected findTopLevelCommand( name: string, ): CommandPrimitive<TObject> | undefined { return this.getTopLevelCommands().findLast( (command) => command.name === name || command.aliases.includes(name), ); } /** * Find all pre-hooks for a command (commands named `pre{commandName}`) */ protected findPreHooks(commandName: string): CommandPrimitive<TObject>[] { return this.commands.filter((cmd) => cmd.name === `pre${commandName}`); } /** * Find all post-hooks for a command (commands named `post{commandName}`) */ protected findPostHooks(commandName: string): CommandPrimitive<TObject>[] { return this.commands.filter((cmd) => cmd.name === `post${commandName}`); } /** * Get global flags (help only, root command flags are NOT global) */ protected getAllGlobalFlags(): Record< string, { aliases: string[]; description?: string; schema: TSchema } > { return { ...this.globalFlags }; } /** * Read a schema's metadata (`title`, `description`, `aliases`, `alias`, …). * * Under zod these options live on the schema's `.meta()` registry rather than * as direct properties (typebox), and they sit on the INNER schema — so any * optional / nullable / default wrappers are peeled first. */ protected schemaMeta(schema: TSchema | undefined): Record<string, any> { if (!schema) return {}; const base = z.schema.unwrap(schema) as any; return (typeof base?.meta === "function" ? base.meta() : undefined) ?? {}; } /** * Build flag definitions (key, aliases, description, schema) from a flags * object schema. Centralises the metadata reading so every call-site (parsing, * arg-splitting, help) extracts aliases/descriptions the same way. */ protected extractFlagDefs(schema: TObject): Array<{ key: string; aliases: string[]; description?: string; schema: TSchema; }> { return Object.entries(schema.properties).map(([key, value]) => { const meta = this.schemaMeta(value as TSchema); const extra: string[] = meta.aliases ?? (meta.alias ? [meta.alias] : []); return { key, aliases: [key, ...extra], description: meta.description, schema: value as TSchema, }; }); } // ───────────────────────────────────────────────────────────────────────────── // Parsing (Flags, Args, Env) // ───────────────────────────────────────────────────────────────────────────── /** * Parse command flags from argv using the command's flag schema */ protected parseCommandFlags( argv: string[], schema: TObject, options: { modeEnabled?: boolean } = {}, ): Record<string, any> { const { modeEnabled = false } = options; const flagDefs = this.extractFlagDefs(schema); // Add mode flags if mode is enabled (they're parsed elsewhere by parseModeFlag) if (modeEnabled) { flagDefs.push({ key: "__mode__", aliases: ["mode", "m"], description: undefined, schema: z.string(), }); } // Tolerate global flags (--help, --verbose) so strict command parsing // doesn't reject them — they're consumed by the onReady global pass, // not by the command. Skip any whose alias the command already claims // (e.g. a command with its own `verbose` flag handles it directly). const claimed = new Set(flagDefs.flatMap((d) => d.aliases)); for (const [key, value] of Object.entries(this.getAllGlobalFlags())) { if (value.aliases.some((a: string) => claimed.has(a))) continue; flagDefs.push({ key: `__global_${key}__`, aliases: value.aliases, description: undefined, schema: value.schema, }); } const parsed = this.parseFlags(argv, flagDefs); // Remove the mode + global flags from parsed result (handled separately) parsed.__mode__ = undefined; for (const key of Object.keys(parsed)) { if (key.startsWith("__global_")) delete parsed[key]; } // apply manually defaults for optional properties that have defaults for (const [key, value] of Object.entries( schema.properties as Record<string, TSchema>, )) { if (!(key in parsed)) { const def = z.schema.getDefault(value); if (def !== undefined) { parsed[key] = def; } } } try { return this.alepha.codec.decode(schema, parsed); } catch (error) { if (error instanceof TypeBoxError) { throw new CommandError( `Invalid flag: ${error.cause.instancePath || "command"} ${error.cause.message}`, ); } throw error; } } /** * Parse and validate environment variables using the command's env schema */ protected parseCommandEnv( schema: TObject, commandName: string, ): Record<string, any> { const result: Record<string, any> = {}; const missing: string[] = []; for (const [key, propSchema] of Object.entries( schema.properties as Record<string, TSchema>, )) { const value = process.env[key]; if (value !== undefined) { result[key] = value; } else { const def = z.schema.getDefault(propSchema); if (def !== undefined) { result[key] = def; } else if (z.schema.isOptional(propSchema)) { // Optional with no default — leave undefined } else { missing.push(key); } } } if (missing.length > 0) { const vars = missing.join(", "); throw new CommandError( `Missing required environment variable${missing.length > 1 ? "s" : ""}: ${vars}`, ); } try { return this.alepha.codec.decode(schema, result); } catch (error) { if (error instanceof TypeBoxError) { throw new CommandError( `Invalid environment variable: ${error.cause.instancePath || "env"} ${error.cause.message}`, ); } throw error; } } /** * Parse --mode or -m flag from argv for environment file loading */ protected parseModeFlag(argv: string[]): string | undefined { for (let i = 0; i < argv.length; i++) { const arg = argv[i]; // Handle --mode=value or -m=value if (arg.startsWith("--mode=") || arg.startsWith("-m=")) { return arg.split("=")[1]; } // Handle --mode value or -m value if (arg === "--mode" || arg === "-m") { const nextArg = argv[i + 1]; if (nextArg && !nextArg.startsWith("-")) { return nextArg; } throw new CommandError("Flag --mode requires a value."); } } return undefined; } /** * Load .env and .env.{mode} files into process.env */ protected async loadModeEnv( root: string, mode: string | undefined, ): Promise<void> { const envFiles = [".env"]; if (mode) { envFiles.push(`.env.${mode}`); } this.log.debug(`Loading env files: ${envFiles.join(", ")}`); await this.envUtils.loadEnv(root, envFiles); } /** * Low-level flag parser - extracts flag values from argv based on definitions */ protected parseFlags( argv: string[], flagDefs: { key: string; aliases: string[]; schema: TSchema }[], options: { strict?: boolean } = {}, ): Record<string, any> { const { strict = true } = options; const result: Record<string, any> = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (!arg.startsWith("-")) continue; const [rawKey, ...valueParts] = arg.replace(/^-{1,2}/, "").split("="); const value = valueParts.join("="); const def = flagDefs.find((d) => d.aliases.includes(rawKey)); if (!def) { if (strict) { throw new CommandError(`Unknown flag: --${rawKey}`); } continue; } // Resolve the underlying schema (peel optional/nullable/default) so flags // like `z.boolean().optional()` are still recognised as booleans. const base = z.schema.unwrap(def.schema); // Check if schema is a union containing boolean (allows flag without value) const isUnionWithBoolean = z.schema.isUnion(base) && (base as TUnion).anyOf.some((s) => z.schema.isBoolean(s)); if (z.schema.isBoolean(base)) { result[def.key] = true; } else if (isUnionWithBoolean && !value) { // Union with boolean: --flag without value → true const nextArg = argv[i + 1]; if (nextArg && !nextArg.startsWith("-")) { // Has a value after space: --flag value result[def.key] = nextArg; i++; // consume next arg } else { // No value: --flag → true result[def.key] = true; } } else if (value) { // Value provided via --flag=value syntax result[def.key] = this.castFlagValue(value, base, rawKey); } else { // Check for space-separated value: --flag value const nextArg = argv[i + 1]; if (nextArg && !nextArg.startsWith("-")) { result[def.key] = this.castFlagValue(nextArg, base, rawKey); } else { throw new CommandError(`Flag --${rawKey} requires a value.`); } } } return result; } /** * Convert a raw flag value string into the value its schema expects. * * zod no longer coerces, so scalar values (number / integer / boolean) are * cast + validated via {@link parseArgumentValue} (same path as positional * args); object / array / record values are JSON-parsed. `schema` is expected * to already be unwrapped of optional/nullable/default. */ protected castFlagValue(value: string, schema: TSchema, rawKey: string): any { if ( z.schema.isObject(schema) || z.schema.isArray(schema) || z.schema.isRecord(schema) ) { try { return JSON.parse(value); } catch { throw new CommandError(`Invalid JSON value for flag --${rawKey}`); } } return this.parseArgumentValue(value, schema); } /** * Get indices of argv elements consumed by flags (for separating args from flags) */ protected getFlagConsumedIndices( argv: string[], flagDefs: { key: string; aliases: string[]; schema: TSchema }[], ): Set<number> { const consumed = new Set<number>(); for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (!arg.startsWith("-")) continue; consumed.add(i); const [rawKey, ...valueParts] = arg.replace(/^-{1,2}/, "").split("="); const hasEqualValue = valueParts.length > 0; const def = flagDefs.find((d) => d.aliases.includes(rawKey)); if (!def) continue; // Peel optional/nullable/default so boolean flags are recognised. const base = z.schema.unwrap(def.schema); // Check if schema is a union containing boolean const isUnionWithBoolean = z.schema.isUnion(base) && (base as TUnion).anyOf.some((s) => z.schema.isBoolean(s)); // If not a boolean flag and no = value, the next arg is consumed as the value // Exception: union with boolean can work without a value if (!z.schema.isBoolean(base) && !isUnionWithBoolean && !hasEqualValue) { const nextArg = argv[i + 1]; if (nextArg && !nextArg.startsWith("-")) { consumed.add(i + 1); } } else if (isUnionWithBoolean && !hasEqualValue) { // Union with boolean: check if next arg looks like a value (not a flag) const nextArg = argv[i + 1]; if (nextArg && !nextArg.startsWith("-")) { consumed.add(i + 1); } } } return consumed; } protected parseCommandArgs( argv: string[], schema?: TSchema, isRootCommand = false, flagSchema?: TObject, ): any { if (!schema) { return undefined; } // Get indices consumed by flags (including space-separated values) const flagDefs = flagSchema ? this.extractFlagDefs(flagSchema) : []; const consumedIndices = this.getFlagConsumedIndices(argv, flagDefs); // Extract positional arguments (non-flag arguments that aren't consumed as flag values) const positionalArgs = argv.filter( (arg, idx) => !arg.startsWith("-") && !consumedIndices.has(idx), ); // For root commands, there's no command name to remove; otherwise slice off the command name const argsOnly = isRootCommand ? positionalArgs : positionalArgs.slice(1); try { if (z.schema.isOptional(schema)) { // Handle optional args: z.text().optional() if (argsOnly.length === 0) { return undefined; } return this.parseArgumentValue(argsOnly[0], schema); } else if (z.schema.isTuple(schema) && schema.items) { // Handle tuple args: z.tuple([z.text(), z.number()]) const result: any[] = []; const items = schema.items; for (let i = 0; i < items.length; i++) { const itemSchema = items[i]; if (i < argsOnly.length) { result.push(this.parseArgumentValue(argsOnly[i], itemSchema)); } else if (z.schema.isOptional(itemSchema)) { result.push(undefined); } else { throw new CommandError( `Missing required argument at position ${i + 1}`, ); } } return result; } else { // Handle single arg: z.text(), z.number(), etc. if (argsOnly.length === 0) { throw new CommandError("Missing required argument"); } return this.parseArgumentValue(argsOnly[0], schema); } } catch (error) { if (error instanceof TypeBoxError) { throw new CommandError(`Invalid argument: ${error.value.message}`); } throw error; } } /** * Convert a string argument value to the appropriate type based on schema */ protected parseArgumentValue(value: string, schema: TSchema): any { if (z.schema.isString(schema)) { return value; } if (z.schema.isNumber(schema) || z.schema.isInteger(schema)) { const num = Number(value); if (Number.isNaN(num)) { throw new CommandError(`Expected number, got "${value}"`); } if (z.schema.isInteger(schema) && !Number.isInteger(num)) { throw new CommandError(`Expected integer, got "${value}"`); } return num; } if (z.schema.isBoolean(schema)) { const lower = value.toLowerCase(); if (lower === "true" || lower === "1") return true; if (lower === "false" || lower === "0") return false; throw new CommandError(`Expected boolean, got "${value}"`); } // For other types, return the string value and let TypeBox validate it return value; } // ───────────────────────────────────────────────────────────────────────────── // Help Generation // ───────────────────────────────────────────────────────────────────────────── /** * Generate usage string for command arguments (e.g., "<path>" or "[path]") */ protected generateArgsUsage(schema?: TSchema): string { if (!schema) { return ""; } if (z.schema.isOptional(schema)) { const typeName = this.getTypeName(schema); const key = this.schemaMeta(schema).title ?? "arg1"; return ` [${key}${typeName}]`; } if (z.schema.isTuple(schema) && schema.items) { const items = schema.items; const args = items.map((item, index) => { const argName = `arg${index + 1}`; const typeName = this.getTypeName(item); if (z.schema.isOptional(item)) { return `[${argName}${typeName}]`; } return `<${argName}${typeName}>`; }); return ` ${args.join(" ")}`; } const typeName = this.getTypeName(schema); const key = this.schemaMeta(schema).title ?? "arg1"; return ` <${key}${typeName}>`; } /** * Get display type name for a schema (e.g., ": number", ": boolean") */ protected getTypeName(schema: TSchema): string { if (!schema) return ""; // Peel optional/nullable/default before inspecting the scalar type. const base = z.schema.unwrap(schema); // Order matters: under zod an integer IS a number (format "safeint"), so the // narrower integer check must come before the number check. if (z.schema.isString(base)) return ""; if (z.schema.isInteger(base)) return ": integer"; if (z.schema.isNumber(base)) return ": number"; if (z.schema.isBoolean(base)) return ": boolean"; return ""; } /** * Print help for a specific command or general CLI help. * * @param command - If provided, shows help for this specific command. * If omitted, shows general CLI help with all commands. */ public printHelp(command?: CommandPrimitive<any>): void { // Help is a document, not a log stream: render bare lines (no timestamp // or level prefix). Embedded colors live in the message itself, so they // survive the `raw` formatter. this.alepha.store.set("alepha.logger.format", "raw"); const cliName = this.name || "cli"; const c = this.color; this.log.info(""); // Newline if (command?.name) { // Command-specific help const hasChildren = command.hasChildren; const argsUsage = hasChildren ? ` ${c.set("CYAN", "<command>")}` : this.generateColoredArgsUsage(command.options.args); const commandPath = this.getCommandPath(command); const usage = `${c.set("GREY_LIGHT", cliName)} ${c.set("CYAN", commandPath)}${argsUsage}`.trim(); this.log.info(`${c.set("WHITE_BOLD", "Usage:")} ${usage}`); if (command.options.description) { this.log.info(``); this.log.info(`\t${command.options.description}`); } // Show subcommands if this is a parent command if (hasChildren) { this.log.info(""); this.log.info(c.set("WHITE_BOLD", "Commands:")); const maxSubCmdLength = this.getMaxChildCmdLength(command.children); for (const child of command.children) { if (child.options.hide) { continue; } const childArgsUsage = this.generateArgsUsage(child.options.args); const cmdStr = [child.name, ...child.aliases].join(", "); const fullCmdStr = `${cmdStr}${childArgsUsage}`; const coloredCmd = `${c.set("GREY_LIGHT", cliName)} ${c.set("CYAN", commandPath)} ${c.set("CYAN", fullCmdStr)}`; const padding = " ".repeat( Math.max(0, maxSubCmdLength - fullCmdStr.length), ); this.log.info( ` ${coloredCmd}${padding} ${child.options.description ?? ""}`, ); } } this.log.info(""); this.log.info(c.set("WHITE_BOLD", "Flags:")); const flags = [ // Read aliases/description from the schema's `.meta()` registry (zod), // not as direct schema properties (typebox) — see extractFlagDefs. ...this.extractFlagDefs(command.flags), // Add --mode flag if command has mode option enabled ...(command.options.mode ? [ { key: "mode", aliases: ["m", "mode"], description: typeof command.options.mode === "string" ? `Environment mode - loads .env.{mode} (default: ${command.options.mode})` : "Environment mode (e.g., production, staging) - loads .env.{mode}", schema: z.string() as TSchema, }, ] : []), ...Object.entries(this.getAllGlobalFlags()).map(([key, value]) => ({ key, ...value, })), ]; const maxFlagLength = this.getMaxFlagLength(flags); for (const flag of flags) { const { aliases, description } = flag; const schema = "schema" in flag ? (flag.schema as TSchema) : undefined; // Sort aliases by length (shorter first: -t before --target) const sortedAliases = (Array.isArray(aliases) ? aliases : [aliases]) .slice() .sort((a, b) => a.length - b.length); const flagStr = sortedAliases .map((a: string) => (a.length === 1 ? `-${a}` : `--${a}`)) .join(", "); const coloredFlag = c.set("GREY_LIGHT", flagStr); const padding = " ".repeat(Math.max(0, maxFlagLength - flagStr.length)); const formattedDesc = this.formatFlagDescription(description, schema); this.log.info(` ${coloredFlag}${padding} ${formattedDesc}`); } // Show environment variables if defined const envVars = Object.entries(command.env.properties); if (envVars.length > 0) { this.log.info(""); this.log.info(c.set("WHITE_BOLD", "Env:")); const maxEnvLength = Math.max(...envVars.map(([key]) => key.length)); for (const [key, schema] of envVars) { const isOptional = z.schema.isOptional(schema as TSchema); const description = (schema as any).description ?? ""; const optionalStr = isOptional ? c.set("GREY_DARK", " (optional)") : c.set("RED", " (required)"); const coloredKey = c.set("CYAN", key); const padding = " ".repeat(Math.max(0, maxEnvLength - key.length)); this.log.info( ` ${coloredKey}${padding} ${description}${optionalStr}`, ); } } } else { // general help this.log.info(this.description || "Available commands:"); this.log.info(""); this.log.info(c.set("WHITE_BOLD", "Commands:")); // Get top-level commands (commands that are not children of other commands) const topLevelCommands = this.getTopLevelCommands(); const maxCmdLength = this.getMaxCmdLength(topLevelCommands); for (const cmd of topLevelCommands) { // skip root command and hooks in list if (cmd.name === "" || cmd.options.hide) { continue; } const cmdStr = [cmd.name, ...cmd.aliases].join(", "); const argsUsage = cmd.hasChildren ? " <command>" : this.generateArgsUsage(cmd.options.args); const fullCmdStr = `${cmdStr}${argsUsage}`; const coloredCmd = `${c.set("GREY_LIGHT", cliName)} ${c.set("CYAN", fullCmdStr)}`; const padding = " ".repeat( Math.max(0, maxCmdLength - fullCmdStr.length), ); this.log.info( ` ${coloredCmd}${padding} ${cmd.options.description ?? ""}`, ); } this.log.info(""); this.log.info(c.set("WHITE_BOLD", "Flags:")); // In general help, also show root command flags const rootCommand = this.commands.find((cmd) => cmd.name === ""); // Read aliases/description from the schema's `.meta()` registry (zod), // not as direct schema properties (typebox) — see extractFlagDefs. const rootFlags = rootCommand ? this.extractFlagDefs(rootCommand.flags) : []; const globalFlags = [ ...rootFlags, ...Object.values(this.getAllGlobalFlags()), ]; const maxFlagLength = this.getMaxFlagLength(globalFlags); for (const { aliases, description, schema } of globalFlags) { const flagStr = aliases .map((a) => (a.length === 1 ? `-${a}` : `--${a}`)) .join(", "); const coloredFlag = c.set("GREY_LIGHT", flagStr); const padding = " ".repeat(Math.max(0, maxFlagLength - flagStr.length)); const formattedDesc = this.formatFlagDescription(description, schema); this.log.info(` ${coloredFlag}${padding} ${formattedDesc}`); } } this.log.info(""); // Newline } /** * Generate colored usage string for command arguments (for help display) */ protected generateColoredArgsUsage(schema?: TSchema): string { if (!schema) { return ""; } const c = this.color; if (z.schema.isOptional(schema)) { const typeName = this.getTypeName(schema); const key = this.schemaMeta(schema).title ?? "arg1"; return ` ${c.set("GREY_DARK", `[${key}${typeName}]`)}`; } if (z.schema.isTuple(schema) && schema.items) { const items = schema.items; const args = items.map((item, index) => { const argName = `arg${index + 1}`; const typeName = this.getTypeName(item); if (z.schema.isOptional(item)) { return c.set("GREY_DARK", `[${argName}${typeName}]`); } return c.set("CYAN", `<${argName}${typeName}>`); }); return ` ${args.join(" ")}`; } const typeName = this.getTypeName(schema); const key = this.schemaMeta(schema).title ?? "arg1"; return ` ${c.set("CYAN", `<${key}${typeName}>`)}`; } /** * Get the full command path (e.g., "deploy vercel" for a nested command) */ protected getCommandPath(command: CommandPrimitive<any>): string { const path: string[] = [command.name]; let current = command; // Walk up the tree to find parents while (true) { const parent = this.findParentCommand(current); if (!parent) break; path.unshift(parent.name); current = parent; } return path.join(" "); } /** * Find the parent command of a nested command */ protected findParentCommand( command: CommandPrimitive<any>, ): CommandPrimitive<any> | undefined { for (const cmd of this.commands) { if (cmd.children.includes(command)) { return cmd; } } return undefined; } /** * Get top-level commands (commands that are not children of other commands) */ protected getTopLevelCommands(): CommandPrimitive<any>[] { const allChildren = new Set<CommandPrimitive<any>>(); // Collect all children for (const command of this.commands) { for (const child of command.children) { allChildren.add(child); } } // Return commands that are not children return this.commands.filter((cmd) => !allChildren.has(cmd)); } /** * Calculate max display length for child commands (for help alignment) */ protected getMaxChildCmdLength(children: CommandPrimitive<any>[]): number { return Math.max( ...children .filter((c) => !c.options.hide) .map((c) => { const cmdStr = [c.name, ...c.aliases].join(", "); const argsUsage = this.generateArgsUsage(c.options.args); return `${cmdStr}${argsUsage}`.length; }), 0, ); } /** * Calculate max display length for commands (for help alignment) */ protected getMaxCmdLength(commands: CommandPrimitive[]): number { return Math.max( ...commands .filter((c) => !c.options.hide && c.name !== "") .map((c) => { const cmdStr = [c.name, ...c.aliases].join(", "); const argsUsage = c.hasChildren ? " <command>" : this.generateArgsUsage(c.options.args); return `${cmdStr}${argsUsage}`.length; }), ); } /** * Calculate max display length for flags (for help alignment) */ protected getMaxFlagLength(flags: { aliases: string[] }[]): number { return Math.max( ...flags.map((f) => { const aliases = Array.isArray(f.aliases) ? f.aliases : [f.aliases]; return aliases .map((a) => (a.length === 1 ? `-${a}` : `--${a}`)) .join(", ").length; }), ); } /** * Extract enum values from a schema if it represents an enum. * Returns undefined if the schema is not an enum. */ protected getEnumValues(schema: TSchema): string[] | undefined { if (!schema) return undefined; const base = z.schema.unwrap(schema); // A zod enum (`z.enum`). if (z.schema.isEnum(base)) { const values = z.schema.enumValues(base); return values.length > 0 && values.every((v) => typeof v === "string") ? values : undefined; } // A union of string literals (alternative enum representation). if (z.schema.isUnion(base)) { const variants = (base as any).anyOf ?? []; const values: string[] = []; for (const variant of variants) { const value = (variant as any).value; // zod literal value if (z.schema.isLiteral(variant) && typeof value === "string") { values.push(value); } else { // Not all variants are string literals, not a simple enum. return undefined; } } return values.length > 0 ? values : undefined; } return undefined; } /** * Format flag description with enum values if applicable. */ protected formatFlagDescription( description: string | undefined, schema: TSchema | undefined, ): string { const baseDesc = description ?? ""; if (!schema) return baseDesc; const enumValues = this.getEnumValues(schema); if (enumValues && enumValues.length > 0) { const valuesStr = enumValues.join(", "); const c = this.color; const enumHint = c.set("GREY_DARK", `[${valuesStr}]`); return baseDesc ? `${baseDesc} ${enumHint}` : enumHint; } return baseDesc; } }