从零开发一个脚手架

617 阅读6分钟

前言

一个普普通通的脚手架,包含部分对工程化,rollup的运用,以及一两个个人觉得挺好用的小功能

之前发过一次,但是因为图床用的vercel,没有xx不一定访问得了.....重新发一次。

源码

chovrio-cli

demo-cli

项目开发环境搭建

快速开发可以考虑试一试modernjs不用自己搭建环境

首先创建文件夹并用vscode打开

mkdir cli-demo # 创建项目文件夹
cd cli-demo # 进入项目文件夹
code . # 使用vscode打开(如果安装vscode的时候没配置环境变量可能会报错,手动打开也可)
pnpm init # 生成package.json文件
mkdir packages # 创建包文件夹(要采用monorepo的方式进行管理)
cd packages
mkdir chovrio # 创建脚手架文件夹
mkdir plugins # 创建插件文件夹
cd chovrio
pnpm init
cd ..
cd plugins 
pnpm init

开始搭建开发环境,首先我们要从一开始就明确技术栈,我这里采用的是 rollup + typescript 并且以monorepo的方式进行管理(虽然暂时没想要写plugin,具体原因文中应该会说到)。

工程化

eslint

pnpm add typescript @types/node -D
pnpm add eslint -D
npx eslint --init

下面是我的一些配置

image-20230312193314090.png

prettier

安装

pnpm add prettier eslint-config-prettier eslint-plugin-prettier -D

创建.prettierrc文件

// 一些配置,可自定义
{
  "semi": false,
  "tabWidth": 2,
  "trailingComma": "none",
  "singleQuote": true,
  "arrowParens": "avoid"
}

配置参考 prettier官网 掘金文章

git提交规范

pnpm add lint-staged husky -D
pnpm pkg set scripts.prepare="husky install"

创建.gitignore文件

node_modules
git init
pnpm prepare # 初始化husky
npx husky add .husky/pre-commit "npx lint-staged"

pre-commit 时执行 npx lint-staged 指令

根目录创建 .lintstagedrc.json 文件控制检查和操作方式

{
    "*.{js,jsx,ts,tsx}": ["prettier --write .", "eslint  --fix"],
    "*.md": ["prettier --write"]
}

git提交规范:commitlint

规范依赖

pnpm add commitlint @commitlint/config-conventional -D
npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'

默认是angular的提交规范,一般默认就够用了,可以自定义

辅助提交依赖

pnpm add commitizen cz-conventional-changelog -D
pnpm pkg set scripts.commit="git add . && git-cz" # package.json 中添加 commit 指令, 执行 `git-cz` 指令

创建文件commitlint.config.ts 这里采用默认规范

module.exports = {
  extends: ['@commitlint/config-conventional']
}

我们在packages/chovrio下新建index.ts

console.log("hello world")

此时我们运行命令pnpm commit

image-20230313144433340.png

更多详细配置的话可以去看看lint-stagedcommitlintcommitizen的文档

monorepo和ts

monorepo(虽然因为东西不多,monorepo没怎么用上,但还是先弄上)

在根目录新建文件pnpm-workspace.yaml

packages:
  - 'packages/*'

修改chovrioplugins下的package.jsonname分别为chovrio@chovrio/plugins

chovrioplugins文件夹下都运行npx tsc --init生成tsconfig.json文件

Rollup集成

我们就一步一步搭建吧

rollup配置文件

-w表示安装到workspace(根工作空间)然后项目的子包都能使用这个依赖(不知道这样理解有没有什么问题),在哪个包里面安装都会安装到根包

pnpm add rollup -wD
pnpm pkg set scripts.build="rollup --config rollup.config.ts --configPlugin typescript" # 配置插件 typescript
cd .\packages\chovrio\
mkdir src
cd src
mkdir core
mkdir utils
mkdir types
mkdir command

将之前测试git流程时在chovrio目录下的创建的index.ts文件移动到core目录里面。

