@nakedev/go-scaffold 0.1.3 → 0.3.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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -50
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +23 -12
  6. package/dist/commands/method.js +94 -17
  7. package/dist/commands/observability.js +114 -0
  8. package/dist/commands/rbac.js +19 -2
  9. package/dist/commands/undo.js +331 -0
  10. package/dist/commands/worker.js +92 -32
  11. package/dist/index.js +368 -64
  12. package/dist/prompts/auth-wizard.js +29 -0
  13. package/dist/prompts/create-wizard.js +5 -2
  14. package/dist/prompts/generate-wizard.js +57 -0
  15. package/dist/prompts/worker-wizard.js +25 -0
  16. package/dist/templates/auth-manifest.js +20 -3
  17. package/dist/templates/create-manifest.js +13 -20
  18. package/dist/templates/module-manifest.js +2 -0
  19. package/dist/templates/observability-manifest.js +24 -0
  20. package/dist/templates/rbac-manifest.js +1 -0
  21. package/dist/templates/worker-manifest.js +23 -6
  22. package/dist/utils/auth-patcher.js +96 -21
  23. package/dist/utils/config.js +58 -10
  24. package/dist/utils/gocheck.js +57 -5
  25. package/dist/utils/golangci-patcher.js +73 -0
  26. package/dist/utils/gomod-patcher.js +53 -0
  27. package/dist/utils/main-patcher.js +58 -4
  28. package/dist/utils/marker-patch.js +125 -3
  29. package/dist/utils/method-patcher.js +97 -18
  30. package/dist/utils/module-location.js +58 -0
  31. package/dist/utils/naming.js +125 -14
  32. package/dist/utils/observability-patcher.js +107 -0
  33. package/dist/utils/openapi-patcher.js +19 -1
  34. package/dist/utils/platform-patcher.js +98 -12
  35. package/dist/utils/rbac-patcher.js +60 -10
  36. package/dist/utils/smoke-run.js +31 -0
  37. package/package.json +11 -4
  38. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  39. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  40. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  41. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  42. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  43. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  44. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  45. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  46. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  50. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  51. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  52. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  53. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  54. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  55. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  56. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  57. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  58. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  59. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  60. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  61. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  62. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  63. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  64. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  65. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  66. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  67. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  68. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  69. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  70. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  71. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  72. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  73. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  74. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  75. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  76. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  77. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  78. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  79. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  80. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  81. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  82. package/templates/create/base/.dockerignore.hbs +13 -0
  83. package/templates/create/base/.env.example.hbs +18 -9
  84. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  85. package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
  86. package/templates/create/base/.golangci.yml.hbs +27 -0
  87. package/templates/create/base/AGENTS.md.hbs +14 -12
  88. package/templates/create/base/Dockerfile.hbs +42 -0
  89. package/templates/create/base/Makefile.hbs +52 -16
  90. package/templates/create/base/README.md.hbs +61 -12
  91. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  92. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  93. package/templates/create/base/go.mod.hbs +4 -4
  94. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  95. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  96. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  97. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  98. package/templates/create/base/redocly.yaml.hbs +21 -0
  99. package/templates/create/features/docs/architecture.md.hbs +32 -11
  100. package/templates/create/features/docs/openapi.yaml.hbs +4 -9
  101. package/templates/create/features/docs/patterns.md.hbs +91 -14
  102. package/templates/create/features/docs/techstack.md.hbs +8 -3
  103. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  104. package/templates/generate/module/dto.go.hbs +8 -1
  105. package/templates/generate/module/errors.go.hbs +5 -0
  106. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  107. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  108. package/templates/generate/module/handler.go.hbs +18 -4
  109. package/templates/generate/module/handler_test.go.hbs +88 -62
  110. package/templates/generate/module/migration.down.sql.hbs +3 -1
  111. package/templates/generate/module/migration.up.sql.hbs +7 -2
  112. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  113. package/templates/generate/module/minimal/handler.go.hbs +8 -2
  114. package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
  115. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  116. package/templates/generate/module/model/model.go.hbs +16 -0
  117. package/templates/generate/module/permission.up.sql.hbs +3 -1
  118. package/templates/generate/module/repository.go.hbs +60 -6
  119. package/templates/generate/module/repository_test.go.hbs +109 -0
  120. package/templates/generate/module/service.go.hbs +15 -4
  121. package/templates/generate/module/service_test.go.hbs +113 -17
  122. package/dist/commands/remove.js +0 -89
  123. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  124. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
