@nakedev/go-scaffold 0.1.4 → 0.3.1

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 +71 -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 +21 -7
  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
@@ -1,12 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AUTH_FILES = void 0;
4
- // output paths are relative to the project root
5
- exports.AUTH_FILES = [
3
+ exports.authFiles = authFiles;
4
+ // Everything that is the same whichever store backs the tokens.
5
+ const SHARED = [
6
6
  { template: "add/auth/internal/shared/middleware/auth.go.hbs", output: "internal/shared/middleware/auth.go" },
7
7
  { template: "add/auth/internal/shared/middleware/ratelimit.go.hbs", output: "internal/shared/middleware/ratelimit.go" },
8
8
  { template: "add/auth/internal/app/user/model/user.go.hbs", output: "internal/app/user/model/user.go" },
9
9
  { template: "add/auth/internal/app/user/model/identity.go.hbs", output: "internal/app/user/model/identity.go" },
10
+ { template: "add/auth/internal/app/user/model/loginthrottle.go.hbs", output: "internal/app/user/model/loginthrottle.go" },
10
11
  { template: "add/auth/internal/app/user/dto.go.hbs", output: "internal/app/user/dto.go" },
11
12
  { template: "add/auth/internal/app/user/errors.go.hbs", output: "internal/app/user/errors.go" },
12
13
  { template: "add/auth/internal/app/user/jwt.go.hbs", output: "internal/app/user/jwt.go" },
@@ -14,6 +15,22 @@ exports.AUTH_FILES = [
14
15
  { template: "add/auth/internal/app/user/repository.go.hbs", output: "internal/app/user/repository.go" },
15
16
  { template: "add/auth/internal/app/user/service.go.hbs", output: "internal/app/user/service.go" },
16
17
  { template: "add/auth/internal/app/user/service_test.go.hbs", output: "internal/app/user/service_test.go" },
18
+ { template: "add/auth/internal/app/user/repository_test.go.hbs", output: "internal/app/user/repository_test.go" },
17
19
  { template: "add/auth/internal/app/user/handler.go.hbs", output: "internal/app/user/handler.go" },
18
20
  { template: "add/auth/cmd/seed/main.go.hbs", output: "cmd/seed/main.go" },
19
21
  ];
22
+ // Only the chosen store's implementation is written. Shipping both would drag
23
+ // go-redis into every project's go.mod for a file it never constructs — the
24
+ // same reason `add worker` writes one queue adapter, not two.
25
+ const POSTGRES = [
26
+ { template: "add/auth/internal/app/user/model/authtoken.go.hbs", output: "internal/app/user/model/authtoken.go" },
27
+ { template: "add/auth/internal/app/user/tokenstore_pg.go.hbs", output: "internal/app/user/tokenstore_pg.go" },
28
+ { template: "add/auth/internal/shared/middleware/ratelimit_memory.go.hbs", output: "internal/shared/middleware/ratelimit_memory.go" },
29
+ ];
30
+ const REDIS = [
31
+ { template: "add/auth/internal/app/user/tokenstore_redis.go.hbs", output: "internal/app/user/tokenstore_redis.go" },
32
+ { template: "add/auth/internal/shared/middleware/ratelimit_redis.go.hbs", output: "internal/shared/middleware/ratelimit_redis.go" },
33
+ ];
34
+ function authFiles(store) {
35
+ return [...SHARED, ...(store === "postgres" ? POSTGRES : REDIS)];
36
+ }
@@ -4,11 +4,14 @@ exports.CREATE_MANIFEST = void 0;
4
4
  exports.CREATE_MANIFEST = [
5
5
  { template: "create/base/go.mod.hbs", output: "go.mod" },
6
6
  { template: "create/base/.gitignore.hbs", output: ".gitignore" },
7
+ { template: "create/base/Dockerfile.hbs", output: "Dockerfile" },
8
+ { template: "create/base/.dockerignore.hbs", output: ".dockerignore" },
7
9
  { template: "create/base/.env.example.hbs", output: ".env.example" },
8
10
  { template: "create/base/Makefile.hbs", output: "Makefile" },
9
11
  { template: "create/base/.golangci.yml.hbs", output: ".golangci.yml" },
10
12
  { template: "create/base/.vscode/settings.json.hbs", output: ".vscode/settings.json" },
11
13
  { template: "create/base/.github/workflows/ci.yml.hbs", output: ".github/workflows/ci.yml" },
14
+ { template: "create/base/.github/dependabot.yml.hbs", output: ".github/dependabot.yml" },
12
15
  { template: "create/base/README.md.hbs", output: "README.md" },
13
16
  { template: "create/base/AGENTS.md.hbs", output: "AGENTS.md" },
14
17
  { template: "create/base/CLAUDE.md.hbs", output: "CLAUDE.md" },
@@ -17,6 +20,7 @@ exports.CREATE_MANIFEST = [
17
20
  output: ".claude/skills/go-scaffold/SKILL.md",
18
21
  },
19
22
  { template: "create/base/cmd/api/main.go.hbs", output: "cmd/api/main.go" },
23
+ { template: "create/base/cmd/api/wiring.go.hbs", output: "cmd/api/wiring.go" },
20
24
  {
21
25
  template: "create/base/internal/platform/database/database.go.hbs",
22
26
  output: "internal/platform/database/database.go",
@@ -45,6 +49,10 @@ exports.CREATE_MANIFEST = [
45
49
  template: "create/base/internal/shared/pagination/pagination.go.hbs",
46
50
  output: "internal/shared/pagination/pagination.go",
47
51
  },
52
+ {
53
+ template: "create/base/internal/shared/tx/tx.go.hbs",
54
+ output: "internal/shared/tx/tx.go",
55
+ },
48
56
  {
49
57
  template: "create/base/internal/shared/middleware/cors.go.hbs",
50
58
  output: "internal/shared/middleware/cors.go",
@@ -87,6 +95,11 @@ exports.CREATE_MANIFEST = [
87
95
  output: "docs/openapi.yaml",
88
96
  when: (ctx) => ctx.openapiDocs,
89
97
  },
98
+ {
99
+ template: "create/base/redocly.yaml.hbs",
100
+ output: "redocly.yaml",
101
+ when: (ctx) => ctx.openapiDocs,
102
+ },
90
103
  {
91
104
  template: "create/features/docs/common/parameters.yaml.hbs",
92
105
  output: "docs/common/parameters.yaml",
@@ -112,24 +125,4 @@ exports.CREATE_MANIFEST = [
112
125
  output: "docs/health/health-readyz.yaml",
113
126
  when: (ctx) => ctx.openapiDocs,
114
127
  },
115
- {
116
- template: "create/features/docs/observability/metrics.yaml.hbs",
117
- output: "docs/observability/metrics.yaml",
118
- when: (ctx) => ctx.openapiDocs && ctx.observability,
119
- },
120
- {
121
- template: "create/features/observability/middleware/metrics.go.hbs",
122
- output: "internal/shared/middleware/metrics.go",
123
- when: (ctx) => ctx.observability,
124
- },
125
- {
126
- template: "create/features/observability/middleware/tracing.go.hbs",
127
- output: "internal/shared/middleware/tracing.go",
128
- when: (ctx) => ctx.observability,
129
- },
130
- {
131
- template: "create/features/observability/platform/telemetry/tracing.go.hbs",
132
- output: "internal/platform/telemetry/tracing.go",
133
- when: (ctx) => ctx.observability,
134
- },
135
128
  ];
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OBSERVABILITY_FILES = void 0;
4
+ // output paths are relative to the project root
5
+ exports.OBSERVABILITY_FILES = [
6
+ {
7
+ template: "create/features/observability/middleware/metrics.go.hbs",
8
+ output: "internal/shared/middleware/metrics.go",
9
+ },
10
+ {
11
+ template: "create/features/observability/middleware/tracing.go.hbs",
12
+ output: "internal/shared/middleware/tracing.go",
13
+ },
14
+ {
15
+ template: "create/features/observability/platform/telemetry/tracing.go.hbs",
16
+ output: "internal/platform/telemetry/tracing.go",
17
+ },
18
+ // only meaningful once there's an openapi.yaml to $ref it from
19
+ {
20
+ template: "create/features/docs/observability/metrics.yaml.hbs",
21
+ output: "docs/observability/metrics.yaml",
22
+ when: (ctx) => ctx.openapiDocs,
23
+ },
24
+ ];
@@ -11,6 +11,7 @@ exports.RBAC_FILES = [
11
11
  { template: "add/rbac/internal/app/role/repository.go.hbs", output: "internal/app/role/repository.go" },
12
12
  { template: "add/rbac/internal/app/role/service.go.hbs", output: "internal/app/role/service.go" },
13
13
  { template: "add/rbac/internal/app/role/service_test.go.hbs", output: "internal/app/role/service_test.go" },
14
+ { template: "add/rbac/internal/app/role/repository_test.go.hbs", output: "internal/app/role/repository_test.go" },
14
15
  { template: "add/rbac/internal/app/role/handler.go.hbs", output: "internal/app/role/handler.go" },
15
16
  { template: "add/rbac/internal/app/role/dto.go.hbs", output: "internal/app/role/dto.go" },
16
17
  { template: "add/rbac/internal/app/role/errors.go.hbs", output: "internal/app/role/errors.go" },
@@ -1,12 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WORKER_FILES = void 0;
4
- // output paths are relative to the project root
5
- exports.WORKER_FILES = [
6
- { template: "add/worker/internal/platform/cache/redis.go.hbs", output: "internal/platform/cache/redis.go" },
7
- { template: "add/worker/internal/platform/queue/client.go.hbs", output: "internal/platform/queue/client.go" },
8
- { template: "add/worker/internal/platform/queue/server.go.hbs", output: "internal/platform/queue/server.go" },
3
+ exports.MAIL_CLIENT_ONLY = void 0;
4
+ exports.workerFiles = workerFiles;
5
+ // The SMTP client on its own. `add auth` installs just this when the project
6
+ // has no worker: mail/task.go is the piece that knows about the queue, and
7
+ // pulling it in would drag platform/queue along with it.
8
+ exports.MAIL_CLIENT_ONLY = [
9
9
  { template: "add/worker/internal/platform/mail/mail.go.hbs", output: "internal/platform/mail/mail.go" },
10
+ ];
11
+ const SHARED = [
12
+ { template: "add/worker/internal/platform/queue/queue.go.hbs", output: "internal/platform/queue/queue.go" },
13
+ ...exports.MAIL_CLIENT_ONLY,
10
14
  { template: "add/worker/internal/platform/mail/task.go.hbs", output: "internal/platform/mail/task.go" },
11
15
  { template: "add/worker/cmd/worker/main.go.hbs", output: "cmd/worker/main.go" },
12
16
  ];
17
+ // Only the chosen backend's adapter is written: an unused adapter would drag
18
+ // its whole dependency tree into go.mod for nothing. Swapping later means
19
+ // re-running `add worker` with the other backend, or copying the adapter in
20
+ // by hand — the queue.go contract it implements does not change.
21
+ const RIVER = [{ template: "add/worker/internal/platform/queue/river.go.hbs", output: "internal/platform/queue/river.go" }];
22
+ const ASYNQ = [
23
+ { template: "add/worker/internal/platform/queue/asynq.go.hbs", output: "internal/platform/queue/asynq.go" },
24
+ // Redis only comes along when it is actually the queue's backing store.
25
+ { template: "add/worker/internal/platform/cache/redis.go.hbs", output: "internal/platform/cache/redis.go" },
26
+ ];
27
+ function workerFiles(backend) {
28
+ return [...SHARED, ...(backend === "river" ? RIVER : ASYNQ)];
29
+ }
@@ -4,7 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.patchConfigForAuth = patchConfigForAuth;
7
+ exports.authWiringLines = authWiringLines;
7
8
  exports.patchMainGoForAuth = patchMainGoForAuth;
9
+ exports.upgradeMailerToQueue = upgradeMailerToQueue;
8
10
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
11
  const marker_patch_1 = require("./marker-patch");
10
12
  const IMPORT_MARKER = "// go-scaffold:imports";
@@ -12,6 +14,7 @@ const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
12
14
  const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
13
15
  const CONFIG_CHECKS_MARKER = "// go-scaffold:config-checks";
14
16
  const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
17
+ const SCHEMA_MARKER = "// go-scaffold:schemas";
15
18
  const MODEL_MARKER = "// go-scaffold:models";
16
19
  const ROUTE_MARKER = "// go-scaffold:routes";
17
20
  const SHUTDOWN_MARKER = "// go-scaffold:shutdown";
@@ -26,6 +29,7 @@ function patchConfigForAuth(configGoPath) {
26
29
  "JWTAccessTTL time.Duration",
27
30
  "JWTRefreshTTL time.Duration",
28
31
  "CookieSecure bool",
32
+ "CookieSameSite string",
29
33
  "",
30
34
  "PasswordResetTTL time.Duration",
31
35
  "PasswordResetURL string",
@@ -37,12 +41,13 @@ function patchConfigForAuth(configGoPath) {
37
41
  "GoogleClientSecret string",
38
42
  "GoogleRedirectURL string",
39
43
  ].join("\n");
40
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, fieldsBlock, "JWTSecret string");
44
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, fieldsBlock, "JWTSecret");
41
45
  const loadBlock = [
42
46
  'JWTSecret: env("JWT_SECRET", "dev-secret-change-me"),',
43
47
  'JWTAccessTTL: time.Duration(envInt("JWT_ACCESS_TTL_MIN", 15)) * time.Minute,',
44
48
  'JWTRefreshTTL: time.Duration(envInt("JWT_REFRESH_TTL_MIN", 43200)) * time.Minute,',
45
49
  'CookieSecure: env("COOKIE_SECURE", "false") == "true",',
50
+ 'CookieSameSite: env("COOKIE_SAMESITE", "strict"),',
46
51
  "",
47
52
  'PasswordResetTTL: time.Duration(envInt("PASSWORD_RESET_TTL_MIN", 30)) * time.Minute,',
48
53
  'PasswordResetURL: env("PASSWORD_RESET_URL", "http://localhost:3000/reset-password"),',
@@ -54,43 +59,113 @@ function patchConfigForAuth(configGoPath) {
54
59
  'GoogleClientSecret: env("GOOGLE_CLIENT_SECRET", ""),',
55
60
  'GoogleRedirectURL: env("GOOGLE_REDIRECT_URL", ""),',
56
61
  ].join("\n");
57
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, 'JWTSecret: env("JWT_SECRET"');
62
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, 'env("JWT_SECRET"');
58
63
  fs_extra_1.default.writeFileSync(configGoPath, content);
59
64
  }
60
- // patchMainGoForAuth wires the user domain into cmd/api: its import, a
61
- // queue.Client (needed for the forgot-password email cmd/api itself never
62
- // enqueued anything before this), its two models in the AutoMigrate call, a
63
- // prod guard against the still-default JWT secret, and its route
64
- // registration (the domain's own Handler.Register splits /auth public vs
65
- // /users protected — main.go doesn't need to know that split, same
66
- // convention as every other module).
67
- function patchMainGoForAuth(mainGoPath, goModule) {
65
+ // authWiringLines is the single source of truth for the two lines that differ
66
+ // between stores, so patchMainGoForAuth and `add rbac`'s rewrite of the same
67
+ // lines can never drift apart.
68
+ function authWiringLines(w) {
69
+ const postgres = w.store === "postgres";
70
+ return {
71
+ tokenStore: postgres ? "user.NewPgTokenStore(db)" : "user.NewRedisTokenStore(rdb)",
72
+ limiter: postgres ? "middleware.NewMemoryLimiter()" : "middleware.NewRedisLimiter(rdb)",
73
+ // with a queue the mail is enqueued and the request returns immediately;
74
+ // without one it goes out inline, which is the cost of not running a worker
75
+ mailer: w.worker ? "mail.NewAsyncClient(q)" : "mail.NewSyncClient(mail.Open(cfg))",
76
+ };
77
+ }
78
+ function patchMainGoForAuth(mainGoPath, w) {
79
+ const { goModule, queueBackend, store } = w;
68
80
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
69
81
  const importLine = `"${goModule}/internal/app/user"`;
70
82
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
71
83
  const modelImportLine = `usermodel "${goModule}/internal/app/user/model"`;
72
84
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
73
- const queueImportLine = `"${goModule}/internal/platform/queue"`;
74
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, queueImportLine, queueImportLine);
85
+ if (w.worker) {
86
+ const queueImportLine = `"${goModule}/internal/platform/queue"`;
87
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, queueImportLine, queueImportLine);
88
+ }
75
89
  const mailImportLine = `"${goModule}/internal/platform/mail"`;
76
90
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, mailImportLine, mailImportLine);
77
91
  const checkBlock = [
78
92
  'if cfg.IsProd() && cfg.JWTSecret == "dev-secret-change-me" {',
79
- '\tlogger.Error("JWT_SECRET is still the dev default — set a real secret before deploying with APP_ENV=production")',
80
- "\tos.Exit(1)",
93
+ '\treturn errors.New("JWT_SECRET is still the dev default — set a real secret before deploying with APP_ENV=production")',
81
94
  "}",
82
95
  ].join("\n");
83
96
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_CHECKS_MARKER, checkBlock, "JWT_SECRET is still the dev default");
84
- const queueInitBlock = ["q, err := queue.NewClient(cfg.RedisURL)", "if err != nil {", '\tlogger.Error("open queue", "error", err)', "\tos.Exit(1)", "}"].join("\n");
85
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, queueInitBlock, "q, err := queue.NewClient(cfg.RedisURL)");
86
- const migrateLine1 = "&usermodel.User{},";
87
- const migrateLine2 = "&usermodel.Identity{},";
88
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, migrateLine1, migrateLine1);
89
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, migrateLine2, migrateLine2);
90
- const routeLine = "user.NewHandler(user.NewService(user.NewRepository(db), user.NewRedisTokenStore(rdb), mail.NewAsyncClient(q), cfg), cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, rdb).Register(api)";
97
+ // Without SMTP the mail client logs the message instead of sending it a
98
+ // deliberate dev convenience that in production means password-reset and
99
+ // email-verification links land in the log aggregator while
100
+ // /auth/forgot-password still answers 200, so nobody finds out the mail
101
+ // never went anywhere.
102
+ const smtpCheckBlock = [
103
+ 'if cfg.IsProd() && cfg.SMTPHost == "" {',
104
+ '\treturn errors.New("SMTP_HOST is unset — password reset and email verification links would be written to the log instead of sent")',
105
+ "}",
106
+ ].join("\n");
107
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_CHECKS_MARKER, smtpCheckBlock, "SMTP_HOST is unset");
108
+ // The enqueuer is built from whatever backend `add worker` chose — the
109
+ // constructor differs, everything downstream of it (mail.NewAsyncClient)
110
+ // only sees the queue.Enqueuer interface and doesn't change.
111
+ if (w.worker) {
112
+ const queueCtor = queueBackend === "river" ? "queue.NewRiverEnqueuer(db)" : "queue.NewAsynqEnqueuer(cfg.RedisURL)";
113
+ const queueInitBlock = [`q, err := ${queueCtor}`, "if err != nil {", '\treturn fmt.Errorf("open queue: %w", err)', "}"].join("\n");
114
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, queueInitBlock, "q, err := queue.New");
115
+ }
116
+ const schemaBlock = [
117
+ 'if err := db.Exec("CREATE SCHEMA IF NOT EXISTS user_svc").Error; err != nil {',
118
+ '\treturn fmt.Errorf("create schema user_svc: %w", err)',
119
+ "}",
120
+ ].join("\n");
121
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS user_svc");
122
+ const migrateLines = ["&usermodel.User{},", "&usermodel.Identity{},", "&usermodel.LoginThrottle{},"];
123
+ // only the Postgres store has a table for AutoMigrate to create
124
+ if (store === "postgres")
125
+ migrateLines.push("&usermodel.AuthToken{},");
126
+ for (const line of migrateLines) {
127
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
128
+ }
129
+ // same two-line shape every generated module uses: a named service, then
130
+ // the handler that registers it. `add rbac` extends both lines later, and a
131
+ // human wiring another domain into user's service edits line one in place.
132
+ const { tokenStore, limiter, mailer } = authWiringLines(w);
133
+ const svcLine = `userSvc := user.NewService(user.NewRepository(db), ${tokenStore}, ${mailer}, cfg)`;
134
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, svcLine, "userSvc :=");
135
+ const routeLine = `user.NewHandler(userSvc, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, ${limiter}).Register(api)`;
91
136
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, routeLine, routeLine);
92
137
  content = content.replace(/\n\t_ = api \/\/ dropped once `generate module` registers the first route\n/, "\n");