chovrio目录下面创建rollup.config.ts

import path from 'path'
import { defineConfig } from 'rollup'

const cliConfig = defineConfig({
  // 入口文件
  input: './src/core/index.ts',
  // 输出目录
  output: {
    file: path.resolve(__dirname, './dist/cli.js')
  },
  // 用到的插件
  plugins: []
})
export default defineConfig([cliConfig])

此时我们运行pnpm build会发现不能识别rollup.config.ts

image-20230313151813786.png

我们修改tsconfig.json

{
  "extends": "./tsconfig.base.json",
  // 这里告诉typescript要编译rollup的配置文件
  "include": ["./rollup.config.ts"],
  "compilerOptions": {
    "esModuleInterop": true,
    "declaration": false,
    "resolveJsonModule": true
  }
}

新建文件tsconfig.base.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "declaration": true,
    "noImplicitOverride": true,
    "noUnusedLocals": true,
    "esModuleInterop": true,
    "useUnknownInCatchVariables": false
  }
}

这里选择在tsconfig.ts中集成tsconfig.base.json是为了减少代码量

运行pnpm build会发现报错,因为我们打包的是ts文件,rollup默认只能打包js文件,我们就需要一些插件来扩展rollup的功能

image-20230313152646512.png

打包ts文件

pnpm add @rollup/plugin-typescript -wD

安装后修改rollup.config.ts文件如下

import path from 'path'
import ts from '@rollup/plugin-typescript'
import { defineConfig } from 'rollup'

const cliConfig = defineConfig({
  // 入口文件
  input: './src/core/index.ts',
  // 输出目录
  output: {
    file: path.resolve(__dirname, './dist/cli.js')
  },
  // 用到的插件
  plugins: [ts({ tsconfig: path.resolve(__dirname, './tsconfig.json') })]
})
export default defineConfig([cliConfig])

再次运行pnpm build,会发现又报错了

image-20230313153729486.png

因为__dirnamecommonjs规范里面的,在esmodule中是不存在的,所以我们要用别的方式使用__dirname,一行就解决了

const __dirname = fileURLToPath(new URL('.', import.meta.url))

我想过直接用process.cwd()来代替__dirname,但是我发现用fileURLToPathURL的比较多,于是我去问了问bing🙃

image-20230313155711738.png

再次打包就能通过了

image-20230313155801693.png

压缩代码和读取json文件

我们再使用一个插件用来压缩打包后的代码

pnpm add @rollup/plugin-terser @rollup/plugin-json -wD
import path from 'path'
import { defineConfig } from 'rollup'
import ts from '@rollup/plugin-typescript'
import terser from '@rollup/plugin-terser'
import json from '@rollup/plugin-json'
import { fileURLToPath } from 'url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const cliConfig = defineConfig({
  // 入口文件
  input: './src/core/index.ts',
  // 输出目录
  output: {
    file: path.resolve(__dirname, './dist/cli.js')
  },
  // 用到的插件
  plugins: [
    ts({ tsconfig: path.resolve(__dirname, './tsconfig.json') }),
    terser({
      toplevel: true
    }),
    json()
  ]
})
export default defineConfig([cliConfig])
pnpm pkg set scripts.build:dev="rollup --config rollup.config.ts --configPlugin typescript --watch" # 开发阶段的打包命令

rollup的配置暂时就到这儿了

脚手架开发

脚手架开发的基础包挺多的,我这里选择的是commander,比较大众

注意这个命令是在chovrio目录下执行的没有加-w这个依赖是只有chovrio这个项目使用的,不用放在工作空间。

rollup的插件啥的放在工作空间是因为如果要开发其它包就可以直接在子包中使用,不用再次安装依赖

pnpm add commander -D
pnpm build:dev

我们先不使用commander,我们先让我们的命令执行起来

新增chovrio/bin/demo.js如下

#!/usr/bin/env node
function start() {
  return import('../dist/cli.js')
}
start()

package.json中增加框选代码

