create-projx 1.6.5 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +92 -19
  2. package/dist/{baseline-PZM4KJJW.js → baseline-FHOZNS4D.js} +2 -2
  3. package/dist/{chunk-6YRBHJ2V.js → chunk-HAT7D4G2.js} +25 -8
  4. package/dist/{chunk-XQ7FE4U3.js → chunk-IMZKHDIL.js} +161 -19
  5. package/dist/index.js +1499 -276
  6. package/dist/{utils-AVKSTHIF.js → utils-BZGSJ7XZ.js} +5 -1
  7. package/package.json +13 -7
  8. package/src/addons/orms/drizzle/express/src/app.ts +81 -0
  9. package/src/addons/orms/drizzle/express/src/modules/_base/auto-routes.ts +278 -0
  10. package/src/addons/orms/drizzle/express/src/modules/_base/index.ts +20 -0
  11. package/src/addons/orms/drizzle/express/src/server.ts +32 -0
  12. package/src/addons/orms/drizzle/express/tests/app.test.ts +24 -0
  13. package/src/addons/orms/drizzle/express/vitest.config.ts +20 -0
  14. package/src/addons/orms/drizzle/fastify/src/app.ts +90 -0
  15. package/src/addons/orms/drizzle/fastify/src/modules/_base/auto-routes.ts +268 -0
  16. package/src/addons/orms/drizzle/fastify/src/modules/_base/index.ts +20 -0
  17. package/src/addons/orms/drizzle/fastify/tests/modules/app.test.ts +20 -0
  18. package/src/addons/orms/drizzle/fastify/vitest.config.ts +31 -0
  19. package/src/addons/orms/drizzle/gen-entity/express-router.ts +21 -0
  20. package/src/addons/orms/drizzle/gen-entity/express-test.ts +61 -0
  21. package/src/addons/orms/drizzle/gen-entity/fastify-router.ts +19 -0
  22. package/src/addons/orms/drizzle/gen-entity/fastify-test.ts +87 -0
  23. package/src/addons/orms/drizzle/manifest.json +52 -0
  24. package/src/addons/orms/drizzle/shared/drizzle.config.ts +12 -0
  25. package/src/addons/orms/drizzle/shared/src/db/client.ts +17 -0
  26. package/src/addons/orms/drizzle/shared/src/db/schema.ts +14 -0
  27. package/src/addons/orms/drizzle/shared/src/modules/_base/query-engine.ts +115 -0
  28. package/src/addons/orms/drizzle/shared/src/modules/_base/registry.ts +15 -0
  29. package/src/addons/orms/sequelize/express/src/app.ts +82 -0
  30. package/src/addons/orms/sequelize/express/src/modules/_base/auto-routes.ts +226 -0
  31. package/src/addons/orms/sequelize/express/src/modules/_base/index.ts +20 -0
  32. package/src/addons/orms/sequelize/express/src/server.ts +32 -0
  33. package/src/addons/orms/sequelize/express/tests/app.test.ts +24 -0
  34. package/src/addons/orms/sequelize/express/vitest.config.ts +20 -0
  35. package/src/addons/orms/sequelize/fastify/src/app.ts +83 -0
  36. package/src/addons/orms/sequelize/fastify/src/modules/_base/auto-routes.ts +216 -0
  37. package/src/addons/orms/sequelize/fastify/src/modules/_base/index.ts +20 -0
  38. package/src/addons/orms/sequelize/fastify/tests/modules/app.test.ts +20 -0
  39. package/src/addons/orms/sequelize/fastify/vitest.config.ts +31 -0
  40. package/src/addons/orms/sequelize/gen-entity/express-router.ts +17 -0
  41. package/src/addons/orms/sequelize/gen-entity/express-test.ts +65 -0
  42. package/src/addons/orms/sequelize/gen-entity/fastify-router.ts +19 -0
  43. package/src/addons/orms/sequelize/gen-entity/fastify-test.ts +89 -0
  44. package/src/addons/orms/sequelize/gen-entity/model.ts +21 -0
  45. package/src/addons/orms/sequelize/manifest.json +53 -0
  46. package/src/addons/orms/sequelize/shared/scripts/db-sync.ts +14 -0
  47. package/src/addons/orms/sequelize/shared/src/db/client.ts +19 -0
  48. package/src/addons/orms/sequelize/shared/src/models/index.ts +9 -0
  49. package/src/addons/orms/sequelize/shared/src/modules/_base/query-engine.ts +101 -0
  50. package/src/addons/orms/sequelize/shared/src/modules/_base/registry.ts +15 -0
  51. package/src/addons/orms/typeorm/express/src/app.ts +82 -0
  52. package/src/addons/orms/typeorm/express/src/modules/_base/auto-routes.ts +249 -0
  53. package/src/addons/orms/typeorm/express/src/modules/_base/index.ts +19 -0
  54. package/src/addons/orms/typeorm/express/src/server.ts +43 -0
  55. package/src/addons/orms/typeorm/express/tests/app.test.ts +24 -0
  56. package/src/addons/orms/typeorm/express/vitest.config.ts +20 -0
  57. package/src/addons/orms/typeorm/fastify/src/app.ts +86 -0
  58. package/src/addons/orms/typeorm/fastify/src/modules/_base/auto-routes.ts +239 -0
  59. package/src/addons/orms/typeorm/fastify/src/modules/_base/index.ts +19 -0
  60. package/src/addons/orms/typeorm/fastify/tests/modules/app.test.ts +20 -0
  61. package/src/addons/orms/typeorm/fastify/vitest.config.ts +31 -0
  62. package/src/addons/orms/typeorm/gen-entity/entity.ts +21 -0
  63. package/src/addons/orms/typeorm/gen-entity/express-router.ts +17 -0
  64. package/src/addons/orms/typeorm/gen-entity/express-test.ts +66 -0
  65. package/src/addons/orms/typeorm/gen-entity/fastify-router.ts +19 -0
  66. package/src/addons/orms/typeorm/gen-entity/fastify-test.ts +89 -0
  67. package/src/addons/orms/typeorm/manifest.json +53 -0
  68. package/src/addons/orms/typeorm/shared/scripts/db-sync.ts +14 -0
  69. package/src/addons/orms/typeorm/shared/src/db/data-source.ts +21 -0
  70. package/src/addons/orms/typeorm/shared/src/entities/index.ts +8 -0
  71. package/src/addons/orms/typeorm/shared/src/modules/_base/query-engine.ts +94 -0
  72. package/src/addons/orms/typeorm/shared/src/modules/_base/registry.ts +15 -0
  73. package/src/addons/orms/typeorm/shared/tsconfig.json +16 -0
  74. package/src/templates/README.md.ejs +21 -4
  75. package/src/templates/ci.yml.ejs +167 -37
  76. package/src/templates/docker-compose.yml.ejs +72 -5
  77. package/src/templates/pre-commit.ejs +28 -4
  78. package/src/templates/setup.sh.ejs +75 -1
  79. package/src/templates/docker-compose.dev.yml.ejs +0 -189
package/dist/index.js CHANGED
@@ -9,12 +9,14 @@ import {
9
9
  matchesSkip,
10
10
  saveBaselineRef,
11
11
  writeTemplateToDir
12
- } from "./chunk-XQ7FE4U3.js";
12
+ } from "./chunk-IMZKHDIL.js";
13
13
  import {
14
14
  COMPONENTS,
15
15
  COMPONENT_MARKER,
16
16
  DEFAULT_ROOT_SKIP_PATTERNS,
17
17
  EXCLUDE,
18
+ KNOWN_FEATURES,
19
+ ORM_PROVIDERS,
18
20
  PACKAGE_MANAGERS,
19
21
  cleanupRepo,
20
22
  detectPackageManager,
@@ -29,22 +31,314 @@ import {
29
31
  readComponentMarker,
30
32
  readFileOrNull,
31
33
  readProjxConfig,
34
+ render,
32
35
  toKebab,
33
36
  toSnake,
34
- toTitle,
35
37
  writeComponentMarker,
36
38
  writeProjxConfig
37
- } from "./chunk-6YRBHJ2V.js";
39
+ } from "./chunk-HAT7D4G2.js";
38
40
 
39
41
  // src/index.ts
40
- import { existsSync as existsSync11 } from "fs";
42
+ import { existsSync as existsSync12 } from "fs";
41
43
  import { resolve } from "path";
42
44
 
45
+ // src/features.ts
46
+ import { existsSync } from "fs";
47
+ import { cp, mkdir, readFile, readdir, writeFile } from "fs/promises";
48
+ import { dirname, join, relative } from "path";
49
+ function parseFeatureFlag(input) {
50
+ const trimmed = input.trim();
51
+ if (!trimmed) return [];
52
+ const pieces = trimmed.split(",");
53
+ const out = [];
54
+ for (const raw of pieces) {
55
+ const piece = raw.trim();
56
+ if (!piece) {
57
+ throw new Error(
58
+ `Invalid feature flag: empty target in "${input}". Use "component[:instance],..." form.`
59
+ );
60
+ }
61
+ const parts = piece.split(":").map((p11) => p11.trim());
62
+ if (parts.length > 2 || parts.some((p11) => !p11)) {
63
+ throw new Error(
64
+ `Invalid feature flag target "${piece}". Expected "component" or "component:instance".`
65
+ );
66
+ }
67
+ const [component, instance] = parts;
68
+ if (!COMPONENTS.includes(component)) {
69
+ throw new Error(
70
+ `Unknown component "${component}". Valid: ${COMPONENTS.join(", ")}.`
71
+ );
72
+ }
73
+ out.push(
74
+ instance ? { component, instance } : { component }
75
+ );
76
+ }
77
+ return out;
78
+ }
79
+ function validateFeatureTargets(targets, instances, _components, supports) {
80
+ const resolved = [];
81
+ for (const t of targets) {
82
+ if (supports && !supports.includes(t.component)) {
83
+ throw new Error(
84
+ `Feature does not support ${t.component}. Supported: ${supports.join(", ")}.`
85
+ );
86
+ }
87
+ const candidates = instances.filter((i) => i.type === t.component).sort((a, b) => a.path.localeCompare(b.path));
88
+ if (candidates.length === 0) {
89
+ throw new Error(
90
+ `No ${t.component} instance found. Add it to --components first.`
91
+ );
92
+ }
93
+ if (t.instance) {
94
+ const match = candidates.find((c) => c.path === t.instance);
95
+ if (!match) {
96
+ const known = candidates.map((c) => c.path).join(", ");
97
+ throw new Error(
98
+ `No ${t.component} instance named "${t.instance}". Known: ${known}.`
99
+ );
100
+ }
101
+ resolved.push({
102
+ component: t.component,
103
+ instance: t.instance,
104
+ path: match.path
105
+ });
106
+ } else {
107
+ resolved.push({
108
+ component: t.component,
109
+ instance: candidates[0].path,
110
+ path: candidates[0].path
111
+ });
112
+ }
113
+ }
114
+ return resolved;
115
+ }
116
+ async function applyFeatures(opts) {
117
+ const featureRoot = join(opts.repoDir, "features");
118
+ for (const [name, raw] of Object.entries(opts.features)) {
119
+ if (!raw) continue;
120
+ const targets = parseFeatureFlag(raw);
121
+ const featureDir = join(featureRoot, name);
122
+ if (!existsSync(featureDir)) {
123
+ throw new Error(
124
+ `Feature "${name}" not found at ${featureDir}. Pin or update the repo to a version that ships this feature.`
125
+ );
126
+ }
127
+ const manifest = JSON.parse(
128
+ await readFile(join(featureDir, "feature.json"), "utf-8")
129
+ );
130
+ if (manifest.requiresOrm && manifest.requiresOrm.length > 0) {
131
+ const orm = opts.vars.orm ?? "prisma";
132
+ if (!manifest.requiresOrm.includes(orm)) {
133
+ throw new Error(
134
+ `Feature "${name}" requires --orm ${manifest.requiresOrm.join(" or ")} (got "${orm}").`
135
+ );
136
+ }
137
+ }
138
+ const resolved = validateFeatureTargets(
139
+ targets,
140
+ opts.instances,
141
+ opts.components,
142
+ manifest.supports
143
+ );
144
+ await applyFeature({
145
+ feature: name,
146
+ featureRoot,
147
+ targets: resolved,
148
+ dest: opts.dest,
149
+ vars: opts.vars
150
+ });
151
+ }
152
+ }
153
+ async function applyFeature(opts) {
154
+ const featureDir = join(opts.featureRoot, opts.feature);
155
+ if (!existsSync(featureDir)) {
156
+ throw new Error(
157
+ `Feature "${opts.feature}" not found at ${featureDir}. Check feature name and repo version.`
158
+ );
159
+ }
160
+ const manifest = await readManifest(featureDir, opts.feature);
161
+ for (const target of opts.targets) {
162
+ if (!manifest.supports.includes(target.component)) {
163
+ throw new Error(
164
+ `Feature "${opts.feature}" does not support ${target.component}. Supported: ${manifest.supports.join(", ")}.`
165
+ );
166
+ }
167
+ await applyTarget({
168
+ featureDir,
169
+ featureName: opts.feature,
170
+ manifest,
171
+ target,
172
+ dest: opts.dest,
173
+ vars: { ...opts.vars, inst: target }
174
+ });
175
+ }
176
+ }
177
+ async function readManifest(featureDir, feature) {
178
+ const path = join(featureDir, "feature.json");
179
+ if (!existsSync(path)) {
180
+ throw new Error(`Feature "${feature}" missing feature.json at ${path}.`);
181
+ }
182
+ const raw = await readFile(path, "utf-8");
183
+ const manifest = JSON.parse(raw);
184
+ if (!manifest.name || !Array.isArray(manifest.supports)) {
185
+ throw new Error(`Feature "${feature}" manifest is malformed.`);
186
+ }
187
+ return manifest;
188
+ }
189
+ async function applyTarget(args2) {
190
+ const stackDir = join(args2.featureDir, args2.target.component);
191
+ if (!existsSync(stackDir)) return;
192
+ const targetPath = join(args2.dest, args2.target.path);
193
+ if (!existsSync(targetPath)) {
194
+ throw new Error(
195
+ `Target instance path ${args2.target.path} not found in ${args2.dest}.`
196
+ );
197
+ }
198
+ const filesDir = join(stackDir, "files");
199
+ if (existsSync(filesDir)) {
200
+ await renderFilesInto(filesDir, targetPath, args2.vars);
201
+ }
202
+ const patchesDir = join(stackDir, "patches");
203
+ if (existsSync(patchesDir)) {
204
+ await applyPatches(patchesDir, targetPath, args2.featureName);
205
+ }
206
+ const envKeys = args2.manifest.env?.[args2.target.component] ?? [];
207
+ if (envKeys.length > 0) {
208
+ await appendEnvExample(targetPath, args2.featureName, envKeys);
209
+ }
210
+ await recordFeatureInMarker(targetPath, args2.featureName);
211
+ }
212
+ async function renderFilesInto(filesDir, targetPath, vars) {
213
+ const entries = await collectFiles(filesDir);
214
+ for (const rel of entries) {
215
+ const src = join(filesDir, rel);
216
+ const isEjs = rel.endsWith(".ejs");
217
+ const outRel = isEjs ? rel.slice(0, -4) : rel;
218
+ const dst = join(targetPath, outRel);
219
+ await mkdir(dirname(dst), { recursive: true });
220
+ if (isEjs || /\.(ts|tsx|js|jsx|py|dart|md|json|yml|yaml|html)$/.test(rel)) {
221
+ const raw = await readFile(src, "utf-8");
222
+ await writeFile(dst, render(raw, vars));
223
+ } else {
224
+ await cp(src, dst);
225
+ }
226
+ }
227
+ }
228
+ async function collectFiles(root) {
229
+ const out = [];
230
+ async function walk(dir) {
231
+ const entries = await readdir(dir, { withFileTypes: true });
232
+ for (const e of entries) {
233
+ const full = join(dir, e.name);
234
+ if (e.isDirectory()) await walk(full);
235
+ else out.push(relative(root, full));
236
+ }
237
+ }
238
+ await walk(root);
239
+ return out.sort();
240
+ }
241
+ async function applyPatches(patchesDir, targetPath, featureName) {
242
+ const files = (await readdir(patchesDir)).filter((f) => f.endsWith(".json")).sort();
243
+ for (const file of files) {
244
+ const raw = await readFile(join(patchesDir, file), "utf-8");
245
+ const patch = JSON.parse(raw);
246
+ if (patch.type === "package-json") {
247
+ await applyPackageJsonPatch(targetPath, patch);
248
+ } else if (patch.type === "text") {
249
+ await applyTextPatch(targetPath, patch, featureName);
250
+ } else {
251
+ throw new Error(
252
+ `Unknown patch type in ${file}: ${patch.type}.`
253
+ );
254
+ }
255
+ }
256
+ }
257
+ async function applyPackageJsonPatch(targetPath, patch) {
258
+ const pkgPath = join(targetPath, "package.json");
259
+ if (!existsSync(pkgPath)) {
260
+ throw new Error(`package-json patch failed: ${pkgPath} not found.`);
261
+ }
262
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
263
+ const merge = patch.merge;
264
+ for (const key of ["dependencies", "devDependencies", "scripts"]) {
265
+ const incoming = merge[key];
266
+ if (!incoming) continue;
267
+ pkg[key] = { ...pkg[key] ?? {}, ...incoming };
268
+ }
269
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
270
+ }
271
+ async function applyTextPatch(targetPath, patch, featureName) {
272
+ const filePath = join(targetPath, patch.file);
273
+ if (!existsSync(filePath)) {
274
+ throw new Error(
275
+ `text patch failed: ${patch.file} not found in ${targetPath}.`
276
+ );
277
+ }
278
+ const content = await readFile(filePath, "utf-8");
279
+ const sentinel = sentinelFor(featureName, patch.anchor, patch.insert);
280
+ if (content.includes(sentinel)) return;
281
+ const idx = content.indexOf(patch.anchor);
282
+ if (idx === -1) {
283
+ throw new Error(
284
+ `text patch anchor "${patch.anchor}" not found in ${patch.file}.`
285
+ );
286
+ }
287
+ const insertWithSentinel = patch.insert + sentinel;
288
+ let next;
289
+ if (patch.position === "before") {
290
+ next = content.slice(0, idx) + insertWithSentinel + content.slice(idx);
291
+ } else {
292
+ const end = idx + patch.anchor.length;
293
+ const after = content.slice(end);
294
+ const newline = after.startsWith("\n") ? "\n" : "\n";
295
+ next = content.slice(0, end) + newline + insertWithSentinel + (after.startsWith("\n") ? after.slice(1) : after);
296
+ }
297
+ await writeFile(filePath, next);
298
+ }
299
+ function sentinelFor(feature, anchor, insert) {
300
+ const hash = simpleHash(anchor + "|" + insert);
301
+ const isHash = anchor.includes("#");
302
+ const open = isHash ? "# " : "// ";
303
+ return `${open}projx-feature: ${feature} ${hash}
304
+ `;
305
+ }
306
+ function simpleHash(s) {
307
+ let h = 0;
308
+ for (let i = 0; i < s.length; i++) h = h * 31 + s.charCodeAt(i) | 0;
309
+ return Math.abs(h).toString(36);
310
+ }
311
+ async function appendEnvExample(targetPath, featureName, keys) {
312
+ const envPath = join(targetPath, ".env.example");
313
+ let content = existsSync(envPath) ? await readFile(envPath, "utf-8") : "";
314
+ const header = `# Added by feature: ${featureName}`;
315
+ if (content.includes(header)) return;
316
+ if (content && !content.endsWith("\n")) content += "\n";
317
+ content += `
318
+ ${header}
319
+ `;
320
+ for (const key of keys) content += `# ${key}=
321
+ `;
322
+ await writeFile(envPath, content);
323
+ }
324
+ async function recordFeatureInMarker(targetPath, featureName) {
325
+ const markerPath = join(targetPath, ".projx-component");
326
+ if (!existsSync(markerPath)) return;
327
+ const raw = await readFile(markerPath, "utf-8");
328
+ const marker = JSON.parse(raw);
329
+ marker.features = marker.features ?? [];
330
+ if (!marker.features.includes(featureName)) {
331
+ marker.features.push(featureName);
332
+ await writeFile(markerPath, JSON.stringify(marker, null, 2) + "\n");
333
+ }
334
+ }
335
+
43
336
  // src/prompts.ts
