@nakedev/go-scaffold 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.patchConfigForAuth = patchConfigForAuth;
7
7
  exports.authWiringLines = authWiringLines;
8
+ exports.authHandlerLineFor = authHandlerLineFor;
8
9
  exports.patchMainGoForAuth = patchMainGoForAuth;
9
10
  exports.upgradeMailerToQueue = upgradeMailerToQueue;
10
11
  const fs_extra_1 = __importDefault(require("fs-extra"));
@@ -17,8 +18,7 @@ const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
17
18
  const SCHEMA_MARKER = "// go-scaffold:schemas";
18
19
  const MODEL_MARKER = "// go-scaffold:models";
19
20
  const ROUTE_MARKER = "// go-scaffold:routes";
20
- const SHUTDOWN_MARKER = "// go-scaffold:shutdown";
21
- // patchConfigForAuth adds JWT/cookie/password-reset/Google OAuth fields to
21
+ // patchConfigForAuth adds JWT/cookie/password-reset/browser OAuth fields to
22
22
  // Config, the same marker-based text insertion patchConfigForWorker uses
23
23
  // (config.go.hbs is only rendered once, at `create` — everything after that
24
24
  // is a real file a human may have already edited).
@@ -28,8 +28,11 @@ function patchConfigForAuth(configGoPath) {
28
28
  "JWTSecret string",
29
29
  "JWTAccessTTL time.Duration",
30
30
  "JWTRefreshTTL time.Duration",
31
+ "JWTRefreshMaxTTL time.Duration",
32
+ "OAuthStateTTL time.Duration",
31
33
  "CookieSecure bool",
32
34
  "CookieSameSite string",
35
+ "AuthBrowserTopology string",
33
36
  "",
34
37
  "PasswordResetTTL time.Duration",
35
38
  "PasswordResetURL string",
@@ -39,15 +42,25 @@ function patchConfigForAuth(configGoPath) {
39
42
  "",
40
43
  "GoogleClientID string",
41
44
  "GoogleClientSecret string",
42
- "GoogleRedirectURL string",
45
+ "GoogleOAuthRedirectURI string",
46
+ "",
47
+ "AuthMFAEnabled bool",
48
+ "MFAIssuer string",
49
+ "MFAEncryptionKey string",
50
+ "MFAChallengeTTL time.Duration",
51
+ "MFATOTPWindow int",
52
+ "MFARecoveryCodeCount int",
43
53
  ].join("\n");
44
54
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, fieldsBlock, "JWTSecret");
45
55
  const loadBlock = [
46
56
  'JWTSecret: env("JWT_SECRET", "dev-secret-change-me"),',
47
57
  'JWTAccessTTL: time.Duration(envInt("JWT_ACCESS_TTL_MIN", 15)) * time.Minute,',
48
58
  'JWTRefreshTTL: time.Duration(envInt("JWT_REFRESH_TTL_MIN", 43200)) * time.Minute,',
59
+ 'JWTRefreshMaxTTL: time.Duration(envInt("JWT_REFRESH_MAX_TTL_MIN", 43200)) * time.Minute,',
60
+ 'OAuthStateTTL: time.Duration(envInt("OAUTH_STATE_TTL_MIN", 10)) * time.Minute,',
49
61
  'CookieSecure: env("COOKIE_SECURE", "false") == "true",',
50
62
  'CookieSameSite: env("COOKIE_SAMESITE", "strict"),',
63
+ 'AuthBrowserTopology: env("AUTH_BROWSER_TOPOLOGY", "same-site"),',
51
64
  "",
52
65
  'PasswordResetTTL: time.Duration(envInt("PASSWORD_RESET_TTL_MIN", 30)) * time.Minute,',
53
66
  'PasswordResetURL: env("PASSWORD_RESET_URL", "http://localhost:3000/reset-password"),',
@@ -57,7 +70,14 @@ function patchConfigForAuth(configGoPath) {
57
70
  "",
58
71
  'GoogleClientID: env("GOOGLE_CLIENT_ID", ""),',
59
72
  'GoogleClientSecret: env("GOOGLE_CLIENT_SECRET", ""),',
60
- 'GoogleRedirectURL: env("GOOGLE_REDIRECT_URL", ""),',
73
+ 'GoogleOAuthRedirectURI: env("GOOGLE_OAUTH_REDIRECT_URI", ""),',
74
+ "",
75
+ 'AuthMFAEnabled: env("AUTH_MFA_ENABLED", "false") == "true",',
76
+ 'MFAIssuer: env("MFA_ISSUER", "go-scaffold"),',
77
+ 'MFAEncryptionKey: env("MFA_ENCRYPTION_KEY", ""),',
78
+ 'MFAChallengeTTL: time.Duration(envInt("MFA_CHALLENGE_TTL_MIN", 5)) * time.Minute,',
79
+ 'MFATOTPWindow: envInt("MFA_TOTP_WINDOW", 1),',
80
+ 'MFARecoveryCodeCount: envInt("MFA_RECOVERY_CODE_COUNT", 10),',
61
81
  ].join("\n");
62
82
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, 'env("JWT_SECRET"');
63
83
  fs_extra_1.default.writeFileSync(configGoPath, content);
