@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
@@ -45,21 +45,23 @@ hand-rolling anything that looks like scaffolding.
45
45
 
46
46
  ## Command quick reference
47
47
 
48
- - `go-scaffold generate module <name>` — model + dto + errors + repository +
49
- service + handler + tests, wired into `cmd/api/main.go` (AutoMigrate +
50
- route registration) and appended to `migrations/`. Use `--no-full` for a
51
- bare skeleton (no default CRUD/routes) when you'd rather add endpoints one
52
- at a time with `generate method`
48
+ - `go-scaffold generate module <name>` — safe minimal model + errors +
49
+ repository + service/handler plumbing, wired into `cmd/api/wiring.go` and
50
+ appended to `migrations/`. Add endpoints one at a time with `generate
51
+ method`, or pass `--full` to opt into a CRUD skeleton with TODO DTO fields
53
52
  - `go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]` —
54
53
  patches an *existing* module's handler + service (and repository, for a
55
54
  `get --get-mode one --field` lookup) in place; never overwrites a method
56
- with the same name — pick a different one if it collides. Endpoint docs in
57
- `docs/openapi.yaml` are **not** auto-updated for methods the command
58
- prints the route so you can add the spec entry by hand
59
- - `go-scaffold remove module <name>` — the inverse of `generate module`:
60
- deletes the package and un-wires main.go / openapi.yaml / migrations. Use
61
- this to drop a domain instead of hand-deleting the folder (a partial
62
- hand-delete leaves duplicate wiring behind)
55
+ with the same name — pick a different one if it collides. With OpenAPI
56
+ enabled it also writes a valid TODO path document and wires the index;
57
+ replace placeholder schemas while implementing the method
58
+ - `go-scaffold undo module <name>` — takes back a `generate module` that
59
+ shouldn't have happened (typo'd name, domain decided against): deletes the
60
+ package, un-wires main.go/OpenAPI, and deletes the module's migration files.
61
+ It refuses if those migrations are committed to git or already applied to
62
+ your database, since then they may exist somewhere this can't reach. Not for
63
+ retiring a domain that has shipped — that's `generate migration drop_<table>`
64
+ plus a reviewed data removal; the table is never dropped either way
63
65
  - Every route in this project is grouped under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
64
66
  (set once at `create` time via `--api-prefix`) — there is no per-domain
65
67
  versioning; a breaking API change gets a new domain package or a new DTO
@@ -0,0 +1,42 @@
1
+ # Build both binaries in one image, ship each from a separate stage — cmd/api
2
+ # and cmd/worker are deployed as two processes, and neither needs the other's.
3
+ #
4
+ # docker build --target api -t {{projectName}}-api .
5
+ # docker build --target worker -t {{projectName}}-worker .
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.
9
+ FROM golang:1.25-alpine AS build
10
+ WORKDIR /src
11
+
12
+ # Dependencies first: this layer is cached until go.mod/go.sum change, so an
13
+ # ordinary code edit doesn't re-download the module graph.
14
+ COPY go.mod go.sum ./
15
+ RUN go mod download
16
+
17
+ COPY . .
18
+ # CGO off so the result runs on a distroless/scratch base with no libc.
19
+ # -trimpath keeps build-machine paths out of panics; -s -w drops the symbol
20
+ # table and DWARF, which is most of the binary size and nothing you can use in
21
+ # production anyway.
22
+ ENV CGO_ENABLED=0
23
+ RUN go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
24
+ RUN if [ -d ./cmd/worker ]; then go build -trimpath -ldflags="-s -w" -o /out/worker ./cmd/worker; fi
25
+
26
+ # nonroot, and no shell: there is nothing in this image to exec into if
27
+ # something gets in. Debug with `docker run --entrypoint` against the build
28
+ # stage instead.
29
+ FROM gcr.io/distroless/static-debian12:nonroot AS api
30
+ WORKDIR /app
31
+ COPY --from=build /out/api /app/api
32
+ # migrations/ is embedded in the binary (migrations/embed.go), so nothing to
33
+ # copy for CheckMigrationVersion. docs/ is only served outside production.
34
+ USER nonroot:nonroot
35
+ EXPOSE 8080
36
+ 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"]
@@ -1,26 +1,46 @@
1
+ # Pinned dev-tool versions — one place, used by `make tools` and by CI, so a
2
+ # lint rule or migration runner never behaves differently on someone's laptop
3
+ # than it does on a pull request.
4
+ #
5
+ # ponytail: installed with `go install`, not tracked as a go.mod `tool`
6
+ # directive — golang-migrate's CLI pulls a driver for every database it
7
+ # supports and golangci-lint pulls its whole linter tree, which between them
8
+ # turned this project's go.sum from 117 lines into 2,700. Tool pinning is not
9
+ # worth that in a scaffold whose point is a clean starting project.
10
+ MIGRATE_VERSION ?= v4.19.1
11
+ GOLANGCI_LINT_VERSION ?= v2.12.2
12
+
1
13
  DB_HOST ?= localhost
