@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
@@ -0,0 +1,271 @@
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.patchMethod = patchMethod;
7
+ exports.markersPresent = markersPresent;
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const marker_patch_1 = require("./marker-patch");
10
+ const naming_1 = require("./naming");
11
+ const DTO_MARKER = "// go-scaffold:dto";
12
+ const REPO_INTERFACE_MARKER = "// go-scaffold:repository-interface";
13
+ const REPO_IMPL_MARKER = "// go-scaffold:repository-methods";
14
+ const SERVICE_MARKER = "// go-scaffold:service-methods";
15
+ const HANDLER_ROUTES_MARKER = "// go-scaffold:handler-routes";
16
+ const HANDLER_FUNCS_MARKER = "// go-scaffold:handler-funcs";
17
+ const FAKE_REPO_MARKER = "// go-scaffold:fake-repo-methods";
18
+ const UNUSED_G_LINE = "\t_ = g\n";
19
+ // writeHandler ensures whatever packages the new handler code references are
20
+ // imported (a minimal module starts with only "gin" imported) and drops the
21
+ // `_ = g` placeholder once a real route makes it unnecessary — a no-op on a
22
+ // full module, which already imports everything and never has that line.
23
+ function writeHandler(handlerPath, content, goModule, needs) {
24
+ for (const pkg of needs) {
25
+ const importPath = pkg.startsWith("net/") ? pkg : `${goModule}/internal/shared/${pkg}`;
26
+ content = (0, marker_patch_1.ensureImport)(content, importPath);
27
+ }
28
+ fs_extra_1.default.writeFileSync(handlerPath, content.replace(UNUSED_G_LINE, ""));
29
+ }
30
+ function assertNotDuplicate(content, needle, what) {
31
+ if (content.includes(needle)) {
32
+ throw new Error(`${what} already exists — pick a different method name`);
33
+ }
34
+ }
35
+ function routeCall(type) {
36
+ return { get: "GET", post: "POST", put: "PUT", patch: "PATCH", delete: "DELETE" }[type];
37
+ }
38
+ function patchMethod(paths, naming, method, opts, goModule) {
39
+ const handlerSig = `func (h *Handler) ${method.handlerName}(`;
40
+ const serviceSig = `func (s *Service) ${method.pascalName}(`;
41
+ const handlerContent = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
42
+ const serviceContent = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
43
+ assertNotDuplicate(handlerContent, handlerSig, `handler method "${method.handlerName}"`);
44
+ assertNotDuplicate(serviceContent, serviceSig, `service method "${method.pascalName}"`);
45
+ if (opts.type === "get" && opts.getMode === "all") {
46
+ patchGetAll(paths, naming, method, goModule);
47
+ }
48
+ else if (opts.type === "get") {
49
+ if (!opts.field)
50
+ throw new Error("--field is required for --type get --get-mode one");
51
+ if (opts.field.toLowerCase() === "id") {
52
+ throw new Error('--field cannot be "id" — GET /:id already exists as the default lookup');
53
+ }
54
+ patchGetOne(paths, naming, method, opts.field, goModule);
55
+ }
56
+ else if (opts.type === "post") {
57
+ patchPost(paths, naming, method, goModule);
58
+ }
59
+ else if (opts.type === "put" || opts.type === "patch") {
60
+ patchResourceAction(paths, naming, method, opts.type, goModule);
61
+ }
62
+ else {
63
+ patchDelete(paths, method, goModule);
64
+ }
65
+ }
66
+ function patchGetAll(paths, naming, method, goModule) {
67
+ let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
68
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_ROUTES_MARKER, `g.GET("/${method.pathSegment}", h.${method.handlerName})`);
69
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_FUNCS_MARKER, [
70
+ `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
71
+ `\tp := pagination.Parse(c)`,
72
+ `\titems, err := h.svc.${method.pascalName}(c.Request.Context(), p.Limit, p.Offset)`,
73
+ `\tif err != nil {`,
74
+ `\t\tc.Error(err)`,
75
+ `\t\treturn`,
76
+ `\t}`,
77
+ `\tout := make([]response, len(items))`,
78
+ `\tfor i := range items {`,
79
+ `\t\tout[i] = toResponse(&items[i])`,
80
+ `\t}`,
81
+ `\tc.JSON(http.StatusOK, p.Response(out))`,
82
+ `}`,
83
+ ``,
84
+ ].join("\n"));
85
+ writeHandler(paths.handlerPath, handler, goModule, ["net/http", "pagination"]);
86
+ let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
87
+ service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
88
+ `func (s *Service) ${method.pascalName}(ctx context.Context, limit, offset int) ([]model.${naming.pascalName}, error) {`,
89
+ `\t// TODO: add real filtering for "${method.name}" — currently reuses FindAll`,
90
+ `\titems, err := s.repo.FindAll(ctx, limit, offset)`,
91
+ `\tif err != nil {`,
92
+ `\t\treturn nil, apperror.NewInternal()`,
93
+ `\t}`,
94
+ `\treturn items, nil`,
95
+ `}`,
96
+ ``,
97
+ ].join("\n"));
98
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
99
+ }
100
+ function patchGetOne(paths, naming, method, rawField, goModule) {
101
+ const fieldParam = (0, naming_1.toCamelCase)(rawField);
102
+ const fieldPascal = (0, naming_1.toPascalCase)(rawField);
103
+ const fieldColumn = (0, naming_1.toDbName)(rawField);
104
+ let repo = fs_extra_1.default.readFileSync(paths.repositoryPath, "utf8");
105
+ assertNotDuplicate(repo, `FindBy${fieldPascal}(`, `repository method "FindBy${fieldPascal}"`);
106
+ repo = (0, marker_patch_1.insertBeforeMarker)(repo, REPO_IMPL_MARKER, [
107
+ `func (r *Repository) FindBy${fieldPascal}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error) {`,
108
+ `\tvar m model.${naming.pascalName}`,
109
+ `\t// TODO: confirm "${fieldColumn}" is the real column name for ${fieldParam}`,
110
+ `\tif err := r.db.WithContext(ctx).First(&m, "${fieldColumn} = ?", ${fieldParam}).Error; err != nil {`,
111
+ `\t\treturn nil, err`,
112
+ `\t}`,
113
+ `\treturn &m, nil`,
114
+ `}`,
115
+ ``,
116
+ ].join("\n"));
117
+ fs_extra_1.default.writeFileSync(paths.repositoryPath, repo);
118
+ let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
119
+ service = (0, marker_patch_1.insertBeforeMarker)(service, REPO_INTERFACE_MARKER, `FindBy${fieldPascal}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error)`);
120
+ // the interface just grew, so the hand-written fakeRepo mock in
121
+ // service_test.go needs a matching stub or the test file stops compiling
122
+ let serviceTest = fs_extra_1.default.readFileSync(paths.serviceTestPath, "utf8");
123
+ serviceTest = (0, marker_patch_1.insertBeforeMarker)(serviceTest, FAKE_REPO_MARKER, [
124
+ // nolint: only matters for a minimal module (fakeRepo never instantiated
125
+ // yet, so unused flags every one of its methods individually); harmless
126
+ // no-op on a full module where fakeRepo is already in use.
127
+ `//nolint:unused`,
128
+ `func (f *fakeRepo) FindBy${fieldPascal}(context.Context, string) (*model.${naming.pascalName}, error) {`,
129
+ `\tif f.err != nil {`,
130
+ `\t\treturn nil, f.err`,
131
+ `\t}`,
132
+ `\treturn f.m, nil`,
133
+ `}`,
134
+ ``,
135
+ ].join("\n"));
136
+ fs_extra_1.default.writeFileSync(paths.serviceTestPath, serviceTest);
137
+ service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
138
+ `func (s *Service) ${method.pascalName}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error) {`,
139
+ `\tm, err := s.repo.FindBy${fieldPascal}(ctx, ${fieldParam})`,
140
+ `\tif err != nil {`,
141
+ `\t\treturn nil, wrapFindErr(err)`,
142
+ `\t}`,
143
+ `\treturn m, nil`,
144
+ `}`,
145
+ ``,
146
+ ].join("\n"));
147
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
148
+ let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
149
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_ROUTES_MARKER, `g.GET("/${fieldColumn}/:${fieldParam}", h.${method.handlerName})`);
150
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_FUNCS_MARKER, [
151
+ `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
152
+ `\t${fieldParam} := c.Param("${fieldParam}")`,
153
+ `\tm, err := h.svc.${method.pascalName}(c.Request.Context(), ${fieldParam})`,
154
+ `\tif err != nil {`,
155
+ `\t\tc.Error(err)`,
156
+ `\t\treturn`,
157
+ `\t}`,
158
+ `\tc.JSON(http.StatusOK, toResponse(m))`,
159
+ `}`,
160
+ ``,
161
+ ].join("\n"));
162
+ writeHandler(paths.handlerPath, handler, goModule, ["net/http"]);
163
+ }
164
+ function patchPost(paths, naming, method, goModule) {
165
+ const inputName = `${method.pascalName}Input`;
166
+ let dto = fs_extra_1.default.readFileSync(paths.dtoPath, "utf8");
167
+ assertNotDuplicate(dto, `type ${inputName} struct`, `DTO "${inputName}"`);
168
+ dto = (0, marker_patch_1.insertBeforeMarker)(dto, DTO_MARKER, [`type ${inputName} struct {`, `\t// TODO: add request fields`, `}`, ``].join("\n"));
169
+ fs_extra_1.default.writeFileSync(paths.dtoPath, dto);
170
+ let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
171
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_ROUTES_MARKER, `g.POST("/${method.pathSegment}", h.${method.handlerName})`);
172
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_FUNCS_MARKER, [
173
+ `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
174
+ `\tvar in ${inputName}`,
175
+ `\tif err := c.ShouldBindJSON(&in); err != nil {`,
176
+ `\t\tc.Error(httpx.BindErr(err))`,
177
+ `\t\treturn`,
178
+ `\t}`,
179
+ `\tm, err := h.svc.${method.pascalName}(c.Request.Context(), in)`,
180
+ `\tif err != nil {`,
181
+ `\t\tc.Error(err)`,
182
+ `\t\treturn`,
183
+ `\t}`,
184
+ `\tc.JSON(http.StatusCreated, toResponse(m))`,
185
+ `}`,
186
+ ``,
187
+ ].join("\n"));
188
+ writeHandler(paths.handlerPath, handler, goModule, ["net/http", "httpx"]);
189
+ let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
190
+ service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
191
+ `func (s *Service) ${method.pascalName}(ctx context.Context, in ${inputName}) (*model.${naming.pascalName}, error) {`,
192
+ `\t// TODO: implement "${method.name}" — this stub does nothing yet`,
193
+ `\t_ = in`,
194
+ `\treturn nil, apperror.NewInternal()`,
195
+ `}`,
196
+ ``,
197
+ ].join("\n"));
198
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
199
+ }
200
+ function patchResourceAction(paths, naming, method, type, goModule) {
201
+ let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
202
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_ROUTES_MARKER, `g.${routeCall(type)}("/:id/${method.pathSegment}", h.${method.handlerName})`);
203
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_FUNCS_MARKER, [
204
+ `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
205
+ `\tid, ok := httpx.ParseID(c)`,
206
+ `\tif !ok {`,
207
+ `\t\treturn`,
208
+ `\t}`,
209
+ `\tm, err := h.svc.${method.pascalName}(c.Request.Context(), id)`,
210
+ `\tif err != nil {`,
211
+ `\t\tc.Error(err)`,
212
+ `\t\treturn`,
213
+ `\t}`,
214
+ `\tc.JSON(http.StatusOK, toResponse(m))`,
215
+ `}`,
216
+ ``,
217
+ ].join("\n"));
218
+ writeHandler(paths.handlerPath, handler, goModule, ["net/http", "httpx"]);
219
+ let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
220
+ service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
221
+ `func (s *Service) ${method.pascalName}(ctx context.Context, id uuid.UUID) (*model.${naming.pascalName}, error) {`,
222
+ `\tm, err := s.repo.FindByID(ctx, id)`,
223
+ `\tif err != nil {`,
224
+ `\t\treturn nil, wrapFindErr(err)`,
225
+ `\t}`,
226
+ `\t// TODO: implement "${method.name}" — currently a no-op save`,
227
+ `\tif err := s.repo.Update(ctx, m); err != nil {`,
228
+ `\t\treturn nil, apperror.NewInternal()`,
229
+ `\t}`,
230
+ `\treturn m, nil`,
231
+ `}`,
232
+ ``,
233
+ ].join("\n"));
234
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
235
+ }
236
+ function patchDelete(paths, method, goModule) {
237
+ let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
238
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_ROUTES_MARKER, `g.DELETE("/:id/${method.pathSegment}", h.${method.handlerName})`);
239
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, HANDLER_FUNCS_MARKER, [
240
+ `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
241
+ `\tid, ok := httpx.ParseID(c)`,
242
+ `\tif !ok {`,
243
+ `\t\treturn`,
244
+ `\t}`,
245
+ `\tif err := h.svc.${method.pascalName}(c.Request.Context(), id); err != nil {`,
246
+ `\t\tc.Error(err)`,
247
+ `\t\treturn`,
248
+ `\t}`,
249
+ `\tc.Status(http.StatusNoContent)`,
250
+ `}`,
251
+ ``,
252
+ ].join("\n"));
253
+ writeHandler(paths.handlerPath, handler, goModule, ["net/http", "httpx"]);
254
+ let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
255
+ service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
256
+ `func (s *Service) ${method.pascalName}(ctx context.Context, id uuid.UUID) error {`,
257
+ `\t// TODO: implement "${method.name}" — this stub does nothing yet`,
258
+ `\treturn apperror.NewInternal()`,
259
+ `}`,
260
+ ``,
261
+ ].join("\n"));
262
+ fs_extra_1.default.writeFileSync(paths.servicePath, service);
263
+ }
264
+ function markersPresent(handlerPath, servicePath) {
265
+ const handler = fs_extra_1.default.readFileSync(handlerPath, "utf8");
266
+ const service = fs_extra_1.default.readFileSync(servicePath, "utf8");
267
+ return ((0, marker_patch_1.hasMarker)(handler, HANDLER_ROUTES_MARKER) &&
268
+ (0, marker_patch_1.hasMarker)(handler, HANDLER_FUNCS_MARKER) &&
269
+ (0, marker_patch_1.hasMarker)(service, SERVICE_MARKER) &&
270
+ (0, marker_patch_1.hasMarker)(service, REPO_INTERFACE_MARKER));
271
+ }
@@ -0,0 +1,17 @@
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.nextMigrationSeq = nextMigrationSeq;
7
+ const fs_extra_1 = __importDefault(require("fs-extra"));
8
+ // golang-migrate sequential numbering: 000001, 000002, ...
9
+ function nextMigrationSeq(migrationsDir) {
10
+ const files = fs_extra_1.default.existsSync(migrationsDir) ? fs_extra_1.default.readdirSync(migrationsDir) : [];
11
+ const nums = files
12
+ .map((f) => f.match(/^(\d+)_/))
13
+ .filter((m) => m !== null)
14
+ .map((m) => parseInt(m[1], 10));
15
+ const next = (nums.length ? Math.max(...nums) : 0) + 1;
16
+ return String(next).padStart(6, "0");
17
+ }
@@ -0,0 +1,33 @@
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.listVersionFolders = listVersionFolders;
7
+ exports.versionsContainingModule = versionsContainingModule;
8
+ exports.nextVersionName = nextVersionName;
9
+ const fs_extra_1 = __importDefault(require("fs-extra"));
10
+ const path_1 = __importDefault(require("path"));
11
+ // folder-based domain versioning lays modules out as internal/app/v<n>/<pkg>.
12
+ // listVersionFolders returns the existing v<n> dirs, sorted ascending.
13
+ function listVersionFolders(projectDir) {
14
+ const appDir = path_1.default.join(projectDir, "internal", "app");
15
+ if (!fs_extra_1.default.existsSync(appDir))
16
+ return [];
17
+ return fs_extra_1.default
18
+ .readdirSync(appDir, { withFileTypes: true })
19
+ .filter((e) => e.isDirectory() && /^v\d+$/.test(e.name))
20
+ .map((e) => e.name)
21
+ .sort((a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10));
22
+ }
23
+ // version folders that actually contain the given module (identified by its
24
+ // handler.go) — a module can legitimately live in more than one version at
25
+ // once (that's the point of versioning), so callers that need exactly one
26
+ // (generate method, remove module) must pick among the matches.
27
+ function versionsContainingModule(projectDir, pkg) {
28
+ return listVersionFolders(projectDir).filter((v) => fs_extra_1.default.existsSync(path_1.default.join(projectDir, "internal", "app", v, pkg, "handler.go")));
29
+ }
30
+ function nextVersionName(versions) {
31
+ const max = versions.reduce((m, v) => Math.max(m, parseInt(v.slice(1), 10) || 0), 0);
32
+ return `v${max + 1}`;
33
+ }
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pluralize = pluralize;
4
+ exports.toPascalCase = toPascalCase;
5
+ exports.toPackageName = toPackageName;
6
+ exports.toCamelCase = toCamelCase;
7
+ exports.toKebabCase = toKebabCase;
8
+ exports.toDbName = toDbName;
9
+ exports.validateGoModulePath = validateGoModulePath;
10
+ exports.assertValidGoModulePath = assertValidGoModulePath;
11
+ exports.assertNotGoKeyword = assertNotGoKeyword;
12
+ exports.validateModuleName = validateModuleName;
13
+ exports.normalizeApiPrefix = normalizeApiPrefix;
14
+ exports.validateApiPrefix = validateApiPrefix;
15
+ exports.resolveModuleNaming = resolveModuleNaming;
16
+ exports.resolveMethodNaming = resolveMethodNaming;
17
+ // ponytail: heuristic pluralizer, not a dependency — covers common English
18
+ // nouns (order/user/category/address); irregular plurals still need a manual
19
+ // rename in the generated file, add a dictionary if that becomes frequent.
20
+ function pluralize(word) {
21
+ if (/[sxz]$/.test(word) || /[^aeiou](ch|sh)$/.test(word))
22
+ return word + "es";
23
+ if (/[^aeiou]y$/.test(word))
24
+ return word.slice(0, -1) + "ies";
25
+ if (word.endsWith("s"))
26
+ return word;
27
+ return word + "s";
28
+ }
29
+ function toPascalCase(value) {
30
+ return value
31
+ .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ""))
32
+ .replace(/^(.)/, (char) => char.toUpperCase());
33
+ }
34
+ // Go package names: lowercase, single word, no separators (effective Go).
35
+ function toPackageName(value) {
36
+ return value.trim().toLowerCase().replace(/[^a-z0-9]/g, "");
37
+ }
38
+ function toCamelCase(value) {
39
+ const pascal = toPascalCase(value);
40
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
41
+ }
42
+ // URL path segment: "findActive" -> "find-active", "reset_password" -> "reset-password".
43
+ function toKebabCase(value) {
44
+ return value
45
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
46
+ .replace(/[_\s]+/g, "-")
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9-]+/g, "-")
49
+ .replace(/^-+|-+$/g, "");
50
+ }
51
+ // Postgres database/identifier name: lowercase snake_case.
52
+ function toDbName(value) {
53
+ return value
54
+ .trim()
55
+ .toLowerCase()
56
+ .replace(/[^a-z0-9]+/g, "_")
57
+ .replace(/^_+|_+$/g, "");
58
+ }
59
+ // Go module path: each '/'-separated segment is letters/digits, optionally
60
+ // with . _ - in the middle — this is what go.mod's `module` line accepts.
61
+ // Rejects spaces and other punctuation that would produce a go.mod that
62
+ // fails to parse (go: errors parsing go.mod: usage: module module/path).
63
+ const VALID_GO_MODULE_PATH = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?(\/[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)*$/;
64
+ function validateGoModulePath(name) {
65
+ return VALID_GO_MODULE_PATH.test(name)
66
+ ? true
67
+ : `invalid project name "${name}" — go.mod module paths can't contain spaces; use letters, numbers, ., _, -, / only (e.g. "my-api" or "github.com/org/my-api")`;
68
+ }
69
+ function assertValidGoModulePath(name) {
70
+ const result = validateGoModulePath(name);
71
+ if (result !== true)
72
+ throw new Error(result);
73
+ }
74
+ // Go's 25 reserved words — a package/func/param named any of these produces
75
+ // code that won't parse (`package func`, `func (h *Handler) type(...)`).
76
+ const GO_KEYWORDS = new Set([
77
+ "break", "case", "chan", "const", "continue", "default", "defer", "else",
78
+ "fallthrough", "for", "func", "go", "goto", "if", "import", "interface",
79
+ "map", "package", "range", "return", "select", "struct", "switch", "type", "var",
80
+ ]);
81
+ // predeclared type names — legal as identifiers, but a *package* named one of
82
+ // these shadows the builtin in generated code that uses it as a type (main.go
83
+ // has `s string`), so reject them for module names specifically.
84
+ const GO_PREDECLARED_TYPES = new Set([
85
+ "any", "bool", "byte", "comparable", "complex64", "complex128", "error",
86
+ "float32", "float64", "int", "int8", "int16", "int32", "int64", "rune",
87
+ "string", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr",
88
+ ]);
89
+ // method/handler/param identifier: only keywords are hard-illegal (a param or
90
+ // func named `string` is legal Go, just shadows the builtin locally).
91
+ function assertNotGoKeyword(ident, role) {
92
+ if (GO_KEYWORDS.has(ident.toLowerCase())) {
93
+ throw new Error(`"${ident}" is a Go keyword — can't use it as a ${role} name; pick another`);
94
+ }
95
+ }
96
+ // module name becomes a Go package name; keywords and predeclared type names
97
+ // both produce code that won't compile (`package func`, or a `string` package
98
+ // shadowing the builtin in main.go). Returns true|message for inquirer, and
99
+ // backs the assert in resolveModuleNaming — one source of truth for both.
100
+ function validateModuleName(rawName) {
101
+ const pkg = toPackageName(rawName);
102
+ if (!pkg)
103
+ return `invalid module name: "${rawName}" (must contain letters/numbers)`;
104
+ if (/^[0-9]/.test(pkg)) {
105
+ return `"${pkg}" starts with a digit — a Go package name can't, so it won't compile; pick another module name`;
106
+ }
107
+ if (GO_KEYWORDS.has(pkg) || GO_PREDECLARED_TYPES.has(pkg)) {
108
+ return `"${pkg}" is a reserved Go word — a package named it won't compile; pick another module name`;
109
+ }
110
+ return true;
111
+ }
112
+ // strips whitespace and leading/trailing slashes so "/api/v1/" and "api/v1"
113
+ // store identically — callers should normalize once and use the result
114
+ // everywhere (config, templates), not just for validation.
115
+ function normalizeApiPrefix(raw) {
116
+ return raw.trim().replace(/^\/+|\/+$/g, "");
117
+ }
118
+ // apiPrefix becomes a URL path (/api/v1/orders) — multiple slash-separated
119
+ // segments are fine (gin's r.Group() joins paths natively, confirmed against
120
+ // gin directly: r.Group("/api/v1") produces /api/v1/orders as expected). The
121
+ // `api := r.Group(...)` variable is always named "api" regardless of the
122
+ // prefix's value, so the prefix itself never needs to be a valid Go
123
+ // identifier — just clean URL segments. Empty string is valid on purpose: it
124
+ // means "no prefix", routes register directly at /orders.
125
+ function validateApiPrefix(raw) {
126
+ const trimmed = normalizeApiPrefix(raw);
127
+ if (trimmed === "")
128
+ return true;
129
+ return /^[a-z][a-z0-9]*(\/[a-z][a-z0-9]*)*$/.test(trimmed)
130
+ ? true
131
+ : `invalid API prefix "${trimmed}" — use lowercase letters/numbers per segment, separated by "/" (e.g. "v1", "api/v1"), or leave blank for none`;
132
+ }
133
+ function resolveModuleNaming(rawName) {
134
+ const check = validateModuleName(rawName);
135
+ if (check !== true)
136
+ throw new Error(check);
137
+ const pkg = toPackageName(rawName);
138
+ return {
139
+ name: pkg,
140
+ pkg,
141
+ pascalName: toPascalCase(pkg),
142
+ plural: pluralize(pkg),
143
+ errorPrefix: pkg.toUpperCase(),
144
+ };
145
+ }
146
+ function resolveMethodNaming(rawName) {
147
+ const cleaned = rawName.trim();
148
+ const pascalName = toPascalCase(cleaned);
149
+ if (!pascalName) {
150
+ throw new Error(`invalid method name: "${rawName}" (must contain letters/numbers)`);
151
+ }
152
+ // handlerName becomes a Go method name (`func (h *Handler) <name>`)
153
+ assertNotGoKeyword(toCamelCase(cleaned), "method");
154
+ return {
155
+ name: cleaned,
156
+ pascalName,
157
+ handlerName: toCamelCase(cleaned),
158
+ pathSegment: toKebabCase(cleaned),
159
+ };
160
+ }
@@ -0,0 +1,47 @@
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.patchOpenapiIndex = patchOpenapiIndex;
7
+ exports.unpatchOpenapiIndex = unpatchOpenapiIndex;
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const marker_patch_1 = require("./marker-patch");
10
+ const PATHS_MARKER = "# go-scaffold:paths";
11
+ const SCHEMAS_MARKER = "# go-scaffold:schemas";
12
+ // exact lines a module contributes to docs/openapi.yaml — shared by patch and
13
+ // unpatch so removal pulls out precisely what was added. apiPrefix is the
14
+ // project-wide prefix chosen at create time (e.g. "v1", "" for none).
15
+ function openapiLines(naming, apiPrefix) {
16
+ const base = apiPrefix ? `/${apiPrefix}/${naming.plural}` : `/${naming.plural}`;
17
+ return {
18
+ paths: [
19
+ `${base}:`,
20
+ ` $ref: './${naming.plural}/collection.yaml'`,
21
+ `${base}/{id}:`,
22
+ ` $ref: './${naming.plural}/item.yaml'`,
23
+ ],
24
+ schemas: [
25
+ `${naming.pascalName}CreateInput: { $ref: './${naming.plural}/schemas.yaml#/${naming.pascalName}CreateInput' }`,
26
+ `${naming.pascalName}UpdateInput: { $ref: './${naming.plural}/schemas.yaml#/${naming.pascalName}UpdateInput' }`,
27
+ `${naming.pascalName}Response: { $ref: './${naming.plural}/schemas.yaml#/${naming.pascalName}Response' }`,
28
+ ],
29
+ };
30
+ }
31
+ // patchOpenapiIndex wires a new module's collection/item docs into the
32
+ // docs/openapi.yaml index — same marker-comment approach as main.go.
33
+ function patchOpenapiIndex(openapiPath, naming, apiPrefix) {
34
+ let content = fs_extra_1.default.readFileSync(openapiPath, "utf8");
35
+ const { paths, schemas } = openapiLines(naming, apiPrefix);
36
+ // sentinels keep re-runs idempotent (same reason as main-patcher)
37
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PATHS_MARKER, paths.join("\n"), paths[0]);
38
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMAS_MARKER, schemas.join("\n"), schemas[2]);
39
+ fs_extra_1.default.writeFileSync(openapiPath, content);
40
+ }
41
+ // unpatchOpenapiIndex removes a module's paths/schemas from the index — inverse
42
+ // of patchOpenapiIndex.
43
+ function unpatchOpenapiIndex(openapiPath, naming, apiPrefix) {
44
+ const content = fs_extra_1.default.readFileSync(openapiPath, "utf8");
45
+ const { paths, schemas } = openapiLines(naming, apiPrefix);
46
+ fs_extra_1.default.writeFileSync(openapiPath, (0, marker_patch_1.removeLines)(content, [...paths, ...schemas]));
47
+ }
@@ -0,0 +1,51 @@
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.getTemplatesRoot = getTemplatesRoot;
7
+ exports.renderString = renderString;
8
+ exports.applyTemplateEntries = applyTemplateEntries;
9
+ exports.gofmtTree = gofmtTree;
10
+ const path_1 = __importDefault(require("path"));
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const fs_extra_1 = __importDefault(require("fs-extra"));
13
+ const child_process_1 = require("child_process");
14
+ const handlebars_1 = __importDefault(require("handlebars"));
15
+ handlebars_1.default.registerHelper("eq", (a, b) => a === b);
16
+ function getTemplatesRoot() {
17
+ const candidates = [
18
+ path_1.default.join(__dirname, "..", "..", "templates"),
19
+ path_1.default.join(__dirname, "..", "..", "..", "templates"),
20
+ ];
21
+ const resolved = candidates.find((candidate) => fs_1.default.existsSync(path_1.default.join(candidate, "create", "base", "go.mod.hbs")));
22
+ if (!resolved) {
23
+ throw new Error("unable to locate templates directory");
24
+ }
25
+ return resolved;
26
+ }
27
+ function renderString(source, context) {
28
+ return handlebars_1.default.compile(source, { noEscape: true })(context);
29
+ }
30
+ async function applyTemplateEntries(projectRoot, entries, context) {
31
+ const root = getTemplatesRoot();
32
+ for (const entry of entries) {
33
+ if (entry.when && !entry.when(context))
34
+ continue;
35
+ const source = await fs_extra_1.default.readFile(path_1.default.join(root, entry.template), "utf8");
36
+ const rendered = renderString(source, context);
37
+ const outputPath = path_1.default.join(projectRoot, entry.output);
38
+ await fs_extra_1.default.ensureDir(path_1.default.dirname(outputPath));
39
+ await fs_extra_1.default.writeFile(outputPath, rendered);
40
+ }
41
+ }
42
+ // gofmt the whole project tree; a missing gofmt (no local Go toolchain) is
43
+ // non-fatal — generated files are already hand-formatted templates.
44
+ function gofmtTree(projectRoot) {
45
+ try {
46
+ (0, child_process_1.execFileSync)("gofmt", ["-w", "."], { cwd: projectRoot, stdio: "ignore" });
47
+ }
48
+ catch {
49
+ // ponytail: no Go toolchain on this machine, skip formatting
50
+ }
51
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@nakedev/go-scaffold",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
+ "bin": {
6
+ "go-scaffold": "./bin/go-scaffold.js"
7
+ },
8
+ "files": [
9
+ "dist",
10
+ "templates",
11
+ "bin"
12
+ ],
13
+ "type": "commonjs",
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsc --watch",
17
+ "test": "node scripts/smoke-test.mjs",
18
+ "verify": "pnpm run build && pnpm run test",
19
+ "prepublishOnly": "pnpm run verify"
20
+ },
21
+ "keywords": [
22
+ "go",
23
+ "gin",
24
+ "gorm",
25
+ "scaffold",
26
+ "cli",
27
+ "generator"
28
+ ],
29
+ "author": "nakedev",
30
+ "license": "MIT",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "@inquirer/prompts": "^7.5.1",
36
+ "commander": "^15.0.0",
37
+ "fs-extra": "^11.3.0",
38
+ "handlebars": "^4.7.8",
39
+ "picocolors": "^1.1.1"
40
+ },
41
+ "devDependencies": {
42
+ "@types/fs-extra": "^11.0.4",
43
+ "@types/node": "^24.0.0",
44
+ "typescript": "^5.7.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=20"
48
+ }
49
+ }