@nakedev/go-scaffold 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -50
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +23 -12
  6. package/dist/commands/method.js +94 -17
  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 +368 -64
  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/module-manifest.js +2 -0
  19. package/dist/templates/observability-manifest.js +24 -0
  20. package/dist/templates/rbac-manifest.js +1 -0
  21. package/dist/templates/worker-manifest.js +23 -6
  22. package/dist/utils/auth-patcher.js +96 -21
  23. package/dist/utils/config.js +58 -10
  24. package/dist/utils/gocheck.js +57 -5
  25. package/dist/utils/golangci-patcher.js +73 -0
  26. package/dist/utils/gomod-patcher.js +53 -0
  27. package/dist/utils/main-patcher.js +58 -4
  28. package/dist/utils/marker-patch.js +125 -3
  29. package/dist/utils/method-patcher.js +97 -18
  30. package/dist/utils/module-location.js +58 -0
  31. package/dist/utils/naming.js +125 -14
  32. package/dist/utils/observability-patcher.js +107 -0
  33. package/dist/utils/openapi-patcher.js +19 -1
  34. package/dist/utils/platform-patcher.js +98 -12
  35. package/dist/utils/rbac-patcher.js +60 -10
  36. package/dist/utils/smoke-run.js +31 -0
  37. package/package.json +11 -4
  38. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  39. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  40. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  41. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  42. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  43. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  44. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  45. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  46. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  50. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  51. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  52. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  53. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  54. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  55. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  56. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  57. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  58. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  59. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  60. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  61. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  62. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  63. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  64. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  65. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  66. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  67. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  68. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  69. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  70. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  71. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  72. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  73. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  74. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  75. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  76. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  77. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  78. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  79. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  80. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  81. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  82. package/templates/create/base/.dockerignore.hbs +13 -0
  83. package/templates/create/base/.env.example.hbs +18 -9
  84. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  85. package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
  86. package/templates/create/base/.golangci.yml.hbs +27 -0
  87. package/templates/create/base/AGENTS.md.hbs +14 -12
  88. package/templates/create/base/Dockerfile.hbs +42 -0
  89. package/templates/create/base/Makefile.hbs +52 -16
  90. package/templates/create/base/README.md.hbs +61 -12
  91. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  92. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  93. package/templates/create/base/go.mod.hbs +4 -4
  94. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  95. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  96. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  97. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  98. package/templates/create/base/redocly.yaml.hbs +21 -0
  99. package/templates/create/features/docs/architecture.md.hbs +32 -11
  100. package/templates/create/features/docs/openapi.yaml.hbs +4 -9
  101. package/templates/create/features/docs/patterns.md.hbs +91 -14
  102. package/templates/create/features/docs/techstack.md.hbs +8 -3
  103. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  104. package/templates/generate/module/dto.go.hbs +8 -1
  105. package/templates/generate/module/errors.go.hbs +5 -0
  106. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  107. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  108. package/templates/generate/module/handler.go.hbs +18 -4
  109. package/templates/generate/module/handler_test.go.hbs +88 -62
  110. package/templates/generate/module/migration.down.sql.hbs +3 -1
  111. package/templates/generate/module/migration.up.sql.hbs +7 -2
  112. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  113. package/templates/generate/module/minimal/handler.go.hbs +8 -2
  114. package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
  115. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  116. package/templates/generate/module/model/model.go.hbs +16 -0
  117. package/templates/generate/module/permission.up.sql.hbs +3 -1
  118. package/templates/generate/module/repository.go.hbs +60 -6
  119. package/templates/generate/module/repository_test.go.hbs +109 -0
  120. package/templates/generate/module/service.go.hbs +15 -4
  121. package/templates/generate/module/service_test.go.hbs +113 -17
  122. package/dist/commands/remove.js +0 -89
  123. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  124. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
