create-projx 1.3.4 → 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 +26 -12
  2. package/dist/index.js +487 -344
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -87,23 +87,37 @@ 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:
90
+ Updates use a 3-tier merge strategy:
91
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
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.
95
95
 
96
- ```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
96
+ Your custom files (controllers, pages, middleware) are never deleted. Files you created that don't exist in the template are always preserved.
97
+
98
+ ### Skip Files
99
+
100
+ To skip component source files, add `skip` to `.projx-component`:
101
101
 
102
- # Or abort:
103
- git merge --abort
102
+ ```json
103
+ {
104
+ "components": ["fastapi"],
105
+ "origin": "init",
106
+ "skip": ["src/**", "tests/**"]
107
+ }
104
108
  ```
105
109
 
106
- Your custom files (controllers, pages, middleware) are never deleted. Files you created that don't exist in the template are always preserved.
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.
107
121
 
108
122
  ## Options
109
123
 
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
  }
@@ -335,17 +330,16 @@ async function runPrompts(nameArg) {
335
330
 
336
331
  // src/scaffold.ts
337
332
  import { copyFileSync, existsSync as existsSync3 } from "fs";
338
- import { mkdir as mkdir3, readFile as readFile3 } from "fs/promises";
333
+ import { mkdir as mkdir3, readFile as readFile4 } 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
- import { existsSync as existsSync2 } from "fs";
344
- 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";
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,7 @@ 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
- }
404
+ var BASELINE_REF = "refs/projx/baseline";
451
405
  function matchesSkip(filePath, patterns) {
452
406
  for (const pattern of patterns) {
453
407
  if (pattern === "**") return true;
@@ -472,9 +426,130 @@ function matchesSkip(filePath, patterns) {
472
426
  }
473
427
  return false;
474
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
+ }
521
+ function createOrphanWorktree(cwd) {
522
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
523
+ const branch = `projx/tmp-${id}`;
524
+ const worktree = join3(tmpdir2(), `projx-wt-${id}`);
525
+ try {
526
+ execSync2("git worktree prune", { cwd, stdio: "pipe" });
527
+ } catch {
528
+ }
529
+ execSync2(`git worktree add --orphan -b ${branch} "${worktree}"`, {
530
+ cwd,
531
+ stdio: "pipe"
532
+ });
533
+ return { worktree, branch };
534
+ }
535
+ function cleanupWorktree(cwd, worktree, branch) {
536
+ try {
537
+ execSync2(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
538
+ } catch {
539
+ try {
540
+ rm2(worktree, { recursive: true, force: true });
541
+ execSync2("git worktree prune", { cwd, stdio: "pipe" });
542
+ } catch {
543
+ }
544
+ }
545
+ try {
546
+ execSync2(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
547
+ } catch {
548
+ }
549
+ }
475
550
  async function removeSkippedFiles(dir, skipPatterns) {
476
551
  if (skipPatterns.length === 0) return;
477
- const { readdir: readdir3, unlink } = await import("fs/promises");
552
+ const { readdir: readdir3, unlink: unlink2 } = await import("fs/promises");
478
553
  const walk = async (current, base) => {
479
554
  const entries = await readdir3(current, { withFileTypes: true });
480
555
  for (const entry of entries) {
@@ -483,60 +558,67 @@ async function removeSkippedFiles(dir, skipPatterns) {
483
558
  if (entry.isDirectory()) {
484
559
  await walk(full, base);
485
560
  } else if (entry.name !== ".projx-component" && matchesSkip(rel, skipPatterns)) {
486
- await unlink(full);
561
+ await unlink2(full);
487
562
  }
488
563
  }
489
564
  };
490
565
  await walk(dir, dir);
491
566
  }
492
- async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips) {
567
+ async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip) {
493
568
  const name = vars.projectName;
494
569
  const nameSnake = toSnake(name);
495
570
  for (const component of components) {
496
571
  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
572
  const skipPatterns = componentSkips?.[component] ?? [];
573
+ const tmpDir = join3(dest, "__cptmp__");
574
+ await copyComponent(repoDir, component, tmpDir);
575
+ const srcDir = join3(tmpDir, component);
510
576
  if (skipPatterns.length > 0) {
511
- await removeSkippedFiles(join3(dest, targetDir), skipPatterns);
577
+ await removeSkippedFiles(srcDir, skipPatterns);
512
578
  }
579
+ const outDir = join3(dest, targetDir);
580
+ await mkdir2(outDir, { recursive: true });
581
+ const { cp: cp2 } = await import("fs/promises");
582
+ if (existsSync2(srcDir)) {
583
+ await cp2(srcDir, outDir, { recursive: true, force: true });
584
+ }
585
+ await rm2(tmpDir, { recursive: true, force: true });
513
586
  await writeComponentMarker(join3(dest, targetDir), component, origin, skipPatterns.length > 0 ? skipPatterns : void 0);
514
587
  }
515
588
  await substituteNames(dest, components, componentPaths, name, nameSnake);
516
589
  const hasBackend = components.includes("fastapi") || components.includes("fastify");
590
+ const skip = rootSkip ?? [];
591
+ const shouldWrite = (file) => !matchesSkip(file, skip);
517
592
  if (hasBackend || components.includes("frontend")) {
518
- await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
519
- await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
520
- }
521
- await writeFile2(join3(dest, "README.md"), await generateReadme(vars));
522
- await mkdir2(join3(dest, ".githooks"), { recursive: true });
523
- await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
524
- await chmod(join3(dest, ".githooks/pre-commit"), 493);
525
- await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
526
- await writeFile2(join3(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
527
- await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
528
- 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
+ }
529
613
  await copyStaticFiles(repoDir, dest);
530
- await mkdir2(join3(dest, ".vscode"), { recursive: true });
531
- 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
+ }
532
618
  const projxConfig = {
533
619
  version,
534
620
  components,
535
- createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
536
- baseline: {
537
- branch: BASELINE_BRANCH,
538
- templateVersion: version
539
- }
621
+ createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
540
622
  };
541
623
  await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
542
624
  }
@@ -558,90 +640,113 @@ async function substituteNames(dest, components, paths, name, nameSnake) {
558
640
  await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
559
641
  }
560
642
  }
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);
643
+ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips, rootSkip) {
644
+ const hasHead = (() => {
645
+ try {
646
+ execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" });
647
+ return true;
648
+ } catch {
649
+ return false;
650
+ }
651
+ })();
652
+ if (!hasHead) {
653
+ await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
654
+ return { status: "clean" };
572
655
  }
573
- }
574
- async function updateBaseline(cwd, repoDir, components, componentPaths, vars, version, componentSkips) {
575
- const worktree = createWorktree(cwd, BASELINE_BRANCH, false);
656
+ const { worktree, branch } = createOrphanWorktree(cwd);
576
657
  try {
577
- execSync2("git rm -rf .", { cwd: worktree, stdio: "pipe" });
578
- await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, "scaffold", componentSkips);
658
+ await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
579
659
  execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
580
660
  const diff = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
581
661
  if (!diff) {
582
- return { changed: false };
662
+ cleanupWorktree(cwd, worktree, branch);
663
+ return { status: "clean" };
583
664
  }
584
665
  execSync2(
585
- `git commit --no-verify -m "projx: update baseline to template v${version}"`,
666
+ `git commit --no-verify -m "projx: template v${version} [${components.join(", ")}]"`,
586
667
  { cwd: worktree, stdio: "pipe" }
587
668
  );
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
669
  try {
612
- execSync2(`${args2.join(" ")} --no-commit`, { cwd, stdio: "pipe" });
670
+ execSync2(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
613
671
  } catch {
672
+ try {
673
+ await rm2(worktree, { recursive: true, force: true });
674
+ execSync2("git worktree prune", { cwd, stdio: "pipe" });
675
+ } catch {
676
+ }
614
677
  }
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) {
678
+ let mergeClean = false;
679
+ try {
680
+ execSync2(
681
+ `git merge ${branch} --allow-unrelated-histories -m "projx: update to template v${version}"`,
682
+ { cwd, stdio: "pipe" }
683
+ );
684
+ mergeClean = true;
685
+ } catch {
686
+ try {
687
+ execSync2("git merge --abort", { cwd, stdio: "pipe" });
688
+ } catch {
689
+ }
690
+ }
691
+ try {
692
+ execSync2(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
693
+ } catch {
694
+ }
695
+ if (mergeClean) {
696
+ saveBaselineRef(cwd);
626
697
  return { status: "clean" };
627
698
  }
628
- return {
629
- status: "conflicts",
630
- conflictedFiles: conflicted.split("\n").filter(Boolean)
631
- };
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);
744
+ return { status: "conflicts" };
745
+ } catch (err) {
746
+ cleanupWorktree(cwd, worktree, branch);
747
+ throw err;
632
748
  }
633
749
  }
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
750
 
646
751
  // src/scaffold.ts
647
752
  async function scaffold(opts, dest, localRepo) {
@@ -652,39 +757,26 @@ async function scaffold(opts, dest, localRepo) {
652
757
  const vars = { projectName: name, components: opts.components, paths };
653
758
  const isLocal = !!localRepo;
654
759
  await mkdir3(dest, { recursive: true });
655
- const dlSpinner = p3.spinner();
760
+ const dlSpinner = p2.spinner();
656
761
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
657
762
  const repoDir = await downloadRepo(localRepo).catch((err) => {
658
763
  dlSpinner.stop("Failed.");
659
- p3.log.error(String(err));
764
+ p2.log.error(String(err));
660
765
  process.exit(1);
661
766
  });
662
767
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
663
768
  try {
664
- 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"));
665
770
  const version = pkg.version;
666
- p3.log.info(`Scaffolding project in ${dest}`);
771
+ p2.log.info(`Scaffolding project in ${dest}`);
667
772
  if (opts.git) {
668
773
  exec("git init", dest);
669
774
  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
775
  }
776
+ const spinner5 = p2.spinner();
777
+ spinner5.start("Scaffolding project");
778
+ await applyTemplate(dest, repoDir, opts.components, paths, vars, version);
779
+ spinner5.stop("Scaffold complete.");
688
780
  if (opts.install) {
689
781
  await installDeps(dest, opts.components);
690
782
  }
@@ -693,13 +785,14 @@ async function scaffold(opts, dest, localRepo) {
693
785
  try {
694
786
  exec("git add -A", dest);
695
787
  exec('git commit --no-verify -m "Initial scaffold from projx"', dest);
788
+ saveBaselineRef(dest);
696
789
  } catch {
697
790
  }
698
791
  }
699
792
  } finally {
700
793
  await cleanupRepo(repoDir, isLocal);
701
794
  }
702
- p3.outro(`Done! Next steps:
795
+ p2.outro(`Done! Next steps:
703
796
 
704
797
  cd ${name}
705
798
  ./setup.sh
@@ -708,7 +801,7 @@ async function scaffold(opts, dest, localRepo) {
708
801
  }
709
802
  async function installDeps(dest, components) {
710
803
  for (const component of components) {
711
- const spinner5 = p3.spinner();
804
+ const spinner5 = p2.spinner();
712
805
  try {
713
806
  switch (component) {
714
807
  case "fastapi":
@@ -717,7 +810,7 @@ async function installDeps(dest, components) {
717
810
  exec("uv sync --all-extras", join4(dest, "fastapi"));
718
811
  spinner5.stop("FastAPI dependencies installed.");
719
812
  } else {
720
- p3.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
813
+ p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
721
814
  }
722
815
  break;
723
816
  case "fastify":
@@ -747,7 +840,7 @@ async function installDeps(dest, components) {
747
840
  exec("flutter pub get", join4(dest, "mobile"));
748
841
  spinner5.stop("Flutter dependencies installed.");
749
842
  } else {
750
- p3.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
843
+ p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
751
844
  }
752
845
  break;
753
846
  case "infra":
@@ -773,127 +866,101 @@ function copyEnvExamples(dest, components) {
773
866
 
774
867
  // src/update.ts
775
868
  import { existsSync as existsSync4, readFileSync } from "fs";
776
- import { readFile as readFile4 } from "fs/promises";
869
+ import { readFile as readFile5, writeFile as writeFile3, unlink } from "fs/promises";
777
870
  import { execSync as execSync3 } from "child_process";
778
871
  import { join as join5 } from "path";
779
- import * as p4 from "@clack/prompts";
872
+ import * as p3 from "@clack/prompts";
780
873
  async function update(cwd, localRepo) {
781
- p4.intro("projx update");
874
+ p3.intro("projx update");
782
875
  const isLocal = !!localRepo;
783
876
  if (!isGitRepo(cwd)) {
784
- p4.log.error(`projx update requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
877
+ p3.log.error("projx update requires a git repo.");
785
878
  process.exit(1);
786
879
  }
