@nakedev/go-scaffold 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -50
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +23 -12
- package/dist/commands/method.js +94 -17
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +368 -64
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/module-manifest.js +2 -0
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +97 -18
- package/dist/utils/module-location.js +58 -0
- package/dist/utils/naming.js +125 -14
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/openapi-patcher.js +19 -1
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/dist/utils/smoke-run.js +31 -0
- package/package.json +11 -4
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +14 -12
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +52 -16
- package/templates/create/base/README.md.hbs +61 -12
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +4 -9
- package/templates/create/features/docs/patterns.md.hbs +91 -14
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler.go.hbs +18 -4
- package/templates/generate/module/handler_test.go.hbs +88 -62
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -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 +16 -0
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +109 -0
- package/templates/generate/module/service.go.hbs +15 -4
- package/templates/generate/module/service_test.go.hbs +113 -17
- package/dist/commands/remove.js +0 -89
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
|
@@ -9,10 +9,12 @@ 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");
|
|
15
16
|
const openapi_patcher_1 = require("../utils/openapi-patcher");
|
|
17
|
+
const golangci_patcher_1 = require("../utils/golangci-patcher");
|
|
16
18
|
const migrations_1 = require("../utils/migrations");
|
|
17
19
|
const gocheck_1 = require("../utils/gocheck");
|
|
18
20
|
const generate_wizard_1 = require("../prompts/generate-wizard");
|
|
@@ -36,12 +38,17 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
|
|
|
36
38
|
throw new Error(`invalid permission code "${opts.permission}" — must start with a lowercase letter and contain only lowercase letters, digits, ':', '_', or '-'`);
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
|
-
const naming = (0,
|
|
41
|
+
const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
|
|
40
42
|
const modulePath = naming.pkg;
|
|
41
43
|
const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
|
|
42
44
|
if (fs_extra_1.default.existsSync(moduleDir) && fs_extra_1.default.readdirSync(moduleDir).length > 0) {
|
|
43
45
|
throw new Error(`${moduleDir} already exists — pick a different name or delete it first`);
|
|
44
46
|
}
|
|
47
|
+
// Every marker this command patches has to be there before the first file
|
|
48
|
+
// is written — a failure after the module folder and its migration exist is
|
|
49
|
+
// a dead end, since the retry trips the "already exists" guard above.
|
|
50
|
+
const mainGoPath = path_1.default.join(projectDir, "cmd", "api", "wiring.go");
|
|
51
|
+
(0, main_patcher_1.assertMainGoPatchable)(mainGoPath);
|
|
45
52
|
// snapshot before writing anything, so assertNoDrift below can tell "we broke
|
|
46
53
|
// it" from "it was already broken"
|
|
47
54
|
const checkBefore = (0, gocheck_1.typeChecks)(projectDir);
|
|
@@ -62,19 +69,22 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
|
|
|
62
69
|
// skip if a create_<plural> migration already exists — re-running after
|
|
63
70
|
// only the module folder was deleted shouldn't leave a duplicate migration.
|
|
64
71
|
const migrationsDir = path_1.default.join(projectDir, "migrations");
|
|
72
|
+
const slug = (0, naming_1.migrationSlug)(naming);
|
|
65
73
|
const migrationExists = fs_extra_1.default.existsSync(migrationsDir) &&
|
|
66
|
-
fs_extra_1.default
|
|
74
|
+
fs_extra_1.default
|
|
75
|
+
.readdirSync(migrationsDir)
|
|
76
|
+
.some((f) => (0, naming_1.migrationSlugAliases)(naming).some((alias) => f.endsWith(`_create_${alias}.up.sql`)));
|
|
67
77
|
let seq = "";
|
|
68
78
|
if (!migrationExists) {
|
|
69
79
|
seq = (0, migrations_1.newMigrationVersion)(migrationsDir);
|
|
70
80
|
const migrationEntries = [
|
|
71
81
|
{
|
|
72
82
|
template: "generate/module/migration.up.sql.hbs",
|
|
73
|
-
output: path_1.default.join("migrations", `${seq}_create_${
|
|
83
|
+
output: path_1.default.join("migrations", `${seq}_create_${slug}.up.sql`),
|
|
74
84
|
},
|
|
75
85
|
{
|
|
76
86
|
template: "generate/module/migration.down.sql.hbs",
|
|
77
|
-
output: path_1.default.join("migrations", `${seq}_create_${
|
|
87
|
+
output: path_1.default.join("migrations", `${seq}_create_${slug}.down.sql`),
|
|
78
88
|
},
|
|
79
89
|
];
|
|
80
90
|
await (0, template_renderer_1.applyTemplateEntries)(projectDir, migrationEntries, context);
|
|
@@ -89,21 +99,22 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
|
|
|
89
99
|
const permissionEntries = [
|
|
90
100
|
{
|
|
91
101
|
template: "generate/module/permission.up.sql.hbs",
|
|
92
|
-
output: path_1.default.join("migrations", `${permissionSeq}_add_${
|
|
102
|
+
output: path_1.default.join("migrations", `${permissionSeq}_add_${slug}_permission.up.sql`),
|
|
93
103
|
},
|
|
94
104
|
{
|
|
95
105
|
template: "generate/module/permission.down.sql.hbs",
|
|
96
|
-
output: path_1.default.join("migrations", `${permissionSeq}_add_${
|
|
106
|
+
output: path_1.default.join("migrations", `${permissionSeq}_add_${slug}_permission.down.sql`),
|
|
97
107
|
},
|
|
98
108
|
];
|
|
99
109
|
await (0, template_renderer_1.applyTemplateEntries)(projectDir, permissionEntries, context);
|
|
100
110
|
}
|
|
101
|
-
|
|
111
|
+
(0, golangci_patcher_1.patchGolangciForModule)(path_1.default.join(projectDir, ".golangci.yml"), config.goModule, naming.pkg);
|
|
102
112
|
(0, main_patcher_1.patchMainGo)(mainGoPath, {
|
|
103
113
|
goModule: config.goModule,
|
|
104
114
|
modulePath,
|
|
105
115
|
pkg: naming.pkg,
|
|
106
116
|
pascalName: naming.pascalName,
|
|
117
|
+
schemaName: naming.schemaName,
|
|
107
118
|
auth: opts.auth,
|
|
108
119
|
permission: opts.permission,
|
|
109
120
|
});
|
|
@@ -124,10 +135,10 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
|
|
|
124
135
|
const routePath = config.apiPrefix ? `/${config.apiPrefix}/${naming.plural}` : `/${naming.plural}`;
|
|
125
136
|
console.log(picocolors_1.default.green(`\ngenerated internal/app/${modulePath}/`));
|
|
126
137
|
if (opts.full) {
|
|
127
|
-
console.log(`registered route ${routePath} in cmd/api/
|
|
138
|
+
console.log(`registered route ${routePath} in cmd/api/wiring.go`);
|
|
128
139
|
}
|
|
129
140
|
else {
|
|
130
|
-
console.log(`registered empty route group ${routePath} in cmd/api/
|
|
141
|
+
console.log(`registered empty route group ${routePath} in cmd/api/wiring.go — ` +
|
|
131
142
|
`add endpoints with \`go-scaffold generate method ${naming.pkg} <name> --type ...\``);
|
|
132
143
|
}
|
|
133
144
|
if (opts.permission) {
|
|
@@ -140,13 +151,13 @@ async function generateModule(rawName, opts, projectDir = process.cwd()) {
|
|
|
140
151
|
console.log(picocolors_1.default.yellow(`note: this project has auth installed, but ${routePath} is PUBLIC — re-run with --auth (and --permission <code> if you also have rbac) to require login`));
|
|
141
152
|
}
|
|
142
153
|
if (seq) {
|
|
143
|
-
console.log(`migration: migrations/${seq}_create_${
|
|
154
|
+
console.log(`migration: migrations/${seq}_create_${slug}.{up,down}.sql`);
|
|
144
155
|
}
|
|
145
156
|
else {
|
|
146
|
-
console.log(`migration: reused existing migrations/*_create_${
|
|
157
|
+
console.log(`migration: reused existing migrations/*_create_${slug}.{up,down}.sql`);
|
|
147
158
|
}
|
|
148
159
|
if (permissionSeq) {
|
|
149
|
-
console.log(`migration: migrations/${permissionSeq}_add_${
|
|
160
|
+
console.log(`migration: migrations/${permissionSeq}_add_${slug}_permission.{up,down}.sql (seeds the "${opts.permission}" permission — grant it to a role via PATCH /roles/:code/permissions)`);
|
|
150
161
|
}
|
|
151
162
|
if (docsMessage)
|
|
152
163
|
console.log(docsMessage);
|
package/dist/commands/method.js
CHANGED
|
@@ -9,29 +9,62 @@ 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 migrations_1 = require("../utils/migrations");
|
|
17
|
+
const openapi_patcher_1 = require("../utils/openapi-patcher");
|
|
15
18
|
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}`;
|
|
19
|
+
// URL path registered in method-patcher.ts, excluding the project-wide API
|
|
20
|
+
// prefix so it can be passed directly to patchOpenapiIndexRaw.
|
|
21
|
+
function methodRoutePath(naming, method, type, getMode, field) {
|
|
22
|
+
const base = `/${naming.plural}`;
|
|
22
23
|
if (type === "get" && getMode === "all")
|
|
23
|
-
return
|
|
24
|
+
return `${base}/${method.pathSegment}`;
|
|
24
25
|
if (type === "get")
|
|
25
|
-
return
|
|
26
|
+
return `${base}/${(0, naming_1.toDbName)(field ?? "")}/{${(0, naming_1.toCamelCase)(field ?? "")}}`;
|
|
26
27
|
if (type === "post")
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
return `${base}/${method.pathSegment}`;
|
|
29
|
+
return `${base}/{id}/${method.pathSegment}`;
|
|
30
|
+
}
|
|
31
|
+
function routeHint(naming, method, type, apiPrefix, getMode, field) {
|
|
32
|
+
const path = methodRoutePath(naming, method, type, getMode, field);
|
|
33
|
+
const prefixed = apiPrefix ? `/${apiPrefix}${path}` : path;
|
|
34
|
+
return `${type.toUpperCase()} ${prefixed}`;
|
|
35
|
+
}
|
|
36
|
+
function methodOpenapiDocument(naming, method, type, getMode, field) {
|
|
37
|
+
const pathParameters = [];
|
|
38
|
+
if (type === "get" && getMode === "one") {
|
|
39
|
+
const parameter = (0, naming_1.toCamelCase)(field ?? "");
|
|
40
|
+
pathParameters.push("parameters:", ` - name: ${parameter}`, " in: path", " required: true", " schema:", " type: string");
|
|
41
|
+
}
|
|
42
|
+
else if (type === "put" || type === "patch" || type === "delete") {
|
|
43
|
+
pathParameters.push("parameters:", " - name: id", " in: path", " required: true", " schema:", " type: string", " format: uuid");
|
|
44
|
+
}
|
|
45
|
+
const operation = [
|
|
46
|
+
`${type}:`,
|
|
47
|
+
` summary: TODO document ${method.name}`,
|
|
48
|
+
` operationId: ${method.handlerName}${naming.pascalName}`,
|
|
49
|
+
` tags: [${naming.plural}]`,
|
|
50
|
+
];
|
|
51
|
+
if (type === "post") {
|
|
52
|
+
operation.push(" requestBody:", " required: true", " content:", " application/json:", " schema:", " type: object", " description: TODO define request fields");
|
|
53
|
+
}
|
|
54
|
+
const status = type === "delete" ? "204" : type === "post" ? "201" : "200";
|
|
55
|
+
operation.push(" responses:", ` \"${status}\":`, ` description: ${type === "delete" ? "completed or already absent" : "TODO define response"}`);
|
|
56
|
+
if (type !== "delete") {
|
|
57
|
+
operation.push(" content:", " application/json:", " schema:", " type: object", " description: TODO replace with the method response schema");
|
|
58
|
+
}
|
|
59
|
+
operation.push(" \"400\": { $ref: '../../common/responses.yaml#/ValidationError' }");
|
|
60
|
+
if (type === "get" || type === "put" || type === "patch") {
|
|
61
|
+
operation.push(" \"404\": { $ref: '../../common/responses.yaml#/NotFoundError' }");
|
|
62
|
+
}
|
|
63
|
+
return [...pathParameters, ...operation, ""].join("\n");
|
|
31
64
|
}
|
|
32
65
|
async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = process.cwd()) {
|
|
33
66
|
const config = (0, config_1.readConfig)(projectDir);
|
|
34
|
-
const naming = (0,
|
|
67
|
+
const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, moduleNameArg ?? (await (0, generate_wizard_1.promptExistingModule)((0, module_location_1.existingModulePackages)(projectDir), "add a method to")));
|
|
35
68
|
const modulePath = naming.pkg;
|
|
36
69
|
const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
|
|
37
70
|
const paths = {
|
|
@@ -56,18 +89,62 @@ async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = p
|
|
|
56
89
|
}
|
|
57
90
|
const getMode = type === "get" ? opts.getMode ?? (await (0, generate_wizard_1.promptGetMode)()) : undefined;
|
|
58
91
|
const field = type === "get" && getMode === "one" ? opts.field ?? (await (0, generate_wizard_1.promptLookupField)()) : undefined;
|
|
59
|
-
// field becomes a Go param name (`func (...)(ctx, <field> string)`)
|
|
92
|
+
// field becomes a Go param name (`func (...)(ctx, <field> string)`) and a
|
|
93
|
+
// column name in the generated `WHERE <field> = ?`
|
|
60
94
|
if (field)
|
|
61
|
-
(0, naming_1.
|
|
95
|
+
(0, naming_1.assertGoIdentifier)((0, naming_1.toCamelCase)(field), "lookup field");
|
|
62
96
|
const method = (0, naming_1.resolveMethodNaming)(methodNameArg ?? (await (0, generate_wizard_1.promptMethodName)()));
|
|
97
|
+
// Every marker this command patches has to exist before the first write:
|
|
98
|
+
// patchMethod writes dto.go, then handler.go, then reads service.go, so a
|
|
99
|
+
// missing service marker used to leave two files patched and the method
|
|
100
|
+
// permanently un-retryable (assertNotDuplicate then sees it as existing).
|
|
101
|
+
if (!(0, method_patcher_1.markersPresent)(paths.handlerPath, paths.servicePath)) {
|
|
102
|
+
throw new Error(`internal/app/${naming.pkg} is missing the marker comments \`generate method\` patches at.\n` +
|
|
103
|
+
`handler.go and service.go must both still carry their \`// go-scaffold:*\` markers —\n` +
|
|
104
|
+
`restore them, or add this method by hand.`);
|
|
105
|
+
}
|
|
106
|
+
const docsRelativePath = config.features.openapiDocs
|
|
107
|
+
? `${naming.plural}/methods/${method.pathSegment}.yaml`
|
|
108
|
+
: undefined;
|
|
109
|
+
if (docsRelativePath && fs_extra_1.default.existsSync(path_1.default.join(projectDir, "docs", docsRelativePath))) {
|
|
110
|
+
throw new Error(`OpenAPI method document already exists: docs/${docsRelativePath}`);
|
|
111
|
+
}
|
|
63
112
|
const checkBefore = (0, gocheck_1.typeChecks)(projectDir);
|
|
64
113
|
(0, method_patcher_1.patchMethod)(paths, naming, method, { type, getMode, field }, config.goModule);
|
|
65
114
|
(0, template_renderer_1.gofmtTree)(projectDir);
|
|
66
115
|
(0, gocheck_1.assertNoDrift)(projectDir, checkBefore, config);
|
|
116
|
+
// A --field lookup queries a column the table doesn't have yet. GORM builds
|
|
117
|
+
// that SQL at runtime, so nothing before the first real request notices:
|
|
118
|
+
// the build passes, vet passes, the generated tests pass. Ship the column
|
|
119
|
+
// and its index with the code that needs them.
|
|
120
|
+
let fieldMigration = "";
|
|
121
|
+
if (field) {
|
|
122
|
+
const migrationsDir = path_1.default.join(projectDir, "migrations");
|
|
123
|
+
fieldMigration = (0, migrations_1.newMigrationVersion)(migrationsDir);
|
|
124
|
+
const fieldColumn = (0, naming_1.toDbName)(field);
|
|
125
|
+
await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
|
|
126
|
+
{ template: "generate/module/field-column.up.sql.hbs", output: path_1.default.join("migrations", `${fieldMigration}_add_${naming.tableName}_${fieldColumn}.up.sql`) },
|
|
127
|
+
{ template: "generate/module/field-column.down.sql.hbs", output: path_1.default.join("migrations", `${fieldMigration}_add_${naming.tableName}_${fieldColumn}.down.sql`) },
|
|
128
|
+
], { ...naming, pkg: naming.pkg, methodName: method.name, fieldColumn });
|
|
129
|
+
}
|
|
130
|
+
if (fieldMigration) {
|
|
131
|
+
console.log(`migration: migrations/${fieldMigration}_add_${naming.tableName}_${(0, naming_1.toDbName)(field)}.{up,down}.sql ` +
|
|
132
|
+
`(adds the column the lookup queries, plus an index on it — check the type before applying)`);
|
|
133
|
+
}
|
|
134
|
+
if (docsRelativePath) {
|
|
135
|
+
const docsPath = path_1.default.join(projectDir, "docs", docsRelativePath);
|
|
136
|
+
fs_extra_1.default.outputFileSync(docsPath, methodOpenapiDocument(naming, method, type, getMode, field));
|
|
137
|
+
(0, openapi_patcher_1.patchOpenapiIndexRaw)(path_1.default.join(projectDir, "docs", "openapi.yaml"), config.apiPrefix, [
|
|
138
|
+
{
|
|
139
|
+
urlPath: methodRoutePath(naming, method, type, getMode, field),
|
|
140
|
+
file: `./${docsRelativePath}`,
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
67
144
|
console.log(picocolors_1.default.green(`\nadded "${method.name}" to internal/app/${modulePath}/`));
|
|
68
145
|
console.log(`route: ${routeHint(naming, method, type, config.apiPrefix, getMode, field)}`);
|
|
69
|
-
if (
|
|
70
|
-
console.log(picocolors_1.default.
|
|
146
|
+
if (docsRelativePath) {
|
|
147
|
+
console.log(picocolors_1.default.green(`docs: docs/${docsRelativePath} (wired into docs/openapi.yaml)`));
|
|
71
148
|
}
|
|
72
149
|
console.log(picocolors_1.default.dim(`\nnext: fill in the TODO in service.go, then \`go build ./...\` / \`go test ./...\``));
|
|
73
150
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
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.addObservability = addObservability;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
10
|
+
const config_1 = require("../utils/config");
|
|
11
|
+
const template_renderer_1 = require("../utils/template-renderer");
|
|
12
|
+
const observability_manifest_1 = require("../templates/observability-manifest");
|
|
13
|
+
const observability_patcher_1 = require("../utils/observability-patcher");
|
|
14
|
+
const gocheck_1 = require("../utils/gocheck");
|
|
15
|
+
const gomod_patcher_1 = require("../utils/gomod-patcher");
|
|
16
|
+
// addObservability scaffolds Prometheus metrics (GET /metrics) and
|
|
17
|
+
// OpenTelemetry tracing for Gin + GORM, wiring both into the files `create`
|
|
18
|
+
// already wrote. Opt-in and separate from `create`: most projects don't want
|
|
19
|
+
// to think about a collector on day one, and unlike worker/auth/rbac this
|
|
20
|
+
// touches files every project already has, so it has to patch rather than
|
|
21
|
+
// just add new ones. `create --observability` calls this immediately after
|
|
22
|
+
// scaffolding, so the two paths produce identical projects.
|
|
23
|
+
async function addObservability(projectDir = process.cwd(), opts = {}) {
|
|
24
|
+
const config = (0, config_1.readConfig)(projectDir);
|
|
25
|
+
const telemetryGoPath = path_1.default.join(projectDir, "internal", "platform", "telemetry", "tracing.go");
|
|
26
|
+
if (fs_extra_1.default.existsSync(telemetryGoPath)) {
|
|
27
|
+
throw new Error(`${telemetryGoPath} already exists — observability looks like it's already been added`);
|
|
28
|
+
}
|
|
29
|
+
const parsedBefore = (0, gocheck_1.parseChecks)(projectDir);
|
|
30
|
+
await (0, template_renderer_1.applyTemplateEntries)(projectDir, observability_manifest_1.OBSERVABILITY_FILES, { openapiDocs: config.features.openapiDocs });
|
|
31
|
+
(0, observability_patcher_1.patchMainGoForObservability)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), config.goModule, config.projectName);
|
|
32
|
+
(0, observability_patcher_1.patchDatabaseGoForObservability)(path_1.default.join(projectDir, "internal", "platform", "database", "database.go"), config.goModule);
|
|
33
|
+
(0, observability_patcher_1.patchConfigForObservability)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
|
|
34
|
+
(0, gomod_patcher_1.patchGoModRequires)(path_1.default.join(projectDir, "go.mod"), [
|
|
35
|
+
"github.com/prometheus/client_golang v1.24.1",
|
|
36
|
+
"go.opentelemetry.io/otel v1.45.0",
|
|
37
|
+
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0",
|
|
38
|
+
"go.opentelemetry.io/otel/sdk v1.45.0",
|
|
39
|
+
"go.opentelemetry.io/otel/trace v1.45.0",
|
|
40
|
+
]);
|
|
41
|
+
(0, observability_patcher_1.patchEnvExampleForObservability)(path_1.default.join(projectDir, ".env.example"));
|
|
42
|
+
const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
|
|
43
|
+
if (config.features.openapiDocs && fs_extra_1.default.existsSync(openapiPath)) {
|
|
44
|
+
(0, observability_patcher_1.patchOpenapiIndexForObservability)(openapiPath);
|
|
45
|
+
}
|
|
46
|
+
(0, template_renderer_1.gofmtTree)(projectDir);
|
|
47
|
+
// parse-only: the otel/prometheus imports this adds aren't in go.mod until
|
|
48
|
+
// the `go mod tidy` printed below, so `go vet` can't be the gate here.
|
|
49
|
+
(0, gocheck_1.assertStillParses)(projectDir, parsedBefore, "added observability");
|
|
50
|
+
// After the gate, next to writeConfig: these are .md files, so the parse
|
|
51
|
+
// check has no reason to guard them, and a throw from it used to strand the
|
|
52
|
+
// docs saying `enabled` while the config it never reached still said false —
|
|
53
|
+
// a disagreement no later command could converge, since the "already added"
|
|
54
|
+
// guard then blocks a re-run.
|
|
55
|
+
const staleDocs = refreshArchitectDocs(projectDir, config);
|
|
56
|
+
(0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, observability: true } });
|
|
57
|
+
if (opts.silent)
|
|
58
|
+
return;
|
|
59
|
+
console.log(picocolors_1.default.green("\nadded internal/platform/telemetry/, internal/shared/middleware/{metrics,tracing}.go, and GET /metrics"));
|
|
60
|
+
console.log("wired into cmd/api/wiring.go and internal/platform/database — every request and GORM query now gets a trace span");
|
|
61
|
+
if (staleDocs.length) {
|
|
62
|
+
// Deliberately not "you edited these". The comparison is a whole-file
|
|
63
|
+
// match against today's template, and techstack.md embeds pinned
|
|
64
|
+
// dependency versions — so every release that bumps one makes every
|
|
65
|
+
// project scaffolded before it look edited. Which is exactly the
|
|
66
|
+
// population this function exists for.
|
|
67
|
+
console.log(picocolors_1.default.yellow(`\nnote: couldn't safely rewrite ${staleDocs.join(" and ")} — ` +
|
|
68
|
+
`${staleDocs.length > 1 ? "they don't" : "it doesn't"} match what this go-scaffold\n` +
|
|
69
|
+
` would have generated, so ${staleDocs.length > 1 ? "they were" : "it was"} left alone rather than overwriting your edits.\n` +
|
|
70
|
+
` ${staleDocs.length > 1 ? "They still describe" : "It still describes"} this project as having no metrics or tracing; update by hand.`));
|
|
71
|
+
}
|
|
72
|
+
console.log(picocolors_1.default.dim("\nnext: go mod tidy, then set OTEL_EXPORTER_OTLP_ENDPOINT to export traces (empty = tracing no-ops, /metrics works either way)"));
|
|
73
|
+
}
|
|
74
|
+
// The architecture docs gate their observability sections on a `create`-time
|
|
75
|
+
// flag, so adding the feature afterwards used to leave techstack.md saying
|
|
76
|
+
// `disabled` and architecture.md missing the section entirely — while the
|
|
77
|
+
// README promises `create --observability` and this command produce the same
|
|
78
|
+
// project.
|
|
79
|
+
//
|
|
80
|
+
// Re-rendering from the templates rather than string-patching keeps the prose
|
|
81
|
+
// in one place (the .hbs), but would also silently discard a user's edits. So
|
|
82
|
+
// it first renders what the file *should* look like today, with observability
|
|
83
|
+
// still off: only a byte-for-byte match proves nobody has touched it. Returns
|
|
84
|
+
// the docs it declined to overwrite, for the caller to warn about.
|
|
85
|
+
function refreshArchitectDocs(projectDir, config) {
|
|
86
|
+
const docs = [
|
|
87
|
+
{ template: "create/features/docs/architecture.md.hbs", output: path_1.default.join("docs", "architect", "architecture.md") },
|
|
88
|
+
{ template: "create/features/docs/techstack.md.hbs", output: path_1.default.join("docs", "architect", "techstack.md") },
|
|
89
|
+
];
|
|
90
|
+
// only what the two templates actually reference — projectName, apiPrefix
|
|
91
|
+
// and the feature flags
|
|
92
|
+
const base = { projectName: config.projectName, apiPrefix: config.apiPrefix, ...config.features };
|
|
93
|
+
const root = (0, template_renderer_1.getTemplatesRoot)();
|
|
94
|
+
const skipped = [];
|
|
95
|
+
for (const doc of docs) {
|
|
96
|
+
const outputPath = path_1.default.join(projectDir, doc.output);
|
|
97
|
+
if (!fs_extra_1.default.existsSync(outputPath))
|
|
98
|
+
continue;
|
|
99
|
+
const source = fs_extra_1.default.readFileSync(path_1.default.join(root, doc.template), "utf8");
|
|
100
|
+
// compare with line endings normalised — a Windows checkout with
|
|
101
|
+
// core.autocrlf=true would otherwise never match, and the feature would
|
|
102
|
+
// silently never fire there
|
|
103
|
+
const onDisk = lf(fs_extra_1.default.readFileSync(outputPath, "utf8"));
|
|
104
|
+
if (onDisk !== lf((0, template_renderer_1.renderString)(source, { ...base, observability: false }))) {
|
|
105
|
+
skipped.push(doc.output);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
fs_extra_1.default.writeFileSync(outputPath, (0, template_renderer_1.renderString)(source, { ...base, observability: true }));
|
|
109
|
+
}
|
|
110
|
+
return skipped;
|
|
111
|
+
}
|
|
112
|
+
function lf(text) {
|
|
113
|
+
return text.replace(/\r\n/g, "\n");
|
|
114
|
+
}
|
package/dist/commands/rbac.js
CHANGED
|
@@ -13,6 +13,8 @@ const rbac_manifest_1 = require("../templates/rbac-manifest");
|
|
|
13
13
|
const migrations_1 = require("../utils/migrations");
|
|
14
14
|
const rbac_patcher_1 = require("../utils/rbac-patcher");
|
|
15
15
|
const openapi_patcher_1 = require("../utils/openapi-patcher");
|
|
16
|
+
const golangci_patcher_1 = require("../utils/golangci-patcher");
|
|
17
|
+
const gocheck_1 = require("../utils/gocheck");
|
|
16
18
|
// URL (relative to the api prefix) -> docs file (relative to docs/) for every
|
|
17
19
|
// route `add rbac` registers or adds onto the user handler.
|
|
18
20
|
const RBAC_OPENAPI_PATHS = [
|
|
@@ -41,6 +43,12 @@ async function addRbac(projectDir = process.cwd()) {
|
|
|
41
43
|
if (fs_extra_1.default.existsSync(roleDir)) {
|
|
42
44
|
throw new Error(`${roleDir} already exists — RBAC looks like it's already been added`);
|
|
43
45
|
}
|
|
46
|
+
const before = (0, gocheck_1.typeChecks)(projectDir);
|
|
47
|
+
// Every file below is patched in place, and main.go's userSvc line is the
|
|
48
|
+
// one rbac cannot construct on its own — check it first so a mismatch costs
|
|
49
|
+
// an error message rather than a half-patched project.
|
|
50
|
+
(0, rbac_patcher_1.assertRbacPatchable)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), config.goModule, config.features.authStore ?? "redis", config.features.worker ?? false);
|
|
51
|
+
const parsedBefore = (0, gocheck_1.parseChecks)(projectDir);
|
|
44
52
|
await (0, template_renderer_1.applyTemplateEntries)(projectDir, rbac_manifest_1.RBAC_FILES, { goModule: config.goModule });
|
|
45
53
|
const migrationsDir = path_1.default.join(projectDir, "migrations");
|
|
46
54
|
fs_extra_1.default.ensureDirSync(migrationsDir);
|
|
@@ -58,7 +66,9 @@ async function addRbac(projectDir = process.cwd()) {
|
|
|
58
66
|
(0, rbac_patcher_1.patchUserDTOForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "dto.go"));
|
|
59
67
|
(0, rbac_patcher_1.patchUserHandlerForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "handler.go"), config.goModule);
|
|
60
68
|
(0, rbac_patcher_1.patchUserErrorsForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "errors.go"));
|
|
61
|
-
(0,
|
|
69
|
+
(0, golangci_patcher_1.patchGolangciForModule)(path_1.default.join(projectDir, ".golangci.yml"), config.goModule, "role");
|
|
70
|
+
// projects scaffolded before --store existed are all Redis-backed
|
|
71
|
+
(0, rbac_patcher_1.patchMainGoForRbac)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), config.goModule, config.features.authStore ?? "redis", config.features.worker ?? false);
|
|
62
72
|
(0, rbac_patcher_1.patchCmdSeedForRbac)(path_1.default.join(projectDir, "cmd", "seed", "main.go"), config.goModule);
|
|
63
73
|
patchEnvExample(path_1.default.join(projectDir, ".env.example"));
|
|
64
74
|
let docsMessage = "";
|
|
@@ -77,9 +87,16 @@ async function addRbac(projectDir = process.cwd()) {
|
|
|
77
87
|
docsMessage = "\ndocs: docs/rbac/*.yaml, wired into docs/openapi.yaml";
|
|
78
88
|
}
|
|
79
89
|
(0, template_renderer_1.gofmtTree)(projectDir);
|
|
90
|
+
(0, gocheck_1.assertStillParses)(projectDir, parsedBefore, "added RBAC");
|
|
91
|
+
// rbac introduces no new third-party dependency, so unlike add auth/worker
|
|
92
|
+
// this can hold the stronger line: the project has to still type-check.
|
|
93
|
+
(0, gocheck_1.assertNoDrift)(projectDir, before, config, {
|
|
94
|
+
didWhat: "added RBAC",
|
|
95
|
+
recover: "internal/app/role/ and the rbac patches were left in place — reconcile cmd/api/wiring.go by hand.",
|
|
96
|
+
});
|
|
80
97
|
(0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, rbac: true } });
|
|
81
98
|
console.log(picocolors_1.default.green("\nadded internal/app/role/ and internal/shared/middleware/authz.go"));
|
|
82
|
-
console.log("registered GET /users, GET /users/:id, PATCH /users/:id/set-role, /roles, and /permissions in cmd/api/
|
|
99
|
+
console.log("registered GET /users, GET /users/:id, PATCH /users/:id/set-role, /roles, and /permissions in cmd/api/wiring.go" + docsMessage);
|
|
83
100
|
console.log(picocolors_1.default.yellow("\n⚠ AUTO_MIGRATE=true does NOT seed the role/permission data — it only creates the\n" +
|
|
84
101
|
" tables from the Go structs. The \"staff\"/\"admin\" roles and their permissions live\n" +
|
|
85
102
|
" in the migration's SQL (INSERT statements), which AutoMigrate never runs. Without\n" +
|