birc-generator 0.9.0 → 1.0.0
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/PROJECT.md +24 -5
- package/README.md +14 -12
- package/bin/birc.js +10 -4
- package/bin/cli-util.js +30 -2
- package/lib/create.js +150 -0
- package/lib/features.js +558 -0
- package/lib/make.js +658 -0
- package/lib/project.js +234 -0
- package/package.json +5 -3
- package/plopfile.js +62 -1334
- package/project-docs/PROJECT.md.hbs +14 -1
- package/templates/aop/OperationLogAspect.hbs +10 -3
- package/templates/auth/AuthController.hbs +73 -0
- package/templates/auth/AuthFlowTest.hbs +112 -0
- package/templates/auth/AuthSecurityCustomizer.hbs +38 -0
- package/templates/auth/AuthUser.hbs +54 -0
- package/templates/auth/AuthUserDAO.hbs +9 -0
- package/templates/auth/JpaUserDetailsService.hbs +47 -0
- package/templates/auth/JwtAuthenticationFilter.hbs +50 -0
- package/templates/auth/JwtProperties.hbs +35 -0
- package/templates/auth/JwtSecretEnvTest.hbs +68 -0
- package/templates/auth/JwtSecretEnvironmentPostProcessor.hbs +102 -0
- package/templates/auth/JwtService.hbs +60 -0
- package/templates/auth/LoginFailedException.hbs +27 -0
- package/templates/auth/LoginRequest.hbs +16 -0
- package/templates/auth/V1__create_auth_users_table.sql.hbs +13 -0
- package/templates/auth/application-yml-block.hbs +7 -0
- package/templates/auth/build-gradle-dep.hbs +4 -0
- package/templates/auth/spring.factories.hbs +1 -0
- package/templates/base/CorsTest.java.hbs +93 -0
- package/templates/base/README.md.hbs +6 -0
- package/templates/base/application-test.yml.hbs +3 -0
- package/templates/base/application.yml.hbs +12 -0
- package/templates/clockin/ClockInApiException.hbs +37 -0
- package/templates/clockin/ClockInApiResponse.hbs +14 -0
- package/templates/clockin/ClockInClient.hbs +251 -0
- package/templates/clockin/ClockInClientConfig.hbs +41 -0
- package/templates/clockin/ClockInController.hbs +77 -0
- package/templates/clockin/ClockInPage.hbs +11 -0
- package/templates/clockin/ClockInProperties.hbs +34 -0
- package/templates/clockin/ClockInRecord.hbs +20 -0
- package/templates/clockin/MemberImage.hbs +7 -0
- package/templates/clockin/OnDutyWeek.hbs +26 -0
- package/templates/clockin/UnclockedMember.hbs +7 -0
- package/templates/clockin/UserPermission.hbs +11 -0
- package/templates/clockin/application-yml-block.hbs +10 -0
- package/templates/docker/docker-compose.prod.yml.hbs +5 -0
- package/templates/docker/docker-compose.yml.hbs +7 -1
- package/templates/docker/env.example.hbs +15 -0
- package/templates/file-upload/FileExtensionUtils.hbs +45 -0
- package/templates/file-upload/FileExtensionUtilsTest.hbs +48 -0
- package/templates/file-upload/FileStorageServiceImpl.hbs +6 -0
- package/templates/multi-module/config/SecurityConfig.java.hbs +123 -8
- package/templates/multi-module/config/SecurityCustomizer.java.hbs +24 -0
- package/templates/openapi/ApiDocsAccessTest.hbs +60 -0
- package/templates/openapi/application-yml-block.hbs +8 -0
- package/test.md +7 -4
- package/versions.js +1 -0
package/lib/project.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { randomBytes, randomInt } = require('node:crypto');
|
|
4
|
+
const { projectConfigMissingMessage, toPascalCaseName } = require('../bin/cli-util');
|
|
5
|
+
|
|
6
|
+
// .env 的 DB 密碼用:16 碼混合大小寫英文 + 數字,隨機生成,避免 change_me 這種預設值。
|
|
7
|
+
function randomDbPassword() {
|
|
8
|
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
9
|
+
return Array.from({ length: 16 }, () => chars[randomInt(chars.length)]).join('');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// HS256 要 256 bit。48 bytes 的 base64 跟 openssl rand -base64 48 一樣長。
|
|
13
|
+
function randomJwtSecret() {
|
|
14
|
+
return randomBytes(48).toString('base64');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fillJwtSecretInEnv(envPath, secret) {
|
|
18
|
+
if (!fs.existsSync(envPath)) {
|
|
19
|
+
fs.writeFileSync(envPath, `JWT_SECRET=${secret}\n`);
|
|
20
|
+
return 'created .env with JWT_SECRET';
|
|
21
|
+
}
|
|
22
|
+
const text = fs.readFileSync(envPath, 'utf8');
|
|
23
|
+
const match = text.match(/^JWT_SECRET=(.*)$/m);
|
|
24
|
+
if (match && match[1].trim() !== '') {
|
|
25
|
+
return 'skipped (JWT_SECRET already set)';
|
|
26
|
+
}
|
|
27
|
+
if (match) {
|
|
28
|
+
fs.writeFileSync(envPath, text.replace(/^JWT_SECRET=.*$/m, `JWT_SECRET=${secret}`));
|
|
29
|
+
return 'filled JWT_SECRET';
|
|
30
|
+
}
|
|
31
|
+
const suffix = text.endsWith('\n') ? '' : '\n';
|
|
32
|
+
fs.writeFileSync(envPath, `${text}${suffix}JWT_SECRET=${secret}\n`);
|
|
33
|
+
return 'appended JWT_SECRET';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function projectRoot() {
|
|
37
|
+
return process.env.BIRC_PROJECT_ROOT || process.cwd();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function projectConfigPath() {
|
|
41
|
+
return path.join(projectRoot(), '.bircrc.json');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadProjectConfig() {
|
|
45
|
+
const configPath = projectConfigPath();
|
|
46
|
+
if (!fs.existsSync(configPath)) {
|
|
47
|
+
throw new Error(projectConfigMissingMessage());
|
|
48
|
+
}
|
|
49
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function detectMultiModule(config) {
|
|
53
|
+
if (typeof config.multiModule === 'boolean') {
|
|
54
|
+
return config.multiModule;
|
|
55
|
+
}
|
|
56
|
+
if (config.daoPath && String(config.daoPath).includes('database-config')) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
const modulesDir = path.join(projectRoot(), 'modules');
|
|
60
|
+
if (!fs.existsSync(modulesDir)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return fs.readdirSync(modulesDir).some((name) => name.endsWith('-database-config'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function inferProjectNameKebab(config) {
|
|
67
|
+
if (config.projectNameKebab) {
|
|
68
|
+
return config.projectNameKebab;
|
|
69
|
+
}
|
|
70
|
+
const modulesDir = path.join(projectRoot(), 'modules');
|
|
71
|
+
if (fs.existsSync(modulesDir)) {
|
|
72
|
+
const match = fs.readdirSync(modulesDir).find((name) => name.endsWith('-database-config'));
|
|
73
|
+
if (match) {
|
|
74
|
+
return match.replace(/-database-config$/, '');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return path.basename(projectRoot());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function persistenceLayout(basePackage, { multiModule, projectNameKebab, srcPath }) {
|
|
81
|
+
const basePackagePath = basePackage.replace(/\./g, '/');
|
|
82
|
+
const src = srcPath || 'src/main/java';
|
|
83
|
+
if (multiModule) {
|
|
84
|
+
const dbRoot = `modules/${projectNameKebab}-database-config/src/main/java/${basePackagePath}/databaseconfig`;
|
|
85
|
+
return {
|
|
86
|
+
entityPackage: `${basePackage}.databaseconfig.entity`,
|
|
87
|
+
daoPackage: `${basePackage}.databaseconfig.dao`,
|
|
88
|
+
entityRoot: `${dbRoot}/entity`,
|
|
89
|
+
daoRoot: `${dbRoot}/dao`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
entityPackage: `${basePackage}.entity`,
|
|
94
|
+
daoPackage: `${basePackage}.dao`,
|
|
95
|
+
entityRoot: `${src}/${basePackagePath}/entity`,
|
|
96
|
+
daoRoot: `${src}/${basePackagePath}/dao`
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolvePersistence(config) {
|
|
101
|
+
if (config.entityPath && config.daoPath && config.entityPackage && config.daoPackage) {
|
|
102
|
+
return {
|
|
103
|
+
entityPackage: config.entityPackage,
|
|
104
|
+
daoPackage: config.daoPackage,
|
|
105
|
+
entityRoot: config.entityPath,
|
|
106
|
+
daoRoot: config.daoPath
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return persistenceLayout(config.basePackage, {
|
|
110
|
+
multiModule: detectMultiModule(config),
|
|
111
|
+
projectNameKebab: inferProjectNameKebab(config),
|
|
112
|
+
srcPath: config.srcPath
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toKebab(name) {
|
|
117
|
+
return String(name || '')
|
|
118
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
119
|
+
.replace(/[\s_]+/g, '-')
|
|
120
|
+
.replace(/^-+|-+$/g, '')
|
|
121
|
+
.toLowerCase();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const JAVA_RESERVED_WORDS = new Set([
|
|
125
|
+
'abstract', 'assert', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class',
|
|
126
|
+
'const', 'continue', 'default', 'do', 'double', 'else', 'enum', 'exports', 'extends',
|
|
127
|
+
'false', 'final', 'finally', 'float', 'for', 'goto', 'if', 'implements', 'import',
|
|
128
|
+
'instanceof', 'int', 'interface', 'long', 'module', 'native', 'new', 'non-sealed',
|
|
129
|
+
'null', 'open', 'opens', 'package', 'permits', 'private', 'protected', 'provides',
|
|
130
|
+
'public', 'record', 'requires', 'return', 'sealed', 'short', 'static', 'strictfp',
|
|
131
|
+
'super', 'switch', 'synchronized', 'this', 'throw', 'throws', 'to', 'transient',
|
|
132
|
+
'transitive', 'true', 'try', 'uses', 'var', 'void', 'volatile', 'while', 'with',
|
|
133
|
+
'yield', '_'
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
const CREATE_DEFAULTS = Object.freeze({
|
|
137
|
+
projectName: 'Practice',
|
|
138
|
+
basePackage: 'tw.edu.ntub.birc.practice'
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
function validateProjectName(value) {
|
|
142
|
+
const projectName = toKebab(value);
|
|
143
|
+
if (!projectName) {
|
|
144
|
+
return '專案名稱轉成 kebab-case 後不可為空。';
|
|
145
|
+
}
|
|
146
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(projectName)) {
|
|
147
|
+
return '專案名稱只能包含英文字母、數字、空白、底線或連字號。';
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateJavaPackage(value) {
|
|
153
|
+
const packageName = String(value || '').trim();
|
|
154
|
+
if (!packageName) {
|
|
155
|
+
return 'Base package 不可為空。';
|
|
156
|
+
}
|
|
157
|
+
const segments = packageName.split('.');
|
|
158
|
+
if (segments.some((segment) => !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment))) {
|
|
159
|
+
return 'Base package 的每一段都必須是合法 Java identifier。';
|
|
160
|
+
}
|
|
161
|
+
if (segments.some((segment) => JAVA_RESERVED_WORDS.has(segment))) {
|
|
162
|
+
return 'Base package 不可使用 Java 保留字。';
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function validatePascalCase(value) {
|
|
168
|
+
return /^[A-Z][A-Za-z0-9]*$/.test(toPascalCaseName(value))
|
|
169
|
+
? true
|
|
170
|
+
: '名稱必須是英文字母開頭,例如 Activity 或 activity。';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validateMigrationName(value) {
|
|
174
|
+
const raw = String(value || '').trim();
|
|
175
|
+
if (!raw) return '遷移名稱不可為空。';
|
|
176
|
+
// 白名單擋掉 SQL 特殊字元與路徑字元:名稱會進 Flyway SQL 與檔名。
|
|
177
|
+
return /^[a-zA-Z0-9_]+$/.test(raw)
|
|
178
|
+
? true
|
|
179
|
+
: '遷移名稱只能包含英文字母、數字和底線,例如 create_users_table。';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const YML_ANCHOR = '# birc-generator:config-anchor';
|
|
183
|
+
const GRADLE_ANCHOR = '// birc-generator:dependency-anchor';
|
|
184
|
+
const GRADLE_PLUGIN_ANCHOR = '// birc-generator:plugin-anchor';
|
|
185
|
+
const GRADLE_ALLPROJECTS_ANCHOR = '// birc-generator:allprojects-anchor';
|
|
186
|
+
|
|
187
|
+
// 插進 yml / gradle 的每段 fragment 都用成對 marker 包起來,這樣才知道它從哪裡到哪裡。
|
|
188
|
+
// 只有開頭 anchor 的話,冪等只能靠比對整段文字,template 改一個字就會重複插;
|
|
189
|
+
// 也沒有辦法把某個 feature 的設定再拿掉。
|
|
190
|
+
function fragmentMarkers(commentToken, marker) {
|
|
191
|
+
return {
|
|
192
|
+
begin: `${commentToken} birc:begin:${marker}`,
|
|
193
|
+
end: `${commentToken} birc:end:${marker}`
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function requireGradleAnchor(errors, featureKey, destBase, gradlePath, anchor) {
|
|
198
|
+
const target = path.resolve(destBase, gradlePath);
|
|
199
|
+
if (!fs.existsSync(target)) {
|
|
200
|
+
errors.push(`${featureKey}: 找不到 ${gradlePath}`);
|
|
201
|
+
} else if (!fs.readFileSync(target, 'utf8').includes(anchor)) {
|
|
202
|
+
errors.push(`${featureKey}: ${gradlePath} 沒有 ${anchor}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function gradleAnchorPattern(anchor) {
|
|
207
|
+
return new RegExp(`${anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
randomDbPassword,
|
|
212
|
+
randomJwtSecret,
|
|
213
|
+
fillJwtSecretInEnv,
|
|
214
|
+
projectRoot,
|
|
215
|
+
projectConfigPath,
|
|
216
|
+
loadProjectConfig,
|
|
217
|
+
detectMultiModule,
|
|
218
|
+
inferProjectNameKebab,
|
|
219
|
+
persistenceLayout,
|
|
220
|
+
resolvePersistence,
|
|
221
|
+
toKebab,
|
|
222
|
+
CREATE_DEFAULTS,
|
|
223
|
+
validateProjectName,
|
|
224
|
+
validateJavaPackage,
|
|
225
|
+
validatePascalCase,
|
|
226
|
+
validateMigrationName,
|
|
227
|
+
YML_ANCHOR,
|
|
228
|
+
GRADLE_ANCHOR,
|
|
229
|
+
GRADLE_PLUGIN_ANCHOR,
|
|
230
|
+
GRADLE_ALLPROJECTS_ANCHOR,
|
|
231
|
+
fragmentMarkers,
|
|
232
|
+
requireGradleAnchor,
|
|
233
|
+
gradleAnchorPattern
|
|
234
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "birc-generator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Spring Boot 4 CRUD scaffold generator built on Plop.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "lucashsu95",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"bin",
|
|
22
22
|
"plopfile.js",
|
|
23
|
+
"lib",
|
|
23
24
|
"versions.js",
|
|
24
25
|
"templates",
|
|
25
26
|
"project-docs",
|
|
@@ -43,12 +44,14 @@
|
|
|
43
44
|
"c8": {
|
|
44
45
|
"include": [
|
|
45
46
|
"plopfile.js",
|
|
47
|
+
"lib",
|
|
46
48
|
"versions.js",
|
|
47
49
|
"bin/cli-util.js"
|
|
48
50
|
],
|
|
49
51
|
"reporter": [
|
|
50
52
|
"text",
|
|
51
|
-
"json-summary"
|
|
53
|
+
"json-summary",
|
|
54
|
+
"cobertura"
|
|
52
55
|
],
|
|
53
56
|
"check-coverage": true,
|
|
54
57
|
"lines": 80,
|
|
@@ -62,7 +65,6 @@
|
|
|
62
65
|
"plop": "^4.0.1"
|
|
63
66
|
},
|
|
64
67
|
"dependencies": {
|
|
65
|
-
"js-yaml": "^5.3.0",
|
|
66
68
|
"node-plop": "^0.32.3"
|
|
67
69
|
}
|
|
68
70
|
}
|