2
14
  DB_PORT ?= 5432
3
15
  DB_USER ?= postgres
4
16
  DB_NAME ?= {{dbName}}
5
17
  PGPASSWORD ?= postgres
18
+ POSTGRES_CONTAINER ?=
6
19
 
7
20
  # which env file to load — defaults to .env (your local override, gitignored).
8
21
  # override to run against another file, e.g. copy .env.example to .env.production,
9
22
  # fill it in, then `make run ENV_FILE=.env.production`.
10
23
  #
11
- # Every target that needs config loads it the same way: drop whole-line comments
12
- # (^#) AND trailing ` # ...` comments the sed only strips a `#` preceded by
13
- # whitespace, so a `#` inside a value (password, DSN) is kept. Without the sed,
14
- # `xargs` hands the comment's words to `export` too: `PORT=8080 # prod: PORT=80`
15
- # would export PORT twice and the comment's value would win. (Can't factor this
16
- # into a make variable: a `#` in a variable value starts a make comment; in a
17
- # recipe line it's passed to the shell untouched.)
24
+ # Every target that needs config loads it the same way: `set -a` marks whatever
25
+ # follows for export, the file is sourced, `set +a` stops.
26
+ #
27
+ # Sourcing rather than piping through xargs, which is what this used to do. The
28
+ # shell already understands the format: `PORT=8080 # prod: 80` keeps 8080,
29
+ # blank lines are nothing, and the reason it changed a quoted value with a
30
+ # space in it survives. xargs word-split `SMTP_PASSWORD="two words"` into two
31
+ # arguments and exported the wrong thing, silently.
32
+ #
33
+ # It does mean .env is executed, so `$(...)` in a value runs. That file is your
34
+ # own gitignored config, and the alternative could not read half of it.
35
+ #
36
+ # (Can't factor this into a make variable: a `#` in a variable value starts a
37
+ # make comment; in a recipe line it is passed to the shell untouched.)
18
38
  ENV_FILE ?= .env
19
39
 
20
- .PHONY: run build test fmt vet lint tidy db-create db-drop migrate-up migrate-down migrate-verify{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
40
+ .PHONY: tools run build test fmt vet lint tidy db-create db-drop migrate-up migrate-up-test migrate-down migrate-verify{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
21
41
 
22
42
  run:
23
- @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); go run ./cmd/api
43
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; go run ./cmd/api
24
44
 
25
45
  build:
26
46
  go build -o bin/api ./cmd/api
@@ -29,7 +49,7 @@ build:
29
49
  # integration tests. A bare `go test ./...` still works, but falls back to
30
50
  # whatever defaults the test files carry.
31
51
  test:
32
- @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); go test ./...
52
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; go test ./...
33
53
 
34
54
  fmt:
35
55
  gofmt -w .
@@ -40,6 +60,11 @@ vet:
40
60
  lint:
41
61
  golangci-lint run
42
62
 
63
+ # install the pinned dev tools into $(go env GOPATH)/bin
64
+ tools:
65
+ go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@$(MIGRATE_VERSION)
66
+ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
67
+
43
68
  tidy:
44
69
  go mod tidy
45
70
 
