@nakedev/go-scaffold 0.1.2 → 0.1.4

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 (131) hide show
  1. package/README.md +93 -14
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +61 -2
  5. package/dist/commands/method.js +66 -15
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +36 -20
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +71 -7
  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/module-manifest.js +2 -0
  16. package/dist/templates/rbac-manifest.js +17 -0
  17. package/dist/templates/worker-manifest.js +12 -0
  18. package/dist/utils/auth-patcher.js +96 -0
  19. package/dist/utils/gocheck.js +65 -0
  20. package/dist/utils/main-patcher.js +8 -1
  21. package/dist/utils/method-patcher.js +80 -16
  22. package/dist/utils/migrations.js +30 -8
  23. package/dist/utils/module-location.js +22 -0
  24. package/dist/utils/naming.js +75 -12
  25. package/dist/utils/openapi-patcher.js +35 -1
  26. package/dist/utils/platform-patcher.js +59 -0
  27. package/dist/utils/rbac-patcher.js +277 -0
  28. package/dist/utils/smoke-run.js +31 -0
  29. package/dist/utils/version.js +24 -0
  30. package/package.json +15 -6
  31. package/scripts/smoke-test.mjs +2058 -0
  32. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  33. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  35. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  36. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  38. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  39. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  40. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  41. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  42. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  43. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  44. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  45. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  46. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  47. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  48. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  49. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  50. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  51. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  52. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  53. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  54. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  55. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  56. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  57. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  58. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  59. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  60. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  61. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  62. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  63. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  64. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  65. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  66. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  67. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  68. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  69. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  70. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  71. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  72. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  73. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  74. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  75. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  76. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  77. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  78. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  79. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  80. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  81. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  82. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  83. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  84. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  85. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  86. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  87. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  88. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  89. package/templates/create/base/.env.example.hbs +20 -0
  90. package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
  91. package/templates/create/base/.gitignore.hbs +2 -0
  92. package/templates/create/base/AGENTS.md.hbs +10 -12
  93. package/templates/create/base/Makefile.hbs +39 -8
  94. package/templates/create/base/README.md.hbs +52 -12
  95. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  96. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  97. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  98. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  99. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  100. package/templates/create/base/migrations/embed.go.hbs +15 -0
  101. package/templates/create/features/docs/architecture.md.hbs +22 -0
  102. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  103. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  104. package/templates/create/features/docs/openapi.yaml.hbs +17 -5
  105. package/templates/create/features/docs/patterns.md.hbs +9 -6
  106. package/templates/create/features/docs/techstack.md.hbs +3 -0
  107. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  108. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  109. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  110. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  111. package/templates/generate/module/handler.go.hbs +37 -6
  112. package/templates/generate/module/handler_test.go.hbs +113 -51
  113. package/templates/generate/module/migration.down.sql.hbs +1 -1
  114. package/templates/generate/module/migration.up.sql.hbs +1 -1
  115. package/templates/generate/module/minimal/handler.go.hbs +28 -4
  116. package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
  117. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  118. package/templates/generate/module/model/model.go.hbs +7 -0
  119. package/templates/generate/module/permission.down.sql.hbs +5 -0
  120. package/templates/generate/module/permission.up.sql.hbs +4 -0
  121. package/templates/generate/module/repository_test.go.hbs +79 -0
  122. package/templates/generate/module/service.go.hbs +6 -4
  123. package/templates/generate/module/service_test.go.hbs +68 -17
  124. package/tests/integration/default-module.test.mjs +46 -0
  125. package/tests/integration/generator-naming.test.mjs +81 -0
  126. package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
  127. package/tests/integration/legacy-method-compat.test.mjs +222 -0
  128. package/tests/integration/remove-module.test.mjs +58 -0
  129. package/tests/unit/naming.test.mjs +94 -0
  130. package/tests/unit/smoke-isolation.test.mjs +35 -0
  131. package/dist/utils/module-paths.js +0 -33
