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