@@ -47,14 +72,17 @@ tidy:
47
72
  # connects to the always-present "postgres" maintenance DB to run CREATE DATABASE,
48
73
  # since the target DB may not exist yet. Safe to re-run — skips if it already exists.
49
74
  # \gexec only works read from stdin, not through -c, hence the pipe.
50
- # no local psql? falls back to `docker exec` into whatever container is publishing
51
- # DB_PORT the project's own `docker compose up`'d postgres, or an unrelated
52
- # shared Postgres container you already have running, either works the same way.
75
+ # `POSTGRES_CONTAINER` selects an exact Docker container when no host psql is
76
+ # installed; otherwise the fallback discovers a container publishing DB_PORT.
53
77
  # override DB_HOST/DB_PORT/DB_USER/DB_NAME/PGPASSWORD to point at a different server.
54
78
  db-create:
55
79
  @if command -v psql >/dev/null 2>&1; then \
56
80
  echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
57
81
  PGPASSWORD=$(PGPASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d postgres && echo "SUCCESS! database $(DB_NAME) is ready"; \
82
+ elif [ -n "$(POSTGRES_CONTAINER)" ]; then \
83
+ echo "no local psql — using docker exec into $(POSTGRES_CONTAINER)"; \
84
+ echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
85
+ docker exec -i -e PGPASSWORD=$(PGPASSWORD) $(POSTGRES_CONTAINER) psql -U $(DB_USER) -d postgres && echo "SUCCESS! database $(DB_NAME) is ready"; \
58
86
  elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
59
87
  container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
60
88
  if [ -z "$$container" ]; then \
@@ -72,6 +100,8 @@ db-create:
72
100
  db-drop:
73
101
  @if command -v psql >/dev/null 2>&1; then \
74
102
  PGPASSWORD=$(PGPASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d postgres -c "DROP DATABASE IF EXISTS $(DB_NAME);" && echo "SUCCESS! database $(DB_NAME) dropped"; \
103
+ elif [ -n "$(POSTGRES_CONTAINER)" ]; then \
104
+ docker exec -i -e PGPASSWORD=$(PGPASSWORD) $(POSTGRES_CONTAINER) psql -U $(DB_USER) -d postgres -c "DROP DATABASE IF EXISTS $(DB_NAME);" && echo "SUCCESS! database $(DB_NAME) dropped"; \
75
105
  elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
76
106
  container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
77
107
  if [ -z "$$container" ]; then \
@@ -85,16 +115,22 @@ db-drop:
85
115
  fi
86
116
 
87
117
  migrate-up:
88
- @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); migrate -path migrations -database "$$DB_DSN" up
118
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$DB_DSN" up
119
+
120
+ # same migration files, applied to TEST_DB_DSN instead of DB_DSN — run this
121
+ # once after `make db-create DB_NAME=..._test` so repository_test.go files
122
+ # see real tables instead of skipping (or failing, if REQUIRE_TEST_DB=true).
123
+ migrate-up-test:
124
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$TEST_DB_DSN" up
89
125
 
90
126
  migrate-down:
91
- @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); migrate -path migrations -database "$$DB_DSN" down 1
127
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$DB_DSN" down 1
92
128
 
93
129
  # runs up -> down-to-zero -> up against $DB_DSN to catch a bit-rotted down.sql
94
130
  # (one that no longer reverses cleanly) before you actually need a rollback.
95
131
  # point DB_DSN at a throwaway/test database first — this drops every table.
96
132
  migrate-verify:
97
- @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); \
133
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; \
98
134
  migrate -path migrations -database "$$DB_DSN" up && \
99
135
  migrate -path migrations -database "$$DB_DSN" down -all && \
100
136
  migrate -path migrations -database "$$DB_DSN" up
@@ -2,12 +2,15 @@
2
2
 
