UNPKG

alepha

Version:

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

351 lines (322 loc) 9.35 kB
import type * as fs from "node:fs/promises"; import type { glob } from "node:fs/promises"; import { type Async, createPrimitive, KIND, Primitive, type Static, type TObject, type TSchema, z, } from "alepha"; import type { AskMethod } from "../helpers/Asker.ts"; import type { RunnerMethod } from "../helpers/Runner.ts"; /** * Declares a CLI command. * * This primitive allows you to define a command, its flags, and its handler * within your Alepha application structure. */ export const $command = < T extends TObject, A extends TSchema, E extends TObject, >( options: CommandPrimitiveOptions<T, A, E>, ) => createPrimitive(CommandPrimitive<T, A, E>, options); // --------------------------------------------------------------------------------------------------------------------- export interface CommandPrimitiveOptions< T extends TObject, A extends TSchema, E extends TObject = TObject, > { /** * The handler function to execute when the command is matched. * * For parent commands with children, the handler is called when: * - The parent command is invoked without a subcommand * - The parent command is invoked with --help (to show available subcommands) */ handler: (args: CommandHandlerArgs<T, A, E>) => Async<void>; /** * The name of the command. If omitted, the property key is used. * * An empty string "" denotes the root command. */ name?: string; /** * A short description of the command, shown in the help message. */ description?: string; /** * An array of alternative names for the command. */ aliases?: string[]; /** * A TypeBox object schema defining the flags for the command. */ flags?: T; /** * A TypeBox object schema defining required environment variables. * * Environment variables are validated before the handler runs (fail fast). * They are displayed in the help output under "Env:" section. * * @example * ```ts * $command({ * env: z.object({ * VERCEL_TOKEN: z.text({ description: "Vercel API token" }), * VERCEL_ORG_ID: z.text({ description: "Organization ID" }).optional(), * }), * handler: async ({ env }) => { * // env.VERCEL_TOKEN is typed & guaranteed to exist * console.log(env.VERCEL_TOKEN); * } * }) * ``` */ env?: E; /** * An optional TypeBox schema defining the arguments for the command. * * @example * args: z.text() * my-cli command <arg1: string> * * args: z.text().optional() * my-cli command [arg1: string] * * args: z.tuple([z.text(), z.number()]) * my-cli command <arg1: string> <arg2: number> * * args: z.tuple([z.text(), z.number().optional()]) * my-cli command <arg1: string> [arg2: number] */ args?: A; /** * Marks this command as the root command. * Equivalent to setting name to an empty string "". */ root?: boolean; /** * Run this command's handler BEFORE the specified target command. * * Pre-hooks are not listed in help and cannot be called directly. * They receive the same parsed flags and args as the target command. * * @example * ```ts * class BuildCommands { * prebuild = $command({ * pre: "build", * handler: async ({ run }) => { * await run("cleaning dist folder...", () => fs.rm("dist")); * } * }); * * build = $command({ * name: "build", * handler: async () => { ... } * }); * } * ``` */ pre?: string; /** * Run this command's handler AFTER the specified target command. * * Post-hooks are not listed in help and cannot be called directly. * They receive the same parsed flags and args as the target command. * * @example * ```ts * class BuildCommands { * build = $command({ * name: "build", * handler: async () => { ... } * }); * * postbuild = $command({ * post: "build", * handler: async ({ run }) => { * await run("generating checksums...", generateChecksums); * } * }); * } * ``` */ post?: string; /** * If true, this command will be hidden from the help output. */ hide?: boolean; /** * Adds a `--mode, -m` flag to load environment files. * * When enabled: * - Loads `.env` and `.env.local` by default * - With `--mode production`, also loads `.env.production` and `.env.production.local` * - The mode value is exposed in the handler as `mode: string | undefined` * * Set to `true` to enable with no default, or a string to set a default mode. * * This follows Vite's environment loading convention. * @see https://vite.dev/guide/env-and-mode * * @example * ```ts * // No default mode * build = $command({ * mode: true, * handler: async ({ mode }) => { * console.log(`Building for ${mode ?? 'development'}...`); * } * }); * * // Default mode "production" * deploy = $command({ * mode: "production", * handler: async ({ mode }) => { * console.log(`Deploying for ${mode}...`); // always defined * } * }); * ``` * * Usage: * - `cli build` - loads .env (mode = undefined) * - `cli build --mode production` - loads .env and .env.production * - `cli deploy` - loads .env and .env.production (default mode) * - `cli deploy --mode staging` - loads .env and .env.staging */ mode?: boolean | string; /** * Child commands (subcommands) for this command. * * When children are defined, the command becomes a parent command that * can be invoked with space-separated subcommands: * * @example * ```ts * class DeployCommands { * // Subcommands * vercel = $command({ * description: "Deploy to Vercel", * handler: async () => { ... } * }); * * cloudflare = $command({ * description: "Deploy to Cloudflare", * handler: async () => { ... } * }); * * // Parent command with children * deploy = $command({ * description: "Deploy the application", * children: [this.vercel, this.cloudflare], * handler: async () => { * // Called when "deploy" is invoked without subcommand * console.log("Available: deploy vercel, deploy cloudflare"); * } * }); * } * ``` * * This allows CLI usage like: * - `cli deploy vercel` - runs the vercel subcommand * - `cli deploy cloudflare` - runs the cloudflare subcommand * - `cli deploy` - runs the parent handler (shows available subcommands) * - `cli deploy --help` - shows help with all available subcommands */ children?: CommandPrimitive<any, any>[]; } // --------------------------------------------------------------------------------------------------------------------- export class CommandPrimitive< T extends TObject = TObject, A extends TSchema = TSchema, E extends TObject = TObject, > extends Primitive<CommandPrimitiveOptions<T, A, E>> { public readonly flags = this.options.flags ?? z.object({}); public readonly env = this.options.env ?? z.object({}); public readonly aliases = this.options.aliases ?? []; protected onInit() { if (this.options.pre || this.options.post) { this.options.hide ??= true; } } public get name(): string { if (this.options.root) { return ""; } if (this.options.pre) { return `pre${this.options.pre}`; } if (this.options.post) { return `post${this.options.post}`; } return this.options.name ?? `${this.config.propertyKey}`; } /** * Get the child commands (subcommands) for this command. */ public get children(): CommandPrimitive<any, any>[] { return this.options.children ?? []; } /** * Check if this command has child commands (is a parent command). */ public get hasChildren(): boolean { return this.children.length > 0; } /** * Find a child command by name or alias. */ public findChild(name: string): CommandPrimitive<any, any> | undefined { return this.children.find( (child) => child.name === name || child.aliases.includes(name), ); } } $command[KIND] = CommandPrimitive; // --------------------------------------------------------------------------------------------------------------------- export interface CommandHandlerArgs< T extends TObject, A extends TSchema = TSchema, E extends TObject = TObject, > { flags: Static<T>; args: A extends TSchema ? Static<A> : Array<string>; env: Static<E>; run: RunnerMethod; ask: AskMethod; glob: typeof glob; fs: typeof fs; /** * The root directory where the command is executed. */ root: string; /** * Display help for the current command. * * Useful for parent commands with children to show available subcommands * when invoked without a specific subcommand. * * @example * ```ts * deploy = $command({ * children: [this.vercel, this.cloudflare], * handler: async ({ help }) => { * help(); // Shows available subcommands * } * }); * ``` */ help: () => void; /** * The current execution mode (e.g., "development", "production", "staging"). * * Use --mode flag to set this value when running the command. */ mode?: string; }