package/dist/index.js CHANGED
@@ -11,15 +11,38 @@ const create_1 = require("./commands/create");
11
11
  const generate_1 = require("./commands/generate");
12
12
  const method_1 = require("./commands/method");
13
13
  const migration_1 = require("./commands/migration");
14
- const remove_1 = require("./commands/remove");
14
+ const undo_1 = require("./commands/undo");
15
15
  const version_1 = require("./utils/version");
16
16
  const worker_1 = require("./commands/worker");
17
17
  const auth_1 = require("./commands/auth");
18
18
  const rbac_1 = require("./commands/rbac");
19
+ const observability_1 = require("./commands/observability");
20
+ const worker_wizard_1 = require("./prompts/worker-wizard");
21
+ const auth_wizard_1 = require("./prompts/auth-wizard");
22
+ const generate_wizard_1 = require("./prompts/generate-wizard");
23
+ const config_1 = require("./utils/config");
24
+ // fail is every command's catch: one place so the two non-obvious cases stay
25
+ // consistent. @inquirer/prompts throws ExitPromptError both on Ctrl-C and when
26
+ // stdin isn't a TTY, and its raw message ("User force closed the prompt with 0
27
+ // null") tells a user nothing — the CI case especially, where the real problem
28
+ // is a missing argument, not the prompt.
29
+ function fail(err) {
30
+ const message = err.message ?? String(err);
31
+ if (err.name === "ExitPromptError") {
32
+ console.error(picocolors_1.default.red(process.stdin.isTTY
33
+ ? "aborted"
34
+ : "no interactive terminal to prompt on — pass every value as an argument/flag (see --help), or add --defaults"));
35
+ }
36
+ else {
37
+ console.error(picocolors_1.default.red(message));
38
+ }
39
+ process.exitCode = 1;
40
+ }
19
41
  const program = new commander_1.Command();
20
42
  program
21
43
  .name("go-scaffold")
22
- .description("Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard")
44
+ .description("Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard\n\n" +
45
+ "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.")
23
46
  .version((0, version_1.cliVersion)());
24
47
  program
25
48
  .command("create [name]")
@@ -29,7 +52,7 @@ program
29
52
  .option("--no-docker", "skip docker-compose.yml (only applies with --defaults)")
30
53
  .option("--no-openapi-docs", "skip docs/openapi.yaml (only applies with --defaults)")
31
54
  .option("--observability", "add Prometheus /metrics + OpenTelemetry tracing (only applies with --defaults; off by default)")
32
- .option("--api-prefix <prefix>", 'URL prefix every route is grouped under (default "v1"; pass "" for none)')
55
+ .option("--api-prefix <prefix>", 'URL prefix every route is grouped under, e.g. v1 or api/v1 (default: none)')
33
56
  .action(async (name, opts) => {
34
57
  try {
35
58
  await (0, create_1.createProject)(name, {
@@ -41,55 +64,96 @@ program
41
64
  });
42
65
  }
43
66
  catch (err) {
44
- console.error(picocolors_1.default.red(err.message));
45
- process.exitCode = 1;
67
+ fail(err);
46
68
  }
47
69
  });
70
+ // runModuleWizard resolves everything `generate module` needs, asking for
71
+ // whatever wasn't passed as a flag. Shared by the bare menu and by
72
+ // `generate module` itself, so both offer the same choices — the flags exist
73
+ // for scripting, not as the only way to reach a decision.
74
+ //
75
+ // --defaults is the escape hatch CI and scripts use: it takes the documented
76
+ // defaults (minimal, no auth) and asks nothing.
77
+ async function runModuleWizard(name, opts) {
78
+ let { full, auth, permission } = opts;
79
+ if (!opts.defaults) {
80
+ // read first, so "not a go-scaffold project" fails before we ask anything,
81
+ // and so the auth/permission questions are only asked when the project
82
+ // actually has the features they depend on — offering them otherwise
83
+ // would present a choice whose only outcome is generateModule's error.
84
+ const config = (0, config_1.readConfig)(process.cwd());
85
+ if (name === undefined)
86
+ name = await (0, generate_wizard_1.promptModuleName)();
87
+ if (full === undefined)
88
+ full = await (0, generate_wizard_1.promptModuleShape)();
89
+ if (auth === undefined && config.features.auth)
90
+ auth = await (0, generate_wizard_1.promptModuleAuth)();
91
+ if (auth && permission === undefined && config.features.rbac)
92
+ permission = await (0, generate_wizard_1.promptModulePermission)();
93
+ }
94
+ await (0, generate_1.generateModule)(name, { full: full ?? false, auth, permission });
95
+ }
96
+ // runGenerateWizard is `generate`/`g` run bare — asks which target, then
97
+ // delegates (each subcommand still prompts for anything else it's missing,
98
+ // e.g. the name). Pulled out to a function, not just the command's .action,
99
+ // so the top-level bare `go-scaffold` invocation can offer the exact same
100
+ // choice without duplicating it.
101
+ async function runGenerateWizard() {
102
+ const target = await (0, prompts_1.select)({
103
+ message: "What do you want to generate?",
104
+ choices: [
105
+ { name: "Module (safe minimal domain; add methods explicitly)", value: "module" },
106
+ { name: "Method (add one endpoint to an existing module)", value: "method" },
107
+ { name: "Migration (reserve a timestamped up/down SQL file pair)", value: "migration" },
108
+ ],
109
+ });
110
+ if (target === "module") {
111
+ await runModuleWizard(undefined, {});
112
+ }
113
+ else if (target === "method") {
114
+ await (0, method_1.generateMethod)(undefined, undefined, {});
115
+ }
116
+ else {
117
+ await (0, migration_1.generateMigration)(undefined);
118
+ }
119
+ }
48
120
  const generate = program