@@ -0,0 +1,331 @@
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.undoModule = undoModule;
7
+ const path_1 = __importDefault(require("path"));
8
+ const child_process_1 = require("child_process");
9
+ const fs_extra_1 = __importDefault(require("fs-extra"));
10
+ const picocolors_1 = __importDefault(require("picocolors"));
11
+ const prompts_1 = require("@inquirer/prompts");
12
+ const config_1 = require("../utils/config");
13
+ const naming_1 = require("../utils/naming");
14
+ const module_location_1 = require("../utils/module-location");
15
+ const main_patcher_1 = require("../utils/main-patcher");
16
+ const openapi_patcher_1 = require("../utils/openapi-patcher");
17
+ const golangci_patcher_1 = require("../utils/golangci-patcher");
18
+ const template_renderer_1 = require("../utils/template-renderer");
19
+ const gocheck_1 = require("../utils/gocheck");
20
+ const generate_wizard_1 = require("../prompts/generate-wizard");
21
+ // undoModule reverses a `generate module` that shouldn't have happened —
22
+ // a typo'd name, a domain you decided against — by deleting everything it
23
+ // created, migration files included.
24
+ //
25
+ // It deliberately does NOT try to retire a domain that is live somewhere.
26
+ // The command this replaced claimed to ("a migration may already be recorded
27
+ // in schema_migrations on production"), and that framing was wrong twice
28
+ // over. Nobody decommissions a shipped domain by running a scaffolding CLI:
29
+ // that needs a deprecation window, a data migration, and a drop migration
30
+ // written by hand. Meanwhile the case people actually hit — undoing a module
31
+ // generated thirty seconds ago — got the production treatment, so the typo's
32
+ // migration survived forever. `migrations/embed.go` is a `//go:embed *`, so
33
+ // that leftover then ran on every database anyone created from then on,
34
+ // building a `oder_svc.oders` table nobody asked for.
35
+ //
36
+ // So: work out whether the migration could possibly have escaped this
37
+ // machine. If it can't have, delete it. If it might have, refuse and say why.
38
+ async function undoModule(rawName, opts, projectDir = process.cwd()) {
39
+ const config = (0, config_1.readConfig)(projectDir);
40
+ const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, rawName ?? (await (0, generate_wizard_1.promptExistingModule)((0, module_location_1.existingModulePackages)(projectDir), "undo")));
41
+ const modulePath = naming.pkg;
42
+ const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
43
+ if (!fs_extra_1.default.existsSync(moduleDir)) {
44
+ throw new Error(`module "${naming.pkg}" not found at internal/app/${modulePath} — nothing to undo`);
45
+ }
46
+ // `user` and `role` live under internal/app/ like any generated module, but
47
+ // they were wired by add auth / add rbac using entirely different line
48
+ // shapes — this command would delete the package and then silently fail to
49
+ // un-wire most of it, leaving main.go and cmd/seed referencing a package
50
+ // that no longer exists. findDependents can't catch it either: the
51
+ // consumer-side-interface convention means `user` never imports `role`.
52
+ const featureOwner = { user: "auth", role: "rbac" };
53
+ const owner = featureOwner[modulePath];
54
+ if (owner && config.features[owner]) {
55
+ throw new Error(`"${modulePath}" belongs to \`go-scaffold add ${owner}\`, not to \`generate module\` — refusing to undo it.\n` +
56
+ `Its wiring in cmd/api/wiring.go (and cmd/seed, docs/openapi.yaml) doesn't match what this command knows how to reverse,\n` +
57
+ `so removing it would leave the project un-compilable. Undo \`add ${owner}\` by hand, or start from a fresh scaffold.`);
58
+ }
59
+ const migrationsDir = path_1.default.join(projectDir, "migrations");
60
+ const migrations = moduleMigrations(migrationsDir, (0, naming_1.migrationSlugAliases)(naming));
61
+ const { checkedDatabase } = assertMigrationsNeverEscaped(projectDir, migrations, naming.pkg);
62
+ if (!opts.yes) {
63
+ const ok = await (0, prompts_1.confirm)({
64
+ message: `Undo module "${naming.pkg}"? Deletes internal/app/${modulePath}/, its docs, ` +
65
+ `${migrations.length ? `${migrations.length} migration file(s), ` : ""}` +
66
+ `and un-wires wiring.go/openapi.yaml. The ${naming.tableName} table itself is not dropped.`,
67
+ default: false,
68
+ });
69
+ if (!ok)
70
+ throw new Error("undo cancelled");
71
+ }
72
+ // detect --auth/--permission from the generated handler.go itself (not
73
+ // stored anywhere else) — unpatchMainGo needs the exact same flags used at
74
+ // generate-time to reconstruct the identical line it's removing.
75
+ const handlerGoPath = path_1.default.join(moduleDir, "handler.go");
76
+ let auth;
77
+ let permission;
78
+ if (fs_extra_1.default.existsSync(handlerGoPath)) {
79
+ const handlerContent = fs_extra_1.default.readFileSync(handlerGoPath, "utf8");
80
+ auth = /jwtSecret\s+string/.test(handlerContent);
81
+ permission = handlerContent.match(/h\.authz\.Require\("([^"]+)"\)/)?.[1];
82
+ }
83
+ // Refuse if another domain still imports this one. Deleting it anyway
84
+ // leaves the project un-compilable, and the error Go reports then points at
85
+ // the surviving module rather than at the removal that caused it.
86
+ const dependents = findDependents(projectDir, config.goModule, modulePath);
87
+ if (dependents.length > 0) {
88
+ throw new Error(`module "${naming.pkg}" is still used by: ${dependents.join(", ")}\n` +
89
+ `remove those references (or the modules themselves) first — deleting internal/app/${modulePath} now would break the build`);
90
+ }
91
+ const before = (0, gocheck_1.typeChecks)(projectDir);
92
+ // Un-wire before deleting, not after. Every step below can throw — a
93
+ // hand-edited wiring.go with the routes marker gone is enough — and if the
94
+ // package is already gone by then, the user's own code inside it is
95
+ // unrecoverable outside of git. This order fails the other way round: a
96
+ // still-present package with its wiring removed, which `generate module`
97
+ // re-wires idempotently.
98
+ // 1. wiring.go
99
+ (0, main_patcher_1.unpatchMainGo)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), {
100
+ goModule: config.goModule,
101
+ modulePath,
102
+ pkg: naming.pkg,
103
+ pascalName: naming.pascalName,
104
+ schemaName: naming.schemaName,
105
+ auth,
106
+ permission,
107
+ });
108
+ (0, golangci_patcher_1.unpatchGolangciForModule)(path_1.default.join(projectDir, ".golangci.yml"), modulePath);
109
+ // 2. openapi index + per-module docs
110
+ const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
111
+ if (fs_extra_1.default.existsSync(openapiPath)) {
112
+ (0, openapi_patcher_1.unpatchOpenapiIndex)(openapiPath, naming, config.apiPrefix);
113
+ fs_extra_1.default.removeSync(path_1.default.join(projectDir, "docs", naming.plural));
114
+ }
115
+ // 3. the migrations — safe to delete, assertMigrationsNeverEscaped proved
116
+ // above that they exist nowhere but this working tree
117
+ for (const file of migrations)
118
+ fs_extra_1.default.removeSync(path_1.default.join(migrationsDir, file));
119
+ // 4. the domain package — last, once nothing left can fail
120
+ fs_extra_1.default.removeSync(moduleDir);
121
+ (0, template_renderer_1.gofmtTree)(projectDir);
122
+ (0, gocheck_1.assertNoDrift)(projectDir, before, config, {
123
+ didWhat: `undid module "${naming.pkg}"`,
124
+ recover: `internal/app/${modulePath}/ is gone; re-run \`go-scaffold generate module ${naming.pkg}\` to put it\nback, then reconcile cmd/api/wiring.go by hand.`,
125
+ });
126
+ console.log(picocolors_1.default.green(`\nundid module "${naming.pkg}"`));
127
+ console.log(` deleted internal/app/${modulePath}/`);
128
+ console.log(` un-wired cmd/api/wiring.go`);
129
+ if (fs_extra_1.default.existsSync(openapiPath))
130
+ console.log(` un-wired docs/openapi.yaml + deleted docs/${naming.plural}/`);
131
+ if (migrations.length)
132
+ console.log(` deleted migrations/${migrations.join(", migrations/")}`);
133
+ if (migrations.length && !checkedDatabase) {
134
+ console.log(picocolors_1.default.yellow(`\ncouldn't check whether those migrations had already been applied — no reachable DB_DSN,\n` +
135
+ `or no migrate CLI. They weren't committed, so no other environment can have them, but if\n` +
136
+ `you had run them against a local database it now records a version with no file behind it.`));
137
+ }
138
+ console.log(picocolors_1.default.dim(`\nnothing was dropped from any database — if you had already run these migrations locally,\n` +
139
+ `the ${naming.tableName} table is still there. \`make db-drop && make db-create && make migrate-up\` is the\n` +
140
+ `quickest way back to a clean dev database.`));
141
+ }
142
+ // moduleMigrations lists the migration files `generate module` created for
143
+ // this module: the create pair, plus the permission pair when it was
144
+ // generated with --permission.
145
+ function moduleMigrations(migrationsDir, slugs) {
146
+ if (!fs_extra_1.default.existsSync(migrationsDir))
147
+ return [];
148
+ return fs_extra_1.default
149
+ .readdirSync(migrationsDir)
150
+ .filter((f) => slugs.some((slug) => f.endsWith(`_create_${slug}.up.sql`) ||
151
+ f.endsWith(`_create_${slug}.down.sql`) ||
152
+ f.endsWith(`_add_${slug}_permission.up.sql`) ||
153
+ f.endsWith(`_add_${slug}_permission.down.sql`)))
154
+ .sort();
155
+ }
156
+ // assertMigrationsNeverEscaped is what lets undo delete migration files at
157
+ // all. A migration that has only ever existed in this working tree cannot be
158
+ // recorded in any other environment's schema_migrations, so removing it can't
159
+ // make two databases disagree. Two ways it could have escaped:
160
+ //
161
+ // 1. it's committed — then it's in whatever anyone pulled or deployed
162
+ // 2. it's applied to the database this project is pointed at
163
+ //
164
+ // A project with no git repository at all has never been pushed anywhere, so
165
+ // "git doesn't know this file" covers it.
166
+ //
167
+ // The git half is the one that has to be right: it's what decides whether a
168
+ // migration could be in someone else's database. The database half only ever
169
+ // protects the developer's own dev database, and that damage is recoverable
170
+ // (`migrate force`), so an unreachable database doesn't block the command —
171
+ // it reports back that it couldn't check, and the caller says so.
172
+ function assertMigrationsNeverEscaped(projectDir, migrations, pkg) {
173
+ if (migrations.length === 0)
174
+ return { checkedDatabase: true };
175
+ const known = migrations.filter((f) => gitKnowsAbout(projectDir, path_1.default.join("migrations", f)));
176
+ if (known.length) {
177
+ throw new Error(`git knows about these migrations, so they may already have been applied somewhere:\n` +
178
+ known.map((f) => ` migrations/${f}`).join("\n") +
179
+ `\n\n\`undo\` is for a module you generated and immediately regretted — it deletes migration\n` +
180
+ `files, which is only safe while they exist nowhere but this working tree. To retire a domain\n` +
181
+ `that has shipped, leave its history alone and write the reversal explicitly:\n` +
182
+ ` go-scaffold generate migration drop_${pkg}`);
183
+ }
184
+ // Check every database this project could mean, not just the first one
185
+ // found: an exported DB_DSN left over in the shell would otherwise shadow
186
+ // the project's own .env and answer for the wrong database entirely.
187
+ let checkedDatabase = true;
188
+ for (const dsn of candidateDsns(projectDir)) {
189
+ const applied = appliedVersion(projectDir, dsn);
190
+ if (applied === null) {
191
+ checkedDatabase = false;
192
+ continue;
193
+ }
194
+ const escaped = migrations.filter((f) => Number(f.split("_")[0]) <= applied);
195
+ if (escaped.length) {
196
+ throw new Error(`these migrations have already been applied to your database (schema_migrations is at ${applied}):\n` +
197
+ escaped.map((f) => ` migrations/${f}`).join("\n") +
198
+ `\n\nDeleting the files now would leave the database recorded at a version with no migration\n` +
199
+ `behind it, and golang-migrate would refuse to move from there. Roll them back first:\n` +
200
+ ` migrate -path migrations -database "$DB_DSN" down ${escaped.length / 2 || 1}\n` +
201
+ `then run this again.`);
202
+ }
203
+ }
204
+ return { checkedDatabase };
205
+ }
206
+ // gitKnowsAbout asks both questions, because either one means the file has
207
+ // left this working tree. The index alone isn't enough: a file that was
208
+ // committed and later `git rm --cached`'d is untracked now but is still in
209
+ // history, and in anything anyone has pulled.
210
+ function gitKnowsAbout(projectDir, relativePath) {
211
+ if (runsClean(projectDir, ["ls-files", "--error-unmatch", relativePath]))
212
+ return true;
213
+ const inHistory = (0, child_process_1.spawnSync)("git", ["log", "--all", "--max-count=1", "--format=%H", "--", relativePath], {
214
+ cwd: projectDir,
215
+ encoding: "utf8",
216
+ });
217
+ return !inHistory.error && inHistory.status === 0 && inHistory.stdout.trim() !== "";
218
+ }
219
+ function runsClean(projectDir, args) {
220
+ try {
221
+ (0, child_process_1.execFileSync)("git", args, { cwd: projectDir, stdio: "ignore" });
222
+ return true;
223
+ }
224
+ catch {
225
+ // not tracked, or not a git repository at all
226
+ return false;
227
+ }
228
+ }
229
+ // appliedVersion reads schema_migrations through the migrate CLI the project
230
+ // already documents. Returns null when it can't tell — no migrate on PATH, no
231
+ // DB_DSN, database unreachable, or nothing applied yet.
232
+ function appliedVersion(projectDir, dsn) {
233
+ // spawnSync, not execFileSync: `migrate version` prints the version on
234
+ // stderr and still exits 0, and execFileSync only hands back stdout. That
235
+ // read as "" -> Number("") -> 0 -> "nothing applied yet", which waved every
236
+ // migration straight through the check this function exists to perform.
237
+ //
238
+ // The timeout matters as much: a DSN pointing at an unroutable host makes
239
+ // migrate sit on the OS TCP timeout, and the CLI froze for 77 seconds with
240
+ // no output before carrying on anyway.
241
+ const res = (0, child_process_1.spawnSync)("migrate", ["-path", "migrations", "-database", dsn, "version"], {
242
+ cwd: projectDir,
243
+ encoding: "utf8",
244
+ timeout: 5000,
245
+ });
246
+ if (res.error)
247
+ return null; // no migrate on PATH, or it timed out
248
+ const output = `${res.stderr ?? ""}\n${res.stdout ?? ""}`;
249
+ // a database that has never been migrated is a definitive "nothing
250
+ // applied", not an inconclusive answer
251
+ if (/no migration/i.test(output))
252
+ return 0;
253
+ return parseVersion(output);
254
+ }
255
+ // parseVersion pulls the bare version number out of migrate's output, and
256
+ // insists on actually finding one: anything else (a connection error, a
257
+ // "dirty" marker, an empty string) has to read as "can't tell", never as 0.
258
+ function parseVersion(output) {
259
+ const match = output.trim().match(/^(\d+)/m);
260
+ return match ? Number(match[1]) : null;
261
+ }
262
+ // dsnFromEnvFile reads DB_DSN out of .env the same way the generated Makefile
263
+ // does, and tolerates the two spellings people actually write: a quoted value,
264
+ // and a leading `export`. Getting either wrong doesn't fail loudly — it hands
265
+ // `migrate` a DSN it can't parse, appliedVersion then reports "can't tell",
266
+ // and the applied-migrations guard is skipped entirely. Both forms silently
267
+ // defeated it.
268
+ // candidateDsns returns every database this project might mean, deduped. Both
269
+ // are checked rather than just the first: an exported DB_DSN pointing at some
270
+ // other environment would otherwise shadow the project's own .env and answer
271
+ // the applied-migrations question about the wrong database.
272
+ function candidateDsns(projectDir) {
273
+ const found = [process.env.DB_DSN, dsnFromEnvFile(projectDir)].filter((d) => Boolean(d));
274
+ return [...new Set(found)];
275
+ }
276
+ function dsnFromEnvFile(projectDir) {
277
+ const envPath = path_1.default.join(projectDir, ".env");
278
+ if (!fs_extra_1.default.existsSync(envPath))
279
+ return undefined;
280
+ const match = fs_extra_1.default.readFileSync(envPath, "utf8").match(/^[ \t]*(?:export[ \t]+)?DB_DSN[ \t]*=[ \t]*(.*)$/m);
281
+ if (!match)
282
+ return undefined;
283
+ let value = match[1].trim();
284
+ const quote = value[0];
285
+ if (quote === '"' || quote === "'") {
286
+ // a quoted value ends at its closing quote; anything after it is a comment
287
+ const end = value.indexOf(quote, 1);
288
+ value = end === -1 ? value.slice(1) : value.slice(1, end);
289
+ }
290
+ else {
291
+ // unquoted: strip a trailing ` # comment`, matching the Makefile's
292
+ // sed -E 's/[[:space:]]+#.*$//'. Not a bare `#` — that's legal in a URL.
293
+ value = value.replace(/[ \t]+#.*$/, "").trim();
294
+ }
295
+ return value || undefined;
296
+ }
297
+ // findDependents lists the .go files in *other* domains that import this one.
298
+ // Import path match, not a bare package-name match: `orders` appears in plenty
299
+ // of strings and comments, but only an import of
300
+ // "<goModule>/internal/app/orders" is a real compile-time dependency.
301
+ function findDependents(projectDir, goModule, modulePath) {
302
+ // The package itself, or one of its subpackages — and nothing else. Dropping
303
+ // the closing quote to catch `/model` was matching on a bare prefix too, so
304
+ // `undo module order` was refused by every module whose name merely starts
305
+ // with it: internal/app/orderitem imports nothing from internal/app/order,
306
+ // but the second path contains the first.
307
+ const base = `"${goModule}/internal/app/${modulePath}`;
308
+ const importsIt = (src) => src.includes(`${base}"`) || src.includes(`${base}/`);
309
+ const appDir = path_1.default.join(projectDir, "internal", "app");
310
+ const hits = [];
311
+ for (const pkg of (0, module_location_1.existingModulePackages)(projectDir)) {
312
+ if (pkg === modulePath)
313
+ continue;
314
+ for (const file of goFilesIn(path_1.default.join(appDir, pkg))) {
315
+ if (importsIt(fs_extra_1.default.readFileSync(file, "utf8"))) {
316
+ hits.push(path_1.default.relative(projectDir, file));
317
+ }
318
+ }
319
+ }
320
+ return hits;
321
+ }
322
+ function goFilesIn(dir) {
323
+ if (!fs_extra_1.default.existsSync(dir))
324
+ return [];
325
+ return fs_extra_1.default.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
326
+ const full = path_1.default.join(dir, entry.name);
327
+ if (entry.isDirectory())
328
+ return goFilesIn(full);
329
+ return entry.name.endsWith(".go") ? [full] : [];
330
+ });
331
+ }
@@ -11,65 +11,125 @@ const config_1 = require("../utils/config");
11
11
  const template_renderer_1 = require("../utils/template-renderer");
