create-ssnode-cli 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +1 -1
  2. package/index.js +17 -21
  3. package/package.json +1 -1
  4. package/secrets.js +32 -34
  5. package/templates/backend/Dockerfile +18 -8
  6. package/templates/backend/config/config.base.yaml +130 -0
  7. package/templates/backend/config/config.develop.yaml +8 -75
  8. package/templates/backend/config/config.prod.yaml +12 -67
  9. package/templates/backend/docker-compose.yml +30 -22
  10. package/templates/backend/ops/docker/config.sh +30 -15
  11. package/templates/backend/ops/docker/install.sh +25 -34
  12. package/templates/backend/ops/docker/run-container.sh +100 -0
  13. package/templates/backend/ops/docker/start.sh +37 -8
  14. package/templates/backend/ops/docker/stop.sh +1 -1
  15. package/templates/backend/ops/ensure-encrypt.cjs +217 -0
  16. package/templates/backend/ops/install.sh +249 -148
  17. package/templates/backend/ops/lib-encrypt.sh +98 -0
  18. package/templates/backend/ops/lib.sh +22 -0
  19. package/templates/backend/ops/pm2/config.sh +11 -6
  20. package/templates/backend/ops/pm2/install.sh +27 -3
  21. package/templates/backend/ops/pm2/start.sh +1 -1
  22. package/templates/backend/ops/start.sh +7 -5
  23. package/templates/backend/ops/status.sh +7 -5
  24. package/templates/backend/ops/stop.sh +7 -5
  25. package/templates/backend/ops/systemd/config.sh +13 -6
  26. package/templates/backend/ops/systemd/install.sh +28 -1
  27. package/templates/backend/ops/systemd/start.sh +1 -1
  28. package/templates/backend/ops/versionswitch.sh +34 -122
  29. package/templates/backend/package-lock.json +7561 -0
  30. package/templates/backend/package.json +3 -1
  31. package/templates/backend/scripts/build.cjs +104 -10
  32. package/templates/backend/scripts/config-crypt.cjs +108 -0
  33. package/templates/backend/scripts/lib/ssh.cjs +72 -3
  34. package/templates/backend/scripts/publish.cjs +17 -9
  35. package/templates/backend/scripts/pulldocker.cjs +208 -0
  36. package/templates/backend/scripts/pushdocker.cjs +323 -0
  37. package/templates/backend/scripts/rollback.cjs +6 -3
  38. package/templates/backend/scripts/server.config.example.cjs +43 -15
  39. package/templates/backend/scripts/slowsql-report.mjs +2 -2
  40. package/templates/backend/src/common/api-log/api-log.service.ts +1 -1
  41. package/templates/backend/src/common/config-crypto.ts +133 -0
  42. package/templates/backend/src/config/config.interface.ts +22 -3
  43. package/templates/backend/src/config/configuration.ts +173 -86
  44. package/templates/backend/src/database/migrations/0001-init.migration.ts +1 -1
  45. package/templates/backend/src/database/slow-sql.logger.ts +1 -1
  46. package/templates/backend/src/database/sqlite.config.ts +2 -2
  47. package/templates/backend/src/main.ts +3 -3
  48. package/templates/backend/src/modules/ops/ops-probe.interface.ts +3 -3
  49. package/templates/backend/src/modules/ops/ops.module.ts +2 -11
  50. package/templates/backend/src/modules/user/user.service.ts +2 -2
  51. package/templates/docs/config-encryption.md +164 -0
  52. package/templates/root/AGENTS.md +5 -4
  53. package/templates/root/CLAUDE.md +1 -1
  54. package/templates/root/README.md +25 -15
  55. package/templates/root/_gitignore +5 -0
  56. package/templates/root/scripts/publish.cjs +20 -2
  57. package/templates/root/scripts/rollback.cjs +20 -2
  58. package/templates/web/config/router.config.ts +8 -0
  59. package/templates/web/package.json +5 -1
  60. package/templates/web/scripts/compress-src-images.cjs +332 -0
  61. package/templates/web/scripts/lib/interactive.cjs +134 -0
  62. package/templates/web/scripts/publish.cjs +299 -200
  63. package/templates/web/scripts/rollback.cjs +110 -41
  64. package/templates/web/scripts/server.config.example.json +17 -0
  65. package/templates/web/src/layouts/Background.module.less +0 -4
  66. package/templates/web/src/layouts/Background.tsx +48 -74
  67. package/templates/web/src/layouts/components/PageTabs/SortableTab.tsx +133 -0
  68. package/templates/web/src/layouts/components/PageTabs/index.module.less +105 -0
  69. package/templates/web/src/layouts/components/PageTabs/index.tsx +110 -0
  70. package/templates/web/src/layouts/components/PageTabs/usePageTabs.ts +224 -0
  71. package/templates/web/src/pages/Backend/Image/index.tsx +4 -4
  72. package/templates/web/src/pages/Maintenance/index.module.less +177 -0
  73. package/templates/web/src/pages/Maintenance/index.tsx +142 -0
  74. package/templates/web/src/services/api.ts +4 -0
  75. package/templates/web/src/services/app-request.ts +8 -1
  76. package/templates/web/src/services/maintenance.ts +67 -0
  77. package/templates/web/src/services/request.ts +5 -0
  78. package/transforms.js +21 -28
  79. package/templates/backend/ops/docker/.env.example +0 -40
  80. package/templates/backend/ops/sqlite/README.md +0 -57
  81. package/templates/backend/ops/sqlite/backup.sh +0 -65
  82. package/templates/backend/ops/sqlite/config.sh +0 -91
  83. package/templates/backend/ops/sqlite/healthcheck.sh +0 -25
  84. package/templates/backend/ops/sqlite/restore.sh +0 -105
  85. package/templates/backend/src/modules/ops/sqlite-backup-list.probe.ts +0 -100
  86. package/templates/backend/src/modules/ops/sqlite-backup-log.probe.ts +0 -63
  87. package/templates/backend/src/modules/ops/sqlite-backup.probe.ts +0 -144
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * backend 拉取(mirror 反向)—— 从服务器拉回 docker 镜像 + 部署配置,合并压缩下载到本地。
4
+ *
5
+ * 用途:迁移备份。发布是「本地 → 服务器」(publish.cjs 薄上传),本脚本是反向的
6
+ * 「服务器 → 本地」:把服务器上现场构建的镜像(docker save)与 APP_ROOT 部署配置
7
+ * 打成一个归档,scp 下载到本地 release/。迁移新机时:docker load 镜像 tar +
8
+ * 解包 app/ 即可起服务(compose 文件注释里的「迁移机 docker load 同 tag 镜像」伏笔)。
9
+ *
10
+ * 目标选择:--env <名> 选 server.config.cjs targets[名];未传且配了 targets 交互列选;
11
+ * 都没有则顶层单服务器形态(与 publish/rollback 同语义,统一走 lib/ssh.cjs pickTarget)。
12
+ * 镜像列表:current/package.json 的 name 派生镜像名(与 ops config.sh pkg_name 同口径:
13
+ * 剥 @scope 取 basename),docker images 按前缀过滤出全部历史 tag(即回滚窗口),单选一个
14
+ * (默认 current 的 version,--tag 可跳过交互)。
15
+ * 配置包:APP_ROOT 根部小文件(.mode/.prod.secrets[加密部署才有]/.prod.docker.env + 顶层 install/start/stop/
16
+ * status/versionswitch 脚本)+ APP_ROOT/config/(部署位配置:config.base.yaml + config.yaml
17
+ * 环境真值 —— 必须随迁,业务配置的单一真相源)+ APP_ROOT/ops/(mode 派发脚本,部署态权威含
18
+ * 运维护过的 config.sh)+ current/ 解引用内容(纯业务包:compose/Dockerfile/config);
19
+ * data/(sqlite+uploads)默认不带,交互询问,--data/--no-data 覆盖;logs/、releases/ 恒不带。
20
+ *
21
+ * 归档结构(下载到 release/pull-<name>-<时间戳>.tar.gz):
22
+ * images/<repo>_<tag>.tar.gz # 迁移机 docker load 用
23
+ * app/ # current 解引用(纯业务包:dist/package.json/Dockerfile/compose/config)
24
+ * ops/ # APP_ROOT/ops/(mode 派发:docker/pm2/systemd + run-container.sh)
25
+ * root/ # APP_ROOT 根部部署态文件(.mode/.prod.secrets[加密部署才有]/.prod.docker.env + 顶层 5 脚本)
26
+ * root/config/ # 部署位配置(config.base.yaml + config.yaml 环境真值)
27
+ * data/ # 可选(交互/--data 决定)
28
+ *
29
+ * 用法: pnpm pulldocker [--env <名>] [--tag <tag>] [--data|--no-data]
30
+ */
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const readline = require('readline');
34
+ const { log, fatal, shq, connect, sshExec, pickTarget } = require('./lib/ssh.cjs');
35
+
36
+ const BACKEND = path.resolve(__dirname, '..');
37
+ const cfg = require('./server.config.cjs');
38
+
39
+ // ── 本地交互(readline;非 TTY 下 ask 走默认值/警告,不挂起) ──
40
+ function ask(question, { def = '' } = {}) {
41
+ return new Promise((resolve) => {
42
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
43
+ if (def !== '') log.warn(`非交互环境,${question.trim()} → 默认 ${def}`);
44
+ resolve(def);
45
+ return;
46
+ }
47
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
48
+ // 空输入(直接回车)回落 def:否则回车得 '' → Number('')=0 → 无效序号死循环
49
+ rl.question(question, (ans) => { rl.close(); resolve(ans.trim() || def); });
50
+ });
51
+ }
52
+
53
+ async function confirm(question, def = false) {
54
+ const hint = def ? '[Y/n]' : '[y/N]';
55
+ const ans = await ask(`${question} ${hint} `, { def: def ? 'y' : 'n' });
56
+ return /^(y|yes)/i.test(ans);
57
+ }
58
+
59
+ // 编号单选:items = [{ label, mark }](mark 为附加标记如「← current」)
60
+ async function pickOne(title, items, { defIndex = 0 } = {}) {
61
+ console.log(`\n${title}`);
62
+ items.forEach((it, i) => console.log(` ${C.gray}${String(i + 1).padStart(2)}.${C.reset} ${it.label}${it.mark ? ` ${C.cyan}${it.mark}${C.reset}` : ''}`));
63
+ const def = defIndex + 1;
64
+ while (true) {
65
+ const ans = await ask(`选择序号 [1-${items.length},回车=${def}]: `, { def: String(def) });
66
+ const n = Number(ans);
67
+ if (Number.isInteger(n) && n >= 1 && n <= items.length) return items[n - 1];
68
+ log.warn(`无效序号: ${ans}(1-${items.length} 或回车)`);
69
+ }
70
+ }
71
+
72
+ // ── 命令行参数 ──
73
+ const C = { reset: '\x1b[0m', cyan: '\x1b[36m', gray: '\x1b[90m' };
74
+ function takeFlag(name) {
75
+ const i = process.argv.indexOf(name);
76
+ return i === -1 ? null : process.argv.splice(i, 2)[1] ?? true;
77
+ }
78
+ const tagArg = takeFlag('--tag');
79
+ if (tagArg !== null && typeof tagArg !== 'string') fatal('--tag 缺少值: --tag <tag>(镜像 tag,即 version)');
80
+ const dataArg = takeFlag('--data') ? true : takeFlag('--no-data') ? false : null;
81
+
82
+ // ── 目标选择:--env > targets 交互列选 > 顶层单服务器(共享 lib/ssh.cjs pickTarget,pickOne 留作镜像单选) ──
83
+
84
+ // ── main ──
85
+ async function main() {
86
+ const { target } = await pickTarget(cfg);
87
+ const appRoot = target.backendServiceDir;
88
+
89
+ const ssh = await connect(target);
90
+ let tmpDir = '';
91
+ let remoteTar = '';
92
+ try {
93
+ // 部署完整性:current 必须在位(name/version 都从服务器侧读,部署态权威,不信任本地包)
94
+ const installed = await sshExec(ssh, `test -f ${shq(`${appRoot}/current/package.json`)} && echo yes || echo no`);
95
+ if (installed !== 'yes') fatal(`远端 ${appRoot}/current/package.json 不存在(尚未部署,无可拉取)`);
96
+
97
+ const mode = await sshExec(ssh, `cat ${shq(`${appRoot}/.mode`)} 2>/dev/null || echo unknown`).catch(() => 'unknown');
98
+ if (mode !== 'docker') log.warn(`远端 .mode=${mode}(非 docker 模式):镜像拉取仅对 docker 部署有意义,配置包照拉`);
99
+
100
+ // 镜像名派生:pkg_name 同口径(剥 @scope 取 basename),服务端 current/package.json 权威
101
+ const pjRaw = await sshExec(ssh, `cat ${shq(`${appRoot}/current/package.json`)}`);
102
+ let pkgName = '', curVersion = '';
103
+ try {
104
+ const pj = JSON.parse(pjRaw);
105
+ pkgName = String(pj.name || '').split('/').pop();
106
+ curVersion = String(pj.version || '');
107
+ } catch (e) { fatal(`current/package.json 解析失败: ${e.message}`); }
108
+ if (!pkgName) fatal('current/package.json 缺 name');
109
+ log.ok(`镜像名(包名派生): ${pkgName}(current @ v${curVersion})`);
110
+
111
+ // 列出该服务全部镜像 tag(回滚窗口全部版本;<none> 悬空层跳过、ERE 元字符转义防误配)
112
+ const imgRe = pkgName.replace(/[.[\]*\\(){}?+|^$]/g, '\\$&');
113
+ const imgRaw = await sshExec(ssh,
114
+ `docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}' | grep -E '^${imgRe}([:/]|$)' | grep -v '<none>' || true`);
115
+ const images = imgRaw ? imgRaw.split('\n').filter(Boolean).map((l) => {
116
+ const [ref, size, since] = l.split('\t');
117
+ return { ref, size, since };
118
+ }) : [];
119
+ if (images.length === 0) fatal(`服务器无 ${pkgName} 前缀镜像(非 docker 部署或已清理)`);
120
+ log.info(`服务器现有镜像 tag:`);
121
+ images.forEach((im) => console.log(` ${C.gray}-${C.reset} ${im.ref} ${C.gray}(${im.size}, ${im.since})${C.reset}`));
122
+
123
+ // 单选一个(--tag 直选;默认 current 的 version)
124
+ let image = null;
125
+ if (tagArg) {
126
+ image = images.find((im) => im.ref === `${pkgName}:${tagArg}` || im.ref.endsWith(`:${tagArg}`));
127
+ if (!image) fatal(`--tag ${tagArg} 未命中镜像列表(可用: ${images.map((i) => i.ref).join(', ')})`);
128
+ log.ok(`--tag 直选: ${image.ref}`);
129
+ } else {
130
+ const defIndex = Math.max(0, images.findIndex((im) => im.ref === `${pkgName}:${curVersion}`));
131
+ const picked = await pickOne('拉取哪个镜像?(默认 current 版本)', images.map((im) => ({
132
+ label: `${im.ref} ${C.gray}(${im.size}, ${im.since})${C.reset}`,
133
+ value: im,
134
+ mark: im.ref === `${pkgName}:${curVersion}` ? '← current' : '',
135
+ })), { defIndex });
136
+ image = picked.value;
137
+ }
138
+
139
+ // data/(sqlite+uploads)是否随包:--data/--no-data > 交互(默认不带)
140
+ const hasData = await sshExec(ssh, `test -d ${shq(`${appRoot}/data`)} && echo yes || echo no`);
141
+ let withData = false;
142
+ if (dataArg !== null) withData = dataArg;
143
+ else if (hasData === 'yes') withData = await confirm(`是否包含 data/(sqlite+uploads,可能很大)?`, false);
144
+ if (withData && hasData !== 'yes') { log.warn('data/ 不存在,跳过'); withData = false; }
145
+ log.ok(`镜像 ${image.ref} + 配置包${withData ? ' + data/' : ''}`);
146
+
147
+ // ── 远端组装归档(临时目录放 APP_ROOT 下:与部署同盘,避免 /tmp tmpfs 空间坑) ──
148
+ const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
149
+ tmpDir = `${appRoot}/.pulldocker-tmp-${ts}-${process.pid}`;
150
+ remoteTar = `${appRoot}/pull-${pkgName}-${ts}.tar.gz`;
151
+ const imgFile = `images/${image.ref.replace(/[:/]/g, '_')}.tar.gz`;
152
+ const realCurrent = await sshExec(ssh, `readlink -f ${shq(`${appRoot}/current`)}`);
153
+
154
+ log.info('组装远端归档 ...');
155
+ await sshExec(ssh, `mkdir -p ${shq(`${tmpDir}/images`)} ${shq(`${tmpDir}/app`)} ${shq(`${tmpDir}/root`)}`);
156
+
157
+ // 1) docker save 镜像(gzip 单层压缩;这层是归档里的大头)
158
+ log.step(`docker save ${image.ref} ...(镜像大时耗时,耐心等待)`);
159
+ await sshExec(ssh, `docker save ${shq(image.ref)} | gzip > ${shq(`${tmpDir}/${imgFile}`)}`);
160
+
161
+ // 2) 配置包:current 解引用内容(纯业务包:compose/Dockerfile/config;顶层脚本与 ops/ 不在
162
+ // current,分别由 3)/3b) 从 APP_ROOT 根与 APP_ROOT/ops/ 捕获)
163
+ await sshExec(ssh, `cp -a ${shq(`${realCurrent}/.`)} ${shq(`${tmpDir}/app/`)}`);
164
+ // 3) APP_ROOT 根部部署态文件(缺哪个跳哪个:systemd/pm2 模式没有 .prod.docker.env);
165
+ // 部署位 config/(config.base.yaml + config.yaml 环境真值)一整目录随迁 —— 业务配置
166
+ // 单一真相源,丢了它迁移机起不来(凭据/端口全在内)
167
+ for (const f of ['.mode', '.prod.secrets', '.prod.docker.env']) {
168
+ await sshExec(ssh, `if [ -f ${shq(`${appRoot}/${f}`)} ]; then cp -a ${shq(`${appRoot}/${f}`)} ${shq(`${tmpDir}/root/`)}; fi`);
169
+ }
170
+ await sshExec(ssh, `if [ -d ${shq(`${appRoot}/config`)} ]; then mkdir -p ${shq(`${tmpDir}/root/config`)} && cp -a ${shq(`${appRoot}/config/.`)} ${shq(`${tmpDir}/root/config/`)}; fi`);
171
+ // 3b) 顶层脚本(install.sh 首次部署拷到 APP_ROOT 根,start.sh 起容器全靠它)+ ops/(mode
172
+ // 派发脚本,部署态权威:含运维护过的 config.sh;current 是纯业务包,这两处都不在)
173
+ const topScripts = ['install.sh', 'start.sh', 'stop.sh', 'versionswitch.sh', 'status.sh'];
174
+ for (const f of topScripts) {
175
+ await sshExec(ssh, `if [ -f ${shq(`${appRoot}/${f}`)} ]; then cp -a ${shq(`${appRoot}/${f}`)} ${shq(`${tmpDir}/root/`)}; fi`);
176
+ }
177
+ await sshExec(ssh, `if [ -d ${shq(`${appRoot}/ops`)} ]; then cp -a ${shq(`${appRoot}/ops`)} ${shq(`${tmpDir}/ops`)}; fi`);
178
+ // 4) data/ 可选(数据库 + 上传,跨版本共享盘)
179
+ if (withData) {
180
+ log.info('拷贝 data/(数据库+上传,可能耗时)...');
181
+ await sshExec(ssh, `cp -a ${shq(`${appRoot}/data`)} ${shq(`${tmpDir}/data`)}`);
182
+ }
183
+
184
+ // 5) 合并压缩(外层 tar.gz:app/root 可压,text 层小;images/*.tar.gz 已压过,gzip 存近似零增益)
185
+ const sizeRaw = await sshExec(ssh, `du -sh ${shq(tmpDir)} | cut -f1`);
186
+ log.info(`归档体积约 ${sizeRaw},tar 打包 ...`);
187
+ await sshExec(ssh, `tar -C ${shq(tmpDir)} -czf ${shq(remoteTar)} .`);
188
+
189
+ // ── 下载(scp) ──
190
+ fs.mkdirSync(path.join(BACKEND, 'release'), { recursive: true });
191
+ const localTar = path.join(BACKEND, 'release', path.basename(remoteTar));
192
+ log.info(`下载 → ${path.relative(BACKEND, localTar)} ...`);
193
+ await ssh.getFile(localTar, remoteTar);
194
+ log.ok(`已下载: ${path.relative(BACKEND, localTar)}`);
195
+
196
+ console.log(`\n归档结构: images/(${image.ref}) + app/(current 纯业务包) + ops/(mode 派发) + root/(.mode/.prod.secrets/.prod.docker.env + 顶层 5 脚本 + config/ 部署位配置)${withData ? ' + data/' : ''}`);
197
+ log.info('迁移新机: pushdocker 一键推(镜像 load + current/ops/根部落位 + 起容器);手动则 tar 解包 → docker load → app/→current、ops/→ops、root/*→APP_ROOT 根 → bash start.sh');
198
+ } finally {
199
+ // 远端清理:临时目录 + 归档均不留(best-effort,不阻塞退出)
200
+ if (ssh) {
201
+ if (tmpDir) await sshExec(ssh, `rm -rf ${shq(tmpDir)}`).catch(() => {});
202
+ if (remoteTar) await sshExec(ssh, `rm -f ${shq(remoteTar)}`).catch(() => {});
203
+ ssh.dispose();
204
+ }
205
+ }
206
+ }
207
+
208
+ main().catch((e) => fatal(e.message));
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * backend 推送(pulldocker 对偶)—— 把拉取归档(镜像+配置)推到目标服务器,docker load 后起容器。
4
+ *
5
+ * 用途:迁移。pulldocker 把镜像(docker save)+ 部署配置拉到本地归档;本脚本做反向:上传归档 →
6
+ * 远端 docker load 镜像 → 按 install.sh 的 docker 部署力学落位(current=真实目录 + 顶层脚本 +
7
+ * ops/(mode 派发)+ 根部 .prod.secrets[加密部署才有]/.prod.docker.env + 部署位 config/(config.base.yaml +
8
+ * config.yaml 环境真值,业务配置单一真相源))→ 跑 APP_ROOT/start.sh 起容器
9
+ * (run-container.sh 停删旧容器重建,env/secrets/配置挂载力学复用单一真相源,本脚本零 docker run 参数)。
10
+ *
11
+ * 目标机两种状态:
12
+ * 裸机(无 .mode):引导落位 —— 建 releases/data/logs 骨架,直接铺内容
13
+ * 已部署(.mode 存在,须为 docker 模式):交互确认后覆盖 —— 旧 root 密钥文件/env 先备份
14
+ * 为 .bak.<ts>,包内 data/ 与远端 data/ 同在时再确认一次,然后按 install.sh 原语落位
15
+ *
16
+ * 端口确认(落位前做,取消零损伤):生效 PORT 的 env 源 = 包内 root/.prod.docker.env(包内赢)
17
+ * > 目标机根部 env > 回退 3001(与 ops/docker/config.sh::read_env_port 同口径)。被非本应用
18
+ * 旧容器占用时交互三选一(换端口 sed 改写该 env 源 / 强推沿用 / 取消);--port <n> 非交互
19
+ * 直选,占用即拦(CI 逃生口)。
20
+ *
21
+ * 输入内容(--from <绝对路径>,未传交互输入;归档/目录两种形态):
22
+ * pull-<name>-<ts>.tar.gz # pulldocker 产出,直接上传远端解
23
+ * 已解包目录 # 本地打 tar 再上传(目录里 images/ + app/ [+ ops/][+ root/][+ data/])
24
+ * 本地校验:images/ 必须非空(docker save 产物)、app/ 必须在(release 内容);
25
+ * 缺 root/:裸机直接拦(没有 .prod.docker.env 起不动),已部署机告警沿用目标机根文件;
26
+ * 缺 ops/(mode 派发脚本,起容器必需):裸机直接拦,已部署机告警沿用目标机 ops/。
27
+ *
28
+ * 用法: pnpm pushdocker [--env <名>] [--from <绝对路径>] [--port <1-65535>]
29
+ */
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const readline = require('readline');
33
+ const { spawn } = require('child_process');
34
+ const { log, fatal, shq, connect, sshExec, pickTarget } = require('./lib/ssh.cjs');
35
+
36
+ const cfg = require('./server.config.cjs');
37
+
38
+ // ── 本地交互(readline;非 TTY 下 ask 走默认值/警告,不挂起) ──
39
+ function ask(question, { def = '' } = {}) {
40
+ return new Promise((resolve) => {
41
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
42
+ if (def === '') { console.error(`✗ 非交互环境无法回答: ${question.trim()}`); process.exit(1); }
43
+ log.warn(`非交互环境,${question.trim()} → 默认 ${def}`);
44
+ resolve(def);
45
+ return;
46
+ }
47
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
48
+ // 空输入(直接回车)回落 def:否则回车得 '' → Number('')=0 → 无效序号死循环
49
+ rl.question(question, (ans) => { rl.close(); resolve(ans.trim() || def); });
50
+ });
51
+ }
52
+
53
+ async function confirm(question, def = false) {
54
+ const hint = def ? '[Y/n]' : '[y/N]';
55
+ const ans = await ask(`${question} ${hint} `, { def: def ? 'y' : 'n' });
56
+ return /^(y|yes)/i.test(ans);
57
+ }
58
+
59
+ const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
60
+ const isValidPort = (v) => /^\d{1,5}$/.test(v) && +v >= 1 && +v <= 65535;
61
+
62
+ // ── 端口确认 ──
63
+ // 读 env 文件 PORT(剥引号/空白,与 ops/docker/config.sh::read_env_port 同口径)
64
+ async function readEnvPort(ssh, envFile) {
65
+ return sshExec(ssh,
66
+ `sed -nE 's/^PORT=//p' ${shq(envFile)} | head -1 ` +
67
+ `| sed -E "s/^['\\\"]?//; s/['\\\"]?$//; s/^[[:space:]]+//; s/[[:space:]]+$//"`);
68
+ }
69
+
70
+ // 远端占用探测 → { free, who[] }。本应用旧容器发布的宿主端口不算占用:run-container.sh 起
71
+ // 容器前会停删同名旧容器,该端口马上腾出。监听判定:$4 末段按 ':' 切与端口比对(IPv4/IPv6
72
+ // 通吃),ss 缺席回退 netstat。who 尽力给出占用者(docker 可见的容器;非容器进程仅见监听行,
73
+ // -p 看进程需 root,不强求)。
74
+ async function remotePortStat(ssh, port, ctName) {
75
+ const script = [
76
+ 'p=$1; ct=$2',
77
+ 'own=$(docker ps -a --filter "name=^/${ct}$" --format "{{.Ports}}" 2>/dev/null | tr "," "\\n" | grep -oE ":[0-9]+->" | tr -d ":>-" | tr "\\n" " ")',
78
+ 'case " $own " in *" $p "*) echo FREE; exit 0;; esac',
79
+ 'if (ss -ltn 2>/dev/null || netstat -ltn 2>/dev/null) | awk -v P="$p" \'{n=split($4,a,":"); if (a[n]==P) f=1} END{exit f?0:1}\'; then',
80
+ ' echo BUSY',
81
+ ' docker ps --format "{{.Names}} {{.Ports}}" 2>/dev/null | grep ":${p}->" | sed "s/^/ /" || true',
82
+ ' (ss -ltnp 2>/dev/null || netstat -ltnp 2>/dev/null) | grep -E "[:.]${p}[[:space:]]" | grep -oE "users:\\(\\(.+\\)\\)" | head -3 | sed "s/^/ /" || true',
83
+ ' exit 0',
84
+ 'fi',
85
+ 'echo FREE',
86
+ ].join('\n');
87
+ const out = await sshExec(ssh, `bash -c ${shq(script)} probe ${shq(port)} ${shq(ctName)}`);
88
+ const [head, ...who] = out.split('\n');
89
+ if (head !== 'FREE' && head !== 'BUSY') throw new Error(`远端端口探测输出异常: ${out}`);
90
+ return { free: head === 'FREE', who: who.filter(Boolean).map((l) => l.trim()) };
91
+ }
92
+
93
+ // ── 命令行参数 ──
94
+ const tagArgIdx = process.argv.indexOf('--from');
95
+ const fromArg = tagArgIdx === -1 ? null : (() => {
96
+ const v = process.argv[tagArgIdx + 1];
97
+ if (!v || v.startsWith('--')) fatal('--from 缺少值: --from <绝对路径>(pull-*.tar.gz 或解包目录)');
98
+ process.argv.splice(tagArgIdx, 2);
99
+ return v;
100
+ })();
101
+
102
+ // --port <1-65535>:非交互端口直选(CI 逃生口;跳过询问,占用即拦)
103
+ const portArgIdx = process.argv.indexOf('--port');
104
+ const portArg = portArgIdx === -1 ? null : (() => {
105
+ const v = process.argv[portArgIdx + 1];
106
+ if (!v || v.startsWith('--')) fatal('--port 缺少值: --port <1-65535>');
107
+ if (!/^\d{1,5}$/.test(v) || +v < 1 || +v > 65535) fatal(`--port 非法: ${v}(须 1-65535)`);
108
+ process.argv.splice(portArgIdx, 2);
109
+ return String(+v);
110
+ })();
111
+
112
+ // ── 目标选择:--env > targets 交互列选 > 顶层单服务器(共享 lib/ssh.cjs pickTarget) ──
113
+
114
+ // ── 输入内容定位与校验(归档或目录 → 统一为本地 tar 路径 + 结构信息) ──
115
+ // 返回 { tarPath, localDir(目录形态时的临时 tar 需清理), hasRoot, hasData }。归档不解包校验,
116
+ // 结构由远端解包后自检(远端为权威,本地解一遍只为省一次上传失败往返,不值得)。
117
+ function resolvePayload(from) {
118
+ const abs = path.resolve(from);
119
+ if (!fs.existsSync(abs)) fatal(`路径不存在: ${abs}`);
120
+ if (fs.statSync(abs).isFile()) {
121
+ if (!/\.tar\.gz$/.test(abs)) fatal(`归档形态仅支持 pull-*.tar.gz(得到: ${path.basename(abs)})`);
122
+ return { tarPath: abs, localDir: '', hasRoot: true }; // 结构远端解包后判
123
+ }
124
+ // 目录形态:校验后本地打 tar(macOS 双治理同 publish: COPYFILE_DISABLE + --no-xattrs)
125
+ for (const d of ['images', 'app']) {
126
+ if (!fs.existsSync(path.join(abs, d))) fatal(`目录缺 ${d}/(结构 = images/ + app/ [+ ops/][+ root/][+ data/]): ${abs}`);
127
+ }
128
+ const imgs = fs.readdirSync(path.join(abs, 'images')).filter((f) => f.endsWith('.tar.gz'));
129
+ if (imgs.length === 0) fatal(`images/ 无 *.tar.gz(docker save 产物缺失): ${abs}/images`);
130
+ const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
131
+ const tmpTar = path.join(abs, `../pushdocker-${path.basename(abs)}-${ts}.tar.gz`);
132
+ return { tarPath: tmpTar, localDir: abs, hasRoot: fs.existsSync(path.join(abs, 'root')) };
133
+ }
134
+
135
+ // 本地 tar 打包(目录 → 归档;同步 spawn,与 publish makeTar 同款双治理)
136
+ function makeTar(dir, tarPath) {
137
+ return new Promise((resolve, reject) => {
138
+ const p = spawn('tar', ['--no-xattrs', '-czf', tarPath, '-C', dir, '.'], {
139
+ stdio: 'inherit', env: { ...process.env, COPYFILE_DISABLE: '1' },
140
+ });
141
+ p.on('error', reject);
142
+ p.on('close', (c) => (c === 0 ? resolve(tarPath) : reject(new Error(`tar exit ${c}`))));
143
+ });
144
+ }
145
+
146
+ async function main() {
147
+ // 0) 目标机 + 输入内容
148
+ const { target } = await pickTarget(cfg);
149
+ const appRoot = target.backendServiceDir;
150
+ const from = fromArg || await ask('输入上传内容绝对路径(pull-*.tar.gz 或解包目录): ');
151
+ const { tarPath, localDir, hasRoot } = resolvePayload(from);
152
+ if (localDir) {
153
+ log.info(`目录形态:本地打 tar(${path.basename(tarPath)})...`);
154
+ await makeTar(localDir, tarPath);
155
+ }
156
+ log.ok(`内容: ${tarPath}`);
157
+ if (!hasRoot) log.warn('包内无 root/(.mode/.prod.secrets[加密部署才有]/.prod.docker.env/config/):裸机部署会被拦,已部署机沿用目标机根文件');
158
+
159
+ const ssh = await connect(target);
160
+ const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
161
+ const staging = `${appRoot}/.pushdocker-tmp-${ts}-${process.pid}`;
162
+ try {
163
+ // 1) 前置检查:docker 必在
164
+ const hasDocker = await sshExec(ssh, `command -v docker >/dev/null 2>&1 && echo yes || echo no`);
165
+ if (hasDocker !== 'yes') throw new Error('远端无 docker 命令:先安装 docker(pull 只迁了镜像和配置,runtime 不带)');
166
+
167
+ // 2) 目标机状态:裸机 / 已部署(须 docker 模式)
168
+ const mode = await sshExec(ssh, `cat ${shq(`${appRoot}/.mode`)} 2>/dev/null || echo none`).catch(() => 'none');
169
+ const deployed = mode !== 'none';
170
+ if (deployed) {
171
+ if (mode !== 'docker') throw new Error(`目标机 .mode=${mode}(非 docker):本脚本只推 docker 部署(mode 不同手动处理)`);
172
+ log.warn('目标机已部署(.mode=docker):覆盖将 rm 旧 current/、用包内文件盖根部 env/密钥');
173
+ const go = await confirm('确认继续推送到已部署的目标机吗?', false);
174
+ if (!go) throw new Error('已取消(目标机未做任何改动)');
175
+ }
176
+
177
+ // 3) 上传 + 解包(scp → APP_ROOT 下临时目录)
178
+ const remoteTar = `${staging}.tar.gz`;
179
+ await sshExec(ssh, `mkdir -p ${shq(appRoot)}`);
180
+ log.info(`上传 ${path.basename(tarPath)} → ${appRoot} ...`);
181
+ await ssh.putFile(tarPath, remoteTar);
182
+ await sshExec(ssh, `mkdir -p ${shq(staging)} && tar -xzf ${shq(remoteTar)} -C ${shq(staging)}`);
183
+
184
+ // 3.1) 远端结构自检(归档形态不解包看不到内容,校验收敛在此;与目录形态本地校验同口径)
185
+ // throw 而非 fatal:fatal 走 process.exit(1) 同步终止会跳过 finally,远端 staging 会残留。
186
+ const rHasApp = await sshExec(ssh, `test -f ${shq(`${staging}/app/package.json`)} && echo yes || echo no`);
187
+ if (rHasApp !== 'yes') throw new Error('包内 app/package.json 不存在(app/ 缺失或结构不对;只收 pulldocker 归档)');
188
+ const nImgs = Number(await sshExec(ssh,
189
+ `ls -1 ${shq(`${staging}/images`)}/*.tar.gz 2>/dev/null | wc -l | tr -d ' '`));
190
+ if (!Number.isInteger(nImgs) || nImgs < 1) throw new Error('包内 images/ 无 *.tar.gz(docker save 产物缺失)');
191
+ const rHasRoot = await sshExec(ssh, `test -d ${shq(`${staging}/root`)} && echo yes || echo no`);
192
+ const rHasOps = await sshExec(ssh, `test -d ${shq(`${staging}/ops`)} && echo yes || echo no`);
193
+ const rHasData = await sshExec(ssh, `test -d ${shq(`${staging}/data`)} && echo yes || echo no`);
194
+ if (rHasRoot !== 'yes' && !deployed) throw new Error('裸机部署但包内无 root/(缺 .prod.docker.env,起不动容器);重新 pulldocker 带齐');
195
+ if (rHasOps !== 'yes' && !deployed) throw new Error('裸机部署但包内无 ops/(缺 mode 派发脚本,起不动容器);重新 pulldocker 带齐');
196
+
197
+ // 3.2) 端口确认(此时未落位,取消零损伤):生效 PORT 的 env 源 = 包内 root/.prod.docker.env
198
+ // (包内赢,落位时盖过去)> 目标机根部 env > 回退 3001(与 config.sh::read_env_port 同口径)。
199
+ // 换端口只 sed 该 env 源的 PORT 行,不另开旁路 —— 落位后 run-container.sh 自然读到新值。
200
+ const stagingEnv = `${staging}/root/.prod.docker.env`;
201
+ const rootEnv = `${appRoot}/.prod.docker.env`;
202
+ const hasStagingEnv = (await sshExec(ssh, `test -f ${shq(stagingEnv)} && echo yes || echo no`)) === 'yes';
203
+ const hasRootEnv = deployed && (await sshExec(ssh, `test -f ${shq(rootEnv)} && echo yes || echo no`)) === 'yes';
204
+ const envFile = hasStagingEnv ? stagingEnv : (hasRootEnv ? rootEnv : '');
205
+ // 容器名按 pkg_name 口径派生(剥 @scope 取 basename;与步骤 7 同款,current 即 app 拷贝)
206
+ const ctName = await sshExec(ssh,
207
+ `grep -m1 '"name"' ${shq(`${staging}/app/package.json`)} ` +
208
+ `| sed -E 's/.*"name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\\1/' | sed -E 's|.*/||'`);
209
+ const port0 = envFile ? await readEnvPort(ssh, envFile) : '';
210
+ let port = port0 || '3001';
211
+ if (!port0) log.warn(`PORT 未定义(根部与包内 env 均无)→ 按回退端口 ${port} 探测(与 config.sh 口径一致)`);
212
+ let portChanged = portArg != null && portArg !== port;
213
+ if (portArg) port = portArg;
214
+ while (true) {
215
+ const st = await remotePortStat(ssh, port, ctName);
216
+ if (st.free) break;
217
+ const whoTxt = st.who.length ? `:${st.who.join('; ')}` : '(占用者不可见,多为非容器进程)';
218
+ if (portArg) throw new Error(`--port ${port} 已被占用${whoTxt}(非交互不询问;换 --port 或停掉占用者后重试)`);
219
+ if (!isTTY) throw new Error(`端口 ${port} 已被占用${whoTxt}(非交互环境无法询问;重跑加 --port <可用端口>)`);
220
+ log.warn(`端口 ${port} 已被占用${st.who.length ? ':' : ''}`);
221
+ st.who.forEach((l) => console.log(` ${l}`));
222
+ const sel = await ask(`请选择: [1] 输入新端口 [2] 仍用 ${port} 继续 [3] 取消 `);
223
+ if (sel === '2') { log.warn(`沿用占用端口 ${port}(若占用者不释放,docker run 会因端口绑定失败)`); break; }
224
+ if (sel === '3') throw new Error(`已取消(端口 ${port} 被占,目标机未做任何改动)`);
225
+ if (sel !== '1') { log.warn(`无效选择: ${sel || '(空)'}(1/2/3)`); continue; }
226
+ const np = await ask(`新端口(1-65535): `);
227
+ if (!isValidPort(np)) { log.warn(`端口非法: ${np || '(空)'}(须 1-65535)`); continue; }
228
+ port = np; portChanged = true; // 先探测确认可用,再落盘(循环回来 free 才写 env)
229
+ }
230
+ if (portChanged && envFile) {
231
+ await sshExec(ssh, `sed -i -E "s/^PORT=.*/PORT=${port}/" ${shq(envFile)}`);
232
+ log.ok(`端口 ${port0 || '(未定义)'} → ${port},已写入 ${envFile === stagingEnv ? '包内 root' : '目标机根部'}/.prod.docker.env`);
233
+ } else if (portChanged) {
234
+ log.warn(`--port ${port} 但根部与包内均无 .prod.docker.env 可写;落位后请手动确认 env 的 PORT`);
235
+ } else if (!portArg) {
236
+ log.info(`端口 ${port} 可用`);
237
+ }
238
+
239
+ // 4) docker load(原生支持 gzip 包;逐个 load 齐活,保留回滚窗口内的全部历史 tag)
240
+ log.info(`docker load ${nImgs} 个镜像 tar ...`);
241
+ const loadOut = await sshExec(ssh,
242
+ `for f in ${shq(`${staging}/images`)}/*.tar.gz; do echo "== $f"; docker load -i "$f" || exit 1; done`);
243
+ console.log(loadOut.split('\n').map((l) => ` ${l}`).join('\n'));
244
+
245
+ // 5) 落位(对齐 install.sh docker 部署原语)
246
+ await sshExec(ssh, `mkdir -p ${shq(`${appRoot}/releases`)} ${shq(`${appRoot}/data`)} ${shq(`${appRoot}/logs`)}`);
247
+ // current:docker=真实目录,整体重建(与 install.sh lay_out_release 同款;data 在宿主 data/,不动)
248
+ log.info('落位 current/(app → current 整体重建)...');
249
+ await sshExec(ssh, `rm -rf ${shq(`${appRoot}/current`)} && mkdir -p ${shq(`${appRoot}/current`)} && cp -a ${shq(`${staging}/app/.`)} ${shq(`${appRoot}/current/`)}`);
250
+ // 顶层脚本 + root 文件:统一走 root/(pulldocker 从 APP_ROOT 根捕获;current 是纯业务包,
251
+ // 顶层脚本不在其中)。已部署机先备份再覆盖;仅拷包内实际有的。
252
+ if (rHasRoot === 'yes') {
253
+ const rootFiles = '.mode .prod.secrets .prod.docker.env install.sh start.sh stop.sh versionswitch.sh status.sh';
254
+ log.info(deployed ? '根部文件备份并覆盖(.bak.<ts>)...' : '根部文件落位(.mode/.prod.secrets/.prod.docker.env + 顶层脚本)...');
255
+ const backupCmd = deployed
256
+ ? `for f in ${rootFiles}; do [ -f ${shq(appRoot)}/$f ] && cp -a ${shq(appRoot)}/$f ${shq(appRoot)}/$f.bak.${ts}; done; true`
257
+ : '';
258
+ await sshExec(ssh,
259
+ `${backupCmd} for f in ${rootFiles}; do [ -f ${shq(`${staging}/root`)}/$f ] && cp -a ${shq(`${staging}/root`)}/$f ${shq(appRoot)}/$f; done; true`);
260
+ await sshExec(ssh, `if [ -f ${shq(`${appRoot}/.prod.secrets`)} ]; then chmod 600 ${shq(`${appRoot}/.prod.secrets`)}; fi`);
261
+ // 部署位 config/(config.base.yaml + config.yaml 环境真值):业务配置单一真相源,必随迁。
262
+ // 已部署机:旧 config 备份为 config.bak.<ts>(运维护过的环境真值,不静默丢)再覆盖。
263
+ const rHasRootCfg = (await sshExec(ssh, `test -d ${shq(`${staging}/root/config`)} && echo yes || echo no`)) === 'yes';
264
+ if (rHasRootCfg) {
265
+ log.info(deployed ? '部署位 config/ 备份并覆盖(config.bak.<ts>)...' : '部署位 config/ 落位(config.base.yaml + config.yaml)...');
266
+ const backupCfg = deployed
267
+ ? `rm -rf ${shq(`${appRoot}/config.bak.${ts}`)} && [ -d ${shq(`${appRoot}/config`)} ] && mv ${shq(`${appRoot}/config`)} ${shq(`${appRoot}/config.bak.${ts}`)}; true`
268
+ : '';
269
+ await sshExec(ssh, `${backupCfg} mkdir -p ${shq(`${appRoot}/config`)} && cp -a ${shq(`${staging}/root/config/.`)} ${shq(`${appRoot}/config/`)}`);
270
+ } else if (!deployed) {
271
+ log.warn('包内无 root/config/(部署位配置缺失):容器将用镜像内兜底 config;建议重新 pulldocker 带齐');
272
+ }
273
+ }
274
+ // ops/(mode 派发:start.sh → ops/docker/start.sh → run-container.sh):已部署机先备份再
275
+ // 整体覆盖,已部署且包内无则跳过(沿用目标机,告警)
276
+ if (rHasOps === 'yes') {
277
+ log.info(deployed ? 'ops/ 备份并整体覆盖(ops.bak.<ts>)...' : 'ops/ 落位(mode 派发脚本)...');
278
+ await sshExec(ssh, `rm -rf ${shq(`${appRoot}/ops.bak.${ts}`)} && [ -d ${shq(`${appRoot}/ops`)} ] && mv ${shq(`${appRoot}/ops`)} ${shq(`${appRoot}/ops.bak.${ts}`)}; true`);
279
+ await sshExec(ssh, `cp -a ${shq(`${staging}/ops`)} ${shq(`${appRoot}/ops`)}`);
280
+ } else if (deployed) {
281
+ log.warn('包内无 ops/:沿用目标机 ops/(旧格式归档;建议重新 pulldocker 带齐)');
282
+ }
283
+ // data/:包内有 → 裸机直接合入(空骨架);已部署机与远端 data 非空共存时需确认覆盖
284
+ if (rHasData === 'yes') {
285
+ const remoteDataN = Number(await sshExec(ssh, `ls -A ${shq(`${appRoot}/data`)} 2>/dev/null | wc -l | tr -d ' '`));
286
+ if (deployed && remoteDataN > 0) {
287
+ log.warn('目标机 data/ 非空且包内带 data/:覆盖会改变运行数据库');
288
+ const goData = await confirm(`备份目标机 data/ 为 data.bak.${ts} 后用包内数据覆盖?`, false);
289
+ if (!goData) log.warn('已跳过 data/(沿用目标机数据)');
290
+ else {
291
+ await sshExec(ssh, `mv ${shq(`${appRoot}/data`)} ${shq(`${appRoot}/data.bak.${ts}`)} && cp -a ${shq(`${staging}/data`)} ${shq(`${appRoot}/data`)}`);
292
+ log.ok(`data/ 已覆盖(旧数据 → data.bak.${ts})`);
293
+ }
294
+ } else {
295
+ log.info('落位 data/(sqlite+uploads)...');
296
+ await sshExec(ssh, `cp -a ${shq(`${staging}/data/.`)} ${shq(`${appRoot}/data/`)}`);
297
+ }
298
+ }
299
+
300
+ // 6) run:APP_ROOT/start.sh 自定位 .mode → ops/docker/start.sh → run-container.sh
301
+ // (停删旧容器 + 用刚 load 的镜像重建;env/secrets 注入全部复用服务器侧脚本,零参数漂移)
302
+ log.info('起容器(bash APP_ROOT/start.sh)...');
303
+ const runOut = await sshExec(ssh, `bash ${shq(`${appRoot}/start.sh`)}`);
304
+ console.log(runOut.split('\n').map((l) => ` ${l}`).join('\n'));
305
+
306
+ // 7) 状态回显:容器名沿用 3.2 从 staging/app/package.json 派生的 ctName(current 即其拷贝)
307
+ const status = await sshExec(ssh,
308
+ `docker ps -a --filter "name=${ctName}" --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.Ports}}'`);
309
+ console.log(status);
310
+ log.ok(`容器 ${ctName}(运行状态见上;看日志: APP_ROOT/ops/docker/logs.sh)`);
311
+ log.ok('推送完成(镜像 + current + 根部配置 + 容器)');
312
+ } finally {
313
+ // 远端清理:staging 目录 + 上传 tar 均 best-effort 不阻塞;本地目录形态的临时 tar 同步清
314
+ if (ssh) {
315
+ await sshExec(ssh, `rm -rf ${shq(staging)}`).catch(() => {});
316
+ await sshExec(ssh, `rm -f ${shq(`${staging}.tar.gz`)}`).catch(() => {});
317
+ ssh.dispose();
318
+ }
319
+ if (localDir && fs.existsSync(tarPath)) fs.rmSync(tarPath, { force: true });
320
+ }
321
+ }
322
+
323
+ main().catch((e) => fatal(e.message));
@@ -4,18 +4,21 @@
4
4
  *
