birc-generator 0.5.5

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 (128) hide show
  1. package/.bircrc.example.json +14 -0
  2. package/AGENTS.md +20 -0
  3. package/PROJECT.md +83 -0
  4. package/README.md +300 -0
  5. package/bin/birc.js +645 -0
  6. package/bin/cli-util.js +428 -0
  7. package/bin/home-install.js +346 -0
  8. package/bin/update-check.js +153 -0
  9. package/implement.md +76 -0
  10. package/install.ps1 +156 -0
  11. package/install.sh +201 -0
  12. package/package.json +68 -0
  13. package/plopfile.js +1358 -0
  14. package/project-docs/AGENTS.md.hbs +12 -0
  15. package/project-docs/PROJECT.md.hbs +128 -0
  16. package/project-docs/implement.hbs +54 -0
  17. package/project-docs/test.hbs +31 -0
  18. package/scripts/install-bin.sh +72 -0
  19. package/scripts/update.sh +11 -0
  20. package/templates/aop/OperationLog.hbs +18 -0
  21. package/templates/aop/OperationLogAspect.hbs +95 -0
  22. package/templates/aop/build-gradle-dep.hbs +1 -0
  23. package/templates/base/Application.hbs +11 -0
  24. package/templates/base/ApplicationTests.java.hbs +11 -0
  25. package/templates/base/BaseService.hbs +15 -0
  26. package/templates/base/BaseServiceImpl.hbs +53 -0
  27. package/templates/base/EntityMapper.hbs +12 -0
  28. package/templates/base/README.md.hbs +56 -0
  29. package/templates/base/Result.hbs +48 -0
  30. package/templates/base/application-test.yml.hbs +27 -0
  31. package/templates/base/application.yml.hbs +18 -0
  32. package/templates/base/bircrc.hbs +14 -0
  33. package/templates/base/build.gradle.hbs +33 -0
  34. package/templates/base/gitignore.hbs +15 -0
  35. package/templates/client/ExternalApiClient.hbs +28 -0
  36. package/templates/client/ExternalApiClientConfig.hbs +26 -0
  37. package/templates/client/ExternalApiProperties.hbs +30 -0
  38. package/templates/client/application-yml-block.hbs +7 -0
  39. package/templates/controller.hbs +55 -0
  40. package/templates/dao.hbs +11 -0
  41. package/templates/docker/Dockerfile.hbs +29 -0
  42. package/templates/docker/docker-compose.prod.yml.hbs +65 -0
  43. package/templates/docker/docker-compose.yml.hbs +52 -0
  44. package/templates/docker/dockerignore.hbs +6 -0
  45. package/templates/docker/env.example.hbs +24 -0
  46. package/templates/dto-request.hbs +19 -0
  47. package/templates/dto-response.hbs +13 -0
  48. package/templates/email/EmailAsyncConfig.hbs +28 -0
  49. package/templates/email/EmailService.hbs +26 -0
  50. package/templates/email/EmailServiceImpl.hbs +49 -0
  51. package/templates/email/application-yml-block.hbs +19 -0
  52. package/templates/email/build-gradle-dep.hbs +2 -0
  53. package/templates/email/sample-template.hbs +16 -0
  54. package/templates/entity.hbs +46 -0
  55. package/templates/exception/ExceptionHandleController.hbs +16 -0
  56. package/templates/exception/NotFoundException.hbs +28 -0
  57. package/templates/exception/ProjectException.hbs +20 -0
  58. package/templates/exception.hbs +30 -0
  59. package/templates/file-upload/EmptyFileException.hbs +25 -0
  60. package/templates/file-upload/FileExtensionIllegalException.hbs +21 -0
  61. package/templates/file-upload/FileExtensionUtils.hbs +20 -0
  62. package/templates/file-upload/FileStorageProperties.hbs +37 -0
  63. package/templates/file-upload/FileStorageService.hbs +15 -0
  64. package/templates/file-upload/FileStorageServiceImpl.hbs +78 -0
  65. package/templates/file-upload/FileTooLargeException.hbs +21 -0
  66. package/templates/file-upload/FileUploadController.hbs +57 -0
  67. package/templates/file-upload/FileUtils.hbs +39 -0
  68. package/templates/file-upload/InvalidStoredFileException.hbs +21 -0
  69. package/templates/file-upload/application-yml-block.hbs +12 -0
  70. package/templates/gitlab-ci/gitlab-ci.hbs +158 -0
  71. package/templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.jar +0 -0
  72. package/templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.properties +8 -0
  73. package/templates/gradle-wrapper/gradlew +248 -0
  74. package/templates/gradle-wrapper/gradlew.bat +93 -0
  75. package/templates/log4j2/build-gradle-dep.hbs +6 -0
  76. package/templates/log4j2/log4j2-spring.xml.hbs +34 -0
  77. package/templates/mapper-gradle-dep.hbs +3 -0
  78. package/templates/mapper.hbs +11 -0
  79. package/templates/migration/add-column.hbs +4 -0
  80. package/templates/migration/create-table.hbs +9 -0
  81. package/templates/migration/custom.hbs +2 -0
  82. package/templates/migration/remove-column.hbs +4 -0
  83. package/templates/migration/seed.hbs +7 -0
  84. package/templates/multi-module/build.gradle.hbs +86 -0
  85. package/templates/multi-module/config/ApplicationConfig.java.hbs +33 -0
  86. package/templates/multi-module/config/Config.java.hbs +19 -0
  87. package/templates/multi-module/config/SecurityConfig.java.hbs +48 -0
  88. package/templates/multi-module/config/build.gradle.hbs +10 -0
  89. package/templates/multi-module/database-config/BaseDAO.java.hbs +31 -0
  90. package/templates/multi-module/database-config/BaseViewDAO.java.hbs +48 -0
  91. package/templates/multi-module/database-config/Config.java.hbs +14 -0
  92. package/templates/multi-module/database-config/JpaConfig.java.hbs +9 -0
  93. package/templates/multi-module/database-config/build.gradle.hbs +17 -0
  94. package/templates/multi-module/settings.gradle.hbs +9 -0
  95. package/templates/oauth/OAuth2LoginSuccessHandler.hbs +31 -0
  96. package/templates/oauth/application-yml-block.hbs +11 -0
  97. package/templates/oauth/build-gradle-dep.hbs +1 -0
  98. package/templates/openapi/OpenApiConfig.hbs +23 -0
  99. package/templates/openapi/build-gradle-dep.hbs +1 -0
  100. package/templates/pagination/GenericSpecification.hbs +76 -0
  101. package/templates/pagination/PageInfo.hbs +20 -0
  102. package/templates/pagination/PageRequest.hbs +27 -0
  103. package/templates/pagination/PageResponse.hbs +24 -0
  104. package/templates/pagination/Pager.hbs +37 -0
  105. package/templates/pagination/SearchCriteria.hbs +26 -0
  106. package/templates/pagination/SpecificationSupport.hbs +34 -0
  107. package/templates/permission/PermissionAspect.hbs +41 -0
  108. package/templates/permission/RequirePermission.hbs +18 -0
  109. package/templates/permission/build-gradle-dep.hbs +2 -0
  110. package/templates/scheduling/SampleSchedule.hbs +22 -0
  111. package/templates/scheduling/SchedulingConfig.hbs +13 -0
  112. package/templates/scheduling/application-yml-block.hbs +5 -0
  113. package/templates/sentry/SentryConfig.hbs +74 -0
  114. package/templates/sentry/application-yml-block.hbs +15 -0
  115. package/templates/sentry/build-gradle-dep.hbs +1 -0
  116. package/templates/service.hbs +9 -0
  117. package/templates/serviceImpl.hbs +23 -0
  118. package/templates/spotless/build-gradle-allprojects.hbs +16 -0
  119. package/templates/spotless/build-gradle-plugin.hbs +1 -0
  120. package/templates/spotless/spotless_formatter.xml +83 -0
  121. package/templates/sso/SsoAutoConfiguration.hbs +17 -0
  122. package/templates/sso/SsoProperties.hbs +27 -0
  123. package/templates/sso/application-yml-block.hbs +7 -0
  124. package/templates/valid-group/ValidGroup.hbs +20 -0
  125. package/test.md +56 -0
  126. package/uninstall.ps1 +60 -0
  127. package/uninstall.sh +76 -0
  128. package/versions.js +10 -0
