birc-generator 0.5.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PROJECT.md CHANGED
@@ -12,7 +12,7 @@ anchor 就不要重寫整份設定;推測以後會用到的模組先不要做
12
12
  | 要做的事 | Seam | 誰會吃到 |
13
13
  | --- | --- | --- |
14
14
  | 新的可選模組 | `FEATURES` + `templates/<key>/` | `create`、`add`(共用 `featureActions`) |
15
- | `make` / `make:*` 骨架 | `templates/*.hbs` + `makeStackActions` / 單層 actions | `make`(整組)、`make:entity`、`make:model`、`make:mapper`、`make:service`、`make:controller`、`make:exception`、`make:migration` |
15
+ | `make` / `make:*` 骨架 | `templates/*.hbs` + `makeStackActions` / 單層 actions | `make`(整組)、`make:entity`、`make:model`、`make:mapper`、`make:service`、`make:controller`、`make:exception`、`make:migration`、`make:seeder` |
16
16
  | 注入給產出專案的 agent 文件 | `project-docs/` | `create`(`injectDocs`)、`docs` |
17
17
  | create 的基礎骨架 | `templates/base/`、`templates/multi-module/`、`templates/gradle-wrapper/` | `create`(含給人看的 `README.md`) |
18
18
  | CLI 進入點 | `bin/birc.js`、`bin/home-install.js`、`install.sh` / `install.ps1` | `birc`(banner、版本、`update`、直接呼叫 node-plop) |
@@ -48,7 +48,7 @@ anchor 就不要重寫整份設定;推測以後會用到的模組先不要做
48
48
 
49
49
  ## 慣例
50
50
 
51
- **make 一次整組。** `make` 串 `makeStackActions`:Entity + DAO + Mapper + DTO + Service + Controller。單層指令共用同一組 actions,不另寫一份。`make:entity` 產出 Entity、`XxxDAO extends BaseDAO`。`make:model` 同 Entity + DAO;`--migration` `create_{table}_table`(表名跟 Entity snake_case 對齊,`Flight` `flight`),`--seed` 生下一版 `seed_{table}` SQL,`--controller` 生 Controller。`--soft-delete` 在 Entity 加 Hibernate `@SoftDelete(columnName = "deleted_at", strategy = TIMESTAMP)`,migration 加 `deleted_at TIMESTAMP NULL`;`deleteById` 不用改。不做 restore / withTrashed。`make:entity --dto` 與 `make:entity --mapper` 一併生 Mapper 與 Request / Response DTO(同一組檔)。`make:mapper` 連 Request / Response DTO,並把 MapStruct 依賴接到 `build.gradle` 的 dependency-anchor 後面(已有則跳過)。`make:service` 的 `XxxService extends BaseService`,CRUD 在 `BaseServiceImpl`;多出來的業務方法寫在 `XxxService` / `XxxServiceImpl`。指令是 `make:entity`,不是 `make::entity`。預設只生引入與初稿;加 `--example` 才生完整範例(CRUD、欄位、`@Column`)。`--example` 的 Entity 與 `make:migration` 一樣帶 `created_at` / `updated_at`(DB 填,JPA `insertable = false`),不進 CreateRequest。多模組時 Entity / DAO 落在 `databaseconfig`。路徑以 `.bircrc.json` 的 `entityPath` / `daoPath` 為準。
51
+ **make 一次整組。** `make` 串 `makeStackActions`:Entity + DAO + Mapper + DTO + Service + Controller。`--migration` 生 `create_{table}_table`(Flyway SQL)。`--seed` 生 `XxxSeeder`(Java,走 DAO;`birc seed` 才執行)。單層指令共用同一組 actions,不另寫一份。`make:entity` 產出 Entity、`XxxDAO extends BaseDAO`。`make:model` 同 Entity + DAO;`--migration` / `--seed` 同上,`--controller` Controller。`make:seeder BookSeeder` 只生 Seeder(寫 `Book` 也可以,結尾 `Seeder` 會剝掉再對 Entity)。`--soft-delete` 在 Entity 加 Hibernate `@SoftDelete(columnName = "deleted_at", strategy = TIMESTAMP)`,migration 加 `deleted_at TIMESTAMP NULL`;`deleteById` 不用改。不做 restore / withTrashed。`make:entity --dto` 與 `make:entity --mapper` 一併生 Mapper 與 Request / Response DTO(同一組檔)。`make:mapper` 連 Request / Response DTO,並把 MapStruct 依賴接到 `build.gradle` 的 dependency-anchor 後面(已有則跳過)。`make:service` 的 `XxxService extends BaseService`,CRUD 在 `BaseServiceImpl`;多出來的業務方法寫在 `XxxService` / `XxxServiceImpl`。指令是 `make:entity`,不是 `make::entity`。預設只生引入與初稿;加 `--example` 或 `--fields` 才生完整範例(CRUD、欄位、`@Column`)。`--example` 的 Entity 與 `make:migration` 一樣帶 `created_at` / `updated_at`(DB 填,JPA `insertable = false`),不進 CreateRequest。多模組時 Entity / DAO 落在 `databaseconfig`。路徑以 `.bircrc.json` 的 `entityPath` / `daoPath` 為準。
52
52
 
53
53
  **例外走 ProjectException。** 基底是專案內的 `ProjectException`(`getErrorCode` / `getHttpStatus`),不是 starter 的 `BusinessException`。`create` 會生 `ProjectException`、`NotFoundException`、`ExceptionHandleController`、`web/Result`。`make:exception` 生的類別一律 `extends ProjectException`。Controller 回傳專案內 `Result<T>`,不引用 `tw.edu.ntub.birc.starter`。
54
54
 
@@ -67,7 +67,7 @@ anchor 就不要重寫整份設定;推測以後會用到的模組先不要做
67
67
  **版本只改一處。** Spring Boot、Flyway、Sentry、MapStruct、Spotless、dependency-management
68
68
  plugin 與 starter 版本都在 `versions.js`;`create` / `add` / `make:mapper` 將它們注入 template。
69
69
 
70
- **`birc` 的 dest。** `bin/birc.js` 把 plop 的 cwd 釘在這個 repo(找 `plopfile.js` 與 `templates/`),dest 與 `.bircrc.json` 走呼叫者目錄(`BIRC_PROJECT_ROOT` + `--dest`)。`create` / `update` 用目前目錄;目前目錄已有 `.bircrc.json`、或目標資料夾已在,`create` 會先失敗。`birc create --yes` 其餘用預設、不要問。`add` / `make:*` / `migrate` / `docs` 從目前目錄往上找 `.bircrc.json`,檔案落在找到的專案根。`birc add email sentry` 可帶 feature 名(也接受 `file-upload` 這種 kebab);沒帶參數仍互動勾選。打錯功能名會給近似建議。未知旗標會失敗並提示正確名稱。`docs` 用 `.bircrc.json` 的專案名,不再問。`make` 撞到已有檔會提示 `--force`;`--force` 覆寫那組 Java。`--fields` 沒配 `--example` 會提醒,欄位不會寫進 Entity。
70
+ **`birc` 的 dest。** `bin/birc.js` 把 plop 的 cwd 釘在這個 repo(找 `plopfile.js` 與 `templates/`),dest 與 `.bircrc.json` 走呼叫者目錄(`BIRC_PROJECT_ROOT` + `--dest`)。`create` / `update` 用目前目錄;目前目錄已有 `.bircrc.json`、或目標資料夾已在,`create` 會先失敗。`birc create --yes` 其餘用預設、不要問。`add` / `make:*` / `migrate` / `seed` / `db:wipe` / `docs` 從目前目錄往上找 `.bircrc.json`,檔案落在找到的專案根。`birc add email sentry` 可帶 feature 名(也接受 `file-upload` 這種 kebab);沒帶參數仍互動勾選。打錯功能名會給近似建議。未知旗標會失敗並提示正確名稱。`docs` 用 `.bircrc.json` 的專案名,不再問。`make` 撞到已有檔會提示 `--force`;`--force` 覆寫那組 Java。`--fields` 有帶就當完整產出,不必再加 `--example`。
71
71
 
