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