ylyx-cli 1.0.2 → 1.0.3

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/bin/ylyx.js CHANGED
@@ -5,6 +5,7 @@ const { generate } = require('../lib/index');
5
5
  const { listTemplates, getTemplateInfo, addTemplate } = require('../lib/template');
6
6
  const { interactiveCreate } = require('../lib/interactive');
7
7
  const { setMode, initConfig } = require('../lib/config');
8
+ const { deploy } = require('../lib/deploy');
8
9
  const pkg = require('../package.json');
9
10
 
10
11
  program.name('ylyx').description('公司内部代码生成模板脚手架工具').version(pkg.version);
@@ -110,6 +111,33 @@ program
110
111
  addTemplate(templateName, templatePath);
111
112
  });
112
113
 
114
+ program
115
+ .command('deploy')
116
+ .description('部署:递归上传本地目录到远端目录(读取当前项目 .ylyxrc.json 的 deploy 字段)')
117
+ .option('--env <env>', '部署环境:test|prod(不传会提示选择)')
118
+ .option('--host <host>', '覆盖 deploy.host')
119
+ .option('--port <port>', '覆盖 deploy.port')
120
+ .option('-u, --username <username>', '覆盖 deploy.username')
121
+ .option('-p, --password <password>', '覆盖 deploy.password')
122
+ .option('--local <path>', '覆盖 deploy.localDir(默认 ./EXTERNAL_DIGIC)')
123
+ .option('--remote <path>', '覆盖 deploy.remoteDir')
124
+ .action(async (options) => {
125
+ try {
126
+ await deploy({
127
+ env: options.env,
128
+ host: options.host,
129
+ port: options.port,
130
+ username: options.username,
131
+ password: options.password,
132
+ localDir: options.local,
133
+ remoteDir: options.remote,
134
+ });
135
+ } catch (error) {
136
+ console.error('❌ 部署失败:', error.message);
137
+ process.exit(1);
138
+ }
139
+ });
140
+
113
141
  program
114
142
  .command('install <repo-url>')
115
143
  .alias('i')
