@nakedev/go-scaffold 0.1.3 → 0.3.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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -50
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +23 -12
  6. package/dist/commands/method.js +94 -17
  7. package/dist/commands/observability.js +114 -0
  8. package/dist/commands/rbac.js +19 -2
  9. package/dist/commands/undo.js +331 -0
  10. package/dist/commands/worker.js +92 -32
  11. package/dist/index.js +368 -64
  12. package/dist/prompts/auth-wizard.js +29 -0
  13. package/dist/prompts/create-wizard.js +5 -2
  14. package/dist/prompts/generate-wizard.js +57 -0
  15. package/dist/prompts/worker-wizard.js +25 -0
  16. package/dist/templates/auth-manifest.js +20 -3
  17. package/dist/templates/create-manifest.js +13 -20
  18. package/dist/templates/module-manifest.js +2 -0
  19. package/dist/templates/observability-manifest.js +24 -0
  20. package/dist/templates/rbac-manifest.js +1 -0
  21. package/dist/templates/worker-manifest.js +23 -6
  22. package/dist/utils/auth-patcher.js +96 -21
  23. package/dist/utils/config.js +58 -10
  24. package/dist/utils/gocheck.js +57 -5
  25. package/dist/utils/golangci-patcher.js +73 -0
  26. package/dist/utils/gomod-patcher.js +53 -0
  27. package/dist/utils/main-patcher.js +58 -4
  28. package/dist/utils/marker-patch.js +125 -3
  29. package/dist/utils/method-patcher.js +97 -18
  30. package/dist/utils/module-location.js +58 -0
  31. package/dist/utils/naming.js +125 -14
  32. package/dist/utils/observability-patcher.js +107 -0
  33. package/dist/utils/openapi-patcher.js +19 -1
  34. package/dist/utils/platform-patcher.js +98 -12
  35. package/dist/utils/rbac-patcher.js +60 -10
  36. package/dist/utils/smoke-run.js +31 -0
  37. package/package.json +11 -4
  38. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  39. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  40. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  41. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  42. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  43. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  44. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  45. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  46. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  50. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  51. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  52. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  53. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  54. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  55. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  56. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  57. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  58. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  59. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  60. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  61. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  62. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  63. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  64. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  65. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  66. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  67. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  68. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  69. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  70. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  71. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  72. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  73. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  74. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  75. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  76. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  77. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  78. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  79. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  80. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  81. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  82. package/templates/create/base/.dockerignore.hbs +13 -0
  83. package/templates/create/base/.env.example.hbs +18 -9
  84. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  85. package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
  86. package/templates/create/base/.golangci.yml.hbs +27 -0
  87. package/templates/create/base/AGENTS.md.hbs +14 -12
  88. package/templates/create/base/Dockerfile.hbs +42 -0
  89. package/templates/create/base/Makefile.hbs +52 -16
  90. package/templates/create/base/README.md.hbs +61 -12
  91. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  92. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  93. package/templates/create/base/go.mod.hbs +4 -4
  94. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  95. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  96. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  97. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  98. package/templates/create/base/redocly.yaml.hbs +21 -0
  99. package/templates/create/features/docs/architecture.md.hbs +32 -11
  100. package/templates/create/features/docs/openapi.yaml.hbs +4 -9
  101. package/templates/create/features/docs/patterns.md.hbs +91 -14
  102. package/templates/create/features/docs/techstack.md.hbs +8 -3
  103. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  104. package/templates/generate/module/dto.go.hbs +8 -1
  105. package/templates/generate/module/errors.go.hbs +5 -0
  106. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  107. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  108. package/templates/generate/module/handler.go.hbs +18 -4
  109. package/templates/generate/module/handler_test.go.hbs +88 -62
  110. package/templates/generate/module/migration.down.sql.hbs +3 -1
  111. package/templates/generate/module/migration.up.sql.hbs +7 -2
  112. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  113. package/templates/generate/module/minimal/handler.go.hbs +8 -2
  114. package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
  115. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  116. package/templates/generate/module/model/model.go.hbs +16 -0
  117. package/templates/generate/module/permission.up.sql.hbs +3 -1
  118. package/templates/generate/module/repository.go.hbs +60 -6
  119. package/templates/generate/module/repository_test.go.hbs +109 -0
  120. package/templates/generate/module/service.go.hbs +15 -4
  121. package/templates/generate/module/service_test.go.hbs +113 -17
  122. package/dist/commands/remove.js +0 -89
  123. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  124. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pranob Onphaeng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  A CLI that scaffolds a Gin + GORM + PostgreSQL Go backend, then keeps
4
4
  generating consistent domain modules into that project as it grows — the Go
5
- counterpart to [nest-scaffold](../nest-scaffold).
5
+ counterpart to nest-scaffold.
6
6
 
7
- You don't hand-wire a new domain into `cmd/api/main.go`, write the
7
+ You don't hand-wire a new domain into `cmd/api/wiring.go`, write the
8
8
  handler/service/repository boilerplate, or decide error-handling conventions
9
9
  each time — the CLI does that, and every module it generates follows the
