@nakedev/go-scaffold 0.1.2 → 0.1.4

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 (131) hide show
  1. package/README.md +93 -14
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +61 -2
  5. package/dist/commands/method.js +66 -15
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +36 -20
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +71 -7
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/module-manifest.js +2 -0
  16. package/dist/templates/rbac-manifest.js +17 -0
  17. package/dist/templates/worker-manifest.js +12 -0
  18. package/dist/utils/auth-patcher.js +96 -0
  19. package/dist/utils/gocheck.js +65 -0
  20. package/dist/utils/main-patcher.js +8 -1
  21. package/dist/utils/method-patcher.js +80 -16
  22. package/dist/utils/migrations.js +30 -8
  23. package/dist/utils/module-location.js +22 -0
  24. package/dist/utils/naming.js +75 -12
  25. package/dist/utils/openapi-patcher.js +35 -1
  26. package/dist/utils/platform-patcher.js +59 -0
  27. package/dist/utils/rbac-patcher.js +277 -0
  28. package/dist/utils/smoke-run.js +31 -0
  29. package/dist/utils/version.js +24 -0
  30. package/package.json +15 -6
  31. package/scripts/smoke-test.mjs +2058 -0
  32. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  33. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  35. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  36. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  38. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  39. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  40. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  41. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  42. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  43. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  44. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  45. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  46. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  47. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  48. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  49. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  50. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  51. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  52. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  53. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  54. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  55. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  56. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  57. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  58. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  59. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  60. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  61. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  62. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  63. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  64. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  65. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  66. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  67. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  68. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  69. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  70. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  71. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  72. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  73. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  74. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  75. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  76. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  77. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  78. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  79. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  80. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  81. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  82. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  83. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  84. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  85. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  86. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  87. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  88. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  89. package/templates/create/base/.env.example.hbs +20 -0
  90. package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
  91. package/templates/create/base/.gitignore.hbs +2 -0
  92. package/templates/create/base/AGENTS.md.hbs +10 -12
  93. package/templates/create/base/Makefile.hbs +39 -8
  94. package/templates/create/base/README.md.hbs +52 -12
  95. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  96. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  97. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  98. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  99. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  100. package/templates/create/base/migrations/embed.go.hbs +15 -0
  101. package/templates/create/features/docs/architecture.md.hbs +22 -0
  102. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  103. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  104. package/templates/create/features/docs/openapi.yaml.hbs +17 -5
  105. package/templates/create/features/docs/patterns.md.hbs +9 -6
  106. package/templates/create/features/docs/techstack.md.hbs +3 -0
  107. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  108. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  109. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  110. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  111. package/templates/generate/module/handler.go.hbs +37 -6
  112. package/templates/generate/module/handler_test.go.hbs +113 -51
  113. package/templates/generate/module/migration.down.sql.hbs +1 -1
  114. package/templates/generate/module/migration.up.sql.hbs +1 -1
  115. package/templates/generate/module/minimal/handler.go.hbs +28 -4
  116. package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
  117. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  118. package/templates/generate/module/model/model.go.hbs +7 -0
  119. package/templates/generate/module/permission.down.sql.hbs +5 -0
  120. package/templates/generate/module/permission.up.sql.hbs +4 -0
  121. package/templates/generate/module/repository_test.go.hbs +79 -0
  122. package/templates/generate/module/service.go.hbs +6 -4
  123. package/templates/generate/module/service_test.go.hbs +68 -17
  124. package/tests/integration/default-module.test.mjs +46 -0
  125. package/tests/integration/generator-naming.test.mjs +81 -0
  126. package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
  127. package/tests/integration/legacy-method-compat.test.mjs +222 -0
  128. package/tests/integration/remove-module.test.mjs +58 -0
  129. package/tests/unit/naming.test.mjs +94 -0
  130. package/tests/unit/smoke-isolation.test.mjs +35 -0
  131. package/dist/utils/module-paths.js +0 -33
package/README.md CHANGED
@@ -87,25 +87,26 @@ directory layout if missing).
87
87
  ### `generate module <name>` (alias `m`) — add a domain module
88
88
 
89
89
  ```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`
90
+ go-scaffold generate module orders # safe minimal module (default)
91
+ go-scaffold generate module orders --full # opt-in CRUD skeleton
92
92
  ```
93
93
 
94
- Full CRUD (default) scaffolds:
94
+ `--full` scaffolds:
95
95
 
96
96
  ```text
97
- internal/app/orders/
97
+ internal/app/order/
98
98
  ├── 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
99
  ├── dto.go # request/response structs (empty stubs — add real fields yourself)
100
- ├── errors.go # ORDERS_NOT_FOUND / ORDERS_CONFLICT / ORDERS_HAS_REFERENCES
100
+ ├── errors.go # ORDER_NOT_FOUND / ORDER_CONFLICT / ORDER_HAS_REFERENCES
101
101
  ├── repository.go # GORM data access
