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
@@ -1,57 +1,79 @@
1
1
  /**
2
2
  * description 一键部署脚本
3
- * v1.6版本
3
+ * v1.7版本
4
4
  * 执行命令:npm run publish
5
5
  * 沙盒命令:npm run publishs
6
6
  * v1.5: 自动递增版本号 + 输出构建包大小
7
7
  * v1.6: 支持秘钥登录(privateKeyPath / passphrase)
8
+ * v1.7: 交互化——多服务器目标菜单选择(key(host))、版本号策略选择(自增默认/维持/自定义);
9
+ * 部署后归档 <dist>_releases/v<版本>_<时间戳>.tar.gz(rollback 列版本回退用),按 keepVersions 轮转
8
10
  */
9
- const shelljs = require('shelljs');
10
- const fs = require('fs');
11
- const path = require('path');
12
- const os = require('os');
13
- const { NodeSSH } = require('node-ssh');
14
- const serverConfig = require('./server.config.json');
11
+ const shelljs = require("shelljs");
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const os = require("os");
15
+ const { NodeSSH } = require("node-ssh");
16
+ const {
17
+ selectEnvKey,
18
+ selectVersion,
19
+ closeInteractive,
20
+ } = require("./lib/interactive.cjs");
21
+ const serverConfig = require("./server.config.json");
15
22
 
16
23
  class DynamicOutput {
17
- constructor() {
18
- this.lastMessage = '';
19
- this.progressEnabled = process.stdout.isTTY;
20
- }
21
-
22
- update(message) {
23
- if (this.progressEnabled) {
24
- process.stdout.write('\r' + ' '.repeat(this.lastMessage.length));
25
- process.stdout.write('\r' + message);
26
- this.lastMessage = message;
27
- } else {
28
- shelljs.echo(message);
29
- }
30
- }
31
-
32
- final(message) {
33
- if (this.progressEnabled) {
34
- process.stdout.write('\r' + message + '\n');
35
- } else {
36
- shelljs.echo(message);
37
- }
38
- this.lastMessage = '';
39
- }
24
+ constructor() {
25
+ this.lastMessage = "";
26
+ this.progressEnabled = process.stdout.isTTY;
27
+ }
28
+
29
+ update(message) {
30
+ if (this.progressEnabled) {
31
+ process.stdout.write("\r" + " ".repeat(this.lastMessage.length));
32
+ process.stdout.write("\r" + message);
33
+ this.lastMessage = message;
34
+ } else {
35
+ shelljs.echo(message);
36
+ }
37
+ }
38
+
39
+ final(message) {
40
+ if (this.progressEnabled) {
41
+ process.stdout.write("\r" + message + "\n");
42
+ } else {
43
+ shelljs.echo(message);
44
+ }
45
+ this.lastMessage = "";
46
+ }
40
47
  }
41
48
 
42
49
  const output = new DynamicOutput();
43
50
  const SSH_STATUS = {
44
- CONNECTING: '正在连接服务器...',
45
- UPLOADING: '正在上传文件...',
46
- DEPLOYING: '正在部署...'
51
+ CONNECTING: "正在连接服务器...",
52
+ UPLOADING: "正在上传文件...",
53
+ DEPLOYING: "正在部署...",
47
54
  };
48
55
 
49
- // 初始化配置
50
- const findEnvIndex = process.argv.indexOf('--env');
51
- let enviromentConfig = serverConfig.deploy;
56
+ // 初始化配置(--env <key> 显式选目标;未传时 TTY 下交互选,非 TTY(CI)静默用 deploy 保持旧行为。
57
+ // key 拼错/不存在时 fail-fast,静默回落 deploy 会发错机器)
58
+ const findEnvIndex = process.argv.indexOf("--env");
59
+ let enviromentConfig;
52
60
  if (findEnvIndex !== -1) {
53
- const configKey = process.argv[findEnvIndex + 1];
54
- enviromentConfig = serverConfig[configKey] || serverConfig.deploy;
61
+ const configKey = process.argv[findEnvIndex + 1];
62
+ if (!configKey) {
63
+ shelljs.echo(
64
+ "❌ --env 缺少值: --env <目标名>(server.config.json 里的 key)",
65
+ );
66
+ process.exit(1);
67
+ }
68
+ if (!serverConfig[configKey]) {
69
+ shelljs.echo(
70
+ `❌ --env ${configKey} 在 server.config.json 中不存在(可用: ${Object.keys(
71
+ serverConfig,
72
+ ).join(", ")})`,
73
+ );
74
+ process.exit(1);
75
+ }
76
+ enviromentConfig = serverConfig[configKey];
55
77
  }
