@nakedev/go-scaffold 0.1.2 → 0.1.3

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 (108) hide show
  1. package/README.md +75 -0
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +59 -1
  5. package/dist/commands/method.js +3 -0
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +19 -2
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +66 -3
  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/rbac-manifest.js +17 -0
  16. package/dist/templates/worker-manifest.js +12 -0
  17. package/dist/utils/auth-patcher.js +96 -0
  18. package/dist/utils/gocheck.js +65 -0
  19. package/dist/utils/main-patcher.js +8 -1
  20. package/dist/utils/migrations.js +30 -8
  21. package/dist/utils/openapi-patcher.js +16 -0
  22. package/dist/utils/platform-patcher.js +59 -0
  23. package/dist/utils/rbac-patcher.js +277 -0
  24. package/dist/utils/version.js +24 -0
  25. package/package.json +2 -2
  26. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  27. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  28. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  29. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  30. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  31. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  32. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  33. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  35. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  36. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  37. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  38. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  39. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  40. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  41. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  42. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  43. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  44. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  45. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  46. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  50. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  52. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  54. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  55. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  56. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  57. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  58. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  59. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  60. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  61. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  62. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  63. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  64. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  65. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  66. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  67. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  68. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  69. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  70. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  71. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  72. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  73. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  74. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  75. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  76. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  77. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  78. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  79. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  80. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  81. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  82. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  83. package/templates/create/base/.env.example.hbs +20 -0
  84. package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
  85. package/templates/create/base/.gitignore.hbs +2 -0
  86. package/templates/create/base/Makefile.hbs +30 -5
  87. package/templates/create/base/README.md.hbs +36 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  89. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  90. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  91. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  92. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  93. package/templates/create/base/migrations/embed.go.hbs +15 -0
  94. package/templates/create/features/docs/architecture.md.hbs +22 -0
  95. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  96. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  97. package/templates/create/features/docs/openapi.yaml.hbs +13 -0
  98. package/templates/create/features/docs/techstack.md.hbs +3 -0
  99. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  100. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  101. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  102. package/templates/generate/module/handler.go.hbs +20 -3
  103. package/templates/generate/module/handler_test.go.hbs +49 -6
  104. package/templates/generate/module/minimal/handler.go.hbs +21 -3
  105. package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
  106. package/templates/generate/module/permission.down.sql.hbs +5 -0
  107. package/templates/generate/module/permission.up.sql.hbs +4 -0
  108. package/dist/utils/module-paths.js +0 -33
package/README.md CHANGED
@@ -157,6 +157,81 @@ returns a clean `500` rather than inventing behavior — see
157
157
  `generate method` prints the route it added but does **not** touch
158
158
  `docs/openapi.yaml` — endpoint-specific spec entries stay hand-written.
159
159
 