880
+ try {
881
+ execSync3("git worktree prune", { cwd, stdio: "pipe" });
882
+ } catch {
883
+ }
787
884
  if (hasUncommittedChanges(cwd)) {
788
- p4.log.error("You have uncommitted changes. Commit or stash them first.");
885
+ p3.log.error("You have uncommitted changes. Commit or stash them first.");
789
886
  process.exit(1);
790
887
  }
791
888
  const configPath = join5(cwd, ".projx");
792
889
  let config;
793
890
  if (existsSync4(configPath)) {
794
- config = JSON.parse(await readFile4(configPath, "utf-8"));
795
- p4.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
891
+ config = JSON.parse(await readFile5(configPath, "utf-8"));
892
+ p3.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
796
893
  } else {
797
- p4.log.warn("No .projx file found. Detecting components from directories.");
894
+ p3.log.warn("No .projx file found. Detecting components from directories.");
798
895
  const detected = COMPONENTS.filter((c) => existsSync4(join5(cwd, c)));
799
896
  if (detected.length === 0) {
800
- p4.log.error("No projx components found. Run 'projx init' first.");
897
+ p3.log.error("No projx components found. Run 'projx init' first.");
801
898
  process.exit(1);
802
899
  }
803
- config = {
804
- version: "0.0.0",
805
- components: detected,
806
- createdAt: "unknown"
807
- };
808
- p4.log.info(`Detected: ${detected.join(", ")}`);
900
+ config = { version: "0.0.0", components: detected, createdAt: "unknown" };
901
+ p3.log.info(`Detected: ${detected.join(", ")}`);
809
902
  }
