不想写需求?摸鱼也要写 CLI 命令!(2)

528 阅读7分钟

「这是我参与2022首次更文挑战的第33天,活动详情查看:2022首次更文挑战」。

一、背景

预祝点赞的看官老爷成为尊贵的奥迪车主!

# 不想写需求?摸鱼也要写 CLI 命令!(1)给大家描述了一个背景,分析了需求:写一个命令行工具 joymax 查询某平台开发环境的短信验证码;最后给大家准备了实现这些功能的技术点:

  1. package.jsonbin 字段与去全局安装;
  2. 命令设计和 commander 组织命令并且解析命令参数;
  3. esbuild 的打包脚本;
  4. 复制到剪贴板的 pbcopy 命令;

这一篇就是详细的每个命令的实现细节了,欢迎点赞评论~

二、joymax.ts

import { Command } from 'commander';

const program = new Command();

program.version(require('../package').version)
 .usage('<command> [options]')
 .command('q', 'get challenge sms code from xxplatform') // joymax q
 .command('add', 'add some alias:phone to config.json') // joymax add
 .command('ccfg', 'modify xxxx config') // joymax ccfg
 .command('ls', 'list cfg.json in path ~') // joymax ls
 .parse(process.argv)

该文件作为命令入口,在其中注册了 q、ccfg、ls 命令,主要工作如下:

  1. commander 初始化 program
  2. 通过 program.version 注册 joymax --version
  3. 通过 program.command 注册子命令,第一个参数是命令,即 joymax q、joymax add、joymax ccfg、joymax ls,第二个参数是介绍这个命令的提示信息;
  4. 调用 promgram.parse 解析进程参数;

三、joymax-q.ts

import { cfgJsonObj } from './cfg'; // 读取 config 配置文件

import { runCommand } from './utils'; // 执行系统命令

import querySmsCodeInfo from './fetch'; // 发送网络请求获取短信验证码

import chalk from 'chalk'; 

// 初始化 program
import { Command } from 'commander';
const program = new Command();

program
 .usage('[phone or phone alias]') // 描述用法的
 .description('lookup sms challenge code and write to Clipboard, just CMD + V;') // 描述命令作用
 .argument('<string>', 'phone number or a alias of phone') // 命令参数 joymax q 参数
 .option('-z, --alipay', 'lookup alipay challenge code from ') // 命令选项 -z
 .on('--help',() => {
  // 监听 --help 命令,调用 joymax q --help 会执行这个回调
  console.log()
  console.log('  Examples:')
  console.log()
  console.log(chalk.gray('    # looking up by phone alias, added by: $ joymax add -n [phoneName] -p [phoneN]'))
  console.log('    $ joymax q someAlias')
  console.log()
  console.log(chalk.gray('    # looking up by phone number: 00016001234'))
  console.log('    $ joymax q 00016001234')
  console.log()
 }).parse(process.argv);

// program.opts() 获取选项
let { alipay: isAlipay } = program.opts();
let [ str = '' ] = program.args; // 获取命令参数

// async IIFE to use await and return 
;(async () => {
 let code;

// 如果参数
 if (/^\d{11}$/.test(str)) { 
  // 参数为手机号,直接传手机号查验证码
  code = await querySmsCodeInfo(str, isAlipay)
 } else {
  // 否则认定是别名
  let p = cfgJsonObj.alias[str];
  code = (p ? await querySmsCodeInfo(p, isAlipay) : '')
 }
 let data = code ? code.data : null
 
 if (data.errno === 0) {
  let xcode = data.result[0];
  // 跑命令写入 MacOS 剪贴板
  await runCommand(`echo ${xcode} | pbcopy`, null);
  // 否则输出到命令行
  console.log(`challenge code 【${xcode}】 from xxx has been copied, just CMD+V`)
 } else {
  console.error('challenge code failed!!!!');
  console.log(data)
 }
})();

该文件实现 joymax q 命令的 ts 文件,其中主要逻辑如下:

  1. 读取本地的 config.json 文件内容,其中包含了访问短信验证码平台的必要配置和用户配置的别名:手机号映射信息;
  2. commander 初始化命令 program,然后调用 useage、description 方法描述命令用法、用途
  3. 调用 program.argument 声明 joymax q xxx 参数, xxx 称为 q 命令的参数,参数将来会保存在 program.arg 这个数组中,参数有多个
  4. 调用 program.option 声明 q 命令的选项, -z--alipay-z 是简写,--alipay 是全写,最后访问这个参数的时候要用 alipay 作为 key 访问;后面如果获取选项需要调用 program.opts() 方法就能取到命令执行的选项;-z 是因为我们业务有在支付宝登录的场景,此时传给验证码 open-api 的参数略有不同;
  5. 根据传入的参数不同做不同处理以同时支持传入手机号和手机号对应的别名的场景;如果匹配是11位数字直接查,否则当别名处理从配置中取得手机号;
  6. 使用 pbcopy 写入剪贴板,最后将验证码输出到命令行,方便写入失败时从命令行复制;

