基于 node-ssh 实现前后台项目自动部署

前言

为了方便自己的前后台项目的部署,而无需每次都通过 xshell 或者其他工具来手动上传文件,过程相对复杂,且容易出错。因此决定自己开发一个自动化部署工具。同时为了方便之后每个项目都能方便的使用,而不是将这个项目上实现的部署代码拷贝到另一个项目上使用,所以需要将该项目作为脚手架的形式做成一个 npm 包,方便其他项目使用。

自动化部署方案

当今市面上自动部署的方案有 Jenkins、GitLab 等等。其中 Jenkins 适合需要高度定制和复杂集成的场景,而 GitLab 则适合希望在一个平台上完成整个开发、测试和部署流程的团队。

而我们项目,只需要将前端代码,即 dist 文件,丢到 nginx 上,同时将服务代码丢到服务器上就行了,因此不需要搞一套复杂的发布流程,因此决定选择使用以 node-ssh 这个库为基础,实现自动化部署。

什么是 node-ssh

node-ssh 是一个基于 Node.js 的模块,它提供了通过 SSH 连接到远程服务器并执行命令的功能。这使得我们可以在 Node.js 环境下轻松地实现对远程服务器的操作,如文件传输、命令执行等。

基于 node-ssh 实现自动化部署的流程

收集服务器及项目发布信息

通过 commander 来解析命令行参数:

js 复制代码
#!/usr/bin/env node

import { program } from 'commander'; // 解析命令行参
import chalk from 'chalk'; // 终端标题美化
import { updateVersion } from '@ci/utils';
import { publish, Options } from '@ci/publish';
import pkg from './package.json';

program.version(updateVersion(pkg.version), '-v, --version');

program
  .name('dnhyxc-ci')
  .description('自动部署工具')
  .usage('<command> [options]')
  .on('--help', () => {
    console.log(`\r\nRun ${chalk.cyan('dnhyxc-ci <command> --help')} for detailed usage of given command\r\n`);
  });

const publishCallback = async (name: string, options: Options) => {
  await publish(name, options);
};

program
  .command('publish <name>')
  .description('项目部署')
  .option('-h, --host [host]', '输入host')
  .option('-p, --port [port]', '输入端口号')
  .option('-u, --username [username]', '输入用户名')
  .option('-m, --password [password]', '输入密码')
  .option('-l, --lcalFilePath [lcalFilePath]', '输入本地文件路径')
  .option('-r, --remoteFilePath [remoteFilePath]', '输入服务器目标文件路径')
  .option('-i, --install', '是否需要安装依赖')
  .action(publishCallback);

// 必须写在所有的 program 语句之后,否则上述 program 语句不会执行
program.parse(process.argv);

通过 require 导入用户需要发布的项目根目录下的 publish.config.js 中的发布配置,如果没有配置,那么就需要用户手动输入配置信息,这样可能会增加用户的操作复杂度,同时容错率也会降低,因此建议提前在项目根目录下配置好:

js 复制代码
const getPublishConfig = () => {
  try {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    const config = require(`${process.cwd()}/publish.config.js`);
    return config;
  } catch (error) {
    console.log(
      beautyLog.warning,
      chalk.yellowBright('当前项目根目录下未配置 publish.config.js 文件,需要手动输入配置信息')
    );
    return null;
  }
};

publish.config.js 发布配置示例:

js 复制代码
module.exports = {
  // 服务器配置
  serverInfo: {
    // 目标服务器IP
    host: '110.69.29.12',
    // 目标服务器用户名
    username: 'root',
    // 端口号
    port: 22
  },
  // 项目配置
  porjectInfo: {
    // 前台项目1配置
    dnhyxc: {
      name: 'dnhyxc',
      // 本地项目路径
      localFilePath: '/Users/dnhyxc/Documents/code/dnhyxc',
      // 目标服务器项目文件路径
      remoteFilePath: '/usr/local/nginx/dnhyxc',
      // 标识是否是服务端项目
      isServer: false
    },
    // 前台项目2配置
    blogClientWeb: {
      name: 'html',
      // 本地项目路径
      localFilePath: '/Users/dnhyxc/Documents/code/blog-client-web',
      // 目标服务器项目文件路径
      remoteFilePath: '/usr/local/nginx/html',
      // 标识是否是服务端项目
      isServer: false
    },
    blogAdminWeb: {
      name: 'admin_html',
      // 本地项目路径
      localFilePath: '/Users/dnhyxc/Documents/code/blog-admin-web',
      // 目标服务器项目文件路径
      remoteFilePath: '/usr/local/nginx/html_admin',
      // 标识是否是服务端项目
      isServer: false
    },
    blogServerWeb: {
      name: 'server',
      // 本地项目路径
      localFilePath: '/Users/dnhyxc/Documents/code/blog-server-web',
      // 目标服务器项目文件路径
      remoteFilePath: '/usr/local/server',
      // 标识是否是服务端项目
      isServer: true
    }
  }
};

