create-projx 1.3.5 → 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 +56 -8
- package/dist/index.js +2128 -205
- 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";
|
|
@@ -330,13 +350,13 @@ async function runPrompts(nameArg) {
|
|
|
330
350
|
|
|
331
351
|
// src/scaffold.ts
|
|
332
352
|
import { copyFileSync, existsSync as existsSync3 } from "fs";
|
|
333
|
-
import { mkdir as mkdir3, readFile as
|
|
353
|
+
import { mkdir as mkdir3, readFile as readFile4 } from "fs/promises";
|
|
334
354
|
import { join as join4 } from "path";
|
|
335
355
|
import * as p2 from "@clack/prompts";
|
|
336
356
|
|
|
337
357
|
// src/baseline.ts
|
|
338
|
-
import { existsSync as existsSync2 } from "fs";
|
|
339
|
-
import { chmod, mkdir as mkdir2, writeFile as writeFile2, rm as rm2 } from "fs/promises";
|
|
358
|
+
import { existsSync as existsSync2, writeFileSync, unlinkSync } from "fs";
|
|
359
|
+
import { chmod, mkdir as mkdir2, writeFile as writeFile2, rm as rm2, readFile as readFile3 } from "fs/promises";
|
|
340
360
|
import { execSync as execSync2 } from "child_process";
|
|
341
361
|
import { join as join3 } from "path";
|
|
342
362
|
import { tmpdir as tmpdir2 } from "os";
|
|
@@ -401,6 +421,7 @@ function generateVscodeSettings(vars) {
|
|
|
401
421
|
}
|
|
402
422
|
|
|
403
423
|
// src/baseline.ts
|
|
424
|
+
var BASELINE_REF = "refs/projx/baseline";
|
|
404
425
|
function matchesSkip(filePath, patterns) {
|
|
405
426
|
for (const pattern of patterns) {
|
|
406
427
|
if (pattern === "**") return true;
|
|
@@ -425,6 +446,98 @@ function matchesSkip(filePath, patterns) {
|
|
|
425
446
|
}
|
|
426
447
|
return false;
|
|
427
448
|
}
|
|
449
|
+
function saveBaselineRef(cwd) {
|
|
450
|
+
try {
|
|
451
|
+
const head = execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" }).toString().trim();
|
|
452
|
+
execSync2(`git update-ref ${BASELINE_REF} ${head}`, { cwd, stdio: "pipe" });
|
|
453
|
+
} catch {
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function getBaselineRef(cwd) {
|
|
457
|
+
try {
|
|
458
|
+
return execSync2(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
|
|
459
|
+
} catch {
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
const sha = execSync2("git log -1 --format=%H -- .projx", { cwd, stdio: "pipe" }).toString().trim();
|
|
463
|
+
if (sha) return sha;
|
|
464
|
+
} catch {
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
function getFileAtRef(cwd, ref, filePath) {
|
|
469
|
+
try {
|
|
470
|
+
return execSync2(`git show ${ref}:"${filePath}"`, { cwd, stdio: "pipe" }).toString();
|
|
471
|
+
} catch {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function mergeFileThreeWay(oursPath, baseContent, theirsContent) {
|
|
476
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
477
|
+
const baseTmp = join3(tmpdir2(), `projx-base-${id}`);
|
|
478
|
+
const theirsTmp = join3(tmpdir2(), `projx-theirs-${id}`);
|
|
479
|
+
try {
|
|
480
|
+
writeFileSync(baseTmp, baseContent);
|
|
481
|
+
writeFileSync(theirsTmp, theirsContent);
|
|
482
|
+
execSync2(`git merge-file "${oursPath}" "${baseTmp}" "${theirsTmp}"`, { stdio: "pipe" });
|
|
483
|
+
return true;
|
|
484
|
+
} catch {
|
|
485
|
+
return false;
|
|
486
|
+
} finally {
|
|
487
|
+
try {
|
|
488
|
+
unlinkSync(baseTmp);
|
|
489
|
+
} catch {
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
unlinkSync(theirsTmp);
|
|
493
|
+
} catch {
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async function collectAllFiles(dir, base) {
|
|
498
|
+
const { readdir: readdir4 } = await import("fs/promises");
|
|
499
|
+
const results = [];
|
|
500
|
+
const walk = async (current) => {
|
|
501
|
+
const entries = await readdir4(current, { withFileTypes: true });
|
|
502
|
+
for (const entry of entries) {
|
|
503
|
+
const full = join3(current, entry.name);
|
|
504
|
+
if (entry.isDirectory()) {
|
|
505
|
+
await walk(full);
|
|
506
|
+
} else {
|
|
507
|
+
results.push(full.slice(base.length + 1));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
await walk(dir);
|
|
512
|
+
return results;
|
|
513
|
+
}
|
|
514
|
+
async function tryThreeWayMerge(cwd, templateDir, baselineRef) {
|
|
515
|
+
const templateFiles = await collectAllFiles(templateDir, templateDir);
|
|
516
|
+
const merged = [];
|
|
517
|
+
const conflicted = [];
|
|
518
|
+
for (const file of templateFiles) {
|
|
519
|
+
const oursPath = join3(cwd, file);
|
|
520
|
+
if (!existsSync2(oursPath)) continue;
|
|
521
|
+
const baseContent = getFileAtRef(cwd, baselineRef, file);
|
|
522
|
+
if (baseContent === null) continue;
|
|
523
|
+
let theirsContent;
|
|
524
|
+
try {
|
|
525
|
+
theirsContent = await readFile3(join3(templateDir, file), "utf-8");
|
|
526
|
+
} catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const oursContent = await readFile3(oursPath, "utf-8");
|
|
530
|
+
if (oursContent === baseContent) continue;
|
|
531
|
+
if (theirsContent === baseContent) continue;
|
|
532
|
+
const clean = mergeFileThreeWay(oursPath, baseContent, theirsContent);
|
|
533
|
+
if (clean) {
|
|
534
|
+
merged.push(file);
|
|
535
|
+
} else {
|
|
536
|
+
conflicted.push(file);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return { merged, conflicted };
|
|
540
|
+
}
|
|
428
541
|
function createOrphanWorktree(cwd) {
|
|
429
542
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
430
543
|
const branch = `projx/tmp-${id}`;
|
|
@@ -456,22 +569,22 @@ function cleanupWorktree(cwd, worktree, branch) {
|
|
|
456
569
|
}
|
|
457
570
|
async function removeSkippedFiles(dir, skipPatterns) {
|
|
458
571
|
if (skipPatterns.length === 0) return;
|
|
459
|
-
const { readdir:
|
|
572
|
+
const { readdir: readdir4, unlink: unlink2 } = await import("fs/promises");
|
|
460
573
|
const walk = async (current, base) => {
|
|
461
|
-
const entries = await
|
|
574
|
+
const entries = await readdir4(current, { withFileTypes: true });
|
|
462
575
|
for (const entry of entries) {
|
|
463
576
|
const full = join3(current, entry.name);
|
|
464
577
|
const rel = full.slice(base.length + 1);
|
|
465
578
|
if (entry.isDirectory()) {
|
|
466
579
|
await walk(full, base);
|
|
467
580
|
} else if (entry.name !== ".projx-component" && matchesSkip(rel, skipPatterns)) {
|
|
468
|
-
await
|
|
581
|
+
await unlink2(full);
|
|
469
582
|
}
|
|
470
583
|
}
|
|
471
584
|
};
|
|
472
585
|
await walk(dir, dir);
|
|
473
586
|
}
|
|
474
|
-
async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips) {
|
|
587
|
+
async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip) {
|
|
475
588
|
const name = vars.projectName;
|
|
476
589
|
const nameSnake = toSnake(name);
|
|
477
590
|
for (const component of components) {
|
|
@@ -494,21 +607,34 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
|
|
|
494
607
|
}
|
|
495
608
|
await substituteNames(dest, components, componentPaths, name, nameSnake);
|
|
496
609
|
const hasBackend = components.includes("fastapi") || components.includes("fastify");
|
|
610
|
+
const skip = rootSkip ?? [];
|
|
611
|
+
const shouldWrite = (file) => !matchesSkip(file, skip);
|
|
497
612
|
if (hasBackend || components.includes("frontend")) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
613
|
+
if (shouldWrite("docker-compose.yml"))
|
|
614
|
+
await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
|
|
615
|
+
if (shouldWrite("docker-compose.dev.yml"))
|
|
616
|
+
await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
|
|
617
|
+
}
|
|
618
|
+
if (shouldWrite("README.md"))
|
|
619
|
+
await writeFile2(join3(dest, "README.md"), await generateReadme(vars));
|
|
620
|
+
if (shouldWrite(".githooks/pre-commit")) {
|
|
621
|
+
await mkdir2(join3(dest, ".githooks"), { recursive: true });
|
|
622
|
+
await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
|
|
623
|
+
await chmod(join3(dest, ".githooks/pre-commit"), 493);
|
|
624
|
+
}
|
|
625
|
+
if (shouldWrite(".github/workflows/ci.yml")) {
|
|
626
|
+
await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
|
|
627
|
+
await writeFile2(join3(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
|
|
628
|
+
}
|
|
629
|
+
if (shouldWrite("setup.sh")) {
|
|
630
|
+
await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
|
|
631
|
+
await chmod(join3(dest, "setup.sh"), 493);
|
|
632
|
+
}
|
|
509
633
|
await copyStaticFiles(repoDir, dest);
|
|
510
|
-
|
|
511
|
-
|
|
634
|
+
if (shouldWrite(".vscode/settings.json")) {
|
|
635
|
+
await mkdir2(join3(dest, ".vscode"), { recursive: true });
|
|
636
|
+
await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
|
|
637
|
+
}
|
|
512
638
|
const projxConfig = {
|
|
513
639
|
version,
|
|
514
640
|
components,
|
|
@@ -534,7 +660,7 @@ async function substituteNames(dest, components, paths, name, nameSnake) {
|
|
|
534
660
|
await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
|
|
535
661
|
}
|
|
536
662
|
}
|
|
537
|
-
async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips) {
|
|
663
|
+
async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips, rootSkip) {
|
|
538
664
|
const hasHead = (() => {
|
|
539
665
|
try {
|
|
540
666
|
execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" });
|
|
@@ -544,15 +670,15 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
|
|
|
544
670
|
}
|
|
545
671
|
})();
|
|
546
672
|
if (!hasHead) {
|
|
547
|
-
await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips);
|
|
673
|
+
await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
548
674
|
return { status: "clean" };
|
|
549
675
|
}
|
|
550
676
|
const { worktree, branch } = createOrphanWorktree(cwd);
|
|
551
677
|
try {
|
|
552
|
-
await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips);
|
|
678
|
+
await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
553
679
|
execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
|
|
554
|
-
const
|
|
555
|
-
if (!
|
|
680
|
+
const diff2 = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
|
|
681
|
+
if (!diff2) {
|
|
556
682
|
cleanupWorktree(cwd, worktree, branch);
|
|
557
683
|
return { status: "clean" };
|
|
558
684
|
}
|
|
@@ -587,9 +713,54 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
|
|
|
587
713
|
} catch {
|
|
588
714
|
}
|
|
589
715
|
if (mergeClean) {
|
|
716
|
+
saveBaselineRef(cwd);
|
|
590
717
|
return { status: "clean" };
|
|
591
718
|
}
|
|
592
|
-
|
|
719
|
+
const baselineRef = getBaselineRef(cwd);
|
|
720
|
+
if (baselineRef) {
|
|
721
|
+
const tmpTemplate = join3(tmpdir2(), `projx-tpl-${Date.now()}`);
|
|
722
|
+
await mkdir2(tmpTemplate, { recursive: true });
|
|
723
|
+
await writeTemplateToDir(tmpTemplate, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
724
|
+
const result = await tryThreeWayMerge(cwd, tmpTemplate, baselineRef);
|
|
725
|
+
await rm2(tmpTemplate, { recursive: true, force: true });
|
|
726
|
+
const projxConfig = {
|
|
727
|
+
version,
|
|
728
|
+
components,
|
|
729
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
|
|
730
|
+
};
|
|
731
|
+
await writeFile2(join3(cwd, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
|
|
732
|
+
if (result.conflicted.length === 0) {
|
|
733
|
+
execSync2("git add -A", { cwd, stdio: "pipe" });
|
|
734
|
+
const staged = execSync2("git diff --cached --stat", { cwd, stdio: "pipe" }).toString().trim();
|
|
735
|
+
if (staged) {
|
|
736
|
+
execSync2(
|
|
737
|
+
`git commit --no-verify -m "projx: update to template v${version} (3-way merge)"`,
|
|
738
|
+
{ cwd, stdio: "pipe" }
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
saveBaselineRef(cwd);
|
|
742
|
+
return result.merged.length > 0 ? { status: "merged", mergedFiles: result.merged } : { status: "clean" };
|
|
743
|
+
}
|
|
744
|
+
for (const f of result.conflicted) {
|
|
745
|
+
try {
|
|
746
|
+
execSync2(`git checkout -- "${f}"`, { cwd, stdio: "pipe" });
|
|
747
|
+
} catch {
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
for (const f of result.merged) {
|
|
751
|
+
try {
|
|
752
|
+
execSync2(`git add "${f}"`, { cwd, stdio: "pipe" });
|
|
753
|
+
} catch {
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
execSync2("git add .projx", { cwd, stdio: "pipe" });
|
|
757
|
+
return {
|
|
758
|
+
status: "conflicts",
|
|
759
|
+
mergedFiles: result.merged,
|
|
760
|
+
conflictedFiles: result.conflicted
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
593
764
|
return { status: "conflicts" };
|
|
594
765
|
} catch (err) {
|
|
595
766
|
cleanupWorktree(cwd, worktree, branch);
|
|
@@ -615,17 +786,17 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
615
786
|
});
|
|
616
787
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
617
788
|
try {
|
|
618
|
-
const pkg = JSON.parse(await
|
|
789
|
+
const pkg = JSON.parse(await readFile4(join4(repoDir, "cli/package.json"), "utf-8"));
|
|
619
790
|
const version = pkg.version;
|
|
620
791
|
p2.log.info(`Scaffolding project in ${dest}`);
|
|
621
792
|
if (opts.git) {
|
|
622
793
|
exec("git init", dest);
|
|
623
794
|
exec("git config core.hooksPath .githooks", dest);
|
|
624
795
|
}
|
|
625
|
-
const
|
|
626
|
-
|
|
796
|
+
const spinner7 = p2.spinner();
|
|
797
|
+
spinner7.start("Scaffolding project");
|
|
627
798
|
await applyTemplate(dest, repoDir, opts.components, paths, vars, version);
|
|
628
|
-
|
|
799
|
+
spinner7.stop("Scaffold complete.");
|
|
629
800
|
if (opts.install) {
|
|
630
801
|
await installDeps(dest, opts.components);
|
|
631
802
|
}
|
|
@@ -634,6 +805,7 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
634
805
|
try {
|
|
635
806
|
exec("git add -A", dest);
|
|
636
807
|
exec('git commit --no-verify -m "Initial scaffold from projx"', dest);
|
|
808
|
+
saveBaselineRef(dest);
|
|
637
809
|
} catch {
|
|
638
810
|
}
|
|
639
811
|
}
|
|
@@ -649,44 +821,44 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
649
821
|
}
|
|
650
822
|
async function installDeps(dest, components) {
|
|
651
823
|
for (const component of components) {
|
|
652
|
-
const
|
|
824
|
+
const spinner7 = p2.spinner();
|
|
653
825
|
try {
|
|
654
826
|
switch (component) {
|
|
655
827
|
case "fastapi":
|
|
656
828
|
if (hasCommand("uv")) {
|
|
657
|
-
|
|
829
|
+
spinner7.start("Installing FastAPI dependencies (uv sync)");
|
|
658
830
|
exec("uv sync --all-extras", join4(dest, "fastapi"));
|
|
659
|
-
|
|
831
|
+
spinner7.stop("FastAPI dependencies installed.");
|
|
660
832
|
} else {
|
|
661
833
|
p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
662
834
|
}
|
|
663
835
|
break;
|
|
664
836
|
case "fastify":
|
|
665
837
|
if (hasCommand("pnpm")) {
|
|
666
|
-
|
|
838
|
+
spinner7.start("Installing Fastify dependencies (pnpm install)");
|
|
667
839
|
exec("pnpm install", join4(dest, "fastify"));
|
|
668
|
-
|
|
840
|
+
spinner7.stop("Fastify dependencies installed.");
|
|
669
841
|
} else {
|
|
670
|
-
|
|
842
|
+
spinner7.start("Installing Fastify dependencies (npm install)");
|
|
671
843
|
exec("npm install", join4(dest, "fastify"));
|
|
672
|
-
|
|
844
|
+
spinner7.stop("Fastify dependencies installed.");
|
|
673
845
|
}
|
|
674
846
|
break;
|
|
675
847
|
case "frontend":
|
|
676
|
-
|
|
848
|
+
spinner7.start("Installing Frontend dependencies (npm install)");
|
|
677
849
|
exec("npm install", join4(dest, "frontend"));
|
|
678
|
-
|
|
850
|
+
spinner7.stop("Frontend dependencies installed.");
|
|
679
851
|
break;
|
|
680
852
|
case "e2e":
|
|
681
|
-
|
|
853
|
+
spinner7.start("Installing E2E dependencies (npm install)");
|
|
682
854
|
exec("npm install", join4(dest, "e2e"));
|
|
683
|
-
|
|
855
|
+
spinner7.stop("E2E dependencies installed.");
|
|
684
856
|
break;
|
|
685
857
|
case "mobile":
|
|
686
858
|
if (hasCommand("flutter")) {
|
|
687
|
-
|
|
859
|
+
spinner7.start("Installing Flutter dependencies");
|
|
688
860
|
exec("flutter pub get", join4(dest, "mobile"));
|
|
689
|
-
|
|
861
|
+
spinner7.stop("Flutter dependencies installed.");
|
|
690
862
|
} else {
|
|
691
863
|
p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
|
|
692
864
|
}
|
|
@@ -695,7 +867,7 @@ async function installDeps(dest, components) {
|
|
|
695
867
|
break;
|
|
696
868
|
}
|
|
697
869
|
} catch {
|
|
698
|
-
|
|
870
|
+
spinner7.stop(`Failed to install ${component} dependencies.`);
|
|
699
871
|
}
|
|
700
872
|
}
|
|
701
873
|
}
|
|
@@ -713,8 +885,8 @@ function copyEnvExamples(dest, components) {
|
|
|
713
885
|
}
|
|
714
886
|
|
|
715
887
|
// src/update.ts
|
|
716
|
-
import { existsSync as existsSync4
|
|
717
|
-
import { readFile as
|
|
888
|
+
import { existsSync as existsSync4 } from "fs";
|
|
889
|
+
import { readFile as readFile5, writeFile as writeFile3, unlink } from "fs/promises";
|
|
718
890
|
import { execSync as execSync3 } from "child_process";
|
|
719
891
|
import { join as join5 } from "path";
|
|
720
892
|
import * as p3 from "@clack/prompts";
|
|
@@ -736,7 +908,7 @@ async function update(cwd, localRepo) {
|
|
|
736
908
|
const configPath = join5(cwd, ".projx");
|
|
737
909
|
let config;
|
|
738
910
|
if (existsSync4(configPath)) {
|
|
739
|
-
config = JSON.parse(await
|
|
911
|
+
config = JSON.parse(await readFile5(configPath, "utf-8"));
|
|
740
912
|
p3.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
|
|
741
913
|
} else {
|
|
742
914
|
p3.log.warn("No .projx file found. Detecting components from directories.");
|
|
@@ -749,9 +921,9 @@ async function update(cwd, localRepo) {
|
|
|
749
921
|
p3.log.info(`Detected: ${detected.join(", ")}`);
|
|
750
922
|
}
|
|
751
923
|
const componentPaths = await discoverComponentPaths(cwd, config.components);
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
p3.log.info(`${c} \u2192 ${
|
|
924
|
+
for (const c of config.components) {
|
|
925
|
+
const dir = componentPaths[c];
|
|
926
|
+
p3.log.info(dir !== c ? `${c} \u2192 ${dir}/` : `${c}/`);
|
|
755
927
|
}
|
|
756
928
|
const componentSkips = {};
|
|
757
929
|
for (const component of config.components) {
|
|
@@ -770,27 +942,41 @@ async function update(cwd, localRepo) {
|
|
|
770
942
|
});
|
|
771
943
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
772
944
|
try {
|
|
773
|
-
const pkg = JSON.parse(await
|
|
945
|
+
const pkg = JSON.parse(await readFile5(join5(repoDir, "cli/package.json"), "utf-8"));
|
|
774
946
|
const version = pkg.version;
|
|
775
947
|
const name = detectProjectName(cwd, config.components, componentPaths);
|
|
776
948
|
const vars = { projectName: name, components: config.components, paths: componentPaths };
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
p3.log.
|
|
785
|
-
p3.
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
949
|
+
const spinner7 = p3.spinner();
|
|
950
|
+
spinner7.start("Applying template update");
|
|
951
|
+
const rootSkip = config.skip ?? [];
|
|
952
|
+
const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
|
|
953
|
+
spinner7.stop("Template applied.");
|
|
954
|
+
if (result.status === "merged") {
|
|
955
|
+
saveBaselineRef(cwd);
|
|
956
|
+
p3.log.success(`${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`);
|
|
957
|
+
p3.outro(`Updated to template v${version}.`);
|
|
958
|
+
} else if (result.status === "conflicts") {
|
|
959
|
+
if (result.mergedFiles && result.mergedFiles.length > 0) {
|
|
960
|
+
p3.log.success(`${result.mergedFiles.length} file(s) merged cleanly and staged.`);
|
|
961
|
+
}
|
|
962
|
+
const conflictCount = result.conflictedFiles?.length ?? 0;
|
|
963
|
+
if (conflictCount > 0) {
|
|
964
|
+
p3.log.warn(`${conflictCount} file(s) need review:`);
|
|
965
|
+
for (const f of result.conflictedFiles) {
|
|
966
|
+
p3.log.info(` ${f}`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const handled = await promptSkipLearning(cwd, componentPaths, version);
|
|
970
|
+
if (!handled) {
|
|
971
|
+
p3.log.info("");
|
|
972
|
+
p3.log.info("Review: git diff");
|
|
973
|
+
p3.log.info("Keep: git add <file>");
|
|
974
|
+
p3.log.info("Discard: git checkout -- <file>");
|
|
975
|
+
p3.log.info(`Commit: git add . && git commit -m "projx: update to v${version}"`);
|
|
976
|
+
p3.outro(`Template v${version} applied. Review with git diff.`);
|
|
977
|
+
}
|
|
793
978
|
} else {
|
|
979
|
+
saveBaselineRef(cwd);
|
|
794
980
|
p3.outro(`Updated to template v${version}.`);
|
|
795
981
|
}
|
|
796
982
|
} catch (err) {
|
|
@@ -816,27 +1002,105 @@ function hasUncommittedChanges(cwd) {
|
|
|
816
1002
|
return false;
|
|
817
1003
|
}
|
|
818
1004
|
}
|
|
819
|
-
function
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
1005
|
+
async function promptSkipLearning(cwd, componentPaths, version) {
|
|
1006
|
+
if (!process.stdin.isTTY) return false;
|
|
1007
|
+
const statusOutput = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
|
|
1008
|
+
if (!statusOutput) return false;
|
|
1009
|
+
const entries = statusOutput.split("\n").filter(Boolean).map((line) => ({
|
|
1010
|
+
status: line.slice(0, 2).trim(),
|
|
1011
|
+
file: line.slice(3).trim()
|
|
1012
|
+
}));
|
|
1013
|
+
const changedFiles = entries.map((e) => e.file).filter((f) => {
|
|
1014
|
+
const base = f.split("/").pop();
|
|
1015
|
+
if (base === ".projx" || base === COMPONENT_MARKER) return false;
|
|
1016
|
+
return true;
|
|
1017
|
+
});
|
|
1018
|
+
if (changedFiles.length === 0) return false;
|
|
1019
|
+
p3.log.warn(`${changedFiles.length} template file(s) differ from your code.`);
|
|
1020
|
+
const selected = await p3.multiselect({
|
|
1021
|
+
message: "Select files to KEEP (unselected will be discarded and skipped on future updates)",
|
|
1022
|
+
options: changedFiles.map((f) => ({ value: f, label: f })),
|
|
1023
|
+
required: false
|
|
1024
|
+
});
|
|
1025
|
+
if (p3.isCancel(selected)) return false;
|
|
1026
|
+
const kept = new Set(selected);
|
|
1027
|
+
const discarded = changedFiles.filter((f) => !kept.has(f));
|
|
1028
|
+
if (discarded.length > 0) {
|
|
1029
|
+
for (const file of discarded) {
|
|
1030
|
+
const entry = entries.find((e) => e.file === file);
|
|
824
1031
|
try {
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1032
|
+
if (entry?.status === "??") {
|
|
1033
|
+
await unlink(join5(cwd, file));
|
|
1034
|
+
} else {
|
|
1035
|
+
execSync3(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
|
|
829
1036
|
}
|
|
830
1037
|
} catch {
|
|
831
1038
|
}
|
|
832
1039
|
}
|
|
1040
|
+
await learnSkips(cwd, discarded, componentPaths);
|
|
1041
|
+
p3.log.success(
|
|
1042
|
+
`Discarded ${discarded.length} file(s) and added to skip list.`
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
if (kept.size > 0) {
|
|
1046
|
+
p3.log.info(`${kept.size} file(s) kept \u2014 commit when ready:`);
|
|
1047
|
+
p3.log.info(
|
|
1048
|
+
` git add . && git commit -m "projx: update to v${version}"`
|
|
1049
|
+
);
|
|
1050
|
+
p3.outro(`Template v${version} applied.`);
|
|
1051
|
+
} else {
|
|
1052
|
+
p3.outro("All template changes discarded. Skip list updated.");
|
|
1053
|
+
}
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
1056
|
+
async function learnSkips(cwd, files, componentPaths) {
|
|
1057
|
+
const componentSkipAdds = {};
|
|
1058
|
+
const rootSkipAdds = [];
|
|
1059
|
+
const dirToComponent = {};
|
|
1060
|
+
for (const [component, dir] of Object.entries(componentPaths)) {
|
|
1061
|
+
dirToComponent[dir] = component;
|
|
1062
|
+
}
|
|
1063
|
+
for (const file of files) {
|
|
1064
|
+
let matched = false;
|
|
1065
|
+
for (const [dir, component] of Object.entries(dirToComponent)) {
|
|
1066
|
+
if (file.startsWith(dir + "/")) {
|
|
1067
|
+
const relative = file.slice(dir.length + 1);
|
|
1068
|
+
if (!componentSkipAdds[component]) componentSkipAdds[component] = [];
|
|
1069
|
+
componentSkipAdds[component].push(relative);
|
|
1070
|
+
matched = true;
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (!matched) {
|
|
1075
|
+
rootSkipAdds.push(file);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
for (const [component, additions] of Object.entries(componentSkipAdds)) {
|
|
1079
|
+
const dir = componentPaths[component];
|
|
1080
|
+
const markerPath = join5(cwd, dir, COMPONENT_MARKER);
|
|
1081
|
+
try {
|
|
1082
|
+
const data = JSON.parse(await readFile5(markerPath, "utf-8"));
|
|
1083
|
+
const existing = data.skip ?? [];
|
|
1084
|
+
data.skip = [.../* @__PURE__ */ new Set([...existing, ...additions])];
|
|
1085
|
+
await writeFile3(markerPath, JSON.stringify(data, null, 2) + "\n");
|
|
1086
|
+
} catch {
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (rootSkipAdds.length > 0) {
|
|
1090
|
+
const configPath = join5(cwd, ".projx");
|
|
1091
|
+
try {
|
|
1092
|
+
const data = JSON.parse(await readFile5(configPath, "utf-8"));
|
|
1093
|
+
const existing = data.skip ?? [];
|
|
1094
|
+
data.skip = [.../* @__PURE__ */ new Set([...existing, ...rootSkipAdds])];
|
|
1095
|
+
await writeFile3(configPath, JSON.stringify(data, null, 2) + "\n");
|
|
1096
|
+
} catch {
|
|
1097
|
+
}
|
|
833
1098
|
}
|
|
834
|
-
return toKebab(cwd.split("/").pop());
|
|
835
1099
|
}
|
|
836
1100
|
|
|
837
1101
|
// src/add.ts
|
|
838
|
-
import { copyFileSync as copyFileSync2, existsSync as existsSync5
|
|
839
|
-
import { readFile as
|
|
1102
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync5 } from "fs";
|
|
1103
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
840
1104
|
import { join as join6 } from "path";
|
|
841
1105
|
import * as p4 from "@clack/prompts";
|
|
842
1106
|
async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
@@ -847,7 +1111,7 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
|
847
1111
|
p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
|
|
848
1112
|
process.exit(1);
|
|
849
1113
|
}
|
|
850
|
-
const config = JSON.parse(await
|
|
1114
|
+
const config = JSON.parse(await readFile6(configPath, "utf-8"));
|
|
851
1115
|
const existing = config.components;
|
|
852
1116
|
const alreadyExists = newComponents.filter((c) => existing.includes(c));
|
|
853
1117
|
if (alreadyExists.length > 0) {
|
|
@@ -872,14 +1136,14 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
|
872
1136
|
const existingPaths = await discoverComponentPaths(cwd, existing);
|
|
873
1137
|
const paths = { ...existingPaths };
|
|
874
1138
|
for (const c of toAdd) paths[c] = c;
|
|
875
|
-
const name =
|
|
1139
|
+
const name = detectProjectName(cwd, existing, paths);
|
|
876
1140
|
const vars = { projectName: name, components: allComponents, paths };
|
|
877
|
-
const pkg = JSON.parse(await
|
|
1141
|
+
const pkg = JSON.parse(await readFile6(join6(repoDir, "cli/package.json"), "utf-8"));
|
|
878
1142
|
const version = pkg.version;
|
|
879
|
-
const
|
|
880
|
-
|
|
1143
|
+
const spinner7 = p4.spinner();
|
|
1144
|
+
spinner7.start("Adding components");
|
|
881
1145
|
await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, "scaffold");
|
|
882
|
-
|
|
1146
|
+
spinner7.stop("Components added.");
|
|
883
1147
|
if (!skipInstall) {
|
|
884
1148
|
await installDeps2(cwd, toAdd);
|
|
885
1149
|
}
|
|
@@ -902,44 +1166,44 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
|
902
1166
|
}
|
|
903
1167
|
async function installDeps2(dest, components) {
|
|
904
1168
|
for (const component of components) {
|
|
905
|
-
const
|
|
1169
|
+
const spinner7 = p4.spinner();
|
|
906
1170
|
try {
|
|
907
1171
|
switch (component) {
|
|
908
1172
|
case "fastapi":
|
|
909
1173
|
if (hasCommand("uv")) {
|
|
910
|
-
|
|
1174
|
+
spinner7.start("Installing FastAPI dependencies");
|
|
911
1175
|
exec("uv sync --all-extras", join6(dest, "fastapi"));
|
|
912
|
-
|
|
1176
|
+
spinner7.stop("FastAPI dependencies installed.");
|
|
913
1177
|
} else {
|
|
914
1178
|
p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
915
1179
|
}
|
|
916
1180
|
break;
|
|
917
1181
|
case "fastify":
|
|
918
1182
|
if (hasCommand("pnpm")) {
|
|
919
|
-
|
|
1183
|
+
spinner7.start("Installing Fastify dependencies");
|
|
920
1184
|
exec("pnpm install", join6(dest, "fastify"));
|
|
921
|
-
|
|
1185
|
+
spinner7.stop("Fastify dependencies installed.");
|
|
922
1186
|
} else {
|
|
923
|
-
|
|
1187
|
+
spinner7.start("Installing Fastify dependencies");
|
|
924
1188
|
exec("npm install", join6(dest, "fastify"));
|
|
925
|
-
|
|
1189
|
+
spinner7.stop("Fastify dependencies installed.");
|
|
926
1190
|
}
|
|
927
1191
|
break;
|
|
928
1192
|
case "frontend":
|
|
929
|
-
|
|
1193
|
+
spinner7.start("Installing Frontend dependencies");
|
|
930
1194
|
exec("npm install", join6(dest, "frontend"));
|
|
931
|
-
|
|
1195
|
+
spinner7.stop("Frontend dependencies installed.");
|
|
932
1196
|
break;
|
|
933
1197
|
case "e2e":
|
|
934
|
-
|
|
1198
|
+
spinner7.start("Installing E2E dependencies");
|
|
935
1199
|
exec("npm install", join6(dest, "e2e"));
|
|
936
|
-
|
|
1200
|
+
spinner7.stop("E2E dependencies installed.");
|
|
937
1201
|
break;
|
|
938
1202
|
case "mobile":
|
|
939
1203
|
if (hasCommand("flutter")) {
|
|
940
|
-
|
|
1204
|
+
spinner7.start("Installing Flutter dependencies");
|
|
941
1205
|
exec("flutter pub get", join6(dest, "mobile"));
|
|
942
|
-
|
|
1206
|
+
spinner7.stop("Flutter dependencies installed.");
|
|
943
1207
|
} else {
|
|
944
1208
|
p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
|
|
945
1209
|
}
|
|
@@ -948,31 +1212,14 @@ async function installDeps2(dest, components) {
|
|
|
948
1212
|
break;
|
|
949
1213
|
}
|
|
950
1214
|
} catch {
|
|
951
|
-
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
function detectProjectName2(cwd, components, paths) {
|
|
956
|
-
for (const component of components) {
|
|
957
|
-
const dir = paths[component] ?? component;
|
|
958
|
-
const pkgPath = join6(cwd, dir, "package.json");
|
|
959
|
-
if (existsSync5(pkgPath)) {
|
|
960
|
-
try {
|
|
961
|
-
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
962
|
-
const n = pkg.name;
|
|
963
|
-
if (n && n.includes("-")) {
|
|
964
|
-
return n.substring(0, n.lastIndexOf("-"));
|
|
965
|
-
}
|
|
966
|
-
} catch {
|
|
967
|
-
}
|
|
1215
|
+
spinner7.stop(`Failed to install ${component} dependencies.`);
|
|
968
1216
|
}
|
|
969
1217
|
}
|
|
970
|
-
return toKebab(cwd.split("/").pop());
|
|
971
1218
|
}
|
|
972
1219
|
|
|
973
1220
|
// src/init.ts
|
|
974
1221
|
import { existsSync as existsSync7 } from "fs";
|
|
975
|
-
import { readFile as
|
|
1222
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
976
1223
|
import { execSync as execSync4 } from "child_process";
|
|
977
1224
|
import { join as join8 } from "path";
|
|
978
1225
|
import * as p5 from "@clack/prompts";
|
|
@@ -1077,10 +1324,10 @@ async function init(cwd, localRepo) {
|
|
|
1077
1324
|
p5.log.error("You have uncommitted changes. Commit or stash them first.");
|
|
1078
1325
|
process.exit(1);
|
|
1079
1326
|
}
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1327
|
+
const spinner7 = p5.spinner();
|
|
1328
|
+
spinner7.start("Scanning for components");
|
|
1082
1329
|
const detected = await detectComponents(cwd);
|
|
1083
|
-
|
|
1330
|
+
spinner7.stop(
|
|
1084
1331
|
detected.length > 0 ? `Found ${detected.length} component(s).` : "No components detected."
|
|
1085
1332
|
);
|
|
1086
1333
|
let confirmed;
|
|
@@ -1108,7 +1355,7 @@ async function init(cwd, localRepo) {
|
|
|
1108
1355
|
});
|
|
1109
1356
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1110
1357
|
try {
|
|
1111
|
-
const pkg = JSON.parse(await
|
|
1358
|
+
const pkg = JSON.parse(await readFile7(join8(repoDir, "cli/package.json"), "utf-8"));
|
|
1112
1359
|
const version = pkg.version;
|
|
1113
1360
|
const applySpinner = p5.spinner();
|
|
1114
1361
|
applySpinner.start("Applying template");
|
|
@@ -1120,6 +1367,9 @@ async function init(cwd, localRepo) {
|
|
|
1120
1367
|
} catch {
|
|
1121
1368
|
}
|
|
1122
1369
|
}
|
|
1370
|
+
if (result.status === "clean" || result.status === "merged") {
|
|
1371
|
+
saveBaselineRef(cwd);
|
|
1372
|
+
}
|
|
1123
1373
|
if (result.status === "conflicts") {
|
|
1124
1374
|
p5.log.warn("Some template files differ from your code. Changes written directly.");
|
|
1125
1375
|
p5.log.info("Review changes:");
|
|
@@ -1197,103 +1447,1734 @@ function hasUncommittedChanges2(cwd) {
|
|
|
1197
1447
|
}
|
|
1198
1448
|
}
|
|
1199
1449
|
|
|
1200
|
-
// src/
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
const
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
+
};
|
|
1213
1467
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
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.`);
|
|
1216
1485
|
continue;
|
|
1217
1486
|
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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);
|
|
1221
1493
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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.`);
|
|
1228
1509
|
}
|
|
1229
|
-
|
|
1510
|
+
} catch {
|
|
1511
|
+
p6.log.error(`Could not read marker for ${component}.`);
|
|
1230
1512
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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.");
|
|
1234
1524
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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);
|
|
1238
1546
|
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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}.`);
|
|
1242
1569
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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.");
|
|
1246
1585
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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}`);
|
|
1250
1604
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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}`);
|
|
1256
1615
|
}
|
|
1257
1616
|
}
|
|
1258
1617
|
}
|
|
1259
|
-
|
|
1618
|
+
if (!hasAny) {
|
|
1619
|
+
p6.log.info("No pinned files. All template files will be updated.");
|
|
1620
|
+
}
|
|
1621
|
+
p6.outro("");
|
|
1260
1622
|
}
|
|
1261
|
-
function printHelp() {
|
|
1262
|
-
console.log(`
|
|
1263
|
-
Usage:
|
|
1264
|
-
projx <name> [options] Create a new project
|
|
1265
|
-
projx init Adopt existing project into projx
|
|
1266
|
-
projx add <components...> Add components to existing project
|
|
1267
|
-
projx update Update scaffolding to latest
|
|
1268
|
-
|
|
1269
|
-
Options:
|
|
1270
|
-
--components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
|
|
1271
|
-
--no-git Skip git init
|
|
1272
|
-
--no-install Skip dependency installation
|
|
1273
|
-
-y, --yes Accept defaults (fastify + frontend + e2e)
|
|
1274
|
-
--local <path> Use local repo instead of downloading (dev only)
|
|
1275
|
-
-h, --help Show this help
|
|
1276
1623
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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 };
|
|
1290
1641
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
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 };
|
|
1294
1652
|
}
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
+
|
|
3026
|
+
// src/index.ts
|
|
3027
|
+
var args = process.argv.slice(2);
|
|
3028
|
+
function parseArgs() {
|
|
3029
|
+
let command = "create";
|
|
3030
|
+
let name;
|
|
3031
|
+
let localRepo;
|
|
3032
|
+
const options = {};
|
|
3033
|
+
const extraArgs = [];
|
|
3034
|
+
const flags = {};
|
|
3035
|
+
for (let i = 0; i < args.length; i++) {
|
|
3036
|
+
const arg = args[i];
|
|
3037
|
+
if (arg === "update" && !name) {
|
|
3038
|
+
command = "update";
|
|
3039
|
+
continue;
|
|
3040
|
+
}
|
|
3041
|
+
if (arg === "add" && !name) {
|
|
3042
|
+
command = "add";
|
|
3043
|
+
continue;
|
|
3044
|
+
}
|
|
3045
|
+
if (arg === "init" && !name) {
|
|
3046
|
+
command = "init";
|
|
3047
|
+
continue;
|
|
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
|
+
}
|
|
3073
|
+
if (arg === "--components") {
|
|
3074
|
+
const val = args[++i];
|
|
3075
|
+
if (val) {
|
|
3076
|
+
options.components = val.split(",").filter(
|
|
3077
|
+
(c) => COMPONENTS.includes(c)
|
|
3078
|
+
);
|
|
3079
|
+
}
|
|
3080
|
+
continue;
|
|
3081
|
+
}
|
|
3082
|
+
if (arg === "--local") {
|
|
3083
|
+
localRepo = resolve2(args[++i] || ".");
|
|
3084
|
+
continue;
|
|
3085
|
+
}
|
|
3086
|
+
if (arg === "--no-git") {
|
|
3087
|
+
options.git = false;
|
|
3088
|
+
continue;
|
|
3089
|
+
}
|
|
3090
|
+
if (arg === "--no-install") {
|
|
3091
|
+
options.install = false;
|
|
3092
|
+
continue;
|
|
3093
|
+
}
|
|
3094
|
+
if (arg === "-y" || arg === "--yes") {
|
|
3095
|
+
options.components = options.components ?? ["fastify", "frontend", "e2e"];
|
|
3096
|
+
continue;
|
|
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
|
+
}
|
|
3111
|
+
if (arg === "--help" || arg === "-h") {
|
|
3112
|
+
printHelp();
|
|
3113
|
+
process.exit(0);
|
|
3114
|
+
}
|
|
3115
|
+
if (arg === "--fields") {
|
|
3116
|
+
const val = args[++i];
|
|
3117
|
+
if (val) extraArgs.push(`--fields=${val}`);
|
|
3118
|
+
continue;
|
|
3119
|
+
}
|
|
3120
|
+
if (!arg.startsWith("-")) {
|
|
3121
|
+
if (command === "add" || command === "pin" || command === "unpin" || command === "gen") {
|
|
3122
|
+
extraArgs.push(arg);
|
|
3123
|
+
} else if (!name) {
|
|
3124
|
+
name = arg;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
return { command, name, options, localRepo, extraArgs, flags };
|
|
3129
|
+
}
|
|
3130
|
+
function printHelp() {
|
|
3131
|
+
console.log(`
|
|
3132
|
+
Usage:
|
|
3133
|
+
projx <name> [options] Create a new project
|
|
3134
|
+
projx init Adopt existing project into projx
|
|
3135
|
+
projx add <components...> Add components to existing project
|
|
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
|
|
3144
|
+
|
|
3145
|
+
Options:
|
|
3146
|
+
--components <list> Comma-separated: fastapi,fastify,frontend,mobile,e2e,infra
|
|
3147
|
+
--no-git Skip git init
|
|
3148
|
+
--no-install Skip dependency installation
|
|
3149
|
+
-y, --yes Accept defaults (fastify + frontend + e2e)
|
|
3150
|
+
--local <path> Use local repo instead of downloading (dev only)
|
|
3151
|
+
-h, --help Show this help
|
|
3152
|
+
|
|
3153
|
+
Examples:
|
|
3154
|
+
npx create-projx my-app
|
|
3155
|
+
npx create-projx my-app --components fastapi,frontend,e2e
|
|
3156
|
+
npx create-projx my-app -y
|
|
3157
|
+
npx create-projx add frontend mobile
|
|
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"
|
|
3164
|
+
`);
|
|
3165
|
+
}
|
|
3166
|
+
async function main() {
|
|
3167
|
+
const { command, name, options, localRepo, extraArgs, flags } = parseArgs();
|
|
3168
|
+
if (command === "init") {
|
|
3169
|
+
await init(process.cwd(), localRepo);
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
if (command === "update") {
|
|
3173
|
+
await update(process.cwd(), localRepo);
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
if (command === "add") {
|
|
3177
|
+
const components = extraArgs.filter(
|
|
1297
3178
|
(c) => COMPONENTS.includes(c)
|
|
1298
3179
|
);
|
|
1299
3180
|
if (components.length === 0) {
|
|
@@ -1303,6 +3184,48 @@ async function main() {
|
|
|
1303
3184
|
await add(process.cwd(), components, localRepo, options.install === false);
|
|
1304
3185
|
return;
|
|
1305
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
|
+
}
|
|
1306
3229
|
let opts;
|
|
1307
3230
|
if (options.components) {
|
|
1308
3231
|
if (!name) {
|
|
@@ -1321,7 +3244,7 @@ async function main() {
|
|
|
1321
3244
|
opts.install = options.install ?? opts.install;
|
|
1322
3245
|
}
|
|
1323
3246
|
const dest = resolve2(process.cwd(), opts.name);
|
|
1324
|
-
if (
|
|
3247
|
+
if (existsSync13(dest)) {
|
|
1325
3248
|
console.error(`Error: ${dest} already exists.`);
|
|
1326
3249
|
process.exit(1);
|
|
1327
3250
|
}
|