image-20230313162456162.png

执行npm link这样可以把bin中的命令配置到环境变量中,相当于每次我们在命令行中输入demo,都会链接到demo.js这个文件,并且去执行它,但是js文件在命令行中并不能直接执行,需要node环境#!/usr/bin/env node

image-20230313161925403.png

实际上我们每次运用包管理工具的时候都会自动去检测package.json中是否有bin这个配置,如果有会自动执行类似npm link的命令将命令写入环境变量。

我们先npm link再执行demo 注意,得在脚手架文件夹下面执行

image-20230313162537117.png

脚手架功能的添加就很千奇百怪了,我这里就写两个个人觉得稍微有一点意思的功能吧。

  • 脚手架配置文件的一种实现思路
  • 部署文件到服务器

主文件

这里就直接cv了,很简单,因为想要集成插件(虽然还没写),所以我写成的一个类,更加直观

// chovrio/core/index.ts
import { Command, program } from 'commander'
import { version } from '../../package.json'
class Chovrio {
  program: Command
  commands: Array<(program: Command) => void>
  constructor() {
    this.program = program
    this.commands = []
    this.init()
    this.program.option('-v, --version').action(() => {
      console.log(version)
    })
    this.program.parse()
  }
  init() {
    this.commands.forEach(command => {
      command(program)
    })
  }
}
const cli = new Chovrio()
cli.program.version(version)

脚手架配置文件

目前就只能是demo.config.js文件,后续可能会支持ts文件,注意文件位置得是命令执行的路径

新建utils/parse.ts

import fs from 'fs'
import type { Config } from '../types'
// 获得模板文件并解析成对象
export default function parse() {
  const config = fs
    .readFileSync(`${process.cwd()}/demo.config.js`, 'utf-8')
    .replace(/[\s|\n]/g, '')
  const reg = /\{(.*)\}/
  if (reg.test(config)) {
    const arr = reg.exec(config)
    if (arr !== null) {
      const config: Config = new Function('return ' + arr[0])()
      return config
    }
  }
}

新建utils/__dirname

import { fileURLToPath } from 'url'
export const __dirname = fileURLToPath(new URL('.', import.meta.url))

新建types/config.ts

import type { RollupOptions } from 'rollup'
export interface Config {
  rollupOptions?: RollupOptions
  deploy?: {
    position: string
  }
}

新建types/index.ts

export * from './config'

这里类型都在一个文件中导出是为了后面方便打包成类型声明文件

新建command/deploy.ts

import parse from '../utils/parse'
import type { Command } from 'commander'
export default function deploy(program: Command) {
  program
    .command('deploy')
    .description('deploy a project to server')
    .action(async () => {
      const config = parse()
      console.log(config)
    })
}

修改core/index.ts

import { Command, program } from 'commander'
import { version } from '../../package.json'
import deploy from '../command/deploy'
class Chovrio {
  program: Command
  commands: Array<(program: Command) => void>
  constructor() {
    this.program = program
    this.commands = [deploy]
    this.init()
    this.program.option('-v, --version').action(() => {
      console.log(version)
    })
    this.program.parse()
  }
  init() {
    this.commands.forEach(command => {
      command(program)
    })
  }
}
const cli = new Chovrio()
cli.program.version(version)

创建配置文件demo.config.js

export default {
  name: 'chovrio',
  age: 19
}

执行命令demo deploy可以看到

image-20230313185051977.png

目前这种字符串转对象的方式只能解析js文件,后续应该会改成别的方法用来支持ts文件,不过目前的效果esmodule和commonjs规范的导出都可以解析

部署文件到服务器

在上一步我们已经创建好了deploy命令

先说说为什么要写这个部署文件的命令吧,因为我的服务器跑ci/co有点....,嗯,又不想每次都手动上传。

我们通过node-ssh连接远程服务器

pnpm add node-ssh

又因为连接远程服务器肯定要用到端口号用户名和密码,你也不想自己的信息被暴露吧😈,所以我们要用到dotenv

