Source

CliWrapper.ts

import { Command } from "commander";
import fs from "fs";
import path from "path";
import { CLI_FILE_NAME } from "./constants";
import { CLIUtils } from "./utils";
import {
  LoggedClass,
  LogLevel,
  LogParameterDescriptor,
  Logging,
  Logger,
} from "@decaf-ts/logging";
import { banners, colorPalettes } from "./banners";
import { readSlogans } from "./slogans";
import { DecafCLieEnvironment } from "./environment";
import buildModule from "./build-module/cli-module";
import releaseModule from "./release-module/cli-module";
import utilsModule from "./utils-module/cli-module";

const MIN_BANNER_WIDTH = 92;
const DEFAULT_LOG_LEVEL = LogLevel.info;
const DEFAULT_PATTERN =
  "{level} [{timestamp}] {app} {context} {separator} {message} {stack}";
const EFFECTIVE_FORMAT =
  typeof DecafCLieEnvironment.format === "string"
    ? DecafCLieEnvironment.format
    : Logging.getConfig().format;
const basePatternCandidate =
  typeof DecafCLieEnvironment.pattern === "string" &&
  DecafCLieEnvironment.pattern.includes("{")
    ? DecafCLieEnvironment.pattern
    : (Logging.getConfig().pattern ?? DEFAULT_PATTERN);
const PATTERN_WITH_PID = basePatternCandidate.includes("{pId}")
  ? basePatternCandidate
  : `${basePatternCandidate}{pId}`;

const applyLoggingConfig = (level: LogLevel) => {
  Logging.setConfig({
    level,
    format: EFFECTIVE_FORMAT,
    pattern: PATTERN_WITH_PID,
  });
};
const CLI_PACKAGE_NAME = "@decaf-ts/cli";

type CliModuleFactory = () => Command;

const INCLUDED_MODULE_FACTORIES: CliModuleFactory[] = [
  buildModule,
  releaseModule,
  utilsModule,
];

const pIdDescriptor: LogParameterDescriptor = {
  key: "pId",
  render(payload) {
    if (payload.config.logLevel === false) return undefined;
    return `, pid: ${process.pid.toString()}`;
  },
  style(rendered, payload) {
    return payload.applyTheme(rendered, "context");
  },
};

try {
  Logging.register(pIdDescriptor);
} catch (e: unknown) {
  throw new Error(`Failed to register process ID logging parameter: ${e}`);
}
applyLoggingConfig(DEFAULT_LOG_LEVEL);

