@nakedev/go-scaffold 0.1.4 → 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 +133 -44
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +21 -11
- package/dist/commands/method.js +32 -3
- 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 +366 -63
- 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/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 +17 -2
- package/dist/utils/module-location.js +37 -1
- package/dist/utils/naming.js +50 -2
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/package.json +3 -5
- 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 +12 -2
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +8 -4
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +43 -13
- package/templates/create/base/README.md.hbs +45 -8
- 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 +0 -4
- package/templates/create/features/docs/patterns.md.hbs +82 -8
- package/templates/create/features/docs/techstack.md.hbs +8 -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_test.go.hbs +8 -1
- 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/model/model.go.hbs +10 -1
- 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 +30 -0
- package/templates/generate/module/service.go.hbs +10 -1
- package/templates/generate/module/service_test.go.hbs +45 -0
- package/dist/commands/remove.js +0 -88
- package/scripts/smoke-test.mjs +0 -2058
- 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/tests/integration/default-module.test.mjs +0 -46
- package/tests/integration/generator-naming.test.mjs +0 -81
- package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
- package/tests/integration/legacy-method-compat.test.mjs +0 -222
- package/tests/integration/remove-module.test.mjs +0 -58
- package/tests/unit/naming.test.mjs +0 -94
- package/tests/unit/smoke-isolation.test.mjs +0 -35
|
@@ -0,0 +1,53 @@
|
|
|
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.patchGoModRequires = patchGoModRequires;
|
|
7
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
8
|
+
// Every `add` command pulls in third-party packages, and none of them used to
|
|
9
|
+
// touch go.mod — the imports just appeared and `go mod tidy` was left to
|
|
10
|
+
// resolve them. Tidy picks the latest release of anything it doesn't already
|
|
11
|
+
// have a version for, so two people running `add auth` a month apart got
|
|
12
|
+
// different builds from the same CLI, and nothing recorded which versions the
|
|
13
|
+
// scaffold was actually written and tested against.
|
|
14
|
+
//
|
|
15
|
+
// Pinning here fixes that: tidy honours a version that's already in go.mod
|
|
16
|
+
// rather than reaching for latest. It's a floor, not a ceiling — if another
|
|
17
|
+
// feature needs something newer, tidy still resolves upward, which is why
|
|
18
|
+
// golang.org/x/crypto can be pinned by `add auth` and later raised by
|
|
19
|
+
// `add observability`.
|
|
20
|
+
//
|
|
21
|
+
// Users still need to run `go mod tidy` (every command says so): these lines
|
|
22
|
+
// have no go.sum entries, and only tidy can add those.
|
|
23
|
+
function patchGoModRequires(goModPath, requires) {
|
|
24
|
+
if (!fs_extra_1.default.existsSync(goModPath))
|
|
25
|
+
return;
|
|
26
|
+
let content = fs_extra_1.default.readFileSync(goModPath, "utf8");
|
|
27
|
+
// already-present modules are left at whatever version they're on: a
|
|
28
|
+
// re-run, or a version the user deliberately bumped, is not ours to reset
|
|
29
|
+
const missing = requires.filter((line) => !hasModule(content, modulePathOf(line)));
|
|
30
|
+
if (missing.length === 0)
|
|
31
|
+
return;
|
|
32
|
+
const block = content.match(/^require \(\n(?:.*\n)*?\)$/m);
|
|
33
|
+
if (!block) {
|
|
34
|
+
// no grouped require block (hand-edited, or single-line `require x y`
|
|
35
|
+
// form) — append one rather than trying to rewrite what's there
|
|
36
|
+
content = content.replace(/\n?$/, "\n") + `\nrequire (\n${missing.map((l) => `\t${l}`).join("\n")}\n)\n`;
|
|
37
|
+
fs_extra_1.default.writeFileSync(goModPath, content);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const updated = block[0].replace(/\n\)$/, `\n${missing.map((l) => `\t${l}`).join("\n")}\n)`);
|
|
41
|
+
// function replacer: a version string can't contain $ today, but this is the
|
|
42
|
+
// same trap ensureImport documents and costs nothing to avoid
|
|
43
|
+
fs_extra_1.default.writeFileSync(goModPath, content.replace(block[0], () => updated));
|
|
44
|
+
}
|
|
45
|
+
function modulePathOf(requireLine) {
|
|
46
|
+
return requireLine.trim().split(/\s+/)[0];
|
|
47
|
+
}
|
|
48
|
+
// Word-boundary match on the module path, so `go.opentelemetry.io/otel`
|
|
49
|
+
// doesn't count as already-present when only `go.opentelemetry.io/otel/sdk`
|
|
50
|
+
// is there.
|
|
51
|
+
function hasModule(goMod, modulePath) {
|
|
52
|
+
return goMod.split("\n").some((line) => modulePathOf(line) === modulePath);
|
|
53
|
+
}
|
|
@@ -3,11 +3,13 @@ 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.assertMainGoPatchable = assertMainGoPatchable;
|
|
6
7
|
exports.patchMainGo = patchMainGo;
|
|
7
8
|
exports.unpatchMainGo = unpatchMainGo;
|
|
8
9
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
10
|
const marker_patch_1 = require("./marker-patch");
|
|
10
11
|
const IMPORT_MARKER = "// go-scaffold:imports";
|
|
12
|
+
const SCHEMA_MARKER = "// go-scaffold:schemas";
|
|
11
13
|
const MODEL_MARKER = "// go-scaffold:models";
|
|
12
14
|
const ROUTE_MARKER = "// go-scaffold:routes";
|
|
13
15
|
// no leading tab: insertBeforeMarker re-indents, and removeLines matches by
|
|
@@ -15,9 +17,19 @@ const ROUTE_MARKER = "// go-scaffold:routes";
|
|
|
15
17
|
const UNUSED_API_LINE = "_ = api // dropped once `generate module` registers the first route";
|
|
16
18
|
// the exact lines patchMainGo inserts for a module — one source of truth so
|
|
17
19
|
// unpatchMainGo removes precisely what patch added.
|
|
20
|
+
//
|
|
21
|
+
// The service gets its own named variable rather than being constructed
|
|
22
|
+
// inline inside NewHandler(...). docs/architect/patterns.md tells you to wire
|
|
23
|
+
// one domain's service into another's constructor when it needs behaviour
|
|
24
|
+
// from it, which means editing this block by hand — and an inline expression
|
|
25
|
+
// has nowhere to hold the result. With a named variable the edit is "add an
|
|
26
|
+
// argument to the end of line one", and unpatchMainGo can still find the line
|
|
27
|
+
// afterwards because it matches on the `<pkg>Svc :=` prefix, not the whole
|
|
28
|
+
// text.
|
|
18
29
|
function mainGoLines(patch) {
|
|
19
30
|
const modelAlias = `${patch.pkg}model`; // every domain's model subpackage is named "model"
|
|
20
|
-
const
|
|
31
|
+
const svcVar = `${patch.pkg}Svc`;
|
|
32
|
+
const handlerArgs = [svcVar];
|
|
21
33
|
if (patch.auth)
|
|
22
34
|
handlerArgs.push("cfg.JWTSecret");
|
|
23
35
|
if (patch.permission)
|
|
@@ -25,7 +37,20 @@ function mainGoLines(patch) {
|
|
|
25
37
|
return {
|
|
26
38
|
importLine: `"${patch.goModule}/internal/app/${patch.modulePath}"`,
|
|
27
39
|
modelImportLine: `${modelAlias} "${patch.goModule}/internal/app/${patch.modulePath}/model"`,
|
|
40
|
+
// AutoMigrate (dev) creates tables but not the schema they live in — see
|
|
41
|
+
// the comment on go-scaffold:schemas in main.go.hbs. One Exec per schema,
|
|
42
|
+
// guarded by its own sentinel so two modules sharing a schema name only
|
|
43
|
+
// ever produce one line (not expected today, but cheap to keep safe).
|
|
44
|
+
schemaLines: [
|
|
45
|
+
`if err := db.Exec("CREATE SCHEMA IF NOT EXISTS ${patch.schemaName}").Error; err != nil {`,
|
|
46
|
+
`\treturn fmt.Errorf("create schema ${patch.schemaName}: %w", err)`,
|
|
47
|
+
`}`,
|
|
48
|
+
].join("\n"),
|
|
49
|
+
schemaSentinel: `CREATE SCHEMA IF NOT EXISTS ${patch.schemaName}`,
|
|
28
50
|
migrateLine: `&${modelAlias}.${patch.pascalName}{},`,
|
|
51
|
+
serviceLine: `${svcVar} := ${patch.pkg}.NewService(${patch.pkg}.NewRepository(db))`,
|
|
52
|
+
// matches the service line however the user has since extended it
|
|
53
|
+
servicePrefix: `${svcVar} :=`,
|
|
29
54
|
// `api` is the one route group declared by main.go.hbs, prefixed with
|
|
30
55
|
// whatever apiPrefix the project chose at create time (e.g. /v1, /api,
|
|
31
56
|
// or none) — every module registers on it, there is no per-module choice.
|
|
@@ -34,20 +59,43 @@ function mainGoLines(patch) {
|
|
|
34
59
|
routeLine: `${patch.pkg}.NewHandler(${handlerArgs.join(", ")}).Register(api)`,
|
|
35
60
|
};
|
|
36
61
|
}
|
|
37
|
-
//
|
|
62
|
+
// assertMainGoPatchable is the pre-flight for it: every marker patchMainGo
|
|
63
|
+
// needs, checked before the caller writes anything. Without it a missing
|
|
64
|
+
// marker surfaced only once the module files and its migration were already
|
|
65
|
+
// on disk, and the retry then hit "internal/app/<pkg> already exists" — a
|
|
66
|
+
// dead end that pointed nowhere useful.
|
|
67
|
+
function assertMainGoPatchable(mainGoPath) {
|
|
68
|
+
if (!fs_extra_1.default.existsSync(mainGoPath)) {
|
|
69
|
+
throw new Error(`${mainGoPath} not found — this doesn't look like a go-scaffold project`);
|
|
70
|
+
}
|
|
71
|
+
const content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
72
|
+
const missing = [IMPORT_MARKER, SCHEMA_MARKER, MODEL_MARKER, ROUTE_MARKER].filter((m) => !(0, marker_patch_1.hasMarker)(content, m));
|
|
73
|
+
if (missing.length) {
|
|
74
|
+
throw new Error(`cmd/api/wiring.go is missing the marker comment${missing.length > 1 ? "s" : ""} this command patches at:\n` +
|
|
75
|
+
missing.map((m) => ` ${m}`).join("\n") +
|
|
76
|
+
`\n\nEither the file was hand-edited, or it was scaffolded by an older go-scaffold that\n` +
|
|
77
|
+
`didn't emit ${missing.length > 1 ? "them" : "it"} yet. Add the marker${missing.length > 1 ? "s" : ""} back where the generated code should go, or\n` +
|
|
78
|
+
`wire this module into main.go by hand.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// patchMainGo wires a newly generated module into cmd/api/wiring.go: its
|
|
38
82
|
// import, its model in the AutoMigrate call, and its route registration —
|
|
39
83
|
// via marker comments rather than a Go AST rewrite (ponytail: text insertion
|
|
40
84
|
// at a fixed marker is enough here; reach for go/ast if main.go ever needs
|
|
41
85
|
// edits markers can't express).
|
|
42
86
|
function patchMainGo(mainGoPath, patch) {
|
|
43
87
|
let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
44
|
-
const { importLine, modelImportLine, migrateLine, routeLine } = mainGoLines(patch);
|
|
88
|
+
const { importLine, modelImportLine, schemaLines, schemaSentinel, migrateLine, serviceLine, servicePrefix, routeLine } = mainGoLines(patch);
|
|
45
89
|
// each guarded by its own sentinel so re-running after only the module
|
|
46
90
|
// folder was deleted (main.go still wired) is a no-op, not a dup that
|
|
47
91
|
// panics gin at startup.
|
|
48
92
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
|
|
49
93
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
|
|
94
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaLines, schemaSentinel);
|
|
50
95
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, migrateLine, migrateLine);
|
|
96
|
+
// sentinel is the prefix, not the whole line: a re-run must not add a
|
|
97
|
+
// second service line just because the first one gained a dependency.
|
|
98
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, serviceLine, servicePrefix);
|
|
51
99
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, routeLine, routeLine);
|
|
52
100
|
content = (0, marker_patch_1.removeLines)(content, [UNUSED_API_LINE]);
|
|
53
101
|
fs_extra_1.default.writeFileSync(mainGoPath, content);
|
|
@@ -57,8 +105,14 @@ function patchMainGo(mainGoPath, patch) {
|
|
|
57
105
|
// main.go still compiles (api would otherwise be declared-and-unused).
|
|
58
106
|
function unpatchMainGo(mainGoPath, patch) {
|
|
59
107
|
let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
60
|
-
const { importLine, modelImportLine, migrateLine, routeLine } = mainGoLines(patch);
|
|
108
|
+
const { importLine, modelImportLine, schemaLines, migrateLine, servicePrefix, routeLine } = mainGoLines(patch);
|
|
61
109
|
content = (0, marker_patch_1.removeLines)(content, [importLine, modelImportLine, migrateLine, routeLine]);
|
|
110
|
+
// by prefix: the service line may have grown arguments since it was written
|
|
111
|
+
content = (0, marker_patch_1.removeLinesByPrefix)(content, [servicePrefix]);
|
|
112
|
+
// by contiguous block, not removeLines: every module's schema block is
|
|
113
|
+
// identical except the schema name, so a line-by-line removal would also
|
|
114
|
+
// strip another module's matching lines.
|
|
115
|
+
content = (0, marker_patch_1.removeBlock)(content, schemaLines);
|
|
62
116
|
if (!content.includes(".Register(api)") && !content.includes(UNUSED_API_LINE)) {
|
|
63
117
|
content = (0, marker_patch_1.insertBeforeMarker)(content, ROUTE_MARKER, UNUSED_API_LINE);
|
|
64
118
|
}
|
|
@@ -10,6 +10,8 @@ exports.hasMarker = hasMarker;
|
|
|
10
10
|
exports.ensureImport = ensureImport;
|
|
11
11
|
exports.insertBeforeMarkerOnce = insertBeforeMarkerOnce;
|
|
12
12
|
exports.removeLines = removeLines;
|
|
13
|
+
exports.removeBlock = removeBlock;
|
|
14
|
+
exports.removeLinesByPrefix = removeLinesByPrefix;
|
|
13
15
|
exports.insertBeforeMarker = insertBeforeMarker;
|
|
14
16
|
function hasMarker(content, marker) {
|
|
15
17
|
return content.split("\n").some((l) => l.trim() === marker);
|
|
@@ -24,7 +26,12 @@ function ensureImport(content, importPath) {
|
|
|
24
26
|
const importLine = `"${importPath}"`;
|
|
25
27
|
if (content.includes(importLine))
|
|
26
28
|
return content;
|
|
27
|
-
|
|
29
|
+
// A function replacer, not a string one: String.replace treats "$$", "$&",
|
|
30
|
+
// digit-groups etc. in a *string* replacement as special patterns even when
|
|
31
|
+
// the search argument is a plain string, not a regex — collapsing any "$$"
|
|
32
|
+
// that happens to appear in importLine. A function's return value is spliced
|
|
33
|
+
// in literally, so this holds regardless of what importPath contains.
|
|
34
|
+
return content.replace(/import \(\n/, () => `import (\n\t${importLine}\n`);
|
|
28
35
|
}
|
|
29
36
|
// insertBeforeMarkerOnce: like insertBeforeMarker but a no-op if `sentinel`
|
|
30
37
|
// already appears in the file. Makes module wiring idempotent — re-running
|
|
@@ -38,7 +45,7 @@ function insertBeforeMarkerOnce(content, marker, block, sentinel) {
|
|
|
38
45
|
return insertBeforeMarker(content, marker, block);
|
|
39
46
|
}
|
|
40
47
|
// removeLines drops every line whose trimmed text exactly equals one of the
|
|
41
|
-
// given lines — the inverse of insertBeforeMarker for `
|
|
48
|
+
// given lines — the inverse of insertBeforeMarker for `undo module`, which
|
|
42
49
|
// needs to pull a module's import/route/path entries back out. Exact-trim
|
|
43
50
|
// match so it can't clip an unrelated line that merely contains the text.
|
|
44
51
|
function removeLines(content, trimmedLines) {
|
|
@@ -48,6 +55,116 @@ function removeLines(content, trimmedLines) {
|
|
|
48
55
|
.filter((l) => !drop.has(l.trim()))
|
|
49
56
|
.join("\n");
|
|
50
57
|
}
|
|
58
|
+
// removeBlock removes a multi-line block inserted by insertBeforeMarker,
|
|
59
|
+
// matched as one contiguous run (each line compared trimmed) rather than
|
|
60
|
+
// line-by-line like removeLines. Needed when a block's individual lines
|
|
61
|
+
// aren't unique on their own — two modules' schema-creation blocks in
|
|
62
|
+
// main.go are identical except for the schema name itself, and removeLines
|
|
63
|
+
// would delete the shared lines from both when asked to remove just one.
|
|
64
|
+
function removeBlock(content, block) {
|
|
65
|
+
const blockLines = block.split("\n").map((l) => l.trim());
|
|
66
|
+
const lines = content.split("\n");
|
|
67
|
+
const out = [];
|
|
68
|
+
for (let i = 0; i < lines.length;) {
|
|
69
|
+
const matches = blockLines.every((bl, j) => lines[i + j]?.trim() === bl);
|
|
70
|
+
if (matches) {
|
|
71
|
+
i += blockLines.length;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
out.push(lines[i]);
|
|
75
|
+
i += 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out.join("\n");
|
|
79
|
+
}
|
|
80
|
+
// removeLinesByPrefix is removeLines for a line the user is expected to edit
|
|
81
|
+
// after it was generated — a service constructor that has since gained a
|
|
82
|
+
// dependency, say. Matching the whole line would miss it and leave the
|
|
83
|
+
// project un-compilable after `undo module`; matching a distinctive prefix
|
|
84
|
+
// (`orderSvc :=`) still finds it. Only use it where the prefix is unique.
|
|
85
|
+
//
|
|
86
|
+
// A match consumes lines until its brackets balance, not just the first one:
|
|
87
|
+
// docs/architect/patterns.md tells you to expand that single wiring line into
|
|
88
|
+
// a multi-line adapter literal, and dropping only line one leaves an orphaned
|
|
89
|
+
// func body behind — a main.go that no longer parses, reported by the Go
|
|
90
|
+
// compiler long after `undo module` has printed success.
|
|
91
|
+
function removeLinesByPrefix(content, prefixes) {
|
|
92
|
+
const lines = content.split("\n");
|
|
93
|
+
const out = [];
|
|
94
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
95
|
+
if (!prefixes.some((p) => lines[i].trim().startsWith(p))) {
|
|
96
|
+
out.push(lines[i]);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const start = i;
|
|
100
|
+
let depth = 0;
|
|
101
|
+
let overran = false;
|
|
102
|
+
do {
|
|
103
|
+
// A marker comment is a structural anchor — no wiring statement can
|
|
104
|
+
// contain one. Reaching it with brackets still open means the count is
|
|
105
|
+
// wrong (a malformed line above), and carrying on would swallow the
|
|
106
|
+
// marker plus every module wired after it. Stop at the boundary.
|
|
107
|
+
if (i > start && lines[i].trim().startsWith("// go-scaffold:")) {
|
|
108
|
+
overran = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
depth += bracketDelta(lines[i]);
|
|
112
|
+
i += 1;
|
|
113
|
+
} while (depth > 0 && i < lines.length);
|
|
114
|
+
// Either we hit a marker or ran out of file with brackets still open.
|
|
115
|
+
// Consuming anyway deletes everything in between — in main.go that's the
|
|
116
|
+
// other modules' wiring and the closing brace, from a command whose whole
|
|
117
|
+
// job is to touch one module. Refuse: undo un-wires before it deletes
|
|
118
|
+
// anything, so throwing here costs the user nothing.
|
|
119
|
+
if (overran || depth > 0) {
|
|
120
|
+
throw new Error(`the statement starting at line ${start + 1} never closes its brackets:\n` +
|
|
121
|
+
` ${lines[start].trim()}\n\n` +
|
|
122
|
+
`Refusing to guess where it ends — removing through to where the count finally balances\n` +
|
|
123
|
+
`would take the lines in between with it. Fix the brackets, or remove it by hand.`);
|
|
124
|
+
}
|
|
125
|
+
i -= 1; // the for's own increment steps past the last consumed line
|
|
126
|
+
}
|
|
127
|
+
return out.join("\n");
|
|
128
|
+
}
|
|
129
|
+
// bracketDelta counts on code only. Brackets inside a string literal or a
|
|
130
|
+
// trailing comment are text, not structure — `order.WithPrefix("(")` on the
|
|
131
|
+
// wiring line would otherwise leave the count permanently open.
|
|
132
|
+
//
|
|
133
|
+
// ponytail: a raw string that spans lines is still counted wrong, since this
|
|
134
|
+
// works a line at a time. Nothing generated produces one; reach for go/ast if
|
|
135
|
+
// that ever changes.
|
|
136
|
+
function bracketDelta(line) {
|
|
137
|
+
let delta = 0;
|
|
138
|
+
for (const ch of stripLiteralsAndComments(line)) {
|
|
139
|
+
if (ch === "(" || ch === "{" || ch === "[")
|
|
140
|
+
delta += 1;
|
|
141
|
+
else if (ch === ")" || ch === "}" || ch === "]")
|
|
142
|
+
delta -= 1;
|
|
143
|
+
}
|
|
144
|
+
return delta;
|
|
145
|
+
}
|
|
146
|
+
function stripLiteralsAndComments(line) {
|
|
147
|
+
let out = "";
|
|
148
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
149
|
+
const ch = line[i];
|
|
150
|
+
if (ch === "/" && line[i + 1] === "/")
|
|
151
|
+
break; // rest of the line is a comment
|
|
152
|
+
if (ch !== '"' && ch !== "'" && ch !== "`") {
|
|
153
|
+
out += ch;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// skip to the closing quote; \" doesn't close an interpreted string, but
|
|
157
|
+
// a backslash is literal inside a raw one
|
|
158
|
+
const quote = ch;
|
|
159
|
+
i += 1;
|
|
160
|
+
while (i < line.length && line[i] !== quote) {
|
|
161
|
+
if (quote !== "`" && line[i] === "\\")
|
|
162
|
+
i += 1;
|
|
163
|
+
i += 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
51
168
|
function insertBeforeMarker(content, marker, block) {
|
|
52
169
|
const lines = content.split("\n");
|
|
53
170
|
const markerLine = lines.find((l) => l.trim() === marker);
|
|
@@ -59,5 +176,10 @@ function insertBeforeMarker(content, marker, block) {
|
|
|
59
176
|
.split("\n")
|
|
60
177
|
.map((line) => (line ? `${indent}${line}` : line))
|
|
61
178
|
.join("\n");
|
|
62
|
-
|
|
179
|
+
// Function replacer, not a string one — see the comment on ensureImport.
|
|
180
|
+
// Every caller of this — main.go/patterns.md route and import wiring,
|
|
181
|
+
// depguard rules, rbac's main.go patches — funnels through here, so fixing
|
|
182
|
+
// it here is what actually closes the bug off, rather than auditing every
|
|
183
|
+
// block a caller happens to pass in today.
|
|
184
|
+
return content.replace(markerLine, () => `${indentedBlock}\n${markerLine}`);
|
|
63
185
|
}
|
|
@@ -151,8 +151,13 @@ function patchGetOne(paths, naming, method, rawField, goModule) {
|
|
|
151
151
|
repo = (0, marker_patch_1.insertBeforeMarker)(repo, REPO_IMPL_MARKER, [
|
|
152
152
|
`func (r *Repository) FindBy${fieldPascal}(ctx context.Context, ${fieldParam} string) (*model.${naming.pascalName}, error) {`,
|
|
153
153
|
`\tvar m model.${naming.pascalName}`,
|
|
154
|
-
`\t// TODO:
|
|
155
|
-
`\
|
|
154
|
+
`\t// TODO: add the "${fieldColumn}" column and an index on it — this query`,
|
|
155
|
+
`\t// compiles against any table and only fails when it runs.`,
|
|
156
|
+
`\t//`,
|
|
157
|
+
`\t// tx.From, like every other method here: a lookup that reached for r.db`,
|
|
158
|
+
`\t// directly would read outside a transaction its caller had opened, and`,
|
|
159
|
+
`\t// so miss that transaction's own uncommitted writes.`,
|
|
160
|
+
`\tif err := tx.From(ctx, r.db).WithContext(ctx).First(&m, "${fieldColumn} = ?", ${fieldParam}).Error; err != nil {`,
|
|
156
161
|
`\t\treturn nil, err`,
|
|
157
162
|
`\t}`,
|
|
158
163
|
`\treturn &m, nil`,
|
|
@@ -287,7 +292,17 @@ function patchResourceAction(paths, naming, method, type, goModule) {
|
|
|
287
292
|
`\t\treturn nil, wrapFindErr(err)`,
|
|
288
293
|
`\t}`,
|
|
289
294
|
`\t// TODO: implement "${method.name}" — currently a no-op save`,
|
|
295
|
+
`\t// The version compared here is the one just read, so this only`,
|
|
296
|
+
`\t// catches a writer that lands between the read above and this write.`,
|
|
297
|
+
`\t// It is NOT the check Update does: that one compares against the version`,
|
|
298
|
+
`\t// the *client* last saw, which is what catches someone submitting a`,
|
|
299
|
+
`\t// form built from a copy that has since gone stale. If this endpoint`,
|
|
300
|
+
`\t// needs that, give it an input struct carrying Version and assign it`,
|
|
301
|
+
`\t// to m.Version here, the way service.go's Update does.`,
|
|
290
302
|
`\tif err := s.repo.Update(ctx, m); err != nil {`,
|
|
303
|
+
`\t\tif errors.Is(err, ErrStaleVersion) {`,
|
|
304
|
+
`\t\t\treturn nil, errStale()`,
|
|
305
|
+
`\t\t}`,
|
|
291
306
|
`\t\treturn nil, apperror.NewInternal()`,
|
|
292
307
|
`\t}`,
|
|
293
308
|
`\treturn m, nil`,
|
|
@@ -17,6 +17,42 @@ function existingModulePackages(projectDir) {
|
|
|
17
17
|
.filter((entry) => entry.isDirectory())
|
|
18
18
|
.map((entry) => entry.name);
|
|
19
19
|
}
|
|
20
|
+
// A Go package name has no word boundaries: "orderitem" cannot tell you it
|
|
21
|
+
// came from "order-items". Everything else a command derives — the model type
|
|
22
|
+
// (OrderItem), the route (/order-items), the table (order_items), the
|
|
23
|
+
// migration filename — does depend on those boundaries, so re-deriving them
|
|
24
|
+
// from the package name yields Orderitem/orderitems and code that doesn't
|
|
25
|
+
// compile.
|
|
26
|
+
//
|
|
27
|
+
// That mattered because the package name is the one form of the name the user
|
|
28
|
+
// actually sees: it's the folder on disk, and it's what `generate module`
|
|
29
|
+
// prints as the next command to run. Following that instruction produced
|
|
30
|
+
// `undefined: model.Orderitem (but have OrderItem)`.
|
|
31
|
+
//
|
|
32
|
+
// model/model.go is where the boundaries survive — the struct name is
|
|
33
|
+
// PascalCase, so kebab-casing it recovers the exact string `generate module`
|
|
34
|
+
// was originally given.
|
|
35
|
+
function pascalNameOnDisk(projectDir, pkg) {
|
|
36
|
+
const modelPath = path_1.default.join(projectDir, "internal", "app", pkg, "model", "model.go");
|
|
37
|
+
if (!fs_extra_1.default.existsSync(modelPath))
|
|
38
|
+
return null;
|
|
39
|
+
const match = fs_extra_1.default.readFileSync(modelPath, "utf8").match(/^type\s+([A-Z]\w*)\s+struct\b/m);
|
|
40
|
+
return match ? match[1] : null;
|
|
41
|
+
}
|
|
20
42
|
function resolveProjectModuleNaming(projectDir, rawName) {
|
|
21
|
-
|
|
43
|
+
const located = (0, naming_1.resolveExistingModuleNaming)(rawName, existingModulePackages(projectDir));
|
|
44
|
+
const pascal = pascalNameOnDisk(projectDir, located.pkg);
|
|
45
|
+
if (!pascal || pascal === located.pascalName)
|
|
46
|
+
return located;
|
|
47
|
+
let fromDisk;
|
|
48
|
+
try {
|
|
49
|
+
fromDisk = (0, naming_1.resolveModuleNaming)((0, naming_1.toKebabCase)(pascal));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return located; // model.go holds something we can't derive a module name from
|
|
53
|
+
}
|
|
54
|
+
// Only trust the correction when it still points at the same package.
|
|
55
|
+
// A hand-renamed struct, or a legacy module whose type never matched its
|
|
56
|
+
// folder, would otherwise silently retarget the command at another module.
|
|
57
|
+
return fromDisk.pkg === located.pkg ? fromDisk : located;
|
|
22
58
|
}
|
package/dist/utils/naming.js
CHANGED
|
@@ -12,11 +12,14 @@ exports.toDbName = toDbName;
|
|
|
12
12
|
exports.validateGoModulePath = validateGoModulePath;
|
|
13
13
|
exports.assertValidGoModulePath = assertValidGoModulePath;
|
|
14
14
|
exports.assertNotGoKeyword = assertNotGoKeyword;
|
|
15
|
+
exports.assertGoIdentifier = assertGoIdentifier;
|
|
15
16
|
exports.validateModuleName = validateModuleName;
|
|
16
17
|
exports.normalizeApiPrefix = normalizeApiPrefix;
|
|
17
18
|
exports.validateApiPrefix = validateApiPrefix;
|
|
18
19
|
exports.resolveModuleNaming = resolveModuleNaming;
|
|
19
20
|
exports.resolveExistingModuleNaming = resolveExistingModuleNaming;
|
|
21
|
+
exports.migrationSlug = migrationSlug;
|
|
22
|
+
exports.migrationSlugAliases = migrationSlugAliases;
|
|
20
23
|
exports.resolveMethodNaming = resolveMethodNaming;
|
|
21
24
|
const pluralize_1 = __importDefault(require("pluralize"));
|
|
22
25
|
// Pluralization must be idempotent because module names may come from the
|
|
@@ -85,6 +88,13 @@ const GO_PREDECLARED_TYPES = new Set([
|
|
|
85
88
|
"float32", "float64", "int", "int8", "int16", "int32", "int64", "rune",
|
|
86
89
|
"string", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr",
|
|
87
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"]);
|
|
88
98
|
// method/handler/param identifier: only keywords are hard-illegal (a param or
|
|
89
99
|
// func named `string` is legal Go, just shadows the builtin locally).
|
|
90
100
|
function assertNotGoKeyword(ident, role) {
|
|
@@ -92,6 +102,19 @@ function assertNotGoKeyword(ident, role) {
|
|
|
92
102
|
throw new Error(`"${ident}" is a Go keyword — can't use it as a ${role} name; pick another`);
|
|
93
103
|
}
|
|
94
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
|
+
}
|
|
95
118
|
// module name becomes a Go package name; keywords and predeclared type names
|
|
96
119
|
// both produce code that won't compile (`package func`, or a `string` package
|
|
97
120
|
// shadowing the builtin in main.go). Returns true|message for inquirer, and
|
|
@@ -106,6 +129,9 @@ function validateModuleName(rawName) {
|
|
|
106
129
|
if (GO_KEYWORDS.has(pkg) || GO_PREDECLARED_TYPES.has(pkg)) {
|
|
107
130
|
return `"${pkg}" is a reserved Go word — a package named it won't compile; pick another module name`;
|
|
108
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
|
+
}
|
|
109
135
|
return true;
|
|
110
136
|
}
|
|
111
137
|
// strips whitespace and leading/trailing slashes so "/api/v1/" and "api/v1"
|
|
@@ -142,6 +168,7 @@ function resolveModuleNaming(rawName) {
|
|
|
142
168
|
pascalName: toPascalCase(singular),
|
|
143
169
|
plural,
|
|
144
170
|
tableName: toDbName(plural),
|
|
171
|
+
schemaName: `${pkg}_svc`,
|
|
145
172
|
errorPrefix: toDbName(singular).toUpperCase(),
|
|
146
173
|
};
|
|
147
174
|
}
|
|
@@ -163,6 +190,12 @@ function resolveLegacyModuleNaming(rawName) {
|
|
|
163
190
|
pascalName: toPascalCase(pkg),
|
|
164
191
|
plural,
|
|
165
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`,
|
|
166
199
|
errorPrefix: pkg.toUpperCase(),
|
|
167
200
|
};
|
|
168
201
|
}
|
|
@@ -206,14 +239,29 @@ function resolveExistingModuleNaming(rawName, existingPackages) {
|
|
|
206
239
|
return resolveLegacyModuleNaming(matches[0]);
|
|
207
240
|
return canonical;
|
|
208
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
|
+
}
|
|
209
255
|
function resolveMethodNaming(rawName) {
|
|
210
256
|
const cleaned = rawName.trim();
|
|
211
257
|
const pascalName = toPascalCase(cleaned);
|
|
212
258
|
if (!pascalName) {
|
|
213
259
|
throw new Error(`invalid method name: "${rawName}" (must contain letters/numbers)`);
|
|
214
260
|
}
|
|
215
|
-
//
|
|
216
|
-
|
|
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");
|
|
217
265
|
return {
|
|
218
266
|
name: cleaned,
|
|
219
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
|
+
}
|