Skip to content

写个 Node.js 命令行工具框架

更新于: at 08:34

前言

最近正在做一个前端脚手架,这其中就要包含很多 cli 工具来方便开发,之前一直都是在写网站或者客户端小程序之类的,这次是我第一次写命令行上的交互。为了学习一下,所以我没有用社区现在流行常用的 commander.js,而是自己实现了一个简单的命令行工具框架。这个框架可以注册和执行命令,支持命令的嵌套,支持命令的参数解析,支持命令的帮助信息输出。同时还包含了一些常用的命令行开发工具包如字体着色、log 输出、spinner 等。下面来讲讲设计的过程和遇到的一些坑点。

命令行框架设计

cli structure

如上面这张图所示,命令行工具主要分成 Command Manager 还有 Create 两个部分。

模板生成器主要负责项目代码层面的快速初始化,标准页面/组件的一键生成等功能。整个流程的步骤可以保存在一个中央仓库中,从中提取最新的 JSON 数据,然后选择并填充模板,插入变量,复制文件并执行生成前和生成后的回调。

本文主要聚焦在 Command Manager 的设计与实现。另外一个部分是参考了 shadcn 的灵感设计来的,属于脚手架的能力,这里不做过多展开。

下面还有几篇文章会讲讲其它的一些附加能力,比如说 logger、spinner 还有框架内置的一些命令。

命令行框架实现

首先我们要定义一下一个命令的组成部分:

命令对象

根据我们上面的定义,就可以简单写出一个 Command 类:

/**
 * 命令对象
 * @template T 命令行选项
 *
 * @example
 * ```ts
 * const command = new Command("test", "test command", {
 *    name: {
 *      description: "command name"
 *      type: "string"
 *    },
 *    age: {
 *      description: "command age"
 *      type: "number"
 *    }
 *  }, async (args) => {
 *      console.log(args.name, args.age);
 * });
 */
export class Command<T extends CommandOptions> {
  /**
   * 命令名称
   */
  #name: string;
  /**
   * 命令描述
   */
  #description: string;
  /**
   * 命令行选项
   */
  #options: T;
  /**
   * 命令执行回调
   */
  #action: (args: InferredArgs<T>) => Promise<void>;
  /**
   * 子命令
   */
  #subCommands?: Command<T>[];

  /**
   * 命令初始化
   * @param name 命令关键词
   * @param description 命令描述
   * @param options 命令参数选项
   * @param action 命令执行函数
   */
  constructor(
    name: string,
    description: string,
    options: T,
    action: (args: InferredArgs<T>) => Promise<void>
  ) {
    this.#name = name;
    this.#description = description;
    this.#options = options;
    this.#action = action;
  }

  get name() {
    return this.#name;
  }
  get description() {
    return this.#description;
  }
  get options() {
    return this.#options;
  }
  get action() {
    return this.#action;
  }
}

值得注意的小设计能够让我们在写 action 回调的时候知道参数是什么以及是什么类型,所以我在这里用了一个泛型,这个泛型扩展了下面的 CommandOptions 类型,它是一个对象,对象的 key 是参数的名称,value 是参数的描述,定义如下:

export type CommandOptions = {
  [key: string]: {
    // 参数是什么类型的
    type: "boolean" | "string" | "number" | "array";
    // 参数的描述
    description: string;
    // 参数的别名
    alias?: string;
    // 这个参数的值有多少个,比如说 --name web native 这个 name 参数就有两个值
    argCount?: number;
  };
};

然后通过一个 InferredArgs 类型来推断出参数的类型:

export type InferredArgs<T extends CommandOptions> = {
  [K in keyof T]: T[K]["type"] extends "boolean"
    ? boolean
    : T[K]["type"] extends "string"
      ? string
      : T[K]["type"] extends "number"
        ? number
        : T[K]["type"] extends "array"
          ? string[]
          : never;
} & { commandOptionArg: string };

这样我们在定义一个 Command 的 Action 的时候就知道参数是什么类型了,开发体验会更好:

command example

命令管理器

在上面我们定义好了一个命令对象,接下来我们要定义一个命令管理器,这个管理器就是一个管家,负责注册命令,执行命令,输出命令的帮助信息。

管理器对象

下面是一个管理器对象,我们在里面存放所有命令和提取命令:

class CommandManager {
  #commands: Map<string, Command<CommandOptions>> = new Map();
  #commandPath: Map<string, string> = new Map();

  public registerCommand<T extends CommandOptions>(
    command: Command<T>,
    pluginPath: string
  ) {
    this.#commands.set(command.name, command as Command<CommandOptionsExtends>);
    this.#commandPath.set(command.name, pluginPath);
  }

  get getCommands() {
    return this.#commands;
  }

  public getCommandPath(name: string) {
    return this.#commandPath.get(name);
  }

  public getCommand(name: string) {
    return this.#commands.get(name);
  }
}

在程序启动的时候,会实例化一个这样的管理器对象,然后 export 出去给其它模块使用,充当一个单例。

命令注册器