@@ -68,26 +88,36 @@ function patchConfigForAuth(configGoPath) {
68
88
  function authWiringLines(w) {
69
89
  const postgres = w.store === "postgres";
70
90
  return {
71
- tokenStore: postgres ? "user.NewPgTokenStore(db)" : "user.NewRedisTokenStore(rdb)",
91
+ tokenStore: postgres ? "user.NewPgTokenStore(db)" : "user.NewRedisTokenStore(rdb, db)",
72
92
  limiter: postgres ? "middleware.NewMemoryLimiter()" : "middleware.NewRedisLimiter(rdb)",
73
93
  // with a queue the mail is enqueued and the request returns immediately;
74
94
  // without one it goes out inline, which is the cost of not running a worker
75
95
  mailer: w.worker ? "mail.NewAsyncClient(q)" : "mail.NewSyncClient(mail.Open(cfg))",
76
96
  };
77
97
  }
98
+ // authHandlerLineFor is the one root-level auth route shape. The feature owns
99
+ // construction; wiring.go only hands it shared infrastructure and, when RBAC
100
+ // is present, the role feature's public capabilities.
101
+ function authHandlerLineFor(w, roleDependencies = ["nil", "nil"]) {
102
+ const args = ["db", "cfg"];
103
+ if (w.store === "redis")
104
+ args.push("rdb");
105
+ if (w.worker)
106
+ args.push("q");
107
+ args.push(...roleDependencies);
108
+ return `user.NewHandlerFromDB(${args.join(", ")}).Register(api)`;
109
+ }
78
110
  function patchMainGoForAuth(mainGoPath, w) {
79
111
  const { goModule, queueBackend, store } = w;
80
112
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
81
113
  const importLine = `"${goModule}/internal/app/user"`;
82
114
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
83
- const modelImportLine = `usermodel "${goModule}/internal/app/user/model"`;
115
+ const modelImportLine = `usermodel "${goModule}/internal/app/user/adapters/outbound/postgres"`;
84
116
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
85
117
  if (w.worker) {
86
118
  const queueImportLine = `"${goModule}/internal/platform/queue"`;
87
119
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, queueImportLine, queueImportLine);
88
120
  }
89
- const mailImportLine = `"${goModule}/internal/platform/mail"`;
90
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, mailImportLine, mailImportLine);
91
121
  const checkBlock = [
92
122
  'if cfg.IsProd() && cfg.JWTSecret == "dev-secret-change-me" {',
93
123
  '\treturn errors.New("JWT_SECRET is still the dev default — set a real secret before deploying with APP_ENV=production")',
@@ -105,6 +135,21 @@ function patchMainGoForAuth(mainGoPath, w) {
105
135
  "}",
106
136
  ].join("\n");
107
137
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_CHECKS_MARKER, smtpCheckBlock, "SMTP_HOST is unset");
138
+ const mfaCheckBlock = [
139
+ "if cfg.AuthMFAEnabled {",
140
+ "\tif err := user.ValidateMFASettings(user.MFASettings{",
141
+ "\t\tEnabled: cfg.AuthMFAEnabled,",
142
+ "\t\tIssuer: cfg.MFAIssuer,",
143
+ "\t\tEncryptionKey: cfg.MFAEncryptionKey,",
144
+ "\t\tChallengeTTL: cfg.MFAChallengeTTL,",
145
+ "\t\tTOTPWindow: cfg.MFATOTPWindow,",
146
+ "\t\tRecoveryCodeCount: cfg.MFARecoveryCodeCount,",
147
+ "\t}); err != nil {",
148
+ "\t\treturn fmt.Errorf(\"invalid MFA configuration: %w\", err)",
149
+ "\t}",
150
+ "}",
151
+ ].join("\n");
152
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_CHECKS_MARKER, mfaCheckBlock, "invalid MFA configuration");
108
153
  // The enqueuer is built from whatever backend `add worker` chose — the