四、joymax-add.ts

// 获取 cfg.json 配置、已经重新刷新 cfg.json 的方法
import { cfgJsonObj, loadCfgJSON } from './cfg';
import { Command } from 'commander';
import chalk from 'chalk';

const program = new Command();

program
 .usage('[phoneAlias:phone]')
 .description('add a alias:phone to cfg.json')
 .option('-n, --name <char>', 'alias for the phone') // -n 别名
 .option('-p, --phone <char>', 'phone number') // -p 电话号码
 .on('--help', () => {
  // --help 输出
  console.log()
  console.log('  Examples:')
  console.log()
  console.log(chalk.gray('    # add a alias:phone to cfg.json'))
  console.log('    $ joymax add -n someAlias -p 00016001234')
  console.log()
 }).parse(process.argv);

// 获取选项的值,获取选项时用全称,即上面的 --name, --phone
let { name, phone } = program.opts()


if (cfgJsonObj.alias[name]) {
 // 不可重复设置同一别名
 console.error(`${name} existed already, it's value is :${cfgJsonObj.alias[name]}`)
} else {
 // 验证手机号格式
 if (/^\d{11}$/.test(phone)) {
  cfgJsonObj.alias[name] = phone
  loadCfgJSON(cfgJsonObj); // 传入新的配置对象,loadCfgJson 就会更新 cfg.json 文件
  console.log(`${name} has been added to cfg.json`);
 } else {
  console.error('phone number should be tested by /^\d{11}$/')
 }
}

实现 joymax add 命令的 ts 文件,主要逻辑如下:

  1. 获取 cfg.json 和 更新 cfg.json 的方法 loadJsonObj
  2. 从选项中获取别名 name 和电话 phone,用于向 cfg.json 中增加配置;
  3. 处理重复别名和更新 cfg.json

五、joymax-ccfg

import { loadCfgJSON, cfgJsonObj } from './cfg';

import { Command } from 'commander';
import chalk from 'chalk'

const program = new Command();

program
 .usage('[xxxKey][xxxxValue]')
 .description('modify armid/dev/appid/zappid/key xxxx options')
 .option('-a, --armid <char>', 'xxxx armid')
 .option('-d, --dev <char>', 'xxxxx dev')
 .option('-a, --appid <char>', 'xxxx appid')
 .option('-z, --zappid <char>', 'xxxx alipay appid')
 .option('-k, --key <char>', 'xxxx key')
 .on('--help',() => {
  console.log()
  console.log('  Examples:')
  console.log()
  console.log(chalk.gray('    # modify armid/dev/appid/zappid/key xxxxx options'))
  console.log('    $ joymax  someAlias')
  console.log()
 }).parse(process.argv);

// 获取选项
let options = program.opts()

Object.keys(options).forEach((fg) => {
 // 更新 cfg.json
 cfgJsonObj[fg] = options[fg]
})

// 写入 cfg.json 并刷新 cfgJsonObj 对象
loadCfgJSON(cfgJsonObj)

实现 joymax ccfg 命令,该命令用于修改 cfg.json 的用于验证码 open api 的配置,前面说过验证码平台是集团通用的,而我们团队就有不同业务,所以需要复用,要想复用这些参数就得接受配置;这部分不多说,如果有需要就搞,不需要就忽略吧;

六、joymax-ls.ts

该文件为实现 joymax ls 命令的 ts 文件:

import { cfgJsonObj } from './cfg';

import { Command } from 'commander';
import chalk from 'chalk';

const program = new Command();

program
 .usage('[config]')
 .description('list cfg.json')
 .on('--help', () => {
  console.log()
  console.log('  Examples:')
  console.log()
  console.log('    $ joymax ls')
  console.log()
 })
 .parse(process.argv)

// 很简单,就是把 cfg.json 输出到命令行
console.log(JSON.stringify(cfgJsonObj, null, 2));

七、配置文件的问题

上一篇中我们提及了一个问题:

cfg.json 的问题,即我们把配置文件 cfg.json 放到了包目录中,很明显这么做有个致命的问题;

当包升级后,用户本地重新安装这个包时,新包的 cfg.json 就会把用户之前安装目中的 cfg.json 覆盖掉从而导致用户的配置,诸如别名、open-api 参数丢失;

