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