@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
package/dist/index.js CHANGED
@@ -20,7 +20,9 @@ const observability_1 = require("./commands/observability");
20
20
  const worker_wizard_1 = require("./prompts/worker-wizard");
21
21
  const auth_wizard_1 = require("./prompts/auth-wizard");
22
22
  const generate_wizard_1 = require("./prompts/generate-wizard");
23
- const config_1 = require("./utils/config");
23
+ const config_1 = require("./commands/config");
24
+ const config_2 = require("./utils/config");
25
+ const module_profile_1 = require("./utils/module-profile");
24
26
  // fail is every command's catch: one place so the two non-obvious cases stay
25
27
  // consistent. @inquirer/prompts throws ExitPromptError on Ctrl-C, and its raw
26
28
  // message ("User force closed the prompt with 0 null") tells a user nothing.
@@ -41,17 +43,20 @@ const program = new commander_1.Command();
41
43
  program
42
44
  .name("go-scaffold")
43
45
  .description("Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard\n\n" +
44
- "Run `go-scaffold` with no arguments to pick what to do from a menu. Every command below also asks for anything you don't pass as a flag.")
46
+ "Run `go-scaffold` with no arguments to pick what to do from a menu. Interactive commands ask for values you omit; read-only commands (`config show`, `config validate`) print or check state without prompts.")
45
47
  .version((0, version_1.cliVersion)());
46
48
  program
47
49
  .command("create [name]")
48
50
  .alias("c")
49
51
  .description("scaffold a new project (bare skeleton — add domains with `generate module`)")
50
- .option("--defaults", "skip the wizard, use defaults (for CI/scripting)")
51
- .option("--no-docker", "skip docker-compose.yml")
52
- .option("--no-openapi-docs", "skip docs/openapi.yaml")
53
- .option("--observability", "add Prometheus /metrics + OpenTelemetry tracing (off by default)")
54
- .option("--api-prefix <prefix>", 'URL prefix every route is grouped under, e.g. v1 or api/v1 (default: none)')
52
+ .option("--defaults", "skip settings prompts; use Docker/OpenAPI on, no prefix, and Lean module defaults (still asks for name if omitted)")
53
+ .option("--no-docker", "do not create docker-compose.yml or include a local Postgres service")
54
+ .option("--no-openapi-docs", "do not create docs/openapi.yaml or per-module OpenAPI files")
55
+ .option("--observability", "include Prometheus /metrics and OpenTelemetry tracing; off unless this flag is passed")
56
+ .option("--api-prefix <prefix>", 'group every API route under a prefix such as "v1" or "api/v1"; omit for no prefix')
57
+ .option("--module-profile <profile>", "default profile for future modules: lean, crud, or cqrs; replaces the two profile questions")
58
+ .option("--module-surface <surface>", "legacy axis flag for future modules: minimal or crud; wizard asks when omitted")
59
+ .option("--application-style <style>", "legacy axis flag for future modules: service or cqrs; wizard asks when omitted")
55
60
  .action(async (name, opts) => {
56
61
  try {
57
62
  await (0, create_1.createProject)(name, {
@@ -60,37 +65,96 @@ program
60
65
  openapiDocs: opts.openapiDocs,
61
66
  observability: opts.observability,
62
67
  apiPrefix: opts.apiPrefix,
68
+ moduleProfile: opts.moduleProfile,
69
+ moduleSurface: opts.moduleSurface,
70
+ applicationStyle: opts.applicationStyle,
63
71
  });
64
72
  }
65
73
  catch (err) {
66
74
  fail(err);
67
75
  }
68
76
  });