810
903
  const componentPaths = await discoverComponentPaths(cwd, config.components);
811
- 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]}/`);
904
+ for (const c of config.components) {
905
+ const dir = componentPaths[c];
906
+ p3.log.info(dir !== c ? `${c} \u2192 ${dir}/` : `${c}/`);
907
+ }
908
+ const componentSkips = {};
909
+ for (const component of config.components) {
910
+ const dir = componentPaths[component];
911
+ const marker = await readComponentMarker(join5(cwd, dir));
912
+ if (marker?.skip && marker.skip.length > 0) {
913
+ componentSkips[component] = marker.skip;
815
914
  }
816
915
  }
817
- const dlSpinner = p4.spinner();
916
+ const dlSpinner = p3.spinner();
818
917
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
819
918
  const repoDir = await downloadRepo(localRepo).catch((err) => {
820
919
  dlSpinner.stop("Failed.");
821
- p4.log.error(String(err));
920
+ p3.log.error(String(err));
822
921
  process.exit(1);
823
922
  });
824
923
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
825
924
  try {
826
- 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"));
827
926
  const version = pkg.version;
828
927
  const name = detectProjectName(cwd, config.components, componentPaths);
829
928
  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] = ["**"];
929
+ const spinner5 = p3.spinner();
930
+ spinner5.start("Applying template update");
931
+ const rootSkip = config.skip ?? [];
932
+ const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
933
+ spinner5.stop("Template applied.");
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.`);
838
941
  }
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
- );
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
+ }
876
948
  }
