create-projx 1.3.4 → 1.3.5

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 +18 -12
  2. package/dist/index.js +196 -317
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -87,24 +87,30 @@ cd my-app
87
87
  npx create-projx@latest update
88
88
  ```
89
89
 
90
- Uses a `projx/baseline` branch that tracks the raw template state. When you update, the baseline advances to the new template version and merges into your branch using git's three-way merge:
91
-
92
- - **Files only the template changed** — auto-merged, no action needed
93
- - **Files only you changed** — preserved, untouched
94
- - **Files both sides changed** — git conflict, you resolve
90
+ If the template merges cleanly with your code, it's auto-committed done. If there are differences, template files are written directly and you review with `git diff`:
95
91
 
96
92
  ```bash
97
- # If conflicts occur:
98
- git status # see conflicted files
99
- # resolve conflicts in your editor
100
- git add . && git commit # finish the merge
101
-
102
- # Or abort:
103
- git merge --abort
93
+ git diff # see what changed
94
+ git checkout -- path/to/file # discard a change you don't want
95
+ git add . && git commit -m "projx: update to vX.X.X" # commit when ready
104
96
  ```
105
97
 
106
98
  Your custom files (controllers, pages, middleware) are never deleted. Files you created that don't exist in the template are always preserved.
107
99
 
100
+ ### Skip Files
101
+
102
+ If a file keeps getting overwritten on every update, add it to `.projx-component`:
103
+
104
+ ```json
105
+ {
106
+ "components": ["fastapi"],
107
+ "origin": "init",
108
+ "skip": ["src/**", "tests/**"]
109
+ }
110
+ ```
111
+
112
+ Skipped files are excluded from template updates. Tooling files (Dockerfile, eslint, tsconfig) still get updated.
113
+
108
114
  ## Options
109
115
 
110
116
  ```
package/dist/index.js CHANGED
@@ -130,11 +130,6 @@ async function copyStaticFiles(repoDir, dest) {
130
130
  manifest.push(file);
131
131
  }
132
132
  }
133
- const gitignore = join(tpl, ".gitignore");
134
- if (existsSync(gitignore)) {
135
- await cp(gitignore, join(dest, ".gitignore"));
136
- manifest.push(".gitignore");
137
- }
138
133
  const extensionsJson = join(tpl, ".vscode/extensions.json");
