@noahyu/cd-cli 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023-present, Noah Yu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,367 @@
1
+ # CD CLI - 简易部署工具
2
+
3
+ 一个简易的部署工具,支持版本管理和零停机部署。
4
+
5
+ ## 功能特性
6
+
7
+ - 🚀 一键部署:本地构建 → 压缩 → 上传 → 解压 → 重启服务
8
+ - 📦 版本管理:支持版本号管理,保留多个历史版本
9
+ - 🔄 零停机部署:使用软链接实现原子性切换
10
+ - ⏪ 快速回滚:一键回滚到任意历史版本
11
+ - 📝 版本列表:查看服务器上所有已部署版本
12
+ - 🔒 安全连接:支持密码和私钥认证
13
+ - 🔄 PM2 集成:自动重启 PM2 应用
14
+ - 📝 配置灵活:支持部署前后执行自定义命令
15
+ - 🎯 文件过滤:支持排除不需要的文件
16
+
17
+ ## 安装
18
+
19
+ ### 全局安装
20
+
21
+ ```bash
22
+ npm install -g @noahyu/cd-cli
23
+ ```
24
+
25
+ 安装后可在任何项目中使用 `pcli-cd` 命令。
26
+
27
+ ## 快速开始
28
+
29
+ ### 1. 在项目中初始化配置
30
+
31
+ ```bash
32
+ cd your-project
33
+ pcli-cd init
34
+ ```
35
+
36
+ 这会在项目根目录创建 `pcli-cd.config.js` 配置文件。
37
+
38
+ - **重要** `pcli-cd.config.js` 文件不应该提交到 Git
39
+
40
+ ### 2. 部署项目
41
+
42
+ ```bash
43
+ pcli-cd deploy
44
+ # 或者使用别名
45
+ pcli-cd cd
46
+
47
+ # 指定版本号部署
48
+ pcli-cd deploy --version v1.0.0
49
+ ```
50
+
51
+ ### 3. 查看版本列表
52
+
53
+ ```bash
54
+ pcli-cd list
55
+ # 或者使用别名
56
+ pcli-cd ls
57
+ ```
58
+
59
+ ### 4. 回滚到指定版本
60
+
61
+ ```bash
62
+ pcli-cd rollback
63
+ # 或者使用别名
64
+ pcli-cd rb
65
+
66
+ # 直接指定版本回滚
67
+ pcli-cd rollback --version v1.0.0
68
+ ```
69
+
70
+ ## 部署架构
71
+
72
+ 部署后的服务器目录结构:
73
+
74
+ ```
75
+ /var/www/your-app/
76
+ ├── .output -> .output-v1.0.1 # 软链接指向当前版本
77
+ ├── .output-v1.0.0/ # 历史版本
78
+ ├── .output-v1.0.1/ # 当前版本
79
+ │ └── server/
80
+ │ └── index.mjs # PM2 启动文件
81
+ └── .output-v1.0.2/ # 新版本 (如果有)
82
+ ```
83
+
84
+ PM2 配置始终指向软链接 `.output/server/index.mjs`,这样切换版本时无需修改 PM2 配置。
85
+
86
+ ## 配置文件
87
+
88
+ 配置文件 `pcli-cd.config.js` 示例:
89
+
90
+ > **重要** `pcli-cd.config.js` 文件不应该提交到 Git
91
+
92
+ ```javascript
93
+ // pcli-cd 部署配置文件
94
+ export default {
95
+ // 构建命令 (可选)
96
+ buildCommand: 'npm run build',
97
+
98
+ // 构建输出目录
99
+ buildDir: '.output',
100
+
101
+ // 版本号 (可选,不指定会在部署时询问)
102
+ version: 'v1.0.0',
103
+
104
+ // 服务器配置
105
+ server: {
106
+ host: '192.168.1.100',
107
+ port: 22,
108
+ username: 'root',
109
+ password: 'your-password', // 密码或私钥二选一
110
+ // privateKey: '/path/to/private/key',
111
+ deployPath: '/var/www/your-app',
112
+ },
113
+
114
+ // PM2 配置 (可选)
115
+ pm2: {
116
+ appName: 'your-app-name',
117
+ restart: true,
118
+ },
119
+
120
+ // 排除的文件 (可选) - 作用于构建产物目录
121
+ // 请根据构建输出的实际内容谨慎配置
122
+ excludeFiles: [
123
+ // '**/*.map', // Source Map 文件
124
+ // '**/.DS_Store', // macOS 系统文件
125
+ // '**/Thumbs.db' // Windows 缩略图缓存
126
+ ],
127
+
128
+ // 部署前命令 (可选)
129
+ beforeDeploy: ['npm run test'],
130
+
131
+ // 部署后命令 (可选)
132
+ afterDeploy: ['npm install --production'],
133
+ }
134
+ ```
135
+
136
+ ## 配置选项
137
+
138
+ | 选项 | 类型 | 必填 | 说明 |
139
+ | ------------------- | -------- | ---- | -------------------------------- |
140
+ | `buildCommand` | string | 否 | 构建命令,如 "npm run build" |
141
+ | `buildDir` | string | 是 | 构建输出目录,如 ".output" |
142
+ | `version` | string | 否 | 版本号,不指定会在部署时询问 |
143
+ | `server.host` | string | 是 | 服务器地址 |
144
+ | `server.port` | number | 否 | SSH 端口,默认 22 |
145
+ | `server.username` | string | 是 | 用户名 |
146
+ | `server.password` | string | 否 | 密码 |
147
+ | `server.privateKey` | string | 否 | 私钥路径 |
148
+ | `server.deployPath` | string | 是 | 服务器部署路径 |
149
+ | `pm2.appName` | string | 否 | PM2 应用名称 |
150
+ | `pm2.restart` | boolean | 否 | 是否重启 PM2 应用 |
151
+ | `excludeFiles` | string[] | 否 | 排除的文件模式(相对于构建目录) |
152
+ | `beforeDeploy` | string[] | 否 | 部署前执行的命令 |
153
+ | `afterDeploy` | string[] | 否 | 部署后执行的命令 |
154
+
155
+ ## 命令详解
156
+
157
+ ### deploy (cd)
158
+
159
+ 部署项目到服务器
160
+
161
+ ```bash
162
+ pcli-cd deploy [options]
163
+
164
+ Options:
165
+ -c, --config <config> 配置文件路径 (默认: ./pcli-cd.config.js)
166
+ -v, --version <version> 指定版本号
167
+ -h, --help 显示帮助信息
168
+ ```
169
+
170
+ ### list (ls)
171
+
172
+ 列出服务器上的所有版本
173
+
174
+ ```bash
175
+ pcli-cd list [options]
176
+
177
+ Options:
178
+ -c, --config <config> 配置文件路径 (默认: ./pcli-cd.config.js)
179
+ -h, --help 显示帮助信息
180
+ ```
181
+
182
+ ### rollback (rb)
183
+
184
+ 回滚到指定版本
185
+
186
+ ```bash
187
+ pcli-cd rollback [options]
188
+
189
+ Options:
190
+ -c, --config <config> 配置文件路径 (默认: ./pcli-cd.config.js)
191
+ -v, --version <version> 回滚到的版本号
192
+ -h, --help 显示帮助信息
193
+ ```
194
+
195
+ ### init
196
+
197
+ 初始化配置文件
198
+
199
+ ```bash
200
+ pcli-cd init
201
+ ```
202
+
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
+ ## excludeFiles 配置说明
261
+
262
+ ⚠️ **重要提醒**:`excludeFiles` 作用于**构建产物目录**(如 `dist/`、`.output/` 等),不是项目根目录。
263
+
264
+ ### 工作原理
265
+
266
+ 1. 首先执行 `buildCommand` 生成构建产物到 `buildDir`
267
+ 2. 然后对 `buildDir` 目录进行压缩,此时应用 `excludeFiles` 规则
268
+ 3. 将压缩包上传到服务器
269
+
270
+ ### 配置建议
271
+
272
+ ```javascript
273
+ // ❌ 错误理解:认为排除的是项目根目录文件
274
+ export default {
275
+ excludeFiles: ['src/**', 'node_modules/**']
276
+ }
277
+
278
+ // ✅ 正确理解:排除的是构建目录内的文件
279
+ export default {
280
+ excludeFiles: [
281
+ // 只有当这些文件确实出现在构建目录中,且确认不需要时才排除
282
+ '**/*.map', // Source Map 文件(通常不需要)
283
+ '**/.DS_Store', // macOS 系统文件
284
+ '**/Thumbs.db' // Windows 缩略图缓存
285
+ ]
286
+ }
287
+ ```
288
+
289
+ ### 注意事项
290
+
291
+ - **运行时依赖**:某些框架(如 Nuxt、Next.js)的构建产物可能包含必需的 `node_modules`
292
+ - **建议**:初次使用时保持 `excludeFiles: []`,观察构建产物内容后再配置
293
+
294
+ ## 版本管理
295
+
296
+ ### 零停机部署流程
297
+
298
+ 1. 构建项目到本地 `.output` 目录
299
+ 2. 压缩并上传到服务器的新版本目录 `.output-v1.0.1`
300
+ 3. 在新版本目录执行部署后命令(如安装依赖)
301
+ 4. 原子性切换软链接 `.output` 指向新版本目录
302
+ 5. 重启 PM2 应用
303
+ 6. 清理旧版本(保留最近3个版本)
304
+
305
+ ### 版本命名规则
306
+
307
+ - 自动生成:`v20250820-114530`(基于时间戳)
308
+ - 手动指定:`v1.0.0`、`v2.1.3` 等
309
+ - 版本号可以是任意字符串,建议使用语义化版本
310
+
311
+ ### 回滚机制
312
+
313
+ 回滚只需要切换软链接指向,无需重新上传文件,速度极快:
314
+
315
+ ```bash
316
+ # 查看所有版本
317
+ pcli-cd list
318
+
319
+ # 回滚到指定版本
320
+ pcli-cd rollback --version v1.0.0
321
+
322
+ # 交互式选择版本回滚
323
+ pcli-cd rollback
324
+ ```
325
+
326
+ ## 配置文件
327
+
328
+ 每个项目只需要一个 `pcli-cd.config.js` 配置文件,工具会自动读取当前目录下的配置。
329
+
330
+ ⚠️ **重要提醒** `pcli-cd.config.js` 文件不应该提交到 Git
331
+
332
+ ## 注意事项
333
+
334
+ - **全局安装后首次使用**:安装后可在任意目录使用 `pcli-cd` 命令
335
+ - **配置文件位置**:每个项目根目录需要有 `pcli-cd.config.js` 配置文件
336
+ - 确保服务器已安装 `unzip` 命令
337
+ - 如果使用 PM2,确保服务器已安装 PM2
338
+ - 私钥文件需要有正确的权限 (600)
339
+ - 建议在生产环境使用私钥认证而非密码
340
+ - PM2 配置文件中的启动路径应该指向软链接而非具体版本目录
341
+ - 部署过程中会自动清理旧版本,默认保留最近3个版本
342
+
343
+ ## 故障排除
344
+
345
+ ### 找不到 pcli-cd 命令
346
+
347
+ ```bash
348
+ # 检查是否正确安装
349
+ npm list -g @noahyu/cd-cli
350
+
351
+ # 重新安装
352
+ npm install -g @noahyu/cd-cli
353
+ ```
354
+
355
+ ### 配置文件错误
356
+
357
+ ```bash
358
+ # 检查配置文件是否存在
359
+ ls pcli-cd.config.js
360
+
361
+ # 重新初始化配置
362
+ pcli-cd init
363
+ ```
364
+
365
+ ## License
366
+
367
+ MIT
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import { deployCommand, initConfig, listVersions, rollbackVersion } from '../src/index.js';
4
+
5
+ program.version('1.0.0').description('Simple CI/CD deployment tool');
6
+ program
7
+ .command('deploy')
8
+ .alias('cd')
9
+ .description('Build and deploy project to server')
10
+ .option('-c, --config <config>', 'Configuration file path', './pcli-cd.config.js')
11
+ .option('-v, --version <version>', 'Specify version number')
12
+ .action(deployCommand);
13
+ program.command('init').description('Initialize CD configuration file').action(initConfig);
14
+ program
15
+ .command('list')
16
+ .alias('ls')
17
+ .description('List deployed versions on server')
18
+ .option('-c, --config <config>', 'Configuration file path', './pcli-cd.config.js')
19
+ .action(listVersions);
20
+ program
21
+ .command('rollback')
22
+ .alias('rb')
23
+ .description('Rollback to a previous version')
24
+ .option('-c, --config <config>', 'Configuration file path', './pcli-cd.config.js')
25
+ .option('-v, --version <version>', 'Version to rollback to')
26
+ .action(rollbackVersion);
27
+ program.parse(process.argv);
@@ -0,0 +1,454 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'fs';
3
+ import { resolve, join } from 'path';
4
+ import { execa } from 'execa';
5
+ import fse from 'fs-extra';
6
+ import archiver from 'archiver';
7
+ import { NodeSSH } from 'node-ssh';
8
+ import ora from 'ora';
9
+ import chalk from 'chalk';
10
+ import inquirer from 'inquirer';
11
+
12
+ async function deployCommand(options) {
13
+ const configPath = resolve(process.cwd(), options.config);
14
+ if (!existsSync(configPath)) {
15
+ console.log(chalk.red(`❌ 配置文件不存在: ${configPath}`));
16
+ console.log(chalk.yellow('💡 请创建 pcli-cd.config.js 配置文件'));
17
+ process.exit(1);
18
+ }
19
+ let config;
20
+ try {
21
+ const configModule = await import(configPath);
22
+ config = configModule.default || configModule;
23
+ }
24
+ catch (error) {
25
+ console.log(chalk.red(`❌ 配置文件读取失败: ${error}`));
26
+ process.exit(1);
27
+ }
28
+ if (!config.version && !options.version) {
29
+ const answers = await inquirer.prompt([
30
+ {
31
+ type: 'input',
32
+ name: 'version',
33
+ message: '请输入版本号 (例如: v1.0.0):',
34
+ default: `v${new Date().toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-')}`,
35
+ validate: (input) => input.trim() !== '' || '版本号不能为空',
36
+ },
37
+ ]);
38
+ config.version = answers.version;
39
+ }
40
+ else {
41
+ config.version = options.version || config.version;
42
+ }
43
+ await deploy(config);
44
+ }
45
+ async function deploy(config) {
46
+ const spinner = ora();
47
+ const tempDir = join(process.cwd(), '.deploy-temp');
48
+ const zipPath = join(tempDir, 'build.zip');
49
+ const version = config.version || `v${Date.now()}`;
50
+ const buildDirName = config.buildDir.split('/').pop() || 'build';
51
+ try {
52
+ await fse.remove(tempDir);
53
+ await fse.ensureDir(tempDir);
54
+ if (config.beforeDeploy) {
55
+ spinner.start('执行构建前命令...');
56
+ for (const cmd of config.beforeDeploy) {
57
+ await execa('bash', ['-c', cmd], { stdio: 'inherit' });
58
+ }
59
+ spinner.succeed('构建前命令执行完成');
60
+ }
61
+ if (config.buildCommand) {
62
+ spinner.start('正在构建项目...');
63
+ await execa('bash', ['-c', config.buildCommand], { stdio: 'inherit' });
64
+ spinner.succeed('项目构建完成');
65
+ }
66
+ const buildPath = resolve(process.cwd(), config.buildDir);
67
+ if (!existsSync(buildPath)) {
68
+ throw new Error(`构建目录不存在: ${buildPath}`);
69
+ }
70
+ spinner.start('正在压缩文件...');
71
+ await createZip(buildPath, zipPath, config.excludeFiles);
72
+ spinner.succeed('文件压缩完成');
73
+ spinner.start('正在连接服务器...');
74
+ const ssh = new NodeSSH();
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
+ });
82
+ spinner.succeed('服务器连接成功');
83
+ const versionDirName = `${buildDirName}-${version}`;
84
+ const versionPath = join(config.server.deployPath, versionDirName);
85
+ const currentLinkPath = join(config.server.deployPath, buildDirName);
86
+ spinner.start('正在准备部署目录...');
87
+ await ssh.execCommand(`mkdir -p ${config.server.deployPath}`);
88
+ await ssh.execCommand(`mkdir -p ${versionPath}`);
89
+ spinner.succeed('部署目录准备完成');
90
+ spinner.start(`正在上传文件到版本目录 ${versionDirName}...`);
91
+ const remoteZipPath = join(versionPath, 'build.zip');
92
+ await ssh.putFile(zipPath, remoteZipPath);
93
+ spinner.succeed('文件上传完成');
94
+ spinner.start('正在解压文件...');
95
+ await ssh.execCommand(`cd ${versionPath} && unzip -o build.zip && rm build.zip`);
96
+ await ssh.execCommand(`
97
+ cd ${versionPath} &&
98
+ if [ -d "${buildDirName}" ]; then
99
+ mv ${buildDirName}/* . 2>/dev/null || true
100
+ mv ${buildDirName}/.[!.]* . 2>/dev/null || true
101
+ rmdir ${buildDirName} 2>/dev/null || true
102
+ fi
103
+ `);
104
+ spinner.succeed('文件解压完成');
105
+ if (config.afterDeploy) {
106
+ spinner.start('执行部署后命令...');
107
+ for (const cmd of config.afterDeploy) {
108
+ await ssh.execCommand(cmd, { cwd: versionPath });
109
+ }
110
+ spinner.succeed('部署后命令执行完成');
111
+ }
112
+ spinner.start(`正在切换到新版本 ${version}...`);
113
+ const tempLinkPath = `${currentLinkPath}.tmp.${Date.now()}`;
114
+ await ssh.execCommand(`ln -sfn ${versionPath} ${tempLinkPath}`);
115
+ await ssh.execCommand(`mv ${tempLinkPath} ${currentLinkPath}`);
116
+ spinner.succeed(`版本切换完成: ${buildDirName} -> ${versionDirName}`);
117
+ if (config.pm2) {
118
+ spinner.start('正在重启 PM2 应用...');
119
+ const { appName, restart = true } = config.pm2;
120
+ if (restart) {
121
+ await new Promise((resolve) => setTimeout(resolve, 1000));
122
+ const result = await ssh.execCommand(`pm2 restart ${appName}`);
123
+ if (result.code === 0) {
124
+ spinner.succeed('PM2 应用重启成功');
125
+ }
126
+ else {
127
+ const startResult = await ssh.execCommand(`pm2 start ${appName}`);
128
+ if (startResult.code === 0) {
129
+ spinner.succeed('PM2 应用启动成功');
130
+ }
131
+ else {
132
+ spinner.warn('PM2 操作失败,请手动检查');
133
+ console.log(chalk.yellow(`重启命令: pm2 restart ${appName}`));
134
+ console.log(chalk.yellow(`启动命令: pm2 start ${appName}`));
135
+ }
136
+ }
137
+ }
138
+ }
139
+ spinner.start('正在清理旧版本...');
140
+ await cleanOldVersions(ssh, config.server.deployPath, buildDirName, 3);
141
+ spinner.succeed('旧版本清理完成');
142
+ ssh.dispose();
143
+ await fse.remove(tempDir);
144
+ console.log(chalk.green('\n🎉 部署完成!'));
145
+ console.log(chalk.blue(`📦 版本: ${version}`));
146
+ console.log(chalk.blue(`🔗 当前链接: ${currentLinkPath} -> ${versionPath}`));
147
+ if (config.pm2) {
148
+ console.log(chalk.blue(`⚡ PM2 应用: ${config.pm2.appName}`));
149
+ console.log(chalk.gray(` 启动文件: ${currentLinkPath}/server/index.mjs`));
150
+ }
151
+ }
152
+ catch (error) {
153
+ spinner.fail('部署失败');
154
+ console.error(chalk.red(`❌ 错误: ${error}`));
155
+ await fse.remove(tempDir);
156
+ process.exit(1);
157
+ }
158
+ }
159
+ async function createZip(sourcePath, outputPath, excludeFiles = []) {
160
+ return new Promise((resolve, reject) => {
161
+ const output = fse.createWriteStream(outputPath);
162
+ const archive = archiver('zip', { zlib: { level: 9 } });
163
+ output.on('close', () => resolve());
164
+ archive.on('error', (err) => reject(err));
165
+ archive.pipe(output);
166
+ archive.glob('**/*', {
167
+ cwd: sourcePath,
168
+ ignore: excludeFiles,
169
+ });
170
+ archive.finalize();
171
+ });
172
+ }
173
+ async function cleanOldVersions(ssh, deployPath, buildDirName, keepCount) {
174
+ try {
175
+ const result = await ssh.execCommand(`find ${deployPath} -maxdepth 1 -type d -name "${buildDirName}-*" | sort -V`);
176
+ if (result.code !== 0) {
177
+ return;
178
+ }
179
+ const versionDirs = result.stdout
180
+ .split('\n')
181
+ .filter((dir) => dir.trim())
182
+ .map((dir) => dir.trim());
183
+ if (versionDirs.length > keepCount) {
184
+ const dirsToDelete = versionDirs.slice(0, -keepCount);
185
+ for (const dir of dirsToDelete) {
186
+ await ssh.execCommand(`rm -rf "${dir}"`);
187
+ }
188
+ }
189
+ }
190
+ catch (error) {
191
+ console.warn(chalk.yellow(`⚠️ 清理旧版本时出现警告: ${error}`));
192
+ }
193
+ }
194
+ async function initConfig() {
195
+ console.log(chalk.blue('🚀 初始化配置文件'));
196
+ const answers = await inquirer.prompt([
197
+ {
198
+ type: 'input',
199
+ name: 'buildCommand',
200
+ message: '构建命令 (如: npm run build):',
201
+ default: 'npm run build',
202
+ },
203
+ {
204
+ type: 'input',
205
+ name: 'buildDir',
206
+ message: '构建输出目录:',
207
+ default: 'dist',
208
+ },
209
+ {
210
+ type: 'input',
211
+ name: 'host',
212
+ message: '服务器地址:',
213
+ validate: (input) => input.trim() !== '' || '请输入服务器地址',
214
+ },
215
+ {
216
+ type: 'input',
217
+ name: 'port',
218
+ message: '服务器端口:',
219
+ default: '22',
220
+ },
221
+ {
222
+ type: 'input',
223
+ name: 'username',
224
+ message: '用户名:',
225
+ validate: (input) => input.trim() !== '' || '请输入用户名',
226
+ },
227
+ {
228
+ type: 'password',
229
+ name: 'password',
230
+ message: '密码 (留空使用私钥):',
231
+ mask: '*',
232
+ },
233
+ {
234
+ type: 'input',
235
+ name: 'deployPath',
236
+ message: '服务器部署路径:',
237
+ validate: (input) => input.trim() !== '' || '请输入部署路径',
238
+ },
239
+ {
240
+ type: 'input',
241
+ name: 'pm2AppName',
242
+ message: 'PM2 应用名称 (可选):',
243
+ },
244
+ ]);
245
+ const config = {
246
+ buildCommand: answers.buildCommand,
247
+ buildDir: answers.buildDir,
248
+ server: {
249
+ host: answers.host,
250
+ port: parseInt(answers.port),
251
+ username: answers.username,
252
+ password: answers.password || undefined,
253
+ deployPath: answers.deployPath,
254
+ },
255
+ excludeFiles: [],
256
+ };
257
+ if (answers.pm2AppName) {
258
+ config.pm2 = {
259
+ appName: answers.pm2AppName,
260
+ restart: true,
261
+ };
262
+ }
263
+ const configContent = `// pcli-cd 部署配置文件
264
+ export default ${JSON.stringify(config, null, 2)}`;
265
+ await fse.writeFile('pcli-cd.config.js', configContent);
266
+ console.log(chalk.green('✅ 配置文件已创建: pcli-cd.config.js'));
267
+ }
268
+ async function listVersions(options) {
269
+ const configPath = resolve(process.cwd(), options.config);
270
+ if (!existsSync(configPath)) {
271
+ console.log(chalk.red(`❌ 配置文件不存在: ${configPath}`));
272
+ process.exit(1);
273
+ }
274
+ let config;
275
+ try {
276
+ const configModule = await import(configPath);
277
+ config = configModule.default || configModule;
278
+ }
279
+ catch (error) {
280
+ console.log(chalk.red(`❌ 配置文件读取失败: ${error}`));
281
+ process.exit(1);
282
+ }
283
+ const spinner = ora('正在获取版本列表...');
284
+ spinner.start();
285
+ try {
286
+ const ssh = new NodeSSH();
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
+ });
294
+ const buildDirName = config.buildDir.split('/').pop() || 'build';
295
+ const currentLinkPath = join(config.server.deployPath, buildDirName);
296
+ const currentResult = await ssh.execCommand(`readlink ${currentLinkPath}`);
297
+ const currentVersion = currentResult.code === 0
298
+ ? currentResult.stdout.trim().split('/').pop()?.replace(`${buildDirName}-`, '') || 'unknown'
299
+ : 'unknown';
300
+ const result = await ssh.execCommand(`find ${config.server.deployPath} -maxdepth 1 -type d -name "${buildDirName}-*" | sort -V`);
301
+ if (result.code !== 0) {
302
+ throw new Error('无法获取版本列表');
303
+ }
304
+ const versions = result.stdout
305
+ .split('\n')
306
+ .filter((dir) => dir.trim())
307
+ .map((dir) => {
308
+ const version = dir.trim().split('/').pop()?.replace(`${buildDirName}-`, '') || '';
309
+ return {
310
+ version,
311
+ path: dir.trim(),
312
+ isCurrent: version === currentVersion,
313
+ };
314
+ })
315
+ .reverse();
316
+ spinner.succeed('版本列表获取成功');
317
+ if (versions.length === 0) {
318
+ console.log(chalk.yellow('📦 服务器上没有找到任何版本'));
319
+ return;
320
+ }
321
+ console.log(chalk.blue('\n📦 已部署的版本:'));
322
+ console.log('─'.repeat(50));
323
+ versions.forEach((version) => {
324
+ const prefix = version.isCurrent ? chalk.green('●') : chalk.gray('○');
325
+ const label = version.isCurrent ? chalk.green(' (当前)') : '';
326
+ const versionText = version.isCurrent
327
+ ? chalk.green(version.version)
328
+ : chalk.white(version.version);
329
+ console.log(`${prefix} ${versionText}${label}`);
330
+ });
331
+ console.log('─'.repeat(50));
332
+ console.log(chalk.gray(`总计: ${versions.length} 个版本`));
333
+ ssh.dispose();
334
+ }
335
+ catch (error) {
336
+ spinner.fail('获取版本列表失败');
337
+ console.error(chalk.red(`❌ 错误: ${error}`));
338
+ process.exit(1);
339
+ }
340
+ }
341
+ async function rollbackVersion(options) {
342
+ const configPath = resolve(process.cwd(), options.config);
343
+ if (!existsSync(configPath)) {
344
+ console.log(chalk.red(`❌ 配置文件不存在: ${configPath}`));
345
+ process.exit(1);
346
+ }
347
+ let config;
348
+ try {
349
+ const configModule = await import(configPath);
350
+ config = configModule.default || configModule;
351
+ }
352
+ catch (error) {
353
+ console.log(chalk.red(`❌ 配置文件读取失败: ${error}`));
354
+ process.exit(1);
355
+ }
356
+ const buildDirName = config.buildDir.split('/').pop() || 'build';
357
+ let targetVersion = options.version;
358
+ if (!targetVersion) {
359
+ const ssh = new NodeSSH();
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
+ });
367
+ const result = await ssh.execCommand(`find ${config.server.deployPath} -maxdepth 1 -type d -name "${buildDirName}-*" | sort -V`);
368
+ if (result.code !== 0) {
369
+ console.log(chalk.red('❌ 无法获取版本列表'));
370
+ process.exit(1);
371
+ }
372
+ const versions = result.stdout
373
+ .split('\n')
374
+ .filter((dir) => dir.trim())
375
+ .map((dir) => dir.trim().split('/').pop()?.replace(`${buildDirName}-`, '') || '')
376
+ .filter(Boolean)
377
+ .reverse();
378
+ if (versions.length === 0) {
379
+ console.log(chalk.yellow('📦 服务器上没有找到任何可回滚的版本'));
380
+ ssh.dispose();
381
+ return;
382
+ }
383
+ const answers = await inquirer.prompt([
384
+ {
385
+ type: 'list',
386
+ name: 'version',
387
+ message: '选择要回滚到的版本:',
388
+ choices: versions,
389
+ },
390
+ ]);
391
+ targetVersion = answers.version;
392
+ ssh.dispose();
393
+ }
394
+ if (!targetVersion) {
395
+ throw new Error('未指定回滚版本');
396
+ }
397
+ await performRollback(config, targetVersion, buildDirName);
398
+ }
399
+ async function performRollback(config, targetVersion, buildDirName) {
400
+ const spinner = ora();
401
+ try {
402
+ spinner.start('正在连接服务器...');
403
+ const ssh = new NodeSSH();
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
+ });
411
+ spinner.succeed('服务器连接成功');
412
+ const versionDirName = `${buildDirName}-${targetVersion}`;
413
+ const versionPath = join(config.server.deployPath, versionDirName);
414
+ const currentLinkPath = join(config.server.deployPath, buildDirName);
415
+ spinner.start('正在检查目标版本...');
416
+ const checkResult = await ssh.execCommand(`test -d ${versionPath}`);
417
+ if (checkResult.code !== 0) {
418
+ throw new Error(`版本 ${targetVersion} 不存在`);
419
+ }
420
+ spinner.succeed('目标版本检查通过');
421
+ spinner.start(`正在回滚到版本 ${targetVersion}...`);
422
+ const tempLinkPath = `${currentLinkPath}.tmp.${Date.now()}`;
423
+ await ssh.execCommand(`ln -sfn ${versionPath} ${tempLinkPath}`);
424
+ await ssh.execCommand(`mv ${tempLinkPath} ${currentLinkPath}`);
425
+ spinner.succeed(`回滚完成: ${buildDirName} -> ${versionDirName}`);
426
+ if (config.pm2) {
427
+ spinner.start('正在重启 PM2 应用...');
428
+ const { appName } = config.pm2;
429
+ await new Promise((resolve) => setTimeout(resolve, 1000));
430
+ const result = await ssh.execCommand(`pm2 restart ${appName}`);
431
+ if (result.code === 0) {
432
+ spinner.succeed('PM2 应用重启成功');
433
+ }
434
+ else {
435
+ spinner.warn('PM2 重启失败,请手动检查');
436
+ console.log(chalk.yellow(`手动重启命令: pm2 restart ${appName}`));
437
+ }
438
+ }
439
+ ssh.dispose();
440
+ console.log(chalk.green('\n🎉 回滚完成!'));
441
+ console.log(chalk.blue(`📦 当前版本: ${targetVersion}`));
442
+ console.log(chalk.blue(`🔗 当前链接: ${currentLinkPath} -> ${versionPath}`));
443
+ if (config.pm2) {
444
+ console.log(chalk.blue(`⚡ PM2 应用: ${config.pm2.appName}`));
445
+ }
446
+ }
447
+ catch (error) {
448
+ spinner.fail('回滚失败');
449
+ console.error(chalk.red(`❌ 错误: ${error}`));
450
+ process.exit(1);
451
+ }
452
+ }
453
+
454
+ export { deployCommand, initConfig, listVersions, rollbackVersion };
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@noahyu/cd-cli",
3
+ "version": "1.0.0",
4
+ "description": "Global CLI tool for simple project deployment with version management",
5
+ "type": "module",
6
+ "main": "./dist/bin/cli.js",
7
+ "files": [
8
+ "/dist"
9
+ ],
10
+ "bin": {
11
+ "pcli-cd": "./dist/bin/cli.js"
12
+ },
13
+ "keywords": [
14
+ "deployment",
15
+ "ci-cd",
16
+ "cli",
17
+ "global",
18
+ "version-management",
19
+ "zero-downtime"
20
+ ],
21
+ "author": "Noah Yu",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/Noah-Ywh/project-cli.git"
25
+ },
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=20.0.0"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "boxen": "^8.0.1",
35
+ "chalk": "^5.6.0",
36
+ "commander": "^14.0.0",
37
+ "ejs": "^3.1.10",
38
+ "execa": "^9.6.0",
39
+ "fs-extra": "^11.3.1",
40
+ "globby": "^14.1.0",
41
+ "inquirer": "^12.9.3",
42
+ "ora": "^8.2.0",
43
+ "parse-json": "^8.3.0",
44
+ "progress": "^2.0.3",
45
+ "semver": "^7.7.2",
46
+ "tslib": "^2.8.1",
47
+ "validate-npm-package-name": "^6.0.2",
48
+ "node-ssh": "^13.2.0",
49
+ "archiver": "^7.0.1"
50
+ },
51
+ "devDependencies": {
52
+ "@rollup/plugin-commonjs": "^28.0.6",
53
+ "@rollup/plugin-json": "^6.1.0",
54
+ "@rollup/plugin-node-resolve": "^16.0.1",
55
+ "@rollup/plugin-typescript": "^12.1.4",
56
+ "@types/ejs": "^3.1.5",
57
+ "@types/fs-extra": "^11.0.4",
58
+ "@types/inquirer": "^9.0.9",
59
+ "@types/node": "^24.3.0",
60
+ "@types/parse-json": "^7.0.0",
61
+ "@types/progress": "^2.0.7",
62
+ "@types/semver": "^7.7.0",
63
+ "@types/validate-npm-package-name": "^4.0.2",
64
+ "@types/archiver": "^6.0.2",
65
+ "rollup": "^4.46.3",
66
+ "rollup-plugin-copy": "^3.5.0"
67
+ },
68
+ "scripts": {
69
+ "build": "rollup -c",
70
+ "build:w": "rollup -c -w",
71
+ "changelog": "conventional-changelog -n '../../changelog.config.js' -i CHANGELOG.md -s -r 0 -k ./package.json --commit-path ./",
72
+ "prettier": "prettier --config .prettierrc --write ./**/*.md",
73
+ "vitest": "vitest",
74
+ "vitest:c": "vitest run --coverage",
75
+ "vitest:u": "vitest --coverage --ui"
76
+ }
77
+ }