前置准备

初始化

  1. 安装 node, 建议 16 以上的版本
  2. 新建一个文件夹,npm init 初始化,并在 package.json 文件中添加 bin 字段声明命令,指向命令执行的 js 文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    {
    "name": "ny-cli",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
    },
    "author": "",
    "license": "ISC",
    "bin": {
    "ny": "bin/cli.js"
    }
    }
  3. bin/cli.js

    添加 #!/usr/bin/env node, 告知 os 此文件以 node 形式运行

    1
    2
    3
    #!/usr/bin/env node

    console.log("Hello World");

未发布到 npm 仓库之前,需要本地调试。npm link 之后,执行 ny-cli 正常输出即成功

  • npm link: 链接到全局
  • npm ls -g —depth=0: 查看全局已链接的包,检查是否 link 成功
  • npm rm —global pkgName: 删除 link 的包

monorepo

npm link 调试有几个问题,推荐使用 monorepo 风格的脚手架

  • 多个 Node.js 版本同时存在可能会出错
  • 软连接错误删除
  • link 失败不会报错并且会回退到直接从 npm 仓库查找同名的包进行安装,导致可能安装错误的包
  • 子工程 package
1
2
3
4
5
6
7
8
{
"scripts": {
"ny": "ny --help"
},
"dependencies": {
"ny-cli": "workspace:*"
}
}
  • pnpm 中使用 workspace: pnpm 只会解析存在工作空间内的包,不会去下载安装 npm 上的包
  • 在子工程中执行 pnpm i, 将只会在子工程内部安装 ny-cli, 而不会在全局安装

准备

  1. 全局安装 pnpm: npm install pnpm -g
  2. 新建文件夹并 pnpm init 初始化,创建 pnpm-workspace.yaml 文件

    1
    2
    3
    4
    # 声明 packages 和 examples 文件夹中子工程是同属一个工作空间,可被其它子工程引用
    packages:
    - "packages/*"
    - "examples/*"
  3. 新建 packages 文件夹,并在此创建 ny-cli 文件夹,并 pnpm init,在生成的 package.json 中配置 bin 字段。同级创建 bin 文件夹及 cli 文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    {
    "name": "ny-cli",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "packageManager": "pnpm@10.6.2",
    "bin": {
    "ny": "./bin/cli.js"
    }
    }
    1
    2
    3
    4
    // cli.js
    #!/usr/bin/env node

    console.log('Hello World');
  4. 新建 examples 文件夹,并在此创建 app 文件夹,并 pnpm init,在生成的 package.json 中配置 dependencies 字段和 scripts 脚本命令

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    {
    "name": "app",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
    "ny": "ny"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "packageManager": "pnpm@10.6.2",
    "dependencies": {
    "ny-cli": "workspace:*"
    }
    }
  5. 在根文件目录执行 pnpm i,完成后在 app 文件目录下执行 pnpm ny,终端输出 Hello World 即说明搭建成功。整体目录如下

模块实现

命令参数

可以使用 commanderyargs 来解析参数,本文档采取 commander 解析参数, yargs 方式可参考 monorepo 脚手架搭建教程

1
2
3
4
5
6
7
const program = require("commander");
const { name, version } = require("../package.json");

// 设置命令行工具的名称、使用说明、版本
program.name(name).usage(`<command>[option]`).version(version);
// 解析输入的命令行参数
program.parse(process.argv);

交互模块

使用 inquirer 处理询问式交互,建议安装 v8 版本,高版本使用会出一些奇怪的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const Inquirer = require(inquirer);

const cwd = process.cwd();

const asws = await new Inquirer.prompt([
{
type: "input",
name: "name",
message: "project name",
default: projectName,
validate: function (val) {
if (!/^[a-zA-Z]+$/.test(val)) {
return "模板名称只能含有英文";
}
if (!/^[A-Z]/.test(val)) {
return "模板名称首字母必须大写";
}
return true;
},
},
{
name: "template",
type: "list",
message: "Please choose a template to create project",
choices: ["react", "vue"],
},
{
type: "list",
message: "Please choose a version",
choices: [
{ name: "Vue2", value: "git@github.com://vue2" },
{ name: "Vue3", value: "git@github.com://vue3" },
],
name: "library",
when: (answers) => answers.template === "vue",
},
{
type: "list",
message: "Please choose a version",
choices: [{ name: "React", value: "git@github.com://react" }],
name: "library",
when: (answers) => answers.template === "react",
},
]);

仓库模块

远程仓库拉取

download-git-repo

1
2
3
4
5
6
7
8
9
10
11
12
13
const { library } = asws;