109
154
  // constructor differs, everything downstream of it (mail.NewAsyncClient)
110
155
  // only sees the queue.Enqueuer interface and doesn't change.
@@ -120,24 +165,27 @@ function patchMainGoForAuth(mainGoPath, w) {
120
165
  ].join("\n");
121
166
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS user_svc");
122
167
  const migrateLines = ["&usermodel.User{},", "&usermodel.Identity{},", "&usermodel.LoginThrottle{},"];
123
- // only the Postgres store has a table for AutoMigrate to create
124
- if (store === "postgres")
125
- migrateLines.push("&usermodel.AuthToken{},");
168
+ // Recovery tokens are always durable in Postgres, even when refresh tokens
169
+ // live in Redis, so reset/verification can share the user transaction.
170
+ migrateLines.push("&usermodel.AuthToken{},");
171
+ migrateLines.push("&usermodel.MFAEnrollment{},", "&usermodel.MFAChallenge{},", "&usermodel.MFARecoveryCode{},");
126
172
  for (const line of migrateLines) {
127
173
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
128
174
  }
129
- // same two-line shape every generated module uses: a named service, then
130
- // the handler that registers it. `add rbac` extends both lines later, and a
131
- // human wiring another domain into user's service edits line one in place.
132
- const { tokenStore, limiter, mailer } = authWiringLines(w);
133
- const svcLine = `userSvc := user.NewService(user.NewRepository(db), ${tokenStore}, ${mailer}, cfg)`;
134
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, svcLine, "userSvc :=");
135
- const routeLine = `user.NewHandler(userSvc, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, ${limiter}).Register(api)`;
175
+ // Keep auth construction inside the feature package. The root only chooses
176
+ // shared infrastructure and registers the resulting handler.
177
+ const routeLine = authHandlerLineFor(w);
136
178
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, routeLine, routeLine);
137
179
  content = content.replace(/\n\t_ = api \/\/ dropped once `generate module` registers the first route\n/, "\n");
138
180
  if (w.worker) {
139
- const shutdownBlock = ["if err := q.Close(); err != nil {", '\tlogger.Error("close queue", "error", err)', "}"].join("\n");
140
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SHUTDOWN_MARKER, shutdownBlock, "if err := q.Close()");
181
+ const cleanupBlock = [
182
+ "defer func() {",
183
+ '\tif err := q.Close(); err != nil {',
184
+ '\t\tlogger.Error("close queue", "error", err)',
185
+ "\t}",
186
+ "}()",
187
+ ].join("\n");
188
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, cleanupBlock, "defer func() {\n\tif err := q.Close()");
141
189
  }
142
190
  fs_extra_1.default.writeFileSync(mainGoPath, content);
143
191
  }