877
- execSync3('git add -A && git commit --no-verify -m "projx: post-update config"', { cwd, stdio: "pipe" });
878
- }
879
- 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}`);
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.`);
883
957
  }
884
- p4.outro(
885
- "Resolve conflicts, then:\n git add . && git commit\n\nOr abort:\n git merge --abort"
886
- );
887
958
  } else {
888
- p4.outro(`Updated to template v${version}. All changes merged cleanly.`);
959
+ saveBaselineRef(cwd);
960
+ p3.outro(`Updated to template v${version}.`);
889
961
  }
890
962
  } 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.");
963
+ p3.log.error(`Update failed: ${err}`);
897
964
  process.exit(1);
898
965
  } finally {
899
966
  await cleanupRepo(repoDir, isLocal);
@@ -915,6 +982,101 @@ function hasUncommittedChanges(cwd) {
915
982
  return false;
916
983
  }
917
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
+ }
918
1080
  function detectProjectName(cwd, components, componentPaths) {
919
1081
  for (const component of components) {
920
1082
  const dir = componentPaths[component] ?? component;
@@ -935,34 +1097,34 @@ function detectProjectName(cwd, components, componentPaths) {
935
1097
 
936
1098
  // src/add.ts
937
1099
  import { copyFileSync as copyFileSync2, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
938
- import { readFile as readFile5 } from "fs/promises";
1100
+ import { readFile as readFile6 } from "fs/promises";
939
1101
  import { join as join6 } from "path";
940
- import * as p5 from "@clack/prompts";
1102
+ import * as p4 from "@clack/prompts";
941
1103
  async function add(cwd, newComponents, localRepo, skipInstall = false) {
942
- p5.intro("projx add");
1104
+ p4.intro("projx add");
943
1105
  const isLocal = !!localRepo;
944
1106
  const configPath = join6(cwd, ".projx");
945
1107
  if (!existsSync5(configPath)) {
946
- p5.log.error("No .projx file found. Run 'projx <name>' to create a project first.");
1108
+ p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
947
1109
  process.exit(1);
948
1110
  }
949
- const config = JSON.parse(await readFile5(configPath, "utf-8"));
1111
+ const config = JSON.parse(await readFile6(configPath, "utf-8"));
950
1112
  const existing = config.components;
951
1113
  const alreadyExists = newComponents.filter((c) => existing.includes(c));
952
1114
  if (alreadyExists.length > 0) {
953
- p5.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
1115
+ p4.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
954
1116
  }
955
1117
  const toAdd = newComponents.filter((c) => !existing.includes(c));
956
1118
  if (toAdd.length === 0) {
957
- p5.log.info("Nothing new to add.");
1119
+ p4.log.info("Nothing new to add.");
958
1120
  process.exit(0);
959
1121
  }
960
- p5.log.info(`Adding: ${toAdd.join(", ")}`);
961
- const dlSpinner = p5.spinner();
1122
+ p4.log.info(`Adding: ${toAdd.join(", ")}`);
1123
+ const dlSpinner = p4.spinner();
962
1124
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
963
1125
  const repoDir = await downloadRepo(localRepo).catch((err) => {
964
1126
  dlSpinner.stop("Failed.");
965
- p5.log.error(String(err));
1127
+ p4.log.error(String(err));
966
1128
  process.exit(1);
967
1129
  });
968
1130
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
@@ -973,33 +1135,12 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
973
1135
  for (const c of toAdd) paths[c] = c;
974
1136
  const name = detectProjectName2(cwd, existing, paths);
975
1137
  const vars = { projectName: name, components: allComponents, paths };
976
- 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"));
977
1139
  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
- }
1140
+ const spinner5 = p4.spinner();
1141
+ spinner5.start("Adding components");
1142
+ await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, "scaffold");
1143
+ spinner5.stop("Components added.");
1003
1144
  if (!skipInstall) {
1004
1145
  await installDeps2(cwd, toAdd);
1005
1146
  }
@@ -1013,16 +1154,16 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
1013
1154
  }
1014
1155
  }
1015
1156
  }
