create-projx 1.2.0 → 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;
406
405
  }
407
406
  }
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")) {
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
+ }
434
+ }
435
+ }
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,130 +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,
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 {
473
+ version,
474
+ components,
475
+ createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
476
+ baseline: {
477
+ branch: BASELINE_BRANCH,
478
+ templateVersion: version
462
479
  }
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`);
480
+ };
481
+ await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
470
482
  }
471
- async function substituteNames(dest, components, name, nameSnake) {
483
+ async function substituteNames(dest, components, paths, name, nameSnake) {
472
484
  if (components.includes("fastapi")) {
473
- await replaceInFile(
474
- join3(dest, "fastapi/pyproject.toml"),
475
- "projx-fastapi",
476
- `${name}-fastapi`
477
- );
485
+ await replaceInFile(join3(dest, `${paths.fastapi}/pyproject.toml`), "projx-fastapi", `${name}-fastapi`);
478
486
  }
479
487
  if (components.includes("fastify")) {
480
- await replaceInFile(
481
- join3(dest, "fastify/package.json"),
482
- "projx-fastify",
483
- `${name}-fastify`
484
- );
488
+ await replaceInFile(join3(dest, `${paths.fastify}/package.json`), "projx-fastify", `${name}-fastify`);
485
489
  }
486
490
  if (components.includes("frontend")) {
487
- await replaceInFile(
488
- join3(dest, "frontend/package.json"),
489
- "projx-frontend",
490
- `${name}-frontend`
491
- );
491
+ await replaceInFile(join3(dest, `${paths.frontend}/package.json`), "projx-frontend", `${name}-frontend`);
492
492
  }
493
493
  if (components.includes("e2e")) {
494
- await replaceInFile(
495
- join3(dest, "e2e/package.json"),
496
- "projx-e2e",
497
- `${name}-e2e`
498
- );
494
+ await replaceInFile(join3(dest, `${paths.e2e}/package.json`), "projx-e2e", `${name}-e2e`);
499
495
  }
500
496
  if (components.includes("mobile")) {
501
- await replaceInFile(
502
- join3(dest, "mobile/pubspec.yaml"),
503
- "projx_mobile",
504
- `${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" }
509
+ );
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" }
505
527
  );
506
- await replaceInDir(
507
- join3(dest, "mobile"),
508
- "package:projx_mobile/",
509
- `package:${nameSnake}_mobile/`,
510
- ".dart"
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" }
511
541
  );
542
+ } finally {
543
+ removeWorktree(cwd, worktree);
512
544
  }
