docs: 部署方法固化进仓库(一键部署脚本 + 完整运维文档)
之前部署用的 SSH 助手放在系统临时目录里,重启就丢了,下一位接手时根本拿不到。 现在把它搬进仓库并补齐文档: 新增 - scripts/ssh-helper.js:SSH 执行与 SFTP 上传。Windows 自带 OpenSSH 但没有 sshpass/plink,无法非交互传密码,所以用 ssh2 直接实现协议层。 凭据全部走环境变量(SSH_HOST/SSH_PORT/SSH_USER/SSH_PASS),脚本里不含任何密钥; ssh2 是 devDependencies,未安装时提示安装而不是裸崩 - scripts/deploy.js:一键部署 —— 打包 → 上传 → 远程执行 update.sh → 轮询确认控制台返回 200,任一步失败都明确报错。可选 DEPLOY_APP / DEPLOY_PORT - npm scripts:deploy、ssh 文档 - TODO.md 新增「部署方法」章节:一键部署与手动部署两种步骤、辅助脚本说明、 update.sh 的行为、pm2 与日志的常用运维命令(含回滚)、服务器上关键文件清单、 新增环境变量时的三件事(.env 真实值 + .env.example 占位符 + 确认 gitignore) - README「部署到服务器」开头加一键更新说明,并指向 TODO.md 的完整章节 - 顺带提醒旧版 Fabric 模组源码在临时目录里会被清理,接手后建议先备份 测试 368 全绿;一键部署已实跑验证(4/4 校验通过,控制台 200 OK)。
这个提交包含在:
@@ -0,0 +1,131 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 部署辅助:用 ssh2 做密码登录的 SSH 执行与 SFTP 上传。
|
||||
*
|
||||
* 为什么要自己写一个:Windows 自带 OpenSSH 但**没有 sshpass / plink**,
|
||||
* 无法在非交互模式下传密码,而部署脚本必须能无人值守地跑完。
|
||||
* 用 ssh2 直接实现协议层就绕开了这个问题。
|
||||
*
|
||||
* **脚本本身不含任何凭据**,全部走环境变量,避免密钥进仓库。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/ssh-helper.js exec "<命令>"
|
||||
* node scripts/ssh-helper.js put <本地文件> <远程路径>
|
||||
*
|
||||
* 环境变量:
|
||||
* SSH_HOST 必填
|
||||
* SSH_PASS 必填
|
||||
* SSH_PORT 可选,默认 22
|
||||
* SSH_USER 可选,默认 root
|
||||
*
|
||||
* 若未安装 ssh2(devDependencies,可选),会给出安装提示而不是裸崩。
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let Client;
|
||||
try {
|
||||
({ Client } = require('ssh2'));
|
||||
} catch {
|
||||
console.error('缺少可选依赖 ssh2。部署相关脚本需要它:\n\n npm install --save-dev ssh2\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const HOST = process.env.SSH_HOST;
|
||||
const PORT = Number(process.env.SSH_PORT || 22);
|
||||
const USER = process.env.SSH_USER || 'root';
|
||||
const PASS = process.env.SSH_PASS;
|
||||
|
||||
if (!HOST || !PASS) {
|
||||
console.error('缺少 SSH_HOST / SSH_PASS 环境变量');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client();
|
||||
client.on('ready', () => resolve(client));
|
||||
client.on('error', reject);
|
||||
client.connect({
|
||||
host: HOST,
|
||||
port: PORT,
|
||||
username: USER,
|
||||
password: PASS,
|
||||
readyTimeout: 30000,
|
||||
algorithms: {
|
||||
// 老版本 OpenSSH 服务器可能只提供这些算法,放宽列表避免握手失败
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group14-sha256', 'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1'
|
||||
],
|
||||
serverHostKey: [
|
||||
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'rsa-sha2-512', 'rsa-sha2-256', 'ssh-rsa'
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {Promise<{code: number, stdout: string, stderr: string}>} */
|
||||
function exec(client, command) {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.exec(command, { pty: false }, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
stream.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
|
||||
stream.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
||||
stream.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function put(client, localFile, remotePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
sftp.fastPut(localFile, remotePath, (putErr) => {
|
||||
if (putErr) reject(putErr);
|
||||
else resolve(remotePath);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [action, ...rest] = process.argv.slice(2);
|
||||
const client = await connect();
|
||||
try {
|
||||
if (action === 'exec') {
|
||||
const result = await exec(client, rest.join(' '));
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
process.exitCode = result.code || 0;
|
||||
} else if (action === 'put') {
|
||||
const local = path.resolve(rest[0]);
|
||||
await put(client, local, rest[1]);
|
||||
console.log(`uploaded ${path.basename(local)} -> ${rest[1]} (${fs.statSync(local).size} bytes)`);
|
||||
} else {
|
||||
console.error('未知动作:' + action);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
} finally {
|
||||
client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('SSH 失败:' + (err && err.message ? err.message : err));
|
||||
process.exit(1);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户