56
78
 
57
79
  const ssh = new NodeSSH();
@@ -59,181 +81,258 @@ const startTime = Date.now();
59
81
 
60
82
  // 构造 SSH 连接配置:按 authMode 决定认证方式,秘钥优先级仍可被命令行 --key 临时覆盖
61
83
  function buildSSHConfig(config) {
62
- const baseConfig = {
63
- host: config.host,
64
- port: config.port,
65
- username: config.username,
66
- readyTimeout: 120_000
67
- };
68
-
69
- // 命令行参数 --key 临时指定秘钥路径,优先级最高,无论 authMode 如何都走秘钥登录
70
- const keyIndex = process.argv.indexOf('--key');
71
- const usePrivateKey = keyIndex !== -1 || config.authMode === 'privateKey';
72
- if (!usePrivateKey) {
73
- if (!config.password) {
74
- throw new Error('未配置登录凭证:请在 server.config.json 中配置 authMode=password + password,或 authMode=privateKey + privateKey');
75
- }
76
- return { ...baseConfig, password: config.password };
77
- }
78
-
79
- // 秘钥登录:优先取命令行 --key,否则取配置 privateKey.privateKeyPath
80
- const privateKeyPath =
81
- keyIndex !== -1 ? process.argv[keyIndex + 1] : config.privateKey?.privateKeyPath;
82
-
83
- if (!privateKeyPath) {
84
- throw new Error('秘钥登录缺少 privateKeyPath:请在 server.config.json 的 privateKey.privateKeyPath 中配置,或通过命令行 --key 传入');
85
- }
86
-
87
- // 支持 ~ 开头的家目录路径
88
- const resolvedKeyPath = privateKeyPath.replace(/^~(?=$|\/|\\)/, os.homedir());
89
- if (!fs.existsSync(resolvedKeyPath)) {
90
- throw new Error(`秘钥文件不存在: ${resolvedKeyPath}`);
91
- }
92
-
93
- const sshConfig = {
94
- ...baseConfig,
95
- // node-ssh@13 要求 privateKey 为字符串(Buffer 会被断言拦截)
96
- privateKey: fs.readFileSync(resolvedKeyPath, 'utf8')
97
- };
98
- if (config.privateKey?.passphrase) {
99
- sshConfig.passphrase = config.privateKey.passphrase;
100
- }
101
- return sshConfig;
84
+ const baseConfig = {
85
+ host: config.host,
86
+ port: config.port,
87
+ username: config.username,
88
+ readyTimeout: 120_000,
89
+ };
90
+
91
+ // 命令行参数 --key 临时指定秘钥路径,优先级最高,无论 authMode 如何都走秘钥登录
92
+ const keyIndex = process.argv.indexOf("--key");
93
+ const usePrivateKey = keyIndex !== -1 || config.authMode === "privateKey";
94
+ if (!usePrivateKey) {
95
+ if (!config.password) {
96
+ throw new Error(
97
+ "未配置登录凭证:请在 server.config.json 中配置 authMode=password + password,或 authMode=privateKey + privateKey",
98
+ );
99
+ }
100
+ return { ...baseConfig, password: config.password };
101
+ }
102
+
103
+ // 秘钥登录:优先取命令行 --key,否则取配置 privateKey.privateKeyPath
104
+ const privateKeyPath =
105
+ keyIndex !== -1
106
+ ? process.argv[keyIndex + 1]
107
+ : config.privateKey?.privateKeyPath;
108
+
109
+ if (!privateKeyPath) {
110
+ throw new Error(
111
+ "秘钥登录缺少 privateKeyPath:请在 server.config.json 的 privateKey.privateKeyPath 中配置,或通过命令行 --key 传入",
112
+ );
113
+ }
114
+
115
+ // 支持 ~ 开头的家目录路径
116
+ const resolvedKeyPath = privateKeyPath.replace(/^~(?=$|\/|\\)/, os.homedir());
117
+ if (!fs.existsSync(resolvedKeyPath)) {
118
+ throw new Error(`秘钥文件不存在: ${resolvedKeyPath}`);
119
+ }
120
+
121
+ const sshConfig = {
122
+ ...baseConfig,
123
+ // node-ssh@13 要求 privateKey 为字符串(Buffer 会被断言拦截)
124
+ privateKey: fs.readFileSync(resolvedKeyPath, "utf8"),
125
+ };
126
+ if (config.privateKey?.passphrase) {
127
+ sshConfig.passphrase = config.privateKey.passphrase;
128
+ }
129
+ return sshConfig;
102
130
  }