513
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" };
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`);
648
+ }
514
649
  async function installDeps(dest, components) {
515
650
  for (const component of components) {
516
- const spinner5 = p2.spinner();
651
+ const spinner5 = p3.spinner();
517
652
  try {
518
653
  switch (component) {
519
654
  case "fastapi":
520
655
  if (hasCommand("uv")) {
521
656
  spinner5.start("Installing FastAPI dependencies (uv sync)");
522
- exec("uv sync --all-extras", join3(dest, "fastapi"));
657
+ exec("uv sync --all-extras", join4(dest, "fastapi"));
523
658
  spinner5.stop("FastAPI dependencies installed.");
524
659
  } else {
525
- 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.");
526
661
  }
527
662
  break;
528
663
  case "fastify":
529
664
  if (hasCommand("pnpm")) {
530
665
  spinner5.start("Installing Fastify dependencies (pnpm install)");
531
- exec("pnpm install", join3(dest, "fastify"));
666
+ exec("pnpm install", join4(dest, "fastify"));
532
667
  spinner5.stop("Fastify dependencies installed.");
533
668
  } else {
534
669
  spinner5.start("Installing Fastify dependencies (npm install)");
535
- exec("npm install", join3(dest, "fastify"));
670
+ exec("npm install", join4(dest, "fastify"));
536
671
  spinner5.stop("Fastify dependencies installed.");
537
672
  }
538
673
  break;
539
674
  case "frontend":
540
675
  spinner5.start("Installing Frontend dependencies (npm install)");
541
- exec("npm install", join3(dest, "frontend"));
676
+ exec("npm install", join4(dest, "frontend"));
542
677
  spinner5.stop("Frontend dependencies installed.");
543
678
  break;
544
679
  case "e2e":
545
680
  spinner5.start("Installing E2E dependencies (npm install)");
546
- exec("npm install", join3(dest, "e2e"));
681
+ exec("npm install", join4(dest, "e2e"));
547
682
  spinner5.stop("E2E dependencies installed.");
548
683
  break;
549
684
  case "mobile":
550
685
  if (hasCommand("flutter")) {
551
686
  spinner5.start("Installing Flutter dependencies");
552
- exec("flutter pub get", join3(dest, "mobile"));
687
+ exec("flutter pub get", join4(dest, "mobile"));
553
688
  spinner5.stop("Flutter dependencies installed.");
554
689
  } else {
555
- p2.log.warn(
556
- "Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
557
- );
690
+ p3.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
558
691
  }
559
692
  break;
560
693
  case "infra":
@@ -567,9 +700,9 @@ async function installDeps(dest, components) {
567
700
  }
568
701
  function copyEnvExamples(dest, components) {
569
702
  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)) {
703
+ const example = join4(dest, component, ".env.example");
704
+ const env = join4(dest, component, ".env");
705
+ if (existsSync3(example) && !existsSync3(env)) {
573
706
  try {
574
707
  copyFileSync(example, env);
575
708
  } catch {
@@ -579,62 +712,32 @@ function copyEnvExamples(dest, components) {
579
712
  }
580
713
 
581
714
  // 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
- }
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";
621
720
  async function update(cwd, localRepo) {
622
- p3.intro("projx update");
721
+ p4.intro("projx update");
623
722
  const isLocal = !!localRepo;
624
- 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");
625
732
  let config;
626
- if (existsSync3(configPath)) {
733
+ if (existsSync4(configPath)) {
627
734
  config = JSON.parse(await readFile4(configPath, "utf-8"));
628
- p3.log.info(
629
- `Found .projx (v${config.version}, components: ${config.components.join(", ")})`
630
- );
735
+ p4.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
631
736
  } 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
- );
737
+ p4.log.warn("No .projx file found. Detecting components from directories.");
738
+ const detected = COMPONENTS.filter((c) => existsSync4(join5(cwd, c)));
636
739
  if (detected.length === 0) {
637
- p3.log.error("No projx components found in this directory.");
740
+ p4.log.error("No projx components found. Run 'projx init' first.");
638
741
  process.exit(1);
639
742
  }
640
743
  config = {
@@ -642,171 +745,93 @@ async function update(cwd, localRepo) {
642
745
  components: detected,
643
746
  createdAt: "unknown"
644
747
  };
645
- p3.log.info(`Detected: ${detected.join(", ")}`);
748
+ p4.log.info(`Detected: ${detected.join(", ")}`);
646
749
  }
647
750
  const componentPaths = await discoverComponentPaths(cwd, config.components);
648
751
  const remapped = config.components.filter((c) => componentPaths[c] !== c);
649
752
  if (remapped.length > 0) {
650
753
  for (const c of remapped) {
651
- p3.log.info(`${c} \u2192 ${componentPaths[c]}/`);
754
+ p4.log.info(`${c} \u2192 ${componentPaths[c]}/`);
652
755
  }
653
756
  }
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);
661
- }
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}`;
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.");
679
775
  }
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);
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;
687
783
  }