69
- // runModuleWizard resolves everything `generate module` needs, asking for
70
- // whatever wasn't passed as a flag. Shared by the bare menu and by
71
- // `generate module` itself, so both offer the same choices — the flags exist
72
- // for scripting, not as the only way to reach a decision.
73
- //
74
- // --defaults is the escape hatch CI and scripts use: it takes the documented
75
- // defaults (minimal, no auth) and asks nothing.
77
+ function assertModuleWizardInputs(name, opts, config) {
78
+ if (process.stdin.isTTY)
79
+ return;
80
+ const missing = [];
81
+ if (name === undefined)
82
+ missing.push("<name>");
83
+ // --defaults answers every optional wizard question. The name is still
84
+ // required because there is no safe module name to invent.
85
+ if (!opts.defaults) {
86
+ if (opts.profile === undefined && opts.full === undefined)
87
+ missing.push("--profile or --full/--no-full");
88
+ if (opts.profile === undefined && opts.cqrs === undefined)
89
+ missing.push("--profile or --cqrs");
90
+ if (config.features.auth && opts.auth === undefined) {
91
+ missing.push("--auth (or --defaults for no auth)");
92
+ }
93
+ if (opts.auth && config.features.rbac && opts.permission === undefined) {
94
+ missing.push("--permission <code> (or --defaults for no permission)");
95
+ }
96
+ }
97
+ if (missing.length > 0) {
98
+ throw new Error(`no interactive terminal to prompt on — \`generate module\` is missing: ${missing.join(", ")}. ` +
99
+ "Pass the missing choices as flags, or add --defaults.");
100
+ }
101
+ }
76
102
  async function runModuleWizard(name, opts) {
77
- let { full, auth, permission } = opts;
103
+ let { full, cqrs, auth, permission } = opts;
104
+ // Read before checking the prompt contract so the error can name the
105
+ // feature-dependent auth/RBAC choices, and so a non-project still fails
106
+ // with the useful project error rather than a list of flags.
107
+ const config = (0, config_2.readConfig)(process.cwd());
108
+ assertModuleWizardInputs(name, opts, config);
109
+ if (opts.profile) {
110
+ const architecture = (0, module_profile_1.architectureForModuleProfile)(opts.profile);
111
+ full = architecture.moduleSurface === "crud";
112
+ cqrs = architecture.applicationStyle === "cqrs";
113
+ }
78
114
  if (!opts.defaults) {
79
- // read first, so "not a go-scaffold project" fails before we ask anything,
80
- // and so the auth/permission questions are only asked when the project
81
- // actually has the features they depend on — offering them otherwise
82
- // would present a choice whose only outcome is generateModule's error.
83
- const config = (0, config_1.readConfig)(process.cwd());
115
+ // auth/permission questions are only asked when the project actually has
116
+ // the features they depend on offering them otherwise would present a
117
+ // choice whose only outcome is generateModule's error.
84
118
  if (name === undefined)
85
119
  name = await (0, generate_wizard_1.promptModuleName)();
86
- if (full === undefined)
87
- full = await (0, generate_wizard_1.promptModuleShape)();
120
+ if (opts.profile === undefined && full === undefined && cqrs === undefined) {
121
+ const profile = await (0, generate_wizard_1.promptModuleProfile)((0, module_profile_1.moduleProfileFor)(config.architecture.defaultModuleSurface, config.architecture.defaultApplicationStyle));
122
+ if (profile === "advanced") {
123
+ const architecture = await (0, generate_wizard_1.promptAdvancedModuleArchitecture)(config.architecture.defaultModuleSurface, config.architecture.defaultApplicationStyle);
124
+ full = architecture.moduleSurface === "crud";
125
+ cqrs = architecture.applicationStyle === "cqrs";
126
+ }
127
+ else {
128
+ const architecture = (0, module_profile_1.architectureForModuleProfile)(profile);
129
+ full = architecture.moduleSurface === "crud";
130
+ cqrs = architecture.applicationStyle === "cqrs";
131
+ }
132
+ }
133
+ else {
134
+ // A legacy axis flag is still an explicit answer. Ask only for the
135
+ // other axis so `--full` never gets silently changed by the profile
136
+ // selector.
137
+ if (full === undefined) {
138
+ full = (await (0, generate_wizard_1.promptModuleSurface)(config.architecture.defaultModuleSurface)) === "crud";
139
+ }
140
+ if (cqrs === undefined) {
141
+ cqrs = (await (0, generate_wizard_1.promptApplicationStyle)(config.architecture.defaultApplicationStyle)) === "cqrs";
142
+ }
143
+ }
88
144
  if (auth === undefined && config.features.auth)
89
145
  auth = await (0, generate_wizard_1.promptModuleAuth)();
90
146
  if (auth && permission === undefined && config.features.rbac)
91
147
  permission = await (0, generate_wizard_1.promptModulePermission)();
92
148
  }
93
- await (0, generate_1.generateModule)(name, { full: full ?? false, auth, permission });
149
+ else {
150
+ // --defaults means "use this project's recorded defaults". Fresh projects
151
+ // still resolve to the historical minimal/service behaviour, while a
152
+ // project config wizard can intentionally choose another default for new
153
+ // modules without making CI scripts interactive.
154
+ full ??= config.architecture.defaultModuleSurface === "crud";
155
+ cqrs ??= config.architecture.defaultApplicationStyle === "cqrs";
156
+ }
157
+ await (0, generate_1.generateModule)(name, { full: full ?? false, cqrs: cqrs ?? false, auth, permission });
94
158
  }