@@ -153,19 +201,62 @@ function patchMainGoForAuth(mainGoPath, w) {
153
201
  // at all — both simply don't contain the line it looks for. Returns whether
154
202
  // it actually upgraded something, so the caller's printed summary can say
155
203
  // which happened instead of always assuming "no auth yet".
156
- function upgradeMailerToQueue(mainGoPath, goModule, queueBackend) {
204
+ function upgradeMailerToQueue(mainGoPath, goModule, queueBackend, compositionGoPath) {
157
205
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
158
206
  const syncMailer = "mail.NewSyncClient(mail.Open(cfg))";
159
- if (!content.includes(syncMailer))
160
- return false;
161
- const queueImportLine = `"${goModule}/internal/platform/queue"`;
162
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, queueImportLine, queueImportLine);
163
- const queueCtor = queueBackend === "river" ? "queue.NewRiverEnqueuer(db)" : "queue.NewAsynqEnqueuer(cfg.RedisURL)";
164
- const queueInitBlock = [`q, err := ${queueCtor}`, "if err != nil {", '\treturn fmt.Errorf("open queue: %w", err)', "}"].join("\n");
165
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, queueInitBlock, "q, err := queue.New");
166
- const shutdownBlock = ["if err := q.Close(); err != nil {", '\tlogger.Error("close queue", "error", err)', "}"].join("\n");
167
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SHUTDOWN_MARKER, shutdownBlock, "if err := q.Close()");
168
- content = content.replace(syncMailer, () => "mail.NewAsyncClient(q)");
169
- fs_extra_1.default.writeFileSync(mainGoPath, content);
170
- return true;
207
+ let upgradedComposition = false;
208
+ // Auth now keeps mailer construction in internal/app/user. A worker added
209
+ // later must upgrade that feature-local composition and thread q into the
210
+ // existing root registration, rather than moving construction back to the
211
+ // composition root.
212
+ if (compositionGoPath && fs_extra_1.default.existsSync(compositionGoPath)) {
213
+ let composition = fs_extra_1.default.readFileSync(compositionGoPath, "utf8");
214
+ if (composition.includes(syncMailer)) {
215
+ composition = (0, marker_patch_1.ensureImport)(composition, `${goModule}/internal/platform/queue`);
216
+ if (!composition.includes("q queue.Enqueuer")) {
217
+ const configParam = "\tcfg config.Config,\n";
218
+ const redisParam = "\trdb *redis.Client,\n";
219
+ const queueAnchor = composition.includes(redisParam) ? redisParam : configParam;
220
+ if (!composition.includes(queueAnchor)) {
221
+ throw new Error(`${compositionGoPath} is missing the cfg parameter needed to upgrade auth mail to a queue`);
222
+ }
223
+ composition = composition.replace(queueAnchor, `${queueAnchor}\tq queue.Enqueuer,\n`);
224
+ }
225
+ composition = composition.replace(syncMailer, "mail.NewAsyncClient(q)");
226
+ fs_extra_1.default.writeFileSync(compositionGoPath, composition);
227
+ upgradedComposition = true;
228
+ const routeMatch = content.match(/user\.NewHandlerFromDB\(db, cfg, ([^)]*)\)\.Register\(api\)/);
229
+ if (!routeMatch) {
230
+ throw new Error("cmd/api/wiring.go is missing auth's feature-local NewHandlerFromDB route while adding the worker");
231
+ }
232
+ const args = routeMatch[1].split(",").map((arg) => arg.trim());
233
+ if (!args.includes("q")) {
234
+ if (args.length < 2)
235
+ throw new Error("auth's NewHandlerFromDB route has no role/authz dependency slots");
236
+ args.splice(args.length - 2, 0, "q");
237
+ content = content.replace(routeMatch[0], `user.NewHandlerFromDB(db, cfg, ${args.join(", ")}).Register(api)`);
238
+ }
239
+ }
240
+ }
241
+ if (upgradedComposition) {
242
+ const queueImportLine = `"${goModule}/internal/platform/queue"`;
243
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, queueImportLine, queueImportLine);
244
+ const queueCtor = queueBackend === "river" ? "queue.NewRiverEnqueuer(db)" : "queue.NewAsynqEnqueuer(cfg.RedisURL)";
245
+ const queueInitBlock = [`q, err := ${queueCtor}`, "if err != nil {", '\treturn fmt.Errorf("open queue: %w", err)', "}"].join("\n");
246
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, queueInitBlock, "q, err := queue.New");
247
+ const cleanupBlock = [
248
+ "defer func() {",
249
+ '\tif err := q.Close(); err != nil {',
250
+ '\t\tlogger.Error("close queue", "error", err)',
251
+ "\t}",
252
+ "}()",
253
+ ].join("\n");
254
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, cleanupBlock, "defer func() {\n\tif err := q.Close()");
255
+ fs_extra_1.default.writeFileSync(mainGoPath, content);
256
+ return true;
257
+ }
258
+ if (content.includes(syncMailer)) {
259
+ throw new Error("auth mailer is not owned by internal/app/user/composition.go; regenerate auth with the canonical split layout before adding a worker");
260
+ }
261
+ return false;
171
262
  }
