Kailiming Blog

自动部署脚本

前端
JavaScripe

这个文件是一个 **Node.js 部署脚本**,用于将本地 Next.js 项目自动构建、打包并上传到远程服务器。

自动部署脚本

这个文件是一个 Node.js 部署脚本,用于将本地 Next.js 项目自动构建、打包并上传到远程服务器。以下是逐段解析:

步骤解析

bash
1. 🧹 删除本地 dist 目录
2. 🔨 执行 pnpm build
3. 📦 打包成 ZIP
4. 🔌 连接 SFTP
5. 🧹 服务器清理旧文件
6. 📤 上传 ZIP
7. 🔧 解压 → 删除 ZIP → 安装依赖 → 重启服务
8. 🧹 删除本地 ZIP

1. 模块导入

模块用途
ssh2-sftp-clientSFTP 客户端,用于文件上传
fs文件系统操作(读取、删除、创建文件)
child_process执行本地 shell 命令(如 pnpm build
path路径处理
url获取当前文件路径(ESM 替代 __dirname
ssh2SSH 客户端,用于执行远程命令
archiver创建 ZIP 压缩包

2. 配置区域

js
const basePath = "/www/wwwroot/***"  // 服务器部署目录
const zipName = 'deploy.zip'              // 压缩包文件名
const packageTool = 'pnpm'                // 包管理工具
const pm2Name = '***'                 // PM2 进程名

部署配置:

  • host/port/username/password — 服务器 SSH 连接信息
  • localDirs — 需要打包上传的本地文件/目录
  • preDeployCommands — 上传前在服务器执行的清理命令
  • postDeployCommands — 上传后执行的解压、安装、重启命令

3. createZip 函数

创建 ZIP 压缩包的异步函数:

遍历 localDirs → 检查文件是否存在 → 目录用 archive.directory() / 文件用 archive.file() → 生成 ZIP

压缩级别设为 9(最高压缩率)。


4. execRemoteCommand 函数

通过 SSH 在远程服务器执行命令,特点:

  • 支持超时控制(默认 5 分钟)
  • 实时输出 stdout/stderr 到控制台
  • 非 0 退出码会报错

完整代码示例

js
import SftpClient from 'ssh2-sftp-client';
import { statSync, existsSync, createWriteStream, unlinkSync, rmSync } from 'fs';
import { execSync } from 'child_process';
import { join, posix, resolve as _resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { Client } from "ssh2";
import archiver from 'archiver';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// ==================== 配置区域 ====================
const basePath = "/www/wwwroot/***" // 服务器目录
const zipName = 'deploy.zip' // ZIP 文件名
const packageTool = 'pnpm' // 包管理工具
const pm2Name = '***' // PM2 进程名
const CONFIG = { // ssh连接配置
  host: '***',
  port: 22,
  username: '***',
  password: '***.',
  remotePath: basePath,
  // 本地需要上传的目录/文件(相对于项目根目录)
  // 注意:部署前请先执行 `npm run build` 生成 dist 目录
  localDirs: ['dist', 'public', 'package.json', 'next.config.ts', '.env.production'],
  // 上传前在服务器执行的命令(清理旧文件等)
  preDeployCommands: [
    `rm -rf ${basePath}/dist`,
    `rm -rf ${basePath}/public`,
  ],
  // 上传后在服务器执行的命令(解压、安装依赖、重启等)
  postDeployCommands: [
    `cd ${basePath} && unzip -o ${zipName}}`,
    `cd ${basePath} && rm -f ${zipName}`,
    `cd ${basePath} && ${packageTool} install`,
    `cd ${basePath} && pm2 restart ${pm2Name} || pm2 start npm --name ${pm2Name} -- start`,
  ],
};
// =================================================

const sftp = new SftpClient();

/**
 * 创建 ZIP 压缩包
 */
async function createZip(outputPath, sourcePaths) {
  return new Promise((resolve, reject) => {
    const output = createWriteStream(outputPath);
    const archive = archiver('zip', { zlib: { level: 9 } });

    output.on('close', () => {
      console.log(`📦 压缩完成: ${outputPath} (${(archive.pointer() / 1024 / 1024).toFixed(2)} MB)\n`);
      resolve();
    });

    archive.on('error', (err) => reject(err));
    archive.on('warning', (err) => {
      if (err.code === 'ENOENT') {
        console.warn(`⚠️ ${err.message}`);
      } else {
        reject(err);
      }
    });

    archive.pipe(output);

    for (const sourcePath of sourcePaths) {
      const fullPath = _resolve(__dirname, sourcePath);
      if (!existsSync(fullPath)) {
        console.warn(`⚠️ 本地路径不存在,跳过: ${sourcePath}`);
        continue;
      }

      const stat = statSync(fullPath);
      if (stat.isDirectory()) {
        archive.directory(fullPath, sourcePath);
      } else {
        archive.file(fullPath, { name: sourcePath });
      }
    }

    archive.finalize();
  });
}

/**
 * 执行远程 SSH 命令(带超时)
 */
async function execRemoteCommand(command, timeoutMs = 300000) {
  return new Promise((resolve, reject) => {
    const conn = new Client();
    let finished = false;

    // 超时处理
    const timer = setTimeout(() => {
      if (!finished) {
        finished = true;
        conn.end();
        reject(new Error(`命令执行超时 (${timeoutMs}ms): ${command}`));
      }
    }, timeoutMs);

    conn
      .on('ready', () => {
        conn.exec(command, (err, stream) => {
          if (err) {
            clearTimeout(timer);
            conn.end();
            return reject(err);
          }
          let stdout = '';
          let stderr = '';
          stream
            .on('close', (code) => {
              clearTimeout(timer);
              if (finished) return;
              finished = true;
              conn.end();
              if (code !== 0) {
                reject(new Error(`命令退出码 ${code}: ${stderr || stdout}`));
              } else {
                resolve(stdout);
              }
            })
            .on('data', (data) => {
              stdout += data;
              process.stdout.write(data);
            })
            .stderr.on('data', (data) => {
              stderr += data;
              process.stderr.write(data);
            });
        });
      })
      .on('error', (err) => {
        clearTimeout(timer);
        if (!finished) {
          finished = true;
          reject(err);
        }
      })
      .connect({
        host: CONFIG.host,
        port: CONFIG.port,
        username: CONFIG.username,
        password: CONFIG.password,
        readyTimeout: 20000,
      });
  });
}

/**
 * 主部署流程
 */
async function deploy() {
  console.log('🚀 开始部署...');
  console.log(`📡 目标服务器: ${CONFIG.host}`);
  console.log(`📁 远程目录: ${CONFIG.remotePath}\n`);

  const zipPath = join(__dirname, zipName);

  try {
    // 1. 清理本地 dist 目录
    const localDistPath = join(__dirname, 'dist');
    if (existsSync(localDistPath)) {
      console.log('🧹 清理本地 dist 目录...');
      rmSync(localDistPath, { recursive: true, force: true });
      console.log('✅ 本地 dist 已清理\n');
    }

    // 2. 执行 pnpm build
    console.log('🔨 执行 pnpm build...');
    execSync('pnpm build', { cwd: __dirname, stdio: 'inherit' });
    console.log('✅ 构建完成\n');

    // 3. 创建 ZIP 压缩包
    console.log('📦 正在打包文件...');
    await createZip(zipPath, CONFIG.localDirs);

    // 4. 连接 SFTP
    console.log('🔌 连接 SFTP...');
    await sftp.connect({
      host: CONFIG.host,
      port: CONFIG.port,
      username: CONFIG.username,
      password: CONFIG.password,
    });
    console.log('✅ SFTP 连接成功\n');

    // 5. 执行部署前命令
    if (CONFIG.preDeployCommands.length > 0) {
      console.log('🧹 执行部署前清理...');
      for (const cmd of CONFIG.preDeployCommands) {
        console.log(`> ${cmd}`);
        await execRemoteCommand(cmd);
      }
      console.log('');
    }

    // 6. 上传 ZIP 文件
    console.log('📤 上传压缩包...');
    const remoteZipPath = posix.join(CONFIG.remotePath, zipName);
    await sftp.put(zipPath, remoteZipPath);
    console.log(`✅ 上传完成: ${remoteZipPath}\n`);

    // 7. 执行部署后命令(解压、安装、重启)
    if (CONFIG.postDeployCommands.length > 0) {
      console.log('🔧 执行部署后命令...');
      for (const cmd of CONFIG.postDeployCommands) {
        console.log(`> ${cmd}`);
        await execRemoteCommand(cmd, 600000); // 10分钟超时
      }
      console.log('');
    }

    console.log('🎉 部署完成!');
  } catch (err) {
    console.error('\n❌ 部署失败:', err.message);
    process.exit(1);
  } finally {
    // 8. 清理本地临时 ZIP 文件
    try {
      if (existsSync(zipPath)) {
        unlinkSync(zipPath);
      }
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
    } catch (e) {
      // ignore cleanup error
    }
    await sftp.end();
  }
}

// 运行部署
deploy();