@nakedev/go-scaffold 0.3.1 → 0.4.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/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/observability-patcher.js +2 -2
- package/dist/utils/platform-patcher.js +10 -1
- package/package.json +1 -1
- package/templates/create/base/Dockerfile.hbs +10 -8
- package/templates/create/base/README.md.hbs +7 -3
- package/templates/create/base/cmd/api/wiring.go.hbs +7 -11
- package/templates/create/features/docs/architecture.md.hbs +12 -3
- package/templates/create/features/docs/techstack.md.hbs +2 -2
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 — files only, never served over HTTP)?",
|
|
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
|
}
|
|
@@ -49,8 +49,8 @@ function patchMainGoForObservability(mainGoPath, goModule, projectName) {
|
|
|
49
49
|
}
|
|
50
50
|
const newUseLine = `${USE_LINE.slice(0, -1)}, middleware.Metrics(), middleware.Tracing("${projectName}"))`;
|
|
51
51
|
content = content.replace(USE_LINE, () => newUseLine);
|
|
52
|
-
//
|
|
53
|
-
// want a scrape target, and Prometheus reaches it in-cluster. It is still
|
|
52
|
+
// Unlike docs/, which is never served at all: production is exactly where
|
|
53
|
+
// you want a scrape target, and Prometheus reaches it in-cluster. It is still
|
|
54
54
|
// unauthenticated and does disclose your route list and traffic shape, so
|
|
55
55
|
// block /metrics at the ingress rather than publishing it to the internet.
|
|
56
56
|
const metricsRoute = 'r.GET("/metrics", gin.WrapH(promhttp.Handler()))';
|
|
@@ -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
|
@@ -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,15 +166,15 @@ 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
|
|
169
|
+
`docs/openapi.yaml` is hand-written and split across sibling files (`common/`, `health/`, per-domain folders) via relative `$ref`. **The server never serves it** — there is no `/docs` route, in any environment, so the API surface and `docs/architect/`'s internal notes stay off the wire. Update the spec by hand whenever an endpoint/DTO changes.
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
To open it in a renderer (Scalar, Swagger UI, Redoc) or a client generator (Hey API), bundle it into one fully-resolved file and point the tool at that file. Bundling is also what importers that don't resolve external `$ref`s need — they read the index as-is and see zero routes (Bruno's "Import Collection" does this; some Postman flows too):
|
|
172
172
|
|
|
173
173
|
```bash
|
|
174
174
|
make openapi-bundle # writes docs/openapi.bundled.yaml (gitignored — regenerate anytime)
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
Then open `docs/openapi.bundled.yaml` — it needs no network and no running server.
|
|
178
178
|
{{/if}}
|
|
179
179
|
|
|
180
180
|
## Adding a domain
|
|
@@ -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 .)`.
|
|
@@ -89,17 +89,13 @@ func run() error {
|
|
|
89
89
|
})
|
|
90
90
|
// go-scaffold:extra-routes
|
|
91
91
|
{{#if openapiDocs}}
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// (`make openapi-bundle`) rather than by leaving this on.
|
|
100
|
-
if !cfg.IsProd() {
|
|
101
|
-
r.Static("/docs", "./docs")
|
|
102
|
-
}
|
|
92
|
+
// ponytail: docs/ is deliberately NOT served over HTTP, in any environment.
|
|
93
|
+
// The whole tree — every path, parameter and schema, plus docs/architect/'s
|
|
94
|
+
// internal design notes — used to be reachable at /docs outside production.
|
|
95
|
+
// Read the spec from the working copy instead: `make openapi-bundle` writes
|
|
96
|
+
// one fully-resolved docs/openapi.bundled.yaml for a renderer or client
|
|
97
|
+
// generator to open as a local file. Publish it deliberately if you want
|
|
98
|
+
// it public.
|
|
103
99
|
{{/if}}
|
|
104
100
|
|
|
105
101
|
api := r.Group("/{{apiPrefix}}")
|
|
@@ -104,9 +104,9 @@ insert, so it doesn't need `gen_random_uuid()`.
|
|
|
104
104
|
## 8. API Documentation
|
|
105
105
|
|
|
106
106
|
**Decision:** Hand-written OpenAPI spec (`docs/openapi.yaml`), split by
|
|
107
|
-
domain module,
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
domain module, read from the working copy only — the server serves no `/docs`
|
|
108
|
+
route in any environment. Renderers that follow relative `$ref`s get a
|
|
109
|
+
fully-resolved single file from `make openapi-bundle`.
|
|
110
110
|
**Rationale:** Cheap while the endpoint count is low; switch to
|
|
111
111
|
comment-generated (swaggo) if hand-updates start drifting.
|
|
112
112
|
{{/if}}
|
|
@@ -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
|
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
- PostgreSQL + GORM baseline: always enabled
|
|
23
23
|
- Graceful shutdown, structured logging, `/livez` + `/readyz`: always enabled
|
|
24
24
|
- Docker Compose (local Postgres): `{{#if docker}}enabled{{else}}disabled{{/if}}`
|
|
25
|
-
- OpenAPI docs (`docs/openapi.yaml`,
|
|
26
|
-
- Metrics + tracing (Prometheus `/metrics`, OpenTelemetry for Gin + GORM): `{{#if observability}}enabled{{else}}disabled{{/if}}`
|
|
25
|
+
- OpenAPI docs (`docs/openapi.yaml`, working-copy only — never served over HTTP): `{{#if openapiDocs}}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
|
|