@nakedev/go-scaffold 0.3.0 → 0.3.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.
package/README.md CHANGED
@@ -39,6 +39,7 @@ depending on your machine's npm/pnpm global-bin config — running
39
39
  go-scaffold create my-api
40
40
  cd my-api
41
41
  make docker-up # if you kept Docker + PostgreSQL
42
+ make db-create # create the database itself (safe to re-run)
42
43
  go mod tidy
43
44
  make run
44
45
  ```
@@ -74,12 +75,14 @@ with `generate module`.
74
75
  | Option | Effect |
75
76
  |---|---|
76
77
  | `--defaults` | Skip the wizard, use defaults (Docker on, OpenAPI docs on, no route prefix) |
77
- | `--no-docker` | Skip `docker-compose.yml` (with `--defaults`) |
78
- | `--no-openapi-docs` | Skip `docs/openapi.yaml` (with `--defaults`) |
79
- | `--observability` | Prometheus `/metrics` + OpenTelemetry tracing (with `--defaults`; off by default — `add observability` does the same later) |
78
+ | `--no-docker` | Skip `docker-compose.yml` |
79
+ | `--no-openapi-docs` | Skip `docs/openapi.yaml` |
80
+ | `--observability` | Prometheus `/metrics` + OpenTelemetry tracing (off by default — `add observability` does the same later) |
80
81
  | `--api-prefix <prefix>` | URL prefix every route is grouped under — opt in with e.g. `v1` or `api/v1`; omit it for none |
81
82
 
82
- Without `--defaults`, an interactive wizard asks the same four questions.
83
+ Without `--defaults`, an interactive wizard asks the same four questions
84
+ skipping any a flag already answered, so `create my-api --no-docker` never asks
85
+ about Docker and never scaffolds it.
83
86
  The prefix is a single project-wide choice made once at `create` time —
84
87
  there's no per-domain versioning (a domain that needs a real breaking change
85
88
  gets a new domain package or a new DTO field, not a duplicated model pointed
@@ -94,11 +94,22 @@ async function addAuth(store = "postgres", projectDir = process.cwd()) {
94
94
  { template: "add/auth/migrations/create_auth_tokens.down.sql.hbs", output: path_1.default.join("migrations", `${authTokensVersion}_create_auth_tokens.down.sql`) },
95
95
  ], {});
96
96
  }
97
+ // Only meaningful when there is a worker; readConfig fills this from the
98
+ // adapter file on disk, so the only way it is still unknown is a project
99
+ // that has internal/platform/queue with neither adapter in it. Guessing
100
+ // here used to emit `queue.NewAsynqEnqueuer` into River-only projects —
101
+ // an undefined symbol that the parse-only gate below cannot see, so the
102
+ // command reported success over a project that no longer compiled.
103
+ const queueBackend = config.features.queue;
104
+ if (worker && !queueBackend) {
105
+ throw new Error("this project has internal/platform/queue but no river.go or asynq.go — can't tell which queue backend to wire auth's mailer onto.\n" +
106
+ "Restore the adapter file, or remove internal/platform/queue and re-run `go-scaffold add worker`.");
107
+ }
97
108
  (0, golangci_patcher_1.patchGolangciForModule)(path_1.default.join(projectDir, ".golangci.yml"), config.goModule, "user");
98
109
  (0, auth_patcher_1.patchConfigForAuth)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
99
110
  (0, auth_patcher_1.patchMainGoForAuth)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), {
100
111
  goModule: config.goModule,
101
- queueBackend: config.features.queue ?? "asynq",
112
+ queueBackend: queueBackend ?? "river",
102
113
  store,
103
114
  worker,
104
115
  });
@@ -150,6 +161,13 @@ function patchMakefile(makefilePath) {
150
161
  let content = fs_extra_1.default.readFileSync(makefilePath, "utf8");
151
162
  if (content.includes("\nseed:\n"))
152
163
  return; // already added
164
+ // Both halves below have to land or neither should: .PHONY naming a target
165
+ // that was never inserted is a Makefile that lies about itself. `build:` is
166
+ // the anchor the target is inserted above, so check it first and bail whole.
167
+ if (!/\nbuild:/.test(content)) {
168
+ console.error(picocolors_1.default.yellow(`skipped the Makefile \`seed\` target — no \`build:\` target to anchor it to in ${makefilePath}.\nAdd it by hand: \`seed:\` running \`go run ./cmd/seed\`.`));
169
+ return;
170
+ }
153
171
  content = content.replace(/^\.PHONY: /m, ".PHONY: seed ");
154
172
  const target = "\n# bootstrap an admin user (idempotent) — SEED_ADMIN_EMAIL/PASSWORD from the\n" +
155
173
  "# environment, not .env, so a real secret never sits in a checked-in file.\n" +
@@ -39,7 +39,15 @@ async function createProject(rawName, opts) {
39
39
  throw new Error(check);
40
40
  }
41
41
  else {
42
- ({ features, apiPrefix } = await (0, create_wizard_1.runCreateWizard)());
42
+ // commander gives `--no-x` options a default of true, and there is no
43
+ // `--docker`/`--openapi-docs` to pass, so false here can only mean the
44
+ // caller opted out explicitly. undefined leaves the question to the wizard.
45
+ ({ features, apiPrefix } = await (0, create_wizard_1.runCreateWizard)({
46
+ docker: opts.docker === false ? false : undefined,
47
+ openapiDocs: opts.openapiDocs === false ? false : undefined,
48
+ observability: opts.observability === true ? true : undefined,
49
+ apiPrefix: opts.apiPrefix,
50
+ }));
43
51
  }
44
52
  const context = {
45
53
  projectName,
@@ -103,6 +103,11 @@ async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = p
103
103
  `handler.go and service.go must both still carry their \`// go-scaffold:*\` markers —\n` +
104
104
  `restore them, or add this method by hand.`);
105
105
  }
106
+ // Before the docs check below, so a name that's already taken is reported as
107
+ // exactly that on every project — otherwise the leftover OpenAPI document
108
+ // gets the blame on a docs-enabled project and the real cause (pick another
109
+ // method name) is the one thing the message doesn't say.
110
+ (0, method_patcher_1.assertMethodAbsent)(paths, method);
106
111
  const docsRelativePath = config.features.openapiDocs
107
112
  ? `${naming.plural}/methods/${method.pathSegment}.yaml`
108
113
  : undefined;