@@ -3,18 +3,45 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CONFIG_SCHEMA_VERSION = void 0;
6
7
  exports.configPath = configPath;
8
+ exports.parseModuleSurface = parseModuleSurface;
9
+ exports.parseApplicationStyle = parseApplicationStyle;
10
+ exports.parseModuleProfile = parseModuleProfile;
7
11
  exports.writeConfig = writeConfig;
8
12
  exports.readConfig = readConfig;
9
13
  exports.isProjectDir = isProjectDir;
10
14
  const path_1 = __importDefault(require("path"));
11
15
  const fs_extra_1 = __importDefault(require("fs-extra"));
16
+ const types_1 = require("../types");
12
17
  const CONFIG_FILE = "go-scaffold.config.json";
18
+ exports.CONFIG_SCHEMA_VERSION = 2;
13
19
  function configPath(projectDir) {
14
20
  return path_1.default.join(projectDir, CONFIG_FILE);
15
21
  }
22
+ function parseModuleSurface(value) {
23
+ if (value === undefined)
24
+ return undefined;
25
+ const normalized = value.trim().toLowerCase();
26
+ assertOneOf(normalized, "--module-surface", ["minimal", "crud"]);
27
+ return normalized;
28
+ }
29
+ function parseApplicationStyle(value) {
30
+ if (value === undefined)
31
+ return undefined;
32
+ const normalized = value.trim().toLowerCase();
33
+ assertOneOf(normalized, "--application-style", ["service", "cqrs"]);
34
+ return normalized;
35
+ }
36
+ function parseModuleProfile(value, flag = "--profile") {
37
+ if (value === undefined)
38
+ return undefined;
39
+ const normalized = value.trim().toLowerCase();
40
+ assertOneOf(normalized, flag, ["lean", "crud", "cqrs"]);
41
+ return normalized;
42
+ }
16
43
  function writeConfig(projectDir, config) {
17
- fs_extra_1.default.writeJsonSync(configPath(projectDir), config, { spaces: 2 });
44
+ fs_extra_1.default.writeJsonSync(configPath(projectDir), normalizeProjectConfig(config, projectDir), { spaces: 2 });
18
45
  }
19
46
  // readConfig falls back to detecting from go.mod when the config file is
20
47
  // missing (e.g. a project scaffolded before this file existed).