138
+ if (w.worker) {
139
+ const shutdownBlock = ["if err := q.Close(); err != nil {", '\tlogger.Error("close queue", "error", err)', "}"].join("\n");
140
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SHUTDOWN_MARKER, shutdownBlock, "if err := q.Close()");
141
+ }
142
+ fs_extra_1.default.writeFileSync(mainGoPath, content);
143
+ }
144
+ // upgradeMailerToQueue is `add worker` arriving after `add auth`. Auth wired a
145
+ // synchronous mailer because there was no queue at the time; now there is one,
146
+ // so the enqueuer gets built and the mailer swapped for the async client.
147
+ //
148
+ // Without this the printed "run `add worker` later to move it onto the queue"
149
+ // would be a lie, and the project would keep blocking on SMTP with a perfectly
150
+ // good queue sitting next to it.
151
+ //
152
+ // No-op on a project whose auth already had a worker, and on one with no auth
153
+ // at all — both simply don't contain the line it looks for. Returns whether
154
+ // it actually upgraded something, so the caller's printed summary can say
155
+ // which happened instead of always assuming "no auth yet".
156
+ function upgradeMailerToQueue(mainGoPath, goModule, queueBackend) {
157
+ let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
158
+ const syncMailer = "mail.NewSyncClient(mail.Open(cfg))";
159
+ if (!content.includes(syncMailer))
160
+ return false;
161
+ const queueImportLine = `"${goModule}/internal/platform/queue"`;
162
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, queueImportLine, queueImportLine);
163
+ const queueCtor = queueBackend === "river" ? "queue.NewRiverEnqueuer(db)" : "queue.NewAsynqEnqueuer(cfg.RedisURL)";
164
+ const queueInitBlock = [`q, err := ${queueCtor}`, "if err != nil {", '\treturn fmt.Errorf("open queue: %w", err)', "}"].join("\n");
165
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, queueInitBlock, "q, err := queue.New");
93
166
  const shutdownBlock = ["if err := q.Close(); err != nil {", '\tlogger.Error("close queue", "error", err)', "}"].join("\n");