10
10
  same shape as the last one.
@@ -52,6 +52,11 @@ go-scaffold generate method orders approve --type patch
52
52
 
53
53
  ## Commands
54
54
 
55
+ Every `add` command shows what it's about to do and asks before writing;
56
+ `-y/--yes` skips that (and `--defaults` implies it) for CI and scripts.
57
+ Running `go-scaffold` with no arguments picks the command from a menu.
58
+
59
+
55
60
  ### `create <name>` — scaffold a new project
56
61
 
57
62
  ```bash
@@ -61,19 +66,20 @@ go-scaffold create my-api --defaults --no-docker --api-prefix beta
61
66
  ```
62
67
 
63
68
  Produces a **bare skeleton only** — `cmd/api`, the shared platform packages
64
- (config/apperror/dberr/httpx/id/middleware/pagination), Docker+Postgres,
69
+ (config/apperror/dberr/httpx/id/middleware/pagination/tx), Docker+Postgres,
65
70
  migrations folder, and the standards docs (`docs/architect/`, `AGENTS.md`,
66
71
  `CLAUDE.md`, `.claude/skills/go-scaffold/`). No domain modules — add those
67
72
  with `generate module`.
68
73
 
69
74
  | Option | Effect |
70
75
  |---|---|
71
- | `--defaults` | Skip the wizard, use defaults (Docker on, OpenAPI docs on, prefix `v1`) |
76
+ | `--defaults` | Skip the wizard, use defaults (Docker on, OpenAPI docs on, no route prefix) |
72
77
  | `--no-docker` | Skip `docker-compose.yml` (with `--defaults`) |
73
78
  | `--no-openapi-docs` | Skip `docs/openapi.yaml` (with `--defaults`) |
74
- | `--api-prefix <prefix>` | URL prefix every route is grouped under (with `--defaults`; default `v1`, `""` for none, `/`-separated segments like `api/v1` are fine) |
79
+ | `--observability` | Prometheus `/metrics` + OpenTelemetry tracing (with `--defaults`; off by default `add observability` does the same later) |
80
+ | `--api-prefix <prefix>` | URL prefix every route is grouped under — opt in with e.g. `v1` or `api/v1`; omit it for none |
75
81
 
76
- Without `--defaults`, an interactive wizard asks the same three questions.
82
+ Without `--defaults`, an interactive wizard asks the same four questions.
77
83
  The prefix is a single project-wide choice made once at `create` time —
78
84
  there's no per-domain versioning (a domain that needs a real breaking change
79
85
  gets a new domain package or a new DTO field, not a duplicated model pointed
@@ -87,25 +93,30 @@ directory layout if missing).
87
93
  ### `generate module <name>` (alias `m`) — add a domain module
88
94
 
89
95
  ```bash
90
- go-scaffold generate module orders # full CRUD (default)
91
- go-scaffold generate module orders --no-full # minimal skeleton add endpoints with `generate method`
96
+ go-scaffold generate module orders # asks for the shape (and auth, if installed)
97
+ go-scaffold generate module orders --full # opt-in CRUD skeleton, no prompt
98
+ go-scaffold generate module orders --defaults # safe minimal module, no prompt (CI/scripting)
92
99
  ```
93
100
 
94
- Full CRUD (default) scaffolds:
101
+ Anything you don't pass as a flag is asked for; `--defaults` takes the
102
+ documented defaults (minimal, no auth) and asks nothing.
103
+
104
+ `--full` scaffolds:
95
105
 
96
106
  ```text
97
- internal/app/orders/
107
+ internal/app/order/
98
108
  ├── model/model.go # domain model + GORM table (id/created_at/updated_at — add real fields yourself; a folder so multi-table domains can add more files)
99
109
  ├── dto.go # request/response structs (empty stubs — add real fields yourself)
100
- ├── errors.go # ORDERS_NOT_FOUND / ORDERS_CONFLICT / ORDERS_HAS_REFERENCES
110
+ ├── errors.go # ORDER_NOT_FOUND / ORDER_CONFLICT / ORDER_HAS_REFERENCES / ORDER_STALE
101
111
  ├── repository.go # GORM data access
102
112
  ├── service.go # business logic + repository interface (mockable)
103
113
  ├── handler.go # Gin routes, registered under the project's API prefix
104
- ├── service_test.go # unit test, fake repo
105
- └── handler_test.go # integration test, real Postgres, tx rollback
114
+ ├── service_test.go # unit test, function-backed repository stub
115
+ ├── handler_test.go # HTTP unit test, service stub, no DB
116
+ └── repository_test.go # Postgres integration test against migrated schema
106
117
  ```
107
118
 