688
- for (const f of touchedFiles) {
689
- 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.`);
690
799
  }
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
- );
800
+ } catch (err) {
713
801
  try {
714
- await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
715
- } finally {
716
- await cleanupRepo(repoDir, isLocal);
802
+ execSync3("git merge --abort", { cwd, stdio: "pipe" });
803
+ } catch {
717
804
  }
718
- 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);
719
810
  }
720
811
  }
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
- );
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;
791
826
  }
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
827
  }
801
828
  function detectProjectName(cwd, components, componentPaths) {
802
829
  for (const component of components) {
803
830
  const dir = componentPaths[component] ?? component;
804
- const pkgPath = join4(cwd, dir, "package.json");
805
- if (existsSync3(pkgPath)) {
831
+ const pkgPath = join5(cwd, dir, "package.json");
832
+ if (existsSync4(pkgPath)) {
806
833
  try {
807
- const pkg = JSON.parse(
808
- readFileSync(pkgPath, "utf-8")
809
- );
834
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
810
835
  const n = pkg.name;
811
836
  if (n && n.includes("-")) {
812
837
  return n.substring(0, n.lastIndexOf("-"));
@@ -819,187 +844,134 @@ function detectProjectName(cwd, components, componentPaths) {
819
844
  }
820
845
 
821
846
  // 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";
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";
826
851
  async function add(cwd, newComponents, localRepo, skipInstall = false) {
827
- p4.intro("projx add");
852
+ p5.intro("projx add");
828
853
  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.");
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.");
832
857
  process.exit(1);
833
858
  }
834
859
  const config = JSON.parse(await readFile5(configPath, "utf-8"));
835
860
  const existing = config.components;
836
861
  const alreadyExists = newComponents.filter((c) => existing.includes(c));
837
862
  if (alreadyExists.length > 0) {
838
- p4.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
863
+ p5.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
839
864
  }
840
865
  const toAdd = newComponents.filter((c) => !existing.includes(c));
841
866
  if (toAdd.length === 0) {
842
- p4.log.info("Nothing new to add.");
867
+ p5.log.info("Nothing new to add.");
843
868
  process.exit(0);
844
869
  }
845
- p4.log.info(`Adding: ${toAdd.join(", ")}`);
846
- const dlSpinner = p4.spinner();
870
+ p5.log.info(`Adding: ${toAdd.join(", ")}`);
871
+ const dlSpinner = p5.spinner();
847
872
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
848
873
  const repoDir = await downloadRepo(localRepo).catch((err) => {
849
874
  dlSpinner.stop("Failed.");
850
- p4.log.error(String(err));
875
+ p5.log.error(String(err));
851
876
  process.exit(1);
852
877
  });
853
878
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
854
879
  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 {
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
+ }
902
924
  }
903
925
  }
926
+ } finally {
927
+ await cleanupRepo(repoDir, isLocal);
904
928
  }
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.
929
+ p5.outro(`Added ${toAdd.join(", ")}.
915
930
 
916
931
  Like projx? Star it: https://github.com/ukanhaupa/projx`);
917
932
  }
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
933
  async function installDeps2(dest, components) {
962
934
  for (const component of components) {
963
- const spinner5 = p4.spinner();
935
+ const spinner5 = p5.spinner();
964
936
  try {
965
937
  switch (component) {
966
938
  case "fastapi":
967
939
  if (hasCommand("uv")) {
968
940
  spinner5.start("Installing FastAPI dependencies");
969
- exec("uv sync --all-extras", join5(dest, "fastapi"));
941
+ exec("uv sync --all-extras", join6(dest, "fastapi"));
970
942
  spinner5.stop("FastAPI dependencies installed.");
971
943
  } else {
972
- 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.");
973
945
  }
974
946
  break;
975
947
  case "fastify":
976
948
  if (hasCommand("pnpm")) {
977
949
  spinner5.start("Installing Fastify dependencies");
978
- exec("pnpm install", join5(dest, "fastify"));
950
+ exec("pnpm install", join6(dest, "fastify"));
979
951
  spinner5.stop("Fastify dependencies installed.");
980
952
  } else {
981
953
  spinner5.start("Installing Fastify dependencies");
982
- exec("npm install", join5(dest, "fastify"));
954
+ exec("npm install", join6(dest, "fastify"));
983
955
  spinner5.stop("Fastify dependencies installed.");
984
956
  }
985
957
  break;
986
958
  case "frontend":
987
959
  spinner5.start("Installing Frontend dependencies");
988
- exec("npm install", join5(dest, "frontend"));
960
+ exec("npm install", join6(dest, "frontend"));
989
961
  spinner5.stop("Frontend dependencies installed.");
990
962
  break;
991
963
  case "e2e":
992
964
  spinner5.start("Installing E2E dependencies");
993
- exec("npm install", join5(dest, "e2e"));
965
+ exec("npm install", join6(dest, "e2e"));
994
966
  spinner5.stop("E2E dependencies installed.");
995
967
  break;
996
968
  case "mobile":
997
969
  if (hasCommand("flutter")) {
998
970
  spinner5.start("Installing Flutter dependencies");
999
- exec("flutter pub get", join5(dest, "mobile"));
971
+ exec("flutter pub get", join6(dest, "mobile"));
1000
972
  spinner5.stop("Flutter dependencies installed.");
1001
973
  } else {
1002
- 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.");
1003
975
  }
1004
976
  break;
1005
977
  case "infra":
@@ -1013,12 +985,10 @@ async function installDeps2(dest, components) {
1013
985
  function detectProjectName2(cwd, components, paths) {
1014
986
  for (const component of components) {
1015
987
  const dir = paths[component] ?? component;
1016
- const pkgPath = join5(cwd, dir, "package.json");
1017
- if (existsSync4(pkgPath)) {
988
+ const pkgPath = join6(cwd, dir, "package.json");
989
+ if (existsSync5(pkgPath)) {
1018
990
  try {
1019
- const pkg = JSON.parse(
1020
- readFileSync2(pkgPath, "utf-8")
1021
- );
991
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
1022
992
  const n = pkg.name;
1023
993
  if (n && n.includes("-")) {
1024
994
  return n.substring(0, n.lastIndexOf("-"));
@@ -1031,22 +1001,22 @@ function detectProjectName2(cwd, components, paths) {
1031
1001
  }
1032
1002
 
1033
1003
  // 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";
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";
1039
1009
 
1040
1010
  // src/detect.ts
1041
- import { existsSync as existsSync5 } from "fs";
1011
+ import { existsSync as existsSync6 } from "fs";
1042
1012
  import { readdir as readdir2 } from "fs/promises";
1043
- import { join as join6 } from "path";
1013
+ import { join as join7 } from "path";
1044
1014
  async function detectComponents(cwd) {
1045
1015
  const results = [];
1046
1016
  const entries = await readdir2(cwd, { withFileTypes: true });
1047
1017
  const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)).map((e) => e.name);
1048
1018
  for (const dir of dirs) {
1049
- const full = join6(cwd, dir);
1019
+ const full = join7(cwd, dir);
1050
1020
  const detections = await scanDirectory(full, dir);
1051
1021
  results.push(...detections);
1052
1022
  }
@@ -1054,7 +1024,7 @@ async function detectComponents(cwd) {
1054
1024
  }
1055
1025
  async function scanDirectory(dir, relPath) {
1056
1026
  const results = [];
1057
- const pyproject = await readFileOrNull(join6(dir, "pyproject.toml"));
1027
+ const pyproject = await readFileOrNull(join7(dir, "pyproject.toml"));
1058
1028
  if (pyproject && /fastapi/i.test(pyproject)) {
1059
1029
  results.push({
1060
1030
  component: "fastapi",
@@ -1091,7 +1061,7 @@ async function scanDirectory(dir, relPath) {
1091
1061
  });
1092
1062
  }
1093
1063
  }
1094
- const pubspec = await readFileOrNull(join6(dir, "pubspec.yaml"));
1064
+ const pubspec = await readFileOrNull(join7(dir, "pubspec.yaml"));
1095
1065
  if (pubspec && /flutter:/i.test(pubspec)) {
1096
1066
  results.push({
1097
1067
  component: "mobile",
@@ -1100,7 +1070,7 @@ async function scanDirectory(dir, relPath) {
1100
1070
  evidence: "pubspec.yaml has flutter dependency"
1101
1071
  });
1102
1072
  }
1103
- 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"));
1104
1074
  if (hasTf) {
1105
1075
  results.push({
1106
1076
  component: "infra",
@@ -1112,7 +1082,7 @@ async function scanDirectory(dir, relPath) {
1112
1082
  return results;
1113
1083
  }
1114
1084
  async function readPkg(dir) {
1115
- const content = await readFileOrNull(join6(dir, "package.json"));
1085
+ const content = await readFileOrNull(join7(dir, "package.json"));
1116
1086
  if (!content) return null;
1117
1087
  try {
1118
1088
  return JSON.parse(content);
@@ -1121,83 +1091,23 @@ async function readPkg(dir) {
1121
1091
  }
1122
1092
  }
1123
1093
 
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
1094
  // src/init.ts
1193
1095
  async function init(cwd, localRepo) {
1194
- p5.intro("projx init");
1096
+ p6.intro("projx init");
1195
1097
  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.");
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.`);
1104
+ process.exit(1);
1105
+ }
1106
+ if (hasUncommittedChanges2(cwd)) {
1107
+ p6.log.error("You have uncommitted changes. Commit or stash them first.");
1198
1108
  process.exit(1);
1199
1109
  }
1200
- const spinner5 = p5.spinner();
1110
+ const spinner5 = p6.spinner();
1201
1111
  spinner5.start("Scanning for components");
1202
1112
  const detected = await detectComponents(cwd);
1203
1113
  spinner5.stop(
@@ -1210,7 +1120,7 @@ async function init(cwd, localRepo) {
1210
1120
  confirmed = await manualSelect(cwd);
1211
1121
  }
1212
1122
  if (confirmed.length === 0) {
1213
- p5.log.warn("No components selected. Nothing to do.");
1123
+ p6.log.warn("No components selected. Nothing to do.");
1214
1124
  process.exit(0);
1215
1125
  }
1216
1126
  const components = confirmed.map((c) => c.component);
@@ -1219,54 +1129,51 @@ async function init(cwd, localRepo) {
1219
1129
  );
1220
1130
  const projectName = toKebab(cwd.split("/").pop());
1221
1131
  const vars = { projectName, components, paths };
1222
- const dlSpinner = p5.spinner();
1132
+ const dlSpinner = p6.spinner();
1223
1133
  dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
1224
1134
  const repoDir = await downloadRepo(localRepo).catch((err) => {
1225
1135
  dlSpinner.stop("Failed.");
1226
- p5.log.error(String(err));
1136
+ p6.log.error(String(err));
1227
1137
  process.exit(1);
1228
1138
  });
1229
1139
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1230
1140
  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
- }
1237
- }
1238
- await generateSharedFiles(cwd, repoDir, vars);
1239
- const pkg = JSON.parse(
1240
- 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
1241
1154
  );
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)) {
1155
+ mergeSpinner.stop("Baseline merged. Your code is preserved.");
1156
+ if (!existsSync7(join8(cwd, ".githooks"))) {
1250
1157
  try {
1251
- execSync3("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1252
- p5.log.success("Git hooks configured.");
1158
+ execSync4("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1159
+ p6.log.success("Git hooks configured.");
1253
1160
  } catch {
1254
- p5.log.warn("Failed to configure git hooks.");
1161
+ p6.log.warn("Failed to configure git hooks.");
1255
1162
  }
1256
1163
  }
1257
1164
  } finally {
1258
1165
  await cleanupRepo(repoDir, isLocal);
1259
1166
  }
1260
- 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");
1261
1168
  }
1262
1169
  async function confirmDetections(detected) {
1263
1170
  const confirmed = [];
1264
1171
  for (const d of detected) {
1265
- const yes = await p5.confirm({
1172
+ const yes = await p6.confirm({
1266
1173
  message: `Found ${LABELS[d.component].label} in ${d.directory}/ \u2014 register as "${d.component}"?`,
1267
1174
  initialValue: true
1268
1175
  });
1269
- if (p5.isCancel(yes)) process.exit(0);
1176
+ if (p6.isCancel(yes)) process.exit(0);
1270
1177
  if (yes) {
1271
1178
  confirmed.push({ component: d.component, directory: d.directory });
1272
1179
  }
@@ -1274,7 +1181,7 @@ async function confirmDetections(detected) {
1274
1181
  return confirmed;
1275
1182
  }
1276
1183
  async function manualSelect(cwd) {
1277
- const selected = await p5.multiselect({
1184
+ const selected = await p6.multiselect({
1278
1185
  message: "No components detected. Select manually:",
1279
1186
  options: COMPONENTS.map((c) => ({
1280
1187
  value: c,
@@ -1283,140 +1190,39 @@ async function manualSelect(cwd) {
1283
1190
  })),
1284
1191
  required: false
1285
1192
  });
1286
- if (p5.isCancel(selected)) process.exit(0);
1193
+ if (p6.isCancel(selected)) process.exit(0);
1287
1194
  const result = [];
1288
1195
  for (const component of selected) {
1289
- const dir = await p5.text({
1196
+ const dir = await p6.text({
1290
1197
  message: `Directory for ${LABELS[component].label}?`,
1291
1198
  placeholder: component,
1292
1199
  defaultValue: component
1293
1200
  });
1294
- if (p5.isCancel(dir)) process.exit(0);
1295
- if (!existsSync6(join7(cwd, dir))) {
1296
- 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.`);
1297
1204
  continue;
1298
1205
  }
1299
1206
  result.push({ component, directory: dir });
1300
1207
  }
1301
1208
  return result;
1302
1209
  }
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
1210
  function isGitRepo2(cwd) {
1413
1211
  try {
1414
- execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1212
+ execSync4("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1415
1213
  return true;
1416
1214
  } catch {
1417
1215
  return false;
1418
1216
  }
1419
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
+ }
1420
1226
 
1421
1227
  // src/index.ts
1422
1228
  var args = process.argv.slice(2);
@@ -1542,7 +1348,7 @@ async function main() {
1542
1348
  opts.install = options.install ?? opts.install;
1543
1349
  }
1544
1350
  const dest = resolve2(process.cwd(), opts.name);
1545
- if (existsSync7(dest)) {
1351
+ if (existsSync8(dest)) {
1546
1352
  console.error(`Error: ${dest} already exists.`);
1547
1353
  process.exit(1);
1548
1354
  }