@nakedev/go-scaffold 0.1.2 → 0.1.3

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 (108) hide show
  1. package/README.md +75 -0
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +59 -1
  5. package/dist/commands/method.js +3 -0
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +19 -2
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +66 -3
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/rbac-manifest.js +17 -0
  16. package/dist/templates/worker-manifest.js +12 -0
  17. package/dist/utils/auth-patcher.js +96 -0
  18. package/dist/utils/gocheck.js +65 -0
  19. package/dist/utils/main-patcher.js +8 -1
  20. package/dist/utils/migrations.js +30 -8
  21. package/dist/utils/openapi-patcher.js +16 -0
  22. package/dist/utils/platform-patcher.js +59 -0
  23. package/dist/utils/rbac-patcher.js +277 -0
  24. package/dist/utils/version.js +24 -0
  25. package/package.json +2 -2
  26. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  27. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  28. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  29. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  30. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  31. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  32. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  33. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  35. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  36. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  37. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  38. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  39. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  40. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  41. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  42. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  43. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  44. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  45. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  46. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  50. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  52. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  54. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  55. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  56. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  57. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  58. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  59. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  60. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  61. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  62. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  63. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  64. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  65. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  66. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  67. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  68. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  69. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  70. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  71. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  72. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  73. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  74. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  75. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  76. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  77. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  78. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  79. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  80. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  81. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  82. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  83. package/templates/create/base/.env.example.hbs +20 -0
  84. package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
  85. package/templates/create/base/.gitignore.hbs +2 -0
  86. package/templates/create/base/Makefile.hbs +30 -5
  87. package/templates/create/base/README.md.hbs +36 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  89. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  90. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  91. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  92. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  93. package/templates/create/base/migrations/embed.go.hbs +15 -0
  94. package/templates/create/features/docs/architecture.md.hbs +22 -0
  95. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  96. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  97. package/templates/create/features/docs/openapi.yaml.hbs +13 -0
  98. package/templates/create/features/docs/techstack.md.hbs +3 -0
  99. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  100. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  101. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  102. package/templates/generate/module/handler.go.hbs +20 -3
  103. package/templates/generate/module/handler_test.go.hbs +49 -6
  104. package/templates/generate/module/minimal/handler.go.hbs +21 -3
  105. package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
  106. package/templates/generate/module/permission.down.sql.hbs +5 -0
  107. package/templates/generate/module/permission.up.sql.hbs +4 -0
  108. package/dist/utils/module-paths.js +0 -33
package/dist/index.js CHANGED
@@ -10,12 +10,17 @@ const picocolors_1 = __importDefault(require("picocolors"));
10
10
  const create_1 = require("./commands/create");
11
11
  const generate_1 = require("./commands/generate");
12
12
  const method_1 = require("./commands/method");
13
+ const migration_1 = require("./commands/migration");
13
14
  const remove_1 = require("./commands/remove");
15
+ const version_1 = require("./utils/version");
16
+ const worker_1 = require("./commands/worker");
17
+ const auth_1 = require("./commands/auth");
18
+ const rbac_1 = require("./commands/rbac");
14
19
  const program = new commander_1.Command();
15
20
  program
16
21
  .name("go-scaffold")
17
22
  .description("Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard")
18
- .version("0.1.0");
23
+ .version((0, version_1.cliVersion)());
19
24
  program
20
25
  .command("create [name]")
21
26
  .alias("c")
@@ -23,6 +28,7 @@ program
23
28
  .option("--defaults", "skip the wizard, use defaults (for CI/scripting)")
24
29
  .option("--no-docker", "skip docker-compose.yml (only applies with --defaults)")
25
30
  .option("--no-openapi-docs", "skip docs/openapi.yaml (only applies with --defaults)")
31
+ .option("--observability", "add Prometheus /metrics + OpenTelemetry tracing (only applies with --defaults; off by default)")
26
32
  .option("--api-prefix <prefix>", 'URL prefix every route is grouped under (default "v1"; pass "" for none)')