package/plopfile.js ADDED
@@ -0,0 +1,1358 @@
1
+ const fs = require('fs');
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 密碼用:6 碼小寫英文,隨機生成,避免 change_me 這種預設值。
8
+ function randomDbPassword() {
9
+ const letters = 'abcdefghijklmnopqrstuvwxyz';
10
+ return Array.from({ length: 6 }, () => letters[randomInt(letters.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
+ function parseFields(fields) {
160
+ if (!fields || !String(fields).trim()) {
161
+ return [];
162
+ }
163
+ return String(fields)
164
+ .split(',')
165
+ .map((f) => {
166
+ const [name, type] = f.split(':');
167
+ const fieldName = (name || '').trim();
168
+ return {
169
+ name: fieldName,
170
+ type: (type || 'String').trim(),
171
+ snakeName: fieldName
172
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
173
+ .replace(/[\s-]+/g, '_')
174
+ .toLowerCase()
175
+ };
176
+ })
177
+ .filter((f) => f.name);
178
+ }
179
+
180
+ function isExample(data) {
181
+ return Boolean(data && data.example) || process.env.BIRC_EXAMPLE === '1';
182
+ }
183
+
184
+ function includeMapperWithEntity(data) {
185
+ return Boolean(data && (data.dto || data.mapper))
186
+ || process.env.BIRC_MAKE_DTO === '1'
187
+ || process.env.BIRC_MAKE_MAPPER === '1';
188
+ }
189
+
190
+ function includeModelMigration(data) {
191
+ return Boolean(data && data.migration) || process.env.BIRC_MAKE_MIGRATION === '1';
192
+ }
193
+
194
+ function includeModelSeed(data) {
195
+ return Boolean(data && data.seed) || process.env.BIRC_MAKE_SEED === '1';
196
+ }
197
+
198
+ function includeModelController(data) {
199
+ return Boolean(data && data.controller) || process.env.BIRC_MAKE_CONTROLLER === '1';
200
+ }
201
+
202
+ function includeSoftDelete(data) {
203
+ return Boolean(data && data.softDelete) || process.env.BIRC_MAKE_SOFT_DELETE === '1';
204
+ }
205
+
206
+ function toSnakeCase(text) {
207
+ return String(text || '')
208
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
209
+ .replace(/[\s-]+/g, '_')
210
+ .toLowerCase();
211
+ }
212
+
213
+ function tableNameFromEntity(entityName) {
214
+ return toSnakeCase(entityName);
215
+ }
216
+
217
+ function fieldImports(fields) {
218
+ const types = new Set((fields || []).map((field) => field.type));
219
+ const imports = [];
220
+ if (types.has('LocalDateTime')) imports.push('java.time.LocalDateTime');
221
+ if (types.has('LocalDate')) imports.push('java.time.LocalDate');
222
+ if (types.has('BigDecimal')) imports.push('java.math.BigDecimal');
223
+ return imports;
224
+ }
225
+
226
+ function resolveMakeFields(data) {
227
+ const fields = parseFields(data.fields || process.env.BIRC_MAKE_FIELDS);
228
+ if (fields.length > 0) {
229
+ return fields;
230
+ }
231
+ if (isExample(data)) {
232
+ return [{ name: 'name', type: 'String', snakeName: 'name' }];
233
+ }
234
+ return [];
235
+ }
236
+
237
+ // 最後防線:prompt 驗證被繞過時(CLI 直呼、程式化呼叫),輸出值也不得
238
+ // 帶 SQL 特殊字元或路徑字元——tableName/columnName 進 Flyway SQL,
239
+ // description 同時進檔名。
240
+ function sanitizeMigrationToken(value) {
241
+ return String(value || '').replace(/[^a-zA-Z0-9_]/g, '');
242
+ }
243
+
244
+ function parseMigrationName(name) {
245
+ const raw = String(name || '').trim();
246
+ let match = raw.match(/^create_(.+)_table$/i);
247
+ if (match) {
248
+ return {
249
+ kind: 'create',
250
+ tableName: sanitizeMigrationToken(match[1]),
251
+ columnName: null,
252
+ description: sanitizeMigrationToken(raw) || 'migration'
253
+ };
254
+ }
255
+ match = raw.match(/^add_(.+)_to_(.+)_table$/i);
256
+ if (match) {
257
+ return {
258
+ kind: 'add',
259
+ columnName: sanitizeMigrationToken(match[1]),
260
+ tableName: sanitizeMigrationToken(match[2]),
261
+ description: sanitizeMigrationToken(raw) || 'migration'
262
+ };
263
+ }
264
+ match = raw.match(/^remove_(.+)_from_(.+)_table$/i);
265
+ if (match) {
266
+ return {
267
+ kind: 'remove',
268
+ columnName: sanitizeMigrationToken(match[1]),
269
+ tableName: sanitizeMigrationToken(match[2]),
270
+ description: sanitizeMigrationToken(raw) || 'migration'
271
+ };
272
+ }
273
+ const customDescription = sanitizeMigrationToken(raw);
274
+ return { kind: 'custom', tableName: null, columnName: null, description: customDescription || 'migration' };
275
+ }
276
+
277
+ function nextMigrationVersion(migrationDir) {
278
+ if (!fs.existsSync(migrationDir)) {
279
+ return 1;
280
+ }
281
+ const versions = fs
282
+ .readdirSync(migrationDir)
283
+ .filter((file) => file.startsWith('V') && file.endsWith('.sql'))
284
+ .map((file) => {
285
+ const match = file.match(/^V(\d+)__/);
286
+ return match ? parseInt(match[1], 10) : 0;
287
+ });
288
+ return versions.length ? Math.max(...versions) + 1 : 1;
289
+ }
290
+
291
+ function migrationTemplate(kind) {
292
+ if (kind === 'create') return 'templates/migration/create-table.hbs';
293
+ if (kind === 'add') return 'templates/migration/add-column.hbs';
294
+ if (kind === 'remove') return 'templates/migration/remove-column.hbs';
295
+ return 'templates/migration/custom.hbs';
296
+ }
297
+
298
+ function makeContext(data) {
299
+ if (data && data.entityName) {
300
+ data.entityName = toPascalCaseName(data.entityName);
301
+ }
302
+ const config = loadProjectConfig();
303
+ const persistence = resolvePersistence(config);
304
+ const basePackage = config.basePackage;
305
+ const srcPath = config.srcPath || 'src/main/java';
306
+ const root = `${srcPath}/${basePackage.replace(/\./g, '/')}`;
307
+ const fields = resolveMakeFields(data);
308
+ const example = isExample(data);
309
+ const extraImports = fieldImports(fields);
310
+ const entityImports = extraImports.includes('java.time.LocalDateTime') || !example
311
+ ? extraImports
312
+ : extraImports.concat('java.time.LocalDateTime');
313
+ return {
314
+ config,
315
+ persistence,
316
+ root,
317
+ gradlePath: config.buildGradlePath || 'build.gradle',
318
+ templateData: {
319
+ ...VERSIONS,
320
+ basePackage,
321
+ makeFields: fields,
322
+ extraImports,
323
+ entityImports,
324
+ withExample: example,
325
+ withSoftDelete: includeSoftDelete(data),
326
+ entityPackage: persistence.entityPackage,
327
+ daoPackage: persistence.daoPackage
328
+ }
329
+ };
330
+ }
331
+
332
+ function exceptionClassName(name) {
333
+ const base = toPascalCaseName(name);
334
+ return /Exception$/i.test(base) ? base.replace(/exception$/i, 'Exception') : `${base}Exception`;
335
+ }
336
+
337
+ function exceptionSkeletonActions(root, templateData) {
338
+ return [
339
+ { type: 'add', path: `${root}/exception/ProjectException.java`, templateFile: 'templates/exception/ProjectException.hbs', data: templateData },
340
+ { type: 'add', path: `${root}/exception/NotFoundException.java`, templateFile: 'templates/exception/NotFoundException.hbs', data: templateData },
341
+ { type: 'add', path: `${root}/controller/ExceptionHandleController.java`, templateFile: 'templates/exception/ExceptionHandleController.hbs', data: templateData },
342
+ { type: 'add', path: `${root}/web/Result.java`, templateFile: 'templates/base/Result.hbs', data: templateData }
343
+ ];
344
+ }
345
+
346
+ function serviceSkeletonActions(root, templateData) {
347
+ return [
348
+ { type: 'add', path: `${root}/mapper/EntityMapper.java`, templateFile: 'templates/base/EntityMapper.hbs', data: templateData },
349
+ { type: 'add', path: `${root}/service/BaseService.java`, templateFile: 'templates/base/BaseService.hbs', data: templateData },
350
+ { type: 'add', path: `${root}/service/impl/BaseServiceImpl.java`, templateFile: 'templates/base/BaseServiceImpl.hbs', data: templateData }
351
+ ];
352
+ }
353
+
354
+ function uniqueFeatureKeys(features) {
355
+ return [...new Set((features || []).filter((featureKey) => FEATURES[featureKey]))];
356
+ }
357
+
358
+ const YML_ANCHOR = '# birc-generator:config-anchor';
359
+ const GRADLE_ANCHOR = '// birc-generator:dependency-anchor';
360
+ const GRADLE_PLUGIN_ANCHOR = '// birc-generator:plugin-anchor';
361
+ const GRADLE_ALLPROJECTS_ANCHOR = '// birc-generator:allprojects-anchor';
362
+
363
+ function requireGradleAnchor(errors, featureKey, destBase, gradlePath, anchor) {
364
+ const target = path.resolve(destBase, gradlePath);
365
+ if (!fs.existsSync(target)) {
366
+ errors.push(`${featureKey}: 找不到 ${gradlePath}`);
367
+ } else if (!fs.readFileSync(target, 'utf8').includes(anchor)) {
368
+ errors.push(`${featureKey}: ${gradlePath} 沒有 ${anchor}`);
369
+ }
370
+ }
371
+
372
+ function gradleAnchorPattern(anchor) {
373
+ return new RegExp(`${anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n`);
374
+ }
375
+
376
+ function isAddForce(data) {
377
+ return Boolean(data && data.force) || process.env.BIRC_ADD_FORCE === '1';
378
+ }
379
+
380
+ function isAddSync(data) {
381
+ return Boolean(data && data.sync) || process.env.BIRC_ADD_SYNC === '1';
382
+ }
383
+
384
+ function preflightAdd(featureKeys, { destBase, ymlPath, gradlePath }) {
385
+ const errors = [];
386
+ for (const featureKey of featureKeys) {
387
+ const feature = FEATURES[featureKey];
388
+ if (feature.ymlBlock) {
389
+ const target = path.resolve(destBase, ymlPath);
390
+ if (!fs.existsSync(target)) {
391
+ errors.push(`${featureKey}: 找不到 ${ymlPath}`);
392
+ } else if (!fs.readFileSync(target, 'utf8').includes(YML_ANCHOR)) {
393
+ errors.push(`${featureKey}: ${ymlPath} 沒有 ${YML_ANCHOR}`);
394
+ }
395
+ }
396
+ if (feature.gradleDep) {
397
+ requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_ANCHOR);
398
+ }
399
+ if (feature.gradlePlugin) {
400
+ requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_PLUGIN_ANCHOR);
401
+ }
402
+ if (feature.gradleAllprojects) {
403
+ requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_ALLPROJECTS_ANCHOR);
404
+ }
405
+ }
406
+ if (errors.length > 0) {
407
+ throw new Error(`add 預檢查失敗,沒有寫任何檔:\n${errors.map((line) => ` ${line}`).join('\n')}`);
408
+ }
409
+ }
410
+
411
+ function buildAddActions(data) {
412
+ const force = isAddForce(data);
413
+ const syncAll = isAddSync(data);
414
+ const config = loadProjectConfig();
415
+ const installed = new Set(config.features || []);
416
+ const selected = syncAll
417
+ ? uniqueFeatureKeys(config.features)
418
+ : uniqueFeatureKeys(data.features);
419
+ const featuresToAdd = selected.filter((featureKey) => force || syncAll || !installed.has(featureKey));
420
+ if (featuresToAdd.length === 0) {
421
+ return [];
422
+ }
423
+
424
+ const basePackage = config.basePackage;
425
+ const basePackagePath = basePackage.replace(/\./g, '/');
426
+ const srcPath = config.srcPath || 'src/main/java';
427
+ const resourcesPath = config.resourcesPath || 'src/main/resources';
428
+ const ymlPath = config.applicationYmlPath || `${resourcesPath}/application.yml`;
429
+ const gradlePath = config.buildGradlePath || 'build.gradle';
430
+ const root = `${srcPath}/${basePackagePath}`;
431
+ const destBase = projectRoot();
432
+
433
+ preflightAdd(featuresToAdd, { destBase, ymlPath, gradlePath });
434
+
435
+ const templateData = {
436
+ ...VERSIONS,
437
+ basePackage,
438
+ projectName: config.projectNameKebab || inferProjectNameKebab(config),
439
+ projectNameKebab: config.projectNameKebab || inferProjectNameKebab(config),
440
+ gitlabProjectPath: data.gitlabProjectPath || '',
441
+ dbRootPassword: randomDbPassword(),
442
+ dbPassword: randomDbPassword()
443
+ };
444
+
445
+ let actions = [];
446
+ for (const featureKey of featuresToAdd) {
447
+ const reinstall = installed.has(featureKey);
448
+ actions = actions.concat(
449
+ featureActions(featureKey, {
450
+ root,
451
+ resourcesRoot: resourcesPath,
452
+ projectRoot: '.',
453
+ ymlPath,
454
+ gradlePath,
455
+ templateData,
456
+ overwriteFiles: force || syncAll,
457
+ skipConfigFragments: reinstall && (force || syncAll)
458
+ })
459
+ );
460
+ }
461
+
462
+ actions.push({ type: 'updateProjectFeatures', features: featuresToAdd });
463
+ return actions;
464
+ }
465
+
466
+ // 每個 feature 對應:要加哪些檔案、要往 application.yml / build.gradle 的哪個 anchor 插入什麼
467
+ const FEATURES = {
468
+ email: {
469
+ label: 'Email(JavaMailSender + Thymeleaf 模板)',
470
+ files: [
471
+ { templateFile: 'templates/email/EmailService.hbs', dest: 'service/EmailService.java' },
472
+ { templateFile: 'templates/email/EmailServiceImpl.hbs', dest: 'service/impl/EmailServiceImpl.java' },
473
+ { templateFile: 'templates/email/EmailAsyncConfig.hbs', dest: 'config/EmailAsyncConfig.java' }
474
+ ],
475
+ resourceFiles: [
476
+ { templateFile: 'templates/email/sample-template.hbs', dest: 'email/sample.html' }
477
+ ],
478
+ ymlBlock: 'templates/email/application-yml-block.hbs',
479
+ gradleDep: 'templates/email/build-gradle-dep.hbs'
480
+ },
481
+ sso: {
482
+ label: 'SSO(校內單一登入,SsoProperties + AutoConfiguration 原始碼)',
483
+ files: [
484
+ { templateFile: 'templates/sso/SsoProperties.hbs', dest: 'security/SsoProperties.java' },
485
+ { templateFile: 'templates/sso/SsoAutoConfiguration.hbs', dest: 'config/SsoAutoConfiguration.java' }
486
+ ],
487
+ resourceFiles: [],
488
+ ymlBlock: 'templates/sso/application-yml-block.hbs',
489
+ gradleDep: null
490
+ },
491
+ oauth: {
492
+ label: 'OAuth2 登入(預設範例接 Google,其他 provider 自行調整)',
493
+ files: [
494
+ { templateFile: 'templates/oauth/OAuth2LoginSuccessHandler.hbs', dest: 'config/OAuth2LoginSuccessHandler.java' }
495
+ ],
496
+ resourceFiles: [],
497
+ ymlBlock: 'templates/oauth/application-yml-block.hbs',
498
+ gradleDep: 'templates/oauth/build-gradle-dep.hbs'
499
+ },
500
+ scheduling: {
501
+ label: '排程(Scheduling Tasks,@EnableScheduling + 範例 Job)',
502
+ files: [
503
+ { templateFile: 'templates/scheduling/SchedulingConfig.hbs', dest: 'config/SchedulingConfig.java' },
504
+ { templateFile: 'templates/scheduling/SampleSchedule.hbs', dest: 'schedule/SampleSchedule.java' }
505
+ ],
506
+ resourceFiles: [],
507
+ ymlBlock: 'templates/scheduling/application-yml-block.hbs',
508
+ gradleDep: null
509
+ },
510
+ aop: {
511
+ label: 'AOP 事件記錄(自訂 @OperationLog 註解 + Aspect)',
512
+ files: [
513
+ { templateFile: 'templates/aop/OperationLog.hbs', dest: 'annotation/OperationLog.java' },
514
+ { templateFile: 'templates/aop/OperationLogAspect.hbs', dest: 'aspect/OperationLogAspect.java' }
515
+ ],
516
+ resourceFiles: [],
517
+ ymlBlock: null,
518
+ gradleDep: 'templates/aop/build-gradle-dep.hbs'
519
+ },
520
+ client: {
521
+ label: 'Client(呼叫外部 API 的 RestClient 封裝)',
522
+ files: [
523
+ { templateFile: 'templates/client/ExternalApiProperties.hbs', dest: 'config/ExternalApiProperties.java' },
524
+ { templateFile: 'templates/client/ExternalApiClientConfig.hbs', dest: 'config/ExternalApiClientConfig.java' },
525
+ { templateFile: 'templates/client/ExternalApiClient.hbs', dest: 'client/ExternalApiClient.java' }
526
+ ],
527
+ resourceFiles: [],
528
+ ymlBlock: 'templates/client/application-yml-block.hbs',
529
+ gradleDep: null
530
+ },
531
+ sentry: {
532
+ label: 'Sentry(錯誤追蹤 + 客戶端例外過濾)',
533
+ files: [
534
+ { templateFile: 'templates/sentry/SentryConfig.hbs', dest: 'config/SentryConfig.java' }
535
+ ],
536
+ resourceFiles: [],
537
+ ymlBlock: 'templates/sentry/application-yml-block.hbs',
538
+ gradleDep: 'templates/sentry/build-gradle-dep.hbs'
539
+ },
540
+ log4j2: {
541
+ label: 'Log4j2(主控台 + rolling file + MDC)',
542
+ files: [],
543
+ resourceFiles: [
544
+ { templateFile: 'templates/log4j2/log4j2-spring.xml.hbs', dest: 'log4j2-spring.xml' }
545
+ ],
546
+ ymlBlock: null,
547
+ gradleDep: 'templates/log4j2/build-gradle-dep.hbs'
548
+ },
549
+ openapi: {
550
+ label: 'OpenAPI(springdoc + Swagger UI + Bearer JWT)',
551
+ files: [
552
+ { templateFile: 'templates/openapi/OpenApiConfig.hbs', dest: 'config/OpenApiConfig.java' }
553
+ ],
554
+ resourceFiles: [],
555
+ ymlBlock: null,
556
+ gradleDep: 'templates/openapi/build-gradle-dep.hbs'
557
+ },
558
+ fileUpload: {
559
+ label: '檔案上傳(本機磁碟儲存,可自行改接 S3 / MinIO)',
560
+ files: [
561
+ { templateFile: 'templates/file-upload/FileStorageProperties.hbs', dest: 'config/FileStorageProperties.java' },
562
+ { templateFile: 'templates/file-upload/FileStorageService.hbs', dest: 'service/FileStorageService.java' },
563
+ { templateFile: 'templates/file-upload/FileStorageServiceImpl.hbs', dest: 'service/impl/FileStorageServiceImpl.java' },
564
+ { templateFile: 'templates/file-upload/FileUploadController.hbs', dest: 'controller/FileUploadController.java' },
565
+ { templateFile: 'templates/file-upload/FileUtils.hbs', dest: 'util/file/FileUtils.java' },
566
+ { templateFile: 'templates/file-upload/FileExtensionUtils.hbs', dest: 'util/file/FileExtensionUtils.java' },
567
+ { templateFile: 'templates/file-upload/EmptyFileException.hbs', dest: 'exception/file/EmptyFileException.java' },
568
+ { templateFile: 'templates/file-upload/FileExtensionIllegalException.hbs', dest: 'exception/file/FileExtensionIllegalException.java' },
569
+ { templateFile: 'templates/file-upload/InvalidStoredFileException.hbs', dest: 'exception/file/InvalidStoredFileException.java' },
570
+ { templateFile: 'templates/file-upload/FileTooLargeException.hbs', dest: 'exception/file/FileTooLargeException.java' }
571
+ ],
572
+ resourceFiles: [],
573
+ ymlBlock: 'templates/file-upload/application-yml-block.hbs',
574
+ gradleDep: null
575
+ },
576
+ pagination: {
577
+ label: '分頁查詢(Specification 動態條件 + PageRequest/PageResponse)',
578
+ files: [
579
+ { templateFile: 'templates/pagination/SearchCriteria.hbs', dest: 'specification/SearchCriteria.java' },
580
+ { templateFile: 'templates/pagination/GenericSpecification.hbs', dest: 'specification/GenericSpecification.java' },
581
+ { templateFile: 'templates/pagination/SpecificationSupport.hbs', dest: 'specification/SpecificationSupport.java' },
582
+ { templateFile: 'templates/pagination/PageRequest.hbs', dest: 'dto/PageRequest.java' },
583
+ { templateFile: 'templates/pagination/PageResponse.hbs', dest: 'dto/PageResponse.java' },
584
+ { templateFile: 'templates/pagination/Pager.hbs', dest: 'dto/Pager.java' },
585
+ { templateFile: 'templates/pagination/PageInfo.hbs', dest: 'dto/PageInfo.java' }
586
+ ],
587
+ resourceFiles: [],
588
+ ymlBlock: null,
589
+ gradleDep: null
590
+ },
591
+ validGroup: {
592
+ label: '驗證群組(ValidGroup:Create / Update / Delete / Submit)',
593
+ files: [
594
+ { templateFile: 'templates/valid-group/ValidGroup.hbs', dest: 'validation/ValidGroup.java' }
595
+ ],
596
+ resourceFiles: [],
597
+ ymlBlock: null,
598
+ gradleDep: null
599
+ },
600
+ permission: {
601
+ label: '權限控管(Spring Security + 自訂 @RequirePermission)',
602
+ files: [
603
+ { templateFile: 'templates/permission/RequirePermission.hbs', dest: 'annotation/RequirePermission.java' },
604
+ { templateFile: 'templates/permission/PermissionAspect.hbs', dest: 'aspect/PermissionAspect.java' }
605
+ ],
606
+ resourceFiles: [],
607
+ ymlBlock: null,
608
+ gradleDep: 'templates/permission/build-gradle-dep.hbs'
609
+ },
610
+ gitlabCi: {
611
+ label: '.gitlab-ci.yml(Harbor build/push + SSH deploy,對齊 teaching-platform)',
612
+ files: [],
613
+ resourceFiles: [],
614
+ rootFiles: [
615
+ { templateFile: 'templates/gitlab-ci/gitlab-ci.hbs', dest: '.gitlab-ci.yml' }
616
+ ],
617
+ ymlBlock: null,
618
+ gradleDep: null
619
+ },
620
+ docker: {
621
+ label: 'Docker(多階段 Dockerfile + compose + .env.example)',
622
+ files: [],
623
+ resourceFiles: [],
624
+ rootFiles: [
625
+ { templateFile: 'templates/docker/Dockerfile.hbs', dest: 'Dockerfile' },
626
+ { templateFile: 'templates/docker/docker-compose.yml.hbs', dest: 'docker-compose.yml' },
627
+ { templateFile: 'templates/docker/docker-compose.prod.yml.hbs', dest: 'docker-compose.prod.yml' },
628
+ { templateFile: 'templates/docker/env.example.hbs', dest: '.env.example' },
629
+ { templateFile: 'templates/docker/dockerignore.hbs', dest: '.dockerignore' }
630
+ ],
631
+ ymlBlock: null,
632
+ gradleDep: null
633
+ },
634
+ spotless: {
635
+ label: 'Spotless(Eclipse 4.31 格式化,commit 前 ./gradlew spotlessApply)',
636
+ files: [],
637
+ resourceFiles: [],
638
+ rootFiles: [
639
+ { templateFile: 'templates/spotless/spotless_formatter.xml', dest: 'spotless_formatter.xml' }
640
+ ],
641
+ ymlBlock: null,
642
+ gradleDep: null,
643
+ gradlePlugin: 'templates/spotless/build-gradle-plugin.hbs',
644
+ gradleAllprojects: 'templates/spotless/build-gradle-allprojects.hbs'
645
+ }
646
+ };
647
+
648
+ const FEATURE_GROUPS = [
649
+ {
650
+ title: '常用',
651
+ keys: ['docker', 'log4j2', 'openapi', 'spotless', 'validGroup', 'pagination', 'fileUpload', 'email', 'client', 'aop']
652
+ },
653
+ { title: '登入', keys: ['sso', 'oauth', 'permission'] },
654
+ { title: '營運', keys: ['gitlabCi', 'sentry', 'scheduling'] }
655
+ ];
656
+ const DEFAULT_FEATURES = new Set(['docker', 'log4j2', 'validGroup', 'spotless']);
657
+
658
+ function groupedFeatureChoices({
659
+ installed = new Set(),
660
+ allowInstalled = false,
661
+ withDefaults = false
662
+ } = {}) {
663
+ const used = new Set();
664
+ const choices = [];
665
+ for (const group of FEATURE_GROUPS) {
666
+ const keys = group.keys.filter((key) => FEATURES[key]);
667
+ if (keys.length === 0) continue;
668
+ choices.push({ type: 'separator', line: `── ${group.title} ──` });
669
+ for (const key of keys) {
670
+ used.add(key);
671
+ choices.push({
672
+ name: FEATURES[key].label,
673
+ value: key,
674
+ checked: withDefaults && DEFAULT_FEATURES.has(key),
675
+ disabled: !allowInstalled && installed.has(key) ? '已安裝' : false
676
+ });
677
+ }
678
+ }
679
+ const leftover = Object.keys(FEATURES).filter((key) => !used.has(key));
680
+ if (leftover.length > 0) {
681
+ choices.push({ type: 'separator', line: '── 其他 ──' });
682
+ for (const key of leftover) {
683
+ choices.push({
684
+ name: FEATURES[key].label,
685
+ value: key,
686
+ checked: withDefaults && DEFAULT_FEATURES.has(key),
687
+ disabled: !allowInstalled && installed.has(key) ? '已安裝' : false
688
+ });
689
+ }
690
+ }
691
+ return choices;
692
+ }
693
+
694
+ // 產生單一 feature 的 plop actions。root / resourcesRoot / ymlPath / gradlePath 都是絕對相對於
695
+ // plop destBasePath 的路徑,create 跟 add 共用同一份邏輯,差別只在 root 前面有沒有專案資料夾。
696
+ function featureActions(featureKey, {
697
+ root,
698
+ resourcesRoot,
699
+ projectRoot,
700
+ ymlPath,
701
+ gradlePath,
702
+ templateData,
703
+ overwriteFiles = false,
704
+ skipConfigFragments = false
705
+ }) {
706
+ const feature = FEATURES[featureKey];
707
+ if (!feature) {
708
+ throw new Error(`未知的 feature: ${featureKey}`);
709
+ }
710
+ const actions = [];
711
+
712
+ for (const f of feature.files) {
713
+ actions.push({
714
+ type: 'add',
715
+ path: `${root}/${f.dest}`,
716
+ templateFile: f.templateFile,
717
+ data: templateData,
718
+ skipIfExists: !overwriteFiles,
719
+ abortOnFail: true
720
+ });
721
+ }
722
+
723
+ for (const f of feature.resourceFiles) {
724
+ actions.push({
725
+ type: 'add',
726
+ path: `${resourcesRoot}/${f.dest}`,
727
+ templateFile: f.templateFile,
728
+ data: templateData,
729
+ skipIfExists: !overwriteFiles,
730
+ abortOnFail: true
731
+ });
732
+ }
733
+
734
+ // rootFiles:不屬於任何 package、也不進 resources 的檔案(.gitlab-ci.yml、Dockerfile 之類),
735
+ // 直接放到專案根目錄(create 是新資料夾根目錄,add 是目前目錄)。
736
+ if (feature.rootFiles) {
737
+ for (const f of feature.rootFiles) {
738
+ actions.push({
739
+ type: 'add',
740
+ path: `${projectRoot}/${f.dest}`,
741
+ templateFile: f.templateFile,
742
+ data: templateData,
743
+ skipIfExists: !overwriteFiles,
744
+ abortOnFail: true
745
+ });
746
+ }
747
+ }
748
+
749
+ if (feature.ymlBlock && !skipConfigFragments) {
750
+ actions.push({
751
+ type: 'appendFragment',
752
+ path: ymlPath,
753
+ pattern: new RegExp(`${YML_ANCHOR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n`),
754
+ templateFile: feature.ymlBlock,
755
+ data: templateData,
756
+ abortOnFail: true
757
+ });
758
+ }
759
+
760
+ const gradleFragments = [
761
+ [feature.gradleDep, GRADLE_ANCHOR],
762
+ [feature.gradlePlugin, GRADLE_PLUGIN_ANCHOR],
763
+ [feature.gradleAllprojects, GRADLE_ALLPROJECTS_ANCHOR]
764
+ ];
765
+ for (const [templateFile, anchor] of gradleFragments) {
766
+ if (templateFile && !skipConfigFragments) {
767
+ actions.push({
768
+ type: 'appendFragment',
769
+ path: gradlePath,
770
+ pattern: gradleAnchorPattern(anchor),
771
+ templateFile,
772
+ data: templateData,
773
+ abortOnFail: true
774
+ });
775
+ }
776
+ }
777
+
778
+ return actions;
779
+ }
780
+
781
+ module.exports = function (plop) {
782
+ plop.setHelper('pascalCase', (text) => text.charAt(0).toUpperCase() + text.slice(1));
783
+ plop.setHelper('camelCase', (text) => text.charAt(0).toLowerCase() + text.slice(1));
784
+ plop.setHelper('lowerCase', (text) => text.toLowerCase());
785
+ plop.setHelper('dashCase', (text) =>
786
+ text.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/[\s_]+/g, '-').toLowerCase()
787
+ );
788
+ plop.setHelper('snakeCase', toSnakeCase);
789
+
790
+ plop.setActionType('copyFile', function (answers, config, plopApi) {
791
+ const from = path.isAbsolute(config.from) ? config.from : path.join(__dirname, config.from);
792
+ const to = path.resolve(plopApi.getDestBasePath(), plopApi.renderString(config.path, answers));
793
+ fs.mkdirSync(path.dirname(to), { recursive: true });
794
+ fs.copyFileSync(from, to);
795
+ if (config.executable) {
796
+ fs.chmodSync(to, 0o755);
797
+ }
798
+ return path.relative(plopApi.getDestBasePath(), to);
799
+ });
800
+
801
+ plop.setActionType('assertCreateDestFree', function (answers, _config, plopApi) {
802
+ const error = createDestConflict({
803
+ cwd: plopApi.getDestBasePath(),
804
+ projectName: answers.projectName
805
+ });
806
+ if (error) {
807
+ throw new Error(error);
808
+ }
809
+ return 'dest 可用';
810
+ });
811
+
812
+ plop.setActionType('copyIfExists', function (answers, config, plopApi) {
813
+ const destBase = plopApi.getDestBasePath();
814
+ const from = path.resolve(destBase, plopApi.renderString(config.from, answers));
815
+ const to = path.resolve(destBase, plopApi.renderString(config.path, answers));
816
+ if (!fs.existsSync(from)) {
817
+ return `skipped (missing ${path.relative(destBase, from)})`;
818
+ }
819
+ fs.copyFileSync(from, to);
820
+ return path.relative(destBase, to);
821
+ });
822
+
823
+ plop.setActionType('appendFragment', function (answers, config, plopApi) {
824
+ const destBase = plopApi.getDestBasePath();
825
+ const target = path.resolve(destBase, plopApi.renderString(config.path, answers));
826
+ const templatePath = path.isAbsolute(config.templateFile)
827
+ ? config.templateFile
828
+ : path.join(__dirname, config.templateFile);
829
+ const templateData = { ...answers, ...(config.data || {}) };
830
+ const fragment = plopApi.renderString(fs.readFileSync(templatePath, 'utf8'), templateData).trim();
831
+ const contents = fs.readFileSync(target, 'utf8');
832
+
833
+ if (contents.includes(fragment)) {
834
+ return `skipped (already installed ${path.relative(destBase, target)})`;
835
+ }
836
+
837
+ const match = contents.match(config.pattern);
838
+ if (!match) {
839
+ throw new Error(`找不到插入 anchor:${path.relative(destBase, target)}`);
840
+ }
841
+
842
+ const next = contents.replace(config.pattern, (anchor) => `${anchor}\n${fragment}\n`);
843
+ fs.writeFileSync(target, next);
844
+ return path.relative(destBase, target);
845
+ });
846
+
847
+ plop.setActionType('updateProjectFeatures', function (answers, config, plopApi) {
848
+ const destBase = plopApi.getDestBasePath();
849
+ const target = path.resolve(destBase, '.bircrc.json');
850
+ const projectConfig = JSON.parse(fs.readFileSync(target, 'utf8'));
851
+ const features = [...new Set([...(projectConfig.features || []), ...(config.features || [])])];
852
+
853
+ if (JSON.stringify(features) === JSON.stringify(projectConfig.features || [])) {
854
+ return 'skipped (.bircrc.json 已是最新)';
855
+ }
856
+
857
+ projectConfig.features = features;
858
+ fs.writeFileSync(target, `${JSON.stringify(projectConfig, null, 2)}\n`);
859
+ return '.bircrc.json';
860
+ });
861
+
862
+ const entityNamePrompt = {
863
+ type: 'input',
864
+ name: 'entityName',
865
+ message: '名稱(例如 Activity 或 activity):',
866
+ filter: toPascalCaseName,
867
+ validate: validatePascalCase
868
+ };
869
+ const fieldsPrompt = {
870
+ type: 'input',
871
+ name: 'fields',
872
+ message: '欄位定義,用逗號分隔,格式 name:type(例如 title:String,startTime:LocalDateTime):',
873
+ when: (answers) => isExample(answers)
874
+ && !String((answers && answers.fields) || process.env.BIRC_MAKE_FIELDS || '').trim()
875
+ };
876
+
877
+ function makeEntityActions(data) {
878
+ const { persistence, templateData } = makeContext(data);
879
+ return [
880
+ { type: 'add', path: `${persistence.entityRoot}/{{pascalCase entityName}}.java`, templateFile: 'templates/entity.hbs', data: templateData },
881
+ { type: 'add', path: `${persistence.daoRoot}/{{pascalCase entityName}}DAO.java`, templateFile: 'templates/dao.hbs', data: templateData }
882
+ ];
883
+ }
884
+
885
+ function makeMapperActions(data) {
886
+ const { root, gradlePath, templateData } = makeContext(data);
887
+ return [
888
+ { type: 'add', path: `${root}/dto/{{pascalCase entityName}}CreateRequest.java`, templateFile: 'templates/dto-request.hbs', data: templateData },
889
+ { type: 'add', path: `${root}/dto/{{pascalCase entityName}}Response.java`, templateFile: 'templates/dto-response.hbs', data: templateData },
890
+ { type: 'add', path: `${root}/mapper/{{pascalCase entityName}}Mapper.java`, templateFile: 'templates/mapper.hbs', data: templateData },
891
+ {
892
+ type: 'appendFragment',
893
+ path: gradlePath,
894
+ pattern: /\/\/ birc-generator:dependency-anchor\n/,
895
+ templateFile: 'templates/mapper-gradle-dep.hbs',
896
+ data: templateData,
897
+ abortOnFail: true
898
+ }
899
+ ];
900
+ }
901
+
902
+ function makeServiceActions(data) {
903
+ const { root, templateData } = makeContext(data);
904
+ return [
905
+ { type: 'add', path: `${root}/service/{{pascalCase entityName}}Service.java`, templateFile: 'templates/service.hbs', data: templateData },
906
+ { type: 'add', path: `${root}/service/impl/{{pascalCase entityName}}ServiceImpl.java`, templateFile: 'templates/serviceImpl.hbs', data: templateData }
907
+ ];
908
+ }
909
+
910
+ function makeControllerActions(data) {
911
+ const { root, templateData } = makeContext(data);
912
+ return [
913
+ {
914
+ type: 'add',
915
+ path: `${root}/web/Result.java`,
916
+ templateFile: 'templates/base/Result.hbs',
917
+ data: templateData,
918
+ skipIfExists: true
919
+ },
920
+ { type: 'add', path: `${root}/controller/{{pascalCase entityName}}Controller.java`, templateFile: 'templates/controller.hbs', data: templateData }
921
+ ];
922
+ }
923
+
924
+ function makeStackActions(data) {
925
+ return [
926
+ ...makeEntityActions(data),
927
+ ...makeMapperActions(data),
928
+ ...makeServiceActions(data),
929
+ ...makeControllerActions(data)
930
+ ];
931
+ }
932
+
933
+ plop.setGenerator('make', {
934
+ description: '一次產生 Entity + DAO + Mapper + DTO + Service + Controller',
935
+ prompts: [entityNamePrompt, fieldsPrompt],
936
+ actions: makeStackActions
937
+ });
938
+
939
+ plop.setGenerator('make:entity', {
940
+ description: '產生 Entity 與 DAO;--dto / --mapper 加 DTO,--soft-delete 加軟刪',
941
+ prompts: [entityNamePrompt, fieldsPrompt],
942
+ actions(data) {
943
+ const actions = makeEntityActions(data);
944
+ if (includeMapperWithEntity(data)) {
945
+ return actions.concat(makeMapperActions(data));
946
+ }
947
+ return actions;
948
+ }
949
+ });
950
+
951
+ plop.setGenerator('make:model', {
952
+ description: '產生 Entity 與 DAO;--migration / --seed / --controller / --soft-delete',
953
+ prompts: [entityNamePrompt, fieldsPrompt],
954
+ actions: makeModelActions
955
+ });
956
+
957
+ plop.setGenerator('make:mapper', {
958
+ description: '產生 MapStruct Mapper 與對應的 Request / Response DTO',
959
+ prompts: [entityNamePrompt, fieldsPrompt],
960
+ actions: makeMapperActions
961
+ });
962
+
963
+ plop.setGenerator('make:service', {
964
+ description: '產生 XxxService(extends BaseService)與 ServiceImpl',
965
+ prompts: [entityNamePrompt],
966
+ actions: makeServiceActions
967
+ });
968
+
969
+ plop.setGenerator('make:controller', {
970
+ description: '產生 Controller',
971
+ prompts: [entityNamePrompt],
972
+ actions: makeControllerActions
973
+ });
974
+
975
+ plop.setGenerator('make:exception', {
976
+ description: '產生繼承 ProjectException 的例外類別',
977
+ prompts: [
978
+ {
979
+ type: 'input',
980
+ name: 'exceptionName',
981
+ message: '例外名稱(例如 DuplicateRegistration 或 NotFoundException):',
982
+ filter: toPascalCaseName,
983
+ validate: validatePascalCase
984
+ },
985
+ { type: 'input', name: 'defaultMessage', message: '預設訊息:', default: '發生錯誤' },
986
+ {
987
+ type: 'list',
988
+ name: 'httpStatus',
989
+ message: 'HTTP status:',
990
+ choices: ['BAD_REQUEST', 'NOT_FOUND', 'UNAUTHORIZED', 'FORBIDDEN', 'CONFLICT', 'INTERNAL_SERVER_ERROR']
991
+ }
992
+ ],
993
+ actions: function (data) {
994
+ const { root, templateData } = makeContext(data);
995
+ const exceptionClass = exceptionClassName(data.exceptionName);
996
+ const errorCode = exceptionClass.replace(/Exception$/, '');
997
+ return [
998
+ {
999
+ type: 'add',
1000
+ path: `${root}/exception/ProjectException.java`,
1001
+ templateFile: 'templates/exception/ProjectException.hbs',
1002
+ data: templateData,
1003
+ skipIfExists: true
1004
+ },
1005
+ {
1006
+ type: 'add',
1007
+ path: `${root}/controller/ExceptionHandleController.java`,
1008
+ templateFile: 'templates/exception/ExceptionHandleController.hbs',
1009
+ data: templateData,
1010
+ skipIfExists: true
1011
+ },
1012
+ {
1013
+ type: 'add',
1014
+ path: `${root}/web/Result.java`,
1015
+ templateFile: 'templates/base/Result.hbs',
1016
+ data: templateData,
1017
+ skipIfExists: true
1018
+ },
1019
+ {
1020
+ type: 'add',
1021
+ path: `${root}/exception/${exceptionClass}.java`,
1022
+ templateFile: 'templates/exception.hbs',
1023
+ data: {
1024
+ ...templateData,
1025
+ exceptionClass,
1026
+ defaultMessage: data.defaultMessage,
1027
+ errorCode,
1028
+ httpStatus: data.httpStatus,
1029
+ withExample: isExample(data)
1030
+ }
1031
+ }
1032
+ ];
1033
+ }
1034
+ });
1035
+
1036
+ // ---------- add:像 php artisan / ng add,往「既有專案」裡加一個功能模組 ----------
1037
+ plop.setGenerator('add', {
1038
+ description: '在既有專案中加入 email / sso / oauth 等功能模組(會改動 application.yml、build.gradle)',
1039
+ prompts: [
1040
+ {
1041
+ type: 'checkbox',
1042
+ name: 'features',
1043
+ message: '要加入哪些功能?(space 選取,enter 確認,可複選)',
1044
+ choices: () => {
1045
+ const installed = new Set(loadProjectConfig().features || []);
1046
+ return groupedFeatureChoices({
1047
+ installed,
1048
+ allowInstalled: isAddForce()
1049
+ });
1050
+ }
1051
+ },
1052
+ {
1053
+ type: 'input',
1054
+ name: 'gitlabProjectPath',
1055
+ message: 'GitLab 專案路徑(CI 只在這個 repo 跑,例如 general-hospital/campus-activity-backend;可留空):',
1056
+ when: (answers) => (answers.features || []).includes('gitlabCi'),
1057
+ default: ''
1058
+ }
1059
+ ],
1060
+ actions: function (data) {
1061
+ if (!isAddSync(data) && (!data.features || data.features.length === 0)) {
1062
+ return [];
1063
+ }
1064
+ return buildAddActions(data);
1065
+ }
1066
+ });
1067
+
1068
+ plop.setGenerator('sync', {
1069
+ description: '用目前 template 覆寫已安裝 feature 的檔(不動 application.yml / build.gradle)',
1070
+ prompts: [],
1071
+ actions: function (data) {
1072
+ return buildAddActions({ ...data, force: true, sync: true });
1073
+ }
1074
+ });
1075
+
1076
+ // ---------- create:像 npm create vite@latest,從零建一個新專案資料夾 ----------
1077
+ plop.setGenerator('create', {
1078
+ description: '建立一個新的 BIRC Spring Boot 多模組專案(互動選擇要不要 email / sso / oauth)',
1079
+ prompts: [
1080
+ {
1081
+ type: 'input',
1082
+ name: 'projectName',
1083
+ message: '專案名稱(會建立同名資料夾,例如 campus-activity):',
1084
+ default: CREATE_DEFAULTS.projectName,
1085
+ validate: validateProjectName
1086
+ },
1087
+ {
1088
+ type: 'input',
1089
+ name: 'basePackage',
1090
+ message: 'Base package(例如 tw.edu.ntub.birc.campus.activity):',
1091
+ default: (answers) => deriveBasePackage(answers.projectName) || CREATE_DEFAULTS.basePackage,
1092
+ validate: validateJavaPackage
1093
+ },
1094
+ {
1095
+ type: 'checkbox',
1096
+ name: 'features',
1097
+ message: '要內建哪些功能?(space 選取,enter 確認,之後也可以用 birc add 再加)',
1098
+ choices: groupedFeatureChoices({ withDefaults: true })
1099
+ },
1100
+ {
1101
+ type: 'input',
1102
+ name: 'gitlabProjectPath',
1103
+ message: 'GitLab 專案路徑(CI 只在這個 repo 跑,例如 general-hospital/campus-activity-backend;可留空):',
1104
+ when: (answers) => (answers.features || []).includes('gitlabCi'),
1105
+ default: ''
1106
+ },
1107
+ {
1108
+ type: 'confirm',
1109
+ name: 'injectDocs',
1110
+ message: '要注入 AGENTS.md / PROJECT.md / implement.md / test.md 嗎?(給 AI coding agent 讀)',
1111
+ default: true
1112
+ }
1113
+ ],
1114
+ actions: function (data) {
1115
+ const projectDir = toKebab(data.projectName);
1116
+ const basePackagePath = data.basePackage.replace(/\./g, '/');
1117
+
1118
+ const layout = persistenceLayout(data.basePackage, {
1119
+ multiModule: true,
1120
+ projectNameKebab: projectDir
1121
+ });
1122
+
1123
+ const templateData = {
1124
+ ...VERSIONS,
1125
+ basePackage: data.basePackage,
1126
+ projectName: data.projectName,
1127
+ projectNameKebab: projectDir,
1128
+ multiModule: true,
1129
+ entityPackage: layout.entityPackage,
1130
+ daoPackage: layout.daoPackage,
1131
+ entityPath: layout.entityRoot,
1132
+ daoPath: layout.daoRoot,
1133
+ gitlabProjectPath: data.gitlabProjectPath || '',
1134
+ dbRootPassword: randomDbPassword(),
1135
+ dbPassword: randomDbPassword(),
1136
+ hasDocker: (data.features || []).includes('docker')
1137
+ };
1138
+
1139
+ const srcPath = 'src/main/java';
1140
+ const testSrcPath = 'src/test/java';
1141
+ const resourcesPath = 'src/main/resources';
1142
+ const root = `${projectDir}/${srcPath}/${basePackagePath}`;
1143
+ const testRoot = `${projectDir}/${testSrcPath}/${basePackagePath}`;
1144
+ const resourcesRoot = `${projectDir}/${resourcesPath}`;
1145
+ const ymlPath = `${resourcesRoot}/application.yml`;
1146
+ const gradlePath = `${projectDir}/build.gradle`;
1147
+
1148
+ let actions = [
1149
+ { type: 'assertCreateDestFree', abortOnFail: true },
1150
+ { type: 'add', path: `${root}/Application.java`, templateFile: 'templates/base/Application.hbs', data: templateData },
1151
+ { type: 'add', path: `${testRoot}/ApplicationTests.java`, templateFile: 'templates/base/ApplicationTests.java.hbs', data: templateData },
1152
+ { type: 'add', path: `${projectDir}/src/test/resources/application.yml`, templateFile: 'templates/base/application-test.yml.hbs', data: templateData },
1153
+ { type: 'add', path: gradlePath, templateFile: 'templates/multi-module/build.gradle.hbs', data: templateData },
1154
+ { type: 'add', path: `${projectDir}/settings.gradle`, templateFile: 'templates/multi-module/settings.gradle.hbs', data: templateData },
1155
+ { type: 'add', path: `${resourcesRoot}/application.yml`, templateFile: 'templates/base/application.yml.hbs', data: templateData },
1156
+ { type: 'add', path: `${projectDir}/.gitignore`, templateFile: 'templates/base/gitignore.hbs', data: templateData },
1157
+ { type: 'copyFile', from: 'templates/gradle-wrapper/gradlew', path: `${projectDir}/gradlew`, executable: true },
1158
+ { type: 'copyFile', from: 'templates/gradle-wrapper/gradlew.bat', path: `${projectDir}/gradlew.bat` },
1159
+ {
1160
+ type: 'copyFile',
1161
+ from: 'templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.jar',
1162
+ path: `${projectDir}/gradle/wrapper/gradle-wrapper.jar`
1163
+ },
1164
+ {
1165
+ type: 'copyFile',
1166
+ from: 'templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.properties',
1167
+ path: `${projectDir}/gradle/wrapper/gradle-wrapper.properties`
1168
+ },
1169
+ {
1170
+ type: 'add',
1171
+ path: `${projectDir}/.bircrc.json`,
1172
+ templateFile: 'templates/base/bircrc.hbs',
1173
+ data: { ...templateData, featuresJson: JSON.stringify(data.features) }
1174
+ }
1175
+ ];
1176
+ actions = actions.concat(exceptionSkeletonActions(root, templateData));
1177
+ actions = actions.concat(serviceSkeletonActions(root, templateData));
1178
+
1179
+ const configRoot = `${projectDir}/modules/${projectDir}-config/src/main/java/${basePackagePath}/config`;
1180
+ actions.push(
1181
+ { type: 'add', path: `${projectDir}/modules/${projectDir}-config/build.gradle`, templateFile: 'templates/multi-module/config/build.gradle.hbs', data: templateData },
1182
+ { type: 'add', path: `${configRoot}/Config.java`, templateFile: 'templates/multi-module/config/Config.java.hbs', data: templateData },
1183
+ { type: 'add', path: `${configRoot}/ApplicationConfig.java`, templateFile: 'templates/multi-module/config/ApplicationConfig.java.hbs', data: templateData },
1184
+ { type: 'add', path: `${configRoot}/SecurityConfig.java`, templateFile: 'templates/multi-module/config/SecurityConfig.java.hbs', data: templateData }
1185
+ );
1186
+
1187
+ const dbConfigRoot = `${projectDir}/modules/${projectDir}-database-config/src/main/java/${basePackagePath}/databaseconfig`;
1188
+ actions.push(
1189
+ { type: 'add', path: `${projectDir}/modules/${projectDir}-database-config/build.gradle`, templateFile: 'templates/multi-module/database-config/build.gradle.hbs', data: templateData },
1190
+ { type: 'add', path: `${dbConfigRoot}/Config.java`, templateFile: 'templates/multi-module/database-config/Config.java.hbs', data: templateData },
1191
+ { type: 'add', path: `${dbConfigRoot}/JpaConfig.java`, templateFile: 'templates/multi-module/database-config/JpaConfig.java.hbs', data: templateData },
1192
+ { type: 'add', path: `${projectDir}/${layout.daoRoot}/BaseViewDAO.java`, templateFile: 'templates/multi-module/database-config/BaseViewDAO.java.hbs', data: templateData },
1193
+ { type: 'add', path: `${projectDir}/${layout.daoRoot}/BaseDAO.java`, templateFile: 'templates/multi-module/database-config/BaseDAO.java.hbs', data: templateData }
1194
+ );
1195
+
1196
+ for (const featureKey of data.features) {
1197
+ actions = actions.concat(
1198
+ featureActions(featureKey, {
1199
+ root,
1200
+ resourcesRoot,
1201
+ projectRoot: projectDir,
1202
+ ymlPath,
1203
+ gradlePath,
1204
+ templateData
1205
+ })
1206
+ );
1207
+ }
1208
+
1209
+ actions.push({
1210
+ type: 'add',
1211
+ path: `${projectDir}/README.md`,
1212
+ templateFile: 'templates/base/README.md.hbs',
1213
+ data: templateData,
1214
+ skipIfExists: true
1215
+ });
1216
+
1217
+ if (data.injectDocs) {
1218
+ actions.push(
1219
+ { type: 'add', path: `${projectDir}/AGENTS.md`, templateFile: 'project-docs/AGENTS.md.hbs', data: templateData },
1220
+ { type: 'add', path: `${projectDir}/PROJECT.md`, templateFile: 'project-docs/PROJECT.md.hbs', data: templateData },
1221
+ { type: 'add', path: `${projectDir}/implement.md`, templateFile: 'project-docs/implement.hbs', data: templateData },
1222
+ { type: 'add', path: `${projectDir}/test.md`, templateFile: 'project-docs/test.hbs', data: templateData }
1223
+ );
1224
+ }
1225
+
1226
+ if ((data.features || []).includes('docker')) {
1227
+ actions.push({
1228
+ type: 'copyIfExists',
1229
+ from: `${projectDir}/.env.example`,
1230
+ path: `${projectDir}/.env`
1231
+ });
1232
+ }
1233
+
1234
+ return actions;
1235
+ }
1236
+ });
1237
+
1238
+ // ---------- docs:給 AI coding agent 讀的目錄與規範 ----------
1239
+ plop.setGenerator('docs', {
1240
+ description: '產生 AGENTS.md / PROJECT.md / implement.md / test.md',
1241
+ prompts: [],
1242
+ actions: function () {
1243
+ const config = loadProjectConfig();
1244
+ const projectName = config.projectNameKebab || inferProjectNameKebab(config);
1245
+ const templateData = { projectName, projectNameKebab: projectName };
1246
+ return [
1247
+ { type: 'add', path: 'AGENTS.md', templateFile: 'project-docs/AGENTS.md.hbs', data: templateData, skipIfExists: true },
1248
+ { type: 'add', path: 'PROJECT.md', templateFile: 'project-docs/PROJECT.md.hbs', data: templateData, skipIfExists: true },
1249
+ { type: 'add', path: 'implement.md', templateFile: 'project-docs/implement.hbs', data: templateData, skipIfExists: true },
1250
+ { type: 'add', path: 'test.md', templateFile: 'project-docs/test.hbs', data: templateData, skipIfExists: true }
1251
+ ];
1252
+ }
1253
+ });
1254
+
1255
+ function migrationDir(config) {
1256
+ const resourcesPath = config.resourcesPath || 'src/main/resources';
1257
+ return config.migrationPath || `${resourcesPath}/db/migration`;
1258
+ }
1259
+
1260
+ function makeMigrationActions(data) {
1261
+ const config = loadProjectConfig();
1262
+ const migrationPath = migrationDir(config);
1263
+ const parsed = parseMigrationName(data.description);
1264
+ const version = data.migrationVersion || nextMigrationVersion(path.join(projectRoot(), migrationPath));
1265
+ const example = isExample(data);
1266
+ return [
1267
+ {
1268
+ type: 'add',
1269
+ path: `${migrationPath}/V${version}__${parsed.description}.sql`,
1270
+ templateFile: migrationTemplate(parsed.kind),
1271
+ data: {
1272
+ description: parsed.description,
1273
+ tableName: parsed.tableName,
1274
+ columnName: parsed.columnName,
1275
+ withExample: example,
1276
+ withSoftDelete: includeSoftDelete(data)
1277
+ }
1278
+ }
1279
+ ];
1280
+ }
1281
+
1282
+ function makeSeedActions(data) {
1283
+ const config = loadProjectConfig();
1284
+ const migrationPath = migrationDir(config);
1285
+ const tableName = tableNameFromEntity(data.entityName);
1286
+ const description = sanitizeMigrationToken(`seed_${tableName}`) || 'seed';
1287
+ const version = data.migrationVersion || nextMigrationVersion(path.join(projectRoot(), migrationPath));
1288
+ return [
1289
+ {
1290
+ type: 'add',
1291
+ path: `${migrationPath}/V${version}__${description}.sql`,
1292
+ templateFile: 'templates/migration/seed.hbs',
1293
+ data: {
1294
+ description,
1295
+ tableName,
1296
+ withExample: isExample(data)
1297
+ }
1298
+ }
1299
+ ];
1300
+ }
1301
+
1302
+ function makeModelActions(data) {
1303
+ const actions = makeEntityActions(data);
1304
+ if (includeMapperWithEntity(data)) {
1305
+ actions.push(...makeMapperActions(data));
1306
+ }
1307
+ const config = loadProjectConfig();
1308
+ const migrationPath = migrationDir(config);
1309
+ let version = nextMigrationVersion(path.join(projectRoot(), migrationPath));
1310
+ if (includeModelMigration(data)) {
1311
+ const tableName = tableNameFromEntity(data.entityName);
1312
+ actions.push(
1313
+ ...makeMigrationActions({
1314
+ ...data,
1315
+ description: `create_${tableName}_table`,
1316
+ migrationVersion: version
1317
+ })
1318
+ );
1319
+ version += 1;
1320
+ }
1321
+ if (includeModelSeed(data)) {
1322
+ actions.push(...makeSeedActions({ ...data, migrationVersion: version }));
1323
+ }
1324
+ if (includeModelController(data)) {
1325
+ actions.push(...makeControllerActions(data));
1326
+ }
1327
+ return actions;
1328
+ }
1329
+
1330
+ const migrationPrompts = [
1331
+ {
1332
+ type: 'input',
1333
+ name: 'description',
1334
+ message: '遷移名稱(例如 create_users_table、add_votes_to_users_table):',
1335
+ validate: validateMigrationName
1336
+ }
1337
+ ];
1338
+
1339
+ plop.setGenerator('make:migration', {
1340
+ description: '新增 Flyway 遷移(Laravel 風格名稱,例如 create_users_table)',
1341
+ prompts: migrationPrompts,
1342
+ actions: makeMigrationActions
1343
+ });
1344
+
1345
+ plop.setGenerator('migration', {
1346
+ description: 'make:migration 的別名',
1347
+ prompts: migrationPrompts,
1348
+ actions: makeMigrationActions
1349
+ });
1350
+ };
1351
+
1352
+ module.exports.FEATURES = FEATURES;
1353
+ module.exports.CREATE_DEFAULTS = CREATE_DEFAULTS;
1354
+ module.exports.DEFAULT_FEATURES = DEFAULT_FEATURES;
1355
+ module.exports.FEATURE_GROUPS = FEATURE_GROUPS;
1356
+ module.exports.groupedFeatureChoices = groupedFeatureChoices;
1357
+ module.exports.validateMigrationName = validateMigrationName;
1358
+ module.exports.parseMigrationName = parseMigrationName;