在软件开发中,我们有时候经常编写一些CLI工具,来提高我们的工作效率如自动化发布、代码转换等,同时CLI也可以做为底层供前端调用,比如老俊之前写过一个electron应用,就是调用cli工具来实现视频转换。但是编写 CLI 工具并非一件容易的事情,需要考虑命令行参数的输入处理、帮助信息的展示等问题。此时,一款npm 包 Clipanion 可以为我们解决此类问题。
Clipanion简介
Clipanion 是一款用于编写 CLI 工具的 JavaScript 框架,它提供了方便的命令行参数解析、帮助信息展示等功能,可以让我们更加专注于业务开发。Clipanion 背后的想法是提供一个不会让您讨厌 CLI 的 CLI 框架。特别是,这意味着 Clipanion 希望:
使用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方法
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 执行
#!/usr/bin/env 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方法,代码如下:
'use strict';
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,献上演示效果