@noahyu/cd-cli 1.1.1 → 1.2.1
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 +12 -16
- package/dist/bin/cli.js +7 -1
- package/dist/src/index.js +117 -57
- package/dist/src/templates/config.ejs +66 -0
- package/dist/src/utils/get-version.js +27 -0
- package/dist/src/utils/template.js +20 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 简易的项目部署工具
|
|
2
2
|
|
|
3
|
-
一个简易的部署工具,支持版本管理和零停机部署。
|
|
3
|
+
> 一个简易的部署工具,支持版本管理和零停机部署。
|
|
4
4
|
|
|
5
5
|
## 功能特性
|
|
6
6
|
|
|
@@ -35,7 +35,8 @@ pcli-cd init
|
|
|
35
35
|
|
|
36
36
|
这会在项目根目录创建 `pcli-cd.config.js` 配置文件。
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
> [!WARNING]
|
|
39
|
+
> `pcli-cd.config.js` 配置文件存在敏感信息,不应该提交到 Git
|
|
39
40
|
|
|
40
41
|
### 2. 部署项目
|
|
41
42
|
|
|
@@ -87,7 +88,8 @@ PM2 配置始终指向软链接 `.output/server/index.mjs`,这样切换版本
|
|
|
87
88
|
|
|
88
89
|
配置文件 `pcli-cd.config.js` 示例:
|
|
89
90
|
|
|
90
|
-
>
|
|
91
|
+
> [!WARNING]
|
|
92
|
+
> `pcli-cd.config.js` 配置文件存在敏感信息,不应该提交到 Git
|
|
91
93
|
|
|
92
94
|
```javascript
|
|
93
95
|
// pcli-cd 部署配置文件
|
|
@@ -106,13 +108,10 @@ export default {
|
|
|
106
108
|
port: 22,
|
|
107
109
|
/** 用户名 */
|
|
108
110
|
username: 'root',
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
privateKeyPath: '/home/user/.ssh/id_rsa',
|
|
114
|
-
/** 密码 */
|
|
115
|
-
password: 'your-password',
|
|
111
|
+
/** SSH 认证方式(优先级:privateKey > privateKeyPath > password) */
|
|
112
|
+
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----', // 私钥
|
|
113
|
+
privateKeyPath: '/home/user/.ssh/id_rsa', // 私钥文件路径
|
|
114
|
+
password: 'your-password', // 密码
|
|
116
115
|
/** 部署目录 */
|
|
117
116
|
deployPath: '/var/www/your-app',
|
|
118
117
|
},
|
|
@@ -344,7 +343,8 @@ pcli-cd rollback
|
|
|
344
343
|
|
|
345
344
|
每个项目只需要一个 `pcli-cd.config.js` 配置文件,工具会自动读取当前目录下的配置。
|
|
346
345
|
|
|
347
|
-
|
|
346
|
+
> [!WARNING]
|
|
347
|
+
> `pcli-cd.config.js` 配置文件存在敏感信息,不应该提交到 Git
|
|
348
348
|
|
|
349
349
|
## 注意事项
|
|
350
350
|
|
|
@@ -420,7 +420,3 @@ ssh -p 22 root@your-server.com
|
|
|
420
420
|
# 测试指定私钥连接
|
|
421
421
|
ssh -i /home/user/.ssh/id_rsa -p 22 root@your-server.com
|
|
422
422
|
```
|
|
423
|
-
|
|
424
|
-
## License
|
|
425
|
-
|
|
426
|
-
MIT
|
package/dist/bin/cli.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { program } from 'commander';
|
|
3
3
|
import { deployCommand, initConfig, listVersions, rollbackVersion } from '../src/index.js';
|
|
4
|
+
import { getVersion } from '../src/utils/get-version.js';
|
|
4
5
|
|
|
5
|
-
program
|
|
6
|
+
program
|
|
7
|
+
.version(getVersion('../../package.json', import.meta.url), '-v, --version')
|
|
8
|
+
.description('Simple CI/CD deployment tool');
|
|
6
9
|
program
|
|
7
10
|
.command('deploy')
|
|
8
11
|
.alias('cd')
|
|
9
12
|
.description('Build and deploy project to server')
|
|
10
13
|
.option('-c, --config <config>', 'Configuration file path', './pcli-cd.config.js')
|
|
11
14
|
.option('-v, --version <version>', 'Specify version number')
|
|
15
|
+
.option('-n, --name <name>', 'Specify deployment environment name (dev, prod, staging, etc.). If not specified, will prompt to select interactively')
|
|
12
16
|
.action(deployCommand);
|
|
13
17
|
program.command('init').description('Initialize CD configuration file').action(initConfig);
|
|
14
18
|
program
|
|
@@ -16,6 +20,7 @@ program
|
|
|
16
20
|
.alias('ls')
|
|
17
21
|
.description('List deployed versions on server')
|
|
18
22
|
.option('-c, --config <config>', 'Configuration file path', './pcli-cd.config.js')
|
|
23
|
+
.option('-n, --name <name>', 'Specify environment name to list (dev, prod, staging, etc.). If not specified, will prompt to select interactively')
|
|
19
24
|
.action(listVersions);
|
|
20
25
|
program
|
|
21
26
|
.command('rollback')
|
|
@@ -23,5 +28,6 @@ program
|
|
|
23
28
|
.description('Rollback to a previous version')
|
|
24
29
|
.option('-c, --config <config>', 'Configuration file path', './pcli-cd.config.js')
|
|
25
30
|
.option('-v, --version <version>', 'Version to rollback to')
|
|
31
|
+
.option('-n, --name <name>', 'Specify environment name to rollback (dev, prod, staging, etc.). If not specified, will prompt to select interactively')
|
|
26
32
|
.action(rollbackVersion);
|
|
27
33
|
program.parse(process.argv);
|
package/dist/src/index.js
CHANGED
|
@@ -8,24 +8,23 @@ import { NodeSSH } from 'node-ssh';
|
|
|
8
8
|
import ora from 'ora';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import inquirer from 'inquirer';
|
|
11
|
+
import { renderConfigTemplate } from './utils/template.js';
|
|
11
12
|
|
|
12
13
|
async function deployCommand(options) {
|
|
13
14
|
const configPath = resolve(process.cwd(), options.config);
|
|
14
|
-
|
|
15
|
-
console.log(chalk.red(`❌ 配置文件不存在: ${configPath}`));
|
|
16
|
-
console.log(chalk.yellow('💡 请创建 pcli-cd.config.js 配置文件'));
|
|
17
|
-
process.exit(1);
|
|
18
|
-
}
|
|
15
|
+
const configResult = await resolveEnvConfig(configPath, options.name);
|
|
19
16
|
let config;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
config = configModule.default || configModule;
|
|
17
|
+
if (configResult.targetConfig) {
|
|
18
|
+
config = configResult.targetConfig;
|
|
23
19
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
else {
|
|
21
|
+
const selected = await selectEnvironmentFromConfigs(configResult.allConfigs);
|
|
22
|
+
config = selected.targetConfig;
|
|
27
23
|
}
|
|
28
|
-
|
|
24
|
+
console.log(chalk.blue(`🚀 部署环境: ${chalk.bold(config.name)}`));
|
|
25
|
+
console.log(chalk.gray(`📍 部署路径: ${config.server.deployPath}`));
|
|
26
|
+
let version = options.version;
|
|
27
|
+
if (!version) {
|
|
29
28
|
const answers = await inquirer.prompt([
|
|
30
29
|
{
|
|
31
30
|
type: 'input',
|
|
@@ -35,18 +34,14 @@ async function deployCommand(options) {
|
|
|
35
34
|
validate: (input) => input.trim() !== '' || '版本号不能为空',
|
|
36
35
|
},
|
|
37
36
|
]);
|
|
38
|
-
|
|
37
|
+
version = answers.version;
|
|
39
38
|
}
|
|
40
|
-
|
|
41
|
-
config.version = options.version || config.version;
|
|
42
|
-
}
|
|
43
|
-
await deploy(config);
|
|
39
|
+
await deploy(config, version);
|
|
44
40
|
}
|
|
45
|
-
async function deploy(config) {
|
|
41
|
+
async function deploy(config, version) {
|
|
46
42
|
const spinner = ora();
|
|
47
43
|
const tempDir = join(process.cwd(), '.deploy-temp');
|
|
48
44
|
const zipPath = join(tempDir, 'build.zip');
|
|
49
|
-
const version = config.version || `v${Date.now()}`;
|
|
50
45
|
const buildDirName = config.buildDir.split('/').pop() || 'build';
|
|
51
46
|
try {
|
|
52
47
|
await fse.remove(tempDir);
|
|
@@ -206,6 +201,13 @@ async function cleanOldVersions(ssh, deployPath, buildDirName, keepCount) {
|
|
|
206
201
|
async function initConfig() {
|
|
207
202
|
console.log(chalk.blue('🚀 初始化配置文件'));
|
|
208
203
|
const answers = await inquirer.prompt([
|
|
204
|
+
{
|
|
205
|
+
type: 'input',
|
|
206
|
+
name: 'envName',
|
|
207
|
+
message: '环境名称 (如: dev, prod, staging):',
|
|
208
|
+
default: 'dev',
|
|
209
|
+
validate: (input) => input.trim() !== '' || '请输入环境名称',
|
|
210
|
+
},
|
|
209
211
|
{
|
|
210
212
|
type: 'input',
|
|
211
213
|
name: 'buildCommand',
|
|
@@ -253,44 +255,35 @@ async function initConfig() {
|
|
|
253
255
|
message: 'PM2 应用名称 (可选):',
|
|
254
256
|
},
|
|
255
257
|
]);
|
|
256
|
-
const
|
|
258
|
+
const configContent = renderConfigTemplate({
|
|
259
|
+
envName: answers.envName,
|
|
257
260
|
buildCommand: answers.buildCommand,
|
|
258
261
|
buildDir: answers.buildDir,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
excludeFiles: [],
|
|
267
|
-
};
|
|
268
|
-
if (answers.pm2AppName) {
|
|
269
|
-
config.pm2 = {
|
|
270
|
-
appName: answers.pm2AppName,
|
|
271
|
-
restart: true,
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
const configContent = `// pcli-cd 部署配置文件
|
|
275
|
-
export default ${JSON.stringify(config, null, 2)}`;
|
|
262
|
+
host: answers.host,
|
|
263
|
+
port: answers.port,
|
|
264
|
+
username: answers.username,
|
|
265
|
+
privateKeyPath: answers.privateKeyPath || undefined,
|
|
266
|
+
deployPath: answers.deployPath,
|
|
267
|
+
pm2AppName: answers.pm2AppName || undefined,
|
|
268
|
+
});
|
|
276
269
|
await fse.writeFile('pcli-cd.config.js', configContent);
|
|
277
270
|
console.log(chalk.green('✅ 配置文件已创建: pcli-cd.config.js'));
|
|
271
|
+
console.log(chalk.blue(`📝 默认环境: ${answers.envName}`));
|
|
272
|
+
console.log(chalk.gray('💡 可以在 apps 数组中添加更多环境配置'));
|
|
273
|
+
console.log(chalk.gray('💡 配置文件包含详细的注释说明'));
|
|
278
274
|
}
|
|
279
275
|
async function listVersions(options) {
|
|
280
276
|
const configPath = resolve(process.cwd(), options.config);
|
|
281
|
-
|
|
282
|
-
console.log(chalk.red(`❌ 配置文件不存在: ${configPath}`));
|
|
283
|
-
process.exit(1);
|
|
284
|
-
}
|
|
277
|
+
const configResult = await resolveEnvConfig(configPath, options.name);
|
|
285
278
|
let config;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
config = configModule.default || configModule;
|
|
279
|
+
if (configResult.targetConfig) {
|
|
280
|
+
config = configResult.targetConfig;
|
|
289
281
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
282
|
+
else {
|
|
283
|
+
const selected = await selectEnvironmentFromConfigs(configResult.allConfigs);
|
|
284
|
+
config = selected.targetConfig;
|
|
293
285
|
}
|
|
286
|
+
console.log(chalk.blue(`🔍 查看环境: ${chalk.bold(config.name)}`));
|
|
294
287
|
const spinner = ora('正在获取版本列表...');
|
|
295
288
|
spinner.start();
|
|
296
289
|
try {
|
|
@@ -344,19 +337,16 @@ async function listVersions(options) {
|
|
|
344
337
|
}
|
|
345
338
|
async function rollbackVersion(options) {
|
|
346
339
|
const configPath = resolve(process.cwd(), options.config);
|
|
347
|
-
|
|
348
|
-
console.log(chalk.red(`❌ 配置文件不存在: ${configPath}`));
|
|
349
|
-
process.exit(1);
|
|
350
|
-
}
|
|
340
|
+
const configResult = await resolveEnvConfig(configPath, options.name);
|
|
351
341
|
let config;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
config = configModule.default || configModule;
|
|
342
|
+
if (configResult.targetConfig) {
|
|
343
|
+
config = configResult.targetConfig;
|
|
355
344
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
345
|
+
else {
|
|
346
|
+
const selected = await selectEnvironmentFromConfigs(configResult.allConfigs);
|
|
347
|
+
config = selected.targetConfig;
|
|
359
348
|
}
|
|
349
|
+
console.log(chalk.blue(`⏪ 回滚环境: ${chalk.bold(config.name)}`));
|
|
360
350
|
const buildDirName = config.buildDir.split('/').pop() || 'build';
|
|
361
351
|
let targetVersion = options.version;
|
|
362
352
|
if (!targetVersion) {
|
|
@@ -453,6 +443,76 @@ async function performRollback(config, targetVersion, buildDirName) {
|
|
|
453
443
|
process.exit(1);
|
|
454
444
|
}
|
|
455
445
|
}
|
|
446
|
+
async function resolveEnvConfig(configPath, envName) {
|
|
447
|
+
if (!existsSync(configPath)) {
|
|
448
|
+
console.log(chalk.red(`❌ 配置文件不存在: ${configPath}`));
|
|
449
|
+
console.log(chalk.yellow('💡 请创建 pcli-cd.config.js 配置文件'));
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
let rawConfig;
|
|
453
|
+
try {
|
|
454
|
+
const configModule = await import(configPath);
|
|
455
|
+
rawConfig = configModule.default || configModule;
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
console.log(chalk.red(`❌ 配置文件读取失败: ${error}`));
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
if (!rawConfig.apps || !Array.isArray(rawConfig.apps)) {
|
|
462
|
+
console.log(chalk.red('❌ 配置文件格式错误:缺少 apps 数组'));
|
|
463
|
+
console.log(chalk.yellow('💡 配置文件应该包含一个 apps 数组,每个元素都是一个环境配置'));
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
if (rawConfig.apps.length === 0) {
|
|
467
|
+
console.log(chalk.red('❌ 配置文件中没有任何环境配置'));
|
|
468
|
+
console.log(chalk.yellow('💡 请在 apps 数组中添加至少一个环境配置'));
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
if (!envName) {
|
|
472
|
+
return {
|
|
473
|
+
targetConfig: null,
|
|
474
|
+
allConfigs: rawConfig.apps,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
const envConfig = rawConfig.apps.find((app) => app.name === envName);
|
|
478
|
+
if (!envConfig) {
|
|
479
|
+
console.log(chalk.red(`❌ 环境配置 "${envName}" 不存在`));
|
|
480
|
+
console.log(chalk.yellow('💡 可用的环境配置:'));
|
|
481
|
+
rawConfig.apps.forEach((app) => {
|
|
482
|
+
console.log(chalk.gray(` - ${app.name}`));
|
|
483
|
+
});
|
|
484
|
+
process.exit(1);
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
targetConfig: envConfig,
|
|
488
|
+
allConfigs: rawConfig.apps,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
async function selectEnvironmentFromConfigs(allConfigs) {
|
|
492
|
+
if (allConfigs.length === 1) {
|
|
493
|
+
return {
|
|
494
|
+
envName: allConfigs[0].name,
|
|
495
|
+
targetConfig: allConfigs[0],
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
const envChoices = allConfigs.map((app) => ({
|
|
499
|
+
name: `${app.name} (${app.server.host})`,
|
|
500
|
+
value: app.name,
|
|
501
|
+
}));
|
|
502
|
+
const answers = await inquirer.prompt([
|
|
503
|
+
{
|
|
504
|
+
type: 'list',
|
|
505
|
+
name: 'envName',
|
|
506
|
+
message: '请选择环境:',
|
|
507
|
+
choices: envChoices,
|
|
508
|
+
},
|
|
509
|
+
]);
|
|
510
|
+
const selectedConfig = allConfigs.find((app) => app.name === answers.envName);
|
|
511
|
+
return {
|
|
512
|
+
envName: answers.envName,
|
|
513
|
+
targetConfig: selectedConfig,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
456
516
|
async function createSSHConnection(server) {
|
|
457
517
|
const ssh = new NodeSSH();
|
|
458
518
|
const connectConfig = {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// pcli-cd.config.js
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
apps: [
|
|
5
|
+
{
|
|
6
|
+
// 环境名称
|
|
7
|
+
name: '<%= envName %>',
|
|
8
|
+
|
|
9
|
+
// 构建命令,部署前执行
|
|
10
|
+
buildCommand: '<%= buildCommand %>',
|
|
11
|
+
|
|
12
|
+
// 构建输出目录
|
|
13
|
+
buildDir: '<%= buildDir %>',
|
|
14
|
+
|
|
15
|
+
// 服务器配置
|
|
16
|
+
server: {
|
|
17
|
+
host: '<%= host %>',
|
|
18
|
+
port: <%= port %>,
|
|
19
|
+
username: '<%= username %>',<% if (privateKeyPath) { %>
|
|
20
|
+
privateKeyPath: '<%= privateKeyPath %>',<% } else { %>
|
|
21
|
+
// 私钥路径
|
|
22
|
+
// privateKeyPath: '~/.ssh/id_rsa',
|
|
23
|
+
|
|
24
|
+
// 或者使用私钥内容
|
|
25
|
+
// privateKey: `-----BEGIN OPENSSH PRIVATE KEY-----
|
|
26
|
+
// ...
|
|
27
|
+
// -----END OPENSSH PRIVATE KEY-----`,
|
|
28
|
+
|
|
29
|
+
// 或者使用密码
|
|
30
|
+
// password: 'your-password',<% } %>
|
|
31
|
+
deployPath: '<%= deployPath %>'
|
|
32
|
+
},<% if (pm2AppName) { %>
|
|
33
|
+
|
|
34
|
+
// PM2 进程管理配置
|
|
35
|
+
pm2: {
|
|
36
|
+
appName: '<%= pm2AppName %>',
|
|
37
|
+
restart: true
|
|
38
|
+
},<% } else { %>
|
|
39
|
+
|
|
40
|
+
// PM2 进程管理配置(可选)
|
|
41
|
+
// pm2: {
|
|
42
|
+
// appName: 'my-app',
|
|
43
|
+
// restart: true
|
|
44
|
+
// },<% } %>
|
|
45
|
+
|
|
46
|
+
// 排除文件列表(可选)
|
|
47
|
+
excludeFiles: [
|
|
48
|
+
// 'node_modules/**',
|
|
49
|
+
// '*.log',
|
|
50
|
+
// '.env*'
|
|
51
|
+
],
|
|
52
|
+
|
|
53
|
+
// 部署前命令(可选)
|
|
54
|
+
// beforeDeploy: [
|
|
55
|
+
// 'npm test',
|
|
56
|
+
// 'npm run lint'
|
|
57
|
+
// ],
|
|
58
|
+
|
|
59
|
+
// 部署后命令(可选)
|
|
60
|
+
// afterDeploy: [
|
|
61
|
+
// 'npm install --production',
|
|
62
|
+
// 'npm run migrate'
|
|
63
|
+
// ]
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fse from 'fs-extra';
|
|
3
|
+
import parseJson from 'parse-json';
|
|
4
|
+
|
|
5
|
+
function getVersion(path, baseUrl) {
|
|
6
|
+
const packageJsonPath = new URL(path, baseUrl);
|
|
7
|
+
let packageJsonContent;
|
|
8
|
+
try {
|
|
9
|
+
packageJsonContent = fse.readFileSync(packageJsonPath, 'utf-8');
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new Error(`无法找到 package.json 文件:${packageJsonPath}`);
|
|
13
|
+
}
|
|
14
|
+
let packageData;
|
|
15
|
+
try {
|
|
16
|
+
packageData = parseJson(packageJsonContent);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error('package.json 文件格式错误,请检查 JSON 语法');
|
|
20
|
+
}
|
|
21
|
+
if (!packageData.version) {
|
|
22
|
+
throw new Error('package.json 中缺少 version 字段');
|
|
23
|
+
}
|
|
24
|
+
return packageData.version || '0.0.0';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { getVersion };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import ejs from 'ejs';
|
|
6
|
+
|
|
7
|
+
function renderConfigTemplate(templateData) {
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
const templatePath = join(__dirname, '../templates/config.ejs');
|
|
11
|
+
try {
|
|
12
|
+
const templateContent = readFileSync(templatePath, 'utf-8');
|
|
13
|
+
return ejs.render(templateContent, templateData);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
throw new Error(`模板渲染失败: ${error}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { renderConfigTemplate };
|