108
- `--no-full` scaffolds the same `model`/`errors`/`repository` (so `generate
119
+ The default minimal mode scaffolds the same `model`/`errors`/`repository` (so `generate
109
120
  method` always has a full data-access surface to call), but `dto`/`service`/
110
121
  `handler` start empty — no default CRUD, no routes, just the plumbing
111
122
  (`Register()`, the `repository` interface, `wrapFindErr`) that `generate
@@ -114,11 +125,13 @@ surface, or you'd rather add endpoints one at a time.
114
125
 
115
126
  Both modes also:
116
127
 
117
- - Register the module in `cmd/api/main.go` (via marker comments — see
128
+ - Register the module in `cmd/api/wiring.go` (via marker comments — see
118
129
  `// go-scaffold:*` in that file) — full wires an actual route, minimal
119
130
  wires an empty route group
120
- - Add the model to the `AutoMigrate(...)` call
121
- - Append `migrations/<seq>_create_<plural>.{up,down}.sql`
131
+ - Create the module's own Postgres schema (`<module>_svc`, e.g. `order_svc`)
132
+ and add the model to the `AutoMigrate(...)` call
133
+ - Append `migrations/<timestamp>_create_<plural>.{up,down}.sql`, which creates that
134
+ same schema for `AUTO_MIGRATE=false`/production
122
135
 
123
136
  What it does **not** do: invent your fields or wire foreign keys between
124
137
  domains — see `docs/architect/patterns.md` in the generated project for the
@@ -145,7 +158,7 @@ overwrites a method with the same name; picks a different one or errors.
145
158
  | `--type` | Route | What's generated |
146
159
  |---|---|---|
147
160
  | `get --get-mode all` | `GET /<plural>/<kebab-name>` | reuses `FindAll` — TODO to add real filtering |
148
- | `get --get-mode one --field <f>` | `GET /<plural>/<f>/:<f>` | a real `FindBy<F>` query added to the repository (+ its interface + `fakeRepo` test stub) |
161
+ | `get --get-mode one --field <f>` | `GET /<plural>/<f>/:<f>` | a real `FindBy<F>` query added to the repository (+ its interface + function-backed repository test stub) |
149
162
  | `post` | `POST /<plural>/<kebab-name>` | adds a body DTO; service is a TODO stub |
150
163
  | `put` / `patch` | `<VERB> /<plural>/:id/<kebab-name>` | finds by id, TODO before saving (safe no-op until implemented) |
151
164
  | `delete` | `DELETE /<plural>/:id/<kebab-name>` | TODO stub |
@@ -154,8 +167,9 @@ Business logic is always left as a `TODO`-marked stub that compiles and
154
167
  returns a clean `500` rather than inventing behavior — see
155
168
  `docs/architect/patterns.md` in the generated project.
156
169
 
157
- `generate method` prints the route it added but does **not** touch
158
- `docs/openapi.yaml` endpoint-specific spec entries stay hand-written.
170
+ When OpenAPI docs are enabled, `generate method` also creates a valid TODO stub
171
+ under `docs/<plural>/methods/` and wires the route into `docs/openapi.yaml`.
172
+ Replace its placeholder request/response schemas while implementing the TODO.
159
173
 
160
174
  **Drift check** — `generate` type-checks the project (`go vet ./...`) before and
161
175
  after it writes. If the project was fine beforehand and the generated code
@@ -190,26 +204,75 @@ Creates timestamped `migrations/<version>_<name>.up.sql` and `.down.sql` TODO
190
204
  stubs. The CLI reserves the names; you own the SQL and should apply it with
191
205
  `make migrate-up` (or `migrate -path migrations -database "$DB_DSN" up`).
192
206
 
193
- ### `add worker` — add Redis-backed background work
207
+ ### `add worker` — add background job processing
194
208
 
195
209
  ```bash
196
- go-scaffold add worker
210
+ go-scaffold add worker # asks where jobs should live
211
+ go-scaffold add worker --queue postgres # River (default)
212
+ go-scaffold add worker --queue redis # Asynq
213
+ go-scaffold add worker --defaults # no prompt, Postgres
197
214
  ```
198
215
 
199
- Adds Redis cache/queue support, async email delivery, and `cmd/worker`. It also
200
- makes `/readyz` report unavailable when Redis is down. Run `go mod tidy` and
201
- provide `REDIS_URL` before starting the API or worker.
216
+ Adds `internal/platform/queue` (a backend-neutral contract plus one adapter),
217
+ async email delivery, and `cmd/worker`.
218
+
219
+ | | `--queue postgres` (River) | `--queue redis` (Asynq) |
220
+ |---|---|---|
221
+ | Extra service to run | none | Redis |
222
+ | Needed by `add auth` | no | no — `add auth --store` decides that separately |
223
+ | Enqueue joins your DB transaction | yes | **no** — needs an outbox |
224
+ | Throughput | thousands/sec | tens of thousands/sec |
225
+ | Inspect pending jobs | plain SQL | asynqmon |
226
+
227
+ The default is Postgres because a job enqueued inside `tx.Do` is then only
228
+ delivered if that transaction commits — no more welcome emails for signups
229
+ that rolled back. Run `make river-migrate` once per database to create
230
+ River's tables, then `make worker`.
231
+
232
+ Application code only ever sees `queue.Job`, `queue.Enqueuer` and
233
+ `queue.Handler` — no backend package appears outside its own adapter file, so
234
+ switching later means writing one adapter, not touching every module that
235
+ enqueues something.
236
+
237
+ ```go
238
+ type WelcomeEmail struct{ To string `json:"to"` }
239
+ func (WelcomeEmail) Kind() string { return "email:welcome" }
240
+
241
+ // cmd/api — the job is discarded with the transaction if this fails
242
+ tx.Do(ctx, db, func(ctx context.Context) error {
243
+ if err := repo.Create(ctx, u); err != nil { return err }
244
+ return jobs.Enqueue(ctx, WelcomeEmail{To: u.Email}, nil)
245
+ })
246
+ ```
202
247
 
203
248
  ### `add auth` — add email/password authentication
204
249
 
205
250
  ```bash
206
- go-scaffold add auth
251
+ go-scaffold add auth # asks where tokens should live, then confirms
252
+ go-scaffold add auth --store postgres # tokens in Postgres, no extra service
253
+ go-scaffold add auth --store redis # tokens in Redis, exact across replicas
254
+ go-scaffold add auth --defaults # Postgres, no prompt at all (CI/scripting)
207
255
  ```
208
256
 
209
- Requires `add worker`. Adds JWT access tokens, Redis-backed refresh-token
210
- rotation, registration/login/logout, password reset, email verification, and
211
- Google OAuth routes. Apply the generated migrations; `AUTO_MIGRATE=true` is
212
- convenient in development, while production should use `migrate up`.
257
+ Adds JWT access tokens, refresh-token rotation with reuse detection,
258
+ registration/login/logout, password reset, email verification, failed-login
259
+ lockout, and Google OAuth routes. Apply the generated migrations;
260
+ `AUTO_MIGRATE=true` is convenient in development, while production should use
261
+ `migrate up`.
262
+
263
+ No prerequisites. On a project with no worker the verification and reset mail
264
+ is sent inline, and `add worker` later moves it onto the queue for you — the
265
+ two endpoints that send mail block on SMTP until you do.
266
+
267
+ | `--store` | Tokens and rate-limit counters | Extra service |
268
+ |---|---|---|
269
+ | `postgres` (default) | `user_svc.auth_tokens`, counters in-process | none |
270
+ | `redis` | Redis | Redis |
271
+
272
+ The rate limiter follows the store rather than being chosen separately, because
273
+ "I want this exact across replicas" is one decision. With `postgres` the per-IP
274
+ budget is per-replica; the failed-login lockout is in Postgres either way, since
275
+ that one can't be approximate.
213
276
 
214
277
  ### `add rbac` — add roles and permissions
215
278
 
@@ -223,29 +286,51 @@ middleware, and role assignment. Its migration seeds the default roles and
223
286
  permissions, so apply it with `migrate up`: AutoMigrate creates tables but does
224
287
  not run SQL seed statements.
225
288
 
226
- ### Observability at project creation
289
+ ### `add observability` add metrics + tracing
227
290
 
228
291
  ```bash
229
- go-scaffold create my-api --defaults --observability
292
+ go-scaffold add observability # on an existing project
293
+ go-scaffold create my-api --defaults --observability # or at creation time
230
294
  ```
231
295
 
232
- Opt-in observability adds Prometheus metrics at `/metrics` and OpenTelemetry
233
- tracing. Tracing is disabled until `OTEL_EXPORTER_OTLP_ENDPOINT` is configured.
296
+ Adds Prometheus metrics at `/metrics` and OpenTelemetry tracing for Gin +
297
+ GORM, patched into `cmd/api/wiring.go` and `internal/platform/database` the same
298
+ way `add worker`/`add auth`/`add rbac` patch an existing project. Tracing is
299
+ disabled until `OTEL_EXPORTER_OTLP_ENDPOINT` is configured; `/metrics` works
300
+ either way. `create --observability` is exactly this command run right after
301
+ scaffolding — the two produce the same project.
234
302
 
235
- ### `remove module <name>` (alias `rm m`) — drop a domain
303
+ ### `undo module <name>` (alias `undo m`) — take back a `generate module`
236
304
 
237
305
  ```bash
238
- go-scaffold remove module orders # confirms first
239
- go-scaffold rm m orders --yes # skip the confirm
306
+ go-scaffold undo module orders # confirms first
307
+ go-scaffold undo m orders --yes # skip the confirm
240
308
  ```
241
309
 
242
- The inverse of `generate module`: deletes `internal/app/<name>/` and reverses
243
- everything that was wired upthe import/AutoMigrate/route in `main.go`, the
244
- paths/schemas in `docs/openapi.yaml`, the per-module docs folder, and the
245
- `create_<plural>` migration. Restores the `_ = api` placeholder if it was the
246
- last module, so the project still builds. Use this instead of hand-deleting
247
- the folder a partial hand-delete leaves stale wiring that duplicates on the
248
- next `generate module` (which would panic gin at startup).
310
+ The inverse of `generate module`, for the case it's actually the inverse of:
311
+ a module you didn't mean to generate a typo'd name, a domain you decided
312
+ against. It deletes `internal/app/<name>/`, the per-module docs folder, **and
313
+ the module's migration files**, and reverses the import/AutoMigrate/route in
314
+ `main.go` plus the paths/schemas in `docs/openapi.yaml`. Restores the
315
+ `_ = api` placeholder if it was the last module, so the project still builds.
316
+
317
+ Deleting the migrations is the point. `migrations/embed.go` is a `//go:embed
318
+ *`, so a typo'd `create_oders` left behind once ran on every database created
319
+ from then on. That's only safe while those files exist nowhere but your
320
+ working tree, so `undo` proves it first and refuses loudly otherwise:
321
+
322
+ - **any of them is tracked by git** — it may already have been pulled or
323
+ deployed somewhere, so nothing is deleted. Retire that domain the explicit
324
+ way instead: `go-scaffold generate migration drop_<name>`.
325
+ - **your database is already at or past that version** (read via the `migrate`
326
+ CLI when it's on `PATH` and a DSN is configured) — deleting the files would
327
+ strand `schema_migrations` at a version with no migration behind it. Run
328
+ `migrate ... down` first, then try again.
329
+
330
+ The table itself is never dropped either way — `undo` only reverses what the
331
+ CLI wrote. Prefer it to hand-deleting the folder: it also un-wires `main.go`,
332
+ `.golangci.yml` and the OpenAPI index, and it refuses when another domain
333
+ still imports this one rather than leaving you an un-compilable project.
249
334
 
250
335
  ## Why no per-domain versioning
251
336
 
@@ -261,7 +346,7 @@ and `float64` in the other for the *same* column, converging it to
261
346
  data with different, silently incompatible interpretations.
262
347
 
263
348
  Instead, every route in a project is grouped under a single project-wide
264
- `--api-prefix` (default `v1`) chosen once at `create` time. A domain that
349
+ `--api-prefix` chosen once at `create` time — opt-in, no prefix unless you ask. A domain that
265
350
  needs a real breaking change gets a new domain package, or a new field on
266
351
  the existing DTO — not a duplicated model pointed at a table it can drift
267
352
  out of sync with.
@@ -269,10 +354,10 @@ out of sync with.
269
354
  ## Project structure produced by `create`
270
355
 
271
356
  ```text
272
- cmd/api/main.go
357
+ cmd/api/wiring.go
273
358
  internal/
274
359
  ├── platform/database/
275
- ├── shared/{config,apperror,dberr,httpx,id,middleware,pagination}/
360
+ ├── shared/{config,apperror,dberr,httpx,id,middleware,pagination,tx}/
276
361
  └── app/ # empty until you `generate module`
277
362
  docs/
278
363
  ├── architect/{architecture,patterns,techstack}.md
@@ -280,8 +365,13 @@ docs/
280
365
  migrations/
281
366
  .github/workflows/ci.yml # build, vet, gofmt check, golangci-lint, go test (with a Postgres service)
282
367
  Makefile
368
+ .env.example
369
+ .gitignore
283
370
  .golangci.yml
371
+ redocly.yaml # if openapi docs enabled
372
+ docker-compose.yml # if Docker enabled
284
373
  .vscode/settings.json
374
+ README.md
285
375
  AGENTS.md
286
376
  CLAUDE.md
287
377
  .claude/skills/go-scaffold/SKILL.md
@@ -290,11 +380,14 @@ go-scaffold.config.json
290
380
 
291
381
  ## Supported stack
292
382
 
383
+ Pinned in the generated `go.mod` — this table mirrors
384
+ `templates/create/base/go.mod.hbs`, which is the source of truth.
385
+
293
386
  | Package | Version |
294
387
  |---|---|
295
- | Gin | v1.10.0 |
296
- | GORM + postgres driver | v1.25.12 / v1.5.9 |
297
- | validator/v10 | v10.20.0 |
388
+ | Gin | v1.10.1 |
389
+ | GORM + postgres driver | v1.31.2 / v1.6.2 |
390
+ | validator/v10 | v10.30.3 |
298
391
  | google/uuid | v1.6.0 |
299
392
 
300
393
  ## License
@@ -11,8 +11,13 @@ const config_1 = require("../utils/config");
11
11
  const template_renderer_1 = require("../utils/template-renderer");
12
12
  const auth_manifest_1 = require("../templates/auth-manifest");
13
13
  const auth_patcher_1 = require("../utils/auth-patcher");
14
+ const platform_patcher_1 = require("../utils/platform-patcher");
15
+ const worker_manifest_1 = require("../templates/worker-manifest");
16
+ const golangci_patcher_1 = require("../utils/golangci-patcher");
14
17
  const migrations_1 = require("../utils/migrations");
15
18
  const openapi_patcher_1 = require("../utils/openapi-patcher");
19
+ const gocheck_1 = require("../utils/gocheck");
20
+ const gomod_patcher_1 = require("../utils/gomod-patcher");
16
21
  // URL (relative to the api prefix) -> docs file (relative to docs/) for every
17
22
  // route `add auth` registers — kept next to AUTH_FILES's route list so the
18
23
  // two are easy to eyeball together when a route changes.
@@ -36,16 +41,29 @@ const AUTH_OPENAPI_PATHS = [
36
41
  // (no roles/permissions) — that's a separate opt-in on top of this, since
37
42
  // most projects need "is this caller logged in" long before they need "can
38
43
  // this caller do X".
39
- async function addAuth(projectDir = process.cwd()) {
44
+ async function addAuth(store = "postgres", projectDir = process.cwd()) {
40
45
  const config = (0, config_1.readConfig)(projectDir);
41
- if (!config.features.worker) {
42
- throw new Error("`go-scaffold add auth` requires `go-scaffold add worker` first the refresh token store needs Redis");
43
- }
46
+ // No longer a prerequisite. Without a worker the verification and reset mail
47
+ // goes out inline instead of through a queuea real trade (those two
48
+ // endpoints then block on SMTP), but not one worth forcing a second binary
49
+ // and a queue-backend decision on someone who only wanted login.
50
+ const worker = config.features.worker ?? false;
44
51
  const userDir = path_1.default.join(projectDir, "internal", "app", "user");
45
52
  if (fs_extra_1.default.existsSync(userDir)) {
46
53
  throw new Error(`${userDir} already exists — auth looks like it's already been added`);
47
54
  }
48
- await (0, template_renderer_1.applyTemplateEntries)(projectDir, auth_manifest_1.AUTH_FILES, { goModule: config.goModule });
55
+ const parsedBefore = (0, gocheck_1.parseChecks)(projectDir);
56
+ // Only `--store redis` needs Redis. With `--store postgres` (the default)
57
+ // auth adds no service at all — which is the whole point of the option.
58
+ if (store === "redis")
59
+ await ensureRedis(projectDir, config.goModule);
60
+ // the SMTP client, and the config it reads, normally arrive with `add worker`
61
+ if (!worker) {
62
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, worker_manifest_1.MAIL_CLIENT_ONLY, { goModule: config.goModule });
63
+ (0, platform_patcher_1.patchConfigForSMTP)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
64
+ patchEnvExampleForSMTP(path_1.default.join(projectDir, ".env.example"));
65
+ }
66
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, (0, auth_manifest_1.authFiles)(store), { goModule: config.goModule });
49
67
  const migrationsDir = path_1.default.join(projectDir, "migrations");
