create-projx 1.6.5 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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-N4WD4VN3.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,354 @@ 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-OLPF7FAN.js";
38
40
 
39
41
  // src/index.ts
40
42
  import { existsSync as existsSync11 } 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((p10) => p10.trim());
62
+ if (parts.length > 2 || parts.some((p10) => !p10)) {
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 orm = typeof args2.vars.orm === "string" ? args2.vars.orm : void 0;
199
+ const ormDir = orm ? join(stackDir, orm) : void 0;
200
+ const hasOrmDir = ormDir !== void 0 && existsSync(ormDir);
201
+ const ormPatchNames = /* @__PURE__ */ new Set();
202
+ if (hasOrmDir) {
203
+ const ormPatchesDir = join(ormDir, "patches");
204
+ if (existsSync(ormPatchesDir)) {
205
+ for (const f of await readdir(ormPatchesDir)) {
206
+ if (f.endsWith(".json")) ormPatchNames.add(f);
207
+ }
208
+ }
209
+ }
210
+ for (const sub of ["files", join("common", "files")]) {
211
+ const filesDir = join(stackDir, sub);
212
+ if (existsSync(filesDir)) {
213
+ await renderFilesInto(filesDir, targetPath, args2.vars);
214
+ }
215
+ }
216
+ for (const sub of ["patches", join("common", "patches")]) {
217
+ const patchesDir = join(stackDir, sub);
218
+ if (existsSync(patchesDir)) {
219
+ await applyPatches(
220
+ patchesDir,
221
+ targetPath,
222
+ args2.featureName,
223
+ ormPatchNames,
224
+ hasOrmDir
225
+ );
226
+ }
227
+ }
228
+ if (hasOrmDir) {
229
+ const ormFilesDir = join(ormDir, "files");
230
+ if (existsSync(ormFilesDir)) {
231
+ await renderFilesInto(ormFilesDir, targetPath, args2.vars);
232
+ }
233
+ const ormPatchesDir = join(ormDir, "patches");
234
+ if (existsSync(ormPatchesDir)) {
235
+ await applyPatches(ormPatchesDir, targetPath, args2.featureName);
236
+ }
237
+ }
238
+ const envKeys = args2.manifest.env?.[args2.target.component] ?? [];
239
+ if (envKeys.length > 0) {
240
+ await appendEnvExample(targetPath, args2.featureName, envKeys);
241
+ }
242
+ await recordFeatureInMarker(targetPath, args2.featureName);
243
+ }
244
+ async function renderFilesInto(filesDir, targetPath, vars) {
245
+ const entries = await collectFiles(filesDir);
246
+ for (const rel of entries) {
247
+ const src = join(filesDir, rel);
248
+ const isEjs = rel.endsWith(".ejs");
249
+ const outRel = isEjs ? rel.slice(0, -4) : rel;
250
+ const dst = join(targetPath, outRel);
251
+ await mkdir(dirname(dst), { recursive: true });
252
+ if (isEjs || /\.(ts|tsx|js|jsx|py|dart|md|json|yml|yaml|html)$/.test(rel)) {
253
+ const raw = await readFile(src, "utf-8");
254
+ await writeFile(dst, render(raw, vars));
255
+ } else {
256
+ await cp(src, dst);
257
+ }
258
+ }
259
+ }
260
+ async function collectFiles(root) {
261
+ const out = [];
262
+ async function walk(dir) {
263
+ const entries = await readdir(dir, { withFileTypes: true });
264
+ for (const e of entries) {
265
+ const full = join(dir, e.name);
266
+ if (e.isDirectory()) await walk(full);
267
+ else out.push(relative(root, full));
268
+ }
269
+ }
270
+ await walk(root);
271
+ return out.sort();
272
+ }
273
+ async function applyPatches(patchesDir, targetPath, featureName, skipNames = /* @__PURE__ */ new Set(), tolerateMissingTarget = false) {
274
+ const files = (await readdir(patchesDir)).filter((f) => f.endsWith(".json")).filter((f) => !skipNames.has(f)).sort();
275
+ for (const file of files) {
276
+ const raw = await readFile(join(patchesDir, file), "utf-8");
277
+ const patch = JSON.parse(raw);
278
+ if (patch.type === "package-json") {
279
+ await applyPackageJsonPatch(targetPath, patch);
280
+ } else if (patch.type === "text") {
281
+ await applyTextPatch(
282
+ targetPath,
283
+ patch,
284
+ featureName,
285
+ tolerateMissingTarget
286
+ );
287
+ } else {
288
+ throw new Error(
289
+ `Unknown patch type in ${file}: ${patch.type}.`
290
+ );
291
+ }
292
+ }
293
+ }
294
+ async function applyPackageJsonPatch(targetPath, patch) {
295
+ const pkgPath = join(targetPath, "package.json");
296
+ if (!existsSync(pkgPath)) {
297
+ throw new Error(`package-json patch failed: ${pkgPath} not found.`);
298
+ }
299
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
300
+ const merge = patch.merge;
301
+ for (const key of ["dependencies", "devDependencies", "scripts"]) {
302
+ const incoming = merge[key];
303
+ if (!incoming) continue;
304
+ pkg[key] = { ...pkg[key] ?? {}, ...incoming };
305
+ }
306
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
307
+ }
308
+ async function applyTextPatch(targetPath, patch, featureName, tolerateMissingTarget = false) {
309
+ const filePath = join(targetPath, patch.file);
310
+ if (!existsSync(filePath)) {
311
+ if (tolerateMissingTarget) return;
312
+ throw new Error(
313
+ `text patch failed: ${patch.file} not found in ${targetPath}.`
314
+ );
315
+ }
316
+ const content = await readFile(filePath, "utf-8");
317
+ const idx = content.indexOf(patch.anchor);
318
+ if (idx === -1) {
319
+ throw new Error(
320
+ `text patch anchor "${patch.anchor}" not found in ${patch.file}.`
321
+ );
322
+ }
323
+ const lineStart = content.lastIndexOf("\n", idx) + 1;
324
+ const indent = content.slice(lineStart, idx).match(/^\s*/)?.[0] ?? "";
325
+ const sentinel = sentinelFor(featureName, patch.anchor, patch.insert, indent);
326
+ if (content.includes(sentinel)) return;
327
+ const insertWithSentinel = patch.insert + sentinel;
328
+ let next;
329
+ if (patch.position === "before") {
330
+ next = content.slice(0, idx) + insertWithSentinel + content.slice(idx);
331
+ } else {
332
+ const end = idx + patch.anchor.length;
333
+ const after = content.slice(end);
334
+ const newline = after.startsWith("\n") ? "\n" : "\n";
335
+ next = content.slice(0, end) + newline + insertWithSentinel + (after.startsWith("\n") ? after.slice(1) : after);
336
+ }
337
+ await writeFile(filePath, next);
338
+ }
339
+ function sentinelFor(feature, anchor, insert, indent = "") {
340
+ const hash = simpleHash(anchor + "|" + insert);
341
+ const isHash = anchor.includes("#");
342
+ const open = isHash ? "# " : "// ";
343
+ return `${indent}${open}projx-feature: ${feature} ${hash}
344
+ `;
345
+ }
346
+ function simpleHash(s) {
347
+ let h = 0;
348
+ for (let i = 0; i < s.length; i++) h = h * 31 + s.charCodeAt(i) | 0;
349
+ return Math.abs(h).toString(36);
350
+ }
351
+ async function appendEnvExample(targetPath, featureName, keys) {
352
+ const envPath = join(targetPath, ".env.example");
353
+ let content = existsSync(envPath) ? await readFile(envPath, "utf-8") : "";
354
+ const header = `# Added by feature: ${featureName}`;
355
+ if (content.includes(header)) return;
356
+ if (content && !content.endsWith("\n")) content += "\n";
357
+ content += `
358
+ ${header}
359
+ `;
360
+ for (const key of keys) content += `# ${key}=
361
+ `;
362
+ await writeFile(envPath, content);
363
+ }
364
+ async function recordFeatureInMarker(targetPath, featureName) {
365
+ const markerPath = join(targetPath, ".projx-component");
366
+ if (!existsSync(markerPath)) return;
367
+ const raw = await readFile(markerPath, "utf-8");
368
+ const marker = JSON.parse(raw);
369
+ marker.features = marker.features ?? [];
370
+ if (!marker.features.includes(featureName)) {
371
+ marker.features.push(featureName);
372
+ await writeFile(markerPath, JSON.stringify(marker, null, 2) + "\n");
373
+ }
374
+ }
375
+
43
376
  // src/prompts.ts
44
377
  import * as p from "@clack/prompts";