160
+ **Drift check** — `generate` type-checks the project (`go vet ./...`) before and
161
+ after it writes. If the project was fine beforehand and the generated code
162
+ doesn't compile, it stops with the compiler output instead of leaving you to
163
+ find it later:
164
+
165
+ ```text
166
+ the generated code doesn't compile, but this project was fine a moment ago.
167
+
168
+ The most likely cause is drift: this project's internal/shared layer has been edited
169
+ since it was scaffolded, so the templates this CLI emits no longer match it.
170
+
171
+ scaffolded with: go-scaffold 0.1.2
172
+ this CLI: go-scaffold 0.3.0
173
+ ```
174
+
175
+ That happens because `generate`'s templates are written against the `shared/`
176
+ layer `create` emits — editing that layer is normal work, but it moves the
177
+ project away from what this CLI's templates expect. `create` records its own
178
+ version in `go-scaffold.config.json` so the message can name both sides. A
179
+ project that was *already* broken (mid-refactor, or `go mod tidy` not run yet)
180
+ is left alone — only a passed-before/broken-after transition is reported. No Go
181
+ on `PATH` means the check is skipped.
182
+
183
+ ### `generate migration <name>` (alias `mig`) — reserve a SQL migration pair
184
+
185
+ ```bash
186
+ go-scaffold generate migration add_status_to_orders
187
+ ```
188
+
189
+ Creates timestamped `migrations/<version>_<name>.up.sql` and `.down.sql` TODO
190
+ stubs. The CLI reserves the names; you own the SQL and should apply it with
191
+ `make migrate-up` (or `migrate -path migrations -database "$DB_DSN" up`).
192
+
193
+ ### `add worker` — add Redis-backed background work
194
+
195
+ ```bash
196
+ go-scaffold add worker
197
+ ```
198
+
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.
202
+
203
+ ### `add auth` — add email/password authentication
204
+
205
+ ```bash
206
+ go-scaffold add auth
207
+ ```
208
+
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`.
213
+
214
+ ### `add rbac` — add roles and permissions
215
+
216
+ ```bash
217
+ go-scaffold add rbac
218
+ go-scaffold generate module secrets --auth --permission secret:manage
219
+ ```
220
+
221
+ Requires `add auth`. Adds role/permission administration, cached authorization
222
+ middleware, and role assignment. Its migration seeds the default roles and
223
+ permissions, so apply it with `migrate up`: AutoMigrate creates tables but does
224
+ not run SQL seed statements.
225
+
226
+ ### Observability at project creation
227
+
228
+ ```bash
229
+ go-scaffold create my-api --defaults --observability
230
+ ```
231
+
232
+ Opt-in observability adds Prometheus metrics at `/metrics` and OpenTelemetry
233
+ tracing. Tracing is disabled until `OTEL_EXPORTER_OTLP_ENDPOINT` is configured.
234
+
160
235
  ### `remove module <name>` (alias `rm m`) — drop a domain
161
236
 
162
237
  ```bash
@@ -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)
@@ -14,20 +14,44 @@ const module_manifest_1 = require("../templates/module-manifest");
14
14
  const main_patcher_1 = require("../utils/main-patcher");
15
15
  const openapi_patcher_1 = require("../utils/openapi-patcher");
16
16
  const migrations_1 = require("../utils/migrations");
17
+ const gocheck_1 = require("../utils/gocheck");
17
18
  const generate_wizard_1 = require("../prompts/generate-wizard");