27
33
  .action(async (name, opts) => {
28
34
  try {
@@ -30,6 +36,7 @@ program
30
36
  defaults: opts.defaults,
31
37
  docker: opts.docker,
32
38
  openapiDocs: opts.openapiDocs,
39
+ observability: opts.observability,
33
40
  apiPrefix: opts.apiPrefix,
34
41
  });
35
42
  }
@@ -51,14 +58,18 @@ const generate = program
51
58
  choices: [
52
59
  { name: "Module (full CRUD domain)", value: "module" },
53
60
  { name: "Method (add one endpoint to an existing module)", value: "method" },
61
+ { name: "Migration (reserve a timestamped up/down SQL file pair)", value: "migration" },
54
62
  ],
55
63
  });
56
64
  if (target === "module") {
57
65
  await (0, generate_1.generateModule)(undefined, { full: true });
58
66
  }
59
- else {
67
+ else if (target === "method") {
60
68
  await (0, method_1.generateMethod)(undefined, undefined, {});
61
69
  }
70
+ else {
71
+ await (0, migration_1.generateMigration)(undefined);
72
+ }
62
73
  }
63
74
  catch (err) {
64
75
  console.error(picocolors_1.default.red(err.message));
@@ -70,9 +81,11 @@ generate
70
81
  .alias("m")
71
82
  .description("scaffold a domain module — full CRUD by default, or a bare skeleton with --no-full")
72
83
  .option("--no-full", "minimal skeleton (model/errors/repository, no default CRUD) — add endpoints one at a time with `generate method`")
84
+ .option("--auth", "require a valid access token for this module's routes (needs `add auth`)")
85
+ .option("--permission <code>", "also require this permission via authz.Require (needs `add rbac`; implies --auth)")
73
86
  .action(async (name, opts) => {
74
87
  try {
75
- await (0, generate_1.generateModule)(name, { full: opts.full });
88
+ await (0, generate_1.generateModule)(name, { full: opts.full, auth: opts.auth, permission: opts.permission });
76
89
  }
77
90
  catch (err) {
78
91
  console.error(picocolors_1.default.red(err.message));
@@ -107,6 +120,56 @@ generate
107
120
  process.exitCode = 1;
108
121
  }
109
122
  });
123
+ generate
124
+ .command("migration [name]")
125
+ .alias("mig")
126
+ .description("reserve a timestamped migrations/<version>_<name>.{up,down}.sql pair (stubs only — you write the SQL)")
127
+ .action(async (name) => {
128
+ try {
129
+ await (0, migration_1.generateMigration)(name);
130
+ }
131
+ catch (err) {
132
+ console.error(picocolors_1.default.red(err.message));
133
+ process.exitCode = 1;
134
+ }
135
+ });
136
+ const add = program.command("add").description("add opt-in infrastructure to an existing go-scaffold project");
137
+ add
138
+ .command("worker")
139
+ .description("add Redis, an Asynq task queue, SMTP mail, and cmd/worker (opt-in — most projects don't need this on day one)")
140
+ .action(async () => {
141
+ try {
142
+ await (0, worker_1.addWorker)();
143
+ }
144
+ catch (err) {
145
+ console.error(picocolors_1.default.red(err.message));
146
+ process.exitCode = 1;
147
+ }
148
+ });
149
+ add
150
+ .command("auth")
151
+ .description("add email/password auth: JWT access tokens, Redis-backed refresh token rotation, register/login/refresh/logout/me (requires `add worker` first)")
152
+ .action(async () => {
153
+ try {
154
+ await (0, auth_1.addAuth)();
155
+ }
156
+ catch (err) {
157
+ console.error(picocolors_1.default.red(err.message));
158
+ process.exitCode = 1;
159
+ }
160
+ });
161
+ add
162
+ .command("rbac")
163
+ .description("add role-based access control: roles/permissions admin API, cached Authz middleware, PATCH /users/:id/set-role (requires `add auth` first)")
164
+ .action(async () => {
165
+ try {
166
+ await (0, rbac_1.addRbac)();
167
+ }
168
+ catch (err) {
169
+ console.error(picocolors_1.default.red(err.message));
170
+ process.exitCode = 1;
171
+ }
172
+ });
110
173
  const remove = program
111
174
  .command("remove")
112
175
  .alias("rm")
@@ -25,6 +25,10 @@ async function runCreateWizard() {
25
25
  message: "Include hand-written OpenAPI docs (docs/openapi.yaml, whole docs/ tree served at /docs)?",
26
26
  default: true,
27
27
  });
