@noahyu/cd-cli 1.0.0 → 1.1.0
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.
- package/README.md +157 -98
- package/dist/src/index.js +121 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 简易项目部署工具
|
|
2
2
|
|
|
3
3
|
一个简易的部署工具,支持版本管理和零停机部署。
|
|
4
4
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- 🔄 零停机部署:使用软链接实现原子性切换
|
|
10
10
|
- ⏪ 快速回滚:一键回滚到任意历史版本
|
|
11
11
|
- 📝 版本列表:查看服务器上所有已部署版本
|
|
12
|
-
- 🔒
|
|
12
|
+
- 🔒 SSH 认证:支持密码、密钥
|
|
13
13
|
- 🔄 PM2 集成:自动重启 PM2 应用
|
|
14
14
|
- 📝 配置灵活:支持部署前后执行自定义命令
|
|
15
15
|
- 🎯 文件过滤:支持排除不需要的文件
|
|
@@ -92,65 +92,139 @@ PM2 配置始终指向软链接 `.output/server/index.mjs`,这样切换版本
|
|
|
92
92
|
```javascript
|
|
93
93
|
// pcli-cd 部署配置文件
|
|
94
94
|
export default {
|
|
95
|
-
|
|
95
|
+
/** 构建命令 (可选):构建项目 */
|
|
96
96
|
buildCommand: 'npm run build',
|
|
97
|
-
|
|
98
|
-
// 构建输出目录
|
|
97
|
+
/** 构建输出目录 */
|
|
99
98
|
buildDir: '.output',
|
|
100
|
-
|
|
101
|
-
// 版本号 (可选,不指定会在部署时询问)
|
|
99
|
+
/** 版本号 (可选,不指定会在部署时询问) */
|
|
102
100
|
version: 'v1.0.0',
|
|
103
|
-
|
|
104
|
-
// 服务器配置
|
|
101
|
+
/** 服务器配置 */
|
|
105
102
|
server: {
|
|
103
|
+
/** 服务器地址 */
|
|
106
104
|
host: '192.168.1.100',
|
|
105
|
+
/** 端口号 */
|
|
107
106
|
port: 22,
|
|
107
|
+
/** 用户名 */
|
|
108
108
|
username: 'root',
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// SSH 认证方式(优先级:privateKey > privateKeyPath > password)
|
|
110
|
+
/** 私钥 */
|
|
111
|
+
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----',
|
|
112
|
+
/** 私钥文件路径 */
|
|
113
|
+
privateKeyPath: '/home/user/.ssh/id_rsa',
|
|
114
|
+
/** 密码 */
|
|
115
|
+
password: 'your-password',
|
|
116
|
+
/** 部署目录 */
|
|
111
117
|
deployPath: '/var/www/your-app',
|
|
112
118
|
},
|
|
113
|
-
|
|
114
|
-
// PM2 配置 (可选)
|
|
119
|
+
/** PM2 配置 (可选) */
|
|
115
120
|
pm2: {
|
|
121
|
+
/** 进程名称 */
|
|
116
122
|
appName: 'your-app-name',
|
|
123
|
+
/** 是否立即重启 */
|
|
117
124
|
restart: true,
|
|
118
125
|
},
|
|
119
126
|
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
/**
|
|
128
|
+
* 排除的文件 (可选) - 作用于构建产物目录
|
|
129
|
+
*
|
|
130
|
+
* 请根据构建输出的实际内容谨慎配置
|
|
131
|
+
*/
|
|
122
132
|
excludeFiles: [
|
|
123
133
|
// '**/*.map', // Source Map 文件
|
|
124
134
|
// '**/.DS_Store', // macOS 系统文件
|
|
125
135
|
// '**/Thumbs.db' // Windows 缩略图缓存
|
|
126
136
|
],
|
|
127
|
-
|
|
128
|
-
// 部署前命令 (可选)
|
|
137
|
+
/** 部署前命令 (可选) */
|
|
129
138
|
beforeDeploy: ['npm run test'],
|
|
130
|
-
|
|
131
|
-
// 部署后命令 (可选)
|
|
139
|
+
/** 部署后命令 (可选) */
|
|
132
140
|
afterDeploy: ['npm install --production'],
|
|
133
141
|
}
|
|
134
142
|
```
|
|
135
143
|
|
|
144
|
+
## SSH 认证配置
|
|
145
|
+
|
|
146
|
+
工具支持三种 SSH 认证方式,优先级为:`privateKey` > `privateKeyPath` > `password`
|
|
147
|
+
|
|
148
|
+
### 方式一:私钥内容(推荐用于 CI/CD)
|
|
149
|
+
|
|
150
|
+
适用于 CI/CD 环境,将私钥内容作为环境变量传入:
|
|
151
|
+
|
|
152
|
+
```javascript
|
|
153
|
+
export default {
|
|
154
|
+
server: {
|
|
155
|
+
host: '192.168.1.100',
|
|
156
|
+
username: 'root',
|
|
157
|
+
privateKey: process.env.SSH_PRIVATE_KEY, // 从环境变量读取
|
|
158
|
+
deployPath: '/var/www/app',
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### 方式二:私钥文件路径(推荐用于本地开发)
|
|
164
|
+
|
|
165
|
+
适用于本地开发环境,使用本地私钥文件:
|
|
166
|
+
|
|
167
|
+
```javascript
|
|
168
|
+
export default {
|
|
169
|
+
server: {
|
|
170
|
+
host: '192.168.1.100',
|
|
171
|
+
username: 'root',
|
|
172
|
+
privateKeyPath: '/home/user/.ssh/id_rsa',
|
|
173
|
+
deployPath: '/var/www/app',
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 方式三:密码认证
|
|
179
|
+
|
|
180
|
+
适用于简单测试环境(生产环境不推荐):
|
|
181
|
+
|
|
182
|
+
```javascript
|
|
183
|
+
export default {
|
|
184
|
+
server: {
|
|
185
|
+
host: '192.168.1.100',
|
|
186
|
+
username: 'root',
|
|
187
|
+
password: 'your-password',
|
|
188
|
+
deployPath: '/var/www/app',
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### 优先级说明
|
|
194
|
+
|
|
195
|
+
当配置了多种认证方式时,工具会按以下优先级选择:
|
|
196
|
+
|
|
197
|
+
1. **privateKey**(私钥内容)- 最高优先级
|
|
198
|
+
2. **privateKeyPath**(私钥文件路径)- 中等优先级
|
|
199
|
+
3. **password**(密码)- 最低优先级
|
|
200
|
+
|
|
201
|
+
### 安全建议
|
|
202
|
+
|
|
203
|
+
- ✅ **生产环境**:使用私钥认证(`privateKey` 或 `privateKeyPath`)
|
|
204
|
+
- ✅ **CI/CD**:使用 `privateKey` + 环境变量
|
|
205
|
+
- ✅ **本地开发**:使用 `privateKeyPath`
|
|
206
|
+
- ⚠️ **测试环境**:可以使用密码认证
|
|
207
|
+
- ❌ **避免**:在配置文件中硬编码密码或私钥内容
|
|
208
|
+
|
|
136
209
|
## 配置选项
|
|
137
210
|
|
|
138
|
-
| 选项
|
|
139
|
-
|
|
|
140
|
-
| `buildCommand`
|
|
141
|
-
| `buildDir`
|
|
142
|
-
| `version`
|
|
143
|
-
| `server.host`
|
|
144
|
-
| `server.port`
|
|
145
|
-
| `server.username`
|
|
146
|
-
| `server.password`
|
|
147
|
-
| `server.privateKey`
|
|
148
|
-
| `server.
|
|
149
|
-
| `
|
|
150
|
-
| `pm2.
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
211
|
+
| 选项 | 类型 | 必填 | 说明 |
|
|
212
|
+
| ----------------------- | -------- | ---- | -------------------------------- |
|
|
213
|
+
| `buildCommand` | string | 否 | 构建命令,如 "npm run build" |
|
|
214
|
+
| `buildDir` | string | 是 | 构建输出目录,如 ".output" |
|
|
215
|
+
| `version` | string | 否 | 版本号,不指定会在部署时询问 |
|
|
216
|
+
| `server.host` | string | 是 | 服务器地址 |
|
|
217
|
+
| `server.port` | number | 否 | SSH 端口,默认 22 |
|
|
218
|
+
| `server.username` | string | 是 | 用户名 |
|
|
219
|
+
| `server.password` | string | 否 | SSH 密码 |
|
|
220
|
+
| `server.privateKey` | string | 否 | SSH 私钥内容,优先级最高 |
|
|
221
|
+
| `server.privateKeyPath` | string | 否 | SSH 私钥文件路径 |
|
|
222
|
+
| `server.deployPath` | string | 是 | 服务器部署路径 |
|
|
223
|
+
| `pm2.appName` | string | 否 | PM2 应用名称 |
|
|
224
|
+
| `pm2.restart` | boolean | 否 | 是否重启 PM2 应用 |
|
|
225
|
+
| `excludeFiles` | string[] | 否 | 排除的文件模式(相对于构建目录) |
|
|
226
|
+
| `beforeDeploy` | string[] | 否 | 部署前执行的命令 |
|
|
227
|
+
| `afterDeploy` | string[] | 否 | 部署后执行的命令 |
|
|
154
228
|
|
|
155
229
|
## 命令详解
|
|
156
230
|
|
|
@@ -200,63 +274,6 @@ Options:
|
|
|
200
274
|
pcli-cd init
|
|
201
275
|
```
|
|
202
276
|
|
|
203
|
-
## 使用场景
|
|
204
|
-
|
|
205
|
-
### 1. Nuxt 3 项目部署
|
|
206
|
-
|
|
207
|
-
```javascript
|
|
208
|
-
// Nuxt.js 项目配置
|
|
209
|
-
export default {
|
|
210
|
-
buildCommand: 'npm run build',
|
|
211
|
-
buildDir: '.output',
|
|
212
|
-
server: {
|
|
213
|
-
host: 'your-server.com',
|
|
214
|
-
username: 'root',
|
|
215
|
-
password: 'your-password',
|
|
216
|
-
deployPath: '/var/www/nuxt-app',
|
|
217
|
-
},
|
|
218
|
-
pm2: {
|
|
219
|
-
appName: 'nuxt-app',
|
|
220
|
-
restart: true,
|
|
221
|
-
},
|
|
222
|
-
excludeFiles: [
|
|
223
|
-
// 注意:以下路径相对于 .output 目录
|
|
224
|
-
// 请根据实际构建产物内容调整
|
|
225
|
-
'src/**', // 如果源码被复制到输出目录
|
|
226
|
-
'**/.DS_Store', // macOS 系统文件
|
|
227
|
-
],
|
|
228
|
-
}
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
### 2. Node.js API 项目部署
|
|
232
|
-
|
|
233
|
-
```javascript
|
|
234
|
-
// Node.js API 项目配置
|
|
235
|
-
export default {
|
|
236
|
-
buildCommand: 'npm run build',
|
|
237
|
-
buildDir: 'dist',
|
|
238
|
-
server: {
|
|
239
|
-
host: 'your-server.com',
|
|
240
|
-
username: 'root',
|
|
241
|
-
privateKey: '~/.ssh/id_rsa',
|
|
242
|
-
deployPath: '/var/www/api'
|
|
243
|
-
},
|
|
244
|
-
pm2: {
|
|
245
|
-
appName: 'api-server',
|
|
246
|
-
restart: true
|
|
247
|
-
},
|
|
248
|
-
excludeFiles: [
|
|
249
|
-
// 注意:以下路径相对于 dist 目录
|
|
250
|
-
// 请根据实际构建产物内容调整
|
|
251
|
-
'src/**', // 如果源码被复制到输出目录
|
|
252
|
-
'**/.DS_Store', // macOS 系统文件
|
|
253
|
-
]
|
|
254
|
-
afterDeploy: [
|
|
255
|
-
'npm install --production'
|
|
256
|
-
]
|
|
257
|
-
}
|
|
258
|
-
```
|
|
259
|
-
|
|
260
277
|
## excludeFiles 配置说明
|
|
261
278
|
|
|
262
279
|
⚠️ **重要提醒**:`excludeFiles` 作用于**构建产物目录**(如 `dist/`、`.output/` 等),不是项目根目录。
|
|
@@ -333,12 +350,16 @@ pcli-cd rollback
|
|
|
333
350
|
|
|
334
351
|
- **全局安装后首次使用**:安装后可在任意目录使用 `pcli-cd` 命令
|
|
335
352
|
- **配置文件位置**:每个项目根目录需要有 `pcli-cd.config.js` 配置文件
|
|
336
|
-
-
|
|
337
|
-
-
|
|
338
|
-
- 私钥文件需要有正确的权限 (600)
|
|
339
|
-
-
|
|
340
|
-
-
|
|
341
|
-
-
|
|
353
|
+
- **SSH 认证**:
|
|
354
|
+
- 支持 OpenSSH 格式的 RSA、ECDSA、Ed25519 私钥
|
|
355
|
+
- 私钥文件需要有正确的权限 (600)
|
|
356
|
+
- 生产环境建议使用私钥认证而非密码
|
|
357
|
+
- CI/CD 环境推荐使用 `privateKey` + 环境变量
|
|
358
|
+
- **服务器要求**:
|
|
359
|
+
- 确保服务器已安装 `unzip` 命令
|
|
360
|
+
- 如果使用 PM2,确保服务器已安装 PM2
|
|
361
|
+
- **PM2 配置**:PM2 配置文件中的启动路径应该指向软链接而非具体版本目录
|
|
362
|
+
- **版本管理**:部署过程中会自动清理旧版本,默认保留最近3个版本
|
|
342
363
|
|
|
343
364
|
## 故障排除
|
|
344
365
|
|
|
@@ -362,6 +383,44 @@ ls pcli-cd.config.js
|
|
|
362
383
|
pcli-cd init
|
|
363
384
|
```
|
|
364
385
|
|
|
386
|
+
### SSH 连接失败
|
|
387
|
+
|
|
388
|
+
**1. 私钥文件不存在**
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
# 检查私钥文件是否存在
|
|
392
|
+
ls -la /home/user/.ssh/id_rsa
|
|
393
|
+
|
|
394
|
+
# 检查私钥文件权限
|
|
395
|
+
chmod 600 /home/user/.ssh/id_rsa
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**2. 私钥格式错误**
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
# 检查私钥格式(应该以此开头)
|
|
402
|
+
head -1 /home/user/.ssh/id_rsa
|
|
403
|
+
# RSA: -----BEGIN RSA PRIVATE KEY-----
|
|
404
|
+
# OpenSSH: -----BEGIN OPENSSH PRIVATE KEY-----
|
|
405
|
+
# ECDSA: -----BEGIN EC PRIVATE KEY-----
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
**3. SSH 认证配置问题**
|
|
409
|
+
|
|
410
|
+
- 确保至少配置了 `password`、`privateKey` 或 `privateKeyPath` 之一
|
|
411
|
+
- 检查私钥文件路径是否正确
|
|
412
|
+
- 验证服务器用户名和地址是否正确
|
|
413
|
+
|
|
414
|
+
**4. 网络连接问题**
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
# 测试 SSH 连接
|
|
418
|
+
ssh -p 22 root@your-server.com
|
|
419
|
+
|
|
420
|
+
# 测试指定私钥连接
|
|
421
|
+
ssh -i /home/user/.ssh/id_rsa -p 22 root@your-server.com
|
|
422
|
+
```
|
|
423
|
+
|
|
365
424
|
## License
|
|
366
425
|
|
|
367
426
|
MIT
|
package/dist/src/index.js
CHANGED
|
@@ -71,15 +71,12 @@ async function deploy(config) {
|
|
|
71
71
|
await createZip(buildPath, zipPath, config.excludeFiles);
|
|
72
72
|
spinner.succeed('文件压缩完成');
|
|
73
73
|
spinner.start('正在连接服务器...');
|
|
74
|
-
const ssh =
|
|
75
|
-
await ssh.connect({
|
|
76
|
-
host: config.server.host,
|
|
77
|
-
port: config.server.port || 22,
|
|
78
|
-
username: config.server.username,
|
|
79
|
-
password: config.server.password,
|
|
80
|
-
privateKey: config.server.privateKey,
|
|
81
|
-
});
|
|
74
|
+
const ssh = await createSSHConnection(config.server);
|
|
82
75
|
spinner.succeed('服务器连接成功');
|
|
76
|
+
await cleanTempLinks(ssh, config.server.deployPath, buildDirName);
|
|
77
|
+
spinner.start('检查部署环境...');
|
|
78
|
+
await handleExistingDeployDir(ssh, config.server.deployPath, buildDirName);
|
|
79
|
+
spinner.succeed('部署环境检查完成');
|
|
83
80
|
const versionDirName = `${buildDirName}-${version}`;
|
|
84
81
|
const versionPath = join(config.server.deployPath, versionDirName);
|
|
85
82
|
const currentLinkPath = join(config.server.deployPath, buildDirName);
|
|
@@ -111,9 +108,22 @@ async function deploy(config) {
|
|
|
111
108
|
}
|
|
112
109
|
spinner.start(`正在切换到新版本 ${version}...`);
|
|
113
110
|
const tempLinkPath = `${currentLinkPath}.tmp.${Date.now()}`;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
try {
|
|
112
|
+
const linkResult = await ssh.execCommand(`ln -sfn ${versionPath} ${tempLinkPath}`);
|
|
113
|
+
if (linkResult.code !== 0) {
|
|
114
|
+
throw new Error(`创建临时软链接失败: ${linkResult.stderr}`);
|
|
115
|
+
}
|
|
116
|
+
const moveResult = await ssh.execCommand(`mv ${tempLinkPath} ${currentLinkPath}`);
|
|
117
|
+
if (moveResult.code !== 0) {
|
|
118
|
+
await ssh.execCommand(`rm -f ${tempLinkPath}`);
|
|
119
|
+
throw new Error(`切换软链接失败: ${moveResult.stderr}`);
|
|
120
|
+
}
|
|
121
|
+
spinner.succeed(`版本切换完成: ${buildDirName} -> ${versionDirName}`);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
await ssh.execCommand(`rm -f ${tempLinkPath}`);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
117
127
|
if (config.pm2) {
|
|
118
128
|
spinner.start('正在重启 PM2 应用...');
|
|
119
129
|
const { appName, restart = true } = config.pm2;
|
|
@@ -225,10 +235,9 @@ async function initConfig() {
|
|
|
225
235
|
validate: (input) => input.trim() !== '' || '请输入用户名',
|
|
226
236
|
},
|
|
227
237
|
{
|
|
228
|
-
type: '
|
|
229
|
-
name: '
|
|
230
|
-
message: '
|
|
231
|
-
mask: '*',
|
|
238
|
+
type: 'input',
|
|
239
|
+
name: 'privateKeyPath',
|
|
240
|
+
message: '私钥路径 (留空稍后填写):',
|
|
232
241
|
},
|
|
233
242
|
{
|
|
234
243
|
type: 'input',
|
|
@@ -249,7 +258,7 @@ async function initConfig() {
|
|
|
249
258
|
host: answers.host,
|
|
250
259
|
port: parseInt(answers.port),
|
|
251
260
|
username: answers.username,
|
|
252
|
-
|
|
261
|
+
privateKeyPath: answers.privateKeyPath || undefined,
|
|
253
262
|
deployPath: answers.deployPath,
|
|
254
263
|
},
|
|
255
264
|
excludeFiles: [],
|
|
@@ -283,14 +292,7 @@ async function listVersions(options) {
|
|
|
283
292
|
const spinner = ora('正在获取版本列表...');
|
|
284
293
|
spinner.start();
|
|
285
294
|
try {
|
|
286
|
-
const ssh =
|
|
287
|
-
await ssh.connect({
|
|
288
|
-
host: config.server.host,
|
|
289
|
-
port: config.server.port || 22,
|
|
290
|
-
username: config.server.username,
|
|
291
|
-
password: config.server.password,
|
|
292
|
-
privateKey: config.server.privateKey,
|
|
293
|
-
});
|
|
295
|
+
const ssh = await createSSHConnection(config.server);
|
|
294
296
|
const buildDirName = config.buildDir.split('/').pop() || 'build';
|
|
295
297
|
const currentLinkPath = join(config.server.deployPath, buildDirName);
|
|
296
298
|
const currentResult = await ssh.execCommand(`readlink ${currentLinkPath}`);
|
|
@@ -356,14 +358,7 @@ async function rollbackVersion(options) {
|
|
|
356
358
|
const buildDirName = config.buildDir.split('/').pop() || 'build';
|
|
357
359
|
let targetVersion = options.version;
|
|
358
360
|
if (!targetVersion) {
|
|
359
|
-
const ssh =
|
|
360
|
-
await ssh.connect({
|
|
361
|
-
host: config.server.host,
|
|
362
|
-
port: config.server.port || 22,
|
|
363
|
-
username: config.server.username,
|
|
364
|
-
password: config.server.password,
|
|
365
|
-
privateKey: config.server.privateKey,
|
|
366
|
-
});
|
|
361
|
+
const ssh = await createSSHConnection(config.server);
|
|
367
362
|
const result = await ssh.execCommand(`find ${config.server.deployPath} -maxdepth 1 -type d -name "${buildDirName}-*" | sort -V`);
|
|
368
363
|
if (result.code !== 0) {
|
|
369
364
|
console.log(chalk.red('❌ 无法获取版本列表'));
|
|
@@ -400,14 +395,7 @@ async function performRollback(config, targetVersion, buildDirName) {
|
|
|
400
395
|
const spinner = ora();
|
|
401
396
|
try {
|
|
402
397
|
spinner.start('正在连接服务器...');
|
|
403
|
-
const ssh =
|
|
404
|
-
await ssh.connect({
|
|
405
|
-
host: config.server.host,
|
|
406
|
-
port: config.server.port || 22,
|
|
407
|
-
username: config.server.username,
|
|
408
|
-
password: config.server.password,
|
|
409
|
-
privateKey: config.server.privateKey,
|
|
410
|
-
});
|
|
398
|
+
const ssh = await createSSHConnection(config.server);
|
|
411
399
|
spinner.succeed('服务器连接成功');
|
|
412
400
|
const versionDirName = `${buildDirName}-${targetVersion}`;
|
|
413
401
|
const versionPath = join(config.server.deployPath, versionDirName);
|
|
@@ -420,9 +408,22 @@ async function performRollback(config, targetVersion, buildDirName) {
|
|
|
420
408
|
spinner.succeed('目标版本检查通过');
|
|
421
409
|
spinner.start(`正在回滚到版本 ${targetVersion}...`);
|
|
422
410
|
const tempLinkPath = `${currentLinkPath}.tmp.${Date.now()}`;
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
411
|
+
try {
|
|
412
|
+
const linkResult = await ssh.execCommand(`ln -sfn ${versionPath} ${tempLinkPath}`);
|
|
413
|
+
if (linkResult.code !== 0) {
|
|
414
|
+
throw new Error(`创建临时软链接失败: ${linkResult.stderr}`);
|
|
415
|
+
}
|
|
416
|
+
const moveResult = await ssh.execCommand(`mv ${tempLinkPath} ${currentLinkPath}`);
|
|
417
|
+
if (moveResult.code !== 0) {
|
|
418
|
+
await ssh.execCommand(`rm -f ${tempLinkPath}`);
|
|
419
|
+
throw new Error(`切换软链接失败: ${moveResult.stderr}`);
|
|
420
|
+
}
|
|
421
|
+
spinner.succeed(`回滚完成: ${buildDirName} -> ${versionDirName}`);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
await ssh.execCommand(`rm -f ${tempLinkPath}`);
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
426
427
|
if (config.pm2) {
|
|
427
428
|
spinner.start('正在重启 PM2 应用...');
|
|
428
429
|
const { appName } = config.pm2;
|
|
@@ -450,5 +451,82 @@ async function performRollback(config, targetVersion, buildDirName) {
|
|
|
450
451
|
process.exit(1);
|
|
451
452
|
}
|
|
452
453
|
}
|
|
454
|
+
async function createSSHConnection(server) {
|
|
455
|
+
const ssh = new NodeSSH();
|
|
456
|
+
const connectConfig = {
|
|
457
|
+
host: server.host,
|
|
458
|
+
port: server.port || 22,
|
|
459
|
+
username: server.username,
|
|
460
|
+
};
|
|
461
|
+
if (server.privateKey) {
|
|
462
|
+
connectConfig.privateKey = server.privateKey;
|
|
463
|
+
}
|
|
464
|
+
else if (server.privateKeyPath) {
|
|
465
|
+
connectConfig.privateKeyPath = server.privateKeyPath;
|
|
466
|
+
}
|
|
467
|
+
else if (server.password) {
|
|
468
|
+
connectConfig.password = server.password;
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
throw new Error('SSH 认证配置错误:必须提供 password、privateKey 或 privateKeyPath 之一');
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
await ssh.connect(connectConfig);
|
|
475
|
+
return ssh;
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
throw new Error(`SSH 连接失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async function cleanTempLinks(ssh, deployPath, buildDirName) {
|
|
482
|
+
try {
|
|
483
|
+
await ssh.execCommand(`find ${deployPath} -name "${buildDirName}.tmp.*" -type l -delete`);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
console.warn(chalk.yellow(`⚠️ 清理临时链接时出现警告: ${error}`));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async function handleExistingDeployDir(ssh, deployPath, buildDirName) {
|
|
490
|
+
const currentLinkPath = join(deployPath, buildDirName);
|
|
491
|
+
const checkResult = await ssh.execCommand(`test -e ${currentLinkPath}`);
|
|
492
|
+
if (checkResult.code !== 0) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const linkCheckResult = await ssh.execCommand(`test -L ${currentLinkPath}`);
|
|
496
|
+
if (linkCheckResult.code === 0) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const typeResult = await ssh.execCommand(`stat -c %F ${currentLinkPath} 2>/dev/null || file -b ${currentLinkPath}`);
|
|
500
|
+
const fileType = typeResult.stdout.trim();
|
|
501
|
+
if (fileType.includes('directory') || fileType === 'directory') {
|
|
502
|
+
const backupPath = `${currentLinkPath}.backup.${Date.now()}`;
|
|
503
|
+
console.log(chalk.yellow(`⚠️ 检测到已存在的目录: ${currentLinkPath}`));
|
|
504
|
+
console.log(chalk.blue(`📁 将备份到: ${backupPath}`));
|
|
505
|
+
const answers = await inquirer.prompt([
|
|
506
|
+
{
|
|
507
|
+
type: 'confirm',
|
|
508
|
+
name: 'proceed',
|
|
509
|
+
message: '是否继续部署?(已存在的目录将被备份)',
|
|
510
|
+
default: true,
|
|
511
|
+
},
|
|
512
|
+
]);
|
|
513
|
+
if (!answers.proceed) {
|
|
514
|
+
throw new Error('用户取消部署');
|
|
515
|
+
}
|
|
516
|
+
const backupResult = await ssh.execCommand(`mv ${currentLinkPath} ${backupPath}`);
|
|
517
|
+
if (backupResult.code !== 0) {
|
|
518
|
+
throw new Error(`备份目录失败: ${backupResult.stderr}`);
|
|
519
|
+
}
|
|
520
|
+
console.log(chalk.green(`✅ 目录已备份到: ${backupPath}`));
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
const backupPath = `${currentLinkPath}.backup.${Date.now()}`;
|
|
524
|
+
const backupResult = await ssh.execCommand(`mv ${currentLinkPath} ${backupPath}`);
|
|
525
|
+
if (backupResult.code !== 0) {
|
|
526
|
+
throw new Error(`备份文件失败: ${backupResult.stderr}`);
|
|
527
|
+
}
|
|
528
|
+
console.log(chalk.green(`✅ 文件已备份到: ${backupPath}`));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
453
531
|
|
|
454
532
|
export { deployCommand, initConfig, listVersions, rollbackVersion };
|