UNPKG

alepha

Version:

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

238 lines (206 loc) 7.05 kB
import { cp, glob, rm } from "node:fs/promises"; import { $inject, Alepha } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { ShellProvider } from "alepha/system"; import { CommandError } from "../errors/CommandError.ts"; export type Task = { name: string; handler: () => any; }; interface Timer { name: string; duration: string; } export interface RunOptions { /** * Rename the command for logging purposes. */ alias?: string; /** * Root directory to execute the command in. */ root?: string; } export interface RunnerMethod { ( cmd: string | Task | Array<string | Task>, options?: RunOptions | (() => any), ): Promise<string>; rm: (glob: string | string[], options?: RunOptions) => Promise<string>; cp: (source: string, dest: string, options?: RunOptions) => Promise<string>; /** * Ends the runner and prints a summary of executed tasks. * * > This is automatically called at the end of command execution. * > But can be called manually if needed to print more stuff before the command ends. */ end: () => void; } /** * Runs CLI tasks (shell commands or functions) and logs their lifecycle. * * Output is intentionally plain and verbose: every task logs a * `Starting …` / `Finished … after Ns` line through the standard logger, * and shelled commands **stream** their stdout/stderr straight to the * terminal (`capture: false`) so tool output — `vite build` warnings, * Biome diagnostics, nested `alepha` subcommands — is visible live. */ export class Runner { protected readonly log = $logger(); protected readonly dateTime = $inject(DateTimeProvider); protected timers: Timer[] = []; protected readonly startTime: number = this.dateTime.nowMillis(); protected readonly alepha = $inject(Alepha); protected readonly shell = $inject(ShellProvider); public readonly run: RunnerMethod; constructor() { this.run = this.createRunMethod(); } /** * Start a new command session. * * Retained for API compatibility (the CLI calls it before each command); * task lifecycle is now logged statelessly, so there is nothing to reset. */ public startCommand(_cliName: string, _commandName: string): void {} protected createRunMethod() { const runFn: RunnerMethod = async ( cmd: string | Task | Array<string | Task>, options?: RunOptions | (() => any), ) => { const root = typeof options === "object" && options.root ? options.root : undefined; if (Array.isArray(cmd)) { return await this.execute( cmd.map((it) => typeof it === "string" ? { name: it, handler: () => this.exec(it, { root }) } : it, ), ); } const alias = typeof options === "object" ? options.alias : undefined; const name = alias ?? (typeof cmd === "string" ? cmd : cmd.name); const handler = typeof options === "function" ? options : typeof cmd === "string" ? () => this.exec(cmd, { root }) : cmd.handler; return await this.execute({ name, handler, }); }; runFn.rm = async ( files: string | string[], options: RunOptions = {}, ): Promise<string> => { if (Array.isArray(files) || files.includes("*")) { return runFn({ name: options.alias ?? `rm -rf ${Array.isArray(files) ? files.join(" ") : files}`, handler: async () => { for await (const file of glob(files)) { this.log.trace(`Removing ${file}`); await rm(file, { recursive: true, force: true }); } }, }); } this.log.trace(`Removing ${files}`); return runFn({ name: options.alias ?? `rm -rf ${files}`, handler: () => rm(files, { recursive: true, force: true }), }); }; runFn.cp = async ( source: string, dist: string, options: RunOptions = {}, ): Promise<string> => { this.log.trace(`Copying ${source} to ${dist}`); return runFn( { name: options.alias ?? `cp -r ${source} ${dist}`, handler: () => cp(source, dist, { recursive: true }), }, options, ); }; runFn.end = () => this.end(); return runFn; } protected async exec( cmd: string, opts: { root?: string } = {}, ): Promise<string> { // Stream child output straight to the terminal only when DEBUG (or more // verbose) is enabled — i.e. under `--verbose`, an agent session // (CLAUDECODE), or `LOG_LEVEL=debug`. Otherwise capture it so a quiet run // (e.g. `alepha verify`) is not buried under sub-process output; captured // output is surfaced on failure by {@link executeTask}. const stream = this.log.isLevelEnabled("DEBUG"); return this.shell.run(cmd, { root: opts.root, capture: !stream }); } /** * Executes one or more tasks. * * @param task - A single task or an array of tasks to run in parallel. */ protected async execute(task: Task | Task[]): Promise<string> { if (Array.isArray(task)) { await Promise.all(task.map((t) => this.executeTask(t))); return ""; // not supported for now } else { return await this.executeTask(task); } } /** * Prints a summary of all executed tasks and their durations. */ public end(): void { if (this.timers.length === 0) return; const totalTime = ( (this.dateTime.nowMillis() - this.startTime) / 1000 ).toFixed(1); this.log.info(`Total time: ${totalTime}s`); // clear timers after rendering this.timers = []; } protected async executeTask(task: Task): Promise<string> { const now = this.dateTime.nowMillis(); this.log.info(`Starting '${task.name}' ...`); let stdout = ""; try { stdout = String((await task.handler()) ?? ""); } catch (error) { // Streamed tasks have already printed their output live and reject // without stdout/stderr attached; this surfaces output from captured // tasks (capture: true — the default below DEBUG) before throwing. const err = error as { stdout?: string; stderr?: string }; const captured = [err?.stdout, err?.stderr] .filter(Boolean) .join("\n") .trim(); if (captured) { this.log.info(`\n\n${captured}`); } throw new CommandError(`Task '${task.name}' failed`, { cause: error }); } if (stdout) this.log.trace(stdout); const duration = ((this.dateTime.nowMillis() - now) / 1000).toFixed(1); const message = stdout && !stdout.includes("\n") ? stdout.trim() : undefined; const suffix = message ? ` - ${message}` : ""; this.log.info(`Finished '${task.name}' after ${duration}s${suffix}`); this.timers.push({ name: task.name, duration: `${duration}s`, }); return stdout; } }