103
131
 
104
- const exec = (command = '') =>
105
- new Promise((resolve, reject) => {
106
- shelljs.exec(command, { silent: true }, (code, stdout, stderr) => {
107
- code === 0 ? resolve(stdout) : reject(new Error(stderr || `Command failed: ${command}`));
108
- });
109
- });
132
+ const exec = (command = "") =>
133
+ new Promise((resolve, reject) => {
134
+ shelljs.exec(command, { silent: true }, (code, stdout, stderr) => {
135
+ code === 0
136
+ ? resolve(stdout)
137
+ : reject(new Error(stderr || `Command failed: ${command}`));
138
+ });
139
+ });
140
+
141
+ function getPackageJsonPath() {
142
+ return path.join(shelljs.pwd().toString(), "package.json");
143
+ }
144
+
145
+ function readVersion() {
146
+ return JSON.parse(fs.readFileSync(getPackageJsonPath(), "utf-8")).version;
147
+ }
148
+
149
+ function writeVersion(version) {
150
+ const packageJson = JSON.parse(
151
+ fs.readFileSync(getPackageJsonPath(), "utf-8"),
152
+ );
153
+ packageJson.version = version;
154
+ fs.writeFileSync(
155
+ getPackageJsonPath(),
156
+ JSON.stringify(packageJson, null, 2) + "\n",
157
+ );
158
+ }
159
+
160
+ function bumpSemver(version) {
161
+ const versionParts = version.split(".");
162
+ versionParts[versionParts.length - 1] =
163
+ Number(versionParts[versionParts.length - 1]) + 1;
164
+ return versionParts.join(".");
165
+ }
110
166
 
167
+ // 非交互(CI)默认行为:读当前版本、自增、写回
111
168
  function bumpVersion() {
112
- const packageJsonPath = path.join(shelljs.pwd().toString(), 'package.json');
113
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
114
- const versionParts = packageJson.version.split('.');
115
- versionParts[versionParts.length - 1] = Number(versionParts[versionParts.length - 1]) + 1;
116
- packageJson.version = versionParts.join('.');
117
- fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
118
- return packageJson.version;
169
+ const next = bumpSemver(readVersion());
170
+ writeVersion(next);
171
+ return next;
119
172
  }
120
173
 
121
174
  function getDirSize(dirPath) {
122
- let totalSize = 0;
123
- const files = shelljs.find(dirPath).filter((f) => shelljs.test('-f', f));
124
- files.forEach((f) => {
125
- totalSize += fs.statSync(f).size;
126
- });
127
- return totalSize;
175
+ let totalSize = 0;
176
+ const files = shelljs.find(dirPath).filter((f) => shelljs.test("-f", f));
177
+ files.forEach((f) => {
178
+ totalSize += fs.statSync(f).size;
179
+ });
180
+ return totalSize;
128
181
  }
