@noahyu/cd-cli 1.1.0 → 1.2.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 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
- - **重要** `pcli-cd.config.js` 文件不应该提交到 Git
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
- > **重要** `pcli-cd.config.js` 文件不应该提交到 Git
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
- // 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',
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
- ⚠️ **重要提醒** `pcli-cd.config.js` 文件不应该提交到 Git
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,16 @@
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.version('1.0.0').description('Simple CI/CD deployment tool');
6
+ program.version(getVersion(), '-v, --version').description('Simple CI/CD deployment tool');
6
7
  program
7
8
  .command('deploy')
8
9
  .alias('cd')
9
10
  .description('Build and deploy project to server')
10
11
  .option('-c, --config <config>', 'Configuration file path', './pcli-cd.config.js')
11
12
  .option('-v, --version <version>', 'Specify version number')
13
+ .option('-n, --name <name>', 'Specify deployment environment name (dev, prod, staging, etc.). If not specified, will prompt to select interactively')
12
14
  .action(deployCommand);
13
15
  program.command('init').description('Initialize CD configuration file').action(initConfig);
14
16
  program
@@ -16,6 +18,7 @@ program
16
18
  .alias('ls')
17
19
  .description('List deployed versions on server')
18
20
  .option('-c, --config <config>', 'Configuration file path', './pcli-cd.config.js')
21
+ .option('-n, --name <name>', 'Specify environment name to list (dev, prod, staging, etc.). If not specified, will prompt to select interactively')
19
22
  .action(listVersions);
20
23
  program
21
24
  .command('rollback')
@@ -23,5 +26,6 @@ program
23
26
  .description('Rollback to a previous version')
24
27
  .option('-c, --config <config>', 'Configuration file path', './pcli-cd.config.js')
25
28
  .option('-v, --version <version>', 'Version to rollback to')
29
+ .option('-n, --name <name>', 'Specify environment name to rollback (dev, prod, staging, etc.). If not specified, will prompt to select interactively')
26
30
  .action(rollbackVersion);
27
31
  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