通过 prompts 收集用户输入的服务器地址、用户名、密码等信息:

js 复制代码
import path from 'node:path';
import prompts from 'prompts';
import archiver from 'archiver';
import chalk from 'chalk';
import ora from 'ora';
import { NodeSSH } from 'node-ssh';
import { beautyLog } from './utils';

export interface Options {
  host: string;
  port: string;
  username: string;
  password: string;
  localFilePath: string;
  remoteFilePath: string;
  install: boolean;
  isServer: boolean;
}

let result: Partial<Options> = {};

const ssh = new NodeSSH();

export const publish = async (projectName: string, options: Options) => {
  const {
    host: _host,
    port: _port,
    username: _username,
    password: _password,
    localFilePath: _localFilePath,
    remoteFilePath: _remoteFilePath,
    install: _install
  } = options;

  // 读取发布项目根目录下的发布配置
  const publishConfig = getPublishConfig();

  const getRemoteFilePath = () => {
    if (publishConfig?.porjectInfo[projectName]) {
      return publishConfig?.porjectInfo[projectName]?.remoteFilePath;
    } else {
      return '';
    }
  };

  const getInstallStatus = (isServer: boolean) => {
    return !!(_install || (publishConfig ? !publishConfig?.porjectInfo[projectName]?.isServer : !isServer));
  };

  try {
    result = await prompts(
      [
        {
          name: 'host',
          type: _host ? null : 'text',
          message: 'host:',
          initial: publishConfig?.serverInfo?.host || '',
          validate: (value) => (value ? true : '请输入host')
        },
        {
          name: 'port',
          type: _port ? null : 'text',
          message: '端口号:',
          initial: publishConfig?.serverInfo?.port || '',
          validate: (value) => (value ? true : '请输入端口号')
        },
        {
          name: 'localFilePath',
          type: _localFilePath ? null : 'text',
          message: '本地项目文件路径:',
          initial: process.cwd(),
          validate: (value) => (value ? true : '请输入本地项目文件路径')
        },
        {
          name: 'remoteFilePath',
          type: _remoteFilePath ? null : 'text',
          message: '目标服务器项目文件路径:',
          initial: getRemoteFilePath() || '',
          validate: (value) => (value ? true : '请输入目标服务器项目文件路径')
        },
        {
          name: 'isServer',
          type: _install || getRemoteFilePath() ? null : 'toggle',
          message: '是否是后台服务:',
          initial: false,
          active: 'yes',
          inactive: 'no'
        },
        {
          name: 'install',
          type: (_, values) => (getInstallStatus(values.isServer) ? null : 'toggle'),
          message: '是否安装依赖:',
          initial: false,
          active: 'yes',
          inactive: 'no'
        },
        {
          name: 'username',
          type: _username ? null : 'text',
          message: '用户名称:',
          initial: publishConfig?.serverInfo?.username || '',
          validate: (value) => (value ? true : '请输入用户名称')
        },
        {
          name: 'password',
          type: _password ? null : 'password',
          message: '密码:',
          validate: (value) => (value ? true : '请输入密码')
        }
      ],
      {
        onCancel: () => {
          throw new Error('User cancelled');
        }
      }
    );
  } catch (cancelled) {
    process.exit(1);
  }

  const { host, port, username, password, localFilePath, remoteFilePath, install } = result;

  await onPublish({
    host: host || _host,
    port: port || _port,
    username: username || _username,
    password: password || _password,
    localFilePath: localFilePath || _localFilePath,
    remoteFilePath: remoteFilePath || _remoteFilePath,
    install: install || _install,
    projectName,
    publishConfig
  });
};

连接服务器

收集到信息之后,通过 node-ssh 连接到远程服务器,具体实现方式如下:

js 复制代码
const onConnectServer = async ({
  host,
  port,
  username,
  password
}: Pick<Options, 'host' | 'port' | 'username' | 'password'>) => {
  const spinner = ora({
    text: chalk.yellowBright(chalk.cyan(`正在连接服务器: ${username}@${host}:${port} ...`))
  }).start();
  try {
    // 连接到服务器
    await ssh.connect({
      host,
      username,
      port,
      password,
      tryKeyboard: true
    });
    spinner.succeed(chalk.greenBright('服务器连接成功!!!'));
  } catch (err) {
    spinner.fail(chalk.redBright(`服务器连接失败: ${err}`));
    process.exit(1);
  }
};