28
+ const observability = await (0, prompts_1.confirm)({
29
+ message: "Add metrics + tracing (Prometheus /metrics, OpenTelemetry over OTLP/HTTP for Gin + GORM)?",
30
+ default: false,
31
+ });
28
32
  const apiPrefixRaw = await (0, prompts_1.input)({
29
33
  message: "API route prefix (e.g. v1, api/v1; leave blank for none):",
30
34
  default: "v1",
@@ -34,10 +38,11 @@ async function runCreateWizard() {
34
38
  console.log("\nSummary:");
35
39
  console.log(` Docker + PostgreSQL: ${docker ? "yes" : "no"}`);
36
40
  console.log(` OpenAPI docs: ${openapiDocs ? "yes" : "no"}`);
41
+ console.log(` Metrics + tracing: ${observability ? "yes" : "no"}`);
37
42
  console.log(` Route prefix: ${apiPrefix ? `/${apiPrefix}` : "(none)"}`);
38
43
  const proceed = await (0, prompts_1.confirm)({ message: "\nCreate project with these settings?", default: true });
39
44
  if (!proceed) {
40
45
  throw new Error("project creation cancelled");
41
46
  }
42
- return { features: { docker, openapiDocs }, apiPrefix };
47
+ return { features: { docker, openapiDocs, observability }, apiPrefix };
43
48
  }
@@ -4,6 +4,7 @@ exports.promptModuleName = promptModuleName;
4
4
  exports.promptMethodName = promptMethodName;
5
5
  exports.promptMethodType = promptMethodType;
6
6
  exports.promptGetMode = promptGetMode;
7
+ exports.promptMigrationName = promptMigrationName;
7
8
  exports.promptLookupField = promptLookupField;
8
9
  const prompts_1 = require("@inquirer/prompts");
9
10
  const naming_1 = require("../utils/naming");
@@ -53,6 +54,13 @@ async function promptGetMode() {
53
54
  ],
54
55
  });
55
56
  }
