From 8d1b8d5122c64220df31689c76297f6d8dd2187e Mon Sep 17 00:00:00 2001 From: lyizai <3945107889@qq.com> Date: Mon, 28 Sep 2026 20:45:08 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E9=83=A8=E7=BD=B2=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E5=9B=BA=E5=8C=96=E8=BF=9B=E4=BB=93=E5=BA=93=EF=BC=88=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC=20+=20=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E8=BF=90=E7=BB=B4=E6=96=87=E6=A1=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前部署用的 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)。 --- README.md | 26 ++++++++ TODO.md | 109 +++++++++++++++++++++++++++++++- package-lock.json | 85 ++++++++++++++++++++++++- package.json | 7 +- scripts/deploy.js | 144 ++++++++++++++++++++++++++++++++++++++++++ scripts/ssh-helper.js | 131 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 495 insertions(+), 7 deletions(-) create mode 100644 scripts/deploy.js create mode 100644 scripts/ssh-helper.js diff --git a/README.md b/README.md index 3963151..10ba8c6 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,32 @@ V2 的接口级配额很小(末影龙只有 **1 次/分钟**),因此 V2 本项目已在 Debian 12 上以 pm2 常驻运行验证过,关键点: +### 0. 一键更新(已装好之后日常用这个) + +```powershell +# Windows PowerShell —— 凭据走环境变量,脚本里不写死密码 +$env:SSH_HOST='103.236.99.177'; $env:SSH_PORT='33851' +$env:SSH_USER='root'; $env:SSH_PASS='<服务器密码>' +npm run deploy +``` + +```bash +# Linux / macOS +SSH_HOST=... SSH_PORT=... SSH_USER=... SSH_PASS='<密码>' npm run deploy +``` + +`scripts/deploy.js` 会依次做:打包 → 上传 → 远程执行 `update.sh` → +轮询确认控制台返回 200,任一步失败都会明确报错。 +需要单条远程命令时用 `node scripts/ssh-helper.js exec "<命令>"`。 + +两个脚本用 `ssh2` 实现(Windows 自带 OpenSSH 但没有 sshpass, +无法非交互传密码);`ssh2` 是 devDependencies,未安装时脚本会提示而不是裸崩。 + +打包和更新都会**排除并保留**服务器上的 `.env`、`data/`、`node_modules/`、`runtime/`, +不会用你本机的空配置覆盖线上真实数据。 + +更多运维命令(回滚、日志路径、服务器上各文件的作用)见 `TODO.md` 的「部署方法」一节。 + ### 1. Node.js 版本要求 ≥ 22 `mineflayer 4.39` 的 `engines` 声明为 `>=22`,用 Node 18 会在运行时出问题。 diff --git a/TODO.md b/TODO.md index f81b2bc..f428efc 100644 --- a/TODO.md +++ b/TODO.md @@ -12,12 +12,12 @@ | 项 | 值 | | --- | --- | | 技术栈 | Node.js ≥ 22 + mineflayer 4.39.0 + mineflayer-pathfinder 2.4.5 | -| 测试 | `npm test`(363 个用例全绿) | +| 测试 | `npm test`(368 个用例全绿) | | 语法与前端 id 自检 | `npm run check` | +| 本地启动 | `npm start`(控制台 http://127.0.0.1:3080/) | | 服务器 | `root@103.236.99.177:33851`,部署目录 `/opt/autostore-bot` | | 控制台 | http://103.236.99.177:78/ ,密码在服务器 `.env` 的 `WEB_PASSWORD` | | 进程管理 | pm2 应用 `autostore-bot`(端口 78);旧版 Fabric 机器人为 `minecraft-bot`(端口 91,已停) | -| 更新方式 | 打包上传 → `bash /opt/autostore-bot/update.sh`(保留 `.env` / `data/` / `node_modules/` / `runtime/`) | | 私有 Node | `/opt/autostore-bot/runtime/current/bin/node`(v24.16.0) | **旧版 Fabric 模组源码**(重要参考,实机可用的实现): @@ -25,6 +25,111 @@ 它在这台服务器上是**真的能走路、能寻路、能扩地**的,所以遇到 mineflayer 行为诡异时, 第一件事应该是拿它的实现逐项对照,而不是自己猜。 +> ⚠️ 上面的临时目录会被系统清理。若已丢失,它只作为历史参考 —— +> 不影响当前项目运行,但排查移动问题时很有价值,建议接手后先备份一份到仓库外。 + +--- + +## 0.1 🚀 部署方法(完整步骤) + +### 方式一:一键部署(推荐) + +```powershell +# Windows PowerShell +$env:SSH_HOST='103.236.99.177'; $env:SSH_PORT='33851' +$env:SSH_USER='root'; $env:SSH_PASS='<服务器密码>' +npm run deploy +``` + +```bash +# Linux / macOS +SSH_HOST=103.236.99.177 SSH_PORT=33851 SSH_USER=root SSH_PASS='<密码>' npm run deploy +``` + +`scripts/deploy.js` 会依次做:打包 → 上传 → 远程执行 `update.sh` → +轮询确认控制台返回 200。任一步失败都会明确报错。 + +可选环境变量:`DEPLOY_APP`(默认 `/opt/autostore-bot`)、`DEPLOY_PORT`(默认 `78`)。 + +### 方式二:手动部署 + +```bash +# 1. 打包(务必排除 data / .env / node_modules,否则会覆盖服务器上的真实数据) +tar -czf update.tar.gz \ + --exclude=node_modules --exclude=.git --exclude=data --exclude='*.log' --exclude=.env \ + -C . . + +# 2. 上传 +scp -P 33851 update.tar.gz root@103.236.99.177:/opt/autostore-bot/update.tar.gz + +# 3. 远程应用(update.sh 会保留 .env / data / node_modules / runtime) +ssh -p 33851 root@103.236.99.177 'bash /opt/autostore-bot/update.sh' +``` + +### 辅助脚本 + +| 脚本 | 用途 | +| --- | --- | +| `scripts/deploy.js` | 一键部署(推荐) | +| `scripts/ssh-helper.js` | 单条远程命令 / 上传:`node scripts/ssh-helper.js exec "<命令>"`、`... put <本地> <远程>` | + +Windows 自带 OpenSSH 但**没有 sshpass / plink**,无法非交互传密码, +所以这两个脚本用 `ssh2` 直接实现协议层(`ssh2` 是 devDependencies, +未安装时脚本会提示 `npm install --save-dev ssh2` 而不是裸崩)。 + +**凭据一律走环境变量,脚本里不写死任何密码。** + +### update.sh 做了什么 + +`/opt/autostore-bot/update.sh` 的行为: +1. 解压 `update.tar.gz` 到 `/opt/autostore-bot`(**保留** `.env`、`data/`、`node_modules/`、`runtime/`) +2. 用私有 Node 重新安装/校验依赖 +3. `pm2 restart autostore-bot --update-env` +4. 打印进程状态与端口监听情况 + +### 常用运维命令 + +```bash +# 进程状态 +pm2 status +pm2 list + +# 重启 / 停止 +pm2 restart autostore-bot --update-env +pm2 stop autostore-bot + +# 日志(实时) +tail -f /opt/autostore-bot/data/pm2-out.log # 运行日志 +tail -f /opt/autostore-bot/data/pm2-error.log # 告警与错误 +pm2 logs autostore-bot + +# 端口 +ss -lntp | grep ':78 ' + +# 回滚:把上一份备份目录改回来(update.sh 会先备份旧版本目录) +ls -1d /opt/autostore-bot/backup-* 2>/dev/null | tail -3 +``` + +### 服务器上的关键文件 + +| 路径 | 说明 | +| --- | --- | +| `/opt/autostore-bot/.env` | **真实凭据**(`MC_USERNAME`、`MC_PASSWORD`、`WEB_PASSWORD`、`SIMP_API_KEY`、`SIMP_API_V2_KEY`),权限 0600,**绝不进仓库** | +| `/opt/autostore-bot/data/autostore.json` | 运行时配置(控制台保存的),会被 `update.sh` 保留 | +| `/opt/autostore-bot/data/bots.json` | 机器人非敏感设置 | +| `/opt/autostore-bot/data/bots.credentials.json` | 界面添加的账号密码,权限 0600 | +| `/opt/autostore-bot/data/chat/<玩家名>.jsonl` | 公屏完整日志 | +| `/opt/autostore-bot/data/expansion-log.jsonl` | 开拓日志 | +| `/opt/autostore-bot/data/mapcache/` | 大地图瓦片缓存 | +| `/opt/autostore-bot/ecosystem.config.js` | pm2 配置 | +| `/opt/autostore-bot/runtime/current` | 私有 Node(服务器自带 Node 太旧时用) | + +### 新增环境变量时 + +1. 在 `.env` 里加真实值(**只在服务器上加,不要提交**); +2. 在 `.env.example` 里加**占位符**(如 ``),绝不含真实密钥; +3. 确认 `.gitignore` 已包含 `.env`(本项目已含,并有 `!.env.example` 例外)。 + --- ## 1. 🔴 最大问题:寻路器驱动的移动会被服务端持续回弹 diff --git a/package-lock.json b/package-lock.json index c197008..3755015 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "autostore-bot", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "autostore-bot", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -18,7 +18,8 @@ "ws": "^8.18.0" }, "devDependencies": { - "minecraft-data": "^3.102.3" + "minecraft-data": "^3.102.3", + "ssh2": "^1.17.0" }, "engines": { "node": ">=18.0.0" @@ -177,6 +178,16 @@ ], "license": "MIT" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/body-parser": { "version": "1.20.8", "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.8.tgz", @@ -243,6 +254,16 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", @@ -357,6 +378,21 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", @@ -1077,6 +1113,14 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.29.0", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nearley": { "version": "2.20.1", "resolved": "https://registry.npmmirror.com/nearley/-/nearley-2.20.1.tgz", @@ -1748,6 +1792,34 @@ "npm": ">= 3.0.0" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2/node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", @@ -1800,6 +1872,13 @@ "license": "0BSD", "optional": true }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", diff --git a/package.json b/package.json index 5769ec7..d25c9d3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "start": "node src/index.js", "dev": "node --watch src/index.js", "test": "node --test", - "check": "node scripts/check-syntax.js && node scripts/check-web-ids.js" + "check": "node scripts/check-syntax.js && node scripts/check-web-ids.js", + "deploy": "node scripts/deploy.js", + "ssh": "node scripts/ssh-helper.js" }, "keywords": [ "minecraft", @@ -31,6 +33,7 @@ "ws": "^8.18.0" }, "devDependencies": { - "minecraft-data": "^3.102.3" + "minecraft-data": "^3.102.3", + "ssh2": "^1.17.0" } } diff --git a/scripts/deploy.js b/scripts/deploy.js new file mode 100644 index 0000000..9074c67 --- /dev/null +++ b/scripts/deploy.js @@ -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 { + // 临时文件删不掉无所谓 + } +} diff --git a/scripts/ssh-helper.js b/scripts/ssh-helper.js new file mode 100644 index 0000000..ce9affa --- /dev/null +++ b/scripts/ssh-helper.js @@ -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); +});