12
12
  const worker_manifest_1 = require("../templates/worker-manifest");
13
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
14
+ const gocheck_1 = require("../utils/gocheck");
15
+ const gomod_patcher_1 = require("../utils/gomod-patcher");
16
+ const auth_patcher_1 = require("../utils/auth-patcher");
17
+ // addWorker scaffolds async job processing: the backend-neutral queue
18
+ // contract (platform/queue), one adapter for the chosen backing store, SMTP
19
+ // mail (platform/mail, with an email:send job as the one thing a fresh
17
20
  // 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
+ // don't need a queue on day one, and an empty worker with no job kinds
22
+ // registered is a stranger scaffold than not having one.
23
+ async function addWorker(backend, projectDir = process.cwd()) {
21
24
  const config = (0, config_1.readConfig)(projectDir);
22
25
  const queueDir = path_1.default.join(projectDir, "internal", "platform", "queue");
23
26
  if (fs_extra_1.default.existsSync(queueDir)) {
24
27
  throw new Error(`${queueDir} already exists — worker infrastructure looks like it's already been added`);
25
28
  }
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"));
29
+ const parsedBefore = (0, gocheck_1.parseChecks)(projectDir);
30
+ const riverQueue = backend === "river";
31
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, (0, worker_manifest_1.workerFiles)(backend), { goModule: config.goModule, riverQueue });
32
+ (0, platform_patcher_1.patchConfigForWorker)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"), { redis: !riverQueue });
33
+ if (!riverQueue) {
34
+ (0, platform_patcher_1.patchMainGoForWorker)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), config.goModule);
35
+ (0, platform_patcher_1.patchComposeForRedis)(path_1.default.join(projectDir, "docker-compose.yml"));
36
+ (0, platform_patcher_1.patchCiForRedis)(path_1.default.join(projectDir, ".github", "workflows", "ci.yml"));
37
+ }
38
+ // pinned to what this scaffold was written against — see gomod-patcher
39
+ (0, gomod_patcher_1.patchGoModRequires)(path_1.default.join(projectDir, "go.mod"), riverQueue
40
+ ? ["github.com/riverqueue/river v0.43.0", "github.com/riverqueue/river/riverdriver/riverdatabasesql v0.43.0"]
41
+ : ["github.com/hibiken/asynq v0.26.0", "github.com/redis/go-redis/v9 v9.22.0"]);
42
+ // auth added before the worker wired a synchronous mailer — now that there
43
+ // is a queue, move it onto it
44
+ const mailerUpgraded = (0, auth_patcher_1.upgradeMailerToQueue)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), config.goModule, backend);
45
+ patchEnvExample(path_1.default.join(projectDir, ".env.example"), { redis: !riverQueue });
46
+ patchMakefile(path_1.default.join(projectDir, "Makefile"), { river: riverQueue });
31
47
  (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"));
48
+ // parse-only: river/asynq aren't in go.mod until the user runs `go mod
49
+ // tidy`, so `go vet` can't be the gate here.
50
+ (0, gocheck_1.assertStillParses)(projectDir, parsedBefore, `added worker (${backend})`);
51
+ (0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, worker: true, queue: backend } });
52
+ console.log(picocolors_1.default.green(`\nadded internal/platform/{queue,mail}/ and cmd/worker/ (queue backend: ${backend})`));
53
+ if (riverQueue) {
54
+ console.log("jobs are rows in your Postgres — no extra service, and an enqueue inside tx.Do commits with it");
55
+ console.log(picocolors_1.default.dim("\nnext: make river-migrate (creates River's tables), then make worker"));
56
+ }
57
+ else {
58
+ console.log(mailerUpgraded
59
+ ? "wired Redis into cmd/api (readyz check) — auth's mailer now enqueues onto it instead of blocking on SMTP"
60
+ : "wired Redis into cmd/api (readyz check) — cmd/api does not enqueue anything yet");
61
+ console.log(picocolors_1.default.yellow("note: a Redis enqueue cannot join a database transaction — see the warning on queue.Asynq"));
62
+ console.log(picocolors_1.default.dim("\nnext: make worker (separate terminal, or `make dev` runs both), then go build ./... to confirm"));
63
+ }
36
64
  }
