create-projx 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { existsSync as existsSync7 } from "fs";
4
+ import { existsSync as existsSync8 } from "fs";
5
5
  import { resolve as resolve2 } from "path";
6
6
 
7
7
  // src/utils.ts
@@ -185,14 +185,32 @@ async function readFileOrNull(path) {
185
185
  return null;
186
186
  }
187
187
  }
188
- async function writeComponentMarker(dir, component) {
188
+ async function readComponentMarker(dir) {
189
+ const raw = await readFileOrNull(join(dir, COMPONENT_MARKER));
190
+ if (!raw) return null;
191
+ try {
192
+ const data = JSON.parse(raw);
193
+ return {
194
+ components: data.components ?? (data.component ? [data.component] : []),
195
+ origin: data.origin ?? "scaffold",
196
+ skip: data.skip
197
+ };
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+ async function writeComponentMarker(dir, component, origin = "scaffold", skip) {
189
203
  const markerPath = join(dir, COMPONENT_MARKER);
190
204
  let components = [component];
205
+ let existingOrigin = origin;
206
+ let existingSkip = skip;
191
207
  const existing = await readFileOrNull(markerPath);
192
208
  if (existing) {
193
209
  try {
194
210
  const data = JSON.parse(existing);
195
211
  const prev = data.components ?? (data.component ? [data.component] : []);
212
+ existingOrigin = data.origin ?? origin;
213
+ existingSkip = skip ?? data.skip;
196
214
  if (!prev.includes(component)) {
197
215
  components = [...prev, component];
198
216
  } else {
@@ -201,10 +219,9 @@ async function writeComponentMarker(dir, component) {
201
219
  } catch {
202
220
  }
203
221
  }
204
- await writeFile(
205
- markerPath,
206
- JSON.stringify({ components }, null, 2) + "\n"
207
- );
222
+ const marker = { components, origin: existingOrigin };
223
+ if (existingSkip && existingSkip.length > 0) marker.skip = existingSkip;
224
+ await writeFile(markerPath, JSON.stringify(marker, null, 2) + "\n");
208
225
  }
209
226
  async function discoverComponentPaths(cwd, components) {
210
227
  const paths = {};
@@ -265,8 +282,8 @@ function render(template, vars) {
265
282
  (_, expr) => {
266
283
  const parts = expr.split(".");
267
284
  let val = vars;
268
- for (const p6 of parts) {
269
- val = val?.[p6];
285
+ for (const p7 of parts) {
286
+ val = val?.[p7];
270
287
  }
271
288
  return String(val ?? "");
272
289
  }
@@ -317,9 +334,17 @@ async function runPrompts(nameArg) {
317
334
  }
318
335
 
319
336
  // src/scaffold.ts
320
- import { copyFileSync, existsSync as existsSync2 } from "fs";
321
- import { chmod, mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
337
+ import { copyFileSync, existsSync as existsSync3 } from "fs";
338
+ import { mkdir as mkdir3, readFile as readFile3 } from "fs/promises";
339
+ import { join as join4 } from "path";
340
+ import * as p3 from "@clack/prompts";
341
+
342
+ // 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";
345
+ import { execSync as execSync2 } from "child_process";
322
346
  import { join as join3 } from "path";
347
+ import { tmpdir as tmpdir2 } from "os";
323
348
  import * as p2 from "@clack/prompts";
324
349
 
325
350
  // src/generators/index.ts
@@ -381,42 +406,110 @@ function generateVscodeSettings(vars) {
381
406
  return JSON.stringify(settings, null, 2) + "\n";
382
407
  }
383
408
 
384
- // src/scaffold.ts
385
- async function scaffold(opts, dest, localRepo) {
386
- const name = toKebab(opts.name);
387
- const nameSnake = toSnake(opts.name);
388
- const paths = Object.fromEntries(
389
- opts.components.map((c) => [c, c])
390
- );
391
- const vars = { projectName: name, components: opts.components, paths };
392
- const isLocal = !!localRepo;
393
- await mkdir2(dest, { recursive: true });
394
- const dlSpinner = p2.spinner();
395
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
396
- const repoDir = await downloadRepo(localRepo).catch((err) => {
397
- dlSpinner.stop("Failed.");
398
- p2.log.error(String(err));
399
- process.exit(1);
400
- });
401
- dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
409
+ // src/baseline.ts
410
+ var BASELINE_BRANCH = "projx/baseline";
411
+ function hasBaseline(cwd) {
402
412
  try {
403
- await doScaffold(opts, dest, repoDir, name, nameSnake, vars);
404
- } finally {
405
- await cleanupRepo(repoDir, isLocal);
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
+ });
406
434
  }
435
+ return worktree;
407
436
  }
408
- async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
409
- p2.log.info(`Scaffolding project in ${dest}`);
410
- for (const component of opts.components) {
411
- const spinner5 = p2.spinner();
412
- spinner5.start(`Copying ${component}/`);
413
- await copyComponent(repoDir, component, dest);
414
- await writeComponentMarker(join3(dest, component), component);
415
- spinner5.stop(`${component}/`);
416
- }
417
- await substituteNames(dest, opts.components, name, nameSnake);
418
- const hasBackend = opts.components.includes("fastapi") || opts.components.includes("fastify");
419
- if (hasBackend || opts.components.includes("frontend")) {
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
+ function matchesSkip(filePath, patterns) {
452
+ for (const pattern of patterns) {
453
+ if (pattern === "**") return true;
454
+ if (pattern.endsWith("/**")) {
455
+ const prefix = pattern.slice(0, -3);
456
+ if (filePath.startsWith(prefix + "/") || filePath === prefix) return true;
457
+ }
458
+ if (pattern.startsWith("**/")) {
459
+ const suffix = pattern.slice(3);
460
+ if (filePath.endsWith(suffix) || filePath.includes("/" + suffix)) return true;
461
+ }
462
+ if (pattern.startsWith("*.")) {
463
+ const ext = pattern.slice(1);
464
+ if (filePath.endsWith(ext)) return true;
465
+ }
466
+ if (filePath === pattern) return true;
467
+ }
468
+ return false;
469
+ }
470
+ async function removeSkippedFiles(dir, skipPatterns) {
471
+ if (skipPatterns.length === 0) return;
472
+ const { readdir: readdir3, unlink } = await import("fs/promises");
473
+ const walk = async (current, base) => {
474
+ const entries = await readdir3(current, { withFileTypes: true });
475
+ for (const entry of entries) {
476
+ const full = join3(current, entry.name);
477
+ const rel = full.slice(base.length + 1);
478
+ if (entry.isDirectory()) {
479
+ await walk(full, base);
480
+ } else if (entry.name !== ".projx-component" && matchesSkip(rel, skipPatterns)) {
481
+ await unlink(full);
482
+ }
483
+ }
484
+ };
485
+ await walk(dir, dir);
486
+ }
487
+ async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips) {
488
+ const name = vars.projectName;
489
+ const nameSnake = toSnake(name);
490
+ for (const component of components) {
491
+ const targetDir = componentPaths[component];
492
+ if (targetDir === component) {
493
+ await copyComponent(repoDir, component, dest);
494
+ } else {
495
+ await copyComponent(repoDir, component, join3(dest, "__tmp__"));
496
+ const { cp: cp2 } = await import("fs/promises");
497
+ const srcDir = join3(dest, "__tmp__", component);
498
+ const outDir = join3(dest, targetDir);
499
+ if (existsSync2(srcDir)) {
500
+ await cp2(srcDir, outDir, { recursive: true, force: true });
501
+ }
502
+ await rm2(join3(dest, "__tmp__"), { recursive: true, force: true });
503
+ }
504
+ const skipPatterns = componentSkips?.[component] ?? [];
505
+ if (skipPatterns.length > 0) {
506
+ await removeSkippedFiles(join3(dest, targetDir), skipPatterns);
507
+ }
508
+ await writeComponentMarker(join3(dest, targetDir), component, origin, skipPatterns.length > 0 ? skipPatterns : void 0);
509
+ }
510
+ await substituteNames(dest, components, componentPaths, name, nameSnake);
511
+ const hasBackend = components.includes("fastapi") || components.includes("fastify");
512
+ if (hasBackend || components.includes("frontend")) {
420
513
  await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
421
514
  await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
422
515
  }
@@ -431,130 +524,225 @@ async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
431
524
  await copyStaticFiles(repoDir, dest);
432
525
  await mkdir2(join3(dest, ".vscode"), { recursive: true });
433
526
  await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
434
- const pkg = JSON.parse(
435
- await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
436
- );
437
527
  const projxConfig = {
438
- version: pkg.version,
439
- components: opts.components,
440
- createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
441
- };
442
- await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2));
443
- if (opts.git) {
444
- try {
445
- exec("git init", dest);
446
- exec("git config core.hooksPath .githooks", dest);
447
- p2.log.success("Git initialized with hooks.");
448
- } catch {
449
- p2.log.warn("Failed to initialize git.");
450
- }
451
- }
452
- if (opts.install) {
453
- await installDeps(dest, opts.components);
454
- }
455
- copyEnvExamples(dest, opts.components);
456
- if (opts.git) {
457
- try {
458
- exec("git add -A", dest);
459
- exec('git commit -m "Initial scaffold from projx"', dest);
460
- p2.log.success("Initial commit created.");
461
- } catch {
528
+ version,
529
+ components,
530
+ createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
531
+ baseline: {
532
+ branch: BASELINE_BRANCH,
533
+ templateVersion: version
462
534
  }
463
- }
464
- p2.outro(`Done! Next steps:
465
-
466
- cd ${name}
467
- ./setup.sh
468
-
469
- Like projx? Star it: https://github.com/ukanhaupa/projx`);
535
+ };
536
+ await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
470
537
  }
471
- async function substituteNames(dest, components, name, nameSnake) {
538
+ async function substituteNames(dest, components, paths, name, nameSnake) {
472
539
  if (components.includes("fastapi")) {
473
- await replaceInFile(
474
- join3(dest, "fastapi/pyproject.toml"),
475
- "projx-fastapi",
476
- `${name}-fastapi`
477
- );
540
+ await replaceInFile(join3(dest, `${paths.fastapi}/pyproject.toml`), "projx-fastapi", `${name}-fastapi`);
478
541
  }
479
542
  if (components.includes("fastify")) {
480
- await replaceInFile(
481
- join3(dest, "fastify/package.json"),
482
- "projx-fastify",
483
- `${name}-fastify`
484
- );
543
+ await replaceInFile(join3(dest, `${paths.fastify}/package.json`), "projx-fastify", `${name}-fastify`);
485
544
  }
486
545
  if (components.includes("frontend")) {
487
- await replaceInFile(
488
- join3(dest, "frontend/package.json"),
489
- "projx-frontend",
490
- `${name}-frontend`
491
- );
546
+ await replaceInFile(join3(dest, `${paths.frontend}/package.json`), "projx-frontend", `${name}-frontend`);
492
547
  }
493
548
  if (components.includes("e2e")) {
494
- await replaceInFile(
495
- join3(dest, "e2e/package.json"),
496
- "projx-e2e",
497
- `${name}-e2e`
498
- );
549
+ await replaceInFile(join3(dest, `${paths.e2e}/package.json`), "projx-e2e", `${name}-e2e`);
499
550
  }
500
551
  if (components.includes("mobile")) {
501
- await replaceInFile(
502
- join3(dest, "mobile/pubspec.yaml"),
503
- "projx_mobile",
504
- `${nameSnake}_mobile`
552
+ await replaceInFile(join3(dest, `${paths.mobile}/pubspec.yaml`), "projx_mobile", `${nameSnake}_mobile`);
553
+ await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
554
+ }
555
+ }
556
+ async function createBaseline(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips) {
557
+ const worktree = createWorktree(cwd, BASELINE_BRANCH, true);
558
+ try {
559
+ await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips);
560
+ execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
561
+ execSync2(
562
+ `git commit --no-verify -m "projx: baseline template v${version} [${components.join(", ")}]"`,
563
+ { cwd: worktree, stdio: "pipe" }
505
564
  );
506
- await replaceInDir(
507
- join3(dest, "mobile"),
508
- "package:projx_mobile/",
509
- `package:${nameSnake}_mobile/`,
510
- ".dart"
565
+ } finally {
566
+ removeWorktree(cwd, worktree);
567
+ }
568
+ }
569
+ async function updateBaseline(cwd, repoDir, components, componentPaths, vars, version, componentSkips) {
570
+ const worktree = createWorktree(cwd, BASELINE_BRANCH, false);
571
+ try {
572
+ execSync2("git rm -rf .", { cwd: worktree, stdio: "pipe" });
573
+ await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, "scaffold", componentSkips);
574
+ execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
575
+ const diff = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
576
+ if (!diff) {
577
+ return { changed: false };
578
+ }
579
+ execSync2(
580
+ `git commit --no-verify -m "projx: update baseline to template v${version}"`,
581
+ { cwd: worktree, stdio: "pipe" }
582
+ );
583
+ return { changed: true };
584
+ } finally {
585
+ removeWorktree(cwd, worktree);
586
+ }
587
+ }
588
+ async function addToBaseline(cwd, repoDir, newComponents, allComponents, componentPaths, vars, version) {
589
+ const worktree = createWorktree(cwd, BASELINE_BRANCH, false);
590
+ try {
591
+ await writeTemplateToDir(worktree, repoDir, allComponents, componentPaths, vars, version, "scaffold");
592
+ execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
593
+ execSync2(
594
+ `git commit --no-verify -m "projx: add ${newComponents.join(", ")} template v${version}"`,
595
+ { cwd: worktree, stdio: "pipe" }
511
596
  );
597
+ } finally {
598
+ removeWorktree(cwd, worktree);
599
+ }
600
+ }
601
+ function mergeBaseline(cwd, message, allowUnrelated = false, oursOnConflict = false) {
602
+ const args2 = [`git merge ${BASELINE_BRANCH}`];
603
+ args2.push(`-m "${message}"`);
604
+ if (allowUnrelated) args2.push("--allow-unrelated-histories");
605
+ if (oursOnConflict) {
606
+ try {
607
+ execSync2(`${args2.join(" ")} --no-commit`, { cwd, stdio: "pipe" });
608
+ } catch {
609
+ }
610
+ execSync2("git checkout --ours .", { cwd, stdio: "pipe" });
611
+ execSync2("git add -A", { cwd, stdio: "pipe" });
612
+ execSync2(`git commit --no-verify --no-edit -m "${message}"`, { cwd, stdio: "pipe" });
613
+ return { status: "clean" };
614
+ }
615
+ try {
616
+ execSync2(args2.join(" "), { cwd, stdio: "pipe" });
617
+ return { status: "clean" };
618
+ } catch {
619
+ const conflicted = execSync2("git diff --name-only --diff-filter=U", { cwd, stdio: "pipe" }).toString().trim();
620
+ if (!conflicted) {
621
+ return { status: "clean" };
622
+ }
623
+ return {
624
+ status: "conflicts",
625
+ conflictedFiles: conflicted.split("\n").filter(Boolean)
626
+ };
627
+ }
628
+ }
629
+ async function reconstructBaseline(cwd, repoDir, components, componentPaths, vars, version, componentSkips) {
630
+ p2.log.warn("projx/baseline branch not found. Reconstructing...");
631
+ await createBaseline(cwd, repoDir, components, componentPaths, vars, version, "scaffold", componentSkips);
632
+ mergeBaseline(
633
+ cwd,
634
+ `projx: reconstructed baseline for template v${version}`,
635
+ true,
636
+ true
637
+ );
638
+ p2.log.success("Baseline reconstructed.");
639
+ }
640
+
641
+ // src/scaffold.ts
642
+ async function scaffold(opts, dest, localRepo) {
643
+ const name = toKebab(opts.name);
644
+ const paths = Object.fromEntries(
645
+ opts.components.map((c) => [c, c])
646
+ );
647
+ const vars = { projectName: name, components: opts.components, paths };
648
+ const isLocal = !!localRepo;
649
+ await mkdir3(dest, { recursive: true });
650
+ const dlSpinner = p3.spinner();
651
+ dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
652
+ const repoDir = await downloadRepo(localRepo).catch((err) => {
653
+ dlSpinner.stop("Failed.");
654
+ p3.log.error(String(err));
655
+ process.exit(1);
656
+ });
657
+ dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
658
+ try {
659
+ const pkg = JSON.parse(await readFile3(join4(repoDir, "cli/package.json"), "utf-8"));
660
+ const version = pkg.version;
661
+ p3.log.info(`Scaffolding project in ${dest}`);
662
+ if (opts.git) {
663
+ exec("git init", dest);
664
+ exec("git config core.hooksPath .githooks", dest);
665
+ const spinner5 = p3.spinner();
666
+ spinner5.start("Creating baseline and scaffold");
667
+ await createBaseline(dest, repoDir, opts.components, paths, vars, version);
668
+ const result = mergeBaseline(
669
+ dest,
670
+ `projx: initial scaffold from template v${version}`,
671
+ true
672
+ );
673
+ spinner5.stop("Scaffold complete.");
674
+ if (result.status === "conflicts") {
675
+ p3.log.warn("Unexpected conflicts during scaffold \u2014 this shouldn't happen.");
676
+ }
677
+ } else {
678
+ const spinner5 = p3.spinner();
679
+ spinner5.start("Copying template files");
680
+ await createBaseline(dest, repoDir, opts.components, paths, vars, version);
681
+ spinner5.stop("Template files copied.");
682
+ }
683
+ if (opts.install) {
684
+ await installDeps(dest, opts.components);
685
+ }
686
+ copyEnvExamples(dest, opts.components);
687
+ if (opts.git) {
688
+ try {
689
+ exec("git add -A", dest);
690
+ exec('git commit --no-verify -m "Initial scaffold from projx"', dest);
691
+ } catch {
692
+ }
693
+ }
694
+ } finally {
695
+ await cleanupRepo(repoDir, isLocal);
512
696
  }
697
+ p3.outro(`Done! Next steps:
698
+
699
+ cd ${name}
700
+ ./setup.sh
701
+
702
+ Like projx? Star it: https://github.com/ukanhaupa/projx`);
513
703
  }
514
704
  async function installDeps(dest, components) {
515
705
  for (const component of components) {
516
- const spinner5 = p2.spinner();
706
+ const spinner5 = p3.spinner();
517
707
  try {
518
708
  switch (component) {
519
709
  case "fastapi":
520
710
  if (hasCommand("uv")) {
521
711
  spinner5.start("Installing FastAPI dependencies (uv sync)");
522
- exec("uv sync --all-extras", join3(dest, "fastapi"));
712
+ exec("uv sync --all-extras", join4(dest, "fastapi"));
523
713
  spinner5.stop("FastAPI dependencies installed.");
524
714
  } else {
525
- p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
715
+ p3.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
526
716
  }
527
717
  break;
528
718
  case "fastify":
529
719
  if (hasCommand("pnpm")) {
530
720
  spinner5.start("Installing Fastify dependencies (pnpm install)");
531
- exec("pnpm install", join3(dest, "fastify"));
721
+ exec("pnpm install", join4(dest, "fastify"));
532
722
  spinner5.stop("Fastify dependencies installed.");
533
723
  } else {
534
724
  spinner5.start("Installing Fastify dependencies (npm install)");
535
- exec("npm install", join3(dest, "fastify"));
725
+ exec("npm install", join4(dest, "fastify"));
536
726
  spinner5.stop("Fastify dependencies installed.");
537
727
  }
538
728
  break;
539
729
  case "frontend":
540
730
  spinner5.start("Installing Frontend dependencies (npm install)");
541
- exec("npm install", join3(dest, "frontend"));
731
+ exec("npm install", join4(dest, "frontend"));
542
732
  spinner5.stop("Frontend dependencies installed.");
543
733
  break;
544
734
  case "e2e":
545
735
  spinner5.start("Installing E2E dependencies (npm install)");
546
- exec("npm install", join3(dest, "e2e"));
736
+ exec("npm install", join4(dest, "e2e"));
547
737
  spinner5.stop("E2E dependencies installed.");
548
738
  break;
549
739
  case "mobile":
550
740
  if (hasCommand("flutter")) {
551
741
  spinner5.start("Installing Flutter dependencies");
552
- exec("flutter pub get", join3(dest, "mobile"));
742
+ exec("flutter pub get", join4(dest, "mobile"));
553
743
  spinner5.stop("Flutter dependencies installed.");
554
744
  } else {
555
- p2.log.warn(
556
- "Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
557
- );
745
+ p3.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
558
746
  }
559
747
  break;
560
748
  case "infra":
@@ -567,9 +755,9 @@ async function installDeps(dest, components) {
567
755
  }
568
756
  function copyEnvExamples(dest, components) {
569
757
  for (const component of components) {
570
- const example = join3(dest, component, ".env.example");
571
- const env = join3(dest, component, ".env");
572
- if (existsSync2(example) && !existsSync2(env)) {
758
+ const example = join4(dest, component, ".env.example");
759
+ const env = join4(dest, component, ".env");
760
+ if (existsSync3(example) && !existsSync3(env)) {
573
761
  try {
574
762
  copyFileSync(example, env);
575
763
  } catch {
@@ -579,62 +767,32 @@ function copyEnvExamples(dest, components) {
579
767
  }
580
768
 
581
769
  // src/update.ts
582
- import { existsSync as existsSync3, readFileSync } from "fs";
583
- import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3, chmod as chmod2, cp as cp2, rm as rm2 } from "fs/promises";
584
- import { execSync as execSync2 } from "child_process";
585
- import { join as join4 } from "path";
586
- import * as p3 from "@clack/prompts";
587
- var NEVER_OVERWRITE = [
588
- /\.env$/,
589
- /\.env\.(dev|staging|prod)$/,
590
- /prisma\/migrations\//,
591
- /src\/migrations\/versions\//,
592
- /\.projx-component$/
593
- ];
594
- function isGitRepo(cwd) {
595
- try {
596
- execSync2("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
597
- return true;
598
- } catch {
599
- return false;
600
- }
601
- }
602
- function hasUncommittedChanges(cwd) {
603
- try {
604
- const status = execSync2("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
605
- return status.length > 0;
606
- } catch {
607
- return false;
608
- }
609
- }
610
- function branchExists(cwd, branch) {
611
- try {
612
- execSync2(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd, stdio: "pipe" });
613
- return true;
614
- } catch {
615
- return false;
616
- }
617
- }
618
- function getCurrentBranch(cwd) {
619
- return execSync2("git branch --show-current", { cwd, stdio: "pipe" }).toString().trim();
620
- }
770
+ import { existsSync as existsSync4, readFileSync } from "fs";
771
+ import { readFile as readFile4 } from "fs/promises";
772
+ import { execSync as execSync3 } from "child_process";
773
+ import { join as join5 } from "path";
774
+ import * as p4 from "@clack/prompts";
621
775
  async function update(cwd, localRepo) {
622
- p3.intro("projx update");
776
+ p4.intro("projx update");
623
777
  const isLocal = !!localRepo;
624
- const configPath = join4(cwd, ".projx");
778
+ if (!isGitRepo(cwd)) {
779
+ p4.log.error(`projx update requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
780
+ process.exit(1);
781
+ }
782
+ if (hasUncommittedChanges(cwd)) {
783
+ p4.log.error("You have uncommitted changes. Commit or stash them first.");
784
+ process.exit(1);
785
+ }
786
+ const configPath = join5(cwd, ".projx");
625
787
  let config;
626
- if (existsSync3(configPath)) {
788
+ if (existsSync4(configPath)) {
627
789
  config = JSON.parse(await readFile4(configPath, "utf-8"));
628
- p3.log.info(
629
- `Found .projx (v${config.version}, components: ${config.components.join(", ")})`
630
- );
790
+ p4.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
631
791
  } else {
632
- p3.log.warn("No .projx file found. Detecting components from directories.");
633
- const detected = COMPONENTS.filter(
634
- (c) => existsSync3(join4(cwd, c))
635
- );
792
+ p4.log.warn("No .projx file found. Detecting components from directories.");
793
+ const detected = COMPONENTS.filter((c) => existsSync4(join5(cwd, c)));
636
794
  if (detected.length === 0) {
637
- p3.log.error("No projx components found in this directory.");
795
+ p4.log.error("No projx components found. Run 'projx init' first.");
638
796
  process.exit(1);
639
797
  }
640
798
  config = {
@@ -642,171 +800,103 @@ async function update(cwd, localRepo) {
642
800
  components: detected,
643
801
  createdAt: "unknown"
644
802
  };
645
- p3.log.info(`Detected: ${detected.join(", ")}`);
803
+ p4.log.info(`Detected: ${detected.join(", ")}`);
646
804
  }
647
805
  const componentPaths = await discoverComponentPaths(cwd, config.components);
648
806
  const remapped = config.components.filter((c) => componentPaths[c] !== c);
649
807
  if (remapped.length > 0) {
650
808
  for (const c of remapped) {
651
- p3.log.info(`${c} \u2192 ${componentPaths[c]}/`);
809
+ p4.log.info(`${c} \u2192 ${componentPaths[c]}/`);
652
810
  }
653
811
  }
654
- const useGitBranch = isGitRepo(cwd);
655
- let branchName;
656
- let originalBranch;
657
- if (useGitBranch) {
658
- if (hasUncommittedChanges(cwd)) {
659
- p3.log.error("You have uncommitted changes. Commit or stash them first.");
660
- process.exit(1);
812
+ const dlSpinner = p4.spinner();
813
+ dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
814
+ const repoDir = await downloadRepo(localRepo).catch((err) => {
815
+ dlSpinner.stop("Failed.");
816
+ p4.log.error(String(err));
817
+ process.exit(1);
818
+ });
819
+ dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
820
+ try {
821
+ const pkg = JSON.parse(await readFile4(join5(repoDir, "cli/package.json"), "utf-8"));
822
+ const version = pkg.version;
823
+ const name = detectProjectName(cwd, config.components, componentPaths);
824
+ const vars = { projectName: name, components: config.components, paths: componentPaths };
825
+ const componentSkips = {};
826
+ for (const component of config.components) {
827
+ const dir = componentPaths[component];
828
+ const marker = await readComponentMarker(join5(cwd, dir));
829
+ if (marker?.skip && marker.skip.length > 0) {
830
+ componentSkips[component] = marker.skip;
831
+ } else if (marker?.origin === "init") {
832
+ componentSkips[component] = ["**"];
833
+ }
661
834
  }
662
- originalBranch = getCurrentBranch(cwd);
663
- const dlSpinner = p3.spinner();
664
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
665
- const repoDir = await downloadRepo(localRepo).catch((err) => {
666
- dlSpinner.stop("Failed.");
667
- p3.log.error(String(err));
668
- process.exit(1);
669
- });
670
- dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
671
- const pkg = JSON.parse(
672
- await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
673
- );
674
- branchName = `projx/update-v${pkg.version}`;
675
- if (branchExists(cwd, branchName)) {
676
- let suffix = 1;
677
- while (branchExists(cwd, `${branchName}-${suffix}`)) suffix++;
678
- branchName = `${branchName}-${suffix}`;
835
+ if (!hasBaseline(cwd)) {
836
+ const rebuildSpinner = p4.spinner();
837
+ rebuildSpinner.start("Establishing baseline (first-time migration)");
838
+ await reconstructBaseline(cwd, repoDir, config.components, componentPaths, vars, config.version || version, componentSkips);
839
+ rebuildSpinner.stop("Baseline established.");
679
840
  }
680
- execSync2(`git checkout -b ${branchName}`, { cwd, stdio: "pipe" });
681
- p3.log.info(`Created branch: ${branchName}`);
682
- let touchedFiles;
683
- try {
684
- touchedFiles = await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
685
- } finally {
686
- await cleanupRepo(repoDir, isLocal);
841
+ const updateSpinner = p4.spinner();
842
+ updateSpinner.start("Updating baseline to latest template");
843
+ const { changed } = await updateBaseline(cwd, repoDir, config.components, componentPaths, vars, version, componentSkips);
844
+ if (!changed) {
845
+ updateSpinner.stop("Already up to date.");
846
+ p4.outro("No template changes to apply.");
847
+ return;
687
848
  }
688
- for (const f of touchedFiles) {
689
- execSync2(`git add "${f}"`, { cwd, stdio: "pipe" });
849
+ updateSpinner.stop("Baseline updated.");
850
+ const mergeSpinner = p4.spinner();
851
+ mergeSpinner.start("Merging template changes");
852
+ const result = mergeBaseline(cwd, `projx: update to template v${version}`);
853
+ mergeSpinner.stop("Merge complete.");
854
+ if (result.status === "conflicts") {
855
+ p4.log.warn(`Merge conflicts in ${result.conflictedFiles.length} file(s):`);
856
+ for (const f of result.conflictedFiles) {
857
+ p4.log.message(` ${f}`);
858
+ }
859
+ p4.outro(
860
+ "Resolve conflicts, then:\n git add . && git commit\n\nOr abort:\n git merge --abort"
861
+ );
862
+ } else {
863
+ p4.outro(`Updated to template v${version}. All changes merged cleanly.`);
690
864
  }
691
- execSync2(`git commit --no-verify -m "projx update to v${pkg.version}"`, { cwd, stdio: "pipe" });
692
- p3.outro(
693
- `Updated on branch: ${branchName}
694
-
695
- Review changes:
696
- git diff ${originalBranch}...${branchName}
697
-
698
- Merge (resolve conflicts for files you customized):
699
- git checkout ${originalBranch} && git merge --no-ff ${branchName}`
700
- );
701
- } else {
702
- const dlSpinner = p3.spinner();
703
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
704
- const repoDir = await downloadRepo(localRepo).catch((err) => {
705
- dlSpinner.stop("Failed.");
706
- p3.log.error(String(err));
707
- process.exit(1);
708
- });
709
- dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
710
- const pkg = JSON.parse(
711
- await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
712
- );
865
+ } catch (err) {
713
866
  try {
714
- await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
715
- } finally {
716
- await cleanupRepo(repoDir, isLocal);
867
+ execSync3("git merge --abort", { cwd, stdio: "pipe" });
868
+ } catch {
717
869
  }
718
- p3.outro(`Updated to v${pkg.version}. Review changes before committing.`);
870
+ p4.log.error(`Update failed: ${err}`);
871
+ p4.log.info("Your code is safe. Run 'git merge --abort' if needed.");
872
+ process.exit(1);
873
+ } finally {
874
+ await cleanupRepo(repoDir, isLocal);
719
875
  }
720
876
  }
721
- async function doUpdate(cwd, config, repoDir, version, componentPaths) {
722
- const name = detectProjectName(cwd, config.components, componentPaths);
723
- const nameSnake = toSnake(name);
724
- const vars = { projectName: name, components: config.components, paths: componentPaths };
725
- const touchedFiles = [];
726
- const usedPaths = /* @__PURE__ */ new Set();
727
- for (const component of config.components) {
728
- const targetDir = componentPaths[component];
729
- if (usedPaths.has(targetDir)) {
730
- p3.log.warn(`${component} shares directory ${targetDir}/ with another component \u2014 skipping overlay to avoid nesting.`);
731
- continue;
732
- }
733
- usedPaths.add(targetDir);
734
- const spinner6 = p3.spinner();
735
- spinner6.start(`Updating ${targetDir}/ (${component})`);
736
- const componentSrc = join4(repoDir, component);
737
- if (!existsSync3(componentSrc)) {
738
- spinner6.stop(`${component} template not found, skipping.`);
739
- continue;
740
- }
741
- const tmpDest = join4(cwd, `.projx-tmp`);
742
- const files = await copyComponent(repoDir, component, tmpDest);
743
- for (const file of files) {
744
- const src = join4(tmpDest, component, file);
745
- const destRel = `${targetDir}/${file}`;
746
- const dest = join4(cwd, destRel);
747
- if (NEVER_OVERWRITE.some((re) => re.test(destRel))) continue;
748
- const dir = dest.substring(0, dest.lastIndexOf("/"));
749
- await mkdir3(dir, { recursive: true });
750
- await cp2(src, dest, { force: true });
751
- touchedFiles.push(destRel);
752
- }
753
- await rm2(tmpDest, { recursive: true, force: true });
754
- if (!existsSync3(join4(cwd, targetDir, ".projx-component"))) {
755
- await writeComponentMarker(join4(cwd, targetDir), component);
756
- touchedFiles.push(`${targetDir}/.projx-component`);
757
- }
758
- spinner6.stop(`${targetDir}/ updated.`);
759
- }
760
- const spinner5 = p3.spinner();
761
- spinner5.start("Updating shared files");
762
- const hasBackend = config.components.includes("fastapi") || config.components.includes("fastify");
763
- if (hasBackend || config.components.includes("frontend")) {
764
- await writeFile3(join4(cwd, "docker-compose.yml"), await generateDockerCompose(vars));
765
- touchedFiles.push("docker-compose.yml");
766
- await writeFile3(join4(cwd, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
767
- touchedFiles.push("docker-compose.dev.yml");
768
- }
769
- await mkdir3(join4(cwd, ".githooks"), { recursive: true });
770
- await writeFile3(join4(cwd, ".githooks/pre-commit"), await generatePreCommit(vars));
771
- await chmod2(join4(cwd, ".githooks/pre-commit"), 493);
772
- touchedFiles.push(".githooks/pre-commit");
773
- await mkdir3(join4(cwd, ".github/workflows"), { recursive: true });
774
- await writeFile3(join4(cwd, ".github/workflows/ci.yml"), await generateCiYml(vars));
775
- touchedFiles.push(".github/workflows/ci.yml");
776
- await writeFile3(join4(cwd, "setup.sh"), await generateSetupSh(vars));
777
- await chmod2(join4(cwd, "setup.sh"), 493);
778
- touchedFiles.push("setup.sh");
779
- await mkdir3(join4(cwd, ".vscode"), { recursive: true });
780
- await writeFile3(join4(cwd, ".vscode/settings.json"), generateVscodeSettings(vars));
781
- touchedFiles.push(".vscode/settings.json");
782
- spinner5.stop("Shared files updated.");
783
- if (config.components.includes("mobile")) {
784
- const mobilePath = componentPaths.mobile ?? "mobile";
785
- await replaceInDir(
786
- join4(cwd, mobilePath),
787
- "package:projx_mobile/",
788
- `package:${nameSnake}_mobile/`,
789
- ".dart"
790
- );
877
+ function isGitRepo(cwd) {
878
+ try {
879
+ execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
880
+ return true;
881
+ } catch {
882
+ return false;
883
+ }
884
+ }
885
+ function hasUncommittedChanges(cwd) {
886
+ try {
887
+ const status = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
888
+ return status.length > 0;
889
+ } catch {
890
+ return false;
791
891
  }
792
- const updatedConfig = {
793
- version,
794
- components: config.components,
795
- createdAt: config.createdAt
796
- };
797
- await writeFile3(join4(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
798
- touchedFiles.push(".projx");
799
- return touchedFiles;
800
892
  }
801
893
  function detectProjectName(cwd, components, componentPaths) {
802
894
  for (const component of components) {
803
895
  const dir = componentPaths[component] ?? component;
804
- const pkgPath = join4(cwd, dir, "package.json");
805
- if (existsSync3(pkgPath)) {
896
+ const pkgPath = join5(cwd, dir, "package.json");
897
+ if (existsSync4(pkgPath)) {
806
898
  try {
807
- const pkg = JSON.parse(
808
- readFileSync(pkgPath, "utf-8")
809
- );
899
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
810
900
  const n = pkg.name;
811
901
  if (n && n.includes("-")) {
812
902
  return n.substring(0, n.lastIndexOf("-"));
@@ -819,187 +909,134 @@ function detectProjectName(cwd, components, componentPaths) {
819
909
  }
820
910
 
821
911
  // src/add.ts
822
- import { copyFileSync as copyFileSync2, existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
823
- import { chmod as chmod3, mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
824
- import { join as join5 } from "path";
825
- import * as p4 from "@clack/prompts";
912
+ import { copyFileSync as copyFileSync2, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
913
+ import { readFile as readFile5 } from "fs/promises";
914
+ import { join as join6 } from "path";
915
+ import * as p5 from "@clack/prompts";
826
916
  async function add(cwd, newComponents, localRepo, skipInstall = false) {
827
- p4.intro("projx add");
917
+ p5.intro("projx add");
828
918
  const isLocal = !!localRepo;
829
- const configPath = join5(cwd, ".projx");
830
- if (!existsSync4(configPath)) {
831
- p4.log.error("No .projx file found. Run 'projx <name>' to create a project first.");
919
+ const configPath = join6(cwd, ".projx");
920
+ if (!existsSync5(configPath)) {
921
+ p5.log.error("No .projx file found. Run 'projx <name>' to create a project first.");
832
922
  process.exit(1);
833
923
  }
834
924
  const config = JSON.parse(await readFile5(configPath, "utf-8"));
835
925
  const existing = config.components;
836
926
  const alreadyExists = newComponents.filter((c) => existing.includes(c));
837
927
  if (alreadyExists.length > 0) {
838
- p4.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
928
+ p5.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
839
929
  }
840
930
  const toAdd = newComponents.filter((c) => !existing.includes(c));
841
931
  if (toAdd.length === 0) {
842
- p4.log.info("Nothing new to add.");
932
+ p5.log.info("Nothing new to add.");
843
933
  process.exit(0);
844
934
  }
845
- p4.log.info(`Adding: ${toAdd.join(", ")}`);
846
- const dlSpinner = p4.spinner();
935
+ p5.log.info(`Adding: ${toAdd.join(", ")}`);
936
+ const dlSpinner = p5.spinner();
847
937
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
848
938
  const repoDir = await downloadRepo(localRepo).catch((err) => {
849
939
  dlSpinner.stop("Failed.");
850
- p4.log.error(String(err));
940
+ p5.log.error(String(err));
851
941
  process.exit(1);
852
942
  });
853
943
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
854
944
  try {
855
- await doAdd(cwd, config, toAdd, repoDir, skipInstall);
856
- } finally {
857
- await cleanupRepo(repoDir, isLocal);
858
- }
859
- }
860
- async function doAdd(cwd, config, toAdd, repoDir, skipInstall) {
861
- const allComponents = [...config.components, ...toAdd];
862
- const existingPaths = await discoverComponentPaths(cwd, config.components);
863
- const paths = { ...existingPaths };
864
- for (const c of toAdd) paths[c] = c;
865
- const name = detectProjectName2(cwd, config.components, paths);
866
- const nameSnake = toSnake(name);
867
- const vars = { projectName: name, components: allComponents, paths };
868
- for (const component of toAdd) {
869
- const spinner6 = p4.spinner();
870
- spinner6.start(`Adding ${component}/`);
871
- await copyComponent(repoDir, component, cwd);
872
- await writeComponentMarker(join5(cwd, component), component);
873
- spinner6.stop(`${component}/`);
874
- }
875
- await substituteNames2(cwd, toAdd, name, nameSnake);
876
- const spinner5 = p4.spinner();
877
- spinner5.start("Regenerating shared files");
878
- const hasBackend = allComponents.includes("fastapi") || allComponents.includes("fastify");
879
- if (hasBackend || allComponents.includes("frontend")) {
880
- await writeFile4(join5(cwd, "docker-compose.yml"), await generateDockerCompose(vars));
881
- await writeFile4(join5(cwd, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
882
- }
883
- await writeFile4(join5(cwd, "README.md"), await generateReadme(vars));
884
- await mkdir4(join5(cwd, ".githooks"), { recursive: true });
885
- await writeFile4(join5(cwd, ".githooks/pre-commit"), await generatePreCommit(vars));
886
- await chmod3(join5(cwd, ".githooks/pre-commit"), 493);
887
- await mkdir4(join5(cwd, ".github/workflows"), { recursive: true });
888
- await writeFile4(join5(cwd, ".github/workflows/ci.yml"), await generateCiYml(vars));
889
- await writeFile4(join5(cwd, "setup.sh"), await generateSetupSh(vars));
890
- await chmod3(join5(cwd, "setup.sh"), 493);
891
- spinner5.stop("Shared files regenerated.");
892
- if (!skipInstall) {
893
- await installDeps2(cwd, toAdd);
894
- }
895
- for (const component of toAdd) {
896
- const example = join5(cwd, component, ".env.example");
897
- const env = join5(cwd, component, ".env");
898
- if (existsSync4(example) && !existsSync4(env)) {
899
- try {
900
- copyFileSync2(example, env);
901
- } catch {
945
+ const allComponents = [...existing, ...toAdd];
946
+ const existingPaths = await discoverComponentPaths(cwd, existing);
947
+ const paths = { ...existingPaths };
948
+ for (const c of toAdd) paths[c] = c;
949
+ const name = detectProjectName2(cwd, existing, paths);
950
+ const vars = { projectName: name, components: allComponents, paths };
951
+ const pkg = JSON.parse(await readFile5(join6(repoDir, "cli/package.json"), "utf-8"));
952
+ const version = pkg.version;
953
+ if (!hasBaseline(cwd)) {
954
+ const rebuildSpinner = p5.spinner();
955
+ rebuildSpinner.start("Establishing baseline");
956
+ await reconstructBaseline(
957
+ cwd,
958
+ repoDir,
959
+ existing,
960
+ existingPaths,
961
+ { projectName: name, components: existing, paths: existingPaths },
962
+ config.version || version
963
+ );
964
+ rebuildSpinner.stop("Baseline established.");
965
+ }
966
+ const spinner5 = p5.spinner();
967
+ spinner5.start("Adding to baseline");
968
+ await addToBaseline(cwd, repoDir, toAdd, allComponents, paths, vars, version);
969
+ spinner5.stop("Baseline updated.");
970
+ const result = mergeBaseline(cwd, `projx: add ${toAdd.join(", ")} from template v${version}`);
971
+ if (result.status === "conflicts") {
972
+ p5.log.warn(`Merge conflicts in ${result.conflictedFiles.length} file(s):`);
973
+ for (const f of result.conflictedFiles) {
974
+ p5.log.message(` ${f}`);
975
+ }
976
+ p5.log.info("Resolve conflicts, then: git add . && git commit");
977
+ }
978
+ if (!skipInstall) {
979
+ await installDeps2(cwd, toAdd);
980
+ }
981
+ for (const component of toAdd) {
982
+ const example = join6(cwd, component, ".env.example");
983
+ const env = join6(cwd, component, ".env");
984
+ if (existsSync5(example) && !existsSync5(env)) {
985
+ try {
986
+ copyFileSync2(example, env);
987
+ } catch {
988
+ }
902
989
  }
903
990
  }
991
+ } finally {
992
+ await cleanupRepo(repoDir, isLocal);
904
993
  }
905
- const pkg = JSON.parse(
906
- await readFile5(join5(repoDir, "cli/package.json"), "utf-8")
907
- );
908
- const updatedConfig = {
909
- version: pkg.version,
910
- components: allComponents,
911
- createdAt: config.createdAt
912
- };
913
- await writeFile4(join5(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
914
- p4.outro(`Added ${toAdd.join(", ")}. Shared files updated for all ${allComponents.length} components.
994
+ p5.outro(`Added ${toAdd.join(", ")}.
915
995
 
916
996
  Like projx? Star it: https://github.com/ukanhaupa/projx`);
917
997
  }
918
- async function substituteNames2(dest, components, name, nameSnake) {
919
- if (components.includes("fastapi")) {
920
- await replaceInFile(
921
- join5(dest, "fastapi/pyproject.toml"),
922
- "projx-fastapi",
923
- `${name}-fastapi`
924
- );
925
- }
926
- if (components.includes("fastify")) {
927
- await replaceInFile(
928
- join5(dest, "fastify/package.json"),
929
- "projx-fastify",
930
- `${name}-fastify`
931
- );
932
- }
933
- if (components.includes("frontend")) {
934
- await replaceInFile(
935
- join5(dest, "frontend/package.json"),
936
- "projx-frontend",
937
- `${name}-frontend`
938
- );
939
- }
940
- if (components.includes("e2e")) {
941
- await replaceInFile(
942
- join5(dest, "e2e/package.json"),
943
- "projx-e2e",
944
- `${name}-e2e`
945
- );
946
- }
947
- if (components.includes("mobile")) {
948
- await replaceInFile(
949
- join5(dest, "mobile/pubspec.yaml"),
950
- "projx_mobile",
951
- `${nameSnake}_mobile`
952
- );
953
- await replaceInDir(
954
- join5(dest, "mobile"),
955
- "package:projx_mobile/",
956
- `package:${nameSnake}_mobile/`,
957
- ".dart"
958
- );
959
- }
960
- }
961
998
  async function installDeps2(dest, components) {
962
999
  for (const component of components) {
963
- const spinner5 = p4.spinner();
1000
+ const spinner5 = p5.spinner();
964
1001
  try {
965
1002
  switch (component) {
966
1003
  case "fastapi":
967
1004
  if (hasCommand("uv")) {
968
1005
  spinner5.start("Installing FastAPI dependencies");
969
- exec("uv sync --all-extras", join5(dest, "fastapi"));
1006
+ exec("uv sync --all-extras", join6(dest, "fastapi"));
970
1007
  spinner5.stop("FastAPI dependencies installed.");
971
1008
  } else {
972
- p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
1009
+ p5.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
973
1010
  }
974
1011
  break;
975
1012
  case "fastify":
976
1013
  if (hasCommand("pnpm")) {
977
1014
  spinner5.start("Installing Fastify dependencies");
978
- exec("pnpm install", join5(dest, "fastify"));
1015
+ exec("pnpm install", join6(dest, "fastify"));
979
1016
  spinner5.stop("Fastify dependencies installed.");
980
1017
  } else {
981
1018
  spinner5.start("Installing Fastify dependencies");
982
- exec("npm install", join5(dest, "fastify"));
1019
+ exec("npm install", join6(dest, "fastify"));
983
1020
  spinner5.stop("Fastify dependencies installed.");
984
1021
  }
985
1022
  break;
986
1023
  case "frontend":
987
1024
  spinner5.start("Installing Frontend dependencies");
988
- exec("npm install", join5(dest, "frontend"));
1025
+ exec("npm install", join6(dest, "frontend"));
989
1026
  spinner5.stop("Frontend dependencies installed.");
990
1027
  break;
991
1028
  case "e2e":
992
1029
  spinner5.start("Installing E2E dependencies");
993
- exec("npm install", join5(dest, "e2e"));
1030
+ exec("npm install", join6(dest, "e2e"));
994
1031
  spinner5.stop("E2E dependencies installed.");
995
1032
  break;
996
1033
  case "mobile":
997
1034
  if (hasCommand("flutter")) {
998
1035
  spinner5.start("Installing Flutter dependencies");
999
- exec("flutter pub get", join5(dest, "mobile"));
1036
+ exec("flutter pub get", join6(dest, "mobile"));
1000
1037
  spinner5.stop("Flutter dependencies installed.");
1001
1038
  } else {
1002
- p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
1039
+ p5.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
1003
1040
  }
1004
1041
  break;
1005
1042
  case "infra":
@@ -1013,12 +1050,10 @@ async function installDeps2(dest, components) {
1013
1050
  function detectProjectName2(cwd, components, paths) {
1014
1051
  for (const component of components) {
1015
1052
  const dir = paths[component] ?? component;
1016
- const pkgPath = join5(cwd, dir, "package.json");
1017
- if (existsSync4(pkgPath)) {
1053
+ const pkgPath = join6(cwd, dir, "package.json");
1054
+ if (existsSync5(pkgPath)) {
1018
1055
  try {
1019
- const pkg = JSON.parse(
1020
- readFileSync2(pkgPath, "utf-8")
1021
- );
1056
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
1022
1057
  const n = pkg.name;
1023
1058
  if (n && n.includes("-")) {
1024
1059
  return n.substring(0, n.lastIndexOf("-"));
@@ -1031,22 +1066,22 @@ function detectProjectName2(cwd, components, paths) {
1031
1066
  }
1032
1067
 
1033
1068
  // src/init.ts
1034
- import { existsSync as existsSync6 } from "fs";
1035
- import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5, chmod as chmod4, cp as cp3 } from "fs/promises";
1036
- import { execSync as execSync3 } from "child_process";
1037
- import { join as join7 } from "path";
1038
- import * as p5 from "@clack/prompts";
1069
+ import { existsSync as existsSync7 } from "fs";
1070
+ import { readFile as readFile6 } from "fs/promises";
1071
+ import { execSync as execSync4 } from "child_process";
1072
+ import { join as join8 } from "path";
1073
+ import * as p6 from "@clack/prompts";
1039
1074
 
1040
1075
  // src/detect.ts
1041
- import { existsSync as existsSync5 } from "fs";
1076
+ import { existsSync as existsSync6 } from "fs";
1042
1077
  import { readdir as readdir2 } from "fs/promises";
1043
- import { join as join6 } from "path";
1078
+ import { join as join7 } from "path";
1044
1079
  async function detectComponents(cwd) {
1045
1080
  const results = [];
1046
1081
  const entries = await readdir2(cwd, { withFileTypes: true });
1047
1082
  const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)).map((e) => e.name);
1048
1083
  for (const dir of dirs) {
1049
- const full = join6(cwd, dir);
1084
+ const full = join7(cwd, dir);
1050
1085
  const detections = await scanDirectory(full, dir);
1051
1086
  results.push(...detections);
1052
1087
  }
@@ -1054,7 +1089,7 @@ async function detectComponents(cwd) {
1054
1089
  }
1055
1090
  async function scanDirectory(dir, relPath) {
1056
1091
  const results = [];
1057
- const pyproject = await readFileOrNull(join6(dir, "pyproject.toml"));
1092
+ const pyproject = await readFileOrNull(join7(dir, "pyproject.toml"));
1058
1093
  if (pyproject && /fastapi/i.test(pyproject)) {
1059
1094
  results.push({
1060
1095
  component: "fastapi",
@@ -1091,7 +1126,7 @@ async function scanDirectory(dir, relPath) {
1091
1126
  });
1092
1127
  }
1093
1128
  }
1094
- const pubspec = await readFileOrNull(join6(dir, "pubspec.yaml"));
1129
+ const pubspec = await readFileOrNull(join7(dir, "pubspec.yaml"));
1095
1130
  if (pubspec && /flutter:/i.test(pubspec)) {
1096
1131
  results.push({
1097
1132
  component: "mobile",
@@ -1100,7 +1135,7 @@ async function scanDirectory(dir, relPath) {
1100
1135
  evidence: "pubspec.yaml has flutter dependency"
1101
1136
  });
1102
1137
  }
1103
- const hasTf = existsSync5(join6(dir, "main.tf")) || existsSync5(join6(dir, "variables.tf")) || existsSync5(join6(dir, "stack/main.tf")) || existsSync5(join6(dir, "versions.tf"));
1138
+ const hasTf = existsSync6(join7(dir, "main.tf")) || existsSync6(join7(dir, "variables.tf")) || existsSync6(join7(dir, "stack/main.tf")) || existsSync6(join7(dir, "versions.tf"));
1104
1139
  if (hasTf) {
1105
1140
  results.push({
1106
1141
  component: "infra",
@@ -1112,7 +1147,7 @@ async function scanDirectory(dir, relPath) {
1112
1147
  return results;
1113
1148
  }
1114
1149
  async function readPkg(dir) {
1115
- const content = await readFileOrNull(join6(dir, "package.json"));
1150
+ const content = await readFileOrNull(join7(dir, "package.json"));
1116
1151
  if (!content) return null;
1117
1152
  try {
1118
1153
  return JSON.parse(content);
@@ -1121,83 +1156,23 @@ async function readPkg(dir) {
1121
1156
  }
1122
1157
  }
1123
1158
 
1124
- // src/diff.ts
1125
- function unifiedDiff(existing, template, label) {
1126
- const a = existing.split("\n");
1127
- const b = template.split("\n");
1128
- const lines = [`--- existing ${label}`, `+++ template ${label}`];
1129
- const lcs = computeLCS(a, b);
1130
- let ai = 0;
1131
- let bi = 0;
1132
- for (const match of lcs) {
1133
- while (ai < match.ai) lines.push(`\x1B[31m- ${a[ai++]}\x1B[0m`);
1134
- while (bi < match.bi) lines.push(`\x1B[32m+ ${b[bi++]}\x1B[0m`);
1135
- lines.push(` ${a[ai]}`);
1136
- ai++;
1137
- bi++;
1138
- }
1139
- while (ai < a.length) lines.push(`\x1B[31m- ${a[ai++]}\x1B[0m`);
1140
- while (bi < b.length) lines.push(`\x1B[32m+ ${b[bi++]}\x1B[0m`);
1141
- if (lines.length > 80) {
1142
- return lines.slice(0, 80).join("\n") + `
1143
- ... (${lines.length - 80} more lines)`;
1144
- }
1145
- return lines.join("\n");
1146
- }
1147
- function computeLCS(a, b) {
1148
- const m = a.length;
1149
- const n = b.length;
1150
- if (m * n > 1e5) {
1151
- return simpleLCS(a, b);
1152
- }
1153
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
1154
- for (let i2 = m - 1; i2 >= 0; i2--) {
1155
- for (let j2 = n - 1; j2 >= 0; j2--) {
1156
- if (a[i2] === b[j2]) {
1157
- dp[i2][j2] = dp[i2 + 1][j2 + 1] + 1;
1158
- } else {
1159
- dp[i2][j2] = Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
1160
- }
1161
- }
1162
- }
1163
- const matches = [];
1164
- let i = 0;
1165
- let j = 0;
1166
- while (i < m && j < n) {
1167
- if (a[i] === b[j]) {
1168
- matches.push({ ai: i, bi: j });
1169
- i++;
1170
- j++;
1171
- } else if (dp[i + 1]?.[j] ?? 0 >= (dp[i]?.[j + 1] ?? 0)) {
1172
- i++;
1173
- } else {
1174
- j++;
1175
- }
1176
- }
1177
- return matches;
1178
- }
1179
- function simpleLCS(a, b) {
1180
- const matches = [];
1181
- let bi = 0;
1182
- for (let ai = 0; ai < a.length && bi < b.length; ai++) {
1183
- const idx = b.indexOf(a[ai], bi);
1184
- if (idx !== -1) {
1185
- matches.push({ ai, bi: idx });
1186
- bi = idx + 1;
1187
- }
1188
- }
1189
- return matches;
1190
- }
1191
-
1192
1159
  // src/init.ts
1193
1160
  async function init(cwd, localRepo) {
1194
- p5.intro("projx init");
1161
+ p6.intro("projx init");
1195
1162
  const isLocal = !!localRepo;
1196
- if (existsSync6(join7(cwd, ".projx"))) {
1197
- p5.log.error("This project is already initialized. Use 'projx update' or 'projx add' instead.");
1163
+ if (existsSync7(join8(cwd, ".projx"))) {
1164
+ p6.log.error("This project is already initialized. Use 'projx update' or 'projx add' instead.");
1165
+ process.exit(1);
1166
+ }
1167
+ if (!isGitRepo2(cwd)) {
1168
+ p6.log.error(`projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
1169
+ process.exit(1);
1170
+ }
1171
+ if (hasUncommittedChanges2(cwd)) {
1172
+ p6.log.error("You have uncommitted changes. Commit or stash them first.");
1198
1173
  process.exit(1);
1199
1174
  }
1200
- const spinner5 = p5.spinner();
1175
+ const spinner5 = p6.spinner();
1201
1176
  spinner5.start("Scanning for components");
1202
1177
  const detected = await detectComponents(cwd);
1203
1178
  spinner5.stop(
@@ -1210,7 +1185,7 @@ async function init(cwd, localRepo) {
1210
1185
  confirmed = await manualSelect(cwd);
1211
1186
  }
1212
1187
  if (confirmed.length === 0) {
1213
- p5.log.warn("No components selected. Nothing to do.");
1188
+ p6.log.warn("No components selected. Nothing to do.");
1214
1189
  process.exit(0);
1215
1190
  }
1216
1191
  const components = confirmed.map((c) => c.component);
@@ -1219,54 +1194,55 @@ async function init(cwd, localRepo) {
1219
1194
  );
1220
1195
  const projectName = toKebab(cwd.split("/").pop());
1221
1196
  const vars = { projectName, components, paths };
1222
- const dlSpinner = p5.spinner();
1197
+ const dlSpinner = p6.spinner();
1223
1198
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
1224
1199
  const repoDir = await downloadRepo(localRepo).catch((err) => {
1225
1200
  dlSpinner.stop("Failed.");
1226
- p5.log.error(String(err));
1201
+ p6.log.error(String(err));
1227
1202
  process.exit(1);
1228
1203
  });
1229
1204
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1230
1205
  try {
1231
- for (const { component, directory } of confirmed) {
1232
- const dir = join7(cwd, directory);
1233
- if (existsSync6(dir)) {
1234
- await writeComponentMarker(dir, component);
1235
- p5.log.success(`${directory}/.projx-component`);
1236
- }
1206
+ const pkg = JSON.parse(await readFile6(join8(repoDir, "cli/package.json"), "utf-8"));
1207
+ const version = pkg.version;
1208
+ const componentSkips = {};
1209
+ for (const c of components) {
1210
+ componentSkips[c] = ["**"];
1237
1211
  }
1238
- await generateSharedFiles(cwd, repoDir, vars);
1239
- const pkg = JSON.parse(
1240
- await readFile6(join7(repoDir, "cli/package.json"), "utf-8")
1212
+ const baselineSpinner = p6.spinner();
1213
+ baselineSpinner.start("Creating template baseline");
1214
+ await createBaseline(cwd, repoDir, components, paths, vars, version, "init", componentSkips);
1215
+ baselineSpinner.stop("Baseline created.");
1216
+ const mergeSpinner = p6.spinner();
1217
+ mergeSpinner.start("Merging baseline (preserving your code)");
1218
+ mergeBaseline(
1219
+ cwd,
1220
+ `projx: adopt template v${version} as baseline`,
1221
+ true,
1222
+ true
1241
1223
  );
1242
- const projxConfig = {
1243
- version: pkg.version,
1244
- components,
1245
- createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1246
- };
1247
- await writeFile5(join7(cwd, ".projx"), JSON.stringify(projxConfig, null, 2));
1248
- p5.log.success(".projx");
1249
- if (isGitRepo2(cwd)) {
1224
+ mergeSpinner.stop("Baseline merged. Your code is preserved.");
1225
+ if (!existsSync7(join8(cwd, ".githooks"))) {
1250
1226
  try {
1251
- execSync3("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1252
- p5.log.success("Git hooks configured.");
1227
+ execSync4("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1228
+ p6.log.success("Git hooks configured.");
1253
1229
  } catch {
1254
- p5.log.warn("Failed to configure git hooks.");
1230
+ p6.log.warn("Failed to configure git hooks.");
1255
1231
  }
1256
1232
  }
1257
1233
  } finally {
1258
1234
  await cleanupRepo(repoDir, isLocal);
1259
1235
  }
1260
- p5.outro("Project initialized. Run './setup.sh' to install dependencies.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1236
+ p6.outro("Project initialized. Run './setup.sh' to install dependencies.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1261
1237
  }
1262
1238
  async function confirmDetections(detected) {
1263
1239
  const confirmed = [];
1264
1240
  for (const d of detected) {
1265
- const yes = await p5.confirm({
1241
+ const yes = await p6.confirm({
1266
1242
  message: `Found ${LABELS[d.component].label} in ${d.directory}/ \u2014 register as "${d.component}"?`,
1267
1243
  initialValue: true
1268
1244
  });
1269
- if (p5.isCancel(yes)) process.exit(0);
1245
+ if (p6.isCancel(yes)) process.exit(0);
1270
1246
  if (yes) {
1271
1247
  confirmed.push({ component: d.component, directory: d.directory });
1272
1248
  }
@@ -1274,7 +1250,7 @@ async function confirmDetections(detected) {
1274
1250
  return confirmed;
1275
1251
  }
1276
1252
  async function manualSelect(cwd) {
1277
- const selected = await p5.multiselect({
1253
+ const selected = await p6.multiselect({
1278
1254
  message: "No components detected. Select manually:",
1279
1255
  options: COMPONENTS.map((c) => ({
1280
1256
  value: c,
@@ -1283,140 +1259,39 @@ async function manualSelect(cwd) {
1283
1259
  })),
1284
1260
  required: false
1285
1261
  });
1286
- if (p5.isCancel(selected)) process.exit(0);
1262
+ if (p6.isCancel(selected)) process.exit(0);
1287
1263
  const result = [];
1288
1264
  for (const component of selected) {
1289
- const dir = await p5.text({
1265
+ const dir = await p6.text({
1290
1266
  message: `Directory for ${LABELS[component].label}?`,
1291
1267
  placeholder: component,
1292
1268
  defaultValue: component
1293
1269
  });
1294
- if (p5.isCancel(dir)) process.exit(0);
1295
- if (!existsSync6(join7(cwd, dir))) {
1296
- p5.log.warn(`${dir}/ does not exist \u2014 skipping.`);
1270
+ if (p6.isCancel(dir)) process.exit(0);
1271
+ if (!existsSync7(join8(cwd, dir))) {
1272
+ p6.log.warn(`${dir}/ does not exist \u2014 skipping.`);
1297
1273
  continue;
1298
1274
  }
1299
1275
  result.push({ component, directory: dir });
1300
1276
  }
1301
1277
  return result;
1302
1278
  }
1303
- async function generateSharedFiles(cwd, repoDir, vars) {
1304
- const files = [];
1305
- const hasBackend = vars.components.includes("fastapi") || vars.components.includes("fastify");
1306
- if (hasBackend || vars.components.includes("frontend")) {
1307
- files.push(
1308
- { path: "docker-compose.yml", content: await generateDockerCompose(vars) },
1309
- { path: "docker-compose.dev.yml", content: await generateDockerComposeDev(vars) }
1310
- );
1311
- }
1312
- files.push(
1313
- { path: "README.md", content: await generateReadme(vars) },
1314
- { path: ".githooks/pre-commit", content: await generatePreCommit(vars), mode: 493 },
1315
- { path: ".github/workflows/ci.yml", content: await generateCiYml(vars) },
1316
- { path: "setup.sh", content: await generateSetupSh(vars), mode: 493 }
1317
- );
1318
- for (const file of files) {
1319
- const dest = join7(cwd, file.path);
1320
- const dir = dest.substring(0, dest.lastIndexOf("/"));
1321
- if (dir !== cwd) await mkdir5(dir, { recursive: true });
1322
- const existing = await readFileOrNull(dest);
1323
- if (existing === null) {
1324
- await writeFile5(dest, file.content);
1325
- if (file.mode) await chmod4(dest, file.mode);
1326
- p5.log.success(file.path);
1327
- } else if (existing === file.content) {
1328
- p5.log.info(`${file.path} \u2014 identical, skipped.`);
1329
- } else {
1330
- const action = await resolveConflict(file.path, existing, file.content);
1331
- if (action === "overwrite") {
1332
- await writeFile5(dest, file.content);
1333
- if (file.mode) await chmod4(dest, file.mode);
1334
- p5.log.success(`${file.path} \u2014 overwritten.`);
1335
- } else {
1336
- p5.log.info(`${file.path} \u2014 kept existing.`);
1337
- }
1338
- }
1339
- }
1340
- const statics = [".editorconfig"];
1341
- for (const file of statics) {
1342
- const src = join7(repoDir, file);
1343
- const dest = join7(cwd, file);
1344
- if (!existsSync6(src)) continue;
1345
- if (!existsSync6(dest)) {
1346
- await cp3(src, dest);
1347
- p5.log.success(file);
1348
- } else {
1349
- const existing = await readFileOrNull(dest);
1350
- const template = await readFileOrNull(src);
1351
- if (existing === template) {
1352
- p5.log.info(`${file} \u2014 identical, skipped.`);
1353
- } else {
1354
- const action = await resolveConflict(file, existing ?? "", template ?? "");
1355
- if (action === "overwrite") {
1356
- await cp3(src, dest, { force: true });
1357
- p5.log.success(`${file} \u2014 overwritten.`);
1358
- } else {
1359
- p5.log.info(`${file} \u2014 kept existing.`);
1360
- }
1361
- }
1362
- }
1363
- }
1364
- const vscodeDest = join7(cwd, ".vscode");
1365
- await mkdir5(vscodeDest, { recursive: true });
1366
- const settingsPath = join7(vscodeDest, "settings.json");
1367
- const settingsContent = generateVscodeSettings(vars);
1368
- const existingSettings = await readFileOrNull(settingsPath);
1369
- if (existingSettings === null) {
1370
- await writeFile5(settingsPath, settingsContent);
1371
- p5.log.success(".vscode/settings.json");
1372
- } else if (existingSettings !== settingsContent) {
1373
- const action = await resolveConflict(".vscode/settings.json", existingSettings, settingsContent);
1374
- if (action === "overwrite") {
1375
- await writeFile5(settingsPath, settingsContent);
1376
- p5.log.success(".vscode/settings.json \u2014 overwritten.");
1377
- } else {
1378
- p5.log.info(".vscode/settings.json \u2014 kept existing.");
1379
- }
1380
- }
1381
- const extSrc = join7(repoDir, ".vscode/extensions.json");
1382
- const extDest = join7(vscodeDest, "extensions.json");
1383
- if (existsSync6(extSrc) && !existsSync6(extDest)) {
1384
- await cp3(extSrc, extDest);
1385
- p5.log.success(".vscode/extensions.json");
1386
- }
1387
- }
1388
- async function resolveConflict(filePath, existing, template) {
1389
- let action = await p5.select({
1390
- message: `${filePath} differs from projx template`,
1391
- options: [
1392
- { value: "diff", label: "View diff" },
1393
- { value: "overwrite", label: "Overwrite with template" },
1394
- { value: "skip", label: "Skip (keep existing)" }
1395
- ]
1396
- });
1397
- if (p5.isCancel(action)) process.exit(0);
1398
- if (action === "diff") {
1399
- const diff = unifiedDiff(existing, template, filePath);
1400
- p5.log.message(diff);
1401
- action = await p5.select({
1402
- message: `${filePath}`,
1403
- options: [
1404
- { value: "overwrite", label: "Overwrite with template" },
1405
- { value: "skip", label: "Skip (keep existing)" }
1406
- ]
1407
- });
1408
- if (p5.isCancel(action)) process.exit(0);
1409
- }
1410
- return action;
1411
- }
1412
1279
  function isGitRepo2(cwd) {
1413
1280
  try {
1414
- execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1281
+ execSync4("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1415
1282
  return true;
1416
1283
  } catch {
1417
1284
  return false;
1418
1285
  }
1419
1286
  }
1287
+ function hasUncommittedChanges2(cwd) {
1288
+ try {
1289
+ const status = execSync4("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1290
+ return status.length > 0;
1291
+ } catch {
1292
+ return false;
1293
+ }
1294
+ }
1420
1295
 
1421
1296
  // src/index.ts
1422
1297
  var args = process.argv.slice(2);
@@ -1542,7 +1417,7 @@ async function main() {
1542
1417
  opts.install = options.install ?? opts.install;
1543
1418
  }
1544
1419
  const dest = resolve2(process.cwd(), opts.name);
1545
- if (existsSync7(dest)) {
1420
+ if (existsSync8(dest)) {
1546
1421
  console.error(`Error: ${dest} already exists.`);
1547
1422
  process.exit(1);
1548
1423
  }