feat(create): 支持从空白模板创建前端项目

- 新增 create 命令,可选择 Ant Design 5 或 6 模板
- 自动生成并支持手动覆盖 Java Git、仓库名、应用标识和生产分支
- 根据 Java Git 仓库名生成小驼峰 appIdentifier
- 使用 AES-256-GCM 混淆模板及默认 Java Git 地址
- 创建后删除模板 Git 记录,不自动安装依赖
- 补充创建流程测试和使用说明
master
LiuJiaNan 2026-08-26 16:15:12 +08:00
parent dd307e7262
commit ab47a55d72
7 changed files with 464 additions and 8 deletions

View File

@ -10,7 +10,7 @@
- Git - Git
- Yarn - Yarn
`src/push.js` 使用的模块均为 Node.js 内置模块,无需安装运行时依赖。 工具使用的模块均为 Node.js 内置模块,无需安装运行时依赖。
业务项目的依赖安装和项目构建只使用 YarnWindows 下会自动调用 `yarn.cmd` 业务项目的依赖安装和项目构建只使用 YarnWindows 下会自动调用 `yarn.cmd`
@ -36,9 +36,32 @@ npm install -g
在业务项目根目录创建或修改 `jjb.config.js` 在业务项目根目录创建或修改 `jjb.config.js`
`javaGit` 必须是完整的 Git 克隆地址,不能只填写 Git 服务地址。 `javaGit` 必须是完整的 Git 克隆地址,不能只填写 Git 服务地址。[jsconfig.json](../../../asdas/jsconfig.json)
## 使用 ## 创建项目
在存放项目根目录执行:
```bash
zy-gbs-cmd create
```
根据提示输入项目目录名并选择模板:
```text
1. 1.0Ant Design 5
2. 2.0Ant Design 6
```
工具会为 `javaGit`、`javaGitName`、`appIdentifier` 和 production 分支生成默认值,直接回车即可使用,也可以手动输入。`appIdentifier` 根据最终 `javaGit` 的仓库名去除 `zcloud_gbs_`、`_gwj` 后转换为小驼峰。
创建成功后模板自带的 Git 记录会被删除,工具不会自动安装依赖。进入新项目后手动执行:
```bash
yarn install
```
## 推送项目
在业务项目根目录执行: 在业务项目根目录执行:

View File

