create-projx 1.0.0 → 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
  }
@@ -271,9 +319,6 @@ async function generateDockerCompose(vars) {
271
319
  async function generateDockerComposeDev(vars) {
272
320
  return renderShared("docker-compose.dev.yml.ejs", vars);
273
321
  }
274
- async function generateMakefile(vars) {
275
- return renderShared("Makefile.ejs", vars);
276
- }
277
322
  async function generatePreCommit(vars) {
278
323
  return renderShared("pre-commit.ejs", vars);
279
324
  }
@@ -291,7 +336,10 @@ async function generateReadme(vars) {
291
336
  async function scaffold(opts, dest, localRepo) {
292
337
  const name = toKebab(opts.name);
293
338
  const nameSnake = toSnake(opts.name);
294
- 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 };
295
343
  const isLocal = !!localRepo;
296
344
  await mkdir2(dest, { recursive: true });
297
345
  const dlSpinner = p2.spinner();
@@ -310,45 +358,28 @@ async function scaffold(opts, dest, localRepo) {
310
358
  }
311
359
  async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
312
360
  p2.log.info(`Scaffolding project in ${dest}`);
313
- const manifest = [];
314
361
  for (const component of opts.components) {
315
- const spinner4 = p2.spinner();
316
- spinner4.start(`Copying ${component}/`);
317
- const files = await copyComponent(repoDir, component, dest);
318
- manifest.push(...files.map((f) => `${component}/${f}`));
319
- 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}/`);
320
367
  }
321
368
  await substituteNames(dest, opts.components, name, nameSnake);
322
369
  const hasBackend = opts.components.includes("fastapi") || opts.components.includes("fastify");
323
370
  if (hasBackend || opts.components.includes("frontend")) {
324
- const dc = await generateDockerCompose(vars);
325
- await writeFile2(join3(dest, "docker-compose.yml"), dc);
326
- manifest.push("docker-compose.yml");
327
- const dcDev = await generateDockerComposeDev(vars);
328
- await writeFile2(join3(dest, "docker-compose.dev.yml"), dcDev);
329
- manifest.push("docker-compose.dev.yml");
330
- }
331
- const makefile = await generateMakefile(vars);
332
- await writeFile2(join3(dest, "Makefile"), makefile);
333
- manifest.push("Makefile");
334
- const readme = await generateReadme(vars);
335
- await writeFile2(join3(dest, "README.md"), readme);
336
- 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));
337
375
  await mkdir2(join3(dest, ".githooks"), { recursive: true });
338
- const preCommit = await generatePreCommit(vars);
339
- await writeFile2(join3(dest, ".githooks/pre-commit"), preCommit);
376
+ await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
340
377
  await chmod(join3(dest, ".githooks/pre-commit"), 493);
341
- manifest.push(".githooks/pre-commit");
342
378
  await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
343
- const lintYml = await generateCiYml(vars);
344
- await writeFile2(join3(dest, ".github/workflows/ci.yml"), lintYml);
345
- manifest.push(".github/workflows/ci.yml");
346
- const setupSh = await generateSetupSh(vars);
347
- 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));
348
381
  await chmod(join3(dest, "setup.sh"), 493);
349
- manifest.push("setup.sh");
350
- const staticFiles = await copyStaticFiles(repoDir, dest);
351
- manifest.push(...staticFiles);
382
+ await copyStaticFiles(repoDir, dest);
352
383
  const pkg = JSON.parse(
353
384
  await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
354
385
  );
@@ -356,7 +387,7 @@ async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
356
387
  version: pkg.version,
357
388
  components: opts.components,
358
389
  createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
359
- files: manifest.sort()
390
+ paths: vars.paths
360
391
  };
361
392
  await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2));
362
393
  if (opts.git) {
@@ -383,7 +414,9 @@ async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
383
414
  p2.outro(`Done! Next steps:
384
415
 
385
416
  cd ${name}
386
- make run-dev`);
417
+ ./setup.sh
418
+
419
+ Like projx? Star it: https://github.com/ukanhaupa/projx`);
387
420
  }
388
421
  async function substituteNames(dest, components, name, nameSnake) {
389
422
  if (components.includes("fastapi")) {
@@ -430,44 +463,44 @@ async function substituteNames(dest, components, name, nameSnake) {
430
463
  }
431
464
  async function installDeps(dest, components) {
432
465
  for (const component of components) {
433
- const spinner4 = p2.spinner();
466
+ const spinner5 = p2.spinner();
434
467
  try {
435
468
  switch (component) {
436
469
  case "fastapi":
437
470
  if (hasCommand("uv")) {
438
- spinner4.start("Installing FastAPI dependencies (uv sync)");
471
+ spinner5.start("Installing FastAPI dependencies (uv sync)");
439
472
  exec("uv sync --all-extras", join3(dest, "fastapi"));
440
- spinner4.stop("FastAPI dependencies installed.");
473
+ spinner5.stop("FastAPI dependencies installed.");
441
474
  } else {
442
475
  p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
443
476
  }
444
477
  break;
445
478
  case "fastify":
446
479
  if (hasCommand("pnpm")) {
447
- spinner4.start("Installing Fastify dependencies (pnpm install)");
480
+ spinner5.start("Installing Fastify dependencies (pnpm install)");
448
481
  exec("pnpm install", join3(dest, "fastify"));
449
- spinner4.stop("Fastify dependencies installed.");
482
+ spinner5.stop("Fastify dependencies installed.");
450
483
  } else {
451
- spinner4.start("Installing Fastify dependencies (npm install)");
484
+ spinner5.start("Installing Fastify dependencies (npm install)");
452
485
  exec("npm install", join3(dest, "fastify"));
453
- spinner4.stop("Fastify dependencies installed.");
486
+ spinner5.stop("Fastify dependencies installed.");
454
487
  }
455
488
  break;
456
489
  case "frontend":
457
- spinner4.start("Installing Frontend dependencies (npm install)");
490
+ spinner5.start("Installing Frontend dependencies (npm install)");
458
491
  exec("npm install", join3(dest, "frontend"));
459
- spinner4.stop("Frontend dependencies installed.");
492
+ spinner5.stop("Frontend dependencies installed.");
460
493
  break;
461
494
  case "e2e":
462
- spinner4.start("Installing E2E dependencies (npm install)");
495
+ spinner5.start("Installing E2E dependencies (npm install)");
463
496
  exec("npm install", join3(dest, "e2e"));
464
- spinner4.stop("E2E dependencies installed.");
497
+ spinner5.stop("E2E dependencies installed.");
465
498
  break;
466
499
  case "mobile":
467
500
  if (hasCommand("flutter")) {
468
- spinner4.start("Installing Flutter dependencies");
501
+ spinner5.start("Installing Flutter dependencies");
469
502
  exec("flutter pub get", join3(dest, "mobile"));
470
- spinner4.stop("Flutter dependencies installed.");
503
+ spinner5.stop("Flutter dependencies installed.");
471
504
  } else {
472
505
  p2.log.warn(
473
506
  "Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
@@ -478,7 +511,7 @@ async function installDeps(dest, components) {
478
511
  break;
479
512
  }
480
513
  } catch {
481
- spinner4.stop(`Failed to install ${component} dependencies.`);
514
+ spinner5.stop(`Failed to install ${component} dependencies.`);
482
515
  }
483
516
  }
484
517
  }
@@ -505,7 +538,8 @@ var NEVER_OVERWRITE = [
505
538
  /\.env$/,
506
539
  /\.env\.(dev|staging|prod)$/,
507
540
  /prisma\/migrations\//,
508
- /src\/migrations\/versions\//
541
+ /src\/migrations\/versions\//,
542
+ /\.projx-component$/
509
543
  ];
510
544
  function isGitRepo(cwd) {
511
545
  try {
@@ -556,11 +590,17 @@ async function update(cwd, localRepo) {
556
590
  config = {
557
591
  version: "0.0.0",
558
592
  components: detected,
559
- createdAt: "unknown",
560
- files: []
593
+ createdAt: "unknown"
561
594
  };
562
595
  p3.log.info(`Detected: ${detected.join(", ")}`);
563
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
+ }
564
604
  const useGitBranch = isGitRepo(cwd);
565
605
  let branchName;
566
606
  let originalBranch;
@@ -590,7 +630,7 @@ async function update(cwd, localRepo) {
590
630
  execSync2(`git checkout -b ${branchName}`, { cwd, stdio: "pipe" });
591
631
  p3.log.info(`Created branch: ${branchName}`);
592
632
  try {
593
- await doUpdate(cwd, config, repoDir, pkg.version);
633
+ await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
594
634
  } finally {
595
635
  await cleanupRepo(repoDir, isLocal);
596
636
  }
@@ -618,76 +658,73 @@ async function update(cwd, localRepo) {
618
658
  await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
619
659
  );
620
660
  try {
621
- await doUpdate(cwd, config, repoDir, pkg.version);
661
+ await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
622
662
  } finally {
623
663
  await cleanupRepo(repoDir, isLocal);
624
664
  }
625
665
  p3.outro(`Updated to v${pkg.version}. Review changes before committing.`);
626
666
  }
627
667
  }
628
- async function doUpdate(cwd, config, repoDir, version) {
629
- const name = detectProjectName(cwd, config.components);
668
+ async function doUpdate(cwd, config, repoDir, version, componentPaths) {
669
+ const name = detectProjectName(cwd, config.components, componentPaths);
630
670
  const nameSnake = toSnake(name);
631
- const vars = { projectName: name, components: config.components };
632
- const newManifest = [];
671
+ const vars = { projectName: name, components: config.components, paths: componentPaths };
633
672
  for (const component of config.components) {
634
- const spinner5 = p3.spinner();
635
- spinner5.start(`Updating ${component}/ template files`);
673
+ const targetDir = componentPaths[component];
674
+ const spinner6 = p3.spinner();
675
+ spinner6.start(`Updating ${targetDir}/ (${component})`);
636
676
  const componentSrc = join4(repoDir, component);
637
677
  if (!existsSync3(componentSrc)) {
638
- spinner5.stop(`${component}/ template not found, skipping.`);
678
+ spinner6.stop(`${component} template not found, skipping.`);
639
679
  continue;
640
680
  }
641
681
  const tmpDest = join4(cwd, `.projx-tmp`);
642
682
  const files = await copyComponent(repoDir, component, tmpDest);
643
683
  for (const file of files) {
644
- const rel = `${component}/${file}`;
645
684
  const src = join4(tmpDest, component, file);
646
- const dest = join4(cwd, rel);
647
- if (NEVER_OVERWRITE.some((re) => re.test(rel))) continue;
648
- 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;
649
688
  const dir = dest.substring(0, dest.lastIndexOf("/"));
650
689
  await mkdir3(dir, { recursive: true });
651
690
  await cp2(src, dest, { force: true });
652
- newManifest.push(rel);
653
691
  }
654
692
  await rm2(tmpDest, { recursive: true, force: true });
655
- 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.`);
656
697
  }
657
- const spinner4 = p3.spinner();
658
- spinner4.start("Updating shared files");
698
+ const spinner5 = p3.spinner();
699
+ spinner5.start("Updating shared files");
659
700
  const hasBackend = config.components.includes("fastapi") || config.components.includes("fastify");
660
701
  if (hasBackend || config.components.includes("frontend")) {
661
702
  await writeFile3(
662
703
  join4(cwd, "docker-compose.yml"),
663
704
  await generateDockerCompose(vars)
664
705
  );
665
- newManifest.push("docker-compose.yml");
666
706
  await writeFile3(
667
707
  join4(cwd, "docker-compose.dev.yml"),
668
708
  await generateDockerComposeDev(vars)
669
709
  );
670
- newManifest.push("docker-compose.dev.yml");
671
710
  }
672
711
  await mkdir3(join4(cwd, ".githooks"), { recursive: true });
673
712
  const preCommit = await generatePreCommit(vars);
674
713
  await writeFile3(join4(cwd, ".githooks/pre-commit"), preCommit);
675
714
  await chmod2(join4(cwd, ".githooks/pre-commit"), 493);
676
- newManifest.push(".githooks/pre-commit");
677
715
  await mkdir3(join4(cwd, ".github/workflows"), { recursive: true });
678
716
  await writeFile3(
679
717
  join4(cwd, ".github/workflows/ci.yml"),
680
718
  await generateCiYml(vars)
681
719
  );
682
- newManifest.push(".github/workflows/ci.yml");
683
720
  const setupSh = await generateSetupSh(vars);
684
721
  await writeFile3(join4(cwd, "setup.sh"), setupSh);
685
722
  await chmod2(join4(cwd, "setup.sh"), 493);
686
- newManifest.push("setup.sh");
687
- spinner4.stop("Shared files updated.");
723
+ spinner5.stop("Shared files updated.");
688
724
  if (config.components.includes("mobile")) {
725
+ const mobilePath = componentPaths.mobile ?? "mobile";
689
726
  await replaceInDir(
690
- join4(cwd, "mobile"),
727
+ join4(cwd, mobilePath),
691
728
  "package:projx_mobile/",
692
729
  `package:${nameSnake}_mobile/`,
693
730
  ".dart"
@@ -697,13 +734,14 @@ async function doUpdate(cwd, config, repoDir, version) {
697
734
  version,
698
735
  components: config.components,
699
736
  createdAt: config.createdAt,
700
- files: [.../* @__PURE__ */ new Set([...config.files, ...newManifest])].sort()
737
+ paths: componentPaths
701
738
  };
702
739
  await writeFile3(join4(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
703
740
  }
704
- function detectProjectName(cwd, components) {
741
+ function detectProjectName(cwd, components, componentPaths) {
705
742
  for (const component of components) {
706
- const pkgPath = join4(cwd, component, "package.json");
743
+ const dir = componentPaths[component] ?? component;
744
+ const pkgPath = join4(cwd, dir, "package.json");
707
745
  if (existsSync3(pkgPath)) {
708
746
  try {
709
747
  const pkg = JSON.parse(
@@ -760,45 +798,37 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
760
798
  }
761
799
  }
762
800
  async function doAdd(cwd, config, toAdd, repoDir, skipInstall) {
763
- const name = detectProjectName2(cwd, config.components);
764
- const nameSnake = toSnake(name);
765
801
  const allComponents = [...config.components, ...toAdd];
766
- const vars = { projectName: name, components: allComponents };
767
- 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 };
768
808
  for (const component of toAdd) {
769
- const spinner5 = p4.spinner();
770
- spinner5.start(`Adding ${component}/`);
771
- const files = await copyComponent(repoDir, component, cwd);
772
- newFiles.push(...files.map((f) => `${component}/${f}`));
773
- 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}/`);
774
814
  }
775
815
  await substituteNames2(cwd, toAdd, name, nameSnake);
776
- const spinner4 = p4.spinner();
777
- spinner4.start("Regenerating shared files");
816
+ const spinner5 = p4.spinner();
817
+ spinner5.start("Regenerating shared files");
778
818
  const hasBackend = allComponents.includes("fastapi") || allComponents.includes("fastify");
779
819
  if (hasBackend || allComponents.includes("frontend")) {
780
- await writeFile4(
781
- join5(cwd, "docker-compose.yml"),
782
- await generateDockerCompose(vars)
783
- );
784
- await writeFile4(
785
- join5(cwd, "docker-compose.dev.yml"),
786
- await generateDockerComposeDev(vars)
787
- );
820
+ await writeFile4(join5(cwd, "docker-compose.yml"), await generateDockerCompose(vars));
821
+ await writeFile4(join5(cwd, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
788
822
  }
789
- await writeFile4(join5(cwd, "Makefile"), await generateMakefile(vars));
790
823
  await writeFile4(join5(cwd, "README.md"), await generateReadme(vars));
791
824
  await mkdir4(join5(cwd, ".githooks"), { recursive: true });
792
825
  await writeFile4(join5(cwd, ".githooks/pre-commit"), await generatePreCommit(vars));
793
826
  await chmod3(join5(cwd, ".githooks/pre-commit"), 493);
794
827
  await mkdir4(join5(cwd, ".github/workflows"), { recursive: true });
795
- await writeFile4(
796
- join5(cwd, ".github/workflows/ci.yml"),
797
- await generateCiYml(vars)
798
- );
828
+ await writeFile4(join5(cwd, ".github/workflows/ci.yml"), await generateCiYml(vars));
799
829
  await writeFile4(join5(cwd, "setup.sh"), await generateSetupSh(vars));
800
830
  await chmod3(join5(cwd, "setup.sh"), 493);
801
- spinner4.stop("Shared files regenerated.");
831
+ spinner5.stop("Shared files regenerated.");
802
832
  if (!skipInstall) {
803
833
  await installDeps2(cwd, toAdd);
804
834
  }
@@ -819,10 +849,12 @@ async function doAdd(cwd, config, toAdd, repoDir, skipInstall) {
819
849
  version: pkg.version,
820
850
  components: allComponents,
821
851
  createdAt: config.createdAt,
822
- files: [.../* @__PURE__ */ new Set([...config.files, ...newFiles])].sort()
852
+ paths
823
853
  };
824
854
  await writeFile4(join5(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
825
- 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`);
826
858
  }
827
859
  async function substituteNames2(dest, components, name, nameSnake) {
828
860
  if (components.includes("fastapi")) {
@@ -869,44 +901,44 @@ async function substituteNames2(dest, components, name, nameSnake) {
869
901
  }
870
902
  async function installDeps2(dest, components) {
871
903
  for (const component of components) {
872
- const spinner4 = p4.spinner();
904
+ const spinner5 = p4.spinner();
873
905
  try {
874
906
  switch (component) {
875
907
  case "fastapi":
876
908
  if (hasCommand("uv")) {
877
- spinner4.start("Installing FastAPI dependencies");
909
+ spinner5.start("Installing FastAPI dependencies");
878
910
  exec("uv sync --all-extras", join5(dest, "fastapi"));
879
- spinner4.stop("FastAPI dependencies installed.");
911
+ spinner5.stop("FastAPI dependencies installed.");
880
912
  } else {
881
913
  p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
882
914
  }
883
915
  break;
884
916
  case "fastify":
885
917
  if (hasCommand("pnpm")) {
886
- spinner4.start("Installing Fastify dependencies");
918
+ spinner5.start("Installing Fastify dependencies");
887
919
  exec("pnpm install", join5(dest, "fastify"));
888
- spinner4.stop("Fastify dependencies installed.");
920
+ spinner5.stop("Fastify dependencies installed.");
889
921
  } else {
890
- spinner4.start("Installing Fastify dependencies");
922
+ spinner5.start("Installing Fastify dependencies");
891
923
  exec("npm install", join5(dest, "fastify"));
892
- spinner4.stop("Fastify dependencies installed.");
924
+ spinner5.stop("Fastify dependencies installed.");
893
925
  }
894
926
  break;
895
927
  case "frontend":
896
- spinner4.start("Installing Frontend dependencies");
928
+ spinner5.start("Installing Frontend dependencies");
897
929
  exec("npm install", join5(dest, "frontend"));
898
- spinner4.stop("Frontend dependencies installed.");
930
+ spinner5.stop("Frontend dependencies installed.");
899
931
  break;
900
932
  case "e2e":
901
- spinner4.start("Installing E2E dependencies");
933
+ spinner5.start("Installing E2E dependencies");
902
934
  exec("npm install", join5(dest, "e2e"));
903
- spinner4.stop("E2E dependencies installed.");
935
+ spinner5.stop("E2E dependencies installed.");
904
936
  break;
905
937
  case "mobile":
906
938
  if (hasCommand("flutter")) {
907
- spinner4.start("Installing Flutter dependencies");
939
+ spinner5.start("Installing Flutter dependencies");
908
940
  exec("flutter pub get", join5(dest, "mobile"));
909
- spinner4.stop("Flutter dependencies installed.");
941
+ spinner5.stop("Flutter dependencies installed.");
910
942
  } else {
911
943
  p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
912
944
  }
@@ -915,13 +947,14 @@ async function installDeps2(dest, components) {
915
947
  break;
916
948
  }
917
949
  } catch {
918
- spinner4.stop(`Failed to install ${component} dependencies.`);
950
+ spinner5.stop(`Failed to install ${component} dependencies.`);
919
951
  }
920
952
  }
921
953
  }
922
- function detectProjectName2(cwd, components) {
954
+ function detectProjectName2(cwd, components, paths) {
923
955
  for (const component of components) {
924
- const pkgPath = join5(cwd, component, "package.json");
956
+ const dir = paths[component] ?? component;
957
+ const pkgPath = join5(cwd, dir, "package.json");
925
958
  if (existsSync4(pkgPath)) {
926
959
  try {
927
960
  const pkg = JSON.parse(
@@ -938,6 +971,382 @@ function detectProjectName2(cwd, components) {
938
971
  return toKebab(cwd.split("/").pop());
939
972
  }
940
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
+
941
1350
  // src/index.ts
942
1351
  var args = process.argv.slice(2);
943
1352
  function parseArgs() {
@@ -956,6 +1365,10 @@ function parseArgs() {
956
1365
  command = "add";
957
1366
  continue;
958
1367
  }
1368
+ if (arg === "init" && !name) {
1369
+ command = "init";
1370
+ continue;
1371
+ }
959
1372
  if (arg === "--components") {
960
1373
  const val = args[++i];
961
1374
  if (val) {
@@ -999,6 +1412,7 @@ function printHelp() {
999
1412
  console.log(`
1000
1413
  Usage:
1001
1414
  projx <name> [options] Create a new project
1415
+ projx init Adopt existing project into projx
1002
1416
  projx add <components...> Add components to existing project
1003
1417
  projx update Update scaffolding to latest
1004
1418
 
@@ -1020,6 +1434,10 @@ function printHelp() {
1020
1434
  }
1021
1435
  async function main() {
1022
1436
  const { command, name, options, localRepo, extraArgs } = parseArgs();
1437
+ if (command === "init") {
1438
+ await init(process.cwd(), localRepo);
1439
+ return;
1440
+ }
1023
1441
  if (command === "update") {
1024
1442
  await update(process.cwd(), localRepo);
1025
1443
  return;
@@ -1053,7 +1471,7 @@ async function main() {
1053
1471
  opts.install = options.install ?? opts.install;
1054
1472
  }
1055
1473
  const dest = resolve2(process.cwd(), opts.name);
1056
- if (existsSync5(dest)) {
1474
+ if (existsSync7(dest)) {
1057
1475
  console.error(`Error: ${dest} already exists.`);
1058
1476
  process.exit(1);
1059
1477
  }