72
72
  **`birc update`。** 先 `detectInstallKind`:`npm` 跑 `npm install -g birc-generator@latest`。curl / PowerShell / clone 走 `bin/home-install.js`(不要再把 Windows 暫存路徑丟給 bash)。`~/.birc-generator` 的 kind 是 `home`,跟用 bash 還是 `irm | iex` 裝無關。
73
73
 
@@ -76,8 +76,8 @@ anchor:`dependencies {}` 的 `// birc-generator:dependency-anchor`、`plugins
76
76
  `// birc-generator:plugin-anchor`、`allprojects {}` 的
77
77
  `// birc-generator:allprojects-anchor`。`add` 的 gradle append 只有 dest 檔裡有對應那一行才插得進去。
78
78
 
79
- **`make:migration`。** Laravel 風格名稱:`create_users_table`、`add_votes_to_users_table`、`remove_votes_from_users_table`。檔案落在 `src/main/resources/db/migration/V{n}__{name}.sql`。
79
+ **`make:migration`。** Laravel 風格名稱:`create_users_table`、`add_votes_to_users_table`、`remove_votes_from_users_table`。檔案落在 `src/main/resources/db/migration/V{n}__{name}.sql`。`create_{table}_table` 讀既有 Entity(先對 `@Table(name)`,沒對上再用檔名)把欄位寫進 CREATE TABLE;`@SoftDelete` 加 `deleted_at`。String 預設長度 50(Entity `length = 50`、SQL `VARCHAR(50)`)。找不到 Entity 時只有 `id`,或 `--example` 時的 `name` + timestamps 初稿。`make` / `make:model --migration --fields` 以旗標欄位為準。`--seed` / `make:seeder BookSeeder` 生 Java `XxxSeeder`(讀 `--fields` 或既有 Entity,略過 `id` / timestamps);沒有欄位時 `run()` 是空的。示範資料不進 Flyway。
80
80
 
81
- **`migrate`。** `birc migrate` 在目標專案跑 Gradle wrapper 的 `flywayMigrate`(Unix `./gradlew`,Windows `gradlew.bat`)。`migrate:reset` 是 `flywayClean` + `flywayMigrate`(`--force` 略過確認)。找不到 wrapper 或 spawn 失敗要印錯誤,不要只帶 banner 就結束。Flyway Community **沒有** Laravel 的 rollback;`migrate:rollback` 只說明這件事。undo 要 Flyway Teams 的 `flywayUndo` 加 `U{version}__*.sql`。
81
+ **`migrate` / `seed` / `db:wipe`。** `birc migrate` 在目標專案跑 Gradle wrapper 的 `flywayMigrate`(Unix `./gradlew`,Windows `gradlew.bat`)。`migrate:reset` 是 `flywayClean` + `flywayMigrate`(`--force` 略過確認)。`birc db:wipe` 只跑 `flywayClean`(`--force` 略過確認)。`birc seed` 跑 `bootRun --args=--spring.main.web-application-type=none --birc.seed`,執行 `Seeder` 後結束。找不到 wrapper 或 spawn 失敗要印錯誤,不要只帶 banner 就結束。Flyway Community **沒有** Laravel 的 rollback;`migrate:rollback` 只說明這件事。undo 要 Flyway Teams 的 `flywayUndo` 加 `U{version}__*.sql`。
82
82
 
83
83
  產出的註解與注入文件用繁中,跟現有 template 一致。目標專案設定欄位以 `.bircrc.example.json` 與 `templates/base/bircrc.hbs` 為準。
package/README.md CHANGED
@@ -146,8 +146,8 @@ birc make
146
146
  輸入 `Activity` 或 `activity`,會一次寫出 Entity、DAO、Mapper、DTO、
147
147
  Service、Controller。小寫會自動收成 PascalCase。只要其中一層,改跑
148
148
  `birc make:entity` 或 `birc make:controller`。預設是初稿,完整範例加
149
- `--example`。欄位可寫 `--fields title:String,startTime:LocalDateTime`,
150
- 就不會再問一次(要配 `--example`,否則只會提醒、欄位不會寫進 Entity)。
149
+ `--example`,或直接帶 `--fields`。欄位可寫 `--fields title:String,startTime:LocalDateTime`,
150
+ 就不會再問一次。
151
151
  檔已存在時會停下來;要覆寫加 `--force`。
152
152
 
153
153
  腳本或課堂示範可 `birc create Practice --yes`,其餘用預設(docker、log4j2、
@@ -165,32 +165,41 @@ ValidGroup、Spotless、注入 agent 文件),不再問。
165
165
  | `birc update` | 更新本機 `birc`。npm 裝的跑 `npm install -g`;curl / PowerShell / clone 拉 GitLab `main` |
166
166
  | `birc add` | 往既有專案加模組。沒帶名稱會互動勾選;`birc add email sentry` 直接加。`--force` 覆寫已選的已安裝 feature 檔 |
167
167
  | `birc sync` | 用目前 template 覆寫全部已安裝 feature 的檔(不動 yml / gradle) |
168
- | `birc make` | 一次生 Entity + DAO + Mapper + DTO + Service + Controller |
168
+ | `birc make` | 一次生 Entity + DAO + Mapper + DTO + Service + Controller;加 `--migration` / `--seed` |
169
169
  | `birc make:entity` | Entity、`XxxDAO`;加 `--dto`、`--mapper`、`--soft-delete`。已有檔要覆寫加 `--force` |
170
170
  | `birc make:model` | 同 `make:entity`;加 `--migration` / `--seed` / `--controller` / `--soft-delete` |
171
171
  | `birc make:mapper` | Mapper 和 CreateRequest / Response |
172
172
  | `birc make:service` | `XxxService extends BaseService`,CRUD 在 `BaseServiceImpl` |
173
173
  | `birc make:controller` | Controller |
174
174
  | `birc make:exception` | 繼承 `ProjectException` 的例外類 |
175
- | `birc make:migration` | Flyway SQL(`create_users_table`、`add_votes_to_users_table`) |
175
+ | `birc make:migration` | Flyway SQL(`create_book_table` 會讀 Book 欄位;`add_votes_to_users_table` 仍是檔名) |
176
+ | `birc make:seeder` | Java `XxxSeeder`(讀 Entity 或 `--fields`) |
176
177
  | `birc docs` | 補 `AGENTS.md` / `PROJECT.md` / `implement.md` / `test.md`(專案名從 `.bircrc.json` 讀) |
177
178
  | `birc migrate` | 在專案裡跑 Gradle wrapper 的 `flywayMigrate`(Unix `./gradlew`,Windows `gradlew.bat`) |
178
179
  | `birc migrate:rollback` | Flyway Community 做不到 rollback,指令會說明替代作法 |
179
180
  | `birc migrate:reset` | `flywayClean` 後再 `flywayMigrate`(`--force` 略過確認) |
181
+ | `birc seed` | 跑 Java Seeder(`bootRun --birc.seed`,跑完結束) |
182
+ | `birc db:wipe` | 清空資料庫(`flywayClean`)。沒加 `--force` 先問一次 |
180
183
 