45
378
  var LABELS = {
46
379
  fastapi: { label: "FastAPI", hint: "Python \u2014 SQLAlchemy, Alembic, uvicorn" },
47
380
  fastify: { label: "Fastify", hint: "Node.js \u2014 Prisma, TypeBox, TypeScript" },
381
+ express: { label: "Express", hint: "Node.js \u2014 Express 5, TypeScript" },
48
382
  frontend: { label: "Frontend", hint: "React 19 + Vite + React Router" },
49
383
  mobile: { label: "Mobile", hint: "Flutter + Riverpod + GoRouter" },
50
384
  e2e: { label: "E2E Tests", hint: "Playwright" },
@@ -78,9 +412,25 @@ async function runPrompts(nameArg) {
78
412
  p.log.warn("No components selected. Creating an empty project.");
79
413
  }
80
414
  const hasJs = components.some(
81
- (c) => ["fastify", "frontend", "e2e"].includes(c)
415
+ (c) => ["fastify", "express", "frontend", "e2e"].includes(c)
416
+ );
417
+ const hasNodeBackend = components.some(
418
+ (c) => ["fastify", "express"].includes(c)
82
419
  );
420
+ let orm = "prisma";
83
421
  let packageManager = "npm";
422
+ if (hasNodeBackend) {
423
+ const choice = await p.select({
424
+ message: "Node backend ORM",
425
+ options: ORM_PROVIDERS.map((provider) => ({
426
+ value: provider,
427
+ label: provider === "prisma" ? "Prisma" : "Drizzle"
428
+ })),
429
+ initialValue: "prisma"
430
+ });
431
+ if (p.isCancel(choice)) process.exit(0);
432
+ orm = choice;
433
+ }
84
434
  if (hasJs) {
85
435
  const pm = await p.select({
86
436
  message: "Package manager",
@@ -90,13 +440,13 @@ async function runPrompts(nameArg) {
90
440
  if (p.isCancel(pm)) process.exit(0);
91
441
  packageManager = pm;
92
442
  }
93
- return { name, components, git: true, install: true, packageManager };
443
+ return { name, components, git: true, install: true, packageManager, orm };
94
444
  }
95
445
 
96
446
  // src/scaffold.ts
97
- import { copyFileSync, existsSync } from "fs";
98
- import { mkdir, readFile } from "fs/promises";
99
- import { join } from "path";
447
+ import { copyFileSync, existsSync as existsSync2 } from "fs";
448
+ import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
449
+ import { join as join2 } from "path";
100
450
  import * as p2 from "@clack/prompts";
101
451
  async function scaffold(opts, dest, localRepo) {
102
452
  const name = toKebab(opts.name);
@@ -108,10 +458,11 @@ async function scaffold(opts, dest, localRepo) {
108
458
  projectName: name,
109
459
  components: opts.components,
110
460
  paths,
111
- pm: pmCommands(pm)
461
+ pm: pmCommands(pm),
462
+ orm: opts.orm ?? "prisma"
112
463
  };
113
464
  const isLocal = !!localRepo;
114
- await mkdir(dest, { recursive: true });
465
+ await mkdir2(dest, { recursive: true });
115
466
  const dlSpinner = p2.spinner();
116
467
  dlSpinner.start(
117
468
  isLocal ? "Using local templates" : "Downloading latest templates"
@@ -124,15 +475,15 @@ async function scaffold(opts, dest, localRepo) {
124
475
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
125
476
  try {
126
477
  const pkg = JSON.parse(
127
- await readFile(join(repoDir, "cli/package.json"), "utf-8")
478
+ await readFile2(join2(repoDir, "cli/package.json"), "utf-8")
128
479
  );
129
480
  const version = pkg.version;
130
481
  p2.log.info(`Scaffolding project in ${dest}`);
131
482
  if (opts.git) {
132
483
  exec("git init", dest);
133
484
  }
134
- const spinner7 = p2.spinner();
135
- spinner7.start("Scaffolding project");
485
+ const spinner6 = p2.spinner();
486
+ spinner6.start("Scaffolding project");
136
487
  await applyTemplate(
137
488
  dest,
138
489
  repoDir,
@@ -144,7 +495,20 @@ async function scaffold(opts, dest, localRepo) {
144
495
  void 0,
145
496
  true
146
497
  );
147
- spinner7.stop("Scaffold complete.");
498
+ spinner6.stop("Scaffold complete.");
499
+ if (opts.features && Object.keys(opts.features).length > 0) {
500
+ const featSpinner = p2.spinner();
501
+ featSpinner.start("Applying features");
502
+ await applyFeatures({
503
+ features: opts.features,
504
+ repoDir,
505
+ components: opts.components,
506
+ instances: opts.components.map((type) => ({ type, path: type })),
507
+ dest,
508
+ vars
509
+ });
510
+ featSpinner.stop("Features applied.");
511
+ }
148
512
  if (opts.install) {
149
513
  await installDeps(dest, opts.components, pm);
150
514
  }
@@ -174,34 +538,45 @@ async function installDeps(dest, components, pm) {
174
538
  const cmds = pmCommands(pm);
175
539
  const pmBin = pm === "bun" ? "bun" : pm;
176
540
  for (const component of components) {
177
- const spinner7 = p2.spinner();
541
+ const spinner6 = p2.spinner();
178
542
  try {
179
543
  switch (component) {
180
544
  case "fastapi":
181
545
  if (hasCommand("uv")) {
182
- spinner7.start("Installing FastAPI dependencies (uv sync)");
183
- exec("uv sync --all-extras", join(dest, "fastapi"));
184
- spinner7.stop("FastAPI dependencies installed.");
546
+ spinner6.start("Installing FastAPI dependencies (uv sync)");
547
+ exec("uv sync --all-extras", join2(dest, "fastapi"));
548
+ spinner6.stop("FastAPI dependencies installed.");
185
549
  } else {
186
550
  p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
187
551
  }
188
552
  break;
189
553
  case "fastify":
190
554
  if (hasCommand(pmBin)) {
191
- spinner7.start(`Installing Fastify dependencies (${cmds.install})`);
192
- exec(cmds.install, join(dest, "fastify"));
193
- spinner7.stop("Fastify dependencies installed.");
555
+ spinner6.start(`Installing Fastify dependencies (${cmds.install})`);
556
+ exec(cmds.install, join2(dest, "fastify"));
557
+ spinner6.stop("Fastify dependencies installed.");
194
558
  } else {
195
559
  p2.log.warn(
196
560
  `${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`
197
561
  );
198
562
  }
199
563
  break;
564
+ case "express":
565
+ if (hasCommand(pmBin)) {
566
+ spinner6.start(`Installing Express dependencies (${cmds.install})`);
567
+ exec(cmds.install, join2(dest, "express"));
568
+ spinner6.stop("Express dependencies installed.");
569
+ } else {
570
+ p2.log.warn(
571
+ `${pm} not found \u2014 run 'cd express && ${cmds.install}' manually.`
572
+ );
573
+ }
574
+ break;
200
575
  case "frontend":
201
576
  if (hasCommand(pmBin)) {
202
- spinner7.start(`Installing Frontend dependencies (${cmds.install})`);
203
- exec(cmds.install, join(dest, "frontend"));
204
- spinner7.stop("Frontend dependencies installed.");
577
+ spinner6.start(`Installing Frontend dependencies (${cmds.install})`);
578
+ exec(cmds.install, join2(dest, "frontend"));
579
+ spinner6.stop("Frontend dependencies installed.");
205
580
  } else {
206
581
  p2.log.warn(
207
582
  `${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`
@@ -210,9 +585,9 @@ async function installDeps(dest, components, pm) {
210
585
  break;
211
586
  case "e2e":
212
587
  if (hasCommand(pmBin)) {
213
- spinner7.start(`Installing E2E dependencies (${cmds.install})`);
214
- exec(cmds.install, join(dest, "e2e"));
215
- spinner7.stop("E2E dependencies installed.");
588
+ spinner6.start(`Installing E2E dependencies (${cmds.install})`);
589
+ exec(cmds.install, join2(dest, "e2e"));
590
+ spinner6.stop("E2E dependencies installed.");
216
591
  } else {
217
592
  p2.log.warn(
218
593
  `${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`
@@ -221,9 +596,9 @@ async function installDeps(dest, components, pm) {
221
596
  break;
222
597
  case "mobile":
223
598
  if (hasCommand("flutter")) {
224
- spinner7.start("Installing Flutter dependencies");
225
- exec("flutter pub get", join(dest, "mobile"));
226
- spinner7.stop("Flutter dependencies installed.");
599
+ spinner6.start("Installing Flutter dependencies");
600
+ exec("flutter pub get", join2(dest, "mobile"));
601
+ spinner6.stop("Flutter dependencies installed.");
227
602
  } else {
228
603
  p2.log.warn(
229
604
  "Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
@@ -234,15 +609,15 @@ async function installDeps(dest, components, pm) {
234
609
  break;
235
610
  }
236
611
  } catch {
237
- spinner7.stop(`Failed to install ${component} dependencies.`);
612
+ spinner6.stop(`Failed to install ${component} dependencies.`);
238
613
  }
239
614
  }
240
615
  }
241
616
  function copyEnvExamples(dest, components) {
242
617
  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)) {
618
+ const example = join2(dest, component, ".env.example");
619
+ const env = join2(dest, component, ".env");
620
+ if (existsSync2(example) && !existsSync2(env)) {
246
621
  try {
247
622
  copyFileSync(example, env);
248
623
  } catch {
@@ -252,10 +627,10 @@ function copyEnvExamples(dest, components) {
252
627
  }
253
628
 
254
629
  // src/update.ts
255
- import { existsSync as existsSync2 } from "fs";
256
- import { readFile as readFile2, unlink } from "fs/promises";
630
+ import { existsSync as existsSync3 } from "fs";
631
+ import { readFile as readFile3, unlink } from "fs/promises";
257
632
  import { execSync } from "child_process";
258
- import { join as join2 } from "path";
633
+ import { join as join3 } from "path";
259
634
  import * as p3 from "@clack/prompts";
260
635
  async function update(cwd, localRepo) {
261
636
  p3.intro("projx update");
@@ -323,7 +698,7 @@ async function update(cwd, localRepo) {
323
698
  const componentSkips = {};
324
699
  for (const component of components) {
325
700
  const dir = componentPaths[component];
326
- const marker = await readComponentMarker(join2(cwd, dir));
701
+ const marker = await readComponentMarker(join3(cwd, dir));
327
702
  if (marker?.skip && marker.skip.length > 0) {
328
703
  componentSkips[component] = marker.skip;
329
704
  }
@@ -340,7 +715,7 @@ async function update(cwd, localRepo) {
340
715
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
341
716
  try {
342
717
  const pkg = JSON.parse(
343
- await readFile2(join2(repoDir, "cli/package.json"), "utf-8")
718
+ await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
344
719
  );
345
720
  const version = pkg.version;
346
721
  const name = detectProjectName(cwd, components, componentPaths);
@@ -366,10 +741,11 @@ async function update(cwd, localRepo) {
366
741
  paths: componentPaths,
367
742
  instances,
368
743
  pm: pmCommands(pm),
369
- nameOverrides
744
+ nameOverrides,
745
+ orm: raw.orm ?? "prisma"
370
746
  };
371
- const spinner7 = p3.spinner();
372
- spinner7.start("Applying template update");
747
+ const spinner6 = p3.spinner();
748
+ spinner6.start("Applying template update");
373
749
  const rootSkip = Array.isArray(raw.skip) ? raw.skip : [];
374
750
  const isLegacyMigration = !raw.defaultsApplied;
375
751
  if (isLegacyMigration) {
@@ -389,7 +765,7 @@ async function update(cwd, localRepo) {
389
765
  isLegacyMigration,
390
766
  extraInstances
391
767
  );
392
- spinner7.stop("Template applied.");
768
+ spinner6.stop("Template applied.");
393
769
  const pinnedUpdates = await findPinnedFilesWithUpdates(
394
770
  cwd,
395
771
  repoDir,
@@ -473,22 +849,21 @@ function hasUncommittedChanges(cwd) {
473
849
  }
474
850
  }
475
851
  async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip) {
476
- const { mkdir: mkdir5, rm: rm2, readFile: readFile7 } = await import("fs/promises");
852
+ const { mkdtemp: mkdtemp2, rm: rm2, readFile: readFile8 } = await import("fs/promises");
477
853
  const { tmpdir: tmpdir2 } = await import("os");
478
- const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-PZM4KJJW.js");
854
+ const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-FKCXQFRD.js");
479
855
  const config = await readProjxConfig(cwd);
480
856
  const rootPinned = Array.isArray(config.skip) ? config.skip : [];
481
857
  const componentPinned = [];
482
858
  for (const component of components) {
483
859
  const dir = componentPaths[component];
484
- const marker = await readComponentMarker(join2(cwd, dir));
860
+ const marker = await readComponentMarker(join3(cwd, dir));
485
861
  if (marker?.skip && marker.skip.length > 0) {
486
862
  componentPinned.push({ component, dir, patterns: marker.skip });
487
863
  }
488
864
  }
489
865
  if (rootPinned.length === 0 && componentPinned.length === 0) return [];
490
- const tmpTemplate = join2(tmpdir2(), `projx-pinned-${Date.now()}`);
491
- await mkdir5(tmpTemplate, { recursive: true });
866
+ const tmpTemplate = await mkdtemp2(join3(tmpdir2(), "projx-pinned-"));
492
867
  void componentSkips;
493
868
  void rootSkip;
494
869
  try {
@@ -507,22 +882,22 @@ async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPat
507
882
  );
508
883
  const updates = [];
509
884
  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");
885
+ const tmplPath = join3(tmpTemplate, file);
886
+ const userPath = join3(cwd, file);
887
+ if (!existsSync3(tmplPath) || !existsSync3(userPath)) continue;
888
+ const tmplContent = await readFile8(tmplPath, "utf-8");
889
+ const userContent = await readFile8(userPath, "utf-8");
515
890
  if (tmplContent !== userContent) updates.push(file);
516
891
  }
517
892
  for (const { dir, patterns } of componentPinned) {
518
893
  for (const pattern of patterns) {
519
894
  if (pattern.includes("*")) continue;
520
895
  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");
896
+ const tmplPath = join3(tmpTemplate, rel);
897
+ const userPath = join3(cwd, rel);
898
+ if (!existsSync3(tmplPath) || !existsSync3(userPath)) continue;
899
+ const tmplContent = await readFile8(tmplPath, "utf-8");
900
+ const userContent = await readFile8(userPath, "utf-8");
526
901
  if (tmplContent !== userContent) updates.push(rel);
527
902
  }
528
903
  }
@@ -596,7 +971,7 @@ async function promptSkipLearning(cwd, componentPaths, version, conflictedFiles)
596
971
  const entry = entries.find((e) => e.file === file);
597
972
  try {
598
973
  if (entry?.status === "??") {
599
- await unlink(join2(cwd, file));
974
+ await unlink(join3(cwd, file));
600
975
  } else {
601
976
  execSync(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
602
977
  }
@@ -630,9 +1005,9 @@ async function learnSkips(cwd, files, componentPaths) {
630
1005
  let matched = false;
631
1006
  for (const [dir, component] of Object.entries(dirToComponent)) {
632
1007
  if (file.startsWith(dir + "/")) {
633
- const relative = file.slice(dir.length + 1);
1008
+ const relative2 = file.slice(dir.length + 1);
634
1009
  if (!componentSkipAdds[component]) componentSkipAdds[component] = [];
635
- componentSkipAdds[component].push(relative);
1010
+ componentSkipAdds[component].push(relative2);
636
1011
  matched = true;
637
1012
  break;
638
1013
  }
@@ -643,10 +1018,10 @@ async function learnSkips(cwd, files, componentPaths) {
643
1018
  }
644
1019
  for (const [component, additions] of Object.entries(componentSkipAdds)) {
645
1020
  const dir = componentPaths[component];
646
- const marker = await readComponentMarker(join2(cwd, dir));
1021
+ const marker = await readComponentMarker(join3(cwd, dir));
647
1022
  if (!marker) continue;
648
1023
  const merged = [.../* @__PURE__ */ new Set([...marker.skip, ...additions])];
649
- await writeComponentMarker(join2(cwd, dir), { ...marker, skip: merged });
1024
+ await writeComponentMarker(join3(cwd, dir), { ...marker, skip: merged });
650
1025
  }
651
1026
  if (rootSkipAdds.length > 0) {
652
1027
  const config = await readProjxConfig(cwd);
@@ -657,14 +1032,14 @@ async function learnSkips(cwd, files, componentPaths) {
657
1032
  }
658
1033
 
659
1034
  // 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";
1035
+ import { copyFileSync as copyFileSync2, existsSync as existsSync4 } from "fs";
1036
+ import { readFile as readFile4 } from "fs/promises";
1037
+ import { join as join4 } from "path";
663
1038
  import * as p4 from "@clack/prompts";
664
1039
  async function add(cwd, newComponents, localRepo, skipInstall = false, customName) {
665
1040
  p4.intro("projx add");
666
1041
  const isLocal = !!localRepo;
667
- if (!existsSync3(join3(cwd, ".projx"))) {
1042
+ if (!existsSync4(join4(cwd, ".projx"))) {
668
1043
  p4.log.error(
669
1044
  "No .projx file found. Run 'npx create-projx <name>' to create a project first."
670
1045
  );
@@ -678,8 +1053,8 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
678
1053
  "--name can only be used when adding a single component type."
679
1054
  );
680
1055
  }
681
- const targetDir = join3(cwd, customName);
682
- if (existsSync3(targetDir)) {
1056
+ const targetDir = join4(cwd, customName);
1057
+ if (existsSync4(targetDir)) {
683
1058
  throw new Error(`Directory '${customName}' already exists.`);
684
1059
  }
685
1060
  return await addInstance(
@@ -730,14 +1105,15 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
730
1105
  components: allComponents,
731
1106
  paths,
732
1107
  instances,
733
- pm: pmCommands(pm)
1108
+ pm: pmCommands(pm),
1109
+ orm: config.orm ?? "prisma"
734
1110
  };
735
1111
  const pkg = JSON.parse(
736
- await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
1112
+ await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
737
1113
  );
738
1114
  const version = pkg.version;
739
- const spinner7 = p4.spinner();
740
- spinner7.start("Adding components");
1115
+ const spinner6 = p4.spinner();
1116
+ spinner6.start("Adding components");
741
1117
  await writeTemplateToDir(
742
1118
  cwd,
743
1119
  repoDir,
@@ -747,7 +1123,7 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
747
1123
  version,
748
1124
  { realCwd: cwd }
749
1125
  );
750
- spinner7.stop("Components added.");
1126
+ spinner6.stop("Components added.");
751
1127
  if (!skipInstall) {
752
1128
  await installDeps2(
753
1129
  cwd,
@@ -756,9 +1132,9 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
756
1132
  );
757
1133
  }
758
1134
  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)) {
1135
+ const example = join4(cwd, component, ".env.example");
1136
+ const env = join4(cwd, component, ".env");
1137
+ if (existsSync4(example) && !existsSync4(env)) {
762
1138
  try {
763
1139
  copyFileSync2(example, env);
764
1140
  } catch {
@@ -798,28 +1174,28 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
798
1174
  components: existing,
799
1175
  paths,
800
1176
  instances,
801
- pm: pmCommands(pm)
1177
+ pm: pmCommands(pm),
1178
+ orm: config.orm ?? "prisma"
802
1179
  };
803
1180
  const pkg = JSON.parse(
804
- await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
1181
+ await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
805
1182
  );
806
1183
  const version = pkg.version;
807
1184
  const INSTANCE_AWARE_ROOT = /* @__PURE__ */ new Set([
808
1185
  ".github/workflows/ci.yml",
809
1186
  ".githooks/pre-commit",
810
1187
  "scripts/setup.sh",
811
- "docker-compose.yml",
812
- "docker-compose.dev.yml"
1188
+ "docker-compose.yml"
813
1189
  ]);
814
1190
  const rawSkip = Array.isArray(config.skip) ? config.skip : [];
815
- const rootSkip = rawSkip.filter((p11) => !INSTANCE_AWARE_ROOT.has(p11));
1191
+ const rootSkip = rawSkip.filter((p10) => !INSTANCE_AWARE_ROOT.has(p10));
816
1192
  const componentSkips = {};
817
1193
  for (const inst of existingInstances) {
818
- const m = await readComponentMarker(join3(cwd, inst.path));
1194
+ const m = await readComponentMarker(join4(cwd, inst.path));
819
1195
  if (m?.skip && m.skip.length > 0) componentSkips[inst.type] = m.skip;
820
1196
  }
821
- const spinner7 = p4.spinner();
822
- spinner7.start(`Scaffolding ${customName}/`);
1197
+ const spinner6 = p4.spinner();
1198
+ spinner6.start(`Scaffolding ${customName}/`);
823
1199
  const result = await applyTemplate(
824
1200
  cwd,
825
1201
  repoDir,
@@ -833,7 +1209,7 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
833
1209
  [newInstance],
834
1210
  [newInstance]
835
1211
  );
836
- spinner7.stop(`Scaffolded ${customName}/.`);
1212
+ spinner6.stop(`Scaffolded ${customName}/.`);
837
1213
  if (result.status === "merged") {
838
1214
  p4.log.success(
839
1215
  `${result.mergedFiles?.length ?? 0} root file(s) merged cleanly.`
@@ -851,9 +1227,9 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
851
1227
  if (!skipInstall) {
852
1228
  await installDeps2(cwd, [{ type, path: customName }], pm);
853
1229
  }
854
- const example = join3(cwd, customName, ".env.example");
855
- const env = join3(cwd, customName, ".env");
856
- if (existsSync3(example) && !existsSync3(env)) {
1230
+ const example = join4(cwd, customName, ".env.example");
1231
+ const env = join4(cwd, customName, ".env");
1232
+ if (existsSync4(example) && !existsSync4(env)) {
857
1233
  try {
858
1234
  copyFileSync2(example, env);
859
1235
  } catch {
@@ -868,26 +1244,39 @@ async function installDeps2(dest, instances, pm) {
868
1244
  const cmds = pmCommands(pm);
869
1245
  const pmBin = pm === "bun" ? "bun" : pm;
870
1246
  for (const { type, path } of instances) {
871
- const dir = join3(dest, path);
872
- const spinner7 = p4.spinner();
1247
+ const dir = join4(dest, path);
1248
+ const spinner6 = p4.spinner();
873
1249
  try {
874
1250
  switch (type) {
875
1251
  case "fastapi":
876
1252
  if (hasCommand("uv")) {
877
- spinner7.start(`Installing FastAPI dependencies (${path}/)`);
1253
+ spinner6.start(`Installing FastAPI dependencies (${path}/)`);
878
1254
  exec("uv sync --all-extras", dir);
879
- spinner7.stop(`FastAPI dependencies installed (${path}/).`);
1255
+ spinner6.stop(`FastAPI dependencies installed (${path}/).`);
880
1256
  } else {
881
1257
  p4.log.warn(`uv not found \u2014 run 'cd ${path} && uv sync' manually.`);
882
1258
  }
883
1259
  break;
884
1260
  case "fastify":
885
1261
  if (hasCommand(pmBin)) {
886
- spinner7.start(
1262
+ spinner6.start(
887
1263
  `Installing Fastify dependencies (${path}/, ${cmds.install})`
888
1264
  );
889
1265
  exec(cmds.install, dir);
890
- spinner7.stop(`Fastify dependencies installed (${path}/).`);
1266
+ spinner6.stop(`Fastify dependencies installed (${path}/).`);
1267
+ } else {
1268
+ p4.log.warn(
1269
+ `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
1270
+ );
1271
+ }
1272
+ break;
1273
+ case "express":
1274
+ if (hasCommand(pmBin)) {
1275
+ spinner6.start(
1276
+ `Installing Express dependencies (${path}/, ${cmds.install})`
1277
+ );
1278
+ exec(cmds.install, dir);
1279
+ spinner6.stop(`Express dependencies installed (${path}/).`);
891
1280
  } else {
892
1281
  p4.log.warn(
893
1282
  `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
@@ -896,11 +1285,11 @@ async function installDeps2(dest, instances, pm) {
896
1285
  break;
897
1286
  case "frontend":
898
1287
  if (hasCommand(pmBin)) {
899
- spinner7.start(
1288
+ spinner6.start(
900
1289
  `Installing Frontend dependencies (${path}/, ${cmds.install})`
901
1290
  );
902
1291
  exec(cmds.install, dir);
903
- spinner7.stop(`Frontend dependencies installed (${path}/).`);
1292
+ spinner6.stop(`Frontend dependencies installed (${path}/).`);
904
1293
  } else {
905
1294
  p4.log.warn(
906
1295
  `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
@@ -909,11 +1298,11 @@ async function installDeps2(dest, instances, pm) {
909
1298
  break;
910
1299
  case "e2e":
911
1300
  if (hasCommand(pmBin)) {
912
- spinner7.start(
1301
+ spinner6.start(
913
1302
  `Installing E2E dependencies (${path}/, ${cmds.install})`
914
1303
  );
915
1304
  exec(cmds.install, dir);
916
- spinner7.stop(`E2E dependencies installed (${path}/).`);
1305
+ spinner6.stop(`E2E dependencies installed (${path}/).`);
917
1306
  } else {
918
1307
  p4.log.warn(
919
1308
  `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
@@ -922,9 +1311,9 @@ async function installDeps2(dest, instances, pm) {
922
1311
  break;
923
1312
  case "mobile":
924
1313
  if (hasCommand("flutter")) {
925
- spinner7.start(`Installing Flutter dependencies (${path}/)`);
1314
+ spinner6.start(`Installing Flutter dependencies (${path}/)`);
926
1315
  exec("flutter pub get", dir);
927
- spinner7.stop(`Flutter dependencies installed (${path}/).`);
1316
+ spinner6.stop(`Flutter dependencies installed (${path}/).`);
928
1317
  } else {
929
1318
  p4.log.warn(
930
1319
  `Flutter not found \u2014 run 'cd ${path} && flutter pub get' manually.`
@@ -935,30 +1324,30 @@ async function installDeps2(dest, instances, pm) {
935
1324
  break;
936
1325
  }
937
1326
  } catch {
938
- spinner7.stop(`Failed to install ${type} dependencies (${path}/).`);
1327
+ spinner6.stop(`Failed to install ${type} dependencies (${path}/).`);
939
1328
  }
940
1329
  }
941
1330
  }
942
1331
 
943
1332
  // src/init.ts
944
- import { existsSync as existsSync5 } from "fs";
945
- import { readFile as readFile4 } from "fs/promises";
1333
+ import { existsSync as existsSync6 } from "fs";
1334
+ import { readFile as readFile5 } from "fs/promises";
946
1335
  import { execSync as execSync2 } from "child_process";
947
- import { join as join5 } from "path";
1336
+ import { join as join6 } from "path";
948
1337
  import * as p5 from "@clack/prompts";
949
1338
 
950
1339
  // src/detect.ts
951
- import { existsSync as existsSync4 } from "fs";
952
- import { readdir } from "fs/promises";
953
- import { join as join4 } from "path";
1340
+ import { existsSync as existsSync5 } from "fs";
1341
+ import { readdir as readdir2 } from "fs/promises";
1342
+ import { join as join5 } from "path";
954
1343
  async function detectComponents(cwd) {
955
1344
  const results = [];
956
- const entries = await readdir(cwd, { withFileTypes: true });
1345
+ const entries = await readdir2(cwd, { withFileTypes: true });
957
1346
  const dirs = entries.filter(
958
1347
  (e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)
959
1348
  ).map((e) => e.name);
960
1349
  for (const dir of dirs) {
961
- const full = join4(cwd, dir);
1350
+ const full = join5(cwd, dir);
962
1351
  const detections = await scanDirectory(full, dir);
963
1352
  results.push(...detections);
964
1353
  }
@@ -966,7 +1355,7 @@ async function detectComponents(cwd) {
966
1355
  }
967
1356
  async function scanDirectory(dir, relPath) {
968
1357
  const results = [];
969
- const pyproject = await readFileOrNull(join4(dir, "pyproject.toml"));
1358
+ const pyproject = await readFileOrNull(join5(dir, "pyproject.toml"));
970
1359
  if (pyproject && /fastapi/i.test(pyproject)) {
971
1360
  results.push({
972
1361
  component: "fastapi",
@@ -986,6 +1375,14 @@ async function scanDirectory(dir, relPath) {
986
1375
  evidence: "package.json has fastify dependency"
987
1376
  });
988
1377
  }
1378
+ if (allDeps.express) {
1379
+ results.push({
1380
+ component: "express",
1381
+ directory: relPath,
1382
+ confidence: "high",
1383
+ evidence: "package.json has express dependency"
1384
+ });
1385
+ }
989
1386
  if (allDeps.react || allDeps["react-dom"]) {
990
1387
  results.push({
991
1388
  component: "frontend",
@@ -1003,7 +1400,7 @@ async function scanDirectory(dir, relPath) {
1003
1400
  });
1004
1401
  }
1005
1402
  }
1006
- const pubspec = await readFileOrNull(join4(dir, "pubspec.yaml"));
1403
+ const pubspec = await readFileOrNull(join5(dir, "pubspec.yaml"));
1007
1404
  if (pubspec && /flutter:/i.test(pubspec)) {
1008
1405
  results.push({
1009
1406
  component: "mobile",
@@ -1012,7 +1409,7 @@ async function scanDirectory(dir, relPath) {
1012
1409
  evidence: "pubspec.yaml has flutter dependency"
1013
1410
  });
1014
1411
  }
1015
- const hasTf = existsSync4(join4(dir, "main.tf")) || existsSync4(join4(dir, "variables.tf")) || existsSync4(join4(dir, "stack/main.tf")) || existsSync4(join4(dir, "versions.tf"));
1412
+ const hasTf = existsSync5(join5(dir, "main.tf")) || existsSync5(join5(dir, "variables.tf")) || existsSync5(join5(dir, "stack/main.tf")) || existsSync5(join5(dir, "versions.tf"));
1016
1413
  if (hasTf) {
1017
1414
  results.push({
1018
1415
  component: "infra",
@@ -1024,7 +1421,7 @@ async function scanDirectory(dir, relPath) {
1024
1421
  return results;
1025
1422
  }
1026
1423
  async function readPkg(dir) {
1027
- const content = await readFileOrNull(join4(dir, "package.json"));
1424
+ const content = await readFileOrNull(join5(dir, "package.json"));
1028
1425
  if (!content) return null;
1029
1426
  try {
1030
1427
  return JSON.parse(content);
@@ -1037,7 +1434,7 @@ async function readPkg(dir) {
1037
1434
  async function init(cwd, localRepo) {
1038
1435
  p5.intro("projx init");
1039
1436
  const isLocal = !!localRepo;
1040
- if (existsSync5(join5(cwd, ".projx"))) {
1437
+ if (existsSync6(join6(cwd, ".projx"))) {
1041
1438
  p5.log.error(
1042
1439
  "This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead."
1043
1440
  );
@@ -1053,10 +1450,10 @@ async function init(cwd, localRepo) {
1053
1450
  p5.log.error("You have uncommitted changes. Commit or stash them first.");
1054
1451
  process.exit(1);
1055
1452
  }
1056
- const spinner7 = p5.spinner();
1057
- spinner7.start("Scanning for components");
1453
+ const spinner6 = p5.spinner();
1454
+ spinner6.start("Scanning for components");
1058
1455
  const detected = await detectComponents(cwd);
1059
- spinner7.stop(
1456
+ spinner6.stop(
1060
1457
  detected.length > 0 ? `Found ${detected.length} component(s).` : "No components detected."
1061
1458
  );
1062
1459
  if (detected.length === 0) {
@@ -1076,7 +1473,7 @@ async function init(cwd, localRepo) {
1076
1473
  confirmed.map((c) => [c.component, c.directory])
1077
1474
  );
1078
1475
  const hasJs = components.some(
1079
- (c) => ["fastify", "frontend", "e2e"].includes(c)
1476
+ (c) => ["fastify", "express", "frontend", "e2e"].includes(c)
1080
1477
  );
1081
1478
  let pm = "npm";
1082
1479
  if (hasJs) {
@@ -1099,7 +1496,8 @@ async function init(cwd, localRepo) {
1099
1496
  projectName,
1100
1497
  components,
1101
1498
  paths,
1102
- pm: pmCommands(pm)
1499
+ pm: pmCommands(pm),
1500
+ orm: "prisma"
1103
1501
  };
1104
1502
  const dlSpinner = p5.spinner();
1105
1503
  dlSpinner.start(
@@ -1113,7 +1511,7 @@ async function init(cwd, localRepo) {
1113
1511
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1114
1512
  try {
1115
1513
  const pkg = JSON.parse(
1116
- await readFile4(join5(repoDir, "cli/package.json"), "utf-8")
1514
+ await readFile5(join6(repoDir, "cli/package.json"), "utf-8")
1117
1515
  );
1118
1516
  const version = pkg.version;
1119
1517
  const applySpinner = p5.spinner();
@@ -1130,7 +1528,7 @@ async function init(cwd, localRepo) {
1130
1528
  true
1131
1529
  );
1132
1530
  applySpinner.stop("Template applied.");
1133
- if (existsSync5(join5(cwd, ".githooks"))) {
1531
+ if (existsSync6(join6(cwd, ".githooks"))) {
1134
1532
  try {
1135
1533
  execSync2("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1136
1534
  } catch {
@@ -1179,7 +1577,7 @@ async function writeBareProjx(cwd, localRepo, isLocal, pm) {
1179
1577
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1180
1578
  try {
1181
1579
  const pkg = JSON.parse(
1182
- await readFile4(join5(repoDir, "cli/package.json"), "utf-8")
1580
+ await readFile5(join6(repoDir, "cli/package.json"), "utf-8")
1183
1581
  );
1184
1582
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1185
1583
  const config = {
@@ -1228,8 +1626,8 @@ function hasUncommittedChanges2(cwd) {
1228
1626
  }
1229
1627
 
1230
1628
  // src/pin.ts
1231
- import { existsSync as existsSync6 } from "fs";
1232
- import { join as join6 } from "path";
1629
+ import { existsSync as existsSync7 } from "fs";
1630
+ import { join as join7 } from "path";
1233
1631
  import * as p6 from "@clack/prompts";
1234
1632
  function classifyPattern(pattern, componentPaths) {
1235
1633
  const dirToComponent = {};
@@ -1249,7 +1647,7 @@ function classifyPattern(pattern, componentPaths) {
1249
1647
  }
1250
1648
  async function pin(cwd, patterns) {
1251
1649
  p6.intro("projx pin");
1252
- if (!existsSync6(join6(cwd, ".projx"))) {
1650
+ if (!existsSync7(join7(cwd, ".projx"))) {
1253
1651
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1254
1652
  process.exit(1);
1255
1653
  }
@@ -1262,20 +1660,20 @@ async function pin(cwd, patterns) {
1262
1660
  p6.log.warn(`Cannot pin ${pattern} \u2014 config files are managed by projx.`);
1263
1661
  continue;
1264
1662
  }
1265
- const { scope, component, relative } = classifyPattern(
1663
+ const { scope, component, relative: relative2 } = classifyPattern(
1266
1664
  pattern,
1267
1665
  componentPaths
1268
1666
  );
1269
1667
  if (scope === "component" && component) {
1270
1668
  if (!componentAdds[component]) componentAdds[component] = [];
1271
- componentAdds[component].push(relative);
1669
+ componentAdds[component].push(relative2);
1272
1670
  } else {
1273
- rootAdds.push(relative);
1671
+ rootAdds.push(relative2);
1274
1672
  }
1275
1673
  }
1276
1674
  for (const [component, additions] of Object.entries(componentAdds)) {
1277
1675
  const dir = componentPaths[component];
1278
- const marker = await readComponentMarker(join6(cwd, dir));
1676
+ const marker = await readComponentMarker(join7(cwd, dir));
1279
1677
  if (!marker) {
1280
1678
  p6.log.error(`Could not read marker for ${component}.`);
1281
1679
  continue;
@@ -1284,7 +1682,7 @@ async function pin(cwd, patterns) {
1284
1682
  const added = merged.length - marker.skip.length;
1285
1683
  if (added > 0) {
1286
1684
  const next = { ...marker, skip: merged };
1287
- await writeComponentMarker(join6(cwd, dir), next);
1685
+ await writeComponentMarker(join7(cwd, dir), next);
1288
1686
  p6.log.success(`${component}: pinned ${additions.join(", ")}`);
1289
1687
  } else {
1290
1688
  p6.log.info(`${component}: already pinned.`);
@@ -1305,7 +1703,7 @@ async function pin(cwd, patterns) {
1305
1703
  }
1306
1704
  async function unpin(cwd, patterns) {
1307
1705
  p6.intro("projx unpin");
1308
- if (!existsSync6(join6(cwd, ".projx"))) {
1706
+ if (!existsSync7(join7(cwd, ".projx"))) {
1309
1707
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1310
1708
  process.exit(1);
1311
1709
  }
@@ -1314,20 +1712,20 @@ async function unpin(cwd, patterns) {
1314
1712
  const rootRemoves = [];
1315
1713
  const componentRemoves = {};
1316
1714
  for (const pattern of patterns) {
1317
- const { scope, component, relative } = classifyPattern(
1715
+ const { scope, component, relative: relative2 } = classifyPattern(
1318
1716
  pattern,
1319
1717
  componentPaths
1320
1718
  );
1321
1719
  if (scope === "component" && component) {
1322
1720
  if (!componentRemoves[component]) componentRemoves[component] = [];
1323
- componentRemoves[component].push(relative);
1721
+ componentRemoves[component].push(relative2);
1324
1722
  } else {
1325
- rootRemoves.push(relative);
1723
+ rootRemoves.push(relative2);
1326
1724
  }
1327
1725
  }
1328
1726
  for (const [component, removals] of Object.entries(componentRemoves)) {
1329
1727
  const dir = componentPaths[component];
1330
- const marker = await readComponentMarker(join6(cwd, dir));
1728
+ const marker = await readComponentMarker(join7(cwd, dir));
1331
1729
  if (!marker) {
1332
1730
  p6.log.error(`Could not read marker for ${component}.`);
1333
1731
  continue;
@@ -1336,7 +1734,7 @@ async function unpin(cwd, patterns) {
1336
1734
  const removed = marker.skip.length - filtered.length;
1337
1735
  if (removed > 0) {
1338
1736
  const next = { ...marker, skip: filtered };
1339
- await writeComponentMarker(join6(cwd, dir), next);
1737
+ await writeComponentMarker(join7(cwd, dir), next);
1340
1738
  p6.log.success(`${component}: unpinned ${removals.join(", ")}`);
1341
1739
  } else {
1342
1740
  p6.log.info(`${component}: not found in skip list.`);
@@ -1357,7 +1755,7 @@ async function unpin(cwd, patterns) {
1357
1755
  }
1358
1756
  async function listPins(cwd) {
1359
1757
  p6.intro("projx pin --list");
1360
- if (!existsSync6(join6(cwd, ".projx"))) {
1758
+ if (!existsSync7(join7(cwd, ".projx"))) {
1361
1759
  p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1362
1760
  process.exit(1);
1363
1761
  }
@@ -1374,7 +1772,7 @@ async function listPins(cwd) {
1374
1772
  }
1375
1773
  for (const component of discovered) {
1376
1774
  const dir = componentPaths[component];
1377
- const marker = await readComponentMarker(join6(cwd, dir));
1775
+ const marker = await readComponentMarker(join7(cwd, dir));
1378
1776
  if (marker?.skip && marker.skip.length > 0) {
1379
1777
  hasAny = true;
1380
1778
  const label = dir !== component ? `${component} (${dir}/)` : `${component}`;
@@ -1391,15 +1789,15 @@ async function listPins(cwd) {
1391
1789
  }
1392
1790
 
1393
1791
  // src/doctor.ts
1394
- import { existsSync as existsSync7 } from "fs";
1395
- import { readdir as readdir2 } from "fs/promises";
1792
+ import { existsSync as existsSync8 } from "fs";
1793
+ import { readdir as readdir3 } from "fs/promises";
1396
1794
  import { execSync as execSync3 } from "child_process";
1397
- import { join as join7 } from "path";
1795
+ import { join as join8 } from "path";
1398
1796
  import * as p7 from "@clack/prompts";
1399
1797
  async function checkConfig(cwd) {
1400
1798
  const results = [];
1401
- const configPath = join7(cwd, ".projx");
1402
- if (!existsSync7(configPath)) {
1799
+ const configPath = join8(cwd, ".projx");
1800
+ if (!existsSync8(configPath)) {
1403
1801
  results.push({
1404
1802
  name: ".projx exists",
1405
1803
  status: "fail",
@@ -1449,8 +1847,8 @@ async function checkComponents(cwd, components, componentPaths) {
1449
1847
  });
1450
1848
  for (const component of components) {
1451
1849
  const dir = componentPaths[component];
1452
- const fullDir = join7(cwd, dir);
1453
- if (!existsSync7(fullDir)) {
1850
+ const fullDir = join8(cwd, dir);
1851
+ if (!existsSync8(fullDir)) {
1454
1852
  results.push({
1455
1853
  name: `${component} directory`,
1456
1854
  status: "fail",
@@ -1590,10 +1988,10 @@ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
1590
1988
  }
1591
1989
  for (const component of components) {
1592
1990
  const dir = componentPaths[component];
1593
- const marker = await readComponentMarker(join7(cwd, dir));
1991
+ const marker = await readComponentMarker(join8(cwd, dir));
1594
1992
  if (marker?.skip && marker.skip.length > 0) {
1595
1993
  for (const pattern of marker.skip) {
1596
- const matches = await patternMatchesAnything(join7(cwd, dir), pattern);
1994
+ const matches = await patternMatchesAnything(join8(cwd, dir), pattern);
1597
1995
  if (!matches) {
1598
1996
  results.push({
1599
1997
  name: `${component} skip`,
@@ -1615,16 +2013,16 @@ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
1615
2013
  }
1616
2014
  async function patternMatchesAnything(dir, pattern) {
1617
2015
  if (pattern === "**") return true;
1618
- if (!existsSync7(dir)) return false;
2016
+ if (!existsSync8(dir)) return false;
1619
2017
  const walk = async (current, base) => {
1620
2018
  let entries;
1621
2019
  try {
1622
- entries = await readdir2(current, { withFileTypes: true });
2020
+ entries = await readdir3(current, { withFileTypes: true });
1623
2021
  } catch {
1624
2022
  return false;
1625
2023
  }
1626
2024
  for (const entry of entries) {
1627
- const full = join7(current, entry.name);
2025
+ const full = join8(current, entry.name);
1628
2026
  const rel = full.slice(base.length + 1);
1629
2027
  if (entry.isDirectory()) {
1630
2028
  if (await walk(full, base)) return true;
@@ -1674,17 +2072,17 @@ function printReport(results) {
1674
2072
  }
1675
2073
 
1676
2074
  // 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";
2075
+ import { existsSync as existsSync9 } from "fs";
2076
+ import { readFile as readFile6, mkdtemp, rm } from "fs/promises";
2077
+ import { join as join9 } from "path";
1680
2078
  import { tmpdir } from "os";
1681
2079
  import * as p8 from "@clack/prompts";
1682
2080
  function isSkipped(file, componentPaths, componentSkips, rootSkip) {
1683
2081
  for (const [component, dir] of Object.entries(componentPaths)) {
1684
2082
  if (file.startsWith(dir + "/")) {
1685
- const relative = file.slice(dir.length + 1);
2083
+ const relative2 = file.slice(dir.length + 1);
1686
2084
  const skips = componentSkips[component] ?? [];
1687
- if (matchesSkip(relative, skips)) return true;
2085
+ if (matchesSkip(relative2, skips)) return true;
1688
2086
  }
1689
2087
  }
1690
2088
  const base = file.split("/").pop();
@@ -1700,7 +2098,7 @@ function fileComponent(file, componentPaths) {
1700
2098
  async function diff(cwd, localRepo) {
1701
2099
  p8.intro("projx diff");
1702
2100
  const isLocal = !!localRepo;
1703
- if (!existsSync8(join8(cwd, ".projx"))) {
2101
+ if (!existsSync9(join9(cwd, ".projx"))) {
1704
2102
  p8.log.error("No .projx file found. Run 'npx create-projx init' first.");
1705
2103
  process.exit(1);
1706
2104
  }
@@ -1709,7 +2107,7 @@ async function diff(cwd, localRepo) {
1709
2107
  const componentSkips = {};
1710
2108
  for (const component of components) {
1711
2109
  const dir = componentPaths[component];
1712
- const marker = await readComponentMarker(join8(cwd, dir));
2110
+ const marker = await readComponentMarker(join9(cwd, dir));
1713
2111
  if (marker?.skip && marker.skip.length > 0) {
1714
2112
  componentSkips[component] = marker.skip;
1715
2113
  }
@@ -1727,7 +2125,7 @@ async function diff(cwd, localRepo) {
1727
2125
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1728
2126
  try {
1729
2127
  const pkg = JSON.parse(
1730
- await readFile5(join8(repoDir, "cli/package.json"), "utf-8")
2128
+ await readFile6(join9(repoDir, "cli/package.json"), "utf-8")
1731
2129
  );
1732
2130
  const version = pkg.version;
1733
2131
  p8.log.info(`Current: v${raw.version ?? "unknown"} \u2192 Template: v${version}`);
@@ -1736,12 +2134,12 @@ async function diff(cwd, localRepo) {
1736
2134
  projectName: name,
1737
2135
  components,
1738
2136
  paths: componentPaths,
1739
- pm: pmCommands(raw.packageManager ?? "npm")
2137
+ pm: pmCommands(raw.packageManager ?? "npm"),
2138
+ orm: raw.orm ?? "prisma"
1740
2139
  };
1741
- const spinner7 = p8.spinner();
1742
- spinner7.start("Analyzing changes");
1743
- const tmpTemplate = join8(tmpdir(), `projx-diff-${Date.now()}`);
1744
- await mkdir2(tmpTemplate, { recursive: true });
2140
+ const spinner6 = p8.spinner();
2141
+ spinner6.start("Analyzing changes");
2142
+ const tmpTemplate = await mkdtemp(join9(tmpdir(), "projx-diff-"));
1745
2143
  await writeTemplateToDir(
1746
2144
  tmpTemplate,
1747
2145
  repoDir,
@@ -1764,16 +2162,16 @@ async function diff(cwd, localRepo) {
1764
2162
  analyses.push({ file, status: "skipped", component });
1765
2163
  continue;
1766
2164
  }
1767
- const oursPath = join8(cwd, file);
1768
- if (!existsSync8(oursPath)) {
2165
+ const oursPath = join9(cwd, file);
2166
+ if (!existsSync9(oursPath)) {
1769
2167
  analyses.push({ file, status: "new", component });
1770
2168
  continue;
1771
2169
  }
1772
2170
  let oursContent;
1773
2171
  let theirsContent;
1774
2172
  try {
1775
- oursContent = await readFile5(oursPath, "utf-8");
1776
- theirsContent = await readFile5(join8(tmpTemplate, file), "utf-8");
2173
+ oursContent = await readFile6(oursPath, "utf-8");
2174
+ theirsContent = await readFile6(join9(tmpTemplate, file), "utf-8");
1777
2175
  } catch {
1778
2176
  continue;
1779
2177
  }
@@ -1799,7 +2197,7 @@ async function diff(cwd, localRepo) {
1799
2197
  }
1800
2198
  }
1801
2199
  await rm(tmpTemplate, { recursive: true, force: true });
1802
- spinner7.stop("Analysis complete.");
2200
+ spinner6.stop("Analysis complete.");
1803
2201
  const groups = {
1804
2202
  new: [],
1805
2203
  "clean-update": [],
@@ -1853,9 +2251,9 @@ async function diff(cwd, localRepo) {
1853
2251
  }
1854
2252
 
1855
2253
  // 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";
2254
+ import { existsSync as existsSync10 } from "fs";
2255
+ import { readFile as readFile7, writeFile as writeFile2, mkdir as mkdir3 } from "fs/promises";
2256
+ import { join as join10 } from "path";
1859
2257
  import * as p9 from "@clack/prompts";
1860
2258
  var FIELD_TYPES = [
1861
2259
  "string",
@@ -1928,11 +2326,23 @@ async function promptEntityConfig(name) {
1928
2326
  initialValue: true
1929
2327
  });
1930
2328
  if (p9.isCancel(required)) process.exit(0);
1931
- fields.push({ name: toSnake(fieldName), type: fieldType, required });
2329
+ fields.push({
2330
+ name: toSnake(fieldName),
2331
+ type: fieldType,
2332
+ required,
2333
+ unique: false,
2334
+ generated: false
2335
+ });
1932
2336
  }
1933
2337
  if (fields.length === 0) {
1934
2338
  p9.log.warn("No fields defined. Adding a default 'name' field.");
1935
- fields.push({ name: "name", type: "string", required: true });
2339
+ fields.push({
2340
+ name: "name",
2341
+ type: "string",
2342
+ required: true,
2343
+ unique: false,
2344
+ generated: false
2345
+ });
1936
2346
  }
1937
2347
  const stringFields = fields.filter(
1938
2348
  (f) => f.type === "string" || f.type === "text"
@@ -1965,7 +2375,14 @@ function parseFieldsFlag(raw) {
1965
2375
  const required = nameType.endsWith("!");
1966
2376
  const name = toSnake(required ? nameType.slice(0, -1) : nameType);
1967
2377
  const type = rest[0] || "string";
1968
- return { name, type, required: required || true };
2378
+ const modifiers = new Set(rest.slice(1).map((item) => item.toLowerCase()));
2379
+ return {
2380
+ name,
2381
+ type,
2382
+ required: required || true,
2383
+ unique: modifiers.has("unique") || modifiers.has("@unique"),
2384
+ generated: modifiers.has("generated") || modifiers.has("server") || modifiers.has("server-generated")
2385
+ };
1969
2386
  });
1970
2387
  }
1971
2388
  function sqlalchemyType(type) {
@@ -2088,24 +2505,6 @@ function typeboxOptional(type) {
2088
2505
  return "Type.Optional(Type.Any())";
2089
2506
  }
2090
2507
  }
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
2508
  function prismaType(type, required) {
2110
2509
  const nullable = required ? "" : "?";
2111
2510
  switch (type) {
@@ -2125,6 +2524,10 @@ function prismaType(type, required) {
2125
2524
  return `Json${nullable}`;
2126
2525
  }
2127
2526
  }
2527
+ function prismaFieldType(field) {
2528
+ const base = prismaType(field.type, field.required);
2529
+ return field.unique ? `${base} @unique` : base;
2530
+ }
2128
2531
  function generateFastifySchemas(config) {
2129
2532
  const className = toPascal(config.name);
2130
2533
  const lines = [];
@@ -2146,7 +2549,7 @@ function generateFastifySchemas(config) {
2146
2549
  lines.push(`export type ${className} = Static<typeof ${className}Schema>;`);
2147
2550
  lines.push("");
2148
2551
  lines.push(`export const Create${className}Schema = Type.Object({`);
2149
- for (const f of config.fields) {
2552
+ for (const f of config.fields.filter((field) => !field.generated)) {
2150
2553
  if (f.required) {
2151
2554
  lines.push(` ${f.name}: ${typeboxType(f.type, true)},`);
2152
2555
  } else {
@@ -2160,7 +2563,7 @@ function generateFastifySchemas(config) {
2160
2563
  );
2161
2564
  lines.push("");
2162
2565
  lines.push(`export const Update${className}Schema = Type.Object({`);
2163
- for (const f of config.fields) {
2566
+ for (const f of config.fields.filter((field) => !field.generated)) {
2164
2567
  lines.push(` ${f.name}: ${typeboxOptional(f.type)},`);
2165
2568
  }
2166
2569
  lines.push(`});`);
@@ -2174,44 +2577,26 @@ function generateFastifySchemas(config) {
2174
2577
  function generateFastifyIndex(config) {
2175
2578
  const className = toPascal(config.name);
2176
2579
  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");
2580
+ const generatedFields = config.fields.filter((field) => field.generated);
2184
2581
  const lines = [];
2185
2582
  lines.push(
2186
- `import { EntityRegistry, type EntityConfig, type FieldMeta } from '../_base/index.js';`
2583
+ `import { EntityRegistry, type EntityConfig } from '../_base/index.js';`
2187
2584
  );
2585
+ if (generatedFields.length > 0) {
2586
+ lines.push(`import { randomBytes } from 'node:crypto';`);
2587
+ }
2188
2588
  lines.push(
2189
2589
  `import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`
2190
2590
  );
2191
2591
  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) {
2592
+ for (const field of generatedFields) {
2209
2593
  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' },`
2594
+ `function generate${className}${toPascal(field.name)}(): string {`
2211
2595
  );
2596
+ lines.push(` return randomBytes(8).toString('hex').toUpperCase();`);
2597
+ lines.push(`}`);
2598
+ lines.push("");
2212
2599
  }
2213
- lines.push(`];`);
2214
- lines.push("");
2215
2600
  const tags = config.apiPrefix.replace(/^\//, "");
2216
2601
  lines.push(`export const ${camelConfig}: EntityConfig = {`);
2217
2602
  lines.push(` name: '${className}',`);
@@ -2222,7 +2607,6 @@ function generateFastifyIndex(config) {
2222
2607
  lines.push(` readonly: ${config.readonly},`);
2223
2608
  lines.push(` softDelete: ${config.softDelete},`);
2224
2609
  lines.push(` bulkOperations: ${config.bulkOperations},`);
2225
- lines.push(` columnNames: [${allColumns.map((c) => `'${c}'`).join(", ")}],`);
2226
2610
  if (config.searchableFields.length > 0) {
2227
2611
  lines.push(
2228
2612
  ` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`
@@ -2230,10 +2614,25 @@ function generateFastifyIndex(config) {
2230
2614
  } else {
2231
2615
  lines.push(` searchableFields: [],`);
2232
2616
  }
2233
- lines.push(` fields,`);
2234
2617
  lines.push(` schema: ${className}Schema,`);
2235
2618
  lines.push(` createSchema: Create${className}Schema,`);
2236
2619
  lines.push(` updateSchema: Update${className}Schema,`);
2620
+ if (generatedFields.length > 0) {
2621
+ lines.push(
2622
+ ` beforeCreateFields: [${generatedFields.map((field) => `'${field.name}'`).join(", ")}],`
2623
+ );
2624
+ lines.push(` beforeCreate: (_request, data) => {`);
2625
+ for (const field of generatedFields) {
2626
+ lines.push(
2627
+ ` if (!('${field.name}' in data) || data.${field.name} == null) {`
2628
+ );
2629
+ lines.push(
2630
+ ` data.${field.name} = generate${className}${toPascal(field.name)}();`
2631
+ );
2632
+ lines.push(` }`);
2633
+ }
2634
+ lines.push(` },`);
2635
+ }
2237
2636
  lines.push(`};`);
2238
2637
  lines.push("");
2239
2638
  lines.push(`EntityRegistry.register(${camelConfig});`);
@@ -2247,7 +2646,7 @@ function generatePrismaModel(config) {
2247
2646
  lines.push(` id String @id @default(uuid())`);
2248
2647
  for (const f of config.fields) {
2249
2648
  const padded = f.name.padEnd(10);
2250
- lines.push(` ${padded} ${prismaType(f.type, f.required)}`);
2649
+ lines.push(` ${padded} ${prismaFieldType(f)}`);
2251
2650
  }
2252
2651
  if (config.softDelete) {
2253
2652
  lines.push(` deleted_at DateTime?`);
@@ -2262,28 +2661,248 @@ function generatePrismaModel(config) {
2262
2661
  lines.push(`}`);
2263
2662
  return lines.join("\n");
2264
2663
  }
2265
- function tsType(type, required) {
2266
- const base = (() => {
2664
+ function drizzleColumn(field) {
2665
+ let expr;
2666
+ switch (field.type) {
2667
+ case "number":
2668
+ expr = `integer('${field.name}')`;
2669
+ break;
2670
+ case "boolean":
2671
+ expr = `boolean('${field.name}')`;
2672
+ break;
2673
+ case "date":
2674
+ expr = `date('${field.name}')`;
2675
+ break;
2676
+ case "datetime":
2677
+ expr = `timestamp('${field.name}', { withTimezone: true })`;
2678
+ break;
2679
+ case "json":
2680
+ expr = `jsonb('${field.name}')`;
2681
+ break;
2682
+ case "text":
2683
+ case "string":
2684
+ expr = `text('${field.name}')`;
2685
+ break;
2686
+ }
2687
+ if (field.required) expr += ".notNull()";
2688
+ if (field.unique) expr += ".unique()";
2689
+ return expr;
2690
+ }
2691
+ function generateDrizzleTable(config) {
2692
+ const lines = [];
2693
+ const tableConst = toCamel(pluralize(toPascal(config.name)));
2694
+ lines.push(`export const ${tableConst} = pgTable('${config.tableName}', {`);
2695
+ lines.push(` id: uuid('id').primaryKey().defaultRandom(),`);
2696
+ lines.push(
2697
+ ` createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),`
2698
+ );
2699
+ lines.push(
2700
+ ` updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),`
2701
+ );
2702
+ if (config.softDelete) {
2703
+ lines.push(` deletedAt: timestamp('deleted_at', { withTimezone: true }),`);
2704
+ }
2705
+ for (const field of config.fields) {
2706
+ lines.push(` ${toCamel(field.name)}: ${drizzleColumn(field)},`);
2707
+ }
2708
+ lines.push(`});`);
2709
+ return lines.join("\n");
2710
+ }
2711
+ function drizzleImports(config) {
2712
+ const used = /* @__PURE__ */ new Set(["pgTable", "uuid", "timestamp"]);
2713
+ for (const field of config.fields) {
2714
+ switch (field.type) {
2715
+ case "number":
2716
+ used.add("integer");
2717
+ break;
2718
+ case "boolean":
2719
+ used.add("boolean");
2720
+ break;
2721
+ case "date":
2722
+ used.add("date");
2723
+ break;
2724
+ case "datetime":
2725
+ used.add("timestamp");
2726
+ break;
2727
+ case "json":
2728
+ used.add("jsonb");
2729
+ break;
2730
+ case "text":
2731
+ case "string":
2732
+ used.add("text");
2733
+ break;
2734
+ }
2735
+ }
2736
+ return [...used].sort();
2737
+ }
2738
+ function zodType(type, required) {
2739
+ const inner = (() => {
2267
2740
  switch (type) {
2268
2741
  case "string":
2269
2742
  case "text":
2270
- case "date":
2271
- case "datetime":
2272
- return "string";
2743
+ return "z.string()";
2273
2744
  case "number":
2274
- return "number";
2745
+ return "z.number()";
2275
2746
  case "boolean":
2276
- return "boolean";
2747
+ return "z.boolean()";
2748
+ case "date":
2749
+ return "z.string().date()";
2750
+ case "datetime":
2751
+ return "z.string().datetime()";
2277
2752
  case "json":
2278
- return "Record<string, unknown>";
2753
+ return "z.unknown()";
2279
2754
  }
2280
2755
  })();
2281
- return required ? base : `${base} | null`;
2756
+ return required ? inner : `${inner}.nullable()`;
2282
2757
  }
2283
- function generateFrontendInterface(config) {
2284
- const className = toPascal(config.name);
2285
- const lines = [];
2286
- lines.push(`export interface ${className} {`);
2758
+ function zodOptional(type) {
2759
+ switch (type) {
2760
+ case "string":
2761
+ case "text":
2762
+ return "z.string().optional()";
2763
+ case "number":
2764
+ return "z.number().optional()";
2765
+ case "boolean":
2766
+ return "z.boolean().optional()";
2767
+ case "date":
2768
+ return "z.string().date().optional()";
2769
+ case "datetime":
2770
+ return "z.string().datetime().optional()";
2771
+ case "json":
2772
+ return "z.unknown().optional()";
2773
+ }
2774
+ }
2775
+ function generateExpressSchemas(config) {
2776
+ const className = toPascal(config.name);
2777
+ const lines = [];
2778
+ lines.push(`import { z } from 'zod';`);
2779
+ lines.push("");
2780
+ lines.push(`export const ${className}Schema = z.object({`);
2781
+ lines.push(` id: z.string().uuid(),`);
2782
+ for (const f of config.fields) {
2783
+ lines.push(` ${f.name}: ${zodType(f.type, f.required)},`);
2784
+ }
2785
+ lines.push(` created_at: z.string().datetime(),`);
2786
+ lines.push(` updated_at: z.string().datetime(),`);
2787
+ if (config.softDelete)
2788
+ lines.push(` deleted_at: z.string().datetime().nullable(),`);
2789
+ lines.push(`});`);
2790
+ lines.push("");
2791
+ lines.push(`export type ${className} = z.infer<typeof ${className}Schema>;`);
2792
+ lines.push("");
2793
+ lines.push(`export const Create${className}Schema = z.object({`);
2794
+ for (const f of config.fields.filter((field) => !field.generated)) {
2795
+ if (f.required) {
2796
+ lines.push(` ${f.name}: ${zodType(f.type, true)},`);
2797
+ } else {
2798
+ lines.push(` ${f.name}: ${zodOptional(f.type)},`);
2799
+ }
2800
+ }
2801
+ lines.push(`});`);
2802
+ lines.push("");
2803
+ lines.push(
2804
+ `export type Create${className} = z.infer<typeof Create${className}Schema>;`
2805
+ );
2806
+ lines.push("");
2807
+ lines.push(`export const Update${className}Schema = z.object({`);
2808
+ for (const f of config.fields.filter((field) => !field.generated)) {
2809
+ lines.push(` ${f.name}: ${zodOptional(f.type)},`);
2810
+ }
2811
+ lines.push(`});`);
2812
+ lines.push("");
2813
+ lines.push(
2814
+ `export type Update${className} = z.infer<typeof Update${className}Schema>;`
2815
+ );
2816
+ lines.push("");
2817
+ return lines.join("\n");
2818
+ }
2819
+ function generateExpressIndex(config) {
2820
+ const className = toPascal(config.name);
2821
+ const camelConfig = className.charAt(0).toLowerCase() + className.slice(1) + "Config";
2822
+ const generatedFields = config.fields.filter((field) => field.generated);
2823
+ const tags = config.apiPrefix.replace(/^\//, "");
2824
+ const lines = [];
2825
+ lines.push(
2826
+ `import { EntityRegistry, type EntityConfig } from '../_base/index.js';`
2827
+ );
2828
+ if (generatedFields.length > 0) {
2829
+ lines.push(`import { randomBytes } from 'node:crypto';`);
2830
+ }
2831
+ lines.push(
2832
+ `import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`
2833
+ );
2834
+ lines.push("");
2835
+ for (const field of generatedFields) {
2836
+ lines.push(
2837
+ `function generate${className}${toPascal(field.name)}(): string {`
2838
+ );
2839
+ lines.push(` return randomBytes(8).toString('hex').toUpperCase();`);
2840
+ lines.push(`}`);
2841
+ lines.push("");
2842
+ }
2843
+ lines.push(`export const ${camelConfig}: EntityConfig = {`);
2844
+ lines.push(` name: '${className}',`);
2845
+ lines.push(` tableName: '${config.tableName}',`);
2846
+ lines.push(` prismaModel: '${className}',`);
2847
+ lines.push(` apiPrefix: '${config.apiPrefix}',`);
2848
+ lines.push(` tags: ['${tags}'],`);
2849
+ lines.push(` readonly: ${config.readonly},`);
2850
+ lines.push(` softDelete: ${config.softDelete},`);
2851
+ lines.push(` bulkOperations: ${config.bulkOperations},`);
2852
+ if (config.searchableFields.length > 0) {
2853
+ lines.push(
2854
+ ` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`
2855
+ );
2856
+ } else {
2857
+ lines.push(` searchableFields: [],`);
2858
+ }
2859
+ lines.push(` schema: ${className}Schema,`);
2860
+ lines.push(` createSchema: Create${className}Schema,`);
2861
+ lines.push(` updateSchema: Update${className}Schema,`);
2862
+ if (generatedFields.length > 0) {
2863
+ lines.push(
2864
+ ` beforeCreateFields: [${generatedFields.map((field) => `'${field.name}'`).join(", ")}],`
2865
+ );
2866
+ lines.push(` beforeCreate: (_request, data) => {`);
2867
+ for (const field of generatedFields) {
2868
+ lines.push(
2869
+ ` if (!('${field.name}' in data) || data.${field.name} == null) {`
2870
+ );
2871
+ lines.push(
2872
+ ` data.${field.name} = generate${className}${toPascal(field.name)}();`
2873
+ );
2874
+ lines.push(` }`);
2875
+ }
2876
+ lines.push(` },`);
2877
+ }
2878
+ lines.push(`};`);
2879
+ lines.push("");
2880
+ lines.push(`EntityRegistry.register(${camelConfig});`);
2881
+ lines.push("");
2882
+ return lines.join("\n");
2883
+ }
2884
+ function tsType(type, required) {
2885
+ const base = (() => {
2886
+ switch (type) {
2887
+ case "string":
2888
+ case "text":
2889
+ case "date":
2890
+ case "datetime":
2891
+ return "string";
2892
+ case "number":
2893
+ return "number";
2894
+ case "boolean":
2895
+ return "boolean";
2896
+ case "json":
2897
+ return "Record<string, unknown>";
2898
+ }
2899
+ })();
2900
+ return required ? base : `${base} | null`;
2901
+ }
2902
+ function generateFrontendInterface(config) {
2903
+ const className = toPascal(config.name);
2904
+ const lines = [];
2905
+ lines.push(`export interface ${className} {`);
2287
2906
  lines.push(` id: string;`);
2288
2907
  for (const f of config.fields) {
2289
2908
  lines.push(` ${f.name}: ${tsType(f.type, f.required)};`);
@@ -2331,7 +2950,8 @@ function dartType(type, required) {
2331
2950
  return required ? base : `${base}?`;
2332
2951
  }
2333
2952
  function toCamel(s) {
2334
- return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
2953
+ const pascal = toPascal(s);
2954
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
2335
2955
  }
2336
2956
  function dartFromJson(fieldName, type, required) {
2337
2957
  const key = `json['${fieldName}']`;
@@ -2565,77 +3185,579 @@ function generateFastifyTest(config) {
2565
3185
  const className = toPascal(config.name);
2566
3186
  const basePath = `/api/v1${config.apiPrefix}`;
2567
3187
  const updateField = config.fields[0];
3188
+ const uniqueFields = config.fields.filter((field) => field.unique);
2568
3189
  const lines = [];
2569
3190
  lines.push(
2570
3191
  `import { describeCrudEntity } from '../helpers/crud-test-base.js';`
2571
3192
  );
3193
+ lines.push(
3194
+ `import { Create${className}Schema } from '../../src/modules/${toKebab(config.name)}/schemas.js';`
3195
+ );
2572
3196
  lines.push("");
2573
3197
  lines.push(`describeCrudEntity({`);
2574
3198
  lines.push(` entityName: '${className}',`);
2575
3199
  lines.push(` basePath: '${basePath}',`);
2576
3200
  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
- }
3201
+ lines.push(` createSchema: Create${className}Schema,`);
3202
+ lines.push(` updatePayload: {`);
3203
+ lines.push(
3204
+ ` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`
3205
+ );
2581
3206
  lines.push(` },`);
3207
+ if (uniqueFields.length > 0) {
3208
+ lines.push(
3209
+ ` uniqueFields: [${uniqueFields.map((field) => `'${field.name}'`).join(", ")}],`
3210
+ );
3211
+ }
3212
+ lines.push(`});`);
3213
+ lines.push("");
3214
+ return lines.join("\n");
3215
+ }
3216
+ function generateExpressTest(config) {
3217
+ const className = toPascal(config.name);
3218
+ const basePath = `/api/v1${config.apiPrefix}`;
3219
+ const updateField = config.fields[0];
3220
+ const uniqueFields = config.fields.filter((field) => field.unique);
3221
+ const lines = [];
3222
+ lines.push(
3223
+ `import { describeCrudEntity } from '../helpers/crud-test-base.js';`
3224
+ );
3225
+ lines.push(
3226
+ `import { Create${className}Schema } from '../../src/modules/${toKebab(config.name)}/schemas.js';`
3227
+ );
3228
+ lines.push("");
3229
+ lines.push(`describeCrudEntity({`);
3230
+ lines.push(` entityName: '${className}',`);
3231
+ lines.push(` basePath: '${basePath}',`);
3232
+ lines.push(` prismaModel: '${className}',`);
3233
+ lines.push(` createSchema: Create${className}Schema,`);
2582
3234
  lines.push(` updatePayload: {`);
2583
3235
  lines.push(
2584
3236
  ` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`
2585
3237
  );
2586
3238
  lines.push(` },`);
3239
+ if (uniqueFields.length > 0) {
3240
+ lines.push(
3241
+ ` uniqueFields: [${uniqueFields.map((field) => `'${field.name}'`).join(", ")}],`
3242
+ );
3243
+ }
2587
3244
  lines.push(`});`);
2588
3245
  lines.push("");
2589
3246
  return lines.join("\n");
2590
3247
  }
2591
- async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, backendFlag) {
3248
+ function addonGenEntityPath(repoDir, orm, fileName) {
3249
+ return join10(repoDir, "addons", "orms", orm, "gen-entity", fileName);
3250
+ }
3251
+ function sampleValue(field) {
3252
+ switch (field.type) {
3253
+ case "string":
3254
+ case "text":
3255
+ return `'sample-${field.name}'`;
3256
+ case "number":
3257
+ return "1";
3258
+ case "boolean":
3259
+ return "true";
3260
+ case "date":
3261
+ return "'2025-01-01'";
3262
+ case "datetime":
3263
+ return "'2025-01-01T00:00:00Z'";
3264
+ case "json":
3265
+ return "{}";
3266
+ }
3267
+ }
3268
+ function sampleJsonPayload(fields) {
3269
+ const props = fields.filter((f) => !f.generated).map((f) => `${f.name}: ${sampleValue(f)}`);
3270
+ return `{ ${props.join(", ")} }`;
3271
+ }
3272
+ function updateJsonPayload(fields) {
3273
+ const editable = fields.find(
3274
+ (f) => !f.generated && (f.type === "string" || f.type === "text")
3275
+ );
3276
+ if (editable) return `{ ${editable.name}: 'updated-${editable.name}' }`;
3277
+ const numeric = fields.find((f) => !f.generated && f.type === "number");
3278
+ if (numeric) return `{ ${numeric.name}: 2 }`;
3279
+ return sampleJsonPayload(fields);
3280
+ }
3281
+ function insertAtAnchor(content, anchor, insertion) {
3282
+ if (content.includes(insertion)) return content;
3283
+ const lines = content.split("\n");
3284
+ for (let i = 0; i < lines.length; i++) {
3285
+ if (lines[i].includes(anchor)) {
3286
+ lines.splice(i + 1, 0, insertion);
3287
+ return lines.join("\n");
3288
+ }
3289
+ }
3290
+ return content;
3291
+ }
3292
+ async function fillTemplate(repoDir, orm, fileName, vars) {
3293
+ const path = addonGenEntityPath(repoDir, orm, fileName);
3294
+ if (!existsSync10(path)) {
3295
+ throw new Error(`Addon template not found: ${path}`);
3296
+ }
3297
+ let content = await readFile7(path, "utf-8");
3298
+ for (const [key, value] of Object.entries(vars)) {
3299
+ content = content.replaceAll(`__${key}__`, value);
3300
+ }
3301
+ return content;
3302
+ }
3303
+ function buildDrizzleEntityVars(config) {
3304
+ const pascal = toPascal(config.name);
3305
+ return {
3306
+ ENTITY_PASCAL: pascal,
3307
+ TABLE_CAMEL: toCamel(pluralize(pascal)),
3308
+ API_PREFIX: config.apiPrefix,
3309
+ TAG: config.apiPrefix.replace(/^\//, ""),
3310
+ SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
3311
+ BULK_OPERATIONS: String(config.bulkOperations),
3312
+ SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
3313
+ UPDATE_PAYLOAD: updateJsonPayload(config.fields)
3314
+ };
3315
+ }
3316
+ function sequelizeFieldType(field) {
3317
+ switch (field.type) {
3318
+ case "string":
3319
+ return { dataType: "STRING", tsType: "string" };
3320
+ case "text":
3321
+ return { dataType: "TEXT", tsType: "string" };
3322
+ case "number":
3323
+ return { dataType: "INTEGER", tsType: "number" };
3324
+ case "boolean":
3325
+ return { dataType: "BOOLEAN", tsType: "boolean" };
3326
+ case "date":
3327
+ return { dataType: "DATEONLY", tsType: "Date" };
3328
+ case "datetime":
3329
+ return { dataType: "DATE", tsType: "Date" };
3330
+ case "json":
3331
+ return { dataType: "JSONB", tsType: "unknown" };
3332
+ }
3333
+ }
3334
+ function sequelizeFieldDeclarations(fields) {
3335
+ return fields.map((f) => {
3336
+ const { tsType: tsType2 } = sequelizeFieldType(f);
3337
+ const nullable = f.required ? "" : " | null";
3338
+ return ` declare ${toCamel(f.name)}: ${tsType2}${nullable};`;
3339
+ }).join("\n");
3340
+ }
3341
+ function sequelizeFieldDefinitions(fields) {
3342
+ return fields.map((f) => {
3343
+ const { dataType } = sequelizeFieldType(f);
3344
+ const parts = [`type: DataTypes.${dataType}`];
3345
+ if (f.required) parts.push("allowNull: false");
3346
+ else parts.push("allowNull: true");
3347
+ if (f.unique) parts.push("unique: true");
3348
+ return ` ${toCamel(f.name)}: { ${parts.join(", ")} },`;
3349
+ }).join("\n");
3350
+ }
3351
+ function buildSequelizeEntityVars(config) {
3352
+ const pascal = toPascal(config.name);
3353
+ const kebab = toKebab(config.name);
3354
+ return {
3355
+ ENTITY_PASCAL: pascal,
3356
+ ENTITY_KEBAB: kebab,
3357
+ TABLE_NAME: config.tableName,
3358
+ API_PREFIX: config.apiPrefix,
3359
+ TAG: config.apiPrefix.replace(/^\//, ""),
3360
+ SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
3361
+ BULK_OPERATIONS: String(config.bulkOperations),
3362
+ SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
3363
+ UPDATE_PAYLOAD: updateJsonPayload(config.fields),
3364
+ FIELD_DECLARATIONS: sequelizeFieldDeclarations(config.fields),
3365
+ FIELD_DEFINITIONS: sequelizeFieldDefinitions(config.fields)
3366
+ };
3367
+ }
3368
+ function typeormColumnType(field) {
3369
+ switch (field.type) {
3370
+ case "string":
3371
+ return "varchar";
3372
+ case "text":
3373
+ return "text";
3374
+ case "number":
3375
+ return "integer";
3376
+ case "boolean":
3377
+ return "boolean";
3378
+ case "date":
3379
+ return "date";
3380
+ case "datetime":
3381
+ return "timestamptz";
3382
+ case "json":
3383
+ return "jsonb";
3384
+ }
3385
+ }
3386
+ function typeormColumnTsType(field) {
3387
+ switch (field.type) {
3388
+ case "string":
3389
+ case "text":
3390
+ return "string";
3391
+ case "number":
3392
+ return "number";
3393
+ case "boolean":
3394
+ return "boolean";
3395
+ case "date":
3396
+ case "datetime":
3397
+ return "Date";
3398
+ case "json":
3399
+ return "unknown";
3400
+ }
3401
+ }
3402
+ function typeormColumnDecorators(fields) {
3403
+ return fields.map((f) => {
3404
+ const dbName = f.name;
3405
+ const propName = toCamel(f.name);
3406
+ const opts = [
3407
+ `type: '${typeormColumnType(f)}'`,
3408
+ `name: '${dbName}'`
3409
+ ];
3410
+ if (!f.required) opts.push("nullable: true");
3411
+ if (f.unique) opts.push("unique: true");
3412
+ const tsType2 = typeormColumnTsType(f);
3413
+ const nullable = f.required ? "!" : "?";
3414
+ return ` @Column({ ${opts.join(", ")} })
3415
+ ${propName}${nullable}: ${tsType2}${f.required ? "" : " | null"};`;
3416
+ }).join("\n\n");
3417
+ }
3418
+ function buildTypeormEntityVars(config) {
3419
+ const pascal = toPascal(config.name);
3420
+ const kebab = toKebab(config.name);
3421
+ return {
3422
+ ENTITY_PASCAL: pascal,
3423
+ ENTITY_KEBAB: kebab,
3424
+ TABLE_NAME: config.tableName,
3425
+ API_PREFIX: config.apiPrefix,
3426
+ TAG: config.apiPrefix.replace(/^\//, ""),
3427
+ SEARCHABLE_FIELDS_ARRAY: config.searchableFields.map((f) => `'${f}'`).join(", "),
3428
+ BULK_OPERATIONS: String(config.bulkOperations),
3429
+ SAMPLE_PAYLOAD: sampleJsonPayload(config.fields),
3430
+ UPDATE_PAYLOAD: updateJsonPayload(config.fields),
3431
+ COLUMN_DECORATORS: typeormColumnDecorators(config.fields)
3432
+ };
3433
+ }
3434
+ async function appendTypeormEntity(repoDir, cwd, dir, framework, config, generated) {
3435
+ const vars = buildTypeormEntityVars(config);
3436
+ const kebab = vars.ENTITY_KEBAB;
3437
+ const entitiesDir = join10(cwd, dir, "src/entities");
3438
+ await mkdir3(entitiesDir, { recursive: true });
3439
+ const entityPath = join10(entitiesDir, `${kebab}.ts`);
3440
+ if (!existsSync10(entityPath)) {
3441
+ const entitySource = await fillTemplate(
3442
+ repoDir,
3443
+ "typeorm",
3444
+ "entity.ts",
3445
+ vars
3446
+ );
3447
+ await writeFile2(entityPath, entitySource);
3448
+ generated.push(`${dir}/src/entities/${kebab}.ts`);
3449
+ }
3450
+ const entitiesIndexPath = join10(entitiesDir, "index.ts");
3451
+ if (existsSync10(entitiesIndexPath)) {
3452
+ let content = await readFile7(entitiesIndexPath, "utf-8");
3453
+ const importLine = `import { ${vars.ENTITY_PASCAL} } from './${kebab}.js';`;
3454
+ const exportLine = ` ${vars.ENTITY_PASCAL},`;
3455
+ const updated = insertAtAnchor(
3456
+ insertAtAnchor(content, "projx-anchor: model-imports", importLine),
3457
+ "projx-anchor: model-exports",
3458
+ exportLine
3459
+ );
3460
+ if (updated !== content) {
3461
+ content = updated;
3462
+ await writeFile2(entitiesIndexPath, content);
3463
+ generated.push(`${dir}/src/entities/index.ts (entity wired)`);
3464
+ }
3465
+ }
3466
+ const moduleDir = join10(cwd, dir, "src/modules", kebab);
3467
+ if (!existsSync10(moduleDir)) {
3468
+ await mkdir3(moduleDir, { recursive: true });
3469
+ const routerSource = await fillTemplate(
3470
+ repoDir,
3471
+ "typeorm",
3472
+ framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
3473
+ vars
3474
+ );
3475
+ await writeFile2(join10(moduleDir, "index.ts"), routerSource);
3476
+ generated.push(`${dir}/src/modules/${kebab}/index.ts`);
3477
+ }
3478
+ const appPath = join10(cwd, dir, "src/app.ts");
3479
+ if (existsSync10(appPath)) {
3480
+ let appContent = await readFile7(appPath, "utf-8");
3481
+ const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
3482
+ const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app);`;
3483
+ const updated = insertAtAnchor(
3484
+ insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
3485
+ "projx-anchor: entity-registrations",
3486
+ registrationLine
3487
+ );
3488
+ if (updated !== appContent) {
3489
+ appContent = updated;
3490
+ await writeFile2(appPath, appContent);
3491
+ generated.push(`${dir}/src/app.ts (entity wired)`);
3492
+ }
3493
+ }
3494
+ const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
3495
+ await mkdir3(testsDir, { recursive: true });
3496
+ const testFile = join10(testsDir, `${kebab}.test.ts`);
3497
+ if (!existsSync10(testFile)) {
3498
+ const testSource = await fillTemplate(
3499
+ repoDir,
3500
+ "typeorm",
3501
+ framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
3502
+ vars
3503
+ );
3504
+ await writeFile2(testFile, testSource);
3505
+ const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
3506
+ generated.push(`${dir}/${testRel}`);
3507
+ }
3508
+ }
3509
+ async function appendSequelizeEntity(repoDir, cwd, dir, framework, config, generated) {
3510
+ const vars = buildSequelizeEntityVars(config);
3511
+ const kebab = vars.ENTITY_KEBAB;
3512
+ const modelsDir = join10(cwd, dir, "src/models");
3513
+ await mkdir3(modelsDir, { recursive: true });
3514
+ const modelPath = join10(modelsDir, `${kebab}.ts`);
3515
+ if (!existsSync10(modelPath)) {
3516
+ const modelSource = await fillTemplate(
3517
+ repoDir,
3518
+ "sequelize",
3519
+ "model.ts",
3520
+ vars
3521
+ );
3522
+ await writeFile2(modelPath, modelSource);
3523
+ generated.push(`${dir}/src/models/${kebab}.ts`);
3524
+ }
3525
+ const modelsIndexPath = join10(modelsDir, "index.ts");
3526
+ if (existsSync10(modelsIndexPath)) {
3527
+ let content = await readFile7(modelsIndexPath, "utf-8");
3528
+ const importLine = `import { ${vars.ENTITY_PASCAL} } from './${kebab}.js';`;
3529
+ const exportLine = ` ${vars.ENTITY_PASCAL},`;
3530
+ const updated = insertAtAnchor(
3531
+ insertAtAnchor(content, "projx-anchor: model-imports", importLine),
3532
+ "projx-anchor: model-exports",
3533
+ exportLine
3534
+ );
3535
+ if (updated !== content) {
3536
+ content = updated;
3537
+ await writeFile2(modelsIndexPath, content);
3538
+ generated.push(`${dir}/src/models/index.ts (model wired)`);
3539
+ }
3540
+ }
3541
+ const moduleDir = join10(cwd, dir, "src/modules", kebab);
3542
+ if (!existsSync10(moduleDir)) {
3543
+ await mkdir3(moduleDir, { recursive: true });
3544
+ const routerSource = await fillTemplate(
3545
+ repoDir,
3546
+ "sequelize",
3547
+ framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
3548
+ vars
3549
+ );
3550
+ await writeFile2(join10(moduleDir, "index.ts"), routerSource);
3551
+ generated.push(`${dir}/src/modules/${kebab}/index.ts`);
3552
+ }
3553
+ const appPath = join10(cwd, dir, "src/app.ts");
3554
+ if (existsSync10(appPath)) {
3555
+ let appContent = await readFile7(appPath, "utf-8");
3556
+ const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
3557
+ const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app);`;
3558
+ const updated = insertAtAnchor(
3559
+ insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
3560
+ "projx-anchor: entity-registrations",
3561
+ registrationLine
3562
+ );
3563
+ if (updated !== appContent) {
3564
+ appContent = updated;
3565
+ await writeFile2(appPath, appContent);
3566
+ generated.push(`${dir}/src/app.ts (entity wired)`);
3567
+ }
3568
+ }
3569
+ const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
3570
+ await mkdir3(testsDir, { recursive: true });
3571
+ const testFile = join10(testsDir, `${kebab}.test.ts`);
3572
+ if (!existsSync10(testFile)) {
3573
+ const testSource = await fillTemplate(
3574
+ repoDir,
3575
+ "sequelize",
3576
+ framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
3577
+ vars
3578
+ );
3579
+ await writeFile2(testFile, testSource);
3580
+ const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
3581
+ generated.push(`${dir}/${testRel}`);
3582
+ }
3583
+ }
3584
+ async function appendDrizzleEntity(repoDir, cwd, dir, framework, config, generated) {
3585
+ const schemaDir = join10(cwd, dir, "src/db");
3586
+ const schemaPath = join10(schemaDir, "schema.ts");
3587
+ const tableConst = toCamel(pluralize(toPascal(config.name)));
3588
+ const tableSource = generateDrizzleTable(config);
3589
+ await mkdir3(schemaDir, { recursive: true });
3590
+ const usedImports = drizzleImports(config);
3591
+ if (!existsSync10(schemaPath)) {
3592
+ await writeFile2(
3593
+ schemaPath,
3594
+ `import { ${usedImports.join(", ")} } from 'drizzle-orm/pg-core';
3595
+
3596
+ ${tableSource}
3597
+ `
3598
+ );
3599
+ generated.push(`${dir}/src/db/schema.ts`);
3600
+ } else {
3601
+ const content = await readFile7(schemaPath, "utf-8");
3602
+ if (!content.includes(`export const ${tableConst} = pgTable(`)) {
3603
+ let updated = content;
3604
+ const importLine = `import { ${usedImports.join(", ")} } from 'drizzle-orm/pg-core';`;
3605
+ if (!updated.includes("drizzle-orm/pg-core")) {
3606
+ updated = importLine + "\n\n" + updated;
3607
+ } else {
3608
+ updated = updated.replace(
3609
+ /import\s+\{([^}]+)\}\s+from\s+'drizzle-orm\/pg-core';/,
3610
+ (_match, imports) => {
3611
+ const names = new Set(
3612
+ String(imports).split(",").map((item) => item.trim()).filter(Boolean)
3613
+ );
3614
+ for (const name of usedImports) {
3615
+ names.add(name);
3616
+ }
3617
+ return `import { ${[...names].sort().join(", ")} } from 'drizzle-orm/pg-core';`;
3618
+ }
3619
+ );
3620
+ }
3621
+ await writeFile2(
3622
+ schemaPath,
3623
+ updated.trimEnd() + "\n\n" + tableSource + "\n"
3624
+ );
3625
+ generated.push(`${dir}/src/db/schema.ts (table added)`);
3626
+ }
3627
+ }
3628
+ const vars = buildDrizzleEntityVars(config);
3629
+ const kebab = toKebab(config.name);
3630
+ const moduleDir = join10(cwd, dir, "src/modules", kebab);
3631
+ if (!existsSync10(moduleDir)) {
3632
+ await mkdir3(moduleDir, { recursive: true });
3633
+ const routerSource = await fillTemplate(
3634
+ repoDir,
3635
+ "drizzle",
3636
+ framework === "fastify" ? "fastify-router.ts" : "express-router.ts",
3637
+ vars
3638
+ );
3639
+ await writeFile2(join10(moduleDir, "index.ts"), routerSource);
3640
+ generated.push(`${dir}/src/modules/${kebab}/index.ts`);
3641
+ }
3642
+ const appPath = join10(cwd, dir, "src/app.ts");
3643
+ if (existsSync10(appPath)) {
3644
+ let appContent = await readFile7(appPath, "utf-8");
3645
+ const importLine = `import { register${vars.ENTITY_PASCAL}Entity } from './modules/${kebab}/index.js';`;
3646
+ const registrationLine = framework === "fastify" ? ` await register${vars.ENTITY_PASCAL}Entity(app);` : ` register${vars.ENTITY_PASCAL}Entity(app, db);`;
3647
+ const updated = insertAtAnchor(
3648
+ insertAtAnchor(appContent, "projx-anchor: entity-imports", importLine),
3649
+ "projx-anchor: entity-registrations",
3650
+ registrationLine
3651
+ );
3652
+ if (updated !== appContent) {
3653
+ appContent = updated;
3654
+ await writeFile2(appPath, appContent);
3655
+ generated.push(`${dir}/src/app.ts (entity wired)`);
3656
+ }
3657
+ }
3658
+ const testsDir = framework === "fastify" ? join10(cwd, dir, "tests/modules") : join10(cwd, dir, "tests");
3659
+ await mkdir3(testsDir, { recursive: true });
3660
+ const testFile = join10(testsDir, `${kebab}.test.ts`);
3661
+ if (!existsSync10(testFile)) {
3662
+ const testSource = await fillTemplate(
3663
+ repoDir,
3664
+ "drizzle",
3665
+ framework === "fastify" ? "fastify-test.ts" : "express-test.ts",
3666
+ vars
3667
+ );
3668
+ await writeFile2(testFile, testSource);
3669
+ const testRel = framework === "fastify" ? `tests/modules/${kebab}.test.ts` : `tests/${kebab}.test.ts`;
3670
+ generated.push(`${dir}/${testRel}`);
3671
+ }
3672
+ }
3673
+ async function resolvePrimaryBackend(cwd, hasFastapi, hasFastify, hasExpress, backendFlag) {
2592
3674
  if (backendFlag) return backendFlag;
2593
- if (hasFastapi && !hasFastify) return "fastapi";
2594
- if (hasFastify && !hasFastapi) return "fastify";
3675
+ const backends = [
3676
+ hasFastapi ? "fastapi" : void 0,
3677
+ hasFastify ? "fastify" : void 0,
3678
+ hasExpress ? "express" : void 0
3679
+ ].filter((item) => item !== void 0);
3680
+ if (backends.length === 1) return backends[0];
2595
3681
  const config = await readProjxConfig(cwd);
2596
- if (config.primaryBackend === "fastapi" || config.primaryBackend === "fastify") {
3682
+ if (config.primaryBackend === "fastapi" || config.primaryBackend === "fastify" || config.primaryBackend === "express") {
2597
3683
  return config.primaryBackend;
2598
3684
  }
2599
- if (!process.stdin.isTTY) return "fastify";
3685
+ if (!process.stdin.isTTY) {
3686
+ return hasFastify ? "fastify" : hasExpress ? "express" : "fastapi";
3687
+ }
2600
3688
  const choice = await p9.select({
2601
- message: "Both backends detected. Which is your primary?",
3689
+ message: "Multiple backends detected. Which is your primary?",
2602
3690
  options: [
2603
- { value: "fastify", label: "fastify (API backend)" },
2604
- { value: "fastapi", label: "fastapi (AI/ML engine)" }
3691
+ ...hasFastify ? [{ value: "fastify", label: "fastify (API backend)" }] : [],
3692
+ ...hasExpress ? [{ value: "express", label: "express (API backend)" }] : [],
3693
+ ...hasFastapi ? [{ value: "fastapi", label: "fastapi (AI/ML engine)" }] : []
2605
3694
  ],
2606
- initialValue: "fastify"
3695
+ initialValue: hasFastify ? "fastify" : hasExpress ? "express" : "fastapi"
2607
3696
  });
2608
3697
  if (p9.isCancel(choice)) process.exit(0);
2609
3698
  await writeProjxConfig(cwd, { ...config, primaryBackend: choice });
2610
3699
  p9.log.success(`Saved primaryBackend: ${choice} to .projx`);
2611
3700
  return choice;
2612
3701
  }
2613
- async function gen(cwd, entityName, fieldsFlag, backendFlag) {
3702
+ async function gen(cwd, entityName, fieldsFlag, backendFlag, localRepo) {
2614
3703
  p9.intro(`projx gen entity ${entityName}`);
2615
- if (!existsSync9(join9(cwd, ".projx"))) {
3704
+ if (!existsSync10(join10(cwd, ".projx"))) {
2616
3705
  p9.log.error("No .projx file found. Run 'npx create-projx init' first.");
2617
3706
  process.exit(1);
2618
3707
  }
2619
3708
  const projxData = await readProjxConfig(cwd);
2620
3709
  const pmName = projxData.packageManager ?? "npm";
2621
3710
  const pm = pmCommands(pmName);
3711
+ const orm = projxData.orm ?? "prisma";
3712
+ const needsAddon = orm === "drizzle" || orm === "sequelize" || orm === "typeorm";
3713
+ const repoDir = needsAddon ? await downloadRepo(localRepo).catch((err) => {
3714
+ p9.cancel(`Failed to fetch templates: ${err.message}`);
3715
+ process.exit(1);
3716
+ }) : "";
3717
+ const isLocal = Boolean(localRepo);
3718
+ try {
3719
+ return await runGen({
3720
+ cwd,
3721
+ entityName,
3722
+ fieldsFlag,
3723
+ backendFlag,
3724
+ pm,
3725
+ orm,
3726
+ repoDir
3727
+ });
3728
+ } finally {
3729
+ if (needsAddon && repoDir) {
3730
+ await cleanupRepo(repoDir, isLocal);
3731
+ }
3732
+ }
3733
+ }
3734
+ async function runGen(opts) {
3735
+ const { cwd, entityName, fieldsFlag, backendFlag, pm, orm, repoDir } = opts;
2622
3736
  const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
2623
3737
  const hasFastapi = discovered.includes("fastapi");
2624
3738
  const hasFastify = discovered.includes("fastify");
3739
+ const hasExpress = discovered.includes("express");
2625
3740
  const hasFrontend = discovered.includes("frontend");
2626
3741
  const hasMobile = discovered.includes("mobile");
2627
- if (!hasFastapi && !hasFastify) {
2628
- p9.log.error("No backend component found. Need fastapi or fastify.");
3742
+ if (!hasFastapi && !hasFastify && !hasExpress) {
3743
+ p9.log.error(
3744
+ "No backend component found. Need fastapi, fastify, or express."
3745
+ );
2629
3746
  process.exit(1);
2630
3747
  }
2631
3748
  const targetBackend = await resolvePrimaryBackend(
2632
3749
  cwd,
2633
3750
  hasFastapi,
2634
3751
  hasFastify,
3752
+ hasExpress,
2635
3753
  backendFlag
2636
3754
  );
2637
3755
  const genFastapi = targetBackend === "fastapi" && hasFastapi;
2638
3756
  const genFastify = targetBackend === "fastify" && hasFastify;
3757
+ const genExpress = targetBackend === "express" && hasExpress;
3758
+ const genDrizzle = orm === "drizzle" && (genFastify || genExpress);
3759
+ const genSequelize = orm === "sequelize" && (genFastify || genExpress);
3760
+ const genTypeorm = orm === "typeorm" && (genFastify || genExpress);
2639
3761
  let config;
2640
3762
  if (fieldsFlag) {
2641
3763
  const fields = parseFieldsFlag(fieldsFlag);
@@ -2658,53 +3780,140 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2658
3780
  const generated = [];
2659
3781
  if (genFastapi) {
2660
3782
  const dir = componentPaths.fastapi;
2661
- const entityDir = join9(cwd, dir, "src/entities", toSnake(config.name));
2662
- if (existsSync9(entityDir)) {
3783
+ const entityDir = join10(cwd, dir, "src/entities", toSnake(config.name));
3784
+ if (existsSync10(entityDir)) {
2663
3785
  p9.log.warn(
2664
3786
  `${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`
2665
3787
  );
2666
3788
  } else {
2667
3789
  await mkdir3(entityDir, { recursive: true });
2668
- await writeFile(
2669
- join9(entityDir, "_model.py"),
3790
+ await writeFile2(
3791
+ join10(entityDir, "_model.py"),
2670
3792
  generateFastAPIModel(config)
2671
3793
  );
2672
- await writeFile(
2673
- join9(entityDir, "__init__.py"),
3794
+ await writeFile2(
3795
+ join10(entityDir, "__init__.py"),
2674
3796
  "from ._model import *\n"
2675
3797
  );
2676
3798
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/_model.py`);
2677
3799
  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));
3800
+ const testsDir = join10(cwd, dir, "tests");
3801
+ const testFile = join10(testsDir, `test_${toSnake(config.name)}_entity.py`);
3802
+ if (existsSync10(testsDir) && !existsSync10(testFile)) {
3803
+ await writeFile2(testFile, generateFastapiTest(config));
2682
3804
  generated.push(`${dir}/tests/test_${toSnake(config.name)}_entity.py`);
2683
3805
  }
2684
3806
  }
2685
3807
  }
2686
- if (genFastify) {
3808
+ if (genFastify && orm !== "drizzle" && orm !== "sequelize" && orm !== "typeorm") {
2687
3809
  const dir = componentPaths.fastify;
2688
- const moduleDir = join9(cwd, dir, "src/modules", toKebab(config.name));
2689
- if (existsSync9(moduleDir)) {
3810
+ const moduleDir = join10(cwd, dir, "src/modules", toKebab(config.name));
3811
+ if (existsSync10(moduleDir)) {
2690
3812
  p9.log.warn(
2691
3813
  `${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`
2692
3814
  );
2693
3815
  } else {
2694
3816
  await mkdir3(moduleDir, { recursive: true });
2695
- await writeFile(
2696
- join9(moduleDir, "schemas.ts"),
3817
+ await writeFile2(
3818
+ join10(moduleDir, "schemas.ts"),
2697
3819
  generateFastifySchemas(config)
2698
3820
  );
2699
- await writeFile(
2700
- join9(moduleDir, "index.ts"),
3821
+ await writeFile2(
3822
+ join10(moduleDir, "index.ts"),
2701
3823
  generateFastifyIndex(config)
2702
3824
  );
2703
3825
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
2704
3826
  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");
3827
+ const appPath = join10(cwd, dir, "src/app.ts");
3828
+ if (existsSync10(appPath)) {
3829
+ const appContent = await readFile7(appPath, "utf-8");
3830
+ const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
3831
+ if (!appContent.includes(importLine)) {
3832
+ const updated = appContent.replace(
3833
+ /^(import\s+'\.\/modules\/.*?';?\s*\n)/m,
3834
+ `$1${importLine}
3835
+ `
3836
+ );
3837
+ if (updated !== appContent) {
3838
+ await writeFile2(appPath, updated);
3839
+ generated.push(`${dir}/src/app.ts (import added)`);
3840
+ }
3841
+ }
3842
+ }
3843
+ const prismaPath = join10(cwd, dir, "prisma/schema.prisma");
3844
+ if (existsSync10(prismaPath)) {
3845
+ const prismaContent = await readFile7(prismaPath, "utf-8");
3846
+ const modelName = `model ${toPascal(config.name)}`;
3847
+ if (!prismaContent.includes(modelName)) {
3848
+ const prismaModel = generatePrismaModel(config);
3849
+ await writeFile2(
3850
+ prismaPath,
3851
+ prismaContent.trimEnd() + "\n\n" + prismaModel + "\n"
3852
+ );
3853
+ generated.push(`${dir}/prisma/schema.prisma (model added)`);
3854
+ }
3855
+ }
3856
+ const testsModulesDir = join10(cwd, dir, "tests/modules");
3857
+ const fastifyTestFile = join10(
3858
+ testsModulesDir,
3859
+ `${toKebab(config.name)}.test.ts`
3860
+ );
3861
+ if (existsSync10(testsModulesDir) && !existsSync10(fastifyTestFile)) {
3862
+ await writeFile2(fastifyTestFile, generateFastifyTest(config));
3863
+ generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
3864
+ }
3865
+ }
3866
+ }
3867
+ if (genFastify && orm === "drizzle") {
3868
+ await appendDrizzleEntity(
3869
+ repoDir,
3870
+ cwd,
3871
+ componentPaths.fastify,
3872
+ "fastify",
3873
+ config,
3874
+ generated
3875
+ );
3876
+ } else if (genFastify && orm === "sequelize") {
3877
+ await appendSequelizeEntity(
3878
+ repoDir,
3879
+ cwd,
3880
+ componentPaths.fastify,
3881
+ "fastify",
3882
+ config,
3883
+ generated
3884
+ );
3885
+ } else if (genFastify && orm === "typeorm") {
3886
+ await appendTypeormEntity(
3887
+ repoDir,
3888
+ cwd,
3889
+ componentPaths.fastify,
3890
+ "fastify",
3891
+ config,
3892
+ generated
3893
+ );
3894
+ }
3895
+ if (genExpress && orm !== "drizzle" && orm !== "sequelize" && orm !== "typeorm") {
3896
+ const dir = componentPaths.express;
3897
+ const moduleDir = join10(cwd, dir, "src/modules", toKebab(config.name));
3898
+ if (existsSync10(moduleDir)) {
3899
+ p9.log.warn(
3900
+ `${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Express.`
3901
+ );
3902
+ } else {
3903
+ await mkdir3(moduleDir, { recursive: true });
3904
+ await writeFile2(
3905
+ join10(moduleDir, "schemas.ts"),
3906
+ generateExpressSchemas(config)
3907
+ );
3908
+ await writeFile2(
3909
+ join10(moduleDir, "index.ts"),
3910
+ generateExpressIndex(config)
3911
+ );
3912
+ generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
3913
+ generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
3914
+ const appPath = join10(cwd, dir, "src/app.ts");
3915
+ if (existsSync10(appPath)) {
3916
+ const appContent = await readFile7(appPath, "utf-8");
2708
3917
  const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
2709
3918
  if (!appContent.includes(importLine)) {
2710
3919
  const updated = appContent.replace(
@@ -2713,75 +3922,103 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2713
3922
  `
2714
3923
  );
2715
3924
  if (updated !== appContent) {
2716
- await writeFile(appPath, updated);
3925
+ await writeFile2(appPath, updated);
2717
3926
  generated.push(`${dir}/src/app.ts (import added)`);
2718
3927
  }
2719
3928
  }
2720
3929
  }
2721
- const prismaPath = join9(cwd, dir, "prisma/schema.prisma");
2722
- if (existsSync9(prismaPath)) {
2723
- const prismaContent = await readFile6(prismaPath, "utf-8");
3930
+ const prismaPath = join10(cwd, dir, "prisma/schema.prisma");
3931
+ if (existsSync10(prismaPath)) {
3932
+ const prismaContent = await readFile7(prismaPath, "utf-8");
2724
3933
  const modelName = `model ${toPascal(config.name)}`;
2725
3934
  if (!prismaContent.includes(modelName)) {
2726
3935
  const prismaModel = generatePrismaModel(config);
2727
- await writeFile(
3936
+ await writeFile2(
2728
3937
  prismaPath,
2729
3938
  prismaContent.trimEnd() + "\n\n" + prismaModel + "\n"
2730
3939
  );
2731
3940
  generated.push(`${dir}/prisma/schema.prisma (model added)`);
2732
3941
  }
2733
3942
  }
2734
- const testsModulesDir = join9(cwd, dir, "tests/modules");
2735
- const fastifyTestFile = join9(
3943
+ const testsModulesDir = join10(cwd, dir, "tests/modules");
3944
+ const expressTestFile = join10(
2736
3945
  testsModulesDir,
2737
3946
  `${toKebab(config.name)}.test.ts`
2738
3947
  );
2739
- if (existsSync9(testsModulesDir) && !existsSync9(fastifyTestFile)) {
2740
- await writeFile(fastifyTestFile, generateFastifyTest(config));
3948
+ if (existsSync10(testsModulesDir) && !existsSync10(expressTestFile)) {
3949
+ await writeFile2(expressTestFile, generateExpressTest(config));
2741
3950
  generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
2742
3951
  }
2743
3952
  }
2744
3953
  }
3954
+ if (genExpress && orm === "drizzle") {
3955
+ await appendDrizzleEntity(
3956
+ repoDir,
3957
+ cwd,
3958
+ componentPaths.express,
3959
+ "express",
3960
+ config,
3961
+ generated
3962
+ );
3963
+ } else if (genExpress && orm === "sequelize") {
3964
+ await appendSequelizeEntity(
3965
+ repoDir,
3966
+ cwd,
3967
+ componentPaths.express,
3968
+ "express",
3969
+ config,
3970
+ generated
3971
+ );
3972
+ } else if (genExpress && orm === "typeorm") {
3973
+ await appendTypeormEntity(
3974
+ repoDir,
3975
+ cwd,
3976
+ componentPaths.express,
3977
+ "express",
3978
+ config,
3979
+ generated
3980
+ );
3981
+ }
2745
3982
  if (hasFrontend) {
2746
3983
  const dir = componentPaths.frontend;
2747
- const typesDir = join9(cwd, dir, "src/types");
3984
+ const typesDir = join10(cwd, dir, "src/types");
2748
3985
  const fileName = toKebab(config.name) + ".ts";
2749
- const filePath = join9(typesDir, fileName);
2750
- if (existsSync9(filePath)) {
3986
+ const filePath = join10(typesDir, fileName);
3987
+ if (existsSync10(filePath)) {
2751
3988
  p9.log.warn(
2752
3989
  `${dir}/src/types/${fileName} already exists. Skipping frontend types.`
2753
3990
  );
2754
3991
  } else {
2755
3992
  await mkdir3(typesDir, { recursive: true });
2756
- await writeFile(filePath, generateFrontendInterface(config));
3993
+ await writeFile2(filePath, generateFrontendInterface(config));
2757
3994
  generated.push(`${dir}/src/types/${fileName}`);
2758
- const barrelPath = join9(typesDir, "index.ts");
3995
+ const barrelPath = join10(typesDir, "index.ts");
2759
3996
  const exportLine = `export * from './${toKebab(config.name)}';`;
2760
- if (existsSync9(barrelPath)) {
2761
- const content = await readFile6(barrelPath, "utf-8");
3997
+ if (existsSync10(barrelPath)) {
3998
+ const content = await readFile7(barrelPath, "utf-8");
2762
3999
  if (!content.includes(exportLine)) {
2763
- await writeFile(
4000
+ await writeFile2(
2764
4001
  barrelPath,
2765
4002
  content.trimEnd() + "\n" + exportLine + "\n"
2766
4003
  );
2767
4004
  }
2768
4005
  } else {
2769
- await writeFile(barrelPath, exportLine + "\n");
4006
+ await writeFile2(barrelPath, exportLine + "\n");
2770
4007
  }
2771
4008
  generated.push(`${dir}/src/types/index.ts`);
2772
4009
  }
2773
4010
  }
2774
4011
  if (hasMobile) {
2775
4012
  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)) {
4013
+ const entityDir = join10(cwd, dir, "lib/entities", toSnake(config.name));
4014
+ const modelPath = join10(entityDir, "model.dart");
4015
+ if (existsSync10(modelPath)) {
2779
4016
  p9.log.warn(
2780
4017
  `${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`
2781
4018
  );
2782
4019
  } else {
2783
4020
  await mkdir3(entityDir, { recursive: true });
2784
- await writeFile(modelPath, generateDartModel(config));
4021
+ await writeFile2(modelPath, generateDartModel(config));
2785
4022
  generated.push(`${dir}/lib/entities/${toSnake(config.name)}/model.dart`);
2786
4023
  }
2787
4024
  }
@@ -2803,13 +4040,33 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2803
4040
  );
2804
4041
  p9.log.info(" alembic upgrade head");
2805
4042
  }
2806
- if (genFastify) {
4043
+ if (genFastify && orm === "prisma") {
2807
4044
  p9.log.info("");
2808
4045
  p9.log.info("Fastify next steps:");
2809
4046
  p9.log.info(
2810
4047
  ` ${pm.prismaExec} migrate dev --name add_${toSnake(config.name)}`
2811
4048
  );
2812
4049
  }
4050
+ if (genDrizzle) {
4051
+ p9.log.info("");
4052
+ p9.log.info("Drizzle next steps:");
4053
+ p9.log.info(` ${pm.exec} drizzle-kit generate`);
4054
+ p9.log.info(` ${pm.exec} drizzle-kit migrate`);
4055
+ }
4056
+ if (genSequelize) {
4057
+ p9.log.info("");
4058
+ p9.log.info("Sequelize next steps:");
4059
+ p9.log.info(
4060
+ ` ${pm.run} db:sync # syncs the schema against $DATABASE_URL`
4061
+ );
4062
+ }
4063
+ if (genTypeorm) {
4064
+ p9.log.info("");
4065
+ p9.log.info("TypeORM next steps:");
4066
+ p9.log.info(
4067
+ ` ${pm.run} db:sync # syncs the schema against $DATABASE_URL`
4068
+ );
4069
+ }
2813
4070
  if (hasFrontend) {
2814
4071
  p9.log.info("");
2815
4072
  p9.log.info("Frontend usage:");
@@ -2828,295 +4085,30 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2828
4085
  p9.outro(`Entity ${className} created.`);
2829
4086
  }
2830
4087
 
2831
- // 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";
2835
- import * as p10 from "@clack/prompts";
2836
- function toPascal2(s) {
2837
- return s.replace(/(?:^|[_\-\s])([a-zA-Z])/g, (_, c) => c.toUpperCase());
2838
- }
2839
- function toCamel2(s) {
2840
- return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
2841
- }
2842
- function metaTypeToTs(type, fieldType, nullable) {
2843
- const base = (() => {
2844
- switch (type) {
2845
- case "str":
2846
- return "string";
2847
- case "int":
2848
- case "float":
2849
- return "number";
2850
- case "bool":
2851
- return "boolean";
2852
- case "datetime":
2853
- case "date":
2854
- return "string";
2855
- case "dict":
2856
- return "Record<string, unknown>";
2857
- default:
2858
- return "unknown";
2859
- }
2860
- })();
2861
- return nullable ? `${base} | null` : base;
2862
- }
2863
- function metaTypeToDart(type, nullable) {
2864
- const base = (() => {
2865
- switch (type) {
2866
- case "str":
2867
- return "String";
2868
- case "int":
2869
- return "int";
2870
- case "float":
2871
- return "double";
2872
- case "bool":
2873
- return "bool";
2874
- case "datetime":
2875
- case "date":
2876
- return "DateTime";
2877
- case "dict":
2878
- return "Map<String, dynamic>";
2879
- default:
2880
- return "dynamic";
2881
- }
2882
- })();
2883
- return nullable ? `${base}?` : base;
2884
- }
2885
- function dartFromJsonExpr(key, type, nullable) {
2886
- const accessor = `json['${key}']`;
2887
- const isDate = type === "datetime" || type === "date";
2888
- if (isDate && nullable)
2889
- return `${accessor} != null ? DateTime.parse(${accessor} as String) : null`;
2890
- if (isDate) return `DateTime.parse(${accessor} as String)`;
2891
- if (type === "dict" && nullable)
2892
- return `${accessor} as Map<String, dynamic>?`;
2893
- if (type === "dict") return `${accessor} as Map<String, dynamic>`;
2894
- const dartT = (() => {
2895
- switch (type) {
2896
- case "str":
2897
- return "String";
2898
- case "int":
2899
- return "int";
2900
- case "float":
2901
- return "double";
2902
- case "bool":
2903
- return "bool";
2904
- default:
2905
- return "dynamic";
2906
- }
2907
- })();
2908
- return nullable ? `${accessor} as ${dartT}?` : `${accessor} as ${dartT}`;
2909
- }
2910
- function dartToJsonExpr(key, camel, type) {
2911
- const isDate = type === "datetime" || type === "date";
2912
- if (isDate) return `'${key}': ${camel}?.toIso8601String()`;
2913
- return `'${key}': ${camel}`;
2914
- }
2915
- function generateTsInterface(entity) {
2916
- const className = toPascal2(entity.name);
2917
- const lines = [];
2918
- lines.push(`export interface ${className} {`);
2919
- for (const f of entity.fields) {
2920
- lines.push(
2921
- ` ${f.key}: ${metaTypeToTs(f.type, f.field_type, f.nullable)};`
2922
- );
2923
- }
2924
- lines.push(`}`);
2925
- lines.push("");
2926
- const createFields = entity.fields.filter((f) => f.in_create);
2927
- lines.push(`export interface Create${className} {`);
2928
- for (const f of createFields) {
2929
- const optional = f.nullable ? "?" : "";
2930
- lines.push(
2931
- ` ${f.key}${optional}: ${metaTypeToTs(f.type, f.field_type, f.nullable)};`
2932
- );
2933
- }
2934
- lines.push(`}`);
2935
- lines.push("");
2936
- const updateFields = entity.fields.filter((f) => f.in_update);
2937
- lines.push(`export interface Update${className} {`);
2938
- for (const f of updateFields) {
2939
- lines.push(` ${f.key}?: ${metaTypeToTs(f.type, f.field_type, true)};`);
2940
- }
2941
- lines.push(`}`);
2942
- lines.push("");
2943
- return lines.join("\n");
2944
- }
2945
- function generateDartModel2(entity) {
2946
- const className = toPascal2(entity.name);
2947
- const lines = [];
2948
- const fields = entity.fields.map((f) => ({
2949
- snake: f.key,
2950
- camel: toCamel2(f.key),
2951
- type: metaTypeToDart(f.type, f.nullable),
2952
- nullable: f.nullable,
2953
- metaType: f.type
2954
- }));
2955
- lines.push(`class ${className} {`);
2956
- for (const f of fields) {
2957
- lines.push(` final ${f.type} ${f.camel};`);
2958
- }
2959
- lines.push("");
2960
- lines.push(` const ${className}({`);
2961
- for (const f of fields) {
2962
- if (f.nullable) {
2963
- lines.push(` this.${f.camel},`);
2964
- } else {
2965
- lines.push(` required this.${f.camel},`);
2966
- }
2967
- }
2968
- lines.push(` });`);
2969
- lines.push("");
2970
- lines.push(` factory ${className}.fromJson(Map<String, dynamic> json) {`);
2971
- lines.push(` return ${className}(`);
2972
- for (const f of fields) {
2973
- lines.push(
2974
- ` ${f.camel}: ${dartFromJsonExpr(f.snake, f.metaType, f.nullable)},`
2975
- );
2976
- }
2977
- lines.push(` );`);
2978
- lines.push(` }`);
2979
- lines.push("");
2980
- lines.push(` Map<String, dynamic> toJson() {`);
2981
- lines.push(` return {`);
2982
- for (const f of fields) {
2983
- lines.push(` ${dartToJsonExpr(f.snake, f.camel, f.metaType)},`);
2984
- }
2985
- lines.push(` };`);
2986
- lines.push(` }`);
2987
- lines.push("");
2988
- lines.push(` ${className} copyWith({`);
2989
- for (const f of fields) {
2990
- lines.push(` ${f.type.replace("?", "")}? ${f.camel},`);
2991
- }
2992
- lines.push(` }) {`);
2993
- lines.push(` return ${className}(`);
2994
- for (const f of fields) {
2995
- lines.push(` ${f.camel}: ${f.camel} ?? this.${f.camel},`);
2996
- }
2997
- lines.push(` );`);
2998
- lines.push(` }`);
2999
- lines.push(`}`);
3000
- lines.push("");
3001
- return lines.join("\n");
3002
- }
3003
- async function sync(cwd, url) {
3004
- p10.intro("projx sync");
3005
- const configPath = join10(cwd, ".projx");
3006
- if (!existsSync10(configPath)) {
3007
- p10.log.error("No .projx file found. Run 'npx create-projx init' first.");
3008
- process.exit(1);
3009
- }
3010
- const { components: discovered, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
3011
- const hasFrontend = discovered.includes("frontend");
3012
- const hasMobile = discovered.includes("mobile");
3013
- if (!hasFrontend && !hasMobile) {
3014
- p10.log.error("No frontend or mobile component found. Nothing to sync.");
3015
- process.exit(1);
3016
- }
3017
- const metaUrl = url || detectMetaUrl(cwd);
3018
- const spinner7 = p10.spinner();
3019
- spinner7.start(`Fetching metadata from ${metaUrl}`);
3020
- let meta;
3021
- try {
3022
- const res = await fetch(metaUrl);
3023
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
3024
- meta = await res.json();
3025
- } catch (err) {
3026
- spinner7.stop("Failed.");
3027
- p10.log.error(`Could not fetch ${metaUrl}: ${err}`);
3028
- p10.log.info("Make sure your backend is running.");
3029
- p10.log.info(
3030
- "Or specify URL: projx sync --url http://localhost:8000/api/v1/_meta"
3031
- );
3032
- process.exit(1);
3033
- }
3034
- spinner7.stop(`Fetched ${meta.entities.length} entity(s).`);
3035
- const generated = [];
3036
- if (hasFrontend) {
3037
- const dir = componentPaths.frontend;
3038
- const typesDir = join10(cwd, dir, "src/types");
3039
- await mkdir4(typesDir, { recursive: true });
3040
- const barrelExports = [];
3041
- for (const entity of meta.entities) {
3042
- const fileName = toKebab(toSnake(entity.name)) + ".ts";
3043
- const filePath = join10(typesDir, fileName);
3044
- await writeFile2(filePath, generateTsInterface(entity));
3045
- generated.push(`${dir}/src/types/${fileName}`);
3046
- barrelExports.push(`export * from './${toKebab(toSnake(entity.name))}';`);
3047
- }
3048
- await writeFile2(
3049
- join10(typesDir, "index.ts"),
3050
- barrelExports.join("\n") + "\n"
3051
- );
3052
- generated.push(`${dir}/src/types/index.ts`);
3053
- }
3054
- if (hasMobile) {
3055
- const dir = componentPaths.mobile;
3056
- for (const entity of meta.entities) {
3057
- const entityDir = join10(cwd, dir, "lib/entities", toSnake(entity.name));
3058
- await mkdir4(entityDir, { recursive: true });
3059
- const modelPath = join10(entityDir, "model.dart");
3060
- await writeFile2(modelPath, generateDartModel2(entity));
3061
- generated.push(`${dir}/lib/entities/${toSnake(entity.name)}/model.dart`);
3062
- }
3063
- }
3064
- p10.log.success(`Synced ${meta.entities.length} entity(s):`);
3065
- for (const f of generated) {
3066
- p10.log.info(` ${f}`);
3067
- }
3068
- if (hasFrontend) {
3069
- p10.log.info("");
3070
- p10.log.info("Frontend usage:");
3071
- for (const entity of meta.entities) {
3072
- const className = toPascal2(entity.name);
3073
- p10.log.info(
3074
- ` import type { ${className} } from '../types/${toKebab(toSnake(entity.name))}';`
3075
- );
3076
- }
3077
- }
3078
- p10.outro("Types are up to date.");
3079
- }
3080
- function detectMetaUrl(cwd) {
3081
- const envFiles = [".env", ".env.dev", ".env.local"];
3082
- for (const envFile of envFiles) {
3083
- const envPath = join10(cwd, envFile);
3084
- if (existsSync10(envPath)) {
3085
- try {
3086
- const content = readFileSync(envPath, "utf-8");
3087
- const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
3088
- if (match) {
3089
- const base = match[1].trim().replace(/["']/g, "");
3090
- return `${base}/api/v1/_meta`;
3091
- }
3092
- } catch {
3093
- }
4088
+ // src/index.ts
4089
+ var args = process.argv.slice(2);
4090
+ function matchFeatureFlag(arg, argv, i) {
4091
+ for (const feat of KNOWN_FEATURES) {
4092
+ const eq = `--${feat}=`;
4093
+ if (arg.startsWith(eq)) {
4094
+ return {
4095
+ feature: feat,
4096
+ value: arg.slice(eq.length),
4097
+ consumedNext: false
4098
+ };
3094
4099
  }
3095
- }
3096
- const frontendEnvFiles = [
3097
- "frontend/.env",
3098
- "frontend/.env.local",
3099
- "frontend/.env.dev"
3100
- ];
3101
- for (const envFile of frontendEnvFiles) {
3102
- const envPath = join10(cwd, envFile);
3103
- if (existsSync10(envPath)) {
3104
- try {
3105
- const content = readFileSync(envPath, "utf-8");
3106
- const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
3107
- if (match) {
3108
- const base = match[1].trim().replace(/["']/g, "");
3109
- return `${base}/api/v1/_meta`;
3110
- }
3111
- } catch {
4100
+ if (arg === `--${feat}`) {
4101
+ const next = argv[i + 1];
4102
+ if (!next || next.startsWith("-")) {
4103
+ throw new Error(
4104
+ `Flag --${feat} requires a value. Use --${feat}=<targets> or --${feat} <targets>.`
4105
+ );
3112
4106
  }
4107
+ return { feature: feat, value: next, consumedNext: true };
3113
4108
  }
3114
4109
  }
3115
- return "http://localhost:8000/api/v1/_meta";
4110
+ return null;
3116
4111
  }
3117
-
3118
- // src/index.ts
3119
- var args = process.argv.slice(2);
3120
4112
  function parseArgs() {
3121
4113
  let command = "create";
3122
4114
  let name;
@@ -3158,10 +4150,6 @@ function parseArgs() {
3158
4150
  command = "gen";
3159
4151
  continue;
3160
4152
  }
3161
- if (arg === "sync" && !name) {
3162
- command = "sync";
3163
- continue;
3164
- }
3165
4153
  if (arg === "--components") {
3166
4154
  const val = args[++i];
3167
4155
  if (val) {
@@ -3169,6 +4157,16 @@ function parseArgs() {
3169
4157
  }
3170
4158
  continue;
3171
4159
  }
4160
+ if (arg === "--orm") {
4161
+ const val = args[++i];
4162
+ if (!val || !ORM_PROVIDERS.includes(val)) {
4163
+ throw new Error(
4164
+ `Invalid --orm. Use one of: ${ORM_PROVIDERS.join(", ")}`
4165
+ );
4166
+ }
4167
+ options.orm = val;
4168
+ continue;
4169
+ }
3172
4170
  if (arg === "--local") {
3173
4171
  localRepo = resolve(args[++i] || ".");
3174
4172
  continue;
@@ -3201,11 +4199,6 @@ function parseArgs() {
3201
4199
  flags.backend = true;
3202
4200
  continue;
3203
4201
  }
3204
- if (arg === "--url") {
3205
- const val = args[++i];
3206
- if (val) extraArgs.push(`--url=${val}`);
3207
- continue;
3208
- }
3209
4202
  if (arg === "--help" || arg === "-h") {
3210
4203
  printHelp();
3211
4204
  process.exit(0);
@@ -3220,6 +4213,16 @@ function parseArgs() {
3220
4213
  if (val) extraArgs.push(`--name=${val}`);
3221
4214
  continue;
3222
4215
  }
4216
+ {
4217
+ const featureMatch = matchFeatureFlag(arg, args, i);
4218
+ if (featureMatch) {
4219
+ const { feature, value, consumedNext } = featureMatch;
4220
+ parseFeatureFlag(value);
4221
+ options.features = { ...options.features ?? {}, [feature]: value };
4222
+ if (consumedNext) i++;
4223
+ continue;
4224
+ }
4225
+ }
3223
4226
  if (!arg.startsWith("-")) {
3224
4227
  if (command === "add" || command === "pin" || command === "unpin" || command === "gen") {
3225
4228
  extraArgs.push(arg);
@@ -3244,10 +4247,11 @@ function printHelp() {
3244
4247
  projx pin --list Show all skip patterns
3245
4248
  projx doctor [--fix] Health check for projx project
3246
4249
  projx gen entity <name> Generate a new entity
3247
- projx sync [--url <url>] Sync types from running backend
3248
4250
 
3249
4251
  Options:
3250
- --components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
4252
+ --components <list> Comma-separated: fastapi,fastify,express,frontend,mobile,e2e,infra
4253
+ --orm <provider> Node backend ORM: prisma (default), drizzle, sequelize, typeorm
4254
+ --auth <targets> Add auth feature. Targets: <component>[:<instance>] (comma-separated)
3251
4255
  --no-git Skip git init
3252
4256
  --no-install Skip dependency installation
3253
4257
  -y, --yes Accept defaults (fastify + frontend + e2e)
@@ -3257,6 +4261,8 @@ function printHelp() {
3257
4261
  Examples:
3258
4262
  npx create-projx my-app
3259
4263
  npx create-projx my-app --components fastapi,frontend,e2e
4264
+ npx create-projx my-app --components express,frontend,e2e --orm drizzle
4265
+ npx create-projx my-app --components fastify,frontend,mobile --auth fastify
3260
4266
  npx create-projx my-app -y
3261
4267
  npx create-projx add frontend mobile
3262
4268
  npx create-projx add fastify --name email-ingestor
@@ -3330,12 +4336,6 @@ async function main() {
3330
4336
  await doctor(process.cwd(), flags.fix);
3331
4337
  return;
3332
4338
  }
3333
- if (command === "sync") {
3334
- const urlArg = extraArgs.find((a) => a.startsWith("--url="));
3335
- const url = urlArg ? urlArg.split("=").slice(1).join("=") : void 0;
3336
- await sync(process.cwd(), url);
3337
- return;
3338
- }
3339
4339
  if (command === "gen") {
3340
4340
  const subcommand = extraArgs[0];
3341
4341
  if (subcommand !== "entity" || !extraArgs[1]) {
@@ -3348,7 +4348,7 @@ async function main() {
3348
4348
  const fieldsArg = extraArgs.find((a) => a.startsWith("--fields="));
3349
4349
  const fieldsFlag = fieldsArg ? fieldsArg.split("=").slice(1).join("=") : void 0;
3350
4350
  const backendFlag = flags.ai ? "fastapi" : flags.backend ? "fastify" : void 0;
3351
- await gen(process.cwd(), entityName, fieldsFlag, backendFlag);
4351
+ await gen(process.cwd(), entityName, fieldsFlag, backendFlag, localRepo);
3352
4352
  return;
3353
4353
  }
3354
4354
  let opts;
@@ -3361,12 +4361,16 @@ async function main() {
3361
4361
  name,
3362
4362
  components: options.components,
3363
4363
  git: options.git ?? true,
3364
- install: options.install ?? true
4364
+ install: options.install ?? true,
4365
+ orm: options.orm ?? "prisma",
4366
+ features: options.features
3365
4367
  };
3366
4368
  } else {
3367
4369
  opts = await runPrompts(name);
3368
4370
  opts.git = options.git ?? opts.git;
3369
4371
  opts.install = options.install ?? opts.install;
4372
+ opts.orm = options.orm ?? opts.orm ?? "prisma";
4373
+ opts.features = options.features ?? opts.features;
3370
4374
  }
3371
4375
  const dest = resolve(process.cwd(), opts.name);
3372
4376
  if (existsSync11(dest)) {