50
68
  fs_extra_1.default.ensureDirSync(migrationsDir);
51
69
  const usersVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
@@ -62,8 +80,35 @@ async function addAuth(projectDir = process.cwd()) {
62
80
  { template: "add/auth/migrations/create_identities.up.sql.hbs", output: path_1.default.join("migrations", `${identitiesVersion}_create_identities.up.sql`) },
63
81
  { template: "add/auth/migrations/create_identities.down.sql.hbs", output: path_1.default.join("migrations", `${identitiesVersion}_create_identities.down.sql`) },
64
82
  ], {});
83
+ // the failed-attempt counter is not a store choice — lockout has to survive a
84
+ // deploy and mean the same thing on every replica whichever store holds tokens
85
+ const throttleVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
86
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
87
+ { template: "add/auth/migrations/create_login_throttle.up.sql.hbs", output: path_1.default.join("migrations", `${throttleVersion}_create_login_throttle.up.sql`) },
88
+ { template: "add/auth/migrations/create_login_throttle.down.sql.hbs", output: path_1.default.join("migrations", `${throttleVersion}_create_login_throttle.down.sql`) },
89
+ ], {});
90
+ if (store === "postgres") {
91
+ const authTokensVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
92
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
93
+ { template: "add/auth/migrations/create_auth_tokens.up.sql.hbs", output: path_1.default.join("migrations", `${authTokensVersion}_create_auth_tokens.up.sql`) },
94
+ { template: "add/auth/migrations/create_auth_tokens.down.sql.hbs", output: path_1.default.join("migrations", `${authTokensVersion}_create_auth_tokens.down.sql`) },
95
+ ], {});
96
+ }
97
+ (0, golangci_patcher_1.patchGolangciForModule)(path_1.default.join(projectDir, ".golangci.yml"), config.goModule, "user");
65
98
  (0, auth_patcher_1.patchConfigForAuth)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