压缩文件

服务器连接成功之后,再通过 archiver 插件实现对本地项目打包好的 dist 文件进行压缩。以便上传到服务器上。其中需要区分是前台项目还是后台 node 服务端项目,前台只需要将项目打包出的 dist 文件进行打包即可,而后台项目是将项目目录下的 src、package.json 等文件打包成一个压缩包,然后上传到服务器上。具体实现如下:

  • 打包前台项目:
js 复制代码
// localFilePath:本地项目的文件路径 /Users/dnhyxc/Documents/code/blog-client-web
const onCompressFile = async (localFilePath: string) => {
  return new Promise((resolve, reject) => {
    const spinner = ora({
      text: chalk.yellowBright(`正在压缩文件: ${chalk.cyan(`${localFilePath}/dist`)}`)
    }).start();
    const archive = archiver('zip', {
      zlib: { level: 9 }
    }).on('error', (err: Error) => {
      console.log(beautyLog.error, chalk.red(`压缩文件失败: ${err}`));
    });
    const output = fs.createWriteStream(`${localFilePath}/dist.zip`);
    output.on('close', (err: Error) => {
      if (err) {
        spinner.fail(chalk.redBright(`压缩文件: ${chalk.cyan(`${localFilePath}/dist`)} 失败`));
        console.log(beautyLog.error, chalk.red(`压缩文件失败: ${err}`));
        reject(err);
        process.exit(1);
      }
      spinner.succeed(chalk.greenBright(`压缩文件: ${chalk.cyan(`${localFilePath}/dist`)} 成功`));
      resolve(1);
    });
    archive.pipe(output);
    // 第二参数表示在压缩包中创建 dist 目录,将压缩内容放在 dist 目录下,而不是散列到压缩包的根目录
    archive.directory(`${localFilePath}/dist`, '/dist');
    archive.finalize();
  });
};
  • 压缩后台服务项目:
js 复制代码
// localFilePath:本地项目的文件路径 /Users/dnhyxc/Documents/code/blog-client-web
const onCompressServiceFile = async (localFilePath: string) => {
  return new Promise((resolve, reject) => {
    const spinner = ora({
      text: chalk.yellowBright(`正在压缩文件: ${chalk.cyan(`${localFilePath}/dist`)}`)
    }).start();
    const srcPath = `${localFilePath}/src`;
    const uploadPath = `${srcPath}/upload`;
    const tempUploadPath = `${localFilePath}/upload`;
    fs.moveSync(uploadPath, tempUploadPath, { overwrite: true });
    const archive = archiver('zip', {
      zlib: { level: 9 }
    }).on('error', (err: Error) => {
      console.log(beautyLog.error, chalk.red(`压缩文件失败: ${err}`));
    });
    const output = fs.createWriteStream(`${localFilePath}/dist.zip`);
    output.on('close', (err: Error) => {
      if (!err) {
        fs.moveSync(tempUploadPath, uploadPath, { overwrite: true });
        spinner.succeed(chalk.greenBright(`压缩文件: ${chalk.cyan(`${localFilePath}/src`)} 等文件成功`));
        resolve(1);
      } else {
        spinner.fail(chalk.redBright(`压缩文件: ${chalk.cyan(`${localFilePath}/src`)} 等文件失败`));
        console.log(beautyLog.error, chalk.red(`压缩文件失败: ${err}`));
        reject(err);
        process.exit(1);
      }
    });
    archive.pipe(output);
    archive.directory(`${localFilePath}/src`, '/src');
    archive.file(path.join(localFilePath, 'package.json'), { name: 'package.json' });
    archive.file(path.join(localFilePath, 'yarn.lock'), { name: 'yarn.lock' });
    archive.finalize();
  });
};

上传文件

文件打包完成之后,再通过 node-ssh 上传打包好的文件到远程服务器的指定目录。

js 复制代码
// localFilePath:本地项目的文件路径 /Users/dnhyxc/Documents/code/blog-client-web
// remoteFilePath:远程服务器上的项目路径 /usr/local/nginx/html
const onPutFile = async (localFilePath: string, remoteFilePath: string) => {
  try {
    // 通过 cliProgress 显示上传进度条
    const progressBar = new cliProgress.SingleBar({
      format: '文件上传中: {bar} | {percentage}% | ETA: {eta}s | {value}MB / {total}MB',
      barCompleteChar: '\u2588',
      barIncompleteChar: '\u2591',
      hideCursor: true
    });
    const localFile = path.resolve(__dirname, `${localFilePath}/dist.zip`);
    const remotePath = path.join(remoteFilePath, path.basename(localFile));
    const stats = fs.statSync(localFile);
    const fileSize = stats.size;
    progressBar.start(Math.ceil(fileSize / 1024 / 1024), 0);
    // 上传文件
    await ssh.putFile(localFile, remotePath, null, {
      concurrency: 10, // 控制上传的并发数
      chunkSize: 16384, // 指定每个数据块的大小,适应慢速连接 16kb
      step: (totalTransferred: number) => {
        progressBar.update(Math.ceil(totalTransferred / 1024 / 1024));
      }
    });
    progressBar.stop();
  } catch (error) {
    console.log(beautyLog.error, chalk.red(`上传文件失败: ${error}`));
    process.exit(1);
  }
};

