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