181
184
  ```bash
182
185
  birc create Practice --yes
183
186
  birc make:entity User --dto
184
187
  birc make:entity User --mapper
185
188
  birc make:entity User --example
186
- birc make:entity User --example --fields title:String,startTime:LocalDateTime
189
+ birc make:entity User --fields title:String,startTime:LocalDateTime
190
+ birc make Book --fields title:String,author:String,isbn:String,price:BigDecimal,publishedAt:LocalDate --migration --seed
191
+ birc migrate
192
+ birc seed
193
+ birc db:wipe
187
194
  birc make:entity User --force
188
195
  birc make:model Flight --migration
189
196
  birc make:model Flight --seed
190
197
  birc make:model Flight --controller
191
198
  birc make:model Flight --soft-delete
192
199
  birc make:model Flight --migration --soft-delete
193
- birc make:migration create_users_table
200
+ birc make:entity Book --fields title:String,author:String,isbn:String,price:BigDecimal,publishedAt:LocalDate
201
+ birc make:migration create_book_table
202
+ birc make:seeder BookSeeder
194
203
  birc add
195
204
  birc add email
196
205
  birc add email sentry
@@ -202,7 +211,7 @@ birc migrate
202
211
 
203
212
  指令是 `make:entity`,中間一條冒號,跟 Laravel artisan 一樣。
204
213
  `birc add` 沒帶名稱時會問你要加哪些模組;也可以寫 `birc add docker` 或 `birc add file-upload`。
205
- `make:migration add_votes_to_users_table` 裡的 `add_votes_...` 只是 SQL 檔名。
214
+ `make:migration create_book_table` 會讀已有的 Book Entity 把欄位寫進 SQL。`birc make Book --fields title:String --migration --seed` 同一指令生骨架、CREATE TABLE 與 `BookSeeder`,再 `birc migrate`、`birc seed`。只補 Seeder 用 `birc make:seeder BookSeeder`。清空資料庫用 `birc db:wipe`。`add_votes_to_users_table` 裡的 `add_votes_...` 仍只是檔名。
206
215
 
207
216
  各指令的細節見 wiki:[make](https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator/-/wikis/基礎/make)、
208
217
  [add](https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator/-/wikis/基礎/add)、
@@ -261,7 +270,7 @@ Mapper 用 MapStruct。Controller 只轉 DTO,業務寫在 Service。
261
270
  SSH 部署骨架,帳密仍放 GitLab CI/CD Variables,不要寫進 repo。
262
271
 
263
272
  Flyway Community 沒有 Laravel 的 rollback。`birc migrate:rollback` 只會
264
- 說明替代作法:再寫一筆往前的遷移。`birc migrate` 會真的跑 Gradle wrapper 的 `flywayMigrate`,不是只印指令。
273
+ 說明替代作法:再寫一筆往前的遷移。`birc migrate` 會真的跑 Gradle wrapper 的 `flywayMigrate`,不是只印指令。示範資料用 `birc seed`;清空資料庫用 `birc db:wipe`。
265
274
 
266
275
  ## 改這個產生器
267
276
 
package/bin/birc.js CHANGED
@@ -25,16 +25,17 @@ const HELP_SECTIONS = [
25
25
  },
26
26
  {
27
27
  title: '骨架',
28
- note: '寫法是 make:種類 [名稱]。預設只生引入與初稿;加 --example 才有完整範例',
28
+ note: '寫法是 make:種類 [名稱]。預設只生引入與初稿;加 --example 或 --fields 才有完整範例',
29
29
  commands: [
30
- ['make', '一次生 Entity + DAO + Mapper + DTO + Service + Controller'],
30
+ ['make', '一次生 Entity + DAO + Mapper + DTO + Service + Controller;--migration / --seed'],
31
31
  ['make:entity', 'Entity、XxxDAO(--dto / --mapper / --soft-delete)'],
32
32
  ['make:model', '同 make:entity;--migration / --seed / --controller / --soft-delete'],
33
33
  ['make:mapper', 'Mapper、CreateRequest、Response'],
34
34
  ['make:service', 'XxxService extends BaseService'],
35
35
  ['make:controller', 'Controller'],
36
36
  ['make:exception', '繼承 ProjectException 的例外'],
37
- ['make:migration', 'Flyway SQL(檔名如 create_users_table)'],
37
+ ['make:migration', 'Flyway SQL(create_book_table 會讀 Entity 欄位)'],
38
+ ['make:seeder', 'Java Seeder(讀 Entity 欄位;birc seed 才執行)'],
38
39
  ]
39
40
  },
40
41
  {
@@ -42,7 +43,9 @@ const HELP_SECTIONS = [
42
43
  commands: [
43
44
  ['migrate', '套用待執行的遷移(flywayMigrate)'],
44
45
  ['migrate:reset', '清 schema 再重跑(flywayClean + migrate)'],
45
- ['migrate:rollback', 'Community 做不到 rollback,會印替代作法']
46
+ ['migrate:rollback', 'Community 做不到 rollback,會印替代作法'],
47
+ ['seed', '跑 Java Seeder(bootRun --birc.seed,跑完就結束)'],
48
+ ['db:wipe', '清空資料庫(flywayClean)。沒加 --force 先問一次']
46
49
  ]
47
50
  },
48
51
  {
@@ -53,7 +56,8 @@ const HELP_SECTIONS = [
53
56
  }
54
57
  ];
55
58
 
56
- const FLYWAY_COMMANDS = new Set(['migrate', 'migrate:rollback', 'migrate:reset']);
59
+ const FLYWAY_COMMANDS = new Set(['migrate', 'migrate:rollback', 'migrate:reset', 'db:wipe']);
60
+ const SEED_COMMANDS = new Set(['seed']);
57
61
 
58
62
  function allCommandNames() {
59
63
  return HELP_SECTIONS.flatMap((section) => section.commands.map(([name]) => name));
@@ -170,12 +174,12 @@ function printHelp() {
170
174
  ` ${'--example'.padEnd(nameWidth + 2)}make:* 產出完整範例`,
171
175
  ` ${'--dto'.padEnd(nameWidth + 2)}make:entity 順便生 Mapper 與 DTO`,
172
176
  ` ${'--mapper'.padEnd(nameWidth + 2)}同 --dto`,
173
- ` ${'--migration'.padEnd(nameWidth + 2)}make:model 順便生 create_{table}_table`,
174
- ` ${'--seed'.padEnd(nameWidth + 2)}make:model 順便生 seed_{table}`,
177
+ ` ${'--migration'.padEnd(nameWidth + 2)}make / make:model 順便生 create_{table}_table`,
178
+ ` ${'--seed'.padEnd(nameWidth + 2)}make / make:model 順便生 Java Seeder`,
175
179
  ` ${'--controller'.padEnd(nameWidth + 2)}make:model 順便生 Controller`,
176
180
  ` ${'--soft-delete'.padEnd(nameWidth + 2)}make:entity / make:model 加 deleted_at 軟刪`,
177
- ` ${'--fields'.padEnd(nameWidth + 2)}make --example 的欄位,例如 title:String,startTime:LocalDateTime`,
178
- ` ${'--force'.padEnd(nameWidth + 2)}migrate:reset 略過確認;add 覆寫已選的已安裝 feature;make 覆寫已有 Java`,
181
+ ` ${'--fields'.padEnd(nameWidth + 2)}make 的欄位,例如 title:String,startTime:LocalDateTime(有帶就當完整產出)`,
182
+ ` ${'--force'.padEnd(nameWidth + 2)}migrate:reset / db:wipe 略過確認;add 覆寫已選的已安裝 feature;make 覆寫已有 Java`,
179
183
  ` ${'--yes'.padEnd(nameWidth + 2)}create 其餘用預設、不要問`,
180
184
  '',
181
185
  '例子',
@@ -193,15 +197,19 @@ function printHelp() {
193
197
  ' birc make:entity User --dto',
194
198
  ' birc make:entity User --mapper',
195
199
  ' birc make:entity User --example',
196
- ' birc make:entity User --example --fields title:String,startTime:LocalDateTime',
200
+ ' birc make:entity User --fields title:String,startTime:LocalDateTime',
201
+ ' birc make Book --fields title:String,author:String --migration --seed',
197
202
  ' birc make:entity User --force',
198
203
  ' birc make:model Flight --migration',
199
204
  ' birc make:model Flight --seed',
200
205
  ' birc make:model Flight --controller',
201
206
  ' birc make:model Flight --soft-delete',
202
- ' birc make:migration create_users_table',
207
+ ' birc make:seeder BookSeeder',
208
+ ' birc make:migration create_book_table',
203
209
  ' birc make:migration add_votes_to_users_table',
204
210
  ' birc migrate',
211
+ ' birc seed',
212
+ ' birc db:wipe',
205
213
  '',
206
214
  'create / update 以外的指令會從目前目錄往上找 .bircrc.json。',
207
215
  ''
@@ -236,7 +244,8 @@ async function runGenerator(name, bypassArr, flags) {
236
244
  process.exit(1);
237
245
  }
238
246
 
239
- if (flags.example) {
247
+ const hasFields = Boolean(String(flags.fields || '').trim());
248
+ if (flags.example || hasFields) {
240
249
  process.env.BIRC_EXAMPLE = '1';
241
250
  }
242
251
  if (flags.dto) {
@@ -293,12 +302,8 @@ async function runGenerator(name, bypassArr, flags) {
293
302
  }
294
303
  if (flags.fields) {
295
304
  process.env.BIRC_MAKE_FIELDS = flags.fields;
296
- const { applyMakeFieldsFlag, fieldsWithoutExampleMessage } = require('./cli-util');
305
+ const { applyMakeFieldsFlag } = require('./cli-util');
297
306
  applyMakeFieldsFlag(generator.prompts, flags.fields);
298
- if (name === 'make' || String(name).startsWith('make:')) {
299
- const warn = fieldsWithoutExampleMessage(flags);
300
- if (warn) process.stderr.write(`${warn}\n`);
301
- }
302
307
  }
303
308
  const answers = await generator.runPrompts(promptBypass);
304
309
  if (createYes) {
@@ -310,7 +315,7 @@ async function runGenerator(name, bypassArr, flags) {
310
315
  if (flags.fields) {
311
316
  answers.fields = flags.fields;
312
317
  }
313
- answers.example = Boolean(answers.example) || flags.example;
318
+ answers.example = Boolean(answers.example) || flags.example || hasFields;
314
319
  answers.dto = Boolean(answers.dto) || flags.dto;
315
320
  answers.mapper = Boolean(answers.mapper) || flags.mapper;
316
321
  answers.migration = Boolean(answers.migration) || flags.migration;
@@ -391,11 +396,16 @@ function printCreateNextSteps(answers) {
391
396
  '',
392
397
  '生 CRUD 骨架(會從目前目錄往上找 .bircrc.json):',
393
398
  ' birc make # Entity + DAO + Mapper + DTO + Service + Controller',
399
+ ' birc make Book --fields title:String --migration --seed',
394
400
  ' birc make:entity # Entity / DAO',
395
401
  ' birc make:entity User --dto',
396
402
  ' birc make:model Flight --migration --seed --controller',
403
+ ' birc make:seeder BookSeeder',
397
404
  ' birc make:entity User --example',
398
- ' birc make:migration create_users_table',
405
+ ' birc make:migration create_book_table',
406
+ ' birc migrate',
407
+ ' birc seed',
408
+ ' birc db:wipe',
399
409
  ' birc add # 之後再補 docker / email 等功能',
400
410
  '',
401
411
  '打包:',
@@ -503,9 +513,14 @@ async function runFlyway(command, flags) {
503
513
  process.exit(1);
504
514
  }
505
515
 
506
- if (command === 'migrate:reset') {
516
+ if (command === 'migrate:reset' || command === 'db:wipe') {
517
+ const wiping = command === 'db:wipe';
507
518
  if (!flags.force) {
508
- const ok = await confirmYes('這會清掉 schema 再重跑全部遷移。確定? [y/N] ');
519
+ const ok = await confirmYes(
520
+ wiping
521
+ ? '這會清掉 schema 與資料。確定? [y/N] '
522
+ : '這會清掉 schema 再重跑全部遷移。確定? [y/N] '
523
+ );
509
524
  if (!ok) {
510
525
  process.stdout.write('已取消。\n');
511
526
  return;
@@ -515,6 +530,9 @@ async function runFlyway(command, flags) {
515
530
  if (cleanStatus !== 0) {
516
531
  process.exit(cleanStatus);
517
532
  }
533
+ if (wiping) {
534
+ process.exit(0);
535
+ }
518
536
  process.exit(runGradle(['flywayMigrate']));
519
537
  }
520
538
 
@@ -635,6 +653,12 @@ async function main() {
635
653
  await runFlyway(command, flags);
636
654
  return;
637
655
  }
656
+ if (SEED_COMMANDS.has(command)) {
657
+ process.exit(runGradle([
658
+ 'bootRun',
659
+ '--args=--spring.main.web-application-type=none --birc.seed'
660
+ ]));
661
+ }
638
662
 
639
663
  await runGenerator(command, flags.positionals.slice(1), flags);
640
664
  }
package/bin/cli-util.js CHANGED
@@ -56,10 +56,11 @@ function projectConfigMissingMessage() {
56
56
  return '找不到 .bircrc.json,先在專案根目錄建立設定檔(可複製 .bircrc.example.json 改名,或用 birc create 建新專案)';
57
57
  }
58
58
 
59
- function findProjectRoot(startDir, exists = fs.existsSync) {
59
+ function findProjectRoot(startDir, exists = fs.existsSync, maxDepth = 10) {
60
60
  let current = path.resolve(startDir || '');
61
61
  const { root } = path.parse(current);
62
- while (true) {
62
+ let depth = 0;
63
+ while (depth < maxDepth) {
63
64
  if (exists(path.join(current, '.bircrc.json'))) {
64
65
  return current;
65
66
  }
@@ -71,7 +72,9 @@ function findProjectRoot(startDir, exists = fs.existsSync) {
71
72
  return null;
72
73
  }
73
74
  current = parent;
75
+ depth += 1;
74
76
  }
77
+ return null;
75
78
  }
76
79
 
77
80
  function toKebabName(value) {
@@ -208,14 +211,6 @@ function alreadyExistsMessage(fail = {}) {
208
211
  return `${fileName} 已在,要覆寫用 --force`;
209
212
  }
210
213
 
211
- function fieldsWithoutExampleMessage(flags = {}) {
212
- const hasFields = Boolean(String(flags.fields || '').trim());
213
- if (!hasFields || flags.example) {
214
- return null;
215
- }
216
- return '--fields 要配 --example 才會寫進 Entity';
217
- }
218
-
219
214
  function levenshtein(left, right) {
220
215
  const a = String(left || '');
221
216
  const b = String(right || '');
@@ -291,10 +286,18 @@ function formatMakeNextSteps(name, answers = {}) {
291
286
  const command = String(name || '');
292
287
  const wantsMigrate = command === 'make:migration'
293
288
  || command === 'migration'
294
- || Boolean(answers.migration)
295
- || Boolean(answers.seed);
289
+ || Boolean(answers.migration);
290
+ const wantsSeed = command === 'make:seeder' || Boolean(answers.seed);
291
+ const lines = [''];
296
292
  if (wantsMigrate) {
297
- return ['', '套用遷移:', ' birc migrate', ''];
293
+ lines.push('套用遷移:', ' birc migrate');
294
+ }
295
+ if (wantsSeed) {
296
+ lines.push('寫入示範資料:', ' birc seed');
297
+ }
298
+ if (wantsMigrate || wantsSeed) {
299
+ lines.push('');
300
+ return lines;
298
301
  }
299
302
  if (command === 'make' || command.startsWith('make:')) {
300
303
  return ['', '骨架已寫好。建表可加 --migration,再 birc migrate。', ''];
@@ -416,7 +419,6 @@ module.exports = {
416
419
  applyCreatePromptDefaults,
417
420
  applyCreateYes,
418
421
  alreadyExistsMessage,
419
- fieldsWithoutExampleMessage,
420
422
  suggestCommand,
421
423
  shouldPrintBanner,
422
424
  formatAddNextSteps,
@@ -1,10 +1,11 @@
1
1
  const fs = require('fs');
2
2
  const https = require('https');
3
+ const crypto = require('crypto');
3
4
  const os = require('os');
4
5
  const path = require('path');
5
6
  const { spawnSync } = require('child_process');
6
7
 
7
- const DEFAULT_BASE_URL = 'https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator';
8
+ const DEFAULT_BASE_URL = 'https://gitlab.ntubimdbirc.tw/birc-backend/birc-generator';
8
9
 
9
10
  function npmCmd(platform = process.platform) {
10
11
  return platform === 'win32' ? 'npm.cmd' : 'npm';
@@ -84,7 +85,8 @@ function installCommand(jsPath, {
84
85
  fs.mkdirSync(dir, { recursive: true });
85
86
 
86
87
  if (platform === 'win32') {
87
- fs.writeFileSync(path.join(dir, 'birc.cmd'), `@echo off\r\nnode "${resolved}" %*\r\n`);
88
+ const cmdPath = resolved.replace(/\//g, '\\');
89
+ fs.writeFileSync(path.join(dir, 'birc.cmd'), `@echo off\r\nnode "${cmdPath}" %*\r\n`);
88
90
  fs.writeFileSync(
89
91
  path.join(dir, 'birc.ps1'),
90
92
  `& node "${resolved.replace(/\\/g, '/')}" @args\n`
@@ -241,7 +243,7 @@ async function confirmCustomBase(baseUrl) {
241
243
  }
242
244
 
243
245
  function npmInstall(cwd, platform = process.platform) {
244
- spawnOrThrow(npmCmd(platform), ['install'], {
246
+ spawnOrThrow(npmCmd(platform), ['install', '--ignore-scripts'], {
245
247
  cwd,
246
248
  stdio: 'inherit',
247
249
  shell: platform === 'win32',
@@ -7,7 +7,7 @@ const pkg = require(path.join(__dirname, '..', 'package.json'));
7
7
 
8
8
  const NPM_LATEST = `https://registry.npmjs.org/${pkg.name}/latest`;
