create-projx 1.3.5 → 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 +56 -8
  2. package/dist/index.js +2128 -205
  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";
@@ -330,13 +350,13 @@ async function runPrompts(nameArg) {
330
350
 
331
351
  // src/scaffold.ts
332
352
  import { copyFileSync, existsSync as existsSync3 } from "fs";
333
- import { mkdir as mkdir3, readFile as readFile3 } from "fs/promises";
353
+ import { mkdir as mkdir3, readFile as readFile4 } from "fs/promises";
334
354
  import { join as join4 } from "path";
335
355
  import * as p2 from "@clack/prompts";
336
356
 
337
357
  // src/baseline.ts
338
- import { existsSync as existsSync2 } from "fs";
339
- import { chmod, mkdir as mkdir2, writeFile as writeFile2, rm as rm2 } from "fs/promises";
358
+ import { existsSync as existsSync2, writeFileSync, unlinkSync } from "fs";
359
+ import { chmod, mkdir as mkdir2, writeFile as writeFile2, rm as rm2, readFile as readFile3 } from "fs/promises";
340
360
  import { execSync as execSync2 } from "child_process";
341
361
  import { join as join3 } from "path";
342
362
  import { tmpdir as tmpdir2 } from "os";
@@ -401,6 +421,7 @@ function generateVscodeSettings(vars) {
401
421
  }
402
422
 
403
423
  // src/baseline.ts
424
+ var BASELINE_REF = "refs/projx/baseline";
404
425
  function matchesSkip(filePath, patterns) {
405
426
  for (const pattern of patterns) {
406
427
  if (pattern === "**") return true;
@@ -425,6 +446,98 @@ function matchesSkip(filePath, patterns) {
425
446
  }
426
447
  return false;
427
448
  }
449
+ function saveBaselineRef(cwd) {
450
+ try {
451
+ const head = execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" }).toString().trim();
452
+ execSync2(`git update-ref ${BASELINE_REF} ${head}`, { cwd, stdio: "pipe" });
453
+ } catch {
454
+ }
455
+ }
456
+ function getBaselineRef(cwd) {
457
+ try {
458
+ return execSync2(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
459
+ } catch {
460
+ }
461
+ try {
462
+ const sha = execSync2("git log -1 --format=%H -- .projx", { cwd, stdio: "pipe" }).toString().trim();
463
+ if (sha) return sha;
464
+ } catch {
465
+ }
466
+ return null;
467
+ }
468
+ function getFileAtRef(cwd, ref, filePath) {
469
+ try {
470
+ return execSync2(`git show ${ref}:"${filePath}"`, { cwd, stdio: "pipe" }).toString();
471
+ } catch {
472
+ return null;
473
+ }
474
+ }
475
+ function mergeFileThreeWay(oursPath, baseContent, theirsContent) {
476
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
477
+ const baseTmp = join3(tmpdir2(), `projx-base-${id}`);
478
+ const theirsTmp = join3(tmpdir2(), `projx-theirs-${id}`);
479
+ try {
480
+ writeFileSync(baseTmp, baseContent);
481
+ writeFileSync(theirsTmp, theirsContent);
482
+ execSync2(`git merge-file "${oursPath}" "${baseTmp}" "${theirsTmp}"`, { stdio: "pipe" });
483
+ return true;
484
+ } catch {
485
+ return false;
486
+ } finally {
487
+ try {
488
+ unlinkSync(baseTmp);
489
+ } catch {
490
+ }
491
+ try {
492
+ unlinkSync(theirsTmp);
493
+ } catch {
494
+ }
495
+ }
496
+ }
497
+ async function collectAllFiles(dir, base) {
498
+ const { readdir: readdir4 } = await import("fs/promises");
499
+ const results = [];
500
+ const walk = async (current) => {
501
+ const entries = await readdir4(current, { withFileTypes: true });
502
+ for (const entry of entries) {
503
+ const full = join3(current, entry.name);
504
+ if (entry.isDirectory()) {
505
+ await walk(full);
506
+ } else {
507
+ results.push(full.slice(base.length + 1));
508
+ }
509
+ }
510
+ };
511
+ await walk(dir);
512
+ return results;
513
+ }
514
+ async function tryThreeWayMerge(cwd, templateDir, baselineRef) {
515
+ const templateFiles = await collectAllFiles(templateDir, templateDir);
516
+ const merged = [];
517
+ const conflicted = [];
518
+ for (const file of templateFiles) {
519
+ const oursPath = join3(cwd, file);
520
+ if (!existsSync2(oursPath)) continue;
521
+ const baseContent = getFileAtRef(cwd, baselineRef, file);
522
+ if (baseContent === null) continue;
523
+ let theirsContent;
524
+ try {
525
+ theirsContent = await readFile3(join3(templateDir, file), "utf-8");
526
+ } catch {
527
+ continue;
528
+ }
529
+ const oursContent = await readFile3(oursPath, "utf-8");
530
+ if (oursContent === baseContent) continue;
531
+ if (theirsContent === baseContent) continue;
532
+ const clean = mergeFileThreeWay(oursPath, baseContent, theirsContent);
533
+ if (clean) {
534
+ merged.push(file);
535
+ } else {
536
+ conflicted.push(file);
537
+ }
538
+ }
539
+ return { merged, conflicted };
540
+ }
428
541
  function createOrphanWorktree(cwd) {
429
542
  const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
430
543
  const branch = `projx/tmp-${id}`;
@@ -456,22 +569,22 @@ function cleanupWorktree(cwd, worktree, branch) {
456
569
  }
457
570
  async function removeSkippedFiles(dir, skipPatterns) {
458
571
  if (skipPatterns.length === 0) return;
459
- const { readdir: readdir3, unlink } = await import("fs/promises");
572
+ const { readdir: readdir4, unlink: unlink2 } = await import("fs/promises");
460
573
  const walk = async (current, base) => {
461
- const entries = await readdir3(current, { withFileTypes: true });
574
+ const entries = await readdir4(current, { withFileTypes: true });
462
575
  for (const entry of entries) {
463
576
  const full = join3(current, entry.name);
464
577
  const rel = full.slice(base.length + 1);
465
578
  if (entry.isDirectory()) {
466
579
  await walk(full, base);
467
580
  } else if (entry.name !== ".projx-component" && matchesSkip(rel, skipPatterns)) {
468
- await unlink(full);
581
+ await unlink2(full);
469
582
  }
470
583
  }
471
584
  };
472
585
  await walk(dir, dir);
473
586
  }
474
- async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips) {
587
+ async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip) {
475
588
  const name = vars.projectName;
476
589
  const nameSnake = toSnake(name);
477
590
  for (const component of components) {
@@ -494,21 +607,34 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
494
607
  }
495
608
  await substituteNames(dest, components, componentPaths, name, nameSnake);
496
609
  const hasBackend = components.includes("fastapi") || components.includes("fastify");
610
+ const skip = rootSkip ?? [];
611
+ const shouldWrite = (file) => !matchesSkip(file, skip);
497
612
  if (hasBackend || components.includes("frontend")) {
498
- await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
499
- await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
500
- }
501
- await writeFile2(join3(dest, "README.md"), await generateReadme(vars));
502
- await mkdir2(join3(dest, ".githooks"), { recursive: true });
503
- await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
504
- await chmod(join3(dest, ".githooks/pre-commit"), 493);
505
- await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
506
- await writeFile2(join3(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
507
- await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
508
- await chmod(join3(dest, "setup.sh"), 493);
613
+ if (shouldWrite("docker-compose.yml"))
614
+ await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
615
+ if (shouldWrite("docker-compose.dev.yml"))
616
+ await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
617
+ }
618
+ if (shouldWrite("README.md"))
619
+ await writeFile2(join3(dest, "README.md"), await generateReadme(vars));
620
+ if (shouldWrite(".githooks/pre-commit")) {
621
+ await mkdir2(join3(dest, ".githooks"), { recursive: true });
622
+ await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
623
+ await chmod(join3(dest, ".githooks/pre-commit"), 493);
624
+ }
625
+ if (shouldWrite(".github/workflows/ci.yml")) {
626
+ await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
627
+ await writeFile2(join3(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
628
+ }
629
+ if (shouldWrite("setup.sh")) {
630
+ await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
631
+ await chmod(join3(dest, "setup.sh"), 493);
632
+ }
509
633
  await copyStaticFiles(repoDir, dest);
510
- await mkdir2(join3(dest, ".vscode"), { recursive: true });
511
- await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
634
+ if (shouldWrite(".vscode/settings.json")) {
635
+ await mkdir2(join3(dest, ".vscode"), { recursive: true });
636
+ await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
637
+ }
512
638
  const projxConfig = {
513
639
  version,
514
640
  components,
@@ -534,7 +660,7 @@ async function substituteNames(dest, components, paths, name, nameSnake) {
534
660
  await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
535
661
  }
536
662
  }
537
- async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips) {
663
+ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips, rootSkip) {
538
664
  const hasHead = (() => {
539
665
  try {
540
666
  execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" });
@@ -544,15 +670,15 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
544
670
  }
545
671
  })();
546
672
  if (!hasHead) {
547
- await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips);
673
+ await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
548
674
  return { status: "clean" };
549
675
  }
550
676
  const { worktree, branch } = createOrphanWorktree(cwd);
551
677
  try {
552
- await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips);
678
+ await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
553
679
  execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
554
- const diff = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
555
- if (!diff) {
680
+ const diff2 = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
681
+ if (!diff2) {
556
682
  cleanupWorktree(cwd, worktree, branch);
557
683
  return { status: "clean" };
558
684
  }
@@ -587,9 +713,54 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
587
713
  } catch {
588
714
  }
589
715
  if (mergeClean) {
716
+ saveBaselineRef(cwd);
590
717
  return { status: "clean" };
591
718
  }
592
- await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips);
719
+ const baselineRef = getBaselineRef(cwd);
720
+ if (baselineRef) {
721
+ const tmpTemplate = join3(tmpdir2(), `projx-tpl-${Date.now()}`);
722
+ await mkdir2(tmpTemplate, { recursive: true });
723
+ await writeTemplateToDir(tmpTemplate, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
724
+ const result = await tryThreeWayMerge(cwd, tmpTemplate, baselineRef);
725
+ await rm2(tmpTemplate, { recursive: true, force: true });
726
+ const projxConfig = {
727
+ version,
728
+ components,
729
+ createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
730
+ };
731
+ await writeFile2(join3(cwd, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
732
+ if (result.conflicted.length === 0) {
733
+ execSync2("git add -A", { cwd, stdio: "pipe" });
734
+ const staged = execSync2("git diff --cached --stat", { cwd, stdio: "pipe" }).toString().trim();
735
+ if (staged) {
736
+ execSync2(
737
+ `git commit --no-verify -m "projx: update to template v${version} (3-way merge)"`,
738
+ { cwd, stdio: "pipe" }
739
+ );
740
+ }
741
+ saveBaselineRef(cwd);
742
+ return result.merged.length > 0 ? { status: "merged", mergedFiles: result.merged } : { status: "clean" };
743
+ }
744
+ for (const f of result.conflicted) {
745
+ try {
746
+ execSync2(`git checkout -- "${f}"`, { cwd, stdio: "pipe" });
747
+ } catch {
748
+ }
749
+ }
750
+ for (const f of result.merged) {
751
+ try {
752
+ execSync2(`git add "${f}"`, { cwd, stdio: "pipe" });
753
+ } catch {
754
+ }
755
+ }
756
+ execSync2("git add .projx", { cwd, stdio: "pipe" });
757
+ return {
758
+ status: "conflicts",
759
+ mergedFiles: result.merged,
760
+ conflictedFiles: result.conflicted
761
+ };
762
+ }
763
+ await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
593
764
  return { status: "conflicts" };
594
765
  } catch (err) {
595
766
  cleanupWorktree(cwd, worktree, branch);
@@ -615,17 +786,17 @@ async function scaffold(opts, dest, localRepo) {
615
786
  });
616
787
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
617
788
  try {
618
- const pkg = JSON.parse(await readFile3(join4(repoDir, "cli/package.json"), "utf-8"));
789
+ const pkg = JSON.parse(await readFile4(join4(repoDir, "cli/package.json"), "utf-8"));
619
790
  const version = pkg.version;
620
791
  p2.log.info(`Scaffolding project in ${dest}`);
621
792
  if (opts.git) {
622
793
  exec("git init", dest);
623
794
  exec("git config core.hooksPath .githooks", dest);
624
795
  }
625
- const spinner5 = p2.spinner();
626
- spinner5.start("Scaffolding project");
796
+ const spinner7 = p2.spinner();
797
+ spinner7.start("Scaffolding project");
627
798
  await applyTemplate(dest, repoDir, opts.components, paths, vars, version);
628
- spinner5.stop("Scaffold complete.");
799
+ spinner7.stop("Scaffold complete.");
629
800
  if (opts.install) {
630
801
  await installDeps(dest, opts.components);
631
802
  }
@@ -634,6 +805,7 @@ async function scaffold(opts, dest, localRepo) {
634
805
  try {
635
806
  exec("git add -A", dest);
636
807
  exec('git commit --no-verify -m "Initial scaffold from projx"', dest);
808
+ saveBaselineRef(dest);
637
809
  } catch {
638
810
  }
639
811
  }
@@ -649,44 +821,44 @@ async function scaffold(opts, dest, localRepo) {
649
821
  }
650
822
  async function installDeps(dest, components) {
651
823
  for (const component of components) {
652
- const spinner5 = p2.spinner();
824
+ const spinner7 = p2.spinner();
653
825
  try {
654
826
  switch (component) {
655
827
  case "fastapi":
656
828
  if (hasCommand("uv")) {
657
- spinner5.start("Installing FastAPI dependencies (uv sync)");
829
+ spinner7.start("Installing FastAPI dependencies (uv sync)");
658
830
  exec("uv sync --all-extras", join4(dest, "fastapi"));
659
- spinner5.stop("FastAPI dependencies installed.");
831
+ spinner7.stop("FastAPI dependencies installed.");
660
832
  } else {
661
833
  p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
662
834
  }
663
835
  break;
664
836
  case "fastify":
665
837
  if (hasCommand("pnpm")) {
666
- spinner5.start("Installing Fastify dependencies (pnpm install)");
838
+ spinner7.start("Installing Fastify dependencies (pnpm install)");
667
839
  exec("pnpm install", join4(dest, "fastify"));
668
- spinner5.stop("Fastify dependencies installed.");
840
+ spinner7.stop("Fastify dependencies installed.");
669
841
  } else {
670
- spinner5.start("Installing Fastify dependencies (npm install)");
842
+ spinner7.start("Installing Fastify dependencies (npm install)");
671
843
  exec("npm install", join4(dest, "fastify"));
672
- spinner5.stop("Fastify dependencies installed.");
844
+ spinner7.stop("Fastify dependencies installed.");
673
845
  }
674
846
  break;
675
847
  case "frontend":
676
- spinner5.start("Installing Frontend dependencies (npm install)");
848
+ spinner7.start("Installing Frontend dependencies (npm install)");
677
849
  exec("npm install", join4(dest, "frontend"));
678
- spinner5.stop("Frontend dependencies installed.");
850
+ spinner7.stop("Frontend dependencies installed.");
679
851
  break;
680
852
  case "e2e":
681
- spinner5.start("Installing E2E dependencies (npm install)");
853
+ spinner7.start("Installing E2E dependencies (npm install)");
682
854
  exec("npm install", join4(dest, "e2e"));
683
- spinner5.stop("E2E dependencies installed.");
855
+ spinner7.stop("E2E dependencies installed.");
684
856
  break;
685
857
  case "mobile":
686
858
  if (hasCommand("flutter")) {
687
- spinner5.start("Installing Flutter dependencies");
859
+ spinner7.start("Installing Flutter dependencies");
688
860
  exec("flutter pub get", join4(dest, "mobile"));
689
- spinner5.stop("Flutter dependencies installed.");
861
+ spinner7.stop("Flutter dependencies installed.");
690
862
  } else {
691
863
  p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
692
864
  }
@@ -695,7 +867,7 @@ async function installDeps(dest, components) {
695
867
  break;
696
868
  }
697
869
  } catch {
698
- spinner5.stop(`Failed to install ${component} dependencies.`);
870
+ spinner7.stop(`Failed to install ${component} dependencies.`);
699
871
  }
700
872
  }
701
873
  }
@@ -713,8 +885,8 @@ function copyEnvExamples(dest, components) {
713
885
  }
714
886
 
715
887
  // src/update.ts
716
- import { existsSync as existsSync4, readFileSync } from "fs";
717
- import { readFile as readFile4 } from "fs/promises";
888
+ import { existsSync as existsSync4 } from "fs";
889
+ import { readFile as readFile5, writeFile as writeFile3, unlink } from "fs/promises";
718
890
  import { execSync as execSync3 } from "child_process";
719
891
  import { join as join5 } from "path";
720
892
  import * as p3 from "@clack/prompts";
@@ -736,7 +908,7 @@ async function update(cwd, localRepo) {
736
908
  const configPath = join5(cwd, ".projx");
737
909
  let config;
738
910
  if (existsSync4(configPath)) {
739
- config = JSON.parse(await readFile4(configPath, "utf-8"));
911
+ config = JSON.parse(await readFile5(configPath, "utf-8"));
740
912
  p3.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
741
913
  } else {
742
914
  p3.log.warn("No .projx file found. Detecting components from directories.");
@@ -749,9 +921,9 @@ async function update(cwd, localRepo) {
749
921
  p3.log.info(`Detected: ${detected.join(", ")}`);
750
922
  }
751
923
  const componentPaths = await discoverComponentPaths(cwd, config.components);
752
- const remapped = config.components.filter((c) => componentPaths[c] !== c);
753
- for (const c of remapped) {
754
- p3.log.info(`${c} \u2192 ${componentPaths[c]}/`);
924
+ for (const c of config.components) {
925
+ const dir = componentPaths[c];
926
+ p3.log.info(dir !== c ? `${c} \u2192 ${dir}/` : `${c}/`);
755
927
  }
756
928
  const componentSkips = {};
757
929
  for (const component of config.components) {
@@ -770,27 +942,41 @@ async function update(cwd, localRepo) {
770
942
  });
771
943
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
772
944
  try {
773
- const pkg = JSON.parse(await readFile4(join5(repoDir, "cli/package.json"), "utf-8"));
945
+ const pkg = JSON.parse(await readFile5(join5(repoDir, "cli/package.json"), "utf-8"));
774
946
  const version = pkg.version;
775
947
  const name = detectProjectName(cwd, config.components, componentPaths);
776
948
  const vars = { projectName: name, components: config.components, paths: componentPaths };
777
- const spinner5 = p3.spinner();
778
- spinner5.start("Applying template update");
779
- const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips);
780
- spinner5.stop("Template applied.");
781
- if (result.status === "conflicts") {
782
- p3.log.warn("Some template files differ from your code. Changes written directly.");
783
- p3.log.info("Review changes:");
784
- p3.log.info(" git diff");
785
- p3.log.info("");
786
- p3.log.info("Keep a change: git add <file>");
787
- p3.log.info("Discard a change: git checkout -- <file>");
788
- p3.log.info('Commit when ready: git add . && git commit -m "projx: update to v' + version + '"');
789
- p3.log.info("");
790
- p3.log.info("To skip files on future updates, add to .projx-component:");
791
- p3.log.info(' { "skip": ["src/**", "tests/**"] }');
792
- p3.outro(`Template v${version} applied. Review with git diff.`);
949
+ const spinner7 = p3.spinner();
950
+ spinner7.start("Applying template update");
951
+ const rootSkip = config.skip ?? [];
952
+ const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
953
+ spinner7.stop("Template applied.");
954
+ if (result.status === "merged") {
955
+ saveBaselineRef(cwd);
956
+ p3.log.success(`${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`);
957
+ p3.outro(`Updated to template v${version}.`);
958
+ } else if (result.status === "conflicts") {
959
+ if (result.mergedFiles && result.mergedFiles.length > 0) {
960
+ p3.log.success(`${result.mergedFiles.length} file(s) merged cleanly and staged.`);
961
+ }
962
+ const conflictCount = result.conflictedFiles?.length ?? 0;
963
+ if (conflictCount > 0) {
964
+ p3.log.warn(`${conflictCount} file(s) need review:`);
965
+ for (const f of result.conflictedFiles) {
966
+ p3.log.info(` ${f}`);
967
+ }
968
+ }
969
+ const handled = await promptSkipLearning(cwd, componentPaths, version);
970
+ if (!handled) {
971
+ p3.log.info("");
972
+ p3.log.info("Review: git diff");
973
+ p3.log.info("Keep: git add <file>");
974
+ p3.log.info("Discard: git checkout -- <file>");
975
+ p3.log.info(`Commit: git add . && git commit -m "projx: update to v${version}"`);
976
+ p3.outro(`Template v${version} applied. Review with git diff.`);
977
+ }
793
978
  } else {
979
+ saveBaselineRef(cwd);
794
980
  p3.outro(`Updated to template v${version}.`);
795
981
  }
796
982
  } catch (err) {
@@ -816,27 +1002,105 @@ function hasUncommittedChanges(cwd) {
816
1002
  return false;
817
1003
  }
818
1004
  }
819
- function detectProjectName(cwd, components, componentPaths) {
820
- for (const component of components) {
821
- const dir = componentPaths[component] ?? component;
822
- const pkgPath = join5(cwd, dir, "package.json");
823
- if (existsSync4(pkgPath)) {
1005
+ async function promptSkipLearning(cwd, componentPaths, version) {
1006
+ if (!process.stdin.isTTY) return false;
1007
+ const statusOutput = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1008
+ if (!statusOutput) return false;
1009
+ const entries = statusOutput.split("\n").filter(Boolean).map((line) => ({
1010
+ status: line.slice(0, 2).trim(),
1011
+ file: line.slice(3).trim()
1012
+ }));
1013
+ const changedFiles = entries.map((e) => e.file).filter((f) => {
1014
+ const base = f.split("/").pop();
1015
+ if (base === ".projx" || base === COMPONENT_MARKER) return false;
1016
+ return true;
1017
+ });
1018
+ if (changedFiles.length === 0) return false;
1019
+ p3.log.warn(`${changedFiles.length} template file(s) differ from your code.`);
1020
+ const selected = await p3.multiselect({
1021
+ message: "Select files to KEEP (unselected will be discarded and skipped on future updates)",
1022
+ options: changedFiles.map((f) => ({ value: f, label: f })),
1023
+ required: false
1024
+ });
1025
+ if (p3.isCancel(selected)) return false;
1026
+ const kept = new Set(selected);
1027
+ const discarded = changedFiles.filter((f) => !kept.has(f));
1028
+ if (discarded.length > 0) {
1029
+ for (const file of discarded) {
1030
+ const entry = entries.find((e) => e.file === file);
824
1031
  try {
825
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
826
- const n = pkg.name;
827
- if (n && n.includes("-")) {
828
- return n.substring(0, n.lastIndexOf("-"));
1032
+ if (entry?.status === "??") {
1033
+ await unlink(join5(cwd, file));
1034
+ } else {
1035
+ execSync3(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
829
1036
  }
830
1037
  } catch {
831
1038
  }
832
1039
  }
1040
+ await learnSkips(cwd, discarded, componentPaths);
1041
+ p3.log.success(
1042
+ `Discarded ${discarded.length} file(s) and added to skip list.`
1043
+ );
1044
+ }
1045
+ if (kept.size > 0) {
1046
+ p3.log.info(`${kept.size} file(s) kept \u2014 commit when ready:`);
1047
+ p3.log.info(
1048
+ ` git add . && git commit -m "projx: update to v${version}"`
1049
+ );
1050
+ p3.outro(`Template v${version} applied.`);
1051
+ } else {
1052
+ p3.outro("All template changes discarded. Skip list updated.");
1053
+ }
1054
+ return true;
1055
+ }
1056
+ async function learnSkips(cwd, files, componentPaths) {
1057
+ const componentSkipAdds = {};
1058
+ const rootSkipAdds = [];
1059
+ const dirToComponent = {};
1060
+ for (const [component, dir] of Object.entries(componentPaths)) {
1061
+ dirToComponent[dir] = component;
1062
+ }
1063
+ for (const file of files) {
1064
+ let matched = false;
1065
+ for (const [dir, component] of Object.entries(dirToComponent)) {
1066
+ if (file.startsWith(dir + "/")) {
1067
+ const relative = file.slice(dir.length + 1);
1068
+ if (!componentSkipAdds[component]) componentSkipAdds[component] = [];
1069
+ componentSkipAdds[component].push(relative);
1070
+ matched = true;
1071
+ break;
1072
+ }
1073
+ }
1074
+ if (!matched) {
1075
+ rootSkipAdds.push(file);
1076
+ }
1077
+ }
1078
+ for (const [component, additions] of Object.entries(componentSkipAdds)) {
1079
+ const dir = componentPaths[component];
1080
+ const markerPath = join5(cwd, dir, COMPONENT_MARKER);
1081
+ try {
1082
+ const data = JSON.parse(await readFile5(markerPath, "utf-8"));
1083
+ const existing = data.skip ?? [];
1084
+ data.skip = [.../* @__PURE__ */ new Set([...existing, ...additions])];
1085
+ await writeFile3(markerPath, JSON.stringify(data, null, 2) + "\n");
1086
+ } catch {
1087
+ }
1088
+ }
1089
+ if (rootSkipAdds.length > 0) {
1090
+ const configPath = join5(cwd, ".projx");
1091
+ try {
1092
+ const data = JSON.parse(await readFile5(configPath, "utf-8"));
1093
+ const existing = data.skip ?? [];
1094
+ data.skip = [.../* @__PURE__ */ new Set([...existing, ...rootSkipAdds])];
1095
+ await writeFile3(configPath, JSON.stringify(data, null, 2) + "\n");
1096
+ } catch {
1097
+ }
833
1098
  }
834
- return toKebab(cwd.split("/").pop());
835
1099
  }
836
1100
 
837
1101
  // src/add.ts
838
- import { copyFileSync as copyFileSync2, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
839
- import { readFile as readFile5 } from "fs/promises";
1102
+ import { copyFileSync as copyFileSync2, existsSync as existsSync5 } from "fs";
1103
+ import { readFile as readFile6 } from "fs/promises";
840
1104
  import { join as join6 } from "path";
841
1105
  import * as p4 from "@clack/prompts";
842
1106
  async function add(cwd, newComponents, localRepo, skipInstall = false) {
@@ -847,7 +1111,7 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
847
1111
  p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
848
1112
  process.exit(1);
849
1113
  }
850
- const config = JSON.parse(await readFile5(configPath, "utf-8"));
1114
+ const config = JSON.parse(await readFile6(configPath, "utf-8"));
851
1115
  const existing = config.components;
852
1116
  const alreadyExists = newComponents.filter((c) => existing.includes(c));
853
1117
  if (alreadyExists.length > 0) {
@@ -872,14 +1136,14 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
872
1136
  const existingPaths = await discoverComponentPaths(cwd, existing);
873
1137
  const paths = { ...existingPaths };
874
1138
  for (const c of toAdd) paths[c] = c;
875
- const name = detectProjectName2(cwd, existing, paths);
1139
+ const name = detectProjectName(cwd, existing, paths);
876
1140
  const vars = { projectName: name, components: allComponents, paths };
877
- const pkg = JSON.parse(await readFile5(join6(repoDir, "cli/package.json"), "utf-8"));
1141
+ const pkg = JSON.parse(await readFile6(join6(repoDir, "cli/package.json"), "utf-8"));
878
1142
  const version = pkg.version;
879
- const spinner5 = p4.spinner();
880
- spinner5.start("Adding components");
1143
+ const spinner7 = p4.spinner();
1144
+ spinner7.start("Adding components");
881
1145
  await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, "scaffold");
882
- spinner5.stop("Components added.");
1146
+ spinner7.stop("Components added.");
883
1147
  if (!skipInstall) {
884
1148
  await installDeps2(cwd, toAdd);
885
1149
  }
@@ -902,44 +1166,44 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
902
1166
  }
903
1167
  async function installDeps2(dest, components) {
904
1168
  for (const component of components) {
905
- const spinner5 = p4.spinner();
1169
+ const spinner7 = p4.spinner();
906
1170
  try {
907
1171
  switch (component) {
908
1172
  case "fastapi":
909
1173
  if (hasCommand("uv")) {
910
- spinner5.start("Installing FastAPI dependencies");
1174
+ spinner7.start("Installing FastAPI dependencies");
911
1175
  exec("uv sync --all-extras", join6(dest, "fastapi"));
912
- spinner5.stop("FastAPI dependencies installed.");
1176
+ spinner7.stop("FastAPI dependencies installed.");
913
1177
  } else {
914
1178
  p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
915
1179
  }
916
1180
  break;
917
1181
  case "fastify":
918
1182
  if (hasCommand("pnpm")) {
919
- spinner5.start("Installing Fastify dependencies");
1183
+ spinner7.start("Installing Fastify dependencies");
920
1184
  exec("pnpm install", join6(dest, "fastify"));
921
- spinner5.stop("Fastify dependencies installed.");
1185
+ spinner7.stop("Fastify dependencies installed.");
922
1186
  } else {
923
- spinner5.start("Installing Fastify dependencies");
1187
+ spinner7.start("Installing Fastify dependencies");
924
1188
  exec("npm install", join6(dest, "fastify"));
925
- spinner5.stop("Fastify dependencies installed.");
1189
+ spinner7.stop("Fastify dependencies installed.");
926
1190
  }
927
1191
  break;
928
1192
  case "frontend":
929
- spinner5.start("Installing Frontend dependencies");
1193
+ spinner7.start("Installing Frontend dependencies");
930
1194
  exec("npm install", join6(dest, "frontend"));
931
- spinner5.stop("Frontend dependencies installed.");
1195
+ spinner7.stop("Frontend dependencies installed.");
932
1196
  break;
933
1197
  case "e2e":
934
- spinner5.start("Installing E2E dependencies");
1198
+ spinner7.start("Installing E2E dependencies");
935
1199
  exec("npm install", join6(dest, "e2e"));
936
- spinner5.stop("E2E dependencies installed.");
1200
+ spinner7.stop("E2E dependencies installed.");
937
1201
  break;
938
1202
  case "mobile":
939
1203
  if (hasCommand("flutter")) {
940
- spinner5.start("Installing Flutter dependencies");
1204
+ spinner7.start("Installing Flutter dependencies");
941
1205
  exec("flutter pub get", join6(dest, "mobile"));
942
- spinner5.stop("Flutter dependencies installed.");
1206
+ spinner7.stop("Flutter dependencies installed.");
943
1207
  } else {
944
1208
  p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
945
1209
  }
@@ -948,31 +1212,14 @@ async function installDeps2(dest, components) {
948
1212
  break;
949
1213
  }
950
1214
  } catch {
951
- spinner5.stop(`Failed to install ${component} dependencies.`);
952
- }
953
- }
954
- }
955
- function detectProjectName2(cwd, components, paths) {
956
- for (const component of components) {
957
- const dir = paths[component] ?? component;
958
- const pkgPath = join6(cwd, dir, "package.json");
959
- if (existsSync5(pkgPath)) {
960
- try {
961
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
962
- const n = pkg.name;
963
- if (n && n.includes("-")) {
964
- return n.substring(0, n.lastIndexOf("-"));
965
- }
966
- } catch {
967
- }
1215
+ spinner7.stop(`Failed to install ${component} dependencies.`);
968
1216
  }
969
1217
  }
970
- return toKebab(cwd.split("/").pop());
971
1218
  }
972
1219
 
973
1220
  // src/init.ts
974
1221
  import { existsSync as existsSync7 } from "fs";
975
- import { readFile as readFile6 } from "fs/promises";
1222
+ import { readFile as readFile7 } from "fs/promises";
976
1223
  import { execSync as execSync4 } from "child_process";
977
1224
  import { join as join8 } from "path";
978
1225
  import * as p5 from "@clack/prompts";
@@ -1077,10 +1324,10 @@ async function init(cwd, localRepo) {
1077
1324
  p5.log.error("You have uncommitted changes. Commit or stash them first.");
1078
1325
  process.exit(1);
1079
1326
  }
1080
- const spinner5 = p5.spinner();
1081
- spinner5.start("Scanning for components");
1327
+ const spinner7 = p5.spinner();
1328
+ spinner7.start("Scanning for components");
1082
1329
  const detected = await detectComponents(cwd);
1083
- spinner5.stop(
1330
+ spinner7.stop(
1084
1331
  detected.length > 0 ? `Found ${detected.length} component(s).` : "No components detected."
1085
1332
  );
1086
1333
  let confirmed;
@@ -1108,7 +1355,7 @@ async function init(cwd, localRepo) {
1108
1355
  });
1109
1356
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1110
1357
  try {
1111
- const pkg = JSON.parse(await readFile6(join8(repoDir, "cli/package.json"), "utf-8"));
1358
+ const pkg = JSON.parse(await readFile7(join8(repoDir, "cli/package.json"), "utf-8"));
1112
1359
  const version = pkg.version;
1113
1360
  const applySpinner = p5.spinner();
1114
1361
  applySpinner.start("Applying template");
@@ -1120,6 +1367,9 @@ async function init(cwd, localRepo) {
1120
1367
  } catch {
1121
1368
  }
1122
1369
  }
1370
+ if (result.status === "clean" || result.status === "merged") {
1371
+ saveBaselineRef(cwd);
1372
+ }
1123
1373
  if (result.status === "conflicts") {
1124
1374
  p5.log.warn("Some template files differ from your code. Changes written directly.");
1125
1375
  p5.log.info("Review changes:");
@@ -1197,103 +1447,1734 @@ function hasUncommittedChanges2(cwd) {
1197
1447
  }
1198
1448
  }
1199
1449
 
1200
- // src/index.ts
1201
- var args = process.argv.slice(2);
1202
- function parseArgs() {
1203
- let command = "create";
1204
- let name;
1205
- let localRepo;
1206
- const options = {};
1207
- const extraArgs = [];
1208
- for (let i = 0; i < args.length; i++) {
1209
- const arg = args[i];
1210
- if (arg === "update" && !name) {
1211
- command = "update";
1212
- continue;
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
+ };
1213
1467
  }
1214
- if (arg === "add" && !name) {
1215
- command = "add";
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.`);
1216
1485
  continue;
1217
1486
  }
1218
- if (arg === "init" && !name) {
1219
- command = "init";
1220
- continue;
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);
1221
1493
  }
1222
- if (arg === "--components") {
1223
- const val = args[++i];
1224
- if (val) {
1225
- options.components = val.split(",").filter(
1226
- (c) => COMPONENTS.includes(c)
1227
- );
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.`);
1228
1509
  }
1229
- continue;
1510
+ } catch {
1511
+ p6.log.error(`Could not read marker for ${component}.`);
1230
1512
  }
1231
- if (arg === "--local") {
1232
- localRepo = resolve2(args[++i] || ".");
1233
- continue;
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.");
1234
1524
  }
1235
- if (arg === "--no-git") {
1236
- options.git = false;
1237
- continue;
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);
1238
1546
  }
1239
- if (arg === "--no-install") {
1240
- options.install = false;
1241
- continue;
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}.`);
1242
1569
  }
1243
- if (arg === "-y" || arg === "--yes") {
1244
- options.components = options.components ?? ["fastify", "frontend", "e2e"];
1245
- continue;
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.");
1246
1585
  }
1247
- if (arg === "--help" || arg === "-h") {
1248
- printHelp();
1249
- process.exit(0);
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}`);
1250
1604
  }
1251
- if (!arg.startsWith("-")) {
1252
- if (command === "add") {
1253
- extraArgs.push(arg);
1254
- } else if (!name) {
1255
- name = arg;
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}`);
1256
1615
  }
1257
1616
  }
1258
1617
  }
1259
- return { command, name, options, localRepo, extraArgs };
1618
+ if (!hasAny) {
1619
+ p6.log.info("No pinned files. All template files will be updated.");
1620
+ }
1621
+ p6.outro("");
1260
1622
  }
1261
- function printHelp() {
1262
- console.log(`
1263
- Usage:
1264
- projx <name> [options] Create a new project
1265
- projx init Adopt existing project into projx
1266
- projx add <components...> Add components to existing project
1267
- projx update Update scaffolding to latest
1268
-
1269
- Options:
1270
- --components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
1271
- --no-git Skip git init
1272
- --no-install Skip dependency installation
1273
- -y, --yes Accept defaults (fastify + frontend + e2e)
1274
- --local <path> Use local repo instead of downloading (dev only)
1275
- -h, --help Show this help
1276
1623
 
1277
- Examples:
1278
- npx create-projx my-app
1279
- npx create-projx my-app --components fastapi,frontend,e2e
1280
- npx create-projx my-app -y
1281
- npx create-projx add frontend mobile
1282
- npx create-projx@latest update
1283
- `);
1284
- }
1285
- async function main() {
1286
- const { command, name, options, localRepo, extraArgs } = parseArgs();
1287
- if (command === "init") {
1288
- await init(process.cwd(), localRepo);
1289
- return;
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 };
1290
1641
  }
1291
- if (command === "update") {
1292
- await update(process.cwd(), localRepo);
1293
- return;
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 };
1294
1652
  }
1295
- if (command === "add") {
1296
- const components = extraArgs.filter(
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
+
3026
+ // src/index.ts
3027
+ var args = process.argv.slice(2);
3028
+ function parseArgs() {
3029
+ let command = "create";
3030
+ let name;
3031
+ let localRepo;
3032
+ const options = {};
3033
+ const extraArgs = [];
3034
+ const flags = {};
3035
+ for (let i = 0; i < args.length; i++) {
3036
+ const arg = args[i];
3037
+ if (arg === "update" && !name) {
3038
+ command = "update";
3039
+ continue;
3040
+ }
3041
+ if (arg === "add" && !name) {
3042
+ command = "add";
3043
+ continue;
3044
+ }
3045
+ if (arg === "init" && !name) {
3046
+ command = "init";
3047
+ continue;
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
+ }
3073
+ if (arg === "--components") {
3074
+ const val = args[++i];
3075
+ if (val) {
3076
+ options.components = val.split(",").filter(
3077
+ (c) => COMPONENTS.includes(c)
3078
+ );
3079
+ }
3080
+ continue;
3081
+ }
3082
+ if (arg === "--local") {
3083
+ localRepo = resolve2(args[++i] || ".");
3084
+ continue;
3085
+ }
3086
+ if (arg === "--no-git") {
3087
+ options.git = false;
3088
+ continue;
3089
+ }
3090
+ if (arg === "--no-install") {
3091
+ options.install = false;
3092
+ continue;
3093
+ }
3094
+ if (arg === "-y" || arg === "--yes") {
3095
+ options.components = options.components ?? ["fastify", "frontend", "e2e"];
3096
+ continue;
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
+ }
3111
+ if (arg === "--help" || arg === "-h") {
3112
+ printHelp();
3113
+ process.exit(0);
3114
+ }
3115
+ if (arg === "--fields") {
3116
+ const val = args[++i];
3117
+ if (val) extraArgs.push(`--fields=${val}`);
3118
+ continue;
3119
+ }
3120
+ if (!arg.startsWith("-")) {
3121
+ if (command === "add" || command === "pin" || command === "unpin" || command === "gen") {
3122
+ extraArgs.push(arg);
3123
+ } else if (!name) {
3124
+ name = arg;
3125
+ }
3126
+ }
3127
+ }
3128
+ return { command, name, options, localRepo, extraArgs, flags };
3129
+ }
3130
+ function printHelp() {
3131
+ console.log(`
3132
+ Usage:
3133
+ projx <name> [options] Create a new project
3134
+ projx init Adopt existing project into projx
3135
+ projx add <components...> Add components to existing project
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
3144
+
3145
+ Options:
3146
+ --components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
3147
+ --no-git Skip git init
3148
+ --no-install Skip dependency installation
3149
+ -y, --yes Accept defaults (fastify + frontend + e2e)
3150
+ --local <path> Use local repo instead of downloading (dev only)
3151
+ -h, --help Show this help
3152
+
3153
+ Examples:
3154
+ npx create-projx my-app
3155
+ npx create-projx my-app --components fastapi,frontend,e2e
3156
+ npx create-projx my-app -y
3157
+ npx create-projx add frontend mobile
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"
3164
+ `);
3165
+ }
3166
+ async function main() {
3167
+ const { command, name, options, localRepo, extraArgs, flags } = parseArgs();
3168
+ if (command === "init") {
3169
+ await init(process.cwd(), localRepo);
3170
+ return;
3171
+ }
3172
+ if (command === "update") {
3173
+ await update(process.cwd(), localRepo);
3174
+ return;
3175
+ }
3176
+ if (command === "add") {
3177
+ const components = extraArgs.filter(
1297
3178
  (c) => COMPONENTS.includes(c)
1298
3179
  );
1299
3180
  if (components.length === 0) {
@@ -1303,6 +3184,48 @@ async function main() {
1303
3184
  await add(process.cwd(), components, localRepo, options.install === false);
1304
3185
  return;
1305
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
+ }
1306
3229
  let opts;
1307
3230
  if (options.components) {
1308
3231
  if (!name) {
@@ -1321,7 +3244,7 @@ async function main() {
1321
3244
  opts.install = options.install ?? opts.install;
1322
3245
  }
1323
3246
  const dest = resolve2(process.cwd(), opts.name);
1324
- if (existsSync8(dest)) {
3247
+ if (existsSync13(dest)) {
1325
3248
  console.error(`Error: ${dest} already exists.`);
1326
3249
  process.exit(1);
1327
3250
  }