@nakedev/go-scaffold 0.1.3 → 0.1.4

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 (38) hide show
  1. package/README.md +18 -14
  2. package/dist/commands/generate.js +2 -1
  3. package/dist/commands/method.js +63 -15
  4. package/dist/commands/remove.js +23 -24
  5. package/dist/index.js +5 -4
  6. package/dist/templates/module-manifest.js +2 -0
  7. package/dist/utils/method-patcher.js +80 -16
  8. package/dist/utils/module-location.js +22 -0
  9. package/dist/utils/naming.js +75 -12
  10. package/dist/utils/openapi-patcher.js +19 -1
  11. package/dist/utils/smoke-run.js +31 -0
  12. package/package.json +14 -5
  13. package/scripts/smoke-test.mjs +2058 -0
  14. package/templates/create/base/.github/workflows/ci.yml.hbs +18 -5
  15. package/templates/create/base/AGENTS.md.hbs +10 -12
  16. package/templates/create/base/Makefile.hbs +9 -3
  17. package/templates/create/base/README.md.hbs +18 -6
  18. package/templates/create/features/docs/openapi.yaml.hbs +4 -5
  19. package/templates/create/features/docs/patterns.md.hbs +9 -6
  20. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  21. package/templates/generate/module/handler.go.hbs +18 -4
  22. package/templates/generate/module/handler_test.go.hbs +81 -62
  23. package/templates/generate/module/migration.down.sql.hbs +1 -1
  24. package/templates/generate/module/migration.up.sql.hbs +1 -1
  25. package/templates/generate/module/minimal/handler.go.hbs +8 -2
  26. package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
  27. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  28. package/templates/generate/module/model/model.go.hbs +7 -0
  29. package/templates/generate/module/repository_test.go.hbs +79 -0
  30. package/templates/generate/module/service.go.hbs +6 -4
  31. package/templates/generate/module/service_test.go.hbs +68 -17
  32. package/tests/integration/default-module.test.mjs +46 -0
  33. package/tests/integration/generator-naming.test.mjs +81 -0
  34. package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
  35. package/tests/integration/legacy-method-compat.test.mjs +222 -0
  36. package/tests/integration/remove-module.test.mjs +58 -0
  37. package/tests/unit/naming.test.mjs +94 -0
  38. package/tests/unit/smoke-isolation.test.mjs +35 -0
package/README.md CHANGED
@@ -87,25 +87,26 @@ directory layout if missing).
87
87
  ### `generate module <name>` (alias `m`) — add a domain module
88
88
 
89
89
  ```bash
90
- go-scaffold generate module orders # full CRUD (default)
91
- go-scaffold generate module orders --no-full # minimal skeleton — add endpoints with `generate method`
90
+ go-scaffold generate module orders # safe minimal module (default)
91
+ go-scaffold generate module orders --full # opt-in CRUD skeleton
92
92
  ```
93
93
 
94
- Full CRUD (default) scaffolds:
94
+ `--full` scaffolds:
95
95
 
96
96
  ```text
97
- internal/app/orders/
97
+ internal/app/order/
98
98
  ├── 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)
99
99
  ├── dto.go # request/response structs (empty stubs — add real fields yourself)
100
- ├── errors.go # ORDERS_NOT_FOUND / ORDERS_CONFLICT / ORDERS_HAS_REFERENCES
100
+ ├── errors.go # ORDER_NOT_FOUND / ORDER_CONFLICT / ORDER_HAS_REFERENCES
101
101
  ├── repository.go # GORM data access
102
102
  ├── service.go # business logic + repository interface (mockable)
103
103
  ├── handler.go # Gin routes, registered under the project's API prefix
104
- ├── service_test.go # unit test, fake repo
105
- └── handler_test.go # integration test, real Postgres, tx rollback
104
+ ├── service_test.go # unit test, function-backed repository stub
105
+ ├── handler_test.go # HTTP unit test, service stub, no DB
106
+ └── repository_test.go # Postgres integration test against migrated schema
106
107
  ```
107
108
 
