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)。
这个提交包含在:
lyizai
2026-09-28 20:45:08 +08:00
父节点 0cd65cf9be
当前提交 8d1b8d5122
共修改 6 个文件,包含 495 行新增和 7 行删除
+144
查看文件
@@ -0,0 +1,144 @@
'use strict';
/**
* 一键部署:打包 → 上传 → 远程执行 update.sh → 校验服务已起来。
*
* 用法(凭据全部走环境变量,脚本里不写死任何密钥):
*
* # Windows PowerShell
* $env:SSH_HOST='103.236.99.177'; $env:SSH_PORT='33851'
* $env:SSH_USER='root'; $env:SSH_PASS='<密码>'
* node scripts/deploy.js
*
* # Linux / macOS
* SSH_HOST=... SSH_PORT=... SSH_USER=... SSH_PASS=... node scripts/deploy.js
*
* 可选环境变量:
* DEPLOY_APP 部署目录,默认 /opt/autostore-bot
* DEPLOY_PORT 健康检查端口,默认 78
*
* 打包时**排除**这些,以免覆盖服务器上的真实数据与环境:
* node_modules / .git / data / *.log / .env
* 服务器上 `update.sh` 同样会保留 .env、data、node_modules、runtime。
*/
const { execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const HOST = process.env.SSH_HOST;
const PASS = process.env.SSH_PASS;
const APP = process.env.DEPLOY_APP || '/opt/autostore-bot';
const PORT = process.env.DEPLOY_PORT || '78';
if (!HOST || !PASS) {
console.error('缺少 SSH_HOST / SSH_PASS 环境变量');
process.exit(2);
}
const ROOT = path.resolve(__dirname, '..');
const HELPER = path.join(__dirname, 'ssh-helper.js');
const TARBALL = path.join(os.tmpdir(), `autostore-deploy-${Date.now()}.tar.gz`);
/** 通过 ssh-helper 执行远程命令,输出直接透传 */
function remote(command) {
const r = spawnSync(process.execPath, [HELPER, 'exec', command], {
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env
});
const stdout = r.stdout ? r.stdout.toString('utf8') : '';
const stderr = r.stderr ? r.stderr.toString('utf8') : '';
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
return { code: r.status || 0, stdout, stderr };
}
/** 通过 ssh-helper 上传文件 */
function upload(localFile, remotePath) {
const r = spawnSync(process.execPath, [HELPER, 'put', localFile, remotePath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env
});
const stdout = r.stdout ? r.stdout.toString('utf8') : '';
const stderr = r.stderr ? r.stderr.toString('utf8') : '';
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
if (r.status !== 0) throw new Error('上传失败');
}
function pack() {
// tar 的 --exclude 必须放在 -C 之前才能生效
const args = [
'-czf', TARBALL,
'--exclude=node_modules',
'--exclude=.git',
'--exclude=data',
'--exclude=*.log',
'--exclude=.env',
'-C', ROOT,
'.'
];
try {
execFileSync('tar', args, { stdio: 'ignore' });
} catch {
console.error('打包失败。Windows 请确保 tar 可用(Win10 1803+ 自带 bsdtar);'
+ '或者手动压缩项目目录后通过 scripts/ssh-helper.js put 上传。');
process.exit(1);
}
return fs.statSync(TARBALL).size;
}
function main() {
console.log(`===== 1/4 打包 ${ROOT} =====`);
const size = pack();
console.log(` ${TARBALL} (${size} bytes)`);
console.log('\n===== 2/4 上传 =====');
upload(TARBALL, `${APP}/update.tar.gz`);
console.log('\n===== 3/4 远程应用更新 =====');
const applied = remote(`bash ${APP}/update.sh 2>&1 | tail -5`);
if (applied.code !== 0) {
console.error('update.sh 返回非零状态,请检查上面的输出');
}
console.log('\n===== 4/4 校验服务 =====');
// 等 pm2 把进程拉起来
atExit(() => {
const health = remote(
`for i in 1 2 3 4 5 6 7 8 9 10; do `
+ `if curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${PORT}/ | grep -q 200; then `
+ `echo "控制台 200 OK"; break; fi; sleep 3; done; `
+ `ss -lntp | grep ':${PORT} ' || echo "警告:端口 ${PORT} 未监听"`
);
if (!health.stdout.includes('200 OK')) {
console.error(`\n服务未就绪,请登录服务器查看日志:\n`
+ ` tail -50 ${APP}/data/pm2-out.log\n`
+ ` tail -50 ${APP}/data/pm2-error.log`);
process.exitCode = 1;
}
});
}
/** 注册退出前执行(保证 1~3 步的输出先打完) */
const exitHooks = [];
function atExit(fn) {
exitHooks.push(fn);
}
process.on('exit', () => {
for (const fn of exitHooks) fn();
});
try {
main();
} catch (err) {
console.error('部署失败:' + (err && err.message ? err.message : err));
process.exitCode = 1;
} finally {
try {
fs.unlinkSync(TARBALL);
} catch {
// 临时文件删不掉无所谓
}
}
+131
查看文件
@@ -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);
});