pnpm add dotenv

在你准备执行脚手架命令的同级目录下创建.env文件

host=xxxxx # 端口号
user=xxxxx # 用户名 这里没用 username的原因是我本地默认用户名会是 chovrio
password=xxxxx # 密码

新建utils/env.ts

import * as dotenv from 'dotenv'
dotenv.config()
export default process.env

安装两个美化命令行的工具

pnpm add ora picocolors

ora可以让我们loading的时候好看一些,picocolors可以改变输出的文字颜色

修改commander/deploy

import parse from '../utils/parse'
import type { Command } from 'commander'
import ora from 'ora'
import pc from 'picocolors'
import { NodeSSH } from 'node-ssh'
import env from '../utils/env'
export default function deploy(program: Command) {
  program
    // 命令
    .command('deploy')
    // 命令描述
    .description('deploy a project to server')
    // 解析到这个命令后 要执行的操作
    .action(async () => {
      const config = parse()
      const ssh = new NodeSSH()
      console.log(config)
      // 1.连接服务器
      // 1.1 存在.env文件且具有数据就读取文件连接
      if (env.user && env.password && env.host) {
        const connectSpinner = ora(pc.blue(`connect server...`)).start()
        await ssh.connect({
          host: env.host,
          username: env.user,
          password: env.password
        })
        connectSpinner.stop()
        console.log('服务器连接成功')
        // 退出终端
        process.exit(0)
      }
    })
}

现在我们执行demo deploy可以看到如下效果(这里没链接上时是有动画)

image-20230313191104485.png

我们现在再写另一种逻辑,就是用户觉得.env文件也不安全,万一不小心忘记忽略了上传到github后就尴尬了,于是新增一个未检测到env文件手动输入的功能,我们要借助一个包readline-sync,node原生的readline好像默认是异步会,和ora搭配在一起使用挺尬的。

pnpm add readline-sync
// deploy.ts
import parse from '../utils/parse'
import type { Command } from 'commander'
import ora from 'ora'
import pc from 'picocolors'
import { NodeSSH } from 'node-ssh'
import readline from 'readline-sync'
import env from '../utils/env'
export default function deploy(program: Command) {
  program
    // 命令
    .command('deploy')
    // 命令描述
    .description('deploy a project to server')
    // 解析到这个命令后 要执行的操作
    .action(async () => {
      const config = parse()
      const ssh = new NodeSSH()
      let host, username, password
      console.log(config)
      // 1.连接服务器
      // 1.1 存在.env文件且具有数据就读取文件连接
      if (env.user && env.password && env.host) {
        host = env.host
        username = env.user
        password = env.password
      } else {
        // 1.2 不存在就采取输入方式连接
        host = readline.question(`Your server ip address${pc.blue('(host)')}:`)
        username = readline.question(
          `The user name you want to log in to${pc.blue('(username)')}:`
        )
        password = readline.question(`Your password${pc.blue('(password)')}:`)
      }
      const connectSpinner = ora(pc.blue(`connect server...`)).start()
      try {
        await ssh.connect({
          host,
          username,
          password
        })
        console.log('数据库连接成功')
      } catch (err) {
        console.log('数据错误,连接失败', err.message)
      }
      connectSpinner.stop()
      process.exit(0)
    })
}

image-20230313191743218.png

这里后面肯定会报错的,连接不上。

这里连接数据库我们已经做好了,接下来就是上传文件到服务器了,

我们先删除原本文件夹(就不全文件复制了,不然太长了)

这里的config就是我们之前写的配置文件,文件里面的deploy.position就是我们要上传的位置

const deleteSpinner = ora(pc.blue(`delete folder...`)).start()
const remotePath = config?.deploy?.position || ''
await ssh.execCommand(`rm -rf ${remotePath}`)
deleteSpinner.stop()
console.log(pc.green('删除文件成功~'))