- if (!existsSync(configPath)) {
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
- try {
21
- const configModule = await import(configPath);
22
- config = configModule.default || configModule;
17
+ if (configResult.targetConfig) {
18
+ config = configResult.targetConfig;
23
19
  }
24
- catch (error) {
25
- console.log(chalk.red(`❌ 配置文件读取失败: ${error}`));
26
- process.exit(1);
20
+ else {
21
+ const selected = await selectEnvironmentFromConfigs(configResult.allConfigs);
22
+ config = selected.targetConfig;
27
23
  }
28
- if (!config.version && !options.version) {
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
- config.version = answers.version;
39
- }
40
- else {
41
- config.version = options.version || config.version;
37
+ version = answers.version;
42
38
  }
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);
@@ -75,8 +70,10 @@ async function deploy(config) {
75
70
  spinner.succeed('服务器连接成功');
76
71
  await cleanTempLinks(ssh, config.server.deployPath, buildDirName);
77
72
  spinner.start('检查部署环境...');
78
- await handleExistingDeployDir(ssh, config.server.deployPath, buildDirName);
79
- spinner.succeed('部署环境检查完成');
73
+ await handleExistingDeployDir(ssh, config.server.deployPath, buildDirName, spinner);
74
+ if (spinner.isSpinning) {
75
+ spinner.succeed('部署环境检查完成');
76
+ }
80
77
  const versionDirName = `${buildDirName}-${version}`;
81
78
  const versionPath = join(config.server.deployPath, versionDirName);
82
79
  const currentLinkPath = join(config.server.deployPath, buildDirName);
@@ -204,6 +201,13 @@ async function cleanOldVersions(ssh, deployPath, buildDirName, keepCount) {
204
201
  async function initConfig() {
205
202
  console.log(chalk.blue('🚀 初始化配置文件'));
206
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
+ },
207
211
  {
208
212
  type: 'input',
209
213
  name: 'buildCommand',
@@ -251,44 +255,35 @@ async function initConfig() {
251
255
  message: 'PM2 应用名称 (可选):',
252
256
  },
253
257
  ]);
254
- const config = {
258
+ const configContent = renderConfigTemplate({
259
+ envName: answers.envName,
255
260
  buildCommand: answers.buildCommand,
256
261
  buildDir: answers.buildDir,
257
- server: {
258
- host: answers.host,
259
- port: parseInt(answers.port),
260
- username: answers.username,
261
- privateKeyPath: answers.privateKeyPath || undefined,
262
- deployPath: answers.deployPath,
263
- },
264
- excludeFiles: [],
265
- };
266
- if (answers.pm2AppName) {
267
- config.pm2 = {
268
- appName: answers.pm2AppName,
269
- restart: true,
270
- };
271
- }
272
- const configContent = `// pcli-cd 部署配置文件
273
- 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
+ });
274
269
  await fse.writeFile('pcli-cd.config.js', configContent);
275
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('💡 配置文件包含详细的注释说明'));
276
274
  }
277
275
  async function listVersions(options) {
278
276
  const configPath = resolve(process.cwd(), options.config);
279
- if (!existsSync(configPath)) {
280
- console.log(chalk.red(`❌ 配置文件不存在: ${configPath}`));
281
- process.exit(1);
282
- }
277
+ const configResult = await resolveEnvConfig(configPath, options.name);
283
278
  let config;
284
- try {
285
- const configModule = await import(configPath);
286
- config = configModule.default || configModule;
279
+ if (configResult.targetConfig) {
280
+ config = configResult.targetConfig;
287
281
  }
288
- catch (error) {
289
- console.log(chalk.red(`❌ 配置文件读取失败: ${error}`));
290
- process.exit(1);
282
+ else {
283
+ const selected = await selectEnvironmentFromConfigs(configResult.allConfigs);
284
+ config = selected.targetConfig;
291
285
  }
286
+ console.log(chalk.blue(`🔍 查看环境: ${chalk.bold(config.name)}`));
292
287
  const spinner = ora('正在获取版本列表...');
293
288
  spinner.start();
294
289
  try {
@@ -342,19 +337,16 @@ async function listVersions(options) {
342
337
  }
343
338
  async function rollbackVersion(options) {
344
339
  const configPath = resolve(process.cwd(), options.config);
345
- if (!existsSync(configPath)) {
346
- console.log(chalk.red(`❌ 配置文件不存在: ${configPath}`));
347
- process.exit(1);
348
- }
340
+ const configResult = await resolveEnvConfig(configPath, options.name);
349
341
  let config;
350
- try {
351
- const configModule = await import(configPath);
352
- config = configModule.default || configModule;
342
+ if (configResult.targetConfig) {
343
+ config = configResult.targetConfig;
353
344
  }
354
- catch (error) {
355
- console.log(chalk.red(`❌ 配置文件读取失败: ${error}`));
356
- process.exit(1);
345
+ else {
346
+ const selected = await selectEnvironmentFromConfigs(configResult.allConfigs);
347
+ config = selected.targetConfig;
357
348
  }
349
+ console.log(chalk.blue(`⏪ 回滚环境: ${chalk.bold(config.name)}`));
358
350
  const buildDirName = config.buildDir.split('/').pop() || 'build';
359
351
  let targetVersion = options.version;
360
352
  if (!targetVersion) {
@@ -451,6 +443,76 @@ async function performRollback(config, targetVersion, buildDirName) {
451
443
  process.exit(1);
452
444
  }
453
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
+ }
454
516
  async function createSSHConnection(server) {
455
517
  const ssh = new NodeSSH();
456
518
  const connectConfig = {
@@ -486,7 +548,7 @@ async function cleanTempLinks(ssh, deployPath, buildDirName) {
486
548
  console.warn(chalk.yellow(`⚠️ 清理临时链接时出现警告: ${error}`));
487
549
  }
488
550
  }
489
- async function handleExistingDeployDir(ssh, deployPath, buildDirName) {
551
+ async function handleExistingDeployDir(ssh, deployPath, buildDirName, spinner) {
490
552
  const currentLinkPath = join(deployPath, buildDirName);
491
553
  const checkResult = await ssh.execCommand(`test -e ${currentLinkPath}`);
492
554
  if (checkResult.code !== 0) {
@@ -500,6 +562,7 @@ async function handleExistingDeployDir(ssh, deployPath, buildDirName) {
500
562
  const fileType = typeResult.stdout.trim();
501
563
  if (fileType.includes('directory') || fileType === 'directory') {
502
564
  const backupPath = `${currentLinkPath}.backup.${Date.now()}`;
565
+ spinner.stop();
503
566
  console.log(chalk.yellow(`⚠️ 检测到已存在的目录: ${currentLinkPath}`));
504
567
  console.log(chalk.blue(`📁 将备份到: ${backupPath}`));
505
568
  const answers = await inquirer.prompt([
@@ -513,18 +576,25 @@ async function handleExistingDeployDir(ssh, deployPath, buildDirName) {
513
576
  if (!answers.proceed) {
514
577
  throw new Error('用户取消部署');
515
578
  }
579
+ spinner.start('正在备份已存在的目录...');
516
580
  const backupResult = await ssh.execCommand(`mv ${currentLinkPath} ${backupPath}`);
517
581
  if (backupResult.code !== 0) {
518
582
  throw new Error(`备份目录失败: ${backupResult.stderr}`);
519
583
  }
584
+ spinner.stop();
520
585
  console.log(chalk.green(`✅ 目录已备份到: ${backupPath}`));
521
586
  }
522
587
  else {
523
588
  const backupPath = `${currentLinkPath}.backup.${Date.now()}`;
589
+ spinner.stop();
590
+ console.log(chalk.yellow(`⚠️ 检测到已存在的文件: ${currentLinkPath}`));
591
+ console.log(chalk.blue(`📁 将备份到: ${backupPath}`));
592
+ spinner.start('正在备份已存在的文件...');
524
593
  const backupResult = await ssh.execCommand(`mv ${currentLinkPath} ${backupPath}`);
525
594
  if (backupResult.code !== 0) {
526
595
  throw new Error(`备份文件失败: ${backupResult.stderr}`);
527
596
  }
597
+ spinner.stop();
528
598
  console.log(chalk.green(`✅ 文件已备份到: ${backupPath}`));
529
599
  }
530
600
  }
@@ -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,31 @@
1
+ #!/usr/bin/env node
2
+ import fse from 'fs-extra';
3
+ import parseJson from 'parse-json';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+
7
+ function getVersion() {
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const packageJsonPath = join(__dirname, '../../package.json');
11
+ let packageJsonContent;
12
+ try {
13
+ packageJsonContent = fse.readFileSync(packageJsonPath, 'utf-8');
14
+ }
15
+ catch {
16
+ throw new Error(`无法找到 package.json 文件:${packageJsonPath}`);
17
+ }
18
+ let packageData;
19
+ try {
20
+ packageData = parseJson(packageJsonContent);
21
+ }
22
+ catch {
23
+ throw new Error('package.json 文件格式错误,请检查 JSON 语法');
24
+ }
25
+ if (!packageData.version) {
26
+ throw new Error('package.json 中缺少 version 字段');
27
+ }
28
+ return packageData.version || '0.0.0';
29
+ }
30
+
31
+ 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noahyu/cd-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Global CLI tool for simple project deployment with version management",
5
5
  "type": "module",
6
6
  "main": "./dist/bin/cli.js",