66
- (0, auth_patcher_1.patchMainGoForAuth)(path_1.default.join(projectDir, "cmd", "api", "main.go"), config.goModule);
99
+ (0, auth_patcher_1.patchMainGoForAuth)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), {
100
+ goModule: config.goModule,
101
+ queueBackend: config.features.queue ?? "asynq",
102
+ store,
103
+ worker,
104
+ });
105
+ (0, gomod_patcher_1.patchGoModRequires)(path_1.default.join(projectDir, "go.mod"), [
106
+ "github.com/golang-jwt/jwt/v5 v5.3.1",
107
+ "golang.org/x/crypto v0.52.0",
108
+ "golang.org/x/oauth2 v0.36.0",
109
+ // go-redis only when something in this project actually constructs a client
110
+ ...(store === "redis" ? ["github.com/redis/go-redis/v9 v9.22.0"] : []),
111
+ ]);
67
112
  patchEnvExample(path_1.default.join(projectDir, ".env.example"));
68
113
  patchMakefile(path_1.default.join(projectDir, "Makefile"));
69
114
  let docsMessage = "";
@@ -81,10 +126,20 @@ async function addAuth(projectDir = process.cwd()) {
81
126
  docsMessage = "\ndocs: docs/auth/*.yaml, wired into docs/openapi.yaml";
82
127
  }