现在在我的服务器的/home/test/hahaha目录下有一个cli文件夹。我们要上传新文件到hahaha这个文件夹里面!

image-20230313192923456.png 这是我的配置文件

// demo.config.js
export default {
  deploy: {
    position: '/home/test/hahaha'
  }
}

此时执行 demo deploy

image-20230313193535742.png

最上面的对象是我们打印的config,服务器上的hahaha文件夹已经没了

image-20230313193611741.png

我们最后再上传程序执行位置下的dist文件夹上去

// 3.上传文件
const uploadSpinner = ora(pc.blue(`upload folder...`)).start()
const status = await ssh.putDirectory(process.cwd() + '/dist', remotePath, {
  recursive: true,
  concurrency: 10
})
uploadSpinner.stop()
if (status) {
  console.log(pc.green('文件上传服务器成功~'))
} else {
  console.log(pc.red('文件上传服务器失败'))
}
process.exit(0)

整个文件deploy文件

// deploy.ts
import parse from '../utils/parse'
import type { Command } from 'commander'
import ora from 'ora'
import pc from 'picocolors'
import { NodeSSH } from 'node-ssh'
import readline from 'readline-sync'
import env from '../utils/env'
export default function deploy(program: Command) {
  program
    // 命令
    .command('deploy')
    // 命令描述
    .description('deploy a project to server')
    // 解析到这个命令后 要执行的操作
    .action(async () => {
      const config = parse()
      const ssh = new NodeSSH()
      let host, username, password
      // 1.连接服务器
      // 1.1 存在.env文件且具有数据就读取文件连接
      if (env.user && env.password && env.host) {
        host = env.host
        username = env.user
        password = env.password
      } else {
        // 1.2 不存在就采取输入方式连接
        host = readline.question(`Your server ip address${pc.blue('(host)')}:`)
        username = readline.question(
          `The user name you want to log in to${pc.blue('(username)')}:`
        )
        password = readline.question(`Your password${pc.blue('(password)')}:`)
      }
      const connectSpinner = ora(pc.blue(`connect server...`)).start()
      try {
        await ssh.connect({
          host,
          username,
          password
        })
        connectSpinner.stop()
        console.log(pc.green('服务器连接成功~'))
      } catch (err) {
        connectSpinner.stop()
        console.log(pc.red('数据错误,连接失败' + err.message))
      }
      // 2.删除原有文件夹内容
      const deleteSpinner = ora(pc.blue(`delete folder...`)).start()
      const remotePath = config?.deploy?.position || ''
      await ssh.execCommand(`rm -rf ${remotePath}`)
      deleteSpinner.stop()
      console.log(pc.green('删除文件成功~'))
      // 3.上传文件
      const uploadSpinner = ora(pc.blue(`upload folder...`)).start()
      const status = await ssh.putDirectory(
        process.cwd() + '/dist',
        remotePath,
        {
          recursive: true,
          concurrency: 10
        }
      )
      uploadSpinner.stop()
      if (status) {
        console.log(pc.green('文件上传服务器成功~'))
      } else {
        console.log(pc.red('文件上传服务器失败'))
      }
      process.exit(0)
    })
}

再次执行demo deploy

image-20230313193945895.png

image-20230313194028137.png

此时上传文件的逻辑就已经完全实现了。

此时我们执行pnpm build会报错

image-20230313194848537.png

不能识别到ts文件,我们的plugin中读取的是chovrio/tsconfig.js

我们修改它如下,就加入了主文件到include里面

{
  "extends": "./tsconfig.base.json",
  "include": ["./rollup.config.ts", "./src/core/index.ts"],
  "compilerOptions": {
    "esModuleInterop": true,
    "declaration": false,
    "resolveJsonModule": true
  }
}

我们再次执行打包会发现存在依赖关系的问题

image-20230313195301266.png

暂时没想到这个问题怎么解决,跟着文档走也没解决,不过开发后是没问题的直接上传bin和dist目录发包是能用的