94
167
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SHUTDOWN_MARKER, shutdownBlock, "if err := q.Close()");
168
+ content = content.replace(syncMailer, () => "mail.NewAsyncClient(q)");
95
169
  fs_extra_1.default.writeFileSync(mainGoPath, content);
170
+ return true;
96
171
  }
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.configPath = configPath;
7
7
  exports.writeConfig = writeConfig;
8
8
  exports.readConfig = readConfig;
9
+ exports.isProjectDir = isProjectDir;
9
10
  const path_1 = __importDefault(require("path"));
10
11
  const fs_extra_1 = __importDefault(require("fs-extra"));
11
12
  const CONFIG_FILE = "go-scaffold.config.json";
@@ -26,28 +27,75 @@ function readConfig(projectDir) {
26
27
  }
27
28
  function detectConfig(projectDir) {
28
29
  const goModPath = path_1.default.join(projectDir, "go.mod");
30
+ const mainGoPath = path_1.default.join(projectDir, "cmd", "api", "main.go");
29
31
  if (!fs_extra_1.default.existsSync(goModPath)) {
30
32
  throw new Error(`no ${CONFIG_FILE} and no go.mod found in ${projectDir} — run this inside a go-scaffold project`);
31
33
  }
34
+ // go.mod alone is just "some Go module". Without cmd/api/main.go there is
35
+ // nothing to wire a module into, and every command would write its files
36
+ // first and only then die on a missing main.go — a half-scaffolded
37
+ // directory the user has to clean up by hand.
38
+ if (!fs_extra_1.default.existsSync(mainGoPath)) {
39
+ throw new Error(`${projectDir} has a go.mod but no cmd/api/main.go — this looks like a plain Go module, not a go-scaffold project.\n` +
40
+ `Run \`go-scaffold create <name>\` to start one.`);
41
+ }
32
42
  const goMod = fs_extra_1.default.readFileSync(goModPath, "utf8");
33
43
  const moduleMatch = goMod.match(/^module\s+(\S+)/m);
34
44
  const goModule = moduleMatch ? moduleMatch[1] : path_1.default.basename(projectDir);
35
- // parse the chosen prefix back out of `api := r.Group("/v1")` in main.go;
36
- // an empty group (`r.Group("")`) or no match at all means no prefix.
45
+ // parse the chosen prefix back out of `api := r.Group("/v1")`; an empty
46
+ // group (`r.Group("")`) or no match at all means no prefix.
47
+ //
48
+ // Read from cmd/api/wiring.go where the composition root lives now, falling
49
+ // back to main.go for projects scaffolded before it was split out — this is
50
+ // the config-less path, so it is exactly the old projects that reach it.
51
+ const wiringGoPath = path_1.default.join(projectDir, "cmd", "api", "wiring.go");
52
+ const compositionRoot = fs_extra_1.default.existsSync(wiringGoPath) ? wiringGoPath : mainGoPath;
37
53
  let apiPrefix = "";
38
- const mainGoPath = path_1.default.join(projectDir, "cmd", "api", "main.go");
39
- if (fs_extra_1.default.existsSync(mainGoPath)) {
40
- const groupMatch = fs_extra_1.default.readFileSync(mainGoPath, "utf8").match(/api\s*:=\s*r\.Group\("\/?([a-z0-9/]*)"\)/);
41
- if (groupMatch)
42
- apiPrefix = groupMatch[1];
43
- }
54
+ const groupMatch = fs_extra_1.default.readFileSync(compositionRoot, "utf8").match(/api\s*:=\s*r\.Group\("\/?([a-z0-9/]*)"\)/);
55
+ if (groupMatch)
56
+ apiPrefix = groupMatch[1];
57
+ // Every feature is detectable from the tree each `add` command creates, so
58
+ // detect them all rather than reporting only docker/openapi. Leaving the
59
+ // rest undefined made a config-less project a dead end: `add auth` refused
60
+ // ("needs add worker first") while `add worker` also refused ("queue
61
+ // already exists"), with no way out. Worse, the first `add` to succeed then
62
+ // wrote a config that recorded the undetected features as absent.
63
+ const has = (...segments) => fs_extra_1.default.existsSync(path_1.default.join(projectDir, ...segments));
64
+ const worker = has("internal", "platform", "queue");
44
65
  return {
45
66
  projectName: path_1.default.basename(projectDir),
46
67
  goModule,
47
68
  apiPrefix,
48
69
  features: {
49
- docker: fs_extra_1.default.existsSync(path_1.default.join(projectDir, "docker-compose.yml")),
50
- openapiDocs: fs_extra_1.default.existsSync(path_1.default.join(projectDir, "docs", "openapi.yaml")),
70
+ docker: has("docker-compose.yml"),
71
+ openapiDocs: has("docs", "openapi.yaml"),
72
+ worker,
73
+ // which adapter file is present is what `add worker --queue` decided
74
+ queue: !worker ? undefined : has("internal", "platform", "queue", "asynq.go") ? "asynq" : "river",
75
+ auth: has("internal", "app", "user"),
76
+ // which store `add auth` chose is readable from which implementation
77
+ // file it wrote — same trick as the queue adapter above. Projects from
78
+ // before the option existed have neither name and read as "redis",
79
+ // which is what they in fact are.
80
+ authStore: !has("internal", "app", "user")
81
+ ? undefined
82
+ : has("internal", "app", "user", "tokenstore_pg.go")
83
+ ? "postgres"
84
+ : "redis",
85
+ rbac: has("internal", "app", "role"),
86
+ observability: has("internal", "platform", "telemetry"),
51
87
  },
52
88
  };
53
89
  }