命令注册本身的逻辑其实非常简单,就是调用管理器对象的注册方法,然后注册进去,在这里我们可以再封装一个方法来完成这个操作

手动注册

/**
 * 注册命令
 * @param {Command<T>} program 命令行程序对象
 */
export const registCommand = <T extends CommandOptions>(
  program: Command<T>,
  pluginPath: string
) => {
  if (program.name && program.description && program.options) {
    commander.registerCommand(program, pluginPath);
  }
};

自动注册

但是为了让用户用起来更加方便,我希望可以自动注册命令,用户只需要通过 npm 的方式安装插件,然后就能自动将插件注册好,具体是下面的流程:

读取 package.json => 拿到插件名称 => 得到插件路径 => 动态加载插件 => 注册插件

读取 package.json
const readPackageJson = async () => {
  const packageJsonPath = path.resolve(process.cwd(), "package.json");
  if (fs.existsSync(packageJsonPath)) {
    const packageJsonContent = await fs.promises.readFile(
      packageJsonPath,
      "utf-8"
    );
    try {
      return JSON.parse(packageJsonContent);
    } catch (error) {
      logger.error("Failed to parse package.json:\n", error as string);
      return null;
    }
  }
  return null;
};

直接读取本路径下面的 package.json 文件,然后返回一个对象。

得到插件名称

首先我们要知道这边哪些是我们的插件,我们可以约定一个名称,比如在这里的名称就是 @dew/plugin- 开头的,然后我们就可以通过这个名称来找到插件的路径。

const getDewPlugins = (dependencies: Record<string, string>) => {
  return Object.keys(dependencies).filter(dep =>
    dep.startsWith("@dew/plugin-")
  );
};
动态加载插件

通过 import 的方式来动态加载插件,在这里有一个坑点,因为用户使用的时候是通过类似 npm -g 的形式来安装工具的,所以在 node resolve 的时候也会在全局的 node_modules 里面找,所以我们要通过 require.resolve 来找到插件的真正路径。

const importPlugin = async (pluginName: string) => {
  const modulePath = require.resolve(pluginName, { paths: [process.cwd()] });
  const loadedModule = await import(modulePath).catch(error => {
    logger.error(
      `Failed to import plugin ${pluginName}:`,
      "If target package has not been built yet ?\n",
      error
    );
    return null;
  });
  return {
    loadedModule,
    modulePath,
  };
};

上面的 path 参数就指定了我们要在当前路径下面找,这样就能找到我们的插件了。同时也可以记录一下插件的路径,方便后面调试的时候知道插件的载入来源。

批量注册插件

在别的模块中都通过 default 的方式导出一个或者多个插件,这样我们就可以根据它有多少个来自动注册插件了。

export const registerPlugins = async (): Promise<void> => {
  // 注册内置命令
  for (const [_, command] of Object.entries(BuiltIns)) {
    registCommand(command as Command<CommandOptionsExtends>, "built-in");
  }

  // 注册外部插件命令
  const packageJson = await readPackageJson();
  if (packageJson?.dependencies || packageJson?.devDependencies) {
    const dewPlugins = getDewPlugins({
      ...packageJson.dependencies,
      ...packageJson.devDependencies,
    });
    await Promise.allSettled(
      dewPlugins.map(async pluginName => {
        const moduleInfo = await importPlugin(pluginName).catch(error => {
          logger.error(`Failed to import plugin ${pluginName}:`, error);
        });
        if (!moduleInfo) {
          logger.error(`Failed to resolve plugin ${pluginName}`);
          return;
        }
        const program = moduleInfo.loadedModule.default;
        if (program.name && program.description && program.options) {
          // check if plugin has default export
          registCommand(moduleInfo.loadedModule.default, moduleInfo.modulePath);
        } else {
          for (const command of Object.values(
            moduleInfo.loadedModule.default
          ) as Command<CommandOptions>[]) {
            registCommand(command, moduleInfo.modulePath);
          }
        }
      })
    );
  }
};

通过上面这样的设计,当用户通过 npm 安装插件之后就能自动载入插件,使用起来就更加方便了。

命令执行器

命令执行器根据用户输入的命令,解析命令和命令中包含的参数,通过命令管理器找到命令对象,通过刚刚解析出来的参数执行里面的回调函数。

/**
 * 执行指定的命令。
 * @param {string} name 命令名称。
 * @param {string[]} args 命令行参数数组。
 */
export const exec = (name: string, args: string[]) => {
  const command = commander.getCommand(name);
  if (!command) {
    console.log("Command not found");
    return;
  }

  const commandArgs = argTransformer(args, command.options);
  command.action(
    commandArgs as unknown as InferredArgs<typeof command.options>
  );
};

在这里我们通过 argTransformer 函数来解析命令行参数,这个函数会将命令行参数数组转换为键值对对象,这样我们就能知道用户输入的参数是什么了。由于上面提到的参数条件非常多,所以下面的函数也有很多的 if 条件分支来判断。但是总体也很好理解,可以看看下面的代码。