9
9
  const GITLAB_PACKAGE =
10
- 'https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator/-/raw/main/package.json';
10
+ 'https://gitlab.ntubimdbirc.tw/birc-backend/birc-generator/-/raw/main/package.json';
11
11
  const SNOOZE_FILE = path.join(os.homedir(), '.birc-update-check.json');
12
12
  const FETCH_TIMEOUT_MS = 2500;
13
13
  const MAX_REDIRECTS = 5;
package/install.ps1 CHANGED
@@ -1,5 +1,5 @@
1
1
  # 用法:
2
- # irm https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator/-/raw/main/install.ps1 | iex
2
+ # irm https://gitlab.ntubimdbirc.tw/birc-backend/birc-generator/-/raw/main/install.ps1 | iex
3
3
  # 或下載後執行: powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1
4
4
  # 需要 Node.js 18+ 與 npm。程式會放到 $HOME\.birc-generator,跟 curl | bash 同一處。
5
5
 
@@ -19,7 +19,7 @@ function Write-Err([string]$Message) {
19
19
  exit 1
20
20
  }
21
21
 
22
- $BaseUrl = if ($env:BIRC_BASE_URL) { $env:BIRC_BASE_URL.TrimEnd('/') } else { 'https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator' }
22
+ $BaseUrl = if ($env:BIRC_BASE_URL) { $env:BIRC_BASE_URL.TrimEnd('/') } else { 'https://gitlab.ntubimdbirc.tw/birc-backend/birc-generator' }
23
23
  $InstallDir = Join-Path $HOME '.birc-generator'