@@ -0,0 +1,103 @@
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.addRbac = addRbac;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const config_1 = require("../utils/config");
11
+ const template_renderer_1 = require("../utils/template-renderer");
12
+ const rbac_manifest_1 = require("../templates/rbac-manifest");
13
+ const migrations_1 = require("../utils/migrations");
14
+ const rbac_patcher_1 = require("../utils/rbac-patcher");
15
+ const openapi_patcher_1 = require("../utils/openapi-patcher");
16
+ // URL (relative to the api prefix) -> docs file (relative to docs/) for every
17
+ // route `add rbac` registers or adds onto the user handler.
18
+ const RBAC_OPENAPI_PATHS = [
19
+ { urlPath: "/roles", file: "./rbac/roles.yaml" },
20
+ { urlPath: "/roles/{code}/permissions", file: "./rbac/role-permissions.yaml" },
21
+ { urlPath: "/roles/{code}", file: "./rbac/role.yaml" },
22
+ { urlPath: "/permissions", file: "./rbac/permissions.yaml" },
23
+ { urlPath: "/users", file: "./rbac/users.yaml" },
24
+ { urlPath: "/users/{id}", file: "./rbac/user.yaml" },
25
+ { urlPath: "/users/{id}/set-role", file: "./rbac/user-set-role.yaml" },
26
+ ];
27
+ // addRbac layers role-based access control on top of `add auth`: a role
28
+ // domain (roles/permissions/role_permissions, admin-manageable), an Authz
29
+ // middleware with a cached role->permission lookup, a Role claim threaded
30
+ // through the JWT, and the admin endpoints that actually need it: listing/
31
+ // viewing other users (GET /users, GET /users/:id) and changing a user's
32
+ // role (PATCH /users/:id/set-role). Opt-in on top of an opt-in — most
33
+ // projects need "is this caller logged in" long before they need "can this
34
+ // caller do X".
35
+ async function addRbac(projectDir = process.cwd()) {
36
+ const config = (0, config_1.readConfig)(projectDir);
37
+ if (!config.features.auth) {
38
+ throw new Error("`go-scaffold add rbac` requires `go-scaffold add auth` first — there's no Role claim to check permissions against otherwise");
39
+ }
40
+ const roleDir = path_1.default.join(projectDir, "internal", "app", "role");
41
+ if (fs_extra_1.default.existsSync(roleDir)) {
42
+ throw new Error(`${roleDir} already exists — RBAC looks like it's already been added`);
43
+ }
44
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, rbac_manifest_1.RBAC_FILES, { goModule: config.goModule });
45
+ const migrationsDir = path_1.default.join(projectDir, "migrations");
46
+ fs_extra_1.default.ensureDirSync(migrationsDir);
47
+ const version = (0, migrations_1.newMigrationVersion)(migrationsDir);
48
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
49
+ { template: "add/rbac/migrations/add_roles.up.sql.hbs", output: path_1.default.join("migrations", `${version}_add_roles.up.sql`) },
50
+ { template: "add/rbac/migrations/add_roles.down.sql.hbs", output: path_1.default.join("migrations", `${version}_add_roles.down.sql`) },
51
+ ], {});
52
+ (0, rbac_patcher_1.patchConfigForRbac)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
53
+ (0, rbac_patcher_1.patchUserModelForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "model", "user.go"));
54
+ (0, rbac_patcher_1.patchMiddlewareAuthForRbac)(path_1.default.join(projectDir, "internal", "shared", "middleware", "auth.go"));
55
+ (0, rbac_patcher_1.patchUserJWTForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "jwt.go"));
56
+ (0, rbac_patcher_1.patchUserServiceForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "service.go"));
57
+ (0, rbac_patcher_1.patchUserServiceTestForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "service_test.go"));
58
+ (0, rbac_patcher_1.patchUserDTOForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "dto.go"));
59
+ (0, rbac_patcher_1.patchUserHandlerForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "handler.go"), config.goModule);
60
+ (0, rbac_patcher_1.patchUserErrorsForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "errors.go"));
61
+ (0, rbac_patcher_1.patchMainGoForRbac)(path_1.default.join(projectDir, "cmd", "api", "main.go"), config.goModule);
62
+ (0, rbac_patcher_1.patchCmdSeedForRbac)(path_1.default.join(projectDir, "cmd", "seed", "main.go"), config.goModule);
63
+ patchEnvExample(path_1.default.join(projectDir, ".env.example"));
64
+ let docsMessage = "";
65
+ const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
66
+ if (config.features.openapiDocs && fs_extra_1.default.existsSync(openapiPath)) {
67
+ const docsEntries = [
68
+ { template: "add/rbac/docs/schemas.yaml.hbs", output: path_1.default.join("docs", "rbac", "schemas.yaml") },
69
+ ...RBAC_OPENAPI_PATHS.map(({ file }) => ({
70
+ template: `add/rbac/docs/${path_1.default.basename(file)}.hbs`,
71
+ output: path_1.default.join("docs", "rbac", path_1.default.basename(file)),
72
+ })),
73
+ ];
74
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, docsEntries, {});
75
+ (0, openapi_patcher_1.patchOpenapiIndexRaw)(openapiPath, config.apiPrefix, RBAC_OPENAPI_PATHS);
76
+ (0, rbac_patcher_1.patchAuthDocsForRbac)(path_1.default.join(projectDir, "docs", "auth", "schemas.yaml"));
77
+ docsMessage = "\ndocs: docs/rbac/*.yaml, wired into docs/openapi.yaml";
78
+ }
79
+ (0, template_renderer_1.gofmtTree)(projectDir);
80
+ (0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, rbac: true } });
81
+ console.log(picocolors_1.default.green("\nadded internal/app/role/ and internal/shared/middleware/authz.go"));
82
+ console.log("registered GET /users, GET /users/:id, PATCH /users/:id/set-role, /roles, and /permissions in cmd/api/main.go" + docsMessage);
83
+ console.log(picocolors_1.default.yellow("\n⚠ AUTO_MIGRATE=true does NOT seed the role/permission data — it only creates the\n" +
84
+ " tables from the Go structs. The \"staff\"/\"admin\" roles and their permissions live\n" +
85
+ " in the migration's SQL (INSERT statements), which AutoMigrate never runs. Without\n" +
86
+ " applying it for real, `make seed` fails with \"unknown role code\" and nobody can be\n" +
87
+ " granted anything. Apply it before relying on RBAC, even in dev:\n" +
88
+ " migrate -path migrations -database \"$DB_DSN\" up (or: make migrate-up)"));
89
+ console.log(picocolors_1.default.dim("\nnext: go mod tidy, then SEED_ADMIN_EMAIL=... SEED_ADMIN_PASSWORD=... make seed to get an admin"));
90
+ }
91
+ function patchEnvExample(envExamplePath) {
92
+ if (!fs_extra_1.default.existsSync(envExamplePath))
93
+ return;
94
+ let content = fs_extra_1.default.readFileSync(envExamplePath, "utf8");
95
+ if (content.includes("AUTHZ_CACHE_TTL_MIN"))
96
+ return; // already added
97
+ content =
98
+ content.replace(/\n?$/, "\n") +
99
+ "\n# how long a role's permission set is cached before Authz.Require re-checks the DB —\n" +
100
+ "# a permission change takes effect for already-issued tokens within this window\n" +
101
+ "AUTHZ_CACHE_TTL_MIN=1\n";
102
+ fs_extra_1.default.writeFileSync(envExamplePath, content);
103
+ }
@@ -9,18 +9,17 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
9
9
  const picocolors_1 = __importDefault(require("picocolors"));