83
128
  (0, template_renderer_1.gofmtTree)(projectDir);
84
- (0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, auth: true } });
129
+ // parse-only: jwt/oauth2/bcrypt aren't in go.mod until the `go mod tidy`
130
+ // printed below, so `go vet` can't be the gate here.
131
+ (0, gocheck_1.assertStillParses)(projectDir, parsedBefore, "added auth");
132
+ (0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, auth: true, authStore: store } });
85
133
  console.log(picocolors_1.default.green("\nadded internal/app/user/, internal/shared/middleware/auth.go, and cmd/seed"));
86
- console.log("registered POST /auth/{register,login,refresh,logout,forgot-password,reset-password}, " +
87
- "GET /auth/google/{login,callback}, and GET /users/me in cmd/api/main.go" +
134
+ console.log(worker
135
+ ? "verification + password-reset mail goes through the queue"
136
+ : "verification + password-reset mail is sent inline (no worker) — run `add worker` later to move it onto the queue");
137
+ console.log(store === "postgres"
138
+ ? "tokens + rate-limit counters: Postgres (user_svc.auth_tokens) and in-process — no Redis"
139
+ : "tokens + rate-limit counters: Redis");
140
+ console.log("registered POST /auth/{register,login,refresh,logout,forgot-password,reset-password,verify-email}, " +
141
+ "GET /auth/google/{login,callback}, GET /users/me, and " +
142
+ "POST /users/me/{resend-verification,logout-all} in cmd/api/wiring.go" +
88
143
  docsMessage);
89
144
  console.log(picocolors_1.default.dim("\nnext: go mod tidy, then apply the new migrations (AUTO_MIGRATE=true picks them up automatically in dev)\n" +
90
145
  "seed an admin: SEED_ADMIN_EMAIL=... SEED_ADMIN_PASSWORD=... make seed"));
@@ -100,8 +155,14 @@ function patchMakefile(makefilePath) {
100
155
  "# environment, not .env, so a real secret never sits in a checked-in file.\n" +
101
156
  "# --fixtures adds throwaway dev sample users, never use it outside dev.\n" +
102
157
  "seed:\n" +
103
- "\t@[ -f .env ] && export $$(grep -v '^#' .env | sed -E 's/[[:space:]]+#.*$//' | xargs); go run ./cmd/seed $(ARGS)\n";
104
- content = content.replace(/\nbuild:/, `${target}\nbuild:`);
158
+ // `$$` throughout: make expands a single `$` as a variable reference, so
159
+ // `$//` became `//` and left sed an unterminated s/// expression — the
160
+ // recipe then failed, `.env` never loaded, and a bare `export` dumped the
161
+ // whole environment. Matches every target in Makefile.hbs verbatim.
162
+ "\t@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; go run ./cmd/seed $(ARGS)\n";
163
+ // Function replacer — target contains a literal "$$", see worker.ts's
164
+ // patchMakefile for why a string replacement would silently mangle it.
165
+ content = content.replace(/\nbuild:/, () => `${target}\nbuild:`);
105
166
  fs_extra_1.default.writeFileSync(makefilePath, content);
106
167
  }
107
168
  function patchEnvExample(envExamplePath) {
@@ -117,6 +178,12 @@ function patchEnvExample(envExamplePath) {
117
178
  "JWT_ACCESS_TTL_MIN=15\n" +
118
179
  "JWT_REFRESH_TTL_MIN=43200\n" +
119
180
  "COOKIE_SECURE=false\n" +
181
+ "# strict | lax | none — the refresh cookie's SameSite. Keep strict while the\n" +
182
+ "# frontend is the same site as this API (localhost:3000 -> localhost:8080 is,\n" +
183
+ "# and so is app.example.com -> api.example.com). A frontend on a different\n" +
184
+ "# site entirely needs none, together with COOKIE_SECURE=true, or the browser\n" +
185
+ "# never sends the cookie to /auth/refresh and sessions die at every expiry.\n" +
186
+ "COOKIE_SAMESITE=strict\n" +
120
187
  "\nPASSWORD_RESET_TTL_MIN=30\n" +
121
188
  "PASSWORD_RESET_URL=http://localhost:3000/reset-password\n" +
122
189
  "\nEMAIL_VERIFY_TTL_MIN=1440\n" +
@@ -127,3 +194,41 @@ function patchEnvExample(envExamplePath) {
127
194
  "GOOGLE_REDIRECT_URL=\n";
128
195
  fs_extra_1.default.writeFileSync(envExamplePath, content);
129
196
  }
197
+ // ensureRedis adds internal/platform/cache + its config/env/main.go wiring
198
+ // when the project doesn't have Redis yet — which is the normal case once
199
+ // the queue lives in Postgres. Idempotent: a project whose queue is Redis
200
+ // already has all of this and nothing happens.
201
+ async function ensureRedis(projectDir, goModule) {
202
+ const cacheGo = path_1.default.join(projectDir, "internal", "platform", "cache", "redis.go");
203
+ if (fs_extra_1.default.existsSync(cacheGo))
204
+ return;
205
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [{ template: "add/worker/internal/platform/cache/redis.go.hbs", output: "internal/platform/cache/redis.go" }], { goModule });
206
+ (0, platform_patcher_1.patchConfigForRedis)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
207
+ (0, platform_patcher_1.patchMainGoForWorker)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), goModule);
208
+ (0, platform_patcher_1.patchComposeForRedis)(path_1.default.join(projectDir, "docker-compose.yml"));
209
+ (0, platform_patcher_1.patchCiForRedis)(path_1.default.join(projectDir, ".github", "workflows", "ci.yml"));
210
+ const envExamplePath = path_1.default.join(projectDir, ".env.example");
211
+ if (fs_extra_1.default.existsSync(envExamplePath)) {
212
+ const content = fs_extra_1.default.readFileSync(envExamplePath, "utf8");
213
+ if (!content.includes("REDIS_URL")) {
214
+ fs_extra_1.default.writeFileSync(envExamplePath, content.replace(/\n?$/, "\n") + "\n# refresh-token store\nREDIS_URL=redis://localhost:6379/0\n");
215
+ }
216
+ }
217
+ }
218
+ // patchEnvExampleForSMTP mirrors `add worker`'s own SMTP block, for the case
219
+ // where auth installed the mail client without a worker. Same append-once
220
+ // shape as every other env patcher.
221
+ function patchEnvExampleForSMTP(envExamplePath) {
222
+ if (!fs_extra_1.default.existsSync(envExamplePath))
223
+ return;
224
+ const content = fs_extra_1.default.readFileSync(envExamplePath, "utf8");
225
+ if (content.includes("SMTP_HOST"))
226
+ return;
227
+ fs_extra_1.default.writeFileSync(envExamplePath, content.replace(/\n?$/, "\n") +
228
+ "\n# leave SMTP_HOST unset to log emails instead of sending them (dev default)\n" +
229
+ "SMTP_HOST=\n" +
230
+ "SMTP_PORT=587\n" +
231
+ "SMTP_USERNAME=\n" +
232
+ "SMTP_PASSWORD=\n" +
233
+ "SMTP_FROM=no-reply@example.local\n");
234
+ }
@@ -13,6 +13,7 @@ const config_1 = require("../utils/config");
13
13
  const naming_1 = require("../utils/naming");