24
24
 
25
25
  function Test-NodeAndNpm {
package/install.sh CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/bin/bash
2
2
  set -e
3
3
 
4
- # 用法: curl -fsSL https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator/-/raw/main/install.sh | bash
4
+ # 用法: curl -fsSL https://gitlab.ntubimdbirc.tw/birc-backend/birc-generator/-/raw/main/install.sh | bash
5
5
  # 或下載後執行: bash install.sh
6
6
 
7
7
  RED='\033[0;31m'
@@ -14,7 +14,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
14
14
  error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
15
15
 
16
16
  INSTALL_DIR="$HOME/.birc-generator"
17
- DEFAULT_BASE_URL="https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator"
17
+ DEFAULT_BASE_URL="https://gitlab.ntubimdbirc.tw/birc-backend/birc-generator"
18
18
  BASE_URL="${BIRC_BASE_URL:-$DEFAULT_BASE_URL}"
19
19
  BASE_URL="${BASE_URL%/}"
20
20
 
@@ -161,7 +161,14 @@ download_project() {
161
161
  tmp_tar="$(mktemp)"
162
162
  mkdir -p "$INSTALL_DIR"
163
163
  curl -fsSL "$TAR_URL" -o "$tmp_tar"
164
+
165
+ info "驗證下載檔完整性..."
166
+ if ! file "$tmp_tar" | grep -qi gzip; then
167
+ rm -f "$tmp_tar"
168
+ error "下載的檔案不是有效的 gzip 壓縮檔。可能來源不正確或已被篡改。已取消安裝。"
169
+ fi
164
170
  validate_tar_safety "$tmp_tar"
171
+
165
172
  tar xzf "$tmp_tar" -C "$INSTALL_DIR" --strip-components=1
166
173
  rm -f "$tmp_tar"
167
174
  cd "$INSTALL_DIR"
@@ -170,7 +177,7 @@ download_project() {
170
177
 
171
178
  install_dependencies() {
172
179
  info "安裝 Node.js 依賴..."
173
- npm install
180
+ npm install --ignore-scripts
174
181
  }
175
182
 
176
183
  create_symlink() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "birc-generator",
3
- "version": "0.5.5",
3
+ "version": "0.8.0",
4
4
  "description": "Spring Boot 4 CRUD scaffold generator built on Plop.js",
5
5
  "license": "MIT",
6
6
  "author": "lucashsu95",
package/plopfile.js CHANGED
@@ -4,10 +4,10 @@ const { randomInt } = require('node:crypto');
4
4
  const VERSIONS = require('./versions');
5
5
  const { deriveBasePackage, projectConfigMissingMessage, toPascalCaseName, createDestConflict } = require('./bin/cli-util');
6
6
 
7
- // .env 的 DB 密碼用:6 碼小寫英文,隨機生成,避免 change_me 這種預設值。
7
+ // .env 的 DB 密碼用:16 碼混合大小寫英文 + 數字,隨機生成,避免 change_me 這種預設值。
8
8
  function randomDbPassword() {
9
- const letters = 'abcdefghijklmnopqrstuvwxyz';
10
- return Array.from({ length: 6 }, () => letters[randomInt(letters.length)]).join('');
9
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
10
+ return Array.from({ length: 16 }, () => chars[randomInt(chars.length)]).join('');
11
11
  }
12
12
 
13
13
  function projectRoot() {
@@ -156,6 +156,8 @@ function validateMigrationName(value) {
156
156
  : '遷移名稱只能包含英文字母、數字和底線,例如 create_users_table。';
157
157
  }
158
158
 
159
+ const DEFAULT_STRING_COLUMN_LENGTH = 50;
160
+
159
161
  function parseFields(fields) {
160
162
  if (!fields || !String(fields).trim()) {
161
163
  return [];
@@ -165,20 +167,24 @@ function parseFields(fields) {
165
167
  .map((f) => {
166
168
  const [name, type] = f.split(':');
167
169
  const fieldName = (name || '').trim();
170
+ const fieldType = (type || 'String').trim();
168
171
  return {
169
172
  name: fieldName,
170
- type: (type || 'String').trim(),
173
+ type: fieldType,
171
174
  snakeName: fieldName
172
175
  .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
173
176
  .replace(/[\s-]+/g, '_')
174
- .toLowerCase()
177
+ .toLowerCase(),
178
+ columnLength: fieldType === 'String' ? DEFAULT_STRING_COLUMN_LENGTH : null
175
179
  };
176
180
  })
177
181
  .filter((f) => f.name);
178
182
  }
179
183
 
180
184
  function isExample(data) {
181
- return Boolean(data && data.example) || process.env.BIRC_EXAMPLE === '1';
185
+ return Boolean(data && data.example)
186
+ || process.env.BIRC_EXAMPLE === '1'
187
+ || parseFields((data && data.fields) || process.env.BIRC_MAKE_FIELDS).length > 0;
182
188
  }
183
189
 
184
190
  function includeMapperWithEntity(data) {
@@ -214,11 +220,266 @@ function tableNameFromEntity(entityName) {
214
220
  return toSnakeCase(entityName);
215
221
  }
216
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
+
217
477
  function fieldImports(fields) {
218
478
  const types = new Set((fields || []).map((field) => field.type));
219
479
  const imports = [];
220
480
  if (types.has('LocalDateTime')) imports.push('java.time.LocalDateTime');
221
481
  if (types.has('LocalDate')) imports.push('java.time.LocalDate');
482
+ if (types.has('Instant')) imports.push('java.time.Instant');
222
483
  if (types.has('BigDecimal')) imports.push('java.math.BigDecimal');
223
484
  return imports;
224
485
  }
@@ -229,7 +490,12 @@ function resolveMakeFields(data) {
229
490
  return fields;
230
491
  }
231
492
  if (isExample(data)) {
232
- return [{ name: 'name', type: 'String', snakeName: 'name' }];
493
+ return [{
494
+ name: 'name',
495
+ type: 'String',
496
+ snakeName: 'name',
497
+ columnLength: DEFAULT_STRING_COLUMN_LENGTH
498
+ }];
233
499
  }
234
500
  return [];
235
501
  }
@@ -351,6 +617,14 @@ function serviceSkeletonActions(root, templateData) {
351
617
  ];
352
618
  }
353
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
+
354
628
  function uniqueFeatureKeys(features) {
355
629
  return [...new Set((features || []).filter((featureKey) => FEATURES[featureKey]))];
356
630
  }
@@ -874,11 +1148,12 @@ module.exports = function (plop) {
874
1148
  && !String((answers && answers.fields) || process.env.BIRC_MAKE_FIELDS || '').trim()
875
1149
  };
876
1150
 
877
- function makeEntityActions(data) {
1151
+ function makeEntityActions(data, options) {
878
1152
  const { persistence, templateData } = makeContext(data);
1153
+ const skipIfExists = Boolean(options && options.skipIfExists);
879
1154
  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 }
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 }
882
1157
  ];
883
1158
  }
884
1159
 
@@ -922,16 +1197,16 @@ module.exports = function (plop) {
922
1197
  }
923
1198
 
924
1199
  function makeStackActions(data) {
925
- return [
1200
+ return appendMakeSqlActions([
926
1201
  ...makeEntityActions(data),
927
1202
  ...makeMapperActions(data),
928
1203
  ...makeServiceActions(data),
929
1204
  ...makeControllerActions(data)
930
- ];
1205
+ ], data);
931
1206
  }
932
1207
 
933
1208
  plop.setGenerator('make', {
934
- description: '一次產生 Entity + DAO + Mapper + DTO + Service + Controller',
1209
+ description: '一次產生 Entity + DAO + Mapper + DTO + Service + Controller;--migration / --seed',
935
1210
  prompts: [entityNamePrompt, fieldsPrompt],
936
1211
  actions: makeStackActions
937
1212
  });
@@ -1175,6 +1450,7 @@ module.exports = function (plop) {
1175
1450
  ];
1176
1451
  actions = actions.concat(exceptionSkeletonActions(root, templateData));
1177
1452
  actions = actions.concat(serviceSkeletonActions(root, templateData));
1453
+ actions = actions.concat(seederSkeletonActions(root, templateData));
1178
1454
 
1179
1455
  const configRoot = `${projectDir}/modules/${projectDir}-config/src/main/java/${basePackagePath}/config`;
1180
1456
  actions.push(
@@ -1263,6 +1539,9 @@ module.exports = function (plop) {
1263
1539
  const parsed = parseMigrationName(data.description);
1264
1540
  const version = data.migrationVersion || nextMigrationVersion(path.join(projectRoot(), migrationPath));
1265
1541
  const example = isExample(data);
1542
+ const migrationColumns = parsed.kind === 'create'
1543
+ ? resolveCreateTableColumns(data, parsed.tableName, config)
1544
+ : [];
1266
1545
  return [
1267
1546
  {
1268
1547
  type: 'add',
@@ -1273,40 +1552,53 @@ module.exports = function (plop) {
1273
1552
  tableName: parsed.tableName,
1274
1553
  columnName: parsed.columnName,
1275
1554
  withExample: example,
1276
- withSoftDelete: includeSoftDelete(data)
1555
+ withSoftDelete: includeSoftDelete(data),
1556
+ migrationColumns
1277
1557
  }
1278
1558
  }
1279
1559
  ];
1280
1560
  }
1281
1561
 
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));
1562
+ function makeSeederActions(data) {
1563
+ data.entityName = entityNameForSeeder(data);
1564
+ const { root, templateData } = makeContext(data);
1565
+ const entityName = data.entityName;
1566
+ const seederClass = `${entityName}Seeder`;
1567
+ const tableName = tableNameFromEntity(entityName);
1568
+ const seedFields = resolveSeedFields(data, tableName, loadProjectConfig());
1288
1569
  return [
1570
+ ...seederSkeletonActions(root, templateData, { skipIfExists: true }),
1289
1571
  {
1290
1572
  type: 'add',
1291
- path: `${migrationPath}/V${version}__${description}.sql`,
1292
- templateFile: 'templates/migration/seed.hbs',
1573
+ path: `${root}/seeder/${seederClass}.java`,
1574
+ templateFile: 'templates/seeder.hbs',
1293
1575
  data: {
1294
- description,
1295
- tableName,
1296
- withExample: isExample(data)
1576
+ ...templateData,
1577
+ extraImports: fieldImports(seedFields),
1578
+ seedFields,
1579
+ seederClass,
1580
+ entityName
1297
1581
  }
1298
1582
  }
1299
1583
  ];
1300
1584
  }
1301
1585
 
1302
1586
  function makeModelActions(data) {
1303
- const actions = makeEntityActions(data);
1587
+ const actions = makeEntityActions(data, { skipIfExists: true });
1304
1588
  if (includeMapperWithEntity(data)) {
1305
1589
  actions.push(...makeMapperActions(data));
1306
1590
  }
1591
+ appendMakeSqlActions(actions, data);
1592
+ if (includeModelController(data)) {
1593
+ actions.push(...makeControllerActions(data));
1594
+ }
1595
+ return actions;
1596
+ }
1597
+
1598
+ function appendMakeSqlActions(actions, data) {
1307
1599
  const config = loadProjectConfig();
1308
1600
  const migrationPath = migrationDir(config);
1309
- let version = nextMigrationVersion(path.join(projectRoot(), migrationPath));
1601
+ const version = nextMigrationVersion(path.join(projectRoot(), migrationPath));
1310
1602
  if (includeModelMigration(data)) {
1311
1603
  const tableName = tableNameFromEntity(data.entityName);
1312
1604
  actions.push(
@@ -1316,13 +1608,9 @@ module.exports = function (plop) {
1316
1608
  migrationVersion: version
1317
1609
  })
1318
1610
  );
1319
- version += 1;
1320
1611
  }
1321
1612
  if (includeModelSeed(data)) {
1322
- actions.push(...makeSeedActions({ ...data, migrationVersion: version }));
1323
- }
1324
- if (includeModelController(data)) {
1325
- actions.push(...makeControllerActions(data));
1613
+ actions.push(...makeSeederActions(data));
1326
1614
  }
1327
1615
  return actions;
1328
1616
  }
@@ -1342,6 +1630,21 @@ module.exports = function (plop) {
1342
1630
  actions: makeMigrationActions
1343
1631
  });
