create-projx 1.3.5 → 1.3.6
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 +16 -8
- package/dist/index.js +312 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,19 +87,17 @@ cd my-app
|
|
|
87
87
|
npx create-projx@latest update
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
Updates use a 3-tier merge strategy:
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
git
|
|
94
|
-
|
|
95
|
-
git add . && git commit -m "projx: update to vX.X.X" # commit when ready
|
|
96
|
-
```
|
|
92
|
+
1. **Git merge** — if the template merges cleanly with your code, it's auto-committed. Done.
|
|
93
|
+
2. **3-way merge** — if git merge fails, each file is merged individually using `git merge-file`. Your additions (extra deps, env vars, custom config) are preserved alongside template updates. Clean merges are auto-staged; only true conflicts need review.
|
|
94
|
+
3. **Direct copy** — if no merge baseline exists, template files are written directly. You pick which changes to keep via an interactive prompt, and discarded files are automatically added to your skip list.
|
|
97
95
|
|
|
98
96
|
Your custom files (controllers, pages, middleware) are never deleted. Files you created that don't exist in the template are always preserved.
|
|
99
97
|
|
|
100
98
|
### Skip Files
|
|
101
99
|
|
|
102
|
-
|
|
100
|
+
To skip component source files, add `skip` to `.projx-component`:
|
|
103
101
|
|
|
104
102
|
```json
|
|
105
103
|
{
|
|
@@ -109,7 +107,17 @@ If a file keeps getting overwritten on every update, add it to `.projx-component
|
|
|
109
107
|
}
|
|
110
108
|
```
|
|
111
109
|
|
|
112
|
-
|
|
110
|
+
To skip root-level files (docker-compose, README), add `skip` to `.projx`:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"version": "1.3.6",
|
|
115
|
+
"components": ["fastapi", "frontend"],
|
|
116
|
+
"skip": ["docker-compose.yml", "README.md"]
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Skipped files are excluded from template updates.
|
|
113
121
|
|
|
114
122
|
## Options
|
|
115
123
|
|
package/dist/index.js
CHANGED
|
@@ -330,13 +330,13 @@ async function runPrompts(nameArg) {
|
|
|
330
330
|
|
|
331
331
|
// src/scaffold.ts
|
|
332
332
|
import { copyFileSync, existsSync as existsSync3 } from "fs";
|
|
333
|
-
import { mkdir as mkdir3, readFile as
|
|
333
|
+
import { mkdir as mkdir3, readFile as readFile4 } from "fs/promises";
|
|
334
334
|
import { join as join4 } from "path";
|
|
335
335
|
import * as p2 from "@clack/prompts";
|
|
336
336
|
|
|
337
337
|
// 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";
|
|
338
|
+
import { existsSync as existsSync2, writeFileSync, unlinkSync } from "fs";
|
|
339
|
+
import { chmod, mkdir as mkdir2, writeFile as writeFile2, rm as rm2, readFile as readFile3 } from "fs/promises";
|
|
340
340
|
import { execSync as execSync2 } from "child_process";
|
|
341
341
|
import { join as join3 } from "path";
|
|
342
342
|
import { tmpdir as tmpdir2 } from "os";
|
|
@@ -401,6 +401,7 @@ function generateVscodeSettings(vars) {
|
|
|
401
401
|
}
|
|
402
402
|
|
|
403
403
|
// src/baseline.ts
|
|
404
|
+
var BASELINE_REF = "refs/projx/baseline";
|
|
404
405
|
function matchesSkip(filePath, patterns) {
|
|
405
406
|
for (const pattern of patterns) {
|
|
406
407
|
if (pattern === "**") return true;
|
|
@@ -425,6 +426,98 @@ function matchesSkip(filePath, patterns) {
|
|
|
425
426
|
}
|
|
426
427
|
return false;
|
|
427
428
|
}
|
|
429
|
+
function saveBaselineRef(cwd) {
|
|
430
|
+
try {
|
|
431
|
+
const head = execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" }).toString().trim();
|
|
432
|
+
execSync2(`git update-ref ${BASELINE_REF} ${head}`, { cwd, stdio: "pipe" });
|
|
433
|
+
} catch {
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function getBaselineRef(cwd) {
|
|
437
|
+
try {
|
|
438
|
+
return execSync2(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
|
|
439
|
+
} catch {
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
const sha = execSync2("git log -1 --format=%H -- .projx", { cwd, stdio: "pipe" }).toString().trim();
|
|
443
|
+
if (sha) return sha;
|
|
444
|
+
} catch {
|
|
445
|
+
}
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
function getFileAtRef(cwd, ref, filePath) {
|
|
449
|
+
try {
|
|
450
|
+
return execSync2(`git show ${ref}:"${filePath}"`, { cwd, stdio: "pipe" }).toString();
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function mergeFileThreeWay(oursPath, baseContent, theirsContent) {
|
|
456
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
457
|
+
const baseTmp = join3(tmpdir2(), `projx-base-${id}`);
|
|
458
|
+
const theirsTmp = join3(tmpdir2(), `projx-theirs-${id}`);
|
|
459
|
+
try {
|
|
460
|
+
writeFileSync(baseTmp, baseContent);
|
|
461
|
+
writeFileSync(theirsTmp, theirsContent);
|
|
462
|
+
execSync2(`git merge-file "${oursPath}" "${baseTmp}" "${theirsTmp}"`, { stdio: "pipe" });
|
|
463
|
+
return true;
|
|
464
|
+
} catch {
|
|
465
|
+
return false;
|
|
466
|
+
} finally {
|
|
467
|
+
try {
|
|
468
|
+
unlinkSync(baseTmp);
|
|
469
|
+
} catch {
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
unlinkSync(theirsTmp);
|
|
473
|
+
} catch {
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async function collectAllFiles(dir, base) {
|
|
478
|
+
const { readdir: readdir3 } = await import("fs/promises");
|
|
479
|
+
const results = [];
|
|
480
|
+
const walk = async (current) => {
|
|
481
|
+
const entries = await readdir3(current, { withFileTypes: true });
|
|
482
|
+
for (const entry of entries) {
|
|
483
|
+
const full = join3(current, entry.name);
|
|
484
|
+
if (entry.isDirectory()) {
|
|
485
|
+
await walk(full);
|
|
486
|
+
} else {
|
|
487
|
+
results.push(full.slice(base.length + 1));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
await walk(dir);
|
|
492
|
+
return results;
|
|
493
|
+
}
|
|
494
|
+
async function tryThreeWayMerge(cwd, templateDir, baselineRef) {
|
|
495
|
+
const templateFiles = await collectAllFiles(templateDir, templateDir);
|
|
496
|
+
const merged = [];
|
|
497
|
+
const conflicted = [];
|
|
498
|
+
for (const file of templateFiles) {
|
|
499
|
+
const oursPath = join3(cwd, file);
|
|
500
|
+
if (!existsSync2(oursPath)) continue;
|
|
501
|
+
const baseContent = getFileAtRef(cwd, baselineRef, file);
|
|
502
|
+
if (baseContent === null) continue;
|
|
503
|
+
let theirsContent;
|
|
504
|
+
try {
|
|
505
|
+
theirsContent = await readFile3(join3(templateDir, file), "utf-8");
|
|
506
|
+
} catch {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const oursContent = await readFile3(oursPath, "utf-8");
|
|
510
|
+
if (oursContent === baseContent) continue;
|
|
511
|
+
if (theirsContent === baseContent) continue;
|
|
512
|
+
const clean = mergeFileThreeWay(oursPath, baseContent, theirsContent);
|
|
513
|
+
if (clean) {
|
|
514
|
+
merged.push(file);
|
|
515
|
+
} else {
|
|
516
|
+
conflicted.push(file);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { merged, conflicted };
|
|
520
|
+
}
|
|
428
521
|
function createOrphanWorktree(cwd) {
|
|
429
522
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
430
523
|
const branch = `projx/tmp-${id}`;
|
|
@@ -456,7 +549,7 @@ function cleanupWorktree(cwd, worktree, branch) {
|
|
|
456
549
|
}
|
|
457
550
|
async function removeSkippedFiles(dir, skipPatterns) {
|
|
458
551
|
if (skipPatterns.length === 0) return;
|
|
459
|
-
const { readdir: readdir3, unlink } = await import("fs/promises");
|
|
552
|
+
const { readdir: readdir3, unlink: unlink2 } = await import("fs/promises");
|
|
460
553
|
const walk = async (current, base) => {
|
|
461
554
|
const entries = await readdir3(current, { withFileTypes: true });
|
|
462
555
|
for (const entry of entries) {
|
|
@@ -465,13 +558,13 @@ async function removeSkippedFiles(dir, skipPatterns) {
|
|
|
465
558
|
if (entry.isDirectory()) {
|
|
466
559
|
await walk(full, base);
|
|
467
560
|
} else if (entry.name !== ".projx-component" && matchesSkip(rel, skipPatterns)) {
|
|
468
|
-
await
|
|
561
|
+
await unlink2(full);
|
|
469
562
|
}
|
|
470
563
|
}
|
|
471
564
|
};
|
|
472
565
|
await walk(dir, dir);
|
|
473
566
|
}
|
|
474
|
-
async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips) {
|
|
567
|
+
async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip) {
|
|
475
568
|
const name = vars.projectName;
|
|
476
569
|
const nameSnake = toSnake(name);
|
|
477
570
|
for (const component of components) {
|
|
@@ -494,21 +587,34 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
|
|
|
494
587
|
}
|
|
495
588
|
await substituteNames(dest, components, componentPaths, name, nameSnake);
|
|
496
589
|
const hasBackend = components.includes("fastapi") || components.includes("fastify");
|
|
590
|
+
const skip = rootSkip ?? [];
|
|
591
|
+
const shouldWrite = (file) => !matchesSkip(file, skip);
|
|
497
592
|
if (hasBackend || components.includes("frontend")) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
593
|
+
if (shouldWrite("docker-compose.yml"))
|
|
594
|
+
await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
|
|
595
|
+
if (shouldWrite("docker-compose.dev.yml"))
|
|
596
|
+
await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
|
|
597
|
+
}
|
|
598
|
+
if (shouldWrite("README.md"))
|
|
599
|
+
await writeFile2(join3(dest, "README.md"), await generateReadme(vars));
|
|
600
|
+
if (shouldWrite(".githooks/pre-commit")) {
|
|
601
|
+
await mkdir2(join3(dest, ".githooks"), { recursive: true });
|
|
602
|
+
await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
|
|
603
|
+
await chmod(join3(dest, ".githooks/pre-commit"), 493);
|
|
604
|
+
}
|
|
605
|
+
if (shouldWrite(".github/workflows/ci.yml")) {
|
|
606
|
+
await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
|
|
607
|
+
await writeFile2(join3(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
|
|
608
|
+
}
|
|
609
|
+
if (shouldWrite("setup.sh")) {
|
|
610
|
+
await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
|
|
611
|
+
await chmod(join3(dest, "setup.sh"), 493);
|
|
612
|
+
}
|
|
509
613
|
await copyStaticFiles(repoDir, dest);
|
|
510
|
-
|
|
511
|
-
|
|
614
|
+
if (shouldWrite(".vscode/settings.json")) {
|
|
615
|
+
await mkdir2(join3(dest, ".vscode"), { recursive: true });
|
|
616
|
+
await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
|
|
617
|
+
}
|
|
512
618
|
const projxConfig = {
|
|
513
619
|
version,
|
|
514
620
|
components,
|
|
@@ -534,7 +640,7 @@ async function substituteNames(dest, components, paths, name, nameSnake) {
|
|
|
534
640
|
await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
|
|
535
641
|
}
|
|
536
642
|
}
|
|
537
|
-
async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips) {
|
|
643
|
+
async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips, rootSkip) {
|
|
538
644
|
const hasHead = (() => {
|
|
539
645
|
try {
|
|
540
646
|
execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" });
|
|
@@ -544,12 +650,12 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
|
|
|
544
650
|
}
|
|
545
651
|
})();
|
|
546
652
|
if (!hasHead) {
|
|
547
|
-
await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips);
|
|
653
|
+
await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
548
654
|
return { status: "clean" };
|
|
549
655
|
}
|
|
550
656
|
const { worktree, branch } = createOrphanWorktree(cwd);
|
|
551
657
|
try {
|
|
552
|
-
await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips);
|
|
658
|
+
await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
553
659
|
execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
|
|
554
660
|
const diff = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
|
|
555
661
|
if (!diff) {
|
|
@@ -587,9 +693,54 @@ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, ver
|
|
|
587
693
|
} catch {
|
|
588
694
|
}
|
|
589
695
|
if (mergeClean) {
|
|
696
|
+
saveBaselineRef(cwd);
|
|
590
697
|
return { status: "clean" };
|
|
591
698
|
}
|
|
592
|
-
|
|
699
|
+
const baselineRef = getBaselineRef(cwd);
|
|
700
|
+
if (baselineRef) {
|
|
701
|
+
const tmpTemplate = join3(tmpdir2(), `projx-tpl-${Date.now()}`);
|
|
702
|
+
await mkdir2(tmpTemplate, { recursive: true });
|
|
703
|
+
await writeTemplateToDir(tmpTemplate, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
704
|
+
const result = await tryThreeWayMerge(cwd, tmpTemplate, baselineRef);
|
|
705
|
+
await rm2(tmpTemplate, { recursive: true, force: true });
|
|
706
|
+
const projxConfig = {
|
|
707
|
+
version,
|
|
708
|
+
components,
|
|
709
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
|
|
710
|
+
};
|
|
711
|
+
await writeFile2(join3(cwd, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
|
|
712
|
+
if (result.conflicted.length === 0) {
|
|
713
|
+
execSync2("git add -A", { cwd, stdio: "pipe" });
|
|
714
|
+
const staged = execSync2("git diff --cached --stat", { cwd, stdio: "pipe" }).toString().trim();
|
|
715
|
+
if (staged) {
|
|
716
|
+
execSync2(
|
|
717
|
+
`git commit --no-verify -m "projx: update to template v${version} (3-way merge)"`,
|
|
718
|
+
{ cwd, stdio: "pipe" }
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
saveBaselineRef(cwd);
|
|
722
|
+
return result.merged.length > 0 ? { status: "merged", mergedFiles: result.merged } : { status: "clean" };
|
|
723
|
+
}
|
|
724
|
+
for (const f of result.conflicted) {
|
|
725
|
+
try {
|
|
726
|
+
execSync2(`git checkout -- "${f}"`, { cwd, stdio: "pipe" });
|
|
727
|
+
} catch {
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
for (const f of result.merged) {
|
|
731
|
+
try {
|
|
732
|
+
execSync2(`git add "${f}"`, { cwd, stdio: "pipe" });
|
|
733
|
+
} catch {
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
execSync2("git add .projx", { cwd, stdio: "pipe" });
|
|
737
|
+
return {
|
|
738
|
+
status: "conflicts",
|
|
739
|
+
mergedFiles: result.merged,
|
|
740
|
+
conflictedFiles: result.conflicted
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
593
744
|
return { status: "conflicts" };
|
|
594
745
|
} catch (err) {
|
|
595
746
|
cleanupWorktree(cwd, worktree, branch);
|
|
@@ -615,7 +766,7 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
615
766
|
});
|
|
616
767
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
617
768
|
try {
|
|
618
|
-
const pkg = JSON.parse(await
|
|
769
|
+
const pkg = JSON.parse(await readFile4(join4(repoDir, "cli/package.json"), "utf-8"));
|
|
619
770
|
const version = pkg.version;
|
|
620
771
|
p2.log.info(`Scaffolding project in ${dest}`);
|
|
621
772
|
if (opts.git) {
|
|
@@ -634,6 +785,7 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
634
785
|
try {
|
|
635
786
|
exec("git add -A", dest);
|
|
636
787
|
exec('git commit --no-verify -m "Initial scaffold from projx"', dest);
|
|
788
|
+
saveBaselineRef(dest);
|
|
637
789
|
} catch {
|
|
638
790
|
}
|
|
639
791
|
}
|
|
@@ -714,7 +866,7 @@ function copyEnvExamples(dest, components) {
|
|
|
714
866
|
|
|
715
867
|
// src/update.ts
|
|
716
868
|
import { existsSync as existsSync4, readFileSync } from "fs";
|
|
717
|
-
import { readFile as
|
|
869
|
+
import { readFile as readFile5, writeFile as writeFile3, unlink } from "fs/promises";
|
|
718
870
|
import { execSync as execSync3 } from "child_process";
|
|
719
871
|
import { join as join5 } from "path";
|
|
720
872
|
import * as p3 from "@clack/prompts";
|
|
@@ -736,7 +888,7 @@ async function update(cwd, localRepo) {
|
|
|
736
888
|
const configPath = join5(cwd, ".projx");
|
|
737
889
|
let config;
|
|
738
890
|
if (existsSync4(configPath)) {
|
|
739
|
-
config = JSON.parse(await
|
|
891
|
+
config = JSON.parse(await readFile5(configPath, "utf-8"));
|
|
740
892
|
p3.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
|
|
741
893
|
} else {
|
|
742
894
|
p3.log.warn("No .projx file found. Detecting components from directories.");
|
|
@@ -749,9 +901,9 @@ async function update(cwd, localRepo) {
|
|
|
749
901
|
p3.log.info(`Detected: ${detected.join(", ")}`);
|
|
750
902
|
}
|
|
751
903
|
const componentPaths = await discoverComponentPaths(cwd, config.components);
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
p3.log.info(`${c} \u2192 ${
|
|
904
|
+
for (const c of config.components) {
|
|
905
|
+
const dir = componentPaths[c];
|
|
906
|
+
p3.log.info(dir !== c ? `${c} \u2192 ${dir}/` : `${c}/`);
|
|
755
907
|
}
|
|
756
908
|
const componentSkips = {};
|
|
757
909
|
for (const component of config.components) {
|
|
@@ -770,27 +922,41 @@ async function update(cwd, localRepo) {
|
|
|
770
922
|
});
|
|
771
923
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
772
924
|
try {
|
|
773
|
-
const pkg = JSON.parse(await
|
|
925
|
+
const pkg = JSON.parse(await readFile5(join5(repoDir, "cli/package.json"), "utf-8"));
|
|
774
926
|
const version = pkg.version;
|
|
775
927
|
const name = detectProjectName(cwd, config.components, componentPaths);
|
|
776
928
|
const vars = { projectName: name, components: config.components, paths: componentPaths };
|
|
777
929
|
const spinner5 = p3.spinner();
|
|
778
930
|
spinner5.start("Applying template update");
|
|
779
|
-
const
|
|
931
|
+
const rootSkip = config.skip ?? [];
|
|
932
|
+
const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
|
|
780
933
|
spinner5.stop("Template applied.");
|
|
781
|
-
if (result.status === "
|
|
782
|
-
|
|
783
|
-
p3.log.
|
|
784
|
-
p3.
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
934
|
+
if (result.status === "merged") {
|
|
935
|
+
saveBaselineRef(cwd);
|
|
936
|
+
p3.log.success(`${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`);
|
|
937
|
+
p3.outro(`Updated to template v${version}.`);
|
|
938
|
+
} else if (result.status === "conflicts") {
|
|
939
|
+
if (result.mergedFiles && result.mergedFiles.length > 0) {
|
|
940
|
+
p3.log.success(`${result.mergedFiles.length} file(s) merged cleanly and staged.`);
|
|
941
|
+
}
|
|
942
|
+
const conflictCount = result.conflictedFiles?.length ?? 0;
|
|
943
|
+
if (conflictCount > 0) {
|
|
944
|
+
p3.log.warn(`${conflictCount} file(s) need review:`);
|
|
945
|
+
for (const f of result.conflictedFiles) {
|
|
946
|
+
p3.log.info(` ${f}`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const handled = await promptSkipLearning(cwd, componentPaths, version);
|
|
950
|
+
if (!handled) {
|
|
951
|
+
p3.log.info("");
|
|
952
|
+
p3.log.info("Review: git diff");
|
|
953
|
+
p3.log.info("Keep: git add <file>");
|
|
954
|
+
p3.log.info("Discard: git checkout -- <file>");
|
|
955
|
+
p3.log.info(`Commit: git add . && git commit -m "projx: update to v${version}"`);
|
|
956
|
+
p3.outro(`Template v${version} applied. Review with git diff.`);
|
|
957
|
+
}
|
|
793
958
|
} else {
|
|
959
|
+
saveBaselineRef(cwd);
|
|
794
960
|
p3.outro(`Updated to template v${version}.`);
|
|
795
961
|
}
|
|
796
962
|
} catch (err) {
|
|
@@ -816,6 +982,101 @@ function hasUncommittedChanges(cwd) {
|
|
|
816
982
|
return false;
|
|
817
983
|
}
|
|
818
984
|
}
|
|
985
|
+
async function promptSkipLearning(cwd, componentPaths, version) {
|
|
986
|
+
if (!process.stdin.isTTY) return false;
|
|
987
|
+
const statusOutput = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
|
|
988
|
+
if (!statusOutput) return false;
|
|
989
|
+
const entries = statusOutput.split("\n").filter(Boolean).map((line) => ({
|
|
990
|
+
status: line.slice(0, 2).trim(),
|
|
991
|
+
file: line.slice(3).trim()
|
|
992
|
+
}));
|
|
993
|
+
const changedFiles = entries.map((e) => e.file).filter((f) => {
|
|
994
|
+
const base = f.split("/").pop();
|
|
995
|
+
if (base === ".projx" || base === COMPONENT_MARKER) return false;
|
|
996
|
+
return true;
|
|
997
|
+
});
|
|
998
|
+
if (changedFiles.length === 0) return false;
|
|
999
|
+
p3.log.warn(`${changedFiles.length} template file(s) differ from your code.`);
|
|
1000
|
+
const selected = await p3.multiselect({
|
|
1001
|
+
message: "Select files to KEEP (unselected will be discarded and skipped on future updates)",
|
|
1002
|
+
options: changedFiles.map((f) => ({ value: f, label: f })),
|
|
1003
|
+
required: false
|
|
1004
|
+
});
|
|
1005
|
+
if (p3.isCancel(selected)) return false;
|
|
1006
|
+
const kept = new Set(selected);
|
|
1007
|
+
const discarded = changedFiles.filter((f) => !kept.has(f));
|
|
1008
|
+
if (discarded.length > 0) {
|
|
1009
|
+
for (const file of discarded) {
|
|
1010
|
+
const entry = entries.find((e) => e.file === file);
|
|
1011
|
+
try {
|
|
1012
|
+
if (entry?.status === "??") {
|
|
1013
|
+
await unlink(join5(cwd, file));
|
|
1014
|
+
} else {
|
|
1015
|
+
execSync3(`git checkout -- "${file}"`, { cwd, stdio: "pipe" });
|
|
1016
|
+
}
|
|
1017
|
+
} catch {
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
await learnSkips(cwd, discarded, componentPaths);
|
|
1021
|
+
p3.log.success(
|
|
1022
|
+
`Discarded ${discarded.length} file(s) and added to skip list.`
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
if (kept.size > 0) {
|
|
1026
|
+
p3.log.info(`${kept.size} file(s) kept \u2014 commit when ready:`);
|
|
1027
|
+
p3.log.info(
|
|
1028
|
+
` git add . && git commit -m "projx: update to v${version}"`
|
|
1029
|
+
);
|
|
1030
|
+
p3.outro(`Template v${version} applied.`);
|
|
1031
|
+
} else {
|
|
1032
|
+
p3.outro("All template changes discarded. Skip list updated.");
|
|
1033
|
+
}
|
|
1034
|
+
return true;
|
|
1035
|
+
}
|
|
1036
|
+
async function learnSkips(cwd, files, componentPaths) {
|
|
1037
|
+
const componentSkipAdds = {};
|
|
1038
|
+
const rootSkipAdds = [];
|
|
1039
|
+
const dirToComponent = {};
|
|
1040
|
+
for (const [component, dir] of Object.entries(componentPaths)) {
|
|
1041
|
+
dirToComponent[dir] = component;
|
|
1042
|
+
}
|
|
1043
|
+
for (const file of files) {
|
|
1044
|
+
let matched = false;
|
|
1045
|
+
for (const [dir, component] of Object.entries(dirToComponent)) {
|
|
1046
|
+
if (file.startsWith(dir + "/")) {
|
|
1047
|
+
const relative = file.slice(dir.length + 1);
|
|
1048
|
+
if (!componentSkipAdds[component]) componentSkipAdds[component] = [];
|
|
1049
|
+
componentSkipAdds[component].push(relative);
|
|
1050
|
+
matched = true;
|
|
1051
|
+
break;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (!matched) {
|
|
1055
|
+
rootSkipAdds.push(file);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
for (const [component, additions] of Object.entries(componentSkipAdds)) {
|
|
1059
|
+
const dir = componentPaths[component];
|
|
1060
|
+
const markerPath = join5(cwd, dir, COMPONENT_MARKER);
|
|
1061
|
+
try {
|
|
1062
|
+
const data = JSON.parse(await readFile5(markerPath, "utf-8"));
|
|
1063
|
+
const existing = data.skip ?? [];
|
|
1064
|
+
data.skip = [.../* @__PURE__ */ new Set([...existing, ...additions])];
|
|
1065
|
+
await writeFile3(markerPath, JSON.stringify(data, null, 2) + "\n");
|
|
1066
|
+
} catch {
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
if (rootSkipAdds.length > 0) {
|
|
1070
|
+
const configPath = join5(cwd, ".projx");
|
|
1071
|
+
try {
|
|
1072
|
+
const data = JSON.parse(await readFile5(configPath, "utf-8"));
|
|
1073
|
+
const existing = data.skip ?? [];
|
|
1074
|
+
data.skip = [.../* @__PURE__ */ new Set([...existing, ...rootSkipAdds])];
|
|
1075
|
+
await writeFile3(configPath, JSON.stringify(data, null, 2) + "\n");
|
|
1076
|
+
} catch {
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
819
1080
|
function detectProjectName(cwd, components, componentPaths) {
|
|
820
1081
|
for (const component of components) {
|
|
821
1082
|
const dir = componentPaths[component] ?? component;
|
|
@@ -836,7 +1097,7 @@ function detectProjectName(cwd, components, componentPaths) {
|
|
|
836
1097
|
|
|
837
1098
|
// src/add.ts
|
|
838
1099
|
import { copyFileSync as copyFileSync2, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
|
|
839
|
-
import { readFile as
|
|
1100
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
840
1101
|
import { join as join6 } from "path";
|
|
841
1102
|
import * as p4 from "@clack/prompts";
|
|
842
1103
|
async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
@@ -847,7 +1108,7 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
|
847
1108
|
p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
|
|
848
1109
|
process.exit(1);
|
|
849
1110
|
}
|
|
850
|
-
const config = JSON.parse(await
|
|
1111
|
+
const config = JSON.parse(await readFile6(configPath, "utf-8"));
|
|
851
1112
|
const existing = config.components;
|
|
852
1113
|
const alreadyExists = newComponents.filter((c) => existing.includes(c));
|
|
853
1114
|
if (alreadyExists.length > 0) {
|
|
@@ -874,7 +1135,7 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
|
874
1135
|
for (const c of toAdd) paths[c] = c;
|
|
875
1136
|
const name = detectProjectName2(cwd, existing, paths);
|
|
876
1137
|
const vars = { projectName: name, components: allComponents, paths };
|
|
877
|
-
const pkg = JSON.parse(await
|
|
1138
|
+
const pkg = JSON.parse(await readFile6(join6(repoDir, "cli/package.json"), "utf-8"));
|
|
878
1139
|
const version = pkg.version;
|
|
879
1140
|
const spinner5 = p4.spinner();
|
|
880
1141
|
spinner5.start("Adding components");
|
|
@@ -972,7 +1233,7 @@ function detectProjectName2(cwd, components, paths) {
|
|
|
972
1233
|
|
|
973
1234
|
// src/init.ts
|
|
974
1235
|
import { existsSync as existsSync7 } from "fs";
|
|
975
|
-
import { readFile as
|
|
1236
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
976
1237
|
import { execSync as execSync4 } from "child_process";
|
|
977
1238
|
import { join as join8 } from "path";
|
|
978
1239
|
import * as p5 from "@clack/prompts";
|
|
@@ -1108,7 +1369,7 @@ async function init(cwd, localRepo) {
|
|
|
1108
1369
|
});
|
|
1109
1370
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1110
1371
|
try {
|
|
1111
|
-
const pkg = JSON.parse(await
|
|
1372
|
+
const pkg = JSON.parse(await readFile7(join8(repoDir, "cli/package.json"), "utf-8"));
|
|
1112
1373
|
const version = pkg.version;
|
|
1113
1374
|
const applySpinner = p5.spinner();
|
|
1114
1375
|
applySpinner.start("Applying template");
|
|
@@ -1120,6 +1381,9 @@ async function init(cwd, localRepo) {
|
|
|
1120
1381
|
} catch {
|
|
1121
1382
|
}
|
|
1122
1383
|
}
|
|
1384
|
+
if (result.status === "clean" || result.status === "merged") {
|
|
1385
|
+
saveBaselineRef(cwd);
|
|
1386
|
+
}
|
|
1123
1387
|
if (result.status === "conflicts") {
|
|
1124
1388
|
p5.log.warn("Some template files differ from your code. Changes written directly.");
|
|
1125
1389
|
p5.log.info("Review changes:");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-projx",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.6",
|
|
4
4
|
"description": "Scaffold production-grade fullstack projects in seconds. FastAPI, Fastify, React, Flutter, Terraform — with auth, database, CI/CD, E2E tests, and Docker. One command, ready to deploy.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|