前端自动化部署实践:使用 npm、bestzip 和 SSH2 实现一键构建、打包与 Linux 服务器发布

前言

在前端项目开发过程中,每次发布新版本通常需要执行一系列重复操作:

  1. 执行前端构建命令。
  2. 将构建产物打包成 ZIP。
  3. 连接 Linux 服务器。
  4. 将 ZIP 上传到服务器。
  5. 执行服务器上的发布脚本。

如果每次都手动操作,不仅效率低,还容易因为操作遗漏导致部署异常。

本文介绍一种基于 npm Scripts + bestzip + ssh2 的轻量级自动化部署方案,将这些步骤整合到构建流程中。

最终只需执行:

npm run build:portal

即可自动完成前端构建、ZIP 压缩、SSH 连接、文件上传以及服务器发布脚本的执行。

该方案不依赖 Jenkins、Docker 或额外的 CI/CD 平台,适合已有发布脚本、希望快速实现自动化部署的前端项目。

一、技术方案

1.1 使用的技术

技术用途
npm Scripts管理构建和部署流程
Yarn执行子项目构建
bestzip压缩前端构建产物
Node.js编写自动化部署脚本
ssh2SSH 连接、SFTP 上传和远程命令执行
dotenv管理部署环境配置

1.2 部署流程

npm run build:portal
        │
        ▼
前端项目构建
        │
        ▼
postbuild:portal 自动执行
        │
        ▼
删除旧 ZIP(如果存在)
        │
        ▼
压缩 dist 内的构建产物
        │
        ▼
生成 dist/portal.zip
        │
        ▼
执行 deploy-portal.cjs
        │
        ▼
读取服务器部署配置
        │
        ▼
建立 SSH 连接
        │
        ▼
通过 SFTP 上传 ZIP
        │
        ▼
执行 Linux 发布脚本
        │
        ▼
输出部署结果

1.3 项目目录结构

以下为本文示例使用的项目结构:

frontend/
├── .gitignore
├── .env.deploy
├── .env.deploy.example
├── .deploy-known-hosts.json
├── package.json
├── scripts/
│   └── deploy-portal.cjs
└── packages/
    └── portal/
        ├── package.json
        └── dist/
            ├── index.html
            ├── assets/
            └── portal.zip

其中:

  • .env.deploy:存放服务器连接信息。
  • .deploy-known-hosts.json:记录 SSH 服务器主机密钥哈希。
  • deploy-portal.cjs:执行文件上传与远程发布。
  • portal.zip:构建完成后的部署包。

二、安装项目依赖

在项目根目录执行:

npm install -D bestzip ssh2 dotenv

三个依赖分别负责:

bestzip

用于将构建目录压缩为 ZIP 文件,支持 Windows、Linux 和 macOS。

ssh2

Node.js SSH 客户端库,主要用于:

  • 使用账号密码建立 SSH 连接。
  • 通过 SFTP 上传文件。
  • 执行服务器上的 Shell 命令。
  • 接收远程命令的标准输出和错误输出。

dotenv

读取本地 .env 配置文件,将服务器地址、账号、密码等信息加载到 process.env 中。


三、配置 npm 构建与自动打包

3.1 原有构建命令

项目使用 Monorepo 目录结构,前端代码位于 packages/portal。

根目录 package.json 中已有以下构建命令:

{
  "scripts": {
    "build:portal": "set NODE_OPTIONS=--openssl-legacy-provider && cd packages/portal && yarn run build"
  }
}

该命令完成以下操作:

  1. 设置 Node.js 环境变量。
  2. 进入 packages/portal。
  3. 使用 Yarn 执行项目构建。

这里的 set NODE_OPTIONS=... 属于 Windows CMD 语法,本文后续打包命令同样以 Windows 环境为例。

3.2 使用 postbuild 自动执行打包

npm 支持 pre 和 post 生命周期脚本。

例如,执行:

npm run build:portal

如果存在对应的 postbuild:portal,npm 会在 build:portal 成功完成之后自动执行它。

因此,无需修改原有构建命令,只需要添加:

{
  "scripts": {
    "build:portal": "set NODE_OPTIONS=--openssl-legacy-provider && cd packages/portal && yarn run build",
    "postbuild:portal": "cd packages/portal/dist && (if exist portal.zip del /Q portal.zip) && bestzip ../portal.zip * && move /Y ..\\portal.zip portal.zip && cd ../../.. && node scripts/deploy-portal.cjs"
  }
}

npm 的 post 脚本机制可参考 npm Scripts 官方文档。

3.3 分析打包命令

第一步:进入构建目录

cd packages/portal/dist