@@ -35,13 +62,142 @@ function readConfig(projectDir) {
35
62
  if (!fs_extra_1.default.existsSync(file))
36
63
  return detectConfig(projectDir);
37
64
  const config = fs_extra_1.default.readJsonSync(file);
65
+ if (!isRecord(config)) {
66
+ throw new Error(`${CONFIG_FILE} must contain a JSON object`);
67
+ }
38
68
  const detected = detectFeatures(projectDir);
39
- const features = { ...config.features };
69
+ const features = { ...(isRecord(config.features) ? config.features : {}) };
40
70
  for (const key of Object.keys(detected)) {
41
71
  if (features[key] === undefined)
42
72
  features[key] = detected[key];
43
73
  }
44
- return { ...config, features };
74
+ return normalizeProjectConfig({ ...config, features: features }, projectDir);
75
+ }
76
+ /**
77
+ * Validate and fill defaults for the project manifest. Projects that omit the
78
+ * architecture/modules keys can still be resolved safely; an explicit schema
79
+ * from before the split-layout contract is rejected so commands never write a
80
+ * mixed old/new tree.
81
+ */
82
+ function normalizeProjectConfig(raw, projectDir) {
83
+ const projectName = requiredString(raw.projectName, "projectName");
84
+ const goModule = requiredString(raw.goModule, "goModule");
85
+ const apiPrefix = requiredString(raw.apiPrefix ?? "", "apiPrefix");
86
+ const schemaVersion = raw.schemaVersion ?? exports.CONFIG_SCHEMA_VERSION;
87
+ if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
88
+ throw new Error(`${CONFIG_FILE} has invalid schemaVersion "${String(schemaVersion)}" — expected a positive integer`);
89
+ }
90
+ if (schemaVersion < exports.CONFIG_SCHEMA_VERSION) {
91
+ throw new Error(`${CONFIG_FILE} uses legacy schemaVersion ${schemaVersion}; go-scaffold ${exports.CONFIG_SCHEMA_VERSION} requires the hexagonal split layout. ` +
92
+ `Keep using go-scaffold 0.4.x for that project, or migrate its modules to internal/app/<module>/{domain,application,ports,adapters} before upgrading.`);
93
+ }
94
+ if (schemaVersion > exports.CONFIG_SCHEMA_VERSION) {
95
+ throw new Error(`${CONFIG_FILE} uses schemaVersion ${schemaVersion}, but this CLI supports up to ${exports.CONFIG_SCHEMA_VERSION} — upgrade go-scaffold first`);
96
+ }
97
+ const detected = detectFeatures(projectDir);
98
+ const features = { ...(isRecord(raw.features) ? raw.features : {}) };
99
+ for (const key of Object.keys(detected)) {
100
+ if (features[key] === undefined)
101
+ features[key] = detected[key];
102
+ }
103
+ validateFeatures(features);
104
+ const architecture = normalizeArchitecture(raw.architecture);
105
+ const modules = normalizeModules(raw.modules);
106
+ return {
107
+ schemaVersion,
108
+ projectName,
109
+ goModule,
110
+ apiPrefix,
111
+ features: features,
112
+ architecture,
113
+ modules,
114
+ ...(raw.scaffoldVersion ? { scaffoldVersion: raw.scaffoldVersion } : {}),
115
+ };
116
+ }
117
+ function normalizeArchitecture(raw) {
118
+ const value = raw === undefined ? {} : raw;
119
+ if (!isRecord(value)) {
120
+ throw new Error(`${CONFIG_FILE}.architecture must be a JSON object`);
121
+ }
122
+ const architecture = {
123
+ ...types_1.DEFAULT_ARCHITECTURE_CONFIG,
124
+ ...value,
125
+ };
126
+ assertOneOf(architecture.style, "architecture.style", ["modular-monolith"]);
127
+ assertOneOf(architecture.boundary, "architecture.boundary", ["hexagonal"]);
128
+ assertOneOf(architecture.packageLayout, "architecture.packageLayout", ["split"]);
129
+ assertOneOf(architecture.defaultModuleSurface, "architecture.defaultModuleSurface", ["minimal", "crud"]);
130
+ assertOneOf(architecture.defaultApplicationStyle, "architecture.defaultApplicationStyle", ["service", "cqrs"]);
131
+ return {
132
+ style: architecture.style,
133
+ boundary: architecture.boundary,
134
+ packageLayout: architecture.packageLayout,
135
+ defaultModuleSurface: architecture.defaultModuleSurface,
136
+ defaultApplicationStyle: architecture.defaultApplicationStyle,
137
+ };
138
+ }
139
+ function normalizeModules(raw) {
140
+ if (raw === undefined)
141
+ return {};
142
+ if (!isRecord(raw))
143
+ throw new Error(`${CONFIG_FILE}.modules must be a JSON object`);
144
+ const modules = {};
145
+ for (const [name, entry] of Object.entries(raw)) {
146
+ if (!name || name.includes("/") || name.includes("\\")) {
147
+ throw new Error(`${CONFIG_FILE}.modules has invalid module key "${name}" — use the module's Go package name`);
148
+ }
149
+ if (!isRecord(entry))
150
+ throw new Error(`${CONFIG_FILE}.modules.${name} must be a JSON object`);
151
+ const boundary = entry.boundary ?? types_1.DEFAULT_ARCHITECTURE_CONFIG.boundary;
152
+ const packageLayout = entry.packageLayout ?? types_1.DEFAULT_ARCHITECTURE_CONFIG.packageLayout;
153
+ assertOneOf(entry.surface, `modules.${name}.surface`, ["minimal", "crud"]);
154
+ assertOneOf(entry.applicationStyle, `modules.${name}.applicationStyle`, ["service", "cqrs"]);
155
+ assertOneOf(boundary, `modules.${name}.boundary`, ["hexagonal"]);
156
+ assertOneOf(packageLayout, `modules.${name}.packageLayout`, ["split"]);
157
+ modules[name] = {
158
+ surface: entry.surface,
159
+ applicationStyle: entry.applicationStyle,
160
+ boundary: boundary,
161
+ packageLayout: packageLayout,
162
+ };
163
+ }
164
+ return modules;
165
+ }
166
+ function validateFeatures(features) {
167
+ if (typeof features.docker !== "boolean")
168
+ throw new Error(`${CONFIG_FILE}.features.docker must be true or false`);
169
+ if (typeof features.openapiDocs !== "boolean")
170
+ throw new Error(`${CONFIG_FILE}.features.openapiDocs must be true or false`);
171
+ if (features.worker !== undefined && typeof features.worker !== "boolean") {
172
+ throw new Error(`${CONFIG_FILE}.features.worker must be true or false`);
173
+ }
174
+ if (features.queue !== undefined)
175
+ assertOneOf(features.queue, "features.queue", ["river", "asynq"]);
176
+ if (features.auth !== undefined && typeof features.auth !== "boolean") {
177
+ throw new Error(`${CONFIG_FILE}.features.auth must be true or false`);
178
+ }
179
+ if (features.authStore !== undefined)
180
+ assertOneOf(features.authStore, "features.authStore", ["postgres", "redis"]);
181
+ if (features.rbac !== undefined && typeof features.rbac !== "boolean") {
182
+ throw new Error(`${CONFIG_FILE}.features.rbac must be true or false`);
183
+ }
184
+ if (features.observability !== undefined && typeof features.observability !== "boolean") {
185
+ throw new Error(`${CONFIG_FILE}.features.observability must be true or false`);
186
+ }
187
+ }
188
+ function requiredString(value, field) {
189
+ if (typeof value !== "string")
190
+ throw new Error(`${CONFIG_FILE}.${field} must be a string`);
191
+ return value;
192
+ }
193
+ function assertOneOf(value, field, allowed) {
194
+ if (typeof value !== "string" || !allowed.includes(value)) {
195
+ const label = field.startsWith("--") ? field : `${CONFIG_FILE}.${field}`;
196
+ throw new Error(`${label} must be one of: ${allowed.join(", ")} (got "${String(value)}")`);
197
+ }
198
+ }
199
+ function isRecord(value) {
200
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
201
  }