19
+ const PERMISSION_CODE_PATTERN = /^[a-z][a-z0-9:_-]*$/;
18
20
  async function generateModule(rawName, opts, projectDir = process.cwd()) {
19
21
  const config = (0, config_1.readConfig)(projectDir);
22
+ // --permission implies --auth (authz.Require must run after RequireAuth) —
23
+ // require both explicitly rather than silently turning one on, so the
24
+ // generated route's protection matches what the command line actually said.
25
+ if (opts.permission && !opts.auth) {
26
+ throw new Error("--permission requires --auth (permission checks run after auth) — pass both, e.g. --auth --permission products:manage");
27
+ }
28
+ if (opts.auth && !config.features.auth) {
29
+ throw new Error("--auth requires `go-scaffold add auth` first — there's no RequireAuth middleware yet");
30
+ }
31
+ if (opts.permission) {
32
+ if (!config.features.rbac) {
33
+ throw new Error("--permission requires `go-scaffold add rbac` first — there's no permissions table or authz middleware yet");
34
+ }
35
+ if (!PERMISSION_CODE_PATTERN.test(opts.permission)) {
36
+ throw new Error(`invalid permission code "${opts.permission}" — must start with a lowercase letter and contain only lowercase letters, digits, ':', '_', or '-'`);
37
+ }
38
+ }
20
39
  const naming = (0, naming_1.resolveModuleNaming)(rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
21
40
  const modulePath = naming.pkg;
22
41
  const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
23
42
  if (fs_extra_1.default.existsSync(moduleDir) && fs_extra_1.default.readdirSync(moduleDir).length > 0) {
24
43
  throw new Error(`${moduleDir} already exists — pick a different name or delete it first`);
25
44
  }
45
+ // snapshot before writing anything, so assertNoDrift below can tell "we broke
46
+ // it" from "it was already broken"
47
+ const checkBefore = (0, gocheck_1.typeChecks)(projectDir);
26
48
  const context = {
27
49
  ...naming,
28
50
  goModule: config.goModule,
29
51
  dbName: (0, naming_1.toDbName)(config.projectName),
30
52
  modulePath,
53
+ auth: opts.auth,
54
+ permission: opts.permission,
31
55
  };
32
56
  const moduleFiles = opts.full ? module_manifest_1.MODULE_FILES : module_manifest_1.MODULE_FILES_MINIMAL;
33
57
  const moduleEntries = moduleFiles.map((f) => ({
@@ -42,7 +66,7 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
42
66
  fs_extra_1.default.readdirSync(migrationsDir).some((f) => f.endsWith(`_create_${naming.plural}.up.sql`));
43
67
  let seq = "";
44
68
  if (!migrationExists) {
45
- seq = (0, migrations_1.nextMigrationSeq)(migrationsDir);
69
+ seq = (0, migrations_1.newMigrationVersion)(migrationsDir);
46
70
  const migrationEntries = [
47
71
  {
48
72
  template: "generate/module/migration.up.sql.hbs",
@@ -55,12 +79,33 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
55
79
  ];
56
80
  await (0, template_renderer_1.applyTemplateEntries)(projectDir, migrationEntries, context);
57
81
  }
82
+ // --permission needs the code to actually exist before any role can be
83
+ // granted it — SetPermissions validates against the real catalog and
84
+ // rejects unknown codes, so an ungenerated permission would leave the
85
+ // route permanently unreachable by anyone, admin included.
86
+ let permissionSeq = "";
87
+ if (opts.permission) {
88
+ permissionSeq = (0, migrations_1.newMigrationVersion)(migrationsDir);
89
+ const permissionEntries = [
90
+ {
91
+ template: "generate/module/permission.up.sql.hbs",
92
+ output: path_1.default.join("migrations", `${permissionSeq}_add_${naming.plural}_permission.up.sql`),
93
+ },
94
+ {
95
+ template: "generate/module/permission.down.sql.hbs",
96
+ output: path_1.default.join("migrations", `${permissionSeq}_add_${naming.plural}_permission.down.sql`),
97
+ },
98
+ ];
99
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, permissionEntries, context);
100
+ }
58
101
  const mainGoPath = path_1.default.join(projectDir, "cmd", "api", "main.go");
59
102
  (0, main_patcher_1.patchMainGo)(mainGoPath, {
60
103
  goModule: config.goModule,
61
104
  modulePath,
62
105
  pkg: naming.pkg,
63
106
  pascalName: naming.pascalName,
107
+ auth: opts.auth,
108
+ permission: opts.permission,
64
109
  });
65
110
  let docsMessage = "";
66
111
  const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
@@ -75,6 +120,7 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
75
120
  docsMessage = `\ndocs: docs/${naming.plural}/{collection,item,schemas}.yaml, wired into docs/openapi.yaml`;
76
121
  }
77
122
  (0, template_renderer_1.gofmtTree)(projectDir);
123
+ (0, gocheck_1.assertNoDrift)(projectDir, checkBefore, config);
78
124
  const routePath = config.apiPrefix ? `/${config.apiPrefix}/${naming.plural}` : `/${naming.plural}`;
79
125
  console.log(picocolors_1.default.green(`\ngenerated internal/app/${modulePath}/`));
80
126
  if (opts.full) {
@@ -84,12 +130,24 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
84
130
  console.log(`registered empty route group ${routePath} in cmd/api/main.go — ` +
85
131
  `add endpoints with \`go-scaffold generate method ${naming.pkg} <name> --type ...\``);
86
132
  }
133
+ if (opts.permission) {
134
+ console.log(`protected: requires a valid access token AND the "${opts.permission}" permission`);
135
+ }
136
+ else if (opts.auth) {
137
+ console.log("protected: requires a valid access token (no specific permission)");
138
+ }
139
+ else if (config.features.auth) {
140
+ 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`));
141
+ }
87
142
  if (seq) {
88
143
  console.log(`migration: migrations/${seq}_create_${naming.plural}.{up,down}.sql`);
89
144
  }
90
145
  else {
91
146
  console.log(`migration: reused existing migrations/*_create_${naming.plural}.{up,down}.sql`);
92
147
  }
148
+ if (permissionSeq) {
149
+ 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)`);
150
+ }
93
151
  if (docsMessage)
94
152
  console.log(docsMessage);
95
153
  console.log(picocolors_1.default.dim(`\nnext: add real fields to model.go/dto.go, run \`go build ./...\`, then apply the migration ` +
@@ -10,6 +10,7 @@ const picocolors_1 = __importDefault(require("picocolors"));
10
10
  const config_1 = require("../utils/config");
11
11
  const naming_1 = require("../utils/naming");
12
12
  const method_patcher_1 = require("../utils/method-patcher");
13
+ const gocheck_1 = require("../utils/gocheck");
13
14
  const template_renderer_1 = require("../utils/template-renderer");
14
15
  const generate_wizard_1 = require("../prompts/generate-wizard");
15
16
  // the actual URL the new route answers on — printed so the user can add the
@@ -59,8 +60,10 @@ async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = p
59
60
  if (field)
60
61
  (0, naming_1.assertNotGoKeyword)((0, naming_1.toCamelCase)(field), "lookup field");
61
62
  const method = (0, naming_1.resolveMethodNaming)(methodNameArg ?? (await (0, generate_wizard_1.promptMethodName)()));
63
+ const checkBefore = (0, gocheck_1.typeChecks)(projectDir);
62
64
  (0, method_patcher_1.patchMethod)(paths, naming, method, { type, getMode, field }, config.goModule);
63
65
  (0, template_renderer_1.gofmtTree)(projectDir);
66
+ (0, gocheck_1.assertNoDrift)(projectDir, checkBefore, config);
64
67
  console.log(picocolors_1.default.green(`\nadded "${method.name}" to internal/app/${modulePath}/`));
65
68
  console.log(`route: ${routeHint(naming, method, type, config.apiPrefix, getMode, field)}`);
66
69
  if (config.features.openapiDocs) {
@@ -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
+ }
@@ -0,0 +1,103 @@
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.addRbac = addRbac;
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 rbac_manifest_1 = require("../templates/rbac-manifest");
13
+ const migrations_1 = require("../utils/migrations");
14
+ const rbac_patcher_1 = require("../utils/rbac-patcher");
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 rbac` registers or adds onto the user handler.
18
+ const RBAC_OPENAPI_PATHS = [
19
+ { urlPath: "/roles", file: "./rbac/roles.yaml" },
20
+ { urlPath: "/roles/{code}/permissions", file: "./rbac/role-permissions.yaml" },
21
+ { urlPath: "/roles/{code}", file: "./rbac/role.yaml" },
22
+ { urlPath: "/permissions", file: "./rbac/permissions.yaml" },
23
+ { urlPath: "/users", file: "./rbac/users.yaml" },
24
+ { urlPath: "/users/{id}", file: "./rbac/user.yaml" },
25
+ { urlPath: "/users/{id}/set-role", file: "./rbac/user-set-role.yaml" },
26
+ ];
27
+ // addRbac layers role-based access control on top of `add auth`: a role
28
+ // domain (roles/permissions/role_permissions, admin-manageable), an Authz
29
+ // middleware with a cached role->permission lookup, a Role claim threaded
30
+ // through the JWT, and the admin endpoints that actually need it: listing/
31
+ // viewing other users (GET /users, GET /users/:id) and changing a user's
32
+ // role (PATCH /users/:id/set-role). Opt-in on top of an opt-in — most
33
+ // projects need "is this caller logged in" long before they need "can this
34
+ // caller do X".
35
+ async function addRbac(projectDir = process.cwd()) {
36
+ const config = (0, config_1.readConfig)(projectDir);
37
+ if (!config.features.auth) {
38
+ throw new Error("`go-scaffold add rbac` requires `go-scaffold add auth` first — there's no Role claim to check permissions against otherwise");
39
+ }
40
+ const roleDir = path_1.default.join(projectDir, "internal", "app", "role");
41
+ if (fs_extra_1.default.existsSync(roleDir)) {
42
+ throw new Error(`${roleDir} already exists — RBAC looks like it's already been added`);
43
+ }
44
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, rbac_manifest_1.RBAC_FILES, { goModule: config.goModule });
45
+ const migrationsDir = path_1.default.join(projectDir, "migrations");
46
+ fs_extra_1.default.ensureDirSync(migrationsDir);
47
+ const version = (0, migrations_1.newMigrationVersion)(migrationsDir);
48
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
49
+ { template: "add/rbac/migrations/add_roles.up.sql.hbs", output: path_1.default.join("migrations", `${version}_add_roles.up.sql`) },
50
+ { template: "add/rbac/migrations/add_roles.down.sql.hbs", output: path_1.default.join("migrations", `${version}_add_roles.down.sql`) },
51
+ ], {});
52
+ (0, rbac_patcher_1.patchConfigForRbac)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
53
+ (0, rbac_patcher_1.patchUserModelForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "model", "user.go"));
54
+ (0, rbac_patcher_1.patchMiddlewareAuthForRbac)(path_1.default.join(projectDir, "internal", "shared", "middleware", "auth.go"));
55
+ (0, rbac_patcher_1.patchUserJWTForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "jwt.go"));
56
+ (0, rbac_patcher_1.patchUserServiceForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "service.go"));
57
+ (0, rbac_patcher_1.patchUserServiceTestForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "service_test.go"));
58
+ (0, rbac_patcher_1.patchUserDTOForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "dto.go"));
59
+ (0, rbac_patcher_1.patchUserHandlerForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "handler.go"), config.goModule);
60
+ (0, rbac_patcher_1.patchUserErrorsForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "errors.go"));
61
+ (0, rbac_patcher_1.patchMainGoForRbac)(path_1.default.join(projectDir, "cmd", "api", "main.go"), config.goModule);
62
+ (0, rbac_patcher_1.patchCmdSeedForRbac)(path_1.default.join(projectDir, "cmd", "seed", "main.go"), config.goModule);
63
+ patchEnvExample(path_1.default.join(projectDir, ".env.example"));
64
+ let docsMessage = "";
65
+ const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
66
+ if (config.features.openapiDocs && fs_extra_1.default.existsSync(openapiPath)) {
67
+ const docsEntries = [
68
+ { template: "add/rbac/docs/schemas.yaml.hbs", output: path_1.default.join("docs", "rbac", "schemas.yaml") },
69
+ ...RBAC_OPENAPI_PATHS.map(({ file }) => ({
70
+ template: `add/rbac/docs/${path_1.default.basename(file)}.hbs`,
71
+ output: path_1.default.join("docs", "rbac", path_1.default.basename(file)),
72
+ })),
73
+ ];
74
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, docsEntries, {});
75
+ (0, openapi_patcher_1.patchOpenapiIndexRaw)(openapiPath, config.apiPrefix, RBAC_OPENAPI_PATHS);
76
+ (0, rbac_patcher_1.patchAuthDocsForRbac)(path_1.default.join(projectDir, "docs", "auth", "schemas.yaml"));
77
+ docsMessage = "\ndocs: docs/rbac/*.yaml, wired into docs/openapi.yaml";
78
+ }
79
+ (0, template_renderer_1.gofmtTree)(projectDir);
80
+ (0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, rbac: true } });
81
+ console.log(picocolors_1.default.green("\nadded internal/app/role/ and internal/shared/middleware/authz.go"));
82
+ console.log("registered GET /users, GET /users/:id, PATCH /users/:id/set-role, /roles, and /permissions in cmd/api/main.go" + docsMessage);
83
+ console.log(picocolors_1.default.yellow("\n⚠ AUTO_MIGRATE=true does NOT seed the role/permission data — it only creates the\n" +
84
+ " tables from the Go structs. The \"staff\"/\"admin\" roles and their permissions live\n" +
85
+ " in the migration's SQL (INSERT statements), which AutoMigrate never runs. Without\n" +
86
+ " applying it for real, `make seed` fails with \"unknown role code\" and nobody can be\n" +
87
+ " granted anything. Apply it before relying on RBAC, even in dev:\n" +
88
+ " migrate -path migrations -database \"$DB_DSN\" up (or: make migrate-up)"));
89
+ console.log(picocolors_1.default.dim("\nnext: go mod tidy, then SEED_ADMIN_EMAIL=... SEED_ADMIN_PASSWORD=... make seed to get an admin"));
90
+ }
91
+ function patchEnvExample(envExamplePath) {
92
+ if (!fs_extra_1.default.existsSync(envExamplePath))
93
+ return;
94
+ let content = fs_extra_1.default.readFileSync(envExamplePath, "utf8");
95
+ if (content.includes("AUTHZ_CACHE_TTL_MIN"))
96
+ return; // already added
97
+ content =
98
+ content.replace(/\n?$/, "\n") +
99
+ "\n# how long a role's permission set is cached before Authz.Require re-checks the DB —\n" +
100
+ "# a permission change takes effect for already-issued tokens within this window\n" +
101
+ "AUTHZ_CACHE_TTL_MIN=1\n";
102
+ fs_extra_1.default.writeFileSync(envExamplePath, content);
103
+ }
@@ -34,6 +34,17 @@ async function removeModule(rawName, opts, projectDir = process.cwd()) {
34
34
  if (!ok)
35
35
  throw new Error("removal cancelled");
36
36
  }
37
+ // detect --auth/--permission from the generated handler.go itself (not
38
+ // stored anywhere else) — unpatchMainGo needs the exact same flags used at
39
+ // generate-time to reconstruct the identical line it's removing.
40
+ const handlerGoPath = path_1.default.join(moduleDir, "handler.go");
41
+ let auth;
42
+ let permission;
43
+ if (fs_extra_1.default.existsSync(handlerGoPath)) {
44
+ const handlerContent = fs_extra_1.default.readFileSync(handlerGoPath, "utf8");
45
+ auth = /jwtSecret\s+string/.test(handlerContent);
46
+ permission = handlerContent.match(/h\.authz\.Require\("([^"]+)"\)/)?.[1];
47
+ }
37
48
  // 1. the domain package
38
49
  fs_extra_1.default.removeSync(moduleDir);
39
50
  // 2. main.go wiring
@@ -42,6 +53,8 @@ async function removeModule(rawName, opts, projectDir = process.cwd()) {
42
53
  modulePath,
43
54
  pkg: naming.pkg,
44
55
  pascalName: naming.pascalName,
56
+ auth,
57
+ permission,
45
58
  });
46
59
  // 3. openapi index + per-module docs
47
60
  const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
@@ -49,12 +62,16 @@ async function removeModule(rawName, opts, projectDir = process.cwd()) {
49
62
  (0, openapi_patcher_1.unpatchOpenapiIndex)(openapiPath, naming, config.apiPrefix);
50
63
  fs_extra_1.default.removeSync(path_1.default.join(projectDir, "docs", naming.plural));
51
64
  }
52
- // 4. migration files (up + down)
65
+ // 4. migration files (up + down) — including the --permission seed
66
+ // migration, if this module was generated with one.
53
67
  const migrationsDir = path_1.default.join(projectDir, "migrations");
54
68
  const removedMigrations = [];
55
69
  if (fs_extra_1.default.existsSync(migrationsDir)) {
56
70
  for (const f of fs_extra_1.default.readdirSync(migrationsDir)) {
57
- if (f.endsWith(`_create_${naming.plural}.up.sql`) || f.endsWith(`_create_${naming.plural}.down.sql`)) {
71
+ if (f.endsWith(`_create_${naming.plural}.up.sql`) ||
72
+ f.endsWith(`_create_${naming.plural}.down.sql`) ||
73
+ f.endsWith(`_add_${naming.plural}_permission.up.sql`) ||
74
+ f.endsWith(`_add_${naming.plural}_permission.down.sql`)) {
58
75
  fs_extra_1.default.removeSync(path_1.default.join(migrationsDir, f));
59
76
  removedMigrations.push(f);
60
77
  }
@@ -0,0 +1,75 @@
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.addWorker = addWorker;
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 worker_manifest_1 = require("../templates/worker-manifest");
13
+ const platform_patcher_1 = require("../utils/platform-patcher");
14
+ // addWorker scaffolds the async task processing subsystem: Redis
15
+ // (platform/cache), Asynq client/server (platform/queue), SMTP mail
16
+ // (platform/mail, with an email:send task type as the one thing the fresh
17
+ // worker actually handles), and cmd/worker itself. Opt-in — most projects
18
+ // don't need a queue on day one, and an empty worker with no task types
19
+ // registered is a stranger scaffold than just not having one.
20
+ async function addWorker(projectDir = process.cwd()) {
21
+ const config = (0, config_1.readConfig)(projectDir);
22
+ const queueDir = path_1.default.join(projectDir, "internal", "platform", "queue");
23
+ if (fs_extra_1.default.existsSync(queueDir)) {
24
+ throw new Error(`${queueDir} already exists — worker infrastructure looks like it's already been added`);
25
+ }
26
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, worker_manifest_1.WORKER_FILES, { goModule: config.goModule });
27
+ (0, platform_patcher_1.patchConfigForWorker)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
28
+ (0, platform_patcher_1.patchMainGoForWorker)(path_1.default.join(projectDir, "cmd", "api", "main.go"), config.goModule);
29
+ patchEnvExample(path_1.default.join(projectDir, ".env.example"));
30
+ patchMakefile(path_1.default.join(projectDir, "Makefile"));
31
+ (0, template_renderer_1.gofmtTree)(projectDir);
32
+ (0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, worker: true } });
33
+ console.log(picocolors_1.default.green("\nadded internal/platform/{cache,queue,mail}/ and cmd/worker/"));
34
+ console.log("wired Redis into cmd/api (readyz check) — cmd/api does not enqueue anything yet");
35
+ console.log(picocolors_1.default.dim("\nnext: make worker (separate terminal, or `make dev` runs both), then go build ./... to confirm"));
36
+ }
37
+ function patchEnvExample(envExamplePath) {
38
+ if (!fs_extra_1.default.existsSync(envExamplePath))
39
+ return;
40
+ let content = fs_extra_1.default.readFileSync(envExamplePath, "utf8");
41
+ if (content.includes("REDIS_URL"))
42
+ return; // already added
43
+ content =
44
+ content.replace(/\n?$/, "\n") +
45
+ "\nREDIS_URL=redis://localhost:6379/0\n" +
46
+ "\n# leave SMTP_HOST unset to log emails instead of sending them (dev default)\n" +
47
+ "SMTP_HOST=\n" +
48
+ "SMTP_PORT=587\n" +
49
+ "SMTP_USERNAME=\n" +
50
+ "SMTP_PASSWORD=\n" +
51
+ "SMTP_FROM=no-reply@example.local\n";
52
+ fs_extra_1.default.writeFileSync(envExamplePath, content);
53
+ }
54
+ function patchMakefile(makefilePath) {
55
+ if (!fs_extra_1.default.existsSync(makefilePath))
56
+ return;
57
+ let content = fs_extra_1.default.readFileSync(makefilePath, "utf8");
58
+ if (content.includes("\nworker:\n"))
59
+ return; // already added
60
+ content = content.replace(/^\.PHONY: /m, ".PHONY: dev worker ");
61
+ const targets = "\n# run both API + worker in one terminal — Ctrl+C kills both\n" +
62
+ "dev:\n" +
63
+ "\t@[ -f .env ] && export $$(grep -v '^#' .env | xargs); \\\n" +
64
+ "\t(trap 'kill 0' SIGINT SIGTERM; \\\n" +
65
+ "\t go run ./cmd/api & \\\n" +
66
+ "\t go run ./cmd/worker & \\\n" +
67
+ "\t wait)\n" +
68
+ "\n" +
69
+ "# background worker for async task processing (email, ...) — requires Redis.\n" +
70
+ "# Use `make dev` to run both in one terminal, or run this in a separate one.\n" +
71
+ "worker:\n" +
72
+ "\t@[ -f .env ] && export $$(grep -v '^#' .env | xargs); go run ./cmd/worker\n";
73
+ content = content.replace(/\nbuild:/, `${targets}\nbuild:`);
74
+ fs_extra_1.default.writeFileSync(makefilePath, content);
75
+ }