139
134
  if (existsSync(extensionsJson)) {
140
135
  await mkdir(join(dest, ".vscode"), { recursive: true });
@@ -282,8 +277,8 @@ function render(template, vars) {
282
277
  (_, expr) => {
283
278
  const parts = expr.split(".");
284
279
  let val = vars;
285
- for (const p7 of parts) {
286
- val = val?.[p7];
280
+ for (const p6 of parts) {
281
+ val = val?.[p6];
287
282
  }
288
283
  return String(val ?? "");
289
284
  }
@@ -337,7 +332,7 @@ async function runPrompts(nameArg) {
337
332
  import { copyFileSync, existsSync as existsSync3 } from "fs";
338
333
  import { mkdir as mkdir3, readFile as readFile3 } from "fs/promises";
339
334
  import { join as join4 } from "path";
340
- import * as p3 from "@clack/prompts";
335
+ import * as p2 from "@clack/prompts";
341
336
 
342
337
  // src/baseline.ts
343
338
  import { existsSync as existsSync2 } from "fs";
@@ -345,7 +340,6 @@ import { chmod, mkdir as mkdir2, writeFile as writeFile2, rm as rm2 } from "fs/p
345
340
  import { execSync as execSync2 } from "child_process";
346
341
  import { join as join3 } from "path";
347
342
  import { tmpdir as tmpdir2 } from "os";
348
- import * as p2 from "@clack/prompts";
349
343
 
350
344
  // src/generators/index.ts
351
345
  import { readFile as readFile2 } from "fs/promises";
@@ -407,47 +401,6 @@ function generateVscodeSettings(vars) {
407
401
  }
408
402
 
409
403
  // src/baseline.ts
410
- var BASELINE_BRANCH = "projx/baseline";
411
- function hasBaseline(cwd) {
412
- try {
413
- execSync2(`git show-ref --verify --quiet refs/heads/${BASELINE_BRANCH}`, {
414
- cwd,
415
- stdio: "pipe"
416
- });
417
- return true;
418
- } catch {
419
- return false;
420
- }
421
- }
422
- function createWorktree(cwd, branch, orphan) {
423
- const worktree = join3(tmpdir2(), `projx-baseline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
424
- if (orphan) {
425
- execSync2(`git worktree add --orphan -b ${branch} "${worktree}"`, {
426
- cwd,
427
- stdio: "pipe"
428
- });
429
- } else {
430
- execSync2(`git worktree add "${worktree}" ${branch}`, {
431
- cwd,
432
- stdio: "pipe"
433
- });
434
- }
435
- return worktree;
436
- }
437
- function removeWorktree(cwd, worktree) {
438
- try {
439
- execSync2(`git worktree remove "${worktree}" --force`, {
440
- cwd,
441
- stdio: "pipe"
442
- });
443
- } catch {
444
- try {
445
- rm2(worktree, { recursive: true, force: true });
446
- execSync2("git worktree prune", { cwd, stdio: "pipe" });
447
- } catch {
448
- }
449
- }
450
- }
451
404
  function matchesSkip(filePath, patterns) {
452
405
  for (const pattern of patterns) {
453
406
  if (pattern === "**") return true;
@@ -472,6 +425,35 @@ function matchesSkip(filePath, patterns) {
472
425
  }
473
426
  return false;
474
427
  }
428
+ function createOrphanWorktree(cwd) {
429
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
430
+ const branch = `projx/tmp-${id}`;
431
+ const worktree = join3(tmpdir2(), `projx-wt-${id}`);
432
+ try {
433
+ execSync2("git worktree prune", { cwd, stdio: "pipe" });
434
+ } catch {
435
+ }
436
+ execSync2(`git worktree add --orphan -b ${branch} "${worktree}"`, {
437
+ cwd,
438
+ stdio: "pipe"
439
+ });
440
+ return { worktree, branch };
441
+ }
442
+ function cleanupWorktree(cwd, worktree, branch) {
443
+ try {
444
+ execSync2(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
445
+ } catch {
446
+ try {
447
+ rm2(worktree, { recursive: true, force: true });
448
+ execSync2("git worktree prune", { cwd, stdio: "pipe" });
449
+ } catch {
450
+ }
451
+ }
452
+ try {
453
+ execSync2(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
454
+ } catch {
455
+ }
456
+ }
475
457
  async function removeSkippedFiles(dir, skipPatterns) {
476
458
  if (skipPatterns.length === 0) return;
477
459
  const { readdir: readdir3, unlink } = await import("fs/promises");
@@ -494,22 +476,20 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
494
476
  const nameSnake = toSnake(name);
495
477
  for (const component of components) {
496
478
  const targetDir = componentPaths[component];
497
- if (targetDir === component) {
498
- await copyComponent(repoDir, component, dest);
499
- } else {
500
- await copyComponent(repoDir, component, join3(dest, "__tmp__"));
501
- const { cp: cp2 } = await import("fs/promises");
502
- const srcDir = join3(dest, "__tmp__", component);
503
- const outDir = join3(dest, targetDir);
504
- if (existsSync2(srcDir)) {
505
- await cp2(srcDir, outDir, { recursive: true, force: true });
506
- }
507
- await rm2(join3(dest, "__tmp__"), { recursive: true, force: true });
508
- }
509
479
  const skipPatterns = componentSkips?.[component] ?? [];
480
+ const tmpDir = join3(dest, "__cptmp__");
481
+ await copyComponent(repoDir, component, tmpDir);
482
+ const srcDir = join3(tmpDir, component);
510
483
  if (skipPatterns.length > 0) {
511
- await removeSkippedFiles(join3(dest, targetDir), skipPatterns);
484
+ await removeSkippedFiles(srcDir, skipPatterns);
485
+ }
486
+ const outDir = join3(dest, targetDir);
487
+ await mkdir2(outDir, { recursive: true });
488
+ const { cp: cp2 } = await import("fs/promises");
489
+ if (existsSync2(srcDir)) {
490
+ await cp2(srcDir, outDir, { recursive: true, force: true });
512
491
  }
492
+ await rm2(tmpDir, { recursive: true, force: true });
513
493
  await writeComponentMarker(join3(dest, targetDir), component, origin, skipPatterns.length > 0 ? skipPatterns : void 0);
514
494
  }
515
495
  await substituteNames(dest, components, componentPaths, name, nameSnake);
@@ -532,11 +512,7 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
532
512
  const projxConfig = {
533
513
  version,
534
514
  components,
535
- createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
536
- baseline: {
537
- branch: BASELINE_BRANCH,
538
- templateVersion: version
539
- }
515
+ createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
540
516
  };
541
517
  await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
542
518
  }
@@ -558,90 +534,68 @@ async function substituteNames(dest, components, paths, name, nameSnake) {
558
534
  await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
559
535
  }
560
536
  }
561
- async function createBaseline(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips) {
562
- const worktree = createWorktree(cwd, BASELINE_BRANCH, true);
563
- try {
564
- await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips);
565
- execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
566
- execSync2(
567
- `git commit --no-verify -m "projx: baseline template v${version} [${components.join(", ")}]"`,
568
- { cwd: worktree, stdio: "pipe" }
569
- );
570
- } finally {
571
- removeWorktree(cwd, worktree);
537
+ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips) {
538
+ const hasHead = (() => {
539
+ try {
540
+ execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" });
541
+ return true;
542
+ } catch {
543
+ return false;
544
+ }
545
+ })();
546
+ if (!hasHead) {
547
+ await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips);
548
+ return { status: "clean" };
572
549
  }
573
- }
574
- async function updateBaseline(cwd, repoDir, components, componentPaths, vars, version, componentSkips) {
575
- const worktree = createWorktree(cwd, BASELINE_BRANCH, false);
550
+ const { worktree, branch } = createOrphanWorktree(cwd);
576
551
  try {
577
- execSync2("git rm -rf .", { cwd: worktree, stdio: "pipe" });
578
- await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, "scaffold", componentSkips);
552
+ await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips);
579
553
  execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
580
554
  const diff = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
581
555
  if (!diff) {
582
- return { changed: false };
556
+ cleanupWorktree(cwd, worktree, branch);
557
+ return { status: "clean" };
583
558
  }
584
559
  execSync2(
585
- `git commit --no-verify -m "projx: update baseline to template v${version}"`,
560
+ `git commit --no-verify -m "projx: template v${version} [${components.join(", ")}]"`,
586
561
  { cwd: worktree, stdio: "pipe" }
587
562
  );
588
- return { changed: true };
589
- } finally {
590
- removeWorktree(cwd, worktree);
591
- }
592
- }
593
- async function addToBaseline(cwd, repoDir, newComponents, allComponents, componentPaths, vars, version) {
594
- const worktree = createWorktree(cwd, BASELINE_BRANCH, false);
595
- try {
596
- await writeTemplateToDir(worktree, repoDir, allComponents, componentPaths, vars, version, "scaffold");
597
- execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
598
- execSync2(
599
- `git commit --no-verify -m "projx: add ${newComponents.join(", ")} template v${version}"`,
600
- { cwd: worktree, stdio: "pipe" }
601
- );
602
- } finally {
603
- removeWorktree(cwd, worktree);
604
- }
605
- }
606
- function mergeBaseline(cwd, message, allowUnrelated = false, oursOnConflict = false) {
607
- const args2 = [`git merge ${BASELINE_BRANCH}`];
608
- args2.push(`-m "${message}"`);
609
- if (allowUnrelated) args2.push("--allow-unrelated-histories");
610
- if (oursOnConflict) {
611
563
  try {
612
- execSync2(`${args2.join(" ")} --no-commit`, { cwd, stdio: "pipe" });
564
+ execSync2(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
613
565
  } catch {
566
+ try {
567
+ await rm2(worktree, { recursive: true, force: true });
568
+ execSync2("git worktree prune", { cwd, stdio: "pipe" });
569
+ } catch {
570
+ }
614
571
  }
615
- execSync2("git checkout --ours .", { cwd, stdio: "pipe" });
616
- execSync2("git add -A", { cwd, stdio: "pipe" });
617
- execSync2(`git commit --no-verify --no-edit -m "${message}"`, { cwd, stdio: "pipe" });
618
- return { status: "clean" };
619
- }
620
- try {
621
- execSync2(args2.join(" "), { cwd, stdio: "pipe" });
622
- return { status: "clean" };
623
- } catch {
624
- const conflicted = execSync2("git diff --name-only --diff-filter=U", { cwd, stdio: "pipe" }).toString().trim();
625
- if (!conflicted) {
572
+ let mergeClean = false;
573
+ try {
574
+ execSync2(
575
+ `git merge ${branch} --allow-unrelated-histories -m "projx: update to template v${version}"`,
576
+ { cwd, stdio: "pipe" }
577
+ );
578
+ mergeClean = true;
579
+ } catch {
580
+ try {
581
+ execSync2("git merge --abort", { cwd, stdio: "pipe" });
582
+ } catch {
583
+ }
584
+ }
585
+ try {
586
+ execSync2(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
587
+ } catch {
588
+ }
589
+ if (mergeClean) {
626
590
  return { status: "clean" };
627
591
  }
628
- return {
629
- status: "conflicts",
630
- conflictedFiles: conflicted.split("\n").filter(Boolean)
631
- };
592
+ await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips);
593
+ return { status: "conflicts" };
594
+ } catch (err) {
595
+ cleanupWorktree(cwd, worktree, branch);
596
+ throw err;
632
597
  }
633
598
  }
634
- async function reconstructBaseline(cwd, repoDir, components, componentPaths, vars, version, componentSkips) {
635
- p2.log.warn("projx/baseline branch not found. Reconstructing...");
636
- await createBaseline(cwd, repoDir, components, componentPaths, vars, version, "scaffold", componentSkips);
637
- mergeBaseline(
638
- cwd,
639
- `projx: reconstructed baseline for template v${version}`,
640
- true,
641
- true
642
- );
643
- p2.log.success("Baseline reconstructed.");
644
- }
645
599
 
646
600
  // src/scaffold.ts
647
601
  async function scaffold(opts, dest, localRepo) {
@@ -652,39 +606,26 @@ async function scaffold(opts, dest, localRepo) {
652
606
  const vars = { projectName: name, components: opts.components, paths };
653
607
  const isLocal = !!localRepo;
654
608
  await mkdir3(dest, { recursive: true });
655
- const dlSpinner = p3.spinner();
609
+ const dlSpinner = p2.spinner();
656
610
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
657
611
  const repoDir = await downloadRepo(localRepo).catch((err) => {
658
612
  dlSpinner.stop("Failed.");
659
- p3.log.error(String(err));
613
+ p2.log.error(String(err));
660
614
  process.exit(1);
661
615
  });
662
616
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
663
617
  try {
664
618
  const pkg = JSON.parse(await readFile3(join4(repoDir, "cli/package.json"), "utf-8"));
665
619
  const version = pkg.version;
666
- p3.log.info(`Scaffolding project in ${dest}`);
620
+ p2.log.info(`Scaffolding project in ${dest}`);
667
621
  if (opts.git) {
668
622
  exec("git init", dest);
669
623
  exec("git config core.hooksPath .githooks", dest);
670
- const spinner5 = p3.spinner();
671
- spinner5.start("Creating baseline and scaffold");
672
- await createBaseline(dest, repoDir, opts.components, paths, vars, version);
673
- const result = mergeBaseline(
674
- dest,
675
- `projx: initial scaffold from template v${version}`,
676
- true
677
- );
678
- spinner5.stop("Scaffold complete.");
679
- if (result.status === "conflicts") {
680
- p3.log.warn("Unexpected conflicts during scaffold \u2014 this shouldn't happen.");
681
- }
682
- } else {
683
- const spinner5 = p3.spinner();
684
- spinner5.start("Copying template files");
685
- await createBaseline(dest, repoDir, opts.components, paths, vars, version);
686
- spinner5.stop("Template files copied.");
687
624
  }
625
+ const spinner5 = p2.spinner();
626
+ spinner5.start("Scaffolding project");
627
+ await applyTemplate(dest, repoDir, opts.components, paths, vars, version);
628
+ spinner5.stop("Scaffold complete.");
688
629
  if (opts.install) {
689
630
  await installDeps(dest, opts.components);
690
631
  }
@@ -699,7 +640,7 @@ async function scaffold(opts, dest, localRepo) {
699
640
  } finally {
700
641
  await cleanupRepo(repoDir, isLocal);
701
642
  }
702
- p3.outro(`Done! Next steps:
643
+ p2.outro(`Done! Next steps:
703
644
 
704
645
  cd ${name}
705
646
  ./setup.sh
@@ -708,7 +649,7 @@ async function scaffold(opts, dest, localRepo) {
708
649
  }
709
650
  async function installDeps(dest, components) {
710
651
  for (const component of components) {
711
- const spinner5 = p3.spinner();
652
+ const spinner5 = p2.spinner();
712
653
  try {
713
654
  switch (component) {
714
655
  case "fastapi":
@@ -717,7 +658,7 @@ async function installDeps(dest, components) {
717
658
  exec("uv sync --all-extras", join4(dest, "fastapi"));
718
659
  spinner5.stop("FastAPI dependencies installed.");
719
660
  } else {
720
- p3.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
661
+ p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
721
662
  }
722
663
  break;
723
664
  case "fastify":
@@ -747,7 +688,7 @@ async function installDeps(dest, components) {
747
688
  exec("flutter pub get", join4(dest, "mobile"));
748
689
  spinner5.stop("Flutter dependencies installed.");
749
690
  } else {
750
- p3.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
691
+ p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
751
692
  }
752
693
  break;
753
694
  case "infra":
@@ -776,49 +717,55 @@ import { existsSync as existsSync4, readFileSync } from "fs";
776
717
  import { readFile as readFile4 } from "fs/promises";
777
718
  import { execSync as execSync3 } from "child_process";
778
719
  import { join as join5 } from "path";
779
- import * as p4 from "@clack/prompts";
720
+ import * as p3 from "@clack/prompts";
780
721
  async function update(cwd, localRepo) {
781
- p4.intro("projx update");
722
+ p3.intro("projx update");
782
723
  const isLocal = !!localRepo;
783
724
  if (!isGitRepo(cwd)) {
784
- p4.log.error(`projx update requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
725
+ p3.log.error("projx update requires a git repo.");
785
726
  process.exit(1);
786
727
  }
728
+ try {
729
+ execSync3("git worktree prune", { cwd, stdio: "pipe" });
730
+ } catch {
731
+ }
787
732
  if (hasUncommittedChanges(cwd)) {
788
- p4.log.error("You have uncommitted changes. Commit or stash them first.");
733
+ p3.log.error("You have uncommitted changes. Commit or stash them first.");
789
734
  process.exit(1);
790
735
  }
791
736
  const configPath = join5(cwd, ".projx");
792
737
  let config;
793
738
  if (existsSync4(configPath)) {
794
739
  config = JSON.parse(await readFile4(configPath, "utf-8"));
795
- p4.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
740
+ p3.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
796
741
  } else {
797
- p4.log.warn("No .projx file found. Detecting components from directories.");
742
+ p3.log.warn("No .projx file found. Detecting components from directories.");
798
743
  const detected = COMPONENTS.filter((c) => existsSync4(join5(cwd, c)));
799
744
  if (detected.length === 0) {
800
- p4.log.error("No projx components found. Run 'projx init' first.");
745
+ p3.log.error("No projx components found. Run 'projx init' first.");
801
746
  process.exit(1);
802
747
  }
803
- config = {
804
- version: "0.0.0",
805
- components: detected,
806
- createdAt: "unknown"
807
- };
808
- p4.log.info(`Detected: ${detected.join(", ")}`);
748
+ config = { version: "0.0.0", components: detected, createdAt: "unknown" };
749
+ p3.log.info(`Detected: ${detected.join(", ")}`);
809
750
  }
810
751
  const componentPaths = await discoverComponentPaths(cwd, config.components);
811
752
  const remapped = config.components.filter((c) => componentPaths[c] !== c);
812
- if (remapped.length > 0) {
813
- for (const c of remapped) {
814
- p4.log.info(`${c} \u2192 ${componentPaths[c]}/`);
753
+ for (const c of remapped) {
754
+ p3.log.info(`${c} \u2192 ${componentPaths[c]}/`);
755
+ }
756
+ const componentSkips = {};
757
+ for (const component of config.components) {
758
+ const dir = componentPaths[component];
759
+ const marker = await readComponentMarker(join5(cwd, dir));
760
+ if (marker?.skip && marker.skip.length > 0) {
761
+ componentSkips[component] = marker.skip;
815
762
  }
816
763
  }
817
- const dlSpinner = p4.spinner();
764
+ const dlSpinner = p3.spinner();
818
765
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
819
766
  const repoDir = await downloadRepo(localRepo).catch((err) => {
820
767
  dlSpinner.stop("Failed.");
821
- p4.log.error(String(err));
768
+ p3.log.error(String(err));
822
769
  process.exit(1);
823
770
  });
824
771
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
@@ -827,73 +774,27 @@ async function update(cwd, localRepo) {
827
774
  const version = pkg.version;
828
775
  const name = detectProjectName(cwd, config.components, componentPaths);
829
776
  const vars = { projectName: name, components: config.components, paths: componentPaths };
830
- const componentSkips = {};
831
- for (const component of config.components) {
832
- const dir = componentPaths[component];
833
- const marker = await readComponentMarker(join5(cwd, dir));
834
- if (marker?.skip && marker.skip.length > 0) {
835
- componentSkips[component] = marker.skip;
836
- } else if (marker?.origin === "init" || !marker?.origin) {
837
- componentSkips[component] = ["**"];
838
- }
839
- }
840
- if (!hasBaseline(cwd)) {
841
- const rebuildSpinner = p4.spinner();
842
- rebuildSpinner.start("Establishing baseline (first-time migration)");
843
- await reconstructBaseline(cwd, repoDir, config.components, componentPaths, vars, config.version || version, componentSkips);
844
- rebuildSpinner.stop("Baseline established.");
845
- }
846
- const updateSpinner = p4.spinner();
847
- updateSpinner.start("Updating baseline to latest template");
848
- const { changed } = await updateBaseline(cwd, repoDir, config.components, componentPaths, vars, version, componentSkips);
849
- if (!changed) {
850
- updateSpinner.stop("Already up to date.");
851
- p4.outro("No template changes to apply.");
852
- return;
853
- }
854
- updateSpinner.stop("Baseline updated.");
855
- const mergeSpinner = p4.spinner();
856
- mergeSpinner.start("Merging template changes");
857
- const result = mergeBaseline(cwd, `projx: update to template v${version}`);
858
- mergeSpinner.stop("Merge complete.");
859
- if (result.status === "clean") {
860
- const { writeFile: writeFile3 } = await import("fs/promises");
861
- const updatedConfig = {
862
- ...config,
863
- version,
864
- baseline: { branch: "projx/baseline", templateVersion: version }
865
- };
866
- await writeFile3(join5(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2) + "\n");
867
- for (const component of config.components) {
868
- const dir = componentPaths[component];
869
- const skip = componentSkips[component];
870
- await writeComponentMarker(
871
- join5(cwd, dir),
872
- component,
873
- skip?.includes("**") ? "init" : "scaffold",
874
- skip
875
- );
876
- }
877
- execSync3('git add -A && git commit --no-verify -m "projx: post-update config"', { cwd, stdio: "pipe" });
878
- }
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.");
879
781
  if (result.status === "conflicts") {
880
- p4.log.warn(`Merge conflicts in ${result.conflictedFiles.length} file(s):`);
881
- for (const f of result.conflictedFiles) {
882
- p4.log.message(` ${f}`);
883
- }
884
- p4.outro(
885
- "Resolve conflicts, then:\n git add . && git commit\n\nOr abort:\n git merge --abort"
886
- );
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.`);
887
793
  } else {
888
- p4.outro(`Updated to template v${version}. All changes merged cleanly.`);
794
+ p3.outro(`Updated to template v${version}.`);
889
795
  }
890
796
  } catch (err) {
891
- try {
892
- execSync3("git merge --abort", { cwd, stdio: "pipe" });
893
- } catch {
894
- }
895
- p4.log.error(`Update failed: ${err}`);
896
- p4.log.info("Your code is safe. Run 'git merge --abort' if needed.");
797
+ p3.log.error(`Update failed: ${err}`);
897
798
  process.exit(1);
898
799
  } finally {
899
800
  await cleanupRepo(repoDir, isLocal);
@@ -937,32 +838,32 @@ function detectProjectName(cwd, components, componentPaths) {
937
838
  import { copyFileSync as copyFileSync2, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
938
839
  import { readFile as readFile5 } from "fs/promises";
939
840
  import { join as join6 } from "path";
940
- import * as p5 from "@clack/prompts";
841
+ import * as p4 from "@clack/prompts";
941
842
  async function add(cwd, newComponents, localRepo, skipInstall = false) {
942
- p5.intro("projx add");
843
+ p4.intro("projx add");
943
844
  const isLocal = !!localRepo;
944
845
  const configPath = join6(cwd, ".projx");
945
846
  if (!existsSync5(configPath)) {
946
- p5.log.error("No .projx file found. Run 'projx <name>' to create a project first.");
847
+ p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
947
848
  process.exit(1);
948
849
  }
949
850
  const config = JSON.parse(await readFile5(configPath, "utf-8"));
950
851
  const existing = config.components;
951
852
  const alreadyExists = newComponents.filter((c) => existing.includes(c));
952
853
  if (alreadyExists.length > 0) {
953
- p5.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
854
+ p4.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
954
855
  }
955
856
  const toAdd = newComponents.filter((c) => !existing.includes(c));
956
857
  if (toAdd.length === 0) {
957
- p5.log.info("Nothing new to add.");
858
+ p4.log.info("Nothing new to add.");
958
859
  process.exit(0);
959
860
  }
960
- p5.log.info(`Adding: ${toAdd.join(", ")}`);
961
- const dlSpinner = p5.spinner();
861
+ p4.log.info(`Adding: ${toAdd.join(", ")}`);
862
+ const dlSpinner = p4.spinner();
962
863
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
963
864
  const repoDir = await downloadRepo(localRepo).catch((err) => {
964
865
  dlSpinner.stop("Failed.");
965
- p5.log.error(String(err));
866
+ p4.log.error(String(err));
966
867
  process.exit(1);
967
868
  });
968
869
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
@@ -975,31 +876,10 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
975
876
  const vars = { projectName: name, components: allComponents, paths };
976
877
  const pkg = JSON.parse(await readFile5(join6(repoDir, "cli/package.json"), "utf-8"));
977
878
  const version = pkg.version;
978
- if (!hasBaseline(cwd)) {
979
- const rebuildSpinner = p5.spinner();
980
- rebuildSpinner.start("Establishing baseline");
981
- await reconstructBaseline(
982
- cwd,
983
- repoDir,
984
- existing,
985
- existingPaths,
986
- { projectName: name, components: existing, paths: existingPaths },
987
- config.version || version
988
- );
989
- rebuildSpinner.stop("Baseline established.");
990
- }
991
- const spinner5 = p5.spinner();
992
- spinner5.start("Adding to baseline");
993
- await addToBaseline(cwd, repoDir, toAdd, allComponents, paths, vars, version);
994
- spinner5.stop("Baseline updated.");
995
- const result = mergeBaseline(cwd, `projx: add ${toAdd.join(", ")} from template v${version}`);
996
- if (result.status === "conflicts") {
997
- p5.log.warn(`Merge conflicts in ${result.conflictedFiles.length} file(s):`);
998
- for (const f of result.conflictedFiles) {
999
- p5.log.message(` ${f}`);
1000
- }
1001
- p5.log.info("Resolve conflicts, then: git add . && git commit");
1002
- }
879
+ const spinner5 = p4.spinner();
880
+ spinner5.start("Adding components");
881
+ await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, "scaffold");
882
+ spinner5.stop("Components added.");
1003
883
  if (!skipInstall) {
1004
884
  await installDeps2(cwd, toAdd);
1005
885
  }
@@ -1013,16 +893,16 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
1013
893
  }
1014
894
  }
1015
895
  }
896
+ p4.outro(`Added ${toAdd.join(", ")}.
897
+
898
+ Like projx? Star it: https://github.com/ukanhaupa/projx`);
1016
899
  } finally {
1017
900
  await cleanupRepo(repoDir, isLocal);
1018
901
  }
1019
- p5.outro(`Added ${toAdd.join(", ")}.
1020
-
1021
- Like projx? Star it: https://github.com/ukanhaupa/projx`);
1022
902
  }
1023
903
  async function installDeps2(dest, components) {
1024
904
  for (const component of components) {
1025
- const spinner5 = p5.spinner();
905
+ const spinner5 = p4.spinner();
1026
906
  try {
1027
907
  switch (component) {
1028
908
  case "fastapi":
@@ -1031,7 +911,7 @@ async function installDeps2(dest, components) {
1031
911
  exec("uv sync --all-extras", join6(dest, "fastapi"));
1032
912
  spinner5.stop("FastAPI dependencies installed.");
1033
913
  } else {
1034
- p5.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
914
+ p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
1035
915
  }
1036
916
  break;
1037
917
  case "fastify":
@@ -1061,7 +941,7 @@ async function installDeps2(dest, components) {
1061
941
  exec("flutter pub get", join6(dest, "mobile"));
1062
942
  spinner5.stop("Flutter dependencies installed.");
1063
943
  } else {
1064
- p5.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
944
+ p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
1065
945
  }
1066
946
  break;
1067
947
  case "infra":
@@ -1095,7 +975,7 @@ import { existsSync as existsSync7 } from "fs";
1095
975
  import { readFile as readFile6 } from "fs/promises";
1096
976
  import { execSync as execSync4 } from "child_process";
1097
977
  import { join as join8 } from "path";
1098
- import * as p6 from "@clack/prompts";
978
+ import * as p5 from "@clack/prompts";
1099
979
 
1100
980
  // src/detect.ts
1101
981
  import { existsSync as existsSync6 } from "fs";
@@ -1183,21 +1063,21 @@ async function readPkg(dir) {
1183
1063
 
1184
1064
  // src/init.ts
1185
1065
  async function init(cwd, localRepo) {
1186
- p6.intro("projx init");
1066
+ p5.intro("projx init");
1187
1067
  const isLocal = !!localRepo;
1188
1068
  if (existsSync7(join8(cwd, ".projx"))) {
1189
- p6.log.error("This project is already initialized. Use 'projx update' or 'projx add' instead.");
1069
+ p5.log.error("This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead.");
1190
1070
  process.exit(1);
1191
1071
  }
1192
1072
  if (!isGitRepo2(cwd)) {
1193
- p6.log.error(`projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
1073
+ p5.log.error(`projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
1194
1074
  process.exit(1);
1195
1075
  }
1196
1076
  if (hasUncommittedChanges2(cwd)) {
1197
- p6.log.error("You have uncommitted changes. Commit or stash them first.");
1077
+ p5.log.error("You have uncommitted changes. Commit or stash them first.");
1198
1078
  process.exit(1);
1199
1079
  }
1200
- const spinner5 = p6.spinner();
1080
+ const spinner5 = p5.spinner();
1201
1081
  spinner5.start("Scanning for components");
1202
1082
  const detected = await detectComponents(cwd);
1203
1083
  spinner5.stop(
@@ -1210,7 +1090,7 @@ async function init(cwd, localRepo) {
1210
1090
  confirmed = await manualSelect(cwd);
1211
1091
  }
1212
1092
  if (confirmed.length === 0) {
1213
- p6.log.warn("No components selected. Nothing to do.");
1093
+ p5.log.warn("No components selected. Nothing to do.");
1214
1094
  process.exit(0);
1215
1095
  }
1216
1096
  const components = confirmed.map((c) => c.component);
@@ -1219,55 +1099,54 @@ async function init(cwd, localRepo) {
1219
1099
  );
1220
1100
  const projectName = toKebab(cwd.split("/").pop());
1221
1101
  const vars = { projectName, components, paths };
1222
- const dlSpinner = p6.spinner();
1102
+ const dlSpinner = p5.spinner();
1223
1103
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
1224
1104
  const repoDir = await downloadRepo(localRepo).catch((err) => {
1225
1105
  dlSpinner.stop("Failed.");
1226
- p6.log.error(String(err));
1106
+ p5.log.error(String(err));
1227
1107
  process.exit(1);
1228
1108
  });
1229
1109
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1230
1110
  try {
1231
1111
  const pkg = JSON.parse(await readFile6(join8(repoDir, "cli/package.json"), "utf-8"));
1232
1112
  const version = pkg.version;
1233
- const componentSkips = {};
1234
- for (const c of components) {
1235
- componentSkips[c] = ["**"];
1236
- }
1237
- const baselineSpinner = p6.spinner();
1238
- baselineSpinner.start("Creating template baseline");
1239
- await createBaseline(cwd, repoDir, components, paths, vars, version, "init", componentSkips);
1240
- baselineSpinner.stop("Baseline created.");
1241
- const mergeSpinner = p6.spinner();
1242
- mergeSpinner.start("Merging baseline (preserving your code)");
1243
- mergeBaseline(
1244
- cwd,
1245
- `projx: adopt template v${version} as baseline`,
1246
- true,
1247
- true
1248
- );
1249
- mergeSpinner.stop("Baseline merged. Your code is preserved.");
1250
- if (!existsSync7(join8(cwd, ".githooks"))) {
1113
+ const applySpinner = p5.spinner();
1114
+ applySpinner.start("Applying template");
1115
+ const result = await applyTemplate(cwd, repoDir, components, paths, vars, version, "init");
1116
+ applySpinner.stop("Template applied.");
1117
+ if (existsSync7(join8(cwd, ".githooks"))) {
1251
1118
  try {
1252
1119
  execSync4("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1253
- p6.log.success("Git hooks configured.");
1254
1120
  } catch {
1255
- p6.log.warn("Failed to configure git hooks.");
1256
1121
  }
1257
1122
  }
1123
+ if (result.status === "conflicts") {
1124
+ p5.log.warn("Some template files differ from your code. Changes written directly.");
1125
+ p5.log.info("Review changes:");
1126
+ p5.log.info(" git diff");
1127
+ p5.log.info("");
1128
+ p5.log.info("Keep a change: git add <file>");
1129
+ p5.log.info("Discard a change: git checkout -- <file>");
1130
+ p5.log.info('Commit when ready: git add . && git commit -m "projx: init"');
1131
+ p5.log.info("");
1132
+ p5.log.info("To skip files on future updates, add to .projx-component:");
1133
+ p5.log.info(' { "skip": ["src/**", "tests/**"] }');
1134
+ p5.outro("Template applied. Review with git diff.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1135
+ } else {
1136
+ p5.outro("Project initialized.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1137
+ }
1258
1138
  } finally {
1259
1139
  await cleanupRepo(repoDir, isLocal);
1260
1140
  }
1261
- p6.outro("Project initialized. Run './setup.sh' to install dependencies.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1262
1141
  }
1263
1142
  async function confirmDetections(detected) {
1264
1143
  const confirmed = [];
1265
1144
  for (const d of detected) {
1266
- const yes = await p6.confirm({
1145
+ const yes = await p5.confirm({
1267
1146
  message: `Found ${LABELS[d.component].label} in ${d.directory}/ \u2014 register as "${d.component}"?`,
1268
1147
  initialValue: true
1269
1148
  });
1270
- if (p6.isCancel(yes)) process.exit(0);
1149
+ if (p5.isCancel(yes)) process.exit(0);
1271
1150
  if (yes) {
1272
1151
  confirmed.push({ component: d.component, directory: d.directory });
1273
1152
  }
@@ -1275,7 +1154,7 @@ async function confirmDetections(detected) {
1275
1154
  return confirmed;
1276
1155
  }
1277
1156
  async function manualSelect(cwd) {
1278
- const selected = await p6.multiselect({
1157
+ const selected = await p5.multiselect({
1279
1158
  message: "No components detected. Select manually:",
1280
1159
  options: COMPONENTS.map((c) => ({
1281
1160
  value: c,
@@ -1284,17 +1163,17 @@ async function manualSelect(cwd) {
1284
1163
  })),
1285
1164
  required: false
1286
1165
  });
1287
- if (p6.isCancel(selected)) process.exit(0);
1166
+ if (p5.isCancel(selected)) process.exit(0);
1288
1167
  const result = [];
1289
1168
  for (const component of selected) {
1290
- const dir = await p6.text({
1169
+ const dir = await p5.text({
1291
1170
  message: `Directory for ${LABELS[component].label}?`,
1292
1171
  placeholder: component,
1293
1172
  defaultValue: component
1294
1173
  });
1295
- if (p6.isCancel(dir)) process.exit(0);
1174
+ if (p5.isCancel(dir)) process.exit(0);
1296
1175
  if (!existsSync7(join8(cwd, dir))) {
1297
- p6.log.warn(`${dir}/ does not exist \u2014 skipping.`);
1176
+ p5.log.warn(`${dir}/ does not exist \u2014 skipping.`);
1298
1177
  continue;
1299
1178
  }
1300
1179
  result.push({ component, directory: dir });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-projx",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "description": "Scaffold production-grade fullstack projects in seconds. FastAPI, Fastify, React, Flutter, Terraform — with auth, database, CI/CD, E2E tests, and Docker. One command, ready to deploy.",
5
5
  "type": "module",
6
6
  "bin": {