44
337
  import * as p from "@clack/prompts";
45
338
  var LABELS = {
46
339
  fastapi: { label: "FastAPI", hint: "Python \u2014 SQLAlchemy, Alembic, uvicorn" },
47
340
  fastify: { label: "Fastify", hint: "Node.js \u2014 Prisma, TypeBox, TypeScript" },
341
+ express: { label: "Express", hint: "Node.js \u2014 Express 5, TypeScript" },
48
342
  frontend: { label: "Frontend", hint: "React 19 + Vite + React Router" },
49
343
  mobile: { label: "Mobile", hint: "Flutter + Riverpod + GoRouter" },
50
344
  e2e: { label: "E2E Tests", hint: "Playwright" },
@@ -78,9 +372,25 @@ async function runPrompts(nameArg) {
78
372
  p.log.warn("No components selected. Creating an empty project.");
79
373
  }
80
374
  const hasJs = components.some(
81
- (c) => ["fastify", "frontend", "e2e"].includes(c)
375
+ (c) => ["fastify", "express", "frontend", "e2e"].includes(c)
376
+ );
377
+ const hasNodeBackend = components.some(
378
+ (c) => ["fastify", "express"].includes(c)
82
379
  );
380
+ let orm = "prisma";
83
381
  let packageManager = "npm";
382
+ if (hasNodeBackend) {
383
+ const choice = await p.select({
384
+ message: "Node backend ORM",
385
+ options: ORM_PROVIDERS.map((provider) => ({
386
+ value: provider,
387
+ label: provider === "prisma" ? "Prisma" : "Drizzle"
388
+ })),
389
+ initialValue: "prisma"
390
+ });
391
+ if (p.isCancel(choice)) process.exit(0);
392
+ orm = choice;
393
+ }
84
394
  if (hasJs) {
85
395
  const pm = await p.select({
86
396
  message: "Package manager",
@@ -90,13 +400,13 @@ async function runPrompts(nameArg) {
90
400
  if (p.isCancel(pm)) process.exit(0);
91
401
  packageManager = pm;
92
402
  }
93
- return { name, components, git: true, install: true, packageManager };
403
+ return { name, components, git: true, install: true, packageManager, orm };
94
404
  }
95
405
 
96
406
  // src/scaffold.ts
97
- import { copyFileSync, existsSync } from "fs";
98
- import { mkdir, readFile } from "fs/promises";
99
- import { join } from "path";
407
+ import { copyFileSync, existsSync as existsSync2 } from "fs";
408
+ import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
409
+ import { join as join2 } from "path";
100
410
  import * as p2 from "@clack/prompts";
101
411
  async function scaffold(opts, dest, localRepo) {
102
412
  const name = toKebab(opts.name);
@@ -108,10 +418,11 @@ async function scaffold(opts, dest, localRepo) {
108
418
  projectName: name,
109
419
  components: opts.components,
110
420
  paths,
111
- pm: pmCommands(pm)
421
+ pm: pmCommands(pm),
422
+ orm: opts.orm ?? "prisma"
112
423
  };
113
424
  const isLocal = !!localRepo;
114
- await mkdir(dest, { recursive: true });
425
+ await mkdir2(dest, { recursive: true });
115
426
  const dlSpinner = p2.spinner();
116
427
  dlSpinner.start(
117
428
  isLocal ? "Using local templates" : "Downloading latest templates"
@@ -124,7 +435,7 @@ async function scaffold(opts, dest, localRepo) {
124
435
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
125
436
  try {
126
437
  const pkg = JSON.parse(
127
- await readFile(join(repoDir, "cli/package.json"), "utf-8")
438
+ await readFile2(join2(repoDir, "cli/package.json"), "utf-8")
128
439
  );
129
440
  const version = pkg.version;
130
441
  p2.log.info(`Scaffolding project in ${dest}`);
@@ -145,6 +456,19 @@ async function scaffold(opts, dest, localRepo) {
145
456
  true
146
457
  );
147
458
  spinner7.stop("Scaffold complete.");
459
+ if (opts.features && Object.keys(opts.features).length > 0) {
460
+ const featSpinner = p2.spinner();
461
+ featSpinner.start("Applying features");
462
+ await applyFeatures({
463
+ features: opts.features,
464
+ repoDir,
465
+ components: opts.components,
466
+ instances: opts.components.map((type) => ({ type, path: type })),
467
+ dest,
468
+ vars
469
+ });
470
+ featSpinner.stop("Features applied.");
471
+ }
148
472
  if (opts.install) {
149
473
  await installDeps(dest, opts.components, pm);
150
474
  }
@@ -180,7 +504,7 @@ async function installDeps(dest, components, pm) {
180
504
  case "fastapi":
181
505
  if (hasCommand("uv")) {
182
506
  spinner7.start("Installing FastAPI dependencies (uv sync)");
183
- exec("uv sync --all-extras", join(dest, "fastapi"));
507
+ exec("uv sync --all-extras", join2(dest, "fastapi"));
184
508
  spinner7.stop("FastAPI dependencies installed.");
185
509
  } else {
186
510
  p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
@@ -189,7 +513,7 @@ async function installDeps(dest, components, pm) {
189
513
  case "fastify":
190
514
  if (hasCommand(pmBin)) {
191
515
  spinner7.start(`Installing Fastify dependencies (${cmds.install})`);
192
- exec(cmds.install, join(dest, "fastify"));
516
+ exec(cmds.install, join2(dest, "fastify"));
193
517
  spinner7.stop("Fastify dependencies installed.");
194
518
  } else {
195
519
  p2.log.warn(
@@ -197,10 +521,21 @@ async function installDeps(dest, components, pm) {
197
521
  );
198
522
  }
199
523
  break;
524
+ case "express":
525
+ if (hasCommand(pmBin)) {
526
+ spinner7.start(`Installing Express dependencies (${cmds.install})`);
527
+ exec(cmds.install, join2(dest, "express"));
528
+ spinner7.stop("Express dependencies installed.");
529
+ } else {
530
+ p2.log.warn(
531
+ `${pm} not found \u2014 run 'cd express && ${cmds.install}' manually.`
532
+ );
533
+ }
534
+ break;
200
535
  case "frontend":
201
536
  if (hasCommand(pmBin)) {
202
537
  spinner7.start(`Installing Frontend dependencies (${cmds.install})`);
203
- exec(cmds.install, join(dest, "frontend"));
538
+ exec(cmds.install, join2(dest, "frontend"));
204
539
  spinner7.stop("Frontend dependencies installed.");
205
540
  } else {
206
541
  p2.log.warn(
@@ -211,7 +546,7 @@ async function installDeps(dest, components, pm) {
211
546
  case "e2e":
212
547
  if (hasCommand(pmBin)) {
213
548
  spinner7.start(`Installing E2E dependencies (${cmds.install})`);
214
- exec(cmds.install, join(dest, "e2e"));
549
+ exec(cmds.install, join2(dest, "e2e"));
215
550
  spinner7.stop("E2E dependencies installed.");
216
551
  } else {
217
552
  p2.log.warn(
@@ -222,7 +557,7 @@ async function installDeps(dest, components, pm) {
222
557
  case "mobile":
223
558
  if (hasCommand("flutter")) {
224
559
  spinner7.start("Installing Flutter dependencies");
225
- exec("flutter pub get", join(dest, "mobile"));
560
+ exec("flutter pub get", join2(dest, "mobile"));
226
561
  spinner7.stop("Flutter dependencies installed.");
227
562
  } else {
228
563
  p2.log.warn(
@@ -240,9 +575,9 @@ async function installDeps(dest, components, pm) {
240
575
  }
241
576
  function copyEnvExamples(dest, components) {
242
577
  for (const component of components) {
243
- const example = join(dest, component, ".env.example");
244
- const env = join(dest, component, ".env");
245
- if (existsSync(example) && !existsSync(env)) {
578
+ const example = join2(dest, component, ".env.example");
579
+ const env = join2(dest, component, ".env");
580
+ if (existsSync2(example) && !existsSync2(env)) {
246
581
  try {
247
582
  copyFileSync(example, env);
248
583
  } catch {
@@ -252,10 +587,10 @@ function copyEnvExamples(dest, components) {
252
587
  }
253
588
 
254
589
  // src/update.ts
255
- import { existsSync as existsSync2 } from "fs";
256
- import { readFile as readFile2, unlink } from "fs/promises";
590
+ import { existsSync as existsSync3 } from "fs";
591
+ import { readFile as readFile3, unlink } from "fs/promises";
257
592
  import { execSync } from "child_process";
258
- import { join as join2 } from "path";
593
+ import { join as join3 } from "path";
259
594
  import * as p3 from "@clack/prompts";
260
595
  async function update(cwd, localRepo) {
261
596
  p3.intro("projx update");
@@ -323,7 +658,7 @@ async function update(cwd, localRepo) {
323
658
  const componentSkips = {};
324
659
  for (const component of components) {
325
660
  const dir = componentPaths[component];
326
- const marker = await readComponentMarker(join2(cwd, dir));
661
+ const marker = await readComponentMarker(join3(cwd, dir));
327
662
  if (marker?.skip && marker.skip.length > 0) {
328
663
  componentSkips[component] = marker.skip;
329
664
  }
@@ -340,7 +675,7 @@ async function update(cwd, localRepo) {
340
675
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
341
676
  try {
342
677
  const pkg = JSON.parse(
343
- await readFile2(join2(repoDir, "cli/package.json"), "utf-8")
678
+ await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
344
679
  );
345
680
  const version = pkg.version;
346
681
  const name = detectProjectName(cwd, components, componentPaths);
@@ -366,7 +701,8 @@ async function update(cwd, localRepo) {
366
701
  paths: componentPaths,
367
702
  instances,
368
703
  pm: pmCommands(pm),
369
- nameOverrides
704
+ nameOverrides,
705
+ orm: raw.orm ?? "prisma"
370
706
  };
371
707
  const spinner7 = p3.spinner();
372
708
  spinner7.start("Applying template update");
@@ -473,22 +809,21 @@ function hasUncommittedChanges(cwd) {
473
809
  }
474
810
  }
475
811
  async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip) {
476
- const { mkdir: mkdir5, rm: rm2, readFile: readFile7 } = await import("fs/promises");
812
+ const { mkdtemp: mkdtemp2, rm: rm2, readFile: readFile8 } = await import("fs/promises");
477
813
  const { tmpdir: tmpdir2 } = await import("os");
478
- const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-PZM4KJJW.js");
814
+ const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-FHOZNS4D.js");
479
815
  const config = await readProjxConfig(cwd);
480
816
  const rootPinned = Array.isArray(config.skip) ? config.skip : [];
481
817
  const componentPinned = [];
482
818
  for (const component of components) {
483
819
  const dir = componentPaths[component];
484
- const marker = await readComponentMarker(join2(cwd, dir));
820
+ const marker = await readComponentMarker(join3(cwd, dir));
485
821
  if (marker?.skip && marker.skip.length > 0) {
486
822
  componentPinned.push({ component, dir, patterns: marker.skip });
487
823
  }
488
824
  }
489
825
  if (rootPinned.length === 0 && componentPinned.length === 0) return [];
490
- const tmpTemplate = join2(tmpdir2(), `projx-pinned-${Date.now()}`);
491
- await mkdir5(tmpTemplate, { recursive: true });
826
+ const tmpTemplate = await mkdtemp2(join3(tmpdir2(), "projx-pinned-"));
492
827
  void componentSkips;
493
828
  void rootSkip;
494
829
  try {
@@ -507,22 +842,22 @@ async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPat
507
842
  );
508
843
  const updates = [];
509
844
  for (const file of rootPinned) {
510
- const tmplPath = join2(tmpTemplate, file);
511
- const userPath = join2(cwd, file);
512
- if (!existsSync2(tmplPath) || !existsSync2(userPath)) continue;
513
- const tmplContent = await readFile7(tmplPath, "utf-8");
514
- const userContent = await readFile7(userPath, "utf-8");
845
+ const tmplPath = join3(tmpTemplate, file);
846
+ const userPath = join3(cwd, file);
847
+ if (!existsSync3(tmplPath) || !existsSync3(userPath)) continue;
848
+ const tmplContent = await readFile8(tmplPath, "utf-8");
849
+ const userContent = await readFile8(userPath, "utf-8");
515
850
  if (tmplContent !== userContent) updates.push(file);
516
851
  }
517
852
  for (const { dir, patterns } of componentPinned) {
518
853
  for (const pattern of patterns) {
519
854
  if (pattern.includes("*")) continue;
520
855
  const rel = `${dir}/${pattern}`;
521
- const tmplPath = join2(tmpTemplate, rel);
522
- const userPath = join2(cwd, rel);
523
- if (!existsSync2(tmplPath) || !existsSync2(userPath)) continue;
524
- const tmplContent = await readFile7(tmplPath, "utf-8");
525
- const userContent = await readFile7(userPath, "utf-8");
856
+ const tmplPath = join3(tmpTemplate, rel);
857
+ const userPath = join3(cwd, rel);
858
+ if (!existsSync3(tmplPath) || !existsSync3(userPath)) continue;
859
+ const tmplContent = await readFile8(tmplPath, "utf-8");
860
+ const userContent = await readFile8(userPath, "utf-8");
526
861
  if (tmplContent !== userContent) updates.push(rel);
527
862
  }
528
863
  }
@@ -596,7 +931,7 @@ async function promptSkipLearning(cwd, componentPaths, version, conflictedFiles)
596
931
  const entry = entries.find((e) => e.file === file);
597
932
  try {
598
933
  if (entry?.status === "??") {
599
- await unlink(join2(cwd, file));
934
+ await unlink(join3(cwd, file));
600
935
  } else {
601
936
  execSync(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
602
937
  }
@@ -630,9 +965,9 @@ async function learnSkips(cwd, files, componentPaths) {
630
965
  let matched = false;
631
966
  for (const [dir, component] of Object.entries(dirToComponent)) {
632
967
  if (file.startsWith(dir + "/")) {
633
- const relative = file.slice(dir.length + 1);
968
+ const relative2 = file.slice(dir.length + 1);
634
969
  if (!componentSkipAdds[component]) componentSkipAdds[component] = [];
635
- componentSkipAdds[component].push(relative);
970
+ componentSkipAdds[component].push(relative2);
636
971
  matched = true;
637
972
  break;
638
973
  }
@@ -643,10 +978,10 @@ async function learnSkips(cwd, files, componentPaths) {
643
978
  }
644
979
  for (const [component, additions] of Object.entries(componentSkipAdds)) {
645
980
  const dir = componentPaths[component];
646
- const marker = await readComponentMarker(join2(cwd, dir));
981
+ const marker = await readComponentMarker(join3(cwd, dir));
647
982
  if (!marker) continue;
648
983
  const merged = [.../* @__PURE__ */ new Set([...marker.skip, ...additions])];
649
- await writeComponentMarker(join2(cwd, dir), { ...marker, skip: merged });
984
+ await writeComponentMarker(join3(cwd, dir), { ...marker, skip: merged });
650
985
  }
651
986
  if (rootSkipAdds.length > 0) {
652
987
  const config = await readProjxConfig(cwd);
@@ -657,14 +992,14 @@ async function learnSkips(cwd, files, componentPaths) {
657
992
  }
658
993
 
659
994
  // src/add.ts
660
- import { copyFileSync as copyFileSync2, existsSync as existsSync3 } from "fs";
661
- import { readFile as readFile3 } from "fs/promises";
662
- import { join as join3 } from "path";
995
+ import { copyFileSync as copyFileSync2, existsSync as existsSync4 } from "fs";
996
+ import { readFile as readFile4 } from "fs/promises";
997
+ import { join as join4 } from "path";
663
998
  import * as p4 from "@clack/prompts";
664
999
  async function add(cwd, newComponents, localRepo, skipInstall = false, customName) {
665
1000
  p4.intro("projx add");
666
1001
  const isLocal = !!localRepo;
667
- if (!existsSync3(join3(cwd, ".projx"))) {
1002
+ if (!existsSync4(join4(cwd, ".projx"))) {
668
1003
  p4.log.error(
669
1004
  "No .projx file found. Run 'npx create-projx <name>' to create a project first."
670
1005
  );
@@ -678,8 +1013,8 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
678
1013
  "--name can only be used when adding a single component type."
679
1014
  );
680
1015
  }
681
- const targetDir = join3(cwd, customName);
682
- if (existsSync3(targetDir)) {
1016
+ const targetDir = join4(cwd, customName);
1017
+ if (existsSync4(targetDir)) {
683
1018
  throw new Error(`Directory '${customName}' already exists.`);
684
1019
  }
685
1020
  return await addInstance(
@@ -730,10 +1065,11 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
730
1065
  components: allComponents,
731
1066
  paths,
732
1067
  instances,
733
- pm: pmCommands(pm)
1068
+ pm: pmCommands(pm),
1069
+ orm: config.orm ?? "prisma"
734
1070
  };
735
1071
  const pkg = JSON.parse(
736
- await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
1072
+ await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
737
1073
  );
738
1074
  const version = pkg.version;
739
1075
  const spinner7 = p4.spinner();
@@ -756,9 +1092,9 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
756
1092
  );
757
1093
  }
758
1094
  for (const component of toAdd) {
759
- const example = join3(cwd, component, ".env.example");
760
- const env = join3(cwd, component, ".env");
761
- if (existsSync3(example) && !existsSync3(env)) {
1095
+ const example = join4(cwd, component, ".env.example");
1096
+ const env = join4(cwd, component, ".env");
1097
+ if (existsSync4(example) && !existsSync4(env)) {
762
1098
  try {
763
1099
  copyFileSync2(example, env);
764
1100
  } catch {
@@ -798,24 +1134,24 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
798
1134
  components: existing,
799
1135
  paths,
800
1136
  instances,
801
- pm: pmCommands(pm)
1137
+ pm: pmCommands(pm),
1138
+ orm: config.orm ?? "prisma"
802
1139
  };
803
1140
  const pkg = JSON.parse(
804
- await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
1141
+ await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
805
1142
  );
806
1143
  const version = pkg.version;
807
1144
  const INSTANCE_AWARE_ROOT = /* @__PURE__ */ new Set([
808
1145
  ".github/workflows/ci.yml",
809
1146
  ".githooks/pre-commit",
810
1147
  "scripts/setup.sh",
811
- "docker-compose.yml",
812
- "docker-compose.dev.yml"
1148
+ "docker-compose.yml"
813
1149
  ]);
814
1150
  const rawSkip = Array.isArray(config.skip) ? config.skip : [];
815
1151
  const rootSkip = rawSkip.filter((p11) => !INSTANCE_AWARE_ROOT.has(p11));
816
1152
  const componentSkips = {};
817
1153
  for (const inst of existingInstances) {
818
- const m = await readComponentMarker(join3(cwd, inst.path));
1154
+ const m = await readComponentMarker(join4(cwd, inst.path));
819
1155
  if (m?.skip && m.skip.length > 0) componentSkips[inst.type] = m.skip;
820
1156
  }
821
1157
  const spinner7 = p4.spinner();
@@ -851,9 +1187,9 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
851
1187
  if (!skipInstall) {
852
1188
  await installDeps2(cwd, [{ type, path: customName }], pm);
853
1189
  }
854
- const example = join3(cwd, customName, ".env.example");
855
- const env = join3(cwd, customName, ".env");
856
- if (existsSync3(example) && !existsSync3(env)) {
1190
+ const example = join4(cwd, customName, ".env.example");
1191
+ const env = join4(cwd, customName, ".env");
1192
+ if (existsSync4(example) && !existsSync4(env)) {
857
1193
  try {
858
1194
  copyFileSync2(example, env);
859
1195
  } catch {
@@ -868,7 +1204,7 @@ async function installDeps2(dest, instances, pm) {
868
1204
  const cmds = pmCommands(pm);
869
1205
  const pmBin = pm === "bun" ? "bun" : pm;
870
1206
  for (const { type, path } of instances) {
871
- const dir = join3(dest, path);
1207
+ const dir = join4(dest, path);
872
1208
  const spinner7 = p4.spinner();
873
1209
  try {
874
1210
  switch (type) {
@@ -894,6 +1230,19 @@ async function installDeps2(dest, instances, pm) {
894
1230
  );
895
1231
  }
896
1232
  break;
1233
+ case "express":
1234
+ if (hasCommand(pmBin)) {
1235
+ spinner7.start(
1236
+ `Installing Express dependencies (${path}/, ${cmds.install})`
1237
+ );
1238
+ exec(cmds.install, dir);
1239
+ spinner7.stop(`Express dependencies installed (${path}/).`);
1240
+ } else {
1241
+ p4.log.warn(
1242
+ `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
1243
+ );
1244
+ }
1245
+ break;
897
1246
  case "frontend":
898
1247
  if (hasCommand(pmBin)) {
899
1248
  spinner7.start(
@@ -941,24 +1290,24 @@ async function installDeps2(dest, instances, pm) {
941
1290
  }
942
1291
 
943
1292
  // src/init.ts
944
- import { existsSync as existsSync5 } from "fs";
945
- import { readFile as readFile4 } from "fs/promises";
1293
+ import { existsSync as existsSync6 } from "fs";
1294
+ import { readFile as readFile5 } from "fs/promises";
946
1295
  import { execSync as execSync2 } from "child_process";
947
- import { join as join5 } from "path";
1296
+ import { join as join6 } from "path";
948
1297
  import * as p5 from "@clack/prompts";
949
1298
 
950
1299
  // src/detect.ts
951
- import { existsSync as existsSync4 } from "fs";
952
- import { readdir } from "fs/promises";
953
- import { join as join4 } from "path";
1300
+ import { existsSync as existsSync5 } from "fs";
1301
+ import { readdir as readdir2 } from "fs/promises";
1302
+ import { join as join5 } from "path";
954
1303
  async function detectComponents(cwd) {
955
1304
  const results = [];
956
- const entries = await readdir(cwd, { withFileTypes: true });
1305
+ const entries = await readdir2(cwd, { withFileTypes: true });
957
1306
  const dirs = entries.filter(
958
1307
  (e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)
959
1308
  ).map((e) => e.name);
960
1309
  for (const dir of dirs) {
961
- const full = join4(cwd, dir);
1310
+ const full = join5(cwd, dir);
962
1311
  const detections = await scanDirectory(full, dir);
963
1312
  results.push(...detections);
964
1313
  }
@@ -966,7 +1315,7 @@ async function detectComponents(cwd) {
966
1315
  }
967
1316
  async function scanDirectory(dir, relPath) {
968
1317
  const results = [];
969
- const pyproject = await readFileOrNull(join4(dir, "pyproject.toml"));
1318
+ const pyproject = await readFileOrNull(join5(dir, "pyproject.toml"));
970
1319
  if (pyproject && /fastapi/i.test(pyproject)) {
971
1320
  results.push({
972
1321
  component: "fastapi",
@@ -986,6 +1335,14 @@ async function scanDirectory(dir, relPath) {
986
1335
  evidence: "package.json has fastify dependency"
987
1336
  });
988
1337
  }
1338
+ if (allDeps.express) {
1339
+ results.push({
1340
+ component: "express",
1341
+ directory: relPath,
1342
+ confidence: "high",
1343
+ evidence: "package.json has express dependency"
1344
+ });
1345
+ }
989
1346
  if (allDeps.react || allDeps["react-dom"]) {
990
1347
  results.push({
991
1348
  component: "frontend",
@@ -1003,7 +1360,7 @@ async function scanDirectory(dir, relPath) {
1003
1360
  });
1004
1361
  }
1005
1362
  }
1006
- const pubspec = await readFileOrNull(join4(dir, "pubspec.yaml"));
1363
+ const pubspec = await readFileOrNull(join5(dir, "pubspec.yaml"));
1007
1364
  if (pubspec && /flutter:/i.test(pubspec)) {
1008
1365
  results.push({
1009
1366
  component: "mobile",
@@ -1012,7 +1369,7 @@ async function scanDirectory(dir, relPath) {
1012
1369
  evidence: "pubspec.yaml has flutter dependency"
1013
1370
  });
1014
1371
  }
1015
- const hasTf = existsSync4(join4(dir, "main.tf")) || existsSync4(join4(dir, "variables.tf")) || existsSync4(join4(dir, "stack/main.tf")) || existsSync4(join4(dir, "versions.tf"));
1372
+ const hasTf = existsSync5(join5(dir, "main.tf")) || existsSync5(join5(dir, "variables.tf")) || existsSync5(join5(dir, "stack/main.tf")) || existsSync5(join5(dir, "versions.tf"));
1016
1373
  if (hasTf) {
1017
1374
  results.push({
1018
1375
  component: "infra",
@@ -1024,7 +1381,7 @@ async function scanDirectory(dir, relPath) {
1024
1381
  return results;
1025
1382
  }
1026
1383
  async function readPkg(dir) {
1027
- const content = await readFileOrNull(join4(dir, "package.json"));
1384
+ const content = await readFileOrNull(join5(dir, "package.json"));
1028
1385
  if (!content) return null;
1029
1386
  try {
1030
1387
  return JSON.parse(content);
@@ -1037,7 +1394,7 @@ async function readPkg(dir) {
1037
1394
  async function init(cwd, localRepo) {
1038
1395
  p5.intro("projx init");
1039
1396
  const isLocal = !!localRepo;
1040
- if (existsSync5(join5(cwd, ".projx"))) {
1397
+ if (existsSync6(join6(cwd, ".projx"))) {
1041
1398
  p5.log.error(
1042
1399
  "This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead."
1043
1400
  );
@@ -1076,7 +1433,7 @@ async function init(cwd, localRepo) {
1076
1433
  confirmed.map((c) => [c.component, c.directory])
1077
1434
  );
1078
1435
  const hasJs = components.some(
1079
- (c) => ["fastify", "frontend", "e2e"].includes(c)
1436
+ (c) => ["fastify", "express", "frontend", "e2e"].includes(c)
1080
1437
  );
1081
1438
  let pm = "npm";
1082
1439
  if (hasJs) {
@@ -1099,7 +1456,8 @@ async function init(cwd, localRepo) {
1099
1456
  projectName,
1100
1457
  components,
1101
1458
  paths,
1102
- pm: pmCommands(pm)
1459
+ pm: pmCommands(pm),
1460
+ orm: "prisma"
1103
1461
  };
1104
1462
  const dlSpinner = p5.spinner();
1105
1463
  dlSpinner.start(
@@ -1113,7 +1471,7 @@ async function init(cwd, localRepo) {
1113
1471
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1114
1472
  try {
1115
1473
  const pkg = JSON.parse(
1116
- await readFile4(join5(repoDir, "cli/package.json"), "utf-8")
1474
+ await readFile5(join6(repoDir, "cli/package.json"), "utf-8")
1117
1475
  );
1118
1476
  const version = pkg.version;
1119
1477
  const applySpinner = p5.spinner();
@@ -1130,7 +1488,7 @@ async function init(cwd, localRepo) {
1130
1488
  true
1131
1489
  );
1132
1490
  applySpinner.stop("Template applied.");
1133
- if (existsSync5(join5(cwd, ".githooks"))) {
1491
+ if (existsSync6(join6(cwd, ".githooks"))) {
1134
1492
  try {
1135
1493
  execSync2("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1136
1494
  } catch {
@@ -1179,7 +1537,7 @@ async function writeBareProjx(cwd, localRepo, isLocal, pm) {
1179
1537
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1180
1538
  try {
1181
1539
  const pkg = JSON.parse(
1182
- await readFile4(join5(repoDir, "cli/package.json"), "utf-8")
1540
+ await readFile5(join6(repoDir, "cli/package.json"), "utf-8")
1183
1541
  );
1184
1542
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1185
1543
  const config = {
@@ -1228,8 +1586,8 @@ function hasUncommittedChanges2(cwd) {
1228
1586
  }
1229
1587
 
1230
1588
  // src/pin.ts
1231
- import { existsSync as existsSync6 } from "fs";
1232
- import { join as join6 } from "path";
1589
+ import { existsSync as existsSync7 } from "fs";
1590
+ import { join as join7 } from "path";
1233
1591
  import * as p6 from "@clack/prompts";
1234
1592
  function classifyPattern(pattern, componentPaths) {
1235
1593
  const dirToComponent = {};
@@ -1249,7 +1607,7 @@ function classifyPattern(pattern, componentPaths) {
1249
1607
  }
1250
1608
  async function pin(cwd, patterns) {
1251
1609
  p6.intro("projx pin");
1252
- if (!existsSync6(join6(cwd, ".projx"))) {
1610
+ if (!existsSync7(join7(cwd, ".projx"))) {
1253
1611
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1254
1612
  process.exit(1);
1255
1613
  }
@@ -1262,20 +1620,20 @@ async function pin(cwd, patterns) {
1262
1620
  p6.log.warn(`Cannot pin ${pattern} \u2014 config files are managed by projx.`);
1263
1621
  continue;
1264
1622
  }
1265
- const { scope, component, relative } = classifyPattern(
1623
+ const { scope, component, relative: relative2 } = classifyPattern(
1266
1624
  pattern,
1267
1625
  componentPaths
1268
1626
  );
1269
1627
  if (scope === "component" && component) {
1270
1628
  if (!componentAdds[component]) componentAdds[component] = [];
1271
- componentAdds[component].push(relative);
1629
+ componentAdds[component].push(relative2);
1272
1630
  } else {
1273
- rootAdds.push(relative);
1631
+ rootAdds.push(relative2);
1274
1632
  }
1275
1633
  }
1276
1634
  for (const [component, additions] of Object.entries(componentAdds)) {
1277
1635
  const dir = componentPaths[component];
1278
- const marker = await readComponentMarker(join6(cwd, dir));
1636
+ const marker = await readComponentMarker(join7(cwd, dir));
1279
1637
  if (!marker) {
1280
1638
  p6.log.error(`Could not read marker for ${component}.`);
1281
1639
  continue;
@@ -1284,7 +1642,7 @@ async function pin(cwd, patterns) {
1284
1642
  const added = merged.length - marker.skip.length;
1285
1643
  if (added > 0) {
1286
1644
  const next = { ...marker, skip: merged };
1287
- await writeComponentMarker(join6(cwd, dir), next);
1645
+ await writeComponentMarker(join7(cwd, dir), next);
1288
1646
  p6.log.success(`${component}: pinned ${additions.join(", ")}`);
1289
1647
  } else {
1290
1648
  p6.log.info(`${component}: already pinned.`);
@@ -1305,7 +1663,7 @@ async function pin(cwd, patterns) {
1305
1663
  }
1306
1664
  async function unpin(cwd, patterns) {
1307
1665
  p6.intro("projx unpin");
1308
- if (!existsSync6(join6(cwd, ".projx"))) {
1666
+ if (!existsSync7(join7(cwd, ".projx"))) {
1309
1667
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1310
1668
  process.exit(1);
1311
1669
  }
@@ -1314,20 +1672,20 @@ async function unpin(cwd, patterns) {
1314
1672
  const rootRemoves = [];
1315
1673
  const componentRemoves = {};
1316
1674
  for (const pattern of patterns) {
1317
- const { scope, component, relative } = classifyPattern(
1675
+ const { scope, component, relative: relative2 } = classifyPattern(
1318
1676
  pattern,
1319
1677
  componentPaths
1320
1678
  );
1321
1679
  if (scope === "component" && component) {
1322
1680
  if (!componentRemoves[component]) componentRemoves[component] = [];
1323
- componentRemoves[component].push(relative);
1681
+ componentRemoves[component].push(relative2);
1324
1682
  } else {
1325
- rootRemoves.push(relative);
1683
+ rootRemoves.push(relative2);
1326
1684
  }
1327
1685
  }
1328
1686
  for (const [component, removals] of Object.entries(componentRemoves)) {
1329
1687
  const dir = componentPaths[component];
1330
- const marker = await readComponentMarker(join6(cwd, dir));
1688
+ const marker = await readComponentMarker(join7(cwd, dir));
1331
1689
  if (!marker) {
1332
1690
  p6.log.error(`Could not read marker for ${component}.`);
1333
1691
  continue;
@@ -1336,7 +1694,7 @@ async function unpin(cwd, patterns) {
1336
1694
  const removed = marker.skip.length - filtered.length;
1337
1695
  if (removed > 0) {
1338
1696
  const next = { ...marker, skip: filtered };
1339
- await writeComponentMarker(join6(cwd, dir), next);
1697
+ await writeComponentMarker(join7(cwd, dir), next);
1340
1698
  p6.log.success(`${component}: unpinned ${removals.join(", ")}`);
1341
1699
  } else {
1342
1700
  p6.log.info(`${component}: not found in skip list.`);
@@ -1357,7 +1715,7 @@ async function unpin(cwd, patterns) {
1357
1715
  }
1358
1716
  async function listPins(cwd) {
1359
1717
  p6.intro("projx pin --list");
1360
- if (!existsSync6(join6(cwd, ".projx"))) {
1718
+ if (!existsSync7(join7(cwd, ".projx"))) {
1361
1719
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1362
1720
  process.exit(1);
1363
1721
  }
@@ -1374,7 +1732,7 @@ async function listPins(cwd) {
1374
1732
  }
1375
1733
  for (const component of discovered) {
1376
1734
  const dir = componentPaths[component];
1377
- const marker = await readComponentMarker(join6(cwd, dir));
1735
+ const marker = await readComponentMarker(join7(cwd, dir));
1378
1736
  if (marker?.skip && marker.skip.length > 0) {
1379
1737
  hasAny = true;
1380
1738
  const label = dir !== component ? `${component} (${dir}/)` : `${component}`;
@@ -1391,15 +1749,15 @@ async function listPins(cwd) {
1391
1749
  }
1392
1750
 
1393
1751
  // src/doctor.ts
1394
- import { existsSync as existsSync7 } from "fs";
1395
- import { readdir as readdir2 } from "fs/promises";
1752
+ import { existsSync as existsSync8 } from "fs";
1753
+ import { readdir as readdir3 } from "fs/promises";
1396
1754
  import { execSync as execSync3 } from "child_process";
1397
- import { join as join7 } from "path";
1755
+ import { join as join8 } from "path";
1398
1756
  import * as p7 from "@clack/prompts";
1399
1757
  async function checkConfig(cwd) {
1400
1758
  const results = [];
1401
- const configPath = join7(cwd, ".projx");
1402
- if (!existsSync7(configPath)) {
1759
+ const configPath = join8(cwd, ".projx");
1760
+ if (!existsSync8(configPath)) {
1403
1761
  results.push({
1404
1762
  name: ".projx exists",
1405
1763
  status: "fail",
@@ -1449,8 +1807,8 @@ async function checkComponents(cwd, components, componentPaths) {
1449
1807
  });
1450
1808
  for (const component of components) {
1451
1809
  const dir = componentPaths[component];
1452
- const fullDir = join7(cwd, dir);
1453
- if (!existsSync7(fullDir)) {
1810
+ const fullDir = join8(cwd, dir);
1811
+ if (!existsSync8(fullDir)) {
1454
1812
  results.push({
1455
1813
  name: `${component} directory`,
1456
1814
  status: "fail",
@@ -1590,10 +1948,10 @@ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
1590
1948
  }
1591
1949
  for (const component of components) {
1592
1950
  const dir = componentPaths[component];
1593
- const marker = await readComponentMarker(join7(cwd, dir));
1951
+ const marker = await readComponentMarker(join8(cwd, dir));
1594
1952
  if (marker?.skip && marker.skip.length > 0) {
1595
1953
  for (const pattern of marker.skip) {
1596
- const matches = await patternMatchesAnything(join7(cwd, dir), pattern);
1954
+ const matches = await patternMatchesAnything(join8(cwd, dir), pattern);
1597
1955
  if (!matches) {
1598
1956
  results.push({
1599
1957
  name: `${component} skip`,
@@ -1615,16 +1973,16 @@ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
1615
1973
  }
1616
1974
  async function patternMatchesAnything(dir, pattern) {
1617
1975
  if (pattern === "**") return true;
1618
- if (!existsSync7(dir)) return false;
1976
+ if (!existsSync8(dir)) return false;
1619
1977
  const walk = async (current, base) => {
1620
1978
  let entries;
1621
1979
  try {
1622
- entries = await readdir2(current, { withFileTypes: true });
1980
+ entries = await readdir3(current, { withFileTypes: true });
1623
1981
  } catch {
1624
1982
  return false;
1625
1983
  }
1626
1984
  for (const entry of entries) {
1627
- const full = join7(current, entry.name);
1985
+ const full = join8(current, entry.name);
1628
1986
  const rel = full.slice(base.length + 1);
1629
1987
  if (entry.isDirectory()) {
1630
1988
  if (await walk(full, base)) return true;
@@ -1674,17 +2032,17 @@ function printReport(results) {
1674
2032
  }
1675
2033
 
1676
2034
  // src/diff.ts
1677
- import { existsSync as existsSync8 } from "fs";
1678
- import { readFile as readFile5, mkdir as mkdir2, rm } from "fs/promises";
1679
- import { join as join8 } from "path";
2035
+ import { existsSync as existsSync9 } from "fs";
2036
+ import { readFile as readFile6, mkdtemp, rm } from "fs/promises";
2037
+ import { join as join9 } from "path";
1680
2038
  import { tmpdir } from "os";
1681
2039
  import * as p8 from "@clack/prompts";
1682
2040
  function isSkipped(file, componentPaths, componentSkips, rootSkip) {
1683
2041
  for (const [component, dir] of Object.entries(componentPaths)) {
1684
2042
  if (file.startsWith(dir + "/")) {
1685
- const relative = file.slice(dir.length + 1);
2043
+ const relative2 = file.slice(dir.length + 1);
1686
2044
  const skips = componentSkips[component] ?? [];
1687
- if (matchesSkip(relative, skips)) return true;
2045
+ if (matchesSkip(relative2, skips)) return true;
1688
2046
  }
1689
2047
  }
1690
2048
  const base = file.split("/").pop();
@@ -1700,7 +2058,7 @@ function fileComponent(file, componentPaths) {
1700
2058
  async function diff(cwd, localRepo) {
1701
2059
  p8.intro("projx diff");
1702
2060
  const isLocal = !!localRepo;
1703
- if (!existsSync8(join8(cwd, ".projx"))) {
2061
+ if (!existsSync9(join9(cwd, ".projx"))) {
1704
2062
  p8.log.error("No .projx file found. Run 'npx create-projx init' first.");
1705
2063
  process.exit(1);
1706
2064
  }
@@ -1709,7 +2067,7 @@ async function diff(cwd, localRepo) {
1709
2067
  const componentSkips = {};
1710
2068
  for (const component of components) {
1711
2069
  const dir = componentPaths[component];
1712
- const marker = await readComponentMarker(join8(cwd, dir));
2070
+ const marker = await readComponentMarker(join9(cwd, dir));
1713
2071
  if (marker?.skip && marker.skip.length > 0) {
1714
2072
  componentSkips[component] = marker.skip;
1715
2073
  }
@@ -1727,7 +2085,7 @@ async function diff(cwd, localRepo) {
1727
2085
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1728
2086
  try {
1729
2087
  const pkg = JSON.parse(
1730
- await readFile5(join8(repoDir, "cli/package.json"), "utf-8")
2088
+ await readFile6(join9(repoDir, "cli/package.json"), "utf-8")
1731
2089
  );
1732
2090
  const version = pkg.version;
1733
2091
  p8.log.info(`Current: v${raw.version ?? "unknown"} \u2192 Template: v${version}`);
@@ -1736,12 +2094,12 @@ async function diff(cwd, localRepo) {
1736
2094
  projectName: name,
1737
2095
  components,
1738
2096
  paths: componentPaths,
1739
- pm: pmCommands(raw.packageManager ?? "npm")
2097
+ pm: pmCommands(raw.packageManager ?? "npm"),
2098
+ orm: raw.orm ?? "prisma"
1740
2099
  };
1741
2100
  const spinner7 = p8.spinner();
1742
2101
  spinner7.start("Analyzing changes");
1743
- const tmpTemplate = join8(tmpdir(), `projx-diff-${Date.now()}`);
1744
- await mkdir2(tmpTemplate, { recursive: true });
2102
+ const tmpTemplate = await mkdtemp(join9(tmpdir(), "projx-diff-"));
1745
2103
  await writeTemplateToDir(
1746
2104
  tmpTemplate,
1747
2105
  repoDir,
@@ -1764,16 +2122,16 @@ async function diff(cwd, localRepo) {
1764
2122
  analyses.push({ file, status: "skipped", component });
1765
2123
  continue;
1766
2124
  }
1767
- const oursPath = join8(cwd, file);
1768
- if (!existsSync8(oursPath)) {
2125
+ const oursPath = join9(cwd, file);
2126
+ if (!existsSync9(oursPath)) {
1769
2127
  analyses.push({ file, status: "new", component });
1770
2128
  continue;
1771
2129
  }
1772
2130
  let oursContent;
1773
2131
  let theirsContent;
1774
2132
  try {
1775
- oursContent = await readFile5(oursPath, "utf-8");
1776
- theirsContent = await readFile5(join8(tmpTemplate, file), "utf-8");
2133
+ oursContent = await readFile6(oursPath, "utf-8");
2134
+ theirsContent = await readFile6(join9(tmpTemplate, file), "utf-8");
1777
2135
  } catch {
1778
2136
  continue;
1779
2137
  }
@@ -1853,9 +2211,10 @@ async function diff(cwd, localRepo) {
1853
2211
  }
1854
2212
 
1855
2213
  // src/gen.ts
1856
- import { existsSync as existsSync9 } from "fs";
1857
- import { readFile as readFile6, writeFile, mkdir as mkdir3 } from "fs/promises";
1858
- import { join as join9 } from "path";
2214
+ import { existsSync as existsSync10 } from "fs";
2215
+ import { readFile as readFile7, writeFile as writeFile2, mkdir as mkdir3 } from "fs/promises";
2216
+ import { join as join10 } from "path";
2217
+ import { fileURLToPath } from "url";
1859
2218
  import * as p9 from "@clack/prompts";
1860
2219
  var FIELD_TYPES = [
1861
2220
  "string",
@@ -1928,11 +2287,23 @@ async function promptEntityConfig(name) {
1928
2287
  initialValue: true
1929
2288
  });
1930
2289
  if (p9.isCancel(required)) process.exit(0);
1931
- fields.push({ name: toSnake(fieldName), type: fieldType, required });
2290
+ fields.push({
2291
+ name: toSnake(fieldName),
2292
+ type: fieldType,
2293
+ required,
2294
+ unique: false,
2295
+ generated: false
2296
+ });
1932
2297
  }
1933
2298
  if (fields.length === 0) {
1934
2299
  p9.log.warn("No fields defined. Adding a default 'name' field.");
1935
- fields.push({ name: "name", type: "string", required: true });
2300
+ fields.push({
2301
+ name: "name",
2302
+ type: "string",
2303
+ required: true,
2304
+ unique: false,
2305
+ generated: false
2306
+ });
1936
2307
  }
1937
2308
  const stringFields = fields.filter(
1938
2309
  (f) => f.type === "string" || f.type === "text"
@@ -1965,7 +2336,14 @@ function parseFieldsFlag(raw) {
1965
2336
  const required = nameType.endsWith("!");
1966
2337
  const name = toSnake(required ? nameType.slice(0, -1) : nameType);
1967
2338
  const type = rest[0] || "string";
1968
- return { name, type, required: required || true };
2339
+ const modifiers = new Set(rest.slice(1).map((item) => item.toLowerCase()));
2340
+ return {
2341
+ name,
2342
+ type,
2343
+ required: required || true,
2344
+ unique: modifiers.has("unique") || modifiers.has("@unique"),
2345
+ generated: modifiers.has("generated") || modifiers.has("server") || modifiers.has("server-generated")
2346
+ };
1969
2347
  });
1970
2348
  }
1971
2349
  function sqlalchemyType(type) {
@@ -2088,24 +2466,6 @@ function typeboxOptional(type) {
2088
2466
  return "Type.Optional(Type.Any())";
2089
2467
  }
2090
2468
  }
2091
- function fieldMetaType(type) {
2092
- switch (type) {
2093
- case "string":
2094
- return { type: "str", fieldType: "text" };
2095
- case "number":
2096
- return { type: "int", fieldType: "number" };
2097
- case "boolean":
2098
- return { type: "bool", fieldType: "boolean" };
2099
- case "date":
2100
- return { type: "date", fieldType: "date" };
2101
- case "datetime":
2102
- return { type: "datetime", fieldType: "datetime" };
2103
- case "text":
2104
- return { type: "str", fieldType: "textarea" };
2105
- case "json":
2106
- return { type: "dict", fieldType: "textarea" };
2107
- }
2108
- }
2109
2469
  function prismaType(type, required) {
2110
2470
  const nullable = required ? "" : "?";
2111
2471
  switch (type) {
@@ -2125,6 +2485,10 @@ function prismaType(type, required) {
2125
2485
  return `Json${nullable}`;
2126
2486
  }
2127
2487
  }
2488
+ function prismaFieldType(field) {
2489
+ const base = prismaType(field.type, field.required);
2490
+ return field.unique ? `${base} @unique` : base;
2491
+ }
2128
2492
  function generateFastifySchemas(config) {
2129
2493
  const className = toPascal(config.name);
2130
2494
  const lines = [];
@@ -2146,7 +2510,7 @@ function generateFastifySchemas(config) {
2146
2510
  lines.push(`export type ${className} = Static<typeof ${className}Schema>;`);
2147
2511
  lines.push("");
2148
2512
  lines.push(`export const Create${className}Schema = Type.Object({`);
2149
- for (const f of config.fields) {
2513
+ for (const f of config.fields.filter((field) => !field.generated)) {
2150
2514
  if (f.required) {
2151
2515
  lines.push(` ${f.name}: ${typeboxType(f.type, true)},`);
2152
2516
  } else {
@@ -2160,7 +2524,7 @@ function generateFastifySchemas(config) {
2160
2524
  );
2161
2525
  lines.push("");
2162
2526
  lines.push(`export const Update${className}Schema = Type.Object({`);
2163
- for (const f of config.fields) {
2527
+ for (const f of config.fields.filter((field) => !field.generated)) {
2164
2528
  lines.push(` ${f.name}: ${typeboxOptional(f.type)},`);
2165
2529
  }
2166
2530
  lines.push(`});`);
@@ -2174,44 +2538,26 @@ function generateFastifySchemas(config) {
2174
2538
  function generateFastifyIndex(config) {
2175
2539
  const className = toPascal(config.name);
2176
2540
  const camelConfig = className.charAt(0).toLowerCase() + className.slice(1) + "Config";
2177
- const allColumns = [
2178
- "id",
2179
- ...config.fields.map((f) => f.name),
2180
- "created_at",
2181
- "updated_at"
2182
- ];
2183
- if (config.softDelete) allColumns.push("deleted_at");
2541
+ const generatedFields = config.fields.filter((field) => field.generated);
2184
2542
  const lines = [];
2185
2543
  lines.push(
2186
- `import { EntityRegistry, type EntityConfig, type FieldMeta } from '../_base/index.js';`
2544
+ `import { EntityRegistry, type EntityConfig } from '../_base/index.js';`
2187
2545
  );
2546
+ if (generatedFields.length > 0) {
2547
+ lines.push(`import { randomBytes } from 'node:crypto';`);
2548
+ }
2188
2549
  lines.push(
2189
2550
  `import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`
2190
2551
  );
2191
2552
  lines.push("");
2192
- lines.push(`const fields: FieldMeta[] = [`);
2193
- lines.push(
2194
- ` { key: 'id', label: 'Id', type: 'str', nullable: false, is_auto: true, is_primary_key: true, filterable: true, has_foreign_key: false, field_type: 'text' },`
2195
- );
2196
- for (const f of config.fields) {
2197
- const meta = fieldMetaType(f.type);
2198
- lines.push(
2199
- ` { key: '${f.name}', label: '${toTitle(f.name)}', type: '${meta.type}', nullable: ${!f.required}, is_auto: false, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: '${meta.fieldType}' },`
2200
- );
2201
- }
2202
- lines.push(
2203
- ` { key: 'created_at', label: 'Created At', type: 'datetime', nullable: false, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`
2204
- );
2205
- lines.push(
2206
- ` { key: 'updated_at', label: 'Updated At', type: 'datetime', nullable: false, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`
2207
- );
2208
- if (config.softDelete) {
2553
+ for (const field of generatedFields) {
2209
2554
  lines.push(
2210
- ` { key: 'deleted_at', label: 'Deleted At', type: 'datetime', nullable: true, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`
2555
+ `function generate${className}${toPascal(field.name)}(): string {`
2211
2556
  );
2557
+ lines.push(` return randomBytes(8).toString('hex').toUpperCase();`);
2558
+ lines.push(`}`);
2559
+ lines.push("");
2212
2560
  }
2213
- lines.push(`];`);
2214
- lines.push("");
2215
2561
  const tags = config.apiPrefix.replace(/^\//, "");
2216
2562
  lines.push(`export const ${camelConfig}: EntityConfig = {`);
2217
2563
  lines.push(` name: '${className}',`);
@@ -2222,7 +2568,6 @@ function generateFastifyIndex(config) {
2222
2568
  lines.push(` readonly: ${config.readonly},`);
2223
2569
  lines.push(` softDelete: ${config.softDelete},`);
2224
2570
  lines.push(` bulkOperations: ${config.bulkOperations},`);
2225
- lines.push(` columnNames: [${allColumns.map((c) => `'${c}'`).join(", ")}],`);
2226
2571
  if (config.searchableFields.length > 0) {
2227
2572
  lines.push(
2228
2573
  ` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`
@@ -2230,10 +2575,25 @@ function generateFastifyIndex(config) {
2230
2575
  } else {
2231
2576
  lines.push(` searchableFields: [],`);
2232
2577
  }
2233
- lines.push(` fields,`);
2234
2578
  lines.push(` schema: ${className}Schema,`);
2235
2579
  lines.push(` createSchema: Create${className}Schema,`);
2236
2580
  lines.push(` updateSchema: Update${className}Schema,`);
2581
+ if (generatedFields.length > 0) {
2582
+ lines.push(
2583
+ ` beforeCreateFields: [${generatedFields.map((field) => `'${field.name}'`).join(", ")}],`
2584
+ );
2585
+ lines.push(` beforeCreate: (_request, data) => {`);
2586
+ for (const field of generatedFields) {
2587
+ lines.push(
2588
+ ` if (!('${field.name}' in data) || data.${field.name} == null) {`
2589
+ );
2590
+ lines.push(
2591
+ ` data.${field.name} = generate${className}${toPascal(field.name)}();`
2592
+ );
2593
+ lines.push(` }`);
2594
+ }
2595
+ lines.push(` },`);
2596
+ }
2237
2597
  lines.push(`};`);
2238
2598
  lines.push("");
2239
2599
  lines.push(`EntityRegistry.register(${camelConfig});`);
@@ -2247,7 +2607,7 @@ function generatePrismaModel(config) {
2247
2607
  lines.push(` id String @id @default(uuid())`);
2248
2608
  for (const f of config.fields) {
2249
2609
  const padded = f.name.padEnd(10);
2250
- lines.push(` ${padded} ${prismaType(f.type, f.required)}`);
2610
+ lines.push(` ${padded} ${prismaFieldType(f)}`);
2251
2611
  }
2252
2612
  if (config.softDelete) {
2253
2613
  lines.push(` deleted_at DateTime?`);
@@ -2262,29 +2622,249 @@ function generatePrismaModel(config) {
2262
2622
  lines.push(`}`);
2263
2623
  return lines.join("\n");
2264
2624
  }
2265
- function tsType(type, required) {
2266
- const base = (() => {
2625
+ function drizzleColumn(field) {
2626
+ let expr;
2627
+ switch (field.type) {
2628
+ case "number":
2629
+ expr = `integer('${field.name}')`;
2630
+ break;
2631
+ case "boolean":
2632
+ expr = `boolean('${field.name}')`;
2633
+ break;
2634
+ case "date":
2635
+ expr = `date('${field.name}')`;
2636
+ break;
2637
+ case "datetime":
2638
+ expr = `timestamp('${field.name}', { withTimezone: true })`;
2639
+ break;
2640
+ case "json":
2641
+ expr = `jsonb('${field.name}')`;
2642
+ break;
2643
+ case "text":
2644
+ case "string":
2645
+ expr = `text('${field.name}')`;
2646
+ break;
2647
+ }
2648
+ if (field.required) expr += ".notNull()";
2649
+ if (field.unique) expr += ".unique()";
2650
+ return expr;
2651
+ }
2652
+ function generateDrizzleTable(config) {
2653
+ const lines = [];
2654
+ const tableConst = toCamel(pluralize(toPascal(config.name)));
2655
+ lines.push(`export const ${tableConst} = pgTable('${config.tableName}', {`);
2656
+ lines.push(` id: uuid('id').primaryKey().defaultRandom(),`);
2657
+ lines.push(
2658
+ ` createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),`
2659
+ );
2660
+ lines.push(
2661
+ ` updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),`
2662
+ );
2663
+ if (config.softDelete) {
2664
+ lines.push(` deletedAt: timestamp('deleted_at', { withTimezone: true }),`);
2665
+ }
2666
+ for (const field of config.fields) {
2667
+ lines.push(` ${toCamel(field.name)}: ${drizzleColumn(field)},`);
2668
+ }
2669
+ lines.push(`});`);
2670
+ return lines.join("\n");
2671
+ }
2672
+ function drizzleImports(config) {
2673
+ const used = /* @__PURE__ */ new Set(["pgTable", "uuid", "timestamp"]);
2674
+ for (const field of config.fields) {
2675
+ switch (field.type) {
2676
+ case "number":
2677
+ used.add("integer");
2678
+ break;
2679
+ case "boolean":
2680
+ used.add("boolean");
2681
+ break;
2682
+ case "date":
2683
+ used.add("date");
2684
+ break;
2685
+ case "datetime":
2686
+ used.add("timestamp");
2687
+ break;
2688
+ case "json":
2689
+ used.add("jsonb");
2690
+ break;
2691
+ case "text":
2692
+ case "string":
2693
+ used.add("text");
2694
+ break;
2695
+ }
2696
+ }
2697
+ return [...used].sort();
2698
+ }
2699
+ function zodType(type, required) {
2700
+ const inner = (() => {
2267
2701
  switch (type) {
2268
2702
  case "string":
2269
2703
  case "text":
2270
- case "date":
2271
- case "datetime":
2272
- return "string";
2704
+ return "z.string()";
2273
2705
  case "number":
2274
- return "number";
2706
+ return "z.number()";
2275
2707
  case "boolean":
2276
- return "boolean";
2708
+ return "z.boolean()";
2709
+ case "date":
2710
+ return "z.string().date()";
2711
+ case "datetime":
2712
+ return "z.string().datetime()";
2277
2713
  case "json":
2278
- return "Record<string, unknown>";
2714
+ return "z.unknown()";
2279
2715
  }
2280
2716
  })();
2281
- return required ? base : `${base} | null`;
2717
+ return required ? inner : `${inner}.nullable()`;
2282
2718
  }
2283
- function generateFrontendInterface(config) {
2719
+ function zodOptional(type) {
2720
+ switch (type) {
2721
+ case "string":
2722
+ case "text":
2723
+ return "z.string().optional()";
2724
+ case "number":
2725
+ return "z.number().optional()";
2726
+ case "boolean":
2727
+ return "z.boolean().optional()";
2728
+ case "date":
2729
+ return "z.string().date().optional()";
2730
+ case "datetime":
2731
+ return "z.string().datetime().optional()";
2732
+ case "json":
2733
+ return "z.unknown().optional()";
2734
+ }
2735
+ }
2736
+ function generateExpressSchemas(config) {
2284
2737
  const className = toPascal(config.name);
2285
2738
  const lines = [];
2286
- lines.push(`export interface ${className} {`);
2287
- lines.push(` id: string;`);
2739
+ lines.push(`import { z } from 'zod';`);
2740
+ lines.push("");
2741
+ lines.push(`export const ${className}Schema = z.object({`);
2742
+ lines.push(` id: z.string().uuid(),`);
2743
+ for (const f of config.fields) {
2744
+ lines.push(` ${f.name}: ${zodType(f.type, f.required)},`);
2745
+ }
2746
+ lines.push(` created_at: z.string().datetime(),`);
2747
+ lines.push(` updated_at: z.string().datetime(),`);
2748
+ if (config.softDelete)
2749
+ lines.push(` deleted_at: z.string().datetime().nullable(),`);
2750
+ lines.push(`});`);
2751
+ lines.push("");
2752
+ lines.push(`export type ${className} = z.infer<typeof ${className}Schema>;`);
2753
+ lines.push("");
2754
+ lines.push(`export const Create${className}Schema = z.object({`);
2755
+ for (const f of config.fields.filter((field) => !field.generated)) {
2756
+ if (f.required) {
2757
+ lines.push(` ${f.name}: ${zodType(f.type, true)},`);
2758
+ } else {
2759
+ lines.push(` ${f.name}: ${zodOptional(f.type)},`);
2760
+ }
2761
+ }
2762
+ lines.push(`});`);
2763
+ lines.push("");
2764
+ lines.push(
2765
+ `export type Create${className} = z.infer<typeof Create${className}Schema>;`
2766
+ );
2767
+ lines.push("");
2768
+ lines.push(`export const Update${className}Schema = z.object({`);
2769
+ for (const f of config.fields.filter((field) => !field.generated)) {
2770
+ lines.push(` ${f.name}: ${zodOptional(f.type)},`);
2771
+ }
2772
+ lines.push(`});`);
2773
+ lines.push("");
2774
+ lines.push(
2775
+ `export type Update${className} = z.infer<typeof Update${className}Schema>;`
2776
+ );
2777
+ lines.push("");
2778
+ return lines.join("\n");
2779
+ }
2780
+ function generateExpressIndex(config) {
2781
+ const className = toPascal(config.name);
2782
+ const camelConfig = className.charAt(0).toLowerCase() + className.slice(1) + "Config";
2783
+ const generatedFields = config.fields.filter((field) => field.generated);
2784
+ const tags = config.apiPrefix.replace(/^\//, "");
2785
+ const lines = [];
2786
+ lines.push(
2787
+ `import { EntityRegistry, type EntityConfig } from '../_base/index.js';`
2788
+ );
2789
+ if (generatedFields.length > 0) {
2790
+ lines.push(`import { randomBytes } from 'node:crypto';`);
2791
+ }
2792
+ lines.push(
2793
+ `import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`
2794
+ );
2795
+ lines.push("");
2796
+ for (const field of generatedFields) {
2797
+ lines.push(
2798
+ `function generate${className}${toPascal(field.name)}(): string {`
2799
+ );
2800
+ lines.push(` return randomBytes(8).toString('hex').toUpperCase();`);
2801
+ lines.push(`}`);
2802
+ lines.push("");
2803
+ }
2804
+ lines.push(`export const ${camelConfig}: EntityConfig = {`);
2805
+ lines.push(` name: '${className}',`);
2806
+ lines.push(` tableName: '${config.tableName}',`);
2807
+ lines.push(` prismaModel: '${className}',`);
2808
+ lines.push(` apiPrefix: '${config.apiPrefix}',`);
2809
+ lines.push(` tags: ['${tags}'],`);
2810
+ lines.push(` readonly: ${config.readonly},`);
2811
+ lines.push(` softDelete: ${config.softDelete},`);
2812
+ lines.push(` bulkOperations: ${config.bulkOperations},`);
2813
+ if (config.searchableFields.length > 0) {
2814
+ lines.push(
2815
+ ` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`
2816
+ );
2817
+ } else {
2818
+ lines.push(` searchableFields: [],`);
2819
+ }
2820
+ lines.push(` schema: ${className}Schema,`);
2821
+ lines.push(` createSchema: Create${className}Schema,`);
2822
+ lines.push(` updateSchema: Update${className}Schema,`);
2823
+ if (generatedFields.length > 0) {
2824
+ lines.push(
2825
+ ` beforeCreateFields: [${generatedFields.map((field) => `'${field.name}'`).join(", ")}],`
2826
+ );
2827
+ lines.push(` beforeCreate: (_request, data) => {`);
2828
+ for (const field of generatedFields) {
2829
+ lines.push(
2830
+ ` if (!('${field.name}' in data) || data.${field.name} == null) {`
2831
+ );
2832
+ lines.push(
2833
+ ` data.${field.name} = generate${className}${toPascal(field.name)}();`
2834
+ );
2835
+ lines.push(` }`);
2836
+ }
2837
+ lines.push(` },`);
2838
+ }
2839
+ lines.push(`};`);
2840
+ lines.push("");
2841
+ lines.push(`EntityRegistry.register(${camelConfig});`);
2842
+ lines.push("");
2843
+ return lines.join("\n");
2844
+ }
2845
+ function tsType(type, required) {
2846
+ const base = (() => {
2847
+ switch (type) {
2848
+ case "string":
2849
+ case "text":
2850
+ case "date":
2851
+ case "datetime":
2852
+ return "string";
2853
+ case "number":
2854
+ return "number";
2855
+ case "boolean":
2856
+ return "boolean";
2857
+ case "json":
2858
+ return "Record<string, unknown>";
2859
+ }
2860
+ })();
2861
+ return required ? base : `${base} | null`;
2862
+ }
2863
+ function generateFrontendInterface(config) {
2864
+ const className = toPascal(config.name);
2865
+ const lines = [];
2866
+ lines.push(`export interface ${className} {`);
2867
+ lines.push(` id: string;`);
2288
2868
  for (const f of config.fields) {
2289
2869
  lines.push(` ${f.name}: ${tsType(f.type, f.required)};`);
2290
2870
  }
@@ -2331,7 +2911,8 @@ function dartType(type, required) {
2331
2911
  return required ? base : `${base}?`;
2332
2912
  }
2333
2913
  function toCamel(s) {
2334
- return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
2914
+ const pascal = toPascal(s);
2915
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
2335
2916
  }
2336
2917
  function dartFromJson(fieldName, type, required) {
2337
2918
  const key = `json['${fieldName}']`;
@@ -2565,45 +3146,499 @@ function generateFastifyTest(config) {
2565
3146
  const className = toPascal(config.name);
2566
3147
  const basePath = `/api/v1${config.apiPrefix}`;
2567
3148
  const updateField = config.fields[0];
3149
+ const uniqueFields = config.fields.filter((field) => field.unique);
2568
3150
  const lines = [];
2569
3151
  lines.push(
2570
3152
  `import { describeCrudEntity } from '../helpers/crud-test-base.js';`
2571
3153
  );
3154
+ lines.push(
3155
+ `import { Create${className}Schema } from '../../src/modules/${toKebab(config.name)}/schemas.js';`
3156
+ );
2572
3157
  lines.push("");
2573
3158
  lines.push(`describeCrudEntity({`);
2574
3159
  lines.push(` entityName: '${className}',`);
2575
3160
  lines.push(` basePath: '${basePath}',`);
2576
3161
  lines.push(` prismaModel: '${className}',`);
2577
- lines.push(` createPayload: {`);
2578
- for (const f of config.fields) {
2579
- lines.push(` ${f.name}: ${tsLiteral(f.type, "create")},`);
2580
- }
3162
+ lines.push(` createSchema: Create${className}Schema,`);
3163
+ lines.push(` updatePayload: {`);
3164
+ lines.push(
3165
+ ` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`
3166
+ );
2581
3167
  lines.push(` },`);
3168
+ if (uniqueFields.length > 0) {
3169
+ lines.push(
3170
+ ` uniqueFields: [${uniqueFields.map((field) => `'${field.name}'`).join(", ")}],`
3171
+ );
3172
+ }
3173
+ lines.push(`});`);
3174
+ lines.push("");
3175
+ return lines.join("\n");
3176
+ }
3177
+ function generateExpressTest(config) {
3178
+ const className = toPascal(config.name);
3179
+ const basePath = `/api/v1${config.apiPrefix}`;
3180
+ const updateField = config.fields[0];
3181
+ const uniqueFields = config.fields.filter((field) => field.unique);
3182
+ const lines = [];
3183
+ lines.push(
3184
+ `import { describeCrudEntity } from '../helpers/crud-test-base.js';`
3185
+ );
3186
+ lines.push(
3187
+ `import { Create${className}Schema } from '../../src/modules/${toKebab(config.name)}/schemas.js';`
3188
+ );
3189
+ lines.push("");
3190
+ lines.push(`describeCrudEntity({`);
3191
+ lines.push(` entityName: '${className}',`);
3192
+ lines.push(` basePath: '${basePath}',`);
3193
+ lines.push(` prismaModel: '${className}',`);
3194
+ lines.push(` createSchema: Create${className}Schema,`);
2582
3195
  lines.push(` updatePayload: {`);
2583
3196
  lines.push(
2584
3197
  ` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`
2585
3198
  );
2586
3199
  lines.push(` },`);
3200
+ if (uniqueFields.length > 0) {
3201
+ lines.push(
3202
+ ` uniqueFields: [${uniqueFields.map((field) => `'${field.name}'`).join(", ")}],`
3203
+ );
3204
+ }
2587
3205
  lines.push(`});`);
2588
3206
  lines.push("");
2589
3207
  return lines.join("\n");
2590
3208
  }
2591
- async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, backendFlag) {
3209
+ function addonGenEntityPath(orm, fileName) {
3210
+ const thisFile = fileURLToPath(import.meta.url);
3211
+ return join10(thisFile, "../../src/addons/orms", orm, "gen-entity", fileName);
3212
+ }
3213
+ function sampleValue(field) {
3214
+ switch (field.type) {
3215
+ case "string":
3216
+ case "text":
3217
+ return `'sample-${field.name}'`;
3218
+ case "number":
3219
+ return "1";
3220
+ case "boolean":
3221
+ return "true";
3222
+ case "date":
3223
+ return "'2025-01-01'";
3224
+ case "datetime":
3225
+ return "'2025-01-01T00:00:00Z'";
3226
+ case "json":
3227
+ return "{}";
3228
+ }
3229
+ }
3230
+ function sampleJsonPayload(fields) {
3231
+ const props = fields.filter((f) => !f.generated).map((f) => `${f.name}: ${sampleValue(f)}`);
3232
+ return `{ ${props.join(", ")} }`;
3233
+ }
3234
+ function updateJsonPayload(fields) {
3235
+ const editable = fields.find(
3236
+ (f) => !f.generated && (f.type === "string" || f.type === "text")
3237
+ );
3238
+ if (editable) return `{ ${editable.name}: 'updated-${editable.name}' }`;
3239
+ const numeric = fields.find((f) => !f.generated && f.type === "number");
3240
+ if (numeric) return `{ ${numeric.name}: 2 }`;
3241
+ return sampleJsonPayload(fields);
3242
+ }
3243
+ function insertAtAnchor(content, anchor, insertion) {
3244
+ if (content.includes(insertion)) return content;
3245
+ const lines = content.split("\n");
3246
+ for (let i = 0; i < lines.length; i++) {
3247
+ if (lines[i].includes(anchor)) {
3248
+ lines.splice(i + 1, 0, insertion);
3249
+ return lines.join("\n");
3250
+ }
3251
+ }
3252
+ return content;
3253
+ }
3254
+ async function fillTemplate(orm, fileName, vars) {
3255
+ const path = addonGenEntityPath(orm, fileName);
3256
+ if (!existsSync10(path)) {
3257
+ throw new Error(`Addon template not found: ${path}`);
3258
+ }
3259
+ let content = await readFile7(path, "utf-8");
3260
+ for (const [key, value] of Object.entries(vars)) {
3261
+ content = content.replaceAll(`__${key}__`, value);
3262
+ }
3263
+ return content;
3264
+ }
3265
+ function buildDrizzleEntityVars(config) {
3266
+ const pascal = toPascal(config.name);
3267
+ return {
3268
+ ENTITY_PASCAL: pascal,
3269
+ TABLE_CAMEL: toCamel(pluralize(pascal)),
3270
+ API_PREFIX: config.apiPrefix,
3271
+ TAG: config.apiPrefix.replace(/^\//, ""),
3272
+ SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
3273
+ BULK_OPERATIONS: String(config.bulkOperations),
3274
+ SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
3275
+ UPDATE_PAYLOAD: updateJsonPayload(config.fields)
3276
+ };
3277
+ }
3278
+ function sequelizeFieldType(field) {
3279
+ switch (field.type) {
3280
+ case "string":
3281
+ return { dataType: "STRING", tsType: "string" };
3282
+ case "text":
3283
+ return { dataType: "TEXT", tsType: "string" };
3284
+ case "number":
3285
+ return { dataType: "INTEGER", tsType: "number" };
3286
+ case "boolean":
3287
+ return { dataType: "BOOLEAN", tsType: "boolean" };
3288
+ case "date":
3289
+ return { dataType: "DATEONLY", tsType: "Date" };
3290
+ case "datetime":
3291
+ return { dataType: "DATE", tsType: "Date" };
3292
+ case "json":
3293
+ return { dataType: "JSONB", tsType: "unknown" };
3294
+ }
3295
+ }
3296
+ function sequelizeFieldDeclarations(fields) {
3297
+ return fields.map((f) => {
3298
+ const { tsType: tsType2 } = sequelizeFieldType(f);
3299
+ const nullable = f.required ? "" : " | null";
3300
+ return ` declare ${toCamel(f.name)}: ${tsType2}${nullable};`;
3301
+ }).join("\n");
3302
+ }
3303
+ function sequelizeFieldDefinitions(fields) {
3304
+ return fields.map((f) => {
3305
+ const { dataType } = sequelizeFieldType(f);
3306
+ const parts = [`type: DataTypes.${dataType}`];
3307
+ if (f.required) parts.push("allowNull: false");
3308
+ else parts.push("allowNull: true");
3309
+ if (f.unique) parts.push("unique: true");
3310
+ return ` ${toCamel(f.name)}: { ${parts.join(", ")} },`;
3311
+ }).join("\n");
3312
+ }
3313
+ function buildSequelizeEntityVars(config) {
3314
+ const pascal = toPascal(config.name);
3315
+ const kebab = toKebab(config.name);
3316
+ return {
3317
+ ENTITY_PASCAL: pascal,
3318
+ ENTITY_KEBAB: kebab,
3319
+ TABLE_NAME: config.tableName,
3320
+ API_PREFIX: config.apiPrefix,
3321
+ TAG: config.apiPrefix.replace(/^\//, ""),
3322
+ SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
3323
+ BULK_OPERATIONS: String(config.bulkOperations),
3324
+ SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
3325
+ UPDATE_PAYLOAD: updateJsonPayload(config.fields),
3326
+ FIELD_DECLARATIONS: sequelizeFieldDeclarations(config.fields),
3327
+ FIELD_DEFINITIONS: sequelizeFieldDefinitions(config.fields)
3328
+ };
3329
+ }
3330
+ function typeormColumnType(field) {
3331
+ switch (field.type) {
3332
+ case "string":
3333
+ return "varchar";
3334
+ case "text":
3335
+ return "text";
3336
+ case "number":
3337
+ return "integer";
3338
+ case "boolean":
3339
+ return "boolean";
3340
+ case "date":
3341
+ return "date";
3342
+ case "datetime":
3343
+ return "timestamptz";
3344
+ case "json":
3345
+ return "jsonb";
3346
+ }
3347
+ }
3348
+ function typeormColumnTsType(field) {
3349
+ switch (field.type) {
3350
+ case "string":
3351
+ case "text":
3352
+ return "string";
3353
+ case "number":
3354
+ return "number";
3355
+ case "boolean":
3356
+ return "boolean";
3357
+ case "date":
3358
+ case "datetime":
3359
+ return "Date";
3360
+ case "json":
3361
+ return "unknown";
3362
+ }
3363
+ }
3364
+ function typeormColumnDecorators(fields) {
3365
+ return fields.map((f) => {
3366
+ const dbName = f.name;
3367
+ const propName = toCamel(f.name);
3368
+ const opts = [
3369
+ `type: '${typeormColumnType(f)}'`,
3370
+ `name: '${dbName}'`
3371
+ ];
3372
+ if (!f.required) opts.push("nullable: true");
3373
+ if (f.unique) opts.push("unique: true");
3374
+ const tsType2 = typeormColumnTsType(f);
3375
+ const nullable = f.required ? "!" : "?";
3376
+ return ` @Column({ ${opts.join(", ")} })
3377
+ ${propName}${nullable}: ${tsType2}${f.required ? "" : " | null"};`;
3378
+ }).join("\n\n");
3379
+ }
3380
+ function buildTypeormEntityVars(config) {
3381
+ const pascal = toPascal(config.name);
3382
+ const kebab = toKebab(config.name);
3383
+ return {
3384
+ ENTITY_PASCAL: pascal,
3385
+ ENTITY_KEBAB: kebab,
3386
+ TABLE_NAME: config.tableName,
3387
+ API_PREFIX: config.apiPrefix,
3388
+ TAG: config.apiPrefix.replace(/^\//, ""),
3389
+ SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
3390
+ BULK_OPERATIONS: String(config.bulkOperations),
3391
+ SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
3392
+ UPDATE_PAYLOAD: updateJsonPayload(config.fields),
3393
+ COLUMN_DECORATORS: typeormColumnDecorators(config.fields)
3394
+ };
3395
+ }
3396
+ async function appendTypeormEntity(cwd, dir, framework, config, generated) {
3397
+ const vars = buildTypeormEntityVars(config);
3398
+ const kebab = vars.ENTITY_KEBAB;
3399
+ const entitiesDir = join10(cwd, dir, "src/entities");
3400
+ await mkdir3(entitiesDir, { recursive: true });
3401
+ const entityPath = join10(entitiesDir, `${kebab}.ts`);
3402
+ if (!existsSync10(entityPath)) {
3403
+ const entitySource = await fillTemplate("typeorm", "entity.ts", vars);
3404
+ await writeFile2(entityPath, entitySource);
3405
+ generated.push(`${dir}/src/entities/${kebab}.ts`);
3406
+ }
3407
+ const entitiesIndexPath = join10(entitiesDir, "index.ts");
3408
+ if (existsSync10(entitiesIndexPath)) {
3409
+ let content = await readFile7(entitiesIndexPath, "utf-8");
3410
+ const importLine = `import { ${vars.ENTITY_PASCAL} } from './${kebab}.js';`;
3411
+ const exportLine = ` ${vars.ENTITY_PASCAL},`;
3412
+ const updated = insertAtAnchor(
3413
+ insertAtAnchor(content, "projx-anchor: model-imports", importLine),
3414
+ "projx-anchor: model-exports",
3415
+ exportLine
3416
+ );
3417
+ if (updated !== content) {
3418
+ content = updated;
3419
+ await writeFile2(entitiesIndexPath, content);
3420
+ generated.push(`${dir}/src/entities/index.ts (entity wired)`);
3421
+ }
3422
+ }
3423
+ const moduleDir = join10(cwd, dir, "src/modules", kebab);
3424
+ if (!existsSync10(moduleDir)) {
3425
+ await mkdir3(moduleDir, { recursive: true });
3426
+ const routerSource = await fillTemplate(
3427
+ "typeorm",
3428
+ framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
3429
+ vars
3430
+ );
3431
+ await writeFile2(join10(moduleDir, "index.ts"), routerSource);
3432
+ generated.push(`${dir}/src/modules/${kebab}/index.ts`);
3433
+ }
3434
+ const appPath = join10(cwd, dir, "src/app.ts");
3435
+ if (existsSync10(appPath)) {
3436
+ let appContent = await readFile7(appPath, "utf-8");
3437
+ const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
3438
+ const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app);`;
3439
+ const updated = insertAtAnchor(
3440
+ insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
3441
+ "projx-anchor: entity-registrations",
3442
+ registrationLine
3443
+ );
3444
+ if (updated !== appContent) {
3445
+ appContent = updated;
3446
+ await writeFile2(appPath, appContent);
3447
+ generated.push(`${dir}/src/app.ts (entity wired)`);
3448
+ }
3449
+ }
3450
+ const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
3451
+ await mkdir3(testsDir, { recursive: true });
3452
+ const testFile = join10(testsDir, `${kebab}.test.ts`);
3453
+ if (!existsSync10(testFile)) {
3454
+ const testSource = await fillTemplate(
3455
+ "typeorm",
3456
+ framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
3457
+ vars
3458
+ );
3459
+ await writeFile2(testFile, testSource);
3460
+ const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
3461
+ generated.push(`${dir}/${testRel}`);
3462
+ }
3463
+ }
3464
+ async function appendSequelizeEntity(cwd, dir, framework, config, generated) {
3465
+ const vars = buildSequelizeEntityVars(config);
3466
+ const kebab = vars.ENTITY_KEBAB;
3467
+ const modelsDir = join10(cwd, dir, "src/models");
3468
+ await mkdir3(modelsDir, { recursive: true });
3469
+ const modelPath = join10(modelsDir, `${kebab}.ts`);
3470
+ if (!existsSync10(modelPath)) {
3471
+ const modelSource = await fillTemplate("sequelize", "model.ts", vars);
3472
+ await writeFile2(modelPath, modelSource);
3473
+ generated.push(`${dir}/src/models/${kebab}.ts`);
3474
+ }
3475
+ const modelsIndexPath = join10(modelsDir, "index.ts");
3476
+ if (existsSync10(modelsIndexPath)) {
3477
+ let content = await readFile7(modelsIndexPath, "utf-8");
3478
+ const importLine = `import { ${vars.ENTITY_PASCAL} } from './${kebab}.js';`;
3479
+ const exportLine = ` ${vars.ENTITY_PASCAL},`;
3480
+ const updated = insertAtAnchor(
3481
+ insertAtAnchor(content, "projx-anchor: model-imports", importLine),
3482
+ "projx-anchor: model-exports",
3483
+ exportLine
3484
+ );
3485
+ if (updated !== content) {
3486
+ content = updated;
3487
+ await writeFile2(modelsIndexPath, content);
3488
+ generated.push(`${dir}/src/models/index.ts (model wired)`);
3489
+ }
3490
+ }
3491
+ const moduleDir = join10(cwd, dir, "src/modules", kebab);
3492
+ if (!existsSync10(moduleDir)) {
3493
+ await mkdir3(moduleDir, { recursive: true });
3494
+ const routerSource = await fillTemplate(
3495
+ "sequelize",
3496
+ framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
3497
+ vars
3498
+ );
3499
+ await writeFile2(join10(moduleDir, "index.ts"), routerSource);
3500
+ generated.push(`${dir}/src/modules/${kebab}/index.ts`);
3501
+ }
3502
+ const appPath = join10(cwd, dir, "src/app.ts");
3503
+ if (existsSync10(appPath)) {
3504
+ let appContent = await readFile7(appPath, "utf-8");
3505
+ const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
3506
+ const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app);`;
3507
+ const updated = insertAtAnchor(
3508
+ insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
3509
+ "projx-anchor: entity-registrations",
3510
+ registrationLine
3511
+ );
3512
+ if (updated !== appContent) {
3513
+ appContent = updated;
3514
+ await writeFile2(appPath, appContent);
3515
+ generated.push(`${dir}/src/app.ts (entity wired)`);
3516
+ }
3517
+ }
3518
+ const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
3519
+ await mkdir3(testsDir, { recursive: true });
3520
+ const testFile = join10(testsDir, `${kebab}.test.ts`);
3521
+ if (!existsSync10(testFile)) {
3522
+ const testSource = await fillTemplate(
3523
+ "sequelize",
3524
+ framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
3525
+ vars
3526
+ );
3527
+ await writeFile2(testFile, testSource);
3528
+ const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
3529
+ generated.push(`${dir}/${testRel}`);
3530
+ }
3531
+ }
3532
+ async function appendDrizzleEntity(cwd, dir, framework, config, generated) {
3533
+ const schemaDir = join10(cwd, dir, "src/db");
3534
+ const schemaPath = join10(schemaDir, "schema.ts");
3535
+ const tableConst = toCamel(pluralize(toPascal(config.name)));
3536
+ const tableSource = generateDrizzleTable(config);
3537
+ await mkdir3(schemaDir, { recursive: true });
3538
+ const usedImports = drizzleImports(config);
3539
+ if (!existsSync10(schemaPath)) {
3540
+ await writeFile2(
3541
+ schemaPath,
3542
+ `import { ${usedImports.join(", ")} } from 'drizzle-orm/pg-core';
3543
+
3544
+ ${tableSource}
3545
+ `
3546
+ );
3547
+ generated.push(`${dir}/src/db/schema.ts`);
3548
+ } else {
3549
+ const content = await readFile7(schemaPath, "utf-8");
3550
+ if (!content.includes(`export const ${tableConst} = pgTable(`)) {
3551
+ let updated = content;
3552
+ const importLine = `import { ${usedImports.join(", ")} } from 'drizzle-orm/pg-core';`;
3553
+ if (!updated.includes("drizzle-orm/pg-core")) {
3554
+ updated = importLine + "\n\n" + updated;
3555
+ } else {
3556
+ updated = updated.replace(
3557
+ /import\s+\{([^}]+)\}\s+from\s+'drizzle-orm\/pg-core';/,
3558
+ (_match, imports) => {
3559
+ const names = new Set(
3560
+ String(imports).split(",").map((item) => item.trim()).filter(Boolean)
3561
+ );
3562
+ for (const name of usedImports) {
3563
+ names.add(name);
3564
+ }
3565
+ return `import { ${[...names].sort().join(", ")} } from 'drizzle-orm/pg-core';`;
3566
+ }
3567
+ );
3568
+ }
3569
+ await writeFile2(
3570
+ schemaPath,
3571
+ updated.trimEnd() + "\n\n" + tableSource + "\n"
3572
+ );
3573
+ generated.push(`${dir}/src/db/schema.ts (table added)`);
3574
+ }
3575
+ }
3576
+ const vars = buildDrizzleEntityVars(config);
3577
+ const kebab = toKebab(config.name);
3578
+ const moduleDir = join10(cwd, dir, "src/modules", kebab);
3579
+ if (!existsSync10(moduleDir)) {
3580
+ await mkdir3(moduleDir, { recursive: true });
3581
+ const routerSource = await fillTemplate(
3582
+ "drizzle",
3583
+ framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
3584
+ vars
3585
+ );
3586
+ await writeFile2(join10(moduleDir, "index.ts"), routerSource);
3587
+ generated.push(`${dir}/src/modules/${kebab}/index.ts`);
3588
+ }
3589
+ const appPath = join10(cwd, dir, "src/app.ts");
3590
+ if (existsSync10(appPath)) {
3591
+ let appContent = await readFile7(appPath, "utf-8");
3592
+ const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
3593
+ const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app, db);`;
3594
+ const updated = insertAtAnchor(
3595
+ insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
3596
+ "projx-anchor: entity-registrations",
3597
+ registrationLine
3598
+ );
3599
+ if (updated !== appContent) {
3600
+ appContent = updated;
3601
+ await writeFile2(appPath, appContent);
3602
+ generated.push(`${dir}/src/app.ts (entity wired)`);
3603
+ }
3604
+ }
3605
+ const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
3606
+ await mkdir3(testsDir, { recursive: true });
3607
+ const testFile = join10(testsDir, `${kebab}.test.ts`);
3608
+ if (!existsSync10(testFile)) {
3609
+ const testSource = await fillTemplate(
3610
+ "drizzle",
3611
+ framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
3612
+ vars
3613
+ );
3614
+ await writeFile2(testFile, testSource);
3615
+ const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
3616
+ generated.push(`${dir}/${testRel}`);
3617
+ }
3618
+ }
3619
+ async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, hasExpress, backendFlag) {
2592
3620
  if (backendFlag) return backendFlag;
2593
- if (hasFastapi && !hasFastify) return "fastapi";
2594
- if (hasFastify && !hasFastapi) return "fastify";
3621
+ const backends = [
3622
+ hasFastapi ? "fastapi" : void 0,
3623
+ hasFastify ? "fastify" : void 0,
3624
+ hasExpress ? "express" : void 0
3625
+ ].filter((item) => item !== void 0);
3626
+ if (backends.length === 1) return backends[0];
2595
3627
  const config = await readProjxConfig(cwd);
2596
- if (config.primaryBackend === "fastapi" || config.primaryBackend === "fastify") {
3628
+ if (config.primaryBackend === "fastapi" || config.primaryBackend === "fastify" || config.primaryBackend === "express") {
2597
3629
  return config.primaryBackend;
2598
3630
  }
2599
- if (!process.stdin.isTTY) return "fastify";
3631
+ if (!process.stdin.isTTY) {
3632
+ return hasFastify ? "fastify" : hasExpress ? "express" : "fastapi";
3633
+ }
2600
3634
  const choice = await p9.select({
2601
- message: "Both backends detected. Which is your primary?",
3635
+ message: "Multiple backends detected. Which is your primary?",
2602
3636
  options: [
2603
- { value: "fastify", label: "fastify (API backend)" },
2604
- { value: "fastapi", label: "fastapi (AI/ML engine)" }
3637
+ ...hasFastify ? [{ value: "fastify", label: "fastify (API backend)" }] : [],
3638
+ ...hasExpress ? [{ value: "express", label: "express (API backend)" }] : [],
3639
+ ...hasFastapi ? [{ value: "fastapi", label: "fastapi (AI/ML engine)" }] : []
2605
3640
  ],
2606
- initialValue: "fastify"
3641
+ initialValue: hasFastify ? "fastify" : hasExpress ? "express" : "fastapi"
2607
3642
  });
2608
3643
  if (p9.isCancel(choice)) process.exit(0);
2609
3644
  await writeProjxConfig(cwd, { ...config, primaryBackend: choice });
@@ -2612,30 +3647,39 @@ async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, backendFlag) {
2612
3647
  }
2613
3648
  async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2614
3649
  p9.intro(`projx gen entity ${entityName}`);
2615
- if (!existsSync9(join9(cwd, ".projx"))) {
3650
+ if (!existsSync10(join10(cwd, ".projx"))) {
2616
3651
  p9.log.error("No .projx file found. Run 'npx create-projx init' first.");
2617
3652
  process.exit(1);
2618
3653
  }
2619
3654
  const projxData = await readProjxConfig(cwd);
2620
3655
  const pmName = projxData.packageManager ?? "npm";
2621
3656
  const pm = pmCommands(pmName);
3657
+ const orm = projxData.orm ?? "prisma";
2622
3658
  const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
2623
3659
  const hasFastapi = discovered.includes("fastapi");
2624
3660
  const hasFastify = discovered.includes("fastify");
3661
+ const hasExpress = discovered.includes("express");
2625
3662
  const hasFrontend = discovered.includes("frontend");
2626
3663
  const hasMobile = discovered.includes("mobile");
2627
- if (!hasFastapi && !hasFastify) {
2628
- p9.log.error("No backend component found. Need fastapi or fastify.");
3664
+ if (!hasFastapi && !hasFastify && !hasExpress) {
3665
+ p9.log.error(
3666
+ "No backend component found. Need fastapi, fastify, or express."
3667
+ );
2629
3668
  process.exit(1);
2630
3669
  }
2631
3670
  const targetBackend = await resolvePrimaryBackend(
2632
3671
  cwd,
2633
3672
  hasFastapi,
2634
3673
  hasFastify,
3674
+ hasExpress,
2635
3675
  backendFlag
2636
3676
  );
2637
3677
  const genFastapi = targetBackend === "fastapi" && hasFastapi;
2638
3678
  const genFastify = targetBackend === "fastify" && hasFastify;
3679
+ const genExpress = targetBackend === "express" && hasExpress;
3680
+ const genDrizzle = orm === "drizzle" && (genFastify || genExpress);
3681
+ const genSequelize = orm === "sequelize" && (genFastify || genExpress);
3682
+ const genTypeorm = orm === "typeorm" && (genFastify || genExpress);
2639
3683
  let config;
2640
3684
  if (fieldsFlag) {
2641
3685
  const fields = parseFieldsFlag(fieldsFlag);
@@ -2658,53 +3702,137 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2658
3702
  const generated = [];
2659
3703
  if (genFastapi) {
2660
3704
  const dir = componentPaths.fastapi;
2661
- const entityDir = join9(cwd, dir, "src/entities", toSnake(config.name));
2662
- if (existsSync9(entityDir)) {
3705
+ const entityDir = join10(cwd, dir, "src/entities", toSnake(config.name));
3706
+ if (existsSync10(entityDir)) {
2663
3707
  p9.log.warn(
2664
3708
  `${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`
2665
3709
  );
2666
3710
  } else {
2667
3711
  await mkdir3(entityDir, { recursive: true });
2668
- await writeFile(
2669
- join9(entityDir, "_model.py"),
3712
+ await writeFile2(
3713
+ join10(entityDir, "_model.py"),
2670
3714
  generateFastAPIModel(config)
2671
3715
  );
2672
- await writeFile(
2673
- join9(entityDir, "__init__.py"),
3716
+ await writeFile2(
3717
+ join10(entityDir, "__init__.py"),
2674
3718
  "from ._model import *\n"
2675
3719
  );
2676
3720
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/_model.py`);
2677
3721
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/__init__.py`);
2678
- const testsDir = join9(cwd, dir, "tests");
2679
- const testFile = join9(testsDir, `test_${toSnake(config.name)}_entity.py`);
2680
- if (existsSync9(testsDir) && !existsSync9(testFile)) {
2681
- await writeFile(testFile, generateFastapiTest(config));
3722
+ const testsDir = join10(cwd, dir, "tests");
3723
+ const testFile = join10(testsDir, `test_${toSnake(config.name)}_entity.py`);
3724
+ if (existsSync10(testsDir) && !existsSync10(testFile)) {
3725
+ await writeFile2(testFile, generateFastapiTest(config));
2682
3726
  generated.push(`${dir}/tests/test_${toSnake(config.name)}_entity.py`);
2683
3727
  }
2684
3728
  }
2685
3729
  }
2686
- if (genFastify) {
3730
+ if (genFastify && orm !== "drizzle" && orm !== "sequelize" && orm !== "typeorm") {
2687
3731
  const dir = componentPaths.fastify;
2688
- const moduleDir = join9(cwd, dir, "src/modules", toKebab(config.name));
2689
- if (existsSync9(moduleDir)) {
3732
+ const moduleDir = join10(cwd, dir, "src/modules", toKebab(config.name));
3733
+ if (existsSync10(moduleDir)) {
2690
3734
  p9.log.warn(
2691
3735
  `${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`
2692
3736
  );
2693
3737
  } else {
2694
3738
  await mkdir3(moduleDir, { recursive: true });
2695
- await writeFile(
2696
- join9(moduleDir, "schemas.ts"),
3739
+ await writeFile2(
3740
+ join10(moduleDir, "schemas.ts"),
2697
3741
  generateFastifySchemas(config)
2698
3742
  );
2699
- await writeFile(
2700
- join9(moduleDir, "index.ts"),
3743
+ await writeFile2(
3744
+ join10(moduleDir, "index.ts"),
2701
3745
  generateFastifyIndex(config)
2702
3746
  );
2703
3747
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
2704
3748
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
2705
- const appPath = join9(cwd, dir, "src/app.ts");
2706
- if (existsSync9(appPath)) {
2707
- const appContent = await readFile6(appPath, "utf-8");
3749
+ const appPath = join10(cwd, dir, "src/app.ts");
3750
+ if (existsSync10(appPath)) {
3751
+ const appContent = await readFile7(appPath, "utf-8");
3752
+ const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
3753
+ if (!appContent.includes(importLine)) {
3754
+ const updated = appContent.replace(
3755
+ /^(import\s+'\.\/modules\/.*?';?\s*\n)/m,
3756
+ `$1${importLine}
3757
+ `
3758
+ );
3759
+ if (updated !== appContent) {
3760
+ await writeFile2(appPath, updated);
3761
+ generated.push(`${dir}/src/app.ts (import added)`);
3762
+ }
3763
+ }
3764
+ }
3765
+ const prismaPath = join10(cwd, dir, "prisma/schema.prisma");
3766
+ if (existsSync10(prismaPath)) {
3767
+ const prismaContent = await readFile7(prismaPath, "utf-8");
3768
+ const modelName = `model ${toPascal(config.name)}`;
3769
+ if (!prismaContent.includes(modelName)) {
3770
+ const prismaModel = generatePrismaModel(config);
3771
+ await writeFile2(
3772
+ prismaPath,
3773
+ prismaContent.trimEnd() + "\n\n" + prismaModel + "\n"
3774
+ );
3775
+ generated.push(`${dir}/prisma/schema.prisma (model added)`);
3776
+ }
3777
+ }
3778
+ const testsModulesDir = join10(cwd, dir, "tests/modules");
3779
+ const fastifyTestFile = join10(
3780
+ testsModulesDir,
3781
+ `${toKebab(config.name)}.test.ts`
3782
+ );
3783
+ if (existsSync10(testsModulesDir) && !existsSync10(fastifyTestFile)) {
3784
+ await writeFile2(fastifyTestFile, generateFastifyTest(config));
3785
+ generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
3786
+ }
3787
+ }
3788
+ }
3789
+ if (genFastify && orm === "drizzle") {
3790
+ await appendDrizzleEntity(
3791
+ cwd,
3792
+ componentPaths.fastify,
3793
+ "fastify",
3794
+ config,
3795
+ generated
3796
+ );
3797
+ } else if (genFastify && orm === "sequelize") {
3798
+ await appendSequelizeEntity(
3799
+ cwd,
3800
+ componentPaths.fastify,
3801
+ "fastify",
3802
+ config,
3803
+ generated
3804
+ );
3805
+ } else if (genFastify && orm === "typeorm") {
3806
+ await appendTypeormEntity(
3807
+ cwd,
3808
+ componentPaths.fastify,
3809
+ "fastify",
3810
+ config,
3811
+ generated
3812
+ );
3813
+ }
3814
+ if (genExpress && orm !== "drizzle" && orm !== "sequelize" && orm !== "typeorm") {
3815
+ const dir = componentPaths.express;
3816
+ const moduleDir = join10(cwd, dir, "src/modules", toKebab(config.name));
3817
+ if (existsSync10(moduleDir)) {
3818
+ p9.log.warn(
3819
+ `${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Express.`
3820
+ );
3821
+ } else {
3822
+ await mkdir3(moduleDir, { recursive: true });
3823
+ await writeFile2(
3824
+ join10(moduleDir, "schemas.ts"),
3825
+ generateExpressSchemas(config)
3826
+ );
3827
+ await writeFile2(
3828
+ join10(moduleDir, "index.ts"),
3829
+ generateExpressIndex(config)
3830
+ );
3831
+ generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
3832
+ generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
3833
+ const appPath = join10(cwd, dir, "src/app.ts");
3834
+ if (existsSync10(appPath)) {
3835
+ const appContent = await readFile7(appPath, "utf-8");
2708
3836
  const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
2709
3837
  if (!appContent.includes(importLine)) {
2710
3838
  const updated = appContent.replace(
@@ -2713,75 +3841,100 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2713
3841
  `
2714
3842
  );
2715
3843
  if (updated !== appContent) {
2716
- await writeFile(appPath, updated);
3844
+ await writeFile2(appPath, updated);
2717
3845
  generated.push(`${dir}/src/app.ts (import added)`);
2718
3846
  }
2719
3847
  }
2720
3848
  }
2721
- const prismaPath = join9(cwd, dir, "prisma/schema.prisma");
2722
- if (existsSync9(prismaPath)) {
2723
- const prismaContent = await readFile6(prismaPath, "utf-8");
3849
+ const prismaPath = join10(cwd, dir, "prisma/schema.prisma");
3850
+ if (existsSync10(prismaPath)) {
3851
+ const prismaContent = await readFile7(prismaPath, "utf-8");
2724
3852
  const modelName = `model ${toPascal(config.name)}`;
2725
3853
  if (!prismaContent.includes(modelName)) {
2726
3854
  const prismaModel = generatePrismaModel(config);
2727
- await writeFile(
3855
+ await writeFile2(
2728
3856
  prismaPath,
2729
3857
  prismaContent.trimEnd() + "\n\n" + prismaModel + "\n"
2730
3858
  );
2731
3859
  generated.push(`${dir}/prisma/schema.prisma (model added)`);
2732
3860
  }
2733
3861
  }
2734
- const testsModulesDir = join9(cwd, dir, "tests/modules");
2735
- const fastifyTestFile = join9(
3862
+ const testsModulesDir = join10(cwd, dir, "tests/modules");
3863
+ const expressTestFile = join10(
2736
3864
  testsModulesDir,
2737
3865
  `${toKebab(config.name)}.test.ts`
2738
3866
  );
2739
- if (existsSync9(testsModulesDir) && !existsSync9(fastifyTestFile)) {
2740
- await writeFile(fastifyTestFile, generateFastifyTest(config));
3867
+ if (existsSync10(testsModulesDir) && !existsSync10(expressTestFile)) {
3868
+ await writeFile2(expressTestFile, generateExpressTest(config));
2741
3869
  generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
2742
3870
  }
2743
3871
  }
2744
3872
  }
3873
+ if (genExpress && orm === "drizzle") {
3874
+ await appendDrizzleEntity(
3875
+ cwd,
3876
+ componentPaths.express,
3877
+ "express",
3878
+ config,
3879
+ generated
3880
+ );
3881
+ } else if (genExpress && orm === "sequelize") {
3882
+ await appendSequelizeEntity(
3883
+ cwd,
3884
+ componentPaths.express,
3885
+ "express",
3886
+ config,
3887
+ generated
3888
+ );
3889
+ } else if (genExpress && orm === "typeorm") {
3890
+ await appendTypeormEntity(
3891
+ cwd,
3892
+ componentPaths.express,
3893
+ "express",
3894
+ config,
3895
+ generated
3896
+ );
3897
+ }
2745
3898
  if (hasFrontend) {
2746
3899
  const dir = componentPaths.frontend;
2747
- const typesDir = join9(cwd, dir, "src/types");
3900
+ const typesDir = join10(cwd, dir, "src/types");
2748
3901
  const fileName = toKebab(config.name) + ".ts";
2749
- const filePath = join9(typesDir, fileName);
2750
- if (existsSync9(filePath)) {
3902
+ const filePath = join10(typesDir, fileName);
3903
+ if (existsSync10(filePath)) {
2751
3904
  p9.log.warn(
2752
3905
  `${dir}/src/types/${fileName} already exists. Skipping frontend types.`
2753
3906
  );
2754
3907
  } else {
2755
3908
  await mkdir3(typesDir, { recursive: true });
2756
- await writeFile(filePath, generateFrontendInterface(config));
3909
+ await writeFile2(filePath, generateFrontendInterface(config));
2757
3910
  generated.push(`${dir}/src/types/${fileName}`);
2758
- const barrelPath = join9(typesDir, "index.ts");
3911
+ const barrelPath = join10(typesDir, "index.ts");
2759
3912
  const exportLine = `export * from './${toKebab(config.name)}';`;
2760
- if (existsSync9(barrelPath)) {
2761
- const content = await readFile6(barrelPath, "utf-8");
3913
+ if (existsSync10(barrelPath)) {
3914
+ const content = await readFile7(barrelPath, "utf-8");
2762
3915
  if (!content.includes(exportLine)) {
2763
- await writeFile(
3916
+ await writeFile2(
2764
3917
  barrelPath,
2765
3918
  content.trimEnd() + "\n" + exportLine + "\n"
2766
3919
  );
2767
3920
  }
2768
3921
  } else {
2769
- await writeFile(barrelPath, exportLine + "\n");
3922
+ await writeFile2(barrelPath, exportLine + "\n");
2770
3923
  }
2771
3924
  generated.push(`${dir}/src/types/index.ts`);
2772
3925
  }
2773
3926
  }
2774
3927
  if (hasMobile) {
2775
3928
  const dir = componentPaths.mobile;
2776
- const entityDir = join9(cwd, dir, "lib/entities", toSnake(config.name));
2777
- const modelPath = join9(entityDir, "model.dart");
2778
- if (existsSync9(modelPath)) {
3929
+ const entityDir = join10(cwd, dir, "lib/entities", toSnake(config.name));
3930
+ const modelPath = join10(entityDir, "model.dart");
3931
+ if (existsSync10(modelPath)) {
2779
3932
  p9.log.warn(
2780
3933
  `${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`
2781
3934
  );
2782
3935
  } else {
2783
3936
  await mkdir3(entityDir, { recursive: true });
2784
- await writeFile(modelPath, generateDartModel(config));
3937
+ await writeFile2(modelPath, generateDartModel(config));
2785
3938
  generated.push(`${dir}/lib/entities/${toSnake(config.name)}/model.dart`);
2786
3939
  }
2787
3940
  }
@@ -2803,13 +3956,33 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2803
3956
  );
2804
3957
  p9.log.info(" alembic upgrade head");
2805
3958
  }
2806
- if (genFastify) {
3959
+ if (genFastify && orm === "prisma") {
2807
3960
  p9.log.info("");
2808
3961
  p9.log.info("Fastify next steps:");
2809
3962
  p9.log.info(
2810
3963
  ` ${pm.prismaExec} migrate dev --name add_${toSnake(config.name)}`
2811
3964
  );
2812
3965
  }
3966
+ if (genDrizzle) {
3967
+ p9.log.info("");
3968
+ p9.log.info("Drizzle next steps:");
3969
+ p9.log.info(` ${pm.exec} drizzle-kit generate`);
3970
+ p9.log.info(` ${pm.exec} drizzle-kit migrate`);
3971
+ }
3972
+ if (genSequelize) {
3973
+ p9.log.info("");
3974
+ p9.log.info("Sequelize next steps:");
3975
+ p9.log.info(
3976
+ ` ${pm.run} db:sync # syncs the schema against $DATABASE_URL`
3977
+ );
3978
+ }
3979
+ if (genTypeorm) {
3980
+ p9.log.info("");
3981
+ p9.log.info("TypeORM next steps:");
3982
+ p9.log.info(
3983
+ ` ${pm.run} db:sync # syncs the schema against $DATABASE_URL`
3984
+ );
3985
+ }
2813
3986
  if (hasFrontend) {
2814
3987
  p9.log.info("");
2815
3988
  p9.log.info("Frontend usage:");
@@ -2829,9 +4002,9 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2829
4002
  }
2830
4003
 
2831
4004
  // src/sync.ts
2832
- import { existsSync as existsSync10, readFileSync } from "fs";
2833
- import { writeFile as writeFile2, mkdir as mkdir4 } from "fs/promises";
2834
- import { join as join10 } from "path";
4005
+ import { existsSync as existsSync11, readFileSync } from "fs";
4006
+ import { writeFile as writeFile3, mkdir as mkdir4 } from "fs/promises";
4007
+ import { join as join11 } from "path";
2835
4008
  import * as p10 from "@clack/prompts";
2836
4009
  function toPascal2(s) {
2837
4010
  return s.replace(/(?:^|[_\-\s])([a-zA-Z])/g, (_, c) => c.toUpperCase());
@@ -3002,8 +4175,8 @@ function generateDartModel2(entity) {
3002
4175
  }
3003
4176
  async function sync(cwd, url) {
3004
4177
  p10.intro("projx sync");
3005
- const configPath = join10(cwd, ".projx");
3006
- if (!existsSync10(configPath)) {
4178
+ const configPath = join11(cwd, ".projx");
4179
+ if (!existsSync11(configPath)) {
3007
4180
  p10.log.error("No .projx file found. Run 'npx create-projx init' first.");
3008
4181
  process.exit(1);
3009
4182
  }
@@ -3035,18 +4208,18 @@ async function sync(cwd, url) {
3035
4208
  const generated = [];
3036
4209
  if (hasFrontend) {
3037
4210
  const dir = componentPaths.frontend;
3038
- const typesDir = join10(cwd, dir, "src/types");
4211
+ const typesDir = join11(cwd, dir, "src/types");
3039
4212
  await mkdir4(typesDir, { recursive: true });
3040
4213
  const barrelExports = [];
3041
4214
  for (const entity of meta.entities) {
3042
4215
  const fileName = toKebab(toSnake(entity.name)) + ".ts";
3043
- const filePath = join10(typesDir, fileName);
3044
- await writeFile2(filePath, generateTsInterface(entity));
4216
+ const filePath = join11(typesDir, fileName);
4217
+ await writeFile3(filePath, generateTsInterface(entity));
3045
4218
  generated.push(`${dir}/src/types/${fileName}`);
3046
4219
  barrelExports.push(`export * from './${toKebab(toSnake(entity.name))}';`);
3047
4220
  }
3048
- await writeFile2(
3049
- join10(typesDir, "index.ts"),
4221
+ await writeFile3(
4222
+ join11(typesDir, "index.ts"),
3050
4223
  barrelExports.join("\n") + "\n"
3051
4224
  );
3052
4225
  generated.push(`${dir}/src/types/index.ts`);
@@ -3054,10 +4227,10 @@ async function sync(cwd, url) {
3054
4227
  if (hasMobile) {
3055
4228
  const dir = componentPaths.mobile;
3056
4229
  for (const entity of meta.entities) {
3057
- const entityDir = join10(cwd, dir, "lib/entities", toSnake(entity.name));
4230
+ const entityDir = join11(cwd, dir, "lib/entities", toSnake(entity.name));
3058
4231
  await mkdir4(entityDir, { recursive: true });
3059
- const modelPath = join10(entityDir, "model.dart");
3060
- await writeFile2(modelPath, generateDartModel2(entity));
4232
+ const modelPath = join11(entityDir, "model.dart");
4233
+ await writeFile3(modelPath, generateDartModel2(entity));
3061
4234
  generated.push(`${dir}/lib/entities/${toSnake(entity.name)}/model.dart`);
3062
4235
  }
3063
4236
  }
@@ -3080,8 +4253,8 @@ async function sync(cwd, url) {
3080
4253
  function detectMetaUrl(cwd) {
3081
4254
  const envFiles = [".env", ".env.dev", ".env.local"];
3082
4255
  for (const envFile of envFiles) {
3083
- const envPath = join10(cwd, envFile);
3084
- if (existsSync10(envPath)) {
4256
+ const envPath = join11(cwd, envFile);
4257
+ if (existsSync11(envPath)) {
3085
4258
  try {
3086
4259
  const content = readFileSync(envPath, "utf-8");
3087
4260
  const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
@@ -3099,8 +4272,8 @@ function detectMetaUrl(cwd) {
3099
4272
  "frontend/.env.dev"
3100
4273
  ];
3101
4274
  for (const envFile of frontendEnvFiles) {
3102
- const envPath = join10(cwd, envFile);
3103
- if (existsSync10(envPath)) {
4275
+ const envPath = join11(cwd, envFile);
4276
+ if (existsSync11(envPath)) {
3104
4277
  try {
3105
4278
  const content = readFileSync(envPath, "utf-8");
3106
4279
  const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
@@ -3117,6 +4290,28 @@ function detectMetaUrl(cwd) {
3117
4290
 
3118
4291
  // src/index.ts
3119
4292
  var args = process.argv.slice(2);
4293
+ function matchFeatureFlag(arg, argv, i) {
4294
+ for (const feat of KNOWN_FEATURES) {
4295
+ const eq = `--${feat}=`;
4296
+ if (arg.startsWith(eq)) {
4297
+ return {
4298
+ feature: feat,
4299
+ value: arg.slice(eq.length),
4300
+ consumedNext: false
4301
+ };
4302
+ }
4303
+ if (arg === `--${feat}`) {
4304
+ const next = argv[i + 1];
4305
+ if (!next || next.startsWith("-")) {
4306
+ throw new Error(
4307
+ `Flag --${feat} requires a value. Use --${feat}=<targets> or --${feat} <targets>.`
4308
+ );
4309
+ }
4310
+ return { feature: feat, value: next, consumedNext: true };
4311
+ }
4312
+ }
4313
+ return null;
4314
+ }
3120
4315
  function parseArgs() {
3121
4316
  let command = "create";
3122
4317
  let name;
@@ -3169,6 +4364,16 @@ function parseArgs() {
3169
4364
  }
3170
4365
  continue;
3171
4366
  }
4367
+ if (arg === "--orm") {
4368
+ const val = args[++i];
4369
+ if (!val || !ORM_PROVIDERS.includes(val)) {
4370
+ throw new Error(
4371
+ `Invalid --orm. Use one of: ${ORM_PROVIDERS.join(", ")}`
4372
+ );
4373
+ }
4374
+ options.orm = val;
4375
+ continue;
4376
+ }
3172
4377
  if (arg === "--local") {
3173
4378
  localRepo = resolve(args[++i] || ".");
3174
4379
  continue;
@@ -3220,6 +4425,16 @@ function parseArgs() {
3220
4425
  if (val) extraArgs.push(`--name=${val}`);
3221
4426
  continue;
3222
4427
  }
4428
+ {
4429
+ const featureMatch = matchFeatureFlag(arg, args, i);
4430
+ if (featureMatch) {
4431
+ const { feature, value, consumedNext } = featureMatch;
4432
+ parseFeatureFlag(value);
4433
+ options.features = { ...options.features ?? {}, [feature]: value };
4434
+ if (consumedNext) i++;
4435
+ continue;
4436
+ }
4437
+ }
3223
4438
  if (!arg.startsWith("-")) {
3224
4439
  if (command === "add" || command === "pin" || command === "unpin" || command === "gen") {
3225
4440
  extraArgs.push(arg);
@@ -3247,7 +4462,9 @@ function printHelp() {
3247
4462
  projx sync [--url <url>] Sync types from running backend
3248
4463
 
3249
4464
  Options:
3250
- --components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
4465
+ --components <list> Comma-separated: fastapi,fastify,express,frontend,mobile,e2e,infra
4466
+ --orm <provider> Node backend ORM: prisma (default), drizzle, sequelize, typeorm
4467
+ --auth <targets> Add auth feature. Targets: <component>[:<instance>] (comma-separated)
3251
4468
  --no-git Skip git init
3252
4469
  --no-install Skip dependency installation
3253
4470
  -y, --yes Accept defaults (fastify + frontend + e2e)
@@ -3257,6 +4474,8 @@ function printHelp() {
3257
4474
  Examples:
3258
4475
  npx create-projx my-app
3259
4476
  npx create-projx my-app --components fastapi,frontend,e2e
4477
+ npx create-projx my-app --components express,frontend,e2e --orm drizzle
4478
+ npx create-projx my-app --components fastify,frontend,mobile --auth fastify,frontend,mobile
3260
4479
  npx create-projx my-app -y
3261
4480
  npx create-projx add frontend mobile
3262
4481
  npx create-projx add fastify --name email-ingestor
@@ -3361,15 +4580,19 @@ async function main() {
3361
4580
  name,
3362
4581
  components: options.components,
3363
4582
  git: options.git ?? true,
3364
- install: options.install ?? true
4583
+ install: options.install ?? true,
4584
+ orm: options.orm ?? "prisma",
4585
+ features: options.features
3365
4586
  };
3366
4587
  } else {
3367
4588
  opts = await runPrompts(name);
3368
4589
  opts.git = options.git ?? opts.git;
3369
4590
  opts.install = options.install ?? opts.install;
4591
+ opts.orm = options.orm ?? opts.orm ?? "prisma";
4592
+ opts.features = options.features ?? opts.features;
3370
4593
  }
3371
4594
  const dest = resolve(process.cwd(), opts.name);
3372
- if (existsSync11(dest)) {
4595
+ if (existsSync12(dest)) {
3373
4596
  console.error(`Error: ${dest} already exists.`);
3374
4597
  process.exit(1);
3375
4598
  }