129
182
 
130
183
  function formatSize(bytes) {
131
- if (bytes < 1024) return `${bytes} B`;
132
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
133
- return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
184
+ if (bytes < 1024) return `${bytes} B`;
185
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
186
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
134
187
  }
135
188
 
136
189
  async function main() {
137
- try {
138
- const newVersion = bumpVersion();
139
- output.final(`📌 版本号已更新: ${newVersion}`);
140
-
141
- output.update('🚀 正在打包项目...');
142
- await exec('npm run build');
143
-
144
- const distPath = path.join(shelljs.pwd().toString(), enviromentConfig.serverWebDist || 'dist');
145
- const distSize = getDirSize(distPath);
146
- output.final(`📦 构建产物大小: ${formatSize(distSize)}`);
147
-
148
- output.update('🗜️ 正在压缩构建文件...');
149
- const { splitIncludes, splitUpload, serverWebDist } = enviromentConfig;
150
- if (serverWebDist !== 'dist') {
151
- await exec(`mv dist/ ${serverWebDist}/`);
152
- }
153
- const splitUploadFileNames = [];
154
-
155
- if (splitIncludes?.length) {
156
- await Promise.all(
157
- splitIncludes.map(async (e) => {
158
- if (splitUpload) {
159
- const targetTar = `${serverWebDist}.${e}.tar.gz`;
160
- await exec(`COPYFILE_DISABLE=1 tar zcvf ${targetTar} ${serverWebDist}/${e}`);
161
- splitUploadFileNames.push(targetTar);
162
- }
163
- await exec(`rm -rf ${serverWebDist}/${e}`);
164
- })
165
- );
166
- }
167
-
168
- const targetTar = `${serverWebDist}.tar.gz`;
169
- await exec(`COPYFILE_DISABLE=1 tar zcvf ${targetTar} ${serverWebDist}/`);
170
- splitUploadFileNames.unshift(targetTar);
171
-
172
- output.update(SSH_STATUS.CONNECTING);
173
- await ssh.connect(buildSSHConfig(enviromentConfig));
174
-
175
- output.update(SSH_STATUS.UPLOADING);
176
- await ssh.mkdir(enviromentConfig.serverWebPath);
177
-
178
- const sftp = await ssh.requestSFTP();
179
- const totalFiles = splitUploadFileNames.length;
180
-
181
- await Promise.all(
182
- splitUploadFileNames.map(async (file, index) => {
183
- const localPath = path.join(shelljs.pwd().toString(), file);
184
- const remotePath = `${enviromentConfig.serverWebPath}/${file}`;
185
-
186
- await ssh.putFile(localPath, remotePath, sftp, {
187
- concurrency: 10,
188
- step: (transferred, _, total) => {
189
- const progress = ((transferred / total) * 100).toFixed(1);
190
- output.update(
191
- `${SSH_STATUS.UPLOADING} (${index + 1}/${totalFiles}) ${progress}% ${file}`
192
- );
193
- }
194
- });
195
- })
196
- );
197
-
198
- output.update(SSH_STATUS.DEPLOYING);
199
- const commands = [
200
- `rm -rf ${serverWebDist}_last_bak.tar.gz`,
201
- `tar zcvf ${serverWebDist}_last_bak.tar.gz ${serverWebDist}_bak/`,
202
- `rm -rf ${serverWebDist}_bak/`,
203
- `mv ${serverWebDist}/ ${serverWebDist}_bak/`,
204
- ...splitUploadFileNames.map((e) => `tar zxvf ${e} && rm -rf ${e}`),
205
- ...(!splitUpload
206
- ? splitIncludes.map((e) => `cp -r ${serverWebDist}_bak/${e} ${serverWebDist}`)
207
- : [])
208
- ];
209
-
210
- for (const cmd of commands) {
211
- await ssh.execCommand(cmd, { cwd: enviromentConfig.serverWebPath });
212
- }
213
-
214
- splitUploadFileNames.forEach((f) => shelljs.rm(f));
215
- if (serverWebDist !== 'dist') {
216
- await exec(`mv ${serverWebDist}/ dist/`);
217
- }
218
- const duration = ((Date.now() - startTime) / 1000).toFixed(1);
219
- const now = new Date();
220
- const timeStr = now.toLocaleString('zh-CN', {
221
- year: 'numeric',
222
- month: '2-digit',
223
- day: '2-digit',
224
- hour: '2-digit',
225
- minute: '2-digit',
226
- second: '2-digit'
227
- });
228
- output.final(
229
- `✅ 部署成功! ${timeStr} 用时:${duration}s\n✅ 访问地址:${enviromentConfig.preview}`
230
- );
231
- } catch (error) {
232
- output.final(`❌ 部署失败: ${error.message}`);
233
- process.exit(1);
234
- } finally {
235
- ssh.dispose();
236
- }
190
+ try {
191
+ // 未显式 --env:TTY 交互选目标(菜单 `key(host)`),非 TTY 静默回落 deploy(CI 兼容)
192
+ if (!enviromentConfig) {
193
+ const envKey = process.stdin.isTTY
194
+ ? await selectEnvKey(serverConfig)
195
+ : Object.keys(serverConfig).length === 1
196
+ ? Object.keys(serverConfig)[0]
197
+ : "deploy";
198
+ enviromentConfig = serverConfig[envKey];
199
+ output.final(`🎯 部署目标: ${envKey}(${enviromentConfig.host})`);
200
+ }
201
+
202
+ // 版本策略:TTY 交互选(自增默认/维持当前/自定义);非 TTY 保持旧行为自动自增
203
+ let newVersion;
204
+ if (process.stdin.isTTY) {
205
+ const currentVersion = readVersion();
206
+ newVersion = await selectVersion(
207
+ currentVersion,
208
+ bumpSemver(currentVersion),
209
+ );
210
+ if (newVersion !== currentVersion) {
211
+ writeVersion(newVersion);
212
+ output.final(`📌 版本号已更新: ${newVersion}`);
213
+ } else {
214
+ output.final(`📌 维持当前版本: ${newVersion}`);
215
+ }
216
+ } else {
217
+ newVersion = bumpVersion();
218
+ output.final(`📌 版本号已更新: ${newVersion}`);
219
+ }
220
+
221
+ output.update("🚀 正在打包项目...");
222
+ await exec("npm run build");
223
+
224
+ const distPath = path.join(
225
+ shelljs.pwd().toString(),
226
+ enviromentConfig.serverWebDist || "dist",
227
+ );
228
+ const distSize = getDirSize(distPath);
229
+ output.final(`📦 构建产物大小: ${formatSize(distSize)}`);
230
+
231
+ output.update("🗜️ 正在压缩构建文件...");
232
+ const { splitIncludes, splitUpload, serverWebDist } = enviromentConfig;
233
+ if (serverWebDist !== "dist") {
234
+ await exec(`mv dist/ ${serverWebDist}/`);
235
+ }
236
+ const splitUploadFileNames = [];
237
+
238
+ if (splitIncludes?.length) {
239
+ await Promise.all(
240
+ splitIncludes.map(async (e) => {
241
+ if (splitUpload) {
242
+ const targetTar = `${serverWebDist}.${e}.tar.gz`;
243
+ await exec(
244
+ `COPYFILE_DISABLE=1 tar zcvf ${targetTar} ${serverWebDist}/${e}`,
245
+ );
246
+ splitUploadFileNames.push(targetTar);
247
+ }
248
+ await exec(`rm -rf ${serverWebDist}/${e}`);
249
+ }),
250
+ );
251
+ }
252
+
253
+ const targetTar = `${serverWebDist}.tar.gz`;
254
+ await exec(`COPYFILE_DISABLE=1 tar zcvf ${targetTar} ${serverWebDist}/`);
255
+ splitUploadFileNames.unshift(targetTar);
256
+
257
+ output.update(SSH_STATUS.CONNECTING);
258
+ await ssh.connect(buildSSHConfig(enviromentConfig));
259
+
260
+ output.update(SSH_STATUS.UPLOADING);
261
+ await ssh.mkdir(enviromentConfig.serverWebPath);
262
+
263
+ const sftp = await ssh.requestSFTP();
264
+ const totalFiles = splitUploadFileNames.length;
265
+
266
+ await Promise.all(
267
+ splitUploadFileNames.map(async (file, index) => {
268
+ const localPath = path.join(shelljs.pwd().toString(), file);
269
+ const remotePath = `${enviromentConfig.serverWebPath}/${file}`;
270
+
271
+ await ssh.putFile(localPath, remotePath, sftp, {
272
+ concurrency: 10,
273
+ step: (transferred, _, total) => {
274
+ const progress = ((transferred / total) * 100).toFixed(1);
275
+ output.update(
276
+ `${SSH_STATUS.UPLOADING} (${
277
+ index + 1
278
+ }/${totalFiles}) ${progress}% ${file}`,
279
+ );
280
+ },
281
+ });
282
+ }),
283
+ );
284
+
285
+ output.update(SSH_STATUS.DEPLOYING);
286
+ // keepVersions:<dist>_releases 保留的版本归档数,默认 3(rollback 交互列版本回退用)
287
+ const keepVersions = enviromentConfig.keepVersions || 1;
288
+
289
+ const commands = [
290
+ `rm -rf ${serverWebDist}_last_bak.tar.gz`,
291
+ `tar zcvf ${serverWebDist}_last_bak.tar.gz ${serverWebDist}_bak/`,
292
+ `rm -rf ${serverWebDist}_bak/`,
293
+ `mv ${serverWebDist}/ ${serverWebDist}_bak/`,
294
+ ...splitUploadFileNames.map((e) => `tar zxvf ${e} && rm -rf ${e}`),
295
+ ...(!splitUpload
296
+ ? splitIncludes.map(
297
+ (e) => `cp -r ${serverWebDist}_bak/${e} ${serverWebDist}`,
298
+ )
299
+ : []),
300
+ // 部署完成后归档本次发布(完整 dist,dist_bak 链之外的多版本历史),再按 keepVersions 轮转清理旧归档
301
+ `mkdir -p ${serverWebDist}_releases`,
302
+ `ts=$(date +%Y%m%d%H%M%S) && tar zcf ${serverWebDist}_releases/v${newVersion}_\${ts}.tar.gz ${serverWebDist}/`,
303
+ `ls -t ${serverWebDist}_releases/*.tar.gz | tail -n +${
304
+ keepVersions + 1
305
+ } | xargs rm -f`,
306
+ ];
307
+
308
+ for (const cmd of commands) {
309
+ await ssh.execCommand(cmd, { cwd: enviromentConfig.serverWebPath });
310
+ }
311
+
312
+ splitUploadFileNames.forEach((f) => shelljs.rm(f));
313
+ if (serverWebDist !== "dist") {
314
+ await exec(`mv ${serverWebDist}/ dist/`);
315
+ }
316
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
317
+ const now = new Date();
318
+ const timeStr = now.toLocaleString("zh-CN", {
319
+ year: "numeric",
320
+ month: "2-digit",
321
+ day: "2-digit",
322
+ hour: "2-digit",
323
+ minute: "2-digit",
324
+ second: "2-digit",
325
+ });
326
+ output.final(
327
+ `✅ 部署成功! ${timeStr} 用时:${duration}s\n✅ 访问地址:${enviromentConfig.preview}`,
328
+ );
329
+ } catch (error) {
330
+ output.final(`❌ 部署失败: ${error.message}`);
331
+ process.exit(1);
332
+ } finally {
333
+ closeInteractive(); // 释放 readline 的 stdin 句柄,进程才能自然退出
334
+ ssh.dispose();
335
+ }
237
336
  }
238
337
 
239
338
  main();