这样可以确保 ZIP 内部直接包含构建产物,而不是完整的 packages/portal/dist 目录层级。

第二步:删除旧 ZIP

(if exist portal.zip del /Q portal.zip)

如果存在旧的 portal.zip,则先删除,避免旧压缩包进入新压缩包。

第三步:压缩构建产物

bestzip ../portal.zip *

这里有个关键细节:

不要直接将 ZIP 输出到正在压缩的目录中。

如果在 dist 目录执行:

bestzip portal.zip *

就可能导致正在生成的 ZIP 文件被包含在压缩内容中。

因此,先将 ZIP 输出到上一级目录:

bestzip ../portal.zip *

第四步:将 ZIP 移动回 dist

move /Y ..\portal.zip portal.zip

此时压缩已经完成,可以安全地将 ZIP 移回 dist 目录。

第五步:返回项目根目录,执行部署脚本

cd ../../.. && node scripts/deploy-portal.cjs

由于部署脚本中使用了基于当前工作目录的路径,必须先返回项目根目录。

最终 ZIP 内部结构:

portal.zip
├── index.html
├── assets/
│   ├── index.js
│   └── index.css
└── ...

而不是:

portal.zip
└── packages/
    └── portal/
        └── dist/
            └── index.html

注意:这里只处理了构建产物目录中已有 ZIP 的情况。对于正式项目,建议确保构建流程会清理旧资源,或者另外添加构建产物清理逻辑。


四、配置服务器连接信息

在项目根目录创建 .env.deploy:

DEPLOY_HOST=192.168.1.100
DEPLOY_PORT=22
DEPLOY_USER=root
DEPLOY_PASSWORD=your_password

DEPLOY_REMOTE_DIR=/root/releases
DEPLOY_SCRIPT=/root/deploy_cp.sh
DEPLOY_ARGS=portal portal.zip

配置说明:

配置项含义
DEPLOY_HOSTLinux 服务器地址
DEPLOY_PORTSSH 端口,默认 22
DEPLOY_USERSSH 登录账号
DEPLOY_PASSWORDSSH 登录密码
DEPLOY_REMOTE_DIRZIP 上传目录
DEPLOY_SCRIPT服务器发布脚本路径
DEPLOY_ARGS发布脚本执行参数

例如:

DEPLOY_SCRIPT=/root/deploy_cp.sh
DEPLOY_ARGS=portal portal.zip

最终执行的 Linux 命令等价于:

bash /root/deploy_cp.sh portal portal.zip

其中:

$1 = portal
$2 = portal.zip

这种方式可以让同一个发布脚本通过参数支持不同项目。

需要注意,DEPLOY_REMOTE_DIR 必须已经存在,并且 SSH 登录账号拥有写入权限。

另外,普通账号通常无法直接访问 /root 下的脚本。实际使用时应按照服务器权限配置选择合适的部署目录和账号。


五、编写 Node.js 部署脚本

在项目根目录创建:

scripts/deploy-portal.cjs

下面是完整实现。相较于最初版本,这里增加了远程参数校验、SSH 指纹记录、临时文件上传和异常处理。

const fs = require('node:fs');
const path = require('node:path');
const { Client } = require('ssh2');

// 加载部署配置
require('dotenv').config({
  path: path.resolve('.env.deploy')
});

// 本地构建产物
const zipPath = path.resolve(
  'packages/portal/dist/portal.zip'
);

// SSH 主机密钥记录文件
const knownHostsFile = path.resolve(
  '.deploy-known-hosts.json'
);

// 远程配置
const remoteDir = process.env.DEPLOY_REMOTE_DIR;
const remoteScript = process.env.DEPLOY_SCRIPT;
const remoteArgs = process.env.DEPLOY_ARGS || '';

const ssh = new Client();

/**
 * 校验部署配置
 */
function validateConfig() {
  const required = [
    'DEPLOY_HOST',
    'DEPLOY_USER',
    'DEPLOY_PASSWORD',
    'DEPLOY_REMOTE_DIR',
    'DEPLOY_SCRIPT'
  ];

  for (const key of required) {
    if (!process.env[key]) {
      throw new Error(`缺少配置:${key}`);
    }
  }

  if (!fs.existsSync(zipPath)) {
    throw new Error(`ZIP 不存在:${zipPath}`);
  }

  // 限定远程路径为普通 Linux 绝对路径
  for (const value of [remoteDir, remoteScript]) {
    if (!/^\/[a-zA-Z0-9_./-]+$/.test(value)) {
      throw new Error(`远程路径不合法:${value}`);
    }
  }

  // 发布参数仅允许普通文件名和标识符字符
  if (!/^[a-zA-Z0-9_.\s/-]*$/.test(remoteArgs)) {
    throw new Error('DEPLOY_ARGS 包含非法字符');
  }

  const port = Number(process.env.DEPLOY_PORT || 22);

  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    throw new Error('DEPLOY_PORT 不合法');
  }
}