1157
+ p4.outro(`Added ${toAdd.join(", ")}.
1158
+
1159
+ Like projx? Star it: https://github.com/ukanhaupa/projx`);
1016
1160
  } finally {
1017
1161
  await cleanupRepo(repoDir, isLocal);
1018
1162
  }
1019
- p5.outro(`Added ${toAdd.join(", ")}.
1020
-
1021
- Like projx? Star it: https://github.com/ukanhaupa/projx`);
1022
1163
  }
1023
1164
  async function installDeps2(dest, components) {
1024
1165
  for (const component of components) {
1025
- const spinner5 = p5.spinner();
1166
+ const spinner5 = p4.spinner();
1026
1167
  try {
1027
1168
  switch (component) {
1028
1169
  case "fastapi":
@@ -1031,7 +1172,7 @@ async function installDeps2(dest, components) {
1031
1172
  exec("uv sync --all-extras", join6(dest, "fastapi"));
1032
1173
  spinner5.stop("FastAPI dependencies installed.");
1033
1174
  } else {
1034
- p5.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
1175
+ p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
1035
1176
  }
1036
1177
  break;
1037
1178
  case "fastify":
@@ -1061,7 +1202,7 @@ async function installDeps2(dest, components) {
1061
1202
  exec("flutter pub get", join6(dest, "mobile"));
1062
1203
  spinner5.stop("Flutter dependencies installed.");
1063
1204
  } else {
1064
- p5.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
1205
+ p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
1065
1206
  }
1066
1207
  break;
1067
1208
  case "infra":
@@ -1092,10 +1233,10 @@ function detectProjectName2(cwd, components, paths) {
1092
1233
 
1093
1234
  // src/init.ts
1094
1235
  import { existsSync as existsSync7 } from "fs";
1095
- import { readFile as readFile6 } from "fs/promises";
1236
+ import { readFile as readFile7 } from "fs/promises";
1096
1237
  import { execSync as execSync4 } from "child_process";
1097
1238
  import { join as join8 } from "path";
1098
- import * as p6 from "@clack/prompts";
1239
+ import * as p5 from "@clack/prompts";
1099
1240
 
1100
1241
  // src/detect.ts
1101
1242
  import { existsSync as existsSync6 } from "fs";
@@ -1183,21 +1324,21 @@ async function readPkg(dir) {
1183
1324
 
1184
1325
  // src/init.ts
1185
1326
  async function init(cwd, localRepo) {
1186
- p6.intro("projx init");
1327
+ p5.intro("projx init");
1187
1328
  const isLocal = !!localRepo;
1188
1329
  if (existsSync7(join8(cwd, ".projx"))) {
1189
- p6.log.error("This project is already initialized. Use 'projx update' or 'projx add' instead.");
1330
+ p5.log.error("This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead.");
1190
1331
  process.exit(1);
1191
1332
  }
1192
1333
  if (!isGitRepo2(cwd)) {
1193
- p6.log.error(`projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
1334
+ p5.log.error(`projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
1194
1335
  process.exit(1);
1195
1336
  }
1196
1337
  if (hasUncommittedChanges2(cwd)) {
1197
- p6.log.error("You have uncommitted changes. Commit or stash them first.");
1338
+ p5.log.error("You have uncommitted changes. Commit or stash them first.");
1198
1339
  process.exit(1);
1199
1340
  }
1200
- const spinner5 = p6.spinner();
1341
+ const spinner5 = p5.spinner();
1201
1342
  spinner5.start("Scanning for components");
1202
1343
  const detected = await detectComponents(cwd);
1203
1344
  spinner5.stop(
@@ -1210,7 +1351,7 @@ async function init(cwd, localRepo) {
1210
1351
  confirmed = await manualSelect(cwd);
1211
1352
  }
1212
1353
  if (confirmed.length === 0) {
1213
- p6.log.warn("No components selected. Nothing to do.");
1354
+ p5.log.warn("No components selected. Nothing to do.");
1214
1355
  process.exit(0);
1215
1356
  }
1216
1357
  const components = confirmed.map((c) => c.component);
@@ -1219,55 +1360,57 @@ async function init(cwd, localRepo) {
1219
1360
  );
1220
1361
  const projectName = toKebab(cwd.split("/").pop());
1221
1362
  const vars = { projectName, components, paths };
1222
- const dlSpinner = p6.spinner();
1363
+ const dlSpinner = p5.spinner();
1223
1364
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
1224
1365
  const repoDir = await downloadRepo(localRepo).catch((err) => {
1225
1366
  dlSpinner.stop("Failed.");
1226
- p6.log.error(String(err));
1367
+ p5.log.error(String(err));
1227
1368
  process.exit(1);
1228
1369
  });
1229
1370
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1230
1371
  try {
1231
- 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"));
1232
1373
  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"))) {
1374
+ const applySpinner = p5.spinner();
1375
+ applySpinner.start("Applying template");
1376
+ const result = await applyTemplate(cwd, repoDir, components, paths, vars, version, "init");
1377
+ applySpinner.stop("Template applied.");
1378
+ if (existsSync7(join8(cwd, ".githooks"))) {
1251
1379
  try {
1252
1380
  execSync4("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1253
- p6.log.success("Git hooks configured.");
1254
1381
  } catch {
1255
- p6.log.warn("Failed to configure git hooks.");
1256
1382
  }
1257
1383
  }
1384
+ if (result.status === "clean" || result.status === "merged") {
1385
+ saveBaselineRef(cwd);
1386
+ }
1387
+ if (result.status === "conflicts") {
1388
+ p5.log.warn("Some template files differ from your code. Changes written directly.");
1389
+ p5.log.info("Review changes:");
1390
+ p5.log.info(" git diff");
1391
+ p5.log.info("");
1392
+ p5.log.info("Keep a change: git add <file>");
1393
+ p5.log.info("Discard a change: git checkout -- <file>");
1394
+ p5.log.info('Commit when ready: git add . && git commit -m "projx: init"');
1395
+ p5.log.info("");
1396
+ p5.log.info("To skip files on future updates, add to .projx-component:");
1397
+ p5.log.info(' { "skip": ["src/**", "tests/**"] }');
1398
+ p5.outro("Template applied. Review with git diff.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1399
+ } else {
1400
+ p5.outro("Project initialized.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1401
+ }
1258
1402
  } finally {
1259
1403
  await cleanupRepo(repoDir, isLocal);
1260
1404
  }
1261
- p6.outro("Project initialized. Run './setup.sh' to install dependencies.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1262
1405
  }
1263
1406
  async function confirmDetections(detected) {
1264
1407
  const confirmed = [];
1265
1408
  for (const d of detected) {
1266
- const yes = await p6.confirm({
1409
+ const yes = await p5.confirm({
1267
1410
  message: `Found ${LABELS[d.component].label} in ${d.directory}/ \u2014 register as "${d.component}"?`,
1268
1411
  initialValue: true
1269
1412
  });
1270
- if (p6.isCancel(yes)) process.exit(0);
1413
+ if (p5.isCancel(yes)) process.exit(0);
1271
1414
  if (yes) {
1272
1415
  confirmed.push({ component: d.component, directory: d.directory });
1273
1416
  }
@@ -1275,7 +1418,7 @@ async function confirmDetections(detected) {
1275
1418
  return confirmed;
1276
1419
  }
1277
1420
  async function manualSelect(cwd) {
1278
- const selected = await p6.multiselect({
1421
+ const selected = await p5.multiselect({
1279
1422
  message: "No components detected. Select manually:",
1280
1423
  options: COMPONENTS.map((c) => ({
1281
1424
  value: c,
@@ -1284,17 +1427,17 @@ async function manualSelect(cwd) {
1284
1427
  })),
1285
1428
  required: false
1286
1429
  });
1287
- if (p6.isCancel(selected)) process.exit(0);
1430
+ if (p5.isCancel(selected)) process.exit(0);
1288
1431
  const result = [];
1289
1432
  for (const component of selected) {
1290
- const dir = await p6.text({
1433
+ const dir = await p5.text({
1291
1434
  message: `Directory for ${LABELS[component].label}?`,
1292
1435
  placeholder: component,
1293
1436
  defaultValue: component
1294
1437
  });
1295
- if (p6.isCancel(dir)) process.exit(0);
1438
+ if (p5.isCancel(dir)) process.exit(0);
1296
1439
  if (!existsSync7(join8(cwd, dir))) {
1297
- p6.log.warn(`${dir}/ does not exist \u2014 skipping.`);
1440
+ p5.log.warn(`${dir}/ does not exist \u2014 skipping.`);
1298
1441
  continue;
1299
1442
  }
1300
1443
  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.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": {