@ -5,9 +5,8 @@ const { version } = require('../package.json');
/* /*
* 命令启动入口 * 命令启动入口
* 1. package.json bin 配置把全局命令 zy-gbs-cmd 指向本文件 * 1. package.json bin 配置把全局命令 zy-gbs-cmd 指向本文件
* 2. 用户执行 zy-gbs-cmd push java production * 2. 本文件解析 createpushhelpv 等用户参数
* 3. 本文件从 process.argv 取出 pushjavaproduction * 3. 根据命令调用对应模块执行项目创建或构建推送
* 4. 确认是 push java 调用 src/push.js 开始真正的构建和推送
*/ */
function printHelp() { function printHelp() {
@ -16,6 +15,7 @@ function printHelp() {
使用 使用
zy-gbs-cmd help 查看帮助 zy-gbs-cmd help 查看帮助
zy-gbs-cmd v 查看版本 zy-gbs-cmd v 查看版本
zy-gbs-cmd create 从空白模板创建项目
zy-gbs-cmd push java [environment] 构建并推送到 Java Git 仓库 zy-gbs-cmd push java [environment] 构建并推送到 Java Git 仓库
`); `);
} }
@ -34,6 +34,11 @@ async function main() {
return; return;
} }
if (command === 'create' && !argument) {
await require('../src/create')();
return;
}
if (command === 'push' && argument === 'java') { if (command === 'push' && argument === 'java') {
// 第 2 步进入推送主流程。production 会作为 environment 传给 pushJava。 // 第 2 步进入推送主流程。production 会作为 environment 传给 pushJava。
await require('../src/push')({ environment }); await require('../src/push')({ environment });
@ -46,6 +51,6 @@ async function main() {
main().catch(error => { main().catch(error => {
// 统一设置非零退出码,便于终端和 CI 判断命令执行失败。 // 统一设置非零退出码,便于终端和 CI 判断命令执行失败。
console.error(`推送失败: ${error.message}`); console.error(`执行失败: ${error.message}`);
process.exitCode = 1; process.exitCode = 1;
}); });

View File

@ -15,7 +15,7 @@
"zy-gbs-cmd": "bin/command.js" "zy-gbs-cmd": "bin/command.js"
}, },
"scripts": { "scripts": {
"test": "node test/package-manager.test.js && node test/push.test.js && node bin/command.js help", "test": "node test/package-manager.test.js && node test/push.test.js && node test/create.test.js && node test/template-repository.test.js && node bin/command.js help",
"prepublishOnly": "yarn test", "prepublishOnly": "yarn test",
"patch": "npm version patch", "patch": "npm version patch",
"minor": "npm version minor", "minor": "npm version minor",

299
src/create.js Normal file
View File

@ -0,0 +1,299 @@
const childProcess = require('child_process');
const fs = require('fs');
const path = require('path');
const readline = require('readline/promises');
const getTemplateRepository = require('./template-repository');
const { getDefaultJavaGitRoot } = getTemplateRepository;
const TEMPLATE_OPTIONS = [
{ branch: '1.0', label: '1.0Ant Design 5' },
{ branch: '2.0', label: '2.0Ant Design 6' },
];
const INVALID_PROJECT_NAME = /[<>:"/\\|?*\u0000-\u001f]/;
const RESERVED_PROJECT_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
function getProjectBaseName(projectName) {
return projectName.endsWith('-react')
? projectName.slice(0, -'-react'.length)
: projectName;
}
function validateProjectName(projectName) {
const normalizedName = projectName.trim();
const baseName = getProjectBaseName(normalizedName);
if (!normalizedName || normalizedName === '.' || normalizedName === '..') {
throw new Error('项目目录名不能为空或特殊路径');
}
if (INVALID_PROJECT_NAME.test(normalizedName) || /[. ]$/.test(normalizedName)) {
throw new Error('项目目录名包含非法字符');
}
if (RESERVED_PROJECT_NAME.test(normalizedName)) {
throw new Error('项目目录名不能使用 Windows 保留名称');
}
if (!baseName) {
throw new Error('项目目录名去掉 -react 后不能为空');
}
return normalizedName;
}
function getDefaultJavaGit(projectName) {
return `${getDefaultJavaGitRoot()}/${getProjectBaseName(projectName)}.git`;
}
function getRepositoryName(javaGit) {
const normalizedGit = javaGit.trim().replace(/[?#].*$/, '').replace(/[\\/]+$/, '');
const repositoryName = normalizedGit.split(/[/:\\]/).pop().replace(/\.git$/i, '');
if (!repositoryName) {
throw new Error('无法从 Java Git 地址提取仓库名称');
}
return repositoryName;
}
function toCamelCase(value) {
const parts = value.split(/[-_]+/).filter(Boolean);
if (!parts.length) {
throw new Error('无法根据 Java Git 地址生成 appIdentifier');
}
const [first, ...rest] = parts;
return `${first.charAt(0).toLowerCase()}${first.slice(1)}${rest
.map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join('')}`;
}
function getDefaultAppIdentifier(javaGit) {
const repositoryName = getRepositoryName(javaGit)
.replace(/^zcloud_gbs_/, '')
.replace(/_gwj$/, '');
return toCamelCase(repositoryName);
}
function replaceConfigValue(source, pattern, value, fieldName) {
let replacements = 0;
const result = source.replace(pattern, (matched, prefix) => {
replacements += 1;
return `${prefix}${JSON.stringify(value)}`;
});
if (replacements !== 1) {
throw new Error(`jjb.config.js 中无法唯一定位 ${fieldName}`);
}
return result;
}
function updateConfigSource(source, values) {
// 模板中 development 和 production 都有 javaGitBranch分支正则必须限定在 production 块内。
let result = replaceConfigValue(
source,
/^(\s*javaGit\s*:\s*)["'][^"'\r\n]*["']/gm,
values.javaGit,
'javaGit'
);
result = replaceConfigValue(
result,
/^(\s*javaGitName\s*:\s*)["'][^"'\r\n]*["']/gm,
values.javaGitName,
'javaGitName'
);
result = replaceConfigValue(
result,
/(\bproduction\s*:\s*\{[\s\S]*?^\s*javaGitBranch\s*:\s*)["'][^"'\r\n]*["']/gm,
values.productionBranch,
'environment.production.javaGitBranch'
);
return replaceConfigValue(
result,
/^(\s*appIdentifier\s*:\s*)["'][^"'\r\n]*["']/gm,
values.appIdentifier,
'appIdentifier'
);
}
function validateConfig(configPath, expectedValues) {
delete require.cache[require.resolve(configPath)];
const importedConfig = require(configPath);
const config = importedConfig.default || importedConfig;
const actualValues = {
javaGit: config.javaGit,
javaGitName: config.javaGitName,
appIdentifier: config.appIdentifier,
productionBranch: config.environment
&& config.environment.production
&& config.environment.production.javaGitBranch,
};
Object.keys(expectedValues).forEach(field => {
if (actualValues[field] !== expectedValues[field]) {
throw new Error(`jjb.config.js 写入失败: ${field}`);
}
});
delete require.cache[require.resolve(configPath)];
}
function updateProjectConfig(projectPath, values) {
const configPath = path.join(projectPath, 'jjb.config.js');
if (!fs.existsSync(configPath)) {
throw new Error('空白模板中未找到 jjb.config.js');
}
const source = fs.readFileSync(configPath, 'utf8');
fs.writeFileSync(configPath, updateConfigSource(source, values), 'utf8');
validateConfig(configPath, values);
}
async function askProjectName(input) {
while (true) {
const answer = await input.question('请输入项目目录名: ');
try {
return validateProjectName(answer);
} catch (error) {
console.log(error.message);
}
}
}
async function selectTemplate(input) {
console.log('请选择模板版本:');
TEMPLATE_OPTIONS.forEach((option, index) => {
console.log(` ${index + 1}. ${option.label}`);
});
while (true) {
const answer = await input.question('请输入序号: ');
const selected = TEMPLATE_OPTIONS[Number(answer) - 1];
if (selected) {
return selected;
}
console.log('请输入有效序号。');
}
}
async function askWithDefault(input, message, defaultValue, validate) {
while (true) {
const answer = (await input.question(`${message}\n(${defaultValue}): `)).trim();
const value = answer || defaultValue;
try {
if (validate) {
validate(value);
}
return value;
} catch (error) {
console.log(error.message);
}
}
}
function validatePathSegment(value, fieldName) {
if (!value || INVALID_PROJECT_NAME.test(value) || value === '.' || value === '..') {
throw new Error(`${fieldName} 不能作为目录或文件名称`);
}
}
function validateGitBranch(branch) {
const result = childProcess.spawnSync('git', ['check-ref-format', '--branch', branch], {
stdio: 'ignore',
});
if (result.status !== 0) {
throw new Error('请输入有效的 Git 分支名称');
}
}
function cloneTemplate(template, temporaryRoot, temporaryProject) {
console.log(`正在拉取空白模板 ${template.label}...`);
childProcess.execFileSync('git', [
'clone',
'--quiet',
'--depth',
'1',
'--single-branch',
'--branch',
template.branch,
getTemplateRepository(),
temporaryProject,
], { cwd: temporaryRoot, stdio: 'inherit' });
}
async function createProject() {
// 全部输入确认后才克隆到临时目录,配置验证成功再移动为正式项目,避免留下半成品。
const projectRoot = process.cwd();
const input = readline.createInterface({ input: process.stdin, output: process.stdout });
let temporaryRoot;
try {
const projectName = await askProjectName(input);
const targetProject = path.join(projectRoot, projectName);
if (fs.existsSync(targetProject)) {
throw new Error(`目标目录已存在: ${targetProject}`);
}
const template = await selectTemplate(input);
const defaultJavaGit = getDefaultJavaGit(projectName);
const javaGit = await askWithDefault(
input,
'请输入 Java Git 地址',
defaultJavaGit,
getRepositoryName
);
const defaultJavaGitName = getRepositoryName(javaGit);
const defaultAppIdentifier = getDefaultAppIdentifier(javaGit);
const javaGitName = await askWithDefault(
input,
'请输入 javaGitName',
defaultJavaGitName,
value => validatePathSegment(value, 'javaGitName')
);
const appIdentifier = await askWithDefault(
input,
'请输入 appIdentifier',
defaultAppIdentifier,
value => validatePathSegment(value, 'appIdentifier')
);
const productionBranch = await askWithDefault(
input,
'请输入 production 分支',
'main',
validateGitBranch
);
temporaryRoot = fs.mkdtempSync(path.join(projectRoot, '.zy-gbs-cmd-create-'));
const temporaryProject = path.join(temporaryRoot, 'project');
cloneTemplate(template, temporaryRoot, temporaryProject);
updateProjectConfig(temporaryProject, {
javaGit,
javaGitName,
appIdentifier,
productionBranch,
});
fs.rmSync(path.join(temporaryProject, '.git'), { recursive: true, force: true });
fs.renameSync(temporaryProject, targetProject);
console.log(`项目创建成功: ${targetProject}`);
console.log(`模板版本: ${template.label}`);
console.log(`javaGit: ${javaGit}`);
console.log(`javaGitName: ${javaGitName}`);
console.log(`appIdentifier: ${appIdentifier}`);
console.log(`production 分支: ${productionBranch}`);
console.log(`请进入项目目录后执行: yarn install`);
} finally {
input.close();
if (temporaryRoot) {
try {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
} catch (error) {
console.warn(`临时目录清理失败: ${error.message}`);
}
}
}
}
module.exports = createProject;
module.exports.getDefaultAppIdentifier = getDefaultAppIdentifier;
module.exports.getDefaultJavaGit = getDefaultJavaGit;
module.exports.getProjectBaseName = getProjectBaseName;
module.exports.getRepositoryName = getRepositoryName;
module.exports.updateConfigSource = updateConfigSource;
module.exports.validateProjectName = validateProjectName;

View File

@ -0,0 +1,51 @@
const crypto = require('crypto');
const TEMPLATE_REPOSITORY = {
ciphertext: 'cxR6dGDVpS51WPbHS21TA6o0wbVouuzXVfbv6lz+ppiYksW4Sb1JZOAap4XgkWZtxxydHCEYciQxPavixupdLw==',
iv: 'KilocwyDH0kSLAOZ',
authTag: 'sH48QfZClkASGy8gOotjnA==',
};
const DEFAULT_JAVA_GIT_ROOT = {
ciphertext: 'qvY4mKNSVQWHIXach/Py87nOSx0/SNu2oTVq5eP620eutGmv',
iv: 'lwToXY+Q5UNPbxZE',
authTag: 't3FAc0J5bTLP3fLWLTRh5w==',
};
const KEY_DATA = [
142, 229, 64, 17, 180, 222, 153, 195,
169, 83, 61, 150, 52, 62, 139, 6,
18, 96, 219, 59, 173, 135, 206, 23,
242, 21, 114, 152, 102, 235, 121, 191,
];
const KEY_MASK = [
147, 217, 89, 108, 185, 148, 24, 86,
5, 239, 241, 7, 32, 16, 246, 137,
247, 234, 180, 150, 199, 181, 214, 139,
221, 241, 15, 34, 175, 234, 162, 243,
];
function decrypt(value) {
// 地址只做客户端源码混淆,运行时使用 Node.js 内置 crypto 解密,不需要用户提供密钥。
const key = Buffer.from(KEY_DATA.map((value, index) => value ^ KEY_MASK[index]));
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
key,
Buffer.from(value.iv, 'base64')
);
decipher.setAuthTag(Buffer.from(value.authTag, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(value.ciphertext, 'base64')),
decipher.final(),
]).toString('utf8');
}
function getTemplateRepository() {
return decrypt(TEMPLATE_REPOSITORY);
}
function getDefaultJavaGitRoot() {
return decrypt(DEFAULT_JAVA_GIT_ROOT);
}
module.exports = getTemplateRepository;
module.exports.getDefaultJavaGitRoot = getDefaultJavaGitRoot;

62
test/create.test.js Normal file
View File

@ -0,0 +1,62 @@
const assert = require('assert');
const {
getDefaultAppIdentifier,
getDefaultJavaGit,
getProjectBaseName,
getRepositoryName,
updateConfigSource,
validateProjectName,
} = require('../src/create');
assert.strictEqual(
getProjectBaseName('zcloud_gbs_basic_info_gwj-react'),
'zcloud_gbs_basic_info_gwj'
);
assert.strictEqual(getProjectBaseName('custom-project'), 'custom-project');
assert.match(
getDefaultJavaGit('zcloud_gbs_basic_info_gwj-react'),
/\/zcloud_gbs\/zcloud_gbs_basic_info_gwj\.git$/
);
assert.strictEqual(
getRepositoryName('https://example.com/group/custom-server.git'),
'custom-server'
);
assert.strictEqual(
getDefaultAppIdentifier(
'https://example.com/group/zcloud_gbs_enterprise_user_gwj.git'
),
'enterpriseUser'
);
assert.strictEqual(
getDefaultAppIdentifier('https://example.com/group/zcloud_gbs_aqd.git'),
'aqd'
);
assert.strictEqual(validateProjectName('demo-react'), 'demo-react');
assert.throws(() => validateProjectName('../demo'), /非法字符/);
const configSource = `module.exports = {
javaGit: "<git-url>",
javaGitName: "<git-name>",
environment: {
development: {
javaGitBranch: "<branch-name>",
},
production: {
javaGitBranch: "<branch-name>",
},
},
appIdentifier: "唯一应用标识与后端gateway相同",
};
`;
const updatedConfig = updateConfigSource(configSource, {
javaGit: 'https://example.com/group/zcloud_gbs_basic_info_gwj.git',
javaGitName: 'customJavaRepository',
appIdentifier: 'customApp',
productionBranch: 'release',
});
assert.match(updatedConfig, /javaGit: "https:\/\/example\.com\/group\/zcloud_gbs_basic_info_gwj\.git"/);
assert.match(updatedConfig, /javaGitName: "customJavaRepository"/);
assert.match(updatedConfig, /appIdentifier: "customApp"/);
assert.match(updatedConfig, /development: \{\s+javaGitBranch: "<branch-name>"/);
assert.match(updatedConfig, /production: \{\s+javaGitBranch: "release"/);

View File

@ -0,0 +1,16 @@
const assert = require('assert');
const crypto = require('crypto');
const getTemplateRepository = require('../src/template-repository');
function getHash(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
assert.strictEqual(
getHash(getTemplateRepository()),
'd2ecabf072ef4b49e60080b0cfa63232d643fdbaff55dfe789dea92e8a09e545'
);
assert.strictEqual(
getHash(getTemplateRepository.getDefaultJavaGitRoot()),
'61f9226c13559eb5eff239ddeade23a0485c9bbba539373cff114589cf305b02'
);