102
102
  ├── service.go # business logic + repository interface (mockable)
103
103
  ├── 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
104
+ ├── service_test.go # unit test, function-backed repository stub
105
+ ├── handler_test.go # HTTP unit test, service stub, no DB
106
+ └── repository_test.go # Postgres integration test against migrated schema
106
107
  ```
107
108
 
108
- `--no-full` scaffolds the same `model`/`errors`/`repository` (so `generate
109
+ The default minimal mode scaffolds the same `model`/`errors`/`repository` (so `generate
109
110
  method` always has a full data-access surface to call), but `dto`/`service`/
110
111
  `handler` start empty — no default CRUD, no routes, just the plumbing
111
112
  (`Register()`, the `repository` interface, `wrapFindErr`) that `generate
@@ -145,7 +146,7 @@ overwrites a method with the same name; picks a different one or errors.
145
146
  | `--type` | Route | What's generated |
146
147
  |---|---|---|
147
148
  | `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) |
149
+ | `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
150
  | `post` | `POST /<plural>/<kebab-name>` | adds a body DTO; service is a TODO stub |
150
151
  | `put` / `patch` | `<VERB> /<plural>/:id/<kebab-name>` | finds by id, TODO before saving (safe no-op until implemented) |
151
152
  | `delete` | `DELETE /<plural>/:id/<kebab-name>` | TODO stub |
@@ -154,8 +155,84 @@ Business logic is always left as a `TODO`-marked stub that compiles and
154
155
  returns a clean `500` rather than inventing behavior — see
155
156
  `docs/architect/patterns.md` in the generated project.
156
157
 
157
- `generate method` prints the route it added but does **not** touch
158
- `docs/openapi.yaml` endpoint-specific spec entries stay hand-written.
158
+ When OpenAPI docs are enabled, `generate method` also creates a valid TODO stub
159
+ under `docs/<plural>/methods/` and wires the route into `docs/openapi.yaml`.
160
+ Replace its placeholder request/response schemas while implementing the TODO.
161
+
162
+ **Drift check** — `generate` type-checks the project (`go vet ./...`) before and
163
+ after it writes. If the project was fine beforehand and the generated code
164
+ doesn't compile, it stops with the compiler output instead of leaving you to
165
+ find it later:
166
+
167
+ ```text
168
+ the generated code doesn't compile, but this project was fine a moment ago.
169
+
170
+ The most likely cause is drift: this project's internal/shared layer has been edited
171
+ since it was scaffolded, so the templates this CLI emits no longer match it.
172
+
173
+ scaffolded with: go-scaffold 0.1.2
174
+ this CLI: go-scaffold 0.3.0
175
+ ```
176
+
177
+ That happens because `generate`'s templates are written against the `shared/`
178
+ layer `create` emits — editing that layer is normal work, but it moves the
179
+ project away from what this CLI's templates expect. `create` records its own
180
+ version in `go-scaffold.config.json` so the message can name both sides. A
181
+ project that was *already* broken (mid-refactor, or `go mod tidy` not run yet)
182
+ is left alone — only a passed-before/broken-after transition is reported. No Go
183
+ on `PATH` means the check is skipped.
184
+
185
+ ### `generate migration <name>` (alias `mig`) — reserve a SQL migration pair
186
+
187
+ ```bash
188
+ go-scaffold generate migration add_status_to_orders
189
+ ```
190
+
191
+ Creates timestamped `migrations/<version>_<name>.up.sql` and `.down.sql` TODO
192
+ stubs. The CLI reserves the names; you own the SQL and should apply it with
193
+ `make migrate-up` (or `migrate -path migrations -database "$DB_DSN" up`).
194
+
195
+ ### `add worker` — add Redis-backed background work
196
+
197
+ ```bash
198
+ go-scaffold add worker
199
+ ```
200
+
201
+ Adds Redis cache/queue support, async email delivery, and `cmd/worker`. It also
202
+ makes `/readyz` report unavailable when Redis is down. Run `go mod tidy` and
203
+ provide `REDIS_URL` before starting the API or worker.
204
+
205
+ ### `add auth` — add email/password authentication
206
+
207
+ ```bash
208
+ go-scaffold add auth
209
+ ```
210
+
211
+ Requires `add worker`. Adds JWT access tokens, Redis-backed refresh-token
212
+ rotation, registration/login/logout, password reset, email verification, and
213
+ Google OAuth routes. Apply the generated migrations; `AUTO_MIGRATE=true` is
214
+ convenient in development, while production should use `migrate up`.
215
+
216
+ ### `add rbac` — add roles and permissions
217
+
218
+ ```bash
219
+ go-scaffold add rbac
220
+ go-scaffold generate module secrets --auth --permission secret:manage
221
+ ```
222
+
223
+ Requires `add auth`. Adds role/permission administration, cached authorization
224
+ middleware, and role assignment. Its migration seeds the default roles and
225
+ permissions, so apply it with `migrate up`: AutoMigrate creates tables but does
226
+ not run SQL seed statements.
227
+
228
+ ### Observability at project creation
229
+
230
+ ```bash
231
+ go-scaffold create my-api --defaults --observability
232
+ ```
233
+
234
+ Opt-in observability adds Prometheus metrics at `/metrics` and OpenTelemetry
235
+ tracing. Tracing is disabled until `OTEL_EXPORTER_OTLP_ENDPOINT` is configured.
159
236
 
160
237
  ### `remove module <name>` (alias `rm m`) — drop a domain
161
238
 
@@ -165,9 +242,11 @@ go-scaffold rm m orders --yes # skip the confirm
165
242
  ```