14
14
  const create_wizard_1 = require("../prompts/create-wizard");
15
15
  const version_1 = require("../utils/version");
16
+ const observability_1 = require("./observability");
16
17
  async function createProject(rawName, opts) {
17
18
  const trimmed = (rawName ?? (await (0, create_wizard_1.promptProjectName)())).trim();
18
19
  if (!trimmed)
@@ -28,7 +29,11 @@ async function createProject(rawName, opts) {
28
29
  let apiPrefix;
29
30
  if (opts.defaults) {
30
31
  features = { docker: opts.docker ?? true, openapiDocs: opts.openapiDocs ?? true, observability: opts.observability ?? false };
31
- apiPrefix = (0, naming_1.normalizeApiPrefix)(opts.apiPrefix ?? "v1");
32
+ // "" to match what the wizard's Enter now gives. --defaults means "don't
33
+ // ask me", not "give me something I didn't ask for", and a prefix is a
34
+ // project-wide decision you cannot change later without rewriting routes
35
+ // and every OpenAPI path.
36
+ apiPrefix = (0, naming_1.normalizeApiPrefix)(opts.apiPrefix ?? "");
32
37
  const check = (0, naming_1.validateApiPrefix)(apiPrefix);
33
38
  if (check !== true)
34
39
  throw new Error(check);
@@ -47,6 +52,13 @@ async function createProject(rawName, opts) {
47
52
  await (0, template_renderer_1.applyTemplateEntries)(projectDir, create_manifest_1.CREATE_MANIFEST, context);
48
53
  (0, template_renderer_1.gofmtTree)(projectDir);
49
54
  (0, config_1.writeConfig)(projectDir, { projectName, goModule, apiPrefix, features, scaffoldVersion: (0, version_1.cliVersion)() });
55
+ // Composed the same way a user would do it by hand — `create` always
56
+ // renders the plain base, then this layers the same patches `add
57
+ // observability` would apply on an existing project, so the two paths
58
+ // produce identical output.
59
+ if (features.observability) {
60
+ await (0, observability_1.addObservability)(projectDir, { silent: true });
61
+ }
50
62
  console.log(picocolors_1.default.green(`\ncreated ${projectName}/`));
51
63
  console.log(`\ncd ${projectName}`);
52
64
  if (features.docker)