5
5
  * 用法:
6
6
  * pnpm rollback -- --only backend # ssh 进服务器,跑 APP_ROOT/versionswitch.sh(交互列版本、选一个)
7
+ * pnpm rollback -- --env <名> # 多服务器:选 server.config.cjs targets[名];不传:配了 targets 交互列选,否则顶层默认目标
7
8
  *
8
9
  * 不外传版本片段:版本选择由服务器 versionswitch.sh 交互完成(systemd/pm2 列 releases 改链,
9
10
  * docker 列镜像 tag 重 run)。本脚本只负责 SSH 连接 + 透传交互 TTY。
10
11
  */
11
12
  const path = require('path');
12
- const { log, fatal, shq, connect, sshExec, sshInteractive } = require('./lib/ssh.cjs');
13
+ const { log, fatal, shq, connect, sshExec, sshInteractive, pickTarget } = require('./lib/ssh.cjs');
13
14
 
14
15
  const cfg = require('./server.config.cjs');
15
16
 
16
17
  async function main() {
17
- const appRoot = cfg.backendServiceDir;
18
- const ssh = await connect(cfg);
18
+ // 多服务器目标:--env <名> > targets 交互列选 > 顶层默认(server.config.cjs)
19
+ const { target } = await pickTarget(cfg);
20
+ const appRoot = target.backendServiceDir;
21
+ const ssh = await connect(target);
19
22
  try {
20
23
  const installed = await sshExec(ssh, `test -f ${shq(`${appRoot}/install.sh`)} && echo yes || echo no`);
21
24
  if (installed !== 'yes') fatal(`远端 ${appRoot}/install.sh 不存在(尚未首次部署,无可回滚版本)`);
@@ -1,3 +1,10 @@
1
+ /*
2
+ * Author Murphy.xie
3
+ * Date 2026-10-23 15:15:32
4
+ * LastEditors Murphy.xie
5
+ * LastEditTime 2026-11-04 14:19:13
6
+ * Description
7
+ */
1
8
  /*
2
9
  * Description
3
10
  */
@@ -8,22 +15,43 @@
8
15
  * ⚠ 含服务器登录密码,server.config.cjs 已 .gitignore 排除,不入库。
9
16
  *
10
17
  * 生产密钥(jwt/admin/appSign)不在本文件 —— systemd/pm2 直读 release 自带
11
- * config/config.prod.yaml(改 current/config/config.prod.yaml + 重启即轮换);
12
- * docker 用服务器侧 APP_ROOT/.env(首次从 .env.example 种子化,空值阻断,运维手填)。
18
+ * 部署位 APP_ROOT/config/config.yaml(install.sh 播种的环境 overlay 副本,改它 + 重启即轮换,
19
+ * 三模式一致;docker 另有 .prod.docker.env 纯部署件[buildops 自动生成],不含业务配置)。
13
20
  */
14
21
  module.exports = {
15
- // ── 服务器连接(SSH,上传 tar + 触发 install.sh 用) ──
16
- server: {
17
- host: '<服务器IP>',
18
- port: 22,
19
- username: 'root',
20
- authMode: 'password', // 'password' | 'privateKey'
21
- password: '<服务器密码>',
22
- // authMode=privateKey 时用(~ 自动展开到家目录)
23
- // privateKey: { privateKeyPath: '~/.ssh/your-key', passphrase: '' }
24
- },
22
+ // ── 多服务器(可选,各机独立实例,互不共享数据) ──
23
+ // 目标解析(publish/rollback/pushdocker/pulldocker 共用):`--env <名>` 显式选;不传时 TTY 下列出 targets 交互选择(非交互(CI)回落首个);未配 targets 则用顶层单服务器形态。
24
+ // 建议与 web/scripts/server.config.json 的 key 同名:根命令 `pnpm publish -- --env <名>` 即 web+backend 同发一台。
25
+ targets: {
26
+ deploy: {
27
+ // ── 服务器连接(SSH,上传 tar + 触发 install.sh 用) ──
28
+ server: {
29
+ host: "<服务器IP>",
30
+ port: 22,
31
+ username: "root",
32
+ authMode: "password", // 'password' | 'privateKey'
33
+ password: "<服务器密码>",
34
+ // authMode=privateKey 时用(~ 自动展开到家目录)
35
+ // privateKey: { privateKeyPath: '~/.ssh/your-key', passphrase: '' }
36
+ },
37
+
38
+ // ── 后端服务目录(服务端 APP_ROOT:current/releases/data/logs 所在) ──
39
+ // 首次部署由 install.sh 在此目录下建 current/releases/data/logs 结构。
40
+ backendServiceDir: "/data/server/fullstack-template",
41
+ keepVersions: 1,
42
+ },
25
43
 
26
- // ── 后端服务目录(服务端 APP_ROOT:current/releases/data/logs 所在) ──
27
- // 首次部署由 install.sh 在此目录下建 current/releases/data/logs 结构。
28
- backendServiceDir: '/data/server/fullstack-template'
44
+ "machine-b": {
45
+ server: {
46
+ host: "<服务器IP>",
47
+ port: 22,
48
+ username: "root",
49
+ authMode: "password",
50
+ password: "<服务器密码>",
51
+ },
52
+ // authMode=privateKey 时:server.privateKey = { privateKeyPath: '~/.ssh/your-key', passphrase: '' }
53
+ backendServiceDir: "/data/server/fullstack-template",
54
+ keepVersions: 1,
55
+ },
56
+ },
29
57
  };
@@ -10,7 +10,7 @@
10
10
  * <db> 为占位值,仅作开关用,不会被当目标路径读取)
11
11
  * --out <file> 输出 markdown 到文件(默认 stdout)
12
12
  *
13
- * 读 data/slowsql/slowsql-YYYY-MM-DD.jsonl,聚合输出:
13
+ * 读 logs/slowsql/slowsql-YYYY-MM-DD.jsonl,聚合输出:
14
14
  * 1. 总览(时间范围/查询量/慢查询/lag/错误)
15
15
  * 2. 慢 SQL 指纹 top 表(含「宿主紧张占比」标注)
16
16
  * 3. lag / 宿主基线摘要
@@ -35,7 +35,7 @@ const OUT = flag('out');
35
35
  const flagValues = new Set([flag('top'), flag('explain'), flag('dialect'), flag('out')]);
36
36
  const targets = args.filter((a) => !a.startsWith('--') && !flagValues.has(a));
37
37
 
38
- const DEFAULT_DIR = path.join(process.cwd(), 'data', 'slowsql');
38
+ const DEFAULT_DIR = path.join(process.cwd(), 'logs', 'slowsql');
39
39
  const files = targets.length
40
40
  ? targets.flatMap((t) => (fs.statSync(t).isDirectory() ? fs.readdirSync(t).map((f) => path.join(t, f)) : [t]))
41
41
  : fs.existsSync(DEFAULT_DIR)