async function download(templateUrl, targetDirectory) {
const downloadGitRepoPromise = util.promisify(downloadGitRepo);
await loading(
"downloading template, please wait",
downloadGitRepoPromise,
templateUrl,
targetDirectory
);
}

await download(library, targetDirectory);

直接创建 shell 命令

1
2
3
4
5
6
7
8
9
10
const { exec, execSync } = require("child_process");

await loading(
"downloading template, please wait",
exec,
`git clone -b master ${library} ${projectName}`,
{
stdio: "ignore",
}
);

复制仓库文件

copy-dir
将 template 文件夹里的模板复制到项目里,比如 packages/ny-cli/template 复制到 examples/app/src/common

  • __dirname: 脚本的物理地址,固定不变的路径
  • process.cwd(): 命令执行的当前目录路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const copydir = require("copy-dir");
const targetDirectory = path.resolve(cwd, "./src/common");

if (fs.existsSync(targetDirectory)) {
console.log("文件夹已经存在");
const { isOverwrite } = await new Inquirer.prompt([
{
name: "isOverwrite",
type: "list",
message: "Target directory exists, Please choose an action",
chooices: [
{ name: "Overwrite", value: true },
{ name: "Cancel", value: false },
],
},
]);
if (!isOverwrite) {
console.log("Cancel");
return;
} else {
await loading(
`Removing common, please wait a minute`,
fs.remove,
targetDirectory
);
copydir.sync(
path.resolve(__dirname, "./template"),
path.resolve(cwd, "./src/common")
);
}
} else {
copydir.sync(
path.resolve(__dirname, "./template"),
path.resolve(cwd, "./src/common")
);
}

后处理模块

模板下载完成后需要根据之前的创建参数调整模板相关配置项

通过方法修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function changeInfo(name) {
let spinner = ora("正在修改相关配置信息...");
let files = ["README.md", "package.json", "package-lock.json", ".env"];
spinner.start();
for (const key in answers) {
files.forEach((file) => {
let src = path.join(cwd, `${name}/${file}`);
let content = fs
.readFileSync(src, "utf-8")
.replace(new RegExp(key, "g"), answers[key]);
fs.writeFileSync(src, content);
});
}
// 更换git相关信息
let git_src = path.join(cwd, `${name}/.git/config`);
fs.writeFileSync(
git_src,
fs.readFileSync(git_src, "utf-8").replace(/templateUrl-xxx/g, name)
);
spinner.succeed("修改相关配置信息成功!");
}

动态配置模板

mustache 库,可参考 monorepo 脚手架搭建教程

美化模块

chalk

1
2
3
const chalk = require("chalk");

console.log(`${chalk.green("hello world")}`);

figlet

1
2
3
4
5
6
7
8
9
10
11
12
const figlet = require("figlet");

console.log(
"\r\n" +
figlet.textSync("ny-cli", {
font: "3D-ASCII",
horizontalLayout: "default",
verticalLayout: "default",
width: 80,
whitespaceBreak: true,
})
);

ora-loading

1
2
3
4
5
6
7
8
const ora = require("ora");

const spinner = ora("Loading...").start();
setTimeout(() => {
spinner.color = "yellow";
spinner.text = "Loading vue";
spinner.succeed();
}, 1000);

封装 loading

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* loading加载效果
* @param {String} message 加载信息
* @param {Function} fn 加载函数
* @param {List} args fn 函数执行的参数
* @returns 异步调用返回值
*/
async function loading(message, fn, ...args) {
const spinner = ora(message);
spinner.start();
try {
let executeRes = await fn(...args);
spinner.succeed();
return executeRes;
} catch (error) {
spinner.fail("request fail, reTrying");
await sleep(1000);
return loading(message, fn, ...args);
}
}

发布和安装

packeages/ny-cli 文件夹下运行发布命令,然后在项目工程中安装

1
2
3
4
5
# 发布
pnpm publish --F ny-cli

# 安装
pnpm add ny-cli -D

完整示例代码

  • cli.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#! /usr/bin/env node

const { Command } = require("commander");
const { name, version } = require("../package.json");
const chalk = require("chalk");
const figlet = require("figlet");

const program = new Command();

program.name(name).usage(`<command>[option]`).version(version);

program
.command("create <project-name>")
.description("create a new project")
.option("-f, --force", "overwrite target directory if it exists")
.action((projectName, cmd) => {
require("../lib/create")(projectName, cmd);
});

program
.command("config [value]")
.description("inspect and modify the config")
.option("-g, --get <key>", "get value by key")
.option("-s, --set <key> <value>", "set option[key] is value")
.option("-d, --delete <key>", "delete option by key")
.action((value, keys) => {
console.log(value, keys);
});