@@ -57,7 +57,15 @@ async function addObservability(projectDir = process.cwd(), opts = {}) {
57
57
  if (opts.silent)
58
58
  return;
59
59
  console.log(picocolors_1.default.green("\nadded internal/platform/telemetry/, internal/shared/middleware/{metrics,tracing}.go, and GET /metrics"));
60
- console.log("wired into cmd/api/wiring.go and internal/platform/database — every request and GORM query now gets a trace span");
60
+ console.log("wired into cmd/api/wiring.go and internal/platform/database — every request and GORM query cmd/api makes now gets a trace span");
61
+ // database.Open is shared, so a River-backed cmd/worker does raise GORM
62
+ // spans — but telemetry.Init, the only caller of otel.SetTracerProvider,
63
+ // runs in cmd/api alone. Those spans reach a no-op provider and vanish, and
64
+ // the worker serves no /metrics. Say so rather than leave someone hunting
65
+ // for background jobs that were never going to appear.
66
+ if (config.features.worker) {
67
+ console.log(picocolors_1.default.yellow("cmd/worker is not instrumented — it initialises no tracer provider, so its spans are dropped and it exposes no /metrics"));
68
+ }
61
69
  if (staleDocs.length) {
62
70
  // Deliberately not "you edited these". The comparison is a whole-file
63
71
  // match against today's template, and techstack.md embeds pinned
@@ -8,7 +8,7 @@ const path_1 = __importDefault(require("path"));
8
8
  const child_process_1 = require("child_process");
9
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
10
10
  const picocolors_1 = __importDefault(require("picocolors"));
11
- const prompts_1 = require("@inquirer/prompts");
11
+ const interactive_1 = require("../prompts/interactive");
12
12
  const config_1 = require("../utils/config");
13
13
  const naming_1 = require("../utils/naming");
14
14
  const module_location_1 = require("../utils/module-location");
@@ -57,10 +57,10 @@ async function undoModule(rawName, opts, projectDir = process.cwd()) {
57
57
  `so removing it would leave the project un-compilable. Undo \`add ${owner}\` by hand, or start from a fresh scaffold.`);
58
58
  }
59
59
  const migrationsDir = path_1.default.join(projectDir, "migrations");
60
- const migrations = moduleMigrations(migrationsDir, (0, naming_1.migrationSlugAliases)(naming));
60
+ const { owned: migrations, unclaimed } = moduleMigrations(migrationsDir, naming);
61
61
  const { checkedDatabase } = assertMigrationsNeverEscaped(projectDir, migrations, naming.pkg);
62
62
  if (!opts.yes) {
63
- const ok = await (0, prompts_1.confirm)({
63
+ const ok = await (0, interactive_1.confirm)({
64
64
  message: `Undo module "${naming.pkg}"? Deletes internal/app/${modulePath}/, its docs, ` +
65
65
  `${migrations.length ? `${migrations.length} migration file(s), ` : ""}` +
66
66
  `and un-wires wiring.go/openapi.yaml. The ${naming.tableName} table itself is not dropped.`,
@@ -135,23 +135,58 @@ async function undoModule(rawName, opts, projectDir = process.cwd()) {
135
135
  `or no migrate CLI. They weren't committed, so no other environment can have them, but if\n` +
136
136
  `you had run them against a local database it now records a version with no file behind it.`));
137
137
  }
138
+ if (unclaimed.length) {
139
+ console.log(picocolors_1.default.yellow(`\nleft in place: ${unclaimed.join(", ")}\n` +
140
+ ` named like this module's column migrations but not referencing ${naming.schemaName}.${naming.tableName},\n` +
141
+ ` so they look like they belong to another table. Delete them by hand if they don't.`));
142
+ }
138
143
  console.log(picocolors_1.default.dim(`\nnothing was dropped from any database — if you had already run these migrations locally,\n` +
139
144
  `the ${naming.tableName} table is still there. \`make db-drop && make db-create && make migrate-up\` is the\n` +
140
145
  `quickest way back to a clean dev database.`));
141
146
  }
142
- // moduleMigrations lists the migration files `generate module` created for
143
- // this module: the create pair, plus the permission pair when it was
144
- // generated with --permission.
145
- function moduleMigrations(migrationsDir, slugs) {
147
+ // moduleMigrations lists every migration file this module caused: the create
148
+ // pair, the permission pair from --permission, and the column pair that
149
+ // `generate method --get-mode one --field <f>` writes (method.ts names it
150
+ // `<version>_add_<tableName>_<column>`).
151
+ //
152
+ // That last one used to be left behind. The module and its create migration
153
+ // went, the ALTER TABLE against the now-uncreated table stayed, and because
154
+ // migrations/embed.go is a `//go:embed *` it then ran on every database made
155
+ // from that project — "schema <x>_svc does not exist" — which is the exact
156
+ // failure this command exists to prevent.
157
+ //
158
+ // The column pair cannot be claimed on filename alone: a module whose table is
159
+ // `orders` would otherwise also claim `_add_orders_logs_email.up.sql`, which
160
+ // belongs to a table named `orders_logs`. So a filename match is confirmed
161
+ // against the file's own `<schema>.<table>` reference, which is unique to this
162
+ // module. Anything that matches the shape but not the schema is reported
163
+ // rather than silently deleted or silently kept.
164
+ function moduleMigrations(migrationsDir, naming) {
146
165
  if (!fs_extra_1.default.existsSync(migrationsDir))
147
- return [];
148
- return fs_extra_1.default
149
- .readdirSync(migrationsDir)
150
- .filter((f) => slugs.some((slug) => f.endsWith(`_create_${slug}.up.sql`) ||
151
- f.endsWith(`_create_${slug}.down.sql`) ||
152
- f.endsWith(`_add_${slug}_permission.up.sql`) ||
153
- f.endsWith(`_add_${slug}_permission.down.sql`)))
154
- .sort();
166
+ return { owned: [], unclaimed: [] };
167
+ const slugs = (0, naming_1.migrationSlugAliases)(naming);
168
+ const owned = [];
169
+ const unclaimed = [];
170
+ for (const f of fs_extra_1.default.readdirSync(migrationsDir).sort()) {
171
+ const suffix = [".up.sql", ".down.sql"].find((e) => f.endsWith(e));
172
+ if (!suffix)
173
+ continue;
174
+ const stem = f.slice(0, -suffix.length);
175
+ if (slugs.some((slug) => stem.endsWith(`_create_${slug}`) || stem.endsWith(`_add_${slug}_permission`))) {
176
+ owned.push(f);
177
+ continue;
178
+ }
179
+ // `<version>_add_<slug>_<column>` — the --field column migration
180
+ if (!slugs.some((slug) => new RegExp(`^\\d+_add_${slug}_.+$`).test(stem)))
181
+ continue;
182
+ if (fs_extra_1.default.readFileSync(path_1.default.join(migrationsDir, f), "utf8").includes(`${naming.schemaName}.${naming.tableName}`)) {
183
+ owned.push(f);
184
+ }
185
+ else {
186
+ unclaimed.push(f);
187
+ }
188
+ }
189
+ return { owned, unclaimed };
155
190
  }
156
191
  // assertMigrationsNeverEscaped is what lets undo delete migration files at
157
192
  // all. A migration that has only ever existed in this working tree cannot be
@@ -98,6 +98,13 @@ function patchMakefile(makefilePath, opts) {
98
98
  let content = fs_extra_1.default.readFileSync(makefilePath, "utf8");
99
99
  if (content.includes("\nworker:\n"))
100
100
  return; // already added
101
+ // Same all-or-nothing rule as `add auth`'s Makefile patch: without the
102
+ // `build:` anchor the targets never get inserted, and a .PHONY line naming
103
+ // them would be a Makefile that lies about what it can run.
104
+ if (!/\nbuild:/.test(content)) {
105
+ console.error(picocolors_1.default.yellow(`skipped the Makefile \`dev\`/\`worker\` targets — no \`build:\` target to anchor them to in ${makefilePath}.\nAdd them by hand: \`worker:\` running \`go run ./cmd/worker\`.`));
106
+ return;
107
+ }
101
108
  content = content.replace(/^\.PHONY: /m, `.PHONY: dev worker${opts.river ? " river-migrate" : ""} `);
102
109
  // River keeps its own tables, versioned by River itself rather than by this
103
110
  // project's migrations/ directory — run its CLI once per database. Pinned
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const commander_1 = require("commander");
8
- const prompts_1 = require("@inquirer/prompts");
8
+ const interactive_1 = require("./prompts/interactive");
9
9
  const picocolors_1 = __importDefault(require("picocolors"));
10
10
  const create_1 = require("./commands/create");
11
11
  const generate_1 = require("./commands/generate");
@@ -22,16 +22,15 @@ const auth_wizard_1 = require("./prompts/auth-wizard");
22
22
  const generate_wizard_1 = require("./prompts/generate-wizard");
23
23
  const config_1 = require("./utils/config");
24
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.
25
+ // consistent. @inquirer/prompts throws ExitPromptError on Ctrl-C, and its raw
26
+ // message ("User force closed the prompt with 0 null") tells a user nothing.
27
+ // The no-TTY case normally never reaches here prompts/interactive.ts rejects
28
+ // before a prompt starts but stdin can also close mid-prompt, which arrives
29
+ // as the same error and deserves the same advice rather than "aborted".
29
30
  function fail(err) {
30
31
  const message = err.message ?? String(err);
31
32
  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"));
33
+ console.error(picocolors_1.default.red(process.stdin.isTTY ? "aborted" : interactive_1.NO_TTY_MESSAGE));
35
34
  }
36
35
  else {
37
36
  console.error(picocolors_1.default.red(message));
@@ -49,9 +48,9 @@ program
49
48
  .alias("c")
50
49
  .description("scaffold a new project (bare skeleton — add domains with `generate module`)")
51
50
  .option("--defaults", "skip the wizard, use defaults (for CI/scripting)")
52
- .option("--no-docker", "skip docker-compose.yml (only applies with --defaults)")
53
- .option("--no-openapi-docs", "skip docs/openapi.yaml (only applies with --defaults)")
54
- .option("--observability", "add Prometheus /metrics + OpenTelemetry tracing (only applies with --defaults; off by default)")
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)")
55
54
  .option("--api-prefix <prefix>", 'URL prefix every route is grouped under, e.g. v1 or api/v1 (default: none)')
56
55
  .action(async (name, opts) => {
57
56
  try {
@@ -99,7 +98,7 @@ async function runModuleWizard(name, opts) {
99
98
  // so the top-level bare `go-scaffold` invocation can offer the exact same
100
99
  // choice without duplicating it.
101
100
  async function runGenerateWizard() {
102
- const target = await (0, prompts_1.select)({
101
+ const target = await (0, interactive_1.select)({
103
102
  message: "What do you want to generate?",
104
103
  choices: [
105
104
  { name: "Module (safe minimal domain; add methods explicitly)", value: "module" },
@@ -213,7 +212,7 @@ async function confirmAdd(summaryLines, opts = {}) {
213
212
  for (const line of summaryLines)
214
213
  console.log(` ${picocolors_1.default.dim("•")} ${line}`);
215
214
  console.log();
216
- const proceed = await (0, prompts_1.confirm)({ message: "Proceed?", default: false });
215
+ const proceed = await (0, interactive_1.confirm)({ message: "Proceed?", default: false });
217
216
  if (!proceed) {
218
217
  throw new Error("cancelled — nothing was written");
219
218
  }
@@ -229,7 +228,7 @@ async function runAddWizard() {
229
228
  // Each add is once-only, and rbac needs auth first. Both facts are already
230
229
  // known here, so say them in the menu rather than letting someone walk three
231
230
  // steps and a confirmation to reach "already been added".
232
- const target = await (0, prompts_1.select)({
231
+ const target = await (0, interactive_1.select)({
233
232
  message: "What do you want to add?",
234
233
  choices: [
235
234
  {
@@ -306,7 +305,14 @@ async function runAddRbac(opts) {
306
305
  await (0, rbac_1.addRbac)();
307
306
  }
308
307
  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);
308
+ const config = (0, config_1.readConfig)(process.cwd());
309
+ await confirmAdd([
310
+ "add Prometheus /metrics + OpenTelemetry tracing",
311
+ "patch cmd/api/wiring.go and internal/platform/database to wire it in — cmd/api only",
312
+ ...(config.features.worker
313
+ ? [picocolors_1.default.yellow("cmd/worker is not instrumented: no tracer provider there, so its spans are dropped and it serves no /metrics")]
314
+ : []),
315
+ ], opts);
310
316
  await (0, observability_1.addObservability)();
311
317
  }
312
318
  const add = program
@@ -359,7 +365,7 @@ async function resolveQueueBackend(opts) {
359
365
  }
360
366
  add
361
367
  .command("auth")
362
- .description("add email/password auth: JWT access tokens, refresh token rotation, register/login/refresh/logout/me (requires `add worker` first)")
368
+ .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)")
363
369
  .option("--store <store>", 'where tokens and rate-limit counters live: "postgres" (default, no extra service) or "redis" (exact across replicas)')
364
370
  .option("--defaults", "skip the prompt, use the Postgres-backed store (for CI/scripting)")
365
371
  .option("-y, --yes", "skip the confirmation summary")
@@ -474,7 +480,7 @@ async function runTopMenu() {
474
480
  // offer what can actually run here.
475
481
  const inProject = (0, config_1.isProjectDir)(process.cwd());
476
482
  const target = inProject
477
- ? await (0, prompts_1.select)({
483
+ ? await (0, interactive_1.select)({
478
484
  message: "What do you want to do?",
479
485
  choices: [
480
486
  { name: "Create a new project", value: "create" },
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.promptAuthStore = promptAuthStore;
4
- const prompts_1 = require("@inquirer/prompts");
4
+ const interactive_1 = require("./interactive");
5
5
  // The one decision `add auth` cannot make for you: where refresh tokens,
6
6
  // one-time tokens and rate-limit counters live. It drives which tokenStore
7
7
  // implementation is written, whether Redis is added to the project at all,
@@ -10,7 +10,7 @@ const prompts_1 = require("@inquirer/prompts");
10
10
  // Mirrors promptQueueBackend: the choice exists as `--store` for scripting,
11
11
  // but nobody should have to know the flag name to discover the option.
12
12
  async function promptAuthStore() {
13
- return (0, prompts_1.select)({
13
+ return (0, interactive_1.select)({
14
14
  message: "Where should refresh tokens and rate-limit counters be stored?",
15
15
  default: "postgres",
16
16
  choices: [
@@ -2,10 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.promptProjectName = promptProjectName;
4
4
  exports.runCreateWizard = runCreateWizard;
5
- const prompts_1 = require("@inquirer/prompts");
5
+ const interactive_1 = require("./interactive");
6
6
  const naming_1 = require("../utils/naming");
7
7
  async function promptProjectName() {
8
- const name = await (0, prompts_1.input)({
8
+ const name = await (0, interactive_1.input)({
9
9
  message: "Project name:",
10
10
  validate: (value) => {
11
11
  if (!value.trim())
@@ -15,35 +15,49 @@ async function promptProjectName() {
15
15
  });
16
16
  return name.trim();
17
17
  }
18
- async function runCreateWizard() {
18
+ async function runCreateWizard(preset = {}) {
19
+ // The closing "create with these settings?" is asked however many answers
20
+ // arrived as flags, so this wizard always needs a terminal — say so before
21
+ // printing a header for questions that are never going to appear.
22
+ (0, interactive_1.assertInteractive)();
19
23
  console.log("\nConfigure your project:\n");
20
- const docker = await (0, prompts_1.confirm)({
21
- message: "Include Docker Compose (local Postgres)?",
22
- default: true,
23
- });
24
- const openapiDocs = await (0, prompts_1.confirm)({
25
- message: "Include hand-written OpenAPI docs (docs/openapi.yaml, whole docs/ tree served at /docs)?",
26
- default: true,
27
- });
28
- const observability = await (0, prompts_1.confirm)({
29
- message: "Add metrics + tracing (Prometheus /metrics, OpenTelemetry over OTLP/HTTP for Gin + GORM)?",
30
- default: false,
31
- });
24
+ const docker = preset.docker ??
25
+ (await (0, interactive_1.confirm)({
26
+ message: "Include Docker Compose (local Postgres)?",
27
+ default: true,
28
+ }));
29
+ const openapiDocs = preset.openapiDocs ??
30
+ (await (0, interactive_1.confirm)({
31
+ message: "Include hand-written OpenAPI docs (docs/openapi.yaml, whole docs/ tree served at /docs)?",
32
+ default: true,
33
+ }));
34
+ const observability = preset.observability ??
35
+ (await (0, interactive_1.confirm)({
36
+ message: "Add metrics + tracing (Prometheus /metrics, OpenTelemetry over OTLP/HTTP for Gin + GORM)?",
37
+ default: false,
38
+ }));
32
39
  // No default, so Enter means what an empty answer looks like it means. A
33
40
  // prefix is opt-in: it puts every route in the project behind a path segment
34
41
  // that is then fixed for the life of the project, which is not something to
35
42
  // acquire by not answering a question.
36
- const apiPrefixRaw = await (0, prompts_1.input)({
37
- message: "API route prefix — leave blank for none, or e.g. v1, api/v1:",
38
- validate: naming_1.validateApiPrefix,
39
- });
43
+ const apiPrefixRaw = preset.apiPrefix ??
44
+ (await (0, interactive_1.input)({
45
+ message: "API route prefix — leave blank for none, or e.g. v1, api/v1:",
46
+ validate: naming_1.validateApiPrefix,
47
+ }));
48
+ const prefixCheck = (0, naming_1.validateApiPrefix)(apiPrefixRaw);
49
+ if (prefixCheck !== true)
50
+ throw new Error(prefixCheck);
40
51
  const apiPrefix = (0, naming_1.normalizeApiPrefix)(apiPrefixRaw);
52
+ // Everything is listed whether it was asked or passed, so a flag never
53
+ // reaches the project without the caller seeing the value it produced.
54
+ const fromFlag = (passed) => (passed !== undefined ? " (from flag)" : "");
41
55
  console.log("\nSummary:");
42
- console.log(` Docker + PostgreSQL: ${docker ? "yes" : "no"}`);
43
- console.log(` OpenAPI docs: ${openapiDocs ? "yes" : "no"}`);
44
- console.log(` Metrics + tracing: ${observability ? "yes" : "no"}`);
45
- console.log(` Route prefix: ${apiPrefix ? `/${apiPrefix}` : "(none)"}`);
46
- const proceed = await (0, prompts_1.confirm)({ message: "\nCreate project with these settings?", default: true });
56
+ console.log(` Docker + PostgreSQL: ${docker ? "yes" : "no"}${fromFlag(preset.docker)}`);
57
+ console.log(` OpenAPI docs: ${openapiDocs ? "yes" : "no"}${fromFlag(preset.openapiDocs)}`);
58
+ console.log(` Metrics + tracing: ${observability ? "yes" : "no"}${fromFlag(preset.observability)}`);
59
+ console.log(` Route prefix: ${apiPrefix ? `/${apiPrefix}` : "(none)"}${fromFlag(preset.apiPrefix)}`);
60
+ const proceed = await (0, interactive_1.confirm)({ message: "\nCreate project with these settings?", default: true });
47
61
  if (!proceed) {
48
62
  throw new Error("project creation cancelled");
49
63
  }
@@ -10,7 +10,7 @@ exports.promptModuleShape = promptModuleShape;
10
10
  exports.promptModuleAuth = promptModuleAuth;
11
11
  exports.promptModulePermission = promptModulePermission;
12
12
  exports.promptExistingModule = promptExistingModule;
13
- const prompts_1 = require("@inquirer/prompts");
13
+ const interactive_1 = require("./interactive");
14
14
  const naming_1 = require("../utils/naming");
15
15
  // wraps an assert-style validator into inquirer's true|string contract so a
16
16
  // reserved word re-prompts inline instead of aborting the whole command.
@@ -24,21 +24,21 @@ function notKeyword(value, role) {
24
24
  }
25
25
  }
26
26
  async function promptModuleName() {
27
- const name = await (0, prompts_1.input)({
27
+ const name = await (0, interactive_1.input)({
28
28
  message: "Module name (singular, e.g. order, product):",
29
29
  validate: (value) => (value.trim() ? (0, naming_1.validateModuleName)(value) : "module name is required"),
30
30
  });
31
31
  return name.trim();
32
32
  }
33
33
  async function promptMethodName() {
34
- const name = await (0, prompts_1.input)({
34
+ const name = await (0, interactive_1.input)({
35
35
  message: "Method name (e.g. approve, findByStatus, resetPassword):",
36
36
  validate: (value) => (value.trim() ? notKeyword(value, "method") : "method name is required"),
37
37
  });
38
38
  return name.trim();
39
39
  }
40
40
  async function promptMethodType() {
41
- return (0, prompts_1.select)({
41
+ return (0, interactive_1.select)({
42
42
  message: "Method type:",
43
43
  choices: [
44
44
  { name: "GET", value: "get" },
@@ -50,7 +50,7 @@ async function promptMethodType() {
50
50
  });
51
51
  }
52
52
  async function promptGetMode() {
53
- return (0, prompts_1.select)({
53
+ return (0, interactive_1.select)({
54
54
  message: "GET mode:",
55
55
  choices: [
56
56
  { name: "List (all) — a new list endpoint with its own filter", value: "all" },
@@ -59,14 +59,14 @@ async function promptGetMode() {
59
59
  });
60
60
  }
61
61
  async function promptMigrationName() {
62
- const name = await (0, prompts_1.input)({
62
+ const name = await (0, interactive_1.input)({
63
63
  message: "Migration name (e.g. add_status_to_orders):",
64
64
  validate: (value) => (value.trim() ? true : "migration name is required"),
65
65
  });
66
66
  return name.trim();
67
67
  }
68
68
  async function promptLookupField() {
69
- const field = await (0, prompts_1.input)({
69
+ const field = await (0, interactive_1.input)({
70
70
  message: "Lookup field (e.g. email, status, slug):",
71
71
  validate: (value) => {
72
72
  if (!value.trim())
@@ -84,7 +84,7 @@ async function promptLookupField() {
84
84
  // reachable by knowing the flag name isn't a choice for anyone driving this
85
85
  // from the menu.
86
86
  async function promptModuleShape() {
87
- return (0, prompts_1.select)({
87
+ return (0, interactive_1.select)({
88
88
  message: "What should the module contain?",
89
89
  default: false,
90
90
  choices: [
@@ -102,7 +102,7 @@ async function promptModuleShape() {
102
102
  });
103
103
  }
104
104
  async function promptModuleAuth() {
105
- return (0, prompts_1.confirm)({
105
+ return (0, interactive_1.confirm)({
106
106
  message: "Require a valid access token for this module's routes?",
107
107
  default: false,
108
108
  });
@@ -111,7 +111,7 @@ async function promptModuleAuth() {
111
111
  // takes no validate — an unusable code is caught by generateModule, which
112
112
  // owns the pattern and the "needs add rbac" rule.
113
113
  async function promptModulePermission() {
114
- const code = await (0, prompts_1.input)({
114
+ const code = await (0, interactive_1.input)({
115
115
  message: "Also require a permission code — leave blank for none, or e.g. products:manage:",
116
116
  });
117
117
  return code.trim() || undefined;
@@ -126,7 +126,7 @@ async function promptExistingModule(packages, action) {
126
126
  if (packages.length === 0) {
127
127
  throw new Error(`no modules in internal/app yet — run \`go-scaffold generate module <name>\` before trying to ${action} one`);
128
128
  }
129
- return (0, prompts_1.select)({
129
+ return (0, interactive_1.select)({
130
130
  message: "Which module?",
131
131
  choices: packages.map((pkg) => ({ name: pkg, value: pkg })),
132
132
  });
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.select = exports.input = exports.confirm = exports.NO_TTY_MESSAGE = void 0;
4
+ exports.assertInteractive = assertInteractive;
5
+ const prompts_1 = require("@inquirer/prompts");
6
+ exports.NO_TTY_MESSAGE = "no interactive terminal to prompt on — pass every value as an argument/flag (see --help), or add --defaults";
7
+ // @inquirer/prompts does reject on a non-TTY stdin, but only after it has
8
+ // written the question and its cursor escapes: a CI log then ends with a
9
+ // question nobody can answer, followed by raw ANSI, followed by the real
10
+ // error. Answering the no-TTY case before the prompt starts leaves only the
11
+ // line that says what to do instead.
12
+ //
13
+ // Every prompt in this CLI is imported from here rather than from
14
+ // @inquirer/prompts directly, so a prompt added later gets this for free.
15
+ // Exported for a caller that prints something before its first prompt: the
16
+ // header belongs to a wizard that cannot run at all here, so it should not be
17
+ // printed either.
18
+ function assertInteractive() {
19
+ if (!process.stdin.isTTY)
20
+ throw new Error(exports.NO_TTY_MESSAGE);
21
+ }
22
+ const confirm = (...args) => {
23
+ assertInteractive();
24
+ return (0, prompts_1.confirm)(...args);
25
+ };
26
+ exports.confirm = confirm;
27
+ const input = (...args) => {
28
+ assertInteractive();
29
+ return (0, prompts_1.input)(...args);
30
+ };
31
+ exports.input = input;
32
+ const select = (...args) => {
33
+ assertInteractive();
34
+ return (0, prompts_1.select)(...args);
35
+ };
36
+ exports.select = select;
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.promptQueueBackend = promptQueueBackend;
4
- const prompts_1 = require("@inquirer/prompts");
4
+ const interactive_1 = require("./interactive");
5
5
  // The one decision `add worker` cannot make for you: where jobs live. It
6
6
  // drives which adapter is written, whether Redis is added to the project at
7
7
  // all, and whether an enqueue can join a database transaction.
8
8
  async function promptQueueBackend() {
9
- return (0, prompts_1.select)({
9
+ return (0, interactive_1.select)({
10
10
  message: "Where should background jobs be stored?",
11
11
  default: "river",
12
12
  choices: [
@@ -18,12 +18,58 @@ function writeConfig(projectDir, config) {
18
18
  }
19
19
  // readConfig falls back to detecting from go.mod when the config file is
20
20
  // missing (e.g. a project scaffolded before this file existed).
21
+ //
22
+ // When the file IS present, any feature key it does not define is filled in
23
+ // from the tree. A file that exists but is missing a key used to mean "false"
24
+ // to every caller, and callers then guessed: `add auth` guessed the queue
25
+ // backend and wrote `queue.NewAsynqEnqueuer` into a River-only project (a
26
+ // symbol that does not exist — and the post-patch gate is parse-only, so the
27
+ // CLI reported success over a project that no longer builds), while
28
+ // `undo module user` read a missing `auth` key as "not auth's" and deleted the
29
+ // whole auth domain. The tree always knew the answer; this stops the guessing.
30
+ //
31
+ // The file still wins wherever it has a value — an explicit `false` is an
32
+ // answer, not a hole.
21
33
  function readConfig(projectDir) {
22
34
  const file = configPath(projectDir);
23
- if (fs_extra_1.default.existsSync(file)) {
24
- return fs_extra_1.default.readJsonSync(file);
35
+ if (!fs_extra_1.default.existsSync(file))
36
+ return detectConfig(projectDir);
37
+ const config = fs_extra_1.default.readJsonSync(file);
38
+ const detected = detectFeatures(projectDir);
39
+ const features = { ...config.features };
40
+ for (const key of Object.keys(detected)) {
41
+ if (features[key] === undefined)
42
+ features[key] = detected[key];
25
43
  }
26
- return detectConfig(projectDir);
44
+ return { ...config, features };
45
+ }
46
+ // detectFeatures answers "what is actually installed here" from the tree
47
+ // alone. Every `add` command leaves a directory or a file behind that nothing
48
+ // else writes, which is what makes this reliable.
49
+ //
50
+ // auth and rbac are keyed on their middleware, not on internal/app/{user,role}:
51
+ // those directories are also what `generate module user` / `generate module
52
+ // role` produce, and mistaking one for the other would make `undo module`
53
+ // refuse to remove a module it generated itself.
54
+ function detectFeatures(projectDir) {
55
+ const has = (...segments) => fs_extra_1.default.existsSync(path_1.default.join(projectDir, ...segments));
56
+ const worker = has("internal", "platform", "queue");
57
+ const auth = has("internal", "app", "user") && has("internal", "shared", "middleware", "auth.go");
58
+ return {
59
+ docker: has("docker-compose.yml"),
60
+ openapiDocs: has("docs", "openapi.yaml"),
61
+ worker,
62
+ // which adapter file is present is what `add worker --queue` decided
63
+ queue: !worker ? undefined : has("internal", "platform", "queue", "asynq.go") ? "asynq" : "river",
64
+ auth,
65
+ // which store `add auth` chose is readable from which implementation
66
+ // file it wrote — same trick as the queue adapter above. Projects from
67
+ // before the option existed have neither name and read as "redis",
68
+ // which is what they in fact are.
69
+ authStore: !auth ? undefined : has("internal", "app", "user", "tokenstore_pg.go") ? "postgres" : "redis",
70
+ rbac: has("internal", "app", "role") && has("internal", "shared", "middleware", "authz.go"),
71
+ observability: has("internal", "platform", "telemetry"),
72
+ };
27
73
  }
28
74
  function detectConfig(projectDir) {
29
75
  const goModPath = path_1.default.join(projectDir, "go.mod");
@@ -60,31 +106,11 @@ function detectConfig(projectDir) {
60
106
  // ("needs add worker first") while `add worker` also refused ("queue
61
107
  // already exists"), with no way out. Worse, the first `add` to succeed then
62
108
  // wrote a config that recorded the undetected features as absent.
63
- const has = (...segments) => fs_extra_1.default.existsSync(path_1.default.join(projectDir, ...segments));
64
- const worker = has("internal", "platform", "queue");
65
109
  return {
66
110
  projectName: path_1.default.basename(projectDir),
67
111
  goModule,
68
112
  apiPrefix,
69
- features: {
70
- docker: has("docker-compose.yml"),
71
- openapiDocs: has("docs", "openapi.yaml"),
72
- worker,
73
- // which adapter file is present is what `add worker --queue` decided
74
- queue: !worker ? undefined : has("internal", "platform", "queue", "asynq.go") ? "asynq" : "river",
75
- auth: has("internal", "app", "user"),
76
- // which store `add auth` chose is readable from which implementation
77
- // file it wrote — same trick as the queue adapter above. Projects from
78
- // before the option existed have neither name and read as "redis",
79
- // which is what they in fact are.
80
- authStore: !has("internal", "app", "user")
81
- ? undefined
82
- : has("internal", "app", "user", "tokenstore_pg.go")
83
- ? "postgres"
84
- : "redis",
85
- rbac: has("internal", "app", "role"),
86
- observability: has("internal", "platform", "telemetry"),
87
- },
113
+ features: detectFeatures(projectDir),
88
114
  };
89
115
  }
90
116
  // isProjectDir answers "would readConfig succeed here?" without throwing, so
@@ -3,6 +3,7 @@ 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.assertMethodAbsent = assertMethodAbsent;
6
7
  exports.patchMethod = patchMethod;
7
8
  exports.markersPresent = markersPresent;
8
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
@@ -38,13 +39,19 @@ function assertNotDuplicate(content, needle, what) {
38
39
  function routeCall(type) {
39
40
  return { get: "GET", post: "POST", put: "PUT", patch: "PATCH", delete: "DELETE" }[type];
40
41
  }
41
- function patchMethod(paths, naming, method, opts, goModule) {
42
+ // assertMethodAbsent is the same check patchMethod runs, exported so callers
43
+ // can reach it before their own pre-flight checks. A name that already exists
44
+ // is the cause the caller needs to hear about, so it has to be reported ahead
45
+ // of anything downstream of it (the OpenAPI document that name would collide
46
+ // with, for one) rather than after.
47
+ function assertMethodAbsent(paths, method) {
42
48
  const handlerSig = `func (h *Handler) ${method.handlerName}(`;
43
49
  const serviceSig = `func (s *Service) ${method.pascalName}(`;
44
- const handlerContent = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
45
- const serviceContent = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
46
- assertNotDuplicate(handlerContent, handlerSig, `handler method "${method.handlerName}"`);
47
- assertNotDuplicate(serviceContent, serviceSig, `service method "${method.pascalName}"`);
50
+ assertNotDuplicate(fs_extra_1.default.readFileSync(paths.handlerPath, "utf8"), handlerSig, `handler method "${method.handlerName}"`);
51
+ assertNotDuplicate(fs_extra_1.default.readFileSync(paths.servicePath, "utf8"), serviceSig, `service method "${method.pascalName}"`);
52
+ }
53
+ function patchMethod(paths, naming, method, opts, goModule) {
54
+ assertMethodAbsent(paths, method);
48
55
  if (opts.type === "get" && opts.getMode === "all") {
49
56
  patchGetAll(paths, naming, method, goModule);
50
57
  }
@@ -10,6 +10,7 @@ exports.patchConfigForWorker = patchConfigForWorker;
10
10
  exports.patchConfigForSMTP = patchConfigForSMTP;
11
11
  exports.patchMainGoForWorker = patchMainGoForWorker;
12
12
  const fs_extra_1 = __importDefault(require("fs-extra"));
13
+ const picocolors_1 = __importDefault(require("picocolors"));
13
14
  const marker_patch_1 = require("./marker-patch");
14
15
  const IMPORT_MARKER = "// go-scaffold:imports";
15
16
  const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
@@ -58,9 +59,17 @@ function patchCiForRedis(ciPath) {
58
59
  return;
59
60
  // `steps:` sits one level under the job, so it's the first line that ends
60
61
  // the `services:` block — insert the service just above it.
62
+ //
63
+ // Anchored on the exact indentation the template emits. A hand-reformatted
64
+ // workflow is the realistic way to miss it, and returning silently there
65
+ // means CI runs without Redis and fails on a connection error that says
66
+ // nothing about this — so say it here instead.
61
67
  const stepsLine = content.split("\n").find((l) => l.trimEnd() === " steps:");
62
- if (!stepsLine)
68
+ if (!stepsLine) {
69
+ console.error(picocolors_1.default.yellow(`skipped adding the Redis service to ${ciPath} — no \`steps:\` line at the expected indentation to anchor it to.\n` +
70
+ `Add a redis service to the workflow's \`services:\` block by hand, or CI will run without one.`));
63
71
  return;
72
+ }
64
73
  const service = [
65
74
  " redis:",
66
75
  " image: redis:7-alpine",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,12 +46,24 @@ missing AutoMigrate wiring, a `repository` interface out of sync with its
46
46
  Run from the project root:
47
47
 
48
48
  ```bash
49
- go-scaffold generate module <name> [--full] [--auth] [--permission <code>]
49
+ go-scaffold generate module <name> --defaults [--full] [--auth] [--permission <code>]
50
50
  go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]
51
51
  go-scaffold generate migration <name>
52
- go-scaffold undo module <name> [-y]
52
+ go-scaffold undo module <name> -y
53
53
  ```
54
54
 
55
+ **Every value has to be a flag.** These commands prompt for whatever you
56
+ leave out, and a prompt in a non-interactive shell exits 1 having written
57
+ nothing — so a bare `generate module products` fails rather than taking the
58
+ default. `--defaults` supplies the unasked-for answers (minimal, no auth);
59
+ combine it with `--full`/`--auth`/`--permission` when you want those. The
60
+ same applies to `--get-mode` (required with `--type get`) and `-y` on
61
+ `undo module`. Every `add` command summarises what it will write and asks
62
+ before writing: `-y` skips that confirmation, but `add auth` and `add worker`
63
+ *also* ask which backing store to use — so those two need `--defaults`
64
+ (or their own `--store`/`--queue` flag plus `-y`), while `add rbac -y` and
65
+ `add observability -y` are enough.
66
+
55
67
  `<name>` for a module is a domain noun in whatever form reads naturally —
56
68
  singular or plural, any case, hyphens or underscores are all accepted and
57
69
  normalized (`products` → `product`, `Orders` → `order`, `order-item` →
@@ -79,7 +91,7 @@ is actually intended — otherwise prefer minimal plus explicit `generate method
79
91
  calls.
80
92
 
81
93
  `--auth` puts the module's routes behind a valid access token (requires
82
- `go-scaffold add auth` in this project first). `--permission <code>` also
94
+ `go-scaffold add auth -y` in this project first). `--permission <code>` also
83
95
  requires that permission via `authz.Require` and seeds it in its own
84
96
  migration — it needs `add rbac`, and `--auth` must be passed alongside it.
85
97
 
@@ -114,7 +126,7 @@ domain decided against. Deletes `internal/app/<pkg>/`, the module's
114
126
  `migrations/<version>_create_<plural>.{up,down}.sql` pair, and reverses
115
127
  everything `generate module` wired up in `cmd/api/wiring.go` (and in
116
128
  `docs/openapi.yaml` + `docs/<plural>/` when OpenAPI is enabled). `-y` skips
117
- the confirmation prompt.
129
+ the confirmation prompt, and is required in a non-interactive shell.
118
130
 
119
131
  The migration files go because `migrations/embed.go` is a `//go:embed *`: a
120
132
  typo's migration left behind runs on every database created from then on.
@@ -11,14 +11,24 @@ package with its own model/handler/service/repository), or add a **new**
11
11
  endpoint to an existing one — stop and run the CLI instead:
12
12
 
13
13
  ```bash
14
- go-scaffold generate module <name>
14
+ go-scaffold generate module <name> --defaults
15
15
  go-scaffold generate method <module> <name> --type <get|post|put|patch|delete>
16
16
  ```
17
17
 
18
+ **Pass every value as a flag.** Run interactively, these commands ask for
19
+ anything you left out — which has no answer in a non-interactive shell, so
20
+ they exit 1 having written nothing. `--defaults` on `generate module` means
21
+ "minimal, no auth, ask nothing"; add `--full`/`--auth`/`--permission` to it
22
+ when you want those. Same rule elsewhere: `--get-mode` is required with
23
+ `--type get`, and `undo module` needs `-y`. Every `add` command confirms
24
+ before writing — `-y` skips that, but `add auth`/`add worker` also ask which
25
+ backing store to use, so those two want `--defaults` (or `--store`/`--queue`
26
+ plus `-y`).
27
+
18
28
  This applies **even when the request doesn't say "module"/"method" or name
19
29
  the CLI at all**. Recognize indirect asks as generation work, for example:
20
30
 
21
- - "add a products feature" → a new domain (`generate module products`)
31
+ - "add a products feature" → a new domain (`generate module products --defaults`)
22
32
  - "let admins approve orders" → a new method on an existing module
23
33
  (`generate method orders approve --type patch`)
24
34
  - "we need an endpoint that lists overdue invoices" → a new `get` method
@@ -45,7 +55,7 @@ hand-rolling anything that looks like scaffolding.
45
55
 
46
56
  ## Command quick reference
47
57
 
48
- - `go-scaffold generate module <name>` — safe minimal model + errors +
58
+ - `go-scaffold generate module <name> --defaults` — safe minimal model + errors +
49
59
  repository + service/handler plumbing, wired into `cmd/api/wiring.go` and
50
60
  appended to `migrations/`. Add endpoints one at a time with `generate
51
61
  method`, or pass `--full` to opt into a CRUD skeleton with TODO DTO fields
@@ -55,7 +65,7 @@ hand-rolling anything that looks like scaffolding.
55
65
  with the same name — pick a different one if it collides. With OpenAPI
56
66
  enabled it also writes a valid TODO path document and wires the index;
57
67
  replace placeholder schemas while implementing the method
58
- - `go-scaffold undo module <name>` — takes back a `generate module` that
68
+ - `go-scaffold undo module <name> -y` — takes back a `generate module` that
59
69
  shouldn't have happened (typo'd name, domain decided against): deletes the
60
70
  package, un-wires main.go/OpenAPI, and deletes the module's migration files.
61
71
  It refuses if those migrations are committed to git or already applied to
@@ -4,8 +4,10 @@
4
4
  # docker build --target api -t {{projectName}}-api .
5
5
  # docker build --target worker -t {{projectName}}-worker .
6
6
  #
7
- # The worker target only builds once `go-scaffold add worker` has created
8
- # cmd/worker; until then `--target api` is the only one that resolves.
7
+ # api is the last stage on purpose: a plain `docker build .` builds whatever
8
+ # comes last, and that has to be the binary every project has. The worker
9
+ # stage only resolves once `go-scaffold add worker` has created cmd/worker —
10
+ # ask for it by name, and only then.
9
11
  FROM golang:1.25-alpine AS build
10
12
  WORKDIR /src
11
13
 
@@ -23,6 +25,12 @@ ENV CGO_ENABLED=0
23
25
  RUN go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
24
26
  RUN if [ -d ./cmd/worker ]; then go build -trimpath -ldflags="-s -w" -o /out/worker ./cmd/worker; fi
25
27
 
28
+ FROM gcr.io/distroless/static-debian12:nonroot AS worker
29
+ WORKDIR /app
30
+ COPY --from=build /out/worker /app/worker
31
+ USER nonroot:nonroot
32
+ ENTRYPOINT ["/app/worker"]
33
+
26
34
  # nonroot, and no shell: there is nothing in this image to exec into if
27
35
  # something gets in. Debug with `docker run --entrypoint` against the build
28
36
  # stage instead.
@@ -34,9 +42,3 @@ COPY --from=build /out/api /app/api
34
42
  USER nonroot:nonroot
35
43
  EXPOSE 8080
36
44
  ENTRYPOINT ["/app/api"]
37
-
38
- FROM gcr.io/distroless/static-debian12:nonroot AS worker
39
- WORKDIR /app
40
- COPY --from=build /out/worker /app/worker
41
- USER nonroot:nonroot
42
- ENTRYPOINT ["/app/worker"]
@@ -166,7 +166,7 @@ TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=
166
166
 
167
167
  ## API spec
168
168
 
169
- `docs/openapi.yaml` is hand-written and served, along with every file it `$ref`s (`common/`, `health/`, per-domain folders), under `/docs` while the server runs — point a renderer (Scalar, Swagger UI, Redoc) or a client generator (Hey API) at `http://localhost:8080/docs/openapi.yaml`. Update the spec by hand whenever an endpoint/DTO changes.
169
+ `docs/openapi.yaml` is hand-written and served, along with every file it `$ref`s (`common/`, `health/`, per-domain folders), under `/docs` while the server runs — point a renderer (Scalar, Swagger UI, Redoc) or a client generator (Hey API) at `http://localhost:8080/docs/openapi.yaml`, or browse the tree at `http://localhost:8080/docs/`. Update the spec by hand whenever an endpoint/DTO changes.
170
170
 
171
171
  Some tools don't resolve external `$ref`s when importing a local file — they read it as-is and see zero routes (Bruno's "Import Collection" does this; some Postman flows too). For those, bundle the spec into one fully-resolved file first:
172
172
 
@@ -197,6 +197,10 @@ docker build --target api -t {{projectName}}-api .
197
197
  docker build --target worker -t {{projectName}}-worker . # once `add worker` exists
198
198
  ```
199
199
 
200
+ `api` is the last stage, so a plain `docker build .` builds it — the worker
201
+ stage has to be asked for by name, and only resolves once `add worker` has
202
+ created `cmd/worker`.
203
+
200
204
  Both stages are distroless and run as `nonroot`, so there is no shell in either
201
205
  image. Debug against the `build` stage instead:
202
206
  `docker run --rm -it --entrypoint sh $(docker build -q --target build .)`.
@@ -94,11 +94,17 @@ func run() error {
94
94
  // resolves $ref over HTTP (Scalar, Swagger UI, Redoc, Hey API pointed at a URL) can reach
95
95
  // them too; StaticFile on just the index file would 404 on every $ref it follows.
96
96
  //
97
+ // gin.Dir(..., true) keeps directory listings on, which r.Static suppresses:
98
+ // without them opening /docs/ in a browser answers 404 with a blank page,
99
+ // which reads as "this is broken" rather than "ask for a file". The listing
100
+ // costs nothing here — every file in the tree is already downloadable — and
101
+ // it makes openapi.yaml discoverable without knowing its name up front.
102
+ //
97
103
  // Not in production: this serves your whole API surface — every path,
98
104
  // parameter and schema — to anyone who asks. Publish the spec deliberately
99
105
  // (`make openapi-bundle`) rather than by leaving this on.
100
106
  if !cfg.IsProd() {
101
- r.Static("/docs", "./docs")
107
+ r.StaticFS("/docs", gin.Dir("./docs", true))
102
108
  }
103
109
  {{/if}}
104
110
 
@@ -133,6 +133,15 @@ newer Gin (→ HTTP/3/quic-go) and every DB driver they support tracing for
133
133
  (MySQL, ClickHouse, MongoDB), respectively, for a Postgres-only project that
134
134
  only wants request/query spans.
135
135
 
136
+ **Scope: `cmd/api` only.** `cmd/worker` is not instrumented. The GORM plugin
137
+ lives in `database.Open`, which the worker also calls, so it does raise spans —
138
+ but `telemetry.Init` is the only thing that calls `otel.SetTracerProvider` and
139
+ it runs in `cmd/api` alone, so those spans reach a no-op provider and are
140
+ discarded. The worker serves no `/metrics` either. To change that, call
141
+ `telemetry.Init` from `cmd/worker/main.go` — split it into `main`/`run` first,
142
+ the way `cmd/api` is, or the `defer` that flushes the exporter on shutdown is
143
+ skipped by the `os.Exit` calls already in there.
144
+
136
145
  {{/if}}
137
146
  ## Evolution Notes
138
147
 
@@ -23,7 +23,7 @@
23
23
  - Graceful shutdown, structured logging, `/livez` + `/readyz`: always enabled
24
24
  - Docker Compose (local Postgres): `{{#if docker}}enabled{{else}}disabled{{/if}}`
25
25
  - OpenAPI docs (`docs/openapi.yaml`, whole `docs/` tree served at `/docs`): `{{#if openapiDocs}}enabled{{else}}disabled{{/if}}`
26
- - Metrics + tracing (Prometheus `/metrics`, OpenTelemetry for Gin + GORM): `{{#if observability}}enabled{{else}}disabled{{/if}}`
26
+ - Metrics + tracing (Prometheus `/metrics`, OpenTelemetry for Gin + GORM, `cmd/api` only): `{{#if observability}}enabled{{else}}disabled{{/if}}`
27
27
  - API route prefix: `{{#if apiPrefix}}/{{apiPrefix}}{{else}}(none){{/if}}`
28
28
  - CI (`.github/workflows/ci.yml` — build, vet, gofmt check, golangci-lint, `go test` with a real Postgres service): always enabled
29
29