3
3
  Gin + GORM backend, scaffolded by [go-scaffold](https://github.com/nakedev/go-scaffold). Organized by feature (domain), Postgres-backed, schema managed with [golang-migrate](https://github.com/golang-migrate/migrate).
4
4
 
5
- This is a bare skeleton no domain modules yet. Add one with:
5
+ This project starts as a bare skeleton. Add a safe minimal domain with:
6
6
 
7
7
  ```bash
8
8
  go-scaffold generate module orders
9
9
  ```
10
10
 
11
+ Add endpoints one at a time with `generate method`, or pass `--full` to opt
12
+ into a CRUD skeleton whose DTO fields and business rules remain explicit TODOs.
13
+
11
14
  See `docs/architect/` for the conventions every generated module follows, and `AGENTS.md`/`CLAUDE.md` if you're working with an AI coding agent in this repo.
12
15
 
13
16
  ## Layout
@@ -24,8 +27,10 @@ internal/
24
27
  │ ├── apperror/ # central error type (status + payload)
25
28
  │ ├── dberr/ # maps DB errors to constraint kind (IsDuplicate, IsForeignKey) — shared by every domain
26
29
  │ ├── httpx/ # HTTP helpers shared by every domain (ParseID, BindErr)
27
- │ ├── middleware/ # RequestID, Logger (slog), Error
28
- └── pagination/ # parses ?limit=&offset=, response envelope
30
+ │ ├── id/ # UUID v7 generation (id.New) — app-side, not a DB default
31
+ ├── middleware/ # RequestID, Logger (slog), Error, CORS
32
+ │ ├── pagination/ # parses ?limit=&offset=, response envelope
33
+ │ └── tx/ # carries a transaction on the ctx (tx.Do / tx.From) so two repositories commit together
29
34
  └── app/ # domain packages — empty until you `generate module`
30
35
  ```
31
36
 
@@ -50,13 +55,14 @@ safe; a `#` inside a value (password, DSN) is kept.
50
55
 
51
56
  `make db-create` connects to Postgres at `DB_HOST`/`DB_PORT`/`DB_USER` (default:
52
57
  `localhost`/`5432`/`postgres`, matching `.env.example`) using the `psql`
53
- client — works the same whether Postgres came from `make docker-up` or an
54
- existing instance you already have running. Override any of
58
+ client — works the same whether Postgres came from
59
+ {{#if docker}}`make docker-up`{{else}}a container{{/if}} or an existing instance you already have
60
+ running. Override any of
55
61
  `DB_HOST`/`DB_PORT`/`DB_USER`/`DB_NAME`/`PGPASSWORD` to point at a different
56
62
  server. If `psql` isn't installed locally and `DB_HOST` is `localhost`, it
57
63
  falls back to `docker exec` into whichever container is publishing
58
- `DB_PORT` — this project's own Postgres (`make docker-up` first) or any
59
- other Postgres container you already have running.
64
+ `DB_PORT` — {{#if docker}}this project's own Postgres (`make docker-up` first) or any
65
+ other Postgres container{{else}}any Postgres container{{/if}} you already have running.
60
66
 
61
67
  Server listens on `:8080` (override with `PORT`). Ctrl+C = graceful shutdown.
62
68
 
@@ -73,6 +79,7 @@ make tidy # go mod tidy
73
79
  make db-create # create the database itself (safe to re-run)
74
80
  make db-drop # drop the database
75
81
  make migrate-up # apply migrations (reads DB_DSN from ENV_FILE)
82
+ make migrate-up-test # same migrations, applied to TEST_DB_DSN instead — see Tests
76
83
  make migrate-down # roll back one migration
77
84
  make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql (point DB_DSN at a throwaway DB)
78
85
  {{#if docker}}make docker-up # docker compose up -d
@@ -129,17 +136,23 @@ make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql befo
129
136
 
130
137
  ## Tests
131
138
 
132
- Integration tests (handler-level) run against a **real Postgres** instance (same engine as prod), each test in a transaction that's rolled back — no leftover rows. If the DB isn't reachable those tests **skip** (unit tests using a fake repo always run).
139
+ Handler and service tests are fast unit tests: handlers depend on a narrow
140
+ service interface, and services use function-backed repository stubs. Neither
141
+ suite requires a database.
133
142
 
134
- They use a **separate `{{dbName}}_test` database**, not the one `DB_DSN` points at. That's deliberate: the harness runs `DropTable` + `AutoMigrate` on every real run, so pointing it at your dev database would wipe whatever `make migrate-up` built there — FK constraints and seed data included. Create it once:
143
+ Repository integration tests run against a **real Postgres** instance using
144
+ the same versioned SQL migrations as production, each test in a transaction
145
+ that's rolled back. Locally they skip when `TEST_DB_DSN` is unset; CI sets
146
+ `REQUIRE_TEST_DB=true`, so a missing or unmigrated database fails instead of
147
+ becoming a false-green skip. Use a separate `{{dbName}}_test` database:
135
148
 
136
149
  ```bash
137
150
  {{#if docker}}
138
151
  make docker-up # local postgres first
139
152
  {{/if}}
140
153
  make db-create DB_NAME={{dbName}}_test
141
- make test
142
- # point somewhere else entirely: TEST_DB_DSN=postgres://... go test ./...
154
+ make migrate-up-test
155
+ TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable REQUIRE_TEST_DB=true go test ./...
143
156
  ```
144
157
 
145
158
  ## Error payload
@@ -170,4 +183,40 @@ Import `docs/openapi.bundled.yaml` instead. Tools that resolve `$ref` over HTTP
170
183
  go-scaffold generate module orders
171
184
  ```
172
185
 
173
- Scaffolds `internal/app/orders/` (model/dto/errors/repository/service/handler + tests), wires it into `cmd/api/main.go` (AutoMigrate, route registration), and appends a migration file. See `docs/architect/patterns.md` for the module shape and the rules for domains with foreign keys.
186
+ Scaffolds the safe minimal `internal/app/order/` module, wires its empty route
187
+ group/model into `cmd/api/wiring.go`, and appends a migration file. Add endpoints
188
+ with `generate method`; use `--full` only when a CRUD skeleton is intentional.
189
+ See `docs/architect/patterns.md` for the module shape and foreign-key rules.
190
+
191
+ ## Deploying
192
+
193
+ Two processes, one image, one target each:
194
+
195
+ ```bash
196
+ docker build --target api -t {{projectName}}-api .
197
+ docker build --target worker -t {{projectName}}-worker . # once `add worker` exists
198
+ ```
199
+
200
+ Both stages are distroless and run as `nonroot`, so there is no shell in either
201
+ image. Debug against the `build` stage instead:
202
+ `docker run --rm -it --entrypoint sh $(docker build -q --target build .)`.
203
+
204
+ `cmd/api` serves HTTP. `cmd/worker` consumes the queue and is a separate
205
+ deployment with **no port and no health endpoint** — scale it independently,
206
+ and remember that not running it means queued mail is never sent, silently.
207
+
208
+ Before the first deploy of a release:
209
+
210
+ | | |
211
+ |---|---|
212
+ | `AUTO_MIGRATE` | leave unset — it defaults off when `APP_ENV=production`, and the app checks the applied migration version instead |
213
+ | migrations | `migrate -path migrations -database "$DB_DSN" up` as its own step, before the new binaries roll |
214
+ | `TRUSTED_PROXIES` | the CIDRs of your ingress, or the auth rate limiter keys on a header anyone can send |
215
+ | `COOKIE_SECURE` | `true` · `COOKIE_SAMESITE=none` as well if your frontend is on a different site |
216
+ | `/metrics` | reachable in-cluster for Prometheus, blocked at the ingress |
217
+ | `JWT_SECRET`, `SMTP_HOST` | the app refuses to start in production without real values |
218
+
219
+ The migration check is what makes the rollout order safe: a binary whose
220
+ embedded migrations are ahead of the database refuses to start, while one
221
+ that's behind logs a warning and serves — so migrating first, then rolling, is
222
+ the order that never leaves a pod serving against a schema it doesn't know.
@@ -1,141 +1,28 @@
1
1
  package main
2
2
 
3
3
  import (
4
- "context"
5
- "errors"
6
4
  "log/slog"
7
- "net/http"
8
5
  "os"
9
- "os/signal"
10
- "syscall"
11
- "time"
12
-
13
- "{{goModule}}/internal/platform/database"
14
- {{#if observability}}
15
- "{{goModule}}/internal/platform/telemetry"
16
- {{/if}}
17
- "{{goModule}}/internal/shared/config"
18
- "{{goModule}}/internal/shared/middleware"
19
- // go-scaffold:imports
20
-
21
- "github.com/gin-gonic/gin"
22
- {{#if observability}}
23
- "github.com/prometheus/client_golang/prometheus/promhttp"
24
- {{/if}}
25
6
  )
26
7
 
8
+ // main decides the exit code and nothing else. Everything it used to do lives
9
+ // in run() — see wiring.go, which is this binary's composition root: the one
10
+ // place that sees every module and hands them what they need.
11
+ //
12
+ // Two things that split buys. Deferred cleanup actually runs, because os.Exit
13
+ // skips defers and this doesn't call it until run has returned — telemetry
14
+ // shutdown and the signal handler used to be silently abandoned on a startup
15
+ // error. And run() is an ordinary function returning an ordinary error, so a
16
+ // test can call it.
17
+ //
18
+ // wiring.go is also the file `go-scaffold` patches. Keeping it out of main.go
19
+ // means the first file you open to understand this binary is not the one that
20
+ // grows a line every time a module is added.
27
21
  func main() {
28
- cfg := config.Load()
29
-
30
- logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
31
- slog.SetDefault(logger)
32
- // go-scaffold:config-checks
33
-
34
- {{#if observability}}
35
- shutdownTelemetry, err := telemetry.Init(context.Background(), "{{projectName}}", cfg.OTELExporterEndpoint)
36
- if err != nil {
37
- logger.Error("init telemetry", "error", err)
38
- os.Exit(1)
39
- }
40
- defer func() { _ = shutdownTelemetry(context.Background()) }()
41
-
42
- {{/if}}
43
- db, err := database.Open(cfg)
44
- if err != nil {
45
- logger.Error("open db", "error", err)
46
- os.Exit(1)
47
- }
48
-
49
- sqlDB, err := db.DB()
50
- if err != nil {
51
- logger.Error("db handle", "error", err)
52
- os.Exit(1)
53
- }
54
- // go-scaffold:platform-init
55
-
56
- if cfg.AutoMigrate {
57
- // ponytail: AutoMigrate is for dev only (add-only, locks the table once data grows)
58
- // prod: set AUTO_MIGRATE=false and run golang-migrate as versioned SQL instead
59
- if err := db.AutoMigrate(
60
- // go-scaffold:models
61
- ); err != nil {
62
- logger.Error("migrate", "error", err)
63
- os.Exit(1)
64
- }
65
- } else if err := database.CheckMigrationVersion(db); err != nil {
66
- logger.Error("migration version check", "error", err)
22
+ if err := run(); err != nil {
23
+ // Correct before and after run() sets the JSON handler: slog's default
24
+ // writes to stderr, so a failure this early is still reported.
25
+ slog.Error("startup failed", "error", err)
67
26
  os.Exit(1)
68
27
  }
69
-
70
- r := gin.New()
71
- r.Use(gin.Recovery(), middleware.CORS(cfg.CORSAllowedOrigins), middleware.RequestID(), middleware.Logger(logger), middleware.Error(!cfg.IsProd()){{#if observability}}, middleware.Metrics(), middleware.Tracing("{{projectName}}"){{/if}})
72
-
73
- // liveness = is the process up / readiness = ready for traffic (can it reach the DB)
74
- r.GET("/livez", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
75
- r.GET("/readyz", func(c *gin.Context) {
76
- if err := sqlDB.PingContext(c.Request.Context()); err != nil {
77
- c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})
78
- return
79
- }
80
- // go-scaffold:readyz-checks
81
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
82
- })
83
- {{#if observability}}
84
- r.GET("/metrics", gin.WrapH(promhttp.Handler()))
85
- {{/if}}
86
- {{#if openapiDocs}}
87
- // hand-written spec at docs/openapi.yaml, split across sibling files (common/, health/,
88
- // <domain>/) via relative $ref — serve the whole tree under one prefix so a client that
89
- // resolves $ref over HTTP (Scalar, Swagger UI, Redoc, Hey API pointed at a URL) can reach
90
- // them too; StaticFile on just the index file would 404 on every $ref it follows.
91
- r.Static("/docs", "./docs")
92
- {{/if}}
93
-
94
- api := r.Group("/{{apiPrefix}}")
95
- // go-scaffold:routes
96
- _ = api // dropped once `generate module` registers the first route
97
-
98
- srv := &http.Server{
99
- Addr: ":" + cfg.Port,
100
- Handler: r,
101
- ReadTimeout: 10 * time.Second,
102
- WriteTimeout: 10 * time.Second,
103
- IdleTimeout: 60 * time.Second,
104
- }
105
-
106
- // graceful shutdown: catch SIGINT/SIGTERM, let in-flight requests finish before closing
107
- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
108
- defer stop()
109
-
110
- go func() {
111
- logger.Info("listening", "addr", srv.Addr)
112
- if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
113
- logger.Error("server", "error", err)
114
- os.Exit(1)
115
- }
116
- }()
117
-
118
- <-ctx.Done()
119
- logger.Info("shutting down")
120
-
121
- shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
122
- defer cancel()
123
- if err := srv.Shutdown(shutdownCtx); err != nil {
124
- logger.Error("shutdown", "error", err)
125
- }
126
- // go-scaffold:shutdown
127
- logger.Info("stopped")
128
- }
129
-
130
- func parseLevel(s string) slog.Level {
131
- switch s {
132
- case "debug":
133
- return slog.LevelDebug
134
- case "warn":
135
- return slog.LevelWarn
136
- case "error":
137
- return slog.LevelError
138
- default:
139
- return slog.LevelInfo
140
- }
141
28
  }
@@ -0,0 +1,161 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "log/slog"
8
+ "net/http"
9
+ "os"
10
+ "os/signal"
11
+ "syscall"
12
+ "time"
13
+
14
+ "{{goModule}}/internal/platform/database"
15
+ "{{goModule}}/internal/shared/config"
16
+ "{{goModule}}/internal/shared/middleware"
17
+ // go-scaffold:imports
18
+
19
+ "github.com/gin-gonic/gin"
20
+ )
21
+
22
+ // run is this binary's composition root — the only function that sees every
23
+ // module and wires them to each other. Modules never import one another; a
24
+ // domain that needs another's behaviour declares a narrow interface and gets
25
+ // the concrete service from here (see docs/architect/patterns.md).
26
+ //
27
+ // It returns an error rather than calling os.Exit, so every defer below runs
28
+ // on the way out and main() holds the only exit in the binary.
29
+ //
30
+ // This is the file `go-scaffold generate module` and `go-scaffold add *` patch,
31
+ // at the // go-scaffold: markers. It grows with the system, which is what a
32
+ // composition root is for.
33
+ func run() error {
34
+ cfg := config.Load()
35
+
36
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
37
+ slog.SetDefault(logger)
38
+ // go-scaffold:config-checks
39
+
40
+ db, err := database.Open(cfg)
41
+ if err != nil {
42
+ return fmt.Errorf("open db: %w", err)
43
+ }
44
+
45
+ sqlDB, err := db.DB()
46
+ if err != nil {
47
+ return fmt.Errorf("db handle: %w", err)
48
+ }
49
+ // go-scaffold:platform-init
50
+
51
+ // AutoMigrate creates tables but never the schema they live in — each
52
+ // domain gets its own (see model.go's TableName), so it has to exist
53
+ // before AutoMigrate runs. The versioned SQL migrations create the same
54
+ // schemas with CREATE SCHEMA IF NOT EXISTS, so this only matters for dev.
55
+ // go-scaffold:schemas
56
+
57
+ if cfg.AutoMigrate {
58
+ // ponytail: AutoMigrate is for dev only (add-only, locks the table once data grows)
59
+ // prod: set AUTO_MIGRATE=false and run golang-migrate as versioned SQL instead
60
+ if err := db.AutoMigrate(
61
+ // go-scaffold:models
62
+ ); err != nil {
63
+ return fmt.Errorf("migrate: %w", err)
64
+ }
65
+ } else if err := database.CheckMigrationVersion(db); err != nil {
66
+ return fmt.Errorf("migration version check: %w", err)
67
+ }
68
+
69
+ r := gin.New()
70
+ // gin trusts every proxy by default, which makes c.ClientIP() — and so
71
+ // anything keyed on it, like the auth rate limiter — whatever
72
+ // X-Forwarded-For the caller felt like sending. Empty TRUSTED_PROXIES
73
+ // means trust nobody and use the peer address; set it to your load
74
+ // balancer's CIDRs when you actually run behind one.
75
+ if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
76
+ return fmt.Errorf("trusted proxies: %w", err)
77
+ }
78
+ r.Use(gin.Recovery(), middleware.CORS(cfg.CORSAllowedOrigins), middleware.RequestID(), middleware.Logger(logger), middleware.Error(!cfg.IsProd()))
79
+
80
+ // liveness = is the process up / readiness = ready for traffic (can it reach the DB)
81
+ r.GET("/livez", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
82
+ r.GET("/readyz", func(c *gin.Context) {
83
+ if err := sqlDB.PingContext(c.Request.Context()); err != nil {
84
+ c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})
85
+ return
86
+ }
87
+ // go-scaffold:readyz-checks
88
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
89
+ })
90
+ // go-scaffold:extra-routes
91
+ {{#if openapiDocs}}
92
+ // hand-written spec at docs/openapi.yaml, split across sibling files (common/, health/,
93
+ // <domain>/) via relative $ref — serve the whole tree under one prefix so a client that
94
+ // resolves $ref over HTTP (Scalar, Swagger UI, Redoc, Hey API pointed at a URL) can reach
95
+ // them too; StaticFile on just the index file would 404 on every $ref it follows.
96
+ //
97
+ // Not in production: this serves your whole API surface — every path,
98
+ // parameter and schema — to anyone who asks. Publish the spec deliberately
99
+ // (`make openapi-bundle`) rather than by leaving this on.
100
+ if !cfg.IsProd() {
101
+ r.Static("/docs", "./docs")
102
+ }
103
+ {{/if}}
104
+
105
+ api := r.Group("/{{apiPrefix}}")
106
+ // go-scaffold:routes
107
+ _ = api // dropped once `generate module` registers the first route
108
+
109
+ srv := &http.Server{
110
+ Addr: ":" + cfg.Port,
111
+ Handler: r,
112
+ ReadTimeout: 10 * time.Second,
113
+ WriteTimeout: 10 * time.Second,
114
+ IdleTimeout: 60 * time.Second,
115
+ }
116
+
117
+ // graceful shutdown: catch SIGINT/SIGTERM, let in-flight requests finish before closing
118
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
119
+ defer stop()
120
+
121
+ // Buffered: if ListenAndServe fails before anything reads this, the
122
+ // goroutine still exits instead of blocking forever on an unread channel.
123
+ serverErr := make(chan error, 1)
124
+ go func() {
125
+ logger.Info("listening", "addr", srv.Addr)
126
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
127
+ serverErr <- err
128
+ }
129
+ }()
130
+
131
+ // A port already in use used to kill the process from inside that
132
+ // goroutine, skipping every defer on the way out. Now it comes back here.
133
+ select {
134
+ case err := <-serverErr:
135
+ return fmt.Errorf("server: %w", err)
136
+ case <-ctx.Done():
137
+ }
138
+ logger.Info("shutting down")
139
+
140
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
141
+ defer cancel()
142
+ if err := srv.Shutdown(shutdownCtx); err != nil {
143
+ logger.Error("shutdown", "error", err)
144
+ }
145
+ // go-scaffold:shutdown
146
+ logger.Info("stopped")
147
+ return nil
148
+ }
149
+
150
+ func parseLevel(s string) slog.Level {
151
+ switch s {
152
+ case "debug":
153
+ return slog.LevelDebug
154
+ case "warn":
155
+ return slog.LevelWarn
156
+ case "error":
157
+ return slog.LevelError
158
+ default:
159
+ return slog.LevelInfo
160
+ }
161
+ }