ylyx-cli 1.0.2 → 1.0.4
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 +28 -0
- package/lib/deploy.js +229 -0
- package/lib/utils.js +14 -0
- package/package.json +6 -2
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,229 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const ora = require('ora');
|
|
5
|
+
const prompts = require('prompts');
|
|
6
|
+
const archiver = require('archiver');
|
|
7
|
+
const { readProjectConfig } = require('./utils');
|
|
8
|
+
|
|
9
|
+
let spinner = null; // 加载实例
|
|
10
|
+
|
|
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 文件名限制)
|
|
17
|
+
const yyyy = date.getFullYear();
|
|
18
|
+
const MM = pad2(date.getMonth() + 1);
|
|
19
|
+
const dd = pad2(date.getDate());
|
|
20
|
+
const HH = pad2(date.getHours());
|
|
21
|
+
const mm = pad2(date.getMinutes());
|
|
22
|
+
const ss = pad2(date.getSeconds());
|
|
23
|
+
return `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sanitizeFileName(name) {
|
|
27
|
+
// Windows 文件名不允许 : * ? " < > | 等,这里做最小化替换,用于 zip 文件名
|
|
28
|
+
return String(name)
|
|
29
|
+
.replace(/[\\/:*?"<>|]/g, '-')
|
|
30
|
+
.replace(/\s+/g, '_')
|
|
31
|
+
.slice(0, 180);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listFilesRecursively(rootDir) {
|
|
35
|
+
const out = [];
|
|
36
|
+
const stack = [rootDir];
|
|
37
|
+
while (stack.length) {
|
|
38
|
+
const current = stack.pop();
|
|
39
|
+
const entries = fs.readdirSync(current);
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
const p = path.join(current, e);
|
|
42
|
+
const st = fs.statSync(p);
|
|
43
|
+
if (st.isDirectory()) stack.push(p);
|
|
44
|
+
else if (st.isFile()) out.push({ path: p, size: st.size, mtimeMs: st.mtimeMs });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// 稳定排序
|
|
48
|
+
out.sort((a, b) => a.path.localeCompare(b.path));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function computeDirHashShort(dir) {
|
|
53
|
+
const h = crypto.createHash('sha256');
|
|
54
|
+
const files = listFilesRecursively(dir);
|
|
55
|
+
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');
|
|
63
|
+
}
|
|
64
|
+
return h.digest('hex').slice(0, 8);
|
|
65
|
+
}
|
|
66
|
+
|
|
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);
|
|
84
|
+
|
|
85
|
+
archive.pipe(output);
|
|
86
|
+
|
|
87
|
+
// 把 srcDir 内容放到 zip 的顶层目录 topFolderName 下
|
|
88
|
+
archive.directory(srcDir, topFolderName);
|
|
89
|
+
archive.finalize();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return { zipPath };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveDeployConfig(overrides = {}) {
|
|
96
|
+
const projectConfig = readProjectConfig();
|
|
97
|
+
const deploy = (projectConfig && projectConfig.deploy) || {};
|
|
98
|
+
|
|
99
|
+
const cfg = {
|
|
100
|
+
host: overrides.host ?? deploy.host,
|
|
101
|
+
port: String(overrides.port ?? deploy.port ?? '22'),
|
|
102
|
+
username: overrides.username ?? deploy.username,
|
|
103
|
+
password: overrides.password ?? deploy.password,
|
|
104
|
+
localDir: overrides.localDir ?? deploy.localDir ?? './EXTERNAL_DIGIC',
|
|
105
|
+
remoteDir: overrides.remoteDir ?? deploy.remoteDir ?? '/usr/local/nginx/html/EXTERNAL_DIGIC',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return cfg;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 执行部署(递归上传目录到远端)
|
|
113
|
+
* 测试服:压缩 + 上传 zip
|
|
114
|
+
* 正式服:只压缩(不上传)
|
|
115
|
+
* @param {{env?:'test'|'prod',host?:string,port?:string|number,username?:string,password?:string,localDir?:string,remoteDir?:string}} overrides
|
|
116
|
+
*/
|
|
117
|
+
async function deploy(overrides = {}) {
|
|
118
|
+
const cfg = resolveDeployConfig(overrides);
|
|
119
|
+
const localFolderPath = cfg.localDir;
|
|
120
|
+
const remoteFolderPath = cfg.remoteDir;
|
|
121
|
+
|
|
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
|
+
if (!fs.existsSync(localFolderPath)) {
|
|
142
|
+
throw new Error(`本地目录不存在:${path.resolve(localFolderPath)}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
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 字段配置)');
|
|
172
|
+
|
|
173
|
+
let Client;
|
|
174
|
+
try {
|
|
175
|
+
// 仅在需要“上传”时才加载,避免“正式服只压缩”也因为缺依赖而崩
|
|
176
|
+
Client = require('ssh2-sftp-client');
|
|
177
|
+
} catch (e) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
"缺少依赖:ssh2-sftp-client。\n" +
|
|
180
|
+
"如果你在 ylyx-cli 仓库本地跑:请先在 ylyx-cli 目录执行 `npm i`。\n" +
|
|
181
|
+
"如果你用 npx:请确认你运行的是已发布且包含依赖的版本,或清理 npx 缓存后重试。"
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const sftp = new Client();
|
|
186
|
+
try {
|
|
187
|
+
spinner = ora('连接服务器...').start();
|
|
188
|
+
await sftp.connect({
|
|
189
|
+
host: cfg.host,
|
|
190
|
+
port: cfg.port,
|
|
191
|
+
username: cfg.username,
|
|
192
|
+
password: cfg.password,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
spinner.text = '确保远端目录存在...';
|
|
196
|
+
await sftp.mkdir(remoteFolderPath, true);
|
|
197
|
+
|
|
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}`);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (spinner) spinner.fail('部署失败');
|
|
209
|
+
throw err;
|
|
210
|
+
} finally {
|
|
211
|
+
try {
|
|
212
|
+
await sftp.end();
|
|
213
|
+
} catch (e) {}
|
|
214
|
+
if (spinner) spinner.info('部署结束');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
deploy,
|
|
220
|
+
resolveDeployConfig,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// 允许直接 node lib/deploy.js 运行(从 .ylyxrc.json 读取 deploy 配置)
|
|
224
|
+
if (require.main === module) {
|
|
225
|
+
deploy().catch((e) => {
|
|
226
|
+
console.error(e);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
});
|
|
229
|
+
}
|
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.
|
|
3
|
+
"version": "1.0.4",
|
|
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"
|