ylyx-cli 1.0.4 → 1.0.5

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.
Files changed (3) hide show
  1. package/bin/ylyx.js +4 -2
  2. package/lib/deploy.js +135 -116
  3. package/package.json +2 -3
package/bin/ylyx.js CHANGED
@@ -114,23 +114,25 @@ program
114
114
  program
115
115
  .command('deploy')
116
116
  .description('部署:递归上传本地目录到远端目录(读取当前项目 .ylyxrc.json 的 deploy 字段)')
117
- .option('--env <env>', '部署环境:test|prod(不传会提示选择)')
118
117
  .option('--host <host>', '覆盖 deploy.host')
119
118
  .option('--port <port>', '覆盖 deploy.port')
120
119
  .option('-u, --username <username>', '覆盖 deploy.username')
121
120
  .option('-p, --password <password>', '覆盖 deploy.password')
122
121
  .option('--local <path>', '覆盖 deploy.localDir(默认 ./EXTERNAL_DIGIC)')
123
122
  .option('--remote <path>', '覆盖 deploy.remoteDir')
123
+ .option('--zip', '部署后把 localDir 压缩成 zip(参考 compressing.zip.compressDir)')
124
+ .option('--zip-out <path>', 'zip 输出目录(默认:./.ylyx-deploy)')
124
125
  .action(async (options) => {
125
126
  try {
126
127
  await deploy({
127
- env: options.env,
128
128
  host: options.host,
129
129
  port: options.port,
130
130
  username: options.username,
131
131
  password: options.password,
132
132
  localDir: options.local,
133
133
  remoteDir: options.remote,
134
+ zipAfter: !!options.zip,
135
+ zipOutDir: options.zipOut,
134
136
  });
135
137
  } catch (error) {
136
138
  console.error('❌ 部署失败:', error.message);
package/lib/deploy.js CHANGED
@@ -1,37 +1,36 @@
1
1
  const path = require('path');
2
- const fs = require('fs');
3
- const crypto = require('crypto');
2
+ const fs = require('fs-extra');
4
3
  const ora = require('ora');
5
- const prompts = require('prompts');
6
- const archiver = require('archiver');
7
4
  const { readProjectConfig } = require('./utils');
8
5
 
9
6
  let spinner = null; // 加载实例
10
7
 
11
- function pad2(n) {
12
- return String(n).padStart(2, '0');
13
- }
14
-
15
- function formatTimestampForFolderName(date = new Date()) {
16
- // 用户要求:YYYY-MM-DD HH:mm:ss(注意:该字符串用于 zip 内的顶层目录名,不受 Windows 文件名限制)
8
+ function formatTimestampForZipName(date = new Date()) {
9
+ const pad2 = (n) => String(n).padStart(2, '0');
17
10
  const yyyy = date.getFullYear();
18
11
  const MM = pad2(date.getMonth() + 1);
19
12
  const dd = pad2(date.getDate());
20
13
  const HH = pad2(date.getHours());
21
14
  const mm = pad2(date.getMinutes());
22
15
  const ss = pad2(date.getSeconds());
23
- return `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}`;
16
+ // zip 文件名避免冒号
17
+ return `${yyyy}-${MM}-${dd}_${HH}-${mm}-${ss}`;
24
18
  }
25
19
 
26
- function sanitizeFileName(name) {
27
- // Windows 文件名不允许 : * ? " < > | 等,这里做最小化替换,用于 zip 文件名
28
- return String(name)
29
- .replace(/[\\/:*?"<>|]/g, '-')
30
- .replace(/\s+/g, '_')
31
- .slice(0, 180);
20
+ function formatBytes(bytes) {
21
+ const n = Number(bytes) || 0;
22
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
23
+ let v = n;
24
+ let i = 0;
25
+ while (v >= 1024 && i < units.length - 1) {
26
+ v /= 1024;
27
+ i++;
28
+ }
29
+ const fixed = i === 0 ? 0 : v < 10 ? 2 : v < 100 ? 1 : 0;
30
+ return `${v.toFixed(fixed)}${units[i]}`;
32
31
  }
33
32
 
34
- function listFilesRecursively(rootDir) {
33
+ function listLocalFilesWithSize(rootDir) {
35
34
  const out = [];
36
35
  const stack = [rootDir];
37
36
  while (stack.length) {
@@ -41,55 +40,83 @@ function listFilesRecursively(rootDir) {
41
40
  const p = path.join(current, e);
42
41
  const st = fs.statSync(p);
43
42
  if (st.isDirectory()) stack.push(p);
44
- else if (st.isFile()) out.push({ path: p, size: st.size, mtimeMs: st.mtimeMs });
43
+ else if (st.isFile()) {
44
+ out.push({
45
+ localPath: p,
46
+ relPosix: path.relative(rootDir, p).replace(/\\/g, '/'),
47
+ size: st.size,
48
+ });
49
+ }
45
50
  }
46
51
  }
47
- // 稳定排序
48
- out.sort((a, b) => a.path.localeCompare(b.path));
52
+ out.sort((a, b) => a.relPosix.localeCompare(b.relPosix));
49
53
  return out;
50
54
  }
51
55
 
52
- function computeDirHashShort(dir) {
53
- const h = crypto.createHash('sha256');
54
- const files = listFilesRecursively(dir);
56
+ async function uploadDirWithProgress({ sftp, localDir, remoteDirPosix, onProgress }) {
57
+ const files = listLocalFilesWithSize(localDir);
58
+ const totalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0);
59
+ if (files.length === 0) return { totalBytes: 0, uploadedBytes: 0, fileCount: 0 };
60
+
61
+ // 先创建需要的目录(去重)
62
+ const dirSet = new Set();
55
63
  for (const f of files) {
56
- const rel = path.relative(dir, f.path).replace(/\\/g, '/');
57
- h.update(rel);
58
- h.update('\n');
59
- h.update(String(f.size));
60
- h.update('\n');
61
- h.update(String(Math.floor(f.mtimeMs)));
62
- h.update('\n');
64
+ const remoteFile = path.posix.join(remoteDirPosix, f.relPosix);
65
+ dirSet.add(path.posix.dirname(remoteFile));
66
+ }
67
+ // 目录从浅到深创建,避免多余失败
68
+ const dirs = Array.from(dirSet).sort((a, b) => a.length - b.length);
69
+ for (const d of dirs) {
70
+ await sftp.mkdir(d, true);
63
71
  }
64
- return h.digest('hex').slice(0, 8);
65
- }
66
72
 
67
- async function zipDirWithTopFolder({ srcDir, outDir, topFolderName }) {
68
- fs.mkdirSync(outDir, { recursive: true });
69
- const zipFileName = `${sanitizeFileName(topFolderName)}.zip`;
70
- const zipPath = path.join(outDir, zipFileName);
71
-
72
- await new Promise((resolve, reject) => {
73
- const output = fs.createWriteStream(zipPath);
74
- const archive = archiver('zip', { zlib: { level: 9 } });
75
-
76
- output.on('close', resolve);
77
- output.on('error', reject);
78
- archive.on('warning', (err) => {
79
- // 非致命 warning(如 ENOENT)按 warning 处理;其它仍抛出
80
- if (err && err.code === 'ENOENT') return;
81
- reject(err);
82
- });
83
- archive.on('error', reject);
73
+ let uploadedBytes = 0;
74
+ let lastRenderAt = 0;
75
+ const render = (state) => {
76
+ if (typeof onProgress !== 'function') return;
77
+ const now = Date.now();
78
+ // 轻微节流,避免刷屏
79
+ if (now - lastRenderAt < 80 && state && state.force !== true) return;
80
+ lastRenderAt = now;
81
+ onProgress(state);
82
+ };
84
83
 
85
- archive.pipe(output);
84
+ for (let i = 0; i < files.length; i++) {
85
+ const f = files[i];
86
+ const remoteFile = path.posix.join(remoteDirPosix, f.relPosix);
87
+ let lastTransferred = 0;
88
+
89
+ await sftp.fastPut(f.localPath, remoteFile, {
90
+ step: (transferred) => {
91
+ // transferred:当前文件已传输的总字节数(累计)
92
+ const delta = Math.max(0, Number(transferred || 0) - lastTransferred);
93
+ lastTransferred = Number(transferred || 0);
94
+ uploadedBytes += delta;
95
+ render({
96
+ uploadedBytes,
97
+ totalBytes,
98
+ fileIndex: i + 1,
99
+ fileCount: files.length,
100
+ currentFile: f.relPosix,
101
+ });
102
+ },
103
+ });
86
104
 
87
- // srcDir 内容放到 zip 的顶层目录 topFolderName 下
88
- archive.directory(srcDir, topFolderName);
89
- archive.finalize();
90
- });
105
+ // fastPut 有时不会触发 step(极小文件/实现差异),这里兜底补齐本文件剩余字节
106
+ if (lastTransferred < f.size) {
107
+ uploadedBytes += f.size - lastTransferred;
108
+ }
109
+ render({
110
+ uploadedBytes,
111
+ totalBytes,
112
+ fileIndex: i + 1,
113
+ fileCount: files.length,
114
+ currentFile: f.relPosix,
115
+ force: true,
116
+ });
117
+ }
91
118
 
92
- return { zipPath };
119
+ return { totalBytes, uploadedBytes, fileCount: files.length };
93
120
  }
94
121
 
95
122
  function resolveDeployConfig(overrides = {}) {
@@ -103,6 +130,8 @@ function resolveDeployConfig(overrides = {}) {
103
130
  password: overrides.password ?? deploy.password,
104
131
  localDir: overrides.localDir ?? deploy.localDir ?? './EXTERNAL_DIGIC',
105
132
  remoteDir: overrides.remoteDir ?? deploy.remoteDir ?? '/usr/local/nginx/html/EXTERNAL_DIGIC',
133
+ zipAfter: overrides.zipAfter ?? deploy.zipAfter ?? false,
134
+ zipOutDir: overrides.zipOutDir ?? deploy.zipOutDir ?? path.join(process.cwd(), '.ylyx-deploy'),
106
135
  };
107
136
 
108
137
  return cfg;
@@ -110,69 +139,24 @@ function resolveDeployConfig(overrides = {}) {
110
139
 
111
140
  /**
112
141
  * 执行部署(递归上传目录到远端)
113
- * 测试服:压缩 + 上传 zip
114
- * 正式服:只压缩(不上传)
115
- * @param {{env?:'test'|'prod',host?:string,port?:string|number,username?:string,password?:string,localDir?:string,remoteDir?:string}} overrides
142
+ * @param {{host?:string,port?:string|number,username?:string,password?:string,localDir?:string,remoteDir?:string,zipAfter?:boolean,zipOutDir?:string}} overrides
116
143
  */
117
144
  async function deploy(overrides = {}) {
118
145
  const cfg = resolveDeployConfig(overrides);
119
146
  const localFolderPath = cfg.localDir;
120
147
  const remoteFolderPath = cfg.remoteDir;
121
148
 
122
- // 选择部署环境(prompts)
123
- let env = overrides.env;
124
- if (!env) {
125
- const ans = await prompts({
126
- type: 'select',
127
- name: 'env',
128
- message: '请选择部署环境',
129
- choices: [
130
- { title: '测试服(压缩 + 上传)', value: 'test' },
131
- { title: '正式服(只压缩)', value: 'prod' },
132
- ],
133
- initial: 0,
134
- });
135
- env = ans.env;
136
- }
137
- if (env !== 'test' && env !== 'prod') {
138
- throw new Error(`不支持的 env:${env}(仅支持 test/prod)`);
139
- }
140
-
141
149
  if (!fs.existsSync(localFolderPath)) {
142
150
  throw new Error(`本地目录不存在:${path.resolve(localFolderPath)}`);
143
151
  }
144
152
 
145
- // buildDir:使用本地目录名(最后一段)
146
- const buildDirName = path.basename(path.resolve(localFolderPath));
147
- const ts = formatTimestampForFolderName(new Date());
148
- const hash = computeDirHashShort(localFolderPath);
149
- const topFolderName = `${buildDirName}-${ts}-${hash}`;
150
-
151
- // zip 输出目录(本地)
152
- const zipOutDir = path.join(process.cwd(), '.ylyx-deploy');
153
-
154
- spinner = ora('开始压缩...').start();
155
- const { zipPath } = await zipDirWithTopFolder({
156
- srcDir: localFolderPath,
157
- outDir: zipOutDir,
158
- topFolderName,
159
- });
160
- spinner.succeed(`压缩完成:${path.relative(process.cwd(), zipPath)}`);
161
-
162
- if (env === 'prod') {
163
- console.log(`正式服:仅压缩不上传。zip 顶层目录名:${topFolderName}`);
164
- return;
165
- }
166
-
167
- // test 环境需要上传
168
- if (!cfg.host) throw new Error('缺少 deploy.host(测试服上传需要,在 .ylyxrc.json 的 deploy 字段配置)');
169
- if (!cfg.username) throw new Error('缺少 deploy.username(测试服上传需要,在 .ylyxrc.json 的 deploy 字段配置)');
170
- if (!cfg.password) throw new Error('缺少 deploy.password(测试服上传需要,在 .ylyxrc.json 的 deploy 字段配置)');
171
- if (!remoteFolderPath) throw new Error('缺少 deploy.remoteDir(测试服上传需要,在 .ylyxrc.json 的 deploy 字段配置)');
153
+ if (!cfg.host) throw new Error('缺少 deploy.host(请在 .ylyxrc.json 的 deploy 字段配置)');
154
+ if (!cfg.username) throw new Error('缺少 deploy.username(请在 .ylyxrc.json 的 deploy 字段配置)');
155
+ if (!cfg.password) throw new Error('缺少 deploy.password(请在 .ylyxrc.json 的 deploy 字段配置)');
156
+ if (!remoteFolderPath) throw new Error('缺少 deploy.remoteDir(请在 .ylyxrc.json 的 deploy 字段配置)');
172
157
 
173
158
  let Client;
174
159
  try {
175
- // 仅在需要“上传”时才加载,避免“正式服只压缩”也因为缺依赖而崩
176
160
  Client = require('ssh2-sftp-client');
177
161
  } catch (e) {
178
162
  throw new Error(
@@ -192,18 +176,27 @@ async function deploy(overrides = {}) {
192
176
  password: cfg.password,
193
177
  });
194
178
 
195
- spinner.text = '确保远端目录存在...';
179
+ spinner.text = '服务器连接成功,准备清理远端目录...';
180
+ try {
181
+ await sftp.rmdir(remoteFolderPath, true);
182
+ } catch (e) {
183
+ // 远端目录不存在/权限问题:继续尝试 mkdir
184
+ }
196
185
  await sftp.mkdir(remoteFolderPath, true);
197
186
 
198
- const remoteZipPath = path.posix.join(
199
- remoteFolderPath.replace(/\\/g, '/'),
200
- path.basename(zipPath)
201
- );
202
-
203
- spinner.text = '开始上传 zip...';
204
- // fastPut 对单文件更快;若目标路径不存在会失败,所以先 mkdir 上面做了
205
- await sftp.fastPut(zipPath, remoteZipPath);
206
- spinner.succeed(`测试服上传成功:${remoteZipPath}`);
187
+ const remoteDirPosix = remoteFolderPath.replace(/\\/g, '/');
188
+ spinner.text = '开始上传目录...';
189
+ const { uploadedBytes, totalBytes, fileCount } = await uploadDirWithProgress({
190
+ sftp,
191
+ localDir: localFolderPath,
192
+ remoteDirPosix,
193
+ onProgress: ({ uploadedBytes: up, totalBytes: tot, fileIndex, fileCount: cnt, currentFile }) => {
194
+ const percent = tot ? ((up / tot) * 100).toFixed(2) : '100.00';
195
+ const shortFile = currentFile ? currentFile.slice(-60) : '';
196
+ spinner.text = `上传进度: ${percent}% (${formatBytes(up)}/${formatBytes(tot)}) 文件:${fileIndex}/${cnt} ${shortFile}`;
197
+ },
198
+ });
199
+ spinner.succeed(`上传成功:${formatBytes(uploadedBytes)}/${formatBytes(totalBytes)},共 ${fileCount} 个文件`);
207
200
  } catch (err) {
208
201
  if (spinner) spinner.fail('部署失败');
209
202
  throw err;
@@ -213,6 +206,32 @@ async function deploy(overrides = {}) {
213
206
  } catch (e) {}
214
207
  if (spinner) spinner.info('部署结束');
215
208
  }
209
+
210
+ // 可选:部署后压缩(参考:compressing.zip.compressDir)
211
+ if (cfg.zipAfter) {
212
+ let compressing;
213
+ try {
214
+ compressing = require('compressing');
215
+ } catch (e) {
216
+ throw new Error(
217
+ "缺少依赖:compressing(用于压缩)。请在 ylyx-cli 目录执行 `npm i` 或安装该依赖后重试。"
218
+ );
219
+ }
220
+
221
+ const buildDirName = path.basename(path.resolve(localFolderPath));
222
+ fs.ensureDirSync(cfg.zipOutDir);
223
+ const zipName = `${buildDirName}.${formatTimestampForZipName(new Date())}.zip`;
224
+ const zipPath = path.join(cfg.zipOutDir, zipName);
225
+
226
+ const zipSpinner = ora(`开始压缩:${zipName}`).start();
227
+ try {
228
+ await compressing.zip.compressDir(localFolderPath, zipPath);
229
+ zipSpinner.succeed(`压缩完成:${path.relative(process.cwd(), zipPath)}`);
230
+ } catch (e) {
231
+ zipSpinner.fail('压缩失败');
232
+ throw e;
233
+ }
234
+ }
216
235
  }
217
236
 
218
237
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ylyx-cli",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "公司内部代码生成模板脚手架工具,支持快速生成项目初始结构和代码模板",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -43,15 +43,14 @@
43
43
  "node": ">=12.0.0"
44
44
  },
45
45
  "dependencies": {
46
- "archiver": "^5.3.2",
47
46
  "commander": "^11.1.0",
47
+ "compressing": "^1.10.1",
48
48
  "inquirer": "^8.2.6",
49
49
  "chalk": "^4.1.2",
50
50
  "fs-extra": "^11.2.0",
51
51
  "handlebars": "^4.7.8",
52
52
  "glob": "^10.3.10",
53
53
  "ora": "^5.4.1",
54
- "prompts": "^2.4.2",
55
54
  "ssh2-sftp-client": "^9.0.4"
56
55
  },
57
56
  "devDependencies": {