/**
 * 验证 SSH 服务器主机密钥
 *
 * 首次连接时自动记录。
 * 后续连接时检查密钥是否发生变化。
 */
function verifyHostKey(hash) {
  const host = process.env.DEPLOY_HOST;
  const port = Number(process.env.DEPLOY_PORT || 22);
  const hostId = `${host}:${port}`;

  let knownHosts = {};

  if (fs.existsSync(knownHostsFile)) {
    knownHosts = JSON.parse(
      fs.readFileSync(knownHostsFile, 'utf8')
    );
  }

  if (knownHosts[hostId]) {
    if (knownHosts[hostId] !== hash) {
      console.error(
        `SSH 主机密钥发生变化:${hostId}`
      );
      return false;
    }

    return true;
  }

  // 首次连接,自动信任并记录密钥
  console.log(
    `首次连接服务器 ${hostId},记录 SSH 主机密钥`
  );

  knownHosts[hostId] = hash;

  fs.writeFileSync(
    knownHostsFile,
    JSON.stringify(knownHosts, null, 2),
    { mode: 0o600 }
  );

  return true;
}

/**
 * 建立 SSH 连接
 */
function connectSSH() {
  return new Promise((resolve, reject) => {
    ssh.once('ready', resolve);
    ssh.once('error', reject);

    ssh.connect({
      host: process.env.DEPLOY_HOST,
      port: Number(process.env.DEPLOY_PORT || 22),
      username: process.env.DEPLOY_USER,
      password: process.env.DEPLOY_PASSWORD,
      readyTimeout: 30000,

      // 验证服务器主机密钥
      hostHash: 'sha256',
      hostVerifier: verifyHostKey,

      // 排查连接问题时可以开启
      // debug: (message) => console.log('[SSH]', message)
    });
  });
}

/**
 * 使用 SFTP 上传 ZIP
 *
 * 先上传为临时文件,完成后重命名。
 */
function uploadZip() {
  return new Promise((resolve, reject) => {
    ssh.sftp((err, sftp) => {
      if (err) return reject(err);

      const remoteZip = `${remoteDir}/portal.zip`;
      const tempZip = `${remoteZip}.uploading`;

      sftp.fastPut(zipPath, tempZip, (err) => {
        if (err) return reject(err);

        // 同名目标文件已存在时,
        // 某些 SFTP 服务器可能拒绝覆盖式重命名
        sftp.unlink(remoteZip, (unlinkErr) => {
          if (
            unlinkErr &&
            unlinkErr.code !== 2
          ) {
            return reject(unlinkErr);
          }

          sftp.rename(tempZip, remoteZip, (err) => {
            if (err) reject(err);
            else resolve();
          });
        });
      });
    });
  });
}

/**
 * 执行远程发布脚本
 */
function executeDeployScript() {
  return new Promise((resolve, reject) => {
    const command =
      `bash '${remoteScript}' ${remoteArgs}`;

    console.log(`执行远程命令:${command}`);

    ssh.exec(command, (err, stream) => {
      if (err) return reject(err);

      stream.on('data', (data) => {
        process.stdout.write(data);
      });

      stream.stderr.on('data', (data) => {
        process.stderr.write(data);
      });

      stream.on('error', reject);

      stream.on('close', (code, signal) => {
        if (code === 0) {
          resolve();
        } else {
          reject(new Error(
            `发布失败,退出码:${code},信号:${signal}`
          ));
        }
      });
    });
  });
}

/**
 * 主部署流程
 */
async function main() {
  try {
    validateConfig();

    console.log('开始连接 SSH 服务器...');

    await connectSSH();

    console.log('SSH 连接成功,开始上传 ZIP...');

    await uploadZip();

    console.log('ZIP 上传完成,执行发布脚本...');

    await executeDeployScript();

    console.log('部署成功!');
  } finally {
    ssh.end();
  }
}

// 程序入口
main().catch((err) => {
  console.error('部署失败:', err.message);
  process.exitCode = 1;
});

说明:示例保留了固定的远程文件名 portal.zip。其中 SFTP 上传采用临时文件方式,降低上传过程中出现不完整正式文件的风险,但删除旧文件再重命名仍然存在短暂空档,并不是严格的原子替换。正式环境如需并发部署与无间断切换,应使用版本化部署目录和原子切换策略。