108
- `--no-full` scaffolds the same `model`/`errors`/`repository` (so `generate
109
+ The default minimal mode scaffolds the same `model`/`errors`/`repository` (so `generate
109
110
  method` always has a full data-access surface to call), but `dto`/`service`/
110
111
  `handler` start empty — no default CRUD, no routes, just the plumbing
111
112
  (`Register()`, the `repository` interface, `wrapFindErr`) that `generate
@@ -145,7 +146,7 @@ overwrites a method with the same name; picks a different one or errors.
145
146
  | `--type` | Route | What's generated |
146
147
  |---|---|---|
147
148
  | `get --get-mode all` | `GET /<plural>/<kebab-name>` | reuses `FindAll` — TODO to add real filtering |
148
- | `get --get-mode one --field <f>` | `GET /<plural>/<f>/:<f>` | a real `FindBy<F>` query added to the repository (+ its interface + `fakeRepo` test stub) |
149
+ | `get --get-mode one --field <f>` | `GET /<plural>/<f>/:<f>` | a real `FindBy<F>` query added to the repository (+ its interface + function-backed repository test stub) |
149
150
  | `post` | `POST /<plural>/<kebab-name>` | adds a body DTO; service is a TODO stub |
150
151
  | `put` / `patch` | `<VERB> /<plural>/:id/<kebab-name>` | finds by id, TODO before saving (safe no-op until implemented) |
151
152
  | `delete` | `DELETE /<plural>/:id/<kebab-name>` | TODO stub |
@@ -154,8 +155,9 @@ Business logic is always left as a `TODO`-marked stub that compiles and
154
155
  returns a clean `500` rather than inventing behavior — see
155
156
  `docs/architect/patterns.md` in the generated project.
156
157
 
157
- `generate method` prints the route it added but does **not** touch
158
- `docs/openapi.yaml` endpoint-specific spec entries stay hand-written.
158
+ When OpenAPI docs are enabled, `generate method` also creates a valid TODO stub
159
+ under `docs/<plural>/methods/` and wires the route into `docs/openapi.yaml`.
160
+ Replace its placeholder request/response schemas while implementing the TODO.
159
161
 
160
162
  **Drift check** — `generate` type-checks the project (`go vet ./...`) before and
161
163
  after it writes. If the project was fine beforehand and the generated code
@@ -240,9 +242,11 @@ go-scaffold rm m orders --yes # skip the confirm
240
242
  ```
241
243
 
242
244
  The inverse of `generate module`: deletes `internal/app/<name>/` and reverses
243
- everything that was wired up — the import/AutoMigrate/route in `main.go`, the
244
- paths/schemas in `docs/openapi.yaml`, the per-module docs folder, and the
245
- `create_<plural>` migration. Restores the `_ = api` placeholder if it was the
245
+ the import/AutoMigrate/route in `main.go`, paths/schemas in `docs/openapi.yaml`,
246
+ and the per-module docs folder. **Existing migrations are preserved** because
247
+ production may already have recorded those immutable versions. The table/data
248
+ are also untouched; create a new `generate migration drop_<table>` migration
249
+ when removal is intentional. Restores the `_ = api` placeholder if it was the
246
250
  last module, so the project still builds. Use this instead of hand-deleting
247
251
  the folder — a partial hand-delete leaves stale wiring that duplicates on the
248
252
  next `generate module` (which would panic gin at startup).
@@ -9,6 +9,7 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
9
9
  const picocolors_1 = __importDefault(require("picocolors"));
10
10
  const config_1 = require("../utils/config");
11
11
  const naming_1 = require("../utils/naming");
12
+ const module_location_1 = require("../utils/module-location");
12
13
  const template_renderer_1 = require("../utils/template-renderer");
13
14
  const module_manifest_1 = require("../templates/module-manifest");
14
15
  const main_patcher_1 = require("../utils/main-patcher");
@@ -36,7 +37,7 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
36
37
  throw new Error(`invalid permission code "${opts.permission}" — must start with a lowercase letter and contain only lowercase letters, digits, ':', '_', or '-'`);
37
38
  }
38
39
  }
39
- const naming = (0, naming_1.resolveModuleNaming)(rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
40
+ const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
40
41
  const modulePath = naming.pkg;
41
42
  const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
42
43
  if (fs_extra_1.default.existsSync(moduleDir) && fs_extra_1.default.readdirSync(moduleDir).length > 0) {
@@ -9,29 +9,61 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
9
9
  const picocolors_1 = __importDefault(require("picocolors"));
10
10
  const config_1 = require("../utils/config");
11
11
  const naming_1 = require("../utils/naming");
12
+ const module_location_1 = require("../utils/module-location");
12
13
  const method_patcher_1 = require("../utils/method-patcher");
13
14
  const gocheck_1 = require("../utils/gocheck");
14
15
  const template_renderer_1 = require("../utils/template-renderer");
16
+ const openapi_patcher_1 = require("../utils/openapi-patcher");
15
17
  const generate_wizard_1 = require("../prompts/generate-wizard");
16
- // the actual URL the new route answers on — printed so the user can add the
17
- // matching openapi.yaml entry by hand (methods are deliberately not wired into
18
- // the spec; see the note in docs/openapi.yaml). Mirrors the paths registered
19
- // in method-patcher.ts.
20
- function routeHint(naming, method, type, apiPrefix, getMode, field) {
21
- const base = apiPrefix ? `/${apiPrefix}/${naming.plural}` : `/${naming.plural}`;
18
+ // URL path registered in method-patcher.ts, excluding the project-wide API
19
+ // prefix so it can be passed directly to patchOpenapiIndexRaw.
20
+ function methodRoutePath(naming, method, type, getMode, field) {
21
+ const base = `/${naming.plural}`;
22
22
  if (type === "get" && getMode === "all")
23
- return `GET ${base}/${method.pathSegment}`;
23
+ return `${base}/${method.pathSegment}`;
24
24
  if (type === "get")
25
- return `GET ${base}/${(0, naming_1.toDbName)(field ?? "")}/{${(0, naming_1.toCamelCase)(field ?? "")}}`;
25
+ return `${base}/${(0, naming_1.toDbName)(field ?? "")}/{${(0, naming_1.toCamelCase)(field ?? "")}}`;
26
26
  if (type === "post")
27
- return `POST ${base}/${method.pathSegment}`;
28
- if (type === "delete")
29
- return `DELETE ${base}/{id}/${method.pathSegment}`;
30
- return `${type.toUpperCase()} ${base}/{id}/${method.pathSegment}`;
27
+ return `${base}/${method.pathSegment}`;
28
+ return `${base}/{id}/${method.pathSegment}`;
29
+ }
30
+ function routeHint(naming, method, type, apiPrefix, getMode, field) {
31
+ const path = methodRoutePath(naming, method, type, getMode, field);
32
+ const prefixed = apiPrefix ? `/${apiPrefix}${path}` : path;
33
+ return `${type.toUpperCase()} ${prefixed}`;
34
+ }
35
+ function methodOpenapiDocument(naming, method, type, getMode, field) {
36
+ const pathParameters = [];
37
+ if (type === "get" && getMode === "one") {
38
+ const parameter = (0, naming_1.toCamelCase)(field ?? "");
39
+ pathParameters.push("parameters:", ` - name: ${parameter}`, " in: path", " required: true", " schema:", " type: string");
40
+ }
41
+ else if (type === "put" || type === "patch" || type === "delete") {
42
+ pathParameters.push("parameters:", " - name: id", " in: path", " required: true", " schema:", " type: string", " format: uuid");
43
+ }
44
+ const operation = [
45
+ `${type}:`,
46
+ ` summary: TODO document ${method.name}`,
47
+ ` operationId: ${method.handlerName}${naming.pascalName}`,
48
+ ` tags: [${naming.plural}]`,
49
+ ];
50
+ if (type === "post") {
51
+ operation.push(" requestBody:", " required: true", " content:", " application/json:", " schema:", " type: object", " description: TODO define request fields");
52
+ }
53
+ const status = type === "delete" ? "204" : type === "post" ? "201" : "200";
54
+ operation.push(" responses:", ` \"${status}\":`, ` description: ${type === "delete" ? "completed or already absent" : "TODO define response"}`);
55
+ if (type !== "delete") {
56
+ operation.push(" content:", " application/json:", " schema:", " type: object", " description: TODO replace with the method response schema");
57
+ }
58
+ operation.push(" \"400\": { $ref: '../../common/responses.yaml#/ValidationError' }");
59
+ if (type === "get" || type === "put" || type === "patch") {
60
+ operation.push(" \"404\": { $ref: '../../common/responses.yaml#/NotFoundError' }");
61
+ }
62
+ return [...pathParameters, ...operation, ""].join("\n");
31
63
  }
