1. 实现原理
把每个 template 单独发包 然后在通过命令来下载不同的包
- 通过 registry.npmjs.org/@vue/cli 可以拿到 npm 包的信息
- 比如 dist-tags.latest 可以拿到最新版本号
- 选择一个模版后,从 npm 包下载模版,这里就直接用 npm install 下载就行
2. 用到的包介绍
2.1 fs-extra
读取文件目录的操作
const fse = require('fs-extra')
//copy文件
fse.copySync('./src', './aaa/bbb/')
2.2 glob
通配符匹配某个目录下的文件
const { glob } = require('glob');
async function main() {
const files = await glob('**', {
cwd: process.cwd(),
ignore: 'node_modules/**'
})
console.log(files);
}
main();
匹配当前目录下除了 node_modules 下的所有文件和目录。
2.3 inquirer
在命令行提问的一个插件 结果成对象形式
import { input, select, password } from '@inquirer/prompts'
const name = await input({ message: '请输入你的名字' })
const job = await select({
message: '选择你的职业',
choices: [
{
name: '教师',
value: '教师',
description: '11111'
},
{
name: '医生',
value: '医生',
description: '22222'
}
]
})
const pass = await password({ message: '请输入密码' })
console.log({
name,
job,
pass
})
2.4 semver
npm 包的版本号的规范
- 1.2.3 分别是 major 主版本号、minor 次版本号、patch 修订版本号
- major:当你做的不兼容的 api 修改时,改这个版本号
- minor:当你做了向下兼容的功能新增时,改这个版本号
- patch:当你做了向下兼容的问题修复时,改这个版本号
const semver = require('semver')
if (semver.valid('1.2.3#')) {
console.log('版本号有效')
} else {
console.log('版本号无效')
}
if (semver.gt('2.0.0', '1.0.8')) {
console.log('有新版本可以安装')
}
if (semver.lte(process.version, '22.0.0')) {
console.log(`node 版本 ${process.version} 小于 22`)
}
2.5 npminstall
从 npm 仓库下载模版到本地的临时目录,这时候就可以用 npminstall 这个包来下载:
const npminstall = require('npminstall');
(async () => {
await npminstall({
pkgs: [
{ name: 'chalk', version: 'latest' },
],
root: process.cwd() + '/aaa',
registry: 'https://registry.npmjs.org',
});
})().catch(err => {
console.error(err);
});
安装 chalk 最新版本到 aaa 目录下。
2.6 cli-spinner、ora
网络请求、文件写入等耗时逻辑需要展示一个 loading, cli-spinner 就是 cli 里的 loading
const Spinner = require('cli-spinner').Spinner
console.log('111')
console.log('222')
const spinner = new Spinner(`安装中.. %s`)
spinner.start()
setTimeout(() => {
spinner.stop(true)
}, 3000)
3 .操作
3.1 项目结构目录
├── package.json
├── packages
│ ├── cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── create
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── template-react
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── questions.json
│ │ └── template
│ ├── template-vue
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── questions.json
│ │ └── template
│ └── utils
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ ├── NpmPackage.ts
│ │ └── versionUtils.ts
│ └── tsconfig.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml
3.2 获取版本信息和npm的包
utils提供一些操作的npm 的方法
import fs from 'node:fs'
import fse from 'fs-extra'
// @ts-ignore
import npminstall from 'npminstall'
import { getLatestVersion, getNpmRegistry } from './versionUtils.js'
import path from 'node:path'
export interface NpmPackageOptions {
name: string
targetPath: string
}
class NpmPackage {
name: string // 包名
version: string = '' // 版本,初始为空
targetPath: string // 目标路径
storePath: string // 存储路径
constructor(options: NpmPackageOptions) {
this.targetPath = options.targetPath
this.name = options.name
// 设置存储路径为目标路径下的node_modules
this.storePath = path.resolve(options.targetPath, 'node_modules')
}
// 准备工作:创建目录并获取最新版本号
async prepare() {
if (!fs.existsSync(this.targetPath)) {
fse.mkdirpSync(this.targetPath) // 递归创建目录
}
const version = await getLatestVersion(this.name)
this.version = version
}
async install() {
await this.prepare()
return npminstall({
pkgs: [
{
name: this.name,
version: this.version
}
],
registry: getNpmRegistry(), // 获取npm镜像地址
root: this.targetPath // 安装到目标路径
})
}
get npmFilePath() {
// 格式:.store/@scope+package@version/node_modules/@scope/package
// 正确格式:@scope+package@version
if (!this.version) {
throw new Error('版本号未初始化,请先调用prepare()')
}
const storeDir = `${this.name.replace('/', '+')}@${this.version}`
return path.resolve(this.storePath, `.store/${storeDir}/node_modules/${this.name}`)
}
async exists() {
await this.prepare()
return fs.existsSync(this.npmFilePath)
}
async getPackageJSON() {
if (await this.exists()) {
return fse.readJsonSync(path.resolve(this.npmFilePath, 'package.json'))
}
return null
}
async getLatestVersion() {
return getLatestVersion(this.name)
}
async update() {
const latestVersion = await this.getLatestVersion()
return npminstall({
root: this.targetPath,
registry: getNpmRegistry(),
pkgs: [
{
name: this.name,
version: latestVersion
}
]
})
}
}
export default NpmPackage
function getNpmRegistry() {
return 'https://registry.npmmirror.com'
}
async function getNpmInfo(packageName: string) {
const register = getNpmRegistry()
const url = urlJoin(register, packageName)
try {
const response = await axios.get(url)
if (response.status === 200) {
return response.data
}
} catch (e) {
return Promise.reject(e)
}
}
async function getLatestVersion(packageName: string) {
const data = await getNpmInfo(packageName)
return data['dist-tags'].latest
}
async function getVersions(packageName: string) {
const data = await getNpmInfo(packageName)
return Object.keys(data.versions)
}
export { getNpmRegistry, getNpmInfo, getLatestVersion, getVersions }
- 从npm下载或更新选定的模板包 将模板文件复制到目标目录
3.2.1 获取包中的信息
- registry.npmmirror.com/create-vite 中的内容
- registry.npmmirror.com/ + 包名 可以拿到包中的一些版本信息 以及最新版本
- 我们可以用axios 调接口 来获取这个信息
3.2.2 获取npm包
npminstall可以并行下载和安装包
// 安装单个包
npminstall({
pkgs: [
{
name: this.name,
version: this.version
}
],
registry: getNpmRegistry(), // 获取npm镜像地址
root: this.targetPath // 安装到目标路径
})
//获取包的路径
get npmFilePath() {
// 格式:.store/@scope+package@version/node_modules/@scope/package
// 正确格式:@scope+package@version
if (!this.version) {
throw new Error('版本号未初始化,请先调用prepare()')
}
const storeDir = `${this.name.replace('/', '+')}@${this.version}`
return path.resolve(this.storePath, `.store/${storeDir}/node_modules/${this.name}`)
}
this.storePath:基础路径(通常是项目路径/node_modules).store/:npminstall的专用存储目录${storeDir}:格式化的包标识node_modules/${this.name}:包的实际安装路径
说明
const storeDir = `${this.name.replace('/', '+')}@${this.version}`
// @vue/cli -> @vue+cli@5.0.8
- 文件系统不支持
/在目录名中 +是安全的替代字符(不会在合法包名中出现)
| 特性 | 标准 npm/yarn | npminstall| |
|---|---|---|
| 存储位置 | 扁平化 node_modules | .store 目录 |
| 磁盘使用 | 可能重复占用空间 | 通过硬链接节省空间 |
| 依赖解析 | 提升依赖 | 隔离依赖 |
3.3 生成create 命令
import { select, input, confirm } from '@inquirer/prompts'
import os from 'node:os'
import { NpmPackage } from '@starry-sky-studio/my-utils'
import path from 'node:path'
import ora, { Ora } from 'ora'
import fse from 'fs-extra'
import { glob } from 'glob'
import ejs from 'ejs'
async function create() {
let spinner: Ora | null = null
try {
const projectTemplate = await select({
message: '请选择项目模版',
choices: [
{
name: 'react 项目',
value: '@starry-sky-studio/template-react-ts'
},
{
name: 'vue 项目',
value: '@starry-sky-studio/template-vue-ts'
}
]
})
let projectName = ''
while (!projectName) {
projectName = await input({ message: '请输入项目名' })
}
const targetPath = path.join(process.cwd(), projectName)
const pkg = new NpmPackage({
name: projectTemplate,
targetPath: path.join(os.homedir(), '.ivy-cli-template')
})
if (fse.existsSync(targetPath)) {
const empty = await confirm({ message: '该目录不为空,是否清空' })
if (empty) {
fse.emptyDirSync(targetPath)
} else {
console.log('🚫 操作已取消')
process.exit(0)
}
}
if (!(await pkg.exists())) {
spinner = ora('下载模版中...').start()
await pkg.install()
spinner.succeed('模版下载完成')
} else {
spinner = ora('更新模版中...').start()
await pkg.update()
spinner.succeed('模版更新完成')
}
spinner = ora('创建项目中...').start()
const templatePath = path.join(pkg.npmFilePath, 'template')
fse.copySync(templatePath, targetPath)
spinner.succeed('项目文件复制完成')
// { projectName: 'name', eslint: true }
const renderData: Record<string, any> = { projectName }
const deleteFiles: string[] = []
const questionConfigPath = path.join(pkg.npmFilePath, 'questions.json')
if (fse.existsSync(questionConfigPath)) {
const config = fse.readJSONSync(questionConfigPath)
for (let key in config) {
const res = await confirm({ message: `是否启用 ${key}?` })
renderData[key] = res
if (!res) {
deleteFiles.push(...config[key].files)
}
}
}
spinner = ora('应用项目配置...').start()
const files = await glob('**', {
cwd: targetPath,
nodir: true,
ignore: 'node_modules/**'
})
for (let i = 0; i < files.length; i++) {
const filePath = path.join(targetPath, files[i])
const renderResult = await ejs.renderFile(filePath, renderData)
fse.writeFileSync(filePath, renderResult)
}
deleteFiles.forEach((item) => {
fse.removeSync(path.join(targetPath, item))
})
spinner.succeed('项目配置完成')
console.log(`\n🎉 项目创建成功:${targetPath}`)
} catch (error) {
// 处理所有错误
if (spinner) {
spinner.fail('操作失败')
}
process.exit(1)
}
}
create()
export default create
- 使用 inquirer 进行用户交互(选择模板、输入项目名)
- 检查目标目录是否存在,处理非空目录
- 使用封装的 NpmPackage 工具下载/更新项目模板
- 将模板复制到目标目录
- 使用 ejs 模板引擎渲染文件内容
- 根据用户选择添加/删除特定功能(如 ESLint
- 提供友好的进度提示和成功信息
-
检查模板包中是否存在
questions.json配置文件 读取这个文件内容{ "eslint": { "files": ["eslint.config.js"] } } //renderData { projectName: 'name', eslint: true } -
然后用glob来检查所有的项目文件
const files = await glob('**', {
cwd: targetPath,
nodir: true,
ignore: 'node_modules/**'
})
for (let i = 0; i < files.length; i++) {
const filePath = path.join(targetPath, files[i])
const renderResult = await ejs.renderFile(filePath, renderData)
fse.writeFileSync(filePath, renderResult)
}
使用ejs 将文件内容重写 <%= projectName %> 使用这个来替换
//在模版项目中的package.json文件
{
"name": "<%= projectName %>",
<% if (eslint) { %>
"lint": "eslint .",
<% } %>
},
"devDependencies": {
<% if (eslint) { %>
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
<% } %>
}
}
//渲染文件重新写后的: /Users/rwr/repo/my-cli/packages/create/name/package.json
{
"name": "name",
"description": "name 项目模板",
"scripts": {
"lint": "eslint .",
},
"devDependencies": {
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
}
}
- 在渲染过程中,如果用户没有选择启用eslint,那么就会删除eslint相关的配置文件(包括eslint.config.js
3.4 cli入口
#!/usr/bin/env node
import create from '@starry-sky-studio/my-create'
import { Command } from 'commander'
import fse from 'fs-extra'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// 获取当前模块的路径
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const pkgJson = fse.readJSONSync(path.join(__dirname, '../package.json'))
// 检测是否通过 pnpm create 调用
const isCreateCommand =
process.argv[1] &&
(process.argv[1].includes('create-starry-sky-studio') ||
process.argv[1].includes('create-starry-sky-studio'))
const program = new Command()
program
.name('starry-sky-studio-my-cil')
.description('Starry Sky Studio 脚手架工具')
.version(pkgJson.version)
.configureOutput({
// 重写输出格式,添加更多信息
outputError: (str, write) => write(`❌ 错误: ${str}`)
})
// 处理 pnpm create 调用
if (isCreateCommand) {
create().catch((err) => {
console.error('项目创建失败:', err.message)
process.exit(1)
})
} else {
// 正常命令模式
program
.command('create')
.description('创建一个新项目')
.action(async () => {
try {
await create()
} catch (err: any) {
console.error('❌ 项目创建失败:', err.message)
process.exit(1)
}
})
// 添加帮助命令
program
.command('help')
.description('显示帮助信息')
.action(() => program.help())
// 添加退出处理
program.hook('preAction', () => {
setupExitHandlers()
})
// 解析命令行参数
program.parse(process.argv)
}
- 主要是一些脚手架的命令
starry-sky-studio-my-cil create 创建一个新项目
starry-sky-studio-my-cil help 显示帮助信息
starry-sky-studio-my-cil --version 显示版本信息
//package.json
"bin": {
"starry-sky-studio-my-cil": "./dist/index.js",
"create-starry-sky-studio": "./dist/index.js"
},
当用户全局安装你的 npm 包时,npm 会将这些命令链接到全局环境,使得用户可以直接在终端中运行这些命令。
🤔:
- 主要是 Not Found - GET registry.npmjs.org/create-star… 原因 把create当成了包的名字
- 这个可以当创建包的时候名字可以改成 create-名字 比如create-vite
# 安装
pnpm add -g @starry-sky-studio/my-cli
# 创建项目
starry-sky-studio-my-cil create
4 管理版本信息changeset
npx changeset add// 将改变的文件添加到changeset会自动更新CHANGELOG.md文件 也会让你选择升级的版本号
npx changeset publish //可以自动发包
5.总结
graph TD
A[开始创建项目] --> B[设置中断处理器]
B --> C[选择项目模板]
C --> D[输入项目名称]
D --> E[检查目标目录]
E --> F[下载/更新模板]
F --> G[复制模板文件]
G --> H[配置项目选项]
H --> I[渲染模板文件]
I --> J[完成创建]
J --> K[显示成功信息]
Z[用户按下 Ctrl+C] --> Y[显示取消信息]
Y --> X[退出程序]
代码仓库 - github.com/liv-rong/no…