create-projx 1.1.2 → 1.3.0

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