import { Command } from "commander";
import fs from "fs";
import path from "path";
import { CLI_FILE_NAME } from "./constants";
import { CLIUtils } from "./utils";
import { Logger, Logging } from "@decaf-ts/logging";
import { style } from "styled-string-builder";
const colors = [
"\x1b[38;5;215m", // soft orange
"\x1b[38;5;209m", // coral
"\x1b[38;5;205m", // pink
"\x1b[38;5;210m", // peachy
"\x1b[38;5;217m", // salmon
"\x1b[38;5;216m", // light coral
"\x1b[38;5;224m", // light peach
"\x1b[38;5;230m", // soft cream
"\x1b[38;5;230m", // soft cream
];
/**
* @description Utility class to handle CLI functionality from all Decaf modules
* @summary This class provides a wrapper around Commander.js to handle CLI commands from different Decaf modules.
* It crawls the filesystem to find CLI modules, loads them, and registers their commands.
*
* @param {string} [basePath] The base path to look for modules in. Defaults to `./`
* @param {number} [crawlLevels] Number of folder levels to crawl to find modules from the basePath. Defaults to 4
*
* @example
* // Create a new CLI wrapper and run it with custom options
* const cli = new CliWrapper('./src', 2);
* cli.run(process.argv).then(() => {
* console.log('CLI commands executed successfully');
* });
*
* @class CliWrapper
*/
export class CliWrapper {
private _command?: Command;
private modules: Record<string, Command> = {};
private readonly rootPath: string;
constructor(
private basePath: string = "./",
private crawlLevels = 4
) {
this.rootPath = path.resolve(__dirname, "..");
}
/**
* @description Retrieves and initializes the Commander Command object
* @summary Lazy-loads the Command object, initializing it with the package name, description, and version
* @return {Command} The initialized Command object
* @private
*/
private get command() {
if (!this._command) {
this._command = new Command();
CLIUtils.initialize(this._command, this.rootPath);
}
return this._command;
}
/**
* @description Loads and registers a module from a file
* @summary Dynamically imports a CLI module from the specified file path, initializes it, and registers it in the modules collection
*
* @param {string} filePath Path to the module file to load
* @param {string} rootPath Repository root path to find the package.json
* @return {Promise<string>} A promise that resolves to the module name
*
* @private
* @mermaid
* sequenceDiagram
* participant CliWrapper
* participant CLIUtils
* participant Module
*
* CliWrapper->>CLIUtils: loadFromFile(filePath)
* CLIUtils-->>CliWrapper: module
* CliWrapper->>CliWrapper: Get module name
* CliWrapper->>Command: new Command()
* Command-->>CliWrapper: cmd
* CliWrapper->>CLIUtils: initialize(cmd, path.dirname(rootPath))
* CliWrapper->>Module: module()
* Note over CliWrapper,Module: Handle Promise if needed
* Module-->>CliWrapper: Command instance
* CliWrapper->>CliWrapper: Store in modules[name]
* CliWrapper-->>CliWrapper: Return name
*/
private async load(filePath: string): Promise<string> {
let name;
try {
let module = await CLIUtils.loadFromFile(filePath);
name = module.name as string;
if (module instanceof Function) module = module() as any;
if (module instanceof Promise) module = await module;
if (!(module instanceof Command))
throw new Error(
`You should export the instantiated Commands class as default.`
);
this.modules[name] = module;
} catch (e: unknown) {
throw new Error(
`failed to load module under ${filePath}: ${e instanceof Error ? e.message : e}`
);
}
return name;
}
/**
* @description Finds and loads all CLI modules in the basePath
* @summary Uses the crawl method to find all CLI modules in the specified base path,
* then loads and registers each module as a subcommand
*
* @return {Promise<void>} A promise that resolves when all modules are loaded
*
* @private
* @mermaid
* sequenceDiagram
* participant CliWrapper
* participant Filesystem
* participant Module
*
* CliWrapper->>Filesystem: Join basePath with cwd
* CliWrapper->>CliWrapper: crawl(basePath, crawlLevels)
* CliWrapper-->>CliWrapper: modules[]
* loop For each module
* alt Not @decaf-ts/cli
* CliWrapper->>CliWrapper: load(module, cwd)
* CliWrapper-->>CliWrapper: name
* CliWrapper->>CliWrapper: Check if command exists
* alt Command doesn't exist
* CliWrapper->>Command: command(name).addCommand(modules[name])
* end
* end
* end
* CliWrapper->>Console: Log loaded modules
*/
private async boot() {
const basePath = path.resolve(this.rootPath, this.basePath);
const modules = this.crawl(basePath, this.crawlLevels);
for (const module of modules) {
if (module.includes("@decaf-ts/cli")) {
continue;
}
let name: string;
try {
name = await this.load(module);
} catch (e: unknown) {
console.error(e);
continue;
}
if (
!this.command.commands.find(
(c) => (c as unknown as Record<string, string>)["_name"] === name
)
)
try {
this.command.command(name).addCommand(this.modules[name]);
} catch (e: unknown) {
console.error(e);
}
}
console.log(
`loaded modules:\n${Object.keys(this.modules)
.map((k) => `- ${k}`)
.join("\n")}`
);
}
/**
* @description Recursively searches for CLI module files in the directory structure
* @summary Crawls the basePath up to the specified number of folder levels to find files named according to CLI_FILE_NAME
*
* @param {string} basePath The absolute base path to start searching in
* @param {number} [levels=2] The maximum number of directory levels to crawl
* @return {string[]} An array of file paths to CLI modules
*
* @private
*/
private crawl(basePath: string, levels: number = 2) {
if (levels <= 0) return [];
return fs.readdirSync(basePath).reduce((accum: string[], file) => {
file = path.join(basePath, file);
if (fs.statSync(file).isDirectory()) {
accum.push(...this.crawl(file, levels - 1));
} else if (file.match(new RegExp(`${CLI_FILE_NAME}\\.[cm]js$`, "gm"))) {
accum.push(file);
}
return accum;
}, []);
}
protected getSlogan(): string {
// Find nearest node_modules from this file's directory to survive bundling
const startDir = __dirname;
let current: string | undefined = startDir;
let nodeModulesDir: string | undefined;
try {
while (current && current !== path.parse(current).root) {
const candidate = path.join(current, "node_modules");
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
nodeModulesDir = candidate;
break;
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
} catch {
// ignore errors during traversal
}
const slogans: string[] = [];
if (nodeModulesDir) {
const scopeDir = path.join(nodeModulesDir, "@decaf-ts");
try {
if (fs.existsSync(scopeDir) && fs.statSync(scopeDir).isDirectory()) {
const pkgs = fs.readdirSync(scopeDir);
for (const pkg of pkgs) {
const depPath = path.join(scopeDir, pkg);
try {
const slogansPath = path.join(
depPath,
"workdocs",
"assets",
"slogans.json"
);
if (
fs.existsSync(slogansPath) &&
fs.statSync(slogansPath).isFile()
) {
const raw = fs.readFileSync(slogansPath, "utf-8");
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
for (const s of parsed) {
if (typeof s === "string" && s.trim().length > 0) {
slogans.push(s.trim());
}
}
}
}
} catch {
// ignore per-package errors
}
}
}
} catch {
// ignore scope directory errors
}
}
if (slogans.length === 0) {
return "Decaf: strongly brewed TypeScript.";
}
const idx = Math.floor(Math.random() * slogans.length);
return slogans[idx];
}
protected printBanner(logger: Logger = Logging.get()) {
let message: string;
try {
message = this.getSlogan();
} catch {
message = "Decaf: strongly brewed TypeScript.";
}
const banner: string | string[] =
`# ░▒▓███████▓▒░ ░▒▓████████▓▒░ ░▒▓██████▓▒░ ░▒▓██████▓▒░ ░▒▓████████▓▒░ ░▒▓████████▓▒░ ░▒▓███████▓▒░
# ( ( ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░
# ) ) ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░
# [=======] ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░ ░▒▓████████▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░ ░▒▓██████▓▒░
# \`-----´ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░
# ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░
# ░▒▓███████▓▒░ ░▒▓████████▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓███████▓▒░
#`.split("\n");
const maxLength = banner.reduce(
(max, line) => Math.max(max, line.length),
0
);
banner.push(`# ${message.padStart(maxLength - 3)}`);
banner.forEach((line, index) => {
const color = colors[index % colors.length] || "";
const logFn = logger
? logger.info.bind(logger)
: console.log.bind(console);
try {
const msg = style(line || "").raw(color).text;
logFn(msg);
} catch {
// Fallback to plain output if styling fails for any reason
logFn(String(line || ""));
}
});
}
/**
* @description Executes the CLI with the provided arguments
* @summary Boots the CLI by loading all modules, then parses and executes the command specified in the arguments
*
* @param {string[]} [args=process.argv] Command line arguments to parse and execute
* @return {Promise<void>} A promise that resolves when the command execution is complete
*
* @mermaid
* sequenceDiagram
* participant Client
* participant CliWrapper
* participant Command
*
* Client->>CliWrapper: run(args)
* CliWrapper->>CliWrapper: boot()
* Note over CliWrapper: Loads all modules
* CliWrapper->>Command: parseAsync(args)
* Command-->>CliWrapper: result
* CliWrapper-->>Client: result
*/
async run(args: string[] = process.argv) {
await this.boot();
this.printBanner();
return this.command.parseAsync(args);
}
}
Source