create-projx 1.0.1 → 1.1.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 existsSync5 } from "fs";
4
+ import { existsSync as existsSync7 } from "fs";
5
5
  import { resolve as resolve2 } from "path";
6
6
 
7
7
  // src/utils.ts
@@ -176,6 +176,47 @@ async function replaceInDir(dir, find, replace, ext) {
176
176
  }
177
177
  }
178
178
  }
179
+ var COMPONENT_MARKER = ".projx-component";
180
+ async function readFileOrNull(path) {
181
+ try {
182
+ return await readFile(path, "utf-8");
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+ async function writeComponentMarker(dir, component) {
188
+ await writeFile(
189
+ join(dir, COMPONENT_MARKER),
190
+ JSON.stringify({ component }, null, 2) + "\n"
191
+ );
192
+ }
193
+ async function discoverComponentPaths(cwd, components) {
194
+ const paths = {};
195
+ const scan = async (dir) => {
196
+ const entries = await readdir(dir, { withFileTypes: true });
197
+ for (const entry of entries) {
198
+ if (!entry.isDirectory()) continue;
199
+ if (EXCLUDE.has(entry.name)) continue;
200
+ if (entry.name.startsWith(".")) continue;
201
+ const full = join(dir, entry.name);
202
+ const marker = join(full, COMPONENT_MARKER);
203
+ if (existsSync(marker)) {
204
+ try {
205
+ const data = JSON.parse(await readFile(marker, "utf-8"));
206
+ if (components.includes(data.component)) {
207
+ paths[data.component] = entry.name;
208
+ }
209
+ } catch {
210
+ }
211
+ }
212
+ }
213
+ };
214
+ await scan(cwd);
215
+ for (const c of components) {
216
+ if (!paths[c]) paths[c] = c;
217
+ }
218
+ return paths;
219
+ }
179
220
  function render(template, vars) {
180
221
  const components = vars.components;
181
222
  const projectName = vars.projectName;
@@ -201,8 +242,15 @@ function render(template, vars) {
201
242
  }
202
243
  if (stack.length > 0 && stack.some((v) => !v)) continue;
203
244
  const replaced = line.replace(
204
- /<%=\s*(\w+)\s*%>/g,
205
- (_, key) => String(vars[key] ?? "")
245
+ /<%=\s*([\w.]+)\s*%>/g,
246
+ (_, expr) => {
247
+ const parts = expr.split(".");
248
+ let val = vars;
249
+ for (const p6 of parts) {
250
+ val = val?.[p6];
251
+ }
252
+ return String(val ?? "");
253
+ }
206
254
  );
207
255
  output.push(replaced);
208
256
  }
@@ -288,7 +336,10 @@ async function generateReadme(vars) {
288
336
  async function scaffold(opts, dest, localRepo) {
289
337
  const name = toKebab(opts.name);
290
338
  const nameSnake = toSnake(opts.name);
291
- const vars = { projectName: name, components: opts.components };
339
+ const paths = Object.fromEntries(
340
+ opts.components.map((c) => [c, c])
341
+ );
342
+ const vars = { projectName: name, components: opts.components, paths };
292
343
  const isLocal = !!localRepo;
293
344
  await mkdir2(dest, { recursive: true });
294
345
  const dlSpinner = p2.spinner();
@@ -307,42 +358,28 @@ async function scaffold(opts, dest, localRepo) {
307
358
  }
308
359
  async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
309
360
  p2.log.info(`Scaffolding project in ${dest}`);
310
- const manifest = [];
311
361
  for (const component of opts.components) {
312
- const spinner4 = p2.spinner();
313
- spinner4.start(`Copying ${component}/`);
314
- const files = await copyComponent(repoDir, component, dest);
315
- manifest.push(...files.map((f) => `${component}/${f}`));
316
- spinner4.stop(`${component}/`);
362
+ const spinner5 = p2.spinner();
363
+ spinner5.start(`Copying ${component}/`);
364
+ await copyComponent(repoDir, component, dest);
365
+ await writeComponentMarker(join3(dest, component), component);
366
+ spinner5.stop(`${component}/`);
317
367
  }
318
368
  await substituteNames(dest, opts.components, name, nameSnake);
319
369
  const hasBackend = opts.components.includes("fastapi") || opts.components.includes("fastify");
320
370
  if (hasBackend || opts.components.includes("frontend")) {
321
- const dc = await generateDockerCompose(vars);
322
- await writeFile2(join3(dest, "docker-compose.yml"), dc);
323
- manifest.push("docker-compose.yml");
324
- const dcDev = await generateDockerComposeDev(vars);
325
- await writeFile2(join3(dest, "docker-compose.dev.yml"), dcDev);
326
- manifest.push("docker-compose.dev.yml");
327
- }
328
- const readme = await generateReadme(vars);
329
- await writeFile2(join3(dest, "README.md"), readme);
330
- manifest.push("README.md");
371
+ await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
372
+ await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
373
+ }
374
+ await writeFile2(join3(dest, "README.md"), await generateReadme(vars));
331
375
  await mkdir2(join3(dest, ".githooks"), { recursive: true });
332
- const preCommit = await generatePreCommit(vars);
333
- await writeFile2(join3(dest, ".githooks/pre-commit"), preCommit);
376
+ await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
334
377
  await chmod(join3(dest, ".githooks/pre-commit"), 493);
335
- manifest.push(".githooks/pre-commit");
336
378
  await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
337
- const lintYml = await generateCiYml(vars);
338
- await writeFile2(join3(dest, ".github/workflows/ci.yml"), lintYml);
339
- manifest.push(".github/workflows/ci.yml");
340
- const setupSh = await generateSetupSh(vars);
341
- await writeFile2(join3(dest, "setup.sh"), setupSh);
379
+ await writeFile2(join3(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
380
+ await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
342
381
  await chmod(join3(dest, "setup.sh"), 493);
343
- manifest.push("setup.sh");
344
- const staticFiles = await copyStaticFiles(repoDir, dest);
345
- manifest.push(...staticFiles);
382
+ await copyStaticFiles(repoDir, dest);
346
383
  const pkg = JSON.parse(
347
384
  await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
348
385
  );
@@ -350,7 +387,7 @@ async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
350
387
  version: pkg.version,
351
388
  components: opts.components,
352
389
  createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
353
- files: manifest.sort()
390
+ paths: vars.paths
354
391
  };
355
392
  await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2));
356
393
  if (opts.git) {
@@ -377,7 +414,9 @@ async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
377
414
  p2.outro(`Done! Next steps:
378
415
 
379
416
  cd ${name}
380
- ./setup.sh`);
417
+ ./setup.sh
418
+
419
+ Like projx? Star it: https://github.com/ukanhaupa/projx`);
381
420
  }
382
421
  async function substituteNames(dest, components, name, nameSnake) {
383
422
  if (components.includes("fastapi")) {
@@ -424,44 +463,44 @@ async function substituteNames(dest, components, name, nameSnake) {
424
463
  }
425
464
  async function installDeps(dest, components) {
426
465
  for (const component of components) {
427
- const spinner4 = p2.spinner();
466
+ const spinner5 = p2.spinner();
428
467
  try {
429
468
  switch (component) {
430
469
  case "fastapi":
431
470
  if (hasCommand("uv")) {
432
- spinner4.start("Installing FastAPI dependencies (uv sync)");
471
+ spinner5.start("Installing FastAPI dependencies (uv sync)");
433
472
  exec("uv sync --all-extras", join3(dest, "fastapi"));
434
- spinner4.stop("FastAPI dependencies installed.");
473
+ spinner5.stop("FastAPI dependencies installed.");
435
474
  } else {
436
475
  p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
437
476
  }
438
477
  break;
439
478
  case "fastify":
440
479
  if (hasCommand("pnpm")) {
441
- spinner4.start("Installing Fastify dependencies (pnpm install)");
480
+ spinner5.start("Installing Fastify dependencies (pnpm install)");
442
481
  exec("pnpm install", join3(dest, "fastify"));
443
- spinner4.stop("Fastify dependencies installed.");
482
+ spinner5.stop("Fastify dependencies installed.");
444
483
  } else {
445
- spinner4.start("Installing Fastify dependencies (npm install)");
484
+ spinner5.start("Installing Fastify dependencies (npm install)");
446
485
  exec("npm install", join3(dest, "fastify"));
447
- spinner4.stop("Fastify dependencies installed.");
486
+ spinner5.stop("Fastify dependencies installed.");
448
487
  }
449
488
  break;
450
489
  case "frontend":
451
- spinner4.start("Installing Frontend dependencies (npm install)");
490
+ spinner5.start("Installing Frontend dependencies (npm install)");
452
491
  exec("npm install", join3(dest, "frontend"));
453
- spinner4.stop("Frontend dependencies installed.");
492
+ spinner5.stop("Frontend dependencies installed.");
454
493
  break;
455
494
  case "e2e":
456
- spinner4.start("Installing E2E dependencies (npm install)");
495
+ spinner5.start("Installing E2E dependencies (npm install)");
457
496
  exec("npm install", join3(dest, "e2e"));
458
- spinner4.stop("E2E dependencies installed.");
497
+ spinner5.stop("E2E dependencies installed.");
459
498
  break;
460
499
  case "mobile":
461
500
  if (hasCommand("flutter")) {
462
- spinner4.start("Installing Flutter dependencies");
501
+ spinner5.start("Installing Flutter dependencies");
463
502
  exec("flutter pub get", join3(dest, "mobile"));
464
- spinner4.stop("Flutter dependencies installed.");
503
+ spinner5.stop("Flutter dependencies installed.");
465
504
  } else {
466
505
  p2.log.warn(
467
506
  "Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
@@ -472,7 +511,7 @@ async function installDeps(dest, components) {
472
511
  break;
473
512
  }
474
513
  } catch {
475
- spinner4.stop(`Failed to install ${component} dependencies.`);
514
+ spinner5.stop(`Failed to install ${component} dependencies.`);
476
515
  }
477
516
  }
478
517
  }
@@ -499,7 +538,8 @@ var NEVER_OVERWRITE = [
499
538
  /\.env$/,
500
539
  /\.env\.(dev|staging|prod)$/,
501
540
  /prisma\/migrations\//,
502
- /src\/migrations\/versions\//
541
+ /src\/migrations\/versions\//,
542
+ /\.projx-component$/
503
543
  ];
504
544
  function isGitRepo(cwd) {
505
545
  try {
@@ -550,11 +590,17 @@ async function update(cwd, localRepo) {
550
590
  config = {
551
591
  version: "0.0.0",
552
592
  components: detected,
553
- createdAt: "unknown",
554
- files: []
593
+ createdAt: "unknown"
555
594
  };
556
595
  p3.log.info(`Detected: ${detected.join(", ")}`);
557
596
  }
597
+ const componentPaths = await discoverComponentPaths(cwd, config.components);
598
+ const remapped = config.components.filter((c) => componentPaths[c] !== c);
599
+ if (remapped.length > 0) {
600
+ for (const c of remapped) {
601
+ p3.log.info(`${c} \u2192 ${componentPaths[c]}/`);
602
+ }
603
+ }
558
604
  const useGitBranch = isGitRepo(cwd);
559
605
  let branchName;
560
606
  let originalBranch;
@@ -584,7 +630,7 @@ async function update(cwd, localRepo) {
584
630
  execSync2(`git checkout -b ${branchName}`, { cwd, stdio: "pipe" });
585
631
  p3.log.info(`Created branch: ${branchName}`);
586
632
  try {
587
- await doUpdate(cwd, config, repoDir, pkg.version);
633
+ await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
588
634
  } finally {
589
635
  await cleanupRepo(repoDir, isLocal);
590
636
  }
@@ -612,76 +658,73 @@ async function update(cwd, localRepo) {
612
658
  await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
613
659
  );
614
660
  try {
615
- await doUpdate(cwd, config, repoDir, pkg.version);
661
+ await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
616
662
  } finally {
617
663
  await cleanupRepo(repoDir, isLocal);
618
664
  }
619
665
  p3.outro(`Updated to v${pkg.version}. Review changes before committing.`);
620
666
  }
621
667
  }
622
- async function doUpdate(cwd, config, repoDir, version) {
623
- const name = detectProjectName(cwd, config.components);
668
+ async function doUpdate(cwd, config, repoDir, version, componentPaths) {
669
+ const name = detectProjectName(cwd, config.components, componentPaths);
624
670
  const nameSnake = toSnake(name);
625
- const vars = { projectName: name, components: config.components };
626
- const newManifest = [];
671
+ const vars = { projectName: name, components: config.components, paths: componentPaths };
627
672
  for (const component of config.components) {
628
- const spinner5 = p3.spinner();
629
- spinner5.start(`Updating ${component}/ template files`);
673
+ const targetDir = componentPaths[component];
674
+ const spinner6 = p3.spinner();
675
+ spinner6.start(`Updating ${targetDir}/ (${component})`);
630
676
  const componentSrc = join4(repoDir, component);
631
677
  if (!existsSync3(componentSrc)) {
632
- spinner5.stop(`${component}/ template not found, skipping.`);
678
+ spinner6.stop(`${component} template not found, skipping.`);
633
679
  continue;
634
680
  }
635
681
  const tmpDest = join4(cwd, `.projx-tmp`);
636
682
  const files = await copyComponent(repoDir, component, tmpDest);
637
683
  for (const file of files) {
638
- const rel = `${component}/${file}`;
639
684
  const src = join4(tmpDest, component, file);
640
- const dest = join4(cwd, rel);
641
- if (NEVER_OVERWRITE.some((re) => re.test(rel))) continue;
642
- if (config.files.length > 0 && !config.files.includes(rel)) continue;
685
+ const destRel = `${targetDir}/${file}`;
686
+ const dest = join4(cwd, destRel);
687
+ if (NEVER_OVERWRITE.some((re) => re.test(destRel))) continue;
643
688
  const dir = dest.substring(0, dest.lastIndexOf("/"));
644
689
  await mkdir3(dir, { recursive: true });
645
690
  await cp2(src, dest, { force: true });
646
- newManifest.push(rel);
647
691
  }
648
692
  await rm2(tmpDest, { recursive: true, force: true });
649
- spinner5.stop(`${component}/ updated.`);
693
+ if (!existsSync3(join4(cwd, targetDir, ".projx-component"))) {
694
+ await writeComponentMarker(join4(cwd, targetDir), component);
695
+ }
696
+ spinner6.stop(`${targetDir}/ updated.`);
650
697
  }
651
- const spinner4 = p3.spinner();
652
- spinner4.start("Updating shared files");
698
+ const spinner5 = p3.spinner();
699
+ spinner5.start("Updating shared files");
653
700
  const hasBackend = config.components.includes("fastapi") || config.components.includes("fastify");
654
701
  if (hasBackend || config.components.includes("frontend")) {
655
702
  await writeFile3(
656
703
  join4(cwd, "docker-compose.yml"),
657
704
  await generateDockerCompose(vars)
658
705
  );
659
- newManifest.push("docker-compose.yml");
660
706
  await writeFile3(
661
707
  join4(cwd, "docker-compose.dev.yml"),
662
708
  await generateDockerComposeDev(vars)
663
709
  );
664
- newManifest.push("docker-compose.dev.yml");
665
710
  }
666
711
  await mkdir3(join4(cwd, ".githooks"), { recursive: true });
667
712
  const preCommit = await generatePreCommit(vars);
668
713
  await writeFile3(join4(cwd, ".githooks/pre-commit"), preCommit);
669
714
  await chmod2(join4(cwd, ".githooks/pre-commit"), 493);
670
- newManifest.push(".githooks/pre-commit");
671
715
  await mkdir3(join4(cwd, ".github/workflows"), { recursive: true });
672
716
  await writeFile3(
673
717
  join4(cwd, ".github/workflows/ci.yml"),
674
718
  await generateCiYml(vars)
675
719
  );
676
- newManifest.push(".github/workflows/ci.yml");
677
720
  const setupSh = await generateSetupSh(vars);
678
721
  await writeFile3(join4(cwd, "setup.sh"), setupSh);
679
722
  await chmod2(join4(cwd, "setup.sh"), 493);
680
- newManifest.push("setup.sh");
681
- spinner4.stop("Shared files updated.");
723
+ spinner5.stop("Shared files updated.");
682
724
  if (config.components.includes("mobile")) {
725
+ const mobilePath = componentPaths.mobile ?? "mobile";
683
726
  await replaceInDir(
684
- join4(cwd, "mobile"),
727
+ join4(cwd, mobilePath),
685
728
  "package:projx_mobile/",
686
729
  `package:${nameSnake}_mobile/`,
687
730
  ".dart"
@@ -691,13 +734,14 @@ async function doUpdate(cwd, config, repoDir, version) {
691
734
  version,
692
735
  components: config.components,
693
736
  createdAt: config.createdAt,
694
- files: [.../* @__PURE__ */ new Set([...config.files, ...newManifest])].sort()
737
+ paths: componentPaths
695
738
  };
696
739
  await writeFile3(join4(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
697
740
  }
698
- function detectProjectName(cwd, components) {
741
+ function detectProjectName(cwd, components, componentPaths) {
699
742
  for (const component of components) {
700
- const pkgPath = join4(cwd, component, "package.json");
743
+ const dir = componentPaths[component] ?? component;
744
+ const pkgPath = join4(cwd, dir, "package.json");
701
745
  if (existsSync3(pkgPath)) {
702
746
  try {
703
747
  const pkg = JSON.parse(
@@ -754,44 +798,37 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
754
798
  }
755
799
  }
756
800
  async function doAdd(cwd, config, toAdd, repoDir, skipInstall) {
757
- const name = detectProjectName2(cwd, config.components);
758
- const nameSnake = toSnake(name);
759
801
  const allComponents = [...config.components, ...toAdd];
760
- const vars = { projectName: name, components: allComponents };
761
- const newFiles = [];
802
+ const existingPaths = await discoverComponentPaths(cwd, config.components);
803
+ const paths = { ...existingPaths };
804
+ for (const c of toAdd) paths[c] = c;
805
+ const name = detectProjectName2(cwd, config.components, paths);
806
+ const nameSnake = toSnake(name);
807
+ const vars = { projectName: name, components: allComponents, paths };
762
808
  for (const component of toAdd) {
763
- const spinner5 = p4.spinner();
764
- spinner5.start(`Adding ${component}/`);
765
- const files = await copyComponent(repoDir, component, cwd);
766
- newFiles.push(...files.map((f) => `${component}/${f}`));
767
- spinner5.stop(`${component}/`);
809
+ const spinner6 = p4.spinner();
810
+ spinner6.start(`Adding ${component}/`);
811
+ await copyComponent(repoDir, component, cwd);
812
+ await writeComponentMarker(join5(cwd, component), component);
813
+ spinner6.stop(`${component}/`);
768
814
  }
769
815
  await substituteNames2(cwd, toAdd, name, nameSnake);
770
- const spinner4 = p4.spinner();
771
- spinner4.start("Regenerating shared files");
816
+ const spinner5 = p4.spinner();
817
+ spinner5.start("Regenerating shared files");
772
818
  const hasBackend = allComponents.includes("fastapi") || allComponents.includes("fastify");
773
819
  if (hasBackend || allComponents.includes("frontend")) {
774
- await writeFile4(
775
- join5(cwd, "docker-compose.yml"),
776
- await generateDockerCompose(vars)
777
- );
778
- await writeFile4(
779
- join5(cwd, "docker-compose.dev.yml"),
780
- await generateDockerComposeDev(vars)
781
- );
820
+ await writeFile4(join5(cwd, "docker-compose.yml"), await generateDockerCompose(vars));
821
+ await writeFile4(join5(cwd, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
782
822
  }
783
823
  await writeFile4(join5(cwd, "README.md"), await generateReadme(vars));
784
824
  await mkdir4(join5(cwd, ".githooks"), { recursive: true });
785
825
  await writeFile4(join5(cwd, ".githooks/pre-commit"), await generatePreCommit(vars));
786
826
  await chmod3(join5(cwd, ".githooks/pre-commit"), 493);
787
827
  await mkdir4(join5(cwd, ".github/workflows"), { recursive: true });
788
- await writeFile4(
789
- join5(cwd, ".github/workflows/ci.yml"),
790
- await generateCiYml(vars)
791
- );
828
+ await writeFile4(join5(cwd, ".github/workflows/ci.yml"), await generateCiYml(vars));
792
829
  await writeFile4(join5(cwd, "setup.sh"), await generateSetupSh(vars));
793
830
  await chmod3(join5(cwd, "setup.sh"), 493);
794
- spinner4.stop("Shared files regenerated.");
831
+ spinner5.stop("Shared files regenerated.");
795
832
  if (!skipInstall) {
796
833
  await installDeps2(cwd, toAdd);
797
834
  }
@@ -812,10 +849,12 @@ async function doAdd(cwd, config, toAdd, repoDir, skipInstall) {
812
849
  version: pkg.version,
813
850
  components: allComponents,
814
851
  createdAt: config.createdAt,
815
- files: [.../* @__PURE__ */ new Set([...config.files, ...newFiles])].sort()
852
+ paths
816
853
  };
817
854
  await writeFile4(join5(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
818
- p4.outro(`Added ${toAdd.join(", ")}. Shared files updated for all ${allComponents.length} components.`);
855
+ p4.outro(`Added ${toAdd.join(", ")}. Shared files updated for all ${allComponents.length} components.
856
+
857
+ Like projx? Star it: https://github.com/ukanhaupa/projx`);
819
858
  }
820
859
  async function substituteNames2(dest, components, name, nameSnake) {
821
860
  if (components.includes("fastapi")) {
@@ -862,44 +901,44 @@ async function substituteNames2(dest, components, name, nameSnake) {
862
901
  }
863
902
  async function installDeps2(dest, components) {
864
903
  for (const component of components) {
865
- const spinner4 = p4.spinner();
904
+ const spinner5 = p4.spinner();
866
905
  try {
867
906
  switch (component) {
868
907
  case "fastapi":
869
908
  if (hasCommand("uv")) {
870
- spinner4.start("Installing FastAPI dependencies");
909
+ spinner5.start("Installing FastAPI dependencies");
871
910
  exec("uv sync --all-extras", join5(dest, "fastapi"));
872
- spinner4.stop("FastAPI dependencies installed.");
911
+ spinner5.stop("FastAPI dependencies installed.");
873
912
  } else {
874
913
  p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
875
914
  }
876
915
  break;
877
916
  case "fastify":
878
917
  if (hasCommand("pnpm")) {
879
- spinner4.start("Installing Fastify dependencies");
918
+ spinner5.start("Installing Fastify dependencies");
880
919
  exec("pnpm install", join5(dest, "fastify"));
881
- spinner4.stop("Fastify dependencies installed.");
920
+ spinner5.stop("Fastify dependencies installed.");
882
921
  } else {
883
- spinner4.start("Installing Fastify dependencies");
922
+ spinner5.start("Installing Fastify dependencies");
884
923
  exec("npm install", join5(dest, "fastify"));
885
- spinner4.stop("Fastify dependencies installed.");
924
+ spinner5.stop("Fastify dependencies installed.");
886
925
  }
887
926
  break;
888
927
  case "frontend":
889
- spinner4.start("Installing Frontend dependencies");
928
+ spinner5.start("Installing Frontend dependencies");
890
929
  exec("npm install", join5(dest, "frontend"));
891
- spinner4.stop("Frontend dependencies installed.");
930
+ spinner5.stop("Frontend dependencies installed.");
892
931
  break;
893
932
  case "e2e":
894
- spinner4.start("Installing E2E dependencies");
933
+ spinner5.start("Installing E2E dependencies");
895
934
  exec("npm install", join5(dest, "e2e"));
896
- spinner4.stop("E2E dependencies installed.");
935
+ spinner5.stop("E2E dependencies installed.");
897
936
  break;
898
937
  case "mobile":
899
938
  if (hasCommand("flutter")) {
900
- spinner4.start("Installing Flutter dependencies");
939
+ spinner5.start("Installing Flutter dependencies");
901
940
  exec("flutter pub get", join5(dest, "mobile"));
902
- spinner4.stop("Flutter dependencies installed.");
941
+ spinner5.stop("Flutter dependencies installed.");
903
942
  } else {
904
943
  p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
905
944
  }
@@ -908,13 +947,14 @@ async function installDeps2(dest, components) {
908
947
  break;
909
948
  }
910
949
  } catch {
911
- spinner4.stop(`Failed to install ${component} dependencies.`);
950
+ spinner5.stop(`Failed to install ${component} dependencies.`);
912
951
  }
913
952
  }
914
953
  }
915
- function detectProjectName2(cwd, components) {
954
+ function detectProjectName2(cwd, components, paths) {
916
955
  for (const component of components) {
917
- const pkgPath = join5(cwd, component, "package.json");
956
+ const dir = paths[component] ?? component;
957
+ const pkgPath = join5(cwd, dir, "package.json");
918
958
  if (existsSync4(pkgPath)) {
919
959
  try {
920
960
  const pkg = JSON.parse(
@@ -931,6 +971,382 @@ function detectProjectName2(cwd, components) {
931
971
  return toKebab(cwd.split("/").pop());
932
972
  }
933
973
 
974
+ // src/init.ts
975
+ import { existsSync as existsSync6 } from "fs";
976
+ import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5, chmod as chmod4, cp as cp3 } from "fs/promises";
977
+ import { execSync as execSync3 } from "child_process";
978
+ import { join as join7 } from "path";
979
+ import * as p5 from "@clack/prompts";
980
+
981
+ // src/detect.ts
982
+ import { existsSync as existsSync5 } from "fs";
983
+ import { readdir as readdir2 } from "fs/promises";
984
+ import { join as join6 } from "path";
985
+ async function detectComponents(cwd) {
986
+ const results = [];
987
+ const entries = await readdir2(cwd, { withFileTypes: true });
988
+ const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)).map((e) => e.name);
989
+ for (const dir of dirs) {
990
+ const full = join6(cwd, dir);
991
+ const detections = await scanDirectory(full, dir);
992
+ results.push(...detections);
993
+ }
994
+ return results;
995
+ }
996
+ async function scanDirectory(dir, relPath) {
997
+ const results = [];
998
+ const pyproject = await readFileOrNull(join6(dir, "pyproject.toml"));
999
+ if (pyproject && /fastapi/i.test(pyproject)) {
1000
+ results.push({
1001
+ component: "fastapi",
1002
+ directory: relPath,
1003
+ confidence: "high",
1004
+ evidence: "pyproject.toml has fastapi dependency"
1005
+ });
1006
+ }
1007
+ const pkg = await readPkg(dir);
1008
+ if (pkg) {
1009
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
1010
+ if (allDeps.fastify) {
1011
+ results.push({
1012
+ component: "fastify",
1013
+ directory: relPath,
1014
+ confidence: "high",
1015
+ evidence: "package.json has fastify dependency"
1016
+ });
1017
+ }
1018
+ if (allDeps.react || allDeps["react-dom"]) {
1019
+ results.push({
1020
+ component: "frontend",
1021
+ directory: relPath,
1022
+ confidence: "high",
1023
+ evidence: "package.json has react dependency"
1024
+ });
1025
+ }
1026
+ if (allDeps["@playwright/test"] || allDeps.playwright) {
1027
+ results.push({
1028
+ component: "e2e",
1029
+ directory: relPath,
1030
+ confidence: "high",
1031
+ evidence: "package.json has playwright dependency"
1032
+ });
1033
+ }
1034
+ }
1035
+ const pubspec = await readFileOrNull(join6(dir, "pubspec.yaml"));
1036
+ if (pubspec && /flutter:/i.test(pubspec)) {
1037
+ results.push({
1038
+ component: "mobile",
1039
+ directory: relPath,
1040
+ confidence: "high",
1041
+ evidence: "pubspec.yaml has flutter dependency"
1042
+ });
1043
+ }
1044
+ const hasTf = existsSync5(join6(dir, "main.tf")) || existsSync5(join6(dir, "variables.tf")) || existsSync5(join6(dir, "stack/main.tf")) || existsSync5(join6(dir, "versions.tf"));
1045
+ if (hasTf) {
1046
+ results.push({
1047
+ component: "infra",
1048
+ directory: relPath,
1049
+ confidence: "high",
1050
+ evidence: "Terraform .tf files found"
1051
+ });
1052
+ }
1053
+ return results;
1054
+ }
1055
+ async function readPkg(dir) {
1056
+ const content = await readFileOrNull(join6(dir, "package.json"));
1057
+ if (!content) return null;
1058
+ try {
1059
+ return JSON.parse(content);
1060
+ } catch {
1061
+ return null;
1062
+ }
1063
+ }
1064
+
1065
+ // src/diff.ts
1066
+ function unifiedDiff(existing, template, label) {
1067
+ const a = existing.split("\n");
1068
+ const b = template.split("\n");
1069
+ const lines = [`--- existing ${label}`, `+++ template ${label}`];
1070
+ const lcs = computeLCS(a, b);
1071
+ let ai = 0;
1072
+ let bi = 0;
1073
+ for (const match of lcs) {
1074
+ while (ai < match.ai) lines.push(`\x1B[31m- ${a[ai++]}\x1B[0m`);
1075
+ while (bi < match.bi) lines.push(`\x1B[32m+ ${b[bi++]}\x1B[0m`);
1076
+ lines.push(` ${a[ai]}`);
1077
+ ai++;
1078
+ bi++;
1079
+ }
1080
+ while (ai < a.length) lines.push(`\x1B[31m- ${a[ai++]}\x1B[0m`);
1081
+ while (bi < b.length) lines.push(`\x1B[32m+ ${b[bi++]}\x1B[0m`);
1082
+ if (lines.length > 80) {
1083
+ return lines.slice(0, 80).join("\n") + `
1084
+ ... (${lines.length - 80} more lines)`;
1085
+ }
1086
+ return lines.join("\n");
1087
+ }
1088
+ function computeLCS(a, b) {
1089
+ const m = a.length;
1090
+ const n = b.length;
1091
+ if (m * n > 1e5) {
1092
+ return simpleLCS(a, b);
1093
+ }
1094
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
1095
+ for (let i2 = m - 1; i2 >= 0; i2--) {
1096
+ for (let j2 = n - 1; j2 >= 0; j2--) {
1097
+ if (a[i2] === b[j2]) {
1098
+ dp[i2][j2] = dp[i2 + 1][j2 + 1] + 1;
1099
+ } else {
1100
+ dp[i2][j2] = Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
1101
+ }
1102
+ }
1103
+ }
1104
+ const matches = [];
1105
+ let i = 0;
1106
+ let j = 0;
1107
+ while (i < m && j < n) {
1108
+ if (a[i] === b[j]) {
1109
+ matches.push({ ai: i, bi: j });
1110
+ i++;
1111
+ j++;
1112
+ } else if (dp[i + 1]?.[j] ?? 0 >= (dp[i]?.[j + 1] ?? 0)) {
1113
+ i++;
1114
+ } else {
1115
+ j++;
1116
+ }
1117
+ }
1118
+ return matches;
1119
+ }
1120
+ function simpleLCS(a, b) {
1121
+ const matches = [];
1122
+ let bi = 0;
1123
+ for (let ai = 0; ai < a.length && bi < b.length; ai++) {
1124
+ const idx = b.indexOf(a[ai], bi);
1125
+ if (idx !== -1) {
1126
+ matches.push({ ai, bi: idx });
1127
+ bi = idx + 1;
1128
+ }
1129
+ }
1130
+ return matches;
1131
+ }
1132
+
1133
+ // src/init.ts
1134
+ async function init(cwd, localRepo) {
1135
+ p5.intro("projx init");
1136
+ const isLocal = !!localRepo;
1137
+ if (existsSync6(join7(cwd, ".projx"))) {
1138
+ p5.log.error("This project is already initialized. Use 'projx update' or 'projx add' instead.");
1139
+ process.exit(1);
1140
+ }
1141
+ const spinner5 = p5.spinner();
1142
+ spinner5.start("Scanning for components");
1143
+ const detected = await detectComponents(cwd);
1144
+ spinner5.stop(
1145
+ detected.length > 0 ? `Found ${detected.length} component(s).` : "No components detected."
1146
+ );
1147
+ let confirmed;
1148
+ if (detected.length > 0) {
1149
+ confirmed = await confirmDetections(detected);
1150
+ } else {
1151
+ confirmed = await manualSelect(cwd);
1152
+ }
1153
+ if (confirmed.length === 0) {
1154
+ p5.log.warn("No components selected. Nothing to do.");
1155
+ process.exit(0);
1156
+ }
1157
+ const components = confirmed.map((c) => c.component);
1158
+ const paths = Object.fromEntries(
1159
+ confirmed.map((c) => [c.component, c.directory])
1160
+ );
1161
+ const projectName = toKebab(cwd.split("/").pop());
1162
+ const vars = { projectName, components, paths };
1163
+ const dlSpinner = p5.spinner();
1164
+ dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
1165
+ const repoDir = await downloadRepo(localRepo).catch((err) => {
1166
+ dlSpinner.stop("Failed.");
1167
+ p5.log.error(String(err));
1168
+ process.exit(1);
1169
+ });
1170
+ dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1171
+ try {
1172
+ for (const { component, directory } of confirmed) {
1173
+ const dir = join7(cwd, directory);
1174
+ if (existsSync6(dir)) {
1175
+ await writeComponentMarker(dir, component);
1176
+ p5.log.success(`${directory}/.projx-component`);
1177
+ }
1178
+ }
1179
+ await generateSharedFiles(cwd, repoDir, vars);
1180
+ const pkg = JSON.parse(
1181
+ await readFile6(join7(repoDir, "cli/package.json"), "utf-8")
1182
+ );
1183
+ const projxConfig = {
1184
+ version: pkg.version,
1185
+ components,
1186
+ createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
1187
+ paths
1188
+ };
1189
+ await writeFile5(join7(cwd, ".projx"), JSON.stringify(projxConfig, null, 2));
1190
+ p5.log.success(".projx");
1191
+ if (isGitRepo2(cwd)) {
1192
+ try {
1193
+ execSync3("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
1194
+ p5.log.success("Git hooks configured.");
1195
+ } catch {
1196
+ p5.log.warn("Failed to configure git hooks.");
1197
+ }
1198
+ }
1199
+ } finally {
1200
+ await cleanupRepo(repoDir, isLocal);
1201
+ }
1202
+ p5.outro("Project initialized. Run './setup.sh' to install dependencies.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1203
+ }
1204
+ async function confirmDetections(detected) {
1205
+ const confirmed = [];
1206
+ for (const d of detected) {
1207
+ const yes = await p5.confirm({
1208
+ message: `Found ${LABELS[d.component].label} in ${d.directory}/ \u2014 register as "${d.component}"?`,
1209
+ initialValue: true
1210
+ });
1211
+ if (p5.isCancel(yes)) process.exit(0);
1212
+ if (yes) {
1213
+ confirmed.push({ component: d.component, directory: d.directory });
1214
+ }
1215
+ }
1216
+ return confirmed;
1217
+ }
1218
+ async function manualSelect(cwd) {
1219
+ const selected = await p5.multiselect({
1220
+ message: "No components detected. Select manually:",
1221
+ options: COMPONENTS.map((c) => ({
1222
+ value: c,
1223
+ label: LABELS[c].label,
1224
+ hint: LABELS[c].hint
1225
+ })),
1226
+ required: false
1227
+ });
1228
+ if (p5.isCancel(selected)) process.exit(0);
1229
+ const result = [];
1230
+ for (const component of selected) {
1231
+ const dir = await p5.text({
1232
+ message: `Directory for ${LABELS[component].label}?`,
1233
+ placeholder: component,
1234
+ defaultValue: component
1235
+ });
1236
+ if (p5.isCancel(dir)) process.exit(0);
1237
+ if (!existsSync6(join7(cwd, dir))) {
1238
+ p5.log.warn(`${dir}/ does not exist \u2014 skipping.`);
1239
+ continue;
1240
+ }
1241
+ result.push({ component, directory: dir });
1242
+ }
1243
+ return result;
1244
+ }
1245
+ async function generateSharedFiles(cwd, repoDir, vars) {
1246
+ const files = [];
1247
+ const hasBackend = vars.components.includes("fastapi") || vars.components.includes("fastify");
1248
+ if (hasBackend || vars.components.includes("frontend")) {
1249
+ files.push(
1250
+ { path: "docker-compose.yml", content: await generateDockerCompose(vars) },
1251
+ { path: "docker-compose.dev.yml", content: await generateDockerComposeDev(vars) }
1252
+ );
1253
+ }
1254
+ files.push(
1255
+ { path: "README.md", content: await generateReadme(vars) },
1256
+ { path: ".githooks/pre-commit", content: await generatePreCommit(vars), mode: 493 },
1257
+ { path: ".github/workflows/ci.yml", content: await generateCiYml(vars) },
1258
+ { path: "setup.sh", content: await generateSetupSh(vars), mode: 493 }
1259
+ );
1260
+ for (const file of files) {
1261
+ const dest = join7(cwd, file.path);
1262
+ const dir = dest.substring(0, dest.lastIndexOf("/"));
1263
+ if (dir !== cwd) await mkdir5(dir, { recursive: true });
1264
+ const existing = await readFileOrNull(dest);
1265
+ if (existing === null) {
1266
+ await writeFile5(dest, file.content);
1267
+ if (file.mode) await chmod4(dest, file.mode);
1268
+ p5.log.success(file.path);
1269
+ } else if (existing === file.content) {
1270
+ p5.log.info(`${file.path} \u2014 identical, skipped.`);
1271
+ } else {
1272
+ const action = await resolveConflict(file.path, existing, file.content);
1273
+ if (action === "overwrite") {
1274
+ await writeFile5(dest, file.content);
1275
+ if (file.mode) await chmod4(dest, file.mode);
1276
+ p5.log.success(`${file.path} \u2014 overwritten.`);
1277
+ } else {
1278
+ p5.log.info(`${file.path} \u2014 kept existing.`);
1279
+ }
1280
+ }
1281
+ }
1282
+ const statics = [".editorconfig", "LICENSE"];
1283
+ for (const file of statics) {
1284
+ const src = join7(repoDir, file);
1285
+ const dest = join7(cwd, file);
1286
+ if (!existsSync6(src)) continue;
1287
+ if (!existsSync6(dest)) {
1288
+ await cp3(src, dest);
1289
+ p5.log.success(file);
1290
+ } else {
1291
+ const existing = await readFileOrNull(dest);
1292
+ const template = await readFileOrNull(src);
1293
+ if (existing === template) {
1294
+ p5.log.info(`${file} \u2014 identical, skipped.`);
1295
+ } else {
1296
+ const action = await resolveConflict(file, existing ?? "", template ?? "");
1297
+ if (action === "overwrite") {
1298
+ await cp3(src, dest, { force: true });
1299
+ p5.log.success(`${file} \u2014 overwritten.`);
1300
+ } else {
1301
+ p5.log.info(`${file} \u2014 kept existing.`);
1302
+ }
1303
+ }
1304
+ }
1305
+ }
1306
+ const vscode = join7(repoDir, ".vscode");
1307
+ if (existsSync6(vscode)) {
1308
+ const vscodeDest = join7(cwd, ".vscode");
1309
+ if (!existsSync6(vscodeDest)) {
1310
+ await cp3(vscode, vscodeDest, { recursive: true });
1311
+ p5.log.success(".vscode/");
1312
+ } else {
1313
+ p5.log.info(".vscode/ \u2014 already exists, skipped.");
1314
+ }
1315
+ }
1316
+ }
1317
+ async function resolveConflict(filePath, existing, template) {
1318
+ let action = await p5.select({
1319
+ message: `${filePath} differs from projx template`,
1320
+ options: [
1321
+ { value: "diff", label: "View diff" },
1322
+ { value: "overwrite", label: "Overwrite with template" },
1323
+ { value: "skip", label: "Skip (keep existing)" }
1324
+ ]
1325
+ });
1326
+ if (p5.isCancel(action)) process.exit(0);
1327
+ if (action === "diff") {
1328
+ const diff = unifiedDiff(existing, template, filePath);
1329
+ p5.log.message(diff);
1330
+ action = await p5.select({
1331
+ message: `${filePath}`,
1332
+ options: [
1333
+ { value: "overwrite", label: "Overwrite with template" },
1334
+ { value: "skip", label: "Skip (keep existing)" }
1335
+ ]
1336
+ });
1337
+ if (p5.isCancel(action)) process.exit(0);
1338
+ }
1339
+ return action;
1340
+ }
1341
+ function isGitRepo2(cwd) {
1342
+ try {
1343
+ execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1344
+ return true;
1345
+ } catch {
1346
+ return false;
1347
+ }
1348
+ }
1349
+
934
1350
  // src/index.ts
935
1351
  var args = process.argv.slice(2);
936
1352
  function parseArgs() {
@@ -949,6 +1365,10 @@ function parseArgs() {
949
1365
  command = "add";
950
1366
  continue;
951
1367
  }
1368
+ if (arg === "init" && !name) {
1369
+ command = "init";
1370
+ continue;
1371
+ }
952
1372
  if (arg === "--components") {
953
1373
  const val = args[++i];
954
1374
  if (val) {
@@ -992,6 +1412,7 @@ function printHelp() {
992
1412
  console.log(`
993
1413
  Usage:
994
1414
  projx <name> [options] Create a new project
1415
+ projx init Adopt existing project into projx
995
1416
  projx add <components...> Add components to existing project
996
1417
  projx update Update scaffolding to latest
997
1418
 
@@ -1013,6 +1434,10 @@ function printHelp() {
1013
1434
  }
1014
1435
  async function main() {
1015
1436
  const { command, name, options, localRepo, extraArgs } = parseArgs();
1437
+ if (command === "init") {
1438
+ await init(process.cwd(), localRepo);
1439
+ return;
1440
+ }
1016
1441
  if (command === "update") {
1017
1442
  await update(process.cwd(), localRepo);
1018
1443
  return;
@@ -1046,7 +1471,7 @@ async function main() {
1046
1471
  opts.install = options.install ?? opts.install;
1047
1472
  }
1048
1473
  const dest = resolve2(process.cwd(), opts.name);
1049
- if (existsSync5(dest)) {
1474
+ if (existsSync7(dest)) {
1050
1475
  console.error(`Error: ${dest} already exists.`);
1051
1476
  process.exit(1);
1052
1477
  }