@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
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;
|
|
@@ -9,22 +12,21 @@ exports.toDbName = toDbName;
|
|
|
9
12
|
exports.validateGoModulePath = validateGoModulePath;
|
|
10
13
|
exports.assertValidGoModulePath = assertValidGoModulePath;
|
|
11
14
|
exports.assertNotGoKeyword = assertNotGoKeyword;
|
|
15
|
+
exports.assertGoIdentifier = assertGoIdentifier;
|
|
12
16
|
exports.validateModuleName = validateModuleName;
|
|
13
17
|
exports.normalizeApiPrefix = normalizeApiPrefix;
|
|
14
18
|
exports.validateApiPrefix = validateApiPrefix;
|
|
15
19
|
exports.resolveModuleNaming = resolveModuleNaming;
|
|
20
|
+
exports.resolveExistingModuleNaming = resolveExistingModuleNaming;
|
|
21
|
+
exports.migrationSlug = migrationSlug;
|
|
22
|
+
exports.migrationSlugAliases = migrationSlugAliases;
|
|
16
23
|
exports.resolveMethodNaming = resolveMethodNaming;
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
//
|
|
24
|
+
const pluralize_1 = __importDefault(require("pluralize"));
|
|
25
|
+
// Pluralization must be idempotent because module names may come from the
|
|
26
|
+
// interactive prompt (singular) or a documented/scripted command (often
|
|
27
|
+
// plural). Keep this wrapper exported for callers that only need inflection.
|
|
20
28
|
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";
|
|
29
|
+
return pluralize_1.default.plural(word);
|
|
28
30
|
}
|
|
29
31
|
function toPascalCase(value) {
|
|
30
32
|
return value
|
|
@@ -86,6 +88,13 @@ const GO_PREDECLARED_TYPES = new Set([
|
|
|
86
88
|
"float32", "float64", "int", "int8", "int16", "int32", "int64", "rune",
|
|
87
89
|
"string", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr",
|
|
88
90
|
]);
|
|
91
|
+
// Directory names the go tool reserves. A module named "vendors" singularises
|
|
92
|
+
// to "vendor", and internal/app/vendor is then treated as a vendor directory:
|
|
93
|
+
// the build fails with "use of vendored package not allowed" and "must be
|
|
94
|
+
// imported as model", for a project that was generated, not hand-written.
|
|
95
|
+
// Not caught by the drift check either — that compares before and after, and
|
|
96
|
+
// this breaks the whole module at once.
|
|
97
|
+
const GO_RESERVED_DIRS = new Set(["vendor", "testdata"]);
|
|
89
98
|
// method/handler/param identifier: only keywords are hard-illegal (a param or
|
|
90
99
|
// func named `string` is legal Go, just shadows the builtin locally).
|
|
91
100
|
function assertNotGoKeyword(ident, role) {
|
|
@@ -93,12 +102,25 @@ function assertNotGoKeyword(ident, role) {
|
|
|
93
102
|
throw new Error(`"${ident}" is a Go keyword — can't use it as a ${role} name; pick another`);
|
|
94
103
|
}
|
|
95
104
|
}
|
|
105
|
+
// Anything that ends up spliced into a Go declaration has to be a legal
|
|
106
|
+
// identifier first. Without this, `generate method thing 2fa` exits 0 over a
|
|
107
|
+
// `func (s *Service) 2fa(...)` spread across four files, and a --field like
|
|
108
|
+
// `x string) error { panic(0) } // ` is injected straight into the generated
|
|
109
|
+
// signatures. Neither is caught later: gofmt's failure is only advisory and
|
|
110
|
+
// go vet needs a project that compiled a moment ago.
|
|
111
|
+
function assertGoIdentifier(ident, role) {
|
|
112
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ident)) {
|
|
113
|
+
throw new Error(`"${ident}" is not a valid Go identifier — can't use it as a ${role} name.\n` +
|
|
114
|
+
`Letters, digits and underscores only, and it can't start with a digit.`);
|
|
115
|
+
}
|
|
116
|
+
assertNotGoKeyword(ident, role);
|
|
117
|
+
}
|
|
96
118
|
// module name becomes a Go package name; keywords and predeclared type names
|
|
97
119
|
// both produce code that won't compile (`package func`, or a `string` package
|
|
98
120
|
// shadowing the builtin in main.go). Returns true|message for inquirer, and
|
|
99
121
|
// backs the assert in resolveModuleNaming — one source of truth for both.
|
|
100
122
|
function validateModuleName(rawName) {
|
|
101
|
-
const pkg = toPackageName(rawName);
|
|
123
|
+
const pkg = toPackageName(pluralize_1.default.singular(toKebabCase(rawName)));
|
|
102
124
|
if (!pkg)
|
|
103
125
|
return `invalid module name: "${rawName}" (must contain letters/numbers)`;
|
|
104
126
|
if (/^[0-9]/.test(pkg)) {
|
|
@@ -107,6 +129,9 @@ function validateModuleName(rawName) {
|
|
|
107
129
|
if (GO_KEYWORDS.has(pkg) || GO_PREDECLARED_TYPES.has(pkg)) {
|
|
108
130
|
return `"${pkg}" is a reserved Go word — a package named it won't compile; pick another module name`;
|
|
109
131
|
}
|
|
132
|
+
if (GO_RESERVED_DIRS.has(pkg)) {
|
|
133
|
+
return `"${pkg}" is a directory name the go tool reserves — internal/app/${pkg} would be treated as a ${pkg} directory and the project wouldn't build; pick another module name`;
|
|
134
|
+
}
|
|
110
135
|
return true;
|
|
111
136
|
}
|
|
112
137
|
// strips whitespace and leading/trailing slashes so "/api/v1/" and "api/v1"
|
|
@@ -134,23 +159,109 @@ function resolveModuleNaming(rawName) {
|
|
|
134
159
|
const check = validateModuleName(rawName);
|
|
135
160
|
if (check !== true)
|
|
136
161
|
throw new Error(check);
|
|
162
|
+
const singular = pluralize_1.default.singular(toKebabCase(rawName));
|
|
163
|
+
const pkg = toPackageName(singular);
|
|
164
|
+
const plural = pluralize_1.default.plural(singular);
|
|
165
|
+
return {
|
|
166
|
+
name: singular,
|
|
167
|
+
pkg,
|
|
168
|
+
pascalName: toPascalCase(singular),
|
|
169
|
+
plural,
|
|
170
|
+
tableName: toDbName(plural),
|
|
171
|
+
schemaName: `${pkg}_svc`,
|
|
172
|
+
errorPrefix: toDbName(singular).toUpperCase(),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function legacyPluralize(word) {
|
|
176
|
+
if (/[sxz]$/.test(word) || /[^aeiou](ch|sh)$/.test(word))
|
|
177
|
+
return word + "es";
|
|
178
|
+
if (/[^aeiou]y$/.test(word))
|
|
179
|
+
return word.slice(0, -1) + "ies";
|
|
180
|
+
if (word.endsWith("s"))
|
|
181
|
+
return word;
|
|
182
|
+
return word + "s";
|
|
183
|
+
}
|
|
184
|
+
function resolveLegacyModuleNaming(rawName) {
|
|
137
185
|
const pkg = toPackageName(rawName);
|
|
186
|
+
const plural = legacyPluralize(pkg);
|
|
138
187
|
return {
|
|
139
188
|
name: pkg,
|
|
140
189
|
pkg,
|
|
141
190
|
pascalName: toPascalCase(pkg),
|
|
142
|
-
plural
|
|
191
|
+
plural,
|
|
192
|
+
tableName: toDbName(plural),
|
|
193
|
+
// Not meaningful for a legacy match: its table already exists in
|
|
194
|
+
// whatever schema the project's own migrations put it in (usually
|
|
195
|
+
// "public", from before this field existed), and nothing re-renders its
|
|
196
|
+
// model/migration templates to move it. Every consumer of this result is
|
|
197
|
+
// generate method/undo module, neither of which reads schemaName.
|
|
198
|
+
schemaName: `${pkg}_svc`,
|
|
143
199
|
errorPrefix: pkg.toUpperCase(),
|
|
144
200
|
};
|
|
145
201
|
}
|
|
202
|
+
// Projects generated before canonical inflection used the raw input as the Go
|
|
203
|
+
// package name. Prefer the canonical package when present, but keep locating
|
|
204
|
+
// legacy plural packages so upgrade does not make method/remove commands lose
|
|
205
|
+
// sight of existing code. Two matches are unsafe: silently choosing one can
|
|
206
|
+
// patch or delete the wrong module.
|
|
207
|
+
function resolveExistingModuleNaming(rawName, existingPackages) {
|
|
208
|
+
const requestedLegacy = resolveLegacyModuleNaming(rawName);
|
|
209
|
+
const existing = new Set(existingPackages);
|
|
210
|
+
let canonical;
|
|
211
|
+
try {
|
|
212
|
+
canonical = resolveModuleNaming(rawName);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (existing.has(requestedLegacy.pkg))
|
|
216
|
+
return requestedLegacy;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
const aliases = existingPackages.filter((pkg) => {
|
|
220
|
+
if (pkg === canonical.pkg)
|
|
221
|
+
return false;
|
|
222
|
+
try {
|
|
223
|
+
return resolveModuleNaming(pkg).pkg === canonical.pkg;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
const matches = [
|
|
230
|
+
...(existing.has(canonical.pkg) ? [canonical.pkg] : []),
|
|
231
|
+
...aliases,
|
|
232
|
+
];
|
|
233
|
+
if (matches.length > 1) {
|
|
234
|
+
throw new Error(`ambiguous module "${rawName}": ${matches.map((pkg) => `internal/app/${pkg}`).join(" and ")} exist`);
|
|
235
|
+
}
|
|
236
|
+
if (matches[0] === canonical.pkg)
|
|
237
|
+
return canonical;
|
|
238
|
+
if (matches[0])
|
|
239
|
+
return resolveLegacyModuleNaming(matches[0]);
|
|
240
|
+
return canonical;
|
|
241
|
+
}
|
|
242
|
+
// Migration filenames read like the thing they create, so they use the
|
|
243
|
+
// snake_case table name: create_order_items.up.sql for order_svc.order_items.
|
|
244
|
+
// Older projects were written with the kebab-case route slug instead
|
|
245
|
+
// (create_order-items.up.sql), so anything that *looks up* an existing pair
|
|
246
|
+
// has to accept both — upgrading the CLI must not strand a module's
|
|
247
|
+
// migrations where `undo` can no longer see them. Identical for the
|
|
248
|
+
// single-word names that are the common case.
|
|
249
|
+
function migrationSlug(naming) {
|
|
250
|
+
return naming.tableName;
|
|
251
|
+
}
|
|
252
|
+
function migrationSlugAliases(naming) {
|
|
253
|
+
return naming.tableName === naming.plural ? [naming.tableName] : [naming.tableName, naming.plural];
|
|
254
|
+
}
|
|
146
255
|
function resolveMethodNaming(rawName) {
|
|
147
256
|
const cleaned = rawName.trim();
|
|
148
257
|
const pascalName = toPascalCase(cleaned);
|
|
149
258
|
if (!pascalName) {
|
|
150
259
|
throw new Error(`invalid method name: "${rawName}" (must contain letters/numbers)`);
|
|
151
260
|
}
|
|
152
|
-
//
|
|
153
|
-
|
|
261
|
+
// both become Go identifiers: handlerName as a method name
|
|
262
|
+
// (`func (h *Handler) <name>`), pascalName as a DTO type (`<Name>Input`)
|
|
263
|
+
assertGoIdentifier(toCamelCase(cleaned), "method");
|
|
264
|
+
assertGoIdentifier(pascalName, "method");
|
|
154
265
|
return {
|
|
155
266
|
name: cleaned,
|
|
156
267
|
pascalName,
|
|
@@ -0,0 +1,107 @@
|
|
|
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.patchMainGoForObservability = patchMainGoForObservability;
|
|
7
|
+
exports.patchDatabaseGoForObservability = patchDatabaseGoForObservability;
|
|
8
|
+
exports.patchConfigForObservability = patchConfigForObservability;
|
|
9
|
+
exports.patchEnvExampleForObservability = patchEnvExampleForObservability;
|
|
10
|
+
exports.patchOpenapiIndexForObservability = patchOpenapiIndexForObservability;
|
|
11
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
12
|
+
const marker_patch_1 = require("./marker-patch");
|
|
13
|
+
const IMPORT_MARKER = "// go-scaffold:imports";
|
|
14
|
+
const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
|
|
15
|
+
const EXTRA_ROUTES_MARKER = "// go-scaffold:extra-routes";
|
|
16
|
+
const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
|
|
17
|
+
const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
|
|
18
|
+
const OPENAPI_PATHS_MARKER = "# go-scaffold:paths";
|
|
19
|
+
// The exact line `create` renders — matched literally rather than through a
|
|
20
|
+
// marker because it's a single call in the middle of other middleware, not a
|
|
21
|
+
// standalone line a marker comment can sit next to.
|
|
22
|
+
const USE_LINE = "r.Use(gin.Recovery(), middleware.CORS(cfg.CORSAllowedOrigins), middleware.RequestID(), middleware.Logger(logger), middleware.Error(!cfg.IsProd()))";
|
|
23
|
+
// patchMainGoForObservability wires telemetry init, the tracing/metrics
|
|
24
|
+
// middleware, and the /metrics route into cmd/api/wiring.go — the same
|
|
25
|
+
// text-marker approach every other `add` command uses, since main.go is a
|
|
26
|
+
// real file a human may have already edited by the time this runs, not a
|
|
27
|
+
// template rendered fresh.
|
|
28
|
+
function patchMainGoForObservability(mainGoPath, goModule, projectName) {
|
|
29
|
+
let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
30
|
+
const telemetryImport = `"${goModule}/internal/platform/telemetry"`;
|
|
31
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, telemetryImport, telemetryImport);
|
|
32
|
+
const promhttpImport = `"github.com/prometheus/client_golang/prometheus/promhttp"`;
|
|
33
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, promhttpImport, promhttpImport);
|
|
34
|
+
// telemetry.go's GORM plugin looks up otel.Tracer() fresh on every query
|
|
35
|
+
// rather than once at registration, so it doesn't matter that this runs
|
|
36
|
+
// after database.Open has already called db.Use(NewGormPlugin()) — no
|
|
37
|
+
// query happens between here and the server actually accepting traffic.
|
|
38
|
+
const initBlock = [
|
|
39
|
+
`shutdownTelemetry, err := telemetry.Init(context.Background(), "${projectName}", cfg.OTELExporterEndpoint)`,
|
|
40
|
+
"if err != nil {",
|
|
41
|
+
'\treturn fmt.Errorf("init telemetry: %w", err)',
|
|
42
|
+
"}",
|
|
43
|
+
"defer func() { _ = shutdownTelemetry(context.Background()) }()",
|
|
44
|
+
].join("\n");
|
|
45
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, initBlock, "shutdownTelemetry, err := telemetry.Init(");
|
|
46
|
+
if (!content.includes(USE_LINE)) {
|
|
47
|
+
throw new Error("cmd/api/wiring.go's r.Use(...) call doesn't match the text this command expects — " +
|
|
48
|
+
"it looks like it's been hand-edited. Add middleware.Metrics() and middleware.Tracing(\"<project>\") to it yourself.");
|
|
49
|
+
}
|
|
50
|
+
const newUseLine = `${USE_LINE.slice(0, -1)}, middleware.Metrics(), middleware.Tracing("${projectName}"))`;
|
|
51
|
+
content = content.replace(USE_LINE, () => newUseLine);
|
|
52
|
+
// Not gated on APP_ENV the way /docs is: production is exactly where you
|
|
53
|
+
// want a scrape target, and Prometheus reaches it in-cluster. It is still
|
|
54
|
+
// unauthenticated and does disclose your route list and traffic shape, so
|
|
55
|
+
// block /metrics at the ingress rather than publishing it to the internet.
|
|
56
|
+
const metricsRoute = 'r.GET("/metrics", gin.WrapH(promhttp.Handler()))';
|
|
57
|
+
const metricsBlock = [
|
|
58
|
+
"// Unauthenticated on purpose (Prometheus scrapes it in-cluster) — block",
|
|
59
|
+
"// /metrics at your ingress so it isn't reachable from the internet.",
|
|
60
|
+
metricsRoute,
|
|
61
|
+
].join("\n");
|
|
62
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, EXTRA_ROUTES_MARKER, metricsBlock, metricsRoute);
|
|
63
|
+
fs_extra_1.default.writeFileSync(mainGoPath, content);
|
|
64
|
+
}
|
|
65
|
+
// patchDatabaseGoForObservability wires the GORM OpenTelemetry plugin into
|
|
66
|
+
// database.Open, so every query gets a span alongside the HTTP request it
|
|
67
|
+
// came from.
|
|
68
|
+
function patchDatabaseGoForObservability(databaseGoPath, goModule) {
|
|
69
|
+
let content = fs_extra_1.default.readFileSync(databaseGoPath, "utf8");
|
|
70
|
+
const telemetryImport = `"${goModule}/internal/platform/telemetry"`;
|
|
71
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, telemetryImport, telemetryImport);
|
|
72
|
+
const pluginBlock = ["if err := db.Use(telemetry.NewGormPlugin()); err != nil {", "\treturn nil, err", "}"].join("\n");
|
|
73
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, pluginBlock, "if err := db.Use(telemetry.NewGormPlugin())");
|
|
74
|
+
fs_extra_1.default.writeFileSync(databaseGoPath, content);
|
|
75
|
+
}
|
|
76
|
+
// patchConfigForObservability adds OTELExporterEndpoint to Config and its
|
|
77
|
+
// env() load — the same marker-based approach patchConfigForWorker uses.
|
|
78
|
+
function patchConfigForObservability(configGoPath) {
|
|
79
|
+
let content = fs_extra_1.default.readFileSync(configGoPath, "utf8");
|
|
80
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, "OTELExporterEndpoint string", "OTELExporterEndpoint");
|
|
81
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, 'OTELExporterEndpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT", ""),', 'env("OTEL_EXPORTER_OTLP_ENDPOINT"');
|
|
82
|
+
fs_extra_1.default.writeFileSync(configGoPath, content);
|
|
83
|
+
}
|
|
84
|
+
// patchEnvExampleForObservability appends OTEL_EXPORTER_OTLP_ENDPOINT —
|
|
85
|
+
// .env.example has no marker infrastructure of its own, so this follows the
|
|
86
|
+
// same append-once pattern as add worker/add auth's env patchers.
|
|
87
|
+
function patchEnvExampleForObservability(envExamplePath) {
|
|
88
|
+
if (!fs_extra_1.default.existsSync(envExamplePath))
|
|
89
|
+
return;
|
|
90
|
+
const content = fs_extra_1.default.readFileSync(envExamplePath, "utf8");
|
|
91
|
+
if (content.includes("OTEL_EXPORTER_OTLP_ENDPOINT"))
|
|
92
|
+
return;
|
|
93
|
+
fs_extra_1.default.writeFileSync(envExamplePath, content.replace(/\n?$/, "\n") +
|
|
94
|
+
"\n# OTLP/HTTP endpoint for trace export (e.g. localhost:4318) — empty disables\n" +
|
|
95
|
+
"# tracing entirely: no exporter is created, no network calls are made\n" +
|
|
96
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT=\n");
|
|
97
|
+
}
|
|
98
|
+
// patchOpenapiIndexForObservability wires /metrics into docs/openapi.yaml.
|
|
99
|
+
// Not routed through patchOpenapiIndexRaw (used for auth/rbac's paths):
|
|
100
|
+
// /metrics is registered directly on the router like /livez and /readyz, not
|
|
101
|
+
// under the api prefix group, so it must never be prefixed.
|
|
102
|
+
function patchOpenapiIndexForObservability(openapiPath) {
|
|
103
|
+
let content = fs_extra_1.default.readFileSync(openapiPath, "utf8");
|
|
104
|
+
const block = "/metrics:\n $ref: './observability/metrics.yaml'";
|
|
105
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, OPENAPI_PATHS_MARKER, block, "/metrics:");
|
|
106
|
+
fs_extra_1.default.writeFileSync(openapiPath, content);
|
|
107
|
+
}
|
|
@@ -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
|
|
@@ -3,7 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.patchComposeForRedis = patchComposeForRedis;
|
|
7
|
+
exports.patchCiForRedis = patchCiForRedis;
|
|
8
|
+
exports.patchConfigForRedis = patchConfigForRedis;
|
|
6
9
|
exports.patchConfigForWorker = patchConfigForWorker;
|
|
10
|
+
exports.patchConfigForSMTP = patchConfigForSMTP;
|
|
7
11
|
exports.patchMainGoForWorker = patchMainGoForWorker;
|
|
8
12
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
13
|
const marker_patch_1 = require("./marker-patch");
|
|
@@ -13,25 +17,107 @@ const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
|
|
|
13
17
|
const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
|
|
14
18
|
const READYZ_MARKER = "// go-scaffold:readyz-checks";
|
|
15
19
|
const SHUTDOWN_MARKER = "// go-scaffold:shutdown";
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
20
|
+
// patchComposeForRedis adds a redis service to docker-compose.yml. Whatever
|
|
21
|
+
// pulls Redis in — `add worker --queue redis`, or `add auth`'s refresh-token
|
|
22
|
+
// store on top of a Postgres queue — makes cmd/api call cache.Open and adds a
|
|
23
|
+
// Redis ping to /readyz. Leaving compose Postgres-only meant the documented
|
|
24
|
+
// path (`make docker-up` then `make run`) produced a permanent 503, which is
|
|
25
|
+
// a rough first five minutes with a brand new project.
|
|
26
|
+
//
|
|
27
|
+
// No-op when the project was scaffolded with --no-docker.
|
|
28
|
+
function patchComposeForRedis(composePath) {
|
|
29
|
+
if (!fs_extra_1.default.existsSync(composePath))
|
|
30
|
+
return;
|
|
31
|
+
const content = fs_extra_1.default.readFileSync(composePath, "utf8");
|
|
32
|
+
if (/^\s{2}redis:/m.test(content))
|
|
33
|
+
return;
|
|
34
|
+
const service = [
|
|
35
|
+
" redis:",
|
|
36
|
+
" image: redis:7-alpine",
|
|
37
|
+
" ports:",
|
|
38
|
+
' - "6379:6379"',
|
|
39
|
+
].join("\n");
|
|
40
|
+
// before the top-level `volumes:` key, so redis stays inside `services:`
|
|
41
|
+
const out = content.includes("\nvolumes:")
|
|
42
|
+
? content.replace("\nvolumes:", () => `\n${service}\n\nvolumes:`)
|
|
43
|
+
: content.replace(/\n?$/, "\n") + `\n${service}\n`;
|
|
44
|
+
fs_extra_1.default.writeFileSync(composePath, out);
|
|
45
|
+
}
|
|
46
|
+
// patchCiForRedis adds a redis service to .github/workflows/ci.yml, for the
|
|
47
|
+
// same reason patchComposeForRedis exists: once cmd/api calls cache.Open, a
|
|
48
|
+
// test that touches the token store has nothing to connect to on a runner
|
|
49
|
+
// whose only service is Postgres. The generated workflow was written at
|
|
50
|
+
// `create` time, before anything needed Redis, and nothing patched it after.
|
|
51
|
+
//
|
|
52
|
+
// No-op when the workflow was deleted or replaced by hand.
|
|
53
|
+
function patchCiForRedis(ciPath) {
|
|
54
|
+
if (!fs_extra_1.default.existsSync(ciPath))
|
|
55
|
+
return;
|
|
56
|
+
const content = fs_extra_1.default.readFileSync(ciPath, "utf8");
|
|
57
|
+
if (/^\s{6}redis:/m.test(content))
|
|
58
|
+
return;
|
|
59
|
+
// `steps:` sits one level under the job, so it's the first line that ends
|
|
60
|
+
// the `services:` block — insert the service just above it.
|
|
61
|
+
const stepsLine = content.split("\n").find((l) => l.trimEnd() === " steps:");
|
|
62
|
+
if (!stepsLine)
|
|
63
|
+
return;
|
|
64
|
+
const service = [
|
|
65
|
+
" redis:",
|
|
66
|
+
" image: redis:7-alpine",
|
|
67
|
+
" ports:",
|
|
68
|
+
" - 6379:6379",
|
|
69
|
+
" options: >-",
|
|
70
|
+
' --health-cmd "redis-cli ping"',
|
|
71
|
+
" --health-interval 10s",
|
|
72
|
+
" --health-timeout 5s",
|
|
73
|
+
" --health-retries 5",
|
|
74
|
+
"",
|
|
75
|
+
].join("\n");
|
|
76
|
+
fs_extra_1.default.writeFileSync(ciPath, content.replace(stepsLine, () => `${service}\n${stepsLine}`));
|
|
77
|
+
}
|
|
78
|
+
// patchConfigForRedis adds RedisURL to Config and its env() load to Load().
|
|
79
|
+
// Split out from the worker patch because Redis is no longer the worker's
|
|
80
|
+
// concern by default — `add auth` needs it for the refresh-token store even
|
|
81
|
+
// when the queue lives in Postgres.
|
|
82
|
+
function patchConfigForRedis(configGoPath) {
|
|
22
83
|
let content = fs_extra_1.default.readFileSync(configGoPath, "utf8");
|
|
23
|
-
|
|
24
|
-
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content,
|
|
84
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, "RedisURL string", "RedisURL");
|
|
85
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, 'RedisURL: env("REDIS_URL", "redis://localhost:6379/0"),', 'env("REDIS_URL"');
|
|
86
|
+
fs_extra_1.default.writeFileSync(configGoPath, content);
|
|
87
|
+
}
|
|
88
|
+
// patchConfigForWorker adds the SMTP_* fields (and RedisURL, when Redis is
|
|
89
|
+
// the queue's backing store) to Config and their env() loads to Load() — via
|
|
90
|
+
// marker comments, the same text-insertion approach as patchMainGo, since
|
|
91
|
+
// config.go.hbs is only rendered once (at `create`) and everything after
|
|
92
|
+
// that is a real file a human may have already edited.
|
|
93
|
+
function patchConfigForWorker(configGoPath, opts) {
|
|
94
|
+
if (opts.redis)
|
|
95
|
+
patchConfigForRedis(configGoPath);
|
|
96
|
+
patchConfigForSMTP(configGoPath);
|
|
97
|
+
}
|
|
98
|
+
// Sentinels here match a bare identifier, never `Name string` or
|
|
99
|
+
// `Name: env(...)`: gofmt aligns struct fields and map values into columns, so
|
|
100
|
+
// the moment one of these blocks lands the literal spacing a sentinel was
|
|
101
|
+
// written with no longer exists in the file. A sentinel that misses means the
|
|
102
|
+
// block gets inserted a second time and the project stops compiling on a
|
|
103
|
+
// redeclared field — which is exactly what `add auth` then `add worker` did.
|
|
104
|
+
//
|
|
105
|
+
// patchConfigForSMTP is split out because `add auth` needs these fields even
|
|
106
|
+
// when there is no worker: without a queue it sends mail synchronously, and it
|
|
107
|
+
// still has to know where to send it. Idempotent, so whichever command gets
|
|
108
|
+
// here first wins and the second is a no-op.
|
|
109
|
+
function patchConfigForSMTP(configGoPath) {
|
|
110
|
+
let content = fs_extra_1.default.readFileSync(configGoPath, "utf8");
|
|
111
|
+
const fieldsBlock = ["SMTPHost string", "SMTPPort string", "SMTPUsername string", "SMTPPassword string", "SMTPFrom string"].join("\n");
|
|
112
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, fieldsBlock, "SMTPHost");
|
|
25
113
|
const loadBlock = [
|
|
26
|
-
'RedisURL: env("REDIS_URL", "redis://localhost:6379/0"),',
|
|
27
|
-
"",
|
|
28
114
|
'SMTPHost: env("SMTP_HOST", ""),',
|
|
29
115
|
'SMTPPort: env("SMTP_PORT", "587"),',
|
|
30
116
|
'SMTPUsername: env("SMTP_USERNAME", ""),',
|
|
31
117
|
'SMTPPassword: env("SMTP_PASSWORD", ""),',
|
|
32
118
|
'SMTPFrom: env("SMTP_FROM", "no-reply@example.local"),',
|
|
33
119
|
].join("\n");
|
|
34
|
-
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, '
|
|
120
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, 'env("SMTP_HOST"');
|
|
35
121
|
fs_extra_1.default.writeFileSync(configGoPath, content);
|
|
36
122
|
}
|
|
37
123
|
// patchMainGoForWorker wires Redis into cmd/api: opened alongside the DB, and
|
|
@@ -44,7 +130,7 @@ function patchMainGoForWorker(mainGoPath, goModule) {
|
|
|
44
130
|
let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
45
131
|
const cacheImport = `"${goModule}/internal/platform/cache"`;
|
|
46
132
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, cacheImport, cacheImport);
|
|
47
|
-
const initBlock = ["rdb, err := cache.Open(cfg)", "if err != nil {", '\
|
|
133
|
+
const initBlock = ["rdb, err := cache.Open(cfg)", "if err != nil {", '\treturn fmt.Errorf("open redis: %w", err)', "}"].join("\n");
|
|
48
134
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, initBlock, "rdb, err := cache.Open(cfg)");
|
|
49
135
|
const readyzBlock = [
|
|
50
136
|
"if err := rdb.Ping(c.Request.Context()).Err(); err != nil {",
|
|
@@ -13,12 +13,17 @@ exports.patchUserServiceTestForRbac = patchUserServiceTestForRbac;
|
|
|
13
13
|
exports.patchUserDTOForRbac = patchUserDTOForRbac;
|
|
14
14
|
exports.patchUserHandlerForRbac = patchUserHandlerForRbac;
|
|
15
15
|
exports.patchUserErrorsForRbac = patchUserErrorsForRbac;
|
|
16
|
+
exports.userSvcLineFor = userSvcLineFor;
|
|
17
|
+
exports.assertRbacPatchable = assertRbacPatchable;
|
|
16
18
|
exports.patchMainGoForRbac = patchMainGoForRbac;
|
|
17
19
|
exports.patchCmdSeedForRbac = patchCmdSeedForRbac;
|
|
18
20
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
21
|
+
const auth_patcher_1 = require("./auth-patcher");
|
|
19
22
|
const marker_patch_1 = require("./marker-patch");
|
|
20
23
|
const IMPORT_MARKER = "// go-scaffold:imports";
|
|
24
|
+
const SCHEMA_MARKER = "// go-scaffold:schemas";
|
|
21
25
|
const MODEL_MARKER = "// go-scaffold:models";
|
|
26
|
+
const ROUTE_MARKER = "// go-scaffold:routes";
|
|
22
27
|
const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
|
|
23
28
|
const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
|
|
24
29
|
// patchAuthDocsForRbac adds `role` to the hand-written MeResponse schema in
|
|
@@ -233,26 +238,71 @@ function patchUserErrorsForRbac(errorsGoPath) {
|
|
|
233
238
|
// isn't enough — REPLACES the `add auth` PR's user.NewHandler(...) call with
|
|
234
239
|
// a version that also builds roleSvc/authz and passes them through, plus
|
|
235
240
|
// registers role's own routes right after it.
|
|
236
|
-
|
|
241
|
+
// userSvcLineFor rebuilds the exact line `add auth` wrote, from the same
|
|
242
|
+
// helper it used. Exported so the command can check for it *before* it starts
|
|
243
|
+
// patching: every other file rbac touches is edited first, and until this
|
|
244
|
+
// existed a mismatch here threw after user.NewService had already grown a
|
|
245
|
+
// roleChecker parameter — leaving a project that no longer compiled and an
|
|
246
|
+
// error telling you to restore a line that wouldn't have fixed it.
|
|
247
|
+
function userSvcLineFor(goModule, store, worker) {
|
|
248
|
+
const { tokenStore, mailer } = (0, auth_patcher_1.authWiringLines)({ goModule, queueBackend: "river", store, worker });
|
|
249
|
+
return `userSvc := user.NewService(user.NewRepository(db), ${tokenStore}, ${mailer}, cfg)`;
|
|
250
|
+
}
|
|
251
|
+
function assertRbacPatchable(mainGoPath, goModule, store, worker) {
|
|
252
|
+
const expected = userSvcLineFor(goModule, store, worker);
|
|
253
|
+
if (fs_extra_1.default.readFileSync(mainGoPath, "utf8").includes(expected))
|
|
254
|
+
return;
|
|
255
|
+
throw new Error("cmd/api/wiring.go's userSvc line doesn't match what `add auth` wrote, so `add rbac` can't extend it.\n" +
|
|
256
|
+
`Expected to find:\n ${expected}\n\n` +
|
|
257
|
+
"It was probably hand-edited. Restore that line and re-run — nothing has been changed yet.");
|
|
258
|
+
}
|
|
259
|
+
function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
|
|
237
260
|
let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
238
261
|
const importLine = `"${goModule}/internal/app/role"`;
|
|
239
262
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
|
|
240
263
|
const modelImportLine = `rolemodel "${goModule}/internal/app/role/model"`;
|
|
241
264
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
|
|
265
|
+
const schemaBlock = [
|
|
266
|
+
'if err := db.Exec("CREATE SCHEMA IF NOT EXISTS role_svc").Error; err != nil {',
|
|
267
|
+
'\treturn fmt.Errorf("create schema role_svc: %w", err)',
|
|
268
|
+
"}",
|
|
269
|
+
].join("\n");
|
|
270
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS role_svc");
|
|
242
271
|
const migrateLines = ["&rolemodel.Role{},", "&rolemodel.Permission{},", "&rolemodel.RolePermission{},"];
|
|
243
272
|
for (const line of migrateLines) {
|
|
244
273
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
|
|
245
274
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
275
|
+
// roleSvc and authz have to be declared *above* userSvc, which now takes
|
|
276
|
+
// roleSvc — so this rewrites the service line in place rather than
|
|
277
|
+
// appending at the marker (which would land below it).
|
|
278
|
+
// Rebuilt from the same helper `add auth` used, so a project on either store
|
|
279
|
+
// gets its own line matched rather than a hardcoded guess at one of them.
|
|
280
|
+
const { limiter } = (0, auth_patcher_1.authWiringLines)({ goModule, queueBackend: "river", store, worker });
|
|
281
|
+
const userSvcLine = userSvcLineFor(goModule, store, worker);
|
|
282
|
+
// Throw rather than skip: the roleSvc/authz declarations this rewrite adds
|
|
283
|
+
// are what the unconditional patches below refer to. Skipping quietly still
|
|
284
|
+
// emits `roleSvc`/`authz` references with nothing declaring them, so the
|
|
285
|
+
// command reports success over a main.go that doesn't compile.
|
|
286
|
+
if (!content.includes(userSvcLine)) {
|
|
287
|
+
throw new Error(`cmd/api/wiring.go's userSvc line doesn't match what \`add auth\` wrote, so \`add rbac\` can't extend it.\n` +
|
|
288
|
+
`Expected to find:\n ${userSvcLine}\n\n` +
|
|
289
|
+
`It was probably hand-edited. Restore that line (add rbac will re-extend it), or apply the rbac wiring by hand:\n` +
|
|
290
|
+
` roleSvc := role.NewService(role.NewRepository(db))\n` +
|
|
291
|
+
` authz := middleware.NewAuthz(roleSvc.PermissionsOf, cfg.AuthzCacheTTL)\n` +
|
|
292
|
+
` ...then pass roleSvc as user.NewService's last argument.`);
|
|
255
293
|
}
|
|
294
|
+
content = content.replace(userSvcLine, [
|
|
295
|
+
"roleSvc := role.NewService(role.NewRepository(db))",
|
|
296
|
+
"authz := middleware.NewAuthz(roleSvc.PermissionsOf, cfg.AuthzCacheTTL)",
|
|
297
|
+
`${userSvcLine.slice(0, -1)}, roleSvc)`,
|
|
298
|
+
].join("\n"));
|
|
299
|
+
const userRouteLine = `user.NewHandler(userSvc, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, ${limiter}).Register(api)`;
|
|
300
|
+
// strip the trailing `).Register(api)` — not just `.Register(api)` — so authz
|
|
301
|
+
// lands inside NewHandler's argument list rather than after its closing paren
|
|
302
|
+
const tail = ").Register(api)";
|
|
303
|
+
content = content.replace(userRouteLine, `${userRouteLine.slice(0, -tail.length)}, authz${tail}`);
|
|
304
|
+
const roleRouteLine = "role.NewHandler(roleSvc, cfg.JWTSecret, authz).Register(api)";
|
|
305
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, roleRouteLine, roleRouteLine);
|
|
256
306
|
fs_extra_1.default.writeFileSync(mainGoPath, content);
|
|
257
307
|
}
|
|
258
308
|
// patchCmdSeedForRbac makes the seeded admin actually an admin: wires a
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSmokeRunConfig = createSmokeRunConfig;
|
|
4
|
+
const POSTGRES_HOST = "127.0.0.1";
|
|
5
|
+
function assertPort(port, label) {
|
|
6
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
7
|
+
throw new Error(`invalid smoke-test ${label}: ${port}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function createSmokeRunConfig(runID, port, dbPort = 5432) {
|
|
11
|
+
assertPort(port, "port");
|
|
12
|
+
assertPort(dbPort, "PostgreSQL port");
|
|
13
|
+
const normalizedRunID = runID.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
14
|
+
if (!normalizedRunID) {
|
|
15
|
+
throw new Error("smoke-test run ID must contain at least one letter or digit");
|
|
16
|
+
}
|
|
17
|
+
const dbName = `go_scaffold_smoke_${normalizedRunID}`;
|
|
18
|
+
return {
|
|
19
|
+
runID: normalizedRunID,
|
|
20
|
+
dbName,
|
|
21
|
+
dbHost: POSTGRES_HOST,
|
|
22
|
+
dbPort,
|
|
23
|
+
dbDsn: `postgres://postgres:postgres@${POSTGRES_HOST}:${dbPort}/${dbName}?sslmode=disable`,
|
|
24
|
+
port,
|
|
25
|
+
baseURL: `http://127.0.0.1:${port}`,
|
|
26
|
+
logPrefix: `go-scaffold-smoke-${normalizedRunID}`,
|
|
27
|
+
ownerToken: `go-scaffold-smoke-${normalizedRunID}`,
|
|
28
|
+
dockerLabel: `go-scaffold.smoke.owner=go-scaffold-smoke-${normalizedRunID}`,
|
|
29
|
+
containerNamePrefix: `go-scaffold-smoke-${normalizedRunID}`,
|
|
30
|
+
};
|
|
31
|
+
}
|