@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.
- package/README.md +18 -14
- package/dist/commands/generate.js +2 -1
- package/dist/commands/method.js +63 -15
- package/dist/commands/remove.js +23 -24
- package/dist/index.js +5 -4
- package/dist/templates/module-manifest.js +2 -0
- package/dist/utils/method-patcher.js +80 -16
- package/dist/utils/module-location.js +22 -0
- package/dist/utils/naming.js +75 -12
- package/dist/utils/openapi-patcher.js +19 -1
- package/dist/utils/smoke-run.js +31 -0
- package/package.json +14 -5
- package/scripts/smoke-test.mjs +2058 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +18 -5
- package/templates/create/base/AGENTS.md.hbs +10 -12
- package/templates/create/base/Makefile.hbs +9 -3
- package/templates/create/base/README.md.hbs +18 -6
- package/templates/create/features/docs/openapi.yaml.hbs +4 -5
- package/templates/create/features/docs/patterns.md.hbs +9 -6
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/handler.go.hbs +18 -4
- package/templates/generate/module/handler_test.go.hbs +81 -62
- package/templates/generate/module/migration.down.sql.hbs +1 -1
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/templates/generate/module/minimal/handler.go.hbs +8 -2
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +7 -0
- package/templates/generate/module/repository_test.go.hbs +79 -0
- package/templates/generate/module/service.go.hbs +6 -4
- package/templates/generate/module/service_test.go.hbs +68 -17
- package/tests/integration/default-module.test.mjs +46 -0
- package/tests/integration/generator-naming.test.mjs +81 -0
- package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
- package/tests/integration/legacy-method-compat.test.mjs +222 -0
- package/tests/integration/remove-module.test.mjs +58 -0
- package/tests/unit/naming.test.mjs +94 -0
- 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 #
|
|
91
|
-
go-scaffold generate module orders --
|
|
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
|
-
|
|
94
|
+
`--full` scaffolds:
|
|
95
95
|
|
|
96
96
|
```text
|
|
97
|
-
internal/app/
|
|
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 #
|
|
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,
|
|
105
|
-
|
|
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
|
-
|
|
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 +
|
|
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`
|
|
158
|
-
`docs
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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,
|
|
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) {
|
package/dist/commands/method.js
CHANGED
|
@@ -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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
23
|
+
return `${base}/${method.pathSegment}`;
|
|
24
24
|
if (type === "get")
|
|
25
|
-
return
|
|
25
|
+
return `${base}/${(0, naming_1.toDbName)(field ?? "")}/{${(0, naming_1.toCamelCase)(field ?? "")}}`;
|
|
26
26
|
if (type === "post")
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,
|
|
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 (
|
|
70
|
-
console.log(picocolors_1.default.
|
|
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
|
}
|
package/dist/commands/remove.js
CHANGED
|
@@ -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
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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,
|
|
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}
|
|
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.
|
|
66
|
-
//
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 (
|
|
87
|
-
console.log(`
|
|
88
|
-
|
|
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 (
|
|
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:
|
|
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
|
|
83
|
-
.option("--
|
|
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
|
|
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
|
-
//
|
|
121
|
-
//
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
}
|
package/dist/utils/naming.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|