create-projx 1.3.6 → 1.4.1
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/README.md +82 -0
- package/dist/index.js +1770 -94
- package/package.json +1 -1
- package/src/templates/docker-compose.dev.yml.ejs +28 -18
- package/src/templates/docker-compose.yml.ejs +18 -12
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { existsSync as
|
|
4
|
+
import { existsSync as existsSync13 } from "fs";
|
|
5
5
|
import { resolve as resolve2 } from "path";
|
|
6
6
|
|
|
7
7
|
// src/utils.ts
|
|
8
8
|
import { execSync } from "child_process";
|
|
9
|
-
import { existsSync } from "fs";
|
|
9
|
+
import { existsSync, readFileSync } from "fs";
|
|
10
10
|
import { cp, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
11
11
|
import { join, resolve } from "path";
|
|
12
12
|
import { tmpdir } from "os";
|
|
@@ -27,6 +27,9 @@ function toKebab(s) {
|
|
|
27
27
|
function toSnake(s) {
|
|
28
28
|
return toKebab(s).replace(/-/g, "_");
|
|
29
29
|
}
|
|
30
|
+
function toTitle(s) {
|
|
31
|
+
return s.split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
32
|
+
}
|
|
30
33
|
function hasCommand(cmd) {
|
|
31
34
|
try {
|
|
32
35
|
execSync(`command -v ${cmd}`, { stdio: "ignore" });
|
|
@@ -258,12 +261,29 @@ function render(template, vars) {
|
|
|
258
261
|
const ifMatch = line.match(/^<%\s*if\s*\((.+?)\)\s*\{?\s*%>$/);
|
|
259
262
|
if (ifMatch) {
|
|
260
263
|
const fn = new Function("components", "projectName", `return ${ifMatch[1]}`);
|
|
261
|
-
|
|
264
|
+
const result = fn(components, projectName);
|
|
265
|
+
stack.push({ active: result, matched: result });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const elseIfMatch = line.match(/^<%\s*\}\s*else\s+if\s*\((.+?)\)\s*\{?\s*%>$/);
|
|
269
|
+
if (elseIfMatch) {
|
|
270
|
+
if (stack.length > 0) {
|
|
271
|
+
const top = stack[stack.length - 1];
|
|
272
|
+
if (top.matched) {
|
|
273
|
+
top.active = false;
|
|
274
|
+
} else {
|
|
275
|
+
const fn = new Function("components", "projectName", `return ${elseIfMatch[1]}`);
|
|
276
|
+
const result = fn(components, projectName);
|
|
277
|
+
top.active = result;
|
|
278
|
+
if (result) top.matched = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
262
281
|
continue;
|
|
263
282
|
}
|
|
264
283
|
if (/^<%\s*\}\s*else\s*\{?\s*%>$/.test(line)) {
|
|
265
284
|
if (stack.length > 0) {
|
|
266
|
-
|
|
285
|
+
const top = stack[stack.length - 1];
|
|
286
|
+
top.active = !top.matched;
|
|
267
287
|
}
|
|
268
288
|
continue;
|
|
269
289
|
}
|
|
@@ -271,14 +291,14 @@ function render(template, vars) {
|
|
|
271
291
|
stack.pop();
|
|
272
292
|
continue;
|
|
273
293
|
}
|
|
274
|
-
if (stack.length > 0 && stack.some((v) => !v)) continue;
|
|
294
|
+
if (stack.length > 0 && stack.some((v) => !v.active)) continue;
|
|
275
295
|
const replaced = line.replace(
|
|
276
296
|
/<%=\s*([\w.]+)\s*%>/g,
|
|
277
297
|
(_, expr) => {
|
|
278
298
|
const parts = expr.split(".");
|
|
279
299
|
let val = vars;
|
|
280
|
-
for (const
|
|
281
|
-
val = val?.[
|
|
300
|
+
for (const p11 of parts) {
|
|
301
|
+
val = val?.[p11];
|
|
282
302
|
}
|
|
283
303
|
return String(val ?? "");
|
|
284
304
|
}
|
|
@@ -287,6 +307,23 @@ function render(template, vars) {
|
|
|
287
307
|
}
|
|
288
308
|
return output.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
289
309
|
}
|
|
310
|
+
function detectProjectName(cwd, components, componentPaths) {
|
|
311
|
+
for (const component of components) {
|
|
312
|
+
const dir = componentPaths[component] ?? component;
|
|
313
|
+
const pkgPath = join(cwd, dir, "package.json");
|
|
314
|
+
if (existsSync(pkgPath)) {
|
|
315
|
+
try {
|
|
316
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
317
|
+
const n = pkg.name;
|
|
318
|
+
if (n && n.includes("-")) {
|
|
319
|
+
return n.substring(0, n.lastIndexOf("-"));
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return toKebab(cwd.split("/").pop());
|
|
326
|
+
}
|
|
290
327
|
|
|
291
328
|
// src/prompts.ts
|
|
292
329
|
import * as p from "@clack/prompts";
|
|
@@ -475,10 +512,10 @@ function mergeFileThreeWay(oursPath, baseContent, theirsContent) {
|
|
|
475
512
|
}
|
|
476
513
|
}
|
|
477
514
|
async function collectAllFiles(dir, base) {
|
|
478
|
-
const { readdir:
|
|
515
|
+
const { readdir: readdir4 } = await import("fs/promises");
|
|
479
516
|
const results = [];
|
|
480
517
|
const walk = async (current) => {
|
|
481
|
-
const entries = await
|
|
518
|
+
const entries = await readdir4(current, { withFileTypes: true });
|
|
482
519
|
for (const entry of entries) {
|
|
483
520
|
const full = join3(current, entry.name);
|
|
484
521
|
if (entry.isDirectory()) {
|
|
@@ -549,9 +586,9 @@ function cleanupWorktree(cwd, worktree, branch) {
|
|
|
549
586
|
}
|
|
550
587
|
async function removeSkippedFiles(dir, skipPatterns) {
|
|
551
588
|
if (skipPatterns.length === 0) return;
|
|
552
|
-
const { readdir:
|
|
589
|
+
const { readdir: readdir4, unlink: unlink2 } = await import("fs/promises");
|
|
553
590
|
const walk = async (current, base) => {
|
|
554
|
-
const entries = await
|
|
591
|
+
const entries = await readdir4(current, { withFileTypes: true });
|
|
555
592
|
for (const entry of entries) {
|
|
556
593
|
const full = join3(current, entry.name);
|
|
557
594
|
const rel = full.slice(base.length + 1);
|
|
@@ -657,8 +694,8 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
|
|
|
657
694
|
try {
|
|
658
695
|
await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
659
696
|
execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
|
|
660
|
-
const
|
|
661
|
-
if (!
|
|
697
|
+
const diff2 = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
|
|
698
|
+
if (!diff2) {
|
|
662
699
|
cleanupWorktree(cwd, worktree, branch);
|
|
663
700
|
return { status: "clean" };
|
|
664
701
|
}
|
|
@@ -773,10 +810,10 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
773
810
|
exec("git init", dest);
|
|
774
811
|
exec("git config core.hooksPath .githooks", dest);
|
|
775
812
|
}
|
|
776
|
-
const
|
|
777
|
-
|
|
813
|
+
const spinner7 = p2.spinner();
|
|
814
|
+
spinner7.start("Scaffolding project");
|
|
778
815
|
await applyTemplate(dest, repoDir, opts.components, paths, vars, version);
|
|
779
|
-
|
|
816
|
+
spinner7.stop("Scaffold complete.");
|
|
780
817
|
if (opts.install) {
|
|
781
818
|
await installDeps(dest, opts.components);
|
|
782
819
|
}
|
|
@@ -801,44 +838,44 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
801
838
|
}
|
|
802
839
|
async function installDeps(dest, components) {
|
|
803
840
|
for (const component of components) {
|
|
804
|
-
const
|
|
841
|
+
const spinner7 = p2.spinner();
|
|
805
842
|
try {
|
|
806
843
|
switch (component) {
|
|
807
844
|
case "fastapi":
|
|
808
845
|
if (hasCommand("uv")) {
|
|
809
|
-
|
|
846
|
+
spinner7.start("Installing FastAPI dependencies (uv sync)");
|
|
810
847
|
exec("uv sync --all-extras", join4(dest, "fastapi"));
|
|
811
|
-
|
|
848
|
+
spinner7.stop("FastAPI dependencies installed.");
|
|
812
849
|
} else {
|
|
813
850
|
p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
814
851
|
}
|
|
815
852
|
break;
|
|
816
853
|
case "fastify":
|
|
817
854
|
if (hasCommand("pnpm")) {
|
|
818
|
-
|
|
855
|
+
spinner7.start("Installing Fastify dependencies (pnpm install)");
|
|
819
856
|
exec("pnpm install", join4(dest, "fastify"));
|
|
820
|
-
|
|
857
|
+
spinner7.stop("Fastify dependencies installed.");
|
|
821
858
|
} else {
|
|
822
|
-
|
|
859
|
+
spinner7.start("Installing Fastify dependencies (npm install)");
|
|
823
860
|
exec("npm install", join4(dest, "fastify"));
|
|
824
|
-
|
|
861
|
+
spinner7.stop("Fastify dependencies installed.");
|
|
825
862
|
}
|
|
826
863
|
break;
|
|
827
864
|
case "frontend":
|
|
828
|
-
|
|
865
|
+
spinner7.start("Installing Frontend dependencies (npm install)");
|
|
829
866
|
exec("npm install", join4(dest, "frontend"));
|
|
830
|
-
|
|
867
|
+
spinner7.stop("Frontend dependencies installed.");
|
|
831
868
|
break;
|
|
832
869
|
case "e2e":
|
|
833
|
-
|
|
870
|
+
spinner7.start("Installing E2E dependencies (npm install)");
|
|
834
871
|
exec("npm install", join4(dest, "e2e"));
|
|
835
|
-
|
|
872
|
+
spinner7.stop("E2E dependencies installed.");
|
|
836
873
|
break;
|
|
837
874
|
case "mobile":
|
|
838
875
|
if (hasCommand("flutter")) {
|
|
839
|
-
|
|
876
|
+
spinner7.start("Installing Flutter dependencies");
|
|
840
877
|
exec("flutter pub get", join4(dest, "mobile"));
|
|
841
|
-
|
|
878
|
+
spinner7.stop("Flutter dependencies installed.");
|
|
842
879
|
} else {
|
|
843
880
|
p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
|
|
844
881
|
}
|
|
@@ -847,7 +884,7 @@ async function installDeps(dest, components) {
|
|
|
847
884
|
break;
|
|
848
885
|
}
|
|
849
886
|
} catch {
|
|
850
|
-
|
|
887
|
+
spinner7.stop(`Failed to install ${component} dependencies.`);
|
|
851
888
|
}
|
|
852
889
|
}
|
|
853
890
|
}
|
|
@@ -865,7 +902,7 @@ function copyEnvExamples(dest, components) {
|
|
|
865
902
|
}
|
|
866
903
|
|
|
867
904
|
// src/update.ts
|
|
868
|
-
import { existsSync as existsSync4
|
|
905
|
+
import { existsSync as existsSync4 } from "fs";
|
|
869
906
|
import { readFile as readFile5, writeFile as writeFile3, unlink } from "fs/promises";
|
|
870
907
|
import { execSync as execSync3 } from "child_process";
|
|
871
908
|
import { join as join5 } from "path";
|
|
@@ -926,11 +963,11 @@ async function update(cwd, localRepo) {
|
|
|
926
963
|
const version = pkg.version;
|
|
927
964
|
const name = detectProjectName(cwd, config.components, componentPaths);
|
|
928
965
|
const vars = { projectName: name, components: config.components, paths: componentPaths };
|
|
929
|
-
const
|
|
930
|
-
|
|
966
|
+
const spinner7 = p3.spinner();
|
|
967
|
+
spinner7.start("Applying template update");
|
|
931
968
|
const rootSkip = config.skip ?? [];
|
|
932
969
|
const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
|
|
933
|
-
|
|
970
|
+
spinner7.stop("Template applied.");
|
|
934
971
|
if (result.status === "merged") {
|
|
935
972
|
saveBaselineRef(cwd);
|
|
936
973
|
p3.log.success(`${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`);
|
|
@@ -1077,26 +1114,9 @@ async function learnSkips(cwd, files, componentPaths) {
|
|
|
1077
1114
|
}
|
|
1078
1115
|
}
|
|
1079
1116
|
}
|
|
1080
|
-
function detectProjectName(cwd, components, componentPaths) {
|
|
1081
|
-
for (const component of components) {
|
|
1082
|
-
const dir = componentPaths[component] ?? component;
|
|
1083
|
-
const pkgPath = join5(cwd, dir, "package.json");
|
|
1084
|
-
if (existsSync4(pkgPath)) {
|
|
1085
|
-
try {
|
|
1086
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
1087
|
-
const n = pkg.name;
|
|
1088
|
-
if (n && n.includes("-")) {
|
|
1089
|
-
return n.substring(0, n.lastIndexOf("-"));
|
|
1090
|
-
}
|
|
1091
|
-
} catch {
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
return toKebab(cwd.split("/").pop());
|
|
1096
|
-
}
|
|
1097
1117
|
|
|
1098
1118
|
// src/add.ts
|
|
1099
|
-
import { copyFileSync as copyFileSync2, existsSync as existsSync5
|
|
1119
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync5 } from "fs";
|
|
1100
1120
|
import { readFile as readFile6 } from "fs/promises";
|
|
1101
1121
|
import { join as join6 } from "path";
|
|
1102
1122
|
import * as p4 from "@clack/prompts";
|
|
@@ -1133,14 +1153,14 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
|
1133
1153
|
const existingPaths = await discoverComponentPaths(cwd, existing);
|
|
1134
1154
|
const paths = { ...existingPaths };
|
|
1135
1155
|
for (const c of toAdd) paths[c] = c;
|
|
1136
|
-
const name =
|
|
1156
|
+
const name = detectProjectName(cwd, existing, paths);
|
|
1137
1157
|
const vars = { projectName: name, components: allComponents, paths };
|
|
1138
1158
|
const pkg = JSON.parse(await readFile6(join6(repoDir, "cli/package.json"), "utf-8"));
|
|
1139
1159
|
const version = pkg.version;
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1160
|
+
const spinner7 = p4.spinner();
|
|
1161
|
+
spinner7.start("Adding components");
|
|
1142
1162
|
await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, "scaffold");
|
|
1143
|
-
|
|
1163
|
+
spinner7.stop("Components added.");
|
|
1144
1164
|
if (!skipInstall) {
|
|
1145
1165
|
await installDeps2(cwd, toAdd);
|
|
1146
1166
|
}
|
|
@@ -1163,44 +1183,44 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
|
1163
1183
|
}
|
|
1164
1184
|
async function installDeps2(dest, components) {
|
|
1165
1185
|
for (const component of components) {
|
|
1166
|
-
const
|
|
1186
|
+
const spinner7 = p4.spinner();
|
|
1167
1187
|
try {
|
|
1168
1188
|
switch (component) {
|
|
1169
1189
|
case "fastapi":
|
|
1170
1190
|
if (hasCommand("uv")) {
|
|
1171
|
-
|
|
1191
|
+
spinner7.start("Installing FastAPI dependencies");
|
|
1172
1192
|
exec("uv sync --all-extras", join6(dest, "fastapi"));
|
|
1173
|
-
|
|
1193
|
+
spinner7.stop("FastAPI dependencies installed.");
|
|
1174
1194
|
} else {
|
|
1175
1195
|
p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
1176
1196
|
}
|
|
1177
1197
|
break;
|
|
1178
1198
|
case "fastify":
|
|
1179
1199
|
if (hasCommand("pnpm")) {
|
|
1180
|
-
|
|
1200
|
+
spinner7.start("Installing Fastify dependencies");
|
|
1181
1201
|
exec("pnpm install", join6(dest, "fastify"));
|
|
1182
|
-
|
|
1202
|
+
spinner7.stop("Fastify dependencies installed.");
|
|
1183
1203
|
} else {
|
|
1184
|
-
|
|
1204
|
+
spinner7.start("Installing Fastify dependencies");
|
|
1185
1205
|
exec("npm install", join6(dest, "fastify"));
|
|
1186
|
-
|
|
1206
|
+
spinner7.stop("Fastify dependencies installed.");
|
|
1187
1207
|
}
|
|
1188
1208
|
break;
|
|
1189
1209
|
case "frontend":
|
|
1190
|
-
|
|
1210
|
+
spinner7.start("Installing Frontend dependencies");
|
|
1191
1211
|
exec("npm install", join6(dest, "frontend"));
|
|
1192
|
-
|
|
1212
|
+
spinner7.stop("Frontend dependencies installed.");
|
|
1193
1213
|
break;
|
|
1194
1214
|
case "e2e":
|
|
1195
|
-
|
|
1215
|
+
spinner7.start("Installing E2E dependencies");
|
|
1196
1216
|
exec("npm install", join6(dest, "e2e"));
|
|
1197
|
-
|
|
1217
|
+
spinner7.stop("E2E dependencies installed.");
|
|
1198
1218
|
break;
|
|
1199
1219
|
case "mobile":
|
|
1200
1220
|
if (hasCommand("flutter")) {
|
|
1201
|
-
|
|
1221
|
+
spinner7.start("Installing Flutter dependencies");
|
|
1202
1222
|
exec("flutter pub get", join6(dest, "mobile"));
|
|
1203
|
-
|
|
1223
|
+
spinner7.stop("Flutter dependencies installed.");
|
|
1204
1224
|
} else {
|
|
1205
1225
|
p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
|
|
1206
1226
|
}
|
|
@@ -1209,27 +1229,10 @@ async function installDeps2(dest, components) {
|
|
|
1209
1229
|
break;
|
|
1210
1230
|
}
|
|
1211
1231
|
} catch {
|
|
1212
|
-
|
|
1232
|
+
spinner7.stop(`Failed to install ${component} dependencies.`);
|
|
1213
1233
|
}
|
|
1214
1234
|
}
|
|
1215
1235
|
}
|
|
1216
|
-
function detectProjectName2(cwd, components, paths) {
|
|
1217
|
-
for (const component of components) {
|
|
1218
|
-
const dir = paths[component] ?? component;
|
|
1219
|
-
const pkgPath = join6(cwd, dir, "package.json");
|
|
1220
|
-
if (existsSync5(pkgPath)) {
|
|
1221
|
-
try {
|
|
1222
|
-
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
1223
|
-
const n = pkg.name;
|
|
1224
|
-
if (n && n.includes("-")) {
|
|
1225
|
-
return n.substring(0, n.lastIndexOf("-"));
|
|
1226
|
-
}
|
|
1227
|
-
} catch {
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
return toKebab(cwd.split("/").pop());
|
|
1232
|
-
}
|
|
1233
1236
|
|
|
1234
1237
|
// src/init.ts
|
|
1235
1238
|
import { existsSync as existsSync7 } from "fs";
|
|
@@ -1338,10 +1341,10 @@ async function init(cwd, localRepo) {
|
|
|
1338
1341
|
p5.log.error("You have uncommitted changes. Commit or stash them first.");
|
|
1339
1342
|
process.exit(1);
|
|
1340
1343
|
}
|
|
1341
|
-
const
|
|
1342
|
-
|
|
1344
|
+
const spinner7 = p5.spinner();
|
|
1345
|
+
spinner7.start("Scanning for components");
|
|
1343
1346
|
const detected = await detectComponents(cwd);
|
|
1344
|
-
|
|
1347
|
+
spinner7.stop(
|
|
1345
1348
|
detected.length > 0 ? `Found ${detected.length} component(s).` : "No components detected."
|
|
1346
1349
|
);
|
|
1347
1350
|
let confirmed;
|
|
@@ -1461,6 +1464,1582 @@ function hasUncommittedChanges2(cwd) {
|
|
|
1461
1464
|
}
|
|
1462
1465
|
}
|
|
1463
1466
|
|
|
1467
|
+
// src/pin.ts
|
|
1468
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1469
|
+
import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
|
|
1470
|
+
import { join as join9 } from "path";
|
|
1471
|
+
import * as p6 from "@clack/prompts";
|
|
1472
|
+
function classifyPattern(pattern, componentPaths) {
|
|
1473
|
+
const dirToComponent = {};
|
|
1474
|
+
for (const [component, dir] of Object.entries(componentPaths)) {
|
|
1475
|
+
dirToComponent[dir] = component;
|
|
1476
|
+
}
|
|
1477
|
+
for (const [dir, component] of Object.entries(dirToComponent)) {
|
|
1478
|
+
if (pattern.startsWith(dir + "/")) {
|
|
1479
|
+
return {
|
|
1480
|
+
scope: "component",
|
|
1481
|
+
component,
|
|
1482
|
+
relative: pattern.slice(dir.length + 1)
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
return { scope: "root", relative: pattern };
|
|
1487
|
+
}
|
|
1488
|
+
async function pin(cwd, patterns) {
|
|
1489
|
+
p6.intro("projx pin");
|
|
1490
|
+
const configPath = join9(cwd, ".projx");
|
|
1491
|
+
if (!existsSync8(configPath)) {
|
|
1492
|
+
p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1493
|
+
process.exit(1);
|
|
1494
|
+
}
|
|
1495
|
+
const config = JSON.parse(await readFile8(configPath, "utf-8"));
|
|
1496
|
+
const componentPaths = await discoverComponentPaths(cwd, config.components);
|
|
1497
|
+
const rootAdds = [];
|
|
1498
|
+
const componentAdds = {};
|
|
1499
|
+
for (const pattern of patterns) {
|
|
1500
|
+
if (pattern === ".projx" || pattern.endsWith(COMPONENT_MARKER)) {
|
|
1501
|
+
p6.log.warn(`Cannot pin ${pattern} \u2014 config files are managed by projx.`);
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
const { scope, component, relative } = classifyPattern(pattern, componentPaths);
|
|
1505
|
+
if (scope === "component" && component) {
|
|
1506
|
+
if (!componentAdds[component]) componentAdds[component] = [];
|
|
1507
|
+
componentAdds[component].push(relative);
|
|
1508
|
+
} else {
|
|
1509
|
+
rootAdds.push(relative);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
for (const [component, additions] of Object.entries(componentAdds)) {
|
|
1513
|
+
const dir = componentPaths[component];
|
|
1514
|
+
const markerPath = join9(cwd, dir, COMPONENT_MARKER);
|
|
1515
|
+
try {
|
|
1516
|
+
const data = JSON.parse(await readFile8(markerPath, "utf-8"));
|
|
1517
|
+
const existing = data.skip ?? [];
|
|
1518
|
+
const merged = [.../* @__PURE__ */ new Set([...existing, ...additions])];
|
|
1519
|
+
const added = merged.length - existing.length;
|
|
1520
|
+
if (added > 0) {
|
|
1521
|
+
data.skip = merged;
|
|
1522
|
+
await writeFile4(markerPath, JSON.stringify(data, null, 2) + "\n");
|
|
1523
|
+
p6.log.success(`${component}: pinned ${additions.join(", ")}`);
|
|
1524
|
+
} else {
|
|
1525
|
+
p6.log.info(`${component}: already pinned.`);
|
|
1526
|
+
}
|
|
1527
|
+
} catch {
|
|
1528
|
+
p6.log.error(`Could not read marker for ${component}.`);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
if (rootAdds.length > 0) {
|
|
1532
|
+
const existing = config.skip ?? [];
|
|
1533
|
+
const merged = [.../* @__PURE__ */ new Set([...existing, ...rootAdds])];
|
|
1534
|
+
const added = merged.length - existing.length;
|
|
1535
|
+
if (added > 0) {
|
|
1536
|
+
config.skip = merged;
|
|
1537
|
+
await writeFile4(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
1538
|
+
p6.log.success(`root: pinned ${rootAdds.join(", ")}`);
|
|
1539
|
+
} else {
|
|
1540
|
+
p6.log.info("root: already pinned.");
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
p6.outro("Skip list updated.");
|
|
1544
|
+
}
|
|
1545
|
+
async function unpin(cwd, patterns) {
|
|
1546
|
+
p6.intro("projx unpin");
|
|
1547
|
+
const configPath = join9(cwd, ".projx");
|
|
1548
|
+
if (!existsSync8(configPath)) {
|
|
1549
|
+
p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1550
|
+
process.exit(1);
|
|
1551
|
+
}
|
|
1552
|
+
const config = JSON.parse(await readFile8(configPath, "utf-8"));
|
|
1553
|
+
const componentPaths = await discoverComponentPaths(cwd, config.components);
|
|
1554
|
+
const rootRemoves = [];
|
|
1555
|
+
const componentRemoves = {};
|
|
1556
|
+
for (const pattern of patterns) {
|
|
1557
|
+
const { scope, component, relative } = classifyPattern(pattern, componentPaths);
|
|
1558
|
+
if (scope === "component" && component) {
|
|
1559
|
+
if (!componentRemoves[component]) componentRemoves[component] = [];
|
|
1560
|
+
componentRemoves[component].push(relative);
|
|
1561
|
+
} else {
|
|
1562
|
+
rootRemoves.push(relative);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
for (const [component, removals] of Object.entries(componentRemoves)) {
|
|
1566
|
+
const dir = componentPaths[component];
|
|
1567
|
+
const markerPath = join9(cwd, dir, COMPONENT_MARKER);
|
|
1568
|
+
try {
|
|
1569
|
+
const data = JSON.parse(await readFile8(markerPath, "utf-8"));
|
|
1570
|
+
const existing = data.skip ?? [];
|
|
1571
|
+
const filtered = existing.filter((s) => !removals.includes(s));
|
|
1572
|
+
const removed = existing.length - filtered.length;
|
|
1573
|
+
if (removed > 0) {
|
|
1574
|
+
if (filtered.length > 0) {
|
|
1575
|
+
data.skip = filtered;
|
|
1576
|
+
} else {
|
|
1577
|
+
delete data.skip;
|
|
1578
|
+
}
|
|
1579
|
+
await writeFile4(markerPath, JSON.stringify(data, null, 2) + "\n");
|
|
1580
|
+
p6.log.success(`${component}: unpinned ${removals.join(", ")}`);
|
|
1581
|
+
} else {
|
|
1582
|
+
p6.log.info(`${component}: not found in skip list.`);
|
|
1583
|
+
}
|
|
1584
|
+
} catch {
|
|
1585
|
+
p6.log.error(`Could not read marker for ${component}.`);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
if (rootRemoves.length > 0) {
|
|
1589
|
+
const existing = config.skip ?? [];
|
|
1590
|
+
const filtered = existing.filter((s) => !rootRemoves.includes(s));
|
|
1591
|
+
const removed = existing.length - filtered.length;
|
|
1592
|
+
if (removed > 0) {
|
|
1593
|
+
if (filtered.length > 0) {
|
|
1594
|
+
config.skip = filtered;
|
|
1595
|
+
} else {
|
|
1596
|
+
delete config.skip;
|
|
1597
|
+
}
|
|
1598
|
+
await writeFile4(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
1599
|
+
p6.log.success(`root: unpinned ${rootRemoves.join(", ")}`);
|
|
1600
|
+
} else {
|
|
1601
|
+
p6.log.info("root: not found in skip list.");
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
p6.outro("Skip list updated.");
|
|
1605
|
+
}
|
|
1606
|
+
async function listPins(cwd) {
|
|
1607
|
+
p6.intro("projx pin --list");
|
|
1608
|
+
const configPath = join9(cwd, ".projx");
|
|
1609
|
+
if (!existsSync8(configPath)) {
|
|
1610
|
+
p6.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1611
|
+
process.exit(1);
|
|
1612
|
+
}
|
|
1613
|
+
const config = JSON.parse(await readFile8(configPath, "utf-8"));
|
|
1614
|
+
const componentPaths = await discoverComponentPaths(cwd, config.components);
|
|
1615
|
+
let hasAny = false;
|
|
1616
|
+
if (config.skip && config.skip.length > 0) {
|
|
1617
|
+
hasAny = true;
|
|
1618
|
+
p6.log.info("root:");
|
|
1619
|
+
for (const s of config.skip) {
|
|
1620
|
+
p6.log.info(` ${s}`);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
for (const component of config.components) {
|
|
1624
|
+
const dir = componentPaths[component];
|
|
1625
|
+
const marker = await readComponentMarker(join9(cwd, dir));
|
|
1626
|
+
if (marker?.skip && marker.skip.length > 0) {
|
|
1627
|
+
hasAny = true;
|
|
1628
|
+
const label = dir !== component ? `${component} (${dir}/)` : `${component}`;
|
|
1629
|
+
p6.log.info(`${label}:`);
|
|
1630
|
+
for (const s of marker.skip) {
|
|
1631
|
+
p6.log.info(` ${s}`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
if (!hasAny) {
|
|
1636
|
+
p6.log.info("No pinned files. All template files will be updated.");
|
|
1637
|
+
}
|
|
1638
|
+
p6.outro("");
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// src/doctor.ts
|
|
1642
|
+
import { existsSync as existsSync9 } from "fs";
|
|
1643
|
+
import { readFile as readFile9, readdir as readdir3 } from "fs/promises";
|
|
1644
|
+
import { execSync as execSync5 } from "child_process";
|
|
1645
|
+
import { join as join10 } from "path";
|
|
1646
|
+
import * as p7 from "@clack/prompts";
|
|
1647
|
+
async function checkConfig(cwd) {
|
|
1648
|
+
const results = [];
|
|
1649
|
+
const configPath = join10(cwd, ".projx");
|
|
1650
|
+
if (!existsSync9(configPath)) {
|
|
1651
|
+
results.push({
|
|
1652
|
+
name: ".projx exists",
|
|
1653
|
+
status: "fail",
|
|
1654
|
+
message: "No .projx file found.",
|
|
1655
|
+
fix: "Run 'npx create-projx init' to initialize."
|
|
1656
|
+
});
|
|
1657
|
+
return { results };
|
|
1658
|
+
}
|
|
1659
|
+
let config;
|
|
1660
|
+
try {
|
|
1661
|
+
config = JSON.parse(await readFile9(configPath, "utf-8"));
|
|
1662
|
+
} catch {
|
|
1663
|
+
results.push({
|
|
1664
|
+
name: ".projx valid JSON",
|
|
1665
|
+
status: "fail",
|
|
1666
|
+
message: ".projx contains invalid JSON."
|
|
1667
|
+
});
|
|
1668
|
+
return { results };
|
|
1669
|
+
}
|
|
1670
|
+
results.push({ name: ".projx exists", status: "pass", message: `v${config.version}` });
|
|
1671
|
+
if (!config.version || !config.components || !Array.isArray(config.components)) {
|
|
1672
|
+
results.push({
|
|
1673
|
+
name: ".projx fields",
|
|
1674
|
+
status: "fail",
|
|
1675
|
+
message: "Missing required fields (version, components)."
|
|
1676
|
+
});
|
|
1677
|
+
return { results };
|
|
1678
|
+
}
|
|
1679
|
+
const invalid = config.components.filter((c) => !COMPONENTS.includes(c));
|
|
1680
|
+
if (invalid.length > 0) {
|
|
1681
|
+
results.push({
|
|
1682
|
+
name: "component names",
|
|
1683
|
+
status: "warn",
|
|
1684
|
+
message: `Unknown components: ${invalid.join(", ")}`
|
|
1685
|
+
});
|
|
1686
|
+
} else {
|
|
1687
|
+
results.push({ name: "component names", status: "pass", message: `${config.components.length} valid` });
|
|
1688
|
+
}
|
|
1689
|
+
return { results, config };
|
|
1690
|
+
}
|
|
1691
|
+
async function checkComponents(cwd, config, componentPaths) {
|
|
1692
|
+
const results = [];
|
|
1693
|
+
for (const component of config.components) {
|
|
1694
|
+
const dir = componentPaths[component];
|
|
1695
|
+
const fullDir = join10(cwd, dir);
|
|
1696
|
+
if (!existsSync9(fullDir)) {
|
|
1697
|
+
results.push({
|
|
1698
|
+
name: `${component} directory`,
|
|
1699
|
+
status: "fail",
|
|
1700
|
+
message: `Directory ${dir}/ not found.`
|
|
1701
|
+
});
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
const marker = await readComponentMarker(fullDir);
|
|
1705
|
+
if (!marker) {
|
|
1706
|
+
results.push({
|
|
1707
|
+
name: `${component} marker`,
|
|
1708
|
+
status: "fail",
|
|
1709
|
+
message: `No ${COMPONENT_MARKER} in ${dir}/.`,
|
|
1710
|
+
fix: `Run 'npx create-projx update' to regenerate markers.`
|
|
1711
|
+
});
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
if (!marker.components.includes(component)) {
|
|
1715
|
+
results.push({
|
|
1716
|
+
name: `${component} marker`,
|
|
1717
|
+
status: "warn",
|
|
1718
|
+
message: `Marker in ${dir}/ does not list "${component}".`
|
|
1719
|
+
});
|
|
1720
|
+
} else {
|
|
1721
|
+
const label = dir !== component ? `${dir}/ (${component})` : `${component}/`;
|
|
1722
|
+
results.push({ name: `${component} marker`, status: "pass", message: label });
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
try {
|
|
1726
|
+
const entries = await readdir3(cwd, { withFileTypes: true });
|
|
1727
|
+
for (const entry of entries) {
|
|
1728
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
1729
|
+
const markerPath = join10(cwd, entry.name, COMPONENT_MARKER);
|
|
1730
|
+
if (!existsSync9(markerPath)) continue;
|
|
1731
|
+
const isKnown = Object.values(componentPaths).includes(entry.name);
|
|
1732
|
+
if (!isKnown) {
|
|
1733
|
+
results.push({
|
|
1734
|
+
name: `orphan marker`,
|
|
1735
|
+
status: "warn",
|
|
1736
|
+
message: `${entry.name}/ has a ${COMPONENT_MARKER} but is not in .projx components.`
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
} catch {
|
|
1741
|
+
}
|
|
1742
|
+
return results;
|
|
1743
|
+
}
|
|
1744
|
+
function checkGit(cwd, fix) {
|
|
1745
|
+
const results = [];
|
|
1746
|
+
try {
|
|
1747
|
+
execSync5("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
|
|
1748
|
+
results.push({ name: "git repo", status: "pass", message: "OK" });
|
|
1749
|
+
} catch {
|
|
1750
|
+
results.push({ name: "git repo", status: "fail", message: "Not a git repository." });
|
|
1751
|
+
return results;
|
|
1752
|
+
}
|
|
1753
|
+
try {
|
|
1754
|
+
const ref = execSync5(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
|
|
1755
|
+
results.push({ name: "baseline ref", status: "pass", message: ref.slice(0, 8) });
|
|
1756
|
+
} catch {
|
|
1757
|
+
if (fix) {
|
|
1758
|
+
saveBaselineRef(cwd);
|
|
1759
|
+
try {
|
|
1760
|
+
execSync5(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" });
|
|
1761
|
+
results.push({ name: "baseline ref", status: "pass", message: "Created from git history." });
|
|
1762
|
+
} catch {
|
|
1763
|
+
results.push({
|
|
1764
|
+
name: "baseline ref",
|
|
1765
|
+
status: "warn",
|
|
1766
|
+
message: "Missing. Could not auto-create.",
|
|
1767
|
+
fix: "Run 'npx create-projx update' to establish baseline."
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
} else {
|
|
1771
|
+
results.push({
|
|
1772
|
+
name: "baseline ref",
|
|
1773
|
+
status: "warn",
|
|
1774
|
+
message: "Missing. Run 'projx doctor --fix' to create.",
|
|
1775
|
+
autoFixable: true
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
try {
|
|
1780
|
+
const worktrees = execSync5("git worktree list --porcelain", { cwd, stdio: "pipe" }).toString();
|
|
1781
|
+
const stale = worktrees.split("\n").filter((l) => l.includes("projx-wt-") || l.includes("projx/tmp-"));
|
|
1782
|
+
if (stale.length > 0) {
|
|
1783
|
+
if (fix) {
|
|
1784
|
+
execSync5("git worktree prune", { cwd, stdio: "pipe" });
|
|
1785
|
+
results.push({ name: "worktrees", status: "pass", message: "Pruned stale worktrees." });
|
|
1786
|
+
} else {
|
|
1787
|
+
results.push({
|
|
1788
|
+
name: "worktrees",
|
|
1789
|
+
status: "warn",
|
|
1790
|
+
message: "Stale projx worktrees found.",
|
|
1791
|
+
fix: "Run 'projx doctor --fix' to prune.",
|
|
1792
|
+
autoFixable: true
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
} else {
|
|
1796
|
+
results.push({ name: "worktrees", status: "pass", message: "Clean" });
|
|
1797
|
+
}
|
|
1798
|
+
} catch {
|
|
1799
|
+
results.push({ name: "worktrees", status: "pass", message: "OK" });
|
|
1800
|
+
}
|
|
1801
|
+
try {
|
|
1802
|
+
const status = execSync5("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
|
|
1803
|
+
if (status) {
|
|
1804
|
+
const count = status.split("\n").length;
|
|
1805
|
+
results.push({ name: "working tree", status: "warn", message: `${count} uncommitted change(s).` });
|
|
1806
|
+
} else {
|
|
1807
|
+
results.push({ name: "working tree", status: "pass", message: "Clean" });
|
|
1808
|
+
}
|
|
1809
|
+
} catch {
|
|
1810
|
+
}
|
|
1811
|
+
return results;
|
|
1812
|
+
}
|
|
1813
|
+
async function checkSkipPatterns(cwd, config, componentPaths) {
|
|
1814
|
+
const results = [];
|
|
1815
|
+
if (config.skip && config.skip.length > 0) {
|
|
1816
|
+
for (const pattern of config.skip) {
|
|
1817
|
+
const matches = await patternMatchesAnything(cwd, pattern);
|
|
1818
|
+
if (!matches) {
|
|
1819
|
+
results.push({
|
|
1820
|
+
name: "root skip",
|
|
1821
|
+
status: "warn",
|
|
1822
|
+
message: `"${pattern}" matches no files \u2014 stale?`
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
for (const component of config.components) {
|
|
1828
|
+
const dir = componentPaths[component];
|
|
1829
|
+
const marker = await readComponentMarker(join10(cwd, dir));
|
|
1830
|
+
if (marker?.skip && marker.skip.length > 0) {
|
|
1831
|
+
for (const pattern of marker.skip) {
|
|
1832
|
+
const matches = await patternMatchesAnything(join10(cwd, dir), pattern);
|
|
1833
|
+
if (!matches) {
|
|
1834
|
+
results.push({
|
|
1835
|
+
name: `${component} skip`,
|
|
1836
|
+
status: "warn",
|
|
1837
|
+
message: `"${pattern}" matches no files \u2014 stale?`
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (results.length === 0 && (config.skip?.length || config.components.some(() => true))) {
|
|
1844
|
+
results.push({ name: "skip patterns", status: "pass", message: "All patterns match files." });
|
|
1845
|
+
}
|
|
1846
|
+
return results;
|
|
1847
|
+
}
|
|
1848
|
+
async function patternMatchesAnything(dir, pattern) {
|
|
1849
|
+
if (pattern === "**") return true;
|
|
1850
|
+
if (!existsSync9(dir)) return false;
|
|
1851
|
+
const walk = async (current, base) => {
|
|
1852
|
+
let entries;
|
|
1853
|
+
try {
|
|
1854
|
+
entries = await readdir3(current, { withFileTypes: true });
|
|
1855
|
+
} catch {
|
|
1856
|
+
return false;
|
|
1857
|
+
}
|
|
1858
|
+
for (const entry of entries) {
|
|
1859
|
+
const full = join10(current, entry.name);
|
|
1860
|
+
const rel = full.slice(base.length + 1);
|
|
1861
|
+
if (entry.isDirectory()) {
|
|
1862
|
+
if (await walk(full, base)) return true;
|
|
1863
|
+
} else if (matchesSkip(rel, [pattern])) {
|
|
1864
|
+
return true;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
return false;
|
|
1868
|
+
};
|
|
1869
|
+
return walk(dir, dir);
|
|
1870
|
+
}
|
|
1871
|
+
async function doctor(cwd, fix = false) {
|
|
1872
|
+
p7.intro("projx doctor");
|
|
1873
|
+
const allResults = [];
|
|
1874
|
+
const { results: configResults, config } = await checkConfig(cwd);
|
|
1875
|
+
allResults.push(...configResults);
|
|
1876
|
+
if (!config) {
|
|
1877
|
+
printReport(allResults);
|
|
1878
|
+
process.exit(1);
|
|
1879
|
+
}
|
|
1880
|
+
const componentPaths = await discoverComponentPaths(cwd, config.components);
|
|
1881
|
+
allResults.push(...await checkComponents(cwd, config, componentPaths));
|
|
1882
|
+
allResults.push(...checkGit(cwd, fix));
|
|
1883
|
+
allResults.push(...await checkSkipPatterns(cwd, config, componentPaths));
|
|
1884
|
+
printReport(allResults);
|
|
1885
|
+
const passed = allResults.filter((r) => r.status === "pass").length;
|
|
1886
|
+
const warns = allResults.filter((r) => r.status === "warn").length;
|
|
1887
|
+
const fails = allResults.filter((r) => r.status === "fail").length;
|
|
1888
|
+
const fixable = allResults.filter((r) => r.autoFixable);
|
|
1889
|
+
if (fixable.length > 0 && !fix) {
|
|
1890
|
+
p7.log.info(`${fixable.length} issue(s) auto-fixable with --fix`);
|
|
1891
|
+
}
|
|
1892
|
+
p7.outro(`${passed} passed, ${warns} warning(s), ${fails} failed`);
|
|
1893
|
+
if (fails > 0) process.exit(1);
|
|
1894
|
+
}
|
|
1895
|
+
function printReport(results) {
|
|
1896
|
+
for (const r of results) {
|
|
1897
|
+
const icon = r.status === "pass" ? "\u2713" : r.status === "warn" ? "\u26A0" : "\u2717";
|
|
1898
|
+
const msg = `${icon} ${r.name} \u2014 ${r.message}`;
|
|
1899
|
+
if (r.status === "pass") p7.log.success(msg);
|
|
1900
|
+
else if (r.status === "warn") p7.log.warn(msg);
|
|
1901
|
+
else p7.log.error(msg);
|
|
1902
|
+
if (r.fix) p7.log.info(` ${r.fix}`);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// src/diff.ts
|
|
1907
|
+
import { existsSync as existsSync10 } from "fs";
|
|
1908
|
+
import { readFile as readFile10, mkdir as mkdir4, rm as rm3 } from "fs/promises";
|
|
1909
|
+
import { join as join11 } from "path";
|
|
1910
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
1911
|
+
import * as p8 from "@clack/prompts";
|
|
1912
|
+
function isSkipped(file, componentPaths, componentSkips, rootSkip) {
|
|
1913
|
+
for (const [component, dir] of Object.entries(componentPaths)) {
|
|
1914
|
+
if (file.startsWith(dir + "/")) {
|
|
1915
|
+
const relative = file.slice(dir.length + 1);
|
|
1916
|
+
const skips = componentSkips[component] ?? [];
|
|
1917
|
+
if (matchesSkip(relative, skips)) return true;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
const base = file.split("/").pop();
|
|
1921
|
+
if (base === ".projx" || base === ".projx-component") return false;
|
|
1922
|
+
return matchesSkip(file, rootSkip);
|
|
1923
|
+
}
|
|
1924
|
+
function fileComponent(file, componentPaths) {
|
|
1925
|
+
for (const [component, dir] of Object.entries(componentPaths)) {
|
|
1926
|
+
if (file.startsWith(dir + "/")) return component;
|
|
1927
|
+
}
|
|
1928
|
+
return void 0;
|
|
1929
|
+
}
|
|
1930
|
+
async function diff(cwd, localRepo) {
|
|
1931
|
+
p8.intro("projx diff");
|
|
1932
|
+
const isLocal = !!localRepo;
|
|
1933
|
+
const configPath = join11(cwd, ".projx");
|
|
1934
|
+
if (!existsSync10(configPath)) {
|
|
1935
|
+
p8.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
1936
|
+
process.exit(1);
|
|
1937
|
+
}
|
|
1938
|
+
const config = JSON.parse(await readFile10(configPath, "utf-8"));
|
|
1939
|
+
const componentPaths = await discoverComponentPaths(cwd, config.components);
|
|
1940
|
+
const componentSkips = {};
|
|
1941
|
+
for (const component of config.components) {
|
|
1942
|
+
const dir = componentPaths[component];
|
|
1943
|
+
const marker = await readComponentMarker(join11(cwd, dir));
|
|
1944
|
+
if (marker?.skip && marker.skip.length > 0) {
|
|
1945
|
+
componentSkips[component] = marker.skip;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
const rootSkip = config.skip ?? [];
|
|
1949
|
+
const dlSpinner = p8.spinner();
|
|
1950
|
+
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
1951
|
+
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
1952
|
+
dlSpinner.stop("Failed.");
|
|
1953
|
+
p8.log.error(String(err));
|
|
1954
|
+
process.exit(1);
|
|
1955
|
+
});
|
|
1956
|
+
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1957
|
+
try {
|
|
1958
|
+
const pkg = JSON.parse(await readFile10(join11(repoDir, "cli/package.json"), "utf-8"));
|
|
1959
|
+
const version = pkg.version;
|
|
1960
|
+
p8.log.info(`Current: v${config.version} \u2192 Template: v${version}`);
|
|
1961
|
+
const name = detectProjectName(cwd, config.components, componentPaths);
|
|
1962
|
+
const vars = { projectName: name, components: config.components, paths: componentPaths };
|
|
1963
|
+
const spinner7 = p8.spinner();
|
|
1964
|
+
spinner7.start("Analyzing changes");
|
|
1965
|
+
const tmpTemplate = join11(tmpdir3(), `projx-diff-${Date.now()}`);
|
|
1966
|
+
await mkdir4(tmpTemplate, { recursive: true });
|
|
1967
|
+
await writeTemplateToDir(tmpTemplate, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
|
|
1968
|
+
const baselineRef = getBaselineRef(cwd);
|
|
1969
|
+
const templateFiles = await collectAllFiles(tmpTemplate, tmpTemplate);
|
|
1970
|
+
const analyses = [];
|
|
1971
|
+
for (const file of templateFiles) {
|
|
1972
|
+
const component = fileComponent(file, componentPaths);
|
|
1973
|
+
if (isSkipped(file, componentPaths, componentSkips, rootSkip)) {
|
|
1974
|
+
analyses.push({ file, status: "skipped", component });
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
const oursPath = join11(cwd, file);
|
|
1978
|
+
if (!existsSync10(oursPath)) {
|
|
1979
|
+
analyses.push({ file, status: "new", component });
|
|
1980
|
+
continue;
|
|
1981
|
+
}
|
|
1982
|
+
let oursContent;
|
|
1983
|
+
let theirsContent;
|
|
1984
|
+
try {
|
|
1985
|
+
oursContent = await readFile10(oursPath, "utf-8");
|
|
1986
|
+
theirsContent = await readFile10(join11(tmpTemplate, file), "utf-8");
|
|
1987
|
+
} catch {
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
if (oursContent === theirsContent) {
|
|
1991
|
+
analyses.push({ file, status: "unchanged", component });
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
if (!baselineRef) {
|
|
1995
|
+
analyses.push({ file, status: "needs-merge", component });
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1998
|
+
const baseContent = getFileAtRef(cwd, baselineRef, file);
|
|
1999
|
+
if (!baseContent) {
|
|
2000
|
+
analyses.push({ file, status: "needs-merge", component });
|
|
2001
|
+
continue;
|
|
2002
|
+
}
|
|
2003
|
+
if (oursContent === baseContent) {
|
|
2004
|
+
analyses.push({ file, status: "clean-update", component });
|
|
2005
|
+
} else if (theirsContent === baseContent) {
|
|
2006
|
+
analyses.push({ file, status: "user-only", component });
|
|
2007
|
+
} else {
|
|
2008
|
+
analyses.push({ file, status: "needs-merge", component });
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
await rm3(tmpTemplate, { recursive: true, force: true });
|
|
2012
|
+
spinner7.stop("Analysis complete.");
|
|
2013
|
+
const groups = {
|
|
2014
|
+
"new": [],
|
|
2015
|
+
"clean-update": [],
|
|
2016
|
+
"needs-merge": [],
|
|
2017
|
+
"user-only": [],
|
|
2018
|
+
"unchanged": [],
|
|
2019
|
+
"skipped": []
|
|
2020
|
+
};
|
|
2021
|
+
for (const a of analyses) {
|
|
2022
|
+
groups[a.status].push(a);
|
|
2023
|
+
}
|
|
2024
|
+
if (groups["new"].length > 0) {
|
|
2025
|
+
p8.log.info(`New files (${groups["new"].length}):`);
|
|
2026
|
+
for (const a of groups["new"]) p8.log.info(` + ${a.file}`);
|
|
2027
|
+
}
|
|
2028
|
+
if (groups["clean-update"].length > 0) {
|
|
2029
|
+
p8.log.success(`Clean updates \u2014 auto-merged (${groups["clean-update"].length}):`);
|
|
2030
|
+
for (const a of groups["clean-update"]) p8.log.info(` ~ ${a.file}`);
|
|
2031
|
+
}
|
|
2032
|
+
if (groups["needs-merge"].length > 0) {
|
|
2033
|
+
p8.log.warn(`Needs merge \u2014 both sides changed (${groups["needs-merge"].length}):`);
|
|
2034
|
+
for (const a of groups["needs-merge"]) p8.log.info(` ! ${a.file}`);
|
|
2035
|
+
}
|
|
2036
|
+
if (groups["user-only"].length > 0) {
|
|
2037
|
+
p8.log.info(`User-modified only \u2014 no template change (${groups["user-only"].length}):`);
|
|
2038
|
+
for (const a of groups["user-only"]) p8.log.info(` = ${a.file}`);
|
|
2039
|
+
}
|
|
2040
|
+
if (groups["skipped"].length > 0) {
|
|
2041
|
+
p8.log.info(`Skipped (${groups["skipped"].length}):`);
|
|
2042
|
+
for (const a of groups["skipped"]) p8.log.info(` - ${a.file}`);
|
|
2043
|
+
}
|
|
2044
|
+
const unchanged = groups["unchanged"].length;
|
|
2045
|
+
if (unchanged > 0) {
|
|
2046
|
+
p8.log.info(`${unchanged} file(s) unchanged.`);
|
|
2047
|
+
}
|
|
2048
|
+
const total = analyses.length - unchanged;
|
|
2049
|
+
if (total === 0) {
|
|
2050
|
+
p8.outro("Everything is up to date.");
|
|
2051
|
+
} else {
|
|
2052
|
+
p8.outro(`${total} file(s) would be affected by update.`);
|
|
2053
|
+
}
|
|
2054
|
+
} finally {
|
|
2055
|
+
await cleanupRepo(repoDir, isLocal);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// src/gen.ts
|
|
2060
|
+
import { existsSync as existsSync11 } from "fs";
|
|
2061
|
+
import { readFile as readFile11, writeFile as writeFile5, mkdir as mkdir5 } from "fs/promises";
|
|
2062
|
+
import { join as join12 } from "path";
|
|
2063
|
+
import * as p9 from "@clack/prompts";
|
|
2064
|
+
var FIELD_TYPES = ["string", "number", "boolean", "date", "datetime", "text", "json"];
|
|
2065
|
+
function toPascal(s) {
|
|
2066
|
+
return s.split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
|
|
2067
|
+
}
|
|
2068
|
+
function pluralize(s) {
|
|
2069
|
+
if (s.endsWith("s") || s.endsWith("x") || s.endsWith("z") || s.endsWith("sh") || s.endsWith("ch")) return s + "es";
|
|
2070
|
+
if (s.endsWith("y") && !/[aeiou]y$/i.test(s)) return s.slice(0, -1) + "ies";
|
|
2071
|
+
return s + "s";
|
|
2072
|
+
}
|
|
2073
|
+
async function promptEntityConfig(name) {
|
|
2074
|
+
const snake = toSnake(name);
|
|
2075
|
+
const tableName = pluralize(snake);
|
|
2076
|
+
const kebab = toKebab(name);
|
|
2077
|
+
const apiPrefix = "/" + pluralize(kebab);
|
|
2078
|
+
const tbl = await p9.text({
|
|
2079
|
+
message: "Table name",
|
|
2080
|
+
placeholder: tableName,
|
|
2081
|
+
defaultValue: tableName
|
|
2082
|
+
});
|
|
2083
|
+
if (p9.isCancel(tbl)) process.exit(0);
|
|
2084
|
+
const prefix = await p9.text({
|
|
2085
|
+
message: "API prefix",
|
|
2086
|
+
placeholder: apiPrefix,
|
|
2087
|
+
defaultValue: apiPrefix
|
|
2088
|
+
});
|
|
2089
|
+
if (p9.isCancel(prefix)) process.exit(0);
|
|
2090
|
+
const readonly = await p9.confirm({
|
|
2091
|
+
message: "Readonly?",
|
|
2092
|
+
initialValue: false
|
|
2093
|
+
});
|
|
2094
|
+
if (p9.isCancel(readonly)) process.exit(0);
|
|
2095
|
+
const softDelete = await p9.confirm({
|
|
2096
|
+
message: "Soft delete?",
|
|
2097
|
+
initialValue: false
|
|
2098
|
+
});
|
|
2099
|
+
if (p9.isCancel(softDelete)) process.exit(0);
|
|
2100
|
+
const bulk = await p9.confirm({
|
|
2101
|
+
message: "Bulk operations?",
|
|
2102
|
+
initialValue: true
|
|
2103
|
+
});
|
|
2104
|
+
if (p9.isCancel(bulk)) process.exit(0);
|
|
2105
|
+
const fields = [];
|
|
2106
|
+
p9.log.info("Define fields (enter empty name to finish):");
|
|
2107
|
+
while (true) {
|
|
2108
|
+
const fieldName = await p9.text({
|
|
2109
|
+
message: `Field ${fields.length + 1} name`,
|
|
2110
|
+
placeholder: "done",
|
|
2111
|
+
defaultValue: ""
|
|
2112
|
+
});
|
|
2113
|
+
if (p9.isCancel(fieldName)) process.exit(0);
|
|
2114
|
+
if (!fieldName) break;
|
|
2115
|
+
const fieldType = await p9.select({
|
|
2116
|
+
message: `${fieldName} type`,
|
|
2117
|
+
options: FIELD_TYPES.map((t) => ({ value: t, label: t })),
|
|
2118
|
+
initialValue: "string"
|
|
2119
|
+
});
|
|
2120
|
+
if (p9.isCancel(fieldType)) process.exit(0);
|
|
2121
|
+
const required = await p9.confirm({
|
|
2122
|
+
message: `${fieldName} required?`,
|
|
2123
|
+
initialValue: true
|
|
2124
|
+
});
|
|
2125
|
+
if (p9.isCancel(required)) process.exit(0);
|
|
2126
|
+
fields.push({ name: toSnake(fieldName), type: fieldType, required });
|
|
2127
|
+
}
|
|
2128
|
+
if (fields.length === 0) {
|
|
2129
|
+
p9.log.warn("No fields defined. Adding a default 'name' field.");
|
|
2130
|
+
fields.push({ name: "name", type: "string", required: true });
|
|
2131
|
+
}
|
|
2132
|
+
const stringFields = fields.filter((f) => f.type === "string" || f.type === "text");
|
|
2133
|
+
let searchableFields = [];
|
|
2134
|
+
if (stringFields.length > 0) {
|
|
2135
|
+
const selected = await p9.multiselect({
|
|
2136
|
+
message: "Searchable fields",
|
|
2137
|
+
options: stringFields.map((f) => ({ value: f.name, label: f.name })),
|
|
2138
|
+
required: false
|
|
2139
|
+
});
|
|
2140
|
+
if (!p9.isCancel(selected)) {
|
|
2141
|
+
searchableFields = selected;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
return {
|
|
2145
|
+
name,
|
|
2146
|
+
tableName: tbl,
|
|
2147
|
+
apiPrefix: prefix.startsWith("/") ? prefix : "/" + prefix,
|
|
2148
|
+
readonly,
|
|
2149
|
+
softDelete,
|
|
2150
|
+
bulkOperations: bulk,
|
|
2151
|
+
fields,
|
|
2152
|
+
searchableFields
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
function parseFieldsFlag(raw) {
|
|
2156
|
+
return raw.split(",").map((f) => {
|
|
2157
|
+
const [nameType, ...rest] = f.trim().split(":");
|
|
2158
|
+
const required = nameType.endsWith("!");
|
|
2159
|
+
const name = toSnake(required ? nameType.slice(0, -1) : nameType);
|
|
2160
|
+
const type = rest[0] || "string";
|
|
2161
|
+
return { name, type, required: required || true };
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
function sqlalchemyType(type) {
|
|
2165
|
+
switch (type) {
|
|
2166
|
+
case "string":
|
|
2167
|
+
return "String(255)";
|
|
2168
|
+
case "number":
|
|
2169
|
+
return "Integer";
|
|
2170
|
+
case "boolean":
|
|
2171
|
+
return "Boolean";
|
|
2172
|
+
case "date":
|
|
2173
|
+
return "Date";
|
|
2174
|
+
case "datetime":
|
|
2175
|
+
return "DateTime";
|
|
2176
|
+
case "text":
|
|
2177
|
+
return "Text";
|
|
2178
|
+
case "json":
|
|
2179
|
+
return "JSON";
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
function generateFastAPIModel(config) {
|
|
2183
|
+
const className = toPascal(config.name);
|
|
2184
|
+
const imports = /* @__PURE__ */ new Set(["Column"]);
|
|
2185
|
+
for (const f of config.fields) {
|
|
2186
|
+
switch (f.type) {
|
|
2187
|
+
case "string":
|
|
2188
|
+
imports.add("String");
|
|
2189
|
+
break;
|
|
2190
|
+
case "number":
|
|
2191
|
+
imports.add("Integer");
|
|
2192
|
+
break;
|
|
2193
|
+
case "boolean":
|
|
2194
|
+
imports.add("Boolean");
|
|
2195
|
+
break;
|
|
2196
|
+
case "date":
|
|
2197
|
+
imports.add("Date");
|
|
2198
|
+
break;
|
|
2199
|
+
case "datetime":
|
|
2200
|
+
imports.add("DateTime");
|
|
2201
|
+
break;
|
|
2202
|
+
case "text":
|
|
2203
|
+
imports.add("Text");
|
|
2204
|
+
break;
|
|
2205
|
+
case "json":
|
|
2206
|
+
imports.add("JSON");
|
|
2207
|
+
break;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
if (config.softDelete) imports.add("DateTime");
|
|
2211
|
+
const importList = [...imports].sort().join(", ");
|
|
2212
|
+
const lines = [];
|
|
2213
|
+
lines.push(`from sqlalchemy import ${importList}`);
|
|
2214
|
+
if (config.softDelete) {
|
|
2215
|
+
lines.push(`from src.entities.base import BaseModel_, SoftDeleteMixin`);
|
|
2216
|
+
lines.push("");
|
|
2217
|
+
lines.push("");
|
|
2218
|
+
lines.push(`class ${className}(SoftDeleteMixin, BaseModel_):`);
|
|
2219
|
+
} else {
|
|
2220
|
+
lines.push(`from src.entities.base import BaseModel_`);
|
|
2221
|
+
lines.push("");
|
|
2222
|
+
lines.push("");
|
|
2223
|
+
lines.push(`class ${className}(BaseModel_):`);
|
|
2224
|
+
}
|
|
2225
|
+
lines.push(` __tablename__ = "${config.tableName}"`);
|
|
2226
|
+
lines.push(` __api_prefix__ = "${config.apiPrefix}"`);
|
|
2227
|
+
if (config.readonly) lines.push(` __readonly__ = True`);
|
|
2228
|
+
if (config.softDelete) lines.push(` __soft_delete__ = True`);
|
|
2229
|
+
if (!config.bulkOperations) lines.push(` __bulk_operations__ = False`);
|
|
2230
|
+
if (config.searchableFields.length > 0) {
|
|
2231
|
+
const fields = config.searchableFields.map((f) => `"${f}"`).join(", ");
|
|
2232
|
+
lines.push(` __searchable_fields__ = {${fields}}`);
|
|
2233
|
+
}
|
|
2234
|
+
lines.push("");
|
|
2235
|
+
for (const field of config.fields) {
|
|
2236
|
+
const nullable = field.required ? "nullable=False" : "nullable=True";
|
|
2237
|
+
lines.push(` ${field.name} = Column(${sqlalchemyType(field.type)}, ${nullable})`);
|
|
2238
|
+
}
|
|
2239
|
+
lines.push("");
|
|
2240
|
+
return lines.join("\n");
|
|
2241
|
+
}
|
|
2242
|
+
function typeboxType(type, required) {
|
|
2243
|
+
const inner = (() => {
|
|
2244
|
+
switch (type) {
|
|
2245
|
+
case "string":
|
|
2246
|
+
return "Type.String()";
|
|
2247
|
+
case "number":
|
|
2248
|
+
return "Type.Number()";
|
|
2249
|
+
case "boolean":
|
|
2250
|
+
return "Type.Boolean()";
|
|
2251
|
+
case "date":
|
|
2252
|
+
return "Type.String({ format: 'date' })";
|
|
2253
|
+
case "datetime":
|
|
2254
|
+
return "Type.String({ format: 'date-time' })";
|
|
2255
|
+
case "text":
|
|
2256
|
+
return "Type.String()";
|
|
2257
|
+
case "json":
|
|
2258
|
+
return "Type.Any()";
|
|
2259
|
+
}
|
|
2260
|
+
})();
|
|
2261
|
+
if (!required) return `Type.Union([${inner}, Type.Null()])`;
|
|
2262
|
+
return inner;
|
|
2263
|
+
}
|
|
2264
|
+
function typeboxOptional(type) {
|
|
2265
|
+
switch (type) {
|
|
2266
|
+
case "string":
|
|
2267
|
+
return "Type.Optional(Type.String())";
|
|
2268
|
+
case "number":
|
|
2269
|
+
return "Type.Optional(Type.Number())";
|
|
2270
|
+
case "boolean":
|
|
2271
|
+
return "Type.Optional(Type.Boolean())";
|
|
2272
|
+
case "date":
|
|
2273
|
+
return "Type.Optional(Type.String({ format: 'date' }))";
|
|
2274
|
+
case "datetime":
|
|
2275
|
+
return "Type.Optional(Type.String({ format: 'date-time' }))";
|
|
2276
|
+
case "text":
|
|
2277
|
+
return "Type.Optional(Type.String())";
|
|
2278
|
+
case "json":
|
|
2279
|
+
return "Type.Optional(Type.Any())";
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
function fieldMetaType(type) {
|
|
2283
|
+
switch (type) {
|
|
2284
|
+
case "string":
|
|
2285
|
+
return { type: "str", fieldType: "text" };
|
|
2286
|
+
case "number":
|
|
2287
|
+
return { type: "int", fieldType: "number" };
|
|
2288
|
+
case "boolean":
|
|
2289
|
+
return { type: "bool", fieldType: "boolean" };
|
|
2290
|
+
case "date":
|
|
2291
|
+
return { type: "date", fieldType: "date" };
|
|
2292
|
+
case "datetime":
|
|
2293
|
+
return { type: "datetime", fieldType: "datetime" };
|
|
2294
|
+
case "text":
|
|
2295
|
+
return { type: "str", fieldType: "textarea" };
|
|
2296
|
+
case "json":
|
|
2297
|
+
return { type: "dict", fieldType: "textarea" };
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
function prismaType(type, required) {
|
|
2301
|
+
const nullable = required ? "" : "?";
|
|
2302
|
+
switch (type) {
|
|
2303
|
+
case "string":
|
|
2304
|
+
return `String${nullable} @db.VarChar(255)`;
|
|
2305
|
+
case "number":
|
|
2306
|
+
return `Int${nullable}`;
|
|
2307
|
+
case "boolean":
|
|
2308
|
+
return `Boolean${nullable} @default(false)`;
|
|
2309
|
+
case "date":
|
|
2310
|
+
return `DateTime${nullable}`;
|
|
2311
|
+
case "datetime":
|
|
2312
|
+
return `DateTime${nullable}`;
|
|
2313
|
+
case "text":
|
|
2314
|
+
return `String${nullable}`;
|
|
2315
|
+
case "json":
|
|
2316
|
+
return `Json${nullable}`;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
function generateFastifySchemas(config) {
|
|
2320
|
+
const className = toPascal(config.name);
|
|
2321
|
+
const lines = [];
|
|
2322
|
+
lines.push(`import { Type, type Static } from '@sinclair/typebox';`);
|
|
2323
|
+
lines.push("");
|
|
2324
|
+
lines.push(`export const ${className}Schema = Type.Object({`);
|
|
2325
|
+
lines.push(` id: Type.String({ format: 'uuid' }),`);
|
|
2326
|
+
for (const f of config.fields) {
|
|
2327
|
+
lines.push(` ${f.name}: ${typeboxType(f.type, f.required)},`);
|
|
2328
|
+
}
|
|
2329
|
+
lines.push(` created_at: Type.String({ format: 'date-time' }),`);
|
|
2330
|
+
lines.push(` updated_at: Type.String({ format: 'date-time' }),`);
|
|
2331
|
+
if (config.softDelete) lines.push(` deleted_at: Type.Union([Type.String({ format: 'date-time' }), Type.Null()]),`);
|
|
2332
|
+
lines.push(`});`);
|
|
2333
|
+
lines.push("");
|
|
2334
|
+
lines.push(`export type ${className} = Static<typeof ${className}Schema>;`);
|
|
2335
|
+
lines.push("");
|
|
2336
|
+
lines.push(`export const Create${className}Schema = Type.Object({`);
|
|
2337
|
+
for (const f of config.fields) {
|
|
2338
|
+
if (f.required) {
|
|
2339
|
+
lines.push(` ${f.name}: ${typeboxType(f.type, true)},`);
|
|
2340
|
+
} else {
|
|
2341
|
+
lines.push(` ${f.name}: ${typeboxOptional(f.type)},`);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
lines.push(`});`);
|
|
2345
|
+
lines.push("");
|
|
2346
|
+
lines.push(`export type Create${className} = Static<typeof Create${className}Schema>;`);
|
|
2347
|
+
lines.push("");
|
|
2348
|
+
lines.push(`export const Update${className}Schema = Type.Object({`);
|
|
2349
|
+
for (const f of config.fields) {
|
|
2350
|
+
lines.push(` ${f.name}: ${typeboxOptional(f.type)},`);
|
|
2351
|
+
}
|
|
2352
|
+
lines.push(`});`);
|
|
2353
|
+
lines.push("");
|
|
2354
|
+
lines.push(`export type Update${className} = Static<typeof Update${className}Schema>;`);
|
|
2355
|
+
lines.push("");
|
|
2356
|
+
return lines.join("\n");
|
|
2357
|
+
}
|
|
2358
|
+
function generateFastifyIndex(config) {
|
|
2359
|
+
const className = toPascal(config.name);
|
|
2360
|
+
const camelConfig = className.charAt(0).toLowerCase() + className.slice(1) + "Config";
|
|
2361
|
+
const allColumns = ["id", ...config.fields.map((f) => f.name), "created_at", "updated_at"];
|
|
2362
|
+
if (config.softDelete) allColumns.push("deleted_at");
|
|
2363
|
+
const lines = [];
|
|
2364
|
+
lines.push(`import { EntityRegistry, type EntityConfig, type FieldMeta } from '../_base/index.js';`);
|
|
2365
|
+
lines.push(`import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`);
|
|
2366
|
+
lines.push("");
|
|
2367
|
+
lines.push(`const fields: FieldMeta[] = [`);
|
|
2368
|
+
lines.push(` { key: 'id', label: 'Id', type: 'str', nullable: false, is_auto: true, is_primary_key: true, filterable: true, has_foreign_key: false, field_type: 'text' },`);
|
|
2369
|
+
for (const f of config.fields) {
|
|
2370
|
+
const meta = fieldMetaType(f.type);
|
|
2371
|
+
lines.push(` { key: '${f.name}', label: '${toTitle(f.name)}', type: '${meta.type}', nullable: ${!f.required}, is_auto: false, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: '${meta.fieldType}' },`);
|
|
2372
|
+
}
|
|
2373
|
+
lines.push(` { key: 'created_at', label: 'Created At', type: 'datetime', nullable: false, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`);
|
|
2374
|
+
lines.push(` { key: 'updated_at', label: 'Updated At', type: 'datetime', nullable: false, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`);
|
|
2375
|
+
if (config.softDelete) {
|
|
2376
|
+
lines.push(` { key: 'deleted_at', label: 'Deleted At', type: 'datetime', nullable: true, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`);
|
|
2377
|
+
}
|
|
2378
|
+
lines.push(`];`);
|
|
2379
|
+
lines.push("");
|
|
2380
|
+
const tags = config.apiPrefix.replace(/^\//, "");
|
|
2381
|
+
lines.push(`export const ${camelConfig}: EntityConfig = {`);
|
|
2382
|
+
lines.push(` name: '${className}',`);
|
|
2383
|
+
lines.push(` tableName: '${config.tableName}',`);
|
|
2384
|
+
lines.push(` prismaModel: '${className}',`);
|
|
2385
|
+
lines.push(` apiPrefix: '${config.apiPrefix}',`);
|
|
2386
|
+
lines.push(` tags: ['${tags}'],`);
|
|
2387
|
+
lines.push(` readonly: ${config.readonly},`);
|
|
2388
|
+
lines.push(` softDelete: ${config.softDelete},`);
|
|
2389
|
+
lines.push(` bulkOperations: ${config.bulkOperations},`);
|
|
2390
|
+
lines.push(` columnNames: [${allColumns.map((c) => `'${c}'`).join(", ")}],`);
|
|
2391
|
+
if (config.searchableFields.length > 0) {
|
|
2392
|
+
lines.push(` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`);
|
|
2393
|
+
} else {
|
|
2394
|
+
lines.push(` searchableFields: [],`);
|
|
2395
|
+
}
|
|
2396
|
+
lines.push(` fields,`);
|
|
2397
|
+
lines.push(` schema: ${className}Schema,`);
|
|
2398
|
+
lines.push(` createSchema: Create${className}Schema,`);
|
|
2399
|
+
lines.push(` updateSchema: Update${className}Schema,`);
|
|
2400
|
+
lines.push(`};`);
|
|
2401
|
+
lines.push("");
|
|
2402
|
+
lines.push(`EntityRegistry.register(${camelConfig});`);
|
|
2403
|
+
lines.push("");
|
|
2404
|
+
return lines.join("\n");
|
|
2405
|
+
}
|
|
2406
|
+
function generatePrismaModel(config) {
|
|
2407
|
+
const className = toPascal(config.name);
|
|
2408
|
+
const lines = [];
|
|
2409
|
+
lines.push(`model ${className} {`);
|
|
2410
|
+
lines.push(` id String @id @default(uuid())`);
|
|
2411
|
+
for (const f of config.fields) {
|
|
2412
|
+
const padded = f.name.padEnd(10);
|
|
2413
|
+
lines.push(` ${padded} ${prismaType(f.type, f.required)}`);
|
|
2414
|
+
}
|
|
2415
|
+
if (config.softDelete) {
|
|
2416
|
+
lines.push(` deleted_at DateTime?`);
|
|
2417
|
+
}
|
|
2418
|
+
lines.push(` created_at DateTime @default(now())`);
|
|
2419
|
+
lines.push(` updated_at DateTime @updatedAt`);
|
|
2420
|
+
lines.push("");
|
|
2421
|
+
for (const sf of config.searchableFields) {
|
|
2422
|
+
lines.push(` @@index([${sf}])`);
|
|
2423
|
+
}
|
|
2424
|
+
lines.push(` @@map("${config.tableName}")`);
|
|
2425
|
+
lines.push(`}`);
|
|
2426
|
+
return lines.join("\n");
|
|
2427
|
+
}
|
|
2428
|
+
function tsType(type, required) {
|
|
2429
|
+
const base = (() => {
|
|
2430
|
+
switch (type) {
|
|
2431
|
+
case "string":
|
|
2432
|
+
case "text":
|
|
2433
|
+
case "date":
|
|
2434
|
+
case "datetime":
|
|
2435
|
+
return "string";
|
|
2436
|
+
case "number":
|
|
2437
|
+
return "number";
|
|
2438
|
+
case "boolean":
|
|
2439
|
+
return "boolean";
|
|
2440
|
+
case "json":
|
|
2441
|
+
return "Record<string, unknown>";
|
|
2442
|
+
}
|
|
2443
|
+
})();
|
|
2444
|
+
return required ? base : `${base} | null`;
|
|
2445
|
+
}
|
|
2446
|
+
function generateFrontendInterface(config) {
|
|
2447
|
+
const className = toPascal(config.name);
|
|
2448
|
+
const lines = [];
|
|
2449
|
+
lines.push(`export interface ${className} {`);
|
|
2450
|
+
lines.push(` id: string;`);
|
|
2451
|
+
for (const f of config.fields) {
|
|
2452
|
+
lines.push(` ${f.name}: ${tsType(f.type, f.required)};`);
|
|
2453
|
+
}
|
|
2454
|
+
if (config.softDelete) lines.push(` deleted_at: string | null;`);
|
|
2455
|
+
lines.push(` created_at: string;`);
|
|
2456
|
+
lines.push(` updated_at: string;`);
|
|
2457
|
+
lines.push(`}`);
|
|
2458
|
+
lines.push("");
|
|
2459
|
+
lines.push(`export interface Create${className} {`);
|
|
2460
|
+
for (const f of config.fields) {
|
|
2461
|
+
if (f.required) {
|
|
2462
|
+
lines.push(` ${f.name}: ${tsType(f.type, true)};`);
|
|
2463
|
+
} else {
|
|
2464
|
+
lines.push(` ${f.name}?: ${tsType(f.type, false)};`);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
lines.push(`}`);
|
|
2468
|
+
lines.push("");
|
|
2469
|
+
lines.push(`export interface Update${className} {`);
|
|
2470
|
+
for (const f of config.fields) {
|
|
2471
|
+
lines.push(` ${f.name}?: ${tsType(f.type, false)};`);
|
|
2472
|
+
}
|
|
2473
|
+
lines.push(`}`);
|
|
2474
|
+
lines.push("");
|
|
2475
|
+
return lines.join("\n");
|
|
2476
|
+
}
|
|
2477
|
+
function dartType(type, required) {
|
|
2478
|
+
const base = (() => {
|
|
2479
|
+
switch (type) {
|
|
2480
|
+
case "string":
|
|
2481
|
+
case "text":
|
|
2482
|
+
return "String";
|
|
2483
|
+
case "number":
|
|
2484
|
+
return "int";
|
|
2485
|
+
case "boolean":
|
|
2486
|
+
return "bool";
|
|
2487
|
+
case "date":
|
|
2488
|
+
case "datetime":
|
|
2489
|
+
return "DateTime";
|
|
2490
|
+
case "json":
|
|
2491
|
+
return "Map<String, dynamic>";
|
|
2492
|
+
}
|
|
2493
|
+
})();
|
|
2494
|
+
return required ? base : `${base}?`;
|
|
2495
|
+
}
|
|
2496
|
+
function toCamel(s) {
|
|
2497
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
2498
|
+
}
|
|
2499
|
+
function dartFromJson(fieldName, type, required) {
|
|
2500
|
+
const key = `json['${fieldName}']`;
|
|
2501
|
+
const isDate = type === "date" || type === "datetime";
|
|
2502
|
+
if (isDate && required) return `DateTime.parse(${key} as String)`;
|
|
2503
|
+
if (isDate && !required) return `${key} != null ? DateTime.parse(${key} as String) : null`;
|
|
2504
|
+
if (type === "json" && !required) return `${key} as Map<String, dynamic>?`;
|
|
2505
|
+
if (type === "json") return `${key} as Map<String, dynamic>`;
|
|
2506
|
+
const dartT = (() => {
|
|
2507
|
+
switch (type) {
|
|
2508
|
+
case "string":
|
|
2509
|
+
case "text":
|
|
2510
|
+
return "String";
|
|
2511
|
+
case "number":
|
|
2512
|
+
return "int";
|
|
2513
|
+
case "boolean":
|
|
2514
|
+
return "bool";
|
|
2515
|
+
default:
|
|
2516
|
+
return "String";
|
|
2517
|
+
}
|
|
2518
|
+
})();
|
|
2519
|
+
return required ? `${key} as ${dartT}` : `${key} as ${dartT}?`;
|
|
2520
|
+
}
|
|
2521
|
+
function dartToJson(fieldName, camelName, type) {
|
|
2522
|
+
const isDate = type === "date" || type === "datetime";
|
|
2523
|
+
if (isDate) return `'${fieldName}': ${camelName}?.toIso8601String()`;
|
|
2524
|
+
return `'${fieldName}': ${camelName}`;
|
|
2525
|
+
}
|
|
2526
|
+
function generateDartModel(config) {
|
|
2527
|
+
const className = toPascal(config.name);
|
|
2528
|
+
const allFields = [
|
|
2529
|
+
{ snake: "id", camel: "id", type: "String", required: true, fieldType: "string" },
|
|
2530
|
+
...config.fields.map((f) => ({
|
|
2531
|
+
snake: f.name,
|
|
2532
|
+
camel: toCamel(f.name),
|
|
2533
|
+
type: dartType(f.type, f.required),
|
|
2534
|
+
required: f.required,
|
|
2535
|
+
fieldType: f.type
|
|
2536
|
+
}))
|
|
2537
|
+
];
|
|
2538
|
+
if (config.softDelete) {
|
|
2539
|
+
allFields.push({ snake: "deleted_at", camel: "deletedAt", type: "DateTime?", required: false, fieldType: "datetime" });
|
|
2540
|
+
}
|
|
2541
|
+
allFields.push(
|
|
2542
|
+
{ snake: "created_at", camel: "createdAt", type: "DateTime", required: true, fieldType: "datetime" },
|
|
2543
|
+
{ snake: "updated_at", camel: "updatedAt", type: "DateTime", required: true, fieldType: "datetime" }
|
|
2544
|
+
);
|
|
2545
|
+
const lines = [];
|
|
2546
|
+
lines.push(`class ${className} {`);
|
|
2547
|
+
for (const f of allFields) {
|
|
2548
|
+
lines.push(` final ${f.type} ${f.camel};`);
|
|
2549
|
+
}
|
|
2550
|
+
lines.push("");
|
|
2551
|
+
lines.push(` const ${className}({`);
|
|
2552
|
+
for (const f of allFields) {
|
|
2553
|
+
if (f.required) {
|
|
2554
|
+
lines.push(` required this.${f.camel},`);
|
|
2555
|
+
} else {
|
|
2556
|
+
lines.push(` this.${f.camel},`);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
lines.push(` });`);
|
|
2560
|
+
lines.push("");
|
|
2561
|
+
lines.push(` factory ${className}.fromJson(Map<String, dynamic> json) {`);
|
|
2562
|
+
lines.push(` return ${className}(`);
|
|
2563
|
+
for (const f of allFields) {
|
|
2564
|
+
lines.push(` ${f.camel}: ${dartFromJson(f.snake, f.fieldType, f.required)},`);
|
|
2565
|
+
}
|
|
2566
|
+
lines.push(` );`);
|
|
2567
|
+
lines.push(` }`);
|
|
2568
|
+
lines.push("");
|
|
2569
|
+
lines.push(` Map<String, dynamic> toJson() {`);
|
|
2570
|
+
lines.push(` return {`);
|
|
2571
|
+
for (const f of allFields) {
|
|
2572
|
+
lines.push(` ${dartToJson(f.snake, f.camel, f.fieldType)},`);
|
|
2573
|
+
}
|
|
2574
|
+
lines.push(` };`);
|
|
2575
|
+
lines.push(` }`);
|
|
2576
|
+
lines.push("");
|
|
2577
|
+
lines.push(` ${className} copyWith({`);
|
|
2578
|
+
for (const f of allFields) {
|
|
2579
|
+
lines.push(` ${f.type.replace("?", "")}? ${f.camel},`);
|
|
2580
|
+
}
|
|
2581
|
+
lines.push(` }) {`);
|
|
2582
|
+
lines.push(` return ${className}(`);
|
|
2583
|
+
for (const f of allFields) {
|
|
2584
|
+
lines.push(` ${f.camel}: ${f.camel} ?? this.${f.camel},`);
|
|
2585
|
+
}
|
|
2586
|
+
lines.push(` );`);
|
|
2587
|
+
lines.push(` }`);
|
|
2588
|
+
lines.push(`}`);
|
|
2589
|
+
lines.push("");
|
|
2590
|
+
return lines.join("\n");
|
|
2591
|
+
}
|
|
2592
|
+
async function gen(cwd, entityName, fieldsFlag) {
|
|
2593
|
+
p9.intro(`projx gen entity ${entityName}`);
|
|
2594
|
+
const configPath = join12(cwd, ".projx");
|
|
2595
|
+
if (!existsSync11(configPath)) {
|
|
2596
|
+
p9.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
2597
|
+
process.exit(1);
|
|
2598
|
+
}
|
|
2599
|
+
const projxConfig = JSON.parse(await readFile11(configPath, "utf-8"));
|
|
2600
|
+
const componentPaths = await discoverComponentPaths(cwd, projxConfig.components);
|
|
2601
|
+
const hasFastapi = projxConfig.components.includes("fastapi");
|
|
2602
|
+
const hasFastify = projxConfig.components.includes("fastify");
|
|
2603
|
+
const hasFrontend = projxConfig.components.includes("frontend");
|
|
2604
|
+
const hasMobile = projxConfig.components.includes("mobile");
|
|
2605
|
+
if (!hasFastapi && !hasFastify) {
|
|
2606
|
+
p9.log.error("No backend component found. Need fastapi or fastify.");
|
|
2607
|
+
process.exit(1);
|
|
2608
|
+
}
|
|
2609
|
+
let config;
|
|
2610
|
+
if (fieldsFlag) {
|
|
2611
|
+
const fields = parseFieldsFlag(fieldsFlag);
|
|
2612
|
+
const snake = toSnake(entityName);
|
|
2613
|
+
const tableName = pluralize(snake);
|
|
2614
|
+
const kebab = toKebab(entityName);
|
|
2615
|
+
config = {
|
|
2616
|
+
name: entityName,
|
|
2617
|
+
tableName,
|
|
2618
|
+
apiPrefix: "/" + pluralize(kebab),
|
|
2619
|
+
readonly: false,
|
|
2620
|
+
softDelete: false,
|
|
2621
|
+
bulkOperations: true,
|
|
2622
|
+
fields,
|
|
2623
|
+
searchableFields: fields.filter((f) => f.type === "string" || f.type === "text").map((f) => f.name)
|
|
2624
|
+
};
|
|
2625
|
+
} else {
|
|
2626
|
+
config = await promptEntityConfig(entityName);
|
|
2627
|
+
}
|
|
2628
|
+
const generated = [];
|
|
2629
|
+
if (hasFastapi) {
|
|
2630
|
+
const dir = componentPaths.fastapi;
|
|
2631
|
+
const entityDir = join12(cwd, dir, "src/entities", toSnake(config.name));
|
|
2632
|
+
if (existsSync11(entityDir)) {
|
|
2633
|
+
p9.log.warn(`${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`);
|
|
2634
|
+
} else {
|
|
2635
|
+
await mkdir5(entityDir, { recursive: true });
|
|
2636
|
+
await writeFile5(join12(entityDir, "_model.py"), generateFastAPIModel(config));
|
|
2637
|
+
generated.push(`${dir}/src/entities/${toSnake(config.name)}/_model.py`);
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
if (hasFastify) {
|
|
2641
|
+
const dir = componentPaths.fastify;
|
|
2642
|
+
const moduleDir = join12(cwd, dir, "src/modules", toKebab(config.name));
|
|
2643
|
+
if (existsSync11(moduleDir)) {
|
|
2644
|
+
p9.log.warn(`${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`);
|
|
2645
|
+
} else {
|
|
2646
|
+
await mkdir5(moduleDir, { recursive: true });
|
|
2647
|
+
await writeFile5(join12(moduleDir, "schemas.ts"), generateFastifySchemas(config));
|
|
2648
|
+
await writeFile5(join12(moduleDir, "index.ts"), generateFastifyIndex(config));
|
|
2649
|
+
generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
|
|
2650
|
+
generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
|
|
2651
|
+
const appPath = join12(cwd, dir, "src/app.ts");
|
|
2652
|
+
if (existsSync11(appPath)) {
|
|
2653
|
+
const appContent = await readFile11(appPath, "utf-8");
|
|
2654
|
+
const importLine = `import './modules/${toKebab(config.name)}/index.js';`;
|
|
2655
|
+
if (!appContent.includes(importLine)) {
|
|
2656
|
+
const updated = appContent.replace(
|
|
2657
|
+
/^(import\s+'\.\/modules\/.*?';?\s*\n)/m,
|
|
2658
|
+
`$1${importLine}
|
|
2659
|
+
`
|
|
2660
|
+
);
|
|
2661
|
+
if (updated !== appContent) {
|
|
2662
|
+
await writeFile5(appPath, updated);
|
|
2663
|
+
generated.push(`${dir}/src/app.ts (import added)`);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
const prismaPath = join12(cwd, dir, "prisma/schema.prisma");
|
|
2668
|
+
if (existsSync11(prismaPath)) {
|
|
2669
|
+
const prismaContent = await readFile11(prismaPath, "utf-8");
|
|
2670
|
+
const modelName = `model ${toPascal(config.name)}`;
|
|
2671
|
+
if (!prismaContent.includes(modelName)) {
|
|
2672
|
+
const prismaModel = generatePrismaModel(config);
|
|
2673
|
+
await writeFile5(prismaPath, prismaContent.trimEnd() + "\n\n" + prismaModel + "\n");
|
|
2674
|
+
generated.push(`${dir}/prisma/schema.prisma (model added)`);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
if (hasFrontend) {
|
|
2680
|
+
const dir = componentPaths.frontend;
|
|
2681
|
+
const typesDir = join12(cwd, dir, "src/types");
|
|
2682
|
+
const fileName = toKebab(config.name) + ".ts";
|
|
2683
|
+
const filePath = join12(typesDir, fileName);
|
|
2684
|
+
if (existsSync11(filePath)) {
|
|
2685
|
+
p9.log.warn(`${dir}/src/types/${fileName} already exists. Skipping frontend types.`);
|
|
2686
|
+
} else {
|
|
2687
|
+
await mkdir5(typesDir, { recursive: true });
|
|
2688
|
+
await writeFile5(filePath, generateFrontendInterface(config));
|
|
2689
|
+
generated.push(`${dir}/src/types/${fileName}`);
|
|
2690
|
+
const barrelPath = join12(typesDir, "index.ts");
|
|
2691
|
+
const exportLine = `export * from './${toKebab(config.name)}';`;
|
|
2692
|
+
if (existsSync11(barrelPath)) {
|
|
2693
|
+
const content = await readFile11(barrelPath, "utf-8");
|
|
2694
|
+
if (!content.includes(exportLine)) {
|
|
2695
|
+
await writeFile5(barrelPath, content.trimEnd() + "\n" + exportLine + "\n");
|
|
2696
|
+
}
|
|
2697
|
+
} else {
|
|
2698
|
+
await writeFile5(barrelPath, exportLine + "\n");
|
|
2699
|
+
}
|
|
2700
|
+
generated.push(`${dir}/src/types/index.ts`);
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
if (hasMobile) {
|
|
2704
|
+
const dir = componentPaths.mobile;
|
|
2705
|
+
const entityDir = join12(cwd, dir, "lib/entities", toSnake(config.name));
|
|
2706
|
+
const modelPath = join12(entityDir, "model.dart");
|
|
2707
|
+
if (existsSync11(modelPath)) {
|
|
2708
|
+
p9.log.warn(`${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`);
|
|
2709
|
+
} else {
|
|
2710
|
+
await mkdir5(entityDir, { recursive: true });
|
|
2711
|
+
await writeFile5(modelPath, generateDartModel(config));
|
|
2712
|
+
generated.push(`${dir}/lib/entities/${toSnake(config.name)}/model.dart`);
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
if (generated.length === 0) {
|
|
2716
|
+
p9.log.warn("Nothing generated.");
|
|
2717
|
+
p9.outro("");
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
p9.log.success("Generated:");
|
|
2721
|
+
for (const f of generated) {
|
|
2722
|
+
p9.log.info(` ${f}`);
|
|
2723
|
+
}
|
|
2724
|
+
const className = toPascal(config.name);
|
|
2725
|
+
if (hasFastapi) {
|
|
2726
|
+
p9.log.info("");
|
|
2727
|
+
p9.log.info("FastAPI next steps:");
|
|
2728
|
+
p9.log.info(` alembic revision --autogenerate -m "add ${config.tableName}"`);
|
|
2729
|
+
p9.log.info(" alembic upgrade head");
|
|
2730
|
+
}
|
|
2731
|
+
if (hasFastify) {
|
|
2732
|
+
p9.log.info("");
|
|
2733
|
+
p9.log.info("Fastify next steps:");
|
|
2734
|
+
p9.log.info(` npx prisma migrate dev --name add_${toSnake(config.name)}`);
|
|
2735
|
+
}
|
|
2736
|
+
if (hasFrontend) {
|
|
2737
|
+
p9.log.info("");
|
|
2738
|
+
p9.log.info("Frontend usage:");
|
|
2739
|
+
p9.log.info(` import type { ${className} } from '../types/${toKebab(config.name)}';`);
|
|
2740
|
+
p9.log.info(` const { data } = await api.list<${className}>('${config.apiPrefix}');`);
|
|
2741
|
+
}
|
|
2742
|
+
if (hasMobile) {
|
|
2743
|
+
p9.log.info("");
|
|
2744
|
+
p9.log.info("Mobile usage:");
|
|
2745
|
+
p9.log.info(` final item = ${className}.fromJson(json);`);
|
|
2746
|
+
}
|
|
2747
|
+
p9.outro(`Entity ${className} created.`);
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// src/sync.ts
|
|
2751
|
+
import { existsSync as existsSync12, readFileSync as readFileSync2 } from "fs";
|
|
2752
|
+
import { readFile as readFile12, writeFile as writeFile6, mkdir as mkdir6 } from "fs/promises";
|
|
2753
|
+
import { join as join13 } from "path";
|
|
2754
|
+
import * as p10 from "@clack/prompts";
|
|
2755
|
+
function toPascal2(s) {
|
|
2756
|
+
return s.replace(/(?:^|[_\-\s])([a-zA-Z])/g, (_, c) => c.toUpperCase());
|
|
2757
|
+
}
|
|
2758
|
+
function toCamel2(s) {
|
|
2759
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
2760
|
+
}
|
|
2761
|
+
function metaTypeToTs(type, fieldType, nullable) {
|
|
2762
|
+
const base = (() => {
|
|
2763
|
+
switch (type) {
|
|
2764
|
+
case "str":
|
|
2765
|
+
return "string";
|
|
2766
|
+
case "int":
|
|
2767
|
+
case "float":
|
|
2768
|
+
return "number";
|
|
2769
|
+
case "bool":
|
|
2770
|
+
return "boolean";
|
|
2771
|
+
case "datetime":
|
|
2772
|
+
case "date":
|
|
2773
|
+
return "string";
|
|
2774
|
+
case "dict":
|
|
2775
|
+
return "Record<string, unknown>";
|
|
2776
|
+
default:
|
|
2777
|
+
return "unknown";
|
|
2778
|
+
}
|
|
2779
|
+
})();
|
|
2780
|
+
return nullable ? `${base} | null` : base;
|
|
2781
|
+
}
|
|
2782
|
+
function metaTypeToDart(type, nullable) {
|
|
2783
|
+
const base = (() => {
|
|
2784
|
+
switch (type) {
|
|
2785
|
+
case "str":
|
|
2786
|
+
return "String";
|
|
2787
|
+
case "int":
|
|
2788
|
+
return "int";
|
|
2789
|
+
case "float":
|
|
2790
|
+
return "double";
|
|
2791
|
+
case "bool":
|
|
2792
|
+
return "bool";
|
|
2793
|
+
case "datetime":
|
|
2794
|
+
case "date":
|
|
2795
|
+
return "DateTime";
|
|
2796
|
+
case "dict":
|
|
2797
|
+
return "Map<String, dynamic>";
|
|
2798
|
+
default:
|
|
2799
|
+
return "dynamic";
|
|
2800
|
+
}
|
|
2801
|
+
})();
|
|
2802
|
+
return nullable ? `${base}?` : base;
|
|
2803
|
+
}
|
|
2804
|
+
function dartFromJsonExpr(key, type, nullable) {
|
|
2805
|
+
const accessor = `json['${key}']`;
|
|
2806
|
+
const isDate = type === "datetime" || type === "date";
|
|
2807
|
+
if (isDate && nullable)
|
|
2808
|
+
return `${accessor} != null ? DateTime.parse(${accessor} as String) : null`;
|
|
2809
|
+
if (isDate) return `DateTime.parse(${accessor} as String)`;
|
|
2810
|
+
if (type === "dict" && nullable)
|
|
2811
|
+
return `${accessor} as Map<String, dynamic>?`;
|
|
2812
|
+
if (type === "dict") return `${accessor} as Map<String, dynamic>`;
|
|
2813
|
+
const dartT = (() => {
|
|
2814
|
+
switch (type) {
|
|
2815
|
+
case "str":
|
|
2816
|
+
return "String";
|
|
2817
|
+
case "int":
|
|
2818
|
+
return "int";
|
|
2819
|
+
case "float":
|
|
2820
|
+
return "double";
|
|
2821
|
+
case "bool":
|
|
2822
|
+
return "bool";
|
|
2823
|
+
default:
|
|
2824
|
+
return "dynamic";
|
|
2825
|
+
}
|
|
2826
|
+
})();
|
|
2827
|
+
return nullable ? `${accessor} as ${dartT}?` : `${accessor} as ${dartT}`;
|
|
2828
|
+
}
|
|
2829
|
+
function dartToJsonExpr(key, camel, type) {
|
|
2830
|
+
const isDate = type === "datetime" || type === "date";
|
|
2831
|
+
if (isDate) return `'${key}': ${camel}?.toIso8601String()`;
|
|
2832
|
+
return `'${key}': ${camel}`;
|
|
2833
|
+
}
|
|
2834
|
+
function generateTsInterface(entity) {
|
|
2835
|
+
const className = toPascal2(entity.name);
|
|
2836
|
+
const lines = [];
|
|
2837
|
+
lines.push(`export interface ${className} {`);
|
|
2838
|
+
for (const f of entity.fields) {
|
|
2839
|
+
lines.push(
|
|
2840
|
+
` ${f.key}: ${metaTypeToTs(f.type, f.field_type, f.nullable)};`
|
|
2841
|
+
);
|
|
2842
|
+
}
|
|
2843
|
+
lines.push(`}`);
|
|
2844
|
+
lines.push("");
|
|
2845
|
+
const createFields = entity.fields.filter((f) => f.in_create);
|
|
2846
|
+
lines.push(`export interface Create${className} {`);
|
|
2847
|
+
for (const f of createFields) {
|
|
2848
|
+
const optional = f.nullable ? "?" : "";
|
|
2849
|
+
lines.push(
|
|
2850
|
+
` ${f.key}${optional}: ${metaTypeToTs(f.type, f.field_type, f.nullable)};`
|
|
2851
|
+
);
|
|
2852
|
+
}
|
|
2853
|
+
lines.push(`}`);
|
|
2854
|
+
lines.push("");
|
|
2855
|
+
const updateFields = entity.fields.filter((f) => f.in_update);
|
|
2856
|
+
lines.push(`export interface Update${className} {`);
|
|
2857
|
+
for (const f of updateFields) {
|
|
2858
|
+
lines.push(` ${f.key}?: ${metaTypeToTs(f.type, f.field_type, true)};`);
|
|
2859
|
+
}
|
|
2860
|
+
lines.push(`}`);
|
|
2861
|
+
lines.push("");
|
|
2862
|
+
return lines.join("\n");
|
|
2863
|
+
}
|
|
2864
|
+
function generateDartModel2(entity) {
|
|
2865
|
+
const className = toPascal2(entity.name);
|
|
2866
|
+
const lines = [];
|
|
2867
|
+
const fields = entity.fields.map((f) => ({
|
|
2868
|
+
snake: f.key,
|
|
2869
|
+
camel: toCamel2(f.key),
|
|
2870
|
+
type: metaTypeToDart(f.type, f.nullable),
|
|
2871
|
+
nullable: f.nullable,
|
|
2872
|
+
metaType: f.type
|
|
2873
|
+
}));
|
|
2874
|
+
lines.push(`class ${className} {`);
|
|
2875
|
+
for (const f of fields) {
|
|
2876
|
+
lines.push(` final ${f.type} ${f.camel};`);
|
|
2877
|
+
}
|
|
2878
|
+
lines.push("");
|
|
2879
|
+
lines.push(` const ${className}({`);
|
|
2880
|
+
for (const f of fields) {
|
|
2881
|
+
if (f.nullable) {
|
|
2882
|
+
lines.push(` this.${f.camel},`);
|
|
2883
|
+
} else {
|
|
2884
|
+
lines.push(` required this.${f.camel},`);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
lines.push(` });`);
|
|
2888
|
+
lines.push("");
|
|
2889
|
+
lines.push(` factory ${className}.fromJson(Map<String, dynamic> json) {`);
|
|
2890
|
+
lines.push(` return ${className}(`);
|
|
2891
|
+
for (const f of fields) {
|
|
2892
|
+
lines.push(
|
|
2893
|
+
` ${f.camel}: ${dartFromJsonExpr(f.snake, f.metaType, f.nullable)},`
|
|
2894
|
+
);
|
|
2895
|
+
}
|
|
2896
|
+
lines.push(` );`);
|
|
2897
|
+
lines.push(` }`);
|
|
2898
|
+
lines.push("");
|
|
2899
|
+
lines.push(` Map<String, dynamic> toJson() {`);
|
|
2900
|
+
lines.push(` return {`);
|
|
2901
|
+
for (const f of fields) {
|
|
2902
|
+
lines.push(` ${dartToJsonExpr(f.snake, f.camel, f.metaType)},`);
|
|
2903
|
+
}
|
|
2904
|
+
lines.push(` };`);
|
|
2905
|
+
lines.push(` }`);
|
|
2906
|
+
lines.push("");
|
|
2907
|
+
lines.push(` ${className} copyWith({`);
|
|
2908
|
+
for (const f of fields) {
|
|
2909
|
+
lines.push(` ${f.type.replace("?", "")}? ${f.camel},`);
|
|
2910
|
+
}
|
|
2911
|
+
lines.push(` }) {`);
|
|
2912
|
+
lines.push(` return ${className}(`);
|
|
2913
|
+
for (const f of fields) {
|
|
2914
|
+
lines.push(` ${f.camel}: ${f.camel} ?? this.${f.camel},`);
|
|
2915
|
+
}
|
|
2916
|
+
lines.push(` );`);
|
|
2917
|
+
lines.push(` }`);
|
|
2918
|
+
lines.push(`}`);
|
|
2919
|
+
lines.push("");
|
|
2920
|
+
return lines.join("\n");
|
|
2921
|
+
}
|
|
2922
|
+
async function sync(cwd, url) {
|
|
2923
|
+
p10.intro("projx sync");
|
|
2924
|
+
const configPath = join13(cwd, ".projx");
|
|
2925
|
+
if (!existsSync12(configPath)) {
|
|
2926
|
+
p10.log.error("No .projx file found. Run 'npx create-projx init' first.");
|
|
2927
|
+
process.exit(1);
|
|
2928
|
+
}
|
|
2929
|
+
const projxConfig = JSON.parse(
|
|
2930
|
+
await readFile12(configPath, "utf-8")
|
|
2931
|
+
);
|
|
2932
|
+
const componentPaths = await discoverComponentPaths(
|
|
2933
|
+
cwd,
|
|
2934
|
+
projxConfig.components
|
|
2935
|
+
);
|
|
2936
|
+
const hasFrontend = projxConfig.components.includes("frontend");
|
|
2937
|
+
const hasMobile = projxConfig.components.includes("mobile");
|
|
2938
|
+
if (!hasFrontend && !hasMobile) {
|
|
2939
|
+
p10.log.error("No frontend or mobile component found. Nothing to sync.");
|
|
2940
|
+
process.exit(1);
|
|
2941
|
+
}
|
|
2942
|
+
const metaUrl = url || detectMetaUrl(cwd);
|
|
2943
|
+
const spinner7 = p10.spinner();
|
|
2944
|
+
spinner7.start(`Fetching metadata from ${metaUrl}`);
|
|
2945
|
+
let meta;
|
|
2946
|
+
try {
|
|
2947
|
+
const res = await fetch(metaUrl);
|
|
2948
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
2949
|
+
meta = await res.json();
|
|
2950
|
+
} catch (err) {
|
|
2951
|
+
spinner7.stop("Failed.");
|
|
2952
|
+
p10.log.error(`Could not fetch ${metaUrl}: ${err}`);
|
|
2953
|
+
p10.log.info("Make sure your backend is running.");
|
|
2954
|
+
p10.log.info(
|
|
2955
|
+
"Or specify URL: projx sync --url http://localhost:8000/api/v1/_meta"
|
|
2956
|
+
);
|
|
2957
|
+
process.exit(1);
|
|
2958
|
+
}
|
|
2959
|
+
spinner7.stop(`Fetched ${meta.entities.length} entity(s).`);
|
|
2960
|
+
const generated = [];
|
|
2961
|
+
if (hasFrontend) {
|
|
2962
|
+
const dir = componentPaths.frontend;
|
|
2963
|
+
const typesDir = join13(cwd, dir, "src/types");
|
|
2964
|
+
await mkdir6(typesDir, { recursive: true });
|
|
2965
|
+
const barrelExports = [];
|
|
2966
|
+
for (const entity of meta.entities) {
|
|
2967
|
+
const fileName = toKebab(toSnake(entity.name)) + ".ts";
|
|
2968
|
+
const filePath = join13(typesDir, fileName);
|
|
2969
|
+
await writeFile6(filePath, generateTsInterface(entity));
|
|
2970
|
+
generated.push(`${dir}/src/types/${fileName}`);
|
|
2971
|
+
barrelExports.push(`export * from './${toKebab(toSnake(entity.name))}';`);
|
|
2972
|
+
}
|
|
2973
|
+
await writeFile6(
|
|
2974
|
+
join13(typesDir, "index.ts"),
|
|
2975
|
+
barrelExports.join("\n") + "\n"
|
|
2976
|
+
);
|
|
2977
|
+
generated.push(`${dir}/src/types/index.ts`);
|
|
2978
|
+
}
|
|
2979
|
+
if (hasMobile) {
|
|
2980
|
+
const dir = componentPaths.mobile;
|
|
2981
|
+
for (const entity of meta.entities) {
|
|
2982
|
+
const entityDir = join13(cwd, dir, "lib/entities", toSnake(entity.name));
|
|
2983
|
+
await mkdir6(entityDir, { recursive: true });
|
|
2984
|
+
const modelPath = join13(entityDir, "model.dart");
|
|
2985
|
+
await writeFile6(modelPath, generateDartModel2(entity));
|
|
2986
|
+
generated.push(`${dir}/lib/entities/${toSnake(entity.name)}/model.dart`);
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
p10.log.success(`Synced ${meta.entities.length} entity(s):`);
|
|
2990
|
+
for (const f of generated) {
|
|
2991
|
+
p10.log.info(` ${f}`);
|
|
2992
|
+
}
|
|
2993
|
+
if (hasFrontend) {
|
|
2994
|
+
p10.log.info("");
|
|
2995
|
+
p10.log.info("Frontend usage:");
|
|
2996
|
+
for (const entity of meta.entities) {
|
|
2997
|
+
const className = toPascal2(entity.name);
|
|
2998
|
+
p10.log.info(
|
|
2999
|
+
` import type { ${className} } from '../types/${toKebab(toSnake(entity.name))}';`
|
|
3000
|
+
);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
p10.outro("Types are up to date.");
|
|
3004
|
+
}
|
|
3005
|
+
function detectMetaUrl(cwd) {
|
|
3006
|
+
const envFiles = [".env", ".env.dev", ".env.local"];
|
|
3007
|
+
for (const envFile of envFiles) {
|
|
3008
|
+
const envPath = join13(cwd, envFile);
|
|
3009
|
+
if (existsSync12(envPath)) {
|
|
3010
|
+
try {
|
|
3011
|
+
const content = readFileSync2(envPath, "utf-8");
|
|
3012
|
+
const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
|
|
3013
|
+
if (match) {
|
|
3014
|
+
const base = match[1].trim().replace(/["']/g, "");
|
|
3015
|
+
return `${base}/api/v1/_meta`;
|
|
3016
|
+
}
|
|
3017
|
+
} catch {
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
const frontendEnvFiles = [
|
|
3022
|
+
"frontend/.env",
|
|
3023
|
+
"frontend/.env.local",
|
|
3024
|
+
"frontend/.env.dev"
|
|
3025
|
+
];
|
|
3026
|
+
for (const envFile of frontendEnvFiles) {
|
|
3027
|
+
const envPath = join13(cwd, envFile);
|
|
3028
|
+
if (existsSync12(envPath)) {
|
|
3029
|
+
try {
|
|
3030
|
+
const content = readFileSync2(envPath, "utf-8");
|
|
3031
|
+
const match = content.match(/VITE_API_URL\s*=\s*(.+)/);
|
|
3032
|
+
if (match) {
|
|
3033
|
+
const base = match[1].trim().replace(/["']/g, "");
|
|
3034
|
+
return `${base}/api/v1/_meta`;
|
|
3035
|
+
}
|
|
3036
|
+
} catch {
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
return "http://localhost:8000/api/v1/_meta";
|
|
3041
|
+
}
|
|
3042
|
+
|
|
1464
3043
|
// src/index.ts
|
|
1465
3044
|
var args = process.argv.slice(2);
|
|
1466
3045
|
function parseArgs() {
|
|
@@ -1469,6 +3048,7 @@ function parseArgs() {
|
|
|
1469
3048
|
let localRepo;
|
|
1470
3049
|
const options = {};
|
|
1471
3050
|
const extraArgs = [];
|
|
3051
|
+
const flags = {};
|
|
1472
3052
|
for (let i = 0; i < args.length; i++) {
|
|
1473
3053
|
const arg = args[i];
|
|
1474
3054
|
if (arg === "update" && !name) {
|
|
@@ -1483,6 +3063,30 @@ function parseArgs() {
|
|
|
1483
3063
|
command = "init";
|
|
1484
3064
|
continue;
|
|
1485
3065
|
}
|
|
3066
|
+
if (arg === "pin" && !name) {
|
|
3067
|
+
command = "pin";
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
if (arg === "unpin" && !name) {
|
|
3071
|
+
command = "unpin";
|
|
3072
|
+
continue;
|
|
3073
|
+
}
|
|
3074
|
+
if (arg === "diff" && !name) {
|
|
3075
|
+
command = "diff";
|
|
3076
|
+
continue;
|
|
3077
|
+
}
|
|
3078
|
+
if (arg === "doctor" && !name) {
|
|
3079
|
+
command = "doctor";
|
|
3080
|
+
continue;
|
|
3081
|
+
}
|
|
3082
|
+
if (arg === "gen" && !name) {
|
|
3083
|
+
command = "gen";
|
|
3084
|
+
continue;
|
|
3085
|
+
}
|
|
3086
|
+
if (arg === "sync" && !name) {
|
|
3087
|
+
command = "sync";
|
|
3088
|
+
continue;
|
|
3089
|
+
}
|
|
1486
3090
|
if (arg === "--components") {
|
|
1487
3091
|
const val = args[++i];
|
|
1488
3092
|
if (val) {
|
|
@@ -1508,19 +3112,37 @@ function parseArgs() {
|
|
|
1508
3112
|
options.components = options.components ?? ["fastify", "frontend", "e2e"];
|
|
1509
3113
|
continue;
|
|
1510
3114
|
}
|
|
3115
|
+
if (arg === "--list" || arg === "-l") {
|
|
3116
|
+
flags.list = true;
|
|
3117
|
+
continue;
|
|
3118
|
+
}
|
|
3119
|
+
if (arg === "--fix") {
|
|
3120
|
+
flags.fix = true;
|
|
3121
|
+
continue;
|
|
3122
|
+
}
|
|
3123
|
+
if (arg === "--url") {
|
|
3124
|
+
const val = args[++i];
|
|
3125
|
+
if (val) extraArgs.push(`--url=${val}`);
|
|
3126
|
+
continue;
|
|
3127
|
+
}
|
|
1511
3128
|
if (arg === "--help" || arg === "-h") {
|
|
1512
3129
|
printHelp();
|
|
1513
3130
|
process.exit(0);
|
|
1514
3131
|
}
|
|
3132
|
+
if (arg === "--fields") {
|
|
3133
|
+
const val = args[++i];
|
|
3134
|
+
if (val) extraArgs.push(`--fields=${val}`);
|
|
3135
|
+
continue;
|
|
3136
|
+
}
|
|
1515
3137
|
if (!arg.startsWith("-")) {
|
|
1516
|
-
if (command === "add") {
|
|
3138
|
+
if (command === "add" || command === "pin" || command === "unpin" || command === "gen") {
|
|
1517
3139
|
extraArgs.push(arg);
|
|
1518
3140
|
} else if (!name) {
|
|
1519
3141
|
name = arg;
|
|
1520
3142
|
}
|
|
1521
3143
|
}
|
|
1522
3144
|
}
|
|
1523
|
-
return { command, name, options, localRepo, extraArgs };
|
|
3145
|
+
return { command, name, options, localRepo, extraArgs, flags };
|
|
1524
3146
|
}
|
|
1525
3147
|
function printHelp() {
|
|
1526
3148
|
console.log(`
|
|
@@ -1529,6 +3151,13 @@ function printHelp() {
|
|
|
1529
3151
|
projx init Adopt existing project into projx
|
|
1530
3152
|
projx add <components...> Add components to existing project
|
|
1531
3153
|
projx update Update scaffolding to latest
|
|
3154
|
+
projx diff Preview what update would change
|
|
3155
|
+
projx pin <patterns...> Skip files on future updates
|
|
3156
|
+
projx unpin <patterns...> Remove files from skip list
|
|
3157
|
+
projx pin --list Show all skip patterns
|
|
3158
|
+
projx doctor [--fix] Health check for projx project
|
|
3159
|
+
projx gen entity <name> Generate a new entity
|
|
3160
|
+
projx sync [--url <url>] Sync types from running backend
|
|
1532
3161
|
|
|
1533
3162
|
Options:
|
|
1534
3163
|
--components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
|
|
@@ -1544,10 +3173,15 @@ function printHelp() {
|
|
|
1544
3173
|
npx create-projx my-app -y
|
|
1545
3174
|
npx create-projx add frontend mobile
|
|
1546
3175
|
npx create-projx@latest update
|
|
3176
|
+
npx create-projx diff
|
|
3177
|
+
npx create-projx pin backend/pyproject.toml
|
|
3178
|
+
npx create-projx doctor --fix
|
|
3179
|
+
npx create-projx gen entity invoice
|
|
3180
|
+
npx create-projx gen entity invoice --fields "name:string,amount:number,status:string"
|
|
1547
3181
|
`);
|
|
1548
3182
|
}
|
|
1549
3183
|
async function main() {
|
|
1550
|
-
const { command, name, options, localRepo, extraArgs } = parseArgs();
|
|
3184
|
+
const { command, name, options, localRepo, extraArgs, flags } = parseArgs();
|
|
1551
3185
|
if (command === "init") {
|
|
1552
3186
|
await init(process.cwd(), localRepo);
|
|
1553
3187
|
return;
|
|
@@ -1567,6 +3201,48 @@ async function main() {
|
|
|
1567
3201
|
await add(process.cwd(), components, localRepo, options.install === false);
|
|
1568
3202
|
return;
|
|
1569
3203
|
}
|
|
3204
|
+
if (command === "pin") {
|
|
3205
|
+
if (flags.list || extraArgs.length === 0) {
|
|
3206
|
+
await listPins(process.cwd());
|
|
3207
|
+
} else {
|
|
3208
|
+
await pin(process.cwd(), extraArgs);
|
|
3209
|
+
}
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
if (command === "unpin") {
|
|
3213
|
+
if (extraArgs.length === 0) {
|
|
3214
|
+
console.error("Error: specify patterns to unpin. Usage: projx unpin <patterns...>");
|
|
3215
|
+
process.exit(1);
|
|
3216
|
+
}
|
|
3217
|
+
await unpin(process.cwd(), extraArgs);
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
if (command === "diff") {
|
|
3221
|
+
await diff(process.cwd(), localRepo);
|
|
3222
|
+
return;
|
|
3223
|
+
}
|
|
3224
|
+
if (command === "doctor") {
|
|
3225
|
+
await doctor(process.cwd(), flags.fix);
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
if (command === "sync") {
|
|
3229
|
+
const urlArg = extraArgs.find((a) => a.startsWith("--url="));
|
|
3230
|
+
const url = urlArg ? urlArg.split("=").slice(1).join("=") : void 0;
|
|
3231
|
+
await sync(process.cwd(), url);
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
if (command === "gen") {
|
|
3235
|
+
const subcommand = extraArgs[0];
|
|
3236
|
+
if (subcommand !== "entity" || !extraArgs[1]) {
|
|
3237
|
+
console.error('Usage: projx gen entity <name> [--fields "name:string,amount:number"]');
|
|
3238
|
+
process.exit(1);
|
|
3239
|
+
}
|
|
3240
|
+
const entityName = extraArgs[1];
|
|
3241
|
+
const fieldsArg = extraArgs.find((a) => a.startsWith("--fields="));
|
|
3242
|
+
const fieldsFlag = fieldsArg ? fieldsArg.split("=").slice(1).join("=") : void 0;
|
|
3243
|
+
await gen(process.cwd(), entityName, fieldsFlag);
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
1570
3246
|
let opts;
|
|
1571
3247
|
if (options.components) {
|
|
1572
3248
|
if (!name) {
|
|
@@ -1585,7 +3261,7 @@ async function main() {
|
|
|
1585
3261
|
opts.install = options.install ?? opts.install;
|
|
1586
3262
|
}
|
|
1587
3263
|
const dest = resolve2(process.cwd(), opts.name);
|
|
1588
|
-
if (
|
|
3264
|
+
if (existsSync13(dest)) {
|
|
1589
3265
|
console.error(`Error: ${dest} already exists.`);
|
|
1590
3266
|
process.exit(1);
|
|
1591
3267
|
}
|