birc-generator 0.9.1 → 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.
Files changed (58) hide show
  1. package/PROJECT.md +23 -4
  2. package/README.md +13 -11
  3. package/bin/birc.js +10 -4
  4. package/bin/cli-util.js +30 -2
  5. package/lib/create.js +150 -0
  6. package/lib/features.js +558 -0
  7. package/lib/make.js +658 -0
  8. package/lib/project.js +234 -0
  9. package/package.json +5 -3
  10. package/plopfile.js +60 -1340
  11. package/project-docs/PROJECT.md.hbs +13 -0
  12. package/templates/aop/OperationLogAspect.hbs +10 -3
  13. package/templates/auth/AuthController.hbs +73 -0
  14. package/templates/auth/AuthFlowTest.hbs +112 -0
  15. package/templates/auth/AuthSecurityCustomizer.hbs +38 -0
  16. package/templates/auth/AuthUser.hbs +54 -0
  17. package/templates/auth/AuthUserDAO.hbs +9 -0
  18. package/templates/auth/JpaUserDetailsService.hbs +47 -0
  19. package/templates/auth/JwtAuthenticationFilter.hbs +50 -0
  20. package/templates/auth/JwtProperties.hbs +35 -0
  21. package/templates/auth/JwtSecretEnvTest.hbs +68 -0
  22. package/templates/auth/JwtSecretEnvironmentPostProcessor.hbs +102 -0
  23. package/templates/auth/JwtService.hbs +60 -0
  24. package/templates/auth/LoginFailedException.hbs +27 -0
  25. package/templates/auth/LoginRequest.hbs +16 -0
  26. package/templates/auth/V1__create_auth_users_table.sql.hbs +13 -0
  27. package/templates/auth/application-yml-block.hbs +7 -0
  28. package/templates/auth/build-gradle-dep.hbs +4 -0
  29. package/templates/auth/spring.factories.hbs +1 -0
  30. package/templates/base/CorsTest.java.hbs +93 -0
  31. package/templates/base/README.md.hbs +6 -0
  32. package/templates/base/application-test.yml.hbs +3 -0
  33. package/templates/base/application.yml.hbs +12 -0
  34. package/templates/clockin/ClockInApiException.hbs +37 -0
  35. package/templates/clockin/ClockInApiResponse.hbs +14 -0
  36. package/templates/clockin/ClockInClient.hbs +251 -0
  37. package/templates/clockin/ClockInClientConfig.hbs +41 -0
  38. package/templates/clockin/ClockInController.hbs +77 -0
  39. package/templates/clockin/ClockInPage.hbs +11 -0
  40. package/templates/clockin/ClockInProperties.hbs +34 -0
  41. package/templates/clockin/ClockInRecord.hbs +20 -0
  42. package/templates/clockin/MemberImage.hbs +7 -0
  43. package/templates/clockin/OnDutyWeek.hbs +26 -0
  44. package/templates/clockin/UnclockedMember.hbs +7 -0
  45. package/templates/clockin/UserPermission.hbs +11 -0
  46. package/templates/clockin/application-yml-block.hbs +10 -0
  47. package/templates/docker/docker-compose.prod.yml.hbs +5 -0
  48. package/templates/docker/docker-compose.yml.hbs +7 -1
  49. package/templates/docker/env.example.hbs +15 -0
  50. package/templates/file-upload/FileExtensionUtils.hbs +45 -0
  51. package/templates/file-upload/FileExtensionUtilsTest.hbs +48 -0
  52. package/templates/file-upload/FileStorageServiceImpl.hbs +6 -0
  53. package/templates/multi-module/config/SecurityConfig.java.hbs +123 -8
  54. package/templates/multi-module/config/SecurityCustomizer.java.hbs +24 -0
  55. package/templates/openapi/ApiDocsAccessTest.hbs +60 -0
  56. package/templates/openapi/application-yml-block.hbs +8 -0
  57. package/test.md +6 -3
  58. package/versions.js +1 -0
package/plopfile.js CHANGED
@@ -1,1060 +1,46 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { randomInt } = require('node:crypto');
4
- const VERSIONS = require('./versions');
5
- const { deriveBasePackage, projectConfigMissingMessage, toPascalCaseName, createDestConflict } = require('./bin/cli-util');
6
-
7
- // .env 的 DB 密碼用:16 碼混合大小寫英文 + 數字,隨機生成,避免 change_me 這種預設值。
8
- function randomDbPassword() {
9
- const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
10
- return Array.from({ length: 16 }, () => chars[randomInt(chars.length)]).join('');
11
- }
12
-
13
- function projectRoot() {
14
- return process.env.BIRC_PROJECT_ROOT || process.cwd();
15
- }
16
-
17
- function projectConfigPath() {
18
- return path.join(projectRoot(), '.bircrc.json');
19
- }
20
-
21
- function loadProjectConfig() {
22
- const configPath = projectConfigPath();
23
- if (!fs.existsSync(configPath)) {
24
- throw new Error(projectConfigMissingMessage());
25
- }
26
- return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
27
- }
28
-
29
- function detectMultiModule(config) {
30
- if (typeof config.multiModule === 'boolean') {
31
- return config.multiModule;
32
- }
33
- if (config.daoPath && String(config.daoPath).includes('database-config')) {
34
- return true;
35
- }
36
- const modulesDir = path.join(projectRoot(), 'modules');
37
- if (!fs.existsSync(modulesDir)) {
38
- return false;
39
- }
40
- return fs.readdirSync(modulesDir).some((name) => name.endsWith('-database-config'));
41
- }
42
-
43
- function inferProjectNameKebab(config) {
44
- if (config.projectNameKebab) {
45
- return config.projectNameKebab;
46
- }
47
- const modulesDir = path.join(projectRoot(), 'modules');
48
- if (fs.existsSync(modulesDir)) {
49
- const match = fs.readdirSync(modulesDir).find((name) => name.endsWith('-database-config'));
50
- if (match) {
51
- return match.replace(/-database-config$/, '');
52
- }
53
- }
54
- return path.basename(projectRoot());
55
- }
56
-
57
- function persistenceLayout(basePackage, { multiModule, projectNameKebab, srcPath }) {
58
- const basePackagePath = basePackage.replace(/\./g, '/');
59
- const src = srcPath || 'src/main/java';
60
- if (multiModule) {
61
- const dbRoot = `modules/${projectNameKebab}-database-config/src/main/java/${basePackagePath}/databaseconfig`;
62
- return {
63
- entityPackage: `${basePackage}.databaseconfig.entity`,
64
- daoPackage: `${basePackage}.databaseconfig.dao`,
65
- entityRoot: `${dbRoot}/entity`,
66
- daoRoot: `${dbRoot}/dao`
67
- };
68
- }
69
- return {
70
- entityPackage: `${basePackage}.entity`,
71
- daoPackage: `${basePackage}.dao`,
72
- entityRoot: `${src}/${basePackagePath}/entity`,
73
- daoRoot: `${src}/${basePackagePath}/dao`
74
- };
75
- }
76
-
77
- function resolvePersistence(config) {
78
- if (config.entityPath && config.daoPath && config.entityPackage && config.daoPackage) {
79
- return {
80
- entityPackage: config.entityPackage,
81
- daoPackage: config.daoPackage,
82
- entityRoot: config.entityPath,
83
- daoRoot: config.daoPath
84
- };
85
- }
86
- return persistenceLayout(config.basePackage, {
87
- multiModule: detectMultiModule(config),
88
- projectNameKebab: inferProjectNameKebab(config),
89
- srcPath: config.srcPath
90
- });
91
- }
92
-
93
- function toKebab(name) {
94
- return String(name || '')
95
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
96
- .replace(/[\s_]+/g, '-')
97
- .replace(/^-+|-+$/g, '')
98
- .toLowerCase();
99
- }
100
-
101
- const JAVA_RESERVED_WORDS = new Set([
102
- 'abstract', 'assert', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class',
103
- 'const', 'continue', 'default', 'do', 'double', 'else', 'enum', 'exports', 'extends',
104
- 'false', 'final', 'finally', 'float', 'for', 'goto', 'if', 'implements', 'import',
105
- 'instanceof', 'int', 'interface', 'long', 'module', 'native', 'new', 'non-sealed',
106
- 'null', 'open', 'opens', 'package', 'permits', 'private', 'protected', 'provides',
107
- 'public', 'record', 'requires', 'return', 'sealed', 'short', 'static', 'strictfp',
108
- 'super', 'switch', 'synchronized', 'this', 'throw', 'throws', 'to', 'transient',
109
- 'transitive', 'true', 'try', 'uses', 'var', 'void', 'volatile', 'while', 'with',
110
- 'yield', '_'
111
- ]);
112
-
113
- const CREATE_DEFAULTS = Object.freeze({
114
- projectName: 'Practice',
115
- basePackage: 'tw.edu.ntub.birc.practice'
116
- });
117
-
118
- function validateProjectName(value) {
119
- const projectName = toKebab(value);
120
- if (!projectName) {
121
- return '專案名稱轉成 kebab-case 後不可為空。';
122
- }
123
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(projectName)) {
124
- return '專案名稱只能包含英文字母、數字、空白、底線或連字號。';
125
- }
126
- return true;
127
- }
128
-
129
- function validateJavaPackage(value) {
130
- const packageName = String(value || '').trim();
131
- if (!packageName) {
132
- return 'Base package 不可為空。';
133
- }
134
- const segments = packageName.split('.');
135
- if (segments.some((segment) => !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment))) {
136
- return 'Base package 的每一段都必須是合法 Java identifier。';
137
- }
138
- if (segments.some((segment) => JAVA_RESERVED_WORDS.has(segment))) {
139
- return 'Base package 不可使用 Java 保留字。';
140
- }
141
- return true;
142
- }
143
-
144
- function validatePascalCase(value) {
145
- return /^[A-Z][A-Za-z0-9]*$/.test(toPascalCaseName(value))
146
- ? true
147
- : '名稱必須是英文字母開頭,例如 Activity 或 activity。';
148
- }
149
-
150
- function validateMigrationName(value) {
151
- const raw = String(value || '').trim();
152
- if (!raw) return '遷移名稱不可為空。';
153
- // 白名單擋掉 SQL 特殊字元與路徑字元:名稱會進 Flyway SQL 與檔名。
154
- return /^[a-zA-Z0-9_]+$/.test(raw)
155
- ? true
156
- : '遷移名稱只能包含英文字母、數字和底線,例如 create_users_table。';
157
- }
158
-
159
- const DEFAULT_STRING_COLUMN_LENGTH = 50;
160
-
161
- function parseFields(fields) {
162
- if (!fields || !String(fields).trim()) {
163
- return [];
164
- }
165
- return String(fields)
166
- .split(',')
167
- .map((f) => {
168
- const [name, type] = f.split(':');
169
- const fieldName = (name || '').trim();
170
- const fieldType = (type || 'String').trim();
171
- return {
172
- name: fieldName,
173
- type: fieldType,
174
- snakeName: fieldName
175
- .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
176
- .replace(/[\s-]+/g, '_')
177
- .toLowerCase(),
178
- columnLength: fieldType === 'String' ? DEFAULT_STRING_COLUMN_LENGTH : null
179
- };
180
- })
181
- .filter((f) => f.name);
182
- }
183
-
184
- function isExample(data) {
185
- return Boolean(data && data.example)
186
- || process.env.BIRC_EXAMPLE === '1'
187
- || parseFields((data && data.fields) || process.env.BIRC_MAKE_FIELDS).length > 0;
188
- }
189
-
190
- function includeMapperWithEntity(data) {
191
- return Boolean(data && (data.dto || data.mapper))
192
- || process.env.BIRC_MAKE_DTO === '1'
193
- || process.env.BIRC_MAKE_MAPPER === '1';
194
- }
195
-
196
- function includeModelMigration(data) {
197
- return Boolean(data && data.migration) || process.env.BIRC_MAKE_MIGRATION === '1';
198
- }
199
-
200
- function includeModelSeed(data) {
201
- return Boolean(data && data.seed) || process.env.BIRC_MAKE_SEED === '1';
202
- }
203
-
204
- function skipExistingMakeFiles(data) {
205
- return includeModelMigration(data) || includeModelSeed(data);
206
- }
207
-
208
- function includeModelController(data) {
209
- return Boolean(data && data.controller) || process.env.BIRC_MAKE_CONTROLLER === '1';
210
- }
211
-
212
- function includeSoftDelete(data) {
213
- return Boolean(data && data.softDelete) || process.env.BIRC_MAKE_SOFT_DELETE === '1';
214
- }
215
-
216
- function toSnakeCase(text) {
217
- return String(text || '')
218
- .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
219
- .replace(/[\s-]+/g, '_')
220
- .toLowerCase();
221
- }
222
-
223
- function tableNameFromEntity(entityName) {
224
- return toSnakeCase(entityName);
225
- }
226
-
227
- const JAVA_SQL_TYPES = Object.freeze({
228
- String: `VARCHAR(${DEFAULT_STRING_COLUMN_LENGTH}) NULL`,
229
- Integer: 'INT NULL',
230
- int: 'INT NULL',
231
- Long: 'BIGINT NULL',
232
- long: 'BIGINT NULL',
233
- Boolean: 'TINYINT(1) NULL',
234
- boolean: 'TINYINT(1) NULL',
235
- BigDecimal: 'DECIMAL(19, 2) NULL',
236
- LocalDate: 'DATE NULL',
237
- LocalDateTime: 'TIMESTAMP NULL',
238
- Instant: 'TIMESTAMP NULL',
239
- Double: 'DOUBLE NULL',
240
- double: 'DOUBLE NULL',
241
- Float: 'FLOAT NULL',
242
- float: 'FLOAT NULL',
243
- Short: 'SMALLINT NULL',
244
- short: 'SMALLINT NULL',
245
- Byte: 'TINYINT NULL',
246
- byte: 'TINYINT NULL'
247
- });
248
-
249
- function sqlTypeForColumn(javaType, columnName) {
250
- if (columnName === 'created_at') {
251
- return 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP';
252
- }
253
- if (columnName === 'updated_at') {
254
- return 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP';
255
- }
256
- if (columnName === 'deleted_at') {
257
- return 'TIMESTAMP NULL';
258
- }
259
- return JAVA_SQL_TYPES[javaType] || null;
260
- }
261
-
262
- function fieldsToMigrationColumns(fields, options) {
263
- const withTimestamps = Boolean(options && options.withTimestamps);
264
- const withSoftDelete = Boolean(options && options.withSoftDelete);
265
- const columns = [];
266
- const seen = new Set();
267
-
268
- function addColumn(name, javaType) {
269
- const columnName = sanitizeMigrationToken(name);
270
- if (!columnName || columnName === 'id' || seen.has(columnName)) {
271
- return;
272
- }
273
- const sqlType = sqlTypeForColumn(javaType, columnName);
274
- if (!sqlType) {
275
- return;
276
- }
277
- seen.add(columnName);
278
- columns.push({ name: columnName, sqlType });
279
- }
280
-
281
- for (const field of fields || []) {
282
- addColumn(field.snakeName || toSnakeCase(field.name), field.type);
283
- }
284
- if (withTimestamps) {
285
- addColumn('created_at', 'LocalDateTime');
286
- addColumn('updated_at', 'LocalDateTime');
287
- }
288
- if (withSoftDelete) {
289
- addColumn('deleted_at', 'LocalDateTime');
290
- }
291
- return columns;
292
- }
293
-
294
- function listJavaFiles(dir, acc = []) {
295
- if (!fs.existsSync(dir)) {
296
- return acc;
297
- }
298
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
299
- const full = path.join(dir, entry.name);
300
- if (entry.isDirectory()) {
301
- listJavaFiles(full, acc);
302
- } else if (entry.isFile() && entry.name.endsWith('.java')) {
303
- acc.push(full);
304
- }
305
- }
306
- return acc;
307
- }
308
-
309
- function parseEntityColumns(source) {
310
- const fields = [];
311
- const fieldRe =
312
- /((?:@\w+(?:\s*\([^)]*\))?\s*)*)private\s+([A-Za-z][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/g;
313
- let match = fieldRe.exec(source);
314
- while (match) {
315
- const annotations = match[1] || '';
316
- const type = match[2];
317
- const name = match[3];
318
- const columnAttr = annotations.match(/@Column\s*\(([^)]*)\)/);
319
- let snakeName = toSnakeCase(name);
320
- if (columnAttr) {
321
- const named = columnAttr[1].match(/name\s*=\s*"([^"]+)"/);
322
- if (named) {
323
- snakeName = named[1];
324
- }
325
- }
326
- fields.push({ name, type, snakeName });
327
- match = fieldRe.exec(source);
328
- }
329
- return {
330
- fields,
331
- withSoftDelete: /@SoftDelete\b/.test(source)
332
- };
333
- }
334
-
335
- function findEntitySourceForTable(tableName, config) {
336
- if (!tableName) {
337
- return null;
338
- }
339
- const persistence = resolvePersistence(config);
340
- const entityDir = path.join(projectRoot(), persistence.entityRoot);
341
- const files = listJavaFiles(entityDir);
342
- const escaped = String(tableName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
343
- const tableRe = new RegExp(`@Table\\s*\\([^)]*name\\s*=\\s*"${escaped}"`, 'i');
344
- for (const file of files) {
345
- const source = fs.readFileSync(file, 'utf8');
346
- if (tableRe.test(source)) {
347
- return source;
348
- }
349
- }
350
- const className = toPascalCaseName(String(tableName).toLowerCase());
351
- if (!className) {
352
- return null;
353
- }
354
- const match = files.find((file) => path.basename(file, '.java') === className);
355
- if (!match) {
356
- return null;
357
- }
358
- return fs.readFileSync(match, 'utf8');
359
- }
360
-
361
- function javaLiteralForSeed(javaType) {
362
- if (javaType === 'String') {
363
- return '"example"';
364
- }
365
- if (javaType === 'LocalDate') {
366
- return 'LocalDate.parse("2020-01-01")';
367
- }
368
- if (javaType === 'LocalDateTime') {
369
- return 'LocalDateTime.parse("2020-01-01T00:00:00")';
370
- }
371
- if (javaType === 'Instant') {
372
- return 'Instant.parse("2020-01-01T00:00:00Z")';
373
- }
374
- if (javaType === 'BigDecimal') {
375
- return 'new BigDecimal("0")';
376
- }
377
- if (javaType === 'Boolean' || javaType === 'boolean') {
378
- return 'false';
379
- }
380
- if (javaType === 'Long' || javaType === 'long') {
381
- return '0L';
382
- }
383
- if (javaType === 'Integer' || javaType === 'int') {
384
- return '0';
385
- }
386
- if (javaType === 'Short' || javaType === 'short') {
387
- return '(short) 0';
388
- }
389
- if (javaType === 'Byte' || javaType === 'byte') {
390
- return '(byte) 0';
391
- }
392
- if (javaType === 'Double' || javaType === 'double') {
393
- return '0D';
394
- }
395
- if (javaType === 'Float' || javaType === 'float') {
396
- return '0F';
397
- }
398
- if (JAVA_SQL_TYPES[javaType]) {
399
- return '0';
400
- }
401
- return null;
402
- }
403
-
404
- const SKIP_SEED_FIELDS = new Set(['id', 'createdAt', 'updatedAt', 'deletedAt', 'created_at', 'updated_at', 'deleted_at']);
405
-
406
- function fieldsToSeedFields(fields) {
407
- const result = [];
408
- const seen = new Set();
409
-
410
- for (const field of fields || []) {
411
- const name = String(field.name || '').trim();
412
- const snakeName = field.snakeName || toSnakeCase(name);
413
- if (!name || SKIP_SEED_FIELDS.has(name) || SKIP_SEED_FIELDS.has(snakeName) || seen.has(name)) {
414
- continue;
415
- }
416
- const literal = javaLiteralForSeed(field.type);
417
- if (!literal) {
418
- continue;
419
- }
420
- seen.add(name);
421
- result.push({
422
- name,
423
- type: field.type,
424
- setter: `set${name.charAt(0).toUpperCase()}${name.slice(1)}`,
425
- literal
426
- });
427
- }
428
- return result;
429
- }
430
-
431
- function resolveSeedFields(data, tableName, config) {
432
- const explicitFields = parseFields(data.fields || process.env.BIRC_MAKE_FIELDS);
433
- if (explicitFields.length > 0) {
434
- return fieldsToSeedFields(explicitFields);
435
- }
436
- const source = findEntitySourceForTable(tableName, config);
437
- if (source) {
438
- return fieldsToSeedFields(parseEntityColumns(source).fields);
439
- }
440
- if (isExample(data)) {
441
- return fieldsToSeedFields([{ name: 'name', type: 'String', snakeName: 'name' }]);
442
- }
443
- return [];
444
- }
445
-
446
- function entityNameForSeeder(data) {
447
- const raw = toPascalCaseName((data && data.entityName) || '');
448
- return raw.replace(/Seeder$/, '') || raw;
449
- }
450
-
451
- function resolveCreateTableColumns(data, tableName, config) {
452
- const example = isExample(data);
453
- const flaggedSoftDelete = includeSoftDelete(data);
454
- const explicitFields = parseFields(data.fields || process.env.BIRC_MAKE_FIELDS);
455
- if (explicitFields.length > 0) {
456
- return fieldsToMigrationColumns(explicitFields, {
457
- withTimestamps: example,
458
- withSoftDelete: flaggedSoftDelete
459
- });
460
- }
461
- const source = findEntitySourceForTable(tableName, config);
462
- if (source) {
463
- const entity = parseEntityColumns(source);
464
- return fieldsToMigrationColumns(entity.fields, {
465
- withTimestamps: false,
466
- withSoftDelete: flaggedSoftDelete || entity.withSoftDelete
467
- });
468
- }
469
- if (example) {
470
- return fieldsToMigrationColumns(
471
- [{ name: 'name', type: 'String', snakeName: 'name' }],
472
- { withTimestamps: true, withSoftDelete: flaggedSoftDelete }
473
- );
474
- }
475
- return fieldsToMigrationColumns([], {
476
- withTimestamps: false,
477
- withSoftDelete: flaggedSoftDelete
478
- });
479
- }
480
-
481
- function fieldImports(fields) {
482
- const types = new Set((fields || []).map((field) => field.type));
483
- const imports = [];
484
- if (types.has('LocalDateTime')) imports.push('java.time.LocalDateTime');
485
- if (types.has('LocalDate')) imports.push('java.time.LocalDate');
486
- if (types.has('Instant')) imports.push('java.time.Instant');
487
- if (types.has('BigDecimal')) imports.push('java.math.BigDecimal');
488
- return imports;
489
- }
490
-
491
- function resolveMakeFields(data) {
492
- const fields = parseFields(data.fields || process.env.BIRC_MAKE_FIELDS);
493
- if (fields.length > 0) {
494
- return fields;
495
- }
496
- if (isExample(data)) {
497
- return [{
498
- name: 'name',
499
- type: 'String',
500
- snakeName: 'name',
501
- columnLength: DEFAULT_STRING_COLUMN_LENGTH
502
- }];
503
- }
504
- return [];
505
- }
506
-
507
- // 最後防線:prompt 驗證被繞過時(CLI 直呼、程式化呼叫),輸出值也不得
508
- // 帶 SQL 特殊字元或路徑字元——tableName/columnName 進 Flyway SQL,
509
- // description 同時進檔名。
510
- function sanitizeMigrationToken(value) {
511
- return String(value || '').replace(/[^a-zA-Z0-9_]/g, '');
512
- }
513
-
514
- function parseMigrationName(name) {
515
- const raw = String(name || '').trim();
516
- let match = raw.match(/^create_(.+)_table$/i);
517
- if (match) {
518
- return {
519
- kind: 'create',
520
- tableName: sanitizeMigrationToken(match[1]),
521
- columnName: null,
522
- description: sanitizeMigrationToken(raw) || 'migration'
523
- };
524
- }
525
- match = raw.match(/^add_(.+)_to_(.+)_table$/i);
526
- if (match) {
527
- return {
528
- kind: 'add',
529
- columnName: sanitizeMigrationToken(match[1]),
530
- tableName: sanitizeMigrationToken(match[2]),
531
- description: sanitizeMigrationToken(raw) || 'migration'
532
- };
533
- }
534
- match = raw.match(/^remove_(.+)_from_(.+)_table$/i);
535
- if (match) {
536
- return {
537
- kind: 'remove',
538
- columnName: sanitizeMigrationToken(match[1]),
539
- tableName: sanitizeMigrationToken(match[2]),
540
- description: sanitizeMigrationToken(raw) || 'migration'
541
- };
542
- }
543
- const customDescription = sanitizeMigrationToken(raw);
544
- return { kind: 'custom', tableName: null, columnName: null, description: customDescription || 'migration' };
545
- }
546
-
547
- function nextMigrationVersion(migrationDir) {
548
- if (!fs.existsSync(migrationDir)) {
549
- return 1;
550
- }
551
- const versions = fs
552
- .readdirSync(migrationDir)
553
- .filter((file) => file.startsWith('V') && file.endsWith('.sql'))
554
- .map((file) => {
555
- const match = file.match(/^V(\d+)__/);
556
- return match ? parseInt(match[1], 10) : 0;
557
- });
558
- return versions.length ? Math.max(...versions) + 1 : 1;
559
- }
560
-
561
- function migrationTemplate(kind) {
562
- if (kind === 'create') return 'templates/migration/create-table.hbs';
563
- if (kind === 'add') return 'templates/migration/add-column.hbs';
564
- if (kind === 'remove') return 'templates/migration/remove-column.hbs';
565
- return 'templates/migration/custom.hbs';
566
- }
567
-
568
- function makeContext(data) {
569
- if (data && data.entityName) {
570
- data.entityName = toPascalCaseName(data.entityName);
571
- }
572
- const config = loadProjectConfig();
573
- const persistence = resolvePersistence(config);
574
- const basePackage = config.basePackage;
575
- const srcPath = config.srcPath || 'src/main/java';
576
- const root = `${srcPath}/${basePackage.replace(/\./g, '/')}`;
577
- const fields = resolveMakeFields(data);
578
- const example = isExample(data);
579
- const extraImports = fieldImports(fields);
580
- const entityImports = extraImports.includes('java.time.LocalDateTime') || !example
581
- ? extraImports
582
- : extraImports.concat('java.time.LocalDateTime');
583
- return {
584
- config,
585
- persistence,
586
- root,
587
- gradlePath: config.buildGradlePath || 'build.gradle',
588
- templateData: {
589
- ...VERSIONS,
590
- basePackage,
591
- makeFields: fields,
592
- extraImports,
593
- entityImports,
594
- withExample: example,
595
- withSoftDelete: includeSoftDelete(data),
596
- entityPackage: persistence.entityPackage,
597
- daoPackage: persistence.daoPackage
598
- }
599
- };
600
- }
601
-
602
- function exceptionClassName(name) {
603
- const base = toPascalCaseName(name);
604
- return /Exception$/i.test(base) ? base.replace(/exception$/i, 'Exception') : `${base}Exception`;
605
- }
606
-
607
- function exceptionSkeletonActions(root, templateData) {
608
- return [
609
- { type: 'add', path: `${root}/exception/ProjectException.java`, templateFile: 'templates/exception/ProjectException.hbs', data: templateData },
610
- { type: 'add', path: `${root}/exception/NotFoundException.java`, templateFile: 'templates/exception/NotFoundException.hbs', data: templateData },
611
- { type: 'add', path: `${root}/controller/ExceptionHandleController.java`, templateFile: 'templates/exception/ExceptionHandleController.hbs', data: templateData },
612
- { type: 'add', path: `${root}/web/Result.java`, templateFile: 'templates/base/Result.hbs', data: templateData }
613
- ];
614
- }
615
-
616
- function serviceSkeletonActions(root, templateData) {
617
- return [
618
- { type: 'add', path: `${root}/mapper/EntityMapper.java`, templateFile: 'templates/base/EntityMapper.hbs', data: templateData },
619
- { type: 'add', path: `${root}/service/BaseService.java`, templateFile: 'templates/base/BaseService.hbs', data: templateData },
620
- { type: 'add', path: `${root}/service/impl/BaseServiceImpl.java`, templateFile: 'templates/base/BaseServiceImpl.hbs', data: templateData }
621
- ];
622
- }
623
-
624
- function seederSkeletonActions(root, templateData, options) {
625
- const skipIfExists = Boolean(options && options.skipIfExists);
626
- return [
627
- { type: 'add', path: `${root}/seeder/Seeder.java`, templateFile: 'templates/seeder/Seeder.hbs', data: templateData, skipIfExists },
628
- { type: 'add', path: `${root}/seeder/SeedRunner.java`, templateFile: 'templates/seeder/SeedRunner.hbs', data: templateData, skipIfExists }
629
- ];
630
- }
631
-
632
- function uniqueFeatureKeys(features) {
633
- return [...new Set((features || []).filter((featureKey) => FEATURES[featureKey]))];
634
- }
635
-
636
- const YML_ANCHOR = '# birc-generator:config-anchor';
637
- const GRADLE_ANCHOR = '// birc-generator:dependency-anchor';
638
- const GRADLE_PLUGIN_ANCHOR = '// birc-generator:plugin-anchor';
639
- const GRADLE_ALLPROJECTS_ANCHOR = '// birc-generator:allprojects-anchor';
640
-
641
- function requireGradleAnchor(errors, featureKey, destBase, gradlePath, anchor) {
642
- const target = path.resolve(destBase, gradlePath);
643
- if (!fs.existsSync(target)) {
644
- errors.push(`${featureKey}: 找不到 ${gradlePath}`);
645
- } else if (!fs.readFileSync(target, 'utf8').includes(anchor)) {
646
- errors.push(`${featureKey}: ${gradlePath} 沒有 ${anchor}`);
647
- }
648
- }
649
-
650
- function gradleAnchorPattern(anchor) {
651
- return new RegExp(`${anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n`);
652
- }
653
-
654
- function isAddForce(data) {
655
- return Boolean(data && data.force) || process.env.BIRC_ADD_FORCE === '1';
656
- }
657
-
658
- function isAddSync(data) {
659
- return Boolean(data && data.sync) || process.env.BIRC_ADD_SYNC === '1';
660
- }
661
-
662
- function preflightAdd(featureKeys, { destBase, ymlPath, gradlePath }) {
663
- const errors = [];
664
- for (const featureKey of featureKeys) {
665
- const feature = FEATURES[featureKey];
666
- if (feature.ymlBlock) {
667
- const target = path.resolve(destBase, ymlPath);
668
- if (!fs.existsSync(target)) {
669
- errors.push(`${featureKey}: 找不到 ${ymlPath}`);
670
- } else if (!fs.readFileSync(target, 'utf8').includes(YML_ANCHOR)) {
671
- errors.push(`${featureKey}: ${ymlPath} 沒有 ${YML_ANCHOR}`);
672
- }
673
- }
674
- if (feature.gradleDep) {
675
- requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_ANCHOR);
676
- }
677
- if (feature.gradlePlugin) {
678
- requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_PLUGIN_ANCHOR);
679
- }
680
- if (feature.gradleAllprojects) {
681
- requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_ALLPROJECTS_ANCHOR);
682
- }
683
- }
684
- if (errors.length > 0) {
685
- throw new Error(`add 預檢查失敗,沒有寫任何檔:\n${errors.map((line) => ` ${line}`).join('\n')}`);
686
- }
687
- }
688
-
689
- function buildAddActions(data) {
690
- const force = isAddForce(data);
691
- const syncAll = isAddSync(data);
692
- const config = loadProjectConfig();
693
- const installed = new Set(config.features || []);
694
- const selected = syncAll
695
- ? uniqueFeatureKeys(config.features)
696
- : uniqueFeatureKeys(data.features);
697
- const featuresToAdd = selected.filter((featureKey) => force || syncAll || !installed.has(featureKey));
698
- if (featuresToAdd.length === 0) {
699
- return [];
700
- }
701
-
702
- const basePackage = config.basePackage;
703
- const basePackagePath = basePackage.replace(/\./g, '/');
704
- const srcPath = config.srcPath || 'src/main/java';
705
- const resourcesPath = config.resourcesPath || 'src/main/resources';
706
- const ymlPath = config.applicationYmlPath || `${resourcesPath}/application.yml`;
707
- const gradlePath = config.buildGradlePath || 'build.gradle';
708
- const root = `${srcPath}/${basePackagePath}`;
709
- const destBase = projectRoot();
710
-
711
- preflightAdd(featuresToAdd, { destBase, ymlPath, gradlePath });
712
-
713
- const templateData = {
714
- ...VERSIONS,
715
- basePackage,
716
- projectName: config.projectNameKebab || inferProjectNameKebab(config),
717
- projectNameKebab: config.projectNameKebab || inferProjectNameKebab(config),
718
- gitlabProjectPath: data.gitlabProjectPath || '',
719
- dbRootPassword: randomDbPassword(),
720
- dbPassword: randomDbPassword()
721
- };
722
-
723
- let actions = [];
724
- for (const featureKey of featuresToAdd) {
725
- const reinstall = installed.has(featureKey);
726
- actions = actions.concat(
727
- featureActions(featureKey, {
728
- root,
729
- resourcesRoot: resourcesPath,
730
- projectRoot: '.',
731
- ymlPath,
732
- gradlePath,
733
- templateData,
734
- overwriteFiles: force || syncAll,
735
- skipConfigFragments: reinstall && (force || syncAll)
736
- })
737
- );
738
- }
739
-
740
- actions.push({ type: 'updateProjectFeatures', features: featuresToAdd });
741
- return actions;
742
- }
743
-
744
- // 每個 feature 對應:要加哪些檔案、要往 application.yml / build.gradle 的哪個 anchor 插入什麼
745
- const FEATURES = {
746
- email: {
747
- label: 'Email(JavaMailSender + Thymeleaf 模板)',
748
- files: [
749
- { templateFile: 'templates/email/EmailService.hbs', dest: 'service/EmailService.java' },
750
- { templateFile: 'templates/email/EmailServiceImpl.hbs', dest: 'service/impl/EmailServiceImpl.java' },
751
- { templateFile: 'templates/email/EmailAsyncConfig.hbs', dest: 'config/EmailAsyncConfig.java' }
752
- ],
753
- resourceFiles: [
754
- { templateFile: 'templates/email/sample-template.hbs', dest: 'email/sample.html' }
755
- ],
756
- ymlBlock: 'templates/email/application-yml-block.hbs',
757
- gradleDep: 'templates/email/build-gradle-dep.hbs'
758
- },
759
- sso: {
760
- label: 'SSO(校內單一登入,SsoProperties + AutoConfiguration 原始碼)',
761
- files: [
762
- { templateFile: 'templates/sso/SsoProperties.hbs', dest: 'security/SsoProperties.java' },
763
- { templateFile: 'templates/sso/SsoAutoConfiguration.hbs', dest: 'config/SsoAutoConfiguration.java' }
764
- ],
765
- resourceFiles: [],
766
- ymlBlock: 'templates/sso/application-yml-block.hbs',
767
- gradleDep: null
768
- },
769
- oauth: {
770
- label: 'OAuth2 登入(預設範例接 Google,其他 provider 自行調整)',
771
- files: [
772
- { templateFile: 'templates/oauth/OAuth2LoginSuccessHandler.hbs', dest: 'config/OAuth2LoginSuccessHandler.java' }
773
- ],
774
- resourceFiles: [],
775
- ymlBlock: 'templates/oauth/application-yml-block.hbs',
776
- gradleDep: 'templates/oauth/build-gradle-dep.hbs'
777
- },
778
- scheduling: {
779
- label: '排程(Scheduling Tasks,@EnableScheduling + 範例 Job)',
780
- files: [
781
- { templateFile: 'templates/scheduling/SchedulingConfig.hbs', dest: 'config/SchedulingConfig.java' },
782
- { templateFile: 'templates/scheduling/SampleSchedule.hbs', dest: 'schedule/SampleSchedule.java' }
783
- ],
784
- resourceFiles: [],
785
- ymlBlock: 'templates/scheduling/application-yml-block.hbs',
786
- gradleDep: null
787
- },
788
- aop: {
789
- label: 'AOP 事件記錄(自訂 @OperationLog 註解 + Aspect)',
790
- files: [
791
- { templateFile: 'templates/aop/OperationLog.hbs', dest: 'annotation/OperationLog.java' },
792
- { templateFile: 'templates/aop/OperationLogAspect.hbs', dest: 'aspect/OperationLogAspect.java' }
793
- ],
794
- resourceFiles: [],
795
- ymlBlock: null,
796
- gradleDep: 'templates/aop/build-gradle-dep.hbs'
797
- },
798
- client: {
799
- label: 'Client(呼叫外部 API 的 RestClient 封裝)',
800
- files: [
801
- { templateFile: 'templates/client/ExternalApiProperties.hbs', dest: 'config/ExternalApiProperties.java' },
802
- { templateFile: 'templates/client/ExternalApiClientConfig.hbs', dest: 'config/ExternalApiClientConfig.java' },
803
- { templateFile: 'templates/client/ExternalApiClient.hbs', dest: 'client/ExternalApiClient.java' }
804
- ],
805
- resourceFiles: [],
806
- ymlBlock: 'templates/client/application-yml-block.hbs',
807
- gradleDep: null
808
- },
809
- sentry: {
810
- label: 'Sentry(錯誤追蹤 + 客戶端例外過濾)',
811
- files: [
812
- { templateFile: 'templates/sentry/SentryConfig.hbs', dest: 'config/SentryConfig.java' }
813
- ],
814
- resourceFiles: [],
815
- ymlBlock: 'templates/sentry/application-yml-block.hbs',
816
- gradleDep: 'templates/sentry/build-gradle-dep.hbs'
817
- },
818
- log4j2: {
819
- label: 'Log4j2(主控台 + rolling file + MDC)',
820
- files: [],
821
- resourceFiles: [
822
- { templateFile: 'templates/log4j2/log4j2-spring.xml.hbs', dest: 'log4j2-spring.xml' }
823
- ],
824
- ymlBlock: null,
825
- gradleDep: 'templates/log4j2/build-gradle-dep.hbs'
826
- },
827
- openapi: {
828
- label: 'OpenAPI(springdoc + Swagger UI + Bearer JWT)',
829
- files: [
830
- { templateFile: 'templates/openapi/OpenApiConfig.hbs', dest: 'config/OpenApiConfig.java' }
831
- ],
832
- resourceFiles: [],
833
- ymlBlock: null,
834
- gradleDep: 'templates/openapi/build-gradle-dep.hbs'
835
- },
836
- fileUpload: {
837
- label: '檔案上傳(本機磁碟儲存,可自行改接 S3 / MinIO)',
838
- files: [
839
- { templateFile: 'templates/file-upload/FileStorageProperties.hbs', dest: 'config/FileStorageProperties.java' },
840
- { templateFile: 'templates/file-upload/FileStorageService.hbs', dest: 'service/FileStorageService.java' },
841
- { templateFile: 'templates/file-upload/FileStorageServiceImpl.hbs', dest: 'service/impl/FileStorageServiceImpl.java' },
842
- { templateFile: 'templates/file-upload/FileUploadController.hbs', dest: 'controller/FileUploadController.java' },
843
- { templateFile: 'templates/file-upload/FileUtils.hbs', dest: 'util/file/FileUtils.java' },
844
- { templateFile: 'templates/file-upload/FileExtensionUtils.hbs', dest: 'util/file/FileExtensionUtils.java' },
845
- { templateFile: 'templates/file-upload/EmptyFileException.hbs', dest: 'exception/file/EmptyFileException.java' },
846
- { templateFile: 'templates/file-upload/FileExtensionIllegalException.hbs', dest: 'exception/file/FileExtensionIllegalException.java' },
847
- { templateFile: 'templates/file-upload/InvalidStoredFileException.hbs', dest: 'exception/file/InvalidStoredFileException.java' },
848
- { templateFile: 'templates/file-upload/FileTooLargeException.hbs', dest: 'exception/file/FileTooLargeException.java' }
849
- ],
850
- resourceFiles: [],
851
- ymlBlock: 'templates/file-upload/application-yml-block.hbs',
852
- gradleDep: null
853
- },
854
- pagination: {
855
- label: '分頁查詢(Specification 動態條件 + PageRequest/PageResponse)',
856
- files: [
857
- { templateFile: 'templates/pagination/SearchCriteria.hbs', dest: 'specification/SearchCriteria.java' },
858
- { templateFile: 'templates/pagination/GenericSpecification.hbs', dest: 'specification/GenericSpecification.java' },
859
- { templateFile: 'templates/pagination/SpecificationSupport.hbs', dest: 'specification/SpecificationSupport.java' },
860
- { templateFile: 'templates/pagination/PageRequest.hbs', dest: 'dto/PageRequest.java' },
861
- { templateFile: 'templates/pagination/PageResponse.hbs', dest: 'dto/PageResponse.java' },
862
- { templateFile: 'templates/pagination/Pager.hbs', dest: 'dto/Pager.java' },
863
- { templateFile: 'templates/pagination/PageInfo.hbs', dest: 'dto/PageInfo.java' }
864
- ],
865
- resourceFiles: [],
866
- ymlBlock: null,
867
- gradleDep: null
868
- },
869
- validGroup: {
870
- label: '驗證群組(ValidGroup:Create / Update / Delete / Submit)',
871
- files: [
872
- { templateFile: 'templates/valid-group/ValidGroup.hbs', dest: 'validation/ValidGroup.java' }
873
- ],
874
- resourceFiles: [],
875
- ymlBlock: null,
876
- gradleDep: null
877
- },
878
- permission: {
879
- label: '權限控管(Spring Security + 自訂 @RequirePermission)',
880
- files: [
881
- { templateFile: 'templates/permission/RequirePermission.hbs', dest: 'annotation/RequirePermission.java' },
882
- { templateFile: 'templates/permission/PermissionAspect.hbs', dest: 'aspect/PermissionAspect.java' }
883
- ],
884
- resourceFiles: [],
885
- ymlBlock: null,
886
- gradleDep: 'templates/permission/build-gradle-dep.hbs'
887
- },
888
- gitlabCi: {
889
- label: '.gitlab-ci.yml(Harbor build/push + SSH deploy,對齊 teaching-platform)',
890
- files: [],
891
- resourceFiles: [],
892
- rootFiles: [
893
- { templateFile: 'templates/gitlab-ci/gitlab-ci.hbs', dest: '.gitlab-ci.yml' }
894
- ],
895
- ymlBlock: null,
896
- gradleDep: null
897
- },
898
- docker: {
899
- label: 'Docker(多階段 Dockerfile + compose + .env.example)',
900
- files: [],
901
- resourceFiles: [],
902
- rootFiles: [
903
- { templateFile: 'templates/docker/Dockerfile.hbs', dest: 'Dockerfile' },
904
- { templateFile: 'templates/docker/docker-compose.yml.hbs', dest: 'docker-compose.yml' },
905
- { templateFile: 'templates/docker/docker-compose.prod.yml.hbs', dest: 'docker-compose.prod.yml' },
906
- { templateFile: 'templates/docker/env.example.hbs', dest: '.env.example' },
907
- { templateFile: 'templates/docker/dockerignore.hbs', dest: '.dockerignore' }
908
- ],
909
- ymlBlock: null,
910
- gradleDep: null
911
- },
912
- spotless: {
913
- label: 'Spotless(Eclipse 4.31 格式化,commit 前 ./gradlew spotlessApply)',
914
- files: [],
915
- resourceFiles: [],
916
- rootFiles: [
917
- { templateFile: 'templates/spotless/spotless_formatter.xml', dest: 'spotless_formatter.xml' }
918
- ],
919
- ymlBlock: null,
920
- gradleDep: null,
921
- gradlePlugin: 'templates/spotless/build-gradle-plugin.hbs',
922
- gradleAllprojects: 'templates/spotless/build-gradle-allprojects.hbs'
923
- }
924
- };
925
-
926
- const FEATURE_GROUPS = [
927
- {
928
- title: '常用',
929
- keys: ['docker', 'log4j2', 'openapi', 'spotless', 'validGroup', 'pagination', 'fileUpload', 'email', 'client', 'aop']
930
- },
931
- { title: '登入', keys: ['sso', 'oauth', 'permission'] },
932
- { title: '營運', keys: ['gitlabCi', 'sentry', 'scheduling'] }
933
- ];
934
- const DEFAULT_FEATURES = new Set(['docker', 'log4j2', 'validGroup', 'spotless']);
935
-
936
- function groupedFeatureChoices({
937
- installed = new Set(),
938
- allowInstalled = false,
939
- withDefaults = false
940
- } = {}) {
941
- const used = new Set();
942
- const choices = [];
943
- for (const group of FEATURE_GROUPS) {
944
- const keys = group.keys.filter((key) => FEATURES[key]);
945
- if (keys.length === 0) continue;
946
- choices.push({ type: 'separator', line: `── ${group.title} ──` });
947
- for (const key of keys) {
948
- used.add(key);
949
- choices.push({
950
- name: FEATURES[key].label,
951
- value: key,
952
- checked: withDefaults && DEFAULT_FEATURES.has(key),
953
- disabled: !allowInstalled && installed.has(key) ? '已安裝' : false
954
- });
955
- }
956
- }
957
- const leftover = Object.keys(FEATURES).filter((key) => !used.has(key));
958
- if (leftover.length > 0) {
959
- choices.push({ type: 'separator', line: '── 其他 ──' });
960
- for (const key of leftover) {
961
- choices.push({
962
- name: FEATURES[key].label,
963
- value: key,
964
- checked: withDefaults && DEFAULT_FEATURES.has(key),
965
- disabled: !allowInstalled && installed.has(key) ? '已安裝' : false
966
- });
967
- }
968
- }
969
- return choices;
970
- }
971
-
972
- // 產生單一 feature 的 plop actions。root / resourcesRoot / ymlPath / gradlePath 都是絕對相對於
973
- // plop destBasePath 的路徑,create 跟 add 共用同一份邏輯,差別只在 root 前面有沒有專案資料夾。
974
- function featureActions(featureKey, {
975
- root,
976
- resourcesRoot,
977
- projectRoot,
978
- ymlPath,
979
- gradlePath,
980
- templateData,
981
- overwriteFiles = false,
982
- skipConfigFragments = false
983
- }) {
984
- const feature = FEATURES[featureKey];
985
- if (!feature) {
986
- throw new Error(`未知的 feature: ${featureKey}`);
987
- }
988
- const actions = [];
989
-
990
- for (const f of feature.files) {
991
- actions.push({
992
- type: 'add',
993
- path: `${root}/${f.dest}`,
994
- templateFile: f.templateFile,
995
- data: templateData,
996
- skipIfExists: !overwriteFiles,
997
- abortOnFail: true
998
- });
999
- }
1000
-
1001
- for (const f of feature.resourceFiles) {
1002
- actions.push({
1003
- type: 'add',
1004
- path: `${resourcesRoot}/${f.dest}`,
1005
- templateFile: f.templateFile,
1006
- data: templateData,
1007
- skipIfExists: !overwriteFiles,
1008
- abortOnFail: true
1009
- });
1010
- }
1011
-
1012
- // rootFiles:不屬於任何 package、也不進 resources 的檔案(.gitlab-ci.yml、Dockerfile 之類),
1013
- // 直接放到專案根目錄(create 是新資料夾根目錄,add 是目前目錄)。
1014
- if (feature.rootFiles) {
1015
- for (const f of feature.rootFiles) {
1016
- actions.push({
1017
- type: 'add',
1018
- path: `${projectRoot}/${f.dest}`,
1019
- templateFile: f.templateFile,
1020
- data: templateData,
1021
- skipIfExists: !overwriteFiles,
1022
- abortOnFail: true
1023
- });
1024
- }
1025
- }
1026
-
1027
- if (feature.ymlBlock && !skipConfigFragments) {
1028
- actions.push({
1029
- type: 'appendFragment',
1030
- path: ymlPath,
1031
- pattern: new RegExp(`${YML_ANCHOR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n`),
1032
- templateFile: feature.ymlBlock,
1033
- data: templateData,
1034
- abortOnFail: true
1035
- });
1036
- }
1037
-
1038
- const gradleFragments = [
1039
- [feature.gradleDep, GRADLE_ANCHOR],
1040
- [feature.gradlePlugin, GRADLE_PLUGIN_ANCHOR],
1041
- [feature.gradleAllprojects, GRADLE_ALLPROJECTS_ANCHOR]
1042
- ];
1043
- for (const [templateFile, anchor] of gradleFragments) {
1044
- if (templateFile && !skipConfigFragments) {
1045
- actions.push({
1046
- type: 'appendFragment',
1047
- path: gradlePath,
1048
- pattern: gradleAnchorPattern(anchor),
1049
- templateFile,
1050
- data: templateData,
1051
- abortOnFail: true
1052
- });
1053
- }
1054
- }
1055
-
1056
- return actions;
1057
- }
3
+ const { deriveBasePackage, toPascalCaseName, createDestConflict } = require('./bin/cli-util');
4
+ const {
5
+ CREATE_DEFAULTS,
6
+ validateProjectName,
7
+ validateJavaPackage,
8
+ validatePascalCase,
9
+ validateMigrationName,
10
+ loadProjectConfig,
11
+ inferProjectNameKebab,
12
+ fillJwtSecretInEnv,
13
+ randomJwtSecret,
14
+ fragmentMarkers
15
+ } = require('./lib/project');
16
+ const {
17
+ FEATURES,
18
+ FEATURE_GROUPS,
19
+ DEFAULT_FEATURES,
20
+ groupedFeatureChoices,
21
+ isAddForce,
22
+ isAddSync,
23
+ buildAddActions
24
+ } = require('./lib/features');
25
+ const {
26
+ isExample,
27
+ parseMigrationName,
28
+ makeEntityActions,
29
+ makeMapperActions,
30
+ makeServiceActions,
31
+ makeControllerActions,
32
+ makeStackActions,
33
+ makeModelActions,
34
+ makeMigrationActions,
35
+ makeSeederActions,
36
+ appendMakeSqlActions,
37
+ toSnakeCase,
38
+ includeMapperWithEntity,
39
+ skipExistingMakeFiles,
40
+ makeContext,
41
+ exceptionClassName
42
+ } = require('./lib/make');
43
+ const { buildCreateActions } = require('./lib/create');
1058
44
 
1059
45
  module.exports = function (plop) {
1060
46
  plop.setHelper('pascalCase', (text) => text.charAt(0).toUpperCase() + text.slice(1));
@@ -1087,6 +73,12 @@ module.exports = function (plop) {
1087
73
  return 'dest 可用';
1088
74
  });
1089
75
 
76
+ plop.setActionType('ensureJwtSecret', function (answers, config, plopApi) {
77
+ const destBase = plopApi.getDestBasePath();
78
+ const target = path.resolve(destBase, plopApi.renderString(config.path, answers));
79
+ return fillJwtSecretInEnv(target, randomJwtSecret());
80
+ });
81
+
1090
82
  plop.setActionType('copyIfExists', function (answers, config, plopApi) {
1091
83
  const destBase = plopApi.getDestBasePath();
1092
84
  const from = path.resolve(destBase, plopApi.renderString(config.from, answers));
@@ -1107,8 +99,9 @@ module.exports = function (plop) {
1107
99
  const templateData = { ...answers, ...(config.data || {}) };
1108
100
  const fragment = plopApi.renderString(fs.readFileSync(templatePath, 'utf8'), templateData).trim();
1109
101
  const contents = fs.readFileSync(target, 'utf8');
102
+ const { begin, end } = fragmentMarkers(config.commentToken, config.marker);
1110
103
 
1111
- if (contents.includes(fragment)) {
104
+ if (contents.includes(begin) || contents.includes(fragment)) {
1112
105
  return `skipped (already installed ${path.relative(destBase, target)})`;
1113
106
  }
1114
107
 
@@ -1117,7 +110,8 @@ module.exports = function (plop) {
1117
110
  throw new Error(`找不到插入 anchor:${path.relative(destBase, target)}`);
1118
111
  }
1119
112
 
1120
- const next = contents.replace(config.pattern, (anchor) => `${anchor}\n${fragment}\n`);
113
+ const block = `${begin}\n${fragment}\n${end}`;
114
+ const next = contents.replace(config.pattern, (anchor) => `${anchor}\n${block}\n`);
1121
115
  fs.writeFileSync(target, next);
1122
116
  return path.relative(destBase, target);
1123
117
  });
@@ -1151,67 +145,14 @@ module.exports = function (plop) {
1151
145
  when: (answers) => isExample(answers)
1152
146
  && !String((answers && answers.fields) || process.env.BIRC_MAKE_FIELDS || '').trim()
1153
147
  };
1154
-
1155
- function makeEntityActions(data, options) {
1156
- const { persistence, templateData } = makeContext(data);
1157
- const skipIfExists = Boolean(options && options.skipIfExists);
1158
- return [
1159
- { type: 'add', path: `${persistence.entityRoot}/{{pascalCase entityName}}.java`, templateFile: 'templates/entity.hbs', data: templateData, skipIfExists },
1160
- { type: 'add', path: `${persistence.daoRoot}/{{pascalCase entityName}}DAO.java`, templateFile: 'templates/dao.hbs', data: templateData, skipIfExists }
1161
- ];
1162
- }
1163
-
1164
- function makeMapperActions(data, options) {
1165
- const { root, gradlePath, templateData } = makeContext(data);
1166
- const skipIfExists = Boolean(options && options.skipIfExists);
1167
- return [
1168
- { type: 'add', path: `${root}/dto/{{pascalCase entityName}}CreateRequest.java`, templateFile: 'templates/dto-request.hbs', data: templateData, skipIfExists },
1169
- { type: 'add', path: `${root}/dto/{{pascalCase entityName}}Response.java`, templateFile: 'templates/dto-response.hbs', data: templateData, skipIfExists },
1170
- { type: 'add', path: `${root}/mapper/{{pascalCase entityName}}Mapper.java`, templateFile: 'templates/mapper.hbs', data: templateData, skipIfExists },
1171
- {
1172
- type: 'appendFragment',
1173
- path: gradlePath,
1174
- pattern: /\/\/ birc-generator:dependency-anchor\n/,
1175
- templateFile: 'templates/mapper-gradle-dep.hbs',
1176
- data: templateData,
1177
- abortOnFail: true
1178
- }
1179
- ];
1180
- }
1181
-
1182
- function makeServiceActions(data, options) {
1183
- const { root, templateData } = makeContext(data);
1184
- const skipIfExists = Boolean(options && options.skipIfExists);
1185
- return [
1186
- { type: 'add', path: `${root}/service/{{pascalCase entityName}}Service.java`, templateFile: 'templates/service.hbs', data: templateData, skipIfExists },
1187
- { type: 'add', path: `${root}/service/impl/{{pascalCase entityName}}ServiceImpl.java`, templateFile: 'templates/serviceImpl.hbs', data: templateData, skipIfExists }
1188
- ];
1189
- }
1190
-
1191
- function makeControllerActions(data, options) {
1192
- const { root, templateData } = makeContext(data);
1193
- const skipIfExists = Boolean(options && options.skipIfExists);
1194
- return [
1195
- {
1196
- type: 'add',
1197
- path: `${root}/web/Result.java`,
1198
- templateFile: 'templates/base/Result.hbs',
1199
- data: templateData,
1200
- skipIfExists: true
1201
- },
1202
- { type: 'add', path: `${root}/controller/{{pascalCase entityName}}Controller.java`, templateFile: 'templates/controller.hbs', data: templateData, skipIfExists }
1203
- ];
1204
- }
1205
-
1206
- function makeStackActions(data) {
1207
- const skipIfExists = skipExistingMakeFiles(data);
1208
- return appendMakeSqlActions([
1209
- ...makeEntityActions(data, { skipIfExists }),
1210
- ...makeMapperActions(data, { skipIfExists }),
1211
- ...makeServiceActions(data, { skipIfExists }),
1212
- ...makeControllerActions(data, { skipIfExists })
1213
- ], data);
1214
- }
148
+ const migrationPrompts = [
149
+ {
150
+ type: 'input',
151
+ name: 'description',
152
+ message: '遷移名稱(例如 create_users_table、add_votes_to_users_table):',
153
+ validate: validateMigrationName
154
+ }
155
+ ];
1215
156
 
1216
157
  plop.setGenerator('make', {
1217
158
  description: '一次產生 Entity + DAO + Mapper + DTO + Service + Controller;--migration / --seed',
@@ -1318,7 +259,6 @@ module.exports = function (plop) {
1318
259
  }
1319
260
  });
1320
261
 
1321
- // ---------- add:像 php artisan / ng add,往「既有專案」裡加一個功能模組 ----------
1322
262
  plop.setGenerator('add', {
1323
263
  description: '在既有專案中加入 email / sso / oauth 等功能模組(會改動 application.yml、build.gradle)',
1324
264
  prompts: [
@@ -1358,7 +298,6 @@ module.exports = function (plop) {
1358
298
  }
1359
299
  });
1360
300
 
1361
- // ---------- create:像 npm create vite@latest,從零建一個新專案資料夾 ----------
1362
301
  plop.setGenerator('create', {
1363
302
  description: '建立一個新的 BIRC Spring Boot 多模組專案(互動選擇要不要 email / sso / oauth)',
1364
303
  prompts: [
@@ -1396,132 +335,9 @@ module.exports = function (plop) {
1396
335
  default: true
1397
336
  }
1398
337
  ],
1399
- actions: function (data) {
1400
- const projectDir = toKebab(data.projectName);
1401
- const basePackagePath = data.basePackage.replace(/\./g, '/');
1402
-
1403
- const layout = persistenceLayout(data.basePackage, {
1404
- multiModule: true,
1405
- projectNameKebab: projectDir
1406
- });
1407
-
1408
- const templateData = {
1409
- ...VERSIONS,
1410
- basePackage: data.basePackage,
1411
- projectName: data.projectName,
1412
- projectNameKebab: projectDir,
1413
- multiModule: true,
1414
- entityPackage: layout.entityPackage,
1415
- daoPackage: layout.daoPackage,
1416
- entityPath: layout.entityRoot,
1417
- daoPath: layout.daoRoot,
1418
- gitlabProjectPath: data.gitlabProjectPath || '',
1419
- dbRootPassword: randomDbPassword(),
1420
- dbPassword: randomDbPassword(),
1421
- hasDocker: (data.features || []).includes('docker')
1422
- };
1423
-
1424
- const srcPath = 'src/main/java';
1425
- const testSrcPath = 'src/test/java';
1426
- const resourcesPath = 'src/main/resources';
1427
- const root = `${projectDir}/${srcPath}/${basePackagePath}`;
1428
- const testRoot = `${projectDir}/${testSrcPath}/${basePackagePath}`;
1429
- const resourcesRoot = `${projectDir}/${resourcesPath}`;
1430
- const ymlPath = `${resourcesRoot}/application.yml`;
1431
- const gradlePath = `${projectDir}/build.gradle`;
1432
-
1433
- let actions = [
1434
- { type: 'assertCreateDestFree', abortOnFail: true },
1435
- { type: 'add', path: `${root}/Application.java`, templateFile: 'templates/base/Application.hbs', data: templateData },
1436
- { type: 'add', path: `${testRoot}/ApplicationTests.java`, templateFile: 'templates/base/ApplicationTests.java.hbs', data: templateData },
1437
- { type: 'add', path: `${projectDir}/src/test/resources/application.yml`, templateFile: 'templates/base/application-test.yml.hbs', data: templateData },
1438
- { type: 'add', path: gradlePath, templateFile: 'templates/multi-module/build.gradle.hbs', data: templateData },
1439
- { type: 'add', path: `${projectDir}/settings.gradle`, templateFile: 'templates/multi-module/settings.gradle.hbs', data: templateData },
1440
- { type: 'add', path: `${resourcesRoot}/application.yml`, templateFile: 'templates/base/application.yml.hbs', data: templateData },
1441
- { type: 'add', path: `${projectDir}/.gitignore`, templateFile: 'templates/base/gitignore.hbs', data: templateData },
1442
- { type: 'copyFile', from: 'templates/gradle-wrapper/gradlew', path: `${projectDir}/gradlew`, executable: true },
1443
- { type: 'copyFile', from: 'templates/gradle-wrapper/gradlew.bat', path: `${projectDir}/gradlew.bat` },
1444
- {
1445
- type: 'copyFile',
1446
- from: 'templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.jar',
1447
- path: `${projectDir}/gradle/wrapper/gradle-wrapper.jar`
1448
- },
1449
- {
1450
- type: 'copyFile',
1451
- from: 'templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.properties',
1452
- path: `${projectDir}/gradle/wrapper/gradle-wrapper.properties`
1453
- },
1454
- {
1455
- type: 'add',
1456
- path: `${projectDir}/.bircrc.json`,
1457
- templateFile: 'templates/base/bircrc.hbs',
1458
- data: { ...templateData, featuresJson: JSON.stringify(data.features) }
1459
- }
1460
- ];
1461
- actions = actions.concat(exceptionSkeletonActions(root, templateData));
1462
- actions = actions.concat(serviceSkeletonActions(root, templateData));
1463
- actions = actions.concat(seederSkeletonActions(root, templateData));
1464
-
1465
- const configRoot = `${projectDir}/modules/${projectDir}-config/src/main/java/${basePackagePath}/config`;
1466
- actions.push(
1467
- { type: 'add', path: `${projectDir}/modules/${projectDir}-config/build.gradle`, templateFile: 'templates/multi-module/config/build.gradle.hbs', data: templateData },
1468
- { type: 'add', path: `${configRoot}/Config.java`, templateFile: 'templates/multi-module/config/Config.java.hbs', data: templateData },
1469
- { type: 'add', path: `${configRoot}/ApplicationConfig.java`, templateFile: 'templates/multi-module/config/ApplicationConfig.java.hbs', data: templateData },
1470
- { type: 'add', path: `${configRoot}/SecurityConfig.java`, templateFile: 'templates/multi-module/config/SecurityConfig.java.hbs', data: templateData }
1471
- );
1472
-
1473
- const dbConfigRoot = `${projectDir}/modules/${projectDir}-database-config/src/main/java/${basePackagePath}/databaseconfig`;
1474
- actions.push(
1475
- { type: 'add', path: `${projectDir}/modules/${projectDir}-database-config/build.gradle`, templateFile: 'templates/multi-module/database-config/build.gradle.hbs', data: templateData },
1476
- { type: 'add', path: `${dbConfigRoot}/Config.java`, templateFile: 'templates/multi-module/database-config/Config.java.hbs', data: templateData },
1477
- { type: 'add', path: `${dbConfigRoot}/JpaConfig.java`, templateFile: 'templates/multi-module/database-config/JpaConfig.java.hbs', data: templateData },
1478
- { type: 'add', path: `${projectDir}/${layout.daoRoot}/BaseViewDAO.java`, templateFile: 'templates/multi-module/database-config/BaseViewDAO.java.hbs', data: templateData },
1479
- { type: 'add', path: `${projectDir}/${layout.daoRoot}/BaseDAO.java`, templateFile: 'templates/multi-module/database-config/BaseDAO.java.hbs', data: templateData }
1480
- );
1481
-
1482
- for (const featureKey of data.features) {
1483
- actions = actions.concat(
1484
- featureActions(featureKey, {
1485
- root,
1486
- resourcesRoot,
1487
- projectRoot: projectDir,
1488
- ymlPath,
1489
- gradlePath,
1490
- templateData
1491
- })
1492
- );
1493
- }
1494
-
1495
- actions.push({
1496
- type: 'add',
1497
- path: `${projectDir}/README.md`,
1498
- templateFile: 'templates/base/README.md.hbs',
1499
- data: templateData,
1500
- skipIfExists: true
1501
- });
1502
-
1503
- if (data.injectDocs) {
1504
- actions.push(
1505
- { type: 'add', path: `${projectDir}/AGENTS.md`, templateFile: 'project-docs/AGENTS.md.hbs', data: templateData },
1506
- { type: 'add', path: `${projectDir}/PROJECT.md`, templateFile: 'project-docs/PROJECT.md.hbs', data: templateData },
1507
- { type: 'add', path: `${projectDir}/implement.md`, templateFile: 'project-docs/implement.hbs', data: templateData },
1508
- { type: 'add', path: `${projectDir}/test.md`, templateFile: 'project-docs/test.hbs', data: templateData }
1509
- );
1510
- }
1511
-
1512
- if ((data.features || []).includes('docker')) {
1513
- actions.push({
1514
- type: 'copyIfExists',
1515
- from: `${projectDir}/.env.example`,
1516
- path: `${projectDir}/.env`
1517
- });
1518
- }
1519
-
1520
- return actions;
1521
- }
338
+ actions: buildCreateActions
1522
339
  });
1523
340
 
1524
- // ---------- docs:給 AI coding agent 讀的目錄與規範 ----------
1525
341
  plop.setGenerator('docs', {
1526
342
  description: '產生 AGENTS.md / PROJECT.md / implement.md / test.md',
1527
343
  prompts: [],
@@ -1538,102 +354,6 @@ module.exports = function (plop) {
1538
354
  }
1539
355
  });
1540
356
 
1541
- function migrationDir(config) {
1542
- const resourcesPath = config.resourcesPath || 'src/main/resources';
1543
- return config.migrationPath || `${resourcesPath}/db/migration`;
1544
- }
1545
-
1546
- function makeMigrationActions(data) {
1547
- const config = loadProjectConfig();
1548
- const migrationPath = migrationDir(config);
1549
- const parsed = parseMigrationName(data.description);
1550
- const version = data.migrationVersion || nextMigrationVersion(path.join(projectRoot(), migrationPath));
1551
- const example = isExample(data);
1552
- const migrationColumns = parsed.kind === 'create'
1553
- ? resolveCreateTableColumns(data, parsed.tableName, config)
1554
- : [];
1555
- return [
1556
- {
1557
- type: 'add',
1558
- path: `${migrationPath}/V${version}__${parsed.description}.sql`,
1559
- templateFile: migrationTemplate(parsed.kind),
1560
- data: {
1561
- description: parsed.description,
1562
- tableName: parsed.tableName,
1563
- columnName: parsed.columnName,
1564
- withExample: example,
1565
- withSoftDelete: includeSoftDelete(data),
1566
- migrationColumns
1567
- }
1568
- }
1569
- ];
1570
- }
1571
-
1572
- function makeSeederActions(data) {
1573
- data.entityName = entityNameForSeeder(data);
1574
- const { root, templateData } = makeContext(data);
1575
- const entityName = data.entityName;
1576
- const seederClass = `${entityName}Seeder`;
1577
- const tableName = tableNameFromEntity(entityName);
1578
- const seedFields = resolveSeedFields(data, tableName, loadProjectConfig());
1579
- return [
1580
- ...seederSkeletonActions(root, templateData, { skipIfExists: true }),
1581
- {
1582
- type: 'add',
1583
- path: `${root}/seeder/${seederClass}.java`,
1584
- templateFile: 'templates/seeder.hbs',
1585
- data: {
1586
- ...templateData,
1587
- extraImports: fieldImports(seedFields),
1588
- seedFields,
1589
- seederClass,
1590
- entityName
1591
- }
1592
- }
1593
- ];
1594
- }
1595
-
1596
- function makeModelActions(data) {
1597
- const actions = makeEntityActions(data, { skipIfExists: true });
1598
- if (includeMapperWithEntity(data)) {
1599
- actions.push(...makeMapperActions(data));
1600
- }
1601
- appendMakeSqlActions(actions, data);
1602
- if (includeModelController(data)) {
1603
- actions.push(...makeControllerActions(data));
1604
- }
1605
- return actions;
1606
- }
1607
-
1608
- function appendMakeSqlActions(actions, data) {
1609
- const config = loadProjectConfig();
1610
- const migrationPath = migrationDir(config);
1611
- const version = nextMigrationVersion(path.join(projectRoot(), migrationPath));
1612
- if (includeModelMigration(data)) {
1613
- const tableName = tableNameFromEntity(data.entityName);
1614
- actions.push(
1615
- ...makeMigrationActions({
1616
- ...data,
1617
- description: `create_${tableName}_table`,
1618
- migrationVersion: version
1619
- })
1620
- );
1621
- }
1622
- if (includeModelSeed(data)) {
1623
- actions.push(...makeSeederActions(data));
1624
- }
1625
- return actions;
1626
- }
1627
-
1628
- const migrationPrompts = [
1629
- {
1630
- type: 'input',
1631
- name: 'description',
1632
- message: '遷移名稱(例如 create_users_table、add_votes_to_users_table):',
1633
- validate: validateMigrationName
1634
- }
1635
- ];
1636
-
1637
357
  plop.setGenerator('make:migration', {
1638
358
  description: '新增 Flyway 遷移(Laravel 風格名稱,例如 create_users_table)',
1639
359
  prompts: migrationPrompts,