create-projx 1.3.5 → 1.3.6

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 +16 -8
  2. package/dist/index.js +312 -48
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -87,19 +87,17 @@ cd my-app
87
87
  npx create-projx@latest update
88
88
  ```
89
89
 
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`:
90
+ Updates use a 3-tier merge strategy:
91
91
 
92
- ```bash
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
96
- ```
92
+ 1. **Git merge** — if the template merges cleanly with your code, it's auto-committed. Done.
93
+ 2. **3-way merge** — if git merge fails, each file is merged individually using `git merge-file`. Your additions (extra deps, env vars, custom config) are preserved alongside template updates. Clean merges are auto-staged; only true conflicts need review.
94
+ 3. **Direct copy** — if no merge baseline exists, template files are written directly. You pick which changes to keep via an interactive prompt, and discarded files are automatically added to your skip list.
97
95
 
98
96
  Your custom files (controllers, pages, middleware) are never deleted. Files you created that don't exist in the template are always preserved.
99
97
 
100
98
  ### Skip Files
101
99
 
102
- If a file keeps getting overwritten on every update, add it to `.projx-component`:
100
+ To skip component source files, add `skip` to `.projx-component`:
103
101
 
104
102
  ```json
105
103
  {
@@ -109,7 +107,17 @@ If a file keeps getting overwritten on every update, add it to `.projx-component
109
107
  }
