@nakedev/go-scaffold 0.3.0 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/dist/commands/auth.js +19 -1
- package/dist/commands/create.js +9 -1
- package/dist/commands/method.js +5 -0
- package/dist/commands/observability.js +9 -1
- package/dist/commands/undo.js +50 -15
- package/dist/commands/worker.js +7 -0
- package/dist/index.js +23 -17
- package/dist/prompts/auth-wizard.js +2 -2
- package/dist/prompts/create-wizard.js +38 -24
- package/dist/prompts/generate-wizard.js +11 -11
- package/dist/prompts/interactive.js +36 -0
- package/dist/prompts/worker-wizard.js +2 -2
- package/dist/utils/config.js +50 -24
- package/dist/utils/method-patcher.js +12 -5
- package/dist/utils/platform-patcher.js +10 -1
- package/package.json +1 -1
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +16 -4
- package/templates/create/base/AGENTS.md.hbs +14 -4
- package/templates/create/base/Dockerfile.hbs +10 -8
- package/templates/create/base/README.md.hbs +5 -1
- package/templates/create/base/cmd/api/wiring.go.hbs +7 -1
- package/templates/create/features/docs/architecture.md.hbs +9 -0
- package/templates/create/features/docs/techstack.md.hbs +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@ depending on your machine's npm/pnpm global-bin config — running
|
|
|
39
39
|
go-scaffold create my-api
|
|
40
40
|
cd my-api
|
|
41
41
|
make docker-up # if you kept Docker + PostgreSQL
|
|
42
|
+
make db-create # create the database itself (safe to re-run)
|
|
42
43
|
go mod tidy
|
|
43
44
|
make run
|
|
44
45
|
```
|
|
@@ -74,12 +75,14 @@ with `generate module`.
|
|
|
74
75
|
| Option | Effect |
|
|
75
76
|
|---|---|
|
|
76
77
|
| `--defaults` | Skip the wizard, use defaults (Docker on, OpenAPI docs on, no route prefix) |
|
|
77
|
-
| `--no-docker` | Skip `docker-compose.yml`
|
|
78
|
-
| `--no-openapi-docs` | Skip `docs/openapi.yaml`
|
|
79
|
-
| `--observability` | Prometheus `/metrics` + OpenTelemetry tracing (
|
|
78
|
+
| `--no-docker` | Skip `docker-compose.yml` |
|
|
79
|
+
| `--no-openapi-docs` | Skip `docs/openapi.yaml` |
|
|
80
|
+
| `--observability` | Prometheus `/metrics` + OpenTelemetry tracing (off by default — `add observability` does the same later) |
|
|
80
81
|
| `--api-prefix <prefix>` | URL prefix every route is grouped under — opt in with e.g. `v1` or `api/v1`; omit it for none |
|
|
81
82
|
|
|
82
|
-
Without `--defaults`, an interactive wizard asks the same four questions
|
|
83
|
+
Without `--defaults`, an interactive wizard asks the same four questions —
|
|
84
|
+
skipping any a flag already answered, so `create my-api --no-docker` never asks
|
|
85
|
+
about Docker and never scaffolds it.
|
|
83
86
|
The prefix is a single project-wide choice made once at `create` time —
|
|
84
87
|
there's no per-domain versioning (a domain that needs a real breaking change
|
|
85
88
|
gets a new domain package or a new DTO field, not a duplicated model pointed
|
package/dist/commands/auth.js
CHANGED
|
@@ -94,11 +94,22 @@ async function addAuth(store = "postgres", projectDir = process.cwd()) {
|
|
|
94
94
|
{ template: "add/auth/migrations/create_auth_tokens.down.sql.hbs", output: path_1.default.join("migrations", `${authTokensVersion}_create_auth_tokens.down.sql`) },
|
|
95
95
|
], {});
|
|
96
96
|
}
|
|
97
|
+
// Only meaningful when there is a worker; readConfig fills this from the
|
|
98
|
+
// adapter file on disk, so the only way it is still unknown is a project
|
|
99
|
+
// that has internal/platform/queue with neither adapter in it. Guessing
|
|
100
|
+
// here used to emit `queue.NewAsynqEnqueuer` into River-only projects —
|
|
101
|
+
// an undefined symbol that the parse-only gate below cannot see, so the
|
|
102
|
+
// command reported success over a project that no longer compiled.
|
|
103
|
+
const queueBackend = config.features.queue;
|
|
104
|
+
if (worker && !queueBackend) {
|
|
105
|
+
throw new Error("this project has internal/platform/queue but no river.go or asynq.go — can't tell which queue backend to wire auth's mailer onto.\n" +
|
|
106
|
+
"Restore the adapter file, or remove internal/platform/queue and re-run `go-scaffold add worker`.");
|
|
107
|
+
}
|
|
97
108
|
(0, golangci_patcher_1.patchGolangciForModule)(path_1.default.join(projectDir, ".golangci.yml"), config.goModule, "user");
|
|
98
109
|
(0, auth_patcher_1.patchConfigForAuth)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
|
|
99
110
|
(0, auth_patcher_1.patchMainGoForAuth)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), {
|
|
100
111
|
goModule: config.goModule,
|
|
101
|
-
queueBackend:
|
|
112
|
+
queueBackend: queueBackend ?? "river",
|
|
102
113
|
store,
|
|
103
114
|
worker,
|
|
104
115
|
});
|
|
@@ -150,6 +161,13 @@ function patchMakefile(makefilePath) {
|
|
|
150
161
|
let content = fs_extra_1.default.readFileSync(makefilePath, "utf8");
|
|
151
162
|
if (content.includes("\nseed:\n"))
|
|
152
163
|
return; // already added
|
|
164
|
+
// Both halves below have to land or neither should: .PHONY naming a target
|
|
165
|
+
// that was never inserted is a Makefile that lies about itself. `build:` is
|
|
166
|
+
// the anchor the target is inserted above, so check it first and bail whole.
|
|
167
|
+
if (!/\nbuild:/.test(content)) {
|
|
168
|
+
console.error(picocolors_1.default.yellow(`skipped the Makefile \`seed\` target — no \`build:\` target to anchor it to in ${makefilePath}.\nAdd it by hand: \`seed:\` running \`go run ./cmd/seed\`.`));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
153
171
|
content = content.replace(/^\.PHONY: /m, ".PHONY: seed ");
|
|
154
172
|
const target = "\n# bootstrap an admin user (idempotent) — SEED_ADMIN_EMAIL/PASSWORD from the\n" +
|
|
155
173
|
"# environment, not .env, so a real secret never sits in a checked-in file.\n" +
|
package/dist/commands/create.js
CHANGED
|
@@ -39,7 +39,15 @@ async function createProject(rawName, opts) {
|
|
|
39
39
|
throw new Error(check);
|
|
40
40
|
}
|
|
41
41
|
else {
|
|
42
|
-
|
|
42
|
+
// commander gives `--no-x` options a default of true, and there is no
|
|
43
|
+
// `--docker`/`--openapi-docs` to pass, so false here can only mean the
|
|
44
|
+
// caller opted out explicitly. undefined leaves the question to the wizard.
|
|
45
|
+
({ features, apiPrefix } = await (0, create_wizard_1.runCreateWizard)({
|
|
46
|
+
docker: opts.docker === false ? false : undefined,
|
|
47
|
+
openapiDocs: opts.openapiDocs === false ? false : undefined,
|
|
48
|
+
observability: opts.observability === true ? true : undefined,
|
|
49
|
+
apiPrefix: opts.apiPrefix,
|
|
50
|
+
}));
|
|
43
51
|
}
|
|
44
52
|
const context = {
|
|
45
53
|
projectName,
|
package/dist/commands/method.js
CHANGED
|
@@ -103,6 +103,11 @@ async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = p
|
|
|
103
103
|
`handler.go and service.go must both still carry their \`// go-scaffold:*\` markers —\n` +
|
|
104
104
|
`restore them, or add this method by hand.`);
|
|
105
105
|
}
|
|
106
|
+
// Before the docs check below, so a name that's already taken is reported as
|
|
107
|
+
// exactly that on every project — otherwise the leftover OpenAPI document
|
|
108
|
+
// gets the blame on a docs-enabled project and the real cause (pick another
|
|
109
|
+
// method name) is the one thing the message doesn't say.
|
|
110
|
+
(0, method_patcher_1.assertMethodAbsent)(paths, method);
|
|
106
111
|
const docsRelativePath = config.features.openapiDocs
|
|
107
112
|
? `${naming.plural}/methods/${method.pathSegment}.yaml`
|
|
108
113
|
: undefined;
|
|
@@ -57,7 +57,15 @@ async function addObservability(projectDir = process.cwd(), opts = {}) {
|
|
|
57
57
|
if (opts.silent)
|
|
58
58
|
return;
|
|
59
59
|
console.log(picocolors_1.default.green("\nadded internal/platform/telemetry/, internal/shared/middleware/{metrics,tracing}.go, and GET /metrics"));
|
|
60
|
-
console.log("wired into cmd/api/wiring.go and internal/platform/database — every request and GORM query now gets a trace span");
|
|
60
|
+
console.log("wired into cmd/api/wiring.go and internal/platform/database — every request and GORM query cmd/api makes now gets a trace span");
|
|
61
|
+
// database.Open is shared, so a River-backed cmd/worker does raise GORM
|
|
62
|
+
// spans — but telemetry.Init, the only caller of otel.SetTracerProvider,
|
|
63
|
+
// runs in cmd/api alone. Those spans reach a no-op provider and vanish, and
|
|
64
|
+
// the worker serves no /metrics. Say so rather than leave someone hunting
|
|
65
|
+
// for background jobs that were never going to appear.
|
|
66
|
+
if (config.features.worker) {
|
|
67
|
+
console.log(picocolors_1.default.yellow("cmd/worker is not instrumented — it initialises no tracer provider, so its spans are dropped and it exposes no /metrics"));
|
|
68
|
+
}
|
|
61
69
|
if (staleDocs.length) {
|
|
62
70
|
// Deliberately not "you edited these". The comparison is a whole-file
|
|
63
71
|
// match against today's template, and techstack.md embeds pinned
|
package/dist/commands/undo.js
CHANGED
|
@@ -8,7 +8,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
8
8
|
const child_process_1 = require("child_process");
|
|
9
9
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
10
10
|
const picocolors_1 = __importDefault(require("picocolors"));
|
|
11
|
-
const
|
|
11
|
+
const interactive_1 = require("../prompts/interactive");
|
|
12
12
|
const config_1 = require("../utils/config");
|
|
13
13
|
const naming_1 = require("../utils/naming");
|
|
14
14
|
const module_location_1 = require("../utils/module-location");
|
|
@@ -57,10 +57,10 @@ async function undoModule(rawName, opts, projectDir = process.cwd()) {
|
|
|
57
57
|
`so removing it would leave the project un-compilable. Undo \`add ${owner}\` by hand, or start from a fresh scaffold.`);
|
|
58
58
|
}
|
|
59
59
|
const migrationsDir = path_1.default.join(projectDir, "migrations");
|
|
60
|
-
const migrations = moduleMigrations(migrationsDir,
|
|
60
|
+
const { owned: migrations, unclaimed } = moduleMigrations(migrationsDir, naming);
|
|
61
61
|
const { checkedDatabase } = assertMigrationsNeverEscaped(projectDir, migrations, naming.pkg);
|
|
62
62
|
if (!opts.yes) {
|
|
63
|
-
const ok = await (0,
|
|
63
|
+
const ok = await (0, interactive_1.confirm)({
|
|
64
64
|
message: `Undo module "${naming.pkg}"? Deletes internal/app/${modulePath}/, its docs, ` +
|
|
65
65
|
`${migrations.length ? `${migrations.length} migration file(s), ` : ""}` +
|
|
66
66
|
`and un-wires wiring.go/openapi.yaml. The ${naming.tableName} table itself is not dropped.`,
|
|
@@ -135,23 +135,58 @@ async function undoModule(rawName, opts, projectDir = process.cwd()) {
|
|
|
135
135
|
`or no migrate CLI. They weren't committed, so no other environment can have them, but if\n` +
|
|
136
136
|
`you had run them against a local database it now records a version with no file behind it.`));
|
|
137
137
|
}
|
|
138
|
+
if (unclaimed.length) {
|
|
139
|
+
console.log(picocolors_1.default.yellow(`\nleft in place: ${unclaimed.join(", ")}\n` +
|
|
140
|
+
` named like this module's column migrations but not referencing ${naming.schemaName}.${naming.tableName},\n` +
|
|
141
|
+
` so they look like they belong to another table. Delete them by hand if they don't.`));
|
|
142
|
+
}
|
|
138
143
|
console.log(picocolors_1.default.dim(`\nnothing was dropped from any database — if you had already run these migrations locally,\n` +
|
|
139
144
|
`the ${naming.tableName} table is still there. \`make db-drop && make db-create && make migrate-up\` is the\n` +
|
|
140
145
|
`quickest way back to a clean dev database.`));
|
|
141
146
|
}
|
|
142
|
-
// moduleMigrations lists
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
147
|
+
// moduleMigrations lists every migration file this module caused: the create
|
|
148
|
+
// pair, the permission pair from --permission, and the column pair that
|
|
149
|
+
// `generate method --get-mode one --field <f>` writes (method.ts names it
|
|
150
|
+
// `<version>_add_<tableName>_<column>`).
|
|
151
|
+
//
|
|
152
|
+
// That last one used to be left behind. The module and its create migration
|
|
153
|
+
// went, the ALTER TABLE against the now-uncreated table stayed, and because
|
|
154
|
+
// migrations/embed.go is a `//go:embed *` it then ran on every database made
|
|
155
|
+
// from that project — "schema <x>_svc does not exist" — which is the exact
|
|
156
|
+
// failure this command exists to prevent.
|
|
157
|
+
//
|
|
158
|
+
// The column pair cannot be claimed on filename alone: a module whose table is
|
|
159
|
+
// `orders` would otherwise also claim `_add_orders_logs_email.up.sql`, which
|
|
160
|
+
// belongs to a table named `orders_logs`. So a filename match is confirmed
|
|
161
|
+
// against the file's own `<schema>.<table>` reference, which is unique to this
|
|
162
|
+
// module. Anything that matches the shape but not the schema is reported
|
|
163
|
+
// rather than silently deleted or silently kept.
|
|
164
|
+
function moduleMigrations(migrationsDir, naming) {
|
|
146
165
|
if (!fs_extra_1.default.existsSync(migrationsDir))
|
|
147
|
-
return [];
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
166
|
+
return { owned: [], unclaimed: [] };
|
|
167
|
+
const slugs = (0, naming_1.migrationSlugAliases)(naming);
|
|
168
|
+
const owned = [];
|
|
169
|
+
const unclaimed = [];
|
|
170
|
+
for (const f of fs_extra_1.default.readdirSync(migrationsDir).sort()) {
|
|
171
|
+
const suffix = [".up.sql", ".down.sql"].find((e) => f.endsWith(e));
|
|
172
|
+
if (!suffix)
|
|
173
|
+
continue;
|
|
174
|
+
const stem = f.slice(0, -suffix.length);
|
|
175
|
+
if (slugs.some((slug) => stem.endsWith(`_create_${slug}`) || stem.endsWith(`_add_${slug}_permission`))) {
|
|
176
|
+
owned.push(f);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// `<version>_add_<slug>_<column>` — the --field column migration
|
|
180
|
+
if (!slugs.some((slug) => new RegExp(`^\\d+_add_${slug}_.+$`).test(stem)))
|
|
181
|
+
continue;
|
|
182
|
+
if (fs_extra_1.default.readFileSync(path_1.default.join(migrationsDir, f), "utf8").includes(`${naming.schemaName}.${naming.tableName}`)) {
|
|
183
|
+
owned.push(f);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
unclaimed.push(f);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { owned, unclaimed };
|
|
155
190
|
}
|
|
156
191
|
// assertMigrationsNeverEscaped is what lets undo delete migration files at
|
|
157
192
|
// all. A migration that has only ever existed in this working tree cannot be
|
package/dist/commands/worker.js
CHANGED
|
@@ -98,6 +98,13 @@ function patchMakefile(makefilePath, opts) {
|
|
|
98
98
|
let content = fs_extra_1.default.readFileSync(makefilePath, "utf8");
|
|
99
99
|
if (content.includes("\nworker:\n"))
|
|
100
100
|
return; // already added
|
|
101
|
+
// Same all-or-nothing rule as `add auth`'s Makefile patch: without the
|
|
102
|
+
// `build:` anchor the targets never get inserted, and a .PHONY line naming
|
|
103
|
+
// them would be a Makefile that lies about what it can run.
|
|
104
|
+
if (!/\nbuild:/.test(content)) {
|
|
105
|
+
console.error(picocolors_1.default.yellow(`skipped the Makefile \`dev\`/\`worker\` targets — no \`build:\` target to anchor them to in ${makefilePath}.\nAdd them by hand: \`worker:\` running \`go run ./cmd/worker\`.`));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
101
108
|
content = content.replace(/^\.PHONY: /m, `.PHONY: dev worker${opts.river ? " river-migrate" : ""} `);
|
|
102
109
|
// River keeps its own tables, versioned by River itself rather than by this
|
|
103
110
|
// project's migrations/ directory — run its CLI once per database. Pinned
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
};
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
const commander_1 = require("commander");
|
|
8
|
-
const
|
|
8
|
+
const interactive_1 = require("./prompts/interactive");
|
|
9
9
|
const picocolors_1 = __importDefault(require("picocolors"));
|
|
10
10
|
const create_1 = require("./commands/create");
|
|
11
11
|
const generate_1 = require("./commands/generate");
|
|
@@ -22,16 +22,15 @@ const auth_wizard_1 = require("./prompts/auth-wizard");
|
|
|
22
22
|
const generate_wizard_1 = require("./prompts/generate-wizard");
|
|
23
23
|
const config_1 = require("./utils/config");
|
|
24
24
|
// fail is every command's catch: one place so the two non-obvious cases stay
|
|
25
|
-
// consistent. @inquirer/prompts throws ExitPromptError
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
25
|
+
// consistent. @inquirer/prompts throws ExitPromptError on Ctrl-C, and its raw
|
|
26
|
+
// message ("User force closed the prompt with 0 null") tells a user nothing.
|
|
27
|
+
// The no-TTY case normally never reaches here — prompts/interactive.ts rejects
|
|
28
|
+
// before a prompt starts — but stdin can also close mid-prompt, which arrives
|
|
29
|
+
// as the same error and deserves the same advice rather than "aborted".
|
|
29
30
|
function fail(err) {
|
|
30
31
|
const message = err.message ?? String(err);
|
|
31
32
|
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"));
|
|
33
|
+
console.error(picocolors_1.default.red(process.stdin.isTTY ? "aborted" : interactive_1.NO_TTY_MESSAGE));
|
|
35
34
|
}
|
|
36
35
|
else {
|
|
37
36
|
console.error(picocolors_1.default.red(message));
|
|
@@ -49,9 +48,9 @@ program
|
|
|
49
48
|
.alias("c")
|
|
50
49
|
.description("scaffold a new project (bare skeleton — add domains with `generate module`)")
|
|
51
50
|
.option("--defaults", "skip the wizard, use defaults (for CI/scripting)")
|
|
52
|
-
.option("--no-docker", "skip docker-compose.yml
|
|
53
|
-
.option("--no-openapi-docs", "skip docs/openapi.yaml
|
|
54
|
-
.option("--observability", "add Prometheus /metrics + OpenTelemetry tracing (
|
|
51
|
+
.option("--no-docker", "skip docker-compose.yml")
|
|
52
|
+
.option("--no-openapi-docs", "skip docs/openapi.yaml")
|
|
53
|
+
.option("--observability", "add Prometheus /metrics + OpenTelemetry tracing (off by default)")
|
|
55
54
|
.option("--api-prefix <prefix>", 'URL prefix every route is grouped under, e.g. v1 or api/v1 (default: none)')
|
|
56
55
|
.action(async (name, opts) => {
|
|
57
56
|
try {
|
|
@@ -99,7 +98,7 @@ async function runModuleWizard(name, opts) {
|
|
|
99
98
|
// so the top-level bare `go-scaffold` invocation can offer the exact same
|
|
100
99
|
// choice without duplicating it.
|
|
101
100
|
async function runGenerateWizard() {
|
|
102
|
-
const target = await (0,
|
|
101
|
+
const target = await (0, interactive_1.select)({
|
|
103
102
|
message: "What do you want to generate?",
|
|
104
103
|
choices: [
|
|
105
104
|
{ name: "Module (safe minimal domain; add methods explicitly)", value: "module" },
|
|
@@ -213,7 +212,7 @@ async function confirmAdd(summaryLines, opts = {}) {
|
|
|
213
212
|
for (const line of summaryLines)
|
|
214
213
|
console.log(` ${picocolors_1.default.dim("•")} ${line}`);
|
|
215
214
|
console.log();
|
|
216
|
-
const proceed = await (0,
|
|
215
|
+
const proceed = await (0, interactive_1.confirm)({ message: "Proceed?", default: false });
|
|
217
216
|
if (!proceed) {
|
|
218
217
|
throw new Error("cancelled — nothing was written");
|
|
219
218
|
}
|
|
@@ -229,7 +228,7 @@ async function runAddWizard() {
|
|
|
229
228
|
// Each add is once-only, and rbac needs auth first. Both facts are already
|
|
230
229
|
// known here, so say them in the menu rather than letting someone walk three
|
|
231
230
|
// steps and a confirmation to reach "already been added".
|
|
232
|
-
const target = await (0,
|
|
231
|
+
const target = await (0, interactive_1.select)({
|
|
233
232
|
message: "What do you want to add?",
|
|
234
233
|
choices: [
|
|
235
234
|
{
|
|
@@ -306,7 +305,14 @@ async function runAddRbac(opts) {
|
|
|
306
305
|
await (0, rbac_1.addRbac)();
|
|
307
306
|
}
|
|
308
307
|
async function runAddObservability(opts) {
|
|
309
|
-
|
|
308
|
+
const config = (0, config_1.readConfig)(process.cwd());
|
|
309
|
+
await confirmAdd([
|
|
310
|
+
"add Prometheus /metrics + OpenTelemetry tracing",
|
|
311
|
+
"patch cmd/api/wiring.go and internal/platform/database to wire it in — cmd/api only",
|
|
312
|
+
...(config.features.worker
|
|
313
|
+
? [picocolors_1.default.yellow("cmd/worker is not instrumented: no tracer provider there, so its spans are dropped and it serves no /metrics")]
|
|
314
|
+
: []),
|
|
315
|
+
], opts);
|
|
310
316
|
await (0, observability_1.addObservability)();
|
|
311
317
|
}
|
|
312
318
|
const add = program
|
|
@@ -359,7 +365,7 @@ async function resolveQueueBackend(opts) {
|
|
|
359
365
|
}
|
|
360
366
|
add
|
|
361
367
|
.command("auth")
|
|
362
|
-
.description("add email/password auth: JWT access tokens, refresh token rotation, register/login/refresh/logout/me (
|
|
368
|
+
.description("add email/password auth: JWT access tokens, refresh token rotation, register/login/refresh/logout/me (no prerequisites — without `add worker` the verification/reset mail is sent inline)")
|
|
363
369
|
.option("--store <store>", 'where tokens and rate-limit counters live: "postgres" (default, no extra service) or "redis" (exact across replicas)')
|
|
364
370
|
.option("--defaults", "skip the prompt, use the Postgres-backed store (for CI/scripting)")
|
|
365
371
|
.option("-y, --yes", "skip the confirmation summary")
|
|
@@ -474,7 +480,7 @@ async function runTopMenu() {
|
|
|
474
480
|
// offer what can actually run here.
|
|
475
481
|
const inProject = (0, config_1.isProjectDir)(process.cwd());
|
|
476
482
|
const target = inProject
|
|
477
|
-
? await (0,
|
|
483
|
+
? await (0, interactive_1.select)({
|
|
478
484
|
message: "What do you want to do?",
|
|
479
485
|
choices: [
|
|
480
486
|
{ name: "Create a new project", value: "create" },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.promptAuthStore = promptAuthStore;
|
|
4
|
-
const
|
|
4
|
+
const interactive_1 = require("./interactive");
|
|
5
5
|
// The one decision `add auth` cannot make for you: where refresh tokens,
|
|
6
6
|
// one-time tokens and rate-limit counters live. It drives which tokenStore
|
|
7
7
|
// implementation is written, whether Redis is added to the project at all,
|
|
@@ -10,7 +10,7 @@ const prompts_1 = require("@inquirer/prompts");
|
|
|
10
10
|
// Mirrors promptQueueBackend: the choice exists as `--store` for scripting,
|
|
11
11
|
// but nobody should have to know the flag name to discover the option.
|
|
12
12
|
async function promptAuthStore() {
|
|
13
|
-
return (0,
|
|
13
|
+
return (0, interactive_1.select)({
|
|
14
14
|
message: "Where should refresh tokens and rate-limit counters be stored?",
|
|
15
15
|
default: "postgres",
|
|
16
16
|
choices: [
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.promptProjectName = promptProjectName;
|
|
4
4
|
exports.runCreateWizard = runCreateWizard;
|
|
5
|
-
const
|
|
5
|
+
const interactive_1 = require("./interactive");
|
|
6
6
|
const naming_1 = require("../utils/naming");
|
|
7
7
|
async function promptProjectName() {
|
|
8
|
-
const name = await (0,
|
|
8
|
+
const name = await (0, interactive_1.input)({
|
|
9
9
|
message: "Project name:",
|
|
10
10
|
validate: (value) => {
|
|
11
11
|
if (!value.trim())
|
|
@@ -15,35 +15,49 @@ async function promptProjectName() {
|
|
|
15
15
|
});
|
|
16
16
|
return name.trim();
|
|
17
17
|
}
|
|
18
|
-
async function runCreateWizard() {
|
|
18
|
+
async function runCreateWizard(preset = {}) {
|
|
19
|
+
// The closing "create with these settings?" is asked however many answers
|
|
20
|
+
// arrived as flags, so this wizard always needs a terminal — say so before
|
|
21
|
+
// printing a header for questions that are never going to appear.
|
|
22
|
+
(0, interactive_1.assertInteractive)();
|
|
19
23
|
console.log("\nConfigure your project:\n");
|
|
20
|
-
const docker =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
const docker = preset.docker ??
|
|
25
|
+
(await (0, interactive_1.confirm)({
|
|
26
|
+
message: "Include Docker Compose (local Postgres)?",
|
|
27
|
+
default: true,
|
|
28
|
+
}));
|
|
29
|
+
const openapiDocs = preset.openapiDocs ??
|
|
30
|
+
(await (0, interactive_1.confirm)({
|
|
31
|
+
message: "Include hand-written OpenAPI docs (docs/openapi.yaml, whole docs/ tree served at /docs)?",
|
|
32
|
+
default: true,
|
|
33
|
+
}));
|
|
34
|
+
const observability = preset.observability ??
|
|
35
|
+
(await (0, interactive_1.confirm)({
|
|
36
|
+
message: "Add metrics + tracing (Prometheus /metrics, OpenTelemetry over OTLP/HTTP for Gin + GORM)?",
|
|
37
|
+
default: false,
|
|
38
|
+
}));
|
|
32
39
|
// No default, so Enter means what an empty answer looks like it means. A
|
|
33
40
|
// prefix is opt-in: it puts every route in the project behind a path segment
|
|
34
41
|
// that is then fixed for the life of the project, which is not something to
|
|
35
42
|
// acquire by not answering a question.
|
|
36
|
-
const apiPrefixRaw =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
const apiPrefixRaw = preset.apiPrefix ??
|
|
44
|
+
(await (0, interactive_1.input)({
|
|
45
|
+
message: "API route prefix — leave blank for none, or e.g. v1, api/v1:",
|
|
46
|
+
validate: naming_1.validateApiPrefix,
|
|
47
|
+
}));
|
|
48
|
+
const prefixCheck = (0, naming_1.validateApiPrefix)(apiPrefixRaw);
|
|
49
|
+
if (prefixCheck !== true)
|
|
50
|
+
throw new Error(prefixCheck);
|
|
40
51
|
const apiPrefix = (0, naming_1.normalizeApiPrefix)(apiPrefixRaw);
|
|
52
|
+
// Everything is listed whether it was asked or passed, so a flag never
|
|
53
|
+
// reaches the project without the caller seeing the value it produced.
|
|
54
|
+
const fromFlag = (passed) => (passed !== undefined ? " (from flag)" : "");
|
|
41
55
|
console.log("\nSummary:");
|
|
42
|
-
console.log(` Docker + PostgreSQL: ${docker ? "yes" : "no"}`);
|
|
43
|
-
console.log(` OpenAPI docs: ${openapiDocs ? "yes" : "no"}`);
|
|
44
|
-
console.log(` Metrics + tracing: ${observability ? "yes" : "no"}`);
|
|
45
|
-
console.log(` Route prefix: ${apiPrefix ? `/${apiPrefix}` : "(none)"}`);
|
|
46
|
-
const proceed = await (0,
|
|
56
|
+
console.log(` Docker + PostgreSQL: ${docker ? "yes" : "no"}${fromFlag(preset.docker)}`);
|
|
57
|
+
console.log(` OpenAPI docs: ${openapiDocs ? "yes" : "no"}${fromFlag(preset.openapiDocs)}`);
|
|
58
|
+
console.log(` Metrics + tracing: ${observability ? "yes" : "no"}${fromFlag(preset.observability)}`);
|
|
59
|
+
console.log(` Route prefix: ${apiPrefix ? `/${apiPrefix}` : "(none)"}${fromFlag(preset.apiPrefix)}`);
|
|
60
|
+
const proceed = await (0, interactive_1.confirm)({ message: "\nCreate project with these settings?", default: true });
|
|
47
61
|
if (!proceed) {
|
|
48
62
|
throw new Error("project creation cancelled");
|
|
49
63
|
}
|
|
@@ -10,7 +10,7 @@ exports.promptModuleShape = promptModuleShape;
|
|
|
10
10
|
exports.promptModuleAuth = promptModuleAuth;
|
|
11
11
|
exports.promptModulePermission = promptModulePermission;
|
|
12
12
|
exports.promptExistingModule = promptExistingModule;
|
|
13
|
-
const
|
|
13
|
+
const interactive_1 = require("./interactive");
|
|
14
14
|
const naming_1 = require("../utils/naming");
|
|
15
15
|
// wraps an assert-style validator into inquirer's true|string contract so a
|
|
16
16
|
// reserved word re-prompts inline instead of aborting the whole command.
|
|
@@ -24,21 +24,21 @@ function notKeyword(value, role) {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
async function promptModuleName() {
|
|
27
|
-
const name = await (0,
|
|
27
|
+
const name = await (0, interactive_1.input)({
|
|
28
28
|
message: "Module name (singular, e.g. order, product):",
|
|
29
29
|
validate: (value) => (value.trim() ? (0, naming_1.validateModuleName)(value) : "module name is required"),
|
|
30
30
|
});
|
|
31
31
|
return name.trim();
|
|
32
32
|
}
|
|
33
33
|
async function promptMethodName() {
|
|
34
|
-
const name = await (0,
|
|
34
|
+
const name = await (0, interactive_1.input)({
|
|
35
35
|
message: "Method name (e.g. approve, findByStatus, resetPassword):",
|
|
36
36
|
validate: (value) => (value.trim() ? notKeyword(value, "method") : "method name is required"),
|
|
37
37
|
});
|
|
38
38
|
return name.trim();
|
|
39
39
|
}
|
|
40
40
|
async function promptMethodType() {
|
|
41
|
-
return (0,
|
|
41
|
+
return (0, interactive_1.select)({
|
|
42
42
|
message: "Method type:",
|
|
43
43
|
choices: [
|
|
44
44
|
{ name: "GET", value: "get" },
|
|
@@ -50,7 +50,7 @@ async function promptMethodType() {
|
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
52
|
async function promptGetMode() {
|
|
53
|
-
return (0,
|
|
53
|
+
return (0, interactive_1.select)({
|
|
54
54
|
message: "GET mode:",
|
|
55
55
|
choices: [
|
|
56
56
|
{ name: "List (all) — a new list endpoint with its own filter", value: "all" },
|
|
@@ -59,14 +59,14 @@ async function promptGetMode() {
|
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
async function promptMigrationName() {
|
|
62
|
-
const name = await (0,
|
|
62
|
+
const name = await (0, interactive_1.input)({
|
|
63
63
|
message: "Migration name (e.g. add_status_to_orders):",
|
|
64
64
|
validate: (value) => (value.trim() ? true : "migration name is required"),
|
|
65
65
|
});
|
|
66
66
|
return name.trim();
|
|
67
67
|
}
|
|
68
68
|
async function promptLookupField() {
|
|
69
|
-
const field = await (0,
|
|
69
|
+
const field = await (0, interactive_1.input)({
|
|
70
70
|
message: "Lookup field (e.g. email, status, slug):",
|
|
71
71
|
validate: (value) => {
|
|
72
72
|
if (!value.trim())
|
|
@@ -84,7 +84,7 @@ async function promptLookupField() {
|
|
|
84
84
|
// reachable by knowing the flag name isn't a choice for anyone driving this
|
|
85
85
|
// from the menu.
|
|
86
86
|
async function promptModuleShape() {
|
|
87
|
-
return (0,
|
|
87
|
+
return (0, interactive_1.select)({
|
|
88
88
|
message: "What should the module contain?",
|
|
89
89
|
default: false,
|
|
90
90
|
choices: [
|
|
@@ -102,7 +102,7 @@ async function promptModuleShape() {
|
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
async function promptModuleAuth() {
|
|
105
|
-
return (0,
|
|
105
|
+
return (0, interactive_1.confirm)({
|
|
106
106
|
message: "Require a valid access token for this module's routes?",
|
|
107
107
|
default: false,
|
|
108
108
|
});
|
|
@@ -111,7 +111,7 @@ async function promptModuleAuth() {
|
|
|
111
111
|
// takes no validate — an unusable code is caught by generateModule, which
|
|
112
112
|
// owns the pattern and the "needs add rbac" rule.
|
|
113
113
|
async function promptModulePermission() {
|
|
114
|
-
const code = await (0,
|
|
114
|
+
const code = await (0, interactive_1.input)({
|
|
115
115
|
message: "Also require a permission code — leave blank for none, or e.g. products:manage:",
|
|
116
116
|
});
|
|
117
117
|
return code.trim() || undefined;
|
|
@@ -126,7 +126,7 @@ async function promptExistingModule(packages, action) {
|
|
|
126
126
|
if (packages.length === 0) {
|
|
127
127
|
throw new Error(`no modules in internal/app yet — run \`go-scaffold generate module <name>\` before trying to ${action} one`);
|
|
128
128
|
}
|
|
129
|
-
return (0,
|
|
129
|
+
return (0, interactive_1.select)({
|
|
130
130
|
message: "Which module?",
|
|
131
131
|
choices: packages.map((pkg) => ({ name: pkg, value: pkg })),
|
|
132
132
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.select = exports.input = exports.confirm = exports.NO_TTY_MESSAGE = void 0;
|
|
4
|
+
exports.assertInteractive = assertInteractive;
|
|
5
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
6
|
+
exports.NO_TTY_MESSAGE = "no interactive terminal to prompt on — pass every value as an argument/flag (see --help), or add --defaults";
|
|
7
|
+
// @inquirer/prompts does reject on a non-TTY stdin, but only after it has
|
|
8
|
+
// written the question and its cursor escapes: a CI log then ends with a
|
|
9
|
+
// question nobody can answer, followed by raw ANSI, followed by the real
|
|
10
|
+
// error. Answering the no-TTY case before the prompt starts leaves only the
|
|
11
|
+
// line that says what to do instead.
|
|
12
|
+
//
|
|
13
|
+
// Every prompt in this CLI is imported from here rather than from
|
|
14
|
+
// @inquirer/prompts directly, so a prompt added later gets this for free.
|
|
15
|
+
// Exported for a caller that prints something before its first prompt: the
|
|
16
|
+
// header belongs to a wizard that cannot run at all here, so it should not be
|
|
17
|
+
// printed either.
|
|
18
|
+
function assertInteractive() {
|
|
19
|
+
if (!process.stdin.isTTY)
|
|
20
|
+
throw new Error(exports.NO_TTY_MESSAGE);
|
|
21
|
+
}
|
|
22
|
+
const confirm = (...args) => {
|
|
23
|
+
assertInteractive();
|
|
24
|
+
return (0, prompts_1.confirm)(...args);
|
|
25
|
+
};
|
|
26
|
+
exports.confirm = confirm;
|
|
27
|
+
const input = (...args) => {
|
|
28
|
+
assertInteractive();
|
|
29
|
+
return (0, prompts_1.input)(...args);
|
|
30
|
+
};
|
|
31
|
+
exports.input = input;
|
|
32
|
+
const select = (...args) => {
|
|
33
|
+
assertInteractive();
|
|
34
|
+
return (0, prompts_1.select)(...args);
|
|
35
|
+
};
|
|
36
|
+
exports.select = select;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.promptQueueBackend = promptQueueBackend;
|
|
4
|
-
const
|
|
4
|
+
const interactive_1 = require("./interactive");
|
|
5
5
|
// The one decision `add worker` cannot make for you: where jobs live. It
|
|
6
6
|
// drives which adapter is written, whether Redis is added to the project at
|
|
7
7
|
// all, and whether an enqueue can join a database transaction.
|
|
8
8
|
async function promptQueueBackend() {
|
|
9
|
-
return (0,
|
|
9
|
+
return (0, interactive_1.select)({
|
|
10
10
|
message: "Where should background jobs be stored?",
|
|
11
11
|
default: "river",
|
|
12
12
|
choices: [
|
package/dist/utils/config.js
CHANGED
|
@@ -18,12 +18,58 @@ function writeConfig(projectDir, config) {
|
|
|
18
18
|
}
|
|
19
19
|
// readConfig falls back to detecting from go.mod when the config file is
|
|
20
20
|
// missing (e.g. a project scaffolded before this file existed).
|
|
21
|
+
//
|
|
22
|
+
// When the file IS present, any feature key it does not define is filled in
|
|
23
|
+
// from the tree. A file that exists but is missing a key used to mean "false"
|
|
24
|
+
// to every caller, and callers then guessed: `add auth` guessed the queue
|
|
25
|
+
// backend and wrote `queue.NewAsynqEnqueuer` into a River-only project (a
|
|
26
|
+
// symbol that does not exist — and the post-patch gate is parse-only, so the
|
|
27
|
+
// CLI reported success over a project that no longer builds), while
|
|
28
|
+
// `undo module user` read a missing `auth` key as "not auth's" and deleted the
|
|
29
|
+
// whole auth domain. The tree always knew the answer; this stops the guessing.
|
|
30
|
+
//
|
|
31
|
+
// The file still wins wherever it has a value — an explicit `false` is an
|
|
32
|
+
// answer, not a hole.
|
|
21
33
|
function readConfig(projectDir) {
|
|
22
34
|
const file = configPath(projectDir);
|
|
23
|
-
if (fs_extra_1.default.existsSync(file))
|
|
24
|
-
return
|
|
35
|
+
if (!fs_extra_1.default.existsSync(file))
|
|
36
|
+
return detectConfig(projectDir);
|
|
37
|
+
const config = fs_extra_1.default.readJsonSync(file);
|
|
38
|
+
const detected = detectFeatures(projectDir);
|
|
39
|
+
const features = { ...config.features };
|
|
40
|
+
for (const key of Object.keys(detected)) {
|
|
41
|
+
if (features[key] === undefined)
|
|
42
|
+
features[key] = detected[key];
|
|
25
43
|
}
|
|
26
|
-
return
|
|
44
|
+
return { ...config, features };
|
|
45
|
+
}
|
|
46
|
+
// detectFeatures answers "what is actually installed here" from the tree
|
|
47
|
+
// alone. Every `add` command leaves a directory or a file behind that nothing
|
|
48
|
+
// else writes, which is what makes this reliable.
|
|
49
|
+
//
|
|
50
|
+
// auth and rbac are keyed on their middleware, not on internal/app/{user,role}:
|
|
51
|
+
// those directories are also what `generate module user` / `generate module
|
|
52
|
+
// role` produce, and mistaking one for the other would make `undo module`
|
|
53
|
+
// refuse to remove a module it generated itself.
|
|
54
|
+
function detectFeatures(projectDir) {
|
|
55
|
+
const has = (...segments) => fs_extra_1.default.existsSync(path_1.default.join(projectDir, ...segments));
|
|
56
|
+
const worker = has("internal", "platform", "queue");
|
|
57
|
+
const auth = has("internal", "app", "user") && has("internal", "shared", "middleware", "auth.go");
|
|
58
|
+
return {
|
|
59
|
+
docker: has("docker-compose.yml"),
|
|
60
|
+
openapiDocs: has("docs", "openapi.yaml"),
|
|
61
|
+
worker,
|
|
62
|
+
// which adapter file is present is what `add worker --queue` decided
|
|
63
|
+
queue: !worker ? undefined : has("internal", "platform", "queue", "asynq.go") ? "asynq" : "river",
|
|
64
|
+
auth,
|
|
65
|
+
// which store `add auth` chose is readable from which implementation
|
|
66
|
+
// file it wrote — same trick as the queue adapter above. Projects from
|
|
67
|
+
// before the option existed have neither name and read as "redis",
|
|
68
|
+
// which is what they in fact are.
|
|
69
|
+
authStore: !auth ? undefined : has("internal", "app", "user", "tokenstore_pg.go") ? "postgres" : "redis",
|
|
70
|
+
rbac: has("internal", "app", "role") && has("internal", "shared", "middleware", "authz.go"),
|
|
71
|
+
observability: has("internal", "platform", "telemetry"),
|
|
72
|
+
};
|
|
27
73
|
}
|
|
28
74
|
function detectConfig(projectDir) {
|
|
29
75
|
const goModPath = path_1.default.join(projectDir, "go.mod");
|
|
@@ -60,31 +106,11 @@ function detectConfig(projectDir) {
|
|
|
60
106
|
// ("needs add worker first") while `add worker` also refused ("queue
|
|
61
107
|
// already exists"), with no way out. Worse, the first `add` to succeed then
|
|
62
108
|
// wrote a config that recorded the undetected features as absent.
|
|
63
|
-
const has = (...segments) => fs_extra_1.default.existsSync(path_1.default.join(projectDir, ...segments));
|
|
64
|
-
const worker = has("internal", "platform", "queue");
|
|
65
109
|
return {
|
|
66
110
|
projectName: path_1.default.basename(projectDir),
|
|
67
111
|
goModule,
|
|
68
112
|
apiPrefix,
|
|
69
|
-
features:
|
|
70
|
-
docker: has("docker-compose.yml"),
|
|
71
|
-
openapiDocs: has("docs", "openapi.yaml"),
|
|
72
|
-
worker,
|
|
73
|
-
// which adapter file is present is what `add worker --queue` decided
|
|
74
|
-
queue: !worker ? undefined : has("internal", "platform", "queue", "asynq.go") ? "asynq" : "river",
|
|
75
|
-
auth: has("internal", "app", "user"),
|
|
76
|
-
// which store `add auth` chose is readable from which implementation
|
|
77
|
-
// file it wrote — same trick as the queue adapter above. Projects from
|
|
78
|
-
// before the option existed have neither name and read as "redis",
|
|
79
|
-
// which is what they in fact are.
|
|
80
|
-
authStore: !has("internal", "app", "user")
|
|
81
|
-
? undefined
|
|
82
|
-
: has("internal", "app", "user", "tokenstore_pg.go")
|
|
83
|
-
? "postgres"
|
|
84
|
-
: "redis",
|
|
85
|
-
rbac: has("internal", "app", "role"),
|
|
86
|
-
observability: has("internal", "platform", "telemetry"),
|
|
87
|
-
},
|
|
113
|
+
features: detectFeatures(projectDir),
|
|
88
114
|
};
|
|
89
115
|
}
|
|
90
116
|
// isProjectDir answers "would readConfig succeed here?" without throwing, so
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.assertMethodAbsent = assertMethodAbsent;
|
|
6
7
|
exports.patchMethod = patchMethod;
|
|
7
8
|
exports.markersPresent = markersPresent;
|
|
8
9
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
@@ -38,13 +39,19 @@ function assertNotDuplicate(content, needle, what) {
|
|
|
38
39
|
function routeCall(type) {
|
|
39
40
|
return { get: "GET", post: "POST", put: "PUT", patch: "PATCH", delete: "DELETE" }[type];
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
+
// assertMethodAbsent is the same check patchMethod runs, exported so callers
|
|
43
|
+
// can reach it before their own pre-flight checks. A name that already exists
|
|
44
|
+
// is the cause the caller needs to hear about, so it has to be reported ahead
|
|
45
|
+
// of anything downstream of it (the OpenAPI document that name would collide
|
|
46
|
+
// with, for one) rather than after.
|
|
47
|
+
function assertMethodAbsent(paths, method) {
|
|
42
48
|
const handlerSig = `func (h *Handler) ${method.handlerName}(`;
|
|
43
49
|
const serviceSig = `func (s *Service) ${method.pascalName}(`;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
assertNotDuplicate(fs_extra_1.default.readFileSync(paths.handlerPath, "utf8"), handlerSig, `handler method "${method.handlerName}"`);
|
|
51
|
+
assertNotDuplicate(fs_extra_1.default.readFileSync(paths.servicePath, "utf8"), serviceSig, `service method "${method.pascalName}"`);
|
|
52
|
+
}
|
|
53
|
+
function patchMethod(paths, naming, method, opts, goModule) {
|
|
54
|
+
assertMethodAbsent(paths, method);
|
|
48
55
|
if (opts.type === "get" && opts.getMode === "all") {
|
|
49
56
|
patchGetAll(paths, naming, method, goModule);
|
|
50
57
|
}
|
|
@@ -10,6 +10,7 @@ exports.patchConfigForWorker = patchConfigForWorker;
|
|
|
10
10
|
exports.patchConfigForSMTP = patchConfigForSMTP;
|
|
11
11
|
exports.patchMainGoForWorker = patchMainGoForWorker;
|
|
12
12
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
13
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
13
14
|
const marker_patch_1 = require("./marker-patch");
|
|
14
15
|
const IMPORT_MARKER = "// go-scaffold:imports";
|
|
15
16
|
const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
|
|
@@ -58,9 +59,17 @@ function patchCiForRedis(ciPath) {
|
|
|
58
59
|
return;
|
|
59
60
|
// `steps:` sits one level under the job, so it's the first line that ends
|
|
60
61
|
// the `services:` block — insert the service just above it.
|
|
62
|
+
//
|
|
63
|
+
// Anchored on the exact indentation the template emits. A hand-reformatted
|
|
64
|
+
// workflow is the realistic way to miss it, and returning silently there
|
|
65
|
+
// means CI runs without Redis and fails on a connection error that says
|
|
66
|
+
// nothing about this — so say it here instead.
|
|
61
67
|
const stepsLine = content.split("\n").find((l) => l.trimEnd() === " steps:");
|
|
62
|
-
if (!stepsLine)
|
|
68
|
+
if (!stepsLine) {
|
|
69
|
+
console.error(picocolors_1.default.yellow(`skipped adding the Redis service to ${ciPath} — no \`steps:\` line at the expected indentation to anchor it to.\n` +
|
|
70
|
+
`Add a redis service to the workflow's \`services:\` block by hand, or CI will run without one.`));
|
|
63
71
|
return;
|
|
72
|
+
}
|
|
64
73
|
const service = [
|
|
65
74
|
" redis:",
|
|
66
75
|
" image: redis:7-alpine",
|
package/package.json
CHANGED
|
@@ -46,12 +46,24 @@ missing AutoMigrate wiring, a `repository` interface out of sync with its
|
|
|
46
46
|
Run from the project root:
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
go-scaffold generate module <name> [--full] [--auth] [--permission <code>]
|
|
49
|
+
go-scaffold generate module <name> --defaults [--full] [--auth] [--permission <code>]
|
|
50
50
|
go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]
|
|
51
51
|
go-scaffold generate migration <name>
|
|
52
|
-
go-scaffold undo module <name>
|
|
52
|
+
go-scaffold undo module <name> -y
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
**Every value has to be a flag.** These commands prompt for whatever you
|
|
56
|
+
leave out, and a prompt in a non-interactive shell exits 1 having written
|
|
57
|
+
nothing — so a bare `generate module products` fails rather than taking the
|
|
58
|
+
default. `--defaults` supplies the unasked-for answers (minimal, no auth);
|
|
59
|
+
combine it with `--full`/`--auth`/`--permission` when you want those. The
|
|
60
|
+
same applies to `--get-mode` (required with `--type get`) and `-y` on
|
|
61
|
+
`undo module`. Every `add` command summarises what it will write and asks
|
|
62
|
+
before writing: `-y` skips that confirmation, but `add auth` and `add worker`
|
|
63
|
+
*also* ask which backing store to use — so those two need `--defaults`
|
|
64
|
+
(or their own `--store`/`--queue` flag plus `-y`), while `add rbac -y` and
|
|
65
|
+
`add observability -y` are enough.
|
|
66
|
+
|
|
55
67
|
`<name>` for a module is a domain noun in whatever form reads naturally —
|
|
56
68
|
singular or plural, any case, hyphens or underscores are all accepted and
|
|
57
69
|
normalized (`products` → `product`, `Orders` → `order`, `order-item` →
|
|
@@ -79,7 +91,7 @@ is actually intended — otherwise prefer minimal plus explicit `generate method
|
|
|
79
91
|
calls.
|
|
80
92
|
|
|
81
93
|
`--auth` puts the module's routes behind a valid access token (requires
|
|
82
|
-
`go-scaffold add auth` in this project first). `--permission <code>` also
|
|
94
|
+
`go-scaffold add auth -y` in this project first). `--permission <code>` also
|
|
83
95
|
requires that permission via `authz.Require` and seeds it in its own
|
|
84
96
|
migration — it needs `add rbac`, and `--auth` must be passed alongside it.
|
|
85
97
|
|
|
@@ -114,7 +126,7 @@ domain decided against. Deletes `internal/app/<pkg>/`, the module's
|
|
|
114
126
|
`migrations/<version>_create_<plural>.{up,down}.sql` pair, and reverses
|
|
115
127
|
everything `generate module` wired up in `cmd/api/wiring.go` (and in
|
|
116
128
|
`docs/openapi.yaml` + `docs/<plural>/` when OpenAPI is enabled). `-y` skips
|
|
117
|
-
the confirmation prompt.
|
|
129
|
+
the confirmation prompt, and is required in a non-interactive shell.
|
|
118
130
|
|
|
119
131
|
The migration files go because `migrations/embed.go` is a `//go:embed *`: a
|
|
120
132
|
typo's migration left behind runs on every database created from then on.
|
|
@@ -11,14 +11,24 @@ package with its own model/handler/service/repository), or add a **new**
|
|
|
11
11
|
endpoint to an existing one — stop and run the CLI instead:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
go-scaffold generate module <name>
|
|
14
|
+
go-scaffold generate module <name> --defaults
|
|
15
15
|
go-scaffold generate method <module> <name> --type <get|post|put|patch|delete>
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
**Pass every value as a flag.** Run interactively, these commands ask for
|
|
19
|
+
anything you left out — which has no answer in a non-interactive shell, so
|
|
20
|
+
they exit 1 having written nothing. `--defaults` on `generate module` means
|
|
21
|
+
"minimal, no auth, ask nothing"; add `--full`/`--auth`/`--permission` to it
|
|
22
|
+
when you want those. Same rule elsewhere: `--get-mode` is required with
|
|
23
|
+
`--type get`, and `undo module` needs `-y`. Every `add` command confirms
|
|
24
|
+
before writing — `-y` skips that, but `add auth`/`add worker` also ask which
|
|
25
|
+
backing store to use, so those two want `--defaults` (or `--store`/`--queue`
|
|
26
|
+
plus `-y`).
|
|
27
|
+
|
|
18
28
|
This applies **even when the request doesn't say "module"/"method" or name
|
|
19
29
|
the CLI at all**. Recognize indirect asks as generation work, for example:
|
|
20
30
|
|
|
21
|
-
- "add a products feature" → a new domain (`generate module products`)
|
|
31
|
+
- "add a products feature" → a new domain (`generate module products --defaults`)
|
|
22
32
|
- "let admins approve orders" → a new method on an existing module
|
|
23
33
|
(`generate method orders approve --type patch`)
|
|
24
34
|
- "we need an endpoint that lists overdue invoices" → a new `get` method
|
|
@@ -45,7 +55,7 @@ hand-rolling anything that looks like scaffolding.
|
|
|
45
55
|
|
|
46
56
|
## Command quick reference
|
|
47
57
|
|
|
48
|
-
- `go-scaffold generate module <name
|
|
58
|
+
- `go-scaffold generate module <name> --defaults` — safe minimal model + errors +
|
|
49
59
|
repository + service/handler plumbing, wired into `cmd/api/wiring.go` and
|
|
50
60
|
appended to `migrations/`. Add endpoints one at a time with `generate
|
|
51
61
|
method`, or pass `--full` to opt into a CRUD skeleton with TODO DTO fields
|
|
@@ -55,7 +65,7 @@ hand-rolling anything that looks like scaffolding.
|
|
|
55
65
|
with the same name — pick a different one if it collides. With OpenAPI
|
|
56
66
|
enabled it also writes a valid TODO path document and wires the index;
|
|
57
67
|
replace placeholder schemas while implementing the method
|
|
58
|
-
- `go-scaffold undo module <name
|
|
68
|
+
- `go-scaffold undo module <name> -y` — takes back a `generate module` that
|
|
59
69
|
shouldn't have happened (typo'd name, domain decided against): deletes the
|
|
60
70
|
package, un-wires main.go/OpenAPI, and deletes the module's migration files.
|
|
61
71
|
It refuses if those migrations are committed to git or already applied to
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
# docker build --target api -t {{projectName}}-api .
|
|
5
5
|
# docker build --target worker -t {{projectName}}-worker .
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# api is the last stage on purpose: a plain `docker build .` builds whatever
|
|
8
|
+
# comes last, and that has to be the binary every project has. The worker
|
|
9
|
+
# stage only resolves once `go-scaffold add worker` has created cmd/worker —
|
|
10
|
+
# ask for it by name, and only then.
|
|
9
11
|
FROM golang:1.25-alpine AS build
|
|
10
12
|
WORKDIR /src
|
|
11
13
|
|
|
@@ -23,6 +25,12 @@ ENV CGO_ENABLED=0
|
|
|
23
25
|
RUN go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
|
|
24
26
|
RUN if [ -d ./cmd/worker ]; then go build -trimpath -ldflags="-s -w" -o /out/worker ./cmd/worker; fi
|
|
25
27
|
|
|
28
|
+
FROM gcr.io/distroless/static-debian12:nonroot AS worker
|
|
29
|
+
WORKDIR /app
|
|
30
|
+
COPY --from=build /out/worker /app/worker
|
|
31
|
+
USER nonroot:nonroot
|
|
32
|
+
ENTRYPOINT ["/app/worker"]
|
|
33
|
+
|
|
26
34
|
# nonroot, and no shell: there is nothing in this image to exec into if
|
|
27
35
|
# something gets in. Debug with `docker run --entrypoint` against the build
|
|
28
36
|
# stage instead.
|
|
@@ -34,9 +42,3 @@ COPY --from=build /out/api /app/api
|
|
|
34
42
|
USER nonroot:nonroot
|
|
35
43
|
EXPOSE 8080
|
|
36
44
|
ENTRYPOINT ["/app/api"]
|
|
37
|
-
|
|
38
|
-
FROM gcr.io/distroless/static-debian12:nonroot AS worker
|
|
39
|
-
WORKDIR /app
|
|
40
|
-
COPY --from=build /out/worker /app/worker
|
|
41
|
-
USER nonroot:nonroot
|
|
42
|
-
ENTRYPOINT ["/app/worker"]
|
|
@@ -166,7 +166,7 @@ TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=
|
|
|
166
166
|
|
|
167
167
|
## API spec
|
|
168
168
|
|
|
169
|
-
`docs/openapi.yaml` is hand-written and served, along with every file it `$ref`s (`common/`, `health/`, per-domain folders), under `/docs` while the server runs — point a renderer (Scalar, Swagger UI, Redoc) or a client generator (Hey API) at `http://localhost:8080/docs/openapi.yaml
|
|
169
|
+
`docs/openapi.yaml` is hand-written and served, along with every file it `$ref`s (`common/`, `health/`, per-domain folders), under `/docs` while the server runs — point a renderer (Scalar, Swagger UI, Redoc) or a client generator (Hey API) at `http://localhost:8080/docs/openapi.yaml`, or browse the tree at `http://localhost:8080/docs/`. Update the spec by hand whenever an endpoint/DTO changes.
|
|
170
170
|
|
|
171
171
|
Some tools don't resolve external `$ref`s when importing a local file — they read it as-is and see zero routes (Bruno's "Import Collection" does this; some Postman flows too). For those, bundle the spec into one fully-resolved file first:
|
|
172
172
|
|
|
@@ -197,6 +197,10 @@ docker build --target api -t {{projectName}}-api .
|
|
|
197
197
|
docker build --target worker -t {{projectName}}-worker . # once `add worker` exists
|
|
198
198
|
```
|
|
199
199
|
|
|
200
|
+
`api` is the last stage, so a plain `docker build .` builds it — the worker
|
|
201
|
+
stage has to be asked for by name, and only resolves once `add worker` has
|
|
202
|
+
created `cmd/worker`.
|
|
203
|
+
|
|
200
204
|
Both stages are distroless and run as `nonroot`, so there is no shell in either
|
|
201
205
|
image. Debug against the `build` stage instead:
|
|
202
206
|
`docker run --rm -it --entrypoint sh $(docker build -q --target build .)`.
|
|
@@ -94,11 +94,17 @@ func run() error {
|
|
|
94
94
|
// resolves $ref over HTTP (Scalar, Swagger UI, Redoc, Hey API pointed at a URL) can reach
|
|
95
95
|
// them too; StaticFile on just the index file would 404 on every $ref it follows.
|
|
96
96
|
//
|
|
97
|
+
// gin.Dir(..., true) keeps directory listings on, which r.Static suppresses:
|
|
98
|
+
// without them opening /docs/ in a browser answers 404 with a blank page,
|
|
99
|
+
// which reads as "this is broken" rather than "ask for a file". The listing
|
|
100
|
+
// costs nothing here — every file in the tree is already downloadable — and
|
|
101
|
+
// it makes openapi.yaml discoverable without knowing its name up front.
|
|
102
|
+
//
|
|
97
103
|
// Not in production: this serves your whole API surface — every path,
|
|
98
104
|
// parameter and schema — to anyone who asks. Publish the spec deliberately
|
|
99
105
|
// (`make openapi-bundle`) rather than by leaving this on.
|
|
100
106
|
if !cfg.IsProd() {
|
|
101
|
-
r.
|
|
107
|
+
r.StaticFS("/docs", gin.Dir("./docs", true))
|
|
102
108
|
}
|
|
103
109
|
{{/if}}
|
|
104
110
|
|
|
@@ -133,6 +133,15 @@ newer Gin (→ HTTP/3/quic-go) and every DB driver they support tracing for
|
|
|
133
133
|
(MySQL, ClickHouse, MongoDB), respectively, for a Postgres-only project that
|
|
134
134
|
only wants request/query spans.
|
|
135
135
|
|
|
136
|
+
**Scope: `cmd/api` only.** `cmd/worker` is not instrumented. The GORM plugin
|
|
137
|
+
lives in `database.Open`, which the worker also calls, so it does raise spans —
|
|
138
|
+
but `telemetry.Init` is the only thing that calls `otel.SetTracerProvider` and
|
|
139
|
+
it runs in `cmd/api` alone, so those spans reach a no-op provider and are
|
|
140
|
+
discarded. The worker serves no `/metrics` either. To change that, call
|
|
141
|
+
`telemetry.Init` from `cmd/worker/main.go` — split it into `main`/`run` first,
|
|
142
|
+
the way `cmd/api` is, or the `defer` that flushes the exporter on shutdown is
|
|
143
|
+
skipped by the `os.Exit` calls already in there.
|
|
144
|
+
|
|
136
145
|
{{/if}}
|
|
137
146
|
## Evolution Notes
|
|
138
147
|
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
- Graceful shutdown, structured logging, `/livez` + `/readyz`: always enabled
|
|
24
24
|
- Docker Compose (local Postgres): `{{#if docker}}enabled{{else}}disabled{{/if}}`
|
|
25
25
|
- OpenAPI docs (`docs/openapi.yaml`, whole `docs/` tree served at `/docs`): `{{#if openapiDocs}}enabled{{else}}disabled{{/if}}`
|
|
26
|
-
- Metrics + tracing (Prometheus `/metrics`, OpenTelemetry for Gin + GORM): `{{#if observability}}enabled{{else}}disabled{{/if}}`
|
|
26
|
+
- Metrics + tracing (Prometheus `/metrics`, OpenTelemetry for Gin + GORM, `cmd/api` only): `{{#if observability}}enabled{{else}}disabled{{/if}}`
|
|
27
27
|
- API route prefix: `{{#if apiPrefix}}/{{apiPrefix}}{{else}}(none){{/if}}`
|
|
28
28
|
- CI (`.github/workflows/ci.yml` — build, vet, gofmt check, golangci-lint, `go test` with a real Postgres service): always enabled
|
|
29
29
|
|