前言
假设我现在为团队写了一个项目模板,但是要怎么使用这个项目模板呢,一种方式是,将它放在公司 gitlab 上,使用的时候拉取下来,不过这样子有点麻烦,每次拉取项目的时候,可能需要去查看项目的仓库地址,同时还需要修改项目名称。有更好的方式吗?我在思考。
突然想到我们平时一直使用的脚手架,就是干了这么一件事,借助 cli, 去拉取框架的项目模板,比如 vue-cli, 通过 yarn global add @vue/cli 安装 @vue/cli, 执行 vue create my-project 命令创建项目模板。
所以需要去实现一个可以拉取自己项目模板的 cli。
项目模板的搭建过程以及源码,可以查看我的上篇文章,为团队提供提供支持 ts、代码规范以及提交规范的项目模板
从这篇文章中你能学到什么?
- 如何为团队开发自己的脚手架工具
- 怎么本地调试脚手架
- 怎么将项目发布到 npm
搭建项目
项目初始化
上篇文章已经实现了一个 cli 的项目模板。cli 创建的方式,这篇文章正在实现,所以我先直接 clone 下来,修改项目名称。
git clone https://github.com/LBINGXIN/ts-cli-template.git
这是当前的目录结构
本地调试
在根目录下创建 bin 文件夹和 bin/ts-cli.js 文件,并设置如下内容:
#! /usr/bin/env node
// #! 符号的名称叫 Shebang,用于指定脚本的解释程序
// 这句脚本的作用是指定用node执行当前脚本文件
// 如果是Linux 或者 macOS 系统下还需要修改此文件的读写权限为 755
// 具体就是通过 chmod 755 ts-cli.js 实现修改
// 用于检查入口文件是否正常执行
console.log('ts-cli running!');
在项目根目录下的 pacakge.json 中增加如下内容:
{
"bin": {
"ts-cli": "./bin/ts-cli.js"
}
}
bin 用来指定命令(ts-cli)的可执行文件的位置,接下来在项目根目录执行 npm link 或 yarn link,将 package.json 中的属性 bin 的值路径添加全局链接,在命令行中执行 ts-cli 就会执行 ./bin/ts-cli.js 文件
npm link
执行 npm link,根据命令行输出结果,可以发现已经 ts-cli 包链接到全局的 node_modules
执行 ts-cli, 就会执行我们在 package.json 配置的可执行文件 ./bin/ts-cli.js,从而输出 ts-cli running!,现在我们实现了 ts-cli 的测试运行了。
有包的安装,同样也有包的卸载,当开发完成后,可以执行 npm unlink 将对应的包卸载, 执行完 npm unlink 后,在执行 ts-cli,提示 ts-cli 不存在该命令,说明我们卸载成功。
npm unlink
这是正常操作的过程,假如你在开发的过程中,修改了命令的名称,或者项目名称,以及其他不可预测的操作后,发现 npm unlink 删除不掉包,这时候可以使用 where ts-cli,查看当前包的位置,然后手动删除对应文件就可以了。
where ts-cli
开发
常用的脚手架工具库
| 库名称 | 作用 |
|---|---|
| commander | 命令行自定义指令 |
| chalk | 控制台输出内容样式美化 |
| ora | 控制台 loading 样式 |
| download-git-repo | 下载远程模版 |
| fs-extra | 系统fs模块的扩展,提供了更多便利的 API,并继承了fs模块的 API |
| shelljs | 支持跨平台调用系统上的命令 |
创建源码目录
在根目录下创建 src 文件夹和 src/index.ts 文件
因为我们使用 ts,需要进行编译,并且编译的结果会输出到 lib 目录中,所以需要修改一下 bin/ts-cli.js 内容如下:
#! /usr/bin/env node
// #! 符号的名称叫 Shebang,用于指定脚本的解释程序
// 这句脚本的作用是指定用node执行当前脚本文件
// 如果是Linux 或者 macOS 系统下还需要修改此文件的读写权限为 755
// 具体就是通过 chmod 755 ts-cli.js 实现修改
// 将构建目录(lib)下的 index.js 作为脚手架的入口
require('../lib/index');
自定义创建项目命令
安装依赖 commander
commander 是一个可以实现自定义命令行指令,并且可以解析命令行参数的 node 插件
npm i commander
实现创建项目的命令
使用 commander 实现创建项目的命令 ts-cli create <app-name>,在 src/index.ts 添加如下内容:
import { program } from 'commander';
program
.command('create <app-name>')
.description('create a new project')
.action(async (name: string) => {
console.log(name)
});
program.parse(process.argv);
先执行 npm run build,编译文件,结果输出到 lib 目录中
npm run build
在命令行执行 ts-cli,可以发现新增了一条 create <app-name> 的命令
为了方便测试,我创建一个 ts-cli-test文件夹,并在 vs code 打开
在 ts-cli-test 目录下执行 ts-cli create <app-name>,<app-name> 为自定义参数,我设置app-name 为 my-app,输出的值也为 my-app,说明我们拿到了用户创建项目的项目名称
实现查看版本号
vue-cli 提供了 vue -V 查看版本号,我们也提供这样的一个功能,在 src/index.ts 添加查看版本号命令,版本号直接获取 package.json 对应的版本号
/* eslint-disable @typescript-eslint/no-var-requires */
program
.version(`${require('../package.json').version}`, '-v --version')
.usage('<command> [options]');
先执行 npm run build 进行编译,然后执行 ts-cli,可以发现多了一条 version 的命令
执行 ts-cli -v 或者 ts-cli --version 查看版本号
为了代码可读性,在 src 目录下创建 src/create.ts文件,用来实现脚手架拉取模板的具体逻辑
/src/index.ts 最终代码
src/index.ts的作用主要是用来创建所有命令,比如我们的 ts-cli create <app-name> 和 ts-cli -v,命令的具体实现逻辑时抽离出去的,比如我们的 src/create.ts,因为我们这个项目暂时只有这两个命令,所以以下就是 src/index.ts的最终代码
import { program } from 'commander';
import create from './create';
// ts-cli -v 或 ts-cli --version 读取 package.json 的version
program
// eslint-disable-next-line @typescript-eslint/no-var-requires
.version(`${require('../package.json').version}`, '-v --version')
.usage('<command> [options]');
// 创建项目命令 ts-cli create <app-name>
program
.command('create <app-name>')
.description('create a new project')
.action(async (name: string) => {
// 创建逻辑
await create(name);
});
program.parse(process.argv);
下载项目模板
安装依赖 download-git-repo
download-git-repo 是一个可以实现 从 git 仓库(GitHub、GitLab、Bitbucket)拉取文件的 node 插件。
npm i download-git-repo
download-git-repo 不支持 promise,我们可以使用 node 库 util.promisify 包装 download-git-repo,使它支持 promise
import { promisify } from 'util';
import { resolve } from 'path';
import * as downloadGitRepo from 'download-git-repo';
const download = promisify(downloadGitRepo);
实现项目拉取
// create 命令
export default async function create(projecrName: string): Promise<void> {
// 模板地址 用户名/项目名 只需要这样就可以,内部会自动添加 github 地址
const templateUrl = `LBINGXIN/ts-cli-template`;
// 如果是公司内部私服gitlab,需要使用全地址,并加上 direct 前缀
// const templateUrl = `direct:http://xxx.com/ts-cli-template.git`;
// 拉取项目的位置 process.cwd() 是当前Node.js进程执行时的文件夹地址——工作目录
const target = resolve(process.cwd(), projecrName);
try {
await download(templateUrl, target);
console.log('download success...');
} catch (err) {
console.log('download failed...');
}
}
/src/create.ts 当前代码
/**
* create 命令的具体任务
*/
import { promisify } from 'util';
import { resolve } from 'path';
import * as downloadGitRepo from 'download-git-repo';
const download = promisify(downloadGitRepo);
// create 命令
export default async function create(projecrName: string): Promise<void> {
// 模板地址 用户名/项目名 只需要这样就可以,内部会自动添加 github 地址
const templateUrl = `LBINGXIN/ts-cli-template`;
// 如果是公司内部私服gitlab,需要使用全地址,并加上 direct 前缀
// const templateUrl = `direct:http://xxx.com/ts-cli-template.git`;
// 拉取项目的位置 process.cwd() 是当前Node.js进程执行时的文件夹地址——工作目录
const target = resolve(process.cwd(), projecrName);
try {
await download(templateUrl, target);
console.log('download success...');
} catch (err) {
console.log('download failed...');
}
}
老样子,先执行 npm run build 进行编译,为了方便测试,我们在 ts-cli-test目录进行测试, 执行 ts-cli create my-app,可以发现输出了 download sucess...,同时也在当前目录下创建了 my-app 项目
ts-cli create my-app
现在我们已经实现了通过 ts-cli,创建一个项目了,不过还有一些点需要处理和优化
- 如果当前项目名称已经存在,需要添加一个退出操作,不需要执行项目创建
- 点开
package.json,会发现项目名称不对,所以需要去做 name 修改 - 如果网络比较慢的时候,我们执行
ts-cli create my-app,会发现命令行很久都没有反应,也不知道执行成功,所以需要添加一个 loading,给用户正在加载的反馈 - 命令行的输出内容,是不是可以美化一下
接下来根据上面这几点,做具体分析,然后在给出 src/create.ts 的完整代码,着急的小伙伴,可以直接在后面一点,查看完整代码。
判断文件是否存在
添加 isFileExist 方法,用于验证当前目录下是否存在要创建的文件
/**
* 验证当前目录下是否已经存在指定文件,如果存在则退出进行
* @param filename 文件名
*/
export function isFileExist(filename: string): void {
// 文件路径
const file = resolve(process.cwd(), filename);
// 验证文件是否已经存在,存在则推出进程
if (existsSync(file)) {
console.log(`${file} 已经存在`);
process.exit(1);
}
}
添加loading
安装依赖 ora
ora 是一个在命令行显示 loading 的 node 插件
需要安装5.4.1版本,最新版可能会有问题
npm i ora@5.4.1
封装一个 loading 公用方法
// 添加加载动画
export const wrapLoading = async (fn, message, ...args) => {
// 使用 ora 初始化,传入提示信息 message
const spinner = ora(message);
// 开始加载动画
spinner.start();
try {
// 执行传入方法 fn
const result = await fn(...args);
// 状态为修改为成功
spinner.succeed();
return result;
} catch (error) {
// 状态为修改为失败
spinner.fail('failed ...');
}
};
修改 package.json 项目名
/**
* 读取指定路径下 json 文件
* @param filename json 文件的路径
*/
export function readJsonFile<T>(filename: string): T {
return JSON.parse(readFileSync(filename, { encoding: 'utf-8', flag: 'r' }));
}
/**
* 覆写指定路径下的 json 文件
* @param filename json 文件的路径
* @param content json 内容
*/
export function writeJsonFile<T>(filename: string, content: T): void {
writeFileSync(filename, JSON.stringify(content, null, 2));
}
/**
* 改写项目中 package.json 的 name、description
*/
export function changePackageInfo(projectName: string): void {
const packagePath = resolve(getProjectPath(projectName), './package.json');
const packageJSON: PackageJSON = readJsonFile<PackageJSON>(packagePath);
packageJSON.name = packageJSON.description = projectName;
writeJsonFile<PackageJSON>(packagePath, packageJSON);
}
美化命令行输出内容
安装依赖 chalk
npm i chalk@4.1.2
需要安装4.1.2版本,最新版可能会有问题
使用方式
import { cyan, gray, red, yellow } from 'chalk';
console.log(cyan('hello world!');
console.log(gray('hello world!');
console.log(red('hello world!');
console.log(yellow('hello world!');
src/create.ts 完整代码
/**
* create 命令的具体任务
*/
import { promisify } from 'util';
import { resolve } from 'path';
import * as downloadGitRepo from 'download-git-repo';
import { existsSync, readFileSync, writeFileSync } from 'fs';
const download = promisify(downloadGitRepo);
import * as ora from 'ora';
import { red, yellow, gray, cyan } from 'chalk';
/**
* 验证当前目录下是否已经存在指定文件,如果存在则退出进行
* @param filename 文件名
*/
export function isFileExist(filename: string): void {
// 文件路径
const file = resolve(process.cwd(), filename);
// 验证文件是否已经存在,存在则推出进程
if (existsSync(file)) {
console.log(red(`${file} 已经存在`));
process.exit(1);
}
}
// 添加加载动画
export const wrapLoading = async (fn, message, ...args) => {
// 使用 ora 初始化,传入提示信息 message
const spinner = ora(message);
// 开始加载动画
spinner.start();
try {
// 执行传入方法 fn
const result = await fn(...args);
// 状态为修改为成功
spinner.succeed();
return result;
} catch (error) {
// 状态为修改为失败
spinner.fail('failed ...');
}
};
/**
* 读取指定路径下 json 文件
* @param filename json 文件的路径
*/
export function readJsonFile<T>(filename: string): T {
return JSON.parse(readFileSync(filename, { encoding: 'utf-8', flag: 'r' }));
}
/**
* 覆写指定路径下的 json 文件
* @param filename json 文件的路径
* @param content json 内容
*/
export function writeJsonFile<T>(filename: string, content: T): void {
writeFileSync(filename, JSON.stringify(content, null, 2));
}
/**
* 获取项目绝对路径
* @param projectName 项目名
*/
export function getProjectPath(projectName: string): string {
return resolve(process.cwd(), projectName);
}
export interface PackageJSON {
name: string;
version: string;
description: string;
scripts: {
[key: string]: string;
};
}
/**
* 改写项目中 package.json 的 name、description
*/
export function changePackageInfo(projectName: string): void {
const packagePath = resolve(getProjectPath(projectName), './package.json');
const packageJSON: PackageJSON = readJsonFile<PackageJSON>(packagePath);
packageJSON.name = packageJSON.description = projectName;
writeJsonFile<PackageJSON>(packagePath, packageJSON);
}
/**
*
* @param projecrName
*/
export async function getRepo(projectName: string): Promise<void> {
// 模板地址 用户名/项目名 只需要这样就可以,内部会自动添加 github 地址
const templateUrl = `LBINGXIN/ts-cli-template`;
// 如果是公司内部私服gitlab,需要使用全地址,并加上 direct 前缀
// const templateUrl = `direct:http://xxx.com/ts-cli-template.git`;
// 拉取项目的位置 process.cwd() 是当前Node.js进程执行时的文件夹地址——工作目录
const target = resolve(process.cwd(), projectName);
await wrapLoading(
download, // 远程下载方法
'waiting download template', // 加载提示信息
templateUrl, // 参数1: 下载地址
target,
);
}
// create 命令
export default async function create(projectName: string): Promise<void> {
// 判断文件是否已经存在
isFileExist(projectName);
await getRepo(projectName);
// 改写项目的 package.json 基本信息,比如 name、description
changePackageInfo(projectName);
// 输出结束提示
end(projectName);
}
/**
* 整个项目安装结束,给用户提示信息
*/
export function end(projectName: string): void {
console.log(`Successfully created project ${yellow(projectName)}`);
console.log('Get started with the following commands:');
console.log('');
console.log(`${gray('$')} ${cyan('cd ' + projectName)}`);
console.log(`${gray('$')} ${cyan('npm run dev')}`);
console.log('');
}
老样子,先执行 npm run build 进行编译,然后在 ts-cli-test 文件夹执行 ts-cli create my-app 进行测试
可以看出在下载文件时,会显示一个蓝色的loading
下来成功时,会有绿色成功标志,并输出提示内容
现在虽然已经完成我们的目标,不过代码还是有点乱,扩展性也不好,下面时重构后的代码,具有较好的扩展性
完整代码
这是源码的一个目录结构
/src/index.ts
import { program } from 'commander';
import create from './order/create';
// 查看版本 ts-cli -v 或 ts-cli --version
/* eslint-disable @typescript-eslint/no-var-requires */
program
.version(`${require('../package.json').version}`, '-v --version')
.usage('<command> [options]');
// 创建项目命令 ts-cli create app-name
program
.command('create <app-name>')
.description('create a new project')
.action(async (name: string) => {
// 创建逻辑
await create(name);
});
program.parse(process.argv);
/src/order/create.ts
/**
* create 命令的具体任务
*/
import { changePackageInfo, end, isFileExist, getRepo } from '../utils/create';
// create 命令
export default async function create(projecrName: string): Promise<void> {
// 判断文件是否已经存在
isFileExist(projecrName);
await getRepo(projecrName);
// 改写项目的 package.json 基本信息,比如 name、description
changePackageInfo(projecrName);
// 结束
end(projecrName);
}
/src/utils/create.ts
/**
* create 命令需要用到的所有方法
*/
import {
getProjectPath,
PackageJSON,
printMsg,
readJsonFile,
writeJsonFile,
wrapLoading,
} from '../utils/common';
import { existsSync } from 'fs';
import { resolve } from 'path';
import { cyan, gray, red, yellow } from 'chalk';
import { promisify } from 'util';
import * as downloadGitRepo from 'download-git-repo';
const download = promisify(downloadGitRepo);
/**
* 验证当前目录下是否已经存在指定文件,如果存在则退出进行
* @param filename 文件名
*/
export function isFileExist(filename: string): void {
// 文件路径
const file = getProjectPath(filename);
// 验证文件是否已经存在,存在则推出进程
if (existsSync(file)) {
printMsg(red(`${file} 已经存在`));
process.exit(1);
}
}
export async function getRepo(projectName: string): Promise<void> {
const requestUrl = `LBINGXIN/ts-cli-template`;
await wrapLoading(
download, // 远程下载方法
'waiting download template', // 加载提示信息
requestUrl, // 参数1: 下载地址
resolve(process.cwd(), projectName),
);
}
/**
* 改写项目中 package.json 的 name、description
*/
export function changePackageInfo(projectName: string): void {
const packagePath = resolve(getProjectPath(projectName), './package.json');
const packageJSON: PackageJSON = readJsonFile<PackageJSON>(packagePath);
packageJSON.name = packageJSON.description = projectName;
writeJsonFile<PackageJSON>(packagePath, packageJSON);
}
/**
* 整个项目安装结束,给用户提示信息
*/
export function end(projectName: string): void {
printMsg(`Successfully created project ${yellow(projectName)}`);
printMsg('Get started with the following commands:');
printMsg('');
printMsg(`${gray('$')} ${cyan('cd ' + projectName)}`);
printMsg(`${gray('$')} ${cyan('npm run dev')}`);
printMsg('');
}
/src/utils/common.ts
/**
* 放一些通用的工具方法
*/
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
import * as clear from 'clear-console';
import * as ora from 'ora';
// 添加加载动画
export const wrapLoading = async (fn, message, ...args) => {
// 使用 ora 初始化,传入提示信息 message
const spinner = ora(message);
// 开始加载动画
spinner.start();
try {
// 执行传入方法 fn
const result = await fn(...args);
// 状态为修改为成功
spinner.succeed();
return result;
} catch (error) {
// 状态为修改为失败
spinner.fail('failed ...');
}
};
export interface PackageJSON {
name: string;
version: string;
description: string;
scripts: {
[key: string]: string;
};
}
export interface JSON {
[key: string]: unknown;
}
/**
* 读取指定路径下 json 文件
* @param filename json 文件的路径
*/
export function readJsonFile<T>(filename: string): T {
return JSON.parse(readFileSync(filename, { encoding: 'utf-8', flag: 'r' }));
}
/**
* 覆写指定路径下的 json 文件
* @param filename json 文件的路径
* @param content json 内容
*/
export function writeJsonFile<T>(filename: string, content: T): void {
writeFileSync(filename, JSON.stringify(content, null, 2));
}
/**
* 获取项目绝对路径
* @param projectName 项目名
*/
export function getProjectPath(projectName: string): string {
return resolve(process.cwd(), projectName);
}
/**
* 打印信息
* @param msg 信息
*/
export function printMsg(msg: string): void {
console.log(msg);
}
/**
* 清空命令行
*/
export function clearConsole(): void {
clear();
}
npm 包发布
准备
如果是发布到 npm,需要创建一个账号
修改 package.json 的如下内容
{
"name": "@lbingxin/ts-cli",
"version": "1.0.0",
"main": "./lib/index.js",
"keywords": [
"typescript",
"cli",
"typescript 脚手架",
"ts 脚手架",
"ts-cli",
"脚手架"
],
"author": "LBINGXIN",
"files": ["package.json", "README.md", "lib"],
"repository": "https://github.com/LBINGXIN/ts-cli.git",
}
- name: 包名,在包名称前加自己的 npm 账户名,采用 npm scope 的方式,scope 对于公司项目的话,便于分类管理,对于个人包也是一样,同时也是避免跟别人包名重复问题。
- version:版本后,后续包的迭代,需要修改版本号
- main:表示包的入口位置
- keywords:关键字,方便别人搜索到你的包
- files:告诉 npm,publish 时发布哪些包到 npm 仓库
- repository:项目仓库
一个好的文档也是必不可少的,所以需要完善README.md
# ts-cli
## 简介
创建一个支持 ts, 代码规范和 git 提交规范的 cli 基础项目
## 安装依赖
### npm 方式
npm i -g @lbingxin/ts-cli
### yarn 方式
yarn global add @lbingxin/ts-cli
## 使用方式
### 查看版本
ts-cli -v
### 创建项目
`<app-name>` 为自定义属性,根据自己的项目名称而定,比如 `my-project`
ts-cli create <app-name>
设置npm镜像源
要发布包到npm上,需要保证当前的镜像源是https://registry.npmjs.org,如果不是的话,会报403错误,通过以下方式查看和设置npm镜像源
查看npm镜像源
npm config get registry
设置npm镜像源
npm config set registry https://registry.npmjs.org
发布
发布时,需要先执行 npm login 登录,根据提示输入账号,密码,邮箱,以及发送到邮箱的验证码,即可完成登录
npm login
执行npm publish --access public,发布包,我们加上属性--access public的原因是因为我们是npm scope私有作用域包,所以需要添加上这个属性,发布成功,就能在npm上查看到自己的包
npm publish --access public
取消发布
如果发布失败,由于npm不支持相同版本号发布,所以有两种选择,1.修改当前项目的版本号,2.撤销发布
根据提示执行取消发布,但还是每次提交前,保证包的正确性,减少bug是切图仔的传统美德
注意:npm规定 上传一个包后可以在24h内删除,删除一个包后在24h内不能再次上传相同包,即版本号不能相同
npm unpublish [<@scope>/]<pkg>@<version>
git打tag
为什么需要打tag呢,可能对于一些没有维护过npm包的同学会有点困惑,但是保持git的tag和npm包版本号一致,是很重要的一件事,对于后续的版本维护有很大的帮助。
运行 git tag -a v1.0.0 -m 备注
git tag -a v1.0.0 -m "ts-cli基础功能"
在VS Code左侧的TAGS可以看到刚才创建的tag
运行git push --tags,推送当前tag到git远程
git push --tags
可以在Github发现,tag已经上传成功了
小结
到这里相信大家都能实现一个自己的 cli 了,对应的代码也发布到 Github,cli 也发布到npm
如果觉得这篇文章对您有帮助,欢迎点赞收藏,有问题也欢迎指出。