alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
351 lines (322 loc) • 9.35 kB
text/typescript
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;
}