90
+ // isProjectDir answers "would readConfig succeed here?" without throwing, so
91
+ // the top-level menu can offer only what can actually run in this directory
92
+ // instead of asking two questions and then failing.
93
+ function isProjectDir(projectDir) {
94
+ try {
95
+ readConfig(projectDir);
96
+ return true;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.typeChecks = typeChecks;
7
+ exports.parseChecks = parseChecks;
8
+ exports.assertStillParses = assertStillParses;
7
9
  exports.assertNoDrift = assertNoDrift;
8
10
  const child_process_1 = require("child_process");
9
11
  const picocolors_1 = __importDefault(require("picocolors"));
@@ -32,6 +34,44 @@ function typeChecks(projectRoot) {
32
34
  return { ok: false, output: `${e.stderr?.toString() ?? ""}${e.stdout?.toString() ?? ""}`.trim() };
33
35
  }
34
36
  }
37
+ // parseChecks asks the weaker question typeChecks can't: does every .go file
38
+ // in the tree still *parse*? gofmt only reads files, so this works before
39
+ // `go mod tidy` has resolved a new feature's third-party imports — which is
40
+ // precisely when `add auth`/`add worker`/`add observability` do their most
41
+ // invasive patching and when `go vet` is guaranteed to fail for reasons that
42
+ // have nothing to do with us.
43
+ function parseChecks(projectRoot) {
44
+ try {
45
+ (0, child_process_1.execFileSync)("gofmt", ["-l", "-e", "."], { cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"] });
46
+ return { ok: true, output: "" };
47
+ }
48
+ catch (err) {
49
+ const e = err;
50
+ if (e.code === "ENOENT")
51
+ return null; // no Go toolchain — can't conclude anything
52
+ return { ok: false, output: `${e.stderr?.toString() ?? ""}`.trim() };
53
+ }
54
+ }
55
+ // assertStillParses is the before/after pair for a command that patches files
56
+ // rather than only adding them. A marker patch that lands in the wrong place
57
+ // produces Go that doesn't parse, and without this the command goes on to
58
+ // print success over it — gofmt's own failure used to be swallowed whole.
59
+ //
60
+ // Same "only blame yourself for a passed → broken transition" rule as
61
+ // assertNoDrift: a project that already had an unparseable file (mid-edit, a
62
+ // scratch file) is not this command's problem.
63
+ function assertStillParses(projectRoot, before, didWhat) {
64
+ if (before === null || !before.ok)
65
+ return;
66
+ const after = parseChecks(projectRoot);
67
+ if (after === null || after.ok)
68
+ return;
69
+ throw new Error(`${picocolors_1.default.red(`${didWhat}, but the result is not valid Go — a patch landed somewhere it doesn't fit.`)}\n\n` +
70
+ `${picocolors_1.default.dim("gofmt says:")}\n${after.output}\n\n` +
71
+ `The files were left as-is so you can see the damage. Most likely one of the files this\n` +
72
+ `command patches was hand-edited away from the shape it was generated in. Please report\n` +
73
+ `this if that isn't the case — the CLI should never write Go that doesn't parse.`);
74
+ }
35
75
  // assertNoDrift is the second half of a before/after pair: given what
36
76
  // typeChecks said *before* the files were written, it re-checks and fails
37
77
  // loudly if generating is what broke the project.
@@ -46,20 +86,32 @@ function typeChecks(projectRoot) {
46
86
  // is normal, expected work — the generated code stops compiling against it.
47
87
  // Without this check that lands as a mystery build error some time later, in
48
88
  // files the user never wrote.
49
- function assertNoDrift(projectRoot, before, config) {
89
+ function assertNoDrift(projectRoot, before, config,
90
+ // What the command did, for commands that patch rather than generate
91
+ // (`add rbac`, `undo module`). Their failure isn't template drift, it's a
92
+ // marker patch that landed against a main.go it didn't recognise.
93
+ patched) {
50
94
  if (before === null || !before.ok)
51
95
  return; // no Go here, or already broken — not ours to judge
52
96
  const after = typeChecks(projectRoot);
53
97
  if (after === null || after.ok)
54
98
  return;
55
99
  const scaffoldedWith = config.scaffoldVersion ?? "unknown (predates version stamping)";
100
+ const versions = ` scaffolded with: go-scaffold ${scaffoldedWith}\n` +
101
+ ` this CLI: go-scaffold ${(0, version_1.cliVersion)()}\n\n` +
102
+ `${picocolors_1.default.dim("go vet ./... says:")}\n${after.output}`;
103
+ if (patched) {
104
+ throw new Error(`${picocolors_1.default.red(`${patched.didWhat}, but the project no longer compiles.`)}\n\n` +
105
+ `This command patches existing files at marker comments. If those files were\n` +
106
+ `hand-edited — or were written by a different CLI version — a patch can land\n` +
107
+ `somewhere it doesn't fit, and the result only shows up as a build error.\n\n` +
108
+ `${versions}\n\n${patched.recover}`);
109
+ }
56
110
  throw new Error(`${picocolors_1.default.red("the generated code doesn't compile, but this project was fine a moment ago.")}\n\n` +
57
111
  `The most likely cause is drift: this project's internal/shared layer has been edited\n` +
58
112
  `since it was scaffolded, so the templates this CLI emits no longer match it.\n\n` +
59
- ` scaffolded with: go-scaffold ${scaffoldedWith}\n` +
60
- ` this CLI: go-scaffold ${(0, version_1.cliVersion)()}\n\n` +
61
- `${picocolors_1.default.dim("go vet ./... says:")}\n${after.output}\n\n` +
113
+ `${versions}\n\n` +
62
114
  `The generated files were left in place — reconcile them with your shared/ layer by\n` +
63
- `hand, or undo (\`go-scaffold remove module <name>\` for a module) and generate again\n` +
115
+ `hand, or undo (\`go-scaffold undo module <name>\` for a module) and generate again\n` +
64
116
  `with a CLI version that matches this project.`);
65
117
  }
@@ -0,0 +1,73 @@
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.patchGolangciForModule = patchGolangciForModule;
7
+ exports.unpatchGolangciForModule = unpatchGolangciForModule;
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const marker_patch_1 = require("./marker-patch");
10
+ const DEPGUARD_MARKER = "# go-scaffold:depguard-rules";
11
+ function ruleName(pkg) {
12
+ return `domain-isolation-${pkg}`;
13
+ }
14
+ // depguardRule is the per-domain boundary rule. It has to be per-domain
15
+ // because depguard matches import paths as static prefixes with no idea which
16
+ // domain a file belongs to: a single `deny: internal/app` rule would also
17
+ // reject `order` importing its own `order/model`. Scoping with `files:` and
18
+ // allowing exactly this domain's own path gives "may import myself, may not
19
+ // import a sibling".
20
+ //
21
+ // Written with no leading indentation — insertBeforeMarker re-indents the
22
+ // whole block to match the marker's own column.
23
+ function depguardRule(goModule, pkg) {
24
+ return [
25
+ `${ruleName(pkg)}:`,
26
+ ` list-mode: lax`,
27
+ ` files:`,
28
+ ` - "**/internal/app/${pkg}/**"`,
29
+ ` allow:`,
30
+ ` - "${goModule}/internal/app/${pkg}"`,
31
+ ` deny:`,
32
+ ` - pkg: "${goModule}/internal/app"`,
33
+ ` desc: >-`,
34
+ ` a domain must not import another domain directly — declare a`,
35
+ ` consumer-side interface for what you need and let`,
36
+ ` cmd/api/wiring.go wire the concrete service in`,
37
+ ` (docs/architect/patterns.md)`,
38
+ ].join("\n");
39
+ }
40
+ // patchGolangciForModule adds this domain's boundary rule. No-op on a project
41
+ // whose .golangci.yml predates the marker (or was replaced wholesale) — a
42
+ // missing lint rule must never fail code generation.
43
+ function patchGolangciForModule(golangciPath, goModule, pkg) {
44
+ if (!fs_extra_1.default.existsSync(golangciPath))
45
+ return;
46
+ const content = fs_extra_1.default.readFileSync(golangciPath, "utf8");
47
+ if (!content.includes(DEPGUARD_MARKER))
48
+ return;
49
+ fs_extra_1.default.writeFileSync(golangciPath, (0, marker_patch_1.insertBeforeMarkerOnce)(content, DEPGUARD_MARKER, depguardRule(goModule, pkg), `${ruleName(pkg)}:`));
50
+ }
51
+ // unpatchGolangciForModule drops the rule again, matched by its heading and
52
+ // everything indented under it — the inverse of patchGolangciForModule.
53
+ function unpatchGolangciForModule(golangciPath, pkg) {
54
+ if (!fs_extra_1.default.existsSync(golangciPath))
55
+ return;
56
+ const lines = fs_extra_1.default.readFileSync(golangciPath, "utf8").split("\n");
57
+ const heading = `${ruleName(pkg)}:`;
58
+ const start = lines.findIndex((l) => l.trim() === heading);
59
+ if (start === -1)
60
+ return;
61
+ const indent = lines[start].length - lines[start].trimStart().length;
62
+ let end = start + 1;
63
+ while (end < lines.length) {
64
+ const line = lines[end];
65
+ // blank lines belong to the block only if something indented follows
66
+ const deeper = line.trim() === "" || line.length - line.trimStart().length > indent;
67
+ if (!deeper)
68
+ break;
69
+ end++;
70
+ }
71
+ lines.splice(start, end - start);
72
+ fs_extra_1.default.writeFileSync(golangciPath, lines.join("\n"));
73
+ }