package/lib/deploy.js ADDED
@@ -0,0 +1,218 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const crypto = require('crypto');
4
+ const Client = require('ssh2-sftp-client');
5
+ const ora = require('ora');
6
+ const prompts = require('prompts');
7
+ const archiver = require('archiver');
8
+ const { readProjectConfig } = require('./utils');
9
+
10
+ let spinner = null; // 加载实例
11
+
12
+ function pad2(n) {
13
+ return String(n).padStart(2, '0');
14
+ }
15
+
16
+ function formatTimestampForFolderName(date = new Date()) {
17
+ // 用户要求:YYYY-MM-DD HH:mm:ss(注意:该字符串用于 zip 内的顶层目录名,不受 Windows 文件名限制)
18
+ const yyyy = date.getFullYear();
19
+ const MM = pad2(date.getMonth() + 1);
20
+ const dd = pad2(date.getDate());
21
+ const HH = pad2(date.getHours());
22
+ const mm = pad2(date.getMinutes());
23
+ const ss = pad2(date.getSeconds());
24
+ return `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}`;
25
+ }
26
+
27
+ function sanitizeFileName(name) {
28
+ // Windows 文件名不允许 : * ? " < > | 等,这里做最小化替换,用于 zip 文件名
29
+ return String(name)
30
+ .replace(/[\\/:*?"<>|]/g, '-')
31
+ .replace(/\s+/g, '_')
32
+ .slice(0, 180);
33
+ }
34
+
35
+ function listFilesRecursively(rootDir) {
36
+ const out = [];
37
+ const stack = [rootDir];
38
+ while (stack.length) {
39
+ const current = stack.pop();
40
+ const entries = fs.readdirSync(current);
41
+ for (const e of entries) {
42
+ const p = path.join(current, e);
43
+ const st = fs.statSync(p);
44
+ if (st.isDirectory()) stack.push(p);
45
+ else if (st.isFile()) out.push({ path: p, size: st.size, mtimeMs: st.mtimeMs });
46
+ }
47
+ }
48
+ // 稳定排序
49
+ out.sort((a, b) => a.path.localeCompare(b.path));
50
+ return out;
51
+ }
52
+
53
+ function computeDirHashShort(dir) {
54
+ const h = crypto.createHash('sha256');
55
+ const files = listFilesRecursively(dir);
56
+ for (const f of files) {
57
+ const rel = path.relative(dir, f.path).replace(/\\/g, '/');
58
+ h.update(rel);
59
+ h.update('\n');
60
+ h.update(String(f.size));
61
+ h.update('\n');
62
+ h.update(String(Math.floor(f.mtimeMs)));
63
+ h.update('\n');
64
+ }
65
+ return h.digest('hex').slice(0, 8);
66
+ }
67
+
68
+ async function zipDirWithTopFolder({ srcDir, outDir, topFolderName }) {
69
+ fs.mkdirSync(outDir, { recursive: true });
70
+ const zipFileName = `${sanitizeFileName(topFolderName)}.zip`;
71
+ const zipPath = path.join(outDir, zipFileName);
72
+
73
+ await new Promise((resolve, reject) => {
74
+ const output = fs.createWriteStream(zipPath);
75
+ const archive = archiver('zip', { zlib: { level: 9 } });
76
+
77
+ output.on('close', resolve);
78
+ output.on('error', reject);
79
+ archive.on('warning', (err) => {
80
+ // 非致命 warning(如 ENOENT)按 warning 处理;其它仍抛出
81
+ if (err && err.code === 'ENOENT') return;
82
+ reject(err);
83
+ });
84
+ archive.on('error', reject);
85
+
86
+ archive.pipe(output);
87
+
88
+ // 把 srcDir 内容放到 zip 的顶层目录 topFolderName 下
89
+ archive.directory(srcDir, topFolderName);
90
+ archive.finalize();
91
+ });
92
+
93
+ return { zipPath };
94
+ }
95
+
96
+ function resolveDeployConfig(overrides = {}) {
97
+ const projectConfig = readProjectConfig();
98
+ const deploy = (projectConfig && projectConfig.deploy) || {};
99
+
100
+ const cfg = {
101
+ host: overrides.host ?? deploy.host,
102
+ port: String(overrides.port ?? deploy.port ?? '22'),
103
+ username: overrides.username ?? deploy.username,
104
+ password: overrides.password ?? deploy.password,
105
+ localDir: overrides.localDir ?? deploy.localDir ?? './EXTERNAL_DIGIC',
106
+ remoteDir: overrides.remoteDir ?? deploy.remoteDir ?? '/usr/local/nginx/html/EXTERNAL_DIGIC',
107
+ };
108
+
109
+ return cfg;
110
+ }
111
+
112
+ /**
113
+ * 执行部署(递归上传目录到远端)
114
+ * 测试服:压缩 + 上传 zip
115
+ * 正式服:只压缩(不上传)
116
+ * @param {{env?:'test'|'prod',host?:string,port?:string|number,username?:string,password?:string,localDir?:string,remoteDir?:string}} overrides
117
+ */
118
+ async function deploy(overrides = {}) {
119
+ const cfg = resolveDeployConfig(overrides);
120
+ const localFolderPath = cfg.localDir;
121
+ const remoteFolderPath = cfg.remoteDir;
122
+
123
+ // 选择部署环境(prompts)
124
+ let env = overrides.env;
125
+ if (!env) {
126
+ const ans = await prompts({
127
+ type: 'select',
128
+ name: 'env',
129
+ message: '请选择部署环境',
130
+ choices: [
131
+ { title: '测试服(压缩 + 上传)', value: 'test' },
132
+ { title: '正式服(只压缩)', value: 'prod' },
133
+ ],
134
+ initial: 0,
135
+ });
136
+ env = ans.env;
137
+ }
138
+ if (env !== 'test' && env !== 'prod') {
139
+ throw new Error(`不支持的 env:${env}(仅支持 test/prod)`);
140
+ }
141
+
142
+ if (!fs.existsSync(localFolderPath)) {
143
+ throw new Error(`本地目录不存在:${path.resolve(localFolderPath)}`);
144
+ }
145
+
146
+ // buildDir:使用本地目录名(最后一段)
147
+ const buildDirName = path.basename(path.resolve(localFolderPath));
148
+ const ts = formatTimestampForFolderName(new Date());
149
+ const hash = computeDirHashShort(localFolderPath);
150
+ const topFolderName = `${buildDirName}-${ts}-${hash}`;
151
+
152
+ // zip 输出目录(本地)
153
+ const zipOutDir = path.join(process.cwd(), '.ylyx-deploy');
154
+
155
+ spinner = ora('开始压缩...').start();
156
+ const { zipPath } = await zipDirWithTopFolder({
157
+ srcDir: localFolderPath,
158
+ outDir: zipOutDir,
159
+ topFolderName,
160
+ });
161
+ spinner.succeed(`压缩完成:${path.relative(process.cwd(), zipPath)}`);
162
+
163
+ if (env === 'prod') {
164
+ console.log(`正式服:仅压缩不上传。zip 顶层目录名:${topFolderName}`);
165
+ return;
166
+ }
167
+
168
+ // test 环境需要上传
169
+ if (!cfg.host) throw new Error('缺少 deploy.host(测试服上传需要,在 .ylyxrc.json 的 deploy 字段配置)');
170
+ if (!cfg.username) throw new Error('缺少 deploy.username(测试服上传需要,在 .ylyxrc.json 的 deploy 字段配置)');
171
+ if (!cfg.password) throw new Error('缺少 deploy.password(测试服上传需要,在 .ylyxrc.json 的 deploy 字段配置)');
172
+ if (!remoteFolderPath) throw new Error('缺少 deploy.remoteDir(测试服上传需要,在 .ylyxrc.json 的 deploy 字段配置)');
173
+
174
+ const sftp = new Client();
175
+ try {
176
+ spinner = ora('连接服务器...').start();
177
+ await sftp.connect({
178
+ host: cfg.host,
179
+ port: cfg.port,
180
+ username: cfg.username,
181
+ password: cfg.password,
182
+ });
183
+
184
+ spinner.text = '确保远端目录存在...';
185
+ await sftp.mkdir(remoteFolderPath, true);
186
+
187
+ const remoteZipPath = path.posix.join(
188
+ remoteFolderPath.replace(/\\/g, '/'),
189
+ path.basename(zipPath)
190
+ );
191
+
192
+ spinner.text = '开始上传 zip...';
193
+ // fastPut 对单文件更快;若目标路径不存在会失败,所以先 mkdir 上面做了
194
+ await sftp.fastPut(zipPath, remoteZipPath);
195
+ spinner.succeed(`测试服上传成功:${remoteZipPath}`);
196
+ } catch (err) {
197
+ if (spinner) spinner.fail('部署失败');
198
+ throw err;
199
+ } finally {
200
+ try {
201
+ await sftp.end();
202
+ } catch (e) {}
203
+ if (spinner) spinner.info('部署结束');
204
+ }
205
+ }
206
+
207
+ module.exports = {
208
+ deploy,
209
+ resolveDeployConfig,
210
+ };
211
+
212
+ // 允许直接 node lib/deploy.js 运行(从 .ylyxrc.json 读取 deploy 配置)
213
+ if (require.main === module) {
214
+ deploy().catch((e) => {
215
+ console.error(e);
216
+ process.exit(1);
217
+ });
218
+ }
package/lib/utils.js CHANGED
@@ -79,6 +79,19 @@ function getOutputDir(customOutputDir) {
79
79
  return process.cwd();
80
80
  }
81
81
 
82
+ /**
83
+ * 读取当前项目目录下的 .ylyxrc.json(安全:不存在返回 {},JSON 错误抛出)
84
+ */
85
+ function readProjectConfig() {
86
+ const configPath = path.join(process.cwd(), '.ylyxrc.json');
87
+ if (!fs.existsSync(configPath)) return {};
88
+ try {
89
+ return fs.readJsonSync(configPath);
90
+ } catch (e) {
91
+ throw new Error(`读取 .ylyxrc.json 失败,请确认它是有效 JSON:${e.message}`);
92
+ }
93
+ }
94
+
82
95
  /**
83
96
  * 注册 Handlebars 辅助函数
84
97
  */
@@ -129,6 +142,7 @@ module.exports = {
129
142
  snakeCase,
130
143
  getTemplatesDir,
131
144
  getOutputDir,
145
+ readProjectConfig,
132
146
  registerHandlebarsHelpers,
133
147
  log,
134
148
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ylyx-cli",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "公司内部代码生成模板脚手架工具,支持快速生成项目初始结构和代码模板",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -43,12 +43,16 @@
43
43
  "node": ">=12.0.0"
44
44
  },
45
45
  "dependencies": {
46
+ "archiver": "^5.3.2",
46
47
  "commander": "^11.1.0",
47
48
  "inquirer": "^8.2.6",
48
49
  "chalk": "^4.1.2",
49
50
  "fs-extra": "^11.2.0",
50
51
  "handlebars": "^4.7.8",
51
- "glob": "^10.3.10"
52
+ "glob": "^10.3.10",
53
+ "ora": "^5.4.1",
54
+ "prompts": "^2.4.2",
55
+ "ssh2-sftp-client": "^9.0.4"
52
56
  },
53
57
  "devDependencies": {
54
58
  "@types/node": "^20.10.0"