110
108
  ```
111
109
 
112
- Skipped files are excluded from template updates. Tooling files (Dockerfile, eslint, tsconfig) still get updated.
110
+ To skip root-level files (docker-compose, README), add `skip` to `.projx`:
111
+
112
+ ```json
113
+ {
114
+ "version": "1.3.6",
115
+ "components": ["fastapi", "frontend"],
116
+ "skip": ["docker-compose.yml", "README.md"]
117
+ }
118
+ ```
119
+
120
+ Skipped files are excluded from template updates.
113
121
 
114
122
  ## Options
115
123
 
package/dist/index.js CHANGED
@@ -330,13 +330,13 @@ async function runPrompts(nameArg) {
330
330
 
331
331
  // src/scaffold.ts
332
332
  import { copyFileSync, existsSync as existsSync3 } from "fs";
333
- import { mkdir as mkdir3, readFile as readFile3 } from "fs/promises";
333
+ import { mkdir as mkdir3, readFile as readFile4 } from "fs/promises";
334
334
  import { join as join4 } from "path";
335
335
  import * as p2 from "@clack/prompts";
336
336
 
337
337
  // 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";
338
+ import { existsSync as existsSync2, writeFileSync, unlinkSync } from "fs";
339
+ import { chmod, mkdir as mkdir2, writeFile as writeFile2, rm as rm2, readFile as readFile3 } from "fs/promises";
340
340
  import { execSync as execSync2 } from "child_process";
341
341
  import { join as join3 } from "path";
342
342
  import { tmpdir as tmpdir2 } from "os";
@@ -401,6 +401,7 @@ function generateVscodeSettings(vars) {
401
401
  }
402
402
 
403
403
  // src/baseline.ts
404
+ var BASELINE_REF = "refs/projx/baseline";
404
405
  function matchesSkip(filePath, patterns) {
405
406
  for (const pattern of patterns) {
406
407
  if (pattern === "**") return true;
@@ -425,6 +426,98 @@ function matchesSkip(filePath, patterns) {
425
426
  }
426
427
  return false;
427
428
  }
429
+ function saveBaselineRef(cwd) {
430
+ try {
431
+ const head = execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" }).toString().trim();
432
+ execSync2(`git update-ref ${BASELINE_REF} ${head}`, { cwd, stdio: "pipe" });
433
+ } catch {
434
+ }
435
+ }
436
+ function getBaselineRef(cwd) {
437
+ try {
438
+ return execSync2(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
439
+ } catch {
440
+ }
441
+ try {
442
+ const sha = execSync2("git log -1 --format=%H -- .projx", { cwd, stdio: "pipe" }).toString().trim();
443
+ if (sha) return sha;
444
+ } catch {
445
+ }
446
+ return null;
447
+ }
448
+ function getFileAtRef(cwd, ref, filePath) {
449
+ try {
450
+ return execSync2(`git show ${ref}:"${filePath}"`, { cwd, stdio: "pipe" }).toString();
451
+ } catch {
452
+ return null;
453
+ }
454
+ }
455
+ function mergeFileThreeWay(oursPath, baseContent, theirsContent) {
456
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
457
+ const baseTmp = join3(tmpdir2(), `projx-base-${id}`);
458
+ const theirsTmp = join3(tmpdir2(), `projx-theirs-${id}`);
459
+ try {
460
+ writeFileSync(baseTmp, baseContent);
461
+ writeFileSync(theirsTmp, theirsContent);
462
+ execSync2(`git merge-file "${oursPath}" "${baseTmp}" "${theirsTmp}"`, { stdio: "pipe" });
463
+ return true;
464
+ } catch {
465
+ return false;
466
+ } finally {
467
+ try {
468
+ unlinkSync(baseTmp);
469
+ } catch {
470
+ }
471
+ try {
472
+ unlinkSync(theirsTmp);
473
+ } catch {
474
+ }
475
+ }
476
+ }
477
+ async function collectAllFiles(dir, base) {
478
+ const { readdir: readdir3 } = await import("fs/promises");
479
+ const results = [];
480
+ const walk = async (current) => {
481
+ const entries = await readdir3(current, { withFileTypes: true });
482
+ for (const entry of entries) {
483
+ const full = join3(current, entry.name);
484
+ if (entry.isDirectory()) {
485
+ await walk(full);
486
+ } else {
487
+ results.push(full.slice(base.length + 1));
488
+ }
489
+ }
490
+ };
491
+ await walk(dir);
492
+ return results;
493
+ }
494
+ async function tryThreeWayMerge(cwd, templateDir, baselineRef) {
495
+ const templateFiles = await collectAllFiles(templateDir, templateDir);
496
+ const merged = [];
497
+ const conflicted = [];
498
+ for (const file of templateFiles) {
499
+ const oursPath = join3(cwd, file);
500
+ if (!existsSync2(oursPath)) continue;
501
+ const baseContent = getFileAtRef(cwd, baselineRef, file);
502
+ if (baseContent === null) continue;
503
+ let theirsContent;
504
+ try {
505
+ theirsContent = await readFile3(join3(templateDir, file), "utf-8");
506
+ } catch {
507
+ continue;
508
+ }
509
+ const oursContent = await readFile3(oursPath, "utf-8");
510
+ if (oursContent === baseContent) continue;
511
+ if (theirsContent === baseContent) continue;
512
+ const clean = mergeFileThreeWay(oursPath, baseContent, theirsContent);
513
+ if (clean) {
514
+ merged.push(file);
515
+ } else {
516
+ conflicted.push(file);
517
+ }
518
+ }
519
+ return { merged, conflicted };
520
+ }
428
521
  function createOrphanWorktree(cwd) {
429
522
  const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
430
523
  const branch = `projx/tmp-${id}`;
@@ -456,7 +549,7 @@ function cleanupWorktree(cwd, worktree, branch) {
456
549
  }
457
550
  async function removeSkippedFiles(dir, skipPatterns) {
458
551
  if (skipPatterns.length === 0) return;
459
- const { readdir: readdir3, unlink } = await import("fs/promises");
552
+ const { readdir: readdir3, unlink: unlink2 } = await import("fs/promises");
460
553
  const walk = async (current, base) => {
461
554
  const entries = await readdir3(current, { withFileTypes: true });
462
555
  for (const entry of entries) {
@@ -465,13 +558,13 @@ async function removeSkippedFiles(dir, skipPatterns) {
465
558
  if (entry.isDirectory()) {
466
559
  await walk(full, base);
467
560
  } else if (entry.name !== ".projx-component" && matchesSkip(rel, skipPatterns)) {
468
- await unlink(full);
561
+ await unlink2(full);
469
562
  }
470
563
  }
471
564
  };
472
565
  await walk(dir, dir);
473
566
  }
474
- async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips) {
567
+ async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip) {
475
568
  const name = vars.projectName;
476
569
  const nameSnake = toSnake(name);
477
570
  for (const component of components) {
@@ -494,21 +587,34 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
494
587
  }
495
588
  await substituteNames(dest, components, componentPaths, name, nameSnake);
496
589
  const hasBackend = components.includes("fastapi") || components.includes("fastify");
590
+ const skip = rootSkip ?? [];
591
+ const shouldWrite = (file) => !matchesSkip(file, skip);
497
592
  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);
593
+ if (shouldWrite("docker-compose.yml"))
594
+ await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
595
+ if (shouldWrite("docker-compose.dev.yml"))
596
+ await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
597
+ }
598
+ if (shouldWrite("README.md"))
599
+ await writeFile2(join3(dest, "README.md"), await generateReadme(vars));
600
+ if (shouldWrite(".githooks/pre-commit")) {
601
+ await mkdir2(join3(dest, ".githooks"), { recursive: true });
602
+ await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
603
+ await chmod(join3(dest, ".githooks/pre-commit"), 493);
604
+ }
605
+ if (shouldWrite(".github/workflows/ci.yml")) {
606
+ await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
607
+ await writeFile2(join3(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
608
+ }
609
+ if (shouldWrite("setup.sh")) {
610
+ await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
611
+ await chmod(join3(dest, "setup.sh"), 493);
612
+ }
509
613
  await copyStaticFiles(repoDir, dest);
510
- await mkdir2(join3(dest, ".vscode"), { recursive: true });
511
- await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
614
+ if (shouldWrite(".vscode/settings.json")) {
615
+ await mkdir2(join3(dest, ".vscode"), { recursive: true });
616
+ await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
617
+ }
512
618
  const projxConfig = {
513
619
  version,
514
620
  components,
@@ -534,7 +640,7 @@ async function substituteNames(dest, components, paths, name, nameSnake) {
534
640
  await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
535
641
  }
536
642
  }
537
- async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips) {
643
+ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips, rootSkip) {
538
644
  const hasHead = (() => {
539
645
  try {
540
646
  execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" });
@@ -544,12 +650,12 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
544
650
  }
545
651
  })();
546
652
  if (!hasHead) {
547
- await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips);
653
+ await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
548
654
  return { status: "clean" };
549
655
  }
550
656
  const { worktree, branch } = createOrphanWorktree(cwd);
551
657
  try {
552
- await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips);
658
+ await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
553
659
  execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
554
660
  const diff = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
555
661
  if (!diff) {
@@ -587,9 +693,54 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
587
693
  } catch {
588
694
  }
589
695
  if (mergeClean) {
696
+ saveBaselineRef(cwd);
590
697
  return { status: "clean" };
591
698
  }
592
- await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips);
699
+ const baselineRef = getBaselineRef(cwd);
700
+ if (baselineRef) {
701
+ const tmpTemplate = join3(tmpdir2(), `projx-tpl-${Date.now()}`);
702
+ await mkdir2(tmpTemplate, { recursive: true });
703
+ await writeTemplateToDir(tmpTemplate, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
704
+ const result = await tryThreeWayMerge(cwd, tmpTemplate, baselineRef);
705
+ await rm2(tmpTemplate, { recursive: true, force: true });
706
+ const projxConfig = {
707
+ version,
708
+ components,
709
+ createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
710
+ };
711
+ await writeFile2(join3(cwd, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
712
+ if (result.conflicted.length === 0) {
713
+ execSync2("git add -A", { cwd, stdio: "pipe" });
714
+ const staged = execSync2("git diff --cached --stat", { cwd, stdio: "pipe" }).toString().trim();
715
+ if (staged) {
716
+ execSync2(
717
+ `git commit --no-verify -m "projx: update to template v${version} (3-way merge)"`,
718
+ { cwd, stdio: "pipe" }
719
+ );
720
+ }
721
+ saveBaselineRef(cwd);
722
+ return result.merged.length > 0 ? { status: "merged", mergedFiles: result.merged } : { status: "clean" };
723
+ }
724
+ for (const f of result.conflicted) {
725
+ try {
726
+ execSync2(`git checkout -- "${f}"`, { cwd, stdio: "pipe" });
727
+ } catch {
728
+ }
729
+ }
730
+ for (const f of result.merged) {
731
+ try {
732
+ execSync2(`git add "${f}"`, { cwd, stdio: "pipe" });
733
+ } catch {
734
+ }
735
+ }
736
+ execSync2("git add .projx", { cwd, stdio: "pipe" });
737
+ return {
738
+ status: "conflicts",
739
+ mergedFiles: result.merged,
740
+ conflictedFiles: result.conflicted
741
+ };
742
+ }
743
+ await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
593
744
  return { status: "conflicts" };
594
745
  } catch (err) {
595
746
  cleanupWorktree(cwd, worktree, branch);
@@ -615,7 +766,7 @@ async function scaffold(opts, dest, localRepo) {
615
766
  });
616
767
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
617
768
  try {
618
- const pkg = JSON.parse(await readFile3(join4(repoDir, "cli/package.json"), "utf-8"));
769
+ const pkg = JSON.parse(await readFile4(join4(repoDir, "cli/package.json"), "utf-8"));
619
770
  const version = pkg.version;
620
771
  p2.log.info(`Scaffolding project in ${dest}`);
621
772
  if (opts.git) {
@@ -634,6 +785,7 @@ async function scaffold(opts, dest, localRepo) {
634
785
  try {
635
786
  exec("git add -A", dest);
636
787
  exec('git commit --no-verify -m "Initial scaffold from projx"', dest);
788
+ saveBaselineRef(dest);
637
789
  } catch {
638
790
  }
639
791
  }
@@ -714,7 +866,7 @@ function copyEnvExamples(dest, components) {
714
866
 
715
867
  // src/update.ts
716
868
  import { existsSync as existsSync4, readFileSync } from "fs";
717
- import { readFile as readFile4 } from "fs/promises";
869
+ import { readFile as readFile5, writeFile as writeFile3, unlink } from "fs/promises";
718
870
  import { execSync as execSync3 } from "child_process";
719
871
  import { join as join5 } from "path";
720
872
  import * as p3 from "@clack/prompts";
@@ -736,7 +888,7 @@ async function update(cwd, localRepo) {
736
888
  const configPath = join5(cwd, ".projx");
737
889
  let config;
738
890
  if (existsSync4(configPath)) {
739
- config = JSON.parse(await readFile4(configPath, "utf-8"));
891
+ config = JSON.parse(await readFile5(configPath, "utf-8"));
740
892
  p3.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
741
893
  } else {
742
894
  p3.log.warn("No .projx file found. Detecting components from directories.");
@@ -749,9 +901,9 @@ async function update(cwd, localRepo) {
749
901
  p3.log.info(`Detected: ${detected.join(", ")}`);
750
902
  }
751
903
  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]}/`);
