@nakedev/go-scaffold 0.3.3 → 0.4.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 (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -6,19 +6,36 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.assertMethodAbsent = assertMethodAbsent;
7
7
  exports.patchMethod = patchMethod;
8
8
  exports.markersPresent = markersPresent;
9
+ exports.markersPresentForPaths = markersPresentForPaths;
9
10
  const fs_extra_1 = __importDefault(require("fs-extra"));
10
11
  const marker_patch_1 = require("./marker-patch");
11
12
  const naming_1 = require("./naming");
12
13
  const DTO_MARKER = "// go-scaffold:dto";
14
+ const AUTH_DTO_MARKER = "// go-scaffold:user-dto";
13
15
  const REPO_INTERFACE_MARKER = "// go-scaffold:repository-interface";
14
16
  const REPO_IMPL_MARKER = "// go-scaffold:repository-methods";
15
17
  const SERVICE_MARKER = "// go-scaffold:service-methods";
16
18
  const SERVICE_INTERFACE_MARKER = "// go-scaffold:service-interface";
19
+ const COMMAND_REPO_INTERFACE_MARKER = "// go-scaffold:command-repository-interface";
20
+ const QUERY_REPO_INTERFACE_MARKER = "// go-scaffold:query-repository-interface";
21
+ const COMMAND_MARKER = "// go-scaffold:command-methods";
22
+ const QUERY_MARKER = "// go-scaffold:query-methods";
23
+ const COMMAND_INTERFACE_MARKER = "// go-scaffold:command-interface";
24
+ const QUERY_INTERFACE_MARKER = "// go-scaffold:query-interface";
17
25
  const HANDLER_ROUTES_MARKER = "// go-scaffold:handler-routes";
18
26
  const HANDLER_FUNCS_MARKER = "// go-scaffold:handler-funcs";
27
+ // Auth deliberately keeps its route/service composition files focused rather
28
+ // than using the generic CRUD marker names. `generate method user ...` is
29
+ // still a supported extension point, so accept both marker vocabularies.
30
+ const AUTH_REPO_INTERFACE_MARKER = "// go-scaffold:user-repository-interface";
31
+ const AUTH_REPO_IMPL_MARKER = "// go-scaffold:user-repository-methods";
32
+ const AUTH_SERVICE_MARKER = "// go-scaffold:user-service-methods";
33
+ const AUTH_HANDLER_ROUTES_MARKER = "// go-scaffold:user-routes";
34
+ const AUTH_HANDLER_FUNCS_MARKER = "// go-scaffold:user-handler-funcs";
19
35
  const REPOSITORY_STUB_FIELDS_MARKER = "// go-scaffold:repository-stub-fields";
20
36
  const REPOSITORY_STUB_METHODS_MARKER = "// go-scaffold:repository-stub-methods";
21
37
  const LEGACY_FAKE_REPO_METHODS_MARKER = "// go-scaffold:fake-repo-methods";
38
+ const AUTH_FAKE_REPO_METHODS_MARKER = "// go-scaffold:user-fake-repo-methods";
22
39
  const UNUSED_G_LINE = "\t_ = g\n";
23
40
  // writeHandler ensures whatever packages the new handler code references are
24
41
  // imported (a minimal module starts with only "gin" imported) and drops the
@@ -31,6 +48,52 @@ function writeHandler(handlerPath, content, goModule, needs) {
31
48
  }
32
49
  fs_extra_1.default.writeFileSync(handlerPath, content.replace(UNUSED_G_LINE, ""));
33
50
  }