10
10
  const prompts_1 = require("@inquirer/prompts");
11
11
  const config_1 = require("../utils/config");
12
- const naming_1 = require("../utils/naming");
12
+ const module_location_1 = require("../utils/module-location");
13
13
  const main_patcher_1 = require("../utils/main-patcher");
14
14
  const openapi_patcher_1 = require("../utils/openapi-patcher");
15
15
  const template_renderer_1 = require("../utils/template-renderer");
16
16
  const generate_wizard_1 = require("../prompts/generate-wizard");
17
- // removeModule is the inverse of generateModule: deletes the domain package and
18
- // pulls its wiring back out of main.go / openapi.yaml / migrations, so dropping
19
- // a domain is one command instead of hand-editing 3+ files (the error-prone
20
- // path that produced the duplicate-registration bug in the first place).
17
+ // removeModule deletes application code and reverses generated wiring while
18
+ // preserving immutable migration history and table data. Destructive schema
19
+ // removal must be an explicit new migration, never a deletion of an applied one.
21
20
  async function removeModule(rawName, opts, projectDir = process.cwd()) {
22
21
  const config = (0, config_1.readConfig)(projectDir);
23
- const naming = (0, naming_1.resolveModuleNaming)(rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
22
+ const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
24
23
  const modulePath = naming.pkg;
25
24
  const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
26
25
  if (!fs_extra_1.default.existsSync(moduleDir)) {
@@ -28,12 +27,24 @@ async function removeModule(rawName, opts, projectDir = process.cwd()) {
28
27
  }
29
28
  if (!opts.yes) {
30
29
  const ok = await (0, prompts_1.confirm)({
31
- message: `Remove module "${naming.pkg}"? Deletes internal/app/${modulePath}/, its migration, and un-wires main.go/openapi.yaml`,
30
+ message: `Remove module "${naming.pkg}"? Deletes internal/app/${modulePath}/ and its docs, ` +
31
+ `then un-wires main.go/openapi.yaml. Existing migrations, table, and data are preserved.`,
32
32
  default: false,
33
33
  });
34
34
  if (!ok)
35
35
  throw new Error("removal cancelled");
36
36
  }
37
+ // detect --auth/--permission from the generated handler.go itself (not
38
+ // stored anywhere else) — unpatchMainGo needs the exact same flags used at
39
+ // generate-time to reconstruct the identical line it's removing.
40
+ const handlerGoPath = path_1.default.join(moduleDir, "handler.go");
41
+ let auth;
42
+ let permission;
43
+ if (fs_extra_1.default.existsSync(handlerGoPath)) {
44
+ const handlerContent = fs_extra_1.default.readFileSync(handlerGoPath, "utf8");
45
+ auth = /jwtSecret\s+string/.test(handlerContent);
46
+ permission = handlerContent.match(/h\.authz\.Require\("([^"]+)"\)/)?.[1];
47
+ }
37
48
  // 1. the domain package
38
49
  fs_extra_1.default.removeSync(moduleDir);
39
50
  // 2. main.go wiring
@@ -42,6 +53,8 @@ async function removeModule(rawName, opts, projectDir = process.cwd()) {
42
53
  modulePath,
43
54
  pkg: naming.pkg,
44
55
  pascalName: naming.pascalName,
56
+ auth,
57
+ permission,
45
58
  });
46
59
  // 3. openapi index + per-module docs
47
60
  const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
@@ -49,24 +62,27 @@ async function removeModule(rawName, opts, projectDir = process.cwd()) {
49
62
  (0, openapi_patcher_1.unpatchOpenapiIndex)(openapiPath, naming, config.apiPrefix);
50
63
  fs_extra_1.default.removeSync(path_1.default.join(projectDir, "docs", naming.plural));
51
64
  }
52
- // 4. migration files (up + down)
65
+ // 4. Migration history is immutable. A migration may already be recorded in
66
+ // schema_migrations on production databases, so deleting its file would make
67
+ // existing and fresh environments disagree and can prevent the app booting.
68
+ // Re-generating the same module reuses this create migration.
53
69
  const migrationsDir = path_1.default.join(projectDir, "migrations");
54
- const removedMigrations = [];
55
- if (fs_extra_1.default.existsSync(migrationsDir)) {
56
- for (const f of fs_extra_1.default.readdirSync(migrationsDir)) {
57
- if (f.endsWith(`_create_${naming.plural}.up.sql`) || f.endsWith(`_create_${naming.plural}.down.sql`)) {
58
- fs_extra_1.default.removeSync(path_1.default.join(migrationsDir, f));
59
- removedMigrations.push(f);
60
- }
61
- }
62
- }
70
+ const preservedMigrations = fs_extra_1.default.existsSync(migrationsDir)
71
+ ? fs_extra_1.default.readdirSync(migrationsDir).filter((f) => f.endsWith(`_create_${naming.plural}.up.sql`) ||
72
+ f.endsWith(`_create_${naming.plural}.down.sql`) ||
73
+ f.endsWith(`_add_${naming.plural}_permission.up.sql`) ||
74
+ f.endsWith(`_add_${naming.plural}_permission.down.sql`))
75
+ : [];
63
76
  (0, template_renderer_1.gofmtTree)(projectDir);
64
77
  console.log(picocolors_1.default.green(`\nremoved module "${naming.pkg}"`));
65
78
  console.log(` deleted internal/app/${modulePath}/`);
66
79
  console.log(` un-wired cmd/api/main.go`);
67
80
  if (fs_extra_1.default.existsSync(openapiPath))
68
81
  console.log(` un-wired docs/openapi.yaml + deleted docs/${naming.plural}/`);
69
- if (removedMigrations.length)
70
- console.log(` deleted ${removedMigrations.join(", ")}`);
71
- console.log(picocolors_1.default.yellow(`\nnote: the ${naming.plural} table (if migrated) is untouched — drop it yourself, or add a down migration`));
82
+ if (preservedMigrations.length) {
83
+ console.log(` preserved migration history: ${preservedMigrations.join(", ")}`);
84
+ }
85
+ console.log(picocolors_1.default.yellow(`\nnote: the ${naming.tableName} table and its data are untouched. ` +
86
+ `To remove them safely, run \`go-scaffold generate migration drop_${naming.tableName}\` ` +
87
+ `and write an explicit up/down migration.`));
72
88
  }
@@ -0,0 +1,75 @@
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.addWorker = addWorker;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const config_1 = require("../utils/config");
11
+ const template_renderer_1 = require("../utils/template-renderer");
12
+ const worker_manifest_1 = require("../templates/worker-manifest");
13
+ const platform_patcher_1 = require("../utils/platform-patcher");
14
+ // addWorker scaffolds the async task processing subsystem: Redis
15
+ // (platform/cache), Asynq client/server (platform/queue), SMTP mail
16
+ // (platform/mail, with an email:send task type as the one thing the fresh
17
+ // worker actually handles), and cmd/worker itself. Opt-in — most projects
18
+ // don't need a queue on day one, and an empty worker with no task types
19
+ // registered is a stranger scaffold than just not having one.
20
+ async function addWorker(projectDir = process.cwd()) {
21
+ const config = (0, config_1.readConfig)(projectDir);
22
+ const queueDir = path_1.default.join(projectDir, "internal", "platform", "queue");
23
+ if (fs_extra_1.default.existsSync(queueDir)) {
24
+ throw new Error(`${queueDir} already exists — worker infrastructure looks like it's already been added`);
25
+ }
26
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, worker_manifest_1.WORKER_FILES, { goModule: config.goModule });
27
+ (0, platform_patcher_1.patchConfigForWorker)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
28
+ (0, platform_patcher_1.patchMainGoForWorker)(path_1.default.join(projectDir, "cmd", "api", "main.go"), config.goModule);
29
+ patchEnvExample(path_1.default.join(projectDir, ".env.example"));
30
+ patchMakefile(path_1.default.join(projectDir, "Makefile"));
31
+ (0, template_renderer_1.gofmtTree)(projectDir);
32
+ (0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, worker: true } });
33
+ console.log(picocolors_1.default.green("\nadded internal/platform/{cache,queue,mail}/ and cmd/worker/"));
34
+ console.log("wired Redis into cmd/api (readyz check) — cmd/api does not enqueue anything yet");
35
+ console.log(picocolors_1.default.dim("\nnext: make worker (separate terminal, or `make dev` runs both), then go build ./... to confirm"));
36
+ }
37
+ function patchEnvExample(envExamplePath) {
38
+ if (!fs_extra_1.default.existsSync(envExamplePath))
39
+ return;
40
+ let content = fs_extra_1.default.readFileSync(envExamplePath, "utf8");
41
+ if (content.includes("REDIS_URL"))
42
+ return; // already added
43
+ content =
44
+ content.replace(/\n?$/, "\n") +
45
+ "\nREDIS_URL=redis://localhost:6379/0\n" +
46
+ "\n# leave SMTP_HOST unset to log emails instead of sending them (dev default)\n" +
47
+ "SMTP_HOST=\n" +
48
+ "SMTP_PORT=587\n" +
49
+ "SMTP_USERNAME=\n" +
50
+ "SMTP_PASSWORD=\n" +
51
+ "SMTP_FROM=no-reply@example.local\n";
52
+ fs_extra_1.default.writeFileSync(envExamplePath, content);
53
+ }
54
+ function patchMakefile(makefilePath) {
55
+ if (!fs_extra_1.default.existsSync(makefilePath))
56
+ return;
57
+ let content = fs_extra_1.default.readFileSync(makefilePath, "utf8");
58
+ if (content.includes("\nworker:\n"))
59
+ return; // already added
60
+ content = content.replace(/^\.PHONY: /m, ".PHONY: dev worker ");
61
+ const targets = "\n# run both API + worker in one terminal — Ctrl+C kills both\n" +
62
+ "dev:\n" +
63
+ "\t@[ -f .env ] && export $$(grep -v '^#' .env | xargs); \\\n" +
64
+ "\t(trap 'kill 0' SIGINT SIGTERM; \\\n" +
65
+ "\t go run ./cmd/api & \\\n" +
66
+ "\t go run ./cmd/worker & \\\n" +
67
+ "\t wait)\n" +
68
+ "\n" +
69
+ "# background worker for async task processing (email, ...) — requires Redis.\n" +
70
+ "# Use `make dev` to run both in one terminal, or run this in a separate one.\n" +
71
+ "worker:\n" +
72
+ "\t@[ -f .env ] && export $$(grep -v '^#' .env | xargs); go run ./cmd/worker\n";
73
+ content = content.replace(/\nbuild:/, `${targets}\nbuild:`);
74
+ fs_extra_1.default.writeFileSync(makefilePath, content);
75
+ }
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
  }