/**
 * @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 extends LoggedClass {
  private _command?: Command;
  private modules: Record<string, Command> = {};
  private includedModuleNames: Set<string> = new Set();
  private readonly rootPath: string;

  private moduleSlogans: Record<string, string[]> = {};
  private globalSlogans: string[] = [];
  private readonly logLevelAttached = new WeakSet<Command>();
  private bannerPrinted = false;

  private static env = DecafCLieEnvironment;

  constructor(
    private basePath: string = DecafCLieEnvironment.cliModuleRoot,
    private crawlLevels = 4
  ) {
    super();
    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 | undefined> {
    const log = this.log.for(this.load);
    let name: string;

    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 (!this.isCommandInstance(module))
        throw new Error(
          `You should export the instantiated Commands class as default.`
        );

      this.ensureLogLevelSupport(module);

      const moduleRoot = this.findModuleRoot(path.dirname(filePath));
      const packageName = this.getPackageNameFromRoot(moduleRoot);
      if (
        packageName === CLI_PACKAGE_NAME &&
        this.includedModuleNames.has(name)
      ) {
        return undefined;
      }

      this.registerModule(name, module, moduleRoot, log);
    } catch (e: unknown) {
      throw new Error(
        `failed to load module under ${filePath}: ${e instanceof Error ? e.message : e}`
      );
    }

    return name;
  }

  private registerModule(
    name: string,
    module: Command,
    moduleRoot: string,
    log: Logger
  ) {
    this.modules[name] = module;

    try {
      const records = readSlogans(log, moduleRoot);
      const strings = this.extractSloganStrings(records);
      if (strings.length) {
        this.moduleSlogans[name] = strings;
      }
    } catch (e: unknown) {
      console.error(`Failed to load slogans for ${name}: ${e}`);
    }
  }

  /**
   * @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 log = this.log.for(this.boot);
    this.ensureLogLevelSupport(this.command);
    this.updateLogLevel(DEFAULT_LOG_LEVEL);
    this.loadIncludedModules();

    const basePath = this.getHostPath();
    const seen = new Set<string>();
    await this.loadModulesFromPath(basePath, seen, log);

    const scopePaths = this.getScopePackageRoots();
    for (const scopePath of scopePaths) {
      await this.loadModulesFromPath(scopePath, seen, log);
    }

    const siblingPackageRoots = this.getSiblingPackageRoots();
    for (const siblingRoot of siblingPackageRoots) {
      await this.loadModulesFromPath(siblingRoot, seen, log);
    }

    log.debug(
      `loaded modules:\n${Object.keys(this.modules)
        .map((k) => `- ${k}`)
        .join("\n")}`
    );
  }

  private loadIncludedModules() {
    const log = this.log.for(this.loadIncludedModules);
    for (const factory of INCLUDED_MODULE_FACTORIES) {
      try {
        const moduleName = factory.name;
        if (!moduleName || this.includedModuleNames.has(moduleName)) {
          continue;
        }

        const command = factory();
        if (!this.isCommandInstance(command)) {
          continue;
        }
        this.ensureLogLevelSupport(command);

        this.includedModuleNames.add(moduleName);
        this.registerModule(moduleName, command, this.rootPath, log);

        if (!this.command.commands.some((cmd) => cmd.name() === moduleName)) {
          this.command.addCommand(command);
        }
      } catch (error: unknown) {
        console.error(
          `failed to load included module ${factory.name}: ${
            error instanceof Error ? error.message : error
          }`
        );
      }
    }
  }

  private async loadModulesFromPath(
    basePath: string,
    seen: Set<string>,
    log: Logger
  ) {
    if (!fs.existsSync(basePath)) {
      return;
    }

    try {
      if (!fs.statSync(basePath).isDirectory()) {
        return;
      }
    } catch {
      return;
    }

    const modules = this.crawl(basePath, this.crawlLevels);

    for (const module of modules) {
      const resolved = path.resolve(module);
      if (seen.has(resolved)) {
        continue;
      }
      seen.add(resolved);

      let name: string | undefined;
      try {
        name = await this.load(resolved);
      } catch (e: unknown) {
        console.error(e);
        continue;
      }
      if (!name) {
        continue;
      }

      const alreadyRegistered = this.command.commands.some(
        (cmd) => cmd.name() === name
      );
      if (alreadyRegistered) {
        log.verbose(`Command ${name} already registered; skipping duplicate`);
        continue;
      }

      try {
        this.command.addCommand(this.modules[name]);
      } catch (e: unknown) {
        console.error(e);
      }
    }
  }

  private getScopePackageRoots(): string[] {
    const scopeRoots = new Set<string>();
    const candidates = [this.rootPath];
    const hostPath = this.getHostPath();
    if (hostPath !== this.rootPath) {
      candidates.push(hostPath);
    }

    for (const candidate of candidates) {
      const scopeDir = path.join(candidate, "node_modules", "@decaf-ts");
      try {
        if (!fs.existsSync(scopeDir) || !fs.statSync(scopeDir).isDirectory()) {
          continue;
        }

        const entries = fs.readdirSync(scopeDir, { withFileTypes: true });
        for (const entry of entries) {
          if (!entry.isDirectory()) continue;
          scopeRoots.add(path.join(scopeDir, entry.name));
        }
      } catch {
        // ignore inaccessible nodes
      }
    }

    return Array.from(scopeRoots);
  }

  private findEnclosingNodeModules(startPath: string): string | undefined {
    let current = startPath;
    const root = path.parse(startPath).root;
    while (current && current !== root) {
      if (path.basename(current) === "node_modules") {
        return current;
      }
      const parent = path.dirname(current);
      if (parent === current) break;
      current = parent;
    }
    if (path.basename(root) === "node_modules") {
      return root;
    }
    return undefined;
  }

  private getSiblingPackageRoots(): string[] {
    const nodeModulesRoot = this.findEnclosingNodeModules(this.rootPath);
    if (!nodeModulesRoot) {
      return [];
    }

    const siblingRoots = new Set<string>();

    try {
      const entries = fs.readdirSync(nodeModulesRoot, {
        withFileTypes: true,
      });

      for (const entry of entries) {
        if (!entry.isDirectory() || entry.name.startsWith(".")) {
          continue;
        }

        const entryPath = path.join(nodeModulesRoot, entry.name);

        if (entry.name.startsWith("@")) {
          try {
            const scopedEntries = fs.readdirSync(entryPath, {
              withFileTypes: true,
            });
            for (const scopedEntry of scopedEntries) {
              if (!scopedEntry.isDirectory()) continue;
              const scopedPath = path.join(entryPath, scopedEntry.name);
              if (scopedPath === this.rootPath) continue;
              siblingRoots.add(scopedPath);
            }
          } catch {
            continue;
          }
          continue;
        }

        if (entryPath === this.rootPath) {
          continue;
        }
        siblingRoots.add(entryPath);
      }
    } catch {
      // Ignore node_modules access problems
    }

    return Array.from(siblingRoots);
  }

  private getHostPath(): string {
    return path.resolve(this.rootPath, this.basePath);
  }

  private getPackageNameFromRoot(root: string): string | undefined {
    try {
      const pkgPath = path.join(root, "package.json");
      if (!fs.existsSync(pkgPath)) {
        return undefined;
      }
      const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
      return typeof pkg.name === "string" ? pkg.name : undefined;
    } catch {
      return undefined;
    }
  }

  private ensureLogLevelSupport(command: Command) {
    if (this.logLevelAttached.has(command)) {
      return;
    }

    this.logLevelAttached.add(command);

    const hasLogLevelOption = command.options.some(
      (option) => option.long === "--logLevel"
    );
    if (!hasLogLevelOption) {
      command.option(
        "--logLevel <level>",
        "Override the CLI log level (error, warn, info, verbose, debug, trace, silly)",
        DEFAULT_LOG_LEVEL
      );
    }

    command.hook("preAction", (_, actionCommand) => {
      const opts = actionCommand.opts() as Record<string, unknown>;
      this.updateLogLevel(opts.logLevel as string | undefined);
    });

    for (const subCommand of command.commands) {
      this.ensureLogLevelSupport(subCommand);
    }
  }

  private updateLogLevel(level?: string) {
    const resolvedLevel = this.resolveLogLevel(level);
    applyLoggingConfig(resolvedLevel);
  }

  private resolveLogLevel(level?: string): LogLevel {
    if (!level) return DEFAULT_LOG_LEVEL;
    const normalized = level.toLowerCase();
    const options = Object.values(LogLevel) as string[];
    if (options.includes(normalized)) {
      return normalized as LogLevel;
    }
    console.warn(
      `Unknown log level "${level}" provided; falling back to ${DEFAULT_LOG_LEVEL}`
    );
    return DEFAULT_LOG_LEVEL;
  }

  /**
   * @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()) {
        if (levels > 0) {
          accum.push(...this.crawl(file, levels - 1));
        }
      } else if (file.match(new RegExp(`${CLI_FILE_NAME}\\.[cm]js$`, "gm"))) {
        accum.push(file);
      }
      return accum;
    }, []);
  }

  private findModuleRoot(initialDir: string): string {
    let current = initialDir;
    const root = path.parse(initialDir).root;
    while (true) {
      if (fs.existsSync(path.join(current, "package.json"))) {
        return current;
      }
      if (current === root) break;
      const parent = path.dirname(current);
      if (parent === current) break;
      current = parent;
    }
    return initialDir;
  }

  protected getSlogan(priorityModule?: string): string {
    this.ensureGlobalSlogans();
    const priority = priorityModule
      ? this.moduleSlogans[priorityModule] || []
      : [];
    const others = this.otherSlogans(priorityModule);

    if (!priority.length && !others.length) {
      return "Decaf: strongly brewed TypeScript.";
    }

    if (!priority.length) {
      return others[Math.floor(Math.random() * others.length)];
    }

    if (!others.length) {
      return priority[Math.floor(Math.random() * priority.length)];
    }

    const pool = this.buildBalancedSloganPool(priority, others);
    return pool[Math.floor(Math.random() * pool.length)];
  }

  private otherSlogans(priorityModule?: string): string[] {
    const modules = Object.entries(this.moduleSlogans)
      .filter(([name]) => name !== priorityModule)
      .flatMap(([, slogans]) => slogans);
    if (this.globalSlogans.length === 0 && modules.length === 0) {
      return [];
    }
    return [...this.globalSlogans, ...modules];
  }

  private buildBalancedSloganPool(
    primary: string[],
    secondary: string[]
  ): string[] {
    const targetLength = Math.max(primary.length, secondary.length);
    const primaryBucket = this.repeatToLength(primary, targetLength);
    const secondaryBucket = this.repeatToLength(secondary, targetLength);
    const result: string[] = [];
    for (let i = 0; i < targetLength; i++) {
      result.push(primaryBucket[i]);
      result.push(secondaryBucket[i]);
    }
    return result;
  }

  private repeatToLength(values: string[], targetLength: number): string[] {
    if (!values.length || targetLength <= 0) return [];
    return Array.from({ length: targetLength }, (_, index) => {
      return values[index % values.length];
    });
  }

  private ensureGlobalSlogans() {
    if (this.globalSlogans.length > 0) return;
    const log = this.log.for(this.ensureGlobalSlogans);
    const gathered: string[] = [];

    const basePaths = [this.rootPath];
    const hostPath = path.resolve(this.rootPath, this.basePath);
    if (hostPath !== this.rootPath) {
      basePaths.push(hostPath);
    }

    for (const candidate of basePaths) {
      this.collectSlogansFromPath(log, candidate, gathered);
      this.collectSlogansFromScope(log, candidate, gathered);
    }

    this.collectSlogansFromSiblingPackages(log, gathered);

    this.globalSlogans = gathered;
  }

  private collectSlogansFromPath(
    log: any,
    basePath: string,
    accumulator: string[]
  ) {
    try {
      const records = readSlogans(log, basePath);
      if (!records) return;
      for (const entry of records) {
        if (entry && typeof entry.Slogan === "string" && entry.Slogan.trim()) {
          accumulator.push(entry.Slogan.trim());
        }
      }
    } catch {
      // readSlogans already logs issues
    }
  }

  private collectSlogansFromScope(
    log: Logger,
    basePath: string,
    accumulator: string[]
  ) {
    const scopeDir = path.join(basePath, "node_modules", "@decaf-ts");
    try {
      if (!fs.existsSync(scopeDir) || !fs.statSync(scopeDir).isDirectory()) {
        return;
      }
      const pkgs = fs.readdirSync(scopeDir);
      for (const pkg of pkgs) {
        const candidate = path.join(scopeDir, pkg);
        try {
          if (
            !fs.existsSync(candidate) ||
            !fs.statSync(candidate).isDirectory()
          ) {
            continue;
          }
          this.collectSlogansFromPath(log, candidate, accumulator);
        } catch {
          // ignore individual package errors
        }
      }
    } catch {
      // ignore scope directory errors
    }
  }

  private collectSlogansFromSiblingPackages(
    log: Logger,
    accumulator: string[]
  ) {
    const siblings = this.getSiblingPackageRoots();
    for (const sibling of siblings) {
      this.collectSlogansFromPath(log, sibling, accumulator);
    }
  }

  private extractSloganStrings(records?: { Slogan: string }[]): string[] {
    if (!records || !records.length) return [];
    return records
      .map((entry) => (entry && entry.Slogan ? entry.Slogan.trim() : ""))
      .filter((value) => value.length > 0);
  }

  private isCommandInstance(value: unknown): value is Command {
    if (value instanceof Command) return true;
    if (!value || typeof value !== "object") return false;
    const candidate = value as Command;
    return (
      typeof candidate.parseAsync === "function" &&
      typeof candidate.addCommand === "function" &&
      typeof candidate.name === "function"
    );
  }

  protected printBanner(args?: string[]) {
    if (this.bannerPrinted) {
      return () => {};
    }
    this.bannerPrinted = true;

    let message: string;
    try {
      const priorityModule = this.getPriorityModule(args);
      message = this.getSlogan(priorityModule);
    } catch {
      message = "Decaf: strongly brewed TypeScript.";
    }

    // Select random banner and color palette
    const bannerTemplate = banners[Math.floor(Math.random() * banners.length)];
    const paletteKeys = Object.keys(colorPalettes);
    const palette =
      colorPalettes[
        paletteKeys[Math.floor(Math.random() * paletteKeys.length)]
      ];

    const rawLines = bannerTemplate
      .split("\n")
      .filter((line) => line.trim().length > 0);
    const maxLineWidth = rawLines.reduce<number>(
      (max, line) => Math.max(max, line.length),
      0
    );
    const messageWidth = Math.max(0, message.length);
    const targetWidth = Math.max(MIN_BANNER_WIDTH, maxLineWidth, messageWidth);
    const banner = rawLines.map((line) => line.padEnd(targetWidth));
    banner.push(message.padStart(targetWidth));

    const reset = "\x1b[0m";
    const paletteLength = Math.max(1, palette.length);
    for (let index = 0; index < banner.length; index++) {
      const color = palette[index % paletteLength] || "";
      process.stdout.write(`${color}${banner[index]}${reset}\n`);
    }

    return () => {};
  }

  private getPriorityModule(args?: string[]): string | undefined {
    if (!args || args.length <= 2) return undefined;
    const candidates = args.slice(2);
    for (const item of candidates) {
      if (!item || item.startsWith("-")) continue;
      const trimmed = item.trim();
      if (!trimmed) continue;
      if (this.modules[trimmed]) {
        return trimmed;
      }
      return trimmed;
    }
    return undefined;
  }

  /**
   * @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();
    let stopAnimation: (() => void) | undefined;
    if (DecafCLieEnvironment.banner) {
      this.bannerPrinted = false;
      stopAnimation = this.printBanner(args);
    }
    try {
      return await this.command.parseAsync(args);
    } finally {
      if (stopAnimation) stopAnimation();
    }
  }

  static accumulateEnvironment(obj: object) {
    this.env = this.env.accumulate(obj) as any;
  }

  static getEnv() {
    return this.env;
  }
}