使用Node.js+Clipanion 编写个CLI工具,实现一个文件遍历树

963 阅读4分钟

在软件开发中,我们有时候经常编写一些CLI工具,来提高我们的工作效率如自动化发布、代码转换等,同时CLI也可以做为底层供前端调用,比如老俊之前写过一个electron应用,就是调用cli工具来实现视频转换。但是编写 CLI 工具并非一件容易的事情,需要考虑命令行参数的输入处理、帮助信息的展示等问题。此时,一款npm 包 Clipanion 可以为我们解决此类问题。
使用Node.js+Clipanion 编写个CLI工具,实现一个文件遍历树

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方法
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命令,测试结果如下:

image.png

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 你好老俊,这里是测试,效果一样
image.png

参数选项的支持

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`,
    ]],
  });

效果如下:
Clipanion工具的使用

最后,我们编写一个遍历文件夹的功能

我们类编写一个小程序,做出一个类似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,献上演示效果
使用Node.js+Clipanion 编写CLI工具,实现一个文件遍历树

老俊说技术公众号