create-projx 1.3.4 → 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 +26 -12
- package/dist/index.js +487 -344
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,23 +87,37 @@ 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
|
-
- **
|
|
94
|
-
|
|
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.
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
Your custom files (controllers, pages, middleware) are never deleted. Files you created that don't exist in the template are always preserved.
|
|
97
|
+
|
|
98
|
+
### Skip Files
|
|
99
|
+
|
|
100
|
+
To skip component source files, add `skip` to `.projx-component`:
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"components": ["fastapi"],
|
|
105
|
+
"origin": "init",
|
|
106
|
+
"skip": ["src/**", "tests/**"]
|
|
107
|
+
}
|
|
104
108
|
```
|
|
105
109
|
|
|
106
|
-
|
|
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.
|
|
107
121
|
|
|
108
122
|
## Options
|
|
109
123
|
|
package/dist/index.js
CHANGED
|
@@ -130,11 +130,6 @@ async function copyStaticFiles(repoDir, dest) {
|
|
|
130
130
|
manifest.push(file);
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
-
const gitignore = join(tpl, ".gitignore");
|
|
134
|
-
if (existsSync(gitignore)) {
|
|
135
|
-
await cp(gitignore, join(dest, ".gitignore"));
|
|
136
|
-
manifest.push(".gitignore");
|
|
137
|
-
}
|
|
138
133
|
const extensionsJson = join(tpl, ".vscode/extensions.json");
|
|
139
134
|
if (existsSync(extensionsJson)) {
|
|
140
135
|
await mkdir(join(dest, ".vscode"), { recursive: true });
|
|
@@ -282,8 +277,8 @@ function render(template, vars) {
|
|
|
282
277
|
(_, expr) => {
|
|
283
278
|
const parts = expr.split(".");
|
|
284
279
|
let val = vars;
|
|
285
|
-
for (const
|
|
286
|
-
val = val?.[
|
|
280
|
+
for (const p6 of parts) {
|
|
281
|
+
val = val?.[p6];
|
|
287
282
|
}
|
|
288
283
|
return String(val ?? "");
|
|
289
284
|
}
|
|
@@ -335,17 +330,16 @@ async function runPrompts(nameArg) {
|
|
|
335
330
|
|
|
336
331
|
// src/scaffold.ts
|
|
337
332
|
import { copyFileSync, existsSync as existsSync3 } from "fs";
|
|
338
|
-
import { mkdir as mkdir3, readFile as
|
|
333
|
+
import { mkdir as mkdir3, readFile as readFile4 } from "fs/promises";
|
|
339
334
|
import { join as join4 } from "path";
|
|
340
|
-
import * as
|
|
335
|
+
import * as p2 from "@clack/prompts";
|
|
341
336
|
|
|
342
337
|
// src/baseline.ts
|
|
343
|
-
import { existsSync as existsSync2 } from "fs";
|
|
344
|
-
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";
|
|
345
340
|
import { execSync as execSync2 } from "child_process";
|
|
346
341
|
import { join as join3 } from "path";
|
|
347
342
|
import { tmpdir as tmpdir2 } from "os";
|
|
348
|
-
import * as p2 from "@clack/prompts";
|
|
349
343
|
|
|
350
344
|
// src/generators/index.ts
|
|
351
345
|
import { readFile as readFile2 } from "fs/promises";
|
|
@@ -407,47 +401,7 @@ function generateVscodeSettings(vars) {
|
|
|
407
401
|
}
|
|
408
402
|
|
|
409
403
|
// src/baseline.ts
|
|
410
|
-
var
|
|
411
|
-
function hasBaseline(cwd) {
|
|
412
|
-
try {
|
|
413
|
-
execSync2(`git show-ref --verify --quiet refs/heads/${BASELINE_BRANCH}`, {
|
|
414
|
-
cwd,
|
|
415
|
-
stdio: "pipe"
|
|
416
|
-
});
|
|
417
|
-
return true;
|
|
418
|
-
} catch {
|
|
419
|
-
return false;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
function createWorktree(cwd, branch, orphan) {
|
|
423
|
-
const worktree = join3(tmpdir2(), `projx-baseline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
424
|
-
if (orphan) {
|
|
425
|
-
execSync2(`git worktree add --orphan -b ${branch} "${worktree}"`, {
|
|
426
|
-
cwd,
|
|
427
|
-
stdio: "pipe"
|
|
428
|
-
});
|
|
429
|
-
} else {
|
|
430
|
-
execSync2(`git worktree add "${worktree}" ${branch}`, {
|
|
431
|
-
cwd,
|
|
432
|
-
stdio: "pipe"
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
return worktree;
|
|
436
|
-
}
|
|
437
|
-
function removeWorktree(cwd, worktree) {
|
|
438
|
-
try {
|
|
439
|
-
execSync2(`git worktree remove "${worktree}" --force`, {
|
|
440
|
-
cwd,
|
|
441
|
-
stdio: "pipe"
|
|
442
|
-
});
|
|
443
|
-
} catch {
|
|
444
|
-
try {
|
|
445
|
-
rm2(worktree, { recursive: true, force: true });
|
|
446
|
-
execSync2("git worktree prune", { cwd, stdio: "pipe" });
|
|
447
|
-
} catch {
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
}
|
|
404
|
+
var BASELINE_REF = "refs/projx/baseline";
|
|
451
405
|
function matchesSkip(filePath, patterns) {
|
|
452
406
|
for (const pattern of patterns) {
|
|
453
407
|
if (pattern === "**") return true;
|
|
@@ -472,9 +426,130 @@ function matchesSkip(filePath, patterns) {
|
|
|
472
426
|
}
|
|
473
427
|
return false;
|
|
474
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
|
+
}
|
|
521
|
+
function createOrphanWorktree(cwd) {
|
|
522
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
523
|
+
const branch = `projx/tmp-${id}`;
|
|
524
|
+
const worktree = join3(tmpdir2(), `projx-wt-${id}`);
|
|
525
|
+
try {
|
|
526
|
+
execSync2("git worktree prune", { cwd, stdio: "pipe" });
|
|
527
|
+
} catch {
|
|
528
|
+
}
|
|
529
|
+
execSync2(`git worktree add --orphan -b ${branch} "${worktree}"`, {
|
|
530
|
+
cwd,
|
|
531
|
+
stdio: "pipe"
|
|
532
|
+
});
|
|
533
|
+
return { worktree, branch };
|
|
534
|
+
}
|
|
535
|
+
function cleanupWorktree(cwd, worktree, branch) {
|
|
536
|
+
try {
|
|
537
|
+
execSync2(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
|
|
538
|
+
} catch {
|
|
539
|
+
try {
|
|
540
|
+
rm2(worktree, { recursive: true, force: true });
|
|
541
|
+
execSync2("git worktree prune", { cwd, stdio: "pipe" });
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
execSync2(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
|
|
547
|
+
} catch {
|
|
548
|
+
}
|
|
549
|
+
}
|
|
475
550
|
async function removeSkippedFiles(dir, skipPatterns) {
|
|
476
551
|
if (skipPatterns.length === 0) return;
|
|
477
|
-
const { readdir: readdir3, unlink } = await import("fs/promises");
|
|
552
|
+
const { readdir: readdir3, unlink: unlink2 } = await import("fs/promises");
|
|
478
553
|
const walk = async (current, base) => {
|
|
479
554
|
const entries = await readdir3(current, { withFileTypes: true });
|
|
480
555
|
for (const entry of entries) {
|
|
@@ -483,60 +558,67 @@ async function removeSkippedFiles(dir, skipPatterns) {
|
|
|
483
558
|
if (entry.isDirectory()) {
|
|
484
559
|
await walk(full, base);
|
|
485
560
|
} else if (entry.name !== ".projx-component" && matchesSkip(rel, skipPatterns)) {
|
|
486
|
-
await
|
|
561
|
+
await unlink2(full);
|
|
487
562
|
}
|
|
488
563
|
}
|
|
489
564
|
};
|
|
490
565
|
await walk(dir, dir);
|
|
491
566
|
}
|
|
492
|
-
async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips) {
|
|
567
|
+
async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip) {
|
|
493
568
|
const name = vars.projectName;
|
|
494
569
|
const nameSnake = toSnake(name);
|
|
495
570
|
for (const component of components) {
|
|
496
571
|
const targetDir = componentPaths[component];
|
|
497
|
-
if (targetDir === component) {
|
|
498
|
-
await copyComponent(repoDir, component, dest);
|
|
499
|
-
} else {
|
|
500
|
-
await copyComponent(repoDir, component, join3(dest, "__tmp__"));
|
|
501
|
-
const { cp: cp2 } = await import("fs/promises");
|
|
502
|
-
const srcDir = join3(dest, "__tmp__", component);
|
|
503
|
-
const outDir = join3(dest, targetDir);
|
|
504
|
-
if (existsSync2(srcDir)) {
|
|
505
|
-
await cp2(srcDir, outDir, { recursive: true, force: true });
|
|
506
|
-
}
|
|
507
|
-
await rm2(join3(dest, "__tmp__"), { recursive: true, force: true });
|
|
508
|
-
}
|
|
509
572
|
const skipPatterns = componentSkips?.[component] ?? [];
|
|
573
|
+
const tmpDir = join3(dest, "__cptmp__");
|
|
574
|
+
await copyComponent(repoDir, component, tmpDir);
|
|
575
|
+
const srcDir = join3(tmpDir, component);
|
|
510
576
|
if (skipPatterns.length > 0) {
|
|
511
|
-
await removeSkippedFiles(
|
|
577
|
+
await removeSkippedFiles(srcDir, skipPatterns);
|
|
512
578
|
}
|
|
579
|
+
const outDir = join3(dest, targetDir);
|
|
580
|
+
await mkdir2(outDir, { recursive: true });
|
|
581
|
+
const { cp: cp2 } = await import("fs/promises");
|
|
582
|
+
if (existsSync2(srcDir)) {
|
|
583
|
+
await cp2(srcDir, outDir, { recursive: true, force: true });
|
|
584
|
+
}
|
|
585
|
+
await rm2(tmpDir, { recursive: true, force: true });
|
|
513
586
|
await writeComponentMarker(join3(dest, targetDir), component, origin, skipPatterns.length > 0 ? skipPatterns : void 0);
|
|
514
587
|
}
|
|
515
588
|
await substituteNames(dest, components, componentPaths, name, nameSnake);
|
|
516
589
|
const hasBackend = components.includes("fastapi") || components.includes("fastify");
|
|
590
|
+
const skip = rootSkip ?? [];
|
|
591
|
+
const shouldWrite = (file) => !matchesSkip(file, skip);
|
|
517
592
|
if (hasBackend || components.includes("frontend")) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
+
}
|
|
529
613
|
await copyStaticFiles(repoDir, dest);
|
|
530
|
-
|
|
531
|
-
|
|
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
|
+
}
|
|
532
618
|
const projxConfig = {
|
|
533
619
|
version,
|
|
534
620
|
components,
|
|
535
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
|
|
536
|
-
baseline: {
|
|
537
|
-
branch: BASELINE_BRANCH,
|
|
538
|
-
templateVersion: version
|
|
539
|
-
}
|
|
621
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
|
|
540
622
|
};
|
|
541
623
|
await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
|
|
542
624
|
}
|
|
@@ -558,90 +640,113 @@ async function substituteNames(dest, components, paths, name, nameSnake) {
|
|
|
558
640
|
await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
|
|
559
641
|
}
|
|
560
642
|
}
|
|
561
|
-
async function
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
643
|
+
async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold", componentSkips, rootSkip) {
|
|
644
|
+
const hasHead = (() => {
|
|
645
|
+
try {
|
|
646
|
+
execSync2("git rev-parse HEAD", { cwd, stdio: "pipe" });
|
|
647
|
+
return true;
|
|
648
|
+
} catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
})();
|
|
652
|
+
if (!hasHead) {
|
|
653
|
+
await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
654
|
+
return { status: "clean" };
|
|
572
655
|
}
|
|
573
|
-
}
|
|
574
|
-
async function updateBaseline(cwd, repoDir, components, componentPaths, vars, version, componentSkips) {
|
|
575
|
-
const worktree = createWorktree(cwd, BASELINE_BRANCH, false);
|
|
656
|
+
const { worktree, branch } = createOrphanWorktree(cwd);
|
|
576
657
|
try {
|
|
577
|
-
|
|
578
|
-
await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, "scaffold", componentSkips);
|
|
658
|
+
await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin, componentSkips, rootSkip);
|
|
579
659
|
execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
|
|
580
660
|
const diff = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
|
|
581
661
|
if (!diff) {
|
|
582
|
-
|
|
662
|
+
cleanupWorktree(cwd, worktree, branch);
|
|
663
|
+
return { status: "clean" };
|
|
583
664
|
}
|
|
584
665
|
execSync2(
|
|
585
|
-
`git commit --no-verify -m "projx:
|
|
666
|
+
`git commit --no-verify -m "projx: template v${version} [${components.join(", ")}]"`,
|
|
586
667
|
{ cwd: worktree, stdio: "pipe" }
|
|
587
668
|
);
|
|
588
|
-
return { changed: true };
|
|
589
|
-
} finally {
|
|
590
|
-
removeWorktree(cwd, worktree);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
async function addToBaseline(cwd, repoDir, newComponents, allComponents, componentPaths, vars, version) {
|
|
594
|
-
const worktree = createWorktree(cwd, BASELINE_BRANCH, false);
|
|
595
|
-
try {
|
|
596
|
-
await writeTemplateToDir(worktree, repoDir, allComponents, componentPaths, vars, version, "scaffold");
|
|
597
|
-
execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
|
|
598
|
-
execSync2(
|
|
599
|
-
`git commit --no-verify -m "projx: add ${newComponents.join(", ")} template v${version}"`,
|
|
600
|
-
{ cwd: worktree, stdio: "pipe" }
|
|
601
|
-
);
|
|
602
|
-
} finally {
|
|
603
|
-
removeWorktree(cwd, worktree);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
function mergeBaseline(cwd, message, allowUnrelated = false, oursOnConflict = false) {
|
|
607
|
-
const args2 = [`git merge ${BASELINE_BRANCH}`];
|
|
608
|
-
args2.push(`-m "${message}"`);
|
|
609
|
-
if (allowUnrelated) args2.push("--allow-unrelated-histories");
|
|
610
|
-
if (oursOnConflict) {
|
|
611
669
|
try {
|
|
612
|
-
execSync2(
|
|
670
|
+
execSync2(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
|
|
613
671
|
} catch {
|
|
672
|
+
try {
|
|
673
|
+
await rm2(worktree, { recursive: true, force: true });
|
|
674
|
+
execSync2("git worktree prune", { cwd, stdio: "pipe" });
|
|
675
|
+
} catch {
|
|
676
|
+
}
|
|
614
677
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
678
|
+
let mergeClean = false;
|
|
679
|
+
try {
|
|
680
|
+
execSync2(
|
|
681
|
+
`git merge ${branch} --allow-unrelated-histories -m "projx: update to template v${version}"`,
|
|
682
|
+
{ cwd, stdio: "pipe" }
|
|
683
|
+
);
|
|
684
|
+
mergeClean = true;
|
|
685
|
+
} catch {
|
|
686
|
+
try {
|
|
687
|
+
execSync2("git merge --abort", { cwd, stdio: "pipe" });
|
|
688
|
+
} catch {
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
execSync2(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
|
|
693
|
+
} catch {
|
|
694
|
+
}
|
|
695
|
+
if (mergeClean) {
|
|
696
|
+
saveBaselineRef(cwd);
|
|
626
697
|
return { status: "clean" };
|
|
627
698
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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);
|
|
744
|
+
return { status: "conflicts" };
|
|
745
|
+
} catch (err) {
|
|
746
|
+
cleanupWorktree(cwd, worktree, branch);
|
|
747
|
+
throw err;
|
|
632
748
|
}
|
|
633
749
|
}
|
|
634
|
-
async function reconstructBaseline(cwd, repoDir, components, componentPaths, vars, version, componentSkips) {
|
|
635
|
-
p2.log.warn("projx/baseline branch not found. Reconstructing...");
|
|
636
|
-
await createBaseline(cwd, repoDir, components, componentPaths, vars, version, "scaffold", componentSkips);
|
|
637
|
-
mergeBaseline(
|
|
638
|
-
cwd,
|
|
639
|
-
`projx: reconstructed baseline for template v${version}`,
|
|
640
|
-
true,
|
|
641
|
-
true
|
|
642
|
-
);
|
|
643
|
-
p2.log.success("Baseline reconstructed.");
|
|
644
|
-
}
|
|
645
750
|
|
|
646
751
|
// src/scaffold.ts
|
|
647
752
|
async function scaffold(opts, dest, localRepo) {
|
|
@@ -652,39 +757,26 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
652
757
|
const vars = { projectName: name, components: opts.components, paths };
|
|
653
758
|
const isLocal = !!localRepo;
|
|
654
759
|
await mkdir3(dest, { recursive: true });
|
|
655
|
-
const dlSpinner =
|
|
760
|
+
const dlSpinner = p2.spinner();
|
|
656
761
|
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
657
762
|
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
658
763
|
dlSpinner.stop("Failed.");
|
|
659
|
-
|
|
764
|
+
p2.log.error(String(err));
|
|
660
765
|
process.exit(1);
|
|
661
766
|
});
|
|
662
767
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
663
768
|
try {
|
|
664
|
-
const pkg = JSON.parse(await
|
|
769
|
+
const pkg = JSON.parse(await readFile4(join4(repoDir, "cli/package.json"), "utf-8"));
|
|
665
770
|
const version = pkg.version;
|
|
666
|
-
|
|
771
|
+
p2.log.info(`Scaffolding project in ${dest}`);
|
|
667
772
|
if (opts.git) {
|
|
668
773
|
exec("git init", dest);
|
|
669
774
|
exec("git config core.hooksPath .githooks", dest);
|
|
670
|
-
const spinner5 = p3.spinner();
|
|
671
|
-
spinner5.start("Creating baseline and scaffold");
|
|
672
|
-
await createBaseline(dest, repoDir, opts.components, paths, vars, version);
|
|
673
|
-
const result = mergeBaseline(
|
|
674
|
-
dest,
|
|
675
|
-
`projx: initial scaffold from template v${version}`,
|
|
676
|
-
true
|
|
677
|
-
);
|
|
678
|
-
spinner5.stop("Scaffold complete.");
|
|
679
|
-
if (result.status === "conflicts") {
|
|
680
|
-
p3.log.warn("Unexpected conflicts during scaffold \u2014 this shouldn't happen.");
|
|
681
|
-
}
|
|
682
|
-
} else {
|
|
683
|
-
const spinner5 = p3.spinner();
|
|
684
|
-
spinner5.start("Copying template files");
|
|
685
|
-
await createBaseline(dest, repoDir, opts.components, paths, vars, version);
|
|
686
|
-
spinner5.stop("Template files copied.");
|
|
687
775
|
}
|
|
776
|
+
const spinner5 = p2.spinner();
|
|
777
|
+
spinner5.start("Scaffolding project");
|
|
778
|
+
await applyTemplate(dest, repoDir, opts.components, paths, vars, version);
|
|
779
|
+
spinner5.stop("Scaffold complete.");
|
|
688
780
|
if (opts.install) {
|
|
689
781
|
await installDeps(dest, opts.components);
|
|
690
782
|
}
|
|
@@ -693,13 +785,14 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
693
785
|
try {
|
|
694
786
|
exec("git add -A", dest);
|
|
695
787
|
exec('git commit --no-verify -m "Initial scaffold from projx"', dest);
|
|
788
|
+
saveBaselineRef(dest);
|
|
696
789
|
} catch {
|
|
697
790
|
}
|
|
698
791
|
}
|
|
699
792
|
} finally {
|
|
700
793
|
await cleanupRepo(repoDir, isLocal);
|
|
701
794
|
}
|
|
702
|
-
|
|
795
|
+
p2.outro(`Done! Next steps:
|
|
703
796
|
|
|
704
797
|
cd ${name}
|
|
705
798
|
./setup.sh
|
|
@@ -708,7 +801,7 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
708
801
|
}
|
|
709
802
|
async function installDeps(dest, components) {
|
|
710
803
|
for (const component of components) {
|
|
711
|
-
const spinner5 =
|
|
804
|
+
const spinner5 = p2.spinner();
|
|
712
805
|
try {
|
|
713
806
|
switch (component) {
|
|
714
807
|
case "fastapi":
|
|
@@ -717,7 +810,7 @@ async function installDeps(dest, components) {
|
|
|
717
810
|
exec("uv sync --all-extras", join4(dest, "fastapi"));
|
|
718
811
|
spinner5.stop("FastAPI dependencies installed.");
|
|
719
812
|
} else {
|
|
720
|
-
|
|
813
|
+
p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
721
814
|
}
|
|
722
815
|
break;
|
|
723
816
|
case "fastify":
|
|
@@ -747,7 +840,7 @@ async function installDeps(dest, components) {
|
|
|
747
840
|
exec("flutter pub get", join4(dest, "mobile"));
|
|
748
841
|
spinner5.stop("Flutter dependencies installed.");
|
|
749
842
|
} else {
|
|
750
|
-
|
|
843
|
+
p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
|
|
751
844
|
}
|
|
752
845
|
break;
|
|
753
846
|
case "infra":
|
|
@@ -773,127 +866,101 @@ function copyEnvExamples(dest, components) {
|
|
|
773
866
|
|
|
774
867
|
// src/update.ts
|
|
775
868
|
import { existsSync as existsSync4, readFileSync } from "fs";
|
|
776
|
-
import { readFile as
|
|
869
|
+
import { readFile as readFile5, writeFile as writeFile3, unlink } from "fs/promises";
|
|
777
870
|
import { execSync as execSync3 } from "child_process";
|
|
778
871
|
import { join as join5 } from "path";
|
|
779
|
-
import * as
|
|
872
|
+
import * as p3 from "@clack/prompts";
|
|
780
873
|
async function update(cwd, localRepo) {
|
|
781
|
-
|
|
874
|
+
p3.intro("projx update");
|
|
782
875
|
const isLocal = !!localRepo;
|
|
783
876
|
if (!isGitRepo(cwd)) {
|
|
784
|
-
|
|
877
|
+
p3.log.error("projx update requires a git repo.");
|
|
785
878
|
process.exit(1);
|
|
786
879
|
}
|
|
880
|
+
try {
|
|
881
|
+
execSync3("git worktree prune", { cwd, stdio: "pipe" });
|
|
882
|
+
} catch {
|
|
883
|
+
}
|
|
787
884
|
if (hasUncommittedChanges(cwd)) {
|
|
788
|
-
|
|
885
|
+
p3.log.error("You have uncommitted changes. Commit or stash them first.");
|
|
789
886
|
process.exit(1);
|
|
790
887
|
}
|
|
791
888
|
const configPath = join5(cwd, ".projx");
|
|
792
889
|
let config;
|
|
793
890
|
if (existsSync4(configPath)) {
|
|
794
|
-
config = JSON.parse(await
|
|
795
|
-
|
|
891
|
+
config = JSON.parse(await readFile5(configPath, "utf-8"));
|
|
892
|
+
p3.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
|
|
796
893
|
} else {
|
|
797
|
-
|
|
894
|
+
p3.log.warn("No .projx file found. Detecting components from directories.");
|
|
798
895
|
const detected = COMPONENTS.filter((c) => existsSync4(join5(cwd, c)));
|
|
799
896
|
if (detected.length === 0) {
|
|
800
|
-
|
|
897
|
+
p3.log.error("No projx components found. Run 'projx init' first.");
|
|
801
898
|
process.exit(1);
|
|
802
899
|
}
|
|
803
|
-
config = {
|
|
804
|
-
|
|
805
|
-
components: detected,
|
|
806
|
-
createdAt: "unknown"
|
|
807
|
-
};
|
|
808
|
-
p4.log.info(`Detected: ${detected.join(", ")}`);
|
|
900
|
+
config = { version: "0.0.0", components: detected, createdAt: "unknown" };
|
|
901
|
+
p3.log.info(`Detected: ${detected.join(", ")}`);
|
|
809
902
|
}
|
|
810
903
|
const componentPaths = await discoverComponentPaths(cwd, config.components);
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
904
|
+
for (const c of config.components) {
|
|
905
|
+
const dir = componentPaths[c];
|
|
906
|
+
p3.log.info(dir !== c ? `${c} \u2192 ${dir}/` : `${c}/`);
|
|
907
|
+
}
|
|
908
|
+
const componentSkips = {};
|
|
909
|
+
for (const component of config.components) {
|
|
910
|
+
const dir = componentPaths[component];
|
|
911
|
+
const marker = await readComponentMarker(join5(cwd, dir));
|
|
912
|
+
if (marker?.skip && marker.skip.length > 0) {
|
|
913
|
+
componentSkips[component] = marker.skip;
|
|
815
914
|
}
|
|
816
915
|
}
|
|
817
|
-
const dlSpinner =
|
|
916
|
+
const dlSpinner = p3.spinner();
|
|
818
917
|
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
819
918
|
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
820
919
|
dlSpinner.stop("Failed.");
|
|
821
|
-
|
|
920
|
+
p3.log.error(String(err));
|
|
822
921
|
process.exit(1);
|
|
823
922
|
});
|
|
824
923
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
825
924
|
try {
|
|
826
|
-
const pkg = JSON.parse(await
|
|
925
|
+
const pkg = JSON.parse(await readFile5(join5(repoDir, "cli/package.json"), "utf-8"));
|
|
827
926
|
const version = pkg.version;
|
|
828
927
|
const name = detectProjectName(cwd, config.components, componentPaths);
|
|
829
928
|
const vars = { projectName: name, components: config.components, paths: componentPaths };
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
929
|
+
const spinner5 = p3.spinner();
|
|
930
|
+
spinner5.start("Applying template update");
|
|
931
|
+
const rootSkip = config.skip ?? [];
|
|
932
|
+
const result = await applyTemplate(cwd, repoDir, config.components, componentPaths, vars, version, "scaffold", componentSkips, rootSkip);
|
|
933
|
+
spinner5.stop("Template applied.");
|
|
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.`);
|
|
838
941
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
}
|
|
846
|
-
const updateSpinner = p4.spinner();
|
|
847
|
-
updateSpinner.start("Updating baseline to latest template");
|
|
848
|
-
const { changed } = await updateBaseline(cwd, repoDir, config.components, componentPaths, vars, version, componentSkips);
|
|
849
|
-
if (!changed) {
|
|
850
|
-
updateSpinner.stop("Already up to date.");
|
|
851
|
-
p4.outro("No template changes to apply.");
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
updateSpinner.stop("Baseline updated.");
|
|
855
|
-
const mergeSpinner = p4.spinner();
|
|
856
|
-
mergeSpinner.start("Merging template changes");
|
|
857
|
-
const result = mergeBaseline(cwd, `projx: update to template v${version}`);
|
|
858
|
-
mergeSpinner.stop("Merge complete.");
|
|
859
|
-
if (result.status === "clean") {
|
|
860
|
-
const { writeFile: writeFile3 } = await import("fs/promises");
|
|
861
|
-
const updatedConfig = {
|
|
862
|
-
...config,
|
|
863
|
-
version,
|
|
864
|
-
baseline: { branch: "projx/baseline", templateVersion: version }
|
|
865
|
-
};
|
|
866
|
-
await writeFile3(join5(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2) + "\n");
|
|
867
|
-
for (const component of config.components) {
|
|
868
|
-
const dir = componentPaths[component];
|
|
869
|
-
const skip = componentSkips[component];
|
|
870
|
-
await writeComponentMarker(
|
|
871
|
-
join5(cwd, dir),
|
|
872
|
-
component,
|
|
873
|
-
skip?.includes("**") ? "init" : "scaffold",
|
|
874
|
-
skip
|
|
875
|
-
);
|
|
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
|
+
}
|
|
876
948
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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.`);
|
|
883
957
|
}
|
|
884
|
-
p4.outro(
|
|
885
|
-
"Resolve conflicts, then:\n git add . && git commit\n\nOr abort:\n git merge --abort"
|
|
886
|
-
);
|
|
887
958
|
} else {
|
|
888
|
-
|
|
959
|
+
saveBaselineRef(cwd);
|
|
960
|
+
p3.outro(`Updated to template v${version}.`);
|
|
889
961
|
}
|
|
890
962
|
} catch (err) {
|
|
891
|
-
|
|
892
|
-
execSync3("git merge --abort", { cwd, stdio: "pipe" });
|
|
893
|
-
} catch {
|
|
894
|
-
}
|
|
895
|
-
p4.log.error(`Update failed: ${err}`);
|
|
896
|
-
p4.log.info("Your code is safe. Run 'git merge --abort' if needed.");
|
|
963
|
+
p3.log.error(`Update failed: ${err}`);
|
|
897
964
|
process.exit(1);
|
|
898
965
|
} finally {
|
|
899
966
|
await cleanupRepo(repoDir, isLocal);
|
|
@@ -915,6 +982,101 @@ function hasUncommittedChanges(cwd) {
|
|
|
915
982
|
return false;
|
|
916
983
|
}
|
|
917
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
|
+
}
|
|
918
1080
|
function detectProjectName(cwd, components, componentPaths) {
|
|
919
1081
|
for (const component of components) {
|
|
920
1082
|
const dir = componentPaths[component] ?? component;
|
|
@@ -935,34 +1097,34 @@ function detectProjectName(cwd, components, componentPaths) {
|
|
|
935
1097
|
|
|
936
1098
|
// src/add.ts
|
|
937
1099
|
import { copyFileSync as copyFileSync2, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
|
|
938
|
-
import { readFile as
|
|
1100
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
939
1101
|
import { join as join6 } from "path";
|
|
940
|
-
import * as
|
|
1102
|
+
import * as p4 from "@clack/prompts";
|
|
941
1103
|
async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
942
|
-
|
|
1104
|
+
p4.intro("projx add");
|
|
943
1105
|
const isLocal = !!localRepo;
|
|
944
1106
|
const configPath = join6(cwd, ".projx");
|
|
945
1107
|
if (!existsSync5(configPath)) {
|
|
946
|
-
|
|
1108
|
+
p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
|
|
947
1109
|
process.exit(1);
|
|
948
1110
|
}
|
|
949
|
-
const config = JSON.parse(await
|
|
1111
|
+
const config = JSON.parse(await readFile6(configPath, "utf-8"));
|
|
950
1112
|
const existing = config.components;
|
|
951
1113
|
const alreadyExists = newComponents.filter((c) => existing.includes(c));
|
|
952
1114
|
if (alreadyExists.length > 0) {
|
|
953
|
-
|
|
1115
|
+
p4.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
|
|
954
1116
|
}
|
|
955
1117
|
const toAdd = newComponents.filter((c) => !existing.includes(c));
|
|
956
1118
|
if (toAdd.length === 0) {
|
|
957
|
-
|
|
1119
|
+
p4.log.info("Nothing new to add.");
|
|
958
1120
|
process.exit(0);
|
|
959
1121
|
}
|
|
960
|
-
|
|
961
|
-
const dlSpinner =
|
|
1122
|
+
p4.log.info(`Adding: ${toAdd.join(", ")}`);
|
|
1123
|
+
const dlSpinner = p4.spinner();
|
|
962
1124
|
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
963
1125
|
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
964
1126
|
dlSpinner.stop("Failed.");
|
|
965
|
-
|
|
1127
|
+
p4.log.error(String(err));
|
|
966
1128
|
process.exit(1);
|
|
967
1129
|
});
|
|
968
1130
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
@@ -973,33 +1135,12 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
|
973
1135
|
for (const c of toAdd) paths[c] = c;
|
|
974
1136
|
const name = detectProjectName2(cwd, existing, paths);
|
|
975
1137
|
const vars = { projectName: name, components: allComponents, paths };
|
|
976
|
-
const pkg = JSON.parse(await
|
|
1138
|
+
const pkg = JSON.parse(await readFile6(join6(repoDir, "cli/package.json"), "utf-8"));
|
|
977
1139
|
const version = pkg.version;
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
cwd,
|
|
983
|
-
repoDir,
|
|
984
|
-
existing,
|
|
985
|
-
existingPaths,
|
|
986
|
-
{ projectName: name, components: existing, paths: existingPaths },
|
|
987
|
-
config.version || version
|
|
988
|
-
);
|
|
989
|
-
rebuildSpinner.stop("Baseline established.");
|
|
990
|
-
}
|
|
991
|
-
const spinner5 = p5.spinner();
|
|
992
|
-
spinner5.start("Adding to baseline");
|
|
993
|
-
await addToBaseline(cwd, repoDir, toAdd, allComponents, paths, vars, version);
|
|
994
|
-
spinner5.stop("Baseline updated.");
|
|
995
|
-
const result = mergeBaseline(cwd, `projx: add ${toAdd.join(", ")} from template v${version}`);
|
|
996
|
-
if (result.status === "conflicts") {
|
|
997
|
-
p5.log.warn(`Merge conflicts in ${result.conflictedFiles.length} file(s):`);
|
|
998
|
-
for (const f of result.conflictedFiles) {
|
|
999
|
-
p5.log.message(` ${f}`);
|
|
1000
|
-
}
|
|
1001
|
-
p5.log.info("Resolve conflicts, then: git add . && git commit");
|
|
1002
|
-
}
|
|
1140
|
+
const spinner5 = p4.spinner();
|
|
1141
|
+
spinner5.start("Adding components");
|
|
1142
|
+
await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, "scaffold");
|
|
1143
|
+
spinner5.stop("Components added.");
|
|
1003
1144
|
if (!skipInstall) {
|
|
1004
1145
|
await installDeps2(cwd, toAdd);
|
|
1005
1146
|
}
|
|
@@ -1013,16 +1154,16 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
|
1013
1154
|
}
|
|
1014
1155
|
}
|
|
1015
1156
|
}
|
|
1157
|
+
p4.outro(`Added ${toAdd.join(", ")}.
|
|
1158
|
+
|
|
1159
|
+
Like projx? Star it: https://github.com/ukanhaupa/projx`);
|
|
1016
1160
|
} finally {
|
|
1017
1161
|
await cleanupRepo(repoDir, isLocal);
|
|
1018
1162
|
}
|
|
1019
|
-
p5.outro(`Added ${toAdd.join(", ")}.
|
|
1020
|
-
|
|
1021
|
-
Like projx? Star it: https://github.com/ukanhaupa/projx`);
|
|
1022
1163
|
}
|
|
1023
1164
|
async function installDeps2(dest, components) {
|
|
1024
1165
|
for (const component of components) {
|
|
1025
|
-
const spinner5 =
|
|
1166
|
+
const spinner5 = p4.spinner();
|
|
1026
1167
|
try {
|
|
1027
1168
|
switch (component) {
|
|
1028
1169
|
case "fastapi":
|
|
@@ -1031,7 +1172,7 @@ async function installDeps2(dest, components) {
|
|
|
1031
1172
|
exec("uv sync --all-extras", join6(dest, "fastapi"));
|
|
1032
1173
|
spinner5.stop("FastAPI dependencies installed.");
|
|
1033
1174
|
} else {
|
|
1034
|
-
|
|
1175
|
+
p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
1035
1176
|
}
|
|
1036
1177
|
break;
|
|
1037
1178
|
case "fastify":
|
|
@@ -1061,7 +1202,7 @@ async function installDeps2(dest, components) {
|
|
|
1061
1202
|
exec("flutter pub get", join6(dest, "mobile"));
|
|
1062
1203
|
spinner5.stop("Flutter dependencies installed.");
|
|
1063
1204
|
} else {
|
|
1064
|
-
|
|
1205
|
+
p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
|
|
1065
1206
|
}
|
|
1066
1207
|
break;
|
|
1067
1208
|
case "infra":
|
|
@@ -1092,10 +1233,10 @@ function detectProjectName2(cwd, components, paths) {
|
|
|
1092
1233
|
|
|
1093
1234
|
// src/init.ts
|
|
1094
1235
|
import { existsSync as existsSync7 } from "fs";
|
|
1095
|
-
import { readFile as
|
|
1236
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
1096
1237
|
import { execSync as execSync4 } from "child_process";
|
|
1097
1238
|
import { join as join8 } from "path";
|
|
1098
|
-
import * as
|
|
1239
|
+
import * as p5 from "@clack/prompts";
|
|
1099
1240
|
|
|
1100
1241
|
// src/detect.ts
|
|
1101
1242
|
import { existsSync as existsSync6 } from "fs";
|
|
@@ -1183,21 +1324,21 @@ async function readPkg(dir) {
|
|
|
1183
1324
|
|
|
1184
1325
|
// src/init.ts
|
|
1185
1326
|
async function init(cwd, localRepo) {
|
|
1186
|
-
|
|
1327
|
+
p5.intro("projx init");
|
|
1187
1328
|
const isLocal = !!localRepo;
|
|
1188
1329
|
if (existsSync7(join8(cwd, ".projx"))) {
|
|
1189
|
-
|
|
1330
|
+
p5.log.error("This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead.");
|
|
1190
1331
|
process.exit(1);
|
|
1191
1332
|
}
|
|
1192
1333
|
if (!isGitRepo2(cwd)) {
|
|
1193
|
-
|
|
1334
|
+
p5.log.error(`projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
|
|
1194
1335
|
process.exit(1);
|
|
1195
1336
|
}
|
|
1196
1337
|
if (hasUncommittedChanges2(cwd)) {
|
|
1197
|
-
|
|
1338
|
+
p5.log.error("You have uncommitted changes. Commit or stash them first.");
|
|
1198
1339
|
process.exit(1);
|
|
1199
1340
|
}
|
|
1200
|
-
const spinner5 =
|
|
1341
|
+
const spinner5 = p5.spinner();
|
|
1201
1342
|
spinner5.start("Scanning for components");
|
|
1202
1343
|
const detected = await detectComponents(cwd);
|
|
1203
1344
|
spinner5.stop(
|
|
@@ -1210,7 +1351,7 @@ async function init(cwd, localRepo) {
|
|
|
1210
1351
|
confirmed = await manualSelect(cwd);
|
|
1211
1352
|
}
|
|
1212
1353
|
if (confirmed.length === 0) {
|
|
1213
|
-
|
|
1354
|
+
p5.log.warn("No components selected. Nothing to do.");
|
|
1214
1355
|
process.exit(0);
|
|
1215
1356
|
}
|
|
1216
1357
|
const components = confirmed.map((c) => c.component);
|
|
@@ -1219,55 +1360,57 @@ async function init(cwd, localRepo) {
|
|
|
1219
1360
|
);
|
|
1220
1361
|
const projectName = toKebab(cwd.split("/").pop());
|
|
1221
1362
|
const vars = { projectName, components, paths };
|
|
1222
|
-
const dlSpinner =
|
|
1363
|
+
const dlSpinner = p5.spinner();
|
|
1223
1364
|
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
1224
1365
|
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
1225
1366
|
dlSpinner.stop("Failed.");
|
|
1226
|
-
|
|
1367
|
+
p5.log.error(String(err));
|
|
1227
1368
|
process.exit(1);
|
|
1228
1369
|
});
|
|
1229
1370
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1230
1371
|
try {
|
|
1231
|
-
const pkg = JSON.parse(await
|
|
1372
|
+
const pkg = JSON.parse(await readFile7(join8(repoDir, "cli/package.json"), "utf-8"));
|
|
1232
1373
|
const version = pkg.version;
|
|
1233
|
-
const
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
baselineSpinner.start("Creating template baseline");
|
|
1239
|
-
await createBaseline(cwd, repoDir, components, paths, vars, version, "init", componentSkips);
|
|
1240
|
-
baselineSpinner.stop("Baseline created.");
|
|
1241
|
-
const mergeSpinner = p6.spinner();
|
|
1242
|
-
mergeSpinner.start("Merging baseline (preserving your code)");
|
|
1243
|
-
mergeBaseline(
|
|
1244
|
-
cwd,
|
|
1245
|
-
`projx: adopt template v${version} as baseline`,
|
|
1246
|
-
true,
|
|
1247
|
-
true
|
|
1248
|
-
);
|
|
1249
|
-
mergeSpinner.stop("Baseline merged. Your code is preserved.");
|
|
1250
|
-
if (!existsSync7(join8(cwd, ".githooks"))) {
|
|
1374
|
+
const applySpinner = p5.spinner();
|
|
1375
|
+
applySpinner.start("Applying template");
|
|
1376
|
+
const result = await applyTemplate(cwd, repoDir, components, paths, vars, version, "init");
|
|
1377
|
+
applySpinner.stop("Template applied.");
|
|
1378
|
+
if (existsSync7(join8(cwd, ".githooks"))) {
|
|
1251
1379
|
try {
|
|
1252
1380
|
execSync4("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
|
|
1253
|
-
p6.log.success("Git hooks configured.");
|
|
1254
1381
|
} catch {
|
|
1255
|
-
p6.log.warn("Failed to configure git hooks.");
|
|
1256
1382
|
}
|
|
1257
1383
|
}
|
|
1384
|
+
if (result.status === "clean" || result.status === "merged") {
|
|
1385
|
+
saveBaselineRef(cwd);
|
|
1386
|
+
}
|
|
1387
|
+
if (result.status === "conflicts") {
|
|
1388
|
+
p5.log.warn("Some template files differ from your code. Changes written directly.");
|
|
1389
|
+
p5.log.info("Review changes:");
|
|
1390
|
+
p5.log.info(" git diff");
|
|
1391
|
+
p5.log.info("");
|
|
1392
|
+
p5.log.info("Keep a change: git add <file>");
|
|
1393
|
+
p5.log.info("Discard a change: git checkout -- <file>");
|
|
1394
|
+
p5.log.info('Commit when ready: git add . && git commit -m "projx: init"');
|
|
1395
|
+
p5.log.info("");
|
|
1396
|
+
p5.log.info("To skip files on future updates, add to .projx-component:");
|
|
1397
|
+
p5.log.info(' { "skip": ["src/**", "tests/**"] }');
|
|
1398
|
+
p5.outro("Template applied. Review with git diff.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
|
|
1399
|
+
} else {
|
|
1400
|
+
p5.outro("Project initialized.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
|
|
1401
|
+
}
|
|
1258
1402
|
} finally {
|
|
1259
1403
|
await cleanupRepo(repoDir, isLocal);
|
|
1260
1404
|
}
|
|
1261
|
-
p6.outro("Project initialized. Run './setup.sh' to install dependencies.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
|
|
1262
1405
|
}
|
|
1263
1406
|
async function confirmDetections(detected) {
|
|
1264
1407
|
const confirmed = [];
|
|
1265
1408
|
for (const d of detected) {
|
|
1266
|
-
const yes = await
|
|
1409
|
+
const yes = await p5.confirm({
|
|
1267
1410
|
message: `Found ${LABELS[d.component].label} in ${d.directory}/ \u2014 register as "${d.component}"?`,
|
|
1268
1411
|
initialValue: true
|
|
1269
1412
|
});
|
|
1270
|
-
if (
|
|
1413
|
+
if (p5.isCancel(yes)) process.exit(0);
|
|
1271
1414
|
if (yes) {
|
|
1272
1415
|
confirmed.push({ component: d.component, directory: d.directory });
|
|
1273
1416
|
}
|
|
@@ -1275,7 +1418,7 @@ async function confirmDetections(detected) {
|
|
|
1275
1418
|
return confirmed;
|
|
1276
1419
|
}
|
|
1277
1420
|
async function manualSelect(cwd) {
|
|
1278
|
-
const selected = await
|
|
1421
|
+
const selected = await p5.multiselect({
|
|
1279
1422
|
message: "No components detected. Select manually:",
|
|
1280
1423
|
options: COMPONENTS.map((c) => ({
|
|
1281
1424
|
value: c,
|
|
@@ -1284,17 +1427,17 @@ async function manualSelect(cwd) {
|
|
|
1284
1427
|
})),
|
|
1285
1428
|
required: false
|
|
1286
1429
|
});
|
|
1287
|
-
if (
|
|
1430
|
+
if (p5.isCancel(selected)) process.exit(0);
|
|
1288
1431
|
const result = [];
|
|
1289
1432
|
for (const component of selected) {
|
|
1290
|
-
const dir = await
|
|
1433
|
+
const dir = await p5.text({
|
|
1291
1434
|
message: `Directory for ${LABELS[component].label}?`,
|
|
1292
1435
|
placeholder: component,
|
|
1293
1436
|
defaultValue: component
|
|
1294
1437
|
});
|
|
1295
|
-
if (
|
|
1438
|
+
if (p5.isCancel(dir)) process.exit(0);
|
|
1296
1439
|
if (!existsSync7(join8(cwd, dir))) {
|
|
1297
|
-
|
|
1440
|
+
p5.log.warn(`${dir}/ does not exist \u2014 skipping.`);
|
|
1298
1441
|
continue;
|
|
1299
1442
|
}
|
|
1300
1443
|
result.push({ component, directory: dir });
|
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": {
|