用 TypeScript 和 oclif 从头开始构建 CLI

原文信息: 查看原文查看原文

Building a CLI from scratch with TypeScript and oclif

- Josh Cunningham

我目前正在开发一对 CLI 工具,其中一个我已经在这里写过了,另一个我很快就会宣布。我喜欢一个好的基于文本的界面,所以我为自己建立的很多工具,以及在工作中构建的工具,都采用了这种形式。我在这个领域肯定还不是专家(至少现在还不是),但我喜欢为选项找到明智的默认值、清晰的标志名称和有用的错误消息。尽管如此,我在派对上还是很有趣的。

去年,为了在构建预算 CLI 时减少一些痛苦和折磨,我调查了现有的 JavaScript CLI 框架,并决定尝试一下 oclif。看起来它具有正确的功能,并且大多数时间不会干扰你,但是,在尝试了几个小时之后,我发现它几乎无法做任何事情,所以我放弃了它,改用了 Node 自带的解析参数的实用工具util.parseArgs()。你可以在这里看到我是如何使用它的。

一年多过去了,我又面临着同样的问题,有了一个新项目。目前,我只是编译文件,并直接用 node ./dist/command.js 执行它们。这个新的 CLI 将拥有更多的命令和选项,因此标志和参数解析只是工作的一部分。我脑海中再次浮现了 oclif 包,所以我看了一下,并看到自从我上次尝试过以来已经发布了 2 个重要版本,其中包含了我可能会使用的许多功能:插件、钩子和发布。看起来我应该能够将它集成到项目中,而无需完全重新设计所有内容的工作方式,这是一个很大的优点。如果可以的话,我不喜欢被困在一个框架中。

我立即开始尝试,并成功运行了起来,但并不确定到底发生了什么。入门教程大约只有半页纸,包括介绍,对于理解我正在做的事情并没有多大帮助。你生成了一个完整的 CLI 项目,然后在那之后就没有太多可以依靠的了,除了阅读代码。指南API 参考 文档是很不错的,但只有当你确切地知道你在寻找什么时才有效。我花了大约一个小时阅读文档,最终对正在发生的事情有了更清楚的理解,但我不得不自己拼凑起来。

我认为一个入门教程应该从零开始,一步步地介绍基础知识,逐渐加深理解。为了在我的预算 CLI 中使其工作,我浏览了需要添加的基础部分,贡献了一个命令,该命令添加了这些部分,而不需要所有其他的模板样板,然后在此过程中编写了下面的教程。希望这些内容能帮到你,如果你决定编写一个 CLI!


本教程假设你:

  • 在你的系统上已经安装了 Node 和 npm
  • 将使用 TypeScript(TS)来构建 CLI,或者打算使用

oclif CLI 有两种选项来创建你所需的文件:

  • generate 命令,从头开始在一个新目录中创建一个新的 npm 项目
  • init 命令,将基本配置添加到现有项目中

generate 命令是最快的方法,可以获得一个完全可用的 CLI,但它会留下许多你可能不需要的样板,并且在接下来该做什么方面存在许多未解答的问题。

我们将从一个空目录开始,逐步通过 init 命令构建一个功能性的 CLI,依靠文档链接来扩展这里的内容,到最后,你应该对自己的 CLI 项目有一个清晰的前进路径。

首先,我们需要一个新的目录和一个 package.json 文件,我们将通过初始化 npm 并安装 TS 来获得:

$ mkdir new-oclif-cli
$ cd new-oclif-cli
$ npm init
# ... 回答所有提示,本教程中默认值是可以接受的

$ npm install typescript

我们将做绝对最少量的设置,以确保 TS 可以编译,因为这不是本教程的重点。如果你刚开始使用 TS,5 分钟了解 TypeScript 工具 是一个很好的起点。现在,我们只想确保 TS 正在将我们的文件编译到正确的位置。

从我们上次结束的地方开始,创建一个 TS 文件,并输出到控制台:

$ mkdir src
$ echo 'console.log("Hi!");' >> src/index.ts

在你的项目根目录中添加一个基本的 tsconfig.json 文件,包含源文件和输出目录的配置:

// tsconfig.json
{
  "include": [ "src/**/*" ],
  "compilerOptions": {
    "outDir": "./dist",
    "module": "nodenext"
  }
}

调用我们在项目中安装的 TS 包来编译这个新文件到项目的 dist 目录,并确保它可以被执行:

$ npm exec tsc
$ node ./dist/index.js
Hi!

如果你在这一步遇到了困难,请参考 TS 文档。如果没有问题,那么恭喜你,你已经用 TS 构建了一个 CLI!

我们将使用 npx 来调用 oclif CLI,它将添加必要的 npm 模块、bin 文件和配置:

npx oclif init

该命令会询问一些问题:

  1. 首先,会询问你要使用哪个目录来安装。接受默认值以在当前工作目录中安装。
  2. 接下来,会询问你要为项目导出的命令名称。当你发布你的项目时,这变得很重要,但是对于本练习而言,你可以接受默认值。
  3. 下一步将自动执行,因为我们已经使用 npm 安装了一个包。init 命令根据锁文件的名称自动检测你使用的包管理器。命令看到了 package-lock.json 文件,并使用 npm 在后台安装了 @oclif/core 包。

如果一切顺利完成,你应该会看到一个消息,例如 "Created CLI new-oclif-cli",控制台中没有错误。你还应该会得到:

  • 一个新的 ./bin 文件夹中的四个新文件
  • 在你的 package.json 中的一个 oclif 对象,配置了 bin 名称、数据目录和 命令发现策略。还有其他 配置选项,其中一些我们稍后会在本教程中涵盖

在我们继续之前,我们需要用我们在 oclif init 命令中选择的模块类型更新我们的 package.json 文件。添加一个顶级属性 type,值为 module 表示 ESM,或者值为 commonjs 表示 CommonJS。

// package.json
{
  // ... 其他属性
  "type": "module"
  // ... 或者
  "type": "commonjs"
}

现在我们准备创建我们的第一个命令!oclif CLI 包括一个方便的 oclif generate command COMMAND_NAME,我们可以使用它,但是,像 oclif generate 一样,它包含了很多样板,所以我们将从头开始构建我们的命令。

./src 中创建一个名为 commands 的目录,并添加一个名为 hello.ts 的文件:

$ mkdir ./src/commands
$ touch ./src/commands/hello.ts

hello.ts 文件中添加以下内容:

// src/commands/hello.ts
import { Command } from "@oclif/core";

export default class Hello extends Command {
  public async run(): Promise<void> {
    this.log("Hello from oclif!");
  }
}

这是所有命令都将采取的基本形式:扩展 Command 类并定义一个 run() 方法。父类中有 许多方法 可用,包括我们这里使用的 log() 方法,用于将消息输出到 stdout

我们尚未将我们的 CLI 打包成可执行二进制文件,但我们可以通过使用初始化过程中添加的文件之一来轻松测试该命令:

$ npm exec tsc
$ ./bin/run.js hello
Hello from oclif!

注意:从现在开始,我们将假设在 TS 文件更改后运行 tsc,或者在另一个标签页中运行 tsc -w 以在更改时自动编译。

oclif 的卖点之一是它能够解析和验证命令运行时传递的 参数标志

我们可以通过在我们创建的类上定义一个静态的 args 属性来为我们的命令添加参数,设置为一个对象。这个对象中的键定义了我们在运行时将使用的属性名称,值指示我们期望的参数类型。

让我们向我们的命令添加一个参数,并简单地将值输出到终端:

// src/commands/hello.ts
import { Args, Command } from "@oclif/core";

export default class Hello extends Command {
  static override args = {
    arg1: Args.string(),
  };

  public async run(): Promise<void> {
    const { args } = await this.parse(Hello);
    this.log("Hello from oclif!");
    this.log("arg1: %s", args.arg1);
  }
}

在这种情况下,我们在第一个位置创建了一个 string 参数,在命令中解析了所有参数,然后使用 this.log 的格式化功能输出值。当我们运行带有参数的命令时,我们可以立即看到值:

$ ./bin/run.js hello an_argument
Hello from oclif!
arg1: an_argument