57
+ async function promptMigrationName() {
58
+ const name = await (0, prompts_1.input)({
59
+ message: "Migration name (e.g. add_status_to_orders):",
60
+ validate: (value) => (value.trim() ? true : "migration name is required"),
61
+ });
62
+ return name.trim();
63
+ }
56
64
  async function promptLookupField() {
57
65
  const field = await (0, prompts_1.input)({
58
66
  message: "Lookup field (e.g. email, status, slug):",
@@ -0,0 +1,19 @@
1
+ "use strict";
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 = [
6
+ { template: "add/auth/internal/shared/middleware/auth.go.hbs", output: "internal/shared/middleware/auth.go" },
7
+ { template: "add/auth/internal/shared/middleware/ratelimit.go.hbs", output: "internal/shared/middleware/ratelimit.go" },
8
+ { template: "add/auth/internal/app/user/model/user.go.hbs", output: "internal/app/user/model/user.go" },
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/dto.go.hbs", output: "internal/app/user/dto.go" },
11
+ { template: "add/auth/internal/app/user/errors.go.hbs", output: "internal/app/user/errors.go" },
12
+ { template: "add/auth/internal/app/user/jwt.go.hbs", output: "internal/app/user/jwt.go" },
13
+ { template: "add/auth/internal/app/user/tokenstore.go.hbs", output: "internal/app/user/tokenstore.go" },
14
+ { template: "add/auth/internal/app/user/repository.go.hbs", output: "internal/app/user/repository.go" },
15
+ { template: "add/auth/internal/app/user/service.go.hbs", output: "internal/app/user/service.go" },
16
+ { template: "add/auth/internal/app/user/service_test.go.hbs", output: "internal/app/user/service_test.go" },
17
+ { template: "add/auth/internal/app/user/handler.go.hbs", output: "internal/app/user/handler.go" },
18
+ { template: "add/auth/cmd/seed/main.go.hbs", output: "cmd/seed/main.go" },
19
+ ];
@@ -45,6 +45,10 @@ exports.CREATE_MANIFEST = [
45
45
  template: "create/base/internal/shared/pagination/pagination.go.hbs",
46
46
  output: "internal/shared/pagination/pagination.go",
47
47
  },
48
+ {
49
+ template: "create/base/internal/shared/middleware/cors.go.hbs",
50
+ output: "internal/shared/middleware/cors.go",
51
+ },
48
52
  {
49
53
  template: "create/base/internal/shared/middleware/error.go.hbs",
50
54
  output: "internal/shared/middleware/error.go",
@@ -58,6 +62,7 @@ exports.CREATE_MANIFEST = [
58
62
  output: "internal/shared/middleware/requestid.go",
59
63
  },
60
64
  { template: "create/base/migrations/.gitkeep.hbs", output: "migrations/.gitkeep" },
65
+ { template: "create/base/migrations/embed.go.hbs", output: "migrations/embed.go" },
61
66
  // architecture standards docs — always included, this is the point of the CLI
62
67
  {
63
68
  template: "create/features/docs/architecture.md.hbs",
@@ -107,4 +112,24 @@ exports.CREATE_MANIFEST = [
107
112
  output: "docs/health/health-readyz.yaml",
108
113
  when: (ctx) => ctx.openapiDocs,
109
114
  },
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
+ },
110
135
  ];
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RBAC_FILES = void 0;
4
+ // output paths are relative to the project root
5
+ exports.RBAC_FILES = [
6
+ { template: "add/rbac/internal/shared/middleware/authz.go.hbs", output: "internal/shared/middleware/authz.go" },
7
+ { template: "add/rbac/internal/shared/middleware/authz_test.go.hbs", output: "internal/shared/middleware/authz_test.go" },
8
+ { template: "add/rbac/internal/app/role/model/role.go.hbs", output: "internal/app/role/model/role.go" },
9
+ { template: "add/rbac/internal/app/role/model/permission.go.hbs", output: "internal/app/role/model/permission.go" },
10
+ { template: "add/rbac/internal/app/role/model/role_permission.go.hbs", output: "internal/app/role/model/role_permission.go" },
11
+ { template: "add/rbac/internal/app/role/repository.go.hbs", output: "internal/app/role/repository.go" },
12
+ { template: "add/rbac/internal/app/role/service.go.hbs", output: "internal/app/role/service.go" },
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/handler.go.hbs", output: "internal/app/role/handler.go" },
15
+ { template: "add/rbac/internal/app/role/dto.go.hbs", output: "internal/app/role/dto.go" },
16
+ { template: "add/rbac/internal/app/role/errors.go.hbs", output: "internal/app/role/errors.go" },
17
+ ];
@@ -0,0 +1,12 @@
1
+ "use strict";
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" },
9
+ { template: "add/worker/internal/platform/mail/mail.go.hbs", output: "internal/platform/mail/mail.go" },
10
+ { template: "add/worker/internal/platform/mail/task.go.hbs", output: "internal/platform/mail/task.go" },
11
+ { template: "add/worker/cmd/worker/main.go.hbs", output: "cmd/worker/main.go" },
12
+ ];
@@ -0,0 +1,96 @@
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.patchConfigForAuth = patchConfigForAuth;
7
+ exports.patchMainGoForAuth = patchMainGoForAuth;
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const marker_patch_1 = require("./marker-patch");
10
+ const IMPORT_MARKER = "// go-scaffold:imports";
11
+ const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
12
+ const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
13
+ const CONFIG_CHECKS_MARKER = "// go-scaffold:config-checks";
14
+ const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
15
+ const MODEL_MARKER = "// go-scaffold:models";
16
+ const ROUTE_MARKER = "// go-scaffold:routes";
17
+ const SHUTDOWN_MARKER = "// go-scaffold:shutdown";
18
+ // patchConfigForAuth adds JWT/cookie/password-reset/Google OAuth fields to
19
+ // Config, the same marker-based text insertion patchConfigForWorker uses
20
+ // (config.go.hbs is only rendered once, at `create` — everything after that
21
+ // is a real file a human may have already edited).
22
+ function patchConfigForAuth(configGoPath) {
23
+ let content = fs_extra_1.default.readFileSync(configGoPath, "utf8");
24
+ const fieldsBlock = [
25
+ "JWTSecret string",
26
+ "JWTAccessTTL time.Duration",
27
+ "JWTRefreshTTL time.Duration",
28
+ "CookieSecure bool",
29
+ "",
30
+ "PasswordResetTTL time.Duration",
31
+ "PasswordResetURL string",
32
+ "",
33
+ "EmailVerifyTTL time.Duration",
34
+ "EmailVerifyURL string",
35
+ "",
36
+ "GoogleClientID string",
37
+ "GoogleClientSecret string",
38
+ "GoogleRedirectURL string",
39
+ ].join("\n");
40
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, fieldsBlock, "JWTSecret string");
41
+ const loadBlock = [
42
+ 'JWTSecret: env("JWT_SECRET", "dev-secret-change-me"),',
43
+ 'JWTAccessTTL: time.Duration(envInt("JWT_ACCESS_TTL_MIN", 15)) * time.Minute,',
44
+ 'JWTRefreshTTL: time.Duration(envInt("JWT_REFRESH_TTL_MIN", 43200)) * time.Minute,',
45
+ 'CookieSecure: env("COOKIE_SECURE", "false") == "true",',
46
+ "",
47
+ 'PasswordResetTTL: time.Duration(envInt("PASSWORD_RESET_TTL_MIN", 30)) * time.Minute,',
48
+ 'PasswordResetURL: env("PASSWORD_RESET_URL", "http://localhost:3000/reset-password"),',
49
+ "",
50
+ 'EmailVerifyTTL: time.Duration(envInt("EMAIL_VERIFY_TTL_MIN", 1440)) * time.Minute,',
51
+ 'EmailVerifyURL: env("EMAIL_VERIFY_URL", "http://localhost:3000/verify-email"),',
52
+ "",
53
+ 'GoogleClientID: env("GOOGLE_CLIENT_ID", ""),',
54
+ 'GoogleClientSecret: env("GOOGLE_CLIENT_SECRET", ""),',
55
+ 'GoogleRedirectURL: env("GOOGLE_REDIRECT_URL", ""),',
56
+ ].join("\n");
57
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, 'JWTSecret: env("JWT_SECRET"');
58
+ fs_extra_1.default.writeFileSync(configGoPath, content);
59
+ }
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) {
68
+ let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
69
+ const importLine = `"${goModule}/internal/app/user"`;
70
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
71
+ const modelImportLine = `usermodel "${goModule}/internal/app/user/model"`;
72
+ 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);
75
+ const mailImportLine = `"${goModule}/internal/platform/mail"`;
76
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, mailImportLine, mailImportLine);
77
+ const checkBlock = [
78
+ '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)",
81
+ "}",
82
+ ].join("\n");
83
+ 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)";
91
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, routeLine, routeLine);
92
+ content = content.replace(/\n\t_ = api \/\/ dropped once `generate module` registers the first route\n/, "\n");
93
+ const shutdownBlock = ["if err := q.Close(); err != nil {", '\tlogger.Error("close queue", "error", err)', "}"].join("\n");
94
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SHUTDOWN_MARKER, shutdownBlock, "if err := q.Close()");
95
+ fs_extra_1.default.writeFileSync(mainGoPath, content);
96
+ }
@@ -0,0 +1,65 @@
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.typeChecks = typeChecks;
7
+ exports.assertNoDrift = assertNoDrift;
8
+ const child_process_1 = require("child_process");
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const version_1 = require("./version");
11
+ // typeChecks runs `go vet ./...` in projectRoot. Returns null when there's no
12
+ // Go toolchain on PATH — the caller can't conclude anything either way then,
13
+ // the same graceful skip as gofmtTree.
14
+ //
15
+ // `go vet` rather than `go build`: build skips _test.go files entirely, and the
16
+ // generated handler_test.go is exactly where a shared/ signature change lands
17
+ // first (it constructs the middleware chain by hand). vet type-checks tests
18
+ // too, so it sees what build would miss.
19
+ function typeChecks(projectRoot) {
20
+ try {
21
+ (0, child_process_1.execFileSync)("go", ["version"], { stdio: "ignore" });
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ try {
27
+ (0, child_process_1.execFileSync)("go", ["vet", "./..."], { cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"] });
28
+ return { ok: true, output: "" };
29
+ }
30
+ catch (err) {
31
+ const e = err;
32
+ return { ok: false, output: `${e.stderr?.toString() ?? ""}${e.stdout?.toString() ?? ""}`.trim() };
33
+ }
34
+ }
35
+ // assertNoDrift is the second half of a before/after pair: given what
36
+ // typeChecks said *before* the files were written, it re-checks and fails
37
+ // loudly if generating is what broke the project.
38
+ //
39
+ // Why compare instead of just checking afterwards: a project can be mid-refactor
40
+ // (or simply not have run `go mod tidy` yet), and blaming the generator for a
41
+ // break it didn't cause is worse than staying quiet. Only the
42
+ // passed-before → broken-after transition is unambiguously ours.
43
+ //
44
+ // The failure this exists for: `generate` renders templates pinned to the
45
+ // shared/ layer that `create` emits, so once a project edits that layer — which
46
+ // is normal, expected work — the generated code stops compiling against it.
47
+ // Without this check that lands as a mystery build error some time later, in
48
+ // files the user never wrote.
49
+ function assertNoDrift(projectRoot, before, config) {
50
+ if (before === null || !before.ok)
51
+ return; // no Go here, or already broken — not ours to judge
52
+ const after = typeChecks(projectRoot);
53
+ if (after === null || after.ok)
54
+ return;
55
+ const scaffoldedWith = config.scaffoldVersion ?? "unknown (predates version stamping)";
56
+ throw new Error(`${picocolors_1.default.red("the generated code doesn't compile, but this project was fine a moment ago.")}\n\n` +
57
+ `The most likely cause is drift: this project's internal/shared layer has been edited\n` +
58
+ `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` +
62
+ `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` +
64
+ `with a CLI version that matches this project.`);
65
+ }
@@ -17,6 +17,11 @@ const UNUSED_API_LINE = "_ = api // dropped once `generate module` registers the
17
17
  // unpatchMainGo removes precisely what patch added.
18
18
  function mainGoLines(patch) {
19
19
  const modelAlias = `${patch.pkg}model`; // every domain's model subpackage is named "model"
20
+ const handlerArgs = [`${patch.pkg}.NewService(${patch.pkg}.NewRepository(db))`];
21
+ if (patch.auth)
22
+ handlerArgs.push("cfg.JWTSecret");
23
+ if (patch.permission)
24
+ handlerArgs.push("authz");
20
25
  return {
21
26
  importLine: `"${patch.goModule}/internal/app/${patch.modulePath}"`,
22
27
  modelImportLine: `${modelAlias} "${patch.goModule}/internal/app/${patch.modulePath}/model"`,
@@ -24,7 +29,9 @@ function mainGoLines(patch) {
24
29
  // `api` is the one route group declared by main.go.hbs, prefixed with
25
30
  // whatever apiPrefix the project chose at create time (e.g. /v1, /api,
26
31
  // or none) — every module registers on it, there is no per-module choice.
27
- routeLine: `${patch.pkg}.NewHandler(${patch.pkg}.NewService(${patch.pkg}.NewRepository(db))).Register(api)`,
32
+ // `authz` only exists in main.go once `add rbac` has run — patch.permission
33
+ // is only ever set once that's already been verified by the caller.
34
+ routeLine: `${patch.pkg}.NewHandler(${handlerArgs.join(", ")}).Register(api)`,
28
35
  };
29
36
  }
30
37
  // patchMainGo wires a newly generated module into cmd/api/main.go: its
@@ -3,15 +3,37 @@ 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.nextMigrationSeq = nextMigrationSeq;
6
+ exports.newMigrationVersion = newMigrationVersion;
7
7
  const fs_extra_1 = __importDefault(require("fs-extra"));
8
- // golang-migrate sequential numbering: 000001, 000002, ...
9
- function nextMigrationSeq(migrationsDir) {
10
- const files = fs_extra_1.default.existsSync(migrationsDir) ? fs_extra_1.default.readdirSync(migrationsDir) : [];
11
- const nums = files
8
+ // golang-migrate timestamp numbering the same convention as the CLI's own
9
+ // `migrate create -ext sql -dir migrations -seq=false <name>`: a 14-digit
10
+ // UTC timestamp (YYYYMMDDHHMMSS). Two people branching from the same base and
11
+ // each adding a migration get different filenames instead of both claiming
12
+ // the next sequential number and colliding on merge. golang-migrate orders
13
+ // by the numeric prefix either way, and a 14-digit timestamp always sorts
14
+ // after any existing 6-digit sequential number, so a project with old-style
15
+ // numbers already in migrations/ is safe to keep generating into.
16
+ function newMigrationVersion(migrationsDir) {
17
+ const existing = new Set((fs_extra_1.default.existsSync(migrationsDir) ? fs_extra_1.default.readdirSync(migrationsDir) : [])
12
18
  .map((f) => f.match(/^(\d+)_/))
13
19
  .filter((m) => m !== null)
14
- .map((m) => parseInt(m[1], 10));
15
- const next = (nums.length ? Math.max(...nums) : 0) + 1;
16
- return String(next).padStart(6, "0");
20
+ .map((m) => m[1]));
21
+ let d = new Date();
22
+ let version = formatVersion(d);
23
+ // Vanishingly unlikely in normal (human-driven) use, but guard the
24
+ // same-second edge case rather than silently overwrite a sibling file.
25
+ while (existing.has(version)) {
26
+ d = new Date(d.getTime() + 1000);
27
+ version = formatVersion(d);
28
+ }
29
+ return version;
30
+ }
31
+ function formatVersion(d) {
32
+ const pad = (n) => String(n).padStart(2, "0");
33
+ return (String(d.getUTCFullYear()) +
34
+ pad(d.getUTCMonth() + 1) +
35
+ pad(d.getUTCDate()) +
36
+ pad(d.getUTCHours()) +
37
+ pad(d.getUTCMinutes()) +
38
+ pad(d.getUTCSeconds()));
17
39
  }
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.patchOpenapiIndex = patchOpenapiIndex;
7
7
  exports.unpatchOpenapiIndex = unpatchOpenapiIndex;
8
+ exports.patchOpenapiIndexRaw = patchOpenapiIndexRaw;
8
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
10
  const marker_patch_1 = require("./marker-patch");
10
11
  const PATHS_MARKER = "# go-scaffold:paths";
@@ -45,3 +46,18 @@ function unpatchOpenapiIndex(openapiPath, naming, apiPrefix) {
45
46
  const { paths, schemas } = openapiLines(naming, apiPrefix);
46
47
  fs_extra_1.default.writeFileSync(openapiPath, (0, marker_patch_1.removeLines)(content, [...paths, ...schemas]));
47
48
  }
49
+ // patchOpenapiIndexRaw wires hand-written path docs into the index — used by
50
+ // `add auth`/`add rbac`, whose endpoints aren't a single CRUD resource so
51
+ // there's no ModuleNaming to derive lines from. Each entry is patched with
52
+ // its own sentinel (the path key) rather than one block for all of them, so
53
+ // re-running `add rbac` after a partial failure doesn't skip entries that
54
+ // never made it in.
55
+ function patchOpenapiIndexRaw(openapiPath, apiPrefix, entries) {
56
+ let content = fs_extra_1.default.readFileSync(openapiPath, "utf8");
57
+ for (const { urlPath, file } of entries) {
58
+ const key = `${apiPrefix ? `/${apiPrefix}` : ""}${urlPath}:`;
59
+ const block = `${key}\n $ref: '${file}'`;
60
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PATHS_MARKER, block, key);
61
+ }
62
+ fs_extra_1.default.writeFileSync(openapiPath, content);
63
+ }
@@ -0,0 +1,59 @@
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.patchConfigForWorker = patchConfigForWorker;
7
+ exports.patchMainGoForWorker = patchMainGoForWorker;
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const marker_patch_1 = require("./marker-patch");
10
+ const IMPORT_MARKER = "// go-scaffold:imports";
11
+ const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
12
+ const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
13
+ const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
14
+ const READYZ_MARKER = "// go-scaffold:readyz-checks";
15
+ const SHUTDOWN_MARKER = "// go-scaffold:shutdown";
16
+ // patchConfigForWorker adds RedisURL + SMTP_* fields to Config and their
17
+ // env() loads to Load() — via marker comments, the same text-insertion
18
+ // approach as patchMainGo, since config.go.hbs is only rendered once (at
19
+ // `create`) and everything after that is a real file a human may have
20
+ // already edited.
21
+ function patchConfigForWorker(configGoPath) {
22
+ let content = fs_extra_1.default.readFileSync(configGoPath, "utf8");
23
+ const fieldsBlock = ["RedisURL string", "", "SMTPHost string", "SMTPPort string", "SMTPUsername string", "SMTPPassword string", "SMTPFrom string"].join("\n");
24
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, fieldsBlock, "RedisURL string");
25
+ const loadBlock = [
26
+ 'RedisURL: env("REDIS_URL", "redis://localhost:6379/0"),',
27
+ "",
28
+ 'SMTPHost: env("SMTP_HOST", ""),',
29
+ 'SMTPPort: env("SMTP_PORT", "587"),',
30
+ 'SMTPUsername: env("SMTP_USERNAME", ""),',
31
+ 'SMTPPassword: env("SMTP_PASSWORD", ""),',
32
+ 'SMTPFrom: env("SMTP_FROM", "no-reply@example.local"),',
33
+ ].join("\n");
34
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, 'RedisURL: env("REDIS_URL"');
35
+ fs_extra_1.default.writeFileSync(configGoPath, content);
36
+ }
37
+ // patchMainGoForWorker wires Redis into cmd/api: opened alongside the DB, and
38
+ // pinged as part of /readyz (so a Redis outage is caught the same way a DB
39
+ // outage already is). It does not create a queue.Client — nothing in cmd/api
40
+ // enqueues a task until some domain actually needs to (e.g. a future `add
41
+ // auth`'s forgot-password flow); an unused *queue.Client sitting in main()
42
+ // would just be dead weight until then.
43
+ function patchMainGoForWorker(mainGoPath, goModule) {
44
+ let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
45
+ const cacheImport = `"${goModule}/internal/platform/cache"`;
46
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, cacheImport, cacheImport);
47
+ const initBlock = ["rdb, err := cache.Open(cfg)", "if err != nil {", '\tlogger.Error("open redis", "error", err)', "\tos.Exit(1)", "}"].join("\n");
48
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, initBlock, "rdb, err := cache.Open(cfg)");
49
+ const readyzBlock = [
50
+ "if err := rdb.Ping(c.Request.Context()).Err(); err != nil {",
51
+ '\tc.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})',
52
+ "\treturn",
53
+ "}",
54
+ ].join("\n");
55
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, READYZ_MARKER, readyzBlock, "if err := rdb.Ping(");
56
+ const shutdownBlock = ["if err := rdb.Close(); err != nil {", '\tlogger.Error("close redis", "error", err)', "}"].join("\n");
57
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SHUTDOWN_MARKER, shutdownBlock, "if err := rdb.Close()");
58
+ fs_extra_1.default.writeFileSync(mainGoPath, content);
59
+ }