51
+ function isCqrs(paths) {
52
+ return Boolean(paths.commandPath &&
53
+ paths.queryPath &&
54
+ fs_extra_1.default.existsSync(paths.commandPath) &&
55
+ fs_extra_1.default.existsSync(paths.queryPath));
56
+ }
57
+ function cqrsTarget(paths, type) {
58
+ if (!isCqrs(paths))
59
+ return null;
60
+ if (type === "get") {
61
+ return {
62
+ path: paths.queryPath,
63
+ implementationMarker: QUERY_MARKER,
64
+ interfaceMarker: QUERY_INTERFACE_MARKER,
65
+ callReceiver: "queries",
66
+ };
67
+ }
68
+ return {
69
+ path: paths.commandPath,
70
+ implementationMarker: COMMAND_MARKER,
71
+ interfaceMarker: COMMAND_INTERFACE_MARKER,
72
+ callReceiver: "commands",
73
+ };
74
+ }
75
+ function insertCqrsImplementation(paths, type, block, goModule) {
76
+ const target = cqrsTarget(paths, type);
77
+ if (!target)
78
+ return;
79
+ let content = fs_extra_1.default.readFileSync(target.path, "utf8");
80
+ if (type !== "get") {
81
+ content = (0, marker_patch_1.ensureImport)(content, `${goModule}/internal/shared/apperror`);
82
+ }
83
+ content = (0, marker_patch_1.insertBeforeMarker)(content, target.implementationMarker, block);
84
+ fs_extra_1.default.writeFileSync(target.path, content);
85
+ }
86
+ function insertCqrsFacade(servicePath, block, goModule, modulePath) {
87
+ let service = fs_extra_1.default.readFileSync(servicePath, "utf8");
88
+ // Minimal CQRS service.go intentionally has no imports until a method is
89
+ // generated. The compatibility facade still uses the normal feature input,
90
+ // model, and UUID types, so make the first generated method compile too.
91
+ service = (0, marker_patch_1.ensureImport)(service, "context");
92
+ service = (0, marker_patch_1.ensureImport)(service, `${goModule}/internal/app/${modulePath}/model`);
93
+ service = (0, marker_patch_1.ensureImport)(service, "github.com/google/uuid");
94
+ service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, block);
95
+ fs_extra_1.default.writeFileSync(servicePath, service);
96
+ }
34
97
  function assertNotDuplicate(content, needle, what) {
35
98
  if (content.includes(needle)) {
36
99
  throw new Error(`${what} already exists — pick a different method name`);
@@ -39,6 +102,16 @@ function assertNotDuplicate(content, needle, what) {
39
102
  function routeCall(type) {
40
103
  return { get: "GET", post: "POST", put: "PUT", patch: "PATCH", delete: "DELETE" }[type];
41
104
  }
105
+ function insertBeforeMethodMarker(content, standard, auth, block) {
106
+ const marker = (0, marker_patch_1.hasMarker)(content, standard) ? standard : (0, marker_patch_1.hasMarker)(content, auth) ? auth : standard;
107
+ return (0, marker_patch_1.insertBeforeMarker)(content, marker, block);
108
+ }
109
+ function hasMethodMarker(content, standard, auth) {
110
+ return (0, marker_patch_1.hasMarker)(content, standard) || (0, marker_patch_1.hasMarker)(content, auth);
111
+ }
112
+ function handlerRouteReceiver(content) {
113
+ return (0, marker_patch_1.hasMarker)(content, AUTH_HANDLER_ROUTES_MARKER) ? "usersGroup" : "g";
114
+ }
42
115
  // assertMethodAbsent is the same check patchMethod runs, exported so callers
43
116
  // can reach it before their own pre-flight checks. A name that already exists
44
117
  // is the cause the caller needs to hear about, so it has to be reported ahead
@@ -67,14 +140,27 @@ function patchMethod(paths, naming, method, opts, goModule) {
67
140
  patchPost(paths, naming, method, goModule);
68
141
  }
69
142
  else if (opts.type === "put" || opts.type === "patch") {
70
- patchResourceAction(paths, naming, method, opts.type, goModule);
143
+ patchResourceAction(paths, method, opts.type, goModule, naming.pkg);
71
144
  }
72
145
  else {
73
- patchDelete(paths, method, goModule);
146
+ patchDelete(paths, method, goModule, naming.pkg);
74
147
  }
75
- patchHandlerServiceInterface(paths.handlerPath, naming, method, opts, goModule);
148
+ ensureNonCqrsServiceImports(paths, goModule);
149
+ patchHandlerServiceInterface(paths, naming, method, opts, goModule);
76
150
  }
77
- function patchHandlerServiceInterface(handlerPath, naming, method, opts, goModule) {
151
+ // Auth keeps service.go intentionally small, so it does not carry the
152
+ // apperror import that ordinary generated CRUD services use. A later
153
+ // `generate method user ...` still inserts the same error-returning method
154
+ // shape; make that extension compile without reintroducing the import into
155
+ // every auth-only project.
156
+ function ensureNonCqrsServiceImports(paths, goModule) {
157
+ if (isCqrs(paths))
158
+ return;
159
+ let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
160
+ service = (0, marker_patch_1.ensureImport)(service, `${goModule}/internal/shared/apperror`);
161
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
162
+ }
163
+ function patchHandlerServiceInterface(paths, naming, method, opts, goModule) {
78
164
  let signature;
79
165
  let needsModel = true;
80
166
  let needsUUID = false;
@@ -88,7 +174,8 @@ function patchHandlerServiceInterface(handlerPath, naming, method, opts, goModul
88
174
  signature = `${method.pascalName}(context.Context, ${method.pascalName}Input) (*model.${naming.pascalName}, error)`;
89
175
  }
90
176
  else if (opts.type === "put" || opts.type === "patch") {
91
- signature = `${method.pascalName}(context.Context, uuid.UUID) (*model.${naming.pascalName}, error)`;
177
+ signature = `${method.pascalName}(context.Context, uuid.UUID) error`;
178
+ needsModel = false;
92
179
  needsUUID = true;
93
180
  }
94
181
  else {
@@ -96,7 +183,7 @@ function patchHandlerServiceInterface(handlerPath, naming, method, opts, goModul
96
183
  needsModel = false;
97
184
  needsUUID = true;
98
185
  }
99
- let handler = fs_extra_1.default.readFileSync(handlerPath, "utf8");
186
+ let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
100
187
  if (!(0, marker_patch_1.hasMarker)(handler, SERVICE_INTERFACE_MARKER)) {
101
188
  // Projects scaffolded before the narrow service interface stored *Service
102
189
  // directly. The concrete type already exposes generated methods, so there
@@ -113,15 +200,22 @@ function patchHandlerServiceInterface(handlerPath, naming, method, opts, goModul
113
200
  if (needsUUID) {
114
201
  handler = (0, marker_patch_1.ensureImport)(handler, "github.com/google/uuid");
115
202
  }
116
- fs_extra_1.default.writeFileSync(handlerPath, handler);
203
+ fs_extra_1.default.writeFileSync(paths.handlerPath, handler);
204
+ const target = cqrsTarget(paths, opts.type);
205
+ if (target) {
206
+ let application = fs_extra_1.default.readFileSync(target.path, "utf8");
207
+ application = (0, marker_patch_1.insertBeforeMarker)(application, target.interfaceMarker, signature);
208
+ fs_extra_1.default.writeFileSync(target.path, application);
209
+ }
117
210
  }
118
211
  function patchGetAll(paths, naming, method, goModule) {
119
212
  let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
120
- handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_ROUTES_MARKER, `g.GET("/${method.pathSegment}", h.${method.handlerName})`);
121
- handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_FUNCS_MARKER, [
213
+ const routeReceiver = handlerRouteReceiver(handler);
214
+ handler = insertBeforeMethodMarker(handler, HANDLER_ROUTES_MARKER, AUTH_HANDLER_ROUTES_MARKER, `${routeReceiver}.GET("/${method.pathSegment}", h.${method.handlerName})`);
215
+ handler = insertBeforeMethodMarker(handler, HANDLER_FUNCS_MARKER, AUTH_HANDLER_FUNCS_MARKER, [
122
216
  `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
123
217
  `\tp := pagination.Parse(c)`,
124
- `\titems, err := h.svc.${method.pascalName}(c.Request.Context(), p.Limit, p.Offset)`,
218
+ `\titems, err := ${isCqrs(paths) ? `h.queries.${method.pascalName}` : `h.svc.${method.pascalName}`}(c.Request.Context(), p.Limit, p.Offset)`,
125
219
  `\tif err != nil {`,
126
220
  `\t\tc.Error(err)`,
127
221
  `\t\treturn`,
@@ -135,19 +229,32 @@ function patchGetAll(paths, naming, method, goModule) {
135
229
  ``,
136
230
  ].join("\n"));
137
231
  writeHandler(paths.handlerPath, handler, goModule, ["net/http", "pagination"]);
138
- let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
139
- service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
140
- `func (s *Service) ${method.pascalName}(ctx context.Context, limit, offset int) ([]model.${naming.pascalName}, error) {`,
232
+ const queryMethod = [
233
+ `func (h *QueryHandler) ${method.pascalName}(ctx context.Context, limit, offset int) ([]model.${naming.pascalName}, error) {`,
141
234
  `\t// TODO: add real filtering for "${method.name}" — currently reuses FindAll`,
142
- `\titems, err := s.repo.FindAll(ctx, limit, offset)`,
235
+ `\titems, err := h.repo.FindAll(ctx, limit, offset)`,
143
236
  `\tif err != nil {`,
144
- `\t\treturn nil, apperror.NewInternal()`,
237
+ `\t\treturn nil, apperror.NewInternal(err)`,
145
238
  `\t}`,
146
239
  `\treturn items, nil`,
147
240
  `}`,
148
241
  ``,
149
- ].join("\n"));
150
- fs_extra_1.default.writeFileSync(paths.servicePath, service);
242
+ ].join("\n");
243
+ const serviceFacade = [
244
+ `func (s *Service) ${method.pascalName}(ctx context.Context, limit, offset int) ([]model.${naming.pascalName}, error) {`,
245
+ `\treturn s.queries.${method.pascalName}(ctx, limit, offset)`,
246
+ `}`,
247
+ ``,
248
+ ].join("\n");
249
+ if (isCqrs(paths)) {
250
+ insertCqrsImplementation(paths, "get", queryMethod, goModule);
251
+ insertCqrsFacade(paths.servicePath, serviceFacade, goModule, naming.pkg);
252
+ }
253
+ else {
254
+ let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
255
+ service = insertBeforeMethodMarker(service, SERVICE_MARKER, AUTH_SERVICE_MARKER, queryMethod.replace("(h *QueryHandler)", "(s *Service)").replace("h.repo", "s.repo"));
256
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
257
+ }
151
258
  }
152
259
  function patchGetOne(paths, naming, method, rawField, goModule) {
153
260
  const fieldParam = (0, naming_1.toCamelCase)(rawField);
@@ -155,7 +262,7 @@ function patchGetOne(paths, naming, method, rawField, goModule) {
155
262
  const fieldColumn = (0, naming_1.toDbName)(rawField);
156
263
  let repo = fs_extra_1.default.readFileSync(paths.repositoryPath, "utf8");
157
264
  assertNotDuplicate(repo, `FindBy${fieldPascal}(`, `repository method "FindBy${fieldPascal}"`);
158
- repo = (0, marker_patch_1.insertBeforeMarker)(repo, REPO_IMPL_MARKER, [
265
+ repo = insertBeforeMethodMarker(repo, REPO_IMPL_MARKER, AUTH_REPO_IMPL_MARKER, [
159
266
  `func (r *Repository) FindBy${fieldPascal}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error) {`,
160
267
  `\tvar m model.${naming.pascalName}`,
161
268
  `\t// TODO: add the "${fieldColumn}" column and an index on it — this query`,
@@ -173,7 +280,14 @@ function patchGetOne(paths, naming, method, rawField, goModule) {
173
280
  ].join("\n"));
174
281
  fs_extra_1.default.writeFileSync(paths.repositoryPath, repo);
175
282
  let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
176
- service = (0, marker_patch_1.insertBeforeMarker)(service, REPO_INTERFACE_MARKER, `FindBy${fieldPascal}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error)`);
283
+ if (isCqrs(paths)) {
284
+ let query = fs_extra_1.default.readFileSync(paths.queryPath, "utf8");
285
+ query = (0, marker_patch_1.insertBeforeMarker)(query, QUERY_REPO_INTERFACE_MARKER, `FindBy${fieldPascal}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error)`);
286
+ fs_extra_1.default.writeFileSync(paths.queryPath, query);
287
+ }
288
+ else {
289
+ service = insertBeforeMethodMarker(service, REPO_INTERFACE_MARKER, AUTH_REPO_INTERFACE_MARKER, `FindBy${fieldPascal}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error)`);
290
+ }
177
291
  // The repository interface just grew, so the test double needs a matching
178
292
  // method or focused service tests stop compiling. New projects use a
179
293
  // function-backed stub; projects scaffolded before that refactor retain the
@@ -193,6 +307,15 @@ function patchGetOne(paths, naming, method, rawField, goModule) {
193
307
  ``,
194
308
  ].join("\n"));
195
309
  }
310
+ else if ((0, marker_patch_1.hasMarker)(serviceTest, AUTH_FAKE_REPO_METHODS_MARKER)) {
311
+ serviceTest = (0, marker_patch_1.insertBeforeMarker)(serviceTest, AUTH_FAKE_REPO_METHODS_MARKER, [
312
+ `//nolint:unused`,
313
+ `func (f *fakeRepo) FindBy${fieldPascal}(context.Context, string) (*model.${naming.pascalName}, error) {`,
314
+ ` return nil, gorm.ErrRecordNotFound`,
315
+ `}`,
316
+ ``,
317
+ ].join("\n"));
318
+ }
196
319
  else if ((0, marker_patch_1.hasMarker)(serviceTest, LEGACY_FAKE_REPO_METHODS_MARKER)) {
197
320
  serviceTest = (0, marker_patch_1.insertBeforeMarker)(serviceTest, LEGACY_FAKE_REPO_METHODS_MARKER, [
198
321
  `//nolint:unused`,
@@ -209,23 +332,37 @@ function patchGetOne(paths, naming, method, rawField, goModule) {
209
332
  throw new Error(`neither current repository stub markers nor legacy "${LEGACY_FAKE_REPO_METHODS_MARKER}" found in ${paths.serviceTestPath}`);
210
333
  }
211
334
  fs_extra_1.default.writeFileSync(paths.serviceTestPath, serviceTest);
212
- service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
213
- `func (s *Service) ${method.pascalName}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error) {`,
214
- `\tm, err := s.repo.FindBy${fieldPascal}(ctx, ${fieldParam})`,
335
+ const queryMethod = [
336
+ `func (h *QueryHandler) ${method.pascalName}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error) {`,
337
+ `\tm, err := h.repo.FindBy${fieldPascal}(ctx, ${fieldParam})`,
215
338
  `\tif err != nil {`,
216
339
  `\t\treturn nil, wrapFindErr(err)`,
217
340
  `\t}`,
218
341
  `\treturn m, nil`,
219
342
  `}`,
220
343
  ``,
221
- ].join("\n"));
222
- fs_extra_1.default.writeFileSync(paths.servicePath, service);
344
+ ].join("\n");
345
+ const serviceFacade = [
346
+ `func (s *Service) ${method.pascalName}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error) {`,
347
+ `\treturn s.queries.${method.pascalName}(ctx, ${fieldParam})`,
348
+ `}`,
349
+ ``,
350
+ ].join("\n");
351
+ if (isCqrs(paths)) {
352
+ insertCqrsImplementation(paths, "get", queryMethod, goModule);
353
+ insertCqrsFacade(paths.servicePath, serviceFacade, goModule, naming.pkg);
354
+ }
355
+ else {
356
+ service = insertBeforeMethodMarker(service, SERVICE_MARKER, AUTH_SERVICE_MARKER, queryMethod.replace("(h *QueryHandler)", "(s *Service)").replace("h.repo", "s.repo"));
357
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
358
+ }
223
359
  let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
224
- handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_ROUTES_MARKER, `g.GET("/${fieldColumn}/:${fieldParam}", h.${method.handlerName})`);
225
- handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_FUNCS_MARKER, [
360
+ const routeReceiver = handlerRouteReceiver(handler);
361
+ handler = insertBeforeMethodMarker(handler, HANDLER_ROUTES_MARKER, AUTH_HANDLER_ROUTES_MARKER, `${routeReceiver}.GET("/${fieldColumn}/:${fieldParam}", h.${method.handlerName})`);
362
+ handler = insertBeforeMethodMarker(handler, HANDLER_FUNCS_MARKER, AUTH_HANDLER_FUNCS_MARKER, [
226
363
  `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
227
364
  `\t${fieldParam} := c.Param("${fieldParam}")`,
228
- `\tm, err := h.svc.${method.pascalName}(c.Request.Context(), ${fieldParam})`,
365
+ `\tm, err := ${isCqrs(paths) ? `h.queries.${method.pascalName}` : `h.svc.${method.pascalName}`}(c.Request.Context(), ${fieldParam})`,
229
366
  `\tif err != nil {`,
230
367
  `\t\tc.Error(err)`,
231
368
  `\t\treturn`,
@@ -240,18 +377,19 @@ function patchPost(paths, naming, method, goModule) {
240
377
  const inputName = `${method.pascalName}Input`;
241
378
  let dto = fs_extra_1.default.readFileSync(paths.dtoPath, "utf8");
242
379
  assertNotDuplicate(dto, `type ${inputName} struct`, `DTO "${inputName}"`);
243
- dto = (0, marker_patch_1.insertBeforeMarker)(dto, DTO_MARKER, [`type ${inputName} struct {`, `\t// TODO: add request fields`, `}`, ``].join("\n"));
380
+ dto = insertBeforeMethodMarker(dto, DTO_MARKER, AUTH_DTO_MARKER, [`type ${inputName} struct {`, `\t// TODO: add request fields`, `}`, ``].join("\n"));
244
381
  fs_extra_1.default.writeFileSync(paths.dtoPath, dto);
245
382
  let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
246
- handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_ROUTES_MARKER, `g.POST("/${method.pathSegment}", h.${method.handlerName})`);
247
- handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_FUNCS_MARKER, [
383
+ const routeReceiver = handlerRouteReceiver(handler);
384
+ handler = insertBeforeMethodMarker(handler, HANDLER_ROUTES_MARKER, AUTH_HANDLER_ROUTES_MARKER, `${routeReceiver}.POST("/${method.pathSegment}", h.${method.handlerName})`);
385
+ handler = insertBeforeMethodMarker(handler, HANDLER_FUNCS_MARKER, AUTH_HANDLER_FUNCS_MARKER, [
248
386
  `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
249
387
  `\tvar in ${inputName}`,
250
388
  `\tif err := c.ShouldBindJSON(&in); err != nil {`,
251
389
  `\t\tc.Error(httpx.BindErr(err))`,
252
390
  `\t\treturn`,
253
391
  `\t}`,
254
- `\tm, err := h.svc.${method.pascalName}(c.Request.Context(), in)`,
392
+ `\tm, err := ${isCqrs(paths) ? `h.commands.${method.pascalName}` : `h.svc.${method.pascalName}`}(c.Request.Context(), in)`,
255
393
  `\tif err != nil {`,
256
394
  `\t\tc.Error(err)`,
257
395
  `\t\treturn`,
@@ -261,73 +399,84 @@ function patchPost(paths, naming, method, goModule) {
261
399
  ``,
262
400
  ].join("\n"));
263
401
  writeHandler(paths.handlerPath, handler, goModule, ["net/http", "httpx"]);
264
- let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
265
- service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
266
- `func (s *Service) ${method.pascalName}(ctx context.Context, in ${inputName}) (*model.${naming.pascalName}, error) {`,
402
+ const commandMethod = [
403
+ `func (h *CommandHandler) ${method.pascalName}(ctx context.Context, in ${inputName}) (*model.${naming.pascalName}, error) {`,
267
404
  `\t// TODO: implement "${method.name}" — this stub does nothing yet`,
268
405
  `\t_ = in`,
269
406
  `\treturn nil, apperror.NewInternal()`,
270
407
  `}`,
271
408
  ``,
272
- ].join("\n"));
273
- fs_extra_1.default.writeFileSync(paths.servicePath, service);
409
+ ].join("\n");
410
+ const serviceFacade = [
411
+ `func (s *Service) ${method.pascalName}(ctx context.Context, in ${inputName}) (*model.${naming.pascalName}, error) {`,
412
+ `\treturn s.commands.${method.pascalName}(ctx, in)`,
413
+ `}`,
414
+ ``,
415
+ ].join("\n");
416
+ if (isCqrs(paths)) {
417
+ insertCqrsImplementation(paths, "post", commandMethod, goModule);
418
+ insertCqrsFacade(paths.servicePath, serviceFacade, goModule, naming.pkg);
419
+ }
420
+ else {
421
+ let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
422
+ service = insertBeforeMethodMarker(service, SERVICE_MARKER, AUTH_SERVICE_MARKER, commandMethod.replace("(h *CommandHandler)", "(s *Service)").replace("h.repo", "s.repo"));
423
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
424
+ }
274
425
  }
275
- function patchResourceAction(paths, naming, method, type, goModule) {
426
+ function patchResourceAction(paths, method, type, goModule, modulePath) {
276
427
  let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
277
- handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_ROUTES_MARKER, `g.${routeCall(type)}("/:id/${method.pathSegment}", h.${method.handlerName})`);
278
- handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_FUNCS_MARKER, [
428
+ const routeReceiver = handlerRouteReceiver(handler);
429
+ handler = insertBeforeMethodMarker(handler, HANDLER_ROUTES_MARKER, AUTH_HANDLER_ROUTES_MARKER, `${routeReceiver}.${routeCall(type)}("/:id/${method.pathSegment}", h.${method.handlerName})`);
430
+ handler = insertBeforeMethodMarker(handler, HANDLER_FUNCS_MARKER, AUTH_HANDLER_FUNCS_MARKER, [
279
431
  `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
280
432
  `\tid, ok := httpx.ParseID(c)`,
281
433
  `\tif !ok {`,
282
434
  `\t\treturn`,
283
435
  `\t}`,
284
- `\tm, err := h.svc.${method.pascalName}(c.Request.Context(), id)`,
285
- `\tif err != nil {`,
436
+ `\tif err := ${isCqrs(paths) ? `h.commands.${method.pascalName}` : `h.svc.${method.pascalName}`}(c.Request.Context(), id); err != nil {`,
286
437
  `\t\tc.Error(err)`,
287
438
  `\t\treturn`,
288
439
  `\t}`,
289
- `\tc.JSON(http.StatusOK, toResponse(m))`,
440
+ `\tc.Status(http.StatusNotImplemented)`,
290
441
  `}`,
291
442
  ``,
292
443
  ].join("\n"));
293
444
  writeHandler(paths.handlerPath, handler, goModule, ["net/http", "httpx"]);
294
- let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
295
- service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
296
- `func (s *Service) ${method.pascalName}(ctx context.Context, id uuid.UUID) (*model.${naming.pascalName}, error) {`,
297
- `\tm, err := s.repo.FindByID(ctx, id)`,
298
- `\tif err != nil {`,
299
- `\t\treturn nil, wrapFindErr(err)`,
300
- `\t}`,
301
- `\t// TODO: implement "${method.name}" — currently a no-op save`,
302
- `\t// The version compared here is the one just read, so this only`,
303
- `\t// catches a writer that lands between the read above and this write.`,
304
- `\t// It is NOT the check Update does: that one compares against the version`,
305
- `\t// the *client* last saw, which is what catches someone submitting a`,
306
- `\t// form built from a copy that has since gone stale. If this endpoint`,
307
- `\t// needs that, give it an input struct carrying Version and assign it`,
308
- `\t// to m.Version here, the way service.go's Update does.`,
309
- `\tif err := s.repo.Update(ctx, m); err != nil {`,
310
- `\t\tif errors.Is(err, ErrStaleVersion) {`,
311
- `\t\t\treturn nil, errStale()`,
312
- `\t\t}`,
313
- `\t\treturn nil, apperror.NewInternal()`,
314
- `\t}`,
315
- `\treturn m, nil`,
445
+ const commandMethod = [
446
+ `func (h *CommandHandler) ${method.pascalName}(ctx context.Context, id uuid.UUID) error {`,
447
+ `\t_ = ctx`,
448
+ `\t_ = id`,
449
+ `\treturn apperror.NewNotImplemented()`,
316
450
  `}`,
317
451
  ``,
318
- ].join("\n"));
319
- fs_extra_1.default.writeFileSync(paths.servicePath, service);
452
+ ].join("\n");
453
+ const serviceFacade = [
454
+ `func (s *Service) ${method.pascalName}(ctx context.Context, id uuid.UUID) error {`,
455
+ `\treturn s.commands.${method.pascalName}(ctx, id)`,
456
+ `}`,
457
+ ``,
458
+ ].join("\n");
459
+ if (isCqrs(paths)) {
460
+ insertCqrsImplementation(paths, type, commandMethod, goModule);
461
+ insertCqrsFacade(paths.servicePath, serviceFacade, goModule, modulePath);
462
+ }
463
+ else {
464
+ let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
465
+ service = insertBeforeMethodMarker(service, SERVICE_MARKER, AUTH_SERVICE_MARKER, commandMethod.replace("(h *CommandHandler)", "(s *Service)"));
466
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
467
+ }
320
468
  }
321
- function patchDelete(paths, method, goModule) {
469
+ function patchDelete(paths, method, goModule, modulePath) {
322
470
  let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
323
- handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_ROUTES_MARKER, `g.DELETE("/:id/${method.pathSegment}", h.${method.handlerName})`);
324
- handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_FUNCS_MARKER, [
471
+ const routeReceiver = handlerRouteReceiver(handler);
472
+ handler = insertBeforeMethodMarker(handler, HANDLER_ROUTES_MARKER, AUTH_HANDLER_ROUTES_MARKER, `${routeReceiver}.DELETE("/:id/${method.pathSegment}", h.${method.handlerName})`);
473
+ handler = insertBeforeMethodMarker(handler, HANDLER_FUNCS_MARKER, AUTH_HANDLER_FUNCS_MARKER, [
325
474
  `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
326
475
  `\tid, ok := httpx.ParseID(c)`,
327
476
  `\tif !ok {`,
328
477
  `\t\treturn`,
329
478
  `\t}`,
330
- `\tif err := h.svc.${method.pascalName}(c.Request.Context(), id); err != nil {`,
479
+ `\tif err := ${isCqrs(paths) ? `h.commands.${method.pascalName}` : `h.svc.${method.pascalName}`}(c.Request.Context(), id); err != nil {`,
331
480
  `\t\tc.Error(err)`,
332
481
  `\t\treturn`,
333
482
  `\t}`,
@@ -336,22 +485,53 @@ function patchDelete(paths, method, goModule) {
336
485
  ``,
337
486
  ].join("\n"));
338
487
  writeHandler(paths.handlerPath, handler, goModule, ["net/http", "httpx"]);
339
- let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
340
- service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
341
- `func (s *Service) ${method.pascalName}(ctx context.Context, id uuid.UUID) error {`,
488
+ const commandMethod = [
489
+ `func (h *CommandHandler) ${method.pascalName}(ctx context.Context, id uuid.UUID) error {`,
342
490
  `\t// TODO: implement "${method.name}" — this stub does nothing yet`,
343
491
  `\treturn apperror.NewInternal()`,
344
492
  `}`,
345
493
  ``,
346
- ].join("\n"));
347
- fs_extra_1.default.writeFileSync(paths.servicePath, service);
494
+ ].join("\n");
495
+ const serviceFacade = [
496
+ `func (s *Service) ${method.pascalName}(ctx context.Context, id uuid.UUID) error {`,
497
+ `\treturn s.commands.${method.pascalName}(ctx, id)`,
498
+ `}`,
499
+ ``,
500
+ ].join("\n");
501
+ if (isCqrs(paths)) {
502
+ insertCqrsImplementation(paths, "delete", commandMethod, goModule);
503
+ insertCqrsFacade(paths.servicePath, serviceFacade, goModule, modulePath);
504
+ }
505
+ else {
506
+ let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
507
+ service = insertBeforeMethodMarker(service, SERVICE_MARKER, AUTH_SERVICE_MARKER, commandMethod.replace("(h *CommandHandler)", "(s *Service)"));
508
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
509
+ }
348
510
  }
349
511
  function markersPresent(handlerPath, servicePath) {
350
- const handler = fs_extra_1.default.readFileSync(handlerPath, "utf8");
351
- const service = fs_extra_1.default.readFileSync(servicePath, "utf8");
352
- return ((0, marker_patch_1.hasMarker)(handler, HANDLER_ROUTES_MARKER) &&
353
- (0, marker_patch_1.hasMarker)(handler, HANDLER_FUNCS_MARKER) &&
512
+ return markersPresentForPaths({ handlerPath, servicePath });
513
+ }
514
+ function markersPresentForPaths(paths) {
515
+ const handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
516
+ const service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
517
+ const baseMarkers = hasMethodMarker(handler, HANDLER_ROUTES_MARKER, AUTH_HANDLER_ROUTES_MARKER) &&
518
+ hasMethodMarker(handler, HANDLER_FUNCS_MARKER, AUTH_HANDLER_FUNCS_MARKER) &&
354
519
  ((0, marker_patch_1.hasMarker)(handler, SERVICE_INTERFACE_MARKER) || handler.includes("svc *Service")) &&
355
- (0, marker_patch_1.hasMarker)(service, SERVICE_MARKER) &&
356
- (0, marker_patch_1.hasMarker)(service, REPO_INTERFACE_MARKER));
520
+ hasMethodMarker(service, SERVICE_MARKER, AUTH_SERVICE_MARKER) &&
521
+ hasMethodMarker(service, REPO_INTERFACE_MARKER, AUTH_REPO_INTERFACE_MARKER);
522
+ const hasCommand = Boolean(paths.commandPath && fs_extra_1.default.existsSync(paths.commandPath));
523
+ const hasQuery = Boolean(paths.queryPath && fs_extra_1.default.existsSync(paths.queryPath));
524
+ if (!hasCommand && !hasQuery)
525
+ return baseMarkers;
526
+ if (!hasCommand || !hasQuery)
527
+ return false;
528
+ const commands = fs_extra_1.default.readFileSync(paths.commandPath, "utf8");
529
+ const queries = fs_extra_1.default.readFileSync(paths.queryPath, "utf8");
530
+ return (baseMarkers &&
531
+ (0, marker_patch_1.hasMarker)(commands, COMMAND_MARKER) &&
532
+ (0, marker_patch_1.hasMarker)(commands, COMMAND_INTERFACE_MARKER) &&
533
+ (0, marker_patch_1.hasMarker)(commands, COMMAND_REPO_INTERFACE_MARKER) &&
534
+ (0, marker_patch_1.hasMarker)(queries, QUERY_MARKER) &&
535
+ (0, marker_patch_1.hasMarker)(queries, QUERY_INTERFACE_MARKER) &&
536
+ (0, marker_patch_1.hasMarker)(queries, QUERY_REPO_INTERFACE_MARKER));
357
537
  }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.moduleProfileFor = moduleProfileFor;
4
+ exports.architectureForModuleProfile = architectureForModuleProfile;
5
+ exports.describeModuleProfile = describeModuleProfile;
6
+ function moduleProfileFor(moduleSurface, applicationStyle) {
7
+ if (moduleSurface === "minimal" && applicationStyle === "service")
8
+ return "lean";
9
+ if (moduleSurface === "crud" && applicationStyle === "service")
10
+ return "crud";
11
+ if (moduleSurface === "minimal" && applicationStyle === "cqrs")
12
+ return "cqrs";
13
+ return "advanced";
14
+ }
15
+ function architectureForModuleProfile(profile) {
16
+ if (profile === "crud") {
17
+ return { moduleSurface: "crud", applicationStyle: "service" };
18
+ }
19
+ if (profile === "cqrs") {
20
+ return { moduleSurface: "minimal", applicationStyle: "cqrs" };
21
+ }
22
+ return { moduleSurface: "minimal", applicationStyle: "service" };
23
+ }
24
+ function describeModuleProfile(profile) {
25
+ if (profile === "lean")
26
+ return "Lean (minimal + service)";
27
+ if (profile === "crud")
28
+ return "CRUD (CRUD surface + service)";
29
+ if (profile === "cqrs")
30
+ return "CQRS (minimal surface + CQRS)";
31
+ return "Advanced (choose surface + application boundary)";
32
+ }
@@ -49,8 +49,8 @@ function patchMainGoForObservability(mainGoPath, goModule, projectName) {
49
49
  }
50
50
  const newUseLine = `${USE_LINE.slice(0, -1)}, middleware.Metrics(), middleware.Tracing("${projectName}"))`;
51
51
  content = content.replace(USE_LINE, () => newUseLine);
52
- // Not gated on APP_ENV the way /docs is: production is exactly where you
53
- // want a scrape target, and Prometheus reaches it in-cluster. It is still
52
+ // Unlike docs/, which is never served at all: production is exactly where
53
+ // you want a scrape target, and Prometheus reaches it in-cluster. It is still
54
54
  // unauthenticated and does disclose your route list and traffic shape, so
55
55
  // block /metrics at the ingress rather than publishing it to the internet.
56
56
  const metricsRoute = 'r.GET("/metrics", gin.WrapH(promhttp.Handler()))';
@@ -17,7 +17,6 @@ const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
17
17
  const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
18
18
  const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
19
19
  const READYZ_MARKER = "// go-scaffold:readyz-checks";
20
- const SHUTDOWN_MARKER = "// go-scaffold:shutdown";
21
20
  // patchComposeForRedis adds a redis service to docker-compose.yml. Whatever
22
21
  // pulls Redis in — `add worker --queue redis`, or `add auth`'s refresh-token
23
22
  // store on top of a Postgres queue — makes cmd/api call cache.Open and adds a
@@ -55,8 +54,7 @@ function patchCiForRedis(ciPath) {
55
54
  if (!fs_extra_1.default.existsSync(ciPath))
56
55
  return;
57
56
  const content = fs_extra_1.default.readFileSync(ciPath, "utf8");
58
- if (/^\s{6}redis:/m.test(content))
59
- return;
57
+ const hasRedisService = /^\s{6}redis:/m.test(content);
60
58
  // `steps:` sits one level under the job, so it's the first line that ends
61
59
  // the `services:` block — insert the service just above it.
62
60
  //
@@ -65,7 +63,7 @@ function patchCiForRedis(ciPath) {
65
63
  // means CI runs without Redis and fails on a connection error that says
66
64
  // nothing about this — so say it here instead.
67
65
  const stepsLine = content.split("\n").find((l) => l.trimEnd() === " steps:");
68
- if (!stepsLine) {
66
+ if (!hasRedisService && !stepsLine) {
69
67
  console.error(picocolors_1.default.yellow(`skipped adding the Redis service to ${ciPath} — no \`steps:\` line at the expected indentation to anchor it to.\n` +
70
68
  `Add a redis service to the workflow's \`services:\` block by hand, or CI will run without one.`));
71
69
  return;
@@ -82,7 +80,25 @@ function patchCiForRedis(ciPath) {
82
80
  " --health-retries 5",
83
81
  "",
84
82
  ].join("\n");
85
- fs_extra_1.default.writeFileSync(ciPath, content.replace(stepsLine, () => `${service}\n${stepsLine}`));
83
+ let patched = hasRedisService ? content : content.replace(stepsLine, () => `${service}\n${stepsLine}`);
84
+ // The service alone is not enough: token-store tests deliberately skip when
85
+ // TEST_REDIS_URL is absent, so a generated workflow could report green while
86
+ // never exercising Redis. Add the required test variables alongside the
87
+ // existing PostgreSQL test variables, anchored on the actual go test line so
88
+ // we do not accidentally put them in the Postgres container's env block.
89
+ const testRunLine = " run: go test ./...";
90
+ if (!patched.includes("REQUIRE_TEST_REDIS")) {
91
+ if (!patched.includes(testRunLine)) {
92
+ console.error(picocolors_1.default.yellow(`skipped requiring Redis integration tests in ${ciPath} — no \`run: go test ./...\` line to anchor the test environment to.\n` +
93
+ `Add TEST_REDIS_URL and REQUIRE_TEST_REDIS=true to the workflow's test step by hand.`));
94
+ }
95
+ else {
96
+ patched = patched.replace(testRunLine, ' TEST_REDIS_URL: redis://127.0.0.1:6379/0\n' +
97
+ ' REQUIRE_TEST_REDIS: "true"\n' +
98
+ testRunLine);
99
+ }
100
+ }
101
+ fs_extra_1.default.writeFileSync(ciPath, patched);
86
102
  }
87
103
  // patchConfigForRedis adds RedisURL to Config and its env() load to Load().
88
104
  // Split out from the worker patch because Redis is no longer the worker's
@@ -141,6 +157,14 @@ function patchMainGoForWorker(mainGoPath, goModule) {
141
157
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, cacheImport, cacheImport);
142
158
  const initBlock = ["rdb, err := cache.Open(cfg)", "if err != nil {", '\treturn fmt.Errorf("open redis: %w", err)', "}"].join("\n");
143
159
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, initBlock, "rdb, err := cache.Open(cfg)");
160
+ const cleanupBlock = [
161
+ "defer func() {",
162
+ '\tif err := rdb.Close(); err != nil {',
163
+ '\t\tlogger.Error("close redis", "error", err)',
164
+ "\t}",
165
+ "}()",
166
+ ].join("\n");
167
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, cleanupBlock, "defer func() {\n\tif err := rdb.Close()");
144
168
  const readyzBlock = [
145
169
  "if err := rdb.Ping(c.Request.Context()).Err(); err != nil {",
146
170
  '\tc.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})',
@@ -148,7 +172,5 @@ function patchMainGoForWorker(mainGoPath, goModule) {
148
172
  "}",
149
173
  ].join("\n");
150
174
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, READYZ_MARKER, readyzBlock, "if err := rdb.Ping(");
151
- const shutdownBlock = ["if err := rdb.Close(); err != nil {", '\tlogger.Error("close redis", "error", err)', "}"].join("\n");
152
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SHUTDOWN_MARKER, shutdownBlock, "if err := rdb.Close()");
153
175
  fs_extra_1.default.writeFileSync(mainGoPath, content);
154
176
  }