@@ -49,16 +56,20 @@ const generate = program
49
56
  const target = await (0, prompts_1.select)({
50
57
  message: "What do you want to generate?",
51
58
  choices: [
52
- { name: "Module (full CRUD domain)", value: "module" },
59
+ { name: "Module (safe minimal domain; add methods explicitly)", 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
- await (0, generate_1.generateModule)(undefined, { full: true });
65
+ await (0, generate_1.generateModule)(undefined, { full: false });
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));
@@ -68,11 +79,14 @@ const generate = program
68
79
  generate
69
80
  .command("module [name]")
70
81
  .alias("m")
71
- .description("scaffold a domain module full CRUD by default, or a bare skeleton with --no-full")
72
- .option("--no-full", "minimal skeleton (model/errors/repository, no default CRUD) add endpoints one at a time with `generate method`")
82
+ .description("scaffold a safe minimal domain module; opt into a CRUD skeleton with --full")
83
+ .option("--full", "generate a CRUD skeleton (DTO fields/business rules remain TODO); minimal is the safe default")
84
+ .option("--no-full", "deprecated compatibility alias; minimal is already the default")
85
+ .option("--auth", "require a valid access token for this module's routes (needs `add auth`)")
86
+ .option("--permission <code>", "also require this permission via authz.Require (needs `add rbac`; implies --auth)")
73
87
  .action(async (name, opts) => {
74
88
  try {
75
- await (0, generate_1.generateModule)(name, { full: opts.full });
89
+ await (0, generate_1.generateModule)(name, { full: opts.full, auth: opts.auth, permission: opts.permission });
76
90
  }
77
91
  catch (err) {
78
92
  console.error(picocolors_1.default.red(err.message));
@@ -107,6 +121,56 @@ generate
107
121
  process.exitCode = 1;
108
122
  }
109
123
  });
124
+ generate
125
+ .command("migration [name]")
126
+ .alias("mig")
127
+ .description("reserve a timestamped migrations/<version>_<name>.{up,down}.sql pair (stubs only — you write the SQL)")
128
+ .action(async (name) => {
129
+ try {
130
+ await (0, migration_1.generateMigration)(name);
131
+ }
132
+ catch (err) {
133
+ console.error(picocolors_1.default.red(err.message));
134
+ process.exitCode = 1;
135
+ }
136
+ });
137
+ const add = program.command("add").description("add opt-in infrastructure to an existing go-scaffold project");
138
+ add
139
+ .command("worker")
140
+ .description("add Redis, an Asynq task queue, SMTP mail, and cmd/worker (opt-in — most projects don't need this on day one)")
141
+ .action(async () => {
142
+ try {
143
+ await (0, worker_1.addWorker)();
144
+ }
145
+ catch (err) {
146
+ console.error(picocolors_1.default.red(err.message));
147
+ process.exitCode = 1;
148
+ }
149
+ });
150
+ add
151
+ .command("auth")
152
+ .description("add email/password auth: JWT access tokens, Redis-backed refresh token rotation, register/login/refresh/logout/me (requires `add worker` first)")
153
+ .action(async () => {
154
+ try {
155
+ await (0, auth_1.addAuth)();
156
+ }
157
+ catch (err) {
158
+ console.error(picocolors_1.default.red(err.message));
159
+ process.exitCode = 1;
160
+ }
161
+ });
162
+ add
163
+ .command("rbac")
164
+ .description("add role-based access control: roles/permissions admin API, cached Authz middleware, PATCH /users/:id/set-role (requires `add auth` first)")
165
+ .action(async () => {
166
+ try {
167
+ await (0, rbac_1.addRbac)();
168
+ }
169
+ catch (err) {
170
+ console.error(picocolors_1.default.red(err.message));
171
+ process.exitCode = 1;
172
+ }
173
+ });
110
174
  const remove = program
111
175
  .command("remove")
112
176
  .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
  ];
@@ -12,6 +12,7 @@ exports.MODULE_FILES = [
12
12
  { template: "generate/module/handler.go.hbs", output: "handler.go" },
13
13
  { template: "generate/module/service_test.go.hbs", output: "service_test.go" },
14
14
  { template: "generate/module/handler_test.go.hbs", output: "handler_test.go" },
15
+ { template: "generate/module/repository_test.go.hbs", output: "repository_test.go" },
15
16
  ];
16
17
  // minimal: same model/errors/repository (generate method's patches assume the
17
18
  // full data-access surface exists), but no default CRUD in dto/service/handler
@@ -25,4 +26,5 @@ exports.MODULE_FILES_MINIMAL = [
25
26
  { template: "generate/module/minimal/handler.go.hbs", output: "handler.go" },
26
27
  { template: "generate/module/minimal/service_test.go.hbs", output: "service_test.go" },
27
28
  { template: "generate/module/minimal/handler_test.go.hbs", output: "handler_test.go" },
29
+ { template: "generate/module/repository_test.go.hbs", output: "repository_test.go" },
28
30
  ];
@@ -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
+ ];