49
121
  .command("generate")
50
122
  .alias("g")
51
123
  .description("add to an existing go-scaffold project")
52
124
  .action(async () => {
53
- // bare `generate`/`g` — ask which target, then delegate (each subcommand
54
- // still prompts for anything else it's missing, e.g. the name).
55
125
  try {
56
- const target = await (0, prompts_1.select)({
57
- message: "What do you want to generate?",
58
- choices: [
59
- { name: "Module (full CRUD domain)", value: "module" },
60
- { name: "Method (add one endpoint to an existing module)", value: "method" },
61
- { name: "Migration (reserve a timestamped up/down SQL file pair)", value: "migration" },
62
- ],
63
- });
64
- if (target === "module") {
65
- await (0, generate_1.generateModule)(undefined, { full: true });
66
- }
67
- else if (target === "method") {
68
- await (0, method_1.generateMethod)(undefined, undefined, {});
69
- }
70
- else {
71
- await (0, migration_1.generateMigration)(undefined);
72
- }
126
+ await runGenerateWizard();
73
127
  }
74
128
  catch (err) {
75
- console.error(picocolors_1.default.red(err.message));
76
- process.exitCode = 1;
129
+ fail(err);
77
130
  }
78
131
  });
79
132
  generate
80
133
  .command("module [name]")
81
134
  .alias("m")
82
- .description("scaffold a domain module full CRUD by default, or a bare skeleton with --no-full")
83
- .option("--no-full", "minimal skeleton (model/errors/repository, no default CRUD) add endpoints one at a time with `generate method`")
135
+ .description("scaffold a safe minimal domain module; opt into a CRUD skeleton with --full")
136
+ .option("--full", "generate a CRUD skeleton (DTO fields/business rules remain TODO); minimal is the safe default")
137
+ // kept working for muscle memory, hidden from help: minimal is already the
138
+ // default, so advertising a flag that asks for it is pure noise.
139
+ .addOption(new commander_1.Option("--no-full", "deprecated compatibility alias; minimal is already the default").hideHelp())
84
140
  .option("--auth", "require a valid access token for this module's routes (needs `add auth`)")
85
- .option("--permission <code>", "also require this permission via authz.Require (needs `add rbac`; implies --auth)")
141
+ .option("--permission <code>", "also require this permission via authz.Require (needs `add rbac`; pass --auth too)")
142
+ .option("--defaults", "skip the prompts, use the defaults (minimal, no auth) — for CI/scripting")
86
143
  .action(async (name, opts) => {
87
144
  try {
88
- await (0, generate_1.generateModule)(name, { full: opts.full, auth: opts.auth, permission: opts.permission });
145
+ // anything not passed as a flag gets asked for — declaring both --full
146
+ // and --no-full leaves opts.full undefined when neither is given, which
147
+ // is exactly the "not answered yet" signal runModuleWizard needs.
148
+ await runModuleWizard(name, {
149
+ full: opts.full,
150
+ auth: opts.auth,
151
+ permission: opts.permission,
152
+ defaults: opts.defaults,
153
+ });
89
154
  }
90
155
  catch (err) {
91
- console.error(picocolors_1.default.red(err.message));
92
- process.exitCode = 1;
156
+ fail(err);
93
157
  }
94
158
  });
