diff --git a/README.md b/README.md index e1d6821..adc5af2 100644 --- a/README.md +++ b/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 +``` + +## 推送项目 在业务项目根目录执行: diff --git a/bin/command.js b/bin/command.js index dfbefef..beb9883 100644 --- a/bin/command.js +++ b/bin/command.js @@ -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; }); diff --git a/package.json b/package.json index 41c6b14..d0b717c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/create.js b/src/create.js new file mode 100644 index 0000000..94856c1 --- /dev/null +++ b/src/create.js @@ -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; diff --git a/src/template-repository.js b/src/template-repository.js new file mode 100644 index 0000000..a173607 --- /dev/null +++ b/src/template-repository.js @@ -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; diff --git a/test/create.test.js b/test/create.test.js new file mode 100644 index 0000000..9f83b35 --- /dev/null +++ b/test/create.test.js @@ -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: "", + javaGitName: "", + environment: { + development: { + javaGitBranch: "", + }, + production: { + javaGitBranch: "", + }, + }, + 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: ""/); +assert.match(updatedConfig, /production: \{\s+javaGitBranch: "release"/); diff --git a/test/template-repository.test.js b/test/template-repository.test.js new file mode 100644 index 0000000..27c0893 --- /dev/null +++ b/test/template-repository.test.js @@ -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' +);