feat(create): 支持从空白模板创建前端项目
- 新增 create 命令,可选择 Ant Design 5 或 6 模板 - 自动生成并支持手动覆盖 Java Git、仓库名、应用标识和生产分支 - 根据 Java Git 仓库名生成小驼峰 appIdentifier - 使用 AES-256-GCM 混淆模板及默认 Java Git 地址 - 创建后删除模板 Git 记录,不自动安装依赖 - 补充创建流程测试和使用说明master
parent
dd307e7262
commit
ab47a55d72
29
README.md
29
README.md
|
|
@ -10,7 +10,7 @@
|
|||
- Git
|
||||
- Yarn
|
||||
|
||||
`src/push.js` 使用的模块均为 Node.js 内置模块,无需安装运行时依赖。
|
||||
工具使用的模块均为 Node.js 内置模块,无需安装运行时依赖。
|
||||
|
||||
业务项目的依赖安装和项目构建只使用 Yarn;Windows 下会自动调用 `yarn.cmd`。
|
||||
|
||||
|
|
@ -36,9 +36,32 @@ npm install -g
|
|||
|
||||
在业务项目根目录创建或修改 `jjb.config.js`:
|
||||
|
||||
`javaGit` 必须是完整的 Git 克隆地址,不能只填写 Git 服务地址。
|
||||
`javaGit` 必须是完整的 Git 克隆地址,不能只填写 Git 服务地址。[jsconfig.json](../../../asdas/jsconfig.json)
|
||||
|
||||
## 使用
|
||||
## 创建项目
|
||||
|
||||
在存放项目根目录执行:
|
||||
|
||||
```bash
|
||||
zy-gbs-cmd create
|
||||
```
|
||||
|
||||
根据提示输入项目目录名并选择模板:
|
||||
|
||||
```text
|
||||
1. 1.0(Ant Design 5)
|
||||
2. 2.0(Ant Design 6)
|
||||
```
|
||||
|
||||
工具会为 `javaGit`、`javaGitName`、`appIdentifier` 和 production 分支生成默认值,直接回车即可使用,也可以手动输入。`appIdentifier` 根据最终 `javaGit` 的仓库名去除 `zcloud_gbs_`、`_gwj` 后转换为小驼峰。
|
||||
|
||||
创建成功后模板自带的 Git 记录会被删除,工具不会自动安装依赖。进入新项目后手动执行:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
## 推送项目
|
||||
|
||||
在业务项目根目录执行:
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ const { version } = require('../package.json');
|
|||
/*
|
||||
* 命令启动入口:
|
||||
* 1. package.json 的 bin 配置把全局命令 zy-gbs-cmd 指向本文件。
|
||||
* 2. 用户执行 zy-gbs-cmd push java production。
|
||||
* 3. 本文件从 process.argv 取出 push、java、production。
|
||||
* 4. 确认是 push java 后,调用 src/push.js 开始真正的构建和推送。
|
||||
* 2. 本文件解析 create、push、help、v 等用户参数。
|
||||
* 3. 根据命令调用对应模块执行项目创建或构建推送。
|
||||
*/
|
||||
|
||||
function printHelp() {
|
||||
|
|
@ -16,6 +15,7 @@ function printHelp() {
|
|||
使用:
|
||||
zy-gbs-cmd help 查看帮助
|
||||
zy-gbs-cmd v 查看版本
|
||||
zy-gbs-cmd create 从空白模板创建项目
|
||||
zy-gbs-cmd push java [environment] 构建并推送到 Java Git 仓库
|
||||
`);
|
||||
}
|
||||
|
|
@ -34,6 +34,11 @@ async function main() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (command === 'create' && !argument) {
|
||||
await require('../src/create')();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'push' && argument === 'java') {
|
||||
// 第 2 步:进入推送主流程。production 会作为 environment 传给 pushJava。
|
||||
await require('../src/push')({ environment });
|
||||
|
|
@ -46,6 +51,6 @@ async function main() {
|
|||
|
||||
main().catch(error => {
|
||||
// 统一设置非零退出码,便于终端和 CI 判断命令执行失败。
|
||||
console.error(`推送失败: ${error.message}`);
|
||||
console.error(`执行失败: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"zy-gbs-cmd": "bin/command.js"
|
||||
},
|
||||
"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",
|
||||
"patch": "npm version patch",
|
||||
"minor": "npm version minor",
|
||||
|
|
|
|||
|
|
@ -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.0(Ant Design 5)' },
|
||||
{ branch: '2.0', label: '2.0(Ant 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"/);
|
||||
|
|
@ -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'
|
||||
);
|
||||
Loading…
Reference in New Issue