166
243
 
167
244
  The inverse of `generate module`: deletes `internal/app/<name>/` and reverses
168
- everything that was wired up — the import/AutoMigrate/route in `main.go`, the
169
- paths/schemas in `docs/openapi.yaml`, the per-module docs folder, and the
170
- `create_<plural>` migration. Restores the `_ = api` placeholder if it was the
245
+ the import/AutoMigrate/route in `main.go`, paths/schemas in `docs/openapi.yaml`,
246
+ and the per-module docs folder. **Existing migrations are preserved** because
247
+ production may already have recorded those immutable versions. The table/data
248
+ are also untouched; create a new `generate migration drop_<table>` migration
249
+ when removal is intentional. Restores the `_ = api` placeholder if it was the
171
250
  last module, so the project still builds. Use this instead of hand-deleting
172
251
  the folder — a partial hand-delete leaves stale wiring that duplicates on the
173
252
  next `generate module` (which would panic gin at startup).
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.addAuth = addAuth;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const config_1 = require("../utils/config");
11
+ const template_renderer_1 = require("../utils/template-renderer");
12
+ const auth_manifest_1 = require("../templates/auth-manifest");
13
+ const auth_patcher_1 = require("../utils/auth-patcher");
14
+ const migrations_1 = require("../utils/migrations");
15
+ const openapi_patcher_1 = require("../utils/openapi-patcher");
16
+ // URL (relative to the api prefix) -> docs file (relative to docs/) for every
17
+ // route `add auth` registers — kept next to AUTH_FILES's route list so the
18
+ // two are easy to eyeball together when a route changes.
19
+ const AUTH_OPENAPI_PATHS = [
20
+ { urlPath: "/auth/register", file: "./auth/register.yaml" },
21
+ { urlPath: "/auth/login", file: "./auth/login.yaml" },
22
+ { urlPath: "/auth/refresh", file: "./auth/refresh.yaml" },
23
+ { urlPath: "/auth/logout", file: "./auth/logout.yaml" },
24
+ { urlPath: "/auth/forgot-password", file: "./auth/forgot-password.yaml" },
25
+ { urlPath: "/auth/reset-password", file: "./auth/reset-password.yaml" },
26
+ { urlPath: "/auth/verify-email", file: "./auth/verify-email.yaml" },
27
+ { urlPath: "/auth/google/login", file: "./auth/google-login.yaml" },
28
+ { urlPath: "/auth/google/callback", file: "./auth/google-callback.yaml" },
29
+ { urlPath: "/users/me", file: "./auth/users-me.yaml" },
30
+ { urlPath: "/users/me/resend-verification", file: "./auth/users-me-resend-verification.yaml" },
31
+ { urlPath: "/users/me/logout-all", file: "./auth/users-me-logout-all.yaml" },
32
+ ];
33
+ // addAuth scaffolds email/password authentication: a users+identities model
34
+ // pair, JWT access tokens, a Redis-backed refresh token store with
35
+ // rotation + reuse detection, and register/login/refresh/logout/me. No RBAC
36
+ // (no roles/permissions) — that's a separate opt-in on top of this, since
37
+ // most projects need "is this caller logged in" long before they need "can
38
+ // this caller do X".
39
+ async function addAuth(projectDir = process.cwd()) {
40
+ 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
+ }
44
+ const userDir = path_1.default.join(projectDir, "internal", "app", "user");
45
+ if (fs_extra_1.default.existsSync(userDir)) {
46
+ throw new Error(`${userDir} already exists — auth looks like it's already been added`);
47
+ }
48
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, auth_manifest_1.AUTH_FILES, { goModule: config.goModule });
49
+ const migrationsDir = path_1.default.join(projectDir, "migrations");
50
+ fs_extra_1.default.ensureDirSync(migrationsDir);
51
+ const usersVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
52
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
53
+ { template: "add/auth/migrations/create_users.up.sql.hbs", output: path_1.default.join("migrations", `${usersVersion}_create_users.up.sql`) },
54
+ { template: "add/auth/migrations/create_users.down.sql.hbs", output: path_1.default.join("migrations", `${usersVersion}_create_users.down.sql`) },
55
+ ], {});
56
+ // identities references users(id) — must apply strictly after it. A
57
+ // second newMigrationVersion() call, scanning the dir again now that the
58
+ // users pair is already written, guarantees a later (or same-second,
59
+ // bumped) timestamp rather than assuming +1 by hand.
60
+ const identitiesVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
61
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
62
+ { template: "add/auth/migrations/create_identities.up.sql.hbs", output: path_1.default.join("migrations", `${identitiesVersion}_create_identities.up.sql`) },
63
+ { template: "add/auth/migrations/create_identities.down.sql.hbs", output: path_1.default.join("migrations", `${identitiesVersion}_create_identities.down.sql`) },
64
+ ], {});
65
+ (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);
67
+ patchEnvExample(path_1.default.join(projectDir, ".env.example"));
68
+ patchMakefile(path_1.default.join(projectDir, "Makefile"));
69
+ let docsMessage = "";
70
+ const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
71
+ if (config.features.openapiDocs && fs_extra_1.default.existsSync(openapiPath)) {
72
+ const docsEntries = [
73
+ { template: "add/auth/docs/schemas.yaml.hbs", output: path_1.default.join("docs", "auth", "schemas.yaml") },
74
+ ...AUTH_OPENAPI_PATHS.map(({ file }) => ({
75
+ template: `add/auth/docs/${path_1.default.basename(file)}.hbs`,
76
+ output: path_1.default.join("docs", "auth", path_1.default.basename(file)),
77
+ })),
78
+ ];
79
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, docsEntries, {});
80
+ (0, openapi_patcher_1.patchOpenapiIndexRaw)(openapiPath, config.apiPrefix, AUTH_OPENAPI_PATHS);
81
+ docsMessage = "\ndocs: docs/auth/*.yaml, wired into docs/openapi.yaml";
82
+ }
83
+ (0, template_renderer_1.gofmtTree)(projectDir);
84
+ (0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, auth: true } });
85
+ 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" +
88
+ docsMessage);
89
+ 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
+ "seed an admin: SEED_ADMIN_EMAIL=... SEED_ADMIN_PASSWORD=... make seed"));
91
+ }
92
+ function patchMakefile(makefilePath) {
93
+ if (!fs_extra_1.default.existsSync(makefilePath))
94
+ return;
95
+ let content = fs_extra_1.default.readFileSync(makefilePath, "utf8");
96
+ if (content.includes("\nseed:\n"))
97
+ return; // already added
98
+ content = content.replace(/^\.PHONY: /m, ".PHONY: seed ");
99
+ const target = "\n# bootstrap an admin user (idempotent) — SEED_ADMIN_EMAIL/PASSWORD from the\n" +
100
+ "# environment, not .env, so a real secret never sits in a checked-in file.\n" +
101
+ "# --fixtures adds throwaway dev sample users, never use it outside dev.\n" +
102
+ "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:`);
105
+ fs_extra_1.default.writeFileSync(makefilePath, content);
106
+ }
107
+ function patchEnvExample(envExamplePath) {
108
+ if (!fs_extra_1.default.existsSync(envExamplePath))
109
+ return;
110
+ let content = fs_extra_1.default.readFileSync(envExamplePath, "utf8");
111
+ if (content.includes("JWT_SECRET"))
112
+ return; // already added
113
+ content =
114
+ content.replace(/\n?$/, "\n") +
115
+ "\n# HS256 signing secret for access tokens — change this before deploying with APP_ENV=production\n" +
116
+ "JWT_SECRET=dev-secret-change-me\n" +
117
+ "JWT_ACCESS_TTL_MIN=15\n" +
118
+ "JWT_REFRESH_TTL_MIN=43200\n" +
119
+ "COOKIE_SECURE=false\n" +
120
+ "\nPASSWORD_RESET_TTL_MIN=30\n" +
121
+ "PASSWORD_RESET_URL=http://localhost:3000/reset-password\n" +
122
+ "\nEMAIL_VERIFY_TTL_MIN=1440\n" +
123
+ "EMAIL_VERIFY_URL=http://localhost:3000/verify-email\n" +
124
+ "\n# leave the Google vars unset to disable Google login (register/login/refresh still work)\n" +
125
+ "GOOGLE_CLIENT_ID=\n" +
126
+ "GOOGLE_CLIENT_SECRET=\n" +
127
+ "GOOGLE_REDIRECT_URL=\n";
128
+ fs_extra_1.default.writeFileSync(envExamplePath, content);
129
+ }
@@ -12,6 +12,7 @@ const create_manifest_1 = require("../templates/create-manifest");
12
12
  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
