@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.
- package/LICENSE +21 -0
- package/README.md +143 -50
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +23 -12
- package/dist/commands/method.js +94 -17
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +368 -64
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/module-manifest.js +2 -0
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +97 -18
- package/dist/utils/module-location.js +58 -0
- package/dist/utils/naming.js +125 -14
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/openapi-patcher.js +19 -1
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/dist/utils/smoke-run.js +31 -0
- package/package.json +11 -4
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +14 -12
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +52 -16
- package/templates/create/base/README.md.hbs +61 -12
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +4 -9
- package/templates/create/features/docs/patterns.md.hbs +91 -14
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler.go.hbs +18 -4
- package/templates/generate/module/handler_test.go.hbs +88 -62
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -1
- package/templates/generate/module/minimal/handler.go.hbs +8 -2
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +16 -0
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +109 -0
- package/templates/generate/module/service.go.hbs +15 -4
- package/templates/generate/module/service_test.go.hbs +113 -17
- package/dist/commands/remove.js +0 -89
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
package/dist/index.js
CHANGED
|
@@ -11,15 +11,38 @@ const create_1 = require("./commands/create");
|
|
|
11
11
|
const generate_1 = require("./commands/generate");
|
|
12
12
|
const method_1 = require("./commands/method");
|
|
13
13
|
const migration_1 = require("./commands/migration");
|
|
14
|
-
const
|
|
14
|
+
const undo_1 = require("./commands/undo");
|
|
15
15
|
const version_1 = require("./utils/version");
|
|
16
16
|
const worker_1 = require("./commands/worker");
|
|
17
17
|
const auth_1 = require("./commands/auth");
|
|
18
18
|
const rbac_1 = require("./commands/rbac");
|
|
19
|
+
const observability_1 = require("./commands/observability");
|
|
20
|
+
const worker_wizard_1 = require("./prompts/worker-wizard");
|
|
21
|
+
const auth_wizard_1 = require("./prompts/auth-wizard");
|
|
22
|
+
const generate_wizard_1 = require("./prompts/generate-wizard");
|
|
23
|
+
const config_1 = require("./utils/config");
|
|
24
|
+
// fail is every command's catch: one place so the two non-obvious cases stay
|
|
25
|
+
// consistent. @inquirer/prompts throws ExitPromptError both on Ctrl-C and when
|
|
26
|
+
// stdin isn't a TTY, and its raw message ("User force closed the prompt with 0
|
|
27
|
+
// null") tells a user nothing — the CI case especially, where the real problem
|
|
28
|
+
// is a missing argument, not the prompt.
|
|
29
|
+
function fail(err) {
|
|
30
|
+
const message = err.message ?? String(err);
|
|
31
|
+
if (err.name === "ExitPromptError") {
|
|
32
|
+
console.error(picocolors_1.default.red(process.stdin.isTTY
|
|
33
|
+
? "aborted"
|
|
34
|
+
: "no interactive terminal to prompt on — pass every value as an argument/flag (see --help), or add --defaults"));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
console.error(picocolors_1.default.red(message));
|
|
38
|
+
}
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
}
|
|
19
41
|
const program = new commander_1.Command();
|
|
20
42
|
program
|
|
21
43
|
.name("go-scaffold")
|
|
22
|
-
.description("Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard"
|
|
44
|
+
.description("Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard\n\n" +
|
|
45
|
+
"Run `go-scaffold` with no arguments to pick what to do from a menu. Every command below also asks for anything you don't pass as a flag.")
|
|
23
46
|
.version((0, version_1.cliVersion)());
|
|
24
47
|
program
|
|
25
48
|
.command("create [name]")
|
|
@@ -29,7 +52,7 @@ program
|
|
|
29
52
|
.option("--no-docker", "skip docker-compose.yml (only applies with --defaults)")
|
|
30
53
|
.option("--no-openapi-docs", "skip docs/openapi.yaml (only applies with --defaults)")
|
|
31
54
|
.option("--observability", "add Prometheus /metrics + OpenTelemetry tracing (only applies with --defaults; off by default)")
|
|
32
|
-
.option("--api-prefix <prefix>", 'URL prefix every route is grouped under
|
|
55
|
+
.option("--api-prefix <prefix>", 'URL prefix every route is grouped under, e.g. v1 or api/v1 (default: none)')
|
|
33
56
|
.action(async (name, opts) => {
|
|
34
57
|
try {
|
|
35
58
|
await (0, create_1.createProject)(name, {
|
|
@@ -41,55 +64,96 @@ program
|
|
|
41
64
|
});
|
|
42
65
|
}
|
|
43
66
|
catch (err) {
|
|
44
|
-
|
|
45
|
-
process.exitCode = 1;
|
|
67
|
+
fail(err);
|
|
46
68
|
}
|
|
47
69
|
});
|
|
70
|
+
// runModuleWizard resolves everything `generate module` needs, asking for
|
|
71
|
+
// whatever wasn't passed as a flag. Shared by the bare menu and by
|
|
72
|
+
// `generate module` itself, so both offer the same choices — the flags exist
|
|
73
|
+
// for scripting, not as the only way to reach a decision.
|
|
74
|
+
//
|
|
75
|
+
// --defaults is the escape hatch CI and scripts use: it takes the documented
|
|
76
|
+
// defaults (minimal, no auth) and asks nothing.
|
|
77
|
+
async function runModuleWizard(name, opts) {
|
|
78
|
+
let { full, auth, permission } = opts;
|
|
79
|
+
if (!opts.defaults) {
|
|
80
|
+
// read first, so "not a go-scaffold project" fails before we ask anything,
|
|
81
|
+
// and so the auth/permission questions are only asked when the project
|
|
82
|
+
// actually has the features they depend on — offering them otherwise
|
|
83
|
+
// would present a choice whose only outcome is generateModule's error.
|
|
84
|
+
const config = (0, config_1.readConfig)(process.cwd());
|
|
85
|
+
if (name === undefined)
|
|
86
|
+
name = await (0, generate_wizard_1.promptModuleName)();
|
|
87
|
+
if (full === undefined)
|
|
88
|
+
full = await (0, generate_wizard_1.promptModuleShape)();
|
|
89
|
+
if (auth === undefined && config.features.auth)
|
|
90
|
+
auth = await (0, generate_wizard_1.promptModuleAuth)();
|
|
91
|
+
if (auth && permission === undefined && config.features.rbac)
|
|
92
|
+
permission = await (0, generate_wizard_1.promptModulePermission)();
|
|
93
|
+
}
|
|
94
|
+
await (0, generate_1.generateModule)(name, { full: full ?? false, auth, permission });
|
|
95
|
+
}
|
|
96
|
+
// runGenerateWizard is `generate`/`g` run bare — asks which target, then
|
|
97
|
+
// delegates (each subcommand still prompts for anything else it's missing,
|
|
98
|
+
// e.g. the name). Pulled out to a function, not just the command's .action,
|
|
99
|
+
// so the top-level bare `go-scaffold` invocation can offer the exact same
|
|
100
|
+
// choice without duplicating it.
|
|
101
|
+
async function runGenerateWizard() {
|
|
102
|
+
const target = await (0, prompts_1.select)({
|
|
103
|
+
message: "What do you want to generate?",
|
|
104
|
+
choices: [
|
|
105
|
+
{ name: "Module (safe minimal domain; add methods explicitly)", value: "module" },
|
|
106
|
+
{ name: "Method (add one endpoint to an existing module)", value: "method" },
|
|
107
|
+
{ name: "Migration (reserve a timestamped up/down SQL file pair)", value: "migration" },
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
if (target === "module") {
|
|
111
|
+
await runModuleWizard(undefined, {});
|
|
112
|
+
}
|
|
113
|
+
else if (target === "method") {
|
|
114
|
+
await (0, method_1.generateMethod)(undefined, undefined, {});
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
await (0, migration_1.generateMigration)(undefined);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
48
120
|
const generate = program
|
|
49
121
|
.command("generate")
|
|
50
122
|
.alias("g")
|
|
51
123
|
.description("add to an existing go-scaffold project")
|
|
52
124
|
.action(async () => {
|
|
53
|
-
// bare `generate`/`g` — ask which target, then delegate (each subcommand
|
|
54
|
-
// still prompts for anything else it's missing, e.g. the name).
|
|
55
125
|
try {
|
|
56
|
-
|
|
57
|
-
message: "What do you want to generate?",
|
|
58
|
-
choices: [
|
|
59
|
-
{ name: "Module (full CRUD domain)", value: "module" },
|
|
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" },
|
|
62
|
-
],
|
|
63
|
-
});
|
|
64
|
-
if (target === "module") {
|
|
65
|
-
await (0, generate_1.generateModule)(undefined, { full: true });
|
|
66
|
-
}
|
|
67
|
-
else if (target === "method") {
|
|
68
|
-
await (0, method_1.generateMethod)(undefined, undefined, {});
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
await (0, migration_1.generateMigration)(undefined);
|
|
72
|
-
}
|
|
126
|
+
await runGenerateWizard();
|
|
73
127
|
}
|
|
74
128
|
catch (err) {
|
|
75
|
-
|
|
76
|
-
process.exitCode = 1;
|
|
129
|
+
fail(err);
|
|
77
130
|
}
|
|
78
131
|
});
|
|
79
132
|
generate
|
|
80
133
|
.command("module [name]")
|
|
81
134
|
.alias("m")
|
|
82
|
-
.description("scaffold a domain module
|
|
83
|
-
.option("--
|
|
135
|
+
.description("scaffold a safe minimal domain module; opt into a CRUD skeleton with --full")
|
|
136
|
+
.option("--full", "generate a CRUD skeleton (DTO fields/business rules remain TODO); minimal is the safe default")
|
|
137
|
+
// kept working for muscle memory, hidden from help: minimal is already the
|
|
138
|
+
// default, so advertising a flag that asks for it is pure noise.
|
|
139
|
+
.addOption(new commander_1.Option("--no-full", "deprecated compatibility alias; minimal is already the default").hideHelp())
|
|
84
140
|
.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`;
|
|
141
|
+
.option("--permission <code>", "also require this permission via authz.Require (needs `add rbac`; pass --auth too)")
|
|
142
|
+
.option("--defaults", "skip the prompts, use the defaults (minimal, no auth) — for CI/scripting")
|
|
86
143
|
.action(async (name, opts) => {
|
|
87
144
|
try {
|
|
88
|
-
|
|
145
|
+
// anything not passed as a flag gets asked for — declaring both --full
|
|
146
|
+
// and --no-full leaves opts.full undefined when neither is given, which
|
|
147
|
+
// is exactly the "not answered yet" signal runModuleWizard needs.
|
|
148
|
+
await runModuleWizard(name, {
|
|
149
|
+
full: opts.full,
|
|
150
|
+
auth: opts.auth,
|
|
151
|
+
permission: opts.permission,
|
|
152
|
+
defaults: opts.defaults,
|
|
153
|
+
});
|
|
89
154
|
}
|
|
90
155
|
catch (err) {
|
|
91
|
-
|
|
92
|
-
process.exitCode = 1;
|
|
156
|
+
fail(err);
|
|
93
157
|
}
|
|
94
158
|
});
|
|
95
159
|
generate
|
|
@@ -116,8 +180,7 @@ generate
|
|
|
116
180
|
});
|
|
117
181
|
}
|
|
118
182
|
catch (err) {
|
|
119
|
-
|
|
120
|
-
process.exitCode = 1;
|
|
183
|
+
fail(err);
|
|
121
184
|
}
|
|
122
185
|
});
|
|
123
186
|
generate
|
|
@@ -129,73 +192,314 @@ generate
|
|
|
129
192
|
await (0, migration_1.generateMigration)(name);
|
|
130
193
|
}
|
|
131
194
|
catch (err) {
|
|
132
|
-
|
|
133
|
-
|
|
195
|
+
fail(err);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
// confirmAdd prints what a target is about to do and asks before it happens
|
|
199
|
+
// — the interactive menu is the one path where nothing was typed out loud
|
|
200
|
+
// yet, so it's the one place a summary earns its keep. A direct
|
|
201
|
+
// `add auth --store redis` skips this on purpose: that command line already
|
|
202
|
+
// says what it does, and asking again would just be in a script's way.
|
|
203
|
+
//
|
|
204
|
+
// Defaults to no, like `undo`'s confirm and unlike the create wizard's. This
|
|
205
|
+
// prompt lands immediately after a select, where Enter meant "choose this" a
|
|
206
|
+
// keystroke ago — carrying that Enter straight through would write ~15 files
|
|
207
|
+
// and patch main.go, and there is no `undo auth` to walk it back. A stray
|
|
208
|
+
// Enter costs a re-run instead.
|
|
209
|
+
async function confirmAdd(summaryLines, opts = {}) {
|
|
210
|
+
if (opts.yes)
|
|
211
|
+
return;
|
|
212
|
+
console.log(picocolors_1.default.bold("\nThis will:"));
|
|
213
|
+
for (const line of summaryLines)
|
|
214
|
+
console.log(` ${picocolors_1.default.dim("•")} ${line}`);
|
|
215
|
+
console.log();
|
|
216
|
+
const proceed = await (0, prompts_1.confirm)({ message: "Proceed?", default: false });
|
|
217
|
+
if (!proceed) {
|
|
218
|
+
throw new Error("cancelled — nothing was written");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// runAddWizard is `add` run bare — asks which target, then delegates. Pulled
|
|
222
|
+
// out to a function for the same reason as runGenerateWizard above: the
|
|
223
|
+
// top-level bare `go-scaffold` invocation reuses it verbatim.
|
|
224
|
+
async function runAddWizard() {
|
|
225
|
+
// read once, up front: every add command reads it anyway, the summary below
|
|
226
|
+
// needs to know what's already installed (e.g. whether auth's mail goes out
|
|
227
|
+
// inline or through an existing queue), and so does the menu itself.
|
|
228
|
+
const config = (0, config_1.readConfig)(process.cwd());
|
|
229
|
+
// Each add is once-only, and rbac needs auth first. Both facts are already
|
|
230
|
+
// known here, so say them in the menu rather than letting someone walk three
|
|
231
|
+
// steps and a confirmation to reach "already been added".
|
|
232
|
+
const target = await (0, prompts_1.select)({
|
|
233
|
+
message: "What do you want to add?",
|
|
234
|
+
choices: [
|
|
235
|
+
{
|
|
236
|
+
name: "Worker (background job queue, SMTP mail, cmd/worker)",
|
|
237
|
+
value: "worker",
|
|
238
|
+
disabled: config.features.worker ? "— already installed" : false,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "Auth (JWT access tokens, refresh rotation, register/login/refresh/logout/me)",
|
|
242
|
+
value: "auth",
|
|
243
|
+
disabled: config.features.auth ? "— already installed" : false,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: "RBAC (roles/permissions, cached Authz middleware)",
|
|
247
|
+
value: "rbac",
|
|
248
|
+
disabled: config.features.rbac
|
|
249
|
+
? "— already installed"
|
|
250
|
+
: config.features.auth
|
|
251
|
+
? false
|
|
252
|
+
: "— needs `add auth` first",
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "Observability (Prometheus /metrics + OpenTelemetry tracing)",
|
|
256
|
+
value: "observability",
|
|
257
|
+
disabled: config.features.observability ? "— already installed" : false,
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
if (target === "worker") {
|
|
262
|
+
await runAddWorker(await resolveQueueBackend({}), {});
|
|
263
|
+
}
|
|
264
|
+
else if (target === "auth") {
|
|
265
|
+
// `--store` is a real fork (it decides whether Redis joins the project at
|
|
266
|
+
// all), so the menu has to ask it the same way the worker menu asks for
|
|
267
|
+
// its queue backend — a choice only reachable by knowing the flag name
|
|
268
|
+
// isn't a choice for anyone driving this from the menu.
|
|
269
|
+
await runAddAuth(await (0, auth_wizard_1.promptAuthStore)(), {});
|
|
270
|
+
}
|
|
271
|
+
else if (target === "rbac") {
|
|
272
|
+
await runAddRbac({});
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
await runAddObservability({});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function runAddWorker(backend, opts) {
|
|
279
|
+
await confirmAdd([
|
|
280
|
+
`add internal/platform/{queue,mail}/ and cmd/worker/ (queue backend: ${backend === "river" ? "postgres/River" : "redis/Asynq"})`,
|
|
281
|
+
backend === "river"
|
|
282
|
+
? "no extra service to run — jobs are rows in your own Postgres"
|
|
283
|
+
: picocolors_1.default.yellow("requires Redis to be running"),
|
|
284
|
+
], opts);
|
|
285
|
+
await (0, worker_1.addWorker)(backend);
|
|
286
|
+
}
|
|
287
|
+
async function runAddAuth(store, opts) {
|
|
288
|
+
const config = (0, config_1.readConfig)(process.cwd());
|
|
289
|
+
await confirmAdd([
|
|
290
|
+
"add internal/app/user/, internal/shared/middleware/auth.go, and cmd/seed",
|
|
291
|
+
store === "postgres"
|
|
292
|
+
? "tokens + rate-limit counters: Postgres (user_svc.auth_tokens), in-process — no extra service"
|
|
293
|
+
: picocolors_1.default.yellow("tokens + rate-limit counters: Redis — requires a Redis server to be running"),
|
|
294
|
+
config.features.worker
|
|
295
|
+
? "verification/reset mail: queued through the worker already installed"
|
|
296
|
+
: picocolors_1.default.yellow("verification/reset mail: sent inline over SMTP (no worker yet) — /auth/register and /auth/forgot-password block until it's sent"),
|
|
297
|
+
], opts);
|
|
298
|
+
await (0, auth_1.addAuth)(store);
|
|
299
|
+
}
|
|
300
|
+
async function runAddRbac(opts) {
|
|
301
|
+
const config = (0, config_1.readConfig)(process.cwd());
|
|
302
|
+
if (!config.features.auth) {
|
|
303
|
+
throw new Error("`add rbac` requires `add auth` first — there's no Role claim to check permissions against otherwise");
|
|
304
|
+
}
|
|
305
|
+
await confirmAdd(["add roles/permissions admin API, cached Authz middleware, and PATCH /users/:id/set-role"], opts);
|
|
306
|
+
await (0, rbac_1.addRbac)();
|
|
307
|
+
}
|
|
308
|
+
async function runAddObservability(opts) {
|
|
309
|
+
await confirmAdd(["add Prometheus /metrics + OpenTelemetry tracing", "patch cmd/api/wiring.go and internal/platform/database to wire it in"], opts);
|
|
310
|
+
await (0, observability_1.addObservability)();
|
|
311
|
+
}
|
|
312
|
+
const add = program
|
|
313
|
+
.command("add")
|
|
314
|
+
.description("add opt-in infrastructure to an existing go-scaffold project")
|
|
315
|
+
.action(async () => {
|
|
316
|
+
try {
|
|
317
|
+
await runAddWizard();
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
fail(err);
|
|
134
321
|
}
|
|
135
322
|
});
|
|
136
|
-
const add = program.command("add").description("add opt-in infrastructure to an existing go-scaffold project");
|
|
137
323
|
add
|
|
138
324
|
.command("worker")
|
|
139
|
-
.description("add
|
|
140
|
-
.
|
|
325
|
+
.description("add a background job queue, SMTP mail, and cmd/worker (opt-in — most projects don't need this on day one)")
|
|
326
|
+
.option("--queue <backend>", "where jobs are stored: postgres (River, default) or redis (Asynq)")
|
|
327
|
+
.option("--defaults", "skip the prompt, use the Postgres-backed queue")
|
|
328
|
+
.option("-y, --yes", "skip the confirmation summary")
|
|
329
|
+
.action(async (opts) => {
|
|
141
330
|
try {
|
|
142
|
-
await (
|
|
331
|
+
await runAddWorker(await resolveQueueBackend(opts), { yes: opts.yes || opts.defaults });
|
|
143
332
|
}
|
|
144
333
|
catch (err) {
|
|
145
|
-
|
|
146
|
-
process.exitCode = 1;
|
|
334
|
+
fail(err);
|
|
147
335
|
}
|
|
148
336
|
});
|
|
337
|
+
// resolveQueueBackend maps the friendly flag values people actually type
|
|
338
|
+
// ("postgres", "redis") onto the adapter names, and falls back to the prompt
|
|
339
|
+
// when neither --queue nor --defaults was given.
|
|
340
|
+
async function resolveQueueBackend(opts) {
|
|
341
|
+
if (opts.queue) {
|
|
342
|
+
const normalized = opts.queue.trim().toLowerCase();
|
|
343
|
+
const byName = {
|
|
344
|
+
postgres: "river",
|
|
345
|
+
river: "river",
|
|
346
|
+
pg: "river",
|
|
347
|
+
redis: "asynq",
|
|
348
|
+
asynq: "asynq",
|
|
349
|
+
};
|
|
350
|
+
const backend = byName[normalized];
|
|
351
|
+
if (!backend) {
|
|
352
|
+
throw new Error(`unknown --queue ${opts.queue} — use "postgres" (River) or "redis" (Asynq)`);
|
|
353
|
+
}
|
|
354
|
+
return backend;
|
|
355
|
+
}
|
|
356
|
+
if (opts.defaults)
|
|
357
|
+
return "river";
|
|
358
|
+
return (0, worker_wizard_1.promptQueueBackend)();
|
|
359
|
+
}
|
|
149
360
|
add
|
|
150
361
|
.command("auth")
|
|
151
|
-
.description("add email/password auth: JWT access tokens,
|
|
152
|
-
.
|
|
362
|
+
.description("add email/password auth: JWT access tokens, refresh token rotation, register/login/refresh/logout/me (requires `add worker` first)")
|
|
363
|
+
.option("--store <store>", 'where tokens and rate-limit counters live: "postgres" (default, no extra service) or "redis" (exact across replicas)')
|
|
364
|
+
.option("--defaults", "skip the prompt, use the Postgres-backed store (for CI/scripting)")
|
|
365
|
+
.option("-y, --yes", "skip the confirmation summary")
|
|
366
|
+
.action(async (opts) => {
|
|
153
367
|
try {
|
|
154
|
-
|
|
368
|
+
// Same shape as `add worker`: an explicit flag wins, --defaults takes
|
|
369
|
+
// the documented default silently, and anything else asks rather than
|
|
370
|
+
// picking a store the caller never saw a choice about.
|
|
371
|
+
let store;
|
|
372
|
+
if (opts.store !== undefined) {
|
|
373
|
+
const normalized = opts.store.trim().toLowerCase();
|
|
374
|
+
if (normalized !== "postgres" && normalized !== "redis") {
|
|
375
|
+
throw new Error(`unknown --store ${opts.store} — use "postgres" or "redis"`);
|
|
376
|
+
}
|
|
377
|
+
store = normalized;
|
|
378
|
+
}
|
|
379
|
+
else if (opts.defaults) {
|
|
380
|
+
store = "postgres";
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
store = await (0, auth_wizard_1.promptAuthStore)();
|
|
384
|
+
}
|
|
385
|
+
await runAddAuth(store, { yes: opts.yes || opts.defaults });
|
|
155
386
|
}
|
|
156
387
|
catch (err) {
|
|
157
|
-
|
|
158
|
-
process.exitCode = 1;
|
|
388
|
+
fail(err);
|
|
159
389
|
}
|
|
160
390
|
});
|
|
161
391
|
add
|
|
162
392
|
.command("rbac")
|
|
163
393
|
.description("add role-based access control: roles/permissions admin API, cached Authz middleware, PATCH /users/:id/set-role (requires `add auth` first)")
|
|
164
|
-
.
|
|
394
|
+
.option("-y, --yes", "skip the confirmation summary")
|
|
395
|
+
.action(async (opts) => {
|
|
165
396
|
try {
|
|
166
|
-
await (
|
|
397
|
+
await runAddRbac({ yes: opts.yes });
|
|
167
398
|
}
|
|
168
399
|
catch (err) {
|
|
169
|
-
|
|
170
|
-
process.exitCode = 1;
|
|
400
|
+
fail(err);
|
|
171
401
|
}
|
|
172
402
|
});
|
|
173
|
-
|
|
174
|
-
.command("
|
|
175
|
-
.
|
|
176
|
-
.
|
|
403
|
+
add
|
|
404
|
+
.command("observability")
|
|
405
|
+
.description("add Prometheus /metrics + OpenTelemetry tracing for Gin + GORM (also available at `create` time via --observability)")
|
|
406
|
+
.option("-y, --yes", "skip the confirmation summary")
|
|
407
|
+
.action(async (opts) => {
|
|
408
|
+
try {
|
|
409
|
+
await runAddObservability({ yes: opts.yes });
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
fail(err);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
const undo = program
|
|
416
|
+
.command("undo")
|
|
417
|
+
.description("undo a `generate module` you didn't mean to run (typo'd name, domain you decided against)")
|
|
177
418
|
.action(async () => {
|
|
178
|
-
// bare `
|
|
419
|
+
// bare `undo` — module is the only target, so prompt for the name
|
|
420
|
+
try {
|
|
421
|
+
await (0, undo_1.undoModule)(undefined, {});
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
fail(err);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
undo
|
|
428
|
+
.command("module [name]")
|
|
429
|
+
.alias("m")
|
|
430
|
+
.description("delete a generated module and everything it wired up, migration files included")
|
|
431
|
+
.option("-y, --yes", "skip the confirmation prompt")
|
|
432
|
+
.action(async (name, opts) => {
|
|
179
433
|
try {
|
|
180
|
-
await (0,
|
|
434
|
+
await (0, undo_1.undoModule)(name, { yes: opts.yes });
|
|
181
435
|
}
|
|
182
436
|
catch (err) {
|
|
183
|
-
|
|
184
|
-
process.exitCode = 1;
|
|
437
|
+
fail(err);
|
|
185
438
|
}
|
|
186
439
|
});
|
|
440
|
+
// `remove module` was this command's old name, back when it claimed to retire
|
|
441
|
+
// a domain that might be live — and so kept the migration files, which for the
|
|
442
|
+
// case people actually used it in (undoing a mistake) meant a typo's migration
|
|
443
|
+
// ran on every database created from then on. Kept as an alias rather than
|
|
444
|
+
// deleted outright: a muscle-memory `rm m` shouldn't be an unrecognised-command
|
|
445
|
+
// error, and the semantics only got safer.
|
|
446
|
+
const remove = program.command("remove", { hidden: true }).alias("rm");
|
|
187
447
|
remove
|
|
188
448
|
.command("module [name]")
|
|
189
449
|
.alias("m")
|
|
190
|
-
.description("delete a domain module and reverse everything `generate module` wired up")
|
|
191
450
|
.option("-y, --yes", "skip the confirmation prompt")
|
|
192
451
|
.action(async (name, opts) => {
|
|
452
|
+
console.error(picocolors_1.default.yellow("`remove module` is now `undo module` — running that instead."));
|
|
193
453
|
try {
|
|
194
|
-
await (0,
|
|
454
|
+
await (0, undo_1.undoModule)(name, { yes: opts.yes });
|
|
195
455
|
}
|
|
196
456
|
catch (err) {
|
|
197
|
-
|
|
198
|
-
process.exitCode = 1;
|
|
457
|
+
fail(err);
|
|
199
458
|
}
|
|
200
459
|
});
|
|
201
|
-
|
|
460
|
+
// runTopMenu is bare `go-scaffold` with no arguments at all — the exact
|
|
461
|
+
// command name is the one thing people forget, so give the same "ask, then
|
|
462
|
+
// delegate" menu `add` and `generate` already give when run bare, instead of
|
|
463
|
+
// Commander's static help text (which lists commands but never lets you act
|
|
464
|
+
// on one).
|
|
465
|
+
//
|
|
466
|
+
// Deliberately NOT a .action() on the root command: giving the root an action
|
|
467
|
+
// makes it callable, which turns a mistyped subcommand into "too many
|
|
468
|
+
// arguments. Expected 0 arguments but got 2: ad, auth" instead of Commander's
|
|
469
|
+
// "unknown command 'ad' (Did you mean add?)". Branching on argv keeps the
|
|
470
|
+
// menu and keeps that error.
|
|
471
|
+
async function runTopMenu() {
|
|
472
|
+
// Every entry but "create" needs a project. Offering them outside one buys
|
|
473
|
+
// two selects and then the same error — so say it once, up front, and only
|
|
474
|
+
// offer what can actually run here.
|
|
475
|
+
const inProject = (0, config_1.isProjectDir)(process.cwd());
|
|
476
|
+
const target = inProject
|
|
477
|
+
? await (0, prompts_1.select)({
|
|
478
|
+
message: "What do you want to do?",
|
|
479
|
+
choices: [
|
|
480
|
+
{ name: "Create a new project", value: "create" },
|
|
481
|
+
{ name: "Generate (module/method/migration in an existing project)", value: "generate" },
|
|
482
|
+
{ name: "Add (auth/worker/rbac/observability in an existing project)", value: "add" },
|
|
483
|
+
{ name: "Undo a generated module", value: "undo" },
|
|
484
|
+
],
|
|
485
|
+
})
|
|
486
|
+
: (console.log(picocolors_1.default.dim(`${process.cwd()} isn't a go-scaffold project — only "create" can run here.\n`)), "create");
|
|
487
|
+
if (target === "create") {
|
|
488
|
+
await (0, create_1.createProject)(undefined, {});
|
|
489
|
+
}
|
|
490
|
+
else if (target === "generate") {
|
|
491
|
+
await runGenerateWizard();
|
|
492
|
+
}
|
|
493
|
+
else if (target === "add") {
|
|
494
|
+
await runAddWizard();
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
await (0, undo_1.undoModule)(undefined, {});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (process.argv.length <= 2) {
|
|
501
|
+
runTopMenu().catch(fail);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
program.parseAsync(process.argv);
|
|
505
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.promptAuthStore = promptAuthStore;
|
|
4
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
5
|
+
// The one decision `add auth` cannot make for you: where refresh tokens,
|
|
6
|
+
// one-time tokens and rate-limit counters live. It drives which tokenStore
|
|
7
|
+
// implementation is written, whether Redis is added to the project at all,
|
|
8
|
+
// and whether the auth_tokens table exists.
|
|
9
|
+
//
|
|
10
|
+
// Mirrors promptQueueBackend: the choice exists as `--store` for scripting,
|
|
11
|
+
// but nobody should have to know the flag name to discover the option.
|
|
12
|
+
async function promptAuthStore() {
|
|
13
|
+
return (0, prompts_1.select)({
|
|
14
|
+
message: "Where should refresh tokens and rate-limit counters be stored?",
|
|
15
|
+
default: "postgres",
|
|
16
|
+
choices: [
|
|
17
|
+
{
|
|
18
|
+
name: "Postgres (user_svc.auth_tokens)",
|
|
19
|
+
value: "postgres",
|
|
20
|
+
description: "no extra service to run; tokens are rows in your own database, and the rate limiter counts in-process",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "Redis",
|
|
24
|
+
value: "redis",
|
|
25
|
+
description: "TTL expiry and rate-limit counters are exact across replicas; needs a Redis server",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -29,9 +29,12 @@ async function runCreateWizard() {
|
|
|
29
29
|
message: "Add metrics + tracing (Prometheus /metrics, OpenTelemetry over OTLP/HTTP for Gin + GORM)?",
|
|
30
30
|
default: false,
|
|
31
31
|
});
|
|
32
|
+
// No default, so Enter means what an empty answer looks like it means. A
|
|
33
|
+
// prefix is opt-in: it puts every route in the project behind a path segment
|
|
34
|
+
// that is then fixed for the life of the project, which is not something to
|
|
35
|
+
// acquire by not answering a question.
|
|
32
36
|
const apiPrefixRaw = await (0, prompts_1.input)({
|
|
33
|
-
message: "API route prefix
|
|
34
|
-
default: "v1",
|
|
37
|
+
message: "API route prefix — leave blank for none, or e.g. v1, api/v1:",
|
|
35
38
|
validate: naming_1.validateApiPrefix,
|
|
36
39
|
});
|
|
37
40
|
const apiPrefix = (0, naming_1.normalizeApiPrefix)(apiPrefixRaw);
|
|
@@ -6,6 +6,10 @@ exports.promptMethodType = promptMethodType;
|
|
|
6
6
|
exports.promptGetMode = promptGetMode;
|
|
7
7
|
exports.promptMigrationName = promptMigrationName;
|
|
8
8
|
exports.promptLookupField = promptLookupField;
|
|
9
|
+
exports.promptModuleShape = promptModuleShape;
|
|
10
|
+
exports.promptModuleAuth = promptModuleAuth;
|
|
11
|
+
exports.promptModulePermission = promptModulePermission;
|
|
12
|
+
exports.promptExistingModule = promptExistingModule;
|
|
9
13
|
const prompts_1 = require("@inquirer/prompts");
|
|
10
14
|
const naming_1 = require("../utils/naming");
|
|
11
15
|
// wraps an assert-style validator into inquirer's true|string contract so a
|
|
@@ -74,3 +78,56 @@ async function promptLookupField() {
|
|
|
74
78
|
});
|
|
75
79
|
return field.trim();
|
|
76
80
|
}
|
|
81
|
+
// The three `generate module` decisions that exist as flags (--full, --auth,
|
|
82
|
+
// --permission). They live here next to the other generate prompts so the
|
|
83
|
+
// subcommand and the bare-menu path can ask them the same way — a choice only
|
|
84
|
+
// reachable by knowing the flag name isn't a choice for anyone driving this
|
|
85
|
+
// from the menu.
|
|
86
|
+
async function promptModuleShape() {
|
|
87
|
+
return (0, prompts_1.select)({
|
|
88
|
+
message: "What should the module contain?",
|
|
89
|
+
default: false,
|
|
90
|
+
choices: [
|
|
91
|
+
{
|
|
92
|
+
name: "Minimal — model + wiring only",
|
|
93
|
+
value: false,
|
|
94
|
+
description: "the safe default; add endpoints one at a time with `generate method`",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "CRUD skeleton — list/get/create/update/delete",
|
|
98
|
+
value: true,
|
|
99
|
+
description: "all five endpoints wired up; DTO fields and business rules are left as TODO",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function promptModuleAuth() {
|
|
105
|
+
return (0, prompts_1.confirm)({
|
|
106
|
+
message: "Require a valid access token for this module's routes?",
|
|
107
|
+
default: false,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Blank is a real answer here (auth without a specific permission), so this
|
|
111
|
+
// takes no validate — an unusable code is caught by generateModule, which
|
|
112
|
+
// owns the pattern and the "needs add rbac" rule.
|
|
113
|
+
async function promptModulePermission() {
|
|
114
|
+
const code = await (0, prompts_1.input)({
|
|
115
|
+
message: "Also require a permission code — leave blank for none, or e.g. products:manage:",
|
|
116
|
+
});
|
|
117
|
+
return code.trim() || undefined;
|
|
118
|
+
}
|
|
119
|
+
// promptExistingModule picks from what's actually on disk, for the commands
|
|
120
|
+
// that operate on a module that already exists (`generate method`, `undo
|
|
121
|
+
// module`). Free text was the wrong control here: the package name is the one
|
|
122
|
+
// form of the name nobody remembers exactly — a typo'd "ordrs" gets
|
|
123
|
+
// singularized to "ordr" on the way to the error, so the message names a
|
|
124
|
+
// string the user never typed.
|
|
125
|
+
async function promptExistingModule(packages, action) {
|
|
126
|
+
if (packages.length === 0) {
|
|
127
|
+
throw new Error(`no modules in internal/app yet — run \`go-scaffold generate module <name>\` before trying to ${action} one`);
|
|
128
|
+
}
|
|
129
|
+
return (0, prompts_1.select)({
|
|
130
|
+
message: "Which module?",
|
|
131
|
+
choices: packages.map((pkg) => ({ name: pkg, value: pkg })),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.promptQueueBackend = promptQueueBackend;
|
|
4
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
5
|
+
// The one decision `add worker` cannot make for you: where jobs live. It
|
|
6
|
+
// drives which adapter is written, whether Redis is added to the project at
|
|
7
|
+
// all, and whether an enqueue can join a database transaction.
|
|
8
|
+
async function promptQueueBackend() {
|
|
9
|
+
return (0, prompts_1.select)({
|
|
10
|
+
message: "Where should background jobs be stored?",
|
|
11
|
+
default: "river",
|
|
12
|
+
choices: [
|
|
13
|
+
{
|
|
14
|
+
name: "Postgres (River)",
|
|
15
|
+
value: "river",
|
|
16
|
+
description: "no extra service to run; a job is only delivered if the transaction that enqueued it commits",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "Redis (Asynq)",
|
|
20
|
+
value: "asynq",
|
|
21
|
+
description: "higher throughput and shareable across languages; needs a Redis server, and an enqueue cannot join a database transaction",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
}
|