@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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -44
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +21 -11
  6. package/dist/commands/method.js +32 -3
  7. package/dist/commands/observability.js +114 -0
  8. package/dist/commands/rbac.js +19 -2
  9. package/dist/commands/undo.js +331 -0
  10. package/dist/commands/worker.js +92 -32
  11. package/dist/index.js +366 -63
  12. package/dist/prompts/auth-wizard.js +29 -0
  13. package/dist/prompts/create-wizard.js +5 -2
  14. package/dist/prompts/generate-wizard.js +57 -0
  15. package/dist/prompts/worker-wizard.js +25 -0
  16. package/dist/templates/auth-manifest.js +20 -3
  17. package/dist/templates/create-manifest.js +13 -20
  18. package/dist/templates/observability-manifest.js +24 -0
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/templates/worker-manifest.js +23 -6
  21. package/dist/utils/auth-patcher.js +96 -21
  22. package/dist/utils/config.js +58 -10
  23. package/dist/utils/gocheck.js +57 -5
  24. package/dist/utils/golangci-patcher.js +73 -0
  25. package/dist/utils/gomod-patcher.js +53 -0
  26. package/dist/utils/main-patcher.js +58 -4
  27. package/dist/utils/marker-patch.js +125 -3
  28. package/dist/utils/method-patcher.js +17 -2
  29. package/dist/utils/module-location.js +37 -1
  30. package/dist/utils/naming.js +50 -2
  31. package/dist/utils/observability-patcher.js +107 -0
  32. package/dist/utils/platform-patcher.js +98 -12
  33. package/dist/utils/rbac-patcher.js +60 -10
  34. package/package.json +3 -5
  35. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  36. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  37. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  38. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  39. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  40. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  41. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  42. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  43. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  44. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  45. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  46. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  47. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  48. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  49. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  50. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  52. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  54. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  55. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  56. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  57. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  58. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  59. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  60. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  61. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  62. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  63. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  64. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  65. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  66. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  67. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  68. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  69. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  70. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  71. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  72. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  73. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  74. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  75. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  76. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  77. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  78. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  79. package/templates/create/base/.dockerignore.hbs +13 -0
  80. package/templates/create/base/.env.example.hbs +18 -9
  81. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  82. package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
  83. package/templates/create/base/.golangci.yml.hbs +27 -0
  84. package/templates/create/base/AGENTS.md.hbs +8 -4
  85. package/templates/create/base/Dockerfile.hbs +42 -0
  86. package/templates/create/base/Makefile.hbs +43 -13
  87. package/templates/create/base/README.md.hbs +45 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  89. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  90. package/templates/create/base/go.mod.hbs +4 -4
  91. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  92. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  93. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  94. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  95. package/templates/create/base/redocly.yaml.hbs +21 -0
  96. package/templates/create/features/docs/architecture.md.hbs +32 -11
  97. package/templates/create/features/docs/openapi.yaml.hbs +0 -4
  98. package/templates/create/features/docs/patterns.md.hbs +82 -8
  99. package/templates/create/features/docs/techstack.md.hbs +8 -3
  100. package/templates/generate/module/dto.go.hbs +8 -1
  101. package/templates/generate/module/errors.go.hbs +5 -0
  102. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  103. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  104. package/templates/generate/module/handler_test.go.hbs +8 -1
  105. package/templates/generate/module/migration.down.sql.hbs +3 -1
  106. package/templates/generate/module/migration.up.sql.hbs +7 -2
  107. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  108. package/templates/generate/module/model/model.go.hbs +10 -1
  109. package/templates/generate/module/permission.up.sql.hbs +3 -1
  110. package/templates/generate/module/repository.go.hbs +60 -6
  111. package/templates/generate/module/repository_test.go.hbs +30 -0
  112. package/templates/generate/module/service.go.hbs +10 -1
  113. package/templates/generate/module/service_test.go.hbs +45 -0
  114. package/dist/commands/remove.js +0 -88
  115. package/scripts/smoke-test.mjs +0 -2058
  116. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  117. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
  118. package/tests/integration/default-module.test.mjs +0 -46
  119. package/tests/integration/generator-naming.test.mjs +0 -81
  120. package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
  121. package/tests/integration/legacy-method-compat.test.mjs +0 -222
  122. package/tests/integration/remove-module.test.mjs +0 -58
  123. package/tests/unit/naming.test.mjs +0 -94
  124. 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 handlerArgs = [`${patch.pkg}.NewService(${patch.pkg}.NewRepository(db))`];
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
- // patchMainGo wires a newly generated module into cmd/api/main.go: its
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
- return content.replace(/import \(\n/, `import (\n\t${importLine}\n`);
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 `remove module`, which
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
- return content.replace(markerLine, `${indentedBlock}\n${markerLine}`);
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: confirm "${fieldColumn}" is the real column name for ${fieldParam}`,
155
- `\tif err := r.db.WithContext(ctx).First(&m, "${fieldColumn} = ?", ${fieldParam}).Error; err != nil {`,
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
- return (0, naming_1.resolveExistingModuleNaming)(rawName, existingModulePackages(projectDir));
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
  }
@@ -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
- // handlerName becomes a Go method name (`func (h *Handler) <name>`)
216
- assertNotGoKeyword(toCamelCase(cleaned), "method");
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
+ }