删除服务器上的 dist 文件

当文件上传完毕之后,删除服务器指定目录上的 dist 文件,以便解压服务器上刚上传的 dist.zip 文件。

js 复制代码
// localFile:本地项目的 dist 文件路径 /Users/dnhyxc/Documents/code/blog-client-web/dist
const onDeleteFile = async (localFile: string) => {
  const spinner = ora({
    text: chalk.yellowBright(`正在删除文件: ${chalk.cyan(localFile)}`)
  }).start();
  try {
    await ssh.execCommand(`rm -rf ${localFile}`);
    spinner.succeed(chalk.greenBright(`删除文件: ${chalk.cyan(`${localFile}`)} 成功`));
  } catch (err) {
    console.log(beautyLog.error, chalk.red(`Failed to delete dist folder: ${err}`));
    spinner.fail(chalk.redBright(`删除文件: ${chalk.cyan(`${localFile}`)} 失败`));
    process.exit(1);
  }
};

解压服务器上刚上传的 dist.zip 文件

当文件上传成功之后,解压服务器上刚上传的 dist.zip 文件到指定的目录下,解压完成之后,删除服务器上的 dist.zip 文件。

js 复制代码
// remotePath:远程服务器上的项目路径 /usr/local/server
const onUnzipZip = async (remotePath: string) => {
  const spinner = ora({
    text: chalk.yellowBright(`正在解压文件: ${chalk.cyan(`${remotePath}/dist.zip`)}`)
  }).start();
  try {
    await ssh.execCommand(`unzip -o ${`${remotePath}/dist.zip`} -d ${remotePath}`);
    spinner.succeed(chalk.greenBright(`解压文件: ${chalk.cyan(`${remotePath}/dist.zip`)} 成功`));
    await onDeleteFile(`${remotePath}/dist.zip`);
  } catch (err) {
    console.log(beautyLog.error, chalk.red(`Failed to unzip dist.zip: ${err}`));
    spinner.fail(chalk.redBright(`解压文件: ${chalk.cyan(`${remotePath}/dist.zip`)} 失败`));
    process.exit(1);
  }
};

删除本地 dist.zip 文件

当文件解压成功之后,删除本地的 dist.zip 文件。

js 复制代码
const onRemoveFile = async (localFile: string) => {
  const spinner = ora({
    text: chalk.yellowBright(`正在删除文件: ${chalk.cyan(localFile)}`)
  }).start();
  return new Promise((resolve, reject) => {
    try {
      const fullPath = path.resolve(localFile);
      // 删除文件
      fs.unlink(fullPath, (err) => {
        if (err === null) {
          spinner.succeed(chalk.greenBright(`删除文件: ${chalk.cyan(localFile)} 成功\n`));
          resolve(1);
        }
      });
    } catch (err) {
      console.error(chalk.red(`Failed to delete file ${localFile}: ${err}`));
      spinner.fail(chalk.redBright(`删除文件: ${chalk.cyan(localFile)} 失败`));
      reject(err);
      process.exit(1);
    }
  });
};

服务端项目安装依赖

如果发布的是 node 服务端项目,需要根据收集到的用户输入信息,判断是否需要安装依赖,如果需要则需要通过方式安装。

js 复制代码
const onInstall = async (remotePath: string) => {
  const spinner = ora({
    text: chalk.yellowBright(chalk.cyan('正在安装依赖...'))
  }).start();
  try {
    const { code, stdout, stderr } = await ssh.execCommand(`cd ${remotePath} && yarn install`);
    if (code === 0) {
      spinner.succeed(chalk.greenBright(`依赖安装成功: \n ${stdout} \n`));
    } else {
      spinner.fail(chalk.redBright(`依赖安装失败: ${stderr}`));
      process.exit(1);
    }
  } catch (error) {
    spinner.fail(chalk.redBright(`依赖安装失败: ${error}`));
    process.exit(1);
  }
};

重启 node 服务

如果发布的是 node 服务端项目,在文件解压完成及依赖安装完成之后,需要重启 node 服务,以便让新发布的功能生效,此时我们可以利用 node-ssh 及服务器上的 pm2 实现对服务的重启。