远程参数校验也是为了匹配本文的简单部署场景,不支持包含空格等特殊字符的路径和参数。


六、实现 SSH 首次连接自动信任

在自动化部署中,另一个容易遇到的问题是 SSH 首次连接。

使用 Windows 自带的 SSH 命令连接新服务器时,通常会要求确认服务器主机密钥:

Are you sure you want to continue connecting
(yes/no/[fingerprint])?

手动输入 yes 后,OpenSSH 会将服务器主机密钥保存到本地 known_hosts。

但 Node.js 的 ssh2 与系统 OpenSSH 是两个独立的 SSH 客户端。

ssh2 不会默认读取 OpenSSH 的 known_hosts,并且在未指定 hostVerifier 时,默认接受服务器主机密钥。

为了避免每次连接都无条件信任服务器,并且不要求其他开发人员手动执行 SSH,本方案额外实现了主机密钥记录机制。

6.1 首次连接

首次连接服务器时:

首次连接服务器 192.168.1.100:22,
记录 SSH 主机密钥

随后自动创建:

.deploy-known-hosts.json

内容示意:

{
  "192.168.1.100:22": "服务器主机密钥的SHA256十六进制哈希"
}

6.2 后续连接

之后每次连接时,脚本都会检查当前服务器的主机密钥哈希是否与已记录值一致。

如果一致,继续连接。

如果不一致,拒绝连接:

SSH 主机密钥发生变化:192.168.1.100:22
部署失败

这样可以避免服务器身份发生变化时仍然继续部署。

6.3 安全说明

该机制属于 TOFU(Trust On First Use,首次使用时信任)。

需要特别注意:

  • 首次连接时仍存在信任错误服务器的风险。
  • 如果服务器重装系统并更换 SSH 主机密钥,需要重新核实服务器身份。
  • 不建议在生产环境中直接删除信任记录并盲目重新接受密钥。
  • 正式生产部署可以改为管理员预先提供经过核验的服务器公钥指纹。

同时,需要区分 SSH 主机密钥验证和 SSH 协议握手。

如果出现:

Connection lost before handshake

并不意味着一定是未接受主机密钥导致的,还需要通过 ssh2 的调试日志检查网络、SSH 服务或协议协商问题。


七、配置 Git 忽略规则

部署配置中包含服务器密码,不能直接提交到 Git 仓库。

在根目录 .gitignore 中添加:

# 本地部署配置
/.env.deploy

# SSH 主机密钥记录
/.deploy-known-hosts.json

# 构建产物
/packages/portal/dist/

为了方便其他开发人员使用,可以额外创建:

.env.deploy.example

示例内容:

DEPLOY_HOST=
DEPLOY_PORT=22
DEPLOY_USER=
DEPLOY_PASSWORD=

DEPLOY_REMOTE_DIR=
DEPLOY_SCRIPT=
DEPLOY_ARGS=portal portal.zip

其他开发人员只需要复制:

copy .env.deploy.example .env.deploy

然后填写自己的服务器连接信息即可。

如果真实密码此前已经提交到 Git 历史中,仅添加 .gitignore 并不能消除泄露风险,还需要更换密码并视情况清理历史。


八、Linux 服务器发布脚本

前面已经配置:

DEPLOY_SCRIPT=/root/deploy_cp.sh
DEPLOY_ARGS=portal portal.zip

因此,部署脚本最终在服务器执行:

bash /root/deploy_cp.sh portal portal.zip

下面提供一个简化的服务器发布脚本示例:

#!/bin/bash

set -e

PROJECT="$1"
ZIP_NAME="$2"

UPLOAD_DIR="/root/releases"
DEPLOY_DIR="/var/www/$PROJECT"

ZIP_PATH="$UPLOAD_DIR/$ZIP_NAME"

if [ ! -f "$ZIP_PATH" ]; then
    echo "部署包不存在:$ZIP_PATH"
    exit 1
fi

mkdir -p "$DEPLOY_DIR"

unzip -oq "$ZIP_PATH" -d "$DEPLOY_DIR"

echo "$PROJECT 发布成功"

例如:

bash /root/deploy_cp.sh portal portal.zip

会将:

/root/releases/portal.zip

解压至:

/var/www/portal/

需要注意,这只是用于说明参数传递的简化脚本。

实际生产环境建议保留历史版本、处理旧文件清理,并采用临时发布目录加软链接切换,避免发布过程中影响正在访问的用户。


九、执行自动化部署

完成以上配置后,在项目根目录执行:

npm run build:portal

预期执行过程如下:

> npm run build:portal

