@sundaysf/cli-v3 0.0.1
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 +302 -0
- package/dist/cli.js +1327 -0
- package/package.json +55 -0
- package/templates/api/.claude/agents/knex-table-implementer.md +36 -0
- package/templates/api/.claude/agents/sundays-backend-builder.md +32 -0
- package/templates/api/.env.example +25 -0
- package/templates/api/.github/workflows/ci.yaml +45 -0
- package/templates/api/.github/workflows/deploy.yaml +72 -0
- package/templates/api/.prettierignore +5 -0
- package/templates/api/.prettierrc +9 -0
- package/templates/api/.sundaysrc +8 -0
- package/templates/api/CLAUDE.md +81 -0
- package/templates/api/Dockerfile +17 -0
- package/templates/api/README.md +164 -0
- package/templates/api/_dockerignore +8 -0
- package/templates/api/_gitignore +23 -0
- package/templates/api/_package.json +58 -0
- package/templates/api/docker-compose.yml +21 -0
- package/templates/api/eslint.config.js +27 -0
- package/templates/api/jest.config.js +33 -0
- package/templates/api/jest.setup.js +25 -0
- package/templates/api/knexfile.ts +5 -0
- package/templates/api/src/app.ts +48 -0
- package/templates/api/src/common/__tests__/common.test.ts +116 -0
- package/templates/api/src/common/config/env.ts +53 -0
- package/templates/api/src/common/errors/http.error.ts +30 -0
- package/templates/api/src/common/logger/index.ts +25 -0
- package/templates/api/src/common/utils/environment.resolver.ts +7 -0
- package/templates/api/src/common/utils/pagination.ts +25 -0
- package/templates/api/src/common/utils/version.resolver.ts +24 -0
- package/templates/api/src/common/validation/parse-dto.ts +20 -0
- package/templates/api/src/controllers/health/__tests__/health.controller.test.ts +52 -0
- package/templates/api/src/controllers/health/health.controller.ts +26 -0
- package/templates/api/src/db/BaseDAO.ts +92 -0
- package/templates/api/src/db/KnexConnection.ts +59 -0
- package/templates/api/src/db/__tests__/base-dao.test.ts +73 -0
- package/templates/api/src/db/__tests__/index.barrel.test.ts +10 -0
- package/templates/api/src/db/__tests__/knex-connection.test.ts +90 -0
- package/templates/api/src/db/d.types.ts +42 -0
- package/templates/api/src/db/dao/sundays-package-version/sundays-package-version.dao.ts +12 -0
- package/templates/api/src/db/index.ts +17 -0
- package/templates/api/src/db/interfaces/sundays-package-version/sundays-package-version.interfaces.ts +5 -0
- package/templates/api/src/db/knex.config.ts +46 -0
- package/templates/api/src/dto/input/.gitkeep +0 -0
- package/templates/api/src/jobs/.gitkeep +0 -0
- package/templates/api/src/middlewares/error/__tests__/error.middleware.test.ts +117 -0
- package/templates/api/src/middlewares/error/error.middleware.ts +70 -0
- package/templates/api/src/middlewares/not-found/__tests__/not-found.middleware.test.ts +54 -0
- package/templates/api/src/middlewares/not-found/not-found.middleware.ts +51 -0
- package/templates/api/src/middlewares/request-id/__tests__/request-id.middleware.test.ts +31 -0
- package/templates/api/src/middlewares/request-id/request-id.middleware.ts +20 -0
- package/templates/api/src/migrations/20240101000000_create_sundays_package_version.ts +15 -0
- package/templates/api/src/routes/__tests__/index-router.test.ts +61 -0
- package/templates/api/src/routes/health/__tests__/health.routes.test.ts +22 -0
- package/templates/api/src/routes/health/health.router.ts +18 -0
- package/templates/api/src/routes/index.ts +77 -0
- package/templates/api/src/seeds/001_sundays_package_version.ts +14 -0
- package/templates/api/src/server.ts +56 -0
- package/templates/api/src/services/.gitkeep +0 -0
- package/templates/api/tsconfig.json +20 -0
- package/templates/api/tsconfig.spec.json +10 -0
- package/templates/api-auth/overlay.json +95 -0
- package/templates/api-auth/src/controllers/auth/__tests__/auth.controller.test.ts +194 -0
- package/templates/api-auth/src/controllers/auth/auth.controller.ts +109 -0
- package/templates/api-auth/src/db/dao/auth/auth.dao.ts +21 -0
- package/templates/api-auth/src/db/dao/user/user.dao.ts +24 -0
- package/templates/api-auth/src/db/interfaces/auth/auth.interfaces.ts +8 -0
- package/templates/api-auth/src/db/interfaces/user/user.interfaces.ts +11 -0
- package/templates/api-auth/src/dto/input/auth/auth.login.dto.ts +14 -0
- package/templates/api-auth/src/dto/input/auth/auth.register.dto.ts +19 -0
- package/templates/api-auth/src/middlewares/auth/__tests__/auth.middleware.test.ts +52 -0
- package/templates/api-auth/src/middlewares/auth/auth.middleware.ts +56 -0
- package/templates/api-auth/src/migrations/20240101000001_create_user.ts +18 -0
- package/templates/api-auth/src/migrations/20240101000002_create_auth.ts +24 -0
- package/templates/api-auth/src/routes/auth/__tests__/auth.routes.test.ts +83 -0
- package/templates/api-auth/src/routes/auth/auth.router.ts +28 -0
- package/templates/api-auth/src/services/jwt/__tests__/jwt.service.test.ts +32 -0
- package/templates/api-auth/src/services/jwt/jwt.service.ts +36 -0
- package/templates/api-auth/src/services/password/__tests__/password.service.test.ts +13 -0
- package/templates/api-auth/src/services/password/password.service.ts +14 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import pc4 from "picocolors";
|
|
6
|
+
|
|
7
|
+
// src/core/paths.ts
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import fs from "fs-extra";
|
|
11
|
+
var here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
var packageRoot = () => {
|
|
13
|
+
let dir = here;
|
|
14
|
+
for (let i = 0; i < 4; i++) {
|
|
15
|
+
if (fs.existsSync(path.join(dir, "package.json")) && fs.existsSync(path.join(dir, "templates"))) {
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
dir = path.dirname(dir);
|
|
19
|
+
}
|
|
20
|
+
throw new Error("Could not locate @sundaysf/cli-v3 package root (templates/ not found)");
|
|
21
|
+
};
|
|
22
|
+
var templatesDir = () => path.join(packageRoot(), "templates");
|
|
23
|
+
var templatePath = (name) => path.join(templatesDir(), name);
|
|
24
|
+
var cliVersion = () => {
|
|
25
|
+
const pkg = fs.readJsonSync(path.join(packageRoot(), "package.json"));
|
|
26
|
+
return pkg.version;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// src/commands/new.ts
|
|
30
|
+
import path4 from "path";
|
|
31
|
+
import { randomBytes } from "crypto";
|
|
32
|
+
import * as p2 from "@clack/prompts";
|
|
33
|
+
import fs5 from "fs-extra";
|
|
34
|
+
import pc2 from "picocolors";
|
|
35
|
+
|
|
36
|
+
// src/core/template-engine.ts
|
|
37
|
+
import path2 from "path";
|
|
38
|
+
import fs2 from "fs-extra";
|
|
39
|
+
var TOKEN_RE = /__SF_[A-Z0-9_]+__/g;
|
|
40
|
+
var RENAME_MAP = {
|
|
41
|
+
_gitignore: ".gitignore",
|
|
42
|
+
"_package.json": "package.json",
|
|
43
|
+
_dockerignore: ".dockerignore"
|
|
44
|
+
};
|
|
45
|
+
var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
46
|
+
".ts",
|
|
47
|
+
".js",
|
|
48
|
+
".cjs",
|
|
49
|
+
".mjs",
|
|
50
|
+
".json",
|
|
51
|
+
".md",
|
|
52
|
+
".yaml",
|
|
53
|
+
".yml",
|
|
54
|
+
".example",
|
|
55
|
+
".html",
|
|
56
|
+
".txt"
|
|
57
|
+
]);
|
|
58
|
+
var TEXT_BASENAMES = /* @__PURE__ */ new Set([
|
|
59
|
+
".sundaysrc",
|
|
60
|
+
".env.example",
|
|
61
|
+
".prettierrc",
|
|
62
|
+
".prettierignore",
|
|
63
|
+
"_gitignore",
|
|
64
|
+
".gitignore",
|
|
65
|
+
"_dockerignore",
|
|
66
|
+
".dockerignore",
|
|
67
|
+
"Dockerfile"
|
|
68
|
+
]);
|
|
69
|
+
var isTextFile = (file) => {
|
|
70
|
+
const base = path2.basename(file);
|
|
71
|
+
return TEXT_BASENAMES.has(base) || TEXT_EXTENSIONS.has(path2.extname(base));
|
|
72
|
+
};
|
|
73
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage", ".git"]);
|
|
74
|
+
var copyTemplate = async (src, dest) => {
|
|
75
|
+
await fs2.copy(src, dest, {
|
|
76
|
+
overwrite: false,
|
|
77
|
+
errorOnExist: false,
|
|
78
|
+
filter: (s) => !SKIP_DIRS.has(path2.basename(s))
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
var listFiles = async (dir) => {
|
|
82
|
+
const out = [];
|
|
83
|
+
const walk = async (d) => {
|
|
84
|
+
const entries = await fs2.readdir(d, { withFileTypes: true });
|
|
85
|
+
for (const e of entries) {
|
|
86
|
+
const full = path2.join(d, e.name);
|
|
87
|
+
if (e.isDirectory()) {
|
|
88
|
+
if (!SKIP_DIRS.has(e.name)) await walk(full);
|
|
89
|
+
} else {
|
|
90
|
+
out.push(full);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
await walk(dir);
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
var renderString = (content, tokens, file = "<string>") => {
|
|
98
|
+
return content.replace(TOKEN_RE, (match) => {
|
|
99
|
+
if (!(match in tokens)) {
|
|
100
|
+
throw new Error(`Unknown template token ${match} in ${file}`);
|
|
101
|
+
}
|
|
102
|
+
return tokens[match];
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
var renderTokens = async (dir, tokens) => {
|
|
106
|
+
const touched = [];
|
|
107
|
+
for (const file of await listFiles(dir)) {
|
|
108
|
+
if (!isTextFile(file)) continue;
|
|
109
|
+
const content = await fs2.readFile(file, "utf8");
|
|
110
|
+
if (!TOKEN_RE.test(content)) {
|
|
111
|
+
TOKEN_RE.lastIndex = 0;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
TOKEN_RE.lastIndex = 0;
|
|
115
|
+
const rendered = renderString(content, tokens, path2.relative(dir, file));
|
|
116
|
+
await fs2.writeFile(file, rendered, "utf8");
|
|
117
|
+
touched.push(file);
|
|
118
|
+
}
|
|
119
|
+
return touched;
|
|
120
|
+
};
|
|
121
|
+
var applyRenames = async (dir) => {
|
|
122
|
+
const renamed = [];
|
|
123
|
+
for (const file of await listFiles(dir)) {
|
|
124
|
+
const target = RENAME_MAP[path2.basename(file)];
|
|
125
|
+
if (!target) continue;
|
|
126
|
+
const dest = path2.join(path2.dirname(file), target);
|
|
127
|
+
await fs2.move(file, dest, { overwrite: true });
|
|
128
|
+
renamed.push(dest);
|
|
129
|
+
}
|
|
130
|
+
return renamed;
|
|
131
|
+
};
|
|
132
|
+
var assertNoLeftoverTokens = async (dir) => {
|
|
133
|
+
const leftovers = [];
|
|
134
|
+
for (const file of await listFiles(dir)) {
|
|
135
|
+
if (!isTextFile(file)) continue;
|
|
136
|
+
const content = await fs2.readFile(file, "utf8");
|
|
137
|
+
const found = content.match(TOKEN_RE);
|
|
138
|
+
if (found) leftovers.push(`${path2.relative(dir, file)}: ${[...new Set(found)].join(", ")}`);
|
|
139
|
+
}
|
|
140
|
+
if (leftovers.length) {
|
|
141
|
+
throw new Error(`Unrendered template tokens:
|
|
142
|
+
${leftovers.join("\n ")}`);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
var assertNoForbiddenFiles = async (dir, forbidden = [".npmrc"]) => {
|
|
146
|
+
for (const file of await listFiles(dir)) {
|
|
147
|
+
if (forbidden.includes(path2.basename(file))) {
|
|
148
|
+
throw new Error(`Forbidden file in generated project: ${path2.relative(dir, file)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// src/core/overlay.ts
|
|
154
|
+
import path3 from "path";
|
|
155
|
+
import fs4 from "fs-extra";
|
|
156
|
+
|
|
157
|
+
// src/core/inject.ts
|
|
158
|
+
import fs3 from "fs-extra";
|
|
159
|
+
var insertBeforeMarker = async (file, marker, lines) => {
|
|
160
|
+
if (!await fs3.pathExists(file)) {
|
|
161
|
+
throw new Error(`Cannot inject into "${file}": file does not exist`);
|
|
162
|
+
}
|
|
163
|
+
const original = await fs3.readFile(file, "utf8");
|
|
164
|
+
const eol = original.includes("\r\n") ? "\r\n" : "\n";
|
|
165
|
+
const fileLines = original.split(eol);
|
|
166
|
+
const markerIndex = fileLines.findIndex((l) => l.includes(marker));
|
|
167
|
+
if (markerIndex === -1) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Marker "${marker}" not found in ${file}. This file must keep the marker so sundaysf can extend it; restore the line and retry.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const existing = new Set(fileLines.map((l) => l.trim()));
|
|
173
|
+
const inserted = [];
|
|
174
|
+
const skipped = [];
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
if (line.trim() !== "" && existing.has(line.trim())) {
|
|
177
|
+
skipped.push(line);
|
|
178
|
+
} else {
|
|
179
|
+
inserted.push(line);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (inserted.length > 0) {
|
|
183
|
+
fileLines.splice(markerIndex, 0, ...inserted);
|
|
184
|
+
await fs3.writeFile(file, fileLines.join(eol), "utf8");
|
|
185
|
+
}
|
|
186
|
+
return { file, inserted, skipped };
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// src/core/overlay.ts
|
|
190
|
+
var OVERLAY_MANIFEST = "overlay.json";
|
|
191
|
+
var sortKeys = (obj) => Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
192
|
+
var mergePackageJson = async (file, patch) => {
|
|
193
|
+
const pkg = await fs4.readJson(file);
|
|
194
|
+
for (const key of ["dependencies", "devDependencies", "scripts"]) {
|
|
195
|
+
if (!patch[key]) continue;
|
|
196
|
+
const current = pkg[key] ?? {};
|
|
197
|
+
pkg[key] = sortKeys({ ...current, ...patch[key] });
|
|
198
|
+
}
|
|
199
|
+
await fs4.writeJson(file, pkg, { spaces: 2 });
|
|
200
|
+
};
|
|
201
|
+
var applyOverlay = async (overlayDir, projectDir) => {
|
|
202
|
+
const manifestPath = path3.join(overlayDir, OVERLAY_MANIFEST);
|
|
203
|
+
const manifest = await fs4.pathExists(manifestPath) ? await fs4.readJson(manifestPath) : {};
|
|
204
|
+
await fs4.copy(overlayDir, projectDir, {
|
|
205
|
+
overwrite: false,
|
|
206
|
+
errorOnExist: false,
|
|
207
|
+
filter: (src) => path3.basename(src) !== OVERLAY_MANIFEST
|
|
208
|
+
});
|
|
209
|
+
if (manifest.packageJson) {
|
|
210
|
+
const candidates = ["package.json", "_package.json"].map((f) => path3.join(projectDir, f));
|
|
211
|
+
const target = candidates.find((f) => fs4.existsSync(f));
|
|
212
|
+
if (!target) throw new Error("Overlay wants to merge package.json but none exists in project");
|
|
213
|
+
await mergePackageJson(target, manifest.packageJson);
|
|
214
|
+
}
|
|
215
|
+
for (const inj of manifest.inject ?? []) {
|
|
216
|
+
await insertBeforeMarker(path3.join(projectDir, inj.file), inj.marker, inj.lines);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// src/core/exec.ts
|
|
221
|
+
import { spinner, log } from "@clack/prompts";
|
|
222
|
+
import { execa } from "execa";
|
|
223
|
+
import pc from "picocolors";
|
|
224
|
+
var StepError = class extends Error {
|
|
225
|
+
constructor(message, output) {
|
|
226
|
+
super(message);
|
|
227
|
+
this.output = output;
|
|
228
|
+
this.name = "StepError";
|
|
229
|
+
}
|
|
230
|
+
output;
|
|
231
|
+
};
|
|
232
|
+
var run = async (cmd, args, options) => {
|
|
233
|
+
try {
|
|
234
|
+
const result = await execa(cmd, args, { ...options, all: true, reject: true });
|
|
235
|
+
return String(result.all ?? result.stdout ?? "");
|
|
236
|
+
} catch (err) {
|
|
237
|
+
const e = err;
|
|
238
|
+
throw new StepError(e.shortMessage ?? e.message, e.all);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
var step = async (label, fn) => {
|
|
242
|
+
const s = spinner();
|
|
243
|
+
s.start(label);
|
|
244
|
+
try {
|
|
245
|
+
await fn();
|
|
246
|
+
s.stop(pc.green("\u2714 ") + label);
|
|
247
|
+
return true;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
const e = err;
|
|
250
|
+
s.stop(pc.red("\u2716 ") + label);
|
|
251
|
+
log.error(e.message);
|
|
252
|
+
if (e.output) {
|
|
253
|
+
log.message(pc.dim(e.output.trim().split("\n").slice(-30).join("\n")));
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// src/core/prompts.ts
|
|
260
|
+
import * as p from "@clack/prompts";
|
|
261
|
+
var isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
262
|
+
var bail = (value) => {
|
|
263
|
+
if (p.isCancel(value)) {
|
|
264
|
+
p.cancel("Operation cancelled.");
|
|
265
|
+
process.exit(130);
|
|
266
|
+
}
|
|
267
|
+
throw new Error("Unexpected prompt result");
|
|
268
|
+
};
|
|
269
|
+
var confirm2 = async (message, initialValue) => {
|
|
270
|
+
const v = await p.confirm({ message, initialValue });
|
|
271
|
+
if (p.isCancel(v)) bail(v);
|
|
272
|
+
return v;
|
|
273
|
+
};
|
|
274
|
+
var text2 = async (message, opts) => {
|
|
275
|
+
const v = await p.text({
|
|
276
|
+
message,
|
|
277
|
+
placeholder: opts.placeholder,
|
|
278
|
+
initialValue: opts.initialValue,
|
|
279
|
+
validate: opts.validate ? (value) => opts.validate(value ?? "") : void 0
|
|
280
|
+
});
|
|
281
|
+
if (p.isCancel(v)) bail(v);
|
|
282
|
+
return String(v ?? "");
|
|
283
|
+
};
|
|
284
|
+
var select2 = async (message, options, initialValue) => {
|
|
285
|
+
const v = await p.select({
|
|
286
|
+
message,
|
|
287
|
+
options,
|
|
288
|
+
initialValue
|
|
289
|
+
});
|
|
290
|
+
if (p.isCancel(v)) bail(v);
|
|
291
|
+
return v;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// src/commands/new.ts
|
|
295
|
+
var PROJECT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
296
|
+
var DEFAULT_PORT = 3005;
|
|
297
|
+
var detectPackageManager = () => {
|
|
298
|
+
const ua = process.env.npm_config_user_agent ?? "";
|
|
299
|
+
return ua.startsWith("pnpm") ? "pnpm" : "npm";
|
|
300
|
+
};
|
|
301
|
+
var validateProjectName = (name) => {
|
|
302
|
+
if (!PROJECT_NAME_RE.test(name)) {
|
|
303
|
+
return "Use lowercase letters, numbers and dashes, starting with a letter or number (max 64 chars)";
|
|
304
|
+
}
|
|
305
|
+
return void 0;
|
|
306
|
+
};
|
|
307
|
+
var validatePort = (raw) => {
|
|
308
|
+
const n = Number(raw);
|
|
309
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
310
|
+
return "Port must be an integer between 1 and 65535";
|
|
311
|
+
return void 0;
|
|
312
|
+
};
|
|
313
|
+
var buildTokens = (name, port, withAuth) => ({
|
|
314
|
+
__SF_PROJECT_NAME__: name,
|
|
315
|
+
__SF_PROJECT_SLUG__: name,
|
|
316
|
+
__SF_DB_NAME__: name.replace(/-/g, "_"),
|
|
317
|
+
__SF_PORT__: String(port),
|
|
318
|
+
__SF_CLI_VERSION__: cliVersion(),
|
|
319
|
+
__SF_YEAR__: String((/* @__PURE__ */ new Date()).getFullYear()),
|
|
320
|
+
// .env.example never carries a real secret; `.env` gets a fresh one (see writeDotEnv).
|
|
321
|
+
__SF_JWT_SECRET__: withAuth ? "change-me-to-a-random-string-of-at-least-32-chars" : ""
|
|
322
|
+
});
|
|
323
|
+
var generateJwtSecret = () => randomBytes(48).toString("base64url");
|
|
324
|
+
var writeDotEnv = async (projectDir, withAuth) => {
|
|
325
|
+
const example = await fs5.readFile(path4.join(projectDir, ".env.example"), "utf8");
|
|
326
|
+
let env = example;
|
|
327
|
+
if (withAuth) {
|
|
328
|
+
env = env.replace(/^JWT_SECRET=.*$/m, `JWT_SECRET=${generateJwtSecret()}`);
|
|
329
|
+
}
|
|
330
|
+
await fs5.writeFile(path4.join(projectDir, ".env"), env, "utf8");
|
|
331
|
+
};
|
|
332
|
+
var scaffoldProject = async (dir, name, port, withAuth) => {
|
|
333
|
+
await fs5.ensureDir(dir);
|
|
334
|
+
await copyTemplate(templatePath("api"), dir);
|
|
335
|
+
if (withAuth) await applyOverlay(templatePath("api-auth"), dir);
|
|
336
|
+
await renderTokens(dir, buildTokens(name, port, withAuth));
|
|
337
|
+
await applyRenames(dir);
|
|
338
|
+
await assertNoLeftoverTokens(dir);
|
|
339
|
+
await assertNoForbiddenFiles(dir);
|
|
340
|
+
await writeDotEnv(dir, withAuth);
|
|
341
|
+
return { dir, name, port, withAuth };
|
|
342
|
+
};
|
|
343
|
+
var isEmptyDir = async (dir) => {
|
|
344
|
+
if (!await fs5.pathExists(dir)) return true;
|
|
345
|
+
const entries = await fs5.readdir(dir);
|
|
346
|
+
return entries.length === 0;
|
|
347
|
+
};
|
|
348
|
+
var runNew = async (rawName, opts) => {
|
|
349
|
+
const interactive = isInteractive() && !opts.yes;
|
|
350
|
+
p2.intro(pc2.bgCyan(pc2.black(` sundaysf v${cliVersion()} `)) + " " + pc2.dim("new API project"));
|
|
351
|
+
let name = rawName?.trim();
|
|
352
|
+
if (!name) {
|
|
353
|
+
if (!interactive) throw new Error("Project name is required: sundaysf new <name>");
|
|
354
|
+
name = await text2("Project name", { placeholder: "my-api", validate: validateProjectName });
|
|
355
|
+
}
|
|
356
|
+
const nameError = validateProjectName(name);
|
|
357
|
+
if (nameError) throw new Error(`Invalid project name "${name}": ${nameError}`);
|
|
358
|
+
const dir = path4.resolve(process.cwd(), name);
|
|
359
|
+
if (!await isEmptyDir(dir)) {
|
|
360
|
+
throw new Error(`Directory "${name}" already exists and is not empty.`);
|
|
361
|
+
}
|
|
362
|
+
let withAuth = opts.withAuth;
|
|
363
|
+
if (withAuth === void 0) {
|
|
364
|
+
withAuth = interactive ? await confirm2("Include auth (user/auth tables, register/login/me, JWT)?", false) : false;
|
|
365
|
+
}
|
|
366
|
+
let portRaw = opts.port;
|
|
367
|
+
if (portRaw === void 0) {
|
|
368
|
+
portRaw = interactive ? await text2("HTTP port", { initialValue: String(DEFAULT_PORT), validate: validatePort }) : String(DEFAULT_PORT);
|
|
369
|
+
}
|
|
370
|
+
const portError = validatePort(portRaw);
|
|
371
|
+
if (portError) throw new Error(`Invalid port "${portRaw}": ${portError}`);
|
|
372
|
+
const port = Number(portRaw);
|
|
373
|
+
const pm = opts.pm ?? detectPackageManager();
|
|
374
|
+
const doInstall = opts.install ?? (interactive ? await confirm2(`Run ${pm} install?`, true) : true);
|
|
375
|
+
const doGit = opts.git ?? (interactive ? await confirm2("Initialize a git repository?", true) : true);
|
|
376
|
+
await step("Scaffolding project files", async () => {
|
|
377
|
+
await scaffoldProject(dir, name, port, withAuth);
|
|
378
|
+
});
|
|
379
|
+
let gitOk = false;
|
|
380
|
+
if (doGit) {
|
|
381
|
+
gitOk = await step("git init", async () => {
|
|
382
|
+
await run("git", ["init", "-b", "main"], { cwd: dir });
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
let installOk = false;
|
|
386
|
+
if (doInstall) {
|
|
387
|
+
installOk = await step(`${pm} install`, async () => {
|
|
388
|
+
await run(pm, ["install"], { cwd: dir, env: { ...process.env, CI: "1" } });
|
|
389
|
+
});
|
|
390
|
+
if (installOk) {
|
|
391
|
+
await step("Formatting with prettier", async () => {
|
|
392
|
+
await run(pm, ["run", "format"], { cwd: dir });
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (gitOk) {
|
|
397
|
+
await step("Initial commit", async () => {
|
|
398
|
+
await run("git", ["add", "-A"], { cwd: dir });
|
|
399
|
+
await run(
|
|
400
|
+
"git",
|
|
401
|
+
["commit", "-q", "-m", `chore: scaffold ${name} with @sundaysf/cli-v3 ${cliVersion()}`],
|
|
402
|
+
{ cwd: dir }
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const next = [
|
|
407
|
+
`cd ${name}`,
|
|
408
|
+
...doInstall && installOk ? [] : [`${pm} install`],
|
|
409
|
+
"docker compose up -d # local Postgres",
|
|
410
|
+
`${pm} run db:migrate`,
|
|
411
|
+
`${pm} run start:dev # http://localhost:${port}/api/health`,
|
|
412
|
+
"sundaysf generate entity product name:string:unique price:decimal"
|
|
413
|
+
];
|
|
414
|
+
p2.note(next.join("\n"), "Next steps");
|
|
415
|
+
p2.outro(pc2.green(`Project "${name}" is ready.`) + (withAuth ? pc2.dim(" (with auth)") : ""));
|
|
416
|
+
};
|
|
417
|
+
var registerNewCommand = (program2) => {
|
|
418
|
+
program2.command("new").description("Create a new API project (Express 5 + Knex + Postgres) ready to work on").argument("[name]", "project name (lowercase, dashes allowed)").option("--with-auth", "include auth: user/auth tables, register/login/me, JWT").option("--no-auth", "explicitly skip auth (no prompt)").option("--port <port>", "HTTP port for the API", void 0).option("--no-install", "skip dependency installation").option("--no-git", "skip git init and initial commit").option("--pm <pm>", "package manager: npm | pnpm").option("-y, --yes", "accept all defaults, no prompts").action(async (name, raw) => {
|
|
419
|
+
const opts = {
|
|
420
|
+
withAuth: raw.withAuth === true ? true : raw.auth === false ? false : void 0,
|
|
421
|
+
port: raw.port,
|
|
422
|
+
install: raw.install,
|
|
423
|
+
git: raw.git,
|
|
424
|
+
pm: raw.pm,
|
|
425
|
+
yes: raw.yes
|
|
426
|
+
};
|
|
427
|
+
if (opts.pm && !["npm", "pnpm"].includes(opts.pm)) {
|
|
428
|
+
throw new Error(`Unsupported package manager "${opts.pm}" (use npm or pnpm)`);
|
|
429
|
+
}
|
|
430
|
+
await runNew(name, opts);
|
|
431
|
+
});
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// src/commands/generate-entity.ts
|
|
435
|
+
import path7 from "path";
|
|
436
|
+
import * as p3 from "@clack/prompts";
|
|
437
|
+
import fs8 from "fs-extra";
|
|
438
|
+
import pc3 from "picocolors";
|
|
439
|
+
import { execa as execa2 } from "execa";
|
|
440
|
+
|
|
441
|
+
// src/core/naming.ts
|
|
442
|
+
var splitWords = (input) => {
|
|
443
|
+
const trimmed = input.trim();
|
|
444
|
+
if (!trimmed) return [];
|
|
445
|
+
return trimmed.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").split(/[\s_-]+/).filter(Boolean).map((w) => w.toLowerCase());
|
|
446
|
+
};
|
|
447
|
+
var capitalize = (w) => w.charAt(0).toUpperCase() + w.slice(1);
|
|
448
|
+
var toKebab = (input) => splitWords(input).join("-");
|
|
449
|
+
var toSnake = (input) => splitWords(input).join("_");
|
|
450
|
+
var toPascal = (input) => splitWords(input).map(capitalize).join("");
|
|
451
|
+
var toCamel = (input) => {
|
|
452
|
+
const words = splitWords(input);
|
|
453
|
+
return words.map((w, i) => i === 0 ? w : capitalize(w)).join("");
|
|
454
|
+
};
|
|
455
|
+
var ENTITY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
456
|
+
var isValidEntityName = (input) => ENTITY_NAME_RE.test(input.trim());
|
|
457
|
+
var entityNames = (input) => {
|
|
458
|
+
if (!isValidEntityName(input)) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`Invalid entity name "${input}": must start with a letter and contain only letters, numbers, "-" or "_"`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
kebab: toKebab(input),
|
|
465
|
+
pascal: toPascal(input),
|
|
466
|
+
camel: toCamel(input),
|
|
467
|
+
snake: toSnake(input)
|
|
468
|
+
};
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// src/core/fields.ts
|
|
472
|
+
var FIELD_TYPES = [
|
|
473
|
+
"string",
|
|
474
|
+
"text",
|
|
475
|
+
"integer",
|
|
476
|
+
"decimal",
|
|
477
|
+
"boolean",
|
|
478
|
+
"date",
|
|
479
|
+
"datetime",
|
|
480
|
+
"json",
|
|
481
|
+
"uuid"
|
|
482
|
+
];
|
|
483
|
+
var RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set(["id", "uuid", "createdAt", "updatedAt"]);
|
|
484
|
+
var FIELD_NAME_RE = /^[a-z][a-zA-Z0-9]*$/;
|
|
485
|
+
var FIELD_SYNTAX = "name:type[?][:unique][=default] e.g. price:decimal title:string:unique categoryId:category.id notes:text?";
|
|
486
|
+
var parseDefault = (raw) => {
|
|
487
|
+
if (raw === "true") return true;
|
|
488
|
+
if (raw === "false") return false;
|
|
489
|
+
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
|
|
490
|
+
return raw.replace(/^['"](.*)['"]$/, "$1");
|
|
491
|
+
};
|
|
492
|
+
var parseFieldSpec = (token) => {
|
|
493
|
+
const fail = (why) => {
|
|
494
|
+
throw new Error(`Invalid field "${token}": ${why}
|
|
495
|
+
Syntax: ${FIELD_SYNTAX}`);
|
|
496
|
+
};
|
|
497
|
+
const colon = token.indexOf(":");
|
|
498
|
+
if (colon <= 0) fail("expected name:type");
|
|
499
|
+
const name = token.slice(0, colon);
|
|
500
|
+
if (!FIELD_NAME_RE.test(name))
|
|
501
|
+
fail("field name must be camelCase (letters and numbers, starting lowercase)");
|
|
502
|
+
if (RESERVED_FIELD_NAMES.has(name)) fail(`"${name}" is generated automatically`);
|
|
503
|
+
let rest = token.slice(colon + 1);
|
|
504
|
+
let def;
|
|
505
|
+
const eq = rest.indexOf("=");
|
|
506
|
+
if (eq !== -1) {
|
|
507
|
+
const rawDefault = rest.slice(eq + 1);
|
|
508
|
+
if (!rawDefault) fail("empty default value");
|
|
509
|
+
def = parseDefault(rawDefault);
|
|
510
|
+
rest = rest.slice(0, eq);
|
|
511
|
+
}
|
|
512
|
+
const parts = rest.split(":").filter(Boolean);
|
|
513
|
+
if (parts.length === 0) fail("missing type");
|
|
514
|
+
let nullable = false;
|
|
515
|
+
const [rawType, ...modifiers] = parts.map((p4) => {
|
|
516
|
+
if (p4.endsWith("?")) {
|
|
517
|
+
nullable = true;
|
|
518
|
+
return p4.slice(0, -1);
|
|
519
|
+
}
|
|
520
|
+
return p4;
|
|
521
|
+
});
|
|
522
|
+
let unique = false;
|
|
523
|
+
for (const m of modifiers) {
|
|
524
|
+
if (m === "unique") unique = true;
|
|
525
|
+
else fail(`unknown modifier "${m}" (supported: unique)`);
|
|
526
|
+
}
|
|
527
|
+
const spec = { name, type: "string", nullable, unique };
|
|
528
|
+
if (rawType.endsWith(".id")) {
|
|
529
|
+
const entity = rawType.slice(0, -".id".length);
|
|
530
|
+
if (!entity) fail("reference must be <entity>.id");
|
|
531
|
+
spec.type = "ref";
|
|
532
|
+
spec.ref = entityNames(entity);
|
|
533
|
+
} else if (FIELD_TYPES.includes(rawType)) {
|
|
534
|
+
spec.type = rawType;
|
|
535
|
+
} else {
|
|
536
|
+
fail(`unknown type "${rawType}" (supported: ${FIELD_TYPES.join(", ")}, <entity>.id)`);
|
|
537
|
+
}
|
|
538
|
+
if (def !== void 0) {
|
|
539
|
+
if (spec.type === "boolean" && typeof def !== "boolean")
|
|
540
|
+
fail("boolean default must be true or false");
|
|
541
|
+
if ((spec.type === "integer" || spec.type === "decimal" || spec.type === "ref") && typeof def !== "number") {
|
|
542
|
+
fail("numeric default expected");
|
|
543
|
+
}
|
|
544
|
+
spec.default = def;
|
|
545
|
+
}
|
|
546
|
+
return spec;
|
|
547
|
+
};
|
|
548
|
+
var parseFields = (tokens) => {
|
|
549
|
+
const specs = tokens.map(parseFieldSpec);
|
|
550
|
+
const seen = /* @__PURE__ */ new Set();
|
|
551
|
+
for (const s of specs) {
|
|
552
|
+
if (seen.has(s.name)) throw new Error(`Duplicate field "${s.name}"`);
|
|
553
|
+
seen.add(s.name);
|
|
554
|
+
}
|
|
555
|
+
return specs;
|
|
556
|
+
};
|
|
557
|
+
var jsLiteral = (v) => typeof v === "string" ? `'${v.replace(/'/g, "\\'")}'` : String(v);
|
|
558
|
+
var toKnexColumn = (f) => {
|
|
559
|
+
let col;
|
|
560
|
+
switch (f.type) {
|
|
561
|
+
case "string":
|
|
562
|
+
col = `table.string('${f.name}', 255)`;
|
|
563
|
+
break;
|
|
564
|
+
case "text":
|
|
565
|
+
col = `table.text('${f.name}')`;
|
|
566
|
+
break;
|
|
567
|
+
case "integer":
|
|
568
|
+
col = `table.integer('${f.name}')`;
|
|
569
|
+
break;
|
|
570
|
+
case "decimal":
|
|
571
|
+
col = `table.decimal('${f.name}', 15, 2)`;
|
|
572
|
+
break;
|
|
573
|
+
case "boolean":
|
|
574
|
+
col = `table.boolean('${f.name}')`;
|
|
575
|
+
break;
|
|
576
|
+
case "date":
|
|
577
|
+
col = `table.date('${f.name}')`;
|
|
578
|
+
break;
|
|
579
|
+
case "datetime":
|
|
580
|
+
col = `table.timestamp('${f.name}')`;
|
|
581
|
+
break;
|
|
582
|
+
case "json":
|
|
583
|
+
col = `table.jsonb('${f.name}')`;
|
|
584
|
+
break;
|
|
585
|
+
case "uuid":
|
|
586
|
+
col = `table.uuid('${f.name}')`;
|
|
587
|
+
break;
|
|
588
|
+
case "ref":
|
|
589
|
+
col = `table.integer('${f.name}').references('id').inTable('${f.ref.snake}').onDelete('CASCADE').index()`;
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
if (!f.nullable) col += ".notNullable()";
|
|
593
|
+
if (f.unique) col += ".unique()";
|
|
594
|
+
if (f.default !== void 0) {
|
|
595
|
+
col += f.type === "json" ? `.defaultTo(JSON.stringify(${jsLiteral(f.default)}))` : `.defaultTo(${jsLiteral(f.default)})`;
|
|
596
|
+
}
|
|
597
|
+
return `${col};`;
|
|
598
|
+
};
|
|
599
|
+
var toTsType = (f) => {
|
|
600
|
+
const base = {
|
|
601
|
+
string: "string",
|
|
602
|
+
text: "string",
|
|
603
|
+
integer: "number",
|
|
604
|
+
decimal: "number",
|
|
605
|
+
boolean: "boolean",
|
|
606
|
+
date: "string",
|
|
607
|
+
datetime: "Date | string",
|
|
608
|
+
json: "Record<string, unknown>",
|
|
609
|
+
uuid: "string",
|
|
610
|
+
ref: "number"
|
|
611
|
+
};
|
|
612
|
+
return f.nullable ? `${base[f.type]} | null` : base[f.type];
|
|
613
|
+
};
|
|
614
|
+
var toTsProp = (f) => {
|
|
615
|
+
const optional = f.nullable || f.default !== void 0 ? "?" : "";
|
|
616
|
+
return `${f.name}${optional}: ${toTsType(f)};`;
|
|
617
|
+
};
|
|
618
|
+
var zodBase = (f) => {
|
|
619
|
+
switch (f.type) {
|
|
620
|
+
case "string":
|
|
621
|
+
return "z.string().trim().min(1).max(255)";
|
|
622
|
+
case "text":
|
|
623
|
+
return "z.string()";
|
|
624
|
+
case "integer":
|
|
625
|
+
return "z.number().int()";
|
|
626
|
+
case "decimal":
|
|
627
|
+
return "z.number()";
|
|
628
|
+
case "boolean":
|
|
629
|
+
return "z.boolean()";
|
|
630
|
+
case "date":
|
|
631
|
+
return "z.iso.date()";
|
|
632
|
+
case "datetime":
|
|
633
|
+
return "z.coerce.date()";
|
|
634
|
+
case "json":
|
|
635
|
+
return "z.record(z.string(), z.unknown())";
|
|
636
|
+
case "uuid":
|
|
637
|
+
return "z.uuid()";
|
|
638
|
+
case "ref":
|
|
639
|
+
return "z.number().int().positive()";
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
var toZodCreate = (f) => {
|
|
643
|
+
let z = zodBase(f);
|
|
644
|
+
if (f.nullable) z += ".nullable()";
|
|
645
|
+
if (f.default !== void 0) z += `.default(${jsLiteral(f.default)})`;
|
|
646
|
+
else if (f.nullable) z += ".optional()";
|
|
647
|
+
return `${f.name}: ${z},`;
|
|
648
|
+
};
|
|
649
|
+
var toZodUpdate = (f) => {
|
|
650
|
+
let z = zodBase(f);
|
|
651
|
+
if (f.nullable) z += ".nullable()";
|
|
652
|
+
return `${f.name}: ${z}.optional(),`;
|
|
653
|
+
};
|
|
654
|
+
var sampleValue = (f, variant = "create") => {
|
|
655
|
+
const alt = variant === "update";
|
|
656
|
+
switch (f.type) {
|
|
657
|
+
case "string":
|
|
658
|
+
return f.unique ? `\`${f.name}-${alt ? "updated-" : ""}\${suffix}\`` : `'${alt ? "Updated" : "Sample"} ${f.name}'`;
|
|
659
|
+
case "text":
|
|
660
|
+
return alt ? "'Updated text'" : "'Sample text'";
|
|
661
|
+
case "integer":
|
|
662
|
+
return alt ? "7" : "42";
|
|
663
|
+
case "decimal":
|
|
664
|
+
return alt ? "99.5" : "19.99";
|
|
665
|
+
case "boolean":
|
|
666
|
+
return alt ? "false" : "true";
|
|
667
|
+
case "date":
|
|
668
|
+
return alt ? "'2025-02-01'" : "'2025-01-15'";
|
|
669
|
+
case "datetime":
|
|
670
|
+
return alt ? "'2025-02-01T10:00:00.000Z'" : "'2025-01-15T10:00:00.000Z'";
|
|
671
|
+
case "json":
|
|
672
|
+
return alt ? "{ key: 'updated' }" : "{ key: 'value' }";
|
|
673
|
+
case "uuid":
|
|
674
|
+
return alt ? "'11111111-1111-4111-8111-111111111111'" : "'00000000-0000-4000-8000-000000000000'";
|
|
675
|
+
case "ref":
|
|
676
|
+
return "1";
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
var hasReferences = (fields) => fields.some((f) => f.type === "ref");
|
|
680
|
+
|
|
681
|
+
// src/core/project.ts
|
|
682
|
+
import path5 from "path";
|
|
683
|
+
import fs6 from "fs-extra";
|
|
684
|
+
var SUNDAYSRC = ".sundaysrc";
|
|
685
|
+
var findProjectRoot = async (cwd = process.cwd()) => {
|
|
686
|
+
let dir = path5.resolve(cwd);
|
|
687
|
+
for (; ; ) {
|
|
688
|
+
if (await fs6.pathExists(path5.join(dir, SUNDAYSRC))) return dir;
|
|
689
|
+
const parent = path5.dirname(dir);
|
|
690
|
+
if (parent === dir) return null;
|
|
691
|
+
dir = parent;
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
var readSundaysrc = async (root) => {
|
|
695
|
+
return await fs6.readJson(path5.join(root, SUNDAYSRC));
|
|
696
|
+
};
|
|
697
|
+
var requireV3Project = async (cwd = process.cwd()) => {
|
|
698
|
+
const root = await findProjectRoot(cwd);
|
|
699
|
+
if (!root) {
|
|
700
|
+
throw new Error(
|
|
701
|
+
"Not inside a Sundays project (no .sundaysrc found in this directory or any parent). Run this command inside a project created with `sundaysf new`."
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const rc = await readSundaysrc(root);
|
|
705
|
+
if (!rc.cli) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`${path5.join(root, SUNDAYSRC)} has no "cli" field: this looks like a v2 project. sundaysf v3 generators require a project created with \`sundaysf new\` (v3).`
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const barrel = path5.join(root, "src", "db", "index.ts");
|
|
711
|
+
if (!await fs6.pathExists(barrel)) {
|
|
712
|
+
throw new Error(`Missing ${barrel}: cannot register generated DAOs.`);
|
|
713
|
+
}
|
|
714
|
+
const content = await fs6.readFile(barrel, "utf8");
|
|
715
|
+
for (const marker of ["// @sundays:interfaces", "// @sundays:daos"]) {
|
|
716
|
+
if (!content.includes(marker)) {
|
|
717
|
+
throw new Error(
|
|
718
|
+
`Marker "${marker}" not found in src/db/index.ts. Restore it so generators can extend the barrel.`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return root;
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
// src/generators/entity/context.ts
|
|
726
|
+
var migrationTimestamp = (date = /* @__PURE__ */ new Date()) => date.toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
727
|
+
|
|
728
|
+
// src/generators/entity/index.ts
|
|
729
|
+
import path6 from "path";
|
|
730
|
+
import fs7 from "fs-extra";
|
|
731
|
+
|
|
732
|
+
// src/generators/entity/render/controller.ts
|
|
733
|
+
var renderController = ({ names }) => {
|
|
734
|
+
const { pascal: P, camel: c, kebab: k } = names;
|
|
735
|
+
return `import type { NextFunction, Request, Response } from 'express';
|
|
736
|
+
import { badRequest, notFound } from '../../common/errors/http.error';
|
|
737
|
+
import { getPagination } from '../../common/utils/pagination';
|
|
738
|
+
import { ${P}DAO } from '../../db';
|
|
739
|
+
import { validate${P}Create } from '../../dto/input/${k}/${k}.create.dto';
|
|
740
|
+
import { validate${P}Update } from '../../dto/input/${k}/${k}.update.dto';
|
|
741
|
+
|
|
742
|
+
export class ${P}Controller {
|
|
743
|
+
private _${c}DAO = new ${P}DAO();
|
|
744
|
+
|
|
745
|
+
/** GET /api/${k}?page=&limit= */
|
|
746
|
+
public async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
747
|
+
try {
|
|
748
|
+
const { page, limit } = getPagination(req.query);
|
|
749
|
+
const result = await this._${c}DAO.getAll(page, limit);
|
|
750
|
+
res.status(200).json(result);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
next(err);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** GET /api/${k}/:uuid */
|
|
757
|
+
public async getByUuid(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
758
|
+
try {
|
|
759
|
+
const row = await this._${c}DAO.getByUuid(req.params.uuid as string);
|
|
760
|
+
if (!row) throw notFound('${P} not found');
|
|
761
|
+
res.status(200).json({ success: true, data: row });
|
|
762
|
+
} catch (err) {
|
|
763
|
+
next(err);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** POST /api/${k} */
|
|
768
|
+
public async create(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
769
|
+
try {
|
|
770
|
+
const input = validate${P}Create(req.body);
|
|
771
|
+
const created = await this._${c}DAO.create(input);
|
|
772
|
+
res.status(201).json({ success: true, data: created });
|
|
773
|
+
} catch (err) {
|
|
774
|
+
next(err);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** PUT /api/${k}/:uuid */
|
|
779
|
+
public async update(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
780
|
+
try {
|
|
781
|
+
const existing = await this._${c}DAO.getByUuid(req.params.uuid as string);
|
|
782
|
+
if (!existing?.id) throw notFound('${P} not found');
|
|
783
|
+
const input = validate${P}Update(req.body);
|
|
784
|
+
if (Object.keys(input).length === 0) throw badRequest('No fields to update');
|
|
785
|
+
const updated = await this._${c}DAO.update(existing.id, input);
|
|
786
|
+
res.status(200).json({ success: true, data: updated });
|
|
787
|
+
} catch (err) {
|
|
788
|
+
next(err);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** DELETE /api/${k}/:uuid */
|
|
793
|
+
public async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
794
|
+
try {
|
|
795
|
+
const existing = await this._${c}DAO.getByUuid(req.params.uuid as string);
|
|
796
|
+
if (!existing?.id) throw notFound('${P} not found');
|
|
797
|
+
await this._${c}DAO.delete(existing.id);
|
|
798
|
+
res.status(200).json({ success: true, message: '${P} deleted' });
|
|
799
|
+
} catch (err) {
|
|
800
|
+
next(err);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
`;
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// src/generators/entity/render/controller-test.ts
|
|
808
|
+
var objectLiteral = (entries) => entries.length ? `{ ${entries.join(", ")} }` : "{}";
|
|
809
|
+
var renderControllerTest = ({ names, fields }) => {
|
|
810
|
+
const { pascal: P, kebab: k } = names;
|
|
811
|
+
const rowFields = fields.map(
|
|
812
|
+
(f) => `${f.name}: ${sampleValue(f).replace(/`([^`]*)\$\{suffix\}`/, "'$1x'")}`
|
|
813
|
+
);
|
|
814
|
+
const createFields = fields.map(
|
|
815
|
+
(f) => `${f.name}: ${sampleValue(f).replace(/`([^`]*)\$\{suffix\}`/, "'$1x'")}`
|
|
816
|
+
);
|
|
817
|
+
const stableFields = fields.filter((f) => f.type !== "datetime").map((f) => `${f.name}: ${sampleValue(f).replace(/`([^`]*)\$\{suffix\}`/, "'$1x'")}`);
|
|
818
|
+
const updatable = fields.find((f) => f.type !== "ref") ?? fields[0];
|
|
819
|
+
const updateBody = updatable ? objectLiteral([
|
|
820
|
+
`${updatable.name}: ${sampleValue(updatable, "update").replace(/`([^`]*)\$\{suffix\}`/, "'$1y'")}`
|
|
821
|
+
]) : "{}";
|
|
822
|
+
return `import type { NextFunction, Request, Response } from 'express';
|
|
823
|
+
import { HttpError } from '../../../common/errors/http.error';
|
|
824
|
+
|
|
825
|
+
const mockDao = {
|
|
826
|
+
getAll: jest.fn(),
|
|
827
|
+
getByUuid: jest.fn(),
|
|
828
|
+
create: jest.fn(),
|
|
829
|
+
update: jest.fn(),
|
|
830
|
+
delete: jest.fn(),
|
|
831
|
+
};
|
|
832
|
+
jest.mock('../../../db', () => ({ ${P}DAO: jest.fn(() => mockDao) }));
|
|
833
|
+
|
|
834
|
+
import { ${P}Controller } from '../${k}.controller';
|
|
835
|
+
|
|
836
|
+
const mockRes = (): Response => {
|
|
837
|
+
const res: Partial<Response> = {};
|
|
838
|
+
res.status = jest.fn().mockReturnValue(res);
|
|
839
|
+
res.json = jest.fn().mockReturnValue(res);
|
|
840
|
+
return res as Response;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const row = ${objectLiteral(["id: 1", "uuid: 'uuid-1'", ...rowFields])};
|
|
844
|
+
const createBody = ${objectLiteral(createFields)};
|
|
845
|
+
const expectedCreate = ${objectLiteral(stableFields)};
|
|
846
|
+
const updateBody = ${updateBody};
|
|
847
|
+
|
|
848
|
+
describe('${P}Controller', () => {
|
|
849
|
+
const controller = new ${P}Controller();
|
|
850
|
+
let next: jest.MockedFunction<NextFunction>;
|
|
851
|
+
const errorOf = (): HttpError => next.mock.calls[0][0] as unknown as HttpError;
|
|
852
|
+
|
|
853
|
+
beforeEach(() => {
|
|
854
|
+
jest.clearAllMocks();
|
|
855
|
+
next = jest.fn();
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('getAll returns the paginated envelope', async () => {
|
|
859
|
+
const page = { success: true, data: [row], page: 2, limit: 5, count: 1, totalCount: 1, totalPages: 1 };
|
|
860
|
+
mockDao.getAll.mockResolvedValue(page);
|
|
861
|
+
const res = mockRes();
|
|
862
|
+
await controller.getAll({ query: { page: '2', limit: '5' } } as unknown as Request, res, next);
|
|
863
|
+
expect(mockDao.getAll).toHaveBeenCalledWith(2, 5);
|
|
864
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
865
|
+
expect(res.json).toHaveBeenCalledWith(page);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it('getByUuid returns the row', async () => {
|
|
869
|
+
mockDao.getByUuid.mockResolvedValue(row);
|
|
870
|
+
const res = mockRes();
|
|
871
|
+
await controller.getByUuid({ params: { uuid: 'uuid-1' } } as unknown as Request, res, next);
|
|
872
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
873
|
+
expect(res.json).toHaveBeenCalledWith({ success: true, data: row });
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('getByUuid answers 404 when missing', async () => {
|
|
877
|
+
mockDao.getByUuid.mockResolvedValue(null);
|
|
878
|
+
await controller.getByUuid({ params: { uuid: 'nope' } } as unknown as Request, mockRes(), next);
|
|
879
|
+
expect(errorOf().statusCode).toBe(404);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('create validates and returns 201', async () => {
|
|
883
|
+
mockDao.create.mockResolvedValue(row);
|
|
884
|
+
const res = mockRes();
|
|
885
|
+
await controller.create({ body: createBody } as Request, res, next);
|
|
886
|
+
expect(mockDao.create).toHaveBeenCalledWith(expect.objectContaining(expectedCreate));
|
|
887
|
+
expect(res.status).toHaveBeenCalledWith(201);
|
|
888
|
+
expect(res.json).toHaveBeenCalledWith({ success: true, data: row });
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it('create rejects unknown fields with 400', async () => {
|
|
892
|
+
await controller.create({ body: { ...createBody, __unknown__: true } } as Request, mockRes(), next);
|
|
893
|
+
expect(errorOf().statusCode).toBe(400);
|
|
894
|
+
expect(mockDao.create).not.toHaveBeenCalled();
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('update resolves the id and returns 200', async () => {
|
|
898
|
+
mockDao.getByUuid.mockResolvedValue(row);
|
|
899
|
+
mockDao.update.mockResolvedValue({ ...row, ...updateBody });
|
|
900
|
+
const res = mockRes();
|
|
901
|
+
await controller.update({ params: { uuid: 'uuid-1' }, body: updateBody } as unknown as Request, res, next);
|
|
902
|
+
expect(mockDao.update).toHaveBeenCalledWith(1, expect.objectContaining(updateBody));
|
|
903
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('update answers 404 when missing and 400 on an empty body', async () => {
|
|
907
|
+
mockDao.getByUuid.mockResolvedValue(null);
|
|
908
|
+
await controller.update({ params: { uuid: 'nope' }, body: updateBody } as unknown as Request, mockRes(), next);
|
|
909
|
+
expect(errorOf().statusCode).toBe(404);
|
|
910
|
+
|
|
911
|
+
next = jest.fn();
|
|
912
|
+
mockDao.getByUuid.mockResolvedValue(row);
|
|
913
|
+
await controller.update({ params: { uuid: 'uuid-1' }, body: {} } as unknown as Request, mockRes(), next);
|
|
914
|
+
expect(errorOf().statusCode).toBe(400);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it('delete removes the row', async () => {
|
|
918
|
+
mockDao.getByUuid.mockResolvedValue(row);
|
|
919
|
+
mockDao.delete.mockResolvedValue(true);
|
|
920
|
+
const res = mockRes();
|
|
921
|
+
await controller.delete({ params: { uuid: 'uuid-1' } } as unknown as Request, res, next);
|
|
922
|
+
expect(mockDao.delete).toHaveBeenCalledWith(1);
|
|
923
|
+
expect(res.json).toHaveBeenCalledWith({ success: true, message: '${P} deleted' });
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('delete answers 404 when missing', async () => {
|
|
927
|
+
mockDao.getByUuid.mockResolvedValue(null);
|
|
928
|
+
await controller.delete({ params: { uuid: 'nope' } } as unknown as Request, mockRes(), next);
|
|
929
|
+
expect(errorOf().statusCode).toBe(404);
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
`;
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
// src/generators/entity/render/dao.ts
|
|
936
|
+
var renderDao = ({ names, fields }) => {
|
|
937
|
+
const uniques = fields.filter((f) => f.unique);
|
|
938
|
+
const knexImport = uniques.length ? `import type { Knex } from 'knex';
|
|
939
|
+
` : "";
|
|
940
|
+
const finders = uniques.map(
|
|
941
|
+
(f) => `
|
|
942
|
+
async getBy${toPascal(f.name)}(${f.name}: ${toTsType(f)}, trx?: Knex.Transaction): Promise<I${names.pascal} | null> {
|
|
943
|
+
const row = await this.q(trx).where({ ${f.name} }).first();
|
|
944
|
+
return (row as I${names.pascal} | undefined) ?? null;
|
|
945
|
+
}`
|
|
946
|
+
).join("\n");
|
|
947
|
+
return `${knexImport}import { BaseDAO } from '../../BaseDAO';
|
|
948
|
+
import type { I${names.pascal} } from '../../interfaces/${names.kebab}/${names.kebab}.interfaces';
|
|
949
|
+
|
|
950
|
+
export class ${names.pascal}DAO extends BaseDAO<I${names.pascal}> {
|
|
951
|
+
protected readonly table = '${names.snake}';
|
|
952
|
+
${finders ? finders + "\n" : ""}}
|
|
953
|
+
`;
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
// src/generators/entity/render/dto.ts
|
|
957
|
+
var header = `import { z } from 'zod';
|
|
958
|
+
import { parseDto } from '../../../common/validation/parse-dto';
|
|
959
|
+
`;
|
|
960
|
+
var renderCreateDto = ({ names, fields }) => {
|
|
961
|
+
const body = fields.map((f) => ` ${toZodCreate(f)}`).join("\n");
|
|
962
|
+
return `${header}
|
|
963
|
+
export const ${names.pascal}CreateSchema = z
|
|
964
|
+
.object({
|
|
965
|
+
${body ? body + "\n" : ""} })
|
|
966
|
+
.strict();
|
|
967
|
+
|
|
968
|
+
export type ${names.pascal}CreateInput = z.infer<typeof ${names.pascal}CreateSchema>;
|
|
969
|
+
|
|
970
|
+
export const validate${names.pascal}Create = (input: unknown): ${names.pascal}CreateInput =>
|
|
971
|
+
parseDto(${names.pascal}CreateSchema, input);
|
|
972
|
+
`;
|
|
973
|
+
};
|
|
974
|
+
var renderUpdateDto = ({ names, fields }) => {
|
|
975
|
+
const body = fields.map((f) => ` ${toZodUpdate(f)}`).join("\n");
|
|
976
|
+
return `${header}
|
|
977
|
+
/** Partial update: every field optional, no defaults applied. */
|
|
978
|
+
export const ${names.pascal}UpdateSchema = z
|
|
979
|
+
.object({
|
|
980
|
+
${body ? body + "\n" : ""} })
|
|
981
|
+
.strict();
|
|
982
|
+
|
|
983
|
+
export type ${names.pascal}UpdateInput = z.infer<typeof ${names.pascal}UpdateSchema>;
|
|
984
|
+
|
|
985
|
+
export const validate${names.pascal}Update = (input: unknown): ${names.pascal}UpdateInput =>
|
|
986
|
+
parseDto(${names.pascal}UpdateSchema, input);
|
|
987
|
+
`;
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
// src/generators/entity/render/interface.ts
|
|
991
|
+
var renderInterface = ({ names, fields }) => {
|
|
992
|
+
const props = fields.map((f) => ` ${toTsProp(f)}`).join("\n");
|
|
993
|
+
return `import type { IEntity } from '../../d.types';
|
|
994
|
+
|
|
995
|
+
export interface I${names.pascal} extends IEntity {
|
|
996
|
+
${props ? props + "\n" : ""}}
|
|
997
|
+
`;
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
// src/generators/entity/render/migration.ts
|
|
1001
|
+
var renderMigration = ({ names, fields }) => {
|
|
1002
|
+
const columns = fields.map((f) => ` ${toKnexColumn(f)}`).join("\n");
|
|
1003
|
+
return `import type { Knex } from 'knex';
|
|
1004
|
+
|
|
1005
|
+
export async function up(knex: Knex): Promise<void> {
|
|
1006
|
+
await knex.schema.createTable('${names.snake}', (table) => {
|
|
1007
|
+
table.increments('id').primary();
|
|
1008
|
+
table.uuid('uuid').notNullable().unique();
|
|
1009
|
+
${columns ? columns + "\n" : ""} table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
|
1010
|
+
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export async function down(knex: Knex): Promise<void> {
|
|
1015
|
+
await knex.schema.dropTableIfExists('${names.snake}');
|
|
1016
|
+
}
|
|
1017
|
+
`;
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
// src/generators/entity/render/route-test.ts
|
|
1021
|
+
var renderRouteTest = ({ names, fields }) => {
|
|
1022
|
+
const { pascal: P, kebab: k, snake } = names;
|
|
1023
|
+
const refs = fields.filter((f) => f.type === "ref");
|
|
1024
|
+
const skip = hasReferences(fields);
|
|
1025
|
+
const createFields = fields.map((f) => ` ${f.name}: ${sampleValue(f)},`).join("\n");
|
|
1026
|
+
const updatable = fields.find((f) => f.type !== "ref");
|
|
1027
|
+
const updateBody = updatable ? `{ ${updatable.name}: ${sampleValue(updatable, "update")} }` : "{}";
|
|
1028
|
+
const describeFn = skip ? "describe.skip" : "describe";
|
|
1029
|
+
const skipNote = skip ? `// SKIPPED until you provide the parent rows: this entity references ${refs.map((r) => `${r.ref.snake} (${r.name})`).join(", ")}.
|
|
1030
|
+
// Create them in beforeAll (e.g. with new ${refs[0].ref.pascal}DAO().create({...})), set the ids in
|
|
1031
|
+
// createBody, delete them in afterAll, and change describe.skip to describe.
|
|
1032
|
+
` : "";
|
|
1033
|
+
return `import { randomUUID } from 'crypto';
|
|
1034
|
+
import request from 'supertest';
|
|
1035
|
+
import app from '../../../app';
|
|
1036
|
+
import { KnexManager } from '../../../db';
|
|
1037
|
+
|
|
1038
|
+
${skipNote}// Integration test: real Postgres (docker compose up -d) with migrations applied.
|
|
1039
|
+
const suffix = randomUUID().slice(0, 8);
|
|
1040
|
+
const createBody = {
|
|
1041
|
+
${createFields ? createFields + "\n" : ""}};
|
|
1042
|
+
const updateBody = ${updateBody};
|
|
1043
|
+
|
|
1044
|
+
${describeFn}('/api/${k}', () => {
|
|
1045
|
+
const created: string[] = [];
|
|
1046
|
+
|
|
1047
|
+
beforeAll(async () => {
|
|
1048
|
+
await KnexManager.connect();
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
afterAll(async () => {
|
|
1052
|
+
if (created.length) await KnexManager.getConnection()('${snake}').whereIn('uuid', created).delete();
|
|
1053
|
+
await KnexManager.disconnect();
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it('creates a ${k}', async () => {
|
|
1057
|
+
const res = await request(app).post('/api/${k}').send(createBody);
|
|
1058
|
+
expect(res.status).toBe(201);
|
|
1059
|
+
expect(res.body.success).toBe(true);
|
|
1060
|
+
expect(res.body.data.uuid).toEqual(expect.any(String));
|
|
1061
|
+
expect(res.body.data.id).toEqual(expect.any(Number));
|
|
1062
|
+
created.push(res.body.data.uuid);
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it('rejects unknown fields', async () => {
|
|
1066
|
+
const res = await request(app).post('/api/${k}').send({ ...createBody, __unknown__: true });
|
|
1067
|
+
expect(res.status).toBe(400);
|
|
1068
|
+
expect(res.body).toMatchObject({ success: false, message: 'Validation failed' });
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it('lists with pagination', async () => {
|
|
1072
|
+
const res = await request(app).get('/api/${k}?page=1&limit=5');
|
|
1073
|
+
expect(res.status).toBe(200);
|
|
1074
|
+
expect(res.body).toMatchObject({ success: true, page: 1, limit: 5 });
|
|
1075
|
+
expect(Array.isArray(res.body.data)).toBe(true);
|
|
1076
|
+
expect(res.body.data.some((row: { uuid: string }) => row.uuid === created[0])).toBe(true);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('reads by uuid', async () => {
|
|
1080
|
+
const res = await request(app).get(\`/api/${k}/\${created[0]}\`);
|
|
1081
|
+
expect(res.status).toBe(200);
|
|
1082
|
+
expect(res.body.data.uuid).toBe(created[0]);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
it('answers 404 for an unknown uuid', async () => {
|
|
1086
|
+
const res = await request(app).get(\`/api/${k}/\${randomUUID()}\`);
|
|
1087
|
+
expect(res.status).toBe(404);
|
|
1088
|
+
expect(res.body).toEqual({ success: false, message: '${P} not found' });
|
|
1089
|
+
});
|
|
1090
|
+
${updatable ? `
|
|
1091
|
+
it('updates', async () => {
|
|
1092
|
+
const res = await request(app).put(\`/api/${k}/\${created[0]}\`).send(updateBody);
|
|
1093
|
+
expect(res.status).toBe(200);
|
|
1094
|
+
expect(res.body.data).toMatchObject(updateBody);
|
|
1095
|
+
});
|
|
1096
|
+
` : ""}
|
|
1097
|
+
it('rejects an empty update', async () => {
|
|
1098
|
+
const res = await request(app).put(\`/api/${k}/\${created[0]}\`).send({});
|
|
1099
|
+
expect(res.status).toBe(400);
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it('deletes', async () => {
|
|
1103
|
+
const res = await request(app).delete(\`/api/${k}/\${created[0]}\`);
|
|
1104
|
+
expect(res.status).toBe(200);
|
|
1105
|
+
expect(res.body).toEqual({ success: true, message: '${P} deleted' });
|
|
1106
|
+
const gone = await request(app).get(\`/api/${k}/\${created[0]}\`);
|
|
1107
|
+
expect(gone.status).toBe(404);
|
|
1108
|
+
created.length = 0;
|
|
1109
|
+
});
|
|
1110
|
+
});
|
|
1111
|
+
`;
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
// src/generators/entity/render/router.ts
|
|
1115
|
+
var renderRouter = ({ names }) => {
|
|
1116
|
+
const { pascal: P, camel: c, kebab: k } = names;
|
|
1117
|
+
return `import { Router } from 'express';
|
|
1118
|
+
import { ${P}Controller } from '../../controllers/${k}/${k}.controller';
|
|
1119
|
+
|
|
1120
|
+
/** Mounted automatically at /api/${k} by src/routes/index.ts. */
|
|
1121
|
+
export class ${P}Router {
|
|
1122
|
+
public router: Router = Router();
|
|
1123
|
+
private readonly _${c}Controller = new ${P}Controller();
|
|
1124
|
+
|
|
1125
|
+
constructor() {
|
|
1126
|
+
this.initRoutes();
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
private initRoutes(): void {
|
|
1130
|
+
const ctrl = this._${c}Controller;
|
|
1131
|
+
this.router.get('/', ctrl.getAll.bind(ctrl));
|
|
1132
|
+
this.router.get('/:uuid', ctrl.getByUuid.bind(ctrl));
|
|
1133
|
+
this.router.post('/', ctrl.create.bind(ctrl));
|
|
1134
|
+
this.router.put('/:uuid', ctrl.update.bind(ctrl));
|
|
1135
|
+
this.router.delete('/:uuid', ctrl.delete.bind(ctrl));
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
`;
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
// src/generators/entity/index.ts
|
|
1142
|
+
var renderEntityFiles = (ctx, opts = {}) => {
|
|
1143
|
+
const { kebab, snake } = ctx.names;
|
|
1144
|
+
const files = [];
|
|
1145
|
+
if (opts.migration !== false) {
|
|
1146
|
+
files.push({
|
|
1147
|
+
path: `src/migrations/${ctx.timestamp}_create_${snake}.ts`,
|
|
1148
|
+
content: renderMigration(ctx)
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
files.push(
|
|
1152
|
+
{ path: `src/db/interfaces/${kebab}/${kebab}.interfaces.ts`, content: renderInterface(ctx) },
|
|
1153
|
+
{ path: `src/db/dao/${kebab}/${kebab}.dao.ts`, content: renderDao(ctx) },
|
|
1154
|
+
{ path: `src/dto/input/${kebab}/${kebab}.create.dto.ts`, content: renderCreateDto(ctx) },
|
|
1155
|
+
{ path: `src/dto/input/${kebab}/${kebab}.update.dto.ts`, content: renderUpdateDto(ctx) },
|
|
1156
|
+
{ path: `src/controllers/${kebab}/${kebab}.controller.ts`, content: renderController(ctx) },
|
|
1157
|
+
{ path: `src/routes/${kebab}/${kebab}.router.ts`, content: renderRouter(ctx) }
|
|
1158
|
+
);
|
|
1159
|
+
if (opts.tests !== false) {
|
|
1160
|
+
files.push(
|
|
1161
|
+
{
|
|
1162
|
+
path: `src/controllers/${kebab}/__tests__/${kebab}.controller.test.ts`,
|
|
1163
|
+
content: renderControllerTest(ctx)
|
|
1164
|
+
},
|
|
1165
|
+
{
|
|
1166
|
+
path: `src/routes/${kebab}/__tests__/${kebab}.routes.test.ts`,
|
|
1167
|
+
content: renderRouteTest(ctx)
|
|
1168
|
+
}
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
return files;
|
|
1172
|
+
};
|
|
1173
|
+
var barrelLinesFor = (ctx) => {
|
|
1174
|
+
const { kebab, pascal } = ctx.names;
|
|
1175
|
+
return {
|
|
1176
|
+
interfaces: [`export type { I${pascal} } from './interfaces/${kebab}/${kebab}.interfaces';`],
|
|
1177
|
+
daos: [`export { ${pascal}DAO } from './dao/${kebab}/${kebab}.dao';`]
|
|
1178
|
+
};
|
|
1179
|
+
};
|
|
1180
|
+
var findExistingMigration = async (root, snake) => {
|
|
1181
|
+
const dir = path6.join(root, "src", "migrations");
|
|
1182
|
+
if (!await fs7.pathExists(dir)) return null;
|
|
1183
|
+
const match = (await fs7.readdir(dir)).find((f) => f.endsWith(`_create_${snake}.ts`));
|
|
1184
|
+
return match ?? null;
|
|
1185
|
+
};
|
|
1186
|
+
var generateEntity = async (ctx, root, opts = {}) => {
|
|
1187
|
+
const warnings = [];
|
|
1188
|
+
let files = renderEntityFiles(ctx, opts);
|
|
1189
|
+
const existingMigration = await findExistingMigration(root, ctx.names.snake);
|
|
1190
|
+
if (existingMigration) {
|
|
1191
|
+
warnings.push(`Migration ${existingMigration} already exists, not creating another one.`);
|
|
1192
|
+
files = files.filter((f) => !f.path.startsWith("src/migrations/"));
|
|
1193
|
+
}
|
|
1194
|
+
for (const f of ctx.fields) {
|
|
1195
|
+
if (f.type === "ref" && !await fs7.pathExists(path6.join(root, "src", "db", "dao", f.ref.kebab))) {
|
|
1196
|
+
warnings.push(
|
|
1197
|
+
`Field "${f.name}" references entity "${f.ref.kebab}" but src/db/dao/${f.ref.kebab}/ does not exist yet. Generate it first or the migration will fail.`
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
const clashes = [];
|
|
1202
|
+
for (const f of files) {
|
|
1203
|
+
if (await fs7.pathExists(path6.join(root, f.path))) clashes.push(f.path);
|
|
1204
|
+
}
|
|
1205
|
+
if (clashes.length && !opts.force) {
|
|
1206
|
+
throw new Error(
|
|
1207
|
+
`These files already exist (use --force to overwrite code files):
|
|
1208
|
+
${clashes.join("\n ")}`
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
const barrelLines = barrelLinesFor(ctx);
|
|
1212
|
+
if (opts.dryRun) return { files, barrelLines, warnings };
|
|
1213
|
+
for (const f of files) {
|
|
1214
|
+
await fs7.outputFile(path6.join(root, f.path), f.content, "utf8");
|
|
1215
|
+
}
|
|
1216
|
+
const barrel = path6.join(root, "src", "db", "index.ts");
|
|
1217
|
+
await insertBeforeMarker(barrel, "// @sundays:interfaces", barrelLines.interfaces);
|
|
1218
|
+
await insertBeforeMarker(barrel, "// @sundays:daos", barrelLines.daos);
|
|
1219
|
+
return { files, barrelLines, warnings };
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
// src/commands/generate-entity.ts
|
|
1223
|
+
var promptFields = async () => {
|
|
1224
|
+
const tokens = [];
|
|
1225
|
+
p3.log.info(`Define the fields (syntax: ${FIELD_SYNTAX})`);
|
|
1226
|
+
for (; ; ) {
|
|
1227
|
+
const name = await text2("Field name (camelCase)", { placeholder: "name" });
|
|
1228
|
+
const type = await select2(
|
|
1229
|
+
"Type",
|
|
1230
|
+
[
|
|
1231
|
+
...FIELD_TYPES.map((t) => ({ value: t, label: t })),
|
|
1232
|
+
{ value: "ref", label: "<entity>.id (foreign key)" }
|
|
1233
|
+
],
|
|
1234
|
+
"string"
|
|
1235
|
+
);
|
|
1236
|
+
let typeToken = type;
|
|
1237
|
+
if (type === "ref") {
|
|
1238
|
+
const entity = await text2("Referenced entity", { placeholder: "category" });
|
|
1239
|
+
typeToken = `${entity}.id`;
|
|
1240
|
+
}
|
|
1241
|
+
const nullable = await confirm2("Nullable?", false);
|
|
1242
|
+
const unique = await confirm2("Unique?", false);
|
|
1243
|
+
const def = await text2("Default value (leave empty for none)", { placeholder: "" });
|
|
1244
|
+
let token = `${name}:${typeToken}${nullable ? "?" : ""}${unique ? ":unique" : ""}`;
|
|
1245
|
+
if (def.trim()) token += `=${def.trim()}`;
|
|
1246
|
+
tokens.push(token);
|
|
1247
|
+
if (!await confirm2("Add another field?", true)) break;
|
|
1248
|
+
}
|
|
1249
|
+
return tokens;
|
|
1250
|
+
};
|
|
1251
|
+
var formatFiles = async (root, files) => {
|
|
1252
|
+
const prettier = path7.join(root, "node_modules", ".bin", "prettier");
|
|
1253
|
+
if (!await fs8.pathExists(prettier)) return;
|
|
1254
|
+
await execa2(prettier, ["--write", "--log-level", "silent", ...files], { cwd: root });
|
|
1255
|
+
};
|
|
1256
|
+
var runGenerateEntity = async (rawName, rawFields, opts) => {
|
|
1257
|
+
p3.intro(pc3.bgCyan(pc3.black(" sundaysf ")) + " " + pc3.dim("generate entity"));
|
|
1258
|
+
const root = await requireV3Project();
|
|
1259
|
+
const names = entityNames(rawName);
|
|
1260
|
+
let tokens = [...rawFields, ...opts.fields ? opts.fields.split(/\s+/).filter(Boolean) : []];
|
|
1261
|
+
if (tokens.length === 0) {
|
|
1262
|
+
if (!isInteractive()) throw new Error(`No fields given.
|
|
1263
|
+
Syntax: ${FIELD_SYNTAX}`);
|
|
1264
|
+
tokens = await promptFields();
|
|
1265
|
+
}
|
|
1266
|
+
const fields = parseFields(tokens);
|
|
1267
|
+
const ctx = { names, fields, timestamp: migrationTimestamp() };
|
|
1268
|
+
const result = await generateEntity(ctx, root, {
|
|
1269
|
+
tests: opts.tests,
|
|
1270
|
+
migration: opts.migration,
|
|
1271
|
+
force: opts.force,
|
|
1272
|
+
dryRun: opts.dryRun
|
|
1273
|
+
});
|
|
1274
|
+
for (const w of result.warnings) p3.log.warn(w);
|
|
1275
|
+
if (opts.dryRun) {
|
|
1276
|
+
for (const f of result.files) {
|
|
1277
|
+
p3.log.step(pc3.bold(f.path));
|
|
1278
|
+
process.stdout.write(f.content + "\n");
|
|
1279
|
+
}
|
|
1280
|
+
p3.log.step(pc3.bold("src/db/index.ts") + " would receive:");
|
|
1281
|
+
process.stdout.write(
|
|
1282
|
+
[...result.barrelLines.interfaces, ...result.barrelLines.daos].join("\n") + "\n"
|
|
1283
|
+
);
|
|
1284
|
+
p3.outro("Dry run: nothing written.");
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
await formatFiles(root, [...result.files.map((f) => f.path), "src/db/index.ts"]);
|
|
1288
|
+
p3.note(
|
|
1289
|
+
[...result.files.map((f) => pc3.green("+ ") + f.path), pc3.yellow("~ ") + "src/db/index.ts"].join(
|
|
1290
|
+
"\n"
|
|
1291
|
+
),
|
|
1292
|
+
`Entity ${names.pascal}`
|
|
1293
|
+
);
|
|
1294
|
+
p3.note(
|
|
1295
|
+
[
|
|
1296
|
+
"npm run db:migrate",
|
|
1297
|
+
"npm run typecheck && npm test",
|
|
1298
|
+
`curl -X POST localhost:$PORT/api/${names.kebab} -H 'content-type: application/json' -d '{...}'`
|
|
1299
|
+
].join("\n"),
|
|
1300
|
+
"Next steps"
|
|
1301
|
+
);
|
|
1302
|
+
p3.outro(pc3.green(`${names.pascal} generated.`) + pc3.dim(` Mounted at /api/${names.kebab}`));
|
|
1303
|
+
};
|
|
1304
|
+
var registerGenerateCommand = (program2) => {
|
|
1305
|
+
const generate = program2.command("generate").alias("g").description("Generate code inside an existing project");
|
|
1306
|
+
generate.command("entity").description(
|
|
1307
|
+
"Generate migration, interface, DAO, DTOs, controller, router and tests for an entity"
|
|
1308
|
+
).argument("<name>", "entity name (product, productCategory, product-category...)").argument("[fields...]", `fields: ${FIELD_SYNTAX}`).option(
|
|
1309
|
+
"--fields <spec>",
|
|
1310
|
+
"fields as a single space separated string (alternative to positional)"
|
|
1311
|
+
).option("--no-tests", "do not generate test files").option("--no-migration", "do not generate the migration").option("--dry-run", "print what would be generated without writing").option("--force", "overwrite existing code files (never migrations)").action(async (name, fields, opts) => {
|
|
1312
|
+
await runGenerateEntity(name, fields, opts);
|
|
1313
|
+
});
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
// src/cli.ts
|
|
1317
|
+
var program = new Command();
|
|
1318
|
+
program.name("sundaysf").description(
|
|
1319
|
+
"Sundays Framework v3 \u2014 scaffold an Express 5 + Knex + Postgres API and generate entities"
|
|
1320
|
+
).version(cliVersion(), "-v, --version").showHelpAfterError();
|
|
1321
|
+
registerNewCommand(program);
|
|
1322
|
+
registerGenerateCommand(program);
|
|
1323
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
1324
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1325
|
+
console.error(pc4.red("\u2716 ") + message);
|
|
1326
|
+
process.exit(1);
|
|
1327
|
+
});
|