37
- function patchEnvExample(envExamplePath) {
65
+ // needsSmtp and needsRedis are checked independently: `add auth` run without
66
+ // a worker already writes SMTP_HOST on its own (auth stands alone — see
67
+ // addAuth), so by the time a Redis-backed worker arrives after it, the old
68
+ // single `if (content.includes("SMTP_HOST")) return` guard fired on the SMTP
69
+ // block alone and quietly skipped the Redis block right along with it —
70
+ // REDIS_URL never made it into .env.example even though config.go now reads
71
+ // it. Each block gets its own guard so one being done doesn't hide the other.
72
+ function patchEnvExample(envExamplePath, opts) {
38
73
  if (!fs_extra_1.default.existsSync(envExamplePath))
39
74
  return;
40
75
  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" +
76
+ const needsRedis = opts.redis && !content.includes("REDIS_URL");
77
+ const needsSmtp = !content.includes("SMTP_HOST");
78
+ if (!needsRedis && !needsSmtp)
79
+ return; // both already added
80
+ content = content.replace(/\n?$/, "\n");
81
+ if (needsRedis) {
82
+ content += "\nREDIS_URL=redis://localhost:6379/0\n";
83
+ }
84
+ if (needsSmtp) {
85
+ content +=
46
86
  "\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";
87
+ "SMTP_HOST=\n" +
88
+ "SMTP_PORT=587\n" +
89
+ "SMTP_USERNAME=\n" +
90
+ "SMTP_PASSWORD=\n" +
91
+ "SMTP_FROM=no-reply@example.local\n";
92
+ }
52
93
  fs_extra_1.default.writeFileSync(envExamplePath, content);
53
94
  }
54
- function patchMakefile(makefilePath) {
95
+ function patchMakefile(makefilePath, opts) {
55
96
  if (!fs_extra_1.default.existsSync(makefilePath))
56
97
  return;
57
98
  let content = fs_extra_1.default.readFileSync(makefilePath, "utf8");
58
99
  if (content.includes("\nworker:\n"))
59
100
  return; // already added
60
- content = content.replace(/^\.PHONY: /m, ".PHONY: dev worker ");
101
+ content = content.replace(/^\.PHONY: /m, `.PHONY: dev worker${opts.river ? " river-migrate" : ""} `);
102
+ // River keeps its own tables, versioned by River itself rather than by this
103
+ // project's migrations/ directory — run its CLI once per database. Pinned
104
+ // by nothing on purpose: it is a one-shot setup command, not a build input.
105
+ const riverTarget = opts.river
106
+ ? "\n# create River's job tables (run once per database, and after upgrading River)\n" +
107
+ "river-migrate:\n" +
108
+ "\t@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; \\\n" +
109
+ '\tgo run github.com/riverqueue/river/cmd/river@latest migrate-up --line main --database-url "$$DB_DSN"\n'
110
+ : "";
111
+ // Both load config exactly the way Makefile.hbs says every target does:
112
+ // via $(ENV_FILE), and through the same sed that strips trailing comments.
113
+ // Without it, .env.example's own `APP_ENV=development # prod: production`
114
+ // reaches `export` as a bare `#` and prints an error on every run.
115
+ const loadEnv = "@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a;";
61
116
  const targets = "\n# run both API + worker in one terminal — Ctrl+C kills both\n" +
62
117
  "dev:\n" +
63
- "\t@[ -f .env ] && export $$(grep -v '^#' .env | xargs); \\\n" +
118
+ `\t${loadEnv} \\\n` +
64
119
  "\t(trap 'kill 0' SIGINT SIGTERM; \\\n" +
65
120
  "\t go run ./cmd/api & \\\n" +
66
121
  "\t go run ./cmd/worker & \\\n" +
67
122
  "\t wait)\n" +
68
123
  "\n" +
69
- "# background worker for async task processing (email, ...) — requires Redis.\n" +
124
+ `# background worker for async job processing (email, ...) — requires ${opts.river ? "Postgres (make river-migrate first)" : "Redis"}.\n` +
70
125
  "# Use `make dev` to run both in one terminal, or run this in a separate one.\n" +
71
126
  "worker:\n" +
72
- "\t@[ -f .env ] && export $$(grep -v '^#' .env | xargs); go run ./cmd/worker\n";
73
- content = content.replace(/\nbuild:/, `${targets}\nbuild:`);
127
+ `\t${loadEnv} go run ./cmd/worker\n` +
128
+ riverTarget;
129
+ // Function replacer: targets contains literal "$$" (Make's escape for a
130
+ // shell "$") which String.replace would otherwise collapse to a single "$"
131
+ // when the replacement is a plain string — turning "$$DB_DSN" into
132
+ // "$DB_DSN" and silently handing the wrong value to every command below.
133
+ content = content.replace(/\nbuild:/, () => `${targets}\nbuild:`);
74
134
  fs_extra_1.default.writeFileSync(makefilePath, content);
75
135
  }