1344
1632
 
1633
+ plop.setGenerator('make:seeder', {
1634
+ description: '產生 Java Seeder;讀既有 Entity 或 --fields',
1635
+ prompts: [
1636
+ {
1637
+ type: 'input',
1638
+ name: 'entityName',
1639
+ message: '名稱(例如 BookSeeder):',
1640
+ filter: toPascalCaseName,
1641
+ validate: validatePascalCase
1642
+ },
1643
+ fieldsPrompt
1644
+ ],
1645
+ actions: makeSeederActions
1646
+ });
1647
+
1345
1648
  plop.setGenerator('migration', {
1346
1649
  description: 'make:migration 的別名',
1347
1650
  prompts: migrationPrompts,
@@ -42,6 +42,7 @@ DB → Entity → Mapper → XxxResponse → Result<T>
42
42
  - `mapper/`(`EntityMapper`;`XxxMapper extends EntityMapper`)
43
43
  - `service/`、`service/impl/`(`BaseService`;`XxxService extends BaseService`)
44
44
  - `controller/`
45
+ - `seeder/`(`Seeder`、`SeedRunner`、`XxxSeeder`)
45
46
  - `web/Result`
46
47
 
47
48
  ## 分層(baseline)
@@ -52,14 +53,16 @@ DB → Entity → Mapper → XxxResponse → Result<T>
52
53
  - `dao`:`XxxDAO extends BaseDAO<Entity, ID>`。自訂查詢才在介面加方法。資料庫 VIEW 不能寫入時,那個 DAO `extends BaseViewDAO`(沒有 save / delete)。不要為了跟 DAO 對稱再做一個 `BaseViewService`。
53
54
  - `service`:Controller 注入 `XxxService` 介面。一對一 `XxxServiceImpl` 是正常的;介面的價值是額外方法的掛點與 Spring 注入,不是預先準備第二套實作。查詢 API 大到該獨立時,另開一個查詢介面(例如只含 search / count),不要把八十個方法塞進同一個 Service。
54
55
  - `controller`:參數驗證與呼叫 Service。回傳專案內 `web.Result<T>`。不回 Entity、不注入 DAO、不引用 `birc-spring-boot-starter`。
56
+ - `seeder`:示範資料。`XxxSeeder implements Seeder`,用 DAO `save`。`birc seed` 才跑,不要把 INSERT 寫進 Flyway。
55
57
 
56
58
  ## 統一規範
57
59
 
58
60
  - 例外丟 `ProjectException` 子類(例如 `NotFoundException`),由 `ExceptionHandleController` 轉成回應。Controller 不手動 try-catch 包錯誤訊息。
59
- - 整組骨架用 `birc make`;單層用 `birc make:entity` / `make:model` / `make:mapper` / `make:service` / `make:controller` / `make:exception`。`make:entity` 只生 Entity 與 DAO;`--dto` 與 `--mapper` 一併生 Mapper 與 DTO。`make:model` 可加 `--migration` / `--seed` / `--controller`。`--soft-delete` 用 Hibernate `@SoftDelete` 的 `deleted_at`,查詢自動排除已刪;不要自寫 `@SQLDelete`。預設是初稿,完整範例加 `--example`。不要複製其他 Entity 的程式碼來改名。
61
+ - 整組骨架用 `birc make`;單層用 `birc make:entity` / `make:model` / `make:mapper` / `make:service` / `make:controller` / `make:exception`。`make:entity` 只生 Entity 與 DAO;`--dto` 與 `--mapper` 一併生 Mapper 與 DTO。`make:model` 可加 `--migration` / `--seed` / `--controller`。`make` 也可加 `--migration` / `--seed`。`make:seeder BookSeeder` 生 Java Seeder。`--soft-delete` 用 Hibernate `@SoftDelete` 的 `deleted_at`,查詢自動排除已刪;不要自寫 `@SQLDelete`。預設是初稿,完整範例加 `--example` 或 `--fields`。`make:migration create_{table}_table` 讀對應 Entity 欄位寫進 SQL。`--seed` 生 Java Seeder,用 DAO 塞資料;`birc seed` 才執行。清空資料庫用 `birc db:wipe`。不要複製其他 Entity 的程式碼來改名。
60
62
  - 欄位驗證用專案內 `validation/ValidGroup`。CRUD 用 `Create` / `Update`。`Delete` 給「刪除還要帶原因、確認碼、樂觀鎖 version」的 body。`Submit` 給草稿可少填、送審要全填(活動送審就是這組)。Controller 的 `@Validated` 與欄位 `groups` 對同一組。
61
63
  - email / sso / oauth / validGroup 用 `birc add`,不要從別的專案抄設定。
62
64
  - 格式化走 Spotless(`./gradlew spotlessApply`),不要另裝 Checkstyle。
65
+ - CORS 只改 `CORS_ALLOWED_ORIGIN_PATTERNS`(逗號分隔 Origin,不要尾斜線、不要 `*`)。`SecurityConfig` 已經用 `http.cors()` 接 `CorsConfigurationSource`。不要在 `WebMvcConfigurer` 加 `addCorsMappings`,Spring Security 會擋 preflight。空白 = 不開放跨來源。
63
66
 
64
67
  ## 已啟用的功能模組
65
68
 
@@ -53,9 +53,9 @@ EOF
53
53
 
54
54
  if birc_is_windows; then
55
55
  if command -v cygpath >/dev/null 2>&1; then
56
- win_path="$(cygpath -w "$script_path")"
56
+ win_path="$(cygpath -w "$script_path" | sed 's|/|\\\\|g')"
57
57
  else
58
- win_path="$script_path"
58
+ win_path="$(echo "$script_path" | sed 's|/|\\\\|g')"
59
59
  fi
60
60
  printf '@echo off\nnode "%s" %%*\n' "$win_path" > "$bin_dir/birc.cmd"
61
61
  fi
@@ -37,13 +37,27 @@ Get-Content .env | ForEach-Object {
37
37
  Windows 用 `.\gradlew.bat bootRun`。
38
38
  {{/if}}
39
39
 
40
+ ## 前端跨來源(CORS)
41
+
42
+ 瀏覽器從別的 Origin 打 API 時,設環境變數後重啟:
43
+
44
+ ```bash
45
+ CORS_ALLOWED_ORIGIN_PATTERNS=http://localhost:5173
46
+ ```
47
+
48
+ 逗號分隔、不要尾斜線、不要 `*`。只改這變數;不要在 `WebMvcConfigurer` 加 `addCorsMappings`,Spring Security 不會吃。
49
+
40
50
  ## 生骨架
41
51
 
42
52
  在有 `.bircrc.json` 的目錄跑即可;子目錄也可以,`birc` 會往上找。
43
53
 
44
54
  ```bash
45
55
  birc make
46
- birc make:entity User --example --fields title:String
56
+ birc make Book --fields title:String --migration --seed
57
+ birc make:seeder BookSeeder
58
+ birc migrate
59
+ birc seed
60
+ birc db:wipe
47
61
  birc add email
48
62
  ```
49
63
 
@@ -33,7 +33,7 @@ public class {{pascalCase entityName}} {
33
33
  {{#if withExample}}
34
34
  {{#each makeFields}}
35
35
 
36
- @Column(name = "{{this.snakeName}}")
36
+ @Column(name = "{{this.snakeName}}"{{#if this.columnLength}}, length = {{this.columnLength}}{{/if}})
37
37
  private {{this.type}} {{this.name}};
38
38
  {{/each}}
39
39
 
@@ -1,4 +1,4 @@
1
1
  -- {{description}}
2
2
 
3
3
  ALTER TABLE {{tableName}}
4
- ADD COLUMN {{columnName}} {{#if withExample}}INT NULL{{else}}VARCHAR(255) NULL{{/if}};
4
+ ADD COLUMN {{columnName}} {{#if withExample}}INT NULL{{else}}VARCHAR(50) NULL{{/if}};
@@ -1,9 +1,6 @@
1
1
  -- {{description}}
2
2
 
3
3
  CREATE TABLE {{tableName}} (
4
- id BIGINT AUTO_INCREMENT PRIMARY KEY{{#if withExample}},
5
- name VARCHAR(255) NULL,
6
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
7
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP{{/if}}{{#if withSoftDelete}},
8
- deleted_at TIMESTAMP NULL{{/if}}
4
+ id BIGINT AUTO_INCREMENT PRIMARY KEY{{#each migrationColumns}},
5
+ {{this.name}} {{this.sqlType}}{{/each}}
9
6
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,31 @@
1
+ package {{basePackage}}.seeder;
2
+
3
+ import java.util.List;
4
+ import org.springframework.boot.ApplicationArguments;
5
+ import org.springframework.boot.ApplicationRunner;
6
+ import org.springframework.boot.SpringApplication;
7
+ import org.springframework.context.ConfigurableApplicationContext;
8
+ import org.springframework.stereotype.Component;
9
+
10
+ @Component
11
+ public class SeedRunner implements ApplicationRunner {
12
+
13
+ private final List<Seeder> seeders;
14
+ private final ConfigurableApplicationContext context;
15
+
16
+ public SeedRunner(List<Seeder> seeders, ConfigurableApplicationContext context) {
17
+ this.seeders = seeders;
18
+ this.context = context;
19
+ }
20
+
21
+ @Override
22
+ public void run(ApplicationArguments args) {
23
+ if (!args.containsOption("birc.seed")) {
24
+ return;
25
+ }
26
+ for (Seeder seeder : seeders) {
27
+ seeder.run();
28
+ }
29
+ System.exit(SpringApplication.exit(context, () -> 0));
30
+ }
31
+ }
@@ -0,0 +1,6 @@
1
+ package {{basePackage}}.seeder;
2
+
3
+ public interface Seeder {
4
+
5
+ void run();
6
+ }
@@ -0,0 +1,38 @@
1
+ package {{basePackage}}.seeder;
2
+
3
+ {{#each extraImports}}
4
+ import {{this}};
5
+ {{/each}}
6
+ {{#if seedFields.length}}
7
+ import lombok.RequiredArgsConstructor;
8
+ import org.springframework.stereotype.Component;
9
+ import {{daoPackage}}.{{pascalCase entityName}}DAO;
10
+ import {{entityPackage}}.{{pascalCase entityName}};
11
+ {{else}}
12
+ import org.springframework.stereotype.Component;
13
+ {{/if}}
14
+
15
+ @Component
16
+ {{#if seedFields.length}}
17
+ @RequiredArgsConstructor
18
+ {{/if}}
19
+ public class {{seederClass}} implements Seeder {
20
+ {{#if seedFields.length}}
21
+
22
+ private final {{pascalCase entityName}}DAO {{camelCase entityName}}DAO;
23
+
24
+ {{/if}}
25
+ @Override
26
+ public void run() {
27
+ {{#if seedFields.length}}
28
+ if ({{camelCase entityName}}DAO.count() > 0) {
29
+ return;
30
+ }
31
+ {{pascalCase entityName}} {{camelCase entityName}} = new {{pascalCase entityName}}();
32
+ {{#each seedFields}}
33
+ {{camelCase ../entityName}}.{{this.setter}}({{{this.literal}}});
34
+ {{/each}}
35
+ {{camelCase entityName}}DAO.save({{camelCase entityName}});
36
+ {{/if}}
37
+ }
38
+ }
package/test.md CHANGED
@@ -35,8 +35,11 @@
35
35
  - CLI dest:檔落在呼叫者目錄,不是 generator repo。
36
36
  - 產出 Java 沒有 `tw.edu.ntub.birc.starter` / `birc-spring-boot-starter`。
37
37
  - `make:entity --dto` / `--mapper` 與 `make:mapper` 同一組 Mapper + DTO;預設 `make:entity` 不生它們。
38
- - `make:model --migration` / `--seed` 的表名跟 Entity `@Table` 一致;migration 與 seed 版本號不重複。
38
+ - `make --fields --migration --seed` CREATE TABLE 與 Java `XxxSeeder`,不生 seed SQL;`make:model --migration` 的表名跟 Entity `@Table` 一致。
39
+ - `make:seeder` / `--seed` 讀既有 Entity 欄位寫進 `setXxx`;沒有欄位時 `run()` 是空的。`make:seeder BookSeeder` 生 `BookSeeder.java`,不是 `BookSeederSeeder`。
40
+ - `make:migration create_{table}_table` 讀既有 Entity 欄位寫進 SQL;找不到 Entity 時仍只有 `id`。
39
41
  - `--soft-delete` 的 Entity 用 `deleted_at` + `SoftDeleteType.TIMESTAMP`;沒加 flag 的 Entity / migration 沒有這欄。
42
+ - `--fields` 有帶就當完整產出,不必再加 `--example`。
40
43
  - `--example` 的驗證群組是專案 `ValidGroup.Create` / `Update`,不是 starter `OnCreate`。`Delete` / `Submit` 是空的 marker,給「刪除要帶 body」與送審用,預設 CRUD 不標;不要刪掉。
41
44
  - Flyway plugin 版本跟得上 Gradle wrapper;`flywayMigrate` 不能再碰到已刪的 Gradle convention API。
42
45
  - 安裝/更新路徑:`detectInstallKind`、自訂 base URL 預設拒絕、tarball 路徑不可逃出目標目錄。
package/uninstall.ps1 CHANGED
@@ -1,5 +1,5 @@
1
1
  # 用法:
2
- # irm https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator/-/raw/main/uninstall.ps1 | iex
2
+ # irm https://gitlab.ntubimdbirc.tw/birc-backend/birc-generator/-/raw/main/uninstall.ps1 | iex
3
3
  # 或下載後執行: powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1
4
4
  # 只拆 curl / PowerShell 裝到 $HOME\.birc-generator 的那份,不會 npm uninstall -g。
5
5
 
@@ -1,7 +0,0 @@
1
- -- {{description}}
2
-
3
- {{#if withExample}}
4
- INSERT INTO {{tableName}} (name) VALUES ('example');
5
- {{else}}
6
- -- INSERT INTO {{tableName}} (...) VALUES (...);
7
- {{/if}}