开始构建前端项目...

Build completed successfully.

开始压缩构建产物...

ZIP 打包完成。

开始连接 SSH 服务器...

首次连接服务器 192.168.1.100:22,
记录 SSH 主机密钥

SSH 连接成功,开始上传 ZIP...

ZIP 上传完成,执行发布脚本...

执行远程命令:
bash '/root/deploy_cp.sh' portal portal.zip

portal 发布成功

部署成功!

至此,一条命令就完成了整个前端部署流程。

可选:增加独立部署命令

当前方案将部署直接放在 postbuild:portal 中,意味着每次正常执行 npm run build:portal 都会尝试连接服务器。

如果希望将普通构建和正式发布区分开,可以调整为:

{
  "scripts": {
    "build:portal": "set NODE_OPTIONS=--openssl-legacy-provider && cd packages/portal && yarn run build",
    "postbuild:portal": "cd packages/portal/dist && (if exist portal.zip del /Q portal.zip) && bestzip ../portal.zip * && move /Y ..\\portal.zip portal.zip",
    "deploy:portal": "node scripts/deploy-portal.cjs",
    "release:portal": "npm run build:portal && npm run deploy:portal"
  }
}

这样可以分别执行:

# 仅构建并生成 ZIP
npm run build:portal

# 将已有 ZIP 上传并发布
npm run deploy:portal

# 构建、打包、上传、发布
npm run release:portal

这种设计可以避免开发人员只想构建项目时意外触发服务器发布。


十、常见问题

10.1 ZIP 内包含 packages/portal/dist 目录

原因是直接使用了包含完整相对路径的压缩源:

bestzip portal.zip packages/portal/dist/*

解决方式是先进入构建目录:

cd packages/portal/dist
bestzip ../portal.zip *

这样 ZIP 内部只包含构建产物。

10.2 ZIP 内包含另一个 portal.zip

原因可能是正在压缩的目录中存在旧 ZIP,或者输出 ZIP 的位置位于压缩源目录中。

解决思路:

  1. 删除旧 ZIP。
  2. 先将新 ZIP 输出到 dist 外部。
  3. 压缩完成后再移动回 dist。

10.3 Node.js 提示 MODULE_NOT_FOUND

例如:

Cannot find module
packages\portal\dist\scripts\deploy-portal.cjs

原因是执行 Node.js 时仍位于 dist 目录。

解决:

cd ../../.. && node scripts/deploy-portal.cjs

确保返回项目根目录。

10.4 SSH 提示 Connection lost before handshake

先排查以下问题:

  • 服务器 SSH 端口是否正确。
  • 服务器 SSH 服务是否正常运行。
  • 服务器是否存在防火墙或连接限制。
  • SSH 握手阶段是否存在协议兼容问题。

可以开启日志:

debug: (message) => console.log('[SSH]', message)

也可以使用系统 SSH 客户端辅助排查:

ssh -vvv -p 22 root@192.168.1.100

需要注意,系统 SSH 能正常连接,不代表 Node.js ssh2 使用完全相同的认证配置和加密算法。

10.5 上传成功但发布脚本执行失败

重点检查:

  • DEPLOY_SCRIPT 是否正确。
  • 当前账号是否有权限读取并执行脚本。
  • 脚本的参数是否正确。
  • 服务器是否安装了脚本依赖的命令。
  • 发布脚本是否以非零退出码表示错误。

本方案会将服务器标准输出和标准错误实时输出至本地终端,方便排查异常。


十一、总结

本方案通过 npm Scripts、bestzip 和 ssh2,将原本需要多个手动步骤的前端部署流程整合到 Node.js 脚本中。

主要优势:

  1. 部署流程简单:通过 npm 命令完成自动化构建和发布。
  2. 依赖少:无需部署额外的 CI/CD 平台。
  3. 复用现有脚本:无需重写服务器上的发布逻辑。
  4. 配置独立:服务器账号、密码、路径与项目代码分离。
  5. 支持首次主机密钥记录:开发人员无需额外手动接受 SSH 主机密钥。
  6. 错误可追踪:上传失败、SSH 连接失败和远程脚本执行失败均可输出错误信息。

当然,这套方案并不能完全替代成熟的 CI/CD 系统。

如果后续需要支持多环境发布、部署审批、自动回滚、发布记录和权限管理,可以在现有脚本的基础上进一步扩展,或者逐步迁移到 GitHub Actions、GitLab CI、Jenkins 等平台。

对于日常的小型或中型前端项目,这种基于现有工具组合的自动化部署方式,已经能够显著减少重复操作,提高发布效率。

参考资料

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注