如果我们在没有修改命令代码的情况下添加第二个参数,则会看到错误:

./bin/run.js hello an_argument another_argument
 ›   Error: Unexpected argument: another_argument
 ›   See more help with --help

USAGE
  $ new-oclif-cli hello [ARG1]

parse() 方法有两个

作用:它既验证传入的参数,又使它们在 run() 方法中的逻辑中可用。如果你的命令使用了参数或标志,则应该在 run() 函数的第一行调用它,以避免部分执行。

使用 命令参数 还有很多其他的可能性,包括文档、预处理、默认值等。花一些时间尝试不同的参数类型和选项,以了解可以做些什么。

现在,让我们为我们的命令添加一个标志。oclif 中的标志解析和验证非常强大和灵活,所以在本教程中我们只是浅尝辄止。

让我们调整我们的命令以添加一个简单的标志。下面的代码排除了上面的参数代码以简化,但这两者可以共存:

import { Command, Flags } from "@oclif/core";

export default class Hello extends Command {
  static override flags = {
    flag: Flags.boolean(),
  };

  public async run(): Promise<void> {
    const { flags } = await this.parse(Hello);
    this.log("Hello from oclif!");
    this.log("flag: %s", flags.flag ? "yes" : "no");
  }
}

你会注意到这里的语法与参数的语法非常相似。我们有一个静态属性 flags,设置为一个对象,其中的键定义了标志名称,值指示标志类型。

如果我们使用标志运行我们的命令,输出应该是:

$ ./bin/run.js hello --flag
Hello from oclif!
flag: yes

与参数类似,如果我们运行一个我们没有定义的标志的命令,结果会是一个错误和使用文档:

./bin/run.js hello --notflag
 ›   Error: Nonexistent flag: --notflag
 ›   See more help with --help

USAGE
  $ new-oclif-cli hello [--flag]

FLAGS
  --flag

关于 命令标志 还有很多事情可以做,包括字符别名、对其他标志的依赖性、可逆性等。

现在我们更了解了如何构建命令,oclif 可以生成的命令应该更容易理解了。运行以下命令使用模板创建一个新命令:

$ npm exec oclif generate command hello2
Adding hello2 to new-oclif-cli!
Creating src/commands/hello2.ts

这将创建一个新文件 ./src/commands/hello2.ts,其中包含了参数和标志。运行这个新命令的帮助标志将显示如何使用它:

./bin/run.js hello2 --help
describe the command here

USAGE
  $ new-oclif-cli hello2 [FILE] [-f] [-n <value>]

ARGUMENTS
  FILE  file to read

FLAGS
  -f, --force
  -n, --name=<value>  name to print

DESCRIPTION
  describe the command here

EXAMPLES
  $ new-oclif-cli hello2

尝试使用 --help 标志运行基本命令以查看输出。

最后,我们希望用户知道如何使用 CLI,因此我们将使用 oclif 创建一个 README 文件。首先,在项目目录中创建一个 README.md 文件,或者打开现有的文件。在文件中的任何位置添加以下模板:

## Table of contents
<!-- toc -->

## Usage
<!-- usage -->

## Commands
<!-- commands -->

注意,顺序、标题和使用的标签取决于你。如果你只想输出命令,只需使用 <!-- commands --> 标签。当你将所有内容放在合适的位置后,运行 oclif readme 命令:

$ npm exec oclif readme
replacing <!-- usage --> in README.md
replacing <!-- commands --> in README.md
replacing <!-- toc --> in README.md

现在,你已经使用 oclif 构建了一个功能性的 CLI,并进行了文档化!

推荐的下一步是:

  • 如果你正在构建一个具有多个命令的大型 CLI,请考虑添加一个 自定义基类 来管理重复的参数、继承标志 和共享功能。
  • 如果你的 CLI 需要用户定义的功能,请查看 插件
  • 如果你需要帮助解决问题,请查看 oclif 的 调试功能错误处理
  • 当你准备好将你的命令推向世界时,oclif 有多种方式可以帮助你 发布
分享于 2024-04-20

访问量 52

预览图片