904
+ for (const c of config.components) {
905
+ const dir = componentPaths[c];
906
+ p3.log.info(dir !== c ? `${c} \u2192 ${dir}/` : `${c}/`);
755
907
  }
756
908
  const componentSkips = {};
757
909
  for (const component of config.components) {
@@ -770,27 +922,41 @@ async function update(cwd, localRepo) {
770
922
  });
771
923
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
772
924
  try {
773
- const pkg = JSON.parse(await readFile4(join5(repoDir, "cli/package.json"), "utf-8"));
925
+ const pkg = JSON.parse(await readFile5(join5(repoDir, "cli/package.json"), "utf-8"));
774
926
  const version = pkg.version;
775
927
  const name = detectProjectName(cwd, config.components, componentPaths);
776
928
  const vars = { projectName: name, components: config.components, paths: componentPaths };
777
929
  const spinner5 = p3.spinner();
778
930
  spinner5.start("Applying template update");
779
- const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips);
931
+ const rootSkip = config.skip ?? [];
932
+ const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
780
933
  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.`);
934
+ if (result.status === "merged") {
935
+ saveBaselineRef(cwd);
936
+ p3.log.success(`${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`);
937
+ p3.outro(`Updated to template v${version}.`);
938
+ } else if (result.status === "conflicts") {
939
+ if (result.mergedFiles && result.mergedFiles.length > 0) {
940
+ p3.log.success(`${result.mergedFiles.length} file(s) merged cleanly and staged.`);
941
+ }
942
+ const conflictCount = result.conflictedFiles?.length ?? 0;
943
+ if (conflictCount > 0) {
944
+ p3.log.warn(`${conflictCount} file(s) need review:`);
945
+ for (const f of result.conflictedFiles) {
946
+ p3.log.info(` ${f}`);
947
+ }
948
+ }
949
+ const handled = await promptSkipLearning(cwd, componentPaths, version);
950
+ if (!handled) {
951
+ p3.log.info("");
952
+ p3.log.info("Review: git diff");
953
+ p3.log.info("Keep: git add <file>");
954
+ p3.log.info("Discard: git checkout -- <file>");
955
+ p3.log.info(`Commit: git add . && git commit -m "projx: update to v${version}"`);
956
+ p3.outro(`Template v${version} applied. Review with git diff.`);
957
+ }
793
958
  } else {
959
+ saveBaselineRef(cwd);
794
960
  p3.outro(`Updated to template v${version}.`);
795
961
  }
796
962
  } catch (err) {
@@ -816,6 +982,101 @@ function hasUncommittedChanges(cwd) {
816
982
  return false;
817
983
  }
818
984
  }
985
+ async function promptSkipLearning(cwd, componentPaths, version) {
986
+ if (!process.stdin.isTTY) return false;
987
+ const statusOutput = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
988
+ if (!statusOutput) return false;
989
+ const entries = statusOutput.split("\n").filter(Boolean).map((line) => ({
990
+ status: line.slice(0, 2).trim(),
991
+ file: line.slice(3).trim()
992
+ }));
993
+ const changedFiles = entries.map((e) => e.file).filter((f) => {
994
+ const base = f.split("/").pop();
995
+ if (base === ".projx" || base === COMPONENT_MARKER) return false;
996
+ return true;
997
+ });
998
+ if (changedFiles.length === 0) return false;
999
+ p3.log.warn(`${changedFiles.length} template file(s) differ from your code.`);
1000
+ const selected = await p3.multiselect({
1001
+ message: "Select files to KEEP (unselected will be discarded and skipped on future updates)",
1002
+ options: changedFiles.map((f) => ({ value: f, label: f })),
1003
+ required: false
1004
+ });
1005
+ if (p3.isCancel(selected)) return false;
1006
+ const kept = new Set(selected);
1007
+ const discarded = changedFiles.filter((f) => !kept.has(f));
1008
+ if (discarded.length > 0) {
1009
+ for (const file of discarded) {
1010
+ const entry = entries.find((e) => e.file === file);
1011
+ try {
1012
+ if (entry?.status === "??") {
1013
+ await unlink(join5(cwd, file));
1014
+ } else {
1015
+ execSync3(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
1016
+ }
1017
+ } catch {
1018
+ }
1019
+ }
1020
+ await learnSkips(cwd, discarded, componentPaths);
1021
+ p3.log.success(
1022
+ `Discarded ${discarded.length} file(s) and added to skip list.`
1023
+ );
1024
+ }
1025
+ if (kept.size > 0) {
1026
+ p3.log.info(`${kept.size} file(s) kept \u2014 commit when ready:`);
1027
+ p3.log.info(
1028
+ ` git add . && git commit -m "projx: update to v${version}"`
1029
+ );
1030
+ p3.outro(`Template v${version} applied.`);
1031
+ } else {
1032
+ p3.outro("All template changes discarded. Skip list updated.");
1033
+ }
1034
+ return true;
1035
+ }
1036
+ async function learnSkips(cwd, files, componentPaths) {
1037
+ const componentSkipAdds = {};
1038
+ const rootSkipAdds = [];
1039
+ const dirToComponent = {};
1040
+ for (const [component, dir] of Object.entries(componentPaths)) {
1041
+ dirToComponent[dir] = component;
1042
+ }
1043
+ for (const file of files) {
1044
+ let matched = false;
1045
+ for (const [dir, component] of Object.entries(dirToComponent)) {
1046
+ if (file.startsWith(dir + "/")) {
1047
+ const relative = file.slice(dir.length + 1);
1048
+ if (!componentSkipAdds[component]) componentSkipAdds[component] = [];
1049
+ componentSkipAdds[component].push(relative);
1050
+ matched = true;
1051
+ break;
1052
+ }
1053
+ }
1054
+ if (!matched) {
1055
+ rootSkipAdds.push(file);
1056
+ }
1057
+ }
1058
+ for (const [component, additions] of Object.entries(componentSkipAdds)) {
1059
+ const dir = componentPaths[component];
1060
+ const markerPath = join5(cwd, dir, COMPONENT_MARKER);
1061
+ try {
1062
+ const data = JSON.parse(await readFile5(markerPath, "utf-8"));
1063
+ const existing = data.skip ?? [];
1064
+ data.skip = [.../* @__PURE__ */ new Set([...existing, ...additions])];
1065
+ await writeFile3(markerPath, JSON.stringify(data, null, 2) + "\n");
1066
+ } catch {
1067
+ }
1068
+ }
1069
+ if (rootSkipAdds.length > 0) {
1070
+ const configPath = join5(cwd, ".projx");
1071
+ try {
1072
+ const data = JSON.parse(await readFile5(configPath, "utf-8"));
1073
+ const existing = data.skip ?? [];
1074
+ data.skip = [.../* @__PURE__ */ new Set([...existing, ...rootSkipAdds])];
1075
+ await writeFile3(configPath, JSON.stringify(data, null, 2) + "\n");
1076
+ } catch {
1077
+ }
1078
+ }
1079
+ }
819
1080
  function detectProjectName(cwd, components, componentPaths) {
820
1081
  for (const component of components) {
821
1082
  const dir = componentPaths[component] ?? component;
@@ -836,7 +1097,7 @@ function detectProjectName(cwd, components, componentPaths) {
836
1097
 
837
1098
  // src/add.ts
838
1099
  import { copyFileSync as copyFileSync2, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
839
- import { readFile as readFile5 } from "fs/promises";
1100
+ import { readFile as readFile6 } from "fs/promises";
840
1101
  import { join as join6 } from "path";
841
1102
  import * as p4 from "@clack/prompts";
842
1103
  async function add(cwd, newComponents, localRepo, skipInstall = false) {
@@ -847,7 +1108,7 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
847
1108
  p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
848
1109
  process.exit(1);
849
1110
  }
850
- const config = JSON.parse(await readFile5(configPath, "utf-8"));
1111
+ const config = JSON.parse(await readFile6(configPath, "utf-8"));
851
1112
  const existing = config.components;
852
1113
  const alreadyExists = newComponents.filter((c) => existing.includes(c));
853
1114
  if (alreadyExists.length > 0) {
@@ -874,7 +1135,7 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
874
1135
  for (const c of toAdd) paths[c] = c;
875
1136
  const name = detectProjectName2(cwd, existing, paths);
876
1137
  const vars = { projectName: name, components: allComponents, paths };
877
- const pkg = JSON.parse(await readFile5(join6(repoDir, "cli/package.json"), "utf-8"));
1138
+ const pkg = JSON.parse(await readFile6(join6(repoDir, "cli/package.json"), "utf-8"));
878
1139
  const version = pkg.version;
879
1140
  const spinner5 = p4.spinner();
880
1141
  spinner5.start("Adding components");
@@ -972,7 +1233,7 @@ function detectProjectName2(cwd, components, paths) {
972
1233
 
973
1234
  // src/init.ts
974
1235
  import { existsSync as existsSync7 } from "fs";
975
- import { readFile as readFile6 } from "fs/promises";
1236
+ import { readFile as readFile7 } from "fs/promises";
976
1237
  import { execSync as execSync4 } from "child_process";
977
1238
  import { join as join8 } from "path";
978
1239
  import * as p5 from "@clack/prompts";
@@ -1108,7 +1369,7 @@ async function init(cwd, localRepo) {
1108
1369
  });
1109
1370
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1110
1371
  try {
1111
- const pkg = JSON.parse(await readFile6(join8(repoDir, "cli/package.json"), "utf-8"));
1372
+ const pkg = JSON.parse(await readFile7(join8(repoDir, "cli/package.json"), "utf-8"));
1112
1373
  const version = pkg.version;
1113
1374
  const applySpinner = p5.spinner();
1114
1375
  applySpinner.start("Applying template");
@@ -1120,6 +1381,9 @@ async function init(cwd, localRepo) {
1120
1381
  } catch {
1121
1382
  }
1122
1383
  }
1384
+ if (result.status === "clean" || result.status === "merged") {
1385
+ saveBaselineRef(cwd);
1386
+ }
1123
1387
  if (result.status === "conflicts") {
1124
1388
  p5.log.warn("Some template files differ from your code. Changes written directly.");
1125
1389
  p5.log.info("Review changes:");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-projx",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
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": {