create-projx 1.3.6 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +40 -0
  2. package/dist/index.js +1750 -91
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { existsSync as existsSync8 } from "fs";
4
+ import { existsSync as existsSync13 } from "fs";
5
5
  import { resolve as resolve2 } from "path";
6
6
 
7
7
  // src/utils.ts
8
8
  import { execSync } from "child_process";
9
- import { existsSync } from "fs";
9
+ import { existsSync, readFileSync } from "fs";
10
10
  import { cp, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
11
11
  import { join, resolve } from "path";
12
12
  import { tmpdir } from "os";
@@ -27,6 +27,9 @@ function toKebab(s) {
27
27
  function toSnake(s) {
28
28
  return toKebab(s).replace(/-/g, "_");
29
29
  }
30
+ function toTitle(s) {
31
+ return s.split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
32
+ }
30
33
  function hasCommand(cmd) {
31
34
  try {
32
35
  execSync(`command -v ${cmd}`, { stdio: "ignore" });
@@ -277,8 +280,8 @@ function render(template, vars) {
277
280
  (_, expr) => {
278
281
  const parts = expr.split(".");
279
282
  let val = vars;
280
- for (const p6 of parts) {
281
- val = val?.[p6];
283
+ for (const p11 of parts) {
284
+ val = val?.[p11];
282
285
  }
283
286
  return String(val ?? "");
284
287
  }
@@ -287,6 +290,23 @@ function render(template, vars) {
287
290
  }
288
291
  return output.join("\n").replace(/\n{3,}/g, "\n\n");
289
292
  }
293
+ function detectProjectName(cwd, components, componentPaths) {
294
+ for (const component of components) {
295
+ const dir = componentPaths[component] ?? component;
296
+ const pkgPath = join(cwd, dir, "package.json");
297
+ if (existsSync(pkgPath)) {
298
+ try {
299
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
300
+ const n = pkg.name;
301
+ if (n && n.includes("-")) {
302
+ return n.substring(0, n.lastIndexOf("-"));
303
+ }
304
+ } catch {
305
+ }
306
+ }
307
+ }
308
+ return toKebab(cwd.split("/").pop());
309
+ }
290
310
 
291
311
  // src/prompts.ts
292
312
  import * as p from "@clack/prompts";
@@ -475,10 +495,10 @@ function mergeFileThreeWay(oursPath, baseContent, theirsContent) {
475
495
  }
476
496
  }
477
497
  async function collectAllFiles(dir, base) {
478
- const { readdir: readdir3 } = await import("fs/promises");
498
+ const { readdir: readdir4 } = await import("fs/promises");
479
499
  const results = [];
480
500
  const walk = async (current) => {
481
- const entries = await readdir3(current, { withFileTypes: true });
501
+ const entries = await readdir4(current, { withFileTypes: true });
482
502
  for (const entry of entries) {
483
503
  const full = join3(current, entry.name);
484
504
  if (entry.isDirectory()) {
@@ -549,9 +569,9 @@ function cleanupWorktree(cwd, worktree, branch) {
549
569
  }
550
570
  async function removeSkippedFiles(dir, skipPatterns) {
551
571
  if (skipPatterns.length === 0) return;
552
- const { readdir: readdir3, unlink: unlink2 } = await import("fs/promises");
572
+ const { readdir: readdir4, unlink: unlink2 } = await import("fs/promises");
553
573
  const walk = async (current, base) => {
554
- const entries = await readdir3(current, { withFileTypes: true });
574
+ const entries = await readdir4(current, { withFileTypes: true });
555
575
  for (const entry of entries) {
556
576
  const full = join3(current, entry.name);
557
577
  const rel = full.slice(base.length + 1);
@@ -657,8 +677,8 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
657
677
  try {
658
678
  await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
659
679
  execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
660
- const diff = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
661
- if (!diff) {
680
+ const diff2 = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
681
+ if (!diff2) {
662
682
  cleanupWorktree(cwd, worktree, branch);
663
683
  return { status: "clean" };
664
684
  }
@@ -773,10 +793,10 @@ async function scaffold(opts, dest, localRepo) {
773
793
  exec("git init", dest);
774
794
  exec("git config core.hooksPath .githooks", dest);
775
795
  }
776
- const spinner5 = p2.spinner();
777
- spinner5.start("Scaffolding project");
796
+ const spinner7 = p2.spinner();
797
+ spinner7.start("Scaffolding project");
778
798
  await applyTemplate(dest, repoDir, opts.components, paths, vars, version);
779
- spinner5.stop("Scaffold complete.");
799
+ spinner7.stop("Scaffold complete.");
780
800
  if (opts.install) {
781
801
  await installDeps(dest, opts.components);
782
802
  }
@@ -801,44 +821,44 @@ async function scaffold(opts, dest, localRepo) {
801
821
  }
802
822
  async function installDeps(dest, components) {
803
823
  for (const component of components) {
804
- const spinner5 = p2.spinner();
824
+ const spinner7 = p2.spinner();
805
825
  try {
806
826
  switch (component) {
807
827
  case "fastapi":
808
828
  if (hasCommand("uv")) {
809
- spinner5.start("Installing FastAPI dependencies (uv sync)");
829
+ spinner7.start("Installing FastAPI dependencies (uv sync)");
810
830
  exec("uv sync --all-extras", join4(dest, "fastapi"));
811
- spinner5.stop("FastAPI dependencies installed.");
831
+ spinner7.stop("FastAPI dependencies installed.");
812
832
  } else {
813
833
  p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
814
834
  }
815
835
  break;
816
836
  case "fastify":
817
837
  if (hasCommand("pnpm")) {
818
- spinner5.start("Installing Fastify dependencies (pnpm install)");
838
+ spinner7.start("Installing Fastify dependencies (pnpm install)");
819
839
  exec("pnpm install", join4(dest, "fastify"));
820
- spinner5.stop("Fastify dependencies installed.");
840
+ spinner7.stop("Fastify dependencies installed.");
821
841
  } else {
822
- spinner5.start("Installing Fastify dependencies (npm install)");
842
+ spinner7.start("Installing Fastify dependencies (npm install)");
823
843
  exec("npm install", join4(dest, "fastify"));
824
- spinner5.stop("Fastify dependencies installed.");
844
+ spinner7.stop("Fastify dependencies installed.");
825
845
  }
826
846
  break;
827
847
  case "frontend":
828
- spinner5.start("Installing Frontend dependencies (npm install)");
848
+ spinner7.start("Installing Frontend dependencies (npm install)");
829
849
  exec("npm install", join4(dest, "frontend"));
830
- spinner5.stop("Frontend dependencies installed.");
850
+ spinner7.stop("Frontend dependencies installed.");
831
851
  break;
832
852
  case "e2e":
833
- spinner5.start("Installing E2E dependencies (npm install)");
853
+ spinner7.start("Installing E2E dependencies (npm install)");
834
854
  exec("npm install", join4(dest, "e2e"));
835
- spinner5.stop("E2E dependencies installed.");
855
+ spinner7.stop("E2E dependencies installed.");
836
856
  break;
837
857
  case "mobile":
838
858
  if (hasCommand("flutter")) {
839
- spinner5.start("Installing Flutter dependencies");
859
+ spinner7.start("Installing Flutter dependencies");
840
860
  exec("flutter pub get", join4(dest, "mobile"));
841
- spinner5.stop("Flutter dependencies installed.");
861
+ spinner7.stop("Flutter dependencies installed.");
842
862
  } else {
843
863
  p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
844
864
  }
@@ -847,7 +867,7 @@ async function installDeps(dest, components) {
847
867
  break;
848
868
  }
849
869
  } catch {
850
- spinner5.stop(`Failed to install ${component} dependencies.`);
870
+ spinner7.stop(`Failed to install ${component} dependencies.`);
851
871
  }
852
872
  }
853
873
  }
@@ -865,7 +885,7 @@ function copyEnvExamples(dest, components) {
865
885
  }
866
886
 
867
887
  // src/update.ts
868
- import { existsSync as existsSync4, readFileSync } from "fs";
888
+ import { existsSync as existsSync4 } from "fs";
869
889
  import { readFile as readFile5, writeFile as writeFile3, unlink } from "fs/promises";
870
890
  import { execSync as execSync3 } from "child_process";
871
891
  import { join as join5 } from "path";
@@ -926,11 +946,11 @@ async function update(cwd, localRepo) {
926
946
  const version = pkg.version;
927
947
  const name = detectProjectName(cwd, config.components, componentPaths);
928
948
  const vars = { projectName: name, components: config.components, paths: componentPaths };
929
- const spinner5 = p3.spinner();
930
- spinner5.start("Applying template update");
949
+ const spinner7 = p3.spinner();
950
+ spinner7.start("Applying template update");
931
951
  const rootSkip = config.skip ?? [];
932
952
  const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
933
- spinner5.stop("Template applied.");
953
+ spinner7.stop("Template applied.");
934
954
  if (result.status === "merged") {
935
955
  saveBaselineRef(cwd);
936
956
  p3.log.success(`${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`);
@@ -1077,26 +1097,9 @@ async function learnSkips(cwd, files, componentPaths) {
1077
1097
  }
1078
1098
  }
1079
1099
  }
1080
- function detectProjectName(cwd, components, componentPaths) {
1081
- for (const component of components) {
1082
- const dir = componentPaths[component] ?? component;
1083
- const pkgPath = join5(cwd, dir, "package.json");
1084
- if (existsSync4(pkgPath)) {
1085
- try {
1086
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1087
- const n = pkg.name;
1088
- if (n && n.includes("-")) {
1089
- return n.substring(0, n.lastIndexOf("-"));
1090
- }
1091
- } catch {
1092
- }
1093
- }
1094
- }
1095
- return toKebab(cwd.split("/").pop());
1096
- }
1097
1100
 
1098
1101
  // src/add.ts
1099
- import { copyFileSync as copyFileSync2, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
1102
+ import { copyFileSync as copyFileSync2, existsSync as existsSync5 } from "fs";
1100
1103
  import { readFile as readFile6 } from "fs/promises";
1101
1104
  import { join as join6 } from "path";
1102
1105
  import * as p4 from "@clack/prompts";
@@ -1133,14 +1136,14 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
1133
1136
  const existingPaths = await discoverComponentPaths(cwd, existing);
1134
1137
  const paths = { ...existingPaths };
1135
1138
  for (const c of toAdd) paths[c] = c;
1136
- const name = detectProjectName2(cwd, existing, paths);
1139
+ const name = detectProjectName(cwd, existing, paths);
1137
1140
  const vars = { projectName: name, components: allComponents, paths };
1138
1141
  const pkg = JSON.parse(await readFile6(join6(repoDir, "cli/package.json"), "utf-8"));
1139
1142
  const version = pkg.version;
1140
- const spinner5 = p4.spinner();
1141
- spinner5.start("Adding components");
1143
+ const spinner7 = p4.spinner();
1144
+ spinner7.start("Adding components");
1142
1145
  await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, "scaffold");
1143
- spinner5.stop("Components added.");
1146
+ spinner7.stop("Components added.");
1144
1147
  if (!skipInstall) {
1145
1148
  await installDeps2(cwd, toAdd);
1146
1149
  }
@@ -1163,44 +1166,44 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
1163
1166
  }
1164
1167
  async function installDeps2(dest, components) {
1165
1168
  for (const component of components) {
1166
- const spinner5 = p4.spinner();
1169
+ const spinner7 = p4.spinner();
1167
1170
  try {
1168
1171
  switch (component) {
1169
1172
  case "fastapi":
1170
1173
  if (hasCommand("uv")) {
1171
- spinner5.start("Installing FastAPI dependencies");
1174
+ spinner7.start("Installing FastAPI dependencies");
1172
1175
  exec("uv sync --all-extras", join6(dest, "fastapi"));
1173
- spinner5.stop("FastAPI dependencies installed.");
1176
+ spinner7.stop("FastAPI dependencies installed.");
1174
1177
  } else {
1175
1178
  p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
1176
1179
  }
1177
1180
  break;
1178
1181
  case "fastify":
1179
1182
  if (hasCommand("pnpm")) {
1180
- spinner5.start("Installing Fastify dependencies");
1183
+ spinner7.start("Installing Fastify dependencies");
1181
1184
  exec("pnpm install", join6(dest, "fastify"));
1182
- spinner5.stop("Fastify dependencies installed.");
1185
+ spinner7.stop("Fastify dependencies installed.");
1183
1186
  } else {
1184
- spinner5.start("Installing Fastify dependencies");
1187
+ spinner7.start("Installing Fastify dependencies");
1185
1188
  exec("npm install", join6(dest, "fastify"));
1186
- spinner5.stop("Fastify dependencies installed.");
1189
+ spinner7.stop("Fastify dependencies installed.");
1187
1190
  }
1188
1191
  break;
1189
1192
  case "frontend":
1190
- spinner5.start("Installing Frontend dependencies");
1193
+ spinner7.start("Installing Frontend dependencies");
1191
1194
  exec("npm install", join6(dest, "frontend"));
1192
- spinner5.stop("Frontend dependencies installed.");
1195
+ spinner7.stop("Frontend dependencies installed.");
1193
1196
  break;
1194
1197
  case "e2e":
1195
- spinner5.start("Installing E2E dependencies");
1198
+ spinner7.start("Installing E2E dependencies");
1196
1199
  exec("npm install", join6(dest, "e2e"));
1197
- spinner5.stop("E2E dependencies installed.");
1200
+ spinner7.stop("E2E dependencies installed.");
1198
1201
  break;
1199
1202
  case "mobile":
1200
1203
  if (hasCommand("flutter")) {
1201
- spinner5.start("Installing Flutter dependencies");
1204
+ spinner7.start("Installing Flutter dependencies");
1202
1205
  exec("flutter pub get", join6(dest, "mobile"));
1203
- spinner5.stop("Flutter dependencies installed.");
1206
+ spinner7.stop("Flutter dependencies installed.");
1204
1207
  } else {
1205
1208
  p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
1206
1209
  }
@@ -1209,27 +1212,10 @@ async function installDeps2(dest, components) {
1209
1212
  break;
1210
1213
  }
1211
1214
  } catch {
1212
- spinner5.stop(`Failed to install ${component} dependencies.`);
1215
+ spinner7.stop(`Failed to install ${component} dependencies.`);
1213
1216
  }
1214
1217
  }
1215
1218
  }
1216
- function detectProjectName2(cwd, components, paths) {
1217
- for (const component of components) {
1218
- const dir = paths[component] ?? component;
1219
- const pkgPath = join6(cwd, dir, "package.json");
1220
- if (existsSync5(pkgPath)) {
1221
- try {
1222
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
1223
- const n = pkg.name;
1224
- if (n && n.includes("-")) {
1225
- return n.substring(0, n.lastIndexOf("-"));
1226
- }
1227
- } catch {
1228
- }
1229
- }
1230
- }
1231
- return toKebab(cwd.split("/").pop());
1232
- }
1233
1219
 
1234
1220
  // src/init.ts
1235
1221
  import { existsSync as existsSync7 } from "fs";
@@ -1338,10 +1324,10 @@ async function init(cwd, localRepo) {
1338
1324
  p5.log.error("You have uncommitted changes. Commit or stash them first.");
1339
1325
  process.exit(1);
1340
1326
  }
1341
- const spinner5 = p5.spinner();
1342
- spinner5.start("Scanning for components");
1327
+ const spinner7 = p5.spinner();
1328
+ spinner7.start("Scanning for components");
1343
1329
  const detected = await detectComponents(cwd);
1344
- spinner5.stop(
1330
+ spinner7.stop(
1345
1331
  detected.length > 0 ? `Found ${detected.length} component(s).` : "No components detected."
1346
1332
  );
1347
1333
  let confirmed;
@@ -1461,6 +1447,1582 @@ function hasUncommittedChanges2(cwd) {
1461
1447
  }
1462
1448
  }
1463
1449
 
1450
+ // src/pin.ts
1451
+ import { existsSync as existsSync8 } from "fs";
1452
+ import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
1453
+ import { join as join9 } from "path";
1454
+ import * as p6 from "@clack/prompts";
1455
+ function classifyPattern(pattern, componentPaths) {
1456
+ const dirToComponent = {};
1457
+ for (const [component, dir] of Object.entries(componentPaths)) {
1458
+ dirToComponent[dir] = component;
1459
+ }
1460
+ for (const [dir, component] of Object.entries(dirToComponent)) {
1461
+ if (pattern.startsWith(dir + "/")) {
1462
+ return {
1463
+ scope: "component",
1464
+ component,
1465
+ relative: pattern.slice(dir.length + 1)
1466
+ };
1467
+ }
1468
+ }
1469
+ return { scope: "root", relative: pattern };
1470
+ }
1471
+ async function pin(cwd, patterns) {
1472
+ p6.intro("projx pin");
1473
+ const configPath = join9(cwd, ".projx");
1474
+ if (!existsSync8(configPath)) {
1475
+ p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1476
+ process.exit(1);
1477
+ }
1478
+ const config = JSON.parse(await readFile8(configPath, "utf-8"));
1479
+ const componentPaths = await discoverComponentPaths(cwd, config.components);
1480
+ const rootAdds = [];
1481
+ const componentAdds = {};
1482
+ for (const pattern of patterns) {
1483
+ if (pattern === ".projx" || pattern.endsWith(COMPONENT_MARKER)) {
1484
+ p6.log.warn(`Cannot pin ${pattern} \u2014 config files are managed by projx.`);
1485
+ continue;
1486
+ }
1487
+ const { scope, component, relative } = classifyPattern(pattern, componentPaths);
1488
+ if (scope === "component" && component) {
1489
+ if (!componentAdds[component]) componentAdds[component] = [];
1490
+ componentAdds[component].push(relative);
1491
+ } else {
1492
+ rootAdds.push(relative);
1493
+ }
1494
+ }
1495
+ for (const [component, additions] of Object.entries(componentAdds)) {
1496
+ const dir = componentPaths[component];
1497
+ const markerPath = join9(cwd, dir, COMPONENT_MARKER);
1498
+ try {
1499
+ const data = JSON.parse(await readFile8(markerPath, "utf-8"));
1500
+ const existing = data.skip ?? [];
1501
+ const merged = [.../* @__PURE__ */ new Set([...existing, ...additions])];
1502
+ const added = merged.length - existing.length;
1503
+ if (added > 0) {
1504
+ data.skip = merged;
1505
+ await writeFile4(markerPath, JSON.stringify(data, null, 2) + "\n");
1506
+ p6.log.success(`${component}: pinned ${additions.join(", ")}`);
1507
+ } else {
1508
+ p6.log.info(`${component}: already pinned.`);
1509
+ }
1510
+ } catch {
1511
+ p6.log.error(`Could not read marker for ${component}.`);
1512
+ }
1513
+ }
1514
+ if (rootAdds.length > 0) {
1515
+ const existing = config.skip ?? [];
1516
+ const merged = [.../* @__PURE__ */ new Set([...existing, ...rootAdds])];
1517
+ const added = merged.length - existing.length;
1518
+ if (added > 0) {
1519
+ config.skip = merged;
1520
+ await writeFile4(configPath, JSON.stringify(config, null, 2) + "\n");
1521
+ p6.log.success(`root: pinned ${rootAdds.join(", ")}`);
1522
+ } else {
1523
+ p6.log.info("root: already pinned.");
1524
+ }
1525
+ }
1526
+ p6.outro("Skip list updated.");
1527
+ }
1528
+ async function unpin(cwd, patterns) {
1529
+ p6.intro("projx unpin");
1530
+ const configPath = join9(cwd, ".projx");
1531
+ if (!existsSync8(configPath)) {
1532
+ p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1533
+ process.exit(1);
1534
+ }
1535
+ const config = JSON.parse(await readFile8(configPath, "utf-8"));
1536
+ const componentPaths = await discoverComponentPaths(cwd, config.components);
1537
+ const rootRemoves = [];
1538
+ const componentRemoves = {};
1539
+ for (const pattern of patterns) {
1540
+ const { scope, component, relative } = classifyPattern(pattern, componentPaths);
1541
+ if (scope === "component" && component) {
1542
+ if (!componentRemoves[component]) componentRemoves[component] = [];
1543
+ componentRemoves[component].push(relative);
1544
+ } else {
1545
+ rootRemoves.push(relative);
1546
+ }
1547
+ }
1548
+ for (const [component, removals] of Object.entries(componentRemoves)) {
1549
+ const dir = componentPaths[component];
1550
+ const markerPath = join9(cwd, dir, COMPONENT_MARKER);
1551
+ try {
1552
+ const data = JSON.parse(await readFile8(markerPath, "utf-8"));
1553
+ const existing = data.skip ?? [];
1554
+ const filtered = existing.filter((s) => !removals.includes(s));
1555
+ const removed = existing.length - filtered.length;
1556
+ if (removed > 0) {
1557
+ if (filtered.length > 0) {
1558
+ data.skip = filtered;
1559
+ } else {
1560
+ delete data.skip;
1561
+ }
1562
+ await writeFile4(markerPath, JSON.stringify(data, null, 2) + "\n");
1563
+ p6.log.success(`${component}: unpinned ${removals.join(", ")}`);
1564
+ } else {
1565
+ p6.log.info(`${component}: not found in skip list.`);
1566
+ }
1567
+ } catch {
1568
+ p6.log.error(`Could not read marker for ${component}.`);
1569
+ }
1570
+ }
1571
+ if (rootRemoves.length > 0) {
1572
+ const existing = config.skip ?? [];
1573
+ const filtered = existing.filter((s) => !rootRemoves.includes(s));
1574
+ const removed = existing.length - filtered.length;
1575
+ if (removed > 0) {
1576
+ if (filtered.length > 0) {
1577
+ config.skip = filtered;
1578
+ } else {
1579
+ delete config.skip;
1580
+ }
1581
+ await writeFile4(configPath, JSON.stringify(config, null, 2) + "\n");
1582
+ p6.log.success(`root: unpinned ${rootRemoves.join(", ")}`);
1583
+ } else {
1584
+ p6.log.info("root: not found in skip list.");
1585
+ }
1586
+ }
1587
+ p6.outro("Skip list updated.");
1588
+ }
1589
+ async function listPins(cwd) {
1590
+ p6.intro("projx pin --list");
1591
+ const configPath = join9(cwd, ".projx");
1592
+ if (!existsSync8(configPath)) {
1593
+ p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
1594
+ process.exit(1);
1595
+ }
1596
+ const config = JSON.parse(await readFile8(configPath, "utf-8"));
1597
+ const componentPaths = await discoverComponentPaths(cwd, config.components);
1598
+ let hasAny = false;
1599
+ if (config.skip && config.skip.length > 0) {
1600
+ hasAny = true;
1601
+ p6.log.info("root:");
1602
+ for (const s of config.skip) {
1603
+ p6.log.info(` ${s}`);
1604
+ }
1605
+ }
1606
+ for (const component of config.components) {
1607
+ const dir = componentPaths[component];
1608
+ const marker = await readComponentMarker(join9(cwd, dir));
1609
+ if (marker?.skip && marker.skip.length > 0) {
1610
+ hasAny = true;
1611
+ const label = dir !== component ? `${component} (${dir}/)` : `${component}`;
1612
+ p6.log.info(`${label}:`);
1613
+ for (const s of marker.skip) {
1614
+ p6.log.info(` ${s}`);
1615
+ }
1616
+ }
1617
+ }
1618
+ if (!hasAny) {
1619
+ p6.log.info("No pinned files. All template files will be updated.");
1620
+ }
1621
+ p6.outro("");
1622
+ }
1623
+
1624
+ // src/doctor.ts
1625
+ import { existsSync as existsSync9 } from "fs";
1626
+ import { readFile as readFile9, readdir as readdir3 } from "fs/promises";
1627
+ import { execSync as execSync5 } from "child_process";
1628
+ import { join as join10 } from "path";
1629
+ import * as p7 from "@clack/prompts";
1630
+ async function checkConfig(cwd) {
1631
+ const results = [];
1632
+ const configPath = join10(cwd, ".projx");
1633
+ if (!existsSync9(configPath)) {
1634
+ results.push({
1635
+ name: ".projx exists",
1636
+ status: "fail",
1637
+ message: "No .projx file found.",
1638
+ fix: "Run 'npx create-projx init' to initialize."
1639
+ });
1640
+ return { results };
1641
+ }
1642
+ let config;
1643
+ try {
1644
+ config = JSON.parse(await readFile9(configPath, "utf-8"));
1645
+ } catch {
1646
+ results.push({
1647
+ name: ".projx valid JSON",
1648
+ status: "fail",
1649
+ message: ".projx contains invalid JSON."
1650
+ });
1651
+ return { results };
1652
+ }
1653
+ results.push({ name: ".projx exists", status: "pass", message: `v${config.version}` });
1654
+ if (!config.version || !config.components || !Array.isArray(config.components)) {
1655
+ results.push({
1656
+ name: ".projx fields",
1657
+ status: "fail",
1658
+ message: "Missing required fields (version, components)."
1659
+ });
1660
+ return { results };
1661
+ }
1662
+ const invalid = config.components.filter((c) => !COMPONENTS.includes(c));
1663
+ if (invalid.length > 0) {
1664
+ results.push({
1665
+ name: "component names",
1666
+ status: "warn",
1667
+ message: `Unknown components: ${invalid.join(", ")}`
1668
+ });
1669
+ } else {
1670
+ results.push({ name: "component names", status: "pass", message: `${config.components.length} valid` });
1671
+ }
1672
+ return { results, config };
1673
+ }
1674
+ async function checkComponents(cwd, config, componentPaths) {
1675
+ const results = [];
1676
+ for (const component of config.components) {
1677
+ const dir = componentPaths[component];
1678
+ const fullDir = join10(cwd, dir);
1679
+ if (!existsSync9(fullDir)) {
1680
+ results.push({
1681
+ name: `${component} directory`,
1682
+ status: "fail",
1683
+ message: `Directory ${dir}/ not found.`
1684
+ });
1685
+ continue;
1686
+ }
1687
+ const marker = await readComponentMarker(fullDir);
1688
+ if (!marker) {
1689
+ results.push({
1690
+ name: `${component} marker`,
1691
+ status: "fail",
1692
+ message: `No ${COMPONENT_MARKER} in ${dir}/.`,
1693
+ fix: `Run 'npx create-projx update' to regenerate markers.`
1694
+ });
1695
+ continue;
1696
+ }
1697
+ if (!marker.components.includes(component)) {
1698
+ results.push({
1699
+ name: `${component} marker`,
1700
+ status: "warn",
1701
+ message: `Marker in ${dir}/ does not list "${component}".`
1702
+ });
1703
+ } else {
1704
+ const label = dir !== component ? `${dir}/ (${component})` : `${component}/`;
1705
+ results.push({ name: `${component} marker`, status: "pass", message: label });
1706
+ }
1707
+ }
1708
+ try {
1709
+ const entries = await readdir3(cwd, { withFileTypes: true });
1710
+ for (const entry of entries) {
1711
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
1712
+ const markerPath = join10(cwd, entry.name, COMPONENT_MARKER);
1713
+ if (!existsSync9(markerPath)) continue;
1714
+ const isKnown = Object.values(componentPaths).includes(entry.name);
1715
+ if (!isKnown) {
1716
+ results.push({
1717
+ name: `orphan marker`,
1718
+ status: "warn",
1719
+ message: `${entry.name}/ has a ${COMPONENT_MARKER} but is not in .projx components.`
1720
+ });
1721
+ }
1722
+ }
1723
+ } catch {
1724
+ }
1725
+ return results;
1726
+ }
1727
+ function checkGit(cwd, fix) {
1728
+ const results = [];
1729
+ try {
1730
+ execSync5("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1731
+ results.push({ name: "git repo", status: "pass", message: "OK" });
1732
+ } catch {
1733
+ results.push({ name: "git repo", status: "fail", message: "Not a git repository." });
1734
+ return results;
1735
+ }
1736
+ try {
1737
+ const ref = execSync5(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
1738
+ results.push({ name: "baseline ref", status: "pass", message: ref.slice(0, 8) });
1739
+ } catch {
1740
+ if (fix) {
1741
+ saveBaselineRef(cwd);
1742
+ try {
1743
+ execSync5(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" });
1744
+ results.push({ name: "baseline ref", status: "pass", message: "Created from git history." });
1745
+ } catch {
1746
+ results.push({
1747
+ name: "baseline ref",
1748
+ status: "warn",
1749
+ message: "Missing. Could not auto-create.",
1750
+ fix: "Run 'npx create-projx update' to establish baseline."
1751
+ });
1752
+ }
1753
+ } else {
1754
+ results.push({
1755
+ name: "baseline ref",
1756
+ status: "warn",
1757
+ message: "Missing. Run 'projx doctor --fix' to create.",
1758
+ autoFixable: true
1759
+ });
1760
+ }
1761
+ }
1762
+ try {
1763
+ const worktrees = execSync5("git worktree list --porcelain", { cwd, stdio: "pipe" }).toString();
1764
+ const stale = worktrees.split("\n").filter((l) => l.includes("projx-wt-") || l.includes("projx/tmp-"));
1765
+ if (stale.length > 0) {
1766
+ if (fix) {
1767
+ execSync5("git worktree prune", { cwd, stdio: "pipe" });
1768
+ results.push({ name: "worktrees", status: "pass", message: "Pruned stale worktrees." });
1769
+ } else {
1770
+ results.push({
1771
+ name: "worktrees",
1772
+ status: "warn",
1773
+ message: "Stale projx worktrees found.",
1774
+ fix: "Run 'projx doctor --fix' to prune.",
1775
+ autoFixable: true
1776
+ });
1777
+ }
1778
+ } else {
1779
+ results.push({ name: "worktrees", status: "pass", message: "Clean" });
1780
+ }
1781
+ } catch {
1782
+ results.push({ name: "worktrees", status: "pass", message: "OK" });
1783
+ }
1784
+ try {
1785
+ const status = execSync5("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1786
+ if (status) {
1787
+ const count = status.split("\n").length;
1788
+ results.push({ name: "working tree", status: "warn", message: `${count} uncommitted change(s).` });
1789
+ } else {
1790
+ results.push({ name: "working tree", status: "pass", message: "Clean" });
1791
+ }
1792
+ } catch {
1793
+ }
1794
+ return results;
1795
+ }
1796
+ async function checkSkipPatterns(cwd, config, componentPaths) {
1797
+ const results = [];
1798
+ if (config.skip && config.skip.length > 0) {
1799
+ for (const pattern of config.skip) {
1800
+ const matches = await patternMatchesAnything(cwd, pattern);
1801
+ if (!matches) {
1802
+ results.push({
1803
+ name: "root skip",
1804
+ status: "warn",
1805
+ message: `"${pattern}" matches no files \u2014 stale?`
1806
+ });
1807
+ }
1808
+ }
1809
+ }
1810
+ for (const component of config.components) {
1811
+ const dir = componentPaths[component];
1812
+ const marker = await readComponentMarker(join10(cwd, dir));
1813
+ if (marker?.skip && marker.skip.length > 0) {
1814
+ for (const pattern of marker.skip) {
1815
+ const matches = await patternMatchesAnything(join10(cwd, dir), pattern);
1816
+ if (!matches) {
1817
+ results.push({
1818
+ name: `${component} skip`,
1819
+ status: "warn",
1820
+ message: `"${pattern}" matches no files \u2014 stale?`
1821
+ });
1822
+ }
1823
+ }
1824
+ }
1825
+ }
1826
+ if (results.length === 0 && (config.skip?.length || config.components.some(() => true))) {
1827
+ results.push({ name: "skip patterns", status: "pass", message: "All patterns match files." });
1828
+ }
1829
+ return results;
1830
+ }
1831
+ async function patternMatchesAnything(dir, pattern) {
1832
+ if (pattern === "**") return true;
1833
+ if (!existsSync9(dir)) return false;
1834
+ const walk = async (current, base) => {
1835
+ let entries;
1836
+ try {
1837
+ entries = await readdir3(current, { withFileTypes: true });
1838
+ } catch {
1839
+ return false;
1840
+ }
1841
+ for (const entry of entries) {
1842
+ const full = join10(current, entry.name);
1843
+ const rel = full.slice(base.length + 1);
1844
+ if (entry.isDirectory()) {
1845
+ if (await walk(full, base)) return true;
1846
+ } else if (matchesSkip(rel, [pattern])) {
1847
+ return true;
1848
+ }
1849
+ }
1850
+ return false;
1851
+ };
1852
+ return walk(dir, dir);
1853
+ }
1854
+ async function doctor(cwd, fix = false) {
1855
+ p7.intro("projx doctor");
1856
+ const allResults = [];
1857
+ const { results: configResults, config } = await checkConfig(cwd);
1858
+ allResults.push(...configResults);
1859
+ if (!config) {
1860
+ printReport(allResults);
1861
+ process.exit(1);
1862
+ }
1863
+ const componentPaths = await discoverComponentPaths(cwd, config.components);
1864
+ allResults.push(...await checkComponents(cwd, config, componentPaths));
1865
+ allResults.push(...checkGit(cwd, fix));
1866
+ allResults.push(...await checkSkipPatterns(cwd, config, componentPaths));
1867
+ printReport(allResults);
1868
+ const passed = allResults.filter((r) => r.status === "pass").length;
1869
+ const warns = allResults.filter((r) => r.status === "warn").length;
1870
+ const fails = allResults.filter((r) => r.status === "fail").length;
1871
+ const fixable = allResults.filter((r) => r.autoFixable);
1872
+ if (fixable.length > 0 && !fix) {
1873
+ p7.log.info(`${fixable.length} issue(s) auto-fixable with --fix`);
1874
+ }
1875
+ p7.outro(`${passed} passed, ${warns} warning(s), ${fails} failed`);
1876
+ if (fails > 0) process.exit(1);
1877
+ }
1878
+ function printReport(results) {
1879
+ for (const r of results) {
1880
+ const icon = r.status === "pass" ? "\u2713" : r.status === "warn" ? "\u26A0" : "\u2717";
1881
+ const msg = `${icon} ${r.name} \u2014 ${r.message}`;
1882
+ if (r.status === "pass") p7.log.success(msg);
1883
+ else if (r.status === "warn") p7.log.warn(msg);
1884
+ else p7.log.error(msg);
1885
+ if (r.fix) p7.log.info(` ${r.fix}`);
1886
+ }
1887
+ }
1888
+
1889
+ // src/diff.ts
1890
+ import { existsSync as existsSync10 } from "fs";
1891
+ import { readFile as readFile10, mkdir as mkdir4, rm as rm3 } from "fs/promises";
1892
+ import { join as join11 } from "path";
1893
+ import { tmpdir as tmpdir3 } from "os";
1894
+ import * as p8 from "@clack/prompts";
1895
+ function isSkipped(file, componentPaths, componentSkips, rootSkip) {
1896
+ for (const [component, dir] of Object.entries(componentPaths)) {
1897
+ if (file.startsWith(dir + "/")) {
1898
+ const relative = file.slice(dir.length + 1);
1899
+ const skips = componentSkips[component] ?? [];
1900
+ if (matchesSkip(relative, skips)) return true;
1901
+ }
1902
+ }
1903
+ const base = file.split("/").pop();
1904
+ if (base === ".projx" || base === ".projx-component") return false;
1905
+ return matchesSkip(file, rootSkip);
1906
+ }
1907
+ function fileComponent(file, componentPaths) {
1908
+ for (const [component, dir] of Object.entries(componentPaths)) {
1909
+ if (file.startsWith(dir + "/")) return component;
1910
+ }
1911
+ return void 0;
1912
+ }
1913
+ async function diff(cwd, localRepo) {
1914
+ p8.intro("projx diff");
1915
+ const isLocal = !!localRepo;
1916
+ const configPath = join11(cwd, ".projx");
1917
+ if (!existsSync10(configPath)) {
1918
+ p8.log.error("No .projx file found. Run 'npx create-projx init' first.");
1919
+ process.exit(1);
1920
+ }
1921
+ const config = JSON.parse(await readFile10(configPath, "utf-8"));
1922
+ const componentPaths = await discoverComponentPaths(cwd, config.components);
1923
+ const componentSkips = {};
1924
+ for (const component of config.components) {
1925
+ const dir = componentPaths[component];
1926
+ const marker = await readComponentMarker(join11(cwd, dir));
1927
+ if (marker?.skip && marker.skip.length > 0) {
1928
+ componentSkips[component] = marker.skip;
1929
+ }
1930
+ }
1931
+ const rootSkip = config.skip ?? [];
1932
+ const dlSpinner = p8.spinner();
1933
+ dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
1934
+ const repoDir = await downloadRepo(localRepo).catch((err) => {
1935
+ dlSpinner.stop("Failed.");
1936
+ p8.log.error(String(err));
1937
+ process.exit(1);
1938
+ });
1939
+ dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1940
+ try {
1941
+ const pkg = JSON.parse(await readFile10(join11(repoDir, "cli/package.json"), "utf-8"));
1942
+ const version = pkg.version;
1943
+ p8.log.info(`Current: v${config.version} \u2192 Template: v${version}`);
1944
+ const name = detectProjectName(cwd, config.components, componentPaths);
1945
+ const vars = { projectName: name, components: config.components, paths: componentPaths };
1946
+ const spinner7 = p8.spinner();
1947
+ spinner7.start("Analyzing changes");
1948
+ const tmpTemplate = join11(tmpdir3(), `projx-diff-${Date.now()}`);
1949
+ await mkdir4(tmpTemplate, { recursive: true });
1950
+ await writeTemplateToDir(tmpTemplate, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
1951
+ const baselineRef = getBaselineRef(cwd);
1952
+ const templateFiles = await collectAllFiles(tmpTemplate, tmpTemplate);
1953
+ const analyses = [];
1954
+ for (const file of templateFiles) {
1955
+ const component = fileComponent(file, componentPaths);
1956
+ if (isSkipped(file, componentPaths, componentSkips, rootSkip)) {
1957
+ analyses.push({ file, status: "skipped", component });
1958
+ continue;
1959
+ }
1960
+ const oursPath = join11(cwd, file);
1961
+ if (!existsSync10(oursPath)) {
1962
+ analyses.push({ file, status: "new", component });
1963
+ continue;
1964
+ }
1965
+ let oursContent;
1966
+ let theirsContent;
1967
+ try {
1968
+ oursContent = await readFile10(oursPath, "utf-8");
1969
+ theirsContent = await readFile10(join11(tmpTemplate, file), "utf-8");
1970
+ } catch {
1971
+ continue;
1972
+ }
1973
+ if (oursContent === theirsContent) {
1974
+ analyses.push({ file, status: "unchanged", component });
1975
+ continue;
1976
+ }
1977
+ if (!baselineRef) {
1978
+ analyses.push({ file, status: "needs-merge", component });
1979
+ continue;
1980
+ }
1981
+ const baseContent = getFileAtRef(cwd, baselineRef, file);
1982
+ if (!baseContent) {
1983
+ analyses.push({ file, status: "needs-merge", component });
1984
+ continue;
1985
+ }
1986
+ if (oursContent === baseContent) {
1987
+ analyses.push({ file, status: "clean-update", component });
1988
+ } else if (theirsContent === baseContent) {
1989
+ analyses.push({ file, status: "user-only", component });
1990
+ } else {
1991
+ analyses.push({ file, status: "needs-merge", component });
1992
+ }
1993
+ }
1994
+ await rm3(tmpTemplate, { recursive: true, force: true });
1995
+ spinner7.stop("Analysis complete.");
1996
+ const groups = {
1997
+ "new": [],
1998
+ "clean-update": [],
1999
+ "needs-merge": [],
2000
+ "user-only": [],
2001
+ "unchanged": [],
2002
+ "skipped": []
2003
+ };
2004
+ for (const a of analyses) {
2005
+ groups[a.status].push(a);
2006
+ }
2007
+ if (groups["new"].length > 0) {
2008
+ p8.log.info(`New files (${groups["new"].length}):`);
2009
+ for (const a of groups["new"]) p8.log.info(` + ${a.file}`);
2010
+ }
2011
+ if (groups["clean-update"].length > 0) {
2012
+ p8.log.success(`Clean updates \u2014 auto-merged (${groups["clean-update"].length}):`);
2013
+ for (const a of groups["clean-update"]) p8.log.info(` ~ ${a.file}`);
2014
+ }
2015
+ if (groups["needs-merge"].length > 0) {
2016
+ p8.log.warn(`Needs merge \u2014 both sides changed (${groups["needs-merge"].length}):`);
2017
+ for (const a of groups["needs-merge"]) p8.log.info(` ! ${a.file}`);
2018
+ }
2019
+ if (groups["user-only"].length > 0) {
2020
+ p8.log.info(`User-modified only \u2014 no template change (${groups["user-only"].length}):`);
2021
+ for (const a of groups["user-only"]) p8.log.info(` = ${a.file}`);
2022
+ }
2023
+ if (groups["skipped"].length > 0) {
2024
+ p8.log.info(`Skipped (${groups["skipped"].length}):`);
2025
+ for (const a of groups["skipped"]) p8.log.info(` - ${a.file}`);
2026
+ }
2027
+ const unchanged = groups["unchanged"].length;
2028
+ if (unchanged > 0) {
2029
+ p8.log.info(`${unchanged} file(s) unchanged.`);
2030
+ }
2031
+ const total = analyses.length - unchanged;
2032
+ if (total === 0) {
2033
+ p8.outro("Everything is up to date.");
2034
+ } else {
2035
+ p8.outro(`${total} file(s) would be affected by update.`);
2036
+ }
2037
+ } finally {
2038
+ await cleanupRepo(repoDir, isLocal);
2039
+ }
2040
+ }
2041
+
2042
+ // src/gen.ts
2043
+ import { existsSync as existsSync11 } from "fs";
2044
+ import { readFile as readFile11, writeFile as writeFile5, mkdir as mkdir5 } from "fs/promises";
2045
+ import { join as join12 } from "path";
2046
+ import * as p9 from "@clack/prompts";
2047
+ var FIELD_TYPES = ["string", "number", "boolean", "date", "datetime", "text", "json"];
2048
+ function toPascal(s) {
2049
+ return s.split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
2050
+ }
2051
+ function pluralize(s) {
2052
+ if (s.endsWith("s") || s.endsWith("x") || s.endsWith("z") || s.endsWith("sh") || s.endsWith("ch")) return s + "es";
2053
+ if (s.endsWith("y") && !/[aeiou]y$/i.test(s)) return s.slice(0, -1) + "ies";
2054
+ return s + "s";
2055
+ }
2056
+ async function promptEntityConfig(name) {
2057
+ const snake = toSnake(name);
2058
+ const tableName = pluralize(snake);
2059
+ const kebab = toKebab(name);
2060
+ const apiPrefix = "/" + pluralize(kebab);
2061
+ const tbl = await p9.text({
2062
+ message: "Table name",
2063
+ placeholder: tableName,
2064
+ defaultValue: tableName
2065
+ });
2066
+ if (p9.isCancel(tbl)) process.exit(0);
2067
+ const prefix = await p9.text({
2068
+ message: "API prefix",
2069
+ placeholder: apiPrefix,
2070
+ defaultValue: apiPrefix
2071
+ });
2072
+ if (p9.isCancel(prefix)) process.exit(0);
2073
+ const readonly = await p9.confirm({
2074
+ message: "Readonly?",
2075
+ initialValue: false
2076
+ });
2077
+ if (p9.isCancel(readonly)) process.exit(0);
2078
+ const softDelete = await p9.confirm({
2079
+ message: "Soft delete?",
2080
+ initialValue: false
2081
+ });
2082
+ if (p9.isCancel(softDelete)) process.exit(0);
2083
+ const bulk = await p9.confirm({
2084
+ message: "Bulk operations?",
2085
+ initialValue: true
2086
+ });
2087
+ if (p9.isCancel(bulk)) process.exit(0);
2088
+ const fields = [];
2089
+ p9.log.info("Define fields (enter empty name to finish):");
2090
+ while (true) {
2091
+ const fieldName = await p9.text({
2092
+ message: `Field ${fields.length + 1} name`,
2093
+ placeholder: "done",
2094
+ defaultValue: ""
2095
+ });
2096
+ if (p9.isCancel(fieldName)) process.exit(0);
2097
+ if (!fieldName) break;
2098
+ const fieldType = await p9.select({
2099
+ message: `${fieldName} type`,
2100
+ options: FIELD_TYPES.map((t) => ({ value: t, label: t })),
2101
+ initialValue: "string"
2102
+ });
2103
+ if (p9.isCancel(fieldType)) process.exit(0);
2104
+ const required = await p9.confirm({
2105
+ message: `${fieldName} required?`,
2106
+ initialValue: true
2107
+ });
2108
+ if (p9.isCancel(required)) process.exit(0);
2109
+ fields.push({ name: toSnake(fieldName), type: fieldType, required });
2110
+ }
2111
+ if (fields.length === 0) {
2112
+ p9.log.warn("No fields defined. Adding a default 'name' field.");
2113
+ fields.push({ name: "name", type: "string", required: true });
2114
+ }
2115
+ const stringFields = fields.filter((f) => f.type === "string" || f.type === "text");
2116
+ let searchableFields = [];
2117
+ if (stringFields.length > 0) {
2118
+ const selected = await p9.multiselect({
2119
+ message: "Searchable fields",
2120
+ options: stringFields.map((f) => ({ value: f.name, label: f.name })),
2121
+ required: false
2122
+ });
2123
+ if (!p9.isCancel(selected)) {
2124
+ searchableFields = selected;
2125
+ }
2126
+ }
2127
+ return {
2128
+ name,
2129
+ tableName: tbl,
2130
+ apiPrefix: prefix.startsWith("/") ? prefix : "/" + prefix,
2131
+ readonly,
2132
+ softDelete,
2133
+ bulkOperations: bulk,
2134
+ fields,
2135
+ searchableFields
2136
+ };
2137
+ }
2138
+ function parseFieldsFlag(raw) {
2139
+ return raw.split(",").map((f) => {
2140
+ const [nameType, ...rest] = f.trim().split(":");
2141
+ const required = nameType.endsWith("!");
2142
+ const name = toSnake(required ? nameType.slice(0, -1) : nameType);
2143
+ const type = rest[0] || "string";
2144
+ return { name, type, required: required || true };
2145
+ });
2146
+ }
2147
+ function sqlalchemyType(type) {
2148
+ switch (type) {
2149
+ case "string":
2150
+ return "String(255)";
2151
+ case "number":
2152
+ return "Integer";
2153
+ case "boolean":
2154
+ return "Boolean";
2155
+ case "date":
2156
+ return "Date";
2157
+ case "datetime":
2158
+ return "DateTime";
2159
+ case "text":
2160
+ return "Text";
2161
+ case "json":
2162
+ return "JSON";
2163
+ }
2164
+ }
2165
+ function generateFastAPIModel(config) {
2166
+ const className = toPascal(config.name);
2167
+ const imports = /* @__PURE__ */ new Set(["Column"]);
2168
+ for (const f of config.fields) {
2169
+ switch (f.type) {
2170
+ case "string":
2171
+ imports.add("String");
2172
+ break;
2173
+ case "number":
2174
+ imports.add("Integer");
2175
+ break;
2176
+ case "boolean":
2177
+ imports.add("Boolean");
2178
+ break;
2179
+ case "date":
2180
+ imports.add("Date");
2181
+ break;
2182
+ case "datetime":
2183
+ imports.add("DateTime");
2184
+ break;
2185
+ case "text":
2186
+ imports.add("Text");
2187
+ break;
2188
+ case "json":
2189
+ imports.add("JSON");
2190
+ break;
2191
+ }
2192
+ }
2193
+ if (config.softDelete) imports.add("DateTime");
2194
+ const importList = [...imports].sort().join(", ");
2195
+ const lines = [];
2196
+ lines.push(`from sqlalchemy import ${importList}`);
2197
+ if (config.softDelete) {
2198
+ lines.push(`from src.entities.base import BaseModel_, SoftDeleteMixin`);
2199
+ lines.push("");
2200
+ lines.push("");
2201
+ lines.push(`class ${className}(SoftDeleteMixin, BaseModel_):`);
2202
+ } else {
2203
+ lines.push(`from src.entities.base import BaseModel_`);
2204
+ lines.push("");
2205
+ lines.push("");
2206
+ lines.push(`class ${className}(BaseModel_):`);
2207
+ }
2208
+ lines.push(` __tablename__ = "${config.tableName}"`);
2209
+ lines.push(` __api_prefix__ = "${config.apiPrefix}"`);
2210
+ if (config.readonly) lines.push(` __readonly__ = True`);
2211
+ if (config.softDelete) lines.push(` __soft_delete__ = True`);
2212
+ if (!config.bulkOperations) lines.push(` __bulk_operations__ = False`);
2213
+ if (config.searchableFields.length > 0) {
2214
+ const fields = config.searchableFields.map((f) => `"${f}"`).join(", ");
2215
+ lines.push(` __searchable_fields__ = {${fields}}`);
2216
+ }
2217
+ lines.push("");
2218
+ for (const field of config.fields) {
2219
+ const nullable = field.required ? "nullable=False" : "nullable=True";
2220
+ lines.push(` ${field.name} = Column(${sqlalchemyType(field.type)}, ${nullable})`);
2221
+ }
2222
+ lines.push("");
2223
+ return lines.join("\n");
2224
+ }
2225
+ function typeboxType(type, required) {
2226
+ const inner = (() => {
2227
+ switch (type) {
2228
+ case "string":
2229
+ return "Type.String()";
2230
+ case "number":
2231
+ return "Type.Number()";
2232
+ case "boolean":
2233
+ return "Type.Boolean()";
2234
+ case "date":
2235
+ return "Type.String({ format: 'date' })";
2236
+ case "datetime":
2237
+ return "Type.String({ format: 'date-time' })";
2238
+ case "text":
2239
+ return "Type.String()";
2240
+ case "json":
2241
+ return "Type.Any()";
2242
+ }
2243
+ })();
2244
+ if (!required) return `Type.Union([${inner}, Type.Null()])`;
2245
+ return inner;
2246
+ }
2247
+ function typeboxOptional(type) {
2248
+ switch (type) {
2249
+ case "string":
2250
+ return "Type.Optional(Type.String())";
2251
+ case "number":
2252
+ return "Type.Optional(Type.Number())";
2253
+ case "boolean":
2254
+ return "Type.Optional(Type.Boolean())";
2255
+ case "date":
2256
+ return "Type.Optional(Type.String({ format: 'date' }))";
2257
+ case "datetime":
2258
+ return "Type.Optional(Type.String({ format: 'date-time' }))";
2259
+ case "text":
2260
+ return "Type.Optional(Type.String())";
2261
+ case "json":
2262
+ return "Type.Optional(Type.Any())";
2263
+ }
2264
+ }
2265
+ function fieldMetaType(type) {
2266
+ switch (type) {
2267
+ case "string":
2268
+ return { type: "str", fieldType: "text" };
2269
+ case "number":
2270
+ return { type: "int", fieldType: "number" };
2271
+ case "boolean":
2272
+ return { type: "bool", fieldType: "boolean" };
2273
+ case "date":
2274
+ return { type: "date", fieldType: "date" };
2275
+ case "datetime":
2276
+ return { type: "datetime", fieldType: "datetime" };
2277
+ case "text":
2278
+ return { type: "str", fieldType: "textarea" };
2279
+ case "json":
2280
+ return { type: "dict", fieldType: "textarea" };
2281
+ }
2282
+ }
2283
+ function prismaType(type, required) {
2284
+ const nullable = required ? "" : "?";
2285
+ switch (type) {
2286
+ case "string":
2287
+ return `String${nullable} @db.VarChar(255)`;
2288
+ case "number":
2289
+ return `Int${nullable}`;
2290
+ case "boolean":
2291
+ return `Boolean${nullable} @default(false)`;
2292
+ case "date":
2293
+ return `DateTime${nullable}`;
2294
+ case "datetime":
2295
+ return `DateTime${nullable}`;
2296
+ case "text":
2297
+ return `String${nullable}`;
2298
+ case "json":
2299
+ return `Json${nullable}`;
2300
+ }
2301
+ }
2302
+ function generateFastifySchemas(config) {
2303
+ const className = toPascal(config.name);
2304
+ const lines = [];
2305
+ lines.push(`import { Type, type Static } from '@sinclair/typebox';`);
2306
+ lines.push("");
2307
+ lines.push(`export const ${className}Schema = Type.Object({`);
2308
+ lines.push(` id: Type.String({ format: 'uuid' }),`);
2309
+ for (const f of config.fields) {
2310
+ lines.push(` ${f.name}: ${typeboxType(f.type, f.required)},`);
2311
+ }
2312
+ lines.push(` created_at: Type.String({ format: 'date-time' }),`);
2313
+ lines.push(` updated_at: Type.String({ format: 'date-time' }),`);
2314
+ if (config.softDelete) lines.push(` deleted_at: Type.Union([Type.String({ format: 'date-time' }), Type.Null()]),`);
2315
+ lines.push(`});`);
2316
+ lines.push("");
2317
+ lines.push(`export type ${className} = Static<typeof ${className}Schema>;`);
2318
+ lines.push("");
2319
+ lines.push(`export const Create${className}Schema = Type.Object({`);
2320
+ for (const f of config.fields) {
2321
+ if (f.required) {
2322
+ lines.push(` ${f.name}: ${typeboxType(f.type, true)},`);
2323
+ } else {
2324
+ lines.push(` ${f.name}: ${typeboxOptional(f.type)},`);
2325
+ }
2326
+ }
2327
+ lines.push(`});`);
2328
+ lines.push("");
2329
+ lines.push(`export type Create${className} = Static<typeof Create${className}Schema>;`);
2330
+ lines.push("");
2331
+ lines.push(`export const Update${className}Schema = Type.Object({`);
2332
+ for (const f of config.fields) {
2333
+ lines.push(` ${f.name}: ${typeboxOptional(f.type)},`);
2334
+ }
2335
+ lines.push(`});`);
2336
+ lines.push("");
2337
+ lines.push(`export type Update${className} = Static<typeof Update${className}Schema>;`);
2338
+ lines.push("");
2339
+ return lines.join("\n");
2340
+ }
2341
+ function generateFastifyIndex(config) {
2342
+ const className = toPascal(config.name);
2343
+ const camelConfig = className.charAt(0).toLowerCase() + className.slice(1) + "Config";
2344
+ const allColumns = ["id", ...config.fields.map((f) => f.name), "created_at", "updated_at"];
2345
+ if (config.softDelete) allColumns.push("deleted_at");
2346
+ const lines = [];
2347
+ lines.push(`import { EntityRegistry, type EntityConfig, type FieldMeta } from '../_base/index.js';`);
2348
+ lines.push(`import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`);
2349
+ lines.push("");
2350
+ lines.push(`const fields: FieldMeta[] = [`);
2351
+ lines.push(` { key: 'id', label: 'Id', type: 'str', nullable: false, is_auto: true, is_primary_key: true, filterable: true, has_foreign_key: false, field_type: 'text' },`);
2352
+ for (const f of config.fields) {
2353
+ const meta = fieldMetaType(f.type);
2354
+ lines.push(` { 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}' },`);
2355
+ }
2356
+ lines.push(` { 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' },`);
2357
+ lines.push(` { 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' },`);
2358
+ if (config.softDelete) {
2359
+ lines.push(` { 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' },`);
2360
+ }
2361
+ lines.push(`];`);
2362
+ lines.push("");
2363
+ const tags = config.apiPrefix.replace(/^\//, "");
2364
+ lines.push(`export const ${camelConfig}: EntityConfig = {`);
2365
+ lines.push(` name: '${className}',`);
2366
+ lines.push(` tableName: '${config.tableName}',`);
2367
+ lines.push(` prismaModel: '${className}',`);
2368
+ lines.push(` apiPrefix: '${config.apiPrefix}',`);
2369
+ lines.push(` tags: ['${tags}'],`);
2370
+ lines.push(` readonly: ${config.readonly},`);
2371
+ lines.push(` softDelete: ${config.softDelete},`);
2372
+ lines.push(` bulkOperations: ${config.bulkOperations},`);
2373
+ lines.push(` columnNames: [${allColumns.map((c) => `'${c}'`).join(", ")}],`);
2374
+ if (config.searchableFields.length > 0) {
2375
+ lines.push(` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`);
2376
+ } else {
2377
+ lines.push(` searchableFields: [],`);
2378
+ }
2379
+ lines.push(` fields,`);
2380
+ lines.push(` schema: ${className}Schema,`);
2381
+ lines.push(` createSchema: Create${className}Schema,`);
2382
+ lines.push(` updateSchema: Update${className}Schema,`);
2383
+ lines.push(`};`);
2384
+ lines.push("");
2385
+ lines.push(`EntityRegistry.register(${camelConfig});`);
2386
+ lines.push("");
2387
+ return lines.join("\n");
2388
+ }
2389
+ function generatePrismaModel(config) {
2390
+ const className = toPascal(config.name);
2391
+ const lines = [];
2392
+ lines.push(`model ${className} {`);
2393
+ lines.push(` id String @id @default(uuid())`);
2394
+ for (const f of config.fields) {
2395
+ const padded = f.name.padEnd(10);
2396
+ lines.push(` ${padded} ${prismaType(f.type, f.required)}`);
2397
+ }
2398
+ if (config.softDelete) {
2399
+ lines.push(` deleted_at DateTime?`);
2400
+ }
2401
+ lines.push(` created_at DateTime @default(now())`);
2402
+ lines.push(` updated_at DateTime @updatedAt`);
2403
+ lines.push("");
2404
+ for (const sf of config.searchableFields) {
2405
+ lines.push(` @@index([${sf}])`);
2406
+ }
2407
+ lines.push(` @@map("${config.tableName}")`);
2408
+ lines.push(`}`);
2409
+ return lines.join("\n");
2410
+ }
2411
+ function tsType(type, required) {
2412
+ const base = (() => {
2413
+ switch (type) {
2414
+ case "string":
2415
+ case "text":
2416
+ case "date":
2417
+ case "datetime":
2418
+ return "string";
2419
+ case "number":
2420
+ return "number";
2421
+ case "boolean":
2422
+ return "boolean";
2423
+ case "json":
2424
+ return "Record<string, unknown>";
2425
+ }
2426
+ })();
2427
+ return required ? base : `${base} | null`;
2428
+ }
2429
+ function generateFrontendInterface(config) {
2430
+ const className = toPascal(config.name);
2431
+ const lines = [];
2432
+ lines.push(`export interface ${className} {`);
2433
+ lines.push(` id: string;`);
2434
+ for (const f of config.fields) {
2435
+ lines.push(` ${f.name}: ${tsType(f.type, f.required)};`);
2436
+ }
2437
+ if (config.softDelete) lines.push(` deleted_at: string | null;`);
2438
+ lines.push(` created_at: string;`);
2439
+ lines.push(` updated_at: string;`);
2440
+ lines.push(`}`);
2441
+ lines.push("");
2442
+ lines.push(`export interface Create${className} {`);
2443
+ for (const f of config.fields) {
2444
+ if (f.required) {
2445
+ lines.push(` ${f.name}: ${tsType(f.type, true)};`);
2446
+ } else {
2447
+ lines.push(` ${f.name}?: ${tsType(f.type, false)};`);
2448
+ }
2449
+ }
2450
+ lines.push(`}`);
2451
+ lines.push("");
2452
+ lines.push(`export interface Update${className} {`);
2453
+ for (const f of config.fields) {
2454
+ lines.push(` ${f.name}?: ${tsType(f.type, false)};`);
2455
+ }
2456
+ lines.push(`}`);
2457
+ lines.push("");
2458
+ return lines.join("\n");
2459
+ }
2460
+ function dartType(type, required) {
2461
+ const base = (() => {
2462
+ switch (type) {
2463
+ case "string":
2464
+ case "text":
2465
+ return "String";
2466
+ case "number":
2467
+ return "int";
2468
+ case "boolean":
2469
+ return "bool";
2470
+ case "date":
2471
+ case "datetime":
2472
+ return "DateTime";
2473
+ case "json":
2474
+ return "Map<String, dynamic>";
2475
+ }
2476
+ })();
2477
+ return required ? base : `${base}?`;
2478
+ }
2479
+ function toCamel(s) {
2480
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
2481
+ }
2482
+ function dartFromJson(fieldName, type, required) {
2483
+ const key = `json['${fieldName}']`;
2484
+ const isDate = type === "date" || type === "datetime";
2485
+ if (isDate && required) return `DateTime.parse(${key} as String)`;
2486
+ if (isDate && !required) return `${key} != null ? DateTime.parse(${key} as String) : null`;
2487
+ if (type === "json" && !required) return `${key} as Map<String, dynamic>?`;
2488
+ if (type === "json") return `${key} as Map<String, dynamic>`;
2489
+ const dartT = (() => {
2490
+ switch (type) {
2491
+ case "string":
2492
+ case "text":
2493
+ return "String";
2494
+ case "number":
2495
+ return "int";
2496
+ case "boolean":
2497
+ return "bool";
2498
+ default:
2499
+ return "String";
2500
+ }
2501
+ })();
2502
+ return required ? `${key} as ${dartT}` : `${key} as ${dartT}?`;
2503
+ }
2504
+ function dartToJson(fieldName, camelName, type) {
2505
+ const isDate = type === "date" || type === "datetime";
2506
+ if (isDate) return `'${fieldName}': ${camelName}?.toIso8601String()`;
2507
+ return `'${fieldName}': ${camelName}`;
2508
+ }
2509
+ function generateDartModel(config) {
2510
+ const className = toPascal(config.name);
2511
+ const allFields = [
2512
+ { snake: "id", camel: "id", type: "String", required: true, fieldType: "string" },
2513
+ ...config.fields.map((f) => ({
2514
+ snake: f.name,
2515
+ camel: toCamel(f.name),
2516
+ type: dartType(f.type, f.required),
2517
+ required: f.required,
2518
+ fieldType: f.type
2519
+ }))
2520
+ ];
2521
+ if (config.softDelete) {
2522
+ allFields.push({ snake: "deleted_at", camel: "deletedAt", type: "DateTime?", required: false, fieldType: "datetime" });
2523
+ }
2524
+ allFields.push(
2525
+ { snake: "created_at", camel: "createdAt", type: "DateTime", required: true, fieldType: "datetime" },
2526
+ { snake: "updated_at", camel: "updatedAt", type: "DateTime", required: true, fieldType: "datetime" }
2527
+ );
2528
+ const lines = [];
2529
+ lines.push(`class ${className} {`);
2530
+ for (const f of allFields) {
2531
+ lines.push(` final ${f.type} ${f.camel};`);
2532
+ }
2533
+ lines.push("");
2534
+ lines.push(` const ${className}({`);
2535
+ for (const f of allFields) {
2536
+ if (f.required) {
2537
+ lines.push(` required this.${f.camel},`);
2538
+ } else {
2539
+ lines.push(` this.${f.camel},`);
2540
+ }
2541
+ }
2542
+ lines.push(` });`);
2543
+ lines.push("");
2544
+ lines.push(` factory ${className}.fromJson(Map<String, dynamic> json) {`);
2545
+ lines.push(` return ${className}(`);
2546
+ for (const f of allFields) {
2547
+ lines.push(` ${f.camel}: ${dartFromJson(f.snake, f.fieldType, f.required)},`);
2548
+ }
2549
+ lines.push(` );`);
2550
+ lines.push(` }`);
2551
+ lines.push("");
2552
+ lines.push(` Map<String, dynamic> toJson() {`);
2553
+ lines.push(` return {`);
2554
+ for (const f of allFields) {
2555
+ lines.push(` ${dartToJson(f.snake, f.camel, f.fieldType)},`);
2556
+ }
2557
+ lines.push(` };`);
2558
+ lines.push(` }`);
2559
+ lines.push("");
2560
+ lines.push(` ${className} copyWith({`);
2561
+ for (const f of allFields) {
2562
+ lines.push(` ${f.type.replace("?", "")}? ${f.camel},`);
2563
+ }
2564
+ lines.push(` }) {`);
2565
+ lines.push(` return ${className}(`);
2566
+ for (const f of allFields) {
2567
+ lines.push(` ${f.camel}: ${f.camel} ?? this.${f.camel},`);
2568
+ }
2569
+ lines.push(` );`);
2570
+ lines.push(` }`);
2571
+ lines.push(`}`);
2572
+ lines.push("");
2573
+ return lines.join("\n");
2574
+ }
2575
+ async function gen(cwd, entityName, fieldsFlag) {
2576
+ p9.intro(`projx gen entity ${entityName}`);
2577
+ const configPath = join12(cwd, ".projx");
2578
+ if (!existsSync11(configPath)) {
2579
+ p9.log.error("No .projx file found. Run 'npx create-projx init' first.");
2580
+ process.exit(1);
2581
+ }
2582
+ const projxConfig = JSON.parse(await readFile11(configPath, "utf-8"));
2583
+ const componentPaths = await discoverComponentPaths(cwd, projxConfig.components);
2584
+ const hasFastapi = projxConfig.components.includes("fastapi");
2585
+ const hasFastify = projxConfig.components.includes("fastify");
2586
+ const hasFrontend = projxConfig.components.includes("frontend");
2587
+ const hasMobile = projxConfig.components.includes("mobile");
2588
+ if (!hasFastapi && !hasFastify) {
2589
+ p9.log.error("No backend component found. Need fastapi or fastify.");
2590
+ process.exit(1);
2591
+ }
2592
+ let config;
2593
+ if (fieldsFlag) {
2594
+ const fields = parseFieldsFlag(fieldsFlag);
2595
+ const snake = toSnake(entityName);
2596
+ const tableName = pluralize(snake);
2597
+ const kebab = toKebab(entityName);
2598
+ config = {
2599
+ name: entityName,
2600
+ tableName,
2601
+ apiPrefix: "/" + pluralize(kebab),
2602
+ readonly: false,
2603
+ softDelete: false,
2604
+ bulkOperations: true,
2605
+ fields,
2606
+ searchableFields: fields.filter((f) => f.type === "string" || f.type === "text").map((f) => f.name)
2607
+ };
2608
+ } else {
2609
+ config = await promptEntityConfig(entityName);
2610
+ }
2611
+ const generated = [];
2612
+ if (hasFastapi) {
2613
+ const dir = componentPaths.fastapi;
2614
+ const entityDir = join12(cwd, dir, "src/entities", toSnake(config.name));
2615
+ if (existsSync11(entityDir)) {
2616
+ p9.log.warn(`${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`);
2617
+ } else {
2618
+ await mkdir5(entityDir, { recursive: true });
2619
+ await writeFile5(join12(entityDir, "_model.py"), generateFastAPIModel(config));
2620
+ generated.push(`${dir}/src/entities/${toSnake(config.name)}/_model.py`);
2621
+ }
2622
+ }
2623
+ if (hasFastify) {
2624
+ const dir = componentPaths.fastify;
2625
+ const moduleDir = join12(cwd, dir, "src/modules", toKebab(config.name));
2626
+ if (existsSync11(moduleDir)) {
2627
+ p9.log.warn(`${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`);
2628
+ } else {
2629
+ await mkdir5(moduleDir, { recursive: true });
2630
+ await writeFile5(join12(moduleDir, "schemas.ts"), generateFastifySchemas(config));
2631
+ await writeFile5(join12(moduleDir, "index.ts"), generateFastifyIndex(config));
2632
+ generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
2633
+ generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
2634
+ const appPath = join12(cwd, dir, "src/app.ts");
2635
+ if (existsSync11(appPath)) {
2636
+ const appContent = await readFile11(appPath, "utf-8");
2637
+ const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
2638
+ if (!appContent.includes(importLine)) {
2639
+ const updated = appContent.replace(
2640
+ /^(import\s+'\.\/modules\/.*?';?\s*\n)/m,
2641
+ `$1${importLine}
2642
+ `
2643
+ );
2644
+ if (updated !== appContent) {
2645
+ await writeFile5(appPath, updated);
2646
+ generated.push(`${dir}/src/app.ts (import added)`);
2647
+ }
2648
+ }
2649
+ }
2650
+ const prismaPath = join12(cwd, dir, "prisma/schema.prisma");
2651
+ if (existsSync11(prismaPath)) {
2652
+ const prismaContent = await readFile11(prismaPath, "utf-8");
2653
+ const modelName = `model ${toPascal(config.name)}`;
2654
+ if (!prismaContent.includes(modelName)) {
2655
+ const prismaModel = generatePrismaModel(config);
2656
+ await writeFile5(prismaPath, prismaContent.trimEnd() + "\n\n" + prismaModel + "\n");
2657
+ generated.push(`${dir}/prisma/schema.prisma (model added)`);
2658
+ }
2659
+ }
2660
+ }
2661
+ }
2662
+ if (hasFrontend) {
2663
+ const dir = componentPaths.frontend;
2664
+ const typesDir = join12(cwd, dir, "src/types");
2665
+ const fileName = toKebab(config.name) + ".ts";
2666
+ const filePath = join12(typesDir, fileName);
2667
+ if (existsSync11(filePath)) {
2668
+ p9.log.warn(`${dir}/src/types/${fileName} already exists. Skipping frontend types.`);
2669
+ } else {
2670
+ await mkdir5(typesDir, { recursive: true });
2671
+ await writeFile5(filePath, generateFrontendInterface(config));
2672
+ generated.push(`${dir}/src/types/${fileName}`);
2673
+ const barrelPath = join12(typesDir, "index.ts");
2674
+ const exportLine = `export * from './${toKebab(config.name)}';`;
2675
+ if (existsSync11(barrelPath)) {
2676
+ const content = await readFile11(barrelPath, "utf-8");
2677
+ if (!content.includes(exportLine)) {
2678
+ await writeFile5(barrelPath, content.trimEnd() + "\n" + exportLine + "\n");
2679
+ }
2680
+ } else {
2681
+ await writeFile5(barrelPath, exportLine + "\n");
2682
+ }
2683
+ generated.push(`${dir}/src/types/index.ts`);
2684
+ }
2685
+ }
2686
+ if (hasMobile) {
2687
+ const dir = componentPaths.mobile;
2688
+ const entityDir = join12(cwd, dir, "lib/entities", toSnake(config.name));
2689
+ const modelPath = join12(entityDir, "model.dart");
2690
+ if (existsSync11(modelPath)) {
2691
+ p9.log.warn(`${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`);
2692
+ } else {
2693
+ await mkdir5(entityDir, { recursive: true });
2694
+ await writeFile5(modelPath, generateDartModel(config));
2695
+ generated.push(`${dir}/lib/entities/${toSnake(config.name)}/model.dart`);
2696
+ }
2697
+ }
2698
+ if (generated.length === 0) {
2699
+ p9.log.warn("Nothing generated.");
2700
+ p9.outro("");
2701
+ return;
2702
+ }
2703
+ p9.log.success("Generated:");
2704
+ for (const f of generated) {
2705
+ p9.log.info(` ${f}`);
2706
+ }
2707
+ const className = toPascal(config.name);
2708
+ if (hasFastapi) {
2709
+ p9.log.info("");
2710
+ p9.log.info("FastAPI next steps:");
2711
+ p9.log.info(` alembic revision --autogenerate -m "add ${config.tableName}"`);
2712
+ p9.log.info(" alembic upgrade head");
2713
+ }
2714
+ if (hasFastify) {
2715
+ p9.log.info("");
2716
+ p9.log.info("Fastify next steps:");
2717
+ p9.log.info(` npx prisma migrate dev --name add_${toSnake(config.name)}`);
2718
+ }
2719
+ if (hasFrontend) {
2720
+ p9.log.info("");
2721
+ p9.log.info("Frontend usage:");
2722
+ p9.log.info(` import type { ${className} } from '../types/${toKebab(config.name)}';`);
2723
+ p9.log.info(` const { data } = await api.list<${className}>('${config.apiPrefix}');`);
2724
+ }
2725
+ if (hasMobile) {
2726
+ p9.log.info("");
2727
+ p9.log.info("Mobile usage:");
2728
+ p9.log.info(` final item = ${className}.fromJson(json);`);
2729
+ }
2730
+ p9.outro(`Entity ${className} created.`);
2731
+ }
2732
+
2733
+ // src/sync.ts
2734
+ import { existsSync as existsSync12, readFileSync as readFileSync2 } from "fs";
2735
+ import { readFile as readFile12, writeFile as writeFile6, mkdir as mkdir6 } from "fs/promises";
2736
+ import { join as join13 } from "path";
2737
+ import * as p10 from "@clack/prompts";
2738
+ function toPascal2(s) {
2739
+ return s.replace(/(?:^|[_\-\s])([a-zA-Z])/g, (_, c) => c.toUpperCase());
2740
+ }
2741
+ function toCamel2(s) {
2742
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
2743
+ }
2744
+ function metaTypeToTs(type, fieldType, nullable) {
2745
+ const base = (() => {
2746
+ switch (type) {
2747
+ case "str":
2748
+ return "string";
2749
+ case "int":
2750
+ case "float":
2751
+ return "number";
2752
+ case "bool":
2753
+ return "boolean";
2754
+ case "datetime":
2755
+ case "date":
2756
+ return "string";
2757
+ case "dict":
2758
+ return "Record<string, unknown>";
2759
+ default:
2760
+ return "unknown";
2761
+ }
2762
+ })();
2763
+ return nullable ? `${base} | null` : base;
2764
+ }
2765
+ function metaTypeToDart(type, nullable) {
2766
+ const base = (() => {
2767
+ switch (type) {
2768
+ case "str":
2769
+ return "String";
2770
+ case "int":
2771
+ return "int";
2772
+ case "float":
2773
+ return "double";
2774
+ case "bool":
2775
+ return "bool";
2776
+ case "datetime":
2777
+ case "date":
2778
+ return "DateTime";
2779
+ case "dict":
2780
+ return "Map<String, dynamic>";
2781
+ default:
2782
+ return "dynamic";
2783
+ }
2784
+ })();
2785
+ return nullable ? `${base}?` : base;
2786
+ }
2787
+ function dartFromJsonExpr(key, type, nullable) {
2788
+ const accessor = `json['${key}']`;
2789
+ const isDate = type === "datetime" || type === "date";
2790
+ if (isDate && nullable)
2791
+ return `${accessor} != null ? DateTime.parse(${accessor} as String) : null`;
2792
+ if (isDate) return `DateTime.parse(${accessor} as String)`;
2793
+ if (type === "dict" && nullable)
2794
+ return `${accessor} as Map<String, dynamic>?`;
2795
+ if (type === "dict") return `${accessor} as Map<String, dynamic>`;
2796
+ const dartT = (() => {
2797
+ switch (type) {
2798
+ case "str":
2799
+ return "String";
2800
+ case "int":
2801
+ return "int";
2802
+ case "float":
2803
+ return "double";
2804
+ case "bool":
2805
+ return "bool";
2806
+ default:
2807
+ return "dynamic";
2808
+ }
2809
+ })();
2810
+ return nullable ? `${accessor} as ${dartT}?` : `${accessor} as ${dartT}`;
2811
+ }
2812
+ function dartToJsonExpr(key, camel, type) {
2813
+ const isDate = type === "datetime" || type === "date";
2814
+ if (isDate) return `'${key}': ${camel}?.toIso8601String()`;
2815
+ return `'${key}': ${camel}`;
2816
+ }
2817
+ function generateTsInterface(entity) {
2818
+ const className = toPascal2(entity.name);
2819
+ const lines = [];
2820
+ lines.push(`export interface ${className} {`);
2821
+ for (const f of entity.fields) {
2822
+ lines.push(
2823
+ ` ${f.key}: ${metaTypeToTs(f.type, f.field_type, f.nullable)};`
2824
+ );
2825
+ }
2826
+ lines.push(`}`);
2827
+ lines.push("");
2828
+ const createFields = entity.fields.filter((f) => f.in_create);
2829
+ lines.push(`export interface Create${className} {`);
2830
+ for (const f of createFields) {
2831
+ const optional = f.nullable ? "?" : "";
2832
+ lines.push(
2833
+ ` ${f.key}${optional}: ${metaTypeToTs(f.type, f.field_type, f.nullable)};`
2834
+ );
2835
+ }
2836
+ lines.push(`}`);
2837
+ lines.push("");
2838
+ const updateFields = entity.fields.filter((f) => f.in_update);
2839
+ lines.push(`export interface Update${className} {`);
2840
+ for (const f of updateFields) {
2841
+ lines.push(` ${f.key}?: ${metaTypeToTs(f.type, f.field_type, true)};`);
2842
+ }
2843
+ lines.push(`}`);
2844
+ lines.push("");
2845
+ return lines.join("\n");
2846
+ }
2847
+ function generateDartModel2(entity) {
2848
+ const className = toPascal2(entity.name);
2849
+ const lines = [];
2850
+ const fields = entity.fields.map((f) => ({
2851
+ snake: f.key,
2852
+ camel: toCamel2(f.key),
2853
+ type: metaTypeToDart(f.type, f.nullable),
2854
+ nullable: f.nullable,
2855
+ metaType: f.type
2856
+ }));
2857
+ lines.push(`class ${className} {`);
2858
+ for (const f of fields) {
2859
+ lines.push(` final ${f.type} ${f.camel};`);
2860
+ }
2861
+ lines.push("");
2862
+ lines.push(` const ${className}({`);
2863
+ for (const f of fields) {
2864
+ if (f.nullable) {
2865
+ lines.push(` this.${f.camel},`);
2866
+ } else {
2867
+ lines.push(` required this.${f.camel},`);
2868
+ }
2869
+ }
2870
+ lines.push(` });`);
2871
+ lines.push("");
2872
+ lines.push(` factory ${className}.fromJson(Map<String, dynamic> json) {`);
2873
+ lines.push(` return ${className}(`);
2874
+ for (const f of fields) {
2875
+ lines.push(
2876
+ ` ${f.camel}: ${dartFromJsonExpr(f.snake, f.metaType, f.nullable)},`
2877
+ );
2878
+ }
2879
+ lines.push(` );`);
2880
+ lines.push(` }`);
2881
+ lines.push("");
2882
+ lines.push(` Map<String, dynamic> toJson() {`);
2883
+ lines.push(` return {`);
2884
+ for (const f of fields) {
2885
+ lines.push(` ${dartToJsonExpr(f.snake, f.camel, f.metaType)},`);
2886
+ }
2887
+ lines.push(` };`);
2888
+ lines.push(` }`);
2889
+ lines.push("");
2890
+ lines.push(` ${className} copyWith({`);
2891
+ for (const f of fields) {
2892
+ lines.push(` ${f.type.replace("?", "")}? ${f.camel},`);
2893
+ }
2894
+ lines.push(` }) {`);
2895
+ lines.push(` return ${className}(`);
2896
+ for (const f of fields) {
2897
+ lines.push(` ${f.camel}: ${f.camel} ?? this.${f.camel},`);
2898
+ }
2899
+ lines.push(` );`);
2900
+ lines.push(` }`);
2901
+ lines.push(`}`);
2902
+ lines.push("");
2903
+ return lines.join("\n");
2904
+ }
2905
+ async function sync(cwd, url) {
2906
+ p10.intro("projx sync");
2907
+ const configPath = join13(cwd, ".projx");
2908
+ if (!existsSync12(configPath)) {
2909
+ p10.log.error("No .projx file found. Run 'npx create-projx init' first.");
2910
+ process.exit(1);
2911
+ }
2912
+ const projxConfig = JSON.parse(
2913
+ await readFile12(configPath, "utf-8")
2914
+ );
2915
+ const componentPaths = await discoverComponentPaths(
2916
+ cwd,
2917
+ projxConfig.components
2918
+ );
2919
+ const hasFrontend = projxConfig.components.includes("frontend");
2920
+ const hasMobile = projxConfig.components.includes("mobile");
2921
+ if (!hasFrontend && !hasMobile) {
2922
+ p10.log.error("No frontend or mobile component found. Nothing to sync.");
2923
+ process.exit(1);
2924
+ }
2925
+ const metaUrl = url || detectMetaUrl(cwd);
2926
+ const spinner7 = p10.spinner();
2927
+ spinner7.start(`Fetching metadata from ${metaUrl}`);
2928
+ let meta;
2929
+ try {
2930
+ const res = await fetch(metaUrl);
2931
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2932
+ meta = await res.json();
2933
+ } catch (err) {
2934
+ spinner7.stop("Failed.");
2935
+ p10.log.error(`Could not fetch ${metaUrl}: ${err}`);
2936
+ p10.log.info("Make sure your backend is running.");
2937
+ p10.log.info(
2938
+ "Or specify URL: projx sync --url http://localhost:8000/api/v1/_meta"
2939
+ );
2940
+ process.exit(1);
2941
+ }
2942
+ spinner7.stop(`Fetched ${meta.entities.length} entity(s).`);
2943
+ const generated = [];
2944
+ if (hasFrontend) {
2945
+ const dir = componentPaths.frontend;
2946
+ const typesDir = join13(cwd, dir, "src/types");
2947
+ await mkdir6(typesDir, { recursive: true });
2948
+ const barrelExports = [];
2949
+ for (const entity of meta.entities) {
2950
+ const fileName = toKebab(toSnake(entity.name)) + ".ts";
2951
+ const filePath = join13(typesDir, fileName);
2952
+ await writeFile6(filePath, generateTsInterface(entity));
2953
+ generated.push(`${dir}/src/types/${fileName}`);
2954
+ barrelExports.push(`export * from './${toKebab(toSnake(entity.name))}';`);
2955
+ }
2956
+ await writeFile6(
2957
+ join13(typesDir, "index.ts"),
2958
+ barrelExports.join("\n") + "\n"
2959
+ );
2960
+ generated.push(`${dir}/src/types/index.ts`);
2961
+ }
2962
+ if (hasMobile) {
2963
+ const dir = componentPaths.mobile;
2964
+ for (const entity of meta.entities) {
2965
+ const entityDir = join13(cwd, dir, "lib/entities", toSnake(entity.name));
2966
+ await mkdir6(entityDir, { recursive: true });
2967
+ const modelPath = join13(entityDir, "model.dart");
2968
+ await writeFile6(modelPath, generateDartModel2(entity));
2969
+ generated.push(`${dir}/lib/entities/${toSnake(entity.name)}/model.dart`);
2970
+ }
2971
+ }
2972
+ p10.log.success(`Synced ${meta.entities.length} entity(s):`);
2973
+ for (const f of generated) {
2974
+ p10.log.info(` ${f}`);
2975
+ }
2976
+ if (hasFrontend) {
2977
+ p10.log.info("");
2978
+ p10.log.info("Frontend usage:");
2979
+ for (const entity of meta.entities) {
2980
+ const className = toPascal2(entity.name);
2981
+ p10.log.info(
2982
+ ` import type { ${className} } from '../types/${toKebab(toSnake(entity.name))}';`
2983
+ );
2984
+ }
2985
+ }
2986
+ p10.outro("Types are up to date.");
2987
+ }
2988
+ function detectMetaUrl(cwd) {
2989
+ const envFiles = [".env", ".env.dev", ".env.local"];
2990
+ for (const envFile of envFiles) {
2991
+ const envPath = join13(cwd, envFile);
2992
+ if (existsSync12(envPath)) {
2993
+ try {
2994
+ const content = readFileSync2(envPath, "utf-8");
2995
+ const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
2996
+ if (match) {
2997
+ const base = match[1].trim().replace(/["']/g, "");
2998
+ return `${base}/api/v1/_meta`;
2999
+ }
3000
+ } catch {
3001
+ }
3002
+ }
3003
+ }
3004
+ const frontendEnvFiles = [
3005
+ "frontend/.env",
3006
+ "frontend/.env.local",
3007
+ "frontend/.env.dev"
3008
+ ];
3009
+ for (const envFile of frontendEnvFiles) {
3010
+ const envPath = join13(cwd, envFile);
3011
+ if (existsSync12(envPath)) {
3012
+ try {
3013
+ const content = readFileSync2(envPath, "utf-8");
3014
+ const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
3015
+ if (match) {
3016
+ const base = match[1].trim().replace(/["']/g, "");
3017
+ return `${base}/api/v1/_meta`;
3018
+ }
3019
+ } catch {
3020
+ }
3021
+ }
3022
+ }
3023
+ return "http://localhost:8000/api/v1/_meta";
3024
+ }
3025
+
1464
3026
  // src/index.ts
1465
3027
  var args = process.argv.slice(2);
1466
3028
  function parseArgs() {
@@ -1469,6 +3031,7 @@ function parseArgs() {
1469
3031
  let localRepo;
1470
3032
  const options = {};
1471
3033
  const extraArgs = [];
3034
+ const flags = {};
1472
3035
  for (let i = 0; i < args.length; i++) {
1473
3036
  const arg = args[i];
1474
3037
  if (arg === "update" && !name) {
@@ -1483,6 +3046,30 @@ function parseArgs() {
1483
3046
  command = "init";
1484
3047
  continue;
1485
3048
  }
3049
+ if (arg === "pin" && !name) {
3050
+ command = "pin";
3051
+ continue;
3052
+ }
3053
+ if (arg === "unpin" && !name) {
3054
+ command = "unpin";
3055
+ continue;
3056
+ }
3057
+ if (arg === "diff" && !name) {
3058
+ command = "diff";
3059
+ continue;
3060
+ }
3061
+ if (arg === "doctor" && !name) {
3062
+ command = "doctor";
3063
+ continue;
3064
+ }
3065
+ if (arg === "gen" && !name) {
3066
+ command = "gen";
3067
+ continue;
3068
+ }
3069
+ if (arg === "sync" && !name) {
3070
+ command = "sync";
3071
+ continue;
3072
+ }
1486
3073
  if (arg === "--components") {
1487
3074
  const val = args[++i];
1488
3075
  if (val) {
@@ -1508,19 +3095,37 @@ function parseArgs() {
1508
3095
  options.components = options.components ?? ["fastify", "frontend", "e2e"];
1509
3096
  continue;
1510
3097
  }
3098
+ if (arg === "--list" || arg === "-l") {
3099
+ flags.list = true;
3100
+ continue;
3101
+ }
3102
+ if (arg === "--fix") {
3103
+ flags.fix = true;
3104
+ continue;
3105
+ }
3106
+ if (arg === "--url") {
3107
+ const val = args[++i];
3108
+ if (val) extraArgs.push(`--url=${val}`);
3109
+ continue;
3110
+ }
1511
3111
  if (arg === "--help" || arg === "-h") {
1512
3112
  printHelp();
1513
3113
  process.exit(0);
1514
3114
  }
3115
+ if (arg === "--fields") {
3116
+ const val = args[++i];
3117
+ if (val) extraArgs.push(`--fields=${val}`);
3118
+ continue;
3119
+ }
1515
3120
  if (!arg.startsWith("-")) {
1516
- if (command === "add") {
3121
+ if (command === "add" || command === "pin" || command === "unpin" || command === "gen") {
1517
3122
  extraArgs.push(arg);
1518
3123
  } else if (!name) {
1519
3124
  name = arg;
1520
3125
  }
1521
3126
  }
1522
3127
  }
1523
- return { command, name, options, localRepo, extraArgs };
3128
+ return { command, name, options, localRepo, extraArgs, flags };
1524
3129
  }
1525
3130
  function printHelp() {
1526
3131
  console.log(`
@@ -1529,6 +3134,13 @@ function printHelp() {
1529
3134
  projx init Adopt existing project into projx
1530
3135
  projx add <components...> Add components to existing project
1531
3136
  projx update Update scaffolding to latest
3137
+ projx diff Preview what update would change
3138
+ projx pin <patterns...> Skip files on future updates
3139
+ projx unpin <patterns...> Remove files from skip list
3140
+ projx pin --list Show all skip patterns
3141
+ projx doctor [--fix] Health check for projx project
3142
+ projx gen entity <name> Generate a new entity
3143
+ projx sync [--url <url>] Sync types from running backend
1532
3144
 
1533
3145
  Options:
1534
3146
  --components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
@@ -1544,10 +3156,15 @@ function printHelp() {
1544
3156
  npx create-projx my-app -y
1545
3157
  npx create-projx add frontend mobile
1546
3158
  npx create-projx@latest update
3159
+ npx create-projx diff
3160
+ npx create-projx pin backend/pyproject.toml
3161
+ npx create-projx doctor --fix
3162
+ npx create-projx gen entity invoice
3163
+ npx create-projx gen entity invoice --fields "name:string,amount:number,status:string"
1547
3164
  `);
1548
3165
  }
1549
3166
  async function main() {
1550
- const { command, name, options, localRepo, extraArgs } = parseArgs();
3167
+ const { command, name, options, localRepo, extraArgs, flags } = parseArgs();
1551
3168
  if (command === "init") {
1552
3169
  await init(process.cwd(), localRepo);
1553
3170
  return;
@@ -1567,6 +3184,48 @@ async function main() {
1567
3184
  await add(process.cwd(), components, localRepo, options.install === false);
1568
3185
  return;
1569
3186
  }
3187
+ if (command === "pin") {
3188
+ if (flags.list || extraArgs.length === 0) {
3189
+ await listPins(process.cwd());
3190
+ } else {
3191
+ await pin(process.cwd(), extraArgs);
3192
+ }
3193
+ return;
3194
+ }
3195
+ if (command === "unpin") {
3196
+ if (extraArgs.length === 0) {
3197
+ console.error("Error: specify patterns to unpin. Usage: projx unpin <patterns...>");
3198
+ process.exit(1);
3199
+ }
3200
+ await unpin(process.cwd(), extraArgs);
3201
+ return;
3202
+ }
3203
+ if (command === "diff") {
3204
+ await diff(process.cwd(), localRepo);
3205
+ return;
3206
+ }
3207
+ if (command === "doctor") {
3208
+ await doctor(process.cwd(), flags.fix);
3209
+ return;
3210
+ }
3211
+ if (command === "sync") {
3212
+ const urlArg = extraArgs.find((a) => a.startsWith("--url="));
3213
+ const url = urlArg ? urlArg.split("=").slice(1).join("=") : void 0;
3214
+ await sync(process.cwd(), url);
3215
+ return;
3216
+ }
3217
+ if (command === "gen") {
3218
+ const subcommand = extraArgs[0];
3219
+ if (subcommand !== "entity" || !extraArgs[1]) {
3220
+ console.error('Usage: projx gen entity <name> [--fields "name:string,amount:number"]');
3221
+ process.exit(1);
3222
+ }
3223
+ const entityName = extraArgs[1];
3224
+ const fieldsArg = extraArgs.find((a) => a.startsWith("--fields="));
3225
+ const fieldsFlag = fieldsArg ? fieldsArg.split("=").slice(1).join("=") : void 0;
3226
+ await gen(process.cwd(), entityName, fieldsFlag);
3227
+ return;
3228
+ }
1570
3229
  let opts;
1571
3230
  if (options.components) {
1572
3231
  if (!name) {
@@ -1585,7 +3244,7 @@ async function main() {
1585
3244
  opts.install = options.install ?? opts.install;
1586
3245
  }
1587
3246
  const dest = resolve2(process.cwd(), opts.name);
1588
- if (existsSync8(dest)) {
3247
+ if (existsSync13(dest)) {
1589
3248
  console.error(`Error: ${dest} already exists.`);
1590
3249
  process.exit(1);
1591
3250
  }