js 复制代码
const onRestartServer = async (remotePath: string) => {
  const spinner = ora({
    text: chalk.yellowBright(chalk.cyan('正在重启服务...'))
  }).start();
  try {
    const { code: deleteCode, stderr: deleteStderr } = await ssh.execCommand('pm2 delete 0');
    const { code: startCode, stderr: startStderr } = await ssh.execCommand(`pm2 start ${remotePath}/src/main.js`);
    const { code: listCode, stdout } = await ssh.execCommand('pm2 list');
    if (deleteCode === 0 && startCode === 0 && listCode === 0) {
      spinner.succeed(chalk.greenBright(`服务启动成功: \n ${stdout} \n`));
    } else {
      spinner.fail(chalk.redBright(`服务启动失败: ${deleteStderr || startStderr}`));
      process.exit(1);
    }
  } catch (error) {
    spinner.fail(chalk.redBright(`服务启动失败: ${error}`));
    process.exit(1);
  }
};

至此,如果控制台没有报错,那么就预示着项目已经成功发布了,此时就可以去浏览器上查看前台项目发布的内容是否生效了。

index.ts 完整代码

js 复制代码
#!/usr/bin/env node

import { program } from 'commander'; // 解析命令行参
import chalk from 'chalk'; // 终端标题美化
import { updateVersion } from '@ci/utils';
import { publish, Options } from '@ci/publish';
import pkg from './package.json';

program.version(updateVersion(pkg.version), '-v, --version');

program
  .name('dnhyxc-ci')
  .description('自动部署工具')
  .usage('<command> [options]')
  .on('--help', () => {
    console.log(`\r\nRun ${chalk.cyan('dnhyxc-ci <command> --help')} for detailed usage of given command\r\n`);
  });

const publishCallback = async (name: string, options: Options) => {
  await publish(name, options);
};

program
  .command('publish <name>')
  .description('项目部署')
  .option('-h, --host [host]', '输入host')
  .option('-p, --port [port]', '输入端口号')
  .option('-u, --username [username]', '输入用户名')
  .option('-m, --password [password]', '输入密码')
  .option('-l, --lcalFilePath [lcalFilePath]', '输入本地文件路径')
  .option('-r, --remoteFilePath [remoteFilePath]', '输入服务器目标文件路径')
  .option('-i, --install', '是否需要安装依赖')
  .action(publishCallback);

// 必须写在所有的 program 语句之后,否则上述 program 语句不会执行
program.parse(process.argv);

publish.ts 完整代码

js 复制代码
import path from 'node:path';
import fs from 'fs-extra';
import { NodeSSH } from 'node-ssh';
import prompts from 'prompts';
import cliProgress from 'cli-progress';
import archiver from 'archiver';
import chalk from 'chalk';
import ora from 'ora';
import { beautyLog } from './utils';

export interface Options {
  host: string;
  port: string;
  username: string;
  password: string;
  localFilePath: string;
  remoteFilePath: string;
  install: boolean;
  isServer: boolean;
}

let result: Partial<Options> = {};

const ssh = new NodeSSH();

const getPublishConfig = () => {
  try {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    const config = require(`${process.cwd()}/publish.config.js`);
    return config;
  } catch (error) {
    console.log(
      beautyLog.warning,
      chalk.yellowBright('当前项目根目录下未配置 publish.config.js 文件,需要手动输入配置信息')
    );
    return null;
  }
};

// 压缩dist
const onCompressFile = async (localFilePath: string) => {
  return new Promise((resolve, reject) => {
    const spinner = ora({
      text: chalk.yellowBright(`正在压缩文件: ${chalk.cyan(`${localFilePath}/dist`)}`)
    }).start();
    const archive = archiver('zip', {
      zlib: { level: 9 }
    }).on('error', (err: Error) => {
      console.log(beautyLog.error, chalk.red(`压缩文件失败: ${err}`));
    });
    const output = fs.createWriteStream(`${localFilePath}/dist.zip`);
    output.on('close', (err: Error) => {
      if (err) {
        spinner.fail(chalk.redBright(`压缩文件: ${chalk.cyan(`${localFilePath}/dist`)} 失败`));
        console.log(beautyLog.error, chalk.red(`压缩文件失败: ${err}`));
        reject(err);
        process.exit(1);
      }
      spinner.succeed(chalk.greenBright(`压缩文件: ${chalk.cyan(`${localFilePath}/dist`)} 成功`));
      resolve(1);
    });
    archive.pipe(output);
    // 第二参数表示在压缩包中创建 dist 目录,将压缩内容放在 dist 目录下,而不是散列到压缩包的根目录
    archive.directory(`${localFilePath}/dist`, '/dist');
    archive.finalize();
  });
};

