@nakedev/go-scaffold 0.1.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 (73) hide show
  1. package/README.md +223 -0
  2. package/bin/go-scaffold.js +2 -0
  3. package/dist/commands/create.js +57 -0
  4. package/dist/commands/generate.js +97 -0
  5. package/dist/commands/method.js +70 -0
  6. package/dist/commands/remove.js +72 -0
  7. package/dist/index.js +138 -0
  8. package/dist/prompts/create-wizard.js +43 -0
  9. package/dist/prompts/generate-wizard.js +68 -0
  10. package/dist/templates/create-manifest.js +110 -0
  11. package/dist/templates/module-manifest.js +28 -0
  12. package/dist/types.js +2 -0
  13. package/dist/utils/config.js +53 -0
  14. package/dist/utils/main-patcher.js +59 -0
  15. package/dist/utils/marker-patch.js +63 -0
  16. package/dist/utils/method-patcher.js +271 -0
  17. package/dist/utils/migrations.js +17 -0
  18. package/dist/utils/module-paths.js +33 -0
  19. package/dist/utils/naming.js +160 -0
  20. package/dist/utils/openapi-patcher.js +47 -0
  21. package/dist/utils/template-renderer.js +51 -0
  22. package/package.json +49 -0
  23. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +83 -0
  24. package/templates/create/base/.env.example.hbs +7 -0
  25. package/templates/create/base/.github/workflows/ci.yml.hbs +46 -0
  26. package/templates/create/base/.gitignore.hbs +5 -0
  27. package/templates/create/base/.golangci.yml.hbs +32 -0
  28. package/templates/create/base/.vscode/settings.json.hbs +11 -0
  29. package/templates/create/base/AGENTS.md.hbs +68 -0
  30. package/templates/create/base/CLAUDE.md.hbs +1 -0
  31. package/templates/create/base/Makefile.hbs +93 -0
  32. package/templates/create/base/README.md.hbs +143 -0
  33. package/templates/create/base/cmd/api/main.go.hbs +116 -0
  34. package/templates/create/base/go.mod.hbs +11 -0
  35. package/templates/create/base/internal/platform/database/database.go.hbs +28 -0
  36. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +36 -0
  37. package/templates/create/base/internal/shared/config/config.go.hbs +46 -0
  38. package/templates/create/base/internal/shared/dberr/dberr.go.hbs +28 -0
  39. package/templates/create/base/internal/shared/httpx/httpx.go.hbs +37 -0
  40. package/templates/create/base/internal/shared/id/id.go.hbs +16 -0
  41. package/templates/create/base/internal/shared/middleware/error.go.hbs +33 -0
  42. package/templates/create/base/internal/shared/middleware/logger.go.hbs +23 -0
  43. package/templates/create/base/internal/shared/middleware/requestid.go.hbs +36 -0
  44. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +39 -0
  45. package/templates/create/base/migrations/.gitkeep.hbs +0 -0
  46. package/templates/create/features/docker-compose.yml.hbs +14 -0
  47. package/templates/create/features/docs/architecture.md.hbs +99 -0
  48. package/templates/create/features/docs/common/parameters.yaml.hbs +13 -0
  49. package/templates/create/features/docs/common/responses.yaml.hbs +20 -0
  50. package/templates/create/features/docs/common/schemas.yaml.hbs +23 -0
  51. package/templates/create/features/docs/health/health-livez.yaml.hbs +13 -0
  52. package/templates/create/features/docs/health/health-readyz.yaml.hbs +21 -0
  53. package/templates/create/features/docs/openapi.yaml.hbs +33 -0
  54. package/templates/create/features/docs/patterns.md.hbs +119 -0
  55. package/templates/create/features/docs/techstack.md.hbs +38 -0
  56. package/templates/generate/module/docs/collection.yaml.hbs +36 -0
  57. package/templates/generate/module/docs/item.yaml.hbs +37 -0
  58. package/templates/generate/module/docs/schemas.yaml.hbs +13 -0
  59. package/templates/generate/module/dto.go.hbs +29 -0
  60. package/templates/generate/module/errors.go.hbs +28 -0
  61. package/templates/generate/module/handler.go.hbs +103 -0
  62. package/templates/generate/module/handler_test.go.hbs +105 -0
  63. package/templates/generate/module/migration.down.sql.hbs +1 -0
  64. package/templates/generate/module/migration.up.sql.hbs +5 -0
  65. package/templates/generate/module/minimal/dto.go.hbs +26 -0
  66. package/templates/generate/module/minimal/handler.go.hbs +24 -0
  67. package/templates/generate/module/minimal/handler_test.go.hbs +70 -0
  68. package/templates/generate/module/minimal/service.go.hbs +45 -0
  69. package/templates/generate/module/minimal/service_test.go.hbs +54 -0
  70. package/templates/generate/module/model/model.go.hbs +20 -0
  71. package/templates/generate/module/repository.go.hbs +49 -0
  72. package/templates/generate/module/service.go.hbs +97 -0
  73. package/templates/generate/module/service_test.go.hbs +65 -0
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # @nakedev/go-scaffold
2
+
3
+ A CLI that scaffolds a Gin + GORM + PostgreSQL Go backend, then keeps
4
+ generating consistent domain modules into that project as it grows — the Go
5
+ counterpart to [nest-scaffold](../nest-scaffold).
6
+
7
+ You don't hand-wire a new domain into `cmd/api/main.go`, write the
8
+ handler/service/repository boilerplate, or decide error-handling conventions
9
+ each time — the CLI does that, and every module it generates follows the
10
+ same shape as the last one.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pnpm install
16
+ pnpm run build
17
+ ```
18
+
19
+ `npm link` / `pnpm link --global` may not put the binary on your `PATH`
20
+ depending on your machine's npm/pnpm global-bin config — rather than fight
21
+ that, run the CLI directly, or add a shell alias once:
22
+
23
+ ```bash
24
+ node bin/go-scaffold.js create my-api --defaults
25
+ # or: alias go-scaffold="node $(pwd)/bin/go-scaffold.js"
26
+ ```
27
+
28
+ The rest of this README uses the bare `go-scaffold ...` form for brevity —
29
+ substitute the `node bin/go-scaffold.js ...` form or your alias if you
30
+ haven't linked it.
31
+
32
+ ## Quick start
33
+
34
+ ```bash
35
+ go-scaffold create my-api
36
+ cd my-api
37
+ make docker-up # if you kept Docker + PostgreSQL
38
+ go mod tidy
39
+ make run
40
+ ```
41
+
42
+ Then grow the project without leaving the CLI:
43
+
44
+ ```bash
45
+ go-scaffold generate module orders
46
+ go-scaffold generate method orders approve --type patch
47
+ ```
48
+
49
+ ## Commands
50
+
51
+ ### `create <name>` — scaffold a new project
52
+
53
+ ```bash
54
+ go-scaffold create my-api # interactive wizard
55
+ go-scaffold create my-api --defaults # no prompts, CI-friendly
56
+ go-scaffold create my-api --defaults --no-docker --api-prefix beta
57
+ ```
58
+
59
+ Produces a **bare skeleton only** — `cmd/api`, the shared platform packages
60
+ (config/apperror/dberr/httpx/id/middleware/pagination), Docker+Postgres,
61
+ migrations folder, and the standards docs (`docs/architect/`, `AGENTS.md`,
62
+ `CLAUDE.md`, `.claude/skills/go-scaffold/`). No domain modules — add those
63
+ with `generate module`.
64
+
65
+ | Option | Effect |
66
+ |---|---|
67
+ | `--defaults` | Skip the wizard, use defaults (Docker on, OpenAPI docs on, prefix `v1`) |
68
+ | `--no-docker` | Skip `docker-compose.yml` (with `--defaults`) |
69
+ | `--no-openapi-docs` | Skip `docs/openapi.yaml` (with `--defaults`) |
70
+ | `--api-prefix <prefix>` | URL prefix every route is grouped under (with `--defaults`; default `v1`, `""` for none, `/`-separated segments like `api/v1` are fine) |
71
+
72
+ Without `--defaults`, an interactive wizard asks the same three questions.
73
+ The prefix is a single project-wide choice made once at `create` time —
74
+ there's no per-domain versioning (a domain that needs a real breaking change
75
+ gets a new domain package or a new DTO field, not a duplicated model pointed
76
+ at the same table under a different URL — see "Why no per-domain versioning"
77
+ below).
78
+
79
+ **Config file** — every `create` writes `go-scaffold.config.json` to the
80
+ project root; `generate` reads it back (or auto-detects from `go.mod` /
81
+ directory layout if missing).
82
+
83
+ ### `generate module <name>` (alias `m`) — add a domain module
84
+
85
+ ```bash
86
+ go-scaffold generate module orders # full CRUD (default)
87
+ go-scaffold generate module orders --no-full # minimal skeleton — add endpoints with `generate method`
88
+ ```
89
+
90
+ Full CRUD (default) scaffolds:
91
+
92
+ ```text
93
+ internal/app/orders/
94
+ ├── model/model.go # domain model + GORM table (id/created_at/updated_at — add real fields yourself; a folder so multi-table domains can add more files)
95
+ ├── dto.go # request/response structs (empty stubs — add real fields yourself)
96
+ ├── errors.go # ORDERS_NOT_FOUND / ORDERS_CONFLICT / ORDERS_HAS_REFERENCES
97
+ ├── repository.go # GORM data access
98
+ ├── service.go # business logic + repository interface (mockable)
99
+ ├── handler.go # Gin routes, registered under the project's API prefix
100
+ ├── service_test.go # unit test, fake repo
101
+ └── handler_test.go # integration test, real Postgres, tx rollback
102
+ ```
103
+
104
+ `--no-full` scaffolds the same `model`/`errors`/`repository` (so `generate
105
+ method` always has a full data-access surface to call), but `dto`/`service`/
106
+ `handler` start empty — no default CRUD, no routes, just the plumbing
107
+ (`Register()`, the `repository` interface, `wrapFindErr`) that `generate
108
+ method` patches into. Use it when a domain doesn't need the full REST
109
+ surface, or you'd rather add endpoints one at a time.
110
+
111
+ Both modes also:
112
+
113
+ - Register the module in `cmd/api/main.go` (via marker comments — see
114
+ `// go-scaffold:*` in that file) — full wires an actual route, minimal
115
+ wires an empty route group
116
+ - Add the model to the `AutoMigrate(...)` call
117
+ - Append `migrations/<seq>_create_<plural>.{up,down}.sql`
118
+
119
+ What it does **not** do: invent your fields or wire foreign keys between
120
+ domains — see `docs/architect/patterns.md` in the generated project for the
121
+ conventions to follow by hand.
122
+
123
+ ### `generate method <module> <name>` (alias `me`) — add one endpoint
124
+
125
+ ```bash
126
+ go-scaffold generate method orders approve --type patch
127
+ go-scaffold generate method orders findByStatus --type get --get-mode one --field status
128
+ go-scaffold g me orders findOverdue --type get --get-mode all
129
+ ```
130
+
131
+ Patches an *existing* module's `handler.go`/`service.go` in place via the
132
+ same marker-comment approach as `main.go` — never a whole new module. Never
133
+ overwrites a method with the same name; picks a different one or errors.
134
+
135
+ | Option | Effect |
136
+ |---|---|
137
+ | `--type <get\|post\|put\|patch\|delete>` | HTTP verb |
138
+ | `--get-mode <all\|one>` | For `get` only — list-style vs. single-record lookup |
139
+ | `--field <name>` | For `get --get-mode one` — the lookup field (e.g. `email`, `status`); can't be `id` |
140
+
141
+ | `--type` | Route | What's generated |
142
+ |---|---|---|
143
+ | `get --get-mode all` | `GET /<plural>/<kebab-name>` | reuses `FindAll` — TODO to add real filtering |
144
+ | `get --get-mode one --field <f>` | `GET /<plural>/<f>/:<f>` | a real `FindBy<F>` query added to the repository (+ its interface + `fakeRepo` test stub) |
145
+ | `post` | `POST /<plural>/<kebab-name>` | adds a body DTO; service is a TODO stub |
146
+ | `put` / `patch` | `<VERB> /<plural>/:id/<kebab-name>` | finds by id, TODO before saving (safe no-op until implemented) |
147
+ | `delete` | `DELETE /<plural>/:id/<kebab-name>` | TODO stub |
148
+
149
+ Business logic is always left as a `TODO`-marked stub that compiles and
150
+ returns a clean `500` rather than inventing behavior — see
151
+ `docs/architect/patterns.md` in the generated project.
152
+
153
+ `generate method` prints the route it added but does **not** touch
154
+ `docs/openapi.yaml` — endpoint-specific spec entries stay hand-written.
155
+
156
+ ### `remove module <name>` (alias `rm m`) — drop a domain
157
+
158
+ ```bash
159
+ go-scaffold remove module orders # confirms first
160
+ go-scaffold rm m orders --yes # skip the confirm
161
+ ```
162
+
163
+ The inverse of `generate module`: deletes `internal/app/<name>/` and reverses
164
+ everything that was wired up — the import/AutoMigrate/route in `main.go`, the
165
+ paths/schemas in `docs/openapi.yaml`, the per-module docs folder, and the
166
+ `create_<plural>` migration. Restores the `_ = api` placeholder if it was the
167
+ last module, so the project still builds. Use this instead of hand-deleting
168
+ the folder — a partial hand-delete leaves stale wiring that duplicates on the
169
+ next `generate module` (which would panic gin at startup).
170
+
171
+ ## Why no per-domain versioning
172
+
173
+ Earlier versions of this CLI let a domain live in a `v1/`/`v2/` folder with
174
+ its own route group and import alias, so the same domain name could exist
175
+ twice with different behavior. It was cut: the migration (and usually the
176
+ DB table) is shared between "versions" of the same domain, but each version
177
+ got its own physically-copied `model.go` — nothing stopped the two structs
178
+ from drifting apart. Verified against a real Postgres instance:
179
+ `AutoMigrate` silently accepted a column typed `int` in one version's model
180
+ and `float64` in the other for the *same* column, converging it to
181
+ `numeric` with no error — the two versions would then read/write the same
182
+ data with different, silently incompatible interpretations.
183
+
184
+ Instead, every route in a project is grouped under a single project-wide
185
+ `--api-prefix` (default `v1`) chosen once at `create` time. A domain that
186
+ needs a real breaking change gets a new domain package, or a new field on
187
+ the existing DTO — not a duplicated model pointed at a table it can drift
188
+ out of sync with.
189
+
190
+ ## Project structure produced by `create`
191
+
192
+ ```text
193
+ cmd/api/main.go
194
+ internal/
195
+ ├── platform/database/
196
+ ├── shared/{config,apperror,dberr,httpx,id,middleware,pagination}/
197
+ └── app/ # empty until you `generate module`
198
+ docs/
199
+ ├── architect/{architecture,patterns,techstack}.md
200
+ └── openapi.yaml + common/ + health/ # if openapi docs enabled
201
+ migrations/
202
+ .github/workflows/ci.yml # build, vet, gofmt check, golangci-lint, go test (with a Postgres service)
203
+ Makefile
204
+ .golangci.yml
205
+ .vscode/settings.json
206
+ AGENTS.md
207
+ CLAUDE.md
208
+ .claude/skills/go-scaffold/SKILL.md
209
+ go-scaffold.config.json
210
+ ```
211
+
212
+ ## Supported stack
213
+
214
+ | Package | Version |
215
+ |---|---|
216
+ | Gin | v1.10.0 |
217
+ | GORM + postgres driver | v1.25.12 / v1.5.9 |
218
+ | validator/v10 | v10.20.0 |
219
+ | google/uuid | v1.6.0 |
220
+
221
+ ## License
222
+
223
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../dist/index.js");
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createProject = createProject;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const template_renderer_1 = require("../utils/template-renderer");
11
+ const create_manifest_1 = require("../templates/create-manifest");
12
+ const config_1 = require("../utils/config");
13
+ const naming_1 = require("../utils/naming");
14
+ const create_wizard_1 = require("../prompts/create-wizard");
15
+ async function createProject(rawName, opts) {
16
+ const trimmed = (rawName ?? (await (0, create_wizard_1.promptProjectName)())).trim();
17
+ if (!trimmed)
18
+ throw new Error("project name is required");
19
+ (0, naming_1.assertValidGoModulePath)(trimmed);
20
+ const goModule = trimmed;
21
+ const projectName = trimmed.includes("/") ? trimmed.split("/").pop() : trimmed;
22
+ const projectDir = path_1.default.resolve(process.cwd(), projectName);
23
+ if (fs_extra_1.default.existsSync(projectDir) && fs_extra_1.default.readdirSync(projectDir).length > 0) {
24
+ throw new Error(`${projectDir} already exists and is not empty`);
25
+ }
26
+ let features;
27
+ let apiPrefix;
28
+ if (opts.defaults) {
29
+ features = { docker: opts.docker ?? true, openapiDocs: opts.openapiDocs ?? true };
30
+ apiPrefix = (0, naming_1.normalizeApiPrefix)(opts.apiPrefix ?? "v1");
31
+ const check = (0, naming_1.validateApiPrefix)(apiPrefix);
32
+ if (check !== true)
33
+ throw new Error(check);
34
+ }
35
+ else {
36
+ ({ features, apiPrefix } = await (0, create_wizard_1.runCreateWizard)());
37
+ }
38
+ const context = {
39
+ projectName,
40
+ goModule,
41
+ dbName: (0, naming_1.toDbName)(projectName),
42
+ apiPrefix,
43
+ ...features,
44
+ };
45
+ await fs_extra_1.default.ensureDir(projectDir);
46
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, create_manifest_1.CREATE_MANIFEST, context);
47
+ (0, template_renderer_1.gofmtTree)(projectDir);
48
+ (0, config_1.writeConfig)(projectDir, { projectName, goModule, apiPrefix, features });
49
+ console.log(picocolors_1.default.green(`\ncreated ${projectName}/`));
50
+ console.log(`\ncd ${projectName}`);
51
+ if (features.docker)
52
+ console.log(`make docker-up`);
53
+ console.log(`make db-create`);
54
+ console.log(`go mod tidy`);
55
+ console.log(`make run`);
56
+ console.log(picocolors_1.default.dim(`\nadd your first domain: go-scaffold generate module <name>`));
57
+ }
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.generateModule = generateModule;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const config_1 = require("../utils/config");
11
+ const naming_1 = require("../utils/naming");
12
+ const template_renderer_1 = require("../utils/template-renderer");
13
+ const module_manifest_1 = require("../templates/module-manifest");
14
+ const main_patcher_1 = require("../utils/main-patcher");
15
+ const openapi_patcher_1 = require("../utils/openapi-patcher");
16
+ const migrations_1 = require("../utils/migrations");
17
+ const generate_wizard_1 = require("../prompts/generate-wizard");
18
+ async function generateModule(rawName, opts, projectDir = process.cwd()) {
19
+ const config = (0, config_1.readConfig)(projectDir);
20
+ const naming = (0, naming_1.resolveModuleNaming)(rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
21
+ const modulePath = naming.pkg;
22
+ const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
23
+ if (fs_extra_1.default.existsSync(moduleDir) && fs_extra_1.default.readdirSync(moduleDir).length > 0) {
24
+ throw new Error(`${moduleDir} already exists — pick a different name or delete it first`);
25
+ }
26
+ const context = {
27
+ ...naming,
28
+ goModule: config.goModule,
29
+ dbName: (0, naming_1.toDbName)(config.projectName),
30
+ modulePath,
31
+ };
32
+ const moduleFiles = opts.full ? module_manifest_1.MODULE_FILES : module_manifest_1.MODULE_FILES_MINIMAL;
33
+ const moduleEntries = moduleFiles.map((f) => ({
34
+ template: f.template,
35
+ output: path_1.default.join("internal", "app", modulePath, f.output),
36
+ }));
37
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, moduleEntries, context);
38
+ // skip if a create_<plural> migration already exists — re-running after
39
+ // only the module folder was deleted shouldn't leave a duplicate migration.
40
+ const migrationsDir = path_1.default.join(projectDir, "migrations");
41
+ const migrationExists = fs_extra_1.default.existsSync(migrationsDir) &&
42
+ fs_extra_1.default.readdirSync(migrationsDir).some((f) => f.endsWith(`_create_${naming.plural}.up.sql`));
43
+ let seq = "";
44
+ if (!migrationExists) {
45
+ seq = (0, migrations_1.nextMigrationSeq)(migrationsDir);
46
+ const migrationEntries = [
47
+ {
48
+ template: "generate/module/migration.up.sql.hbs",
49
+ output: path_1.default.join("migrations", `${seq}_create_${naming.plural}.up.sql`),
50
+ },
51
+ {
52
+ template: "generate/module/migration.down.sql.hbs",
53
+ output: path_1.default.join("migrations", `${seq}_create_${naming.plural}.down.sql`),
54
+ },
55
+ ];
56
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, migrationEntries, context);
57
+ }
58
+ const mainGoPath = path_1.default.join(projectDir, "cmd", "api", "main.go");
59
+ (0, main_patcher_1.patchMainGo)(mainGoPath, {
60
+ goModule: config.goModule,
61
+ modulePath,
62
+ pkg: naming.pkg,
63
+ pascalName: naming.pascalName,
64
+ });
65
+ let docsMessage = "";
66
+ const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
67
+ if (opts.full && config.features.openapiDocs && fs_extra_1.default.existsSync(openapiPath)) {
68
+ const docsEntries = [
69
+ { template: "generate/module/docs/collection.yaml.hbs", output: path_1.default.join("docs", naming.plural, "collection.yaml") },
70
+ { template: "generate/module/docs/item.yaml.hbs", output: path_1.default.join("docs", naming.plural, "item.yaml") },
71
+ { template: "generate/module/docs/schemas.yaml.hbs", output: path_1.default.join("docs", naming.plural, "schemas.yaml") },
72
+ ];
73
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, docsEntries, context);
74
+ (0, openapi_patcher_1.patchOpenapiIndex)(openapiPath, naming, config.apiPrefix);
75
+ docsMessage = `\ndocs: docs/${naming.plural}/{collection,item,schemas}.yaml, wired into docs/openapi.yaml`;
76
+ }
77
+ (0, template_renderer_1.gofmtTree)(projectDir);
78
+ const routePath = config.apiPrefix ? `/${config.apiPrefix}/${naming.plural}` : `/${naming.plural}`;
79
+ console.log(picocolors_1.default.green(`\ngenerated internal/app/${modulePath}/`));
80
+ if (opts.full) {
81
+ console.log(`registered route ${routePath} in cmd/api/main.go`);
82
+ }
83
+ else {
84
+ console.log(`registered empty route group ${routePath} in cmd/api/main.go — ` +
85
+ `add endpoints with \`go-scaffold generate method ${naming.pkg} <name> --type ...\``);
86
+ }
87
+ if (seq) {
88
+ console.log(`migration: migrations/${seq}_create_${naming.plural}.{up,down}.sql`);
89
+ }
90
+ else {
91
+ console.log(`migration: reused existing migrations/*_create_${naming.plural}.{up,down}.sql`);
92
+ }
93
+ if (docsMessage)
94
+ console.log(docsMessage);
95
+ console.log(picocolors_1.default.dim(`\nnext: add real fields to model.go/dto.go, run \`go build ./...\`, then apply the migration ` +
96
+ `(AUTO_MIGRATE=true handles it in dev, or \`migrate -path migrations -database "$DB_DSN" up\`)`));
97
+ }
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.generateMethod = generateMethod;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const config_1 = require("../utils/config");
11
+ const naming_1 = require("../utils/naming");
12
+ const method_patcher_1 = require("../utils/method-patcher");
13
+ const template_renderer_1 = require("../utils/template-renderer");
14
+ const generate_wizard_1 = require("../prompts/generate-wizard");
15
+ // the actual URL the new route answers on — printed so the user can add the
16
+ // matching openapi.yaml entry by hand (methods are deliberately not wired into
17
+ // the spec; see the note in docs/openapi.yaml). Mirrors the paths registered
18
+ // in method-patcher.ts.
19
+ function routeHint(naming, method, type, apiPrefix, getMode, field) {
20
+ const base = apiPrefix ? `/${apiPrefix}/${naming.plural}` : `/${naming.plural}`;
21
+ if (type === "get" && getMode === "all")
22
+ return `GET ${base}/${method.pathSegment}`;
23
+ if (type === "get")
24
+ return `GET ${base}/${(0, naming_1.toDbName)(field ?? "")}/{${(0, naming_1.toCamelCase)(field ?? "")}}`;
25
+ if (type === "post")
26
+ return `POST ${base}/${method.pathSegment}`;
27
+ if (type === "delete")
28
+ return `DELETE ${base}/{id}/${method.pathSegment}`;
29
+ return `${type.toUpperCase()} ${base}/{id}/${method.pathSegment}`;
30
+ }
31
+ async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = process.cwd()) {
32
+ const config = (0, config_1.readConfig)(projectDir);
33
+ const naming = (0, naming_1.resolveModuleNaming)(moduleNameArg ?? (await (0, generate_wizard_1.promptModuleName)()));
34
+ const modulePath = naming.pkg;
35
+ const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
36
+ const paths = {
37
+ dtoPath: path_1.default.join(moduleDir, "dto.go"),
38
+ repositoryPath: path_1.default.join(moduleDir, "repository.go"),
39
+ servicePath: path_1.default.join(moduleDir, "service.go"),
40
+ handlerPath: path_1.default.join(moduleDir, "handler.go"),
41
+ serviceTestPath: path_1.default.join(moduleDir, "service_test.go"),
42
+ };
43
+ for (const p of Object.values(paths)) {
44
+ if (!fs_extra_1.default.existsSync(p)) {
45
+ throw new Error(`module "${naming.pkg}" not found at ${moduleDir} (missing ${path_1.default.basename(p)}) — ` +
46
+ `run \`go-scaffold generate module ${naming.pkg}\` first`);
47
+ }
48
+ }
49
+ const type = opts.type ?? (await (0, generate_wizard_1.promptMethodType)());
50
+ if (opts.getMode && type !== "get") {
51
+ throw new Error("--get-mode can only be used with --type get");
52
+ }
53
+ if (opts.field && !(type === "get" && opts.getMode === "one")) {
54
+ throw new Error("--field can only be used with --type get --get-mode one");
55
+ }
56
+ const getMode = type === "get" ? opts.getMode ?? (await (0, generate_wizard_1.promptGetMode)()) : undefined;
57
+ const field = type === "get" && getMode === "one" ? opts.field ?? (await (0, generate_wizard_1.promptLookupField)()) : undefined;
58
+ // field becomes a Go param name (`func (...)(ctx, <field> string)`)
59
+ if (field)
60
+ (0, naming_1.assertNotGoKeyword)((0, naming_1.toCamelCase)(field), "lookup field");
61
+ const method = (0, naming_1.resolveMethodNaming)(methodNameArg ?? (await (0, generate_wizard_1.promptMethodName)()));
62
+ (0, method_patcher_1.patchMethod)(paths, naming, method, { type, getMode, field }, config.goModule);
63
+ (0, template_renderer_1.gofmtTree)(projectDir);
64
+ console.log(picocolors_1.default.green(`\nadded "${method.name}" to internal/app/${modulePath}/`));
65
+ console.log(`route: ${routeHint(naming, method, type, config.apiPrefix, getMode, field)}`);
66
+ if (config.features.openapiDocs) {
67
+ console.log(picocolors_1.default.yellow(`docs: add this route to docs/openapi.yaml by hand — \`generate method\` doesn't touch the spec`));
68
+ }
69
+ console.log(picocolors_1.default.dim(`\nnext: fill in the TODO in service.go, then \`go build ./...\` / \`go test ./...\``));
70
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.removeModule = removeModule;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const prompts_1 = require("@inquirer/prompts");
11
+ const config_1 = require("../utils/config");
12
+ const naming_1 = require("../utils/naming");
13
+ const main_patcher_1 = require("../utils/main-patcher");
14
+ const openapi_patcher_1 = require("../utils/openapi-patcher");
15
+ const template_renderer_1 = require("../utils/template-renderer");
16
+ const generate_wizard_1 = require("../prompts/generate-wizard");
17
+ // removeModule is the inverse of generateModule: deletes the domain package and
18
+ // pulls its wiring back out of main.go / openapi.yaml / migrations, so dropping
19
+ // a domain is one command instead of hand-editing 3+ files (the error-prone
20
+ // path that produced the duplicate-registration bug in the first place).
21
+ async function removeModule(rawName, opts, projectDir = process.cwd()) {
22
+ const config = (0, config_1.readConfig)(projectDir);
23
+ const naming = (0, naming_1.resolveModuleNaming)(rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
24
+ const modulePath = naming.pkg;
25
+ const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
26
+ if (!fs_extra_1.default.existsSync(moduleDir)) {
27
+ throw new Error(`module "${naming.pkg}" not found at internal/app/${modulePath} — nothing to remove`);
28
+ }
29
+ if (!opts.yes) {
30
+ const ok = await (0, prompts_1.confirm)({
31
+ message: `Remove module "${naming.pkg}"? Deletes internal/app/${modulePath}/, its migration, and un-wires main.go/openapi.yaml`,
32
+ default: false,
33
+ });
34
+ if (!ok)
35
+ throw new Error("removal cancelled");
36
+ }
37
+ // 1. the domain package
38
+ fs_extra_1.default.removeSync(moduleDir);
39
+ // 2. main.go wiring
40
+ (0, main_patcher_1.unpatchMainGo)(path_1.default.join(projectDir, "cmd", "api", "main.go"), {
41
+ goModule: config.goModule,
42
+ modulePath,
43
+ pkg: naming.pkg,
44
+ pascalName: naming.pascalName,
45
+ });
46
+ // 3. openapi index + per-module docs
47
+ const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
48
+ if (fs_extra_1.default.existsSync(openapiPath)) {
49
+ (0, openapi_patcher_1.unpatchOpenapiIndex)(openapiPath, naming, config.apiPrefix);
50
+ fs_extra_1.default.removeSync(path_1.default.join(projectDir, "docs", naming.plural));
51
+ }
52
+ // 4. migration files (up + down)
53
+ const migrationsDir = path_1.default.join(projectDir, "migrations");
54
+ const removedMigrations = [];
55
+ if (fs_extra_1.default.existsSync(migrationsDir)) {
56
+ for (const f of fs_extra_1.default.readdirSync(migrationsDir)) {
57
+ if (f.endsWith(`_create_${naming.plural}.up.sql`) || f.endsWith(`_create_${naming.plural}.down.sql`)) {
58
+ fs_extra_1.default.removeSync(path_1.default.join(migrationsDir, f));
59
+ removedMigrations.push(f);
60
+ }
61
+ }
62
+ }
63
+ (0, template_renderer_1.gofmtTree)(projectDir);
64
+ console.log(picocolors_1.default.green(`\nremoved module "${naming.pkg}"`));
65
+ console.log(` deleted internal/app/${modulePath}/`);
66
+ console.log(` un-wired cmd/api/main.go`);
67
+ if (fs_extra_1.default.existsSync(openapiPath))
68
+ console.log(` un-wired docs/openapi.yaml + deleted docs/${naming.plural}/`);
69
+ if (removedMigrations.length)
70
+ console.log(` deleted ${removedMigrations.join(", ")}`);
71
+ console.log(picocolors_1.default.yellow(`\nnote: the ${naming.plural} table (if migrated) is untouched — drop it yourself, or add a down migration`));
72
+ }
package/dist/index.js ADDED
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const commander_1 = require("commander");
8
+ const prompts_1 = require("@inquirer/prompts");
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const create_1 = require("./commands/create");
11
+ const generate_1 = require("./commands/generate");
12
+ const method_1 = require("./commands/method");
13
+ const remove_1 = require("./commands/remove");
14
+ const program = new commander_1.Command();
15
+ program
16
+ .name("go-scaffold")
17
+ .description("Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard")
18
+ .version("0.1.0");
19
+ program
20
+ .command("create [name]")
21
+ .alias("c")
22
+ .description("scaffold a new project (bare skeleton — add domains with `generate module`)")
23
+ .option("--defaults", "skip the wizard, use defaults (for CI/scripting)")
24
+ .option("--no-docker", "skip docker-compose.yml (only applies with --defaults)")
25
+ .option("--no-openapi-docs", "skip docs/openapi.yaml (only applies with --defaults)")
26
+ .option("--api-prefix <prefix>", 'URL prefix every route is grouped under (default "v1"; pass "" for none)')
27
+ .action(async (name, opts) => {
28
+ try {
29
+ await (0, create_1.createProject)(name, {
30
+ defaults: opts.defaults,
31
+ docker: opts.docker,
32
+ openapiDocs: opts.openapiDocs,
33
+ apiPrefix: opts.apiPrefix,
34
+ });
35
+ }
36
+ catch (err) {
37
+ console.error(picocolors_1.default.red(err.message));
38
+ process.exitCode = 1;
39
+ }
40
+ });
41
+ const generate = program
42
+ .command("generate")
43
+ .alias("g")
44
+ .description("add to an existing go-scaffold project")
45
+ .action(async () => {
46
+ // bare `generate`/`g` — ask which target, then delegate (each subcommand
47
+ // still prompts for anything else it's missing, e.g. the name).
48
+ try {
49
+ const target = await (0, prompts_1.select)({
50
+ message: "What do you want to generate?",
51
+ choices: [
52
+ { name: "Module (full CRUD domain)", value: "module" },
53
+ { name: "Method (add one endpoint to an existing module)", value: "method" },
54
+ ],
55
+ });
56
+ if (target === "module") {
57
+ await (0, generate_1.generateModule)(undefined, { full: true });
58
+ }
59
+ else {
60
+ await (0, method_1.generateMethod)(undefined, undefined, {});
61
+ }
62
+ }
63
+ catch (err) {
64
+ console.error(picocolors_1.default.red(err.message));
65
+ process.exitCode = 1;
66
+ }
67
+ });
68
+ generate
69
+ .command("module [name]")
70
+ .alias("m")
71
+ .description("scaffold a domain module — full CRUD by default, or a bare skeleton with --no-full")
72
+ .option("--no-full", "minimal skeleton (model/errors/repository, no default CRUD) — add endpoints one at a time with `generate method`")
73
+ .action(async (name, opts) => {
74
+ try {
75
+ await (0, generate_1.generateModule)(name, { full: opts.full });
76
+ }
77
+ catch (err) {
78
+ console.error(picocolors_1.default.red(err.message));
79
+ process.exitCode = 1;
80
+ }
81
+ });
82
+ generate
83
+ .command("method [module] [name]")
84
+ .alias("me")
85
+ .description("add one endpoint to an existing module (patches handler/service in place)")
86
+ .option("--type <type>", "get|post|put|patch|delete")
87
+ .option("--get-mode <mode>", "for --type get only: all|one")
88
+ .option("--field <name>", "for --type get --get-mode one: the lookup field (e.g. email, status)")
89
+ .action(async (moduleName, methodName, opts) => {
90
+ try {
91
+ const type = opts.type;
92
+ if (type && !["get", "post", "put", "patch", "delete"].includes(type)) {
93
+ throw new Error(`--type must be one of: get, post, put, patch, delete (got "${type}")`);
94
+ }
95
+ const getMode = opts.getMode;
96
+ if (getMode && !["all", "one"].includes(getMode)) {
97
+ throw new Error(`--get-mode must be "all" or "one" (got "${getMode}")`);
98
+ }
99
+ await (0, method_1.generateMethod)(moduleName, methodName, {
100
+ type,
101
+ getMode,
102
+ field: opts.field,
103
+ });
104
+ }
105
+ catch (err) {
106
+ console.error(picocolors_1.default.red(err.message));
107
+ process.exitCode = 1;
108
+ }
109
+ });
110
+ const remove = program
111
+ .command("remove")
112
+ .alias("rm")
113
+ .description("remove a domain module (deletes the package + un-wires main.go/openapi.yaml/migration)")
114
+ .action(async () => {
115
+ // bare `remove`/`rm` — module is the only target, so prompt for the name
116
+ try {
117
+ await (0, remove_1.removeModule)(undefined, {});
118
+ }
119
+ catch (err) {
120
+ console.error(picocolors_1.default.red(err.message));
121
+ process.exitCode = 1;
122
+ }
123
+ });
124
+ remove
125
+ .command("module [name]")
126
+ .alias("m")
127
+ .description("delete a domain module and reverse everything `generate module` wired up")
128
+ .option("-y, --yes", "skip the confirmation prompt")
129
+ .action(async (name, opts) => {
130
+ try {
131
+ await (0, remove_1.removeModule)(name, { yes: opts.yes });
132
+ }
133
+ catch (err) {
134
+ console.error(picocolors_1.default.red(err.message));
135
+ process.exitCode = 1;
136
+ }
137
+ });
138
+ program.parseAsync(process.argv);