这怎么解决?请看官老爷思考2s;

最简单的方法就是把配置文件写到一个其他目录,这里我们选择用户的家目录(即MacOS 的 ~ 目录,/Users/xxx 就是家目录,xxx 是个用户名)安装的时候先判断家目录中是否有,有的话就合并,并且本地的优先级高,如果家目录中没有这个包,这就说明这是初次安装,这个时候就把包目录中的 cfg.json 复制到家目录就好了;

那这么做有什么好处呢?这样做,用户升级包的时候用户的配置不会丢失,另外,如果此后新包新增了配置项,用户也只需要升级包版本就好,无须关注这些配置;

说了这么多该怎么实现呢?

7.1 npm hooks

上面说了,处理配置文件这个事儿要在安装完成后处理?what❓❓❓ 我怎么知道何时结束....

事实上 npm 提供了这种能力 —— Life Cycle Scripts(生命周期脚本),这里不得不来一通🌈屁夸夸人家的设计,是一种类似 hook 的存在,我们只需要在这个包的 package.jsonscripts 中设置一个 postinstall 脚本即可;

postinstall 会在这个包被安装后调用,对应的还有个 preinstall,这个是在被安装前调用的;所以我们在 package.json 中加入这一条:

{

  "scripts": {
    "test": "bin/joymax.js",
    "build": "./esbiubiu",
    "postinstall": "./mergeCfg" // 就是这个了
  },
  "bin": {
    "joymax": "bin/joymax.js"
  },
  "keywords": [
    "challenge code"
  ],

}

7.2 mergeCfg 脚本

我们在包的根目录下新建一个 js 文件,并且为其添加可执行权限:

$ touch mergeCfg.js
$ chmod +x mergeCfg.js
  • mergeCfg.js 脚本内容如下:
#!/usr/bin/env node
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
// const cp = require('child_process');

const CFG_NAME = 'cfg.json';

// 获取用户 Macbook 上的家目录,例如 /User/Mr.right
const HOME_PATH = os.homedir() || process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;

// 拼接家目录下的配置文件路径
const HOME_CFG_PATH = path.join(HOME_PATH, CFG_NAME);

// 包目录中的配置文件路径
const PKG_CFG_PATH = path.resolve(__dirname, CFG_NAME);

const CHARSET = 'utf8'

// async IIFE to use await & return
;(async () => {
  let homeCfgStat;
  let homeCfgJSON;
  let alreadyHome;
  let finalCfg;
  let homeCfgJSONString;
  try {
    // 检查用户家目录中是否有配置文件
    // 有就读取
    homeCfgStat = await fs.stat(HOME_CFG_PATH);
    alreadyHome = homeCfgStat.isFile()
    homeCfgJSONString = await fs.readFile(HOME_CFG_PATH, CHARSET)
    homeCfgJSON = homeCfgJSONString ? JSON.parse(homeCfgJSONString) : {}
  } catch (e) {
    // fs.stat 解析不存在目录会报错,报错说明压根没有
    homeCfgJSONString = await fs.readFile(PKG_CFG_PATH, CHARSET)
    homeCfgJSON = homeCfgJSONString ? JSON.parse(homeCfgJSONString) : {}
  }

  homeCfgJSONString = null

  const pkgCfgJSON = JSON.parse(await fs.readFile(PKG_CFG_PATH, CHARSET));

  if (alreadyHome) {
     // 存在做合并
     // merge, the priority local is higher
    finalCfg = Object.assign({}, pkgCfgJSON, homeCfgJSON)
  } else {
    // 不存在就复制一份到家目录
    // copy to ~
    finalCfg = pkgCfgJSON
  }

  // 写入家目录
  await fs.writeFile(HOME_CFG_PATH, JSON.stringify(finalCfg, null, 2));

  // cp.exec(`chmod ug +w ${HOME_CFG_PATH}`);

  console.log('MERGE CFG.JSON FINISHED!!!!');
})();

八、总结

本篇小作文完成了前所有指令的实现,我们回顾一下:

  1. package.json bin 字段保证全局安装时实现 joymax
  2. commander 注册 q、ls、add、ccfg 命令、解析参数、选项;
  3. npm 生命周期脚本 postinstall 处理用户本地配置和包中的配置文件关系,确保用户的配置不丢失;

这是个内网工具,没办法开源,请各位看官老爷见谅,但是源码都已经贴出来了;有人说你司没有短信验证码 open-api 咋办?

机会这不就来了,只要能查验证码,写个服务不就行了,这不绩效就上来了吗,卷起来吧!

上个图吧,这是用别名查询验证码:

image.png

最后再次预祝点赞的老爷成为尊贵的奥迪车主!!