// 压缩服务dist
const onCompressServiceFile = async (localFilePath: string) => {
  return new Promise((resolve, reject) => {
    const spinner = ora({
      text: chalk.yellowBright(`正在压缩文件: ${chalk.cyan(`${localFilePath}/dist`)}`)
    }).start();
    const srcPath = `${localFilePath}/src`;
    const uploadPath = `${srcPath}/upload`;
    const tempUploadPath = `${localFilePath}/upload`;
    fs.moveSync(uploadPath, tempUploadPath, { overwrite: true });
    const archive = archiver('zip', {
      zlib: { level: 9 }
    }).on('error', (err: Error) => {
      console.log(beautyLog.error, chalk.red(`压缩文件失败: ${err}`));
    });
    const output = fs.createWriteStream(`${localFilePath}/dist.zip`);
    output.on('close', (err: Error) => {
      if (!err) {
        fs.moveSync(tempUploadPath, uploadPath, { overwrite: true });
        spinner.succeed(chalk.greenBright(`压缩文件: ${chalk.cyan(`${localFilePath}/src`)} 等文件成功`));
        resolve(1);
      } else {
        spinner.fail(chalk.redBright(`压缩文件: ${chalk.cyan(`${localFilePath}/src`)} 等文件失败`));
        console.log(beautyLog.error, chalk.red(`压缩文件失败: ${err}`));
        reject(err);
        process.exit(1);
      }
    });
    archive.pipe(output);
    archive.directory(`${localFilePath}/src`, '/src');
    archive.file(path.join(localFilePath, 'package.json'), { name: 'package.json' });
    archive.file(path.join(localFilePath, 'yarn.lock'), { name: 'yarn.lock' });
    archive.finalize();
  });
};

// 上传文件
const onPutFile = async (localFilePath: string, remoteFilePath: string) => {
  try {
    const progressBar = new cliProgress.SingleBar({
      format: '文件上传中: {bar} | {percentage}% | ETA: {eta}s | {value}MB / {total}MB',
      barCompleteChar: '\u2588',
      barIncompleteChar: '\u2591',
      hideCursor: true
    });
    const localFile = path.resolve(__dirname, `${localFilePath}/dist.zip`);
    const remotePath = path.join(remoteFilePath, path.basename(localFile));
    const stats = fs.statSync(localFile);
    const fileSize = stats.size;
    progressBar.start(Math.ceil(fileSize / 1024 / 1024), 0);
    await ssh.putFile(localFile, remotePath, null, {
      concurrency: 10, // 控制上传的并发数
      chunkSize: 16384, // 指定每个数据块的大小,适应慢速连接 16kb
      step: (totalTransferred: number) => {
        progressBar.update(Math.ceil(totalTransferred / 1024 / 1024));
      }
    });
    progressBar.stop();
  } catch (error) {
    console.log(beautyLog.error, chalk.red(`上传文件失败: ${error}`));
    process.exit(1);
  }
};

// 删除文件
const onDeleteFile = async (localFile: string) => {
  const spinner = ora({
    text: chalk.yellowBright(`正在删除文件: ${chalk.cyan(localFile)}`)
  }).start();
  try {
    await ssh.execCommand(`rm -rf ${localFile}`);
    spinner.succeed(chalk.greenBright(`删除文件: ${chalk.cyan(`${localFile}`)} 成功`));
  } catch (err) {
    console.log(beautyLog.error, chalk.red(`Failed to delete dist folder: ${err}`));
    spinner.fail(chalk.redBright(`删除文件: ${chalk.cyan(`${localFile}`)} 失败`));
    process.exit(1);
  }
};

// 删除本地文件
const onRemoveFile = async (localFile: string) => {
  const spinner = ora({
    text: chalk.yellowBright(`正在删除文件: ${chalk.cyan(localFile)}`)
  }).start();
  return new Promise((resolve, reject) => {
    try {
      const fullPath = path.resolve(localFile);
      // 删除文件
      fs.unlink(fullPath, (err) => {
        if (err === null) {
          spinner.succeed(chalk.greenBright(`删除文件: ${chalk.cyan(localFile)} 成功\n`));
          resolve(1);
        }
      });
    } catch (err) {
      console.error(chalk.red(`Failed to delete file ${localFile}: ${err}`));
      spinner.fail(chalk.redBright(`删除文件: ${chalk.cyan(localFile)} 失败`));
      reject(err);
      process.exit(1);
    }
  });
};