+ const version_1 = require("../utils/version");
15
16
  async function createProject(rawName, opts) {
16
17
  const trimmed = (rawName ?? (await (0, create_wizard_1.promptProjectName)())).trim();
17
18
  if (!trimmed)
@@ -26,7 +27,7 @@ async function createProject(rawName, opts) {
26
27
  let features;
27
28
  let apiPrefix;
28
29
  if (opts.defaults) {
29
- features = { docker: opts.docker ?? true, openapiDocs: opts.openapiDocs ?? true };
30
+ features = { docker: opts.docker ?? true, openapiDocs: opts.openapiDocs ?? true, observability: opts.observability ?? false };
30
31
  apiPrefix = (0, naming_1.normalizeApiPrefix)(opts.apiPrefix ?? "v1");
31
32
  const check = (0, naming_1.validateApiPrefix)(apiPrefix);
32
33
  if (check !== true)
@@ -45,7 +46,7 @@ async function createProject(rawName, opts) {
45
46
  await fs_extra_1.default.ensureDir(projectDir);
46
47
  await (0, template_renderer_1.applyTemplateEntries)(projectDir, create_manifest_1.CREATE_MANIFEST, context);
47
48
  (0, template_renderer_1.gofmtTree)(projectDir);
48
- (0, config_1.writeConfig)(projectDir, { projectName, goModule, apiPrefix, features });
49
+ (0, config_1.writeConfig)(projectDir, { projectName, goModule, apiPrefix, features, scaffoldVersion: (0, version_1.cliVersion)() });
49
50
  console.log(picocolors_1.default.green(`\ncreated ${projectName}/`));
50
51
  console.log(`\ncd ${projectName}`);
51
52
  if (features.docker)
@@ -9,25 +9,50 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
9
9
  const picocolors_1 = __importDefault(require("picocolors"));
10
10
  const config_1 = require("../utils/config");
11
11
  const naming_1 = require("../utils/naming");
12
+ const module_location_1 = require("../utils/module-location");
12
13
  const template_renderer_1 = require("../utils/template-renderer");
13
14
  const module_manifest_1 = require("../templates/module-manifest");
14
15
  const main_patcher_1 = require("../utils/main-patcher");
15
16
  const openapi_patcher_1 = require("../utils/openapi-patcher");
16
17
  const migrations_1 = require("../utils/migrations");
18
+ const gocheck_1 = require("../utils/gocheck");
17
19
  const generate_wizard_1 = require("../prompts/generate-wizard");
20
+ const PERMISSION_CODE_PATTERN = /^[a-z][a-z0-9:_-]*$/;
18
21
  async function generateModule(rawName, opts, projectDir = process.cwd()) {
19
22
  const config = (0, config_1.readConfig)(projectDir);
20
- const naming = (0, naming_1.resolveModuleNaming)(rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
23
+ // --permission implies --auth (authz.Require must run after RequireAuth)
24
+ // require both explicitly rather than silently turning one on, so the
25
+ // generated route's protection matches what the command line actually said.
26
+ if (opts.permission && !opts.auth) {
27
+ throw new Error("--permission requires --auth (permission checks run after auth) — pass both, e.g. --auth --permission products:manage");
28
+ }
29
+ if (opts.auth && !config.features.auth) {
30
+ throw new Error("--auth requires `go-scaffold add auth` first — there's no RequireAuth middleware yet");
31
+ }
32
+ if (opts.permission) {
33
+ if (!config.features.rbac) {
34
+ throw new Error("--permission requires `go-scaffold add rbac` first — there's no permissions table or authz middleware yet");
35
+ }
36
+ if (!PERMISSION_CODE_PATTERN.test(opts.permission)) {
37
+ throw new Error(`invalid permission code "${opts.permission}" — must start with a lowercase letter and contain only lowercase letters, digits, ':', '_', or '-'`);
38
+ }
39
+ }
40
+ const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
21
41
  const modulePath = naming.pkg;
22
42
  const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
23
43
  if (fs_extra_1.default.existsSync(moduleDir) && fs_extra_1.default.readdirSync(moduleDir).length > 0) {
24
44
  throw new Error(`${moduleDir} already exists — pick a different name or delete it first`);
25
45
  }
46
+ // snapshot before writing anything, so assertNoDrift below can tell "we broke
47
+ // it" from "it was already broken"
48
+ const checkBefore = (0, gocheck_1.typeChecks)(projectDir);
26
49
  const context = {
27
50
  ...naming,
28
51
  goModule: config.goModule,
29
52
  dbName: (0, naming_1.toDbName)(config.projectName),
30
53
  modulePath,
54
+ auth: opts.auth,
55
+ permission: opts.permission,
31
56
  };
32
57
  const moduleFiles = opts.full ? module_manifest_1.MODULE_FILES : module_manifest_1.MODULE_FILES_MINIMAL;
33
58
  const moduleEntries = moduleFiles.map((f) => ({
@@ -42,7 +67,7 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
42
67
  fs_extra_1.default.readdirSync(migrationsDir).some((f) => f.endsWith(`_create_${naming.plural}.up.sql`));
43
68
  let seq = "";
44
69
  if (!migrationExists) {
45
- seq = (0, migrations_1.nextMigrationSeq)(migrationsDir);
70
+ seq = (0, migrations_1.newMigrationVersion)(migrationsDir);
46
71
  const migrationEntries = [
47
72
  {
48
73
  template: "generate/module/migration.up.sql.hbs",
@@ -55,12 +80,33 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
55
80
  ];
56
81
  await (0, template_renderer_1.applyTemplateEntries)(projectDir, migrationEntries, context);
57
82
  }
83
+ // --permission needs the code to actually exist before any role can be
84
+ // granted it — SetPermissions validates against the real catalog and
85
+ // rejects unknown codes, so an ungenerated permission would leave the
86
+ // route permanently unreachable by anyone, admin included.
87
+ let permissionSeq = "";
88
+ if (opts.permission) {
89
+ permissionSeq = (0, migrations_1.newMigrationVersion)(migrationsDir);
90
+ const permissionEntries = [
91
+ {
92
+ template: "generate/module/permission.up.sql.hbs",
93
+ output: path_1.default.join("migrations", `${permissionSeq}_add_${naming.plural}_permission.up.sql`),
94
+ },
95
+ {
96
+ template: "generate/module/permission.down.sql.hbs",
97
+ output: path_1.default.join("migrations", `${permissionSeq}_add_${naming.plural}_permission.down.sql`),
98
+ },
99
+ ];
100
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, permissionEntries, context);
101
+ }
58
102
  const mainGoPath = path_1.default.join(projectDir, "cmd", "api", "main.go");
59
103
  (0, main_patcher_1.patchMainGo)(mainGoPath, {
60
104
  goModule: config.goModule,
61
105
  modulePath,
62
106
  pkg: naming.pkg,
63
107
  pascalName: naming.pascalName,
108
+ auth: opts.auth,
109
+ permission: opts.permission,
64
110
  });
65
111
  let docsMessage = "";
66
112
  const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
@@ -75,6 +121,7 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
75
121
  docsMessage = `\ndocs: docs/${naming.plural}/{collection,item,schemas}.yaml, wired into docs/openapi.yaml`;
76
122
  }
77
123
  (0, template_renderer_1.gofmtTree)(projectDir);
124
+ (0, gocheck_1.assertNoDrift)(projectDir, checkBefore, config);
78
125
  const routePath = config.apiPrefix ? `/${config.apiPrefix}/${naming.plural}` : `/${naming.plural}`;
79
126
  console.log(picocolors_1.default.green(`\ngenerated internal/app/${modulePath}/`));
80
127
  if (opts.full) {
@@ -84,12 +131,24 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
84
131
  console.log(`registered empty route group ${routePath} in cmd/api/main.go — ` +
85
132
  `add endpoints with \`go-scaffold generate method ${naming.pkg} <name> --type ...\``);
86
133
  }
134
+ if (opts.permission) {
135
+ console.log(`protected: requires a valid access token AND the "${opts.permission}" permission`);
136
+ }
137
+ else if (opts.auth) {
138
+ console.log("protected: requires a valid access token (no specific permission)");
139
+ }
140
+ else if (config.features.auth) {
141
+ console.log(picocolors_1.default.yellow(`note: this project has auth installed, but ${routePath} is PUBLIC — re-run with --auth (and --permission <code> if you also have rbac) to require login`));
142
+ }
87
143
  if (seq) {
88
144
  console.log(`migration: migrations/${seq}_create_${naming.plural}.{up,down}.sql`);
89
145
  }
90
146
  else {
91
147
  console.log(`migration: reused existing migrations/*_create_${naming.plural}.{up,down}.sql`);
92
148
  }
149
+ if (permissionSeq) {
150
+ console.log(`migration: migrations/${permissionSeq}_add_${naming.plural}_permission.{up,down}.sql (seeds the "${opts.permission}" permission — grant it to a role via PATCH /roles/:code/permissions)`);
151
+ }
93
152
  if (docsMessage)
94
153
  console.log(docsMessage);
95
154
  console.log(picocolors_1.default.dim(`\nnext: add real fields to model.go/dto.go, run \`go build ./...\`, then apply the migration ` +
@@ -9,28 +9,61 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
9
9
  const picocolors_1 = __importDefault(require("picocolors"));
10
10
  const config_1 = require("../utils/config");
11
11
  const naming_1 = require("../utils/naming");
12
+ const module_location_1 = require("../utils/module-location");
12
13
  const method_patcher_1 = require("../utils/method-patcher");
14
+ const gocheck_1 = require("../utils/gocheck");
13
15
  const template_renderer_1 = require("../utils/template-renderer");
16
+ const openapi_patcher_1 = require("../utils/openapi-patcher");
14
17
  const generate_wizard_1 = require("../prompts/generate-wizard");
15
- // the actual URL the new route answers on — printed so the user can add the
16
- // matching openapi.yaml entry by hand (methods are deliberately not wired into
17
- // the spec; see the note in docs/openapi.yaml). Mirrors the paths registered
18
- // in method-patcher.ts.
19
- function routeHint(naming, method, type, apiPrefix, getMode, field) {
20
- const base = apiPrefix ? `/${apiPrefix}/${naming.plural}` : `/${naming.plural}`;
18
+ // URL path registered in method-patcher.ts, excluding the project-wide API
19
+ // prefix so it can be passed directly to patchOpenapiIndexRaw.
20
+ function methodRoutePath(naming, method, type, getMode, field) {
21
+ const base = `/${naming.plural}`;
21
22
  if (type === "get" && getMode === "all")
22
- return `GET ${base}/${method.pathSegment}`;
23
+ return `${base}/${method.pathSegment}`;
23
24
  if (type === "get")
24
- return `GET ${base}/${(0, naming_1.toDbName)(field ?? "")}/{${(0, naming_1.toCamelCase)(field ?? "")}}`;
25
+ return `${base}/${(0, naming_1.toDbName)(field ?? "")}/{${(0, naming_1.toCamelCase)(field ?? "")}}`;
25
26
  if (type === "post")
26
- return `POST ${base}/${method.pathSegment}`;
27
- if (type === "delete")
28
- return `DELETE ${base}/{id}/${method.pathSegment}`;
29
- return `${type.toUpperCase()} ${base}/{id}/${method.pathSegment}`;
27
+ return `${base}/${method.pathSegment}`;
28
+ return `${base}/{id}/${method.pathSegment}`;
29
+ }
30
+ function routeHint(naming, method, type, apiPrefix, getMode, field) {
31
+ const path = methodRoutePath(naming, method, type, getMode, field);
32
+ const prefixed = apiPrefix ? `/${apiPrefix}${path}` : path;
33
+ return `${type.toUpperCase()} ${prefixed}`;
34
+ }
35
+ function methodOpenapiDocument(naming, method, type, getMode, field) {
36
+ const pathParameters = [];
37
+ if (type === "get" && getMode === "one") {
38
+ const parameter = (0, naming_1.toCamelCase)(field ?? "");
39
+ pathParameters.push("parameters:", ` - name: ${parameter}`, " in: path", " required: true", " schema:", " type: string");
40
+ }
41
+ else if (type === "put" || type === "patch" || type === "delete") {
42
+ pathParameters.push("parameters:", " - name: id", " in: path", " required: true", " schema:", " type: string", " format: uuid");
43
+ }
44
+ const operation = [
45
+ `${type}:`,
46
+ ` summary: TODO document ${method.name}`,
47
+ ` operationId: ${method.handlerName}${naming.pascalName}`,
48
+ ` tags: [${naming.plural}]`,
49
+ ];
50
+ if (type === "post") {
51
+ operation.push(" requestBody:", " required: true", " content:", " application/json:", " schema:", " type: object", " description: TODO define request fields");
52
+ }
53
+ const status = type === "delete" ? "204" : type === "post" ? "201" : "200";
54
+ operation.push(" responses:", ` \"${status}\":`, ` description: ${type === "delete" ? "completed or already absent" : "TODO define response"}`);
55
+ if (type !== "delete") {
56
+ operation.push(" content:", " application/json:", " schema:", " type: object", " description: TODO replace with the method response schema");
57
+ }
58
+ operation.push(" \"400\": { $ref: '../../common/responses.yaml#/ValidationError' }");
59
+ if (type === "get" || type === "put" || type === "patch") {
60
+ operation.push(" \"404\": { $ref: '../../common/responses.yaml#/NotFoundError' }");
61
+ }
62
+ return [...pathParameters, ...operation, ""].join("\n");
30
63
  }
31
64
  async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = process.cwd()) {
32
65
  const config = (0, config_1.readConfig)(projectDir);
33
- const naming = (0, naming_1.resolveModuleNaming)(moduleNameArg ?? (await (0, generate_wizard_1.promptModuleName)()));
66
+ const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, moduleNameArg ?? (await (0, generate_wizard_1.promptModuleName)()));
34
67
  const modulePath = naming.pkg;
35
68
  const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
36
69
  const paths = {
@@ -59,12 +92,30 @@ async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = p
59
92
  if (field)
60
93
  (0, naming_1.assertNotGoKeyword)((0, naming_1.toCamelCase)(field), "lookup field");
61
94
  const method = (0, naming_1.resolveMethodNaming)(methodNameArg ?? (await (0, generate_wizard_1.promptMethodName)()));
95
+ const docsRelativePath = config.features.openapiDocs
96
+ ? `${naming.plural}/methods/${method.pathSegment}.yaml`
97
+ : undefined;
98
+ if (docsRelativePath && fs_extra_1.default.existsSync(path_1.default.join(projectDir, "docs", docsRelativePath))) {
99
+ throw new Error(`OpenAPI method document already exists: docs/${docsRelativePath}`);
100
+ }
101
+ const checkBefore = (0, gocheck_1.typeChecks)(projectDir);
62
102
  (0, method_patcher_1.patchMethod)(paths, naming, method, { type, getMode, field }, config.goModule);
63
103
  (0, template_renderer_1.gofmtTree)(projectDir);
104
+ (0, gocheck_1.assertNoDrift)(projectDir, checkBefore, config);
105
+ if (docsRelativePath) {
106
+ const docsPath = path_1.default.join(projectDir, "docs", docsRelativePath);
107
+ fs_extra_1.default.outputFileSync(docsPath, methodOpenapiDocument(naming, method, type, getMode, field));
108
+ (0, openapi_patcher_1.patchOpenapiIndexRaw)(path_1.default.join(projectDir, "docs", "openapi.yaml"), config.apiPrefix, [
109
+ {
110
+ urlPath: methodRoutePath(naming, method, type, getMode, field),
111
+ file: `./${docsRelativePath}`,
112
+ },
113
+ ]);
114
+ }
64
115
  console.log(picocolors_1.default.green(`\nadded "${method.name}" to internal/app/${modulePath}/`));
65
116
  console.log(`route: ${routeHint(naming, method, type, config.apiPrefix, getMode, field)}`);
66
- if (config.features.openapiDocs) {
67
- console.log(picocolors_1.default.yellow(`docs: add this route to docs/openapi.yaml by hand — \`generate method\` doesn't touch the spec`));
117
+ if (docsRelativePath) {
118
+ console.log(picocolors_1.default.green(`docs: docs/${docsRelativePath} (wired into docs/openapi.yaml)`));
68
119
  }
69
120
  console.log(picocolors_1.default.dim(`\nnext: fill in the TODO in service.go, then \`go build ./...\` / \`go test ./...\``));
70
121
  }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.generateMigration = generateMigration;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const config_1 = require("../utils/config");
11
+ const migrations_1 = require("../utils/migrations");
12
+ const naming_1 = require("../utils/naming");
13
+ const generate_wizard_1 = require("../prompts/generate-wizard");
14
+ // generate migration doesn't know your schema, so unlike `generate module`
15
+ // it can't render real SQL — it only reserves the timestamped filename pair
16
+ // so two people adding a migration on the same day never collide, and stubs
17
+ // each with a TODO instead of leaving the CLI to guess at columns.
18
+ async function generateMigration(rawName, projectDir = process.cwd()) {
19
+ (0, config_1.readConfig)(projectDir); // throws with a clear message if this isn't a go-scaffold project
20
+ const name = (0, naming_1.toDbName)(rawName ?? (await (0, generate_wizard_1.promptMigrationName)()));
21
+ if (!name) {
22
+ throw new Error(`invalid migration name: "${rawName}" (must contain letters/numbers)`);
23
+ }
24
+ const migrationsDir = path_1.default.join(projectDir, "migrations");
25
+ fs_extra_1.default.ensureDirSync(migrationsDir);
26
+ const version = (0, migrations_1.newMigrationVersion)(migrationsDir);
27
+ const upPath = path_1.default.join(migrationsDir, `${version}_${name}.up.sql`);
28
+ const downPath = path_1.default.join(migrationsDir, `${version}_${name}.down.sql`);
29
+ fs_extra_1.default.writeFileSync(upPath, `-- TODO: write the up migration for ${name}\n`);
30
+ fs_extra_1.default.writeFileSync(downPath, `-- TODO: write the down migration for ${name} (reverses the up migration)\n`);
31
+ console.log(picocolors_1.default.green(`\ngenerated migrations/${version}_${name}.{up,down}.sql`));
32
+ console.log(picocolors_1.default.dim(`\nnext: write the SQL, then \`make migrate-up\` (dev) or apply it as a deploy step ` +
33
+ `(AUTO_MIGRATE=true also picks up model changes automatically in dev — this file matters most for prod)`));
34
+ }