46
202
  // detectFeatures answers "what is actually installed here" from the tree
47
203
  // alone. Every `add` command leaves a directory or a file behind that nothing
@@ -66,7 +222,11 @@ function detectFeatures(projectDir) {
66
222
  // file it wrote — same trick as the queue adapter above. Projects from
67
223
  // before the option existed have neither name and read as "redis",
68
224
  // which is what they in fact are.
69
- authStore: !auth ? undefined : has("internal", "app", "user", "tokenstore_pg.go") ? "postgres" : "redis",
225
+ authStore: !auth
226
+ ? undefined
227
+ : has("internal", "app", "user", "adapters", "outbound", "postgres", "tokenstore_pg.go")
228
+ ? "postgres"
229
+ : "redis",
70
230
  rbac: has("internal", "app", "role") && has("internal", "shared", "middleware", "authz.go"),
71
231
  observability: has("internal", "platform", "telemetry"),
72
232
  };
@@ -107,10 +267,13 @@ function detectConfig(projectDir) {
107
267
  // already exists"), with no way out. Worse, the first `add` to succeed then
108
268
  // wrote a config that recorded the undetected features as absent.
109
269
  return {
270
+ schemaVersion: exports.CONFIG_SCHEMA_VERSION,
110
271
  projectName: path_1.default.basename(projectDir),
111
272
  goModule,
112
273
  apiPrefix,
113
274
  features: detectFeatures(projectDir),
275
+ architecture: { ...types_1.DEFAULT_ARCHITECTURE_CONFIG },
276
+ modules: {},
114
277
  };
115
278
  }