32
64
  async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = process.cwd()) {
33
65
  const config = (0, config_1.readConfig)(projectDir);
34
- const naming = (0, naming_1.resolveModuleNaming)(moduleNameArg ?? (await (0, generate_wizard_1.promptModuleName)()));
66
+ const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, moduleNameArg ?? (await (0, generate_wizard_1.promptModuleName)()));
35
67
  const modulePath = naming.pkg;
36
68
  const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
37
69
  const paths = {
@@ -60,14 +92,30 @@ async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = p
60
92
  if (field)
61
93
  (0, naming_1.assertNotGoKeyword)((0, naming_1.toCamelCase)(field), "lookup field");
62
94
  const method = (0, naming_1.resolveMethodNaming)(methodNameArg ?? (await (0, generate_wizard_1.promptMethodName)()));
95
+ const docsRelativePath = config.features.openapiDocs
96
+ ? `${naming.plural}/methods/${method.pathSegment}.yaml`
97
+ : undefined;
98
+ if (docsRelativePath && fs_extra_1.default.existsSync(path_1.default.join(projectDir, "docs", docsRelativePath))) {
99
+ throw new Error(`OpenAPI method document already exists: docs/${docsRelativePath}`);
100
+ }
63
101
  const checkBefore = (0, gocheck_1.typeChecks)(projectDir);
64
102
  (0, method_patcher_1.patchMethod)(paths, naming, method, { type, getMode, field }, config.goModule);
65
103
  (0, template_renderer_1.gofmtTree)(projectDir);
66
104
  (0, gocheck_1.assertNoDrift)(projectDir, checkBefore, config);
105
+ if (docsRelativePath) {
106
+ const docsPath = path_1.default.join(projectDir, "docs", docsRelativePath);
107
+ fs_extra_1.default.outputFileSync(docsPath, methodOpenapiDocument(naming, method, type, getMode, field));
108
+ (0, openapi_patcher_1.patchOpenapiIndexRaw)(path_1.default.join(projectDir, "docs", "openapi.yaml"), config.apiPrefix, [
109
+ {
110
+ urlPath: methodRoutePath(naming, method, type, getMode, field),
111
+ file: `./${docsRelativePath}`,
112
+ },
113
+ ]);
114
+ }
67
115
  console.log(picocolors_1.default.green(`\nadded "${method.name}" to internal/app/${modulePath}/`));
68
116
  console.log(`route: ${routeHint(naming, method, type, config.apiPrefix, getMode, field)}`);
69
- if (config.features.openapiDocs) {
70
- console.log(picocolors_1.default.yellow(`docs: add this route to docs/openapi.yaml by hand — \`generate method\` doesn't touch the spec`));
117
+ if (docsRelativePath) {
118
+ console.log(picocolors_1.default.green(`docs: docs/${docsRelativePath} (wired into docs/openapi.yaml)`));
71
119
  }
72
120
  console.log(picocolors_1.default.dim(`\nnext: fill in the TODO in service.go, then \`go build ./...\` / \`go test ./...\``));
73
121
  }
@@ -9,18 +9,17 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
9
9
  const picocolors_1 = __importDefault(require("picocolors"));
10
10
  const prompts_1 = require("@inquirer/prompts");
11
11
  const config_1 = require("../utils/config");
12
- const naming_1 = require("../utils/naming");
12
+ const module_location_1 = require("../utils/module-location");
13
13
  const main_patcher_1 = require("../utils/main-patcher");
14
14
  const openapi_patcher_1 = require("../utils/openapi-patcher");
15
15
  const template_renderer_1 = require("../utils/template-renderer");
16
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).
17
+ // removeModule deletes application code and reverses generated wiring while
18
+ // preserving immutable migration history and table data. Destructive schema
19
+ // removal must be an explicit new migration, never a deletion of an applied one.
21
20
  async function removeModule(rawName, opts, projectDir = process.cwd()) {
22
21
  const config = (0, config_1.readConfig)(projectDir);
23
- const naming = (0, naming_1.resolveModuleNaming)(rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
22
+ const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
24
23
  const modulePath = naming.pkg;
25
24
  const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
26
25
  if (!fs_extra_1.default.existsSync(moduleDir)) {
@@ -28,7 +27,8 @@ async function removeModule(rawName, opts, projectDir = process.cwd()) {
28
27
  }
29
28
  if (!opts.yes) {
30
29
  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`,
30
+ message: `Remove module "${naming.pkg}"? Deletes internal/app/${modulePath}/ and its docs, ` +
31
+ `then un-wires main.go/openapi.yaml. Existing migrations, table, and data are preserved.`,
32
32
  default: false,
33
33
  });
34
34
  if (!ok)
@@ -62,28 +62,27 @@ async function removeModule(rawName, opts, projectDir = process.cwd()) {
62
62
  (0, openapi_patcher_1.unpatchOpenapiIndex)(openapiPath, naming, config.apiPrefix);
63
63
  fs_extra_1.default.removeSync(path_1.default.join(projectDir, "docs", naming.plural));
64
64
  }
65
- // 4. migration files (up + down) including the --permission seed
66
- // migration, if this module was generated with one.
65
+ // 4. Migration history is immutable. A migration may already be recorded in
66
+ // schema_migrations on production databases, so deleting its file would make
67
+ // existing and fresh environments disagree and can prevent the app booting.
68
+ // Re-generating the same module reuses this create migration.
67
69
  const migrationsDir = path_1.default.join(projectDir, "migrations");
68
- const removedMigrations = [];
69
- if (fs_extra_1.default.existsSync(migrationsDir)) {
70
- for (const f of fs_extra_1.default.readdirSync(migrationsDir)) {
71
- if (f.endsWith(`_create_${naming.plural}.up.sql`) ||
72
- f.endsWith(`_create_${naming.plural}.down.sql`) ||
73
- f.endsWith(`_add_${naming.plural}_permission.up.sql`) ||
74
- f.endsWith(`_add_${naming.plural}_permission.down.sql`)) {
75
- fs_extra_1.default.removeSync(path_1.default.join(migrationsDir, f));
76
- removedMigrations.push(f);
77
- }
78
- }
79
- }
70
+ const preservedMigrations = fs_extra_1.default.existsSync(migrationsDir)
71
+ ? fs_extra_1.default.readdirSync(migrationsDir).filter((f) => f.endsWith(`_create_${naming.plural}.up.sql`) ||
72
+ f.endsWith(`_create_${naming.plural}.down.sql`) ||
73
+ f.endsWith(`_add_${naming.plural}_permission.up.sql`) ||
74
+ f.endsWith(`_add_${naming.plural}_permission.down.sql`))
75
+ : [];
80
76
  (0, template_renderer_1.gofmtTree)(projectDir);
81
77
  console.log(picocolors_1.default.green(`\nremoved module "${naming.pkg}"`));
82
78
  console.log(` deleted internal/app/${modulePath}/`);
83
79
  console.log(` un-wired cmd/api/main.go`);
84
80
  if (fs_extra_1.default.existsSync(openapiPath))
85
81
  console.log(` un-wired docs/openapi.yaml + deleted docs/${naming.plural}/`);
86
- if (removedMigrations.length)
87
- console.log(` deleted ${removedMigrations.join(", ")}`);
88
- console.log(picocolors_1.default.yellow(`\nnote: the ${naming.plural} table (if migrated) is untouched — drop it yourself, or add a down migration`));
82
+ if (preservedMigrations.length) {
83
+ console.log(` preserved migration history: ${preservedMigrations.join(", ")}`);
84
+ }
85
+ console.log(picocolors_1.default.yellow(`\nnote: the ${naming.tableName} table and its data are untouched. ` +
86
+ `To remove them safely, run \`go-scaffold generate migration drop_${naming.tableName}\` ` +
87
+ `and write an explicit up/down migration.`));
89
88
  }
package/dist/index.js CHANGED
@@ -56,13 +56,13 @@ const generate = program
56
56
  const target = await (0, prompts_1.select)({
57
57
  message: "What do you want to generate?",
58
58
  choices: [
59
- { name: "Module (full CRUD domain)", value: "module" },
59
+ { name: "Module (safe minimal domain; add methods explicitly)", value: "module" },
60
60
  { name: "Method (add one endpoint to an existing module)", value: "method" },
61
61
  { name: "Migration (reserve a timestamped up/down SQL file pair)", value: "migration" },
62
62
  ],
63
63
  });
64
64
  if (target === "module") {
65
- await (0, generate_1.generateModule)(undefined, { full: true });
65
+ await (0, generate_1.generateModule)(undefined, { full: false });
66
66
  }
67
67
  else if (target === "method") {
68
68
  await (0, method_1.generateMethod)(undefined, undefined, {});
@@ -79,8 +79,9 @@ const generate = program
79
79
  generate
80
80
  .command("module [name]")
81
81
  .alias("m")
82
- .description("scaffold a domain module full CRUD by default, or a bare skeleton with --no-full")
83
- .option("--no-full", "minimal skeleton (model/errors/repository, no default CRUD) add endpoints one at a time with `generate method`")
82
+ .description("scaffold a safe minimal domain module; opt into a CRUD skeleton with --full")
83
+ .option("--full", "generate a CRUD skeleton (DTO fields/business rules remain TODO); minimal is the safe default")
84
+ .option("--no-full", "deprecated compatibility alias; minimal is already the default")
84
85
  .option("--auth", "require a valid access token for this module's routes (needs `add auth`)")
85
86
  .option("--permission <code>", "also require this permission via authz.Require (needs `add rbac`; implies --auth)")
86
87
  .action(async (name, opts) => {
@@ -12,6 +12,7 @@ exports.MODULE_FILES = [
12
12
  { template: "generate/module/handler.go.hbs", output: "handler.go" },
13
13
  { template: "generate/module/service_test.go.hbs", output: "service_test.go" },
14
14
  { template: "generate/module/handler_test.go.hbs", output: "handler_test.go" },
15
+ { template: "generate/module/repository_test.go.hbs", output: "repository_test.go" },
15
16
  ];
16
17
  // minimal: same model/errors/repository (generate method's patches assume the
17
18
  // full data-access surface exists), but no default CRUD in dto/service/handler
@@ -25,4 +26,5 @@ exports.MODULE_FILES_MINIMAL = [
25
26
  { template: "generate/module/minimal/handler.go.hbs", output: "handler.go" },
26
27
  { template: "generate/module/minimal/service_test.go.hbs", output: "service_test.go" },
27
28
  { template: "generate/module/minimal/handler_test.go.hbs", output: "handler_test.go" },
29
+ { template: "generate/module/repository_test.go.hbs", output: "repository_test.go" },
28
30
  ];
@@ -12,9 +12,12 @@ const DTO_MARKER = "// go-scaffold:dto";
12
12
  const REPO_INTERFACE_MARKER = "// go-scaffold:repository-interface";
13
13
  const REPO_IMPL_MARKER = "// go-scaffold:repository-methods";
14
14
  const SERVICE_MARKER = "// go-scaffold:service-methods";
15
+ const SERVICE_INTERFACE_MARKER = "// go-scaffold:service-interface";
15
16
  const HANDLER_ROUTES_MARKER = "// go-scaffold:handler-routes";
16
17
  const HANDLER_FUNCS_MARKER = "// go-scaffold:handler-funcs";
17
- const FAKE_REPO_MARKER = "// go-scaffold:fake-repo-methods";
18
+ const REPOSITORY_STUB_FIELDS_MARKER = "// go-scaffold:repository-stub-fields";
19
+ const REPOSITORY_STUB_METHODS_MARKER = "// go-scaffold:repository-stub-methods";
20
+ const LEGACY_FAKE_REPO_METHODS_MARKER = "// go-scaffold:fake-repo-methods";
18
21
  const UNUSED_G_LINE = "\t_ = g\n";
19
22
  // writeHandler ensures whatever packages the new handler code references are
20
23
  // imported (a minimal module starts with only "gin" imported) and drops the
@@ -62,6 +65,48 @@ function patchMethod(paths, naming, method, opts, goModule) {
62
65
  else {
63
66
  patchDelete(paths, method, goModule);
64
67
  }
68
+ patchHandlerServiceInterface(paths.handlerPath, naming, method, opts, goModule);
69
+ }
70
+ function patchHandlerServiceInterface(handlerPath, naming, method, opts, goModule) {
71
+ let signature;
72
+ let needsModel = true;
73
+ let needsUUID = false;
74
+ if (opts.type === "get" && opts.getMode === "all") {
75
+ signature = `${method.pascalName}(context.Context, int, int) ([]model.${naming.pascalName}, error)`;
76
+ }
77
+ else if (opts.type === "get") {
78
+ signature = `${method.pascalName}(context.Context, string) (*model.${naming.pascalName}, error)`;
79
+ }
80
+ else if (opts.type === "post") {
81
+ signature = `${method.pascalName}(context.Context, ${method.pascalName}Input) (*model.${naming.pascalName}, error)`;
82
+ }
83
+ else if (opts.type === "put" || opts.type === "patch") {
84
+ signature = `${method.pascalName}(context.Context, uuid.UUID) (*model.${naming.pascalName}, error)`;
85
+ needsUUID = true;
86
+ }
87
+ else {
88
+ signature = `${method.pascalName}(context.Context, uuid.UUID) error`;
89
+ needsModel = false;
90
+ needsUUID = true;
91
+ }
92
+ let handler = fs_extra_1.default.readFileSync(handlerPath, "utf8");
93
+ if (!(0, marker_patch_1.hasMarker)(handler, SERVICE_INTERFACE_MARKER)) {
94
+ // Projects scaffolded before the narrow service interface stored *Service
95
+ // directly. The concrete type already exposes generated methods, so there
96
+ // is no interface declaration to patch and no migration is required.
97
+ if (handler.includes("svc *Service"))
98
+ return;
99
+ throw new Error(`marker "${SERVICE_INTERFACE_MARKER}" not found and Handler does not use legacy *Service wiring`);
100
+ }
101
+ handler = (0, marker_patch_1.insertBeforeMarker)(handler, SERVICE_INTERFACE_MARKER, signature);
102
+ handler = (0, marker_patch_1.ensureImport)(handler, "context");
103
+ if (needsModel) {
104
+ handler = (0, marker_patch_1.ensureImport)(handler, `${goModule}/internal/app/${naming.pkg}/model`);
105
+ }
106
+ if (needsUUID) {
107
+ handler = (0, marker_patch_1.ensureImport)(handler, "github.com/google/uuid");
108
+ }
109
+ fs_extra_1.default.writeFileSync(handlerPath, handler);
65
110
  }
66
111
  function patchGetAll(paths, naming, method, goModule) {
67
112
  let handler = fs_extra_1.default.readFileSync(paths.handlerPath, "utf8");
@@ -117,22 +162,40 @@ function patchGetOne(paths, naming, method, rawField, goModule) {
117
162
  fs_extra_1.default.writeFileSync(paths.repositoryPath, repo);
118
163
  let service = fs_extra_1.default.readFileSync(paths.servicePath, "utf8");
119
164
  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
165
+ // The repository interface just grew, so the test double needs a matching
166
+ // method or focused service tests stop compiling. New projects use a
167
+ // function-backed stub; projects scaffolded before that refactor retain the
168
+ // old fakeRepo marker and error behavior.
122
169
  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"));
170
+ if ((0, marker_patch_1.hasMarker)(serviceTest, REPOSITORY_STUB_FIELDS_MARKER) &&
171
+ (0, marker_patch_1.hasMarker)(serviceTest, REPOSITORY_STUB_METHODS_MARKER)) {
172
+ serviceTest = (0, marker_patch_1.insertBeforeMarker)(serviceTest, REPOSITORY_STUB_FIELDS_MARKER, `findBy${fieldPascal}Fn func(context.Context, string) (*model.${naming.pascalName}, error)`);
173
+ serviceTest = (0, marker_patch_1.insertBeforeMarker)(serviceTest, REPOSITORY_STUB_METHODS_MARKER, [
174
+ `//nolint:unused`,
175
+ `func (s *repositoryStub) FindBy${fieldPascal}(ctx context.Context, value string) (*model.${naming.pascalName}, error) {`,
176
+ `\tif s.findBy${fieldPascal}Fn == nil {`,
177
+ `\t\tpanic("unexpected repository.FindBy${fieldPascal} call")`,
178
+ `\t}`,
179
+ `\treturn s.findBy${fieldPascal}Fn(ctx, value)`,
180
+ `}`,
181
+ ``,
182
+ ].join("\n"));
183
+ }
184
+ else if ((0, marker_patch_1.hasMarker)(serviceTest, LEGACY_FAKE_REPO_METHODS_MARKER)) {
185
+ serviceTest = (0, marker_patch_1.insertBeforeMarker)(serviceTest, LEGACY_FAKE_REPO_METHODS_MARKER, [
186
+ `//nolint:unused`,
187
+ `func (f *fakeRepo) FindBy${fieldPascal}(context.Context, string) (*model.${naming.pascalName}, error) {`,
188
+ `\tif f.err != nil {`,
189
+ `\t\treturn nil, f.err`,
190
+ `\t}`,
191
+ `\treturn f.m, nil`,
192
+ `}`,
193
+ ``,
194
+ ].join("\n"));
195
+ }
196
+ else {
197
+ throw new Error(`neither current repository stub markers nor legacy "${LEGACY_FAKE_REPO_METHODS_MARKER}" found in ${paths.serviceTestPath}`);
198
+ }
136
199
  fs_extra_1.default.writeFileSync(paths.serviceTestPath, serviceTest);
137
200
  service = (0, marker_patch_1.insertBeforeMarker)(service, SERVICE_MARKER, [
138
201
  `func (s *Service) ${method.pascalName}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error) {`,
@@ -266,6 +329,7 @@ function markersPresent(handlerPath, servicePath) {
266
329
  const service = fs_extra_1.default.readFileSync(servicePath, "utf8");
267
330
  return ((0, marker_patch_1.hasMarker)(handler, HANDLER_ROUTES_MARKER) &&
268
331
  (0, marker_patch_1.hasMarker)(handler, HANDLER_FUNCS_MARKER) &&
332
+ ((0, marker_patch_1.hasMarker)(handler, SERVICE_INTERFACE_MARKER) || handler.includes("svc *Service")) &&
269
333
  (0, marker_patch_1.hasMarker)(service, SERVICE_MARKER) &&
270
334
  (0, marker_patch_1.hasMarker)(service, REPO_INTERFACE_MARKER));
271
335
  }
@@ -0,0 +1,22 @@
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.existingModulePackages = existingModulePackages;
7
+ exports.resolveProjectModuleNaming = resolveProjectModuleNaming;
8
+ const path_1 = __importDefault(require("path"));
9
+ const fs_extra_1 = __importDefault(require("fs-extra"));
10
+ const naming_1 = require("./naming");
11
+ function existingModulePackages(projectDir) {
12
+ const appDir = path_1.default.join(projectDir, "internal", "app");
13
+ if (!fs_extra_1.default.existsSync(appDir))
14
+ return [];
15
+ return fs_extra_1.default
16
+ .readdirSync(appDir, { withFileTypes: true })
17
+ .filter((entry) => entry.isDirectory())
18
+ .map((entry) => entry.name);
19
+ }
20
+ function resolveProjectModuleNaming(projectDir, rawName) {
21
+ return (0, naming_1.resolveExistingModuleNaming)(rawName, existingModulePackages(projectDir));
22
+ }
@@ -1,4 +1,7 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.pluralize = pluralize;
4
7
  exports.toPascalCase = toPascalCase;
@@ -13,18 +16,14 @@ exports.validateModuleName = validateModuleName;
13
16
  exports.normalizeApiPrefix = normalizeApiPrefix;
14
17
  exports.validateApiPrefix = validateApiPrefix;
15
18
  exports.resolveModuleNaming = resolveModuleNaming;
19
+ exports.resolveExistingModuleNaming = resolveExistingModuleNaming;
16
20
  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.
21
+ const pluralize_1 = __importDefault(require("pluralize"));
22
+ // Pluralization must be idempotent because module names may come from the
23
+ // interactive prompt (singular) or a documented/scripted command (often
24
+ // plural). Keep this wrapper exported for callers that only need inflection.
20
25
  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";
26
+ return pluralize_1.default.plural(word);
28
27
  }
29
28
  function toPascalCase(value) {
30
29
  return value
@@ -98,7 +97,7 @@ function assertNotGoKeyword(ident, role) {
98
97
  // shadowing the builtin in main.go). Returns true|message for inquirer, and
99
98
  // backs the assert in resolveModuleNaming — one source of truth for both.
100
99
  function validateModuleName(rawName) {
101
- const pkg = toPackageName(rawName);
100
+ const pkg = toPackageName(pluralize_1.default.singular(toKebabCase(rawName)));
102
101
  if (!pkg)
103
102
  return `invalid module name: "${rawName}" (must contain letters/numbers)`;
104
103
  if (/^[0-9]/.test(pkg)) {
@@ -134,15 +133,79 @@ function resolveModuleNaming(rawName) {
134
133
  const check = validateModuleName(rawName);
135
134
  if (check !== true)
136
135
  throw new Error(check);
136
+ const singular = pluralize_1.default.singular(toKebabCase(rawName));
137
+ const pkg = toPackageName(singular);
138
+ const plural = pluralize_1.default.plural(singular);
139
+ return {
140
+ name: singular,
141
+ pkg,
142
+ pascalName: toPascalCase(singular),
143
+ plural,
144
+ tableName: toDbName(plural),
145
+ errorPrefix: toDbName(singular).toUpperCase(),
146
+ };
147
+ }
148
+ function legacyPluralize(word) {
149
+ if (/[sxz]$/.test(word) || /[^aeiou](ch|sh)$/.test(word))
150
+ return word + "es";
151
+ if (/[^aeiou]y$/.test(word))
152
+ return word.slice(0, -1) + "ies";
153
+ if (word.endsWith("s"))
154
+ return word;
155
+ return word + "s";
156
+ }
157
+ function resolveLegacyModuleNaming(rawName) {
137
158
  const pkg = toPackageName(rawName);
159
+ const plural = legacyPluralize(pkg);
138
160
  return {
139
161
  name: pkg,
140
162
  pkg,
141
163
  pascalName: toPascalCase(pkg),
142
- plural: pluralize(pkg),
164
+ plural,
165
+ tableName: toDbName(plural),
143
166
  errorPrefix: pkg.toUpperCase(),
144
167
  };
145
168
  }
169
+ // Projects generated before canonical inflection used the raw input as the Go
170
+ // package name. Prefer the canonical package when present, but keep locating
171
+ // legacy plural packages so upgrade does not make method/remove commands lose
172
+ // sight of existing code. Two matches are unsafe: silently choosing one can
173
+ // patch or delete the wrong module.
174
+ function resolveExistingModuleNaming(rawName, existingPackages) {
175
+ const requestedLegacy = resolveLegacyModuleNaming(rawName);
176
+ const existing = new Set(existingPackages);
177
+ let canonical;
178
+ try {
179
+ canonical = resolveModuleNaming(rawName);
180
+ }
181
+ catch (error) {
182
+ if (existing.has(requestedLegacy.pkg))
183
+ return requestedLegacy;
184
+ throw error;
185
+ }
186
+ const aliases = existingPackages.filter((pkg) => {
187
+ if (pkg === canonical.pkg)
188
+ return false;
189
+ try {
190
+ return resolveModuleNaming(pkg).pkg === canonical.pkg;
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ });
196
+ const matches = [
197
+ ...(existing.has(canonical.pkg) ? [canonical.pkg] : []),
198
+ ...aliases,
199
+ ];
200
+ if (matches.length > 1) {
201
+ throw new Error(`ambiguous module "${rawName}": ${matches.map((pkg) => `internal/app/${pkg}`).join(" and ")} exist`);
202
+ }
203
+ if (matches[0] === canonical.pkg)
204
+ return canonical;
205
+ if (matches[0])
206
+ return resolveLegacyModuleNaming(matches[0]);
207
+ return canonical;
208
+ }
146
209
  function resolveMethodNaming(rawName) {
147
210
  const cleaned = rawName.trim();
148
211
  const pascalName = toPascalCase(cleaned);
@@ -44,7 +44,25 @@ function patchOpenapiIndex(openapiPath, naming, apiPrefix) {
44
44
  function unpatchOpenapiIndex(openapiPath, naming, apiPrefix) {
45
45
  const content = fs_extra_1.default.readFileSync(openapiPath, "utf8");
46
46
  const { paths, schemas } = openapiLines(naming, apiPrefix);
47
- fs_extra_1.default.writeFileSync(openapiPath, (0, marker_patch_1.removeLines)(content, [...paths, ...schemas]));
47
+ const withoutKnownEntries = (0, marker_patch_1.removeLines)(content, [...paths, ...schemas]);
48
+ // `generate method` can add any number of module-specific path documents.
49
+ // Remove every two-line path/$ref block owned by this module, otherwise the
50
+ // index keeps dangling references after the docs folder is deleted.
51
+ const lines = withoutKnownEntries.split("\n");
52
+ const moduleRefPrefix = `./${naming.plural}/`;
53
+ const kept = [];
54
+ for (let i = 0; i < lines.length; i += 1) {
55
+ const current = lines[i];
56
+ const next = lines[i + 1];
57
+ const isPathKey = /^\s*\/[^:]+:\s*$/.test(current);
58
+ const refMatch = next?.match(/^\s*\$ref:\s*['"]([^'"]+)['"]\s*$/);
59
+ if (isPathKey && refMatch?.[1].startsWith(moduleRefPrefix)) {
60
+ i += 1;
61
+ continue;
62
+ }
63
+ kept.push(current);
64
+ }
65
+ fs_extra_1.default.writeFileSync(openapiPath, kept.join("\n"));
48
66
  }
49
67
  // patchOpenapiIndexRaw wires hand-written path docs into the index — used by
50
68
  // `add auth`/`add rbac`, whose endpoints aren't a single CRUD resource so