program.on("--help", function () {
console.log(
"\r\n" +
figlet.textSync("my-cli", {
font: "3D-ASCII",
horizontalLayout: "default",
verticalLayout: "default",
width: 80,
whitespaceBreak: true,
})
);
console.log();
console.log(
`Run ${chalk.cyan(
"my-cli <command> --help"
)} for detailed usage of given command.`
);
console.log();
});

program.parse(process.argv);
  • create.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
const path = require("path");
const fs = require("fs-extra");
const Inquirer = require("inquirer");
const downloadGitRepo = require("download-git-repo");
const chalk = require("chalk");
const util = require("util");
const { loading } = require("./util");

module.exports = async function (projectName, options) {
// 获取当前工作目录
const cwd = process.cwd();
const targetDirectory = path.resolve(cwd, projectName);

// // 处理文件夹
await handleFolder(projectName, options, targetDirectory);

// 1. 选择模板
const { library } = await new Inquirer.prompt([
{
type: "input",
name: "name",
message: "project name",
default: projectName,
validate: function (val) {
if (!/^[a-zA-Z]+$/.test(val)) {
return "模板名称只能含有英文";
}
if (!/^[A-Z]/.test(val)) {
return "模板名称首字母必须大写";
}
return true;
},
},
{
name: "template",
type: "list",
message: "Please choose a template to create project",
choices: ["react", "vue"],
},
{
type: "list",
message: "Please choose a version",
choices: [
{ name: "Vue2", value: "git@github.com://vue2" },
{ name: "Vue3", value: "git@github.com://vue3" },
],
name: "library",
when: (answers) => answers.template === "vue",
},
{
type: "list",
message: "Please choose a version",
choices: [{ name: "React", value: "git@github.com://react" }],
name: "library",
when: (answers) => answers.template === "react",
},
]);

// 2. 下载
await download(library, targetDirectory);

// 3. 模板使用提示
success(projectName);
};

function success(name) {
spinner.succeed("template download success!");
changeInfo(name);
console.log(`- cd ${chalk.cyan(name)}`);
console.log(`- ${chalk.blue("npm install")}`);
console.log(`- ${chalk.yellow("npm run dev")}`);
}

// 处理文件夹创建重名问题
async function handleFolder(projectName, options, targetDirectory) {
if (fs.existsSync(targetDirectory)) {
if (options.force) {
// 删除重名目录
await fs.remove(targetDirectory);
} else {
let { isOverwrite } = await new Inquirer.prompt([
{
name: "isOverwrite",
type: "list",
message: "Target directory exists, Please choose an action",
chooices: [
{ name: "Overwrite", value: true },
{ name: "Cancel", value: false },
],
},
]);
if (!isOverwrite) {
console.log("Cancel");
return;
} else {
await loading(
`Removing ${projectName}, please wait a minute`,
fs.remove,
targetDirectory
);
}
}
}
}

// 下载git仓库
async function download(templateUrl, targetDirectory) {
const downloadGitRepoPromise = util.promisify(downloadGitRepo);
await loading(
"downloading template, please wait",
downloadGitRepoPromise,
templateUrl,
targetDirectory
);
}

function changeInfo(name) {
let spinner = ora("正在修改相关配置信息...");
let files = ["README.md", "package.json", "package-lock.json", ".env"];
spinner.start();
for (const key in answers) {
files.forEach((file) => {
let src = path.join(cwd, `${name}/${file}`);
let content = fs
.readFileSync(src, "utf-8")
.replace(new RegExp(key, "g"), answers[key]);
fs.writeFileSync(src, content);
});
}
// 更换git相关信息
let git_src = path.join(cwd, `${name}/.git/config`);
fs.writeFileSync(
git_src,
fs.readFileSync(git_src, "utf-8").replace(/templateUrl-xxx/g, name)
);
spinner.succeed("修改相关配置信息成功!");
}
  • util.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const ora = require("ora");

/**
* 睡觉函数
* @param {Number} n 睡眠时间
*/
function sleep(n) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, n);
});
}

/**
* loading加载效果
* @param {String} message 加载信息
* @param {Function} fn 加载函数
* @param {List} args fn 函数执行的参数
* @returns 异步调用返回值
*/
async function loading(message, fn, ...args) {
const spinner = ora(message);
spinner.start();
try {
let executeRes = await fn(...args);
spinner.succeed();
return executeRes;
} catch (error) {
spinner.fail("request fail, reTrying");
await sleep(1000);
return loading(message, fn, ...args);
}
}

module.exports = { loading };

参考文章