// 解压文件
const onUnzipZip = async (remotePath: string) => {
  const spinner = ora({
    text: chalk.yellowBright(`正在解压文件: ${chalk.cyan(`${remotePath}/dist.zip`)}`)
  }).start();
  try {
    await ssh.execCommand(`unzip -o ${`${remotePath}/dist.zip`} -d ${remotePath}`);
    spinner.succeed(chalk.greenBright(`解压文件: ${chalk.cyan(`${remotePath}/dist.zip`)} 成功`));
    await onDeleteFile(`${remotePath}/dist.zip`);
  } catch (err) {
    console.log(beautyLog.error, chalk.red(`Failed to unzip dist.zip: ${err}`));
    spinner.fail(chalk.redBright(`解压文件: ${chalk.cyan(`${remotePath}/dist.zip`)} 失败`));
    process.exit(1);
  }
};

// 服务器安装依赖
const onInstall = async (remotePath: string) => {
  const spinner = ora({
    text: chalk.yellowBright(chalk.cyan('正在安装依赖...'))
  }).start();
  try {
    const { code, stdout, stderr } = await ssh.execCommand(`cd ${remotePath} && yarn install`);
    if (code === 0) {
      spinner.succeed(chalk.greenBright(`依赖安装成功: \n ${stdout} \n`));
    } else {
      spinner.fail(chalk.redBright(`依赖安装失败: ${stderr}`));
      process.exit(1);
    }
  } catch (error) {
    spinner.fail(chalk.redBright(`依赖安装失败: ${error}`));
    process.exit(1);
  }
};

// 重启后台项目
const onRestartServer = async (remotePath: string) => {
  const spinner = ora({
    text: chalk.yellowBright(chalk.cyan('正在重启服务...'))
  }).start();
  try {
    const { code: deleteCode, stderr: deleteStderr } = await ssh.execCommand('pm2 delete 0');
    const { code: startCode, stderr: startStderr } = await ssh.execCommand(`pm2 start ${remotePath}/src/main.js`);
    const { code: listCode, stdout } = await ssh.execCommand('pm2 list');
    if (deleteCode === 0 && startCode === 0 && listCode === 0) {
      spinner.succeed(chalk.greenBright(`服务启动成功: \n ${stdout} \n`));
    } else {
      spinner.fail(chalk.redBright(`服务启动失败: ${deleteStderr || startStderr}`));
      process.exit(1);
    }
  } catch (error) {
    spinner.fail(chalk.redBright(`服务启动失败: ${error}`));
    process.exit(1);
  }
};

// 连接服务器
const onConnectServer = async ({
  host,
  port,
  username,
  password
}: Pick<Options, 'host' | 'port' | 'username' | 'password'>) => {
  const spinner = ora({
    text: chalk.yellowBright(chalk.cyan(`正在连接服务器: ${username}@${host}:${port} ...`))
  }).start();
  try {
    // 连接到服务器
    await ssh.connect({
      host,
      username,
      port,
      password,
      tryKeyboard: true
    });
    spinner.succeed(chalk.greenBright('服务器连接成功!!!'));
  } catch (err) {
    spinner.fail(chalk.redBright(`服务器连接失败: ${err}`));
    process.exit(1);
  }
};

// 连接服务器并上传文件
const onPublish = async ({
  username,
  host,
  port,
  password,
  localFilePath,
  remoteFilePath,
  projectName,
  install,
  publishConfig
}: Omit<Options, 'isServer'> & { projectName: string; publishConfig: { porjectInfo: any; projectInfo: any } }) => {
  try {
    await onConnectServer({
      host,
      username,
      port,
      password
    });
    // 判断是否是服务端项目
    if (publishConfig?.porjectInfo[projectName]?.isServer) {
      await onCompressServiceFile(localFilePath);
    } else {
      await onCompressFile(localFilePath);
    }
    await onPutFile(localFilePath, remoteFilePath);
    await onDeleteFile(`${remoteFilePath}/dist`);
    await onUnzipZip(remoteFilePath);
    await onRemoveFile(`${localFilePath}/dist.zip`);
    if (install) {
      await onInstall(remoteFilePath);
    }
    if (publishConfig?.porjectInfo[projectName]?.isServer) {
      await onRestartServer(remoteFilePath);
    }
    console.log(
      beautyLog.success,
      chalk.greenBright(chalk.bgCyan(` 🎉 🎉 🎉 ${projectName} 项目部署成功!!! 🎉 🎉 🎉 \n`))
    );
  } catch (err) {
    console.log(beautyLog.error, chalk.red(`部署失败: ${err}`));
  } finally {
    // 关闭 SSH 连接
    ssh.dispose();
  }
};