116
279
  // isProjectDir answers "would readConfig succeed here?" without throwing, so
@@ -0,0 +1,68 @@
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.refreshProjectDocs = refreshProjectDocs;
7
+ exports.docsRefreshWarning = docsRefreshWarning;
8
+ const path_1 = __importDefault(require("path"));
9
+ const fs_extra_1 = __importDefault(require("fs-extra"));
10
+ const naming_1 = require("./naming");
11
+ const template_renderer_1 = require("./template-renderer");
12
+ const PROJECT_DOCS = [
13
+ { template: "create/base/README.md.hbs", output: "README.md" },
14
+ { template: "create/features/docs/architecture.md.hbs", output: path_1.default.join("docs", "architect", "architecture.md") },
15
+ { template: "create/features/docs/techstack.md.hbs", output: path_1.default.join("docs", "architect", "techstack.md") },
16
+ ];
17
+ /**
18
+ * Refresh generated project docs after an incremental feature is installed.
19
+ *
20
+ * A generated doc is only safe to rewrite when its bytes still match the
21
+ * version rendered from the project's current config. This keeps feature
22
+ * commands from destroying a maintainer's edits while still preventing the
23
+ * common stale-doc state where config says a feature is enabled but README or
24
+ * architect docs still describe the base skeleton.
25
+ */
26
+ function refreshProjectDocs(projectDir, config, featureOverrides) {
27
+ const beforeFeatures = { ...config.features };
28
+ const afterFeatures = { ...beforeFeatures, ...featureOverrides };
29
+ const beforeContext = {
30
+ projectName: config.projectName,
31
+ dbName: (0, naming_1.toDbName)(config.projectName),
32
+ apiPrefix: config.apiPrefix,
33
+ ...config.architecture,
34
+ ...beforeFeatures,
35
+ };
36
+ const afterContext = {
37
+ projectName: config.projectName,
38
+ dbName: (0, naming_1.toDbName)(config.projectName),
39
+ apiPrefix: config.apiPrefix,
40
+ ...config.architecture,
41
+ ...afterFeatures,
42
+ };
43
+ const root = (0, template_renderer_1.getTemplatesRoot)();
44
+ const skipped = [];
45
+ for (const doc of PROJECT_DOCS) {
46
+ const outputPath = path_1.default.join(projectDir, doc.output);
47
+ if (!fs_extra_1.default.existsSync(outputPath))
48
+ continue;
49
+ const source = fs_extra_1.default.readFileSync(path_1.default.join(root, doc.template), "utf8");
50
+ const onDisk = normalizeLineEndings(fs_extra_1.default.readFileSync(outputPath, "utf8"));
51
+ const expectedBefore = normalizeLineEndings((0, template_renderer_1.renderString)(source, beforeContext));
52
+ if (onDisk !== expectedBefore) {
53
+ skipped.push(doc.output);
54
+ continue;
55
+ }
56
+ fs_extra_1.default.writeFileSync(outputPath, (0, template_renderer_1.renderString)(source, afterContext));
57
+ }
58
+ return skipped;
59
+ }
60
+ function docsRefreshWarning(skipped, feature) {
61
+ if (skipped.length === 0)
62
+ return "";
63
+ const files = skipped.join(", ");
64
+ return `\nwarning: ${files} did not match the generated version, so ${feature} left them untouched — update them by hand`;
65
+ }
66
+ function normalizeLineEndings(text) {
67
+ return text.replace(/\r\n/g, "\n");
68
+ }