95
159
  generate
@@ -116,8 +180,7 @@ generate
116
180
  });
117
181
  }
118
182
  catch (err) {
119
- console.error(picocolors_1.default.red(err.message));
120
- process.exitCode = 1;
183
+ fail(err);
121
184
  }
122
185
  });
123
186
  generate
@@ -129,73 +192,314 @@ generate
129
192
  await (0, migration_1.generateMigration)(name);
130
193
  }
131
194
  catch (err) {
132
- console.error(picocolors_1.default.red(err.message));
133
- process.exitCode = 1;
195
+ fail(err);
196
+ }
197
+ });
198
+ // confirmAdd prints what a target is about to do and asks before it happens
199
+ // — the interactive menu is the one path where nothing was typed out loud
200
+ // yet, so it's the one place a summary earns its keep. A direct
201
+ // `add auth --store redis` skips this on purpose: that command line already
202
+ // says what it does, and asking again would just be in a script's way.
203
+ //
204
+ // Defaults to no, like `undo`'s confirm and unlike the create wizard's. This
205
+ // prompt lands immediately after a select, where Enter meant "choose this" a
206
+ // keystroke ago — carrying that Enter straight through would write ~15 files
207
+ // and patch main.go, and there is no `undo auth` to walk it back. A stray
208
+ // Enter costs a re-run instead.
209
+ async function confirmAdd(summaryLines, opts = {}) {
210
+ if (opts.yes)
211
+ return;
212
+ console.log(picocolors_1.default.bold("\nThis will:"));
213
+ for (const line of summaryLines)
214
+ console.log(` ${picocolors_1.default.dim("•")} ${line}`);
215
+ console.log();
216
+ const proceed = await (0, prompts_1.confirm)({ message: "Proceed?", default: false });
217
+ if (!proceed) {
218
+ throw new Error("cancelled — nothing was written");
219
+ }
220
+ }
221
+ // runAddWizard is `add` run bare — asks which target, then delegates. Pulled
222
+ // out to a function for the same reason as runGenerateWizard above: the
223
+ // top-level bare `go-scaffold` invocation reuses it verbatim.
224
+ async function runAddWizard() {
225
+ // read once, up front: every add command reads it anyway, the summary below
226
+ // needs to know what's already installed (e.g. whether auth's mail goes out
227
+ // inline or through an existing queue), and so does the menu itself.
228
+ const config = (0, config_1.readConfig)(process.cwd());
229
+ // Each add is once-only, and rbac needs auth first. Both facts are already
230
+ // known here, so say them in the menu rather than letting someone walk three
231
+ // steps and a confirmation to reach "already been added".
232
+ const target = await (0, prompts_1.select)({
233
+ message: "What do you want to add?",
234
+ choices: [
235
+ {
236
+ name: "Worker (background job queue, SMTP mail, cmd/worker)",
237
+ value: "worker",
238
+ disabled: config.features.worker ? "— already installed" : false,
239
+ },
240
+ {
241
+ name: "Auth (JWT access tokens, refresh rotation, register/login/refresh/logout/me)",
242
+ value: "auth",
243
+ disabled: config.features.auth ? "— already installed" : false,
244
+ },
245
+ {
246
+ name: "RBAC (roles/permissions, cached Authz middleware)",
247
+ value: "rbac",
248
+ disabled: config.features.rbac
249
+ ? "— already installed"
250
+ : config.features.auth
251
+ ? false
252
+ : "— needs `add auth` first",
253
+ },
254
+ {
255
+ name: "Observability (Prometheus /metrics + OpenTelemetry tracing)",
256
+ value: "observability",
257
+ disabled: config.features.observability ? "— already installed" : false,
258
+ },
259
+ ],
260
+ });
261
+ if (target === "worker") {
262
+ await runAddWorker(await resolveQueueBackend({}), {});
263
+ }
264
+ else if (target === "auth") {
265
+ // `--store` is a real fork (it decides whether Redis joins the project at
266
+ // all), so the menu has to ask it the same way the worker menu asks for
267
+ // its queue backend — a choice only reachable by knowing the flag name
268
+ // isn't a choice for anyone driving this from the menu.
269
+ await runAddAuth(await (0, auth_wizard_1.promptAuthStore)(), {});
270
+ }
271
+ else if (target === "rbac") {
272
+ await runAddRbac({});
273
+ }
274
+ else {
275
+ await runAddObservability({});
276
+ }
277
+ }
278
+ async function runAddWorker(backend, opts) {
279
+ await confirmAdd([
280
+ `add internal/platform/{queue,mail}/ and cmd/worker/ (queue backend: ${backend === "river" ? "postgres/River" : "redis/Asynq"})`,
281
+ backend === "river"
282
+ ? "no extra service to run — jobs are rows in your own Postgres"
283
+ : picocolors_1.default.yellow("requires Redis to be running"),
284
+ ], opts);
285
+ await (0, worker_1.addWorker)(backend);
286
+ }
287
+ async function runAddAuth(store, opts) {
288
+ const config = (0, config_1.readConfig)(process.cwd());
289
+ await confirmAdd([
290
+ "add internal/app/user/, internal/shared/middleware/auth.go, and cmd/seed",
291
+ store === "postgres"
292
+ ? "tokens + rate-limit counters: Postgres (user_svc.auth_tokens), in-process — no extra service"
293
+ : picocolors_1.default.yellow("tokens + rate-limit counters: Redis — requires a Redis server to be running"),
294
+ config.features.worker
295
+ ? "verification/reset mail: queued through the worker already installed"
296
+ : 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"),
297
+ ], opts);
298
+ await (0, auth_1.addAuth)(store);
299
+ }
300
+ async function runAddRbac(opts) {
301
+ const config = (0, config_1.readConfig)(process.cwd());
302
+ if (!config.features.auth) {
303
+ throw new Error("`add rbac` requires `add auth` first — there's no Role claim to check permissions against otherwise");
304
+ }
305
+ await confirmAdd(["add roles/permissions admin API, cached Authz middleware, and PATCH /users/:id/set-role"], opts);
306
+ await (0, rbac_1.addRbac)();
307
+ }
308
+ async function runAddObservability(opts) {
309
+ await confirmAdd(["add Prometheus /metrics + OpenTelemetry tracing", "patch cmd/api/wiring.go and internal/platform/database to wire it in"], opts);
310
+ await (0, observability_1.addObservability)();
311
+ }
312
+ const add = program
313
+ .command("add")
314
+ .description("add opt-in infrastructure to an existing go-scaffold project")
315
+ .action(async () => {
316
+ try {
317
+ await runAddWizard();
318
+ }
319
+ catch (err) {
320
+ fail(err);
134
321
  }
135
322
  });
