import { parse, stringify } from 'yaml';
import * as path from 'path';
import * as dotenv from 'dotenv';
import { copySync } from 'fs-extra';
import chalk from 'chalk';
import spawn from 'cross-spawn';
import inquirer, { QuestionCollection } from 'inquirer';
import { cliConfig, logs, fs } from '@opentiny/cli-devkit';
import { createEditor } from 'properties-parser';
import {
buildCommand,
buildConfigs,
BuildTool,
devCommand,
devDependencies,
LowcodeEngine,
ProjectInfo,
removedCommand,
removeDependencies,
ServerFrameworks,
VueVersion,
} from './interfaces';
import utils from './utils';
import { existsSync, readFileSync, rmSync, writeFileSync } from 'fs';
const log = logs('tiny-toolkit-pro');
const VUE_TEMPLATE_PATH = 'tinyvue';
const DEFAULT_PROJECT_NAME = 'tiny-pro';
* 询问创建项目的描述,使用的技术栈
*
* @returns object { description: 项目描述,framework: 框架, name: 项目名称 ,serverFramework:使用技术栈, dialect:数据库,DB_host:数据库地址,DB_port:数据库端口,database:数据库名称,username:数据库用户名,password:数据库密码,}
*/
const getProjectInfo = (): Promise<ProjectInfo> => {
const question: QuestionCollection<ProjectInfo> = [
{
type: 'input',
name: 'name',
message: '请输入项目名称:',
default: DEFAULT_PROJECT_NAME,
validate: (input: string) => Boolean(input),
},
{
type: 'input',
name: 'description',
message: '请输入项目描述:',
default: '基于TinyPro套件创建的中后台系统',
},
{
type: 'list',
name: 'framework',
message: '请选择您希望使用的客户端技术栈:',
choices: [{ name: 'vue', value: VUE_TEMPLATE_PATH }],
default: VUE_TEMPLATE_PATH,
prefix: '*',
},
{
type: 'list',
name: 'serverFramework',
message: '请选择您希望使用的服务端技术栈:',
choices: [
{ name: 'SpringBoot', value: ServerFrameworks.SpringBoot },
{ name: 'Nest.js', value: ServerFrameworks.NestJs },
{ name: '暂不配置', value: ServerFrameworks.Skip },
],
default: ServerFrameworks.NestJs,
prefix: '*',
when: (answers) => answers.framework === VUE_TEMPLATE_PATH,
},
{
type: 'list',
name: 'lowcodeEngine',
message: '是否集成低代码设计器:',
choices: [
{ name: '是,集成低代码设计器', value: LowcodeEngine.Include },
{ name: '否,暂不集成', value: LowcodeEngine.Skip },
],
default: LowcodeEngine.Skip,
prefix: '*',
when: (answers) => answers.framework === VUE_TEMPLATE_PATH,
},
{
type: 'list',
name: 'buildTool',
message: '请选择你想要的构建工具: ',
choices: [
{ name: 'Vite', value: BuildTool.Vite },
{ name: 'Webpack', value: BuildTool.Webpack },
{ name: 'Rspack', value: BuildTool.Rspack },
{ name: 'Farm', value: BuildTool.Farm },
],
default: BuildTool.Vite,
prefix: '*',
},
{
type: 'list',
name: 'serverConfirm',
message:
'请确保已安装数据库服务(参考文档 https://www.opentiny.design/tiny-cli/docs/toolkits/pro#database):',
choices: [
{ name: '已完成数据库服务安装,开始配置', value: true },
{ name: '暂不配置服务端', value: false },
],
prefix: '*',
when: (answers) =>
answers.framework === VUE_TEMPLATE_PATH &&
answers.serverFramework !== ServerFrameworks.Skip,
},
{
type: 'input',
name: 'redisHost',
message: '请输入Redis地址:',
default: 'localhost',
prefix: '*',
when: (answers) => answers.serverConfirm,
},
{
type: 'input',
name: 'redisPort',
message: '请输入Redis端口:',
default: 6379,
prefix: '*',
when: (answers) => answers.serverConfirm,
},
{
type: 'list',
name: 'dialect',
message: '请选择数据库类型:',
choices: [
{ name: 'MySql', value: 'mysql' },
{ name: '暂不配置', value: '' },
],
default: 'mysql',
prefix: '*',
when: (answers) => answers.serverConfirm,
},
{
type: 'input',
name: 'host',
message: '请输入数据库地址:',
default: 'localhost',
prefix: '*',
when: (answers) => answers.dialect,
},
{
type: 'input',
name: 'port',
message: '请输入数据库端口:',
default: 3306,
prefix: '*',
when: (answers) => answers.host,
},
{
type: 'input',
name: 'database',
message: '请输入数据库名称:',
prefix: '*',
validate: (input: string) => Boolean(input),
when: (answers) => answers.host,
},
{
type: 'input',
name: 'username',
message: '请输入登录用户名:',
default: 'root',
prefix: '*',
when: (answers) => answers.host,
},
{
type: 'password',
name: 'password',
message: '请输入密码:',
prefix: '*',
when: (answers) => answers.host,
},
];
return inquirer.prompt(question);
};
* 同步创建低代码设计器项目文件目录、文件
* @answers 询问客户端问题的选择值
*/
const createLowcodeDesignerSync = (answers: ProjectInfo) => {
const { name, lowcodeEngine } = answers;
if (lowcodeEngine !== LowcodeEngine.Include) {
return;
}
const lowcodeFrom = utils.getTemplatePath(lowcodeEngine);
const lowcodeTo = utils.getDistPath(`${name}/${lowcodeEngine}`);
copySync(lowcodeFrom, lowcodeTo);
log.success('低代码设计器模板复制成功');
};
* 同步创建服务端项目文件目录、文件
* @answers 询问客户端问题的选择值
* @dbAnswers 询问服务端配置的选择值
*/
const createServerSync = (answers: ProjectInfo) => {
const { name, serverFramework } = answers;
const serverFrom = utils.getTemplatePath(`${serverFramework}`);
const serverTo = utils.getDistPath(`${name}/${serverFramework}`);
if (serverFramework === ServerFrameworks.SpringBoot) {
console.log('springboot的服务端配置');
copySync(serverFrom, serverTo);
const config = {
DB_HOST: answers.host ?? 'localhost',
DB_PORT: Number(answers.port ?? 3306),
DB_USERNAME: answers.username ?? 'root',
DB_PASSWORD: answers.password ?? 'root',
DB_NAME: answers.database ?? 'mydb',
DB_SYNCHRONIZE: false,
DB_AUTOLOADENTITIES: true,
AUTH_SECRET: 'secret',
REDIS_SECONDS: 7200,
REDIS_HOST: answers.redisHost ?? 'localhost',
REDIS_PORT: Number(answers.redisPort ?? 6379),
EXPIRES_IN: '2h',
PAGINATION_PAGE: 1,
PAGINATION_LIMIT: 10,
SERVER_PORT: 3000,
};
const propertiesFilePath = path.join(
serverTo,
'src/main/resources/application.properties'
);
if (!existsSync(propertiesFilePath)) {
log.error(`❌ 未找到 Spring Boot 配置文件:${propertiesFilePath}`);
return;
}
if (!existsSync(propertiesFilePath)) {
log.error(`❌ 未找到 Spring Boot 配置文件:${propertiesFilePath}`);
return;
}
const editor = createEditor(propertiesFilePath);
editor.set('server.port', config.SERVER_PORT.toString());
editor.set(
'spring.datasource.url',
`jdbc:mysql://${config.DB_HOST}:${config.DB_PORT}/${config.DB_NAME}?allowMultiQueries=true&serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false`
);
editor.set('spring.datasource.username', config.DB_USERNAME);
editor.set('spring.datasource.password', config.DB_PASSWORD);
editor.set(
'spring.datasource.driver-class-name',
'com.mysql.cj.jdbc.Driver'
);
editor.set('spring.datasource.hikari.pool-name', 'HikariCPDatasource');
editor.set('spring.datasource.hikari.minimum-idle', '5');
editor.set('spring.datasource.hikari.idle-timeout', '180000');
editor.set('spring.datasource.hikari.maximum-pool-size', '10');
editor.set('spring.datasource.hikari.auto-commit', 'true');
editor.set('spring.datasource.hikari.max-lifetime', '180000');
editor.set('spring.datasource.hikari.connection-timeout', '30000');
editor.set('mybatis-plus.mapper-locations', 'classpath:mappers/*.xml');
editor.set('mybatis-plus.type-aliases-package', 'com.TinyPro.entity.po');
editor.set(
'mybatis-plus.configuration.map-underscore-to-camel-case',
'true'
);
editor.set(
'mybatis-plus.configuration.default-enum-type-handler',
'org.apache.ibatis.type.EnumOrdinalTypeHandler'
);
editor.set('spring.jpa.hibernate.ddl-auto', 'update');
editor.set(
'spring.jpa.database-platform',
'org.hibernate.dialect.MySQL8Dialect'
);
editor.set(
'spring.jpa.properties.hibernate.dialect',
'org.hibernate.dialect.MySQL8Dialect'
);
editor.set(
'spring.jpa.properties.hibernate.dialect.storage_engine',
'innodb'
);
editor.set(
'spring.jpa.properties.hibernate.globally_quoted_identifiers',
'true'
);
editor.set(
'spring.jpa.hibernate.naming.physical-strategy',
'org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl'
);
editor.set('jwt.secret', '0Zi4SA==');
editor.set('spring.data.redis.host', config.REDIS_HOST);
editor.set('spring.data.redis.port', config.REDIS_PORT.toString());
editor.save(propertiesFilePath);
log.success(`✅ Spring Boot 配置文件已更新:${propertiesFilePath}`);
} else if (serverFramework === ServerFrameworks.NestJs) {
const config = {
DATABASE_HOST: answers.dialect && (answers.host ?? 'localhost'),
DATABASE_PORT: answers.dialect && Number(answers.port ?? 3306),
DATABASE_USERNAME: answers.dialect && (answers.username ?? 'root'),
DATABASE_PASSWORD: answers.dialect && (answers.password ?? 'root'),
DATABASE_NAME: answers.dialect && answers.database,
DATABASE_SYNCHRONIZE: false,
DATABASE_AUTOLOADENTITIES: true,
AUTH_SECRET: 'secret',
REDIS_SECONDS: 7200,
REDIS_HOST: answers.redisHost ?? 'localhost',
REDIS_PORT: Number(answers.redisPort ?? 6379),
EXPIRES_IN: '2h',
PAGINATION_PAGE: 1,
PAGINATION_LIMIT: 10,
};
const envStr = objToEnv(config);
const overwriteDockerComposeConfig = {
MYSQL_ROOT_PASSWORD: config.DATABASE_PASSWORD,
MYSQL_DATABASE: config.DATABASE_NAME,
MYSQL_USER:
config.DATABASE_USERNAME === 'root' ? undefined : config.DATABASE_NAME,
MYSQL_PASSWORD:
config.DATABASE_USERNAME === 'root'
? undefined
: config.DATABASE_PASSWORD,
};
copySync(serverFrom, serverTo);
writeFileSync(path.join(serverTo, '.env'), envStr);
const dockerComposeYaml = readFileSync(
path.join(serverTo, 'docker-compose.yml')
).toString();
const yaml = parse(dockerComposeYaml);
const { services } = yaml;
const mysql = services.mysql;
mysql.environment = overwriteDockerComposeConfig;
writeFileSync(path.join(serverTo, 'docker-compose.yml'), stringify(yaml));
}
};
const objToEnv = (obj: Record<string, any>) => {
return Object.entries(obj)
.map(([key, value]) => {
const v = typeof value === 'string' ? `'${value}'` : value;
return [key, '=', v].join(' ');
})
.join('\n');
};
const packageJsonProcess = (
buildTool: BuildTool,
packages: Record<string, string | Record<string, string | boolean>>,
currentPath: string
) => {
const match = (pattern: RegExp, items: Array<string>) => {
return items.filter((item) => pattern.test(item));
};
const removeDeps = () => {
const devDeps = devDependencies[buildTool];
devDeps.forEach((devDep) => {
if (typeof devDep === 'string') {
packages.devDependencies[devDep] = undefined;
}
if (devDep instanceof RegExp) {
const deps = match(devDep, Object.keys(packages.devDependencies));
if (!deps.length) {
return;
}
deps.forEach((dep) => {
if (packages.devDependencies[dep]) {
packages.devDependencies[dep] = undefined;
}
});
}
});
const dependencies = removeDependencies[buildTool];
dependencies.forEach((dep: string | RegExp) => {
if (typeof dep === 'string') {
if (!packages.dependencies[dep]) {
return;
}
packages.dependencies[dep] = undefined;
}
if (dep instanceof RegExp) {
const keys = match(dep, Object.keys(packages.devDependencies));
keys.forEach((key) => {
if (!packages.dependencies[key]) {
return;
}
packages.dependencies[key] = undefined;
});
}
});
};
const replaceScript = (name: string, command: string | undefined) => {
packages.scripts[name] = command;
};
const removeScripts = () => {
const scripts = removedCommand;
scripts.forEach((script) => {
replaceScript(script, undefined);
});
};
const replaceBuildCommand = () => {
const command = buildCommand[buildTool];
replaceScript('build', command);
};
const replaceDevCommand = () => {
const command = devCommand[buildTool];
replaceScript('start', command);
};
const remove = () => {
const removedPaths = buildConfigs[buildTool];
const paths = removedPaths
.filter((removedPath) => existsSync(path.join(currentPath, removedPath)))
.map((p) => path.join(currentPath, p));
if (!paths.length) {
return;
}
let willRemovePath = '';
try {
paths.forEach((removePath) => {
willRemovePath = removePath;
rmSync(removePath, { recursive: true, force: true });
});
} catch {
log.error(`删除${willRemovePath}错误`);
}
};
removeDeps();
removeScripts();
replaceBuildCommand();
replaceDevCommand();
return remove;
};
* 同步创建客户端项目文件目录、文件
* @answers 询问客户端问题的选择值
* @dbAnswers 询问服务端配置的选择值
*/
const createProjectSync = (answers: ProjectInfo) => {
const {
description,
name,
serverConfirm,
buildTool,
serverFramework,
lowcodeEngine,
} = answers;
const templatePath = VueVersion.Vue3;
const from = utils.getTemplatePath(templatePath);
const to = utils.getDistPath(serverConfirm ? `${name}/web` : name);
fs.copyTpl(from, to);
if (serverFramework === ServerFrameworks.NestJs) {
try {
const packageJsonPath = path.join(to, 'package.json');
let packageJson = JSON.parse(
fs.readFileSync(packageJsonPath, { encoding: 'utf8' })
);
packageJson = { ...packageJson, name, description };
const remove = packageJsonProcess(buildTool, packageJson, to);
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2), {
encoding: 'utf8',
});
remove();
} catch (e) {
log.error(e);
log.error('配置项目信息创建失败');
}
}
try {
const envPath = path.join(to, 'dev.env');
const envContent = fs.readFileSync(envPath, { encoding: 'utf8' });
let updatedEnvContent = envContent;
if (!serverConfirm) {
const envConfig = dotenv.parse(envContent);
delete envConfig.VITE_MOCK_IGNORE;
updatedEnvContent = Object.keys(envConfig)
.map((key) => `${key} = ${envConfig[key]}`)
.join('\n');
}
const lowcodeEnabled = lowcodeEngine === LowcodeEngine.Include;
updatedEnvContent = updatedEnvContent.replace(
/VITE_LOWCODE_DESIGNER_ENABLED=false/,
`VITE_LOWCODE_DESIGNER_ENABLED=${lowcodeEnabled}`
);
fs.writeFileSync(envPath, updatedEnvContent);
log.success(`低代码设计器环境变量已设置为: ${lowcodeEnabled}`);
} catch (e) {
log.error(e);
log.error('配置环境变量失败');
log.info('请手动配置env信息');
}
if (serverConfirm) {
createServerSync(answers);
}
createLowcodeDesignerSync(answers);
};
export const installDependencies = (answers: ProjectInfo) => {
const prefix = cliConfig.getBinName();
const { name, serverFramework, serverConfirm, lowcodeEngine } = answers;
if (serverConfirm && serverFramework === ServerFrameworks.EggJs) {
log.info('正在安装服务端 npm 依赖,安装过程需要几十秒,请耐心等待...');
const installServiceResult = spawn.sync('npm', ['install'], {
cwd: `${name}/${serverFramework}/`,
stdio: 'inherit',
});
if (installServiceResult.status === 0) {
log.success('服务端 npm 依赖安装成功');
} else {
throw new Error(installServiceResult.error);
}
}
log.info('正在安装客户端 npm 依赖,安装过程需要几十秒,请耐心等待...');
const installClientResult = spawn.sync('npm', ['install'], {
cwd: serverConfirm ? `${name}/web` : `${name}/`,
stdio: 'inherit',
});
if (installClientResult.status === 0) {
log.success('客户端 npm 依赖安装成功');
} else {
throw new Error(installClientResult.error);
}
if (lowcodeEngine && lowcodeEngine === LowcodeEngine.Include) {
log.info(
'正在安装低代码设计器 npm 依赖,安装过程需要几十秒,请耐心等待...'
);
const installLowcodeResult = spawn.sync('npm', ['install'], {
cwd: `${name}/${lowcodeEngine}/`,
stdio: 'inherit',
});
if (installLowcodeResult.status === 0) {
log.success('低代码设计器 npm 依赖安装成功');
} else {
throw new Error(installLowcodeResult.error);
}
}
console.log(
chalk.yellow(
'\n--------------------初始化成功,请按下面提示进行操作--------------------\n'
)
);
if (serverConfirm) {
console.log(
chalk.green(
`${chalk.yellow(
`$ cd ${name}/web && npm run start`
)} # 开启web开发环境`
)
);
console.log(
chalk.green(
`${chalk.yellow(
serverFramework === ServerFrameworks.SpringBoot
? `请查看 ${name}/${serverFramework}/README_CN.md `
: `$ cd ${name}/${serverFramework} && npm run dev`
)} # 开启server开发环境`
)
);
} else {
console.log(
chalk.green(
`${chalk.yellow(
`$ cd ${name} && ${prefix} start`
)} # 可一键开启项目开发环境`
)
);
}
if (lowcodeEngine && lowcodeEngine === LowcodeEngine.Include) {
console.log(
chalk.green(
`${chalk.yellow(
`$ cd ${name}/${lowcodeEngine} && npm run dev`
)} # 开启低代码设计器开发环境`
)
);
}
console.log(
chalk.green(
`${chalk.yellow(`$ ${prefix} help`)} # 可查看当前套件的详细帮助`
)
);
console.log(
chalk.green(
`\n建议将现有初始化的代码提交一次到master分支, 方便后续切换到 ${chalk.yellow(
'daily/x.y.z'
)} 分支进行开发`
)
);
console.log(
chalk.yellow(
'\n-------------------- 技术支持:官方小助手微信opentiny-official --------------------\n'
)
);
};
export default async () => {
let projectInfo: ProjectInfo;
try {
projectInfo = await getProjectInfo();
createProjectSync(projectInfo);
} catch (e) {
log.error(e);
log.error('项目模板创建失败');
return;
}
log.info('初始化成功,请运行npm i或tiny i 安装依赖');
};