export const publish = async (projectName: string, options: Options) => {
  const {
    host: _host,
    port: _port,
    username: _username,
    password: _password,
    localFilePath: _localFilePath,
    remoteFilePath: _remoteFilePath,
    install: _install
  } = options;

  const publishConfig = getPublishConfig();

  const getRemoteFilePath = () => {
    if (publishConfig?.porjectInfo[projectName]) {
      return publishConfig?.porjectInfo[projectName]?.remoteFilePath;
    } else {
      // console.log(beautyLog.warning, chalk.yellowBright(`未找到项目 ${projectName} 的配置信息`));
      return '';
    }
  };

  const getInstallStatus = (isServer: boolean) => {
    return !!(_install || (publishConfig ? !publishConfig?.porjectInfo[projectName]?.isServer : !isServer));
  };

  try {
    result = await prompts(
      [
        {
          name: 'host',
          type: _host ? null : 'text',
          message: 'host:',
          initial: publishConfig?.serverInfo?.host || '',
          validate: (value) => (value ? true : '请输入host')
        },
        {
          name: 'port',
          type: _port ? null : 'text',
          message: '端口号:',
          initial: publishConfig?.serverInfo?.port || '',
          validate: (value) => (value ? true : '请输入端口号')
        },
        {
          name: 'localFilePath',
          type: _localFilePath ? null : 'text',
          message: '本地项目文件路径:',
          initial: process.cwd(),
          validate: (value) => (value ? true : '请输入本地项目文件路径')
        },
        {
          name: 'remoteFilePath',
          type: _remoteFilePath ? null : 'text',
          message: '目标服务器项目文件路径:',
          initial: getRemoteFilePath() || '',
          validate: (value) => (value ? true : '请输入目标服务器项目文件路径')
        },
        {
          name: 'isServer',
          type: _install || getRemoteFilePath() ? null : 'toggle',
          message: '是否是后台服务:',
          initial: false,
          active: 'yes',
          inactive: 'no'
        },
        {
          name: 'install',
          type: (_, values) => (getInstallStatus(values.isServer) ? null : 'toggle'),
          message: '是否安装依赖:',
          initial: false,
          active: 'yes',
          inactive: 'no'
        },
        {
          name: 'username',
          type: _username ? null : 'text',
          message: '用户名称:',
          initial: publishConfig?.serverInfo?.username || '',
          validate: (value) => (value ? true : '请输入用户名称')
        },
        {
          name: 'password',
          type: _password ? null : 'password',
          message: '密码:',
          validate: (value) => (value ? true : '请输入密码')
        }
      ],
      {
        onCancel: () => {
          throw new Error('User cancelled');
        }
      }
    );
  } catch (cancelled) {
    process.exit(1);
  }

  const { host, port, username, password, localFilePath, remoteFilePath, install } = result;

  await onPublish({
    host: host || _host,
    port: port || _port,
    username: username || _username,
    password: password || _password,
    localFilePath: localFilePath || _localFilePath,
    remoteFilePath: remoteFilePath || _remoteFilePath,
    install: install || _install,
    projectName,
    publishConfig
  });
};

项目源码

当前工具项目源码可在 github 上查看 dnhyxc-tools/packages/ci

总结

本文介绍了发布前台项目及后台 node 服务端项目的流程,主要涉及到收集用户输入信息、连接服务器、压缩文件、上传文件、删除服务器上的 dist 文件、解压服务器上刚上传的 dist.zip 文件、删除本地 dist.zip 文件、服务端项目安装依赖、重启 node 服务等步骤。通过这些步骤,实现了一个在项目打包之后可直接自动发布的工具。

相关推荐
danplus18 分钟前
node发送邮件:如何实现Node.js发信功能?
服务器·node.js·外贸开发信·邮件群发·蜂邮edm邮件营销·邮件接口·营销邮件
青稞儿22 分钟前
面试题高频之token无感刷新(vue3+node.js)
vue.js·node.js
一个很帅的帅哥13 小时前
实现浏览器的下拉加载功能(类似知乎)
开发语言·javascript·mysql·mongodb·node.js·vue·express
Bang邦16 小时前
使用nvm管理Node.js多版本
前端·node.js·node多版本管理
新知图书17 小时前
Node.js快速入门
node.js
FakeOccupational18 小时前
nodejs 007:错误npm error Error: EPERM: operation not permitted, symlink
前端·npm·node.js
亦舒.18 小时前
JSDelivr & NPM CDN 国内加速节点
前端·npm·node.js
代码搬运媛18 小时前
code eintegrity npm err sha512
前端·npm·node.js
猿来如此呀1 天前
运行npm install 时,卡在sill idealTree buildDeps没有反应
前端·npm·node.js
八了个戒1 天前
Koa (下一代web框架) 【Node.js进阶】
前端·node.js