在软件开发中,我们有时候经常编写一些CLI工具,来提高我们的工作效率如自动化发布、代码转换等,同时CLI也可以做为底层供前端调用,比如老俊之前写过一个electron应用,就是调用cli工具来实现视频转换。但是编写 CLI 工具并非一件容易的事情,需要考虑命令行参数的输入处理、帮助信息的展示等问题。此时,一款npm 包 Clipanion 可以为我们解决此类问题。
Clipanion简介
Clipanion 是一款用于编写 CLI 工具的 JavaScript 框架,它提供了方便的命令行参数解析、帮助信息展示等功能,可以让我们更加专注于业务开发。Clipanion 背后的想法是提供一个不会让您讨厌 CLI 的 CLI 框架。特别是,这意味着 Clipanion 希望:
-
正确,无论您的选项定义如何,都具有一致且可预测的行为。
-
功能齐全,无需编写自定义代码来支持特定的 CLI 模式。
-
使用Clipanion并发布
-
1、安装Clipanion
yarn add clipanion
-
2、编写命令行业务代码
import {Command, Option} from 'clipanion';
export default class HelloCommand extends Command {
name = Option.String();
async execute() {
this.context.stdout.write(`Hello ${this.name}!\n`);
}
}
- 3、新建CLI实例,导入命令行类,执行runExit方法 | |
```typescript | |
import { Cli } from 'clipanion'; | |
import HelloCommand from './HelloCommand.mjs'; | |
const [node, app, ...args] = process.argv; | |
const cli = new Cli({ | |
binaryLabel: `My Application`, | |
binaryName: `${node} ${app}`, | |
binaryVersion: `1.0.0`, | |
}) | |
cli.register(HelloCommand); | |
cli.runExit(args); |
- 4、配置命令行入口
首先我们需要在package.json中配置bin属性,声明命令行入口文件
"bin": { | |
"jun-run": "run.js" | |
}, |
由于我们使用 Node.js 实现,因此命令行对应的入口 js 文件(此处即 run.js)需要声明当前文件使用 node 执行
// 此处编写 yourCommand 命令的逻辑 |
- 5、发布到npm,当别人安装你的 npm 包时,就能在终端中执行jun-run命令,测试结果如下:
Clipanion使用教程
命令行路径
Clipanion 支持为每个命令提供一个或多个路径。路径是一个固定字符串的列表,必须找到这些字符串才能将命令选为执行候选。使用每个命令类的静态 paths 属性声明路径,因为一个Command名下面可以有好多种操作,如果jun-run install用于安装,jun-run uninstall用于卸载,所以需要Command Paths来支持分开处理。
import {Command, Option} from 'clipanion'; | |
export default class HelloCommand extends Command { | |
static paths = [[`test`], [`t`],Command.Default]; | |
name = Option.String(); | |
async execute() { | |
this.context.stdout.write(`Hello ${this.name}!\n`); | |
} | |
} |
如上面那样进行修改后,我们就可以执行jun-run test 你好老俊,这里是测试,效果一样
参数选项的支持
Clipanion 支持许多不同类型的选项。在大多数情况下,短样式和长样式选项都受支持,尽管它们各自具有自己的特性,这些特性会稍微影响它们的使用时间。
数组
只是支持多次设置的字符串选项:如
--email foo@baz --email bar@baz => Command {"email": ["foo@baz", "bar@baz"]}
同时也支持元组选项:如
--point x1 y1 --point x2 y2 => Command {"point": [["x1", "y1"], ["x2", "y2"]]}
具体的定义方式如下:
import {Command, Option} from 'clipanion'; | |
export default class HelloCommand extends Command { | |
static paths = [[`test`], [`t`],Command.Default]; | |
arr = Option.Array('--email,-e') //数组 | |
arr2 = Option.Array('--point,-p',{arity:3}) //元组 | |
// name = Option.String(); | |
async execute() { | |
this.context.stdout.write(`Hello ${this.arr}!\n`); | |
this.context.stdout.write(`Hello ${this.arr2}!\n`); | |
} | |
} |
字符串
毋庸置疑,字符串肯定是最常见的参数类型。
格式:
--path /path/to/foo | |
=> Command {"path": "/path/to/foo"} | |
--path=/path/to/foo | |
=> Command {"path": "/path/to/foo"} |
定义:
name = Option.String('--name,-n') | |
async execute() { | |
this.context.stdout.write(`Hello ${this.name}!\n`); | |
} |
布尔型
布尔型非常简单,指定该命令接受布尔标志作为选项。如果未提供默认值,该选项将以 undefined 开头。
格式:--flag
定义
isTest = Option.Boolean('--flag')
其他类型
还有很多其他类型,如Counter,Proxy,Rest等,这里就不多介绍了,具体看文档
参数验证
对于参数验证,Clipanion 提供了与 Typanion 的自动(且可选)集成,Typanion 是一个提供静态和运行时输入验证和强制的库。使用方式如下:
import * as t from 'typanion'; | |
class PowerCommand extends Command { | |
a = Option.String({validator: t.isNumber()}); | |
b = Option.String({validator: t.isNumber()}); | |
async execute() { | |
this.context.stdout.write(`${this.a ** this.b}\n`); | |
} | |
} |
同时,Option也支持设置required将选项必填,如:
url = Option.String('-u,--url', { | |
description: 'book url, e.g(https://www.abc.com', | |
required: true, | |
}) |
自定义错误处理
Clipanion支持异常捕获,在某些情况下,您可能想要控制 Clipanion 在命令抛出时执行的操作。在这种情况下,只需在声明命令时重写 catch 方法即可:
import {Command} from 'clipanion'; | |
export class HelloCommand extends Command { | |
async execute() { | |
throw new Error(`Hello world`); | |
} | |
async catch(error: unknown) { | |
// You can do whatever you want here, like rethrow the original error | |
throw error; | |
} | |
} |
帮助
几乎每个CLI工具都会自带帮助选项,通过--help,-h展示,Clipanion自然也是支持的,Clipanion 包含可轻松记录和添加帮助功能的工具,只需定义一个 usage 静态属性,如:
static usage = Command.Usage({ | |
category: `My category`, | |
description: `A small description of the command.`, | |
details: ` | |
A longer description of the command with some \`markdown code\`. | |
Multiple paragraphs are allowed. Clipanion will take care of both reindenting the content and wrapping the paragraphs as needed. | |
`, | |
examples: [[ | |
`A basic example`, | |
`$0 my-command`, | |
], [ | |
`A second example`, | |
`$0 my-command --with-parameter`, | |
]], | |
}); |
最后,我们编写一个遍历文件夹的功能
我们类编写一个小程序,做出一个类似Ubuntu的tree命令的功能
Clipanion 支持注册多个Command,我们新建一个Command,代码如下:
import {Command, Option} from 'clipanion'; | |
import tree from './tree.mjs' | |
import path from 'path' | |
export default class TreeCommand extends Command { | |
static paths = [[`tree`], [`t`],Command.Default]; | |
static usage = Command.Usage({ | |
description: `遍历文件夹下的文件,以树的形式展示`, | |
examples: [[ | |
`使用示例:`, | |
`jun-run tree --path=文件夹路径`, | |
]], | |
}); | |
path = Option.String('--path,-p',{ | |
tolerateBoolean:true | |
}) | |
async execute() { | |
if(!this.path){ | |
this.path = path.resolve('.') | |
} | |
const string = tree(this.path, { | |
allFiles: true, | |
exclude: [/lcov/],//正则写法 | |
maxDepth: 4, | |
}); | |
console.log(string); | |
} | |
async catch(error) { | |
// You can do whatever you want here, like rethrow the original error | |
this.context.stdout.write(`发生错误\n`); | |
throw error; | |
} | |
} |
遍历的算法在tree方法,代码如下:
; | |
import fs from 'fs'; | |
import nodePath from 'path'; | |
//定义选项 | |
const DEFAULT_OPTIONS = { | |
allFiles: false, | |
dirsFirst: false, | |
dirsOnly: false, | |
sizes: false, | |
exclude: [], | |
maxDepth: Number.POSITIVE_INFINITY, | |
reverse: false, | |
trailingSlash: false, | |
ascii: false, | |
}; | |
//分隔 | |
const SYMBOLS_ANSI = { | |
BRANCH: '├── ', | |
EMPTY: '', | |
INDENT: ' ', | |
LAST_BRANCH: '└── ', | |
VERTICAL: '│ ', | |
}; | |
const SYMBOLS_ASCII = { | |
BRANCH: '|-- ', | |
EMPTY: '', | |
INDENT: ' ', | |
LAST_BRANCH: '`-- ', | |
VERTICAL: '| ', | |
}; | |
const EXCLUDED_PATTERNS = [/\.DS_Store/]; | |
function isHiddenFile(filename) { | |
return filename[0] === '.'; | |
} | |
function print( | |
filename, | |
path, | |
currentDepth, | |
precedingSymbols, | |
options, | |
isLast, | |
) { | |
const isDir = fs.lstatSync(path).isDirectory(); | |
// We treat all non-directory paths as files and don't | |
// recurse into them, including symlinks, sockets, etc. | |
const isFile = !isDir; | |
const lines = []; | |
const SYMBOLS = options.ascii ? SYMBOLS_ASCII : SYMBOLS_ANSI; | |
// Do not show these regardless. | |
for (let i = 0; i < EXCLUDED_PATTERNS.length; i++) { | |
if (EXCLUDED_PATTERNS[i].test(path)) { | |
return lines; | |
} | |
} | |
// Handle directories only. | |
if (isFile && options.dirsOnly) { | |
return lines; | |
} | |
// Handle excluded patterns. | |
for (let i = 0; i < options.exclude.length; i++) { | |
if (options.exclude[i].test(path)) { | |
return lines; | |
} | |
} | |
// Handle max depth. | |
if (currentDepth > options.maxDepth) { | |
return lines; | |
} | |
// Handle current file. | |
const line = [precedingSymbols]; | |
if (currentDepth >= 1) { | |
line.push(isLast ? SYMBOLS.LAST_BRANCH : SYMBOLS.BRANCH); | |
} | |
// if (options.sizes) { | |
// const filesize = isDir ? folderSize(path) : fs.statSync(path).size; | |
// const prettifiedFilesize = prettyBytes(filesize); | |
// line.push(prettifiedFilesize.replace(' ', '')); | |
// line.push(' '); | |
// } | |
line.push(filename); | |
if (isDir && options.trailingSlash) { | |
line.push('/'); | |
} | |
lines.push(line.join('')); | |
if (isFile) { | |
return lines; | |
} | |
// Contents of a directory. | |
let contents = fs.readdirSync(path); | |
contents.sort(); | |
if (options.reverse) { | |
contents.reverse(); | |
} | |
// Handle showing of all files. | |
if (!options.allFiles) { | |
contents = contents.filter((content) => !isHiddenFile(content)); | |
} | |
if (options.dirsOnly) { | |
// We have to filter here instead of at the start of the function | |
// because we need to know how many non-directories there are before | |
// we even start recursing. | |
contents = contents.filter((file) => | |
fs.lstatSync(nodePath.join(path, file)).isDirectory(), | |
); | |
} | |
// Sort directories first. | |
if (options.dirsFirst) { | |
const dirs = contents.filter((content) => | |
fs.lstatSync(nodePath.join(path, content)).isDirectory(), | |
); | |
const files = contents.filter( | |
(content) => !fs.lstatSync(nodePath.join(path, content)).isDirectory(), | |
); | |
contents = [].concat(dirs, files); | |
} | |
contents.forEach((content, index) => { | |
const isCurrentLast = index === contents.length - 1; | |
const linesForFile = print( | |
content, | |
nodePath.join(path, content), | |
currentDepth + 1, | |
precedingSymbols + | |
(currentDepth >= 1 | |
? isLast | |
? SYMBOLS.INDENT | |
: SYMBOLS.VERTICAL | |
: SYMBOLS.EMPTY), | |
options, | |
isCurrentLast, | |
); | |
lines.push.apply(lines, linesForFile); | |
}); | |
return lines; | |
} | |
function tree(path, options) { | |
const combinedOptions = Object.assign({}, DEFAULT_OPTIONS, options); | |
return print( | |
nodePath.basename(nodePath.join(process.cwd(), path)), | |
path, | |
0, | |
'', | |
combinedOptions, | |
).join('\n'); | |
} | |
export default tree |
最后,运行 jun-run tree,献上演示效果