95
159
  // runGenerateWizard is `generate`/`g` run bare — asks which target, then
96
160
  // delegates (each subcommand still prompts for anything else it's missing,
@@ -101,7 +165,7 @@ async function runGenerateWizard() {
101
165
  const target = await (0, interactive_1.select)({
102
166
  message: "What do you want to generate?",
103
167
  choices: [
104
- { name: "Module (safe minimal domain; add methods explicitly)", value: "module" },
168
+ { name: "Module (choose Lean / CRUD / CQRS profile)", value: "module" },
105
169
  { name: "Method (add one endpoint to an existing module)", value: "method" },
106
170
  { name: "Migration (reserve a timestamped up/down SQL file pair)", value: "migration" },
107
171
  ],
@@ -119,7 +183,7 @@ async function runGenerateWizard() {
119
183
  const generate = program
120
184
  .command("generate")
121
185
  .alias("g")
122
- .description("add to an existing go-scaffold project")
186
+ .description("add a module, endpoint, or migration to an existing project; bare `generate` opens a target wizard")
123
187
  .action(async () => {
124
188
  try {
125
189
  await runGenerateWizard();
@@ -131,21 +195,29 @@ const generate = program
131
195
  generate
132
196
  .command("module [name]")
133
197
  .alias("m")
134
- .description("scaffold a safe minimal domain module; opt into a CRUD skeleton with --full")
135
- .option("--full", "generate a CRUD skeleton (DTO fields/business rules remain TODO); minimal is the safe default")
198
+ .description("scaffold a domain module; the wizard asks for a Lean, CRUD, CQRS, or Advanced profile")
199
+ .option("--profile <profile>", "choose the architecture preset: lean (minimal + service), crud (CRUD + service), or cqrs (minimal + CQRS); auth may still prompt")
200
+ .option("--full", "legacy alias for the CRUD profile: generate list/get/create/update/delete skeletons; fields and rules remain TODO")
201
+ .option("--cqrs", "legacy axis flag: split state-changing commands from read-only queries; combine with --full for CRUD + CQRS")
136
202
  // kept working for muscle memory, hidden from help: minimal is already the
137
203
  // default, so advertising a flag that asks for it is pure noise.
138
204
  .addOption(new commander_1.Option("--no-full", "deprecated compatibility alias; minimal is already the default").hideHelp())
139
- .option("--auth", "require a valid access token for this module's routes (needs `add auth`)")
140
- .option("--permission <code>", "also require this permission via authz.Require (needs `add rbac`; pass --auth too)")
141
- .option("--defaults", "skip the prompts, use the defaults (minimal, no auth) for CI/scripting")
205
+ .option("--auth", "require a valid access token for this module's routes (needs `add auth`; omitted means public)")
206
+ .option("--permission <code>", "also require this permission via authz.Require (needs `add rbac` and --auth; e.g. users:manage)")
207
+ .option("--defaults", "skip every wizard question; use recorded project defaults and keep the module public (for CI/scripting)")
142
208
  .action(async (name, opts) => {
143
209
  try {
210
+ const profile = (0, config_2.parseModuleProfile)(opts.profile);
211
+ if (profile && (opts.full !== undefined || opts.cqrs !== undefined)) {
212
+ throw new Error("--profile cannot be combined with --full, --no-full, or --cqrs — choose one configuration style");
213
+ }
144
214
  // anything not passed as a flag gets asked for — declaring both --full
145
215
  // and --no-full leaves opts.full undefined when neither is given, which
146
216
  // is exactly the "not answered yet" signal runModuleWizard needs.
147
217
  await runModuleWizard(name, {
218
+ profile,
148
219
  full: opts.full,
220
+ cqrs: opts.cqrs,
149
221
  auth: opts.auth,
150
222
  permission: opts.permission,
151
223
  defaults: opts.defaults,
@@ -155,13 +227,46 @@ generate
155
227
  fail(err);
156
228
  }
157
229
  });
230
+ const configCommand = program
231
+ .command("config")
232
+ .description("configure future module defaults interactively, or inspect/validate the project config")
233
+ .action(async () => {
234
+ try {
235
+ await (0, config_1.configureProject)();
236
+ }
237
+ catch (err) {
238
+ fail(err);
239
+ }
240
+ });
241
+ configCommand
242
+ .command("show")
243
+ .description("print the resolved project config, including installed features and module choices; no wizard")
244
+ .action(() => {
245
+ try {
246
+ (0, config_1.showProjectConfig)();
247
+ }
248
+ catch (err) {
249
+ fail(err);
250
+ }
251
+ });
252
+ configCommand
253
+ .command("validate")
254
+ .description("validate project config and detected scaffold state without changing files; no wizard")
255
+ .action(() => {
256
+ try {
257
+ (0, config_1.validateProjectConfig)();
258
+ }
259
+ catch (err) {
260
+ fail(err);
261
+ }
262
+ });
158
263
  generate
159
264
  .command("method [module] [name]")
160
265
  .alias("me")
161
- .description("add one endpoint to an existing module (patches handler/service in place)")
162
- .option("--type <type>", "get|post|put|patch|delete")
163
- .option("--get-mode <mode>", "for --type get only: all|one")
164
- .option("--field <name>", "for --type get --get-mode one: the lookup field (e.g. email, status)")
266
+ .description("add one endpoint to an existing module; omitted module, name, and endpoint details are asked by the wizard")
267
+ .option("--type <type>", "HTTP verb: get, post, put, patch, or delete; omitted means the wizard asks")
268
+ .option("--get-mode <mode>", "GET only: all for a list endpoint or one for a lookup by another field")
269
+ .option("--field <name>", "GET one only: lookup column such as email/status/slug; id is already the built-in lookup")
165
270
  .action(async (moduleName, methodName, opts) => {
166
271
  try {
167
272
  const type = opts.type;
@@ -185,7 +290,7 @@ generate
185
290
  generate
186
291
  .command("migration [name]")
187
292
  .alias("mig")
188
- .description("reserve a timestamped migrations/<version>_<name>.{up,down}.sql pair (stubs only you write the SQL)")
293
+ .description("reserve a timestamped up/down SQL pair; omitted name opens a migration-name wizard and the SQL remains your responsibility")
189
294
  .action(async (name) => {
190
295
  try {
191
296
  await (0, migration_1.generateMigration)(name);
@@ -224,7 +329,7 @@ async function runAddWizard() {
224
329
  // read once, up front: every add command reads it anyway, the summary below
225
330
  // needs to know what's already installed (e.g. whether auth's mail goes out
226
331
  // inline or through an existing queue), and so does the menu itself.
227
- const config = (0, config_1.readConfig)(process.cwd());
332
+ const config = (0, config_2.readConfig)(process.cwd());
228
333
  // Each add is once-only, and rbac needs auth first. Both facts are already
229
334
  // known here, so say them in the menu rather than letting someone walk three
230
335
  // steps and a confirmation to reach "already been added".
@@ -265,7 +370,7 @@ async function runAddWizard() {
265
370
  // all), so the menu has to ask it the same way the worker menu asks for
266
371
  // its queue backend — a choice only reachable by knowing the flag name
267
372
  // isn't a choice for anyone driving this from the menu.
268
- await runAddAuth(await (0, auth_wizard_1.promptAuthStore)(), {});
373
+ await runAddAuth(await (0, auth_wizard_1.promptAuthStore)(), await (0, auth_wizard_1.promptBrowserTopology)(), {});
269
374
  }
270
375
  else if (target === "rbac") {
271
376
  await runAddRbac({});
@@ -283,21 +388,33 @@ async function runAddWorker(backend, opts) {
283
388
  ], opts);
284
389
  await (0, worker_1.addWorker)(backend);
285
390
  }
286
- async function runAddAuth(store, opts) {
287
- const config = (0, config_1.readConfig)(process.cwd());
391
+ async function runAddAuth(store, browserTopology, opts) {
392
+ const config = (0, config_2.readConfig)(process.cwd());
288
393
  await confirmAdd([
289
394
  "add internal/app/user/, internal/shared/middleware/auth.go, and cmd/seed",
395
+ `browser OAuth: frontend-owned callback with server-side provider redirect URI (${browserTopology})`,
290
396
  store === "postgres"
291
- ? "tokens + rate-limit counters: Postgres (user_svc.auth_tokens), in-process — no extra service"
292
- : picocolors_1.default.yellow("tokens + rate-limit counters: Redis — requires a Redis server to be running"),
397
+ ? "refresh + recovery tokens: Postgres (user_svc.auth_tokens), rate-limit counters in-process — no extra service"
398
+ : picocolors_1.default.yellow("refresh tokens + rate-limit counters: Redis; recovery tokens: Postgres — requires Redis"),
293
399
  config.features.worker
294
400
  ? "verification/reset mail: queued through the worker already installed"
295
401
  : picocolors_1.default.yellow("verification/reset mail: sent inline over SMTP (no worker yet) — /auth/register and /auth/forgot-password block until it's sent"),
296
402
  ], opts);
297
- await (0, auth_1.addAuth)(store);
403
+ await (0, auth_1.addAuth)(store, process.cwd(), browserTopology);
404
+ }
405
+ // Explicit browser flags are intentionally resolved before the confirmation
406
+ // summary. `--defaults` is the stable non-TTY escape hatch; `--yes` with no
407
+ // browser flags also uses the local default so existing CI invocations such as
408
+ // `add auth --store postgres --yes` do not unexpectedly start prompting.
409
+ async function resolveBrowserTopology(opts) {
410
+ if (opts.browserTopology !== undefined)
411
+ return (0, auth_wizard_1.validateBrowserTopology)(opts.browserTopology);
412
+ if (opts.defaults || opts.yes)
413
+ return auth_wizard_1.DEFAULT_BROWSER_TOPOLOGY;
414
+ return (0, auth_wizard_1.promptBrowserTopology)();
298
415
  }
299
416
  async function runAddRbac(opts) {
300
- const config = (0, config_1.readConfig)(process.cwd());
417
+ const config = (0, config_2.readConfig)(process.cwd());
301
418
  if (!config.features.auth) {
302
419
  throw new Error("`add rbac` requires `add auth` first — there's no Role claim to check permissions against otherwise");
303
420
  }
@@ -305,7 +422,7 @@ async function runAddRbac(opts) {
305
422
  await (0, rbac_1.addRbac)();
306
423
  }
307
424
  async function runAddObservability(opts) {
308
- const config = (0, config_1.readConfig)(process.cwd());
425
+ const config = (0, config_2.readConfig)(process.cwd());
309
426
  await confirmAdd([
310
427
  "add Prometheus /metrics + OpenTelemetry tracing",
311
428
  "patch cmd/api/wiring.go and internal/platform/database to wire it in — cmd/api only",
@@ -317,7 +434,7 @@ async function runAddObservability(opts) {
317
434
  }
318
435
  const add = program
319
436
  .command("add")
320
- .description("add opt-in infrastructure to an existing go-scaffold project")
437
+ .description("add opt-in infrastructure to an existing project; bare `add` opens a worker/auth/RBAC/observability wizard")
321
438
  .action(async () => {
322
439
  try {
323
440
  await runAddWizard();
@@ -330,8 +447,8 @@ add
330
447
  .command("worker")
331
448
  .description("add a background job queue, SMTP mail, and cmd/worker (opt-in — most projects don't need this on day one)")
332
449
  .option("--queue <backend>", "where jobs are stored: postgres (River, default) or redis (Asynq)")
333
- .option("--defaults", "skip the prompt, use the Postgres-backed queue")
334
- .option("-y, --yes", "skip the confirmation summary")
450
+ .option("--defaults", "skip queue and confirmation prompts; use Postgres/River")
451
+ .option("-y, --yes", "skip only the confirmation summary; an omitted queue still opens the queue wizard")
335
452
  .action(async (opts) => {
336
453
  try {
337
454
  await runAddWorker(await resolveQueueBackend(opts), { yes: opts.yes || opts.defaults });
@@ -367,8 +484,9 @@ add
367
484
  .command("auth")
368
485
  .description("add email/password auth: JWT access tokens, refresh token rotation, register/login/refresh/logout/me (no prerequisites — without `add worker` the verification/reset mail is sent inline)")
369
486
  .option("--store <store>", 'where tokens and rate-limit counters live: "postgres" (default, no extra service) or "redis" (exact across replicas)')
370
- .option("--defaults", "skip the prompt, use the Postgres-backed store (for CI/scripting)")
371
- .option("-y, --yes", "skip the confirmation summary")
487
+ .option("--browser-topology <topology>", "browser deployment topology for cookie/CORS policy: same-origin, same-site (different origin), or cross-site (requires HTTPS deployment)")
488
+ .option("--defaults", "skip store, browser-topology, and confirmation prompts; use Postgres plus local same-site defaults")
489
+ .option("-y, --yes", "skip confirmation; omitted store/topology use local Postgres and same-site defaults")
372
490
  .action(async (opts) => {
373
491
  try {
374
492
  // Same shape as `add worker`: an explicit flag wins, --defaults takes
@@ -388,7 +506,8 @@ add
388
506
  else {
389
507
  store = await (0, auth_wizard_1.promptAuthStore)();
390
508
  }
391
- await runAddAuth(store, { yes: opts.yes || opts.defaults });
509
+ const browserTopology = await resolveBrowserTopology(opts);
510
+ await runAddAuth(store, browserTopology, { yes: opts.yes || opts.defaults });
392
511
  }
393
512
  catch (err) {
394
513
  fail(err);
@@ -396,8 +515,8 @@ add
396
515
  });
397
516
  add
398
517
  .command("rbac")
399
- .description("add role-based access control: roles/permissions admin API, cached Authz middleware, PATCH /users/:id/set-role (requires `add auth` first)")
400
- .option("-y, --yes", "skip the confirmation summary")
518
+ .description("add role-based access control: roles/permissions admin API, cached Authz middleware, and PATCH /users/:id/set-role (requires `add auth`; direct command only needs confirmation)")
519
+ .option("-y, --yes", "skip the confirmation summary; the bare `add` wizard can select this target")
401
520
  .action(async (opts) => {
402
521
  try {
403
522
  await runAddRbac({ yes: opts.yes });
@@ -408,8 +527,8 @@ add
408
527
  });
409
528
  add
410
529
  .command("observability")
411
- .description("add Prometheus /metrics + OpenTelemetry tracing for Gin + GORM (also available at `create` time via --observability)")
412
- .option("-y, --yes", "skip the confirmation summary")
530
+ .description("add Prometheus /metrics + OpenTelemetry tracing for Gin + GORM (also available at create time via --observability; direct command only needs confirmation)")
531
+ .option("-y, --yes", "skip the confirmation summary; the bare `add` wizard can select this target")
413
532
  .action(async (opts) => {
414
533
  try {
415
534
  await runAddObservability({ yes: opts.yes });
@@ -420,7 +539,7 @@ add
420
539
  });
421
540
  const undo = program
422
541
  .command("undo")
423
- .description("undo a `generate module` you didn't mean to run (typo'd name, domain you decided against)")
542
+ .description("undo a generated module and its wiring; bare `undo` opens a module selector and confirmation")
424
543
  .action(async () => {
425
544
  // bare `undo` — module is the only target, so prompt for the name
426
545
  try {
@@ -433,8 +552,8 @@ const undo = program
433
552
  undo
434
553
  .command("module [name]")
435
554
  .alias("m")
436
- .description("delete a generated module and everything it wired up, migration files included")
437
- .option("-y, --yes", "skip the confirmation prompt")
555
+ .description("delete a generated module, its owned migrations/docs, and the wiring it added; omitted name opens a selector")
556
+ .option("-y, --yes", "skip the destructive confirmation prompt (use only after reviewing the target)")
438
557
  .action(async (name, opts) => {
439
558
  try {
440
559
  await (0, undo_1.undoModule)(name, { yes: opts.yes });
@@ -449,10 +568,14 @@ undo
449
568
  // ran on every database created from then on. Kept as an alias rather than
450
569
  // deleted outright: a muscle-memory `rm m` shouldn't be an unrecognised-command
451
570
  // error, and the semantics only got safer.
452
- const remove = program.command("remove", { hidden: true }).alias("rm");
571
+ const remove = program
572
+ .command("remove", { hidden: true })
573
+ .alias("rm")
574
+ .description("deprecated hidden alias for undo; kept for backwards compatibility");
453
575
  remove
454
576
  .command("module [name]")
455
577
  .alias("m")
578
+ .description("deprecated hidden alias for `undo module`; kept for backwards compatibility")
456
579
  .option("-y, --yes", "skip the confirmation prompt")
457
580
  .action(async (name, opts) => {
458
581
  console.error(picocolors_1.default.yellow("`remove module` is now `undo module` — running that instead."));
@@ -478,13 +601,14 @@ async function runTopMenu() {
478
601
  // Every entry but "create" needs a project. Offering them outside one buys
479
602
  // two selects and then the same error — so say it once, up front, and only
480
603
  // offer what can actually run here.
481
- const inProject = (0, config_1.isProjectDir)(process.cwd());
604
+ const inProject = (0, config_2.isProjectDir)(process.cwd());
482
605
  const target = inProject
483
606
  ? await (0, interactive_1.select)({
484
607
  message: "What do you want to do?",
485
608
  choices: [
486
609
  { name: "Create a new project", value: "create" },
487
610
  { name: "Generate (module/method/migration in an existing project)", value: "generate" },
611
+ { name: "Configure project generation defaults", value: "config" },
488
612
  { name: "Add (auth/worker/rbac/observability in an existing project)", value: "add" },
489
613
  { name: "Undo a generated module", value: "undo" },
490
614
  ],
@@ -496,6 +620,9 @@ async function runTopMenu() {
496
620
  else if (target === "generate") {
497
621
  await runGenerateWizard();
498
622
  }
623
+ else if (target === "config") {
624
+ await (0, config_1.configureProject)();
625
+ }
499
626
  else if (target === "add") {
500
627
  await runAddWizard();
501
628
  }
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_BROWSER_TOPOLOGY = void 0;
3
4
  exports.promptAuthStore = promptAuthStore;
5
+ exports.validateBrowserTopology = validateBrowserTopology;
6
+ exports.promptBrowserTopology = promptBrowserTopology;
4
7
  const interactive_1 = require("./interactive");
5
- // The one decision `add auth` cannot make for you: where refresh tokens,
6
- // one-time tokens and rate-limit counters live. It drives which tokenStore
7
- // implementation is written, whether Redis is added to the project at all,
8
- // and whether the auth_tokens table exists.
8
+ // The one decision `add auth` cannot make for you: where refresh tokens and
9
+ // rate-limit counters live. Recovery tokens always use the durable Postgres
10
+ // table so consumption can share a transaction with the user update.
9
11
  //
10
12
  // Mirrors promptQueueBackend: the choice exists as `--store` for scripting,
11
13
  // but nobody should have to know the flag name to discover the option.
@@ -17,13 +19,45 @@ async function promptAuthStore() {
17
19
  {
18
20
  name: "Postgres (user_svc.auth_tokens)",
19
21
  value: "postgres",
20
- description: "no extra service to run; tokens are rows in your own database, and the rate limiter counts in-process",
22
+ description: "no extra service to run; refresh and recovery tokens use your database, and the rate limiter counts in-process",
21
23
  },
22
24
  {
23
25
  name: "Redis",
24
26
  value: "redis",
25
- description: "TTL expiry and rate-limit counters are exact across replicas; needs a Redis server",
27
+ description: "refresh TTL and rate-limit counters are exact across replicas; recovery stays transactional in Postgres; needs Redis",
26
28
  },
27
29
  ],
28
30
  });
29
31
  }
32
+ exports.DEFAULT_BROWSER_TOPOLOGY = "same-site";
33
+ const TOPOLOGIES = [
34
+ {
35
+ name: "Same-origin",
36
+ value: "same-origin",
37
+ description: "frontend and API share scheme, host, and port; simplest cookie boundary",
38
+ },
39
+ {
40
+ name: "Same-site, different-origin",
41
+ value: "same-site",
42
+ description: "for example localhost:3000 + localhost:8080 or app.example.com + api.example.com",
43
+ },
44
+ {
45
+ name: "Cross-site",
46
+ value: "cross-site",
47
+ description: "different registrable sites; requires SameSite=None, Secure cookies, HTTPS, and exact CORS",
48
+ },
49
+ ];
50
+ function validateBrowserTopology(raw) {
51
+ const value = raw.trim().toLowerCase();
52
+ if (!TOPOLOGIES.some((topology) => topology.value === value)) {
53
+ throw new Error(`Browser topology must be one of: ${TOPOLOGIES.map((topology) => topology.value).join(", ")} (got "${raw}")`);
54
+ }
55
+ return value;
56
+ }
57
+ async function promptBrowserTopology() {
58
+ return (0, interactive_1.select)({
59
+ message: "How are the browser frontend and API deployed?",
60
+ default: exports.DEFAULT_BROWSER_TOPOLOGY,
61
+ choices: TOPOLOGIES,
62
+ });
63
+ }
@@ -4,6 +4,9 @@ exports.promptProjectName = promptProjectName;
4
4
  exports.runCreateWizard = runCreateWizard;
5
5
  const interactive_1 = require("./interactive");
6
6
  const naming_1 = require("../utils/naming");
7
+ const module_profile_1 = require("../utils/module-profile");
8
+ const types_1 = require("../types");
9
+ const generate_wizard_1 = require("./generate-wizard");
7
10
  async function promptProjectName() {
8
11
  const name = await (0, interactive_1.input)({
9
12
  message: "Project name:",
@@ -28,7 +31,7 @@ async function runCreateWizard(preset = {}) {
28
31
  }));
29
32
  const openapiDocs = preset.openapiDocs ??
30
33
  (await (0, interactive_1.confirm)({
31
- message: "Include hand-written OpenAPI docs (docs/openapi.yaml, whole docs/ tree served at /docs)?",
34
+ message: "Include hand-written OpenAPI docs (docs/openapi.yaml files only, never served over HTTP)?",
32
35
  default: true,
33
36
  }));
34
37
  const observability = preset.observability ??
@@ -49,6 +52,31 @@ async function runCreateWizard(preset = {}) {
49
52
  if (prefixCheck !== true)
50
53
  throw new Error(prefixCheck);
51
54
  const apiPrefix = (0, naming_1.normalizeApiPrefix)(apiPrefixRaw);
55
+ let moduleSurface;
56
+ let applicationStyle;
57
+ if (preset.moduleProfile) {
58
+ ({ moduleSurface, applicationStyle } = (0, generate_wizard_1.moduleArchitectureForProfile)(preset.moduleProfile));
59
+ }
60
+ else if (preset.moduleSurface !== undefined || preset.applicationStyle !== undefined) {
61
+ // Keep the two old axis flags useful for partial scripted invocations. If
62
+ // only one was supplied, ask only for the other one instead of silently
63
+ // overriding the explicit flag with a profile choice.
64
+ moduleSurface =
65
+ preset.moduleSurface ??
66
+ (await (0, generate_wizard_1.promptModuleSurface)(types_1.DEFAULT_ARCHITECTURE_CONFIG.defaultModuleSurface));
67
+ applicationStyle =
68
+ preset.applicationStyle ??
69
+ (await (0, generate_wizard_1.promptApplicationStyle)(types_1.DEFAULT_ARCHITECTURE_CONFIG.defaultApplicationStyle));
70
+ }
71
+ else {
72
+ const profile = await (0, generate_wizard_1.promptModuleProfile)((0, module_profile_1.moduleProfileFor)(types_1.DEFAULT_ARCHITECTURE_CONFIG.defaultModuleSurface, types_1.DEFAULT_ARCHITECTURE_CONFIG.defaultApplicationStyle));
73
+ if (profile === "advanced") {
74
+ ({ moduleSurface, applicationStyle } = await (0, generate_wizard_1.promptAdvancedModuleArchitecture)());
75
+ }
76
+ else {
77
+ ({ moduleSurface, applicationStyle } = (0, generate_wizard_1.moduleArchitectureForProfile)(profile));
78
+ }
79
+ }
52
80
  // Everything is listed whether it was asked or passed, so a flag never
53
81
  // reaches the project without the caller seeing the value it produced.
54
82
  const fromFlag = (passed) => (passed !== undefined ? " (from flag)" : "");
@@ -57,9 +85,22 @@ async function runCreateWizard(preset = {}) {
57
85
  console.log(` OpenAPI docs: ${openapiDocs ? "yes" : "no"}${fromFlag(preset.openapiDocs)}`);
58
86
  console.log(` Metrics + tracing: ${observability ? "yes" : "no"}${fromFlag(preset.observability)}`);
59
87
  console.log(` Route prefix: ${apiPrefix ? `/${apiPrefix}` : "(none)"}${fromFlag(preset.apiPrefix)}`);
88
+ const profile = (0, generate_wizard_1.moduleProfileDescription)(moduleSurface, applicationStyle);
89
+ const profileSource = preset.moduleProfile !== undefined ? " (from --module-profile)" : "";
90
+ console.log(` Default module profile: ${profile}${profileSource}`);
91
+ console.log(` Default module surface: ${moduleSurface}${fromFlag(preset.moduleSurface)}`);
92
+ console.log(` Default application style: ${applicationStyle}${fromFlag(preset.applicationStyle)}`);
60
93
  const proceed = await (0, interactive_1.confirm)({ message: "\nCreate project with these settings?", default: true });
61
94
  if (!proceed) {
62
95
  throw new Error("project creation cancelled");
63
96
  }
64
- return { features: { docker, openapiDocs, observability }, apiPrefix };
97
+ return {
98
+ features: { docker, openapiDocs, observability },
99
+ apiPrefix,
100
+ architecture: {
101
+ ...types_1.DEFAULT_ARCHITECTURE_CONFIG,
102
+ defaultModuleSurface: moduleSurface,
103
+ defaultApplicationStyle: applicationStyle,
104
+ },
105
+ };
65
106
  }