create-projx 1.1.0 → 1.1.2

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
@@ -122,7 +122,7 @@ async function copyComponent(repoDir, component, dest) {
122
122
  async function copyStaticFiles(repoDir, dest) {
123
123
  const manifest = [];
124
124
  const tpl = repoDir;
125
- const statics = [".editorconfig", "LICENSE"];
125
+ const statics = [".editorconfig"];
126
126
  for (const file of statics) {
127
127
  const src = join(tpl, file);
128
128
  if (existsSync(src)) {
@@ -135,10 +135,11 @@ async function copyStaticFiles(repoDir, dest) {
135
135
  await cp(gitignore, join(dest, ".gitignore"));
136
136
  manifest.push(".gitignore");
137
137
  }
138
- const vscode = join(tpl, ".vscode");
139
- if (existsSync(vscode)) {
140
- await cp(vscode, join(dest, ".vscode"), { recursive: true });
141
- manifest.push(".vscode/settings.json", ".vscode/extensions.json");
138
+ const extensionsJson = join(tpl, ".vscode/extensions.json");
139
+ if (existsSync(extensionsJson)) {
140
+ await mkdir(join(dest, ".vscode"), { recursive: true });
141
+ await cp(extensionsJson, join(dest, ".vscode/extensions.json"));
142
+ manifest.push(".vscode/extensions.json");
142
143
  }
143
144
  const scripts = join(tpl, "scripts");
144
145
  if (existsSync(scripts)) {
@@ -185,9 +186,24 @@ async function readFileOrNull(path) {
185
186
  }
186
187
  }
187
188
  async function writeComponentMarker(dir, component) {
189
+ const markerPath = join(dir, COMPONENT_MARKER);
190
+ let components = [component];
191
+ const existing = await readFileOrNull(markerPath);
192
+ if (existing) {
193
+ try {
194
+ const data = JSON.parse(existing);
195
+ const prev = data.components ?? (data.component ? [data.component] : []);
196
+ if (!prev.includes(component)) {
197
+ components = [...prev, component];
198
+ } else {
199
+ return;
200
+ }
201
+ } catch {
202
+ }
203
+ }
188
204
  await writeFile(
189
- join(dir, COMPONENT_MARKER),
190
- JSON.stringify({ component }, null, 2) + "\n"
205
+ markerPath,
206
+ JSON.stringify({ components }, null, 2) + "\n"
191
207
  );
192
208
  }
193
209
  async function discoverComponentPaths(cwd, components) {
@@ -203,8 +219,11 @@ async function discoverComponentPaths(cwd, components) {
203
219
  if (existsSync(marker)) {
204
220
  try {
205
221
  const data = JSON.parse(await readFile(marker, "utf-8"));
206
- if (components.includes(data.component)) {
207
- paths[data.component] = entry.name;
222
+ const markerComponents = data.components ?? (data.component ? [data.component] : []);
223
+ for (const mc of markerComponents) {
224
+ if (components.includes(mc)) {
225
+ paths[mc] = entry.name;
226
+ }
208
227
  }
209
228
  } catch {
210
229
  }
@@ -331,6 +350,36 @@ async function generateCiYml(vars) {
331
350
  async function generateReadme(vars) {
332
351
  return renderShared("README.md.ejs", vars);
333
352
  }
353
+ function generateVscodeSettings(vars) {
354
+ const settings = {};
355
+ if (vars.components.includes("fastapi")) {
356
+ settings["[python]"] = {
357
+ "editor.defaultFormatter": "charliermarsh.ruff",
358
+ "editor.codeActionsOnSave": { "source.fixAll.ruff": "explicit" }
359
+ };
360
+ }
361
+ settings["[typescript]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
362
+ settings["[typescriptreact]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
363
+ settings["[javascript]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
364
+ settings["[json]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
365
+ settings["[css]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
366
+ settings["[yaml]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
367
+ settings["editor.formatOnSave"] = true;
368
+ settings["editor.codeActionsOnSave"] = { "source.fixAll.eslint": "explicit" };
369
+ settings["eslint.useFlatConfig"] = true;
370
+ const prettierComponent = ["frontend", "fastify", "e2e"].find(
371
+ (c) => vars.components.includes(c)
372
+ );
373
+ if (prettierComponent) {
374
+ settings["prettier.configPath"] = `${vars.paths[prettierComponent]}/.prettierrc`;
375
+ }
376
+ if (vars.components.includes("fastapi")) {
377
+ settings["ruff.lineLength"] = 120;
378
+ settings["python.analysis.extraPaths"] = [`${vars.paths.fastapi}/src`];
379
+ settings["python.analysis.importFormat"] = "absolute";
380
+ }
381
+ return JSON.stringify(settings, null, 2) + "\n";
382
+ }
334
383
 
335
384
  // src/scaffold.ts
336
385
  async function scaffold(opts, dest, localRepo) {
@@ -380,6 +429,8 @@ async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
380
429
  await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
381
430
  await chmod(join3(dest, "setup.sh"), 493);
382
431
  await copyStaticFiles(repoDir, dest);
432
+ await mkdir2(join3(dest, ".vscode"), { recursive: true });
433
+ await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
383
434
  const pkg = JSON.parse(
384
435
  await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
385
436
  );
@@ -541,6 +592,10 @@ var NEVER_OVERWRITE = [
541
592
  /src\/migrations\/versions\//,
542
593
  /\.projx-component$/
543
594
  ];
595
+ var MERGE_DEPS = [
596
+ /^[^/]+\/package\.json$/,
597
+ /^[^/]+\/pyproject\.toml$/
598
+ ];
544
599
  function isGitRepo(cwd) {
545
600
  try {
546
601
  execSync2("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
@@ -629,13 +684,16 @@ async function update(cwd, localRepo) {
629
684
  }
630
685
  execSync2(`git checkout -b ${branchName}`, { cwd, stdio: "pipe" });
631
686
  p3.log.info(`Created branch: ${branchName}`);
687
+ let touchedFiles;
632
688
  try {
633
- await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
689
+ touchedFiles = await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
634
690
  } finally {
635
691
  await cleanupRepo(repoDir, isLocal);
636
692
  }
637
- execSync2("git add -A", { cwd, stdio: "pipe" });
638
- execSync2(`git commit -m "projx update to v${pkg.version}"`, { cwd, stdio: "pipe" });
693
+ for (const f of touchedFiles) {
694
+ execSync2(`git add "${f}"`, { cwd, stdio: "pipe" });
695
+ }
696
+ execSync2(`git commit --no-verify -m "projx update to v${pkg.version}"`, { cwd, stdio: "pipe" });
639
697
  p3.outro(
640
698
  `Updated on branch: ${branchName}
641
699
 
@@ -669,8 +727,15 @@ async function doUpdate(cwd, config, repoDir, version, componentPaths) {
669
727
  const name = detectProjectName(cwd, config.components, componentPaths);
670
728
  const nameSnake = toSnake(name);
671
729
  const vars = { projectName: name, components: config.components, paths: componentPaths };
730
+ const touchedFiles = [];
731
+ const usedPaths = /* @__PURE__ */ new Set();
672
732
  for (const component of config.components) {
673
733
  const targetDir = componentPaths[component];
734
+ if (usedPaths.has(targetDir)) {
735
+ p3.log.warn(`${component} shares directory ${targetDir}/ with another component \u2014 skipping overlay to avoid nesting.`);
736
+ continue;
737
+ }
738
+ usedPaths.add(targetDir);
674
739
  const spinner6 = p3.spinner();
675
740
  spinner6.start(`Updating ${targetDir}/ (${component})`);
676
741
  const componentSrc = join4(repoDir, component);
@@ -687,11 +752,21 @@ async function doUpdate(cwd, config, repoDir, version, componentPaths) {
687
752
  if (NEVER_OVERWRITE.some((re) => re.test(destRel))) continue;
688
753
  const dir = dest.substring(0, dest.lastIndexOf("/"));
689
754
  await mkdir3(dir, { recursive: true });
690
- await cp2(src, dest, { force: true });
755
+ if (MERGE_DEPS.some((re) => re.test(destRel)) && existsSync3(dest)) {
756
+ const merged = await mergeDeps(dest, src);
757
+ if (merged) {
758
+ await writeFile3(dest, merged);
759
+ touchedFiles.push(destRel);
760
+ }
761
+ } else {
762
+ await cp2(src, dest, { force: true });
763
+ touchedFiles.push(destRel);
764
+ }
691
765
  }
692
766
  await rm2(tmpDest, { recursive: true, force: true });
693
767
  if (!existsSync3(join4(cwd, targetDir, ".projx-component"))) {
694
768
  await writeComponentMarker(join4(cwd, targetDir), component);
769
+ touchedFiles.push(`${targetDir}/.projx-component`);
695
770
  }
696
771
  spinner6.stop(`${targetDir}/ updated.`);
697
772
  }
@@ -699,27 +774,24 @@ async function doUpdate(cwd, config, repoDir, version, componentPaths) {
699
774
  spinner5.start("Updating shared files");
700
775
  const hasBackend = config.components.includes("fastapi") || config.components.includes("fastify");
701
776
  if (hasBackend || config.components.includes("frontend")) {
702
- await writeFile3(
703
- join4(cwd, "docker-compose.yml"),
704
- await generateDockerCompose(vars)
705
- );
706
- await writeFile3(
707
- join4(cwd, "docker-compose.dev.yml"),
708
- await generateDockerComposeDev(vars)
709
- );
777
+ await writeFile3(join4(cwd, "docker-compose.yml"), await generateDockerCompose(vars));
778
+ touchedFiles.push("docker-compose.yml");
779
+ await writeFile3(join4(cwd, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
780
+ touchedFiles.push("docker-compose.dev.yml");
710
781
  }
711
782
  await mkdir3(join4(cwd, ".githooks"), { recursive: true });
712
- const preCommit = await generatePreCommit(vars);
713
- await writeFile3(join4(cwd, ".githooks/pre-commit"), preCommit);
783
+ await writeFile3(join4(cwd, ".githooks/pre-commit"), await generatePreCommit(vars));
714
784
  await chmod2(join4(cwd, ".githooks/pre-commit"), 493);
785
+ touchedFiles.push(".githooks/pre-commit");
715
786
  await mkdir3(join4(cwd, ".github/workflows"), { recursive: true });
716
- await writeFile3(
717
- join4(cwd, ".github/workflows/ci.yml"),
718
- await generateCiYml(vars)
719
- );
720
- const setupSh = await generateSetupSh(vars);
721
- await writeFile3(join4(cwd, "setup.sh"), setupSh);
787
+ await writeFile3(join4(cwd, ".github/workflows/ci.yml"), await generateCiYml(vars));
788
+ touchedFiles.push(".github/workflows/ci.yml");
789
+ await writeFile3(join4(cwd, "setup.sh"), await generateSetupSh(vars));
722
790
  await chmod2(join4(cwd, "setup.sh"), 493);
791
+ touchedFiles.push("setup.sh");
792
+ await mkdir3(join4(cwd, ".vscode"), { recursive: true });
793
+ await writeFile3(join4(cwd, ".vscode/settings.json"), generateVscodeSettings(vars));
794
+ touchedFiles.push(".vscode/settings.json");
723
795
  spinner5.stop("Shared files updated.");
724
796
  if (config.components.includes("mobile")) {
725
797
  const mobilePath = componentPaths.mobile ?? "mobile";
@@ -737,6 +809,8 @@ async function doUpdate(cwd, config, repoDir, version, componentPaths) {
737
809
  paths: componentPaths
738
810
  };
739
811
  await writeFile3(join4(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
812
+ touchedFiles.push(".projx");
813
+ return touchedFiles;
740
814
  }
741
815
  function detectProjectName(cwd, components, componentPaths) {
742
816
  for (const component of components) {
@@ -757,6 +831,63 @@ function detectProjectName(cwd, components, componentPaths) {
757
831
  }
758
832
  return toKebab(cwd.split("/").pop());
759
833
  }
834
+ async function mergeDeps(existingPath, templatePath) {
835
+ if (existingPath.endsWith("package.json")) {
836
+ return mergePackageJson(existingPath, templatePath);
837
+ }
838
+ if (existingPath.endsWith("pyproject.toml")) {
839
+ return mergePyprojectToml(existingPath, templatePath);
840
+ }
841
+ return null;
842
+ }
843
+ async function mergePackageJson(existingPath, templatePath) {
844
+ const existingRaw = await readFileOrNull(existingPath);
845
+ const templateRaw = await readFileOrNull(templatePath);
846
+ if (!existingRaw || !templateRaw) return null;
847
+ try {
848
+ const existing = JSON.parse(existingRaw);
849
+ const template = JSON.parse(templateRaw);
850
+ if (template.dependencies) {
851
+ existing.dependencies = { ...template.dependencies, ...existing.dependencies };
852
+ }
853
+ if (template.devDependencies) {
854
+ existing.devDependencies = { ...template.devDependencies, ...existing.devDependencies };
855
+ }
856
+ if (template.scripts) {
857
+ existing.scripts = { ...template.scripts, ...existing.scripts };
858
+ }
859
+ return JSON.stringify(existing, null, 2) + "\n";
860
+ } catch {
861
+ return null;
862
+ }
863
+ }
864
+ async function mergePyprojectToml(existingPath, templatePath) {
865
+ const existingRaw = await readFileOrNull(existingPath);
866
+ const templateRaw = await readFileOrNull(templatePath);
867
+ if (!existingRaw || !templateRaw) return null;
868
+ const templateDeps = extractTomlDeps(templateRaw);
869
+ if (templateDeps.length === 0) return null;
870
+ const existingDeps = extractTomlDeps(existingRaw);
871
+ const existingNames = new Set(existingDeps.map((d) => d.replace(/[><=!~[].*/, "").trim().toLowerCase()));
872
+ const newDeps = templateDeps.filter((d) => {
873
+ const name = d.replace(/[><=!~[].*/, "").trim().toLowerCase();
874
+ return !existingNames.has(name);
875
+ });
876
+ if (newDeps.length === 0) return null;
877
+ const depsMatch = existingRaw.match(/^dependencies\s*=\s*\[([^\]]*)\]/m);
878
+ if (!depsMatch) return null;
879
+ const closingBracket = existingRaw.indexOf("]", depsMatch.index);
880
+ const before = existingRaw.slice(0, closingBracket);
881
+ const after = existingRaw.slice(closingBracket);
882
+ const indent = " ";
883
+ const newLines = newDeps.map((d) => `${indent}"${d}",`).join("\n");
884
+ return before.trimEnd() + "\n" + newLines + "\n" + after;
885
+ }
886
+ function extractTomlDeps(toml) {
887
+ const match = toml.match(/^dependencies\s*=\s*\[([\s\S]*?)\]/m);
888
+ if (!match) return [];
889
+ return match[1].split("\n").map((l) => l.trim()).filter((l) => l.startsWith('"') || l.startsWith("'")).map((l) => l.replace(/^["']|["'],?$/g, "").trim()).filter(Boolean);
890
+ }
760
891
 
761
892
  // src/add.ts
762
893
  import { copyFileSync as copyFileSync2, existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
@@ -1279,7 +1410,7 @@ async function generateSharedFiles(cwd, repoDir, vars) {
1279
1410
  }
1280
1411
  }
1281
1412
  }
1282
- const statics = [".editorconfig", "LICENSE"];
1413
+ const statics = [".editorconfig"];
1283
1414
  for (const file of statics) {
1284
1415
  const src = join7(repoDir, file);
1285
1416
  const dest = join7(cwd, file);
@@ -1303,16 +1434,29 @@ async function generateSharedFiles(cwd, repoDir, vars) {
1303
1434
  }
1304
1435
  }
1305
1436
  }
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/");
1437
+ const vscodeDest = join7(cwd, ".vscode");
1438
+ await mkdir5(vscodeDest, { recursive: true });
1439
+ const settingsPath = join7(vscodeDest, "settings.json");
1440
+ const settingsContent = generateVscodeSettings(vars);
1441
+ const existingSettings = await readFileOrNull(settingsPath);
1442
+ if (existingSettings === null) {
1443
+ await writeFile5(settingsPath, settingsContent);
1444
+ p5.log.success(".vscode/settings.json");
1445
+ } else if (existingSettings !== settingsContent) {
1446
+ const action = await resolveConflict(".vscode/settings.json", existingSettings, settingsContent);
1447
+ if (action === "overwrite") {
1448
+ await writeFile5(settingsPath, settingsContent);
1449
+ p5.log.success(".vscode/settings.json \u2014 overwritten.");
1312
1450
  } else {
1313
- p5.log.info(".vscode/ \u2014 already exists, skipped.");
1451
+ p5.log.info(".vscode/settings.json \u2014 kept existing.");
1314
1452
  }
1315
1453
  }
1454
+ const extSrc = join7(repoDir, ".vscode/extensions.json");
1455
+ const extDest = join7(vscodeDest, "extensions.json");
1456
+ if (existsSync6(extSrc) && !existsSync6(extDest)) {
1457
+ await cp3(extSrc, extDest);
1458
+ p5.log.success(".vscode/extensions.json");
1459
+ }
1316
1460
  }
1317
1461
  async function resolveConflict(filePath, existing, template) {
1318
1462
  let action = await p5.select({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-projx",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Scaffold production-grade projects. Pick your stack (FastAPI, Fastify, React, Flutter), get a fully wired template with auth, database, CI/CD, and E2E tests.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,9 +7,66 @@ on:
7
7
  branches: [main]
8
8
 
9
9
  jobs:
10
+ changes:
11
+ name: Detect changes
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ pull-requests: read
15
+ outputs:
16
+ <% if (components.includes('fastapi')) { %>
17
+ fastapi: ${{ steps.filter.outputs.<%= paths.fastapi %> }}
18
+ <% } %>
19
+ <% if (components.includes('fastify')) { %>
20
+ fastify: ${{ steps.filter.outputs.<%= paths.fastify %> }}
21
+ <% } %>
22
+ <% if (components.includes('frontend')) { %>
23
+ frontend: ${{ steps.filter.outputs.<%= paths.frontend %> }}
24
+ <% } %>
25
+ <% if (components.includes('mobile')) { %>
26
+ mobile: ${{ steps.filter.outputs.<%= paths.mobile %> }}
27
+ <% } %>
28
+ <% if (components.includes('e2e')) { %>
29
+ e2e: ${{ steps.filter.outputs.<%= paths.e2e %> }}
30
+ <% } %>
31
+ <% if (components.includes('infra')) { %>
32
+ infra: ${{ steps.filter.outputs.<%= paths.infra %> }}
33
+ <% } %>
34
+ steps:
35
+ - uses: actions/checkout@v5
36
+ - uses: dorny/paths-filter@v3
37
+ id: filter
38
+ with:
39
+ filters: |
40
+ <% if (components.includes('fastapi')) { %>
41
+ <%= paths.fastapi %>:
42
+ - '<%= paths.fastapi %>/**'
43
+ <% } %>
44
+ <% if (components.includes('fastify')) { %>
45
+ <%= paths.fastify %>:
46
+ - '<%= paths.fastify %>/**'
47
+ <% } %>
48
+ <% if (components.includes('frontend')) { %>
49
+ <%= paths.frontend %>:
50
+ - '<%= paths.frontend %>/**'
51
+ <% } %>
52
+ <% if (components.includes('mobile')) { %>
53
+ <%= paths.mobile %>:
54
+ - '<%= paths.mobile %>/**'
55
+ <% } %>
56
+ <% if (components.includes('e2e')) { %>
57
+ <%= paths.e2e %>:
58
+ - '<%= paths.e2e %>/**'
59
+ <% } %>
60
+ <% if (components.includes('infra')) { %>
61
+ <%= paths.infra %>:
62
+ - '<%= paths.infra %>/**'
63
+ <% } %>
10
64
  <% if (components.includes('fastapi')) { %>
65
+
11
66
  fastapi:
12
67
  name: FastAPI (format + lint)
68
+ needs: changes
69
+ if: needs.changes.outputs.fastapi == 'true'
13
70
  runs-on: ubuntu-latest
14
71
  defaults:
15
72
  run:
@@ -22,8 +79,11 @@ jobs:
22
79
  - run: uv run ruff check src tests
23
80
  <% } %>
24
81
  <% if (components.includes('fastify')) { %>
82
+
25
83
  fastify:
26
84
  name: Fastify (format + lint + typecheck)
85
+ needs: changes
86
+ if: needs.changes.outputs.fastify == 'true'
27
87
  runs-on: ubuntu-latest
28
88
  defaults:
29
89
  run:
@@ -45,8 +105,11 @@ jobs:
45
105
  - run: npx tsc --noEmit
46
106
  <% } %>
47
107
  <% if (components.includes('frontend')) { %>
108
+
48
109
  frontend:
49
110
  name: Frontend (format + lint + typecheck)
111
+ needs: changes
112
+ if: needs.changes.outputs.frontend == 'true'
50
113
  runs-on: ubuntu-latest
51
114
  defaults:
52
115
  run:
@@ -64,8 +127,11 @@ jobs:
64
127
  - run: npx tsc --noEmit
65
128
  <% } %>
66
129
  <% if (components.includes('mobile')) { %>
130
+
67
131
  mobile:
68
132
  name: Flutter (format + analyze)
133
+ needs: changes
134
+ if: needs.changes.outputs.mobile == 'true'
69
135
  runs-on: ubuntu-latest
70
136
  defaults:
71
137
  run:
@@ -81,8 +147,11 @@ jobs:
81
147
  - run: dart analyze --fatal-infos
82
148
  <% } %>
83
149
  <% if (components.includes('e2e')) { %>
150
+
84
151
  e2e:
85
152
  name: E2E (format + lint + typecheck)
153
+ needs: changes
154
+ if: needs.changes.outputs.e2e == 'true'
86
155
  runs-on: ubuntu-latest
87
156
  defaults:
88
157
  run:
@@ -100,8 +169,11 @@ jobs:
100
169
  - run: npx tsc --noEmit
101
170
  <% } %>
102
171
  <% if (components.includes('infra')) { %>
172
+
103
173
  infra:
104
174
  name: Terraform (fmt + validate)
175
+ needs: changes
176
+ if: needs.changes.outputs.infra == 'true'
105
177
  runs-on: ubuntu-latest
106
178
  defaults:
107
179
  run: