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
@@ -0,0 +1,558 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const VERSIONS = require('../versions');
4
+ const {
5
+ randomDbPassword,
6
+ loadProjectConfig,
7
+ inferProjectNameKebab,
8
+ resolvePersistence,
9
+ projectRoot,
10
+ YML_ANCHOR,
11
+ GRADLE_ANCHOR,
12
+ GRADLE_PLUGIN_ANCHOR,
13
+ GRADLE_ALLPROJECTS_ANCHOR,
14
+ requireGradleAnchor,
15
+ gradleAnchorPattern
16
+ } = require('./project');
17
+
18
+ function uniqueFeatureKeys(features) {
19
+ return [...new Set((features || []).filter((featureKey) => FEATURES[featureKey]))];
20
+ }
21
+
22
+ function isAddForce(data) {
23
+ return Boolean(data && data.force) || process.env.BIRC_ADD_FORCE === '1';
24
+ }
25
+
26
+ function isAddSync(data) {
27
+ return Boolean(data && data.sync) || process.env.BIRC_ADD_SYNC === '1';
28
+ }
29
+
30
+ function preflightAdd(featureKeys, { destBase, ymlPath, gradlePath }) {
31
+ const errors = [];
32
+ for (const featureKey of featureKeys) {
33
+ const feature = FEATURES[featureKey];
34
+ if (feature.ymlBlock) {
35
+ const target = path.resolve(destBase, ymlPath);
36
+ if (!fs.existsSync(target)) {
37
+ errors.push(`${featureKey}: 找不到 ${ymlPath}`);
38
+ } else if (!fs.readFileSync(target, 'utf8').includes(YML_ANCHOR)) {
39
+ errors.push(`${featureKey}: ${ymlPath} 沒有 ${YML_ANCHOR}`);
40
+ }
41
+ }
42
+ if (feature.gradleDep) {
43
+ requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_ANCHOR);
44
+ }
45
+ if (feature.gradlePlugin) {
46
+ requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_PLUGIN_ANCHOR);
47
+ }
48
+ if (feature.gradleAllprojects) {
49
+ requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_ALLPROJECTS_ANCHOR);
50
+ }
51
+ }
52
+ if (errors.length > 0) {
53
+ throw new Error(`add 預檢查失敗,沒有寫任何檔:\n${errors.map((line) => ` ${line}`).join('\n')}`);
54
+ }
55
+ }
56
+
57
+ function buildAddActions(data) {
58
+ const force = isAddForce(data);
59
+ const syncAll = isAddSync(data);
60
+ const config = loadProjectConfig();
61
+ const installed = new Set(config.features || []);
62
+ const selected = syncAll
63
+ ? uniqueFeatureKeys(config.features)
64
+ : uniqueFeatureKeys(data.features);
65
+ const featuresToAdd = selected.filter((featureKey) => force || syncAll || !installed.has(featureKey));
66
+ if (featuresToAdd.length === 0) {
67
+ return [];
68
+ }
69
+
70
+ const basePackage = config.basePackage;
71
+ const basePackagePath = basePackage.replace(/\./g, '/');
72
+ const srcPath = config.srcPath || 'src/main/java';
73
+ const resourcesPath = config.resourcesPath || 'src/main/resources';
74
+ const ymlPath = config.applicationYmlPath || `${resourcesPath}/application.yml`;
75
+ const gradlePath = config.buildGradlePath || 'build.gradle';
76
+ const root = `${srcPath}/${basePackagePath}`;
77
+ const testRoot = `${config.testSrcPath || 'src/test/java'}/${basePackagePath}`;
78
+ const persistence = resolvePersistence(config);
79
+ const destBase = projectRoot();
80
+
81
+ preflightAdd(featuresToAdd, { destBase, ymlPath, gradlePath });
82
+
83
+ const templateData = {
84
+ ...VERSIONS,
85
+ basePackage,
86
+ projectName: config.projectNameKebab || inferProjectNameKebab(config),
87
+ projectNameKebab: config.projectNameKebab || inferProjectNameKebab(config),
88
+ gitlabProjectPath: data.gitlabProjectPath || '',
89
+ dbRootPassword: randomDbPassword(),
90
+ dbPassword: randomDbPassword(),
91
+ entityPackage: persistence.entityPackage,
92
+ daoPackage: persistence.daoPackage
93
+ };
94
+
95
+ let actions = [];
96
+ for (const featureKey of featuresToAdd) {
97
+ const reinstall = installed.has(featureKey);
98
+ actions = actions.concat(
99
+ featureActions(featureKey, {
100
+ root,
101
+ testRoot,
102
+ entityRoot: persistence.entityRoot,
103
+ daoRoot: persistence.daoRoot,
104
+ resourcesRoot: resourcesPath,
105
+ projectRoot: '.',
106
+ ymlPath,
107
+ gradlePath,
108
+ templateData,
109
+ overwriteFiles: force || syncAll,
110
+ skipConfigFragments: reinstall && (force || syncAll)
111
+ })
112
+ );
113
+ }
114
+
115
+ actions.push({ type: 'updateProjectFeatures', features: featuresToAdd });
116
+ return actions;
117
+ }
118
+
119
+ // 每個 feature 對應:要加哪些檔案、要往 application.yml / build.gradle 的哪個 anchor 插入什麼
120
+ const FEATURES = {
121
+ email: {
122
+ label: 'Email(JavaMailSender + Thymeleaf 模板)',
123
+ files: [
124
+ { templateFile: 'templates/email/EmailService.hbs', dest: 'service/EmailService.java' },
125
+ { templateFile: 'templates/email/EmailServiceImpl.hbs', dest: 'service/impl/EmailServiceImpl.java' },
126
+ { templateFile: 'templates/email/EmailAsyncConfig.hbs', dest: 'config/EmailAsyncConfig.java' }
127
+ ],
128
+ resourceFiles: [
129
+ { templateFile: 'templates/email/sample-template.hbs', dest: 'email/sample.html' }
130
+ ],
131
+ ymlBlock: 'templates/email/application-yml-block.hbs',
132
+ gradleDep: 'templates/email/build-gradle-dep.hbs'
133
+ },
134
+ auth: {
135
+ label: '帳號登入(auth_users 表 + JWT,POST /api/auth/login 回 X-Auth-Token)',
136
+ files: [
137
+ { templateFile: 'templates/auth/JwtProperties.hbs', dest: 'config/JwtProperties.java' },
138
+ { templateFile: 'templates/auth/AuthSecurityCustomizer.hbs', dest: 'config/AuthSecurityCustomizer.java' },
139
+ { templateFile: 'templates/auth/JwtSecretEnvironmentPostProcessor.hbs', dest: 'config/JwtSecretEnvironmentPostProcessor.java' },
140
+ { templateFile: 'templates/auth/JwtService.hbs', dest: 'security/JwtService.java' },
141
+ { templateFile: 'templates/auth/JwtAuthenticationFilter.hbs', dest: 'security/JwtAuthenticationFilter.java' },
142
+ { templateFile: 'templates/auth/JpaUserDetailsService.hbs', dest: 'security/JpaUserDetailsService.java' },
143
+ { templateFile: 'templates/auth/LoginRequest.hbs', dest: 'dto/LoginRequest.java' },
144
+ { templateFile: 'templates/auth/AuthController.hbs', dest: 'controller/AuthController.java' },
145
+ { templateFile: 'templates/auth/LoginFailedException.hbs', dest: 'exception/LoginFailedException.java' }
146
+ ],
147
+ entityFiles: [{ templateFile: 'templates/auth/AuthUser.hbs', dest: 'AuthUser.java' }],
148
+ daoFiles: [{ templateFile: 'templates/auth/AuthUserDAO.hbs', dest: 'AuthUserDAO.java' }],
149
+ testFiles: [
150
+ { templateFile: 'templates/auth/AuthFlowTest.hbs', dest: 'AuthFlowTest.java' },
151
+ { templateFile: 'templates/auth/JwtSecretEnvTest.hbs', dest: 'JwtSecretEnvTest.java' }
152
+ ],
153
+ resourceFiles: [
154
+ { templateFile: 'templates/auth/V1__create_auth_users_table.sql.hbs', dest: 'db/migration/V1__create_auth_users_table.sql' },
155
+ { templateFile: 'templates/auth/spring.factories.hbs', dest: 'META-INF/spring.factories' }
156
+ ],
157
+ ymlBlock: 'templates/auth/application-yml-block.hbs',
158
+ gradleDep: 'templates/auth/build-gradle-dep.hbs'
159
+ },
160
+
161
+ sso: {
162
+ label: 'SSO(只有設定骨架,驗票與發證要自己接)',
163
+ files: [
164
+ { templateFile: 'templates/sso/SsoProperties.hbs', dest: 'security/SsoProperties.java' },
165
+ { templateFile: 'templates/sso/SsoAutoConfiguration.hbs', dest: 'config/SsoAutoConfiguration.java' }
166
+ ],
167
+ resourceFiles: [],
168
+ ymlBlock: 'templates/sso/application-yml-block.hbs',
169
+ gradleDep: null
170
+ },
171
+ oauth: {
172
+ label: 'OAuth2 登入(範例接 Google,success handler 要自己掛 SecurityCustomizer)',
173
+ files: [
174
+ { templateFile: 'templates/oauth/OAuth2LoginSuccessHandler.hbs', dest: 'config/OAuth2LoginSuccessHandler.java' }
175
+ ],
176
+ resourceFiles: [],
177
+ ymlBlock: 'templates/oauth/application-yml-block.hbs',
178
+ gradleDep: 'templates/oauth/build-gradle-dep.hbs'
179
+ },
180
+ scheduling: {
181
+ label: '排程(Scheduling Tasks,@EnableScheduling + 範例 Job)',
182
+ files: [
183
+ { templateFile: 'templates/scheduling/SchedulingConfig.hbs', dest: 'config/SchedulingConfig.java' },
184
+ { templateFile: 'templates/scheduling/SampleSchedule.hbs', dest: 'schedule/SampleSchedule.java' }
185
+ ],
186
+ resourceFiles: [],
187
+ ymlBlock: 'templates/scheduling/application-yml-block.hbs',
188
+ gradleDep: null
189
+ },
190
+ aop: {
191
+ label: 'AOP 事件記錄(自訂 @OperationLog 註解 + Aspect)',
192
+ files: [
193
+ { templateFile: 'templates/aop/OperationLog.hbs', dest: 'annotation/OperationLog.java' },
194
+ { templateFile: 'templates/aop/OperationLogAspect.hbs', dest: 'aspect/OperationLogAspect.java' }
195
+ ],
196
+ resourceFiles: [],
197
+ ymlBlock: null,
198
+ gradleDep: 'templates/aop/build-gradle-dep.hbs'
199
+ },
200
+ client: {
201
+ label: 'Client(呼叫外部 API 的 RestClient 封裝)',
202
+ files: [
203
+ { templateFile: 'templates/client/ExternalApiProperties.hbs', dest: 'config/ExternalApiProperties.java' },
204
+ { templateFile: 'templates/client/ExternalApiClientConfig.hbs', dest: 'config/ExternalApiClientConfig.java' },
205
+ { templateFile: 'templates/client/ExternalApiClient.hbs', dest: 'client/ExternalApiClient.java' }
206
+ ],
207
+ resourceFiles: [],
208
+ ymlBlock: 'templates/client/application-yml-block.hbs',
209
+ gradleDep: null
210
+ },
211
+ clockin: {
212
+ label: '簽到(接中心簽到系統 BIRC CMS 的 API)',
213
+ files: [
214
+ { templateFile: 'templates/clockin/ClockInProperties.hbs', dest: 'config/ClockInProperties.java' },
215
+ { templateFile: 'templates/clockin/ClockInClientConfig.hbs', dest: 'config/ClockInClientConfig.java' },
216
+ { templateFile: 'templates/clockin/ClockInClient.hbs', dest: 'client/ClockInClient.java' },
217
+ { templateFile: 'templates/clockin/ClockInApiResponse.hbs', dest: 'dto/clockin/ClockInApiResponse.java' },
218
+ { templateFile: 'templates/clockin/ClockInRecord.hbs', dest: 'dto/clockin/ClockInRecord.java' },
219
+ { templateFile: 'templates/clockin/ClockInPage.hbs', dest: 'dto/clockin/ClockInPage.java' },
220
+ { templateFile: 'templates/clockin/UnclockedMember.hbs', dest: 'dto/clockin/UnclockedMember.java' },
221
+ { templateFile: 'templates/clockin/UserPermission.hbs', dest: 'dto/clockin/UserPermission.java' },
222
+ { templateFile: 'templates/clockin/OnDutyWeek.hbs', dest: 'dto/clockin/OnDutyWeek.java' },
223
+ { templateFile: 'templates/clockin/MemberImage.hbs', dest: 'dto/clockin/MemberImage.java' },
224
+ {
225
+ templateFile: 'templates/clockin/ClockInApiException.hbs',
226
+ dest: 'exception/clockin/ClockInApiException.java'
227
+ },
228
+ { templateFile: 'templates/clockin/ClockInController.hbs', dest: 'controller/ClockInController.java' }
229
+ ],
230
+ resourceFiles: [],
231
+ ymlBlock: 'templates/clockin/application-yml-block.hbs',
232
+ gradleDep: null
233
+ },
234
+ sentry: {
235
+ label: 'Sentry(錯誤追蹤 + 客戶端例外過濾)',
236
+ files: [
237
+ { templateFile: 'templates/sentry/SentryConfig.hbs', dest: 'config/SentryConfig.java' }
238
+ ],
239
+ resourceFiles: [],
240
+ ymlBlock: 'templates/sentry/application-yml-block.hbs',
241
+ gradleDep: 'templates/sentry/build-gradle-dep.hbs'
242
+ },
243
+ log4j2: {
244
+ label: 'Log4j2(主控台 + rolling file + MDC)',
245
+ files: [],
246
+ resourceFiles: [
247
+ { templateFile: 'templates/log4j2/log4j2-spring.xml.hbs', dest: 'log4j2-spring.xml' }
248
+ ],
249
+ ymlBlock: null,
250
+ gradleDep: 'templates/log4j2/build-gradle-dep.hbs'
251
+ },
252
+ openapi: {
253
+ label: 'OpenAPI(springdoc + Swagger UI + Bearer JWT)',
254
+ files: [
255
+ { templateFile: 'templates/openapi/OpenApiConfig.hbs', dest: 'config/OpenApiConfig.java' }
256
+ ],
257
+ testFiles: [
258
+ { templateFile: 'templates/openapi/ApiDocsAccessTest.hbs', dest: 'ApiDocsAccessTest.java' }
259
+ ],
260
+ resourceFiles: [],
261
+ ymlBlock: 'templates/openapi/application-yml-block.hbs',
262
+ gradleDep: 'templates/openapi/build-gradle-dep.hbs'
263
+ },
264
+ fileUpload: {
265
+ label: '檔案上傳(本機磁碟儲存,可自行改接 S3 / MinIO)',
266
+ files: [
267
+ { templateFile: 'templates/file-upload/FileStorageProperties.hbs', dest: 'config/FileStorageProperties.java' },
268
+ { templateFile: 'templates/file-upload/FileStorageService.hbs', dest: 'service/FileStorageService.java' },
269
+ { templateFile: 'templates/file-upload/FileStorageServiceImpl.hbs', dest: 'service/impl/FileStorageServiceImpl.java' },
270
+ { templateFile: 'templates/file-upload/FileUploadController.hbs', dest: 'controller/FileUploadController.java' },
271
+ { templateFile: 'templates/file-upload/FileUtils.hbs', dest: 'util/file/FileUtils.java' },
272
+ { templateFile: 'templates/file-upload/FileExtensionUtils.hbs', dest: 'util/file/FileExtensionUtils.java' },
273
+ { templateFile: 'templates/file-upload/EmptyFileException.hbs', dest: 'exception/file/EmptyFileException.java' },
274
+ { templateFile: 'templates/file-upload/FileExtensionIllegalException.hbs', dest: 'exception/file/FileExtensionIllegalException.java' },
275
+ { templateFile: 'templates/file-upload/InvalidStoredFileException.hbs', dest: 'exception/file/InvalidStoredFileException.java' },
276
+ { templateFile: 'templates/file-upload/FileTooLargeException.hbs', dest: 'exception/file/FileTooLargeException.java' }
277
+ ],
278
+ testFiles: [
279
+ { templateFile: 'templates/file-upload/FileExtensionUtilsTest.hbs', dest: 'util/file/FileExtensionUtilsTest.java' }
280
+ ],
281
+ resourceFiles: [],
282
+ ymlBlock: 'templates/file-upload/application-yml-block.hbs',
283
+ gradleDep: null
284
+ },
285
+ pagination: {
286
+ label: '分頁查詢(Specification 動態條件 + PageRequest/PageResponse)',
287
+ files: [
288
+ { templateFile: 'templates/pagination/SearchCriteria.hbs', dest: 'specification/SearchCriteria.java' },
289
+ { templateFile: 'templates/pagination/GenericSpecification.hbs', dest: 'specification/GenericSpecification.java' },
290
+ { templateFile: 'templates/pagination/SpecificationSupport.hbs', dest: 'specification/SpecificationSupport.java' },
291
+ { templateFile: 'templates/pagination/PageRequest.hbs', dest: 'dto/PageRequest.java' },
292
+ { templateFile: 'templates/pagination/PageResponse.hbs', dest: 'dto/PageResponse.java' },
293
+ { templateFile: 'templates/pagination/Pager.hbs', dest: 'dto/Pager.java' },
294
+ { templateFile: 'templates/pagination/PageInfo.hbs', dest: 'dto/PageInfo.java' }
295
+ ],
296
+ resourceFiles: [],
297
+ ymlBlock: null,
298
+ gradleDep: null
299
+ },
300
+ validGroup: {
301
+ label: '驗證群組(ValidGroup:Create / Update / Delete / Submit)',
302
+ files: [
303
+ { templateFile: 'templates/valid-group/ValidGroup.hbs', dest: 'validation/ValidGroup.java' }
304
+ ],
305
+ resourceFiles: [],
306
+ ymlBlock: null,
307
+ gradleDep: null
308
+ },
309
+ permission: {
310
+ label: '權限控管(@RequirePermission,需搭配帳號登入或自己的登入機制)',
311
+ files: [
312
+ { templateFile: 'templates/permission/RequirePermission.hbs', dest: 'annotation/RequirePermission.java' },
313
+ { templateFile: 'templates/permission/PermissionAspect.hbs', dest: 'aspect/PermissionAspect.java' }
314
+ ],
315
+ resourceFiles: [],
316
+ ymlBlock: null,
317
+ gradleDep: 'templates/permission/build-gradle-dep.hbs'
318
+ },
319
+ gitlabCi: {
320
+ label: '.gitlab-ci.yml(Harbor build/push + SSH deploy,對齊 teaching-platform)',
321
+ files: [],
322
+ resourceFiles: [],
323
+ rootFiles: [
324
+ { templateFile: 'templates/gitlab-ci/gitlab-ci.hbs', dest: '.gitlab-ci.yml' }
325
+ ],
326
+ ymlBlock: null,
327
+ gradleDep: null
328
+ },
329
+ docker: {
330
+ label: 'Docker(多階段 Dockerfile + compose + .env.example)',
331
+ files: [],
332
+ resourceFiles: [],
333
+ rootFiles: [
334
+ { templateFile: 'templates/docker/Dockerfile.hbs', dest: 'Dockerfile' },
335
+ { templateFile: 'templates/docker/docker-compose.yml.hbs', dest: 'docker-compose.yml' },
336
+ { templateFile: 'templates/docker/docker-compose.prod.yml.hbs', dest: 'docker-compose.prod.yml' },
337
+ { templateFile: 'templates/docker/env.example.hbs', dest: '.env.example' },
338
+ { templateFile: 'templates/docker/dockerignore.hbs', dest: '.dockerignore' }
339
+ ],
340
+ ymlBlock: null,
341
+ gradleDep: null
342
+ },
343
+ spotless: {
344
+ label: 'Spotless(Eclipse 4.31 格式化,commit 前 ./gradlew spotlessApply)',
345
+ files: [],
346
+ resourceFiles: [],
347
+ rootFiles: [
348
+ { templateFile: 'templates/spotless/spotless_formatter.xml', dest: 'spotless_formatter.xml' }
349
+ ],
350
+ ymlBlock: null,
351
+ gradleDep: null,
352
+ gradlePlugin: 'templates/spotless/build-gradle-plugin.hbs',
353
+ gradleAllprojects: 'templates/spotless/build-gradle-allprojects.hbs'
354
+ }
355
+ };
356
+
357
+ const FEATURE_GROUPS = [
358
+ {
359
+ title: '常用',
360
+ keys: [
361
+ 'docker',
362
+ 'log4j2',
363
+ 'openapi',
364
+ 'spotless',
365
+ 'validGroup',
366
+ 'pagination',
367
+ 'fileUpload',
368
+ 'email',
369
+ 'client',
370
+ 'clockin',
371
+ 'aop'
372
+ ]
373
+ },
374
+ { title: '登入', keys: ['auth', 'sso', 'oauth', 'permission'] },
375
+ { title: '營運', keys: ['gitlabCi', 'sentry', 'scheduling'] }
376
+ ];
377
+ const DEFAULT_FEATURES = new Set(['docker', 'log4j2', 'validGroup', 'spotless']);
378
+
379
+ function groupedFeatureChoices({
380
+ installed = new Set(),
381
+ allowInstalled = false,
382
+ withDefaults = false
383
+ } = {}) {
384
+ const used = new Set();
385
+ const choices = [];
386
+ for (const group of FEATURE_GROUPS) {
387
+ const keys = group.keys.filter((key) => FEATURES[key]);
388
+ if (keys.length === 0) continue;
389
+ choices.push({ type: 'separator', line: `── ${group.title} ──` });
390
+ for (const key of keys) {
391
+ used.add(key);
392
+ choices.push({
393
+ name: FEATURES[key].label,
394
+ value: key,
395
+ checked: withDefaults && DEFAULT_FEATURES.has(key),
396
+ disabled: !allowInstalled && installed.has(key) ? '已安裝' : false
397
+ });
398
+ }
399
+ }
400
+ const leftover = Object.keys(FEATURES).filter((key) => !used.has(key));
401
+ if (leftover.length > 0) {
402
+ choices.push({ type: 'separator', line: '── 其他 ──' });
403
+ for (const key of leftover) {
404
+ choices.push({
405
+ name: FEATURES[key].label,
406
+ value: key,
407
+ checked: withDefaults && DEFAULT_FEATURES.has(key),
408
+ disabled: !allowInstalled && installed.has(key) ? '已安裝' : false
409
+ });
410
+ }
411
+ }
412
+ return choices;
413
+ }
414
+
415
+ // 產生單一 feature 的 plop actions。root / resourcesRoot / ymlPath / gradlePath 都是絕對相對於
416
+ // plop destBasePath 的路徑,create 跟 add 共用同一份邏輯,差別只在 root 前面有沒有專案資料夾。
417
+ function featureActions(featureKey, {
418
+ root,
419
+ testRoot,
420
+ entityRoot,
421
+ daoRoot,
422
+ resourcesRoot,
423
+ projectRoot,
424
+ ymlPath,
425
+ gradlePath,
426
+ templateData,
427
+ overwriteFiles = false,
428
+ skipConfigFragments = false
429
+ }) {
430
+ const feature = FEATURES[featureKey];
431
+ if (!feature) {
432
+ throw new Error(`未知的 feature: ${featureKey}`);
433
+ }
434
+ const actions = [];
435
+
436
+ for (const f of feature.files) {
437
+ actions.push({
438
+ type: 'add',
439
+ path: `${root}/${f.dest}`,
440
+ templateFile: f.templateFile,
441
+ data: templateData,
442
+ skipIfExists: !overwriteFiles,
443
+ abortOnFail: true
444
+ });
445
+ }
446
+
447
+ for (const f of feature.testFiles || []) {
448
+ actions.push({
449
+ type: 'add',
450
+ path: `${testRoot}/${f.dest}`,
451
+ templateFile: f.templateFile,
452
+ data: templateData,
453
+ skipIfExists: !overwriteFiles,
454
+ abortOnFail: true
455
+ });
456
+ }
457
+
458
+ // Entity / DAO 不跟其它 Java 檔同一個 root:多模組時它們落在 databaseconfig 模組。
459
+ const persistenceFiles = [
460
+ [feature.entityFiles, entityRoot],
461
+ [feature.daoFiles, daoRoot]
462
+ ];
463
+ for (const [files, persistenceRoot] of persistenceFiles) {
464
+ for (const f of files || []) {
465
+ actions.push({
466
+ type: 'add',
467
+ path: `${persistenceRoot}/${f.dest}`,
468
+ templateFile: f.templateFile,
469
+ data: templateData,
470
+ skipIfExists: !overwriteFiles,
471
+ abortOnFail: true
472
+ });
473
+ }
474
+ }
475
+
476
+ for (const f of feature.resourceFiles) {
477
+ actions.push({
478
+ type: 'add',
479
+ path: `${resourcesRoot}/${f.dest}`,
480
+ templateFile: f.templateFile,
481
+ data: templateData,
482
+ skipIfExists: !overwriteFiles,
483
+ abortOnFail: true
484
+ });
485
+ }
486
+
487
+ // rootFiles:不屬於任何 package、也不進 resources 的檔案(.gitlab-ci.yml、Dockerfile 之類),
488
+ // 直接放到專案根目錄(create 是新資料夾根目錄,add 是目前目錄)。
489
+ if (feature.rootFiles) {
490
+ for (const f of feature.rootFiles) {
491
+ actions.push({
492
+ type: 'add',
493
+ path: `${projectRoot}/${f.dest}`,
494
+ templateFile: f.templateFile,
495
+ data: templateData,
496
+ skipIfExists: !overwriteFiles,
497
+ abortOnFail: true
498
+ });
499
+ }
500
+ }
501
+
502
+ if (feature.ymlBlock && !skipConfigFragments) {
503
+ actions.push({
504
+ type: 'appendFragment',
505
+ path: ymlPath,
506
+ pattern: new RegExp(`${YML_ANCHOR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n`),
507
+ templateFile: feature.ymlBlock,
508
+ data: templateData,
509
+ marker: featureKey,
510
+ commentToken: '#',
511
+ abortOnFail: true
512
+ });
513
+ }
514
+
515
+ // 同一個 feature 可能同時插 dependencies / plugins / allprojects 三處,marker 要分得開。
516
+ const gradleFragments = [
517
+ [feature.gradleDep, GRADLE_ANCHOR, featureKey],
518
+ [feature.gradlePlugin, GRADLE_PLUGIN_ANCHOR, `${featureKey}-plugin`],
519
+ [feature.gradleAllprojects, GRADLE_ALLPROJECTS_ANCHOR, `${featureKey}-allprojects`]
520
+ ];
521
+ for (const [templateFile, anchor, marker] of gradleFragments) {
522
+ if (templateFile && !skipConfigFragments) {
523
+ actions.push({
524
+ type: 'appendFragment',
525
+ path: gradlePath,
526
+ pattern: gradleAnchorPattern(anchor),
527
+ templateFile,
528
+ data: templateData,
529
+ marker,
530
+ commentToken: '//',
531
+ abortOnFail: true
532
+ });
533
+ }
534
+ }
535
+
536
+ // auth 的簽章金鑰只寫 .env(gitignore),不寫進 .env.example。
537
+ // 已有值不改,sync / add --force 也不該換一把新鑰匙。
538
+ if (featureKey === 'auth') {
539
+ actions.push({
540
+ type: 'ensureJwtSecret',
541
+ path: `${projectRoot}/.env`
542
+ });
543
+ }
544
+
545
+ return actions;
546
+ }
547
+
548
+ module.exports = {
549
+ FEATURES,
550
+ FEATURE_GROUPS,
551
+ DEFAULT_FEATURES,
552
+ uniqueFeatureKeys,
553
+ isAddForce,
554
+ isAddSync,
555
+ groupedFeatureChoices,
556
+ featureActions,
557
+ buildAddActions
558
+ };