136
- const add = program.command("add").description("add opt-in infrastructure to an existing go-scaffold project");
137
323
  add
138
324
  .command("worker")
139
- .description("add Redis, an Asynq task queue, SMTP mail, and cmd/worker (opt-in — most projects don't need this on day one)")
140
- .action(async () => {
325
+ .description("add a background job queue, SMTP mail, and cmd/worker (opt-in — most projects don't need this on day one)")
326
+ .option("--queue <backend>", "where jobs are stored: postgres (River, default) or redis (Asynq)")
327
+ .option("--defaults", "skip the prompt, use the Postgres-backed queue")
328
+ .option("-y, --yes", "skip the confirmation summary")
329
+ .action(async (opts) => {
141
330
  try {
142
- await (0, worker_1.addWorker)();
331
+ await runAddWorker(await resolveQueueBackend(opts), { yes: opts.yes || opts.defaults });
143
332
  }
144
333
  catch (err) {
145
- console.error(picocolors_1.default.red(err.message));
146
- process.exitCode = 1;
334
+ fail(err);
147
335
  }
148
336
  });
337
+ // resolveQueueBackend maps the friendly flag values people actually type
338
+ // ("postgres", "redis") onto the adapter names, and falls back to the prompt
339
+ // when neither --queue nor --defaults was given.
340
+ async function resolveQueueBackend(opts) {
341
+ if (opts.queue) {
342
+ const normalized = opts.queue.trim().toLowerCase();
343
+ const byName = {
344
+ postgres: "river",
345
+ river: "river",
346
+ pg: "river",
347
+ redis: "asynq",
348
+ asynq: "asynq",
349
+ };
350
+ const backend = byName[normalized];
351
+ if (!backend) {
352
+ throw new Error(`unknown --queue ${opts.queue} — use "postgres" (River) or "redis" (Asynq)`);
353
+ }
354
+ return backend;
355
+ }
356
+ if (opts.defaults)
357
+ return "river";
358
+ return (0, worker_wizard_1.promptQueueBackend)();
359
+ }
149
360
  add
150
361
  .command("auth")
151
- .description("add email/password auth: JWT access tokens, Redis-backed refresh token rotation, register/login/refresh/logout/me (requires `add worker` first)")
152
- .action(async () => {
362
+ .description("add email/password auth: JWT access tokens, refresh token rotation, register/login/refresh/logout/me (requires `add worker` first)")
363
+ .option("--store <store>", 'where tokens and rate-limit counters live: "postgres" (default, no extra service) or "redis" (exact across replicas)')
364
+ .option("--defaults", "skip the prompt, use the Postgres-backed store (for CI/scripting)")
365
+ .option("-y, --yes", "skip the confirmation summary")
366
+ .action(async (opts) => {
153
367
  try {
154
- await (0, auth_1.addAuth)();
368
+ // Same shape as `add worker`: an explicit flag wins, --defaults takes
369
+ // the documented default silently, and anything else asks rather than
370
+ // picking a store the caller never saw a choice about.
371
+ let store;
372
+ if (opts.store !== undefined) {
373
+ const normalized = opts.store.trim().toLowerCase();
374
+ if (normalized !== "postgres" && normalized !== "redis") {
375
+ throw new Error(`unknown --store ${opts.store} — use "postgres" or "redis"`);
376
+ }
377
+ store = normalized;
378
+ }
379
+ else if (opts.defaults) {
380
+ store = "postgres";
381
+ }
382
+ else {
383
+ store = await (0, auth_wizard_1.promptAuthStore)();
384
+ }
385
+ await runAddAuth(store, { yes: opts.yes || opts.defaults });
155
386
  }
156
387
  catch (err) {
157
- console.error(picocolors_1.default.red(err.message));
158
- process.exitCode = 1;
388
+ fail(err);
159
389
  }
160
390
  });
161
391
  add
162
392
  .command("rbac")
163
393
  .description("add role-based access control: roles/permissions admin API, cached Authz middleware, PATCH /users/:id/set-role (requires `add auth` first)")
164
- .action(async () => {
394
+ .option("-y, --yes", "skip the confirmation summary")
395
+ .action(async (opts) => {
165
396
  try {
166
- await (0, rbac_1.addRbac)();
397
+ await runAddRbac({ yes: opts.yes });
167
398
  }
168
399
  catch (err) {
169
- console.error(picocolors_1.default.red(err.message));
170
- process.exitCode = 1;
400
+ fail(err);
171
401
  }
172
402
  });
173
- const remove = program
174
- .command("remove")
175
- .alias("rm")
176
- .description("remove a domain module (deletes the package + un-wires main.go/openapi.yaml/migration)")
403
+ add
404
+ .command("observability")
405
+ .description("add Prometheus /metrics + OpenTelemetry tracing for Gin + GORM (also available at `create` time via --observability)")
406
+ .option("-y, --yes", "skip the confirmation summary")
407
+ .action(async (opts) => {
408
+ try {
409
+ await runAddObservability({ yes: opts.yes });
410
+ }
411
+ catch (err) {
412
+ fail(err);
413
+ }
414
+ });
415
+ const undo = program
416
+ .command("undo")
417
+ .description("undo a `generate module` you didn't mean to run (typo'd name, domain you decided against)")
177
418
  .action(async () => {
178
- // bare `remove`/`rm` — module is the only target, so prompt for the name
419
+ // bare `undo` — module is the only target, so prompt for the name
420
+ try {
421
+ await (0, undo_1.undoModule)(undefined, {});
422
+ }
423
+ catch (err) {
424
+ fail(err);
425
+ }
426
+ });
427
+ undo
428
+ .command("module [name]")
429
+ .alias("m")
430
+ .description("delete a generated module and everything it wired up, migration files included")
431
+ .option("-y, --yes", "skip the confirmation prompt")
432
+ .action(async (name, opts) => {
179
433
  try {
180
- await (0, remove_1.removeModule)(undefined, {});
434
+ await (0, undo_1.undoModule)(name, { yes: opts.yes });
181
435
  }
182
436
  catch (err) {
183
- console.error(picocolors_1.default.red(err.message));
184
- process.exitCode = 1;
437
+ fail(err);
185
438
  }
186
439
  });
440
+ // `remove module` was this command's old name, back when it claimed to retire
441
+ // a domain that might be live — and so kept the migration files, which for the
442
+ // case people actually used it in (undoing a mistake) meant a typo's migration
443
+ // ran on every database created from then on. Kept as an alias rather than
444
+ // deleted outright: a muscle-memory `rm m` shouldn't be an unrecognised-command
445
+ // error, and the semantics only got safer.
446
+ const remove = program.command("remove", { hidden: true }).alias("rm");
187
447
  remove
188
448
  .command("module [name]")
189
449
  .alias("m")
190
- .description("delete a domain module and reverse everything `generate module` wired up")
191
450
  .option("-y, --yes", "skip the confirmation prompt")
192
451
  .action(async (name, opts) => {
452
+ console.error(picocolors_1.default.yellow("`remove module` is now `undo module` — running that instead."));
193
453
  try {
194
- await (0, remove_1.removeModule)(name, { yes: opts.yes });
454
+ await (0, undo_1.undoModule)(name, { yes: opts.yes });
195
455
  }
196
456
  catch (err) {
197
- console.error(picocolors_1.default.red(err.message));
198
- process.exitCode = 1;
457
+ fail(err);
199
458
  }
200
459
  });
201
- program.parseAsync(process.argv);
460
+ // runTopMenu is bare `go-scaffold` with no arguments at all — the exact
461
+ // command name is the one thing people forget, so give the same "ask, then
462
+ // delegate" menu `add` and `generate` already give when run bare, instead of
463
+ // Commander's static help text (which lists commands but never lets you act
464
+ // on one).
465
+ //
466
+ // Deliberately NOT a .action() on the root command: giving the root an action
467
+ // makes it callable, which turns a mistyped subcommand into "too many
468
+ // arguments. Expected 0 arguments but got 2: ad, auth" instead of Commander's
469
+ // "unknown command 'ad' (Did you mean add?)". Branching on argv keeps the
470
+ // menu and keeps that error.
471
+ async function runTopMenu() {
472
+ // Every entry but "create" needs a project. Offering them outside one buys
473
+ // two selects and then the same error — so say it once, up front, and only
474
+ // offer what can actually run here.
475
+ const inProject = (0, config_1.isProjectDir)(process.cwd());
476
+ const target = inProject
477
+ ? await (0, prompts_1.select)({
478
+ message: "What do you want to do?",
479
+ choices: [
480
+ { name: "Create a new project", value: "create" },
481
+ { name: "Generate (module/method/migration in an existing project)", value: "generate" },
482
+ { name: "Add (auth/worker/rbac/observability in an existing project)", value: "add" },
483
+ { name: "Undo a generated module", value: "undo" },
484
+ ],
485
+ })
486
+ : (console.log(picocolors_1.default.dim(`${process.cwd()} isn't a go-scaffold project — only "create" can run here.\n`)), "create");
487
+ if (target === "create") {
488
+ await (0, create_1.createProject)(undefined, {});
489
+ }
490
+ else if (target === "generate") {
491
+ await runGenerateWizard();
492
+ }
493
+ else if (target === "add") {
494
+ await runAddWizard();
495
+ }
496
+ else {
497
+ await (0, undo_1.undoModule)(undefined, {});
498
+ }
499
+ }
500
+ if (process.argv.length <= 2) {
501
+ runTopMenu().catch(fail);
502
+ }
503
+ else {
504
+ program.parseAsync(process.argv);
505
+ }
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.promptAuthStore = promptAuthStore;
4
+ const prompts_1 = require("@inquirer/prompts");
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.
9
+ //
10
+ // Mirrors promptQueueBackend: the choice exists as `--store` for scripting,
11
+ // but nobody should have to know the flag name to discover the option.
12
+ async function promptAuthStore() {
13
+ return (0, prompts_1.select)({
14
+ message: "Where should refresh tokens and rate-limit counters be stored?",
15
+ default: "postgres",
16
+ choices: [
17
+ {
18
+ name: "Postgres (user_svc.auth_tokens)",
19
+ value: "postgres",
20
+ description: "no extra service to run; tokens are rows in your own database, and the rate limiter counts in-process",
21
+ },
22
+ {
23
+ name: "Redis",
24
+ value: "redis",
25
+ description: "TTL expiry and rate-limit counters are exact across replicas; needs a Redis server",
26
+ },
27
+ ],
28
+ });
29
+ }
@@ -29,9 +29,12 @@ async function runCreateWizard() {
29
29
  message: "Add metrics + tracing (Prometheus /metrics, OpenTelemetry over OTLP/HTTP for Gin + GORM)?",
30
30
  default: false,
31
31
  });
32
+ // No default, so Enter means what an empty answer looks like it means. A
33
+ // prefix is opt-in: it puts every route in the project behind a path segment
34
+ // that is then fixed for the life of the project, which is not something to
35
+ // acquire by not answering a question.
32
36
  const apiPrefixRaw = await (0, prompts_1.input)({
33
- message: "API route prefix (e.g. v1, api/v1; leave blank for none):",
34
- default: "v1",
37
+ message: "API route prefix — leave blank for none, or e.g. v1, api/v1:",
35
38
  validate: naming_1.validateApiPrefix,
36
39
  });
37
40
  const apiPrefix = (0, naming_1.normalizeApiPrefix)(apiPrefixRaw);
@@ -6,6 +6,10 @@ exports.promptMethodType = promptMethodType;
6
6
  exports.promptGetMode = promptGetMode;
7
7
  exports.promptMigrationName = promptMigrationName;
8
8
  exports.promptLookupField = promptLookupField;
9
+ exports.promptModuleShape = promptModuleShape;
10
+ exports.promptModuleAuth = promptModuleAuth;
11
+ exports.promptModulePermission = promptModulePermission;
12
+ exports.promptExistingModule = promptExistingModule;
9
13
  const prompts_1 = require("@inquirer/prompts");
10
14
  const naming_1 = require("../utils/naming");
11
15
  // wraps an assert-style validator into inquirer's true|string contract so a
@@ -74,3 +78,56 @@ async function promptLookupField() {
74
78
  });
75
79
  return field.trim();
76
80
  }
81
+ // The three `generate module` decisions that exist as flags (--full, --auth,
82
+ // --permission). They live here next to the other generate prompts so the
83
+ // subcommand and the bare-menu path can ask them the same way — a choice only
84
+ // reachable by knowing the flag name isn't a choice for anyone driving this
85
+ // from the menu.
86
+ async function promptModuleShape() {
87
+ return (0, prompts_1.select)({
88
+ message: "What should the module contain?",
89
+ default: false,
90
+ choices: [
91
+ {
92
+ name: "Minimal — model + wiring only",
93
+ value: false,
94
+ description: "the safe default; add endpoints one at a time with `generate method`",
95
+ },
96
+ {
97
+ name: "CRUD skeleton — list/get/create/update/delete",
98
+ value: true,
99
+ description: "all five endpoints wired up; DTO fields and business rules are left as TODO",
100
+ },
101
+ ],
102
+ });
103
+ }
104
+ async function promptModuleAuth() {
105
+ return (0, prompts_1.confirm)({
106
+ message: "Require a valid access token for this module's routes?",
107
+ default: false,
108
+ });
109
+ }
110
+ // Blank is a real answer here (auth without a specific permission), so this
111
+ // takes no validate — an unusable code is caught by generateModule, which
112
+ // owns the pattern and the "needs add rbac" rule.
113
+ async function promptModulePermission() {
114
+ const code = await (0, prompts_1.input)({
115
+ message: "Also require a permission code — leave blank for none, or e.g. products:manage:",
116
+ });
117
+ return code.trim() || undefined;
118
+ }
119
+ // promptExistingModule picks from what's actually on disk, for the commands
120
+ // that operate on a module that already exists (`generate method`, `undo
121
+ // module`). Free text was the wrong control here: the package name is the one
122
+ // form of the name nobody remembers exactly — a typo'd "ordrs" gets
123
+ // singularized to "ordr" on the way to the error, so the message names a
124
+ // string the user never typed.
125
+ async function promptExistingModule(packages, action) {
126
+ if (packages.length === 0) {
127
+ throw new Error(`no modules in internal/app yet — run \`go-scaffold generate module <name>\` before trying to ${action} one`);
128
+ }
129
+ return (0, prompts_1.select)({
130
+ message: "Which module?",
131
+ choices: packages.map((pkg) => ({ name: pkg, value: pkg })),
132
+ });
133
+ }
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.promptQueueBackend = promptQueueBackend;
4
+ const prompts_1 = require("@inquirer/prompts");
5
+ // The one decision `add worker` cannot make for you: where jobs live. It
6
+ // drives which adapter is written, whether Redis is added to the project at
7
+ // all, and whether an enqueue can join a database transaction.
8
+ async function promptQueueBackend() {
9
+ return (0, prompts_1.select)({
10
+ message: "Where should background jobs be stored?",
11
+ default: "river",
12
+ choices: [
13
+ {
14
+ name: "Postgres (River)",
15
+ value: "river",
16
+ description: "no extra service to run; a job is only delivered if the transaction that enqueued it commits",
17
+ },
18
+ {
19
+ name: "Redis (Asynq)",
20
+ value: "asynq",
21
+ description: "higher throughput and shareable across languages; needs a Redis server, and an enqueue cannot join a database transaction",
22
+ },
23
+ ],
24
+ });
25
+ }