/**
 * 将命令行参数数组转换为键值对对象的函数。
 * @param {string[]} args 命令行参数数组,例如:["--name=123", "--age=18"] 或 ["-h"] 或 ["--help"] 或 ["-s", "proxy", "http://127.0.0.1:8888", "--name=123"]。
 * @returns 返回一个对象,其中包含了从命令行参数解析出的键值对。
 */
const argTransformer = (args: string[], options: CommandOptions) => {
  const result: CommandArgs = {
    commandOptionArg: "",
  };
  for (let i = 0; i < args.length; i++) {
    const arg = args[i];
    if (arg.startsWith("--")) {
      // 如果参数以 "--" 开头,表示是一个完整的键值对
      const [key, value] = arg.split("=");
      const keyName = key.slice(2);
      if (options[keyName]) {
        if (options[keyName].argCount && options[keyName].argCount > 1) {
          // indicate that next [argCount] args are values
          // e.g. --name=123 456 789 -> {name: [123, 456, 789]}
          const values = [];
          for (let j = 0; j < options[keyName].argCount; j++) {
            values.push(args[++i]);
          }
          result[keyName] = values;
        } else if (value) {
          // 如果参数有值,直接使用该值
          result[keyName] = value;
        } else {
          // 如果参数没有值,使用 true
          result[keyName] = true;
        }
      } else {
        logger.error(`Unknown option: ${keyName}`);
        process.exit(1);
      }
    } else if (arg.startsWith("-")) {
      // 如果参数以 "-" 开头,表示是一个标志或键值对的键
      const key = arg.slice(1);

      // 检查是否设置了别名,构建一个反向映射
      const aliasMap = Object.entries(options).reduce(
        (acc, [key, value]) => {
          if (value.alias) {
            acc[value.alias] = key;
          }
          return acc;
        },
        {} as Record<string, string>
      );

      if (aliasMap[key]) {
        const argCount = options[aliasMap[key]].argCount;
        if (argCount && argCount > 1) {
          // indicate that next [argCount] args are values
          // e.g. -n 123 456 789 -> {n: [123, 456, 789]}
          const values = [];
          for (let j = 0; j < argCount; j++) {
            values.push(args[++i]);
          }
          result[aliasMap[key]] = values;
        } else {
          if (args[i + 1] && !args[i + 1].startsWith("-")) {
            // 如果下一个参数存在且不以 "-" 开头,表示当前键有对应的值
            const value = args[i + 1];
            result[aliasMap[key]] = value;
            i++;
          } else {
            // 如果下一个参数不存在或以 "-" 开头,表示当前键没有对应的值
            result[aliasMap[key]] = true;
          }
        }
      } else {
        logger.error(`Unknown option: ${key}`);
        process.exit(1);
      }
    } else {
      if (i === 0) {
        // 如果第一个参数不是以 "-" 开头,表示是命令名称
        result.commandOptionArg = arg;
      }
    }
  }
  return result;
};

命令行工具入口

在上面定义好了管理器、注册器、执行器之后,可以建一个 cli 的入口文件,然后在这里面注册命令,然后执行命令。

const args = process.argv.slice(2);

if (!args.length) {
  logger.info("使用 help 或 -h 查看帮助");
  process.exit(0);
}

(async () => {
  registCommand(
    new Command("help", "show help info", {}, async () => {
      exec("list", args);
    }),
    "built-in"
  );
  await registerPlugins();
  exec(args[0], args.slice(1));
})();

其中上面的 process.argv 是一个数组,它包含了用户在命令行中输入的所有参数,我们通过 slice(2) 来去掉前面两个参数,这样就能得到用户输入的命令和参数了。

命令帮助生成

命令肯定是需要帮助的,所以我们要在命令行中输入 --help 或者 -h 的时候能够输出命令的帮助信息,这个帮助信息包含了命令的名称、描述、参数的描述等等。这个能力当然可以通过开发者自己在定义 Command 的时候写好,但是我们也可以通过代码来自动生成这个帮助信息,这样开发起来就更加方便了。

export function generateHelpText<T extends CommandOptions>(
  command: Command<T>
): string {
  let helpText = `Command: ${command.name}\n`;
  helpText += `Description: ${command.description}\n\n`;
  helpText += "Options:\n";
  for (const [key, value] of Object.entries(command.options)) {
    helpText += `  ${value.alias ? `-${value.alias}, ` : ""}--${key}\t${value.description}\n`;
  }
  helpText += "  -h, --help\tShow help information\n";
  return helpText;
}

上面的函数根据命令对象,获取命令的基本信息,然后根据命令的参数选项来生成带有格式的帮助信息,这样就能在用户输入 --help 的时候输出这个帮助信息了。

cli help

总结

本文介绍了一下最近怎么从零开始设计和实现 Node.js 命令行工具框架。从命令对象的定义开始,一步步构建了命令管理器、命令注册器和命令执行器。如果你最近也再写类似的东西,希望这篇文章对你有帮助,之后会继续讲一下怎么利用框架开发插件、框架的扩展功能以及脚手架的设计。