create-projx 1.1.2 → 1.3.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 +75 -15
- package/dist/index.js +473 -740
- package/package.json +13 -4
- package/src/templates/README.md.ejs +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { existsSync as
|
|
4
|
+
import { existsSync as existsSync8 } from "fs";
|
|
5
5
|
import { resolve as resolve2 } from "path";
|
|
6
6
|
|
|
7
7
|
// src/utils.ts
|
|
@@ -185,14 +185,16 @@ async function readFileOrNull(path) {
|
|
|
185
185
|
return null;
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
-
async function writeComponentMarker(dir, component) {
|
|
188
|
+
async function writeComponentMarker(dir, component, origin = "scaffold") {
|
|
189
189
|
const markerPath = join(dir, COMPONENT_MARKER);
|
|
190
190
|
let components = [component];
|
|
191
|
+
let existingOrigin = origin;
|
|
191
192
|
const existing = await readFileOrNull(markerPath);
|
|
192
193
|
if (existing) {
|
|
193
194
|
try {
|
|
194
195
|
const data = JSON.parse(existing);
|
|
195
196
|
const prev = data.components ?? (data.component ? [data.component] : []);
|
|
197
|
+
existingOrigin = data.origin ?? origin;
|
|
196
198
|
if (!prev.includes(component)) {
|
|
197
199
|
components = [...prev, component];
|
|
198
200
|
} else {
|
|
@@ -203,7 +205,7 @@ async function writeComponentMarker(dir, component) {
|
|
|
203
205
|
}
|
|
204
206
|
await writeFile(
|
|
205
207
|
markerPath,
|
|
206
|
-
JSON.stringify({ components }, null, 2) + "\n"
|
|
208
|
+
JSON.stringify({ components, origin: existingOrigin }, null, 2) + "\n"
|
|
207
209
|
);
|
|
208
210
|
}
|
|
209
211
|
async function discoverComponentPaths(cwd, components) {
|
|
@@ -265,8 +267,8 @@ function render(template, vars) {
|
|
|
265
267
|
(_, expr) => {
|
|
266
268
|
const parts = expr.split(".");
|
|
267
269
|
let val = vars;
|
|
268
|
-
for (const
|
|
269
|
-
val = val?.[
|
|
270
|
+
for (const p7 of parts) {
|
|
271
|
+
val = val?.[p7];
|
|
270
272
|
}
|
|
271
273
|
return String(val ?? "");
|
|
272
274
|
}
|
|
@@ -317,9 +319,17 @@ async function runPrompts(nameArg) {
|
|
|
317
319
|
}
|
|
318
320
|
|
|
319
321
|
// src/scaffold.ts
|
|
320
|
-
import { copyFileSync, existsSync as
|
|
321
|
-
import {
|
|
322
|
+
import { copyFileSync, existsSync as existsSync3 } from "fs";
|
|
323
|
+
import { mkdir as mkdir3, readFile as readFile3 } from "fs/promises";
|
|
324
|
+
import { join as join4 } from "path";
|
|
325
|
+
import * as p3 from "@clack/prompts";
|
|
326
|
+
|
|
327
|
+
// src/baseline.ts
|
|
328
|
+
import { existsSync as existsSync2 } from "fs";
|
|
329
|
+
import { chmod, mkdir as mkdir2, writeFile as writeFile2, rm as rm2 } from "fs/promises";
|
|
330
|
+
import { execSync as execSync2 } from "child_process";
|
|
322
331
|
import { join as join3 } from "path";
|
|
332
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
323
333
|
import * as p2 from "@clack/prompts";
|
|
324
334
|
|
|
325
335
|
// src/generators/index.ts
|
|
@@ -381,42 +391,70 @@ function generateVscodeSettings(vars) {
|
|
|
381
391
|
return JSON.stringify(settings, null, 2) + "\n";
|
|
382
392
|
}
|
|
383
393
|
|
|
384
|
-
// src/
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const nameSnake = toSnake(opts.name);
|
|
388
|
-
const paths = Object.fromEntries(
|
|
389
|
-
opts.components.map((c) => [c, c])
|
|
390
|
-
);
|
|
391
|
-
const vars = { projectName: name, components: opts.components, paths };
|
|
392
|
-
const isLocal = !!localRepo;
|
|
393
|
-
await mkdir2(dest, { recursive: true });
|
|
394
|
-
const dlSpinner = p2.spinner();
|
|
395
|
-
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
396
|
-
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
397
|
-
dlSpinner.stop("Failed.");
|
|
398
|
-
p2.log.error(String(err));
|
|
399
|
-
process.exit(1);
|
|
400
|
-
});
|
|
401
|
-
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
394
|
+
// src/baseline.ts
|
|
395
|
+
var BASELINE_BRANCH = "projx/baseline";
|
|
396
|
+
function hasBaseline(cwd) {
|
|
402
397
|
try {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
398
|
+
execSync2(`git show-ref --verify --quiet refs/heads/${BASELINE_BRANCH}`, {
|
|
399
|
+
cwd,
|
|
400
|
+
stdio: "pipe"
|
|
401
|
+
});
|
|
402
|
+
return true;
|
|
403
|
+
} catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function createWorktree(cwd, branch, orphan) {
|
|
408
|
+
const worktree = join3(tmpdir2(), `projx-baseline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
409
|
+
if (orphan) {
|
|
410
|
+
execSync2(`git worktree add --orphan -b ${branch} "${worktree}"`, {
|
|
411
|
+
cwd,
|
|
412
|
+
stdio: "pipe"
|
|
413
|
+
});
|
|
414
|
+
} else {
|
|
415
|
+
execSync2(`git worktree add "${worktree}" ${branch}`, {
|
|
416
|
+
cwd,
|
|
417
|
+
stdio: "pipe"
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return worktree;
|
|
421
|
+
}
|
|
422
|
+
function removeWorktree(cwd, worktree) {
|
|
423
|
+
try {
|
|
424
|
+
execSync2(`git worktree remove "${worktree}" --force`, {
|
|
425
|
+
cwd,
|
|
426
|
+
stdio: "pipe"
|
|
427
|
+
});
|
|
428
|
+
} catch {
|
|
429
|
+
try {
|
|
430
|
+
rm2(worktree, { recursive: true, force: true });
|
|
431
|
+
execSync2("git worktree prune", { cwd, stdio: "pipe" });
|
|
432
|
+
} catch {
|
|
433
|
+
}
|
|
406
434
|
}
|
|
407
435
|
}
|
|
408
|
-
async function
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
436
|
+
async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, origin) {
|
|
437
|
+
const name = vars.projectName;
|
|
438
|
+
const nameSnake = toSnake(name);
|
|
439
|
+
for (const component of components) {
|
|
440
|
+
const targetDir = componentPaths[component];
|
|
441
|
+
if (targetDir === component) {
|
|
442
|
+
await copyComponent(repoDir, component, dest);
|
|
443
|
+
} else {
|
|
444
|
+
await copyComponent(repoDir, component, join3(dest, "__tmp__"));
|
|
445
|
+
const { cp: cp2 } = await import("fs/promises");
|
|
446
|
+
const srcDir = join3(dest, "__tmp__", component);
|
|
447
|
+
const outDir = join3(dest, targetDir);
|
|
448
|
+
if (existsSync2(srcDir)) {
|
|
449
|
+
await cp2(srcDir, outDir, { recursive: true, force: true });
|
|
450
|
+
}
|
|
451
|
+
await rm2(join3(dest, "__tmp__"), { recursive: true, force: true });
|
|
452
|
+
}
|
|
453
|
+
await writeComponentMarker(join3(dest, targetDir), component, origin);
|
|
454
|
+
}
|
|
455
|
+
await substituteNames(dest, components, componentPaths, name, nameSnake);
|
|
456
|
+
const hasBackend = components.includes("fastapi") || components.includes("fastify");
|
|
457
|
+
if (hasBackend || components.includes("frontend")) {
|
|
420
458
|
await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
|
|
421
459
|
await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
|
|
422
460
|
}
|
|
@@ -431,131 +469,225 @@ async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
|
|
|
431
469
|
await copyStaticFiles(repoDir, dest);
|
|
432
470
|
await mkdir2(join3(dest, ".vscode"), { recursive: true });
|
|
433
471
|
await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
|
|
434
|
-
const pkg = JSON.parse(
|
|
435
|
-
await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
|
|
436
|
-
);
|
|
437
472
|
const projxConfig = {
|
|
438
|
-
version
|
|
439
|
-
components
|
|
473
|
+
version,
|
|
474
|
+
components,
|
|
440
475
|
createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (opts.git) {
|
|
445
|
-
try {
|
|
446
|
-
exec("git init", dest);
|
|
447
|
-
exec("git config core.hooksPath .githooks", dest);
|
|
448
|
-
p2.log.success("Git initialized with hooks.");
|
|
449
|
-
} catch {
|
|
450
|
-
p2.log.warn("Failed to initialize git.");
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
if (opts.install) {
|
|
454
|
-
await installDeps(dest, opts.components);
|
|
455
|
-
}
|
|
456
|
-
copyEnvExamples(dest, opts.components);
|
|
457
|
-
if (opts.git) {
|
|
458
|
-
try {
|
|
459
|
-
exec("git add -A", dest);
|
|
460
|
-
exec('git commit -m "Initial scaffold from projx"', dest);
|
|
461
|
-
p2.log.success("Initial commit created.");
|
|
462
|
-
} catch {
|
|
476
|
+
baseline: {
|
|
477
|
+
branch: BASELINE_BRANCH,
|
|
478
|
+
templateVersion: version
|
|
463
479
|
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
cd ${name}
|
|
468
|
-
./setup.sh
|
|
469
|
-
|
|
470
|
-
Like projx? Star it: https://github.com/ukanhaupa/projx`);
|
|
480
|
+
};
|
|
481
|
+
await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2) + "\n");
|
|
471
482
|
}
|
|
472
|
-
async function substituteNames(dest, components, name, nameSnake) {
|
|
483
|
+
async function substituteNames(dest, components, paths, name, nameSnake) {
|
|
473
484
|
if (components.includes("fastapi")) {
|
|
474
|
-
await replaceInFile(
|
|
475
|
-
join3(dest, "fastapi/pyproject.toml"),
|
|
476
|
-
"projx-fastapi",
|
|
477
|
-
`${name}-fastapi`
|
|
478
|
-
);
|
|
485
|
+
await replaceInFile(join3(dest, `${paths.fastapi}/pyproject.toml`), "projx-fastapi", `${name}-fastapi`);
|
|
479
486
|
}
|
|
480
487
|
if (components.includes("fastify")) {
|
|
481
|
-
await replaceInFile(
|
|
482
|
-
join3(dest, "fastify/package.json"),
|
|
483
|
-
"projx-fastify",
|
|
484
|
-
`${name}-fastify`
|
|
485
|
-
);
|
|
488
|
+
await replaceInFile(join3(dest, `${paths.fastify}/package.json`), "projx-fastify", `${name}-fastify`);
|
|
486
489
|
}
|
|
487
490
|
if (components.includes("frontend")) {
|
|
488
|
-
await replaceInFile(
|
|
489
|
-
join3(dest, "frontend/package.json"),
|
|
490
|
-
"projx-frontend",
|
|
491
|
-
`${name}-frontend`
|
|
492
|
-
);
|
|
491
|
+
await replaceInFile(join3(dest, `${paths.frontend}/package.json`), "projx-frontend", `${name}-frontend`);
|
|
493
492
|
}
|
|
494
493
|
if (components.includes("e2e")) {
|
|
495
|
-
await replaceInFile(
|
|
496
|
-
join3(dest, "e2e/package.json"),
|
|
497
|
-
"projx-e2e",
|
|
498
|
-
`${name}-e2e`
|
|
499
|
-
);
|
|
494
|
+
await replaceInFile(join3(dest, `${paths.e2e}/package.json`), "projx-e2e", `${name}-e2e`);
|
|
500
495
|
}
|
|
501
496
|
if (components.includes("mobile")) {
|
|
502
|
-
await replaceInFile(
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
497
|
+
await replaceInFile(join3(dest, `${paths.mobile}/pubspec.yaml`), "projx_mobile", `${nameSnake}_mobile`);
|
|
498
|
+
await replaceInDir(join3(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${nameSnake}_mobile/`, ".dart");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async function createBaseline(cwd, repoDir, components, componentPaths, vars, version, origin = "scaffold") {
|
|
502
|
+
const worktree = createWorktree(cwd, BASELINE_BRANCH, true);
|
|
503
|
+
try {
|
|
504
|
+
await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, origin);
|
|
505
|
+
execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
|
|
506
|
+
execSync2(
|
|
507
|
+
`git commit --no-verify -m "projx: baseline template v${version} [${components.join(", ")}]"`,
|
|
508
|
+
{ cwd: worktree, stdio: "pipe" }
|
|
506
509
|
);
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
510
|
+
} finally {
|
|
511
|
+
removeWorktree(cwd, worktree);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function updateBaseline(cwd, repoDir, components, componentPaths, vars, version) {
|
|
515
|
+
const worktree = createWorktree(cwd, BASELINE_BRANCH, false);
|
|
516
|
+
try {
|
|
517
|
+
execSync2("git rm -rf .", { cwd: worktree, stdio: "pipe" });
|
|
518
|
+
await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, "scaffold");
|
|
519
|
+
execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
|
|
520
|
+
const diff = execSync2("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
|
|
521
|
+
if (!diff) {
|
|
522
|
+
return { changed: false };
|
|
523
|
+
}
|
|
524
|
+
execSync2(
|
|
525
|
+
`git commit --no-verify -m "projx: update baseline to template v${version}"`,
|
|
526
|
+
{ cwd: worktree, stdio: "pipe" }
|
|
512
527
|
);
|
|
528
|
+
return { changed: true };
|
|
529
|
+
} finally {
|
|
530
|
+
removeWorktree(cwd, worktree);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
async function addToBaseline(cwd, repoDir, newComponents, allComponents, componentPaths, vars, version) {
|
|
534
|
+
const worktree = createWorktree(cwd, BASELINE_BRANCH, false);
|
|
535
|
+
try {
|
|
536
|
+
await writeTemplateToDir(worktree, repoDir, allComponents, componentPaths, vars, version, "scaffold");
|
|
537
|
+
execSync2("git add -A", { cwd: worktree, stdio: "pipe" });
|
|
538
|
+
execSync2(
|
|
539
|
+
`git commit --no-verify -m "projx: add ${newComponents.join(", ")} template v${version}"`,
|
|
540
|
+
{ cwd: worktree, stdio: "pipe" }
|
|
541
|
+
);
|
|
542
|
+
} finally {
|
|
543
|
+
removeWorktree(cwd, worktree);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function mergeBaseline(cwd, message, allowUnrelated = false, oursOnConflict = false) {
|
|
547
|
+
const args2 = [`git merge ${BASELINE_BRANCH}`];
|
|
548
|
+
args2.push(`-m "${message}"`);
|
|
549
|
+
if (allowUnrelated) args2.push("--allow-unrelated-histories");
|
|
550
|
+
if (oursOnConflict) {
|
|
551
|
+
try {
|
|
552
|
+
execSync2(`${args2.join(" ")} --no-commit`, { cwd, stdio: "pipe" });
|
|
553
|
+
} catch {
|
|
554
|
+
}
|
|
555
|
+
execSync2("git checkout --ours .", { cwd, stdio: "pipe" });
|
|
556
|
+
execSync2("git add -A", { cwd, stdio: "pipe" });
|
|
557
|
+
execSync2(`git commit --no-verify --no-edit -m "${message}"`, { cwd, stdio: "pipe" });
|
|
558
|
+
return { status: "clean" };
|
|
513
559
|
}
|
|
560
|
+
try {
|
|
561
|
+
execSync2(args2.join(" "), { cwd, stdio: "pipe" });
|
|
562
|
+
return { status: "clean" };
|
|
563
|
+
} catch {
|
|
564
|
+
const conflicted = execSync2("git diff --name-only --diff-filter=U", { cwd, stdio: "pipe" }).toString().trim();
|
|
565
|
+
if (!conflicted) {
|
|
566
|
+
return { status: "clean" };
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
status: "conflicts",
|
|
570
|
+
conflictedFiles: conflicted.split("\n").filter(Boolean)
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async function reconstructBaseline(cwd, repoDir, components, componentPaths, vars, version) {
|
|
575
|
+
p2.log.warn("projx/baseline branch not found. Reconstructing...");
|
|
576
|
+
await createBaseline(cwd, repoDir, components, componentPaths, vars, version);
|
|
577
|
+
mergeBaseline(
|
|
578
|
+
cwd,
|
|
579
|
+
`projx: reconstructed baseline for template v${version}`,
|
|
580
|
+
true,
|
|
581
|
+
true
|
|
582
|
+
);
|
|
583
|
+
p2.log.success("Baseline reconstructed.");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// src/scaffold.ts
|
|
587
|
+
async function scaffold(opts, dest, localRepo) {
|
|
588
|
+
const name = toKebab(opts.name);
|
|
589
|
+
const paths = Object.fromEntries(
|
|
590
|
+
opts.components.map((c) => [c, c])
|
|
591
|
+
);
|
|
592
|
+
const vars = { projectName: name, components: opts.components, paths };
|
|
593
|
+
const isLocal = !!localRepo;
|
|
594
|
+
await mkdir3(dest, { recursive: true });
|
|
595
|
+
const dlSpinner = p3.spinner();
|
|
596
|
+
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
597
|
+
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
598
|
+
dlSpinner.stop("Failed.");
|
|
599
|
+
p3.log.error(String(err));
|
|
600
|
+
process.exit(1);
|
|
601
|
+
});
|
|
602
|
+
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
603
|
+
try {
|
|
604
|
+
const pkg = JSON.parse(await readFile3(join4(repoDir, "cli/package.json"), "utf-8"));
|
|
605
|
+
const version = pkg.version;
|
|
606
|
+
p3.log.info(`Scaffolding project in ${dest}`);
|
|
607
|
+
if (opts.git) {
|
|
608
|
+
exec("git init", dest);
|
|
609
|
+
exec("git config core.hooksPath .githooks", dest);
|
|
610
|
+
const spinner5 = p3.spinner();
|
|
611
|
+
spinner5.start("Creating baseline and scaffold");
|
|
612
|
+
await createBaseline(dest, repoDir, opts.components, paths, vars, version);
|
|
613
|
+
const result = mergeBaseline(
|
|
614
|
+
dest,
|
|
615
|
+
`projx: initial scaffold from template v${version}`,
|
|
616
|
+
true
|
|
617
|
+
);
|
|
618
|
+
spinner5.stop("Scaffold complete.");
|
|
619
|
+
if (result.status === "conflicts") {
|
|
620
|
+
p3.log.warn("Unexpected conflicts during scaffold \u2014 this shouldn't happen.");
|
|
621
|
+
}
|
|
622
|
+
} else {
|
|
623
|
+
const spinner5 = p3.spinner();
|
|
624
|
+
spinner5.start("Copying template files");
|
|
625
|
+
await createBaseline(dest, repoDir, opts.components, paths, vars, version);
|
|
626
|
+
spinner5.stop("Template files copied.");
|
|
627
|
+
}
|
|
628
|
+
if (opts.install) {
|
|
629
|
+
await installDeps(dest, opts.components);
|
|
630
|
+
}
|
|
631
|
+
copyEnvExamples(dest, opts.components);
|
|
632
|
+
if (opts.git) {
|
|
633
|
+
try {
|
|
634
|
+
exec("git add -A", dest);
|
|
635
|
+
exec('git commit --no-verify -m "Initial scaffold from projx"', dest);
|
|
636
|
+
} catch {
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
} finally {
|
|
640
|
+
await cleanupRepo(repoDir, isLocal);
|
|
641
|
+
}
|
|
642
|
+
p3.outro(`Done! Next steps:
|
|
643
|
+
|
|
644
|
+
cd ${name}
|
|
645
|
+
./setup.sh
|
|
646
|
+
|
|
647
|
+
Like projx? Star it: https://github.com/ukanhaupa/projx`);
|
|
514
648
|
}
|
|
515
649
|
async function installDeps(dest, components) {
|
|
516
650
|
for (const component of components) {
|
|
517
|
-
const spinner5 =
|
|
651
|
+
const spinner5 = p3.spinner();
|
|
518
652
|
try {
|
|
519
653
|
switch (component) {
|
|
520
654
|
case "fastapi":
|
|
521
655
|
if (hasCommand("uv")) {
|
|
522
656
|
spinner5.start("Installing FastAPI dependencies (uv sync)");
|
|
523
|
-
exec("uv sync --all-extras",
|
|
657
|
+
exec("uv sync --all-extras", join4(dest, "fastapi"));
|
|
524
658
|
spinner5.stop("FastAPI dependencies installed.");
|
|
525
659
|
} else {
|
|
526
|
-
|
|
660
|
+
p3.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
527
661
|
}
|
|
528
662
|
break;
|
|
529
663
|
case "fastify":
|
|
530
664
|
if (hasCommand("pnpm")) {
|
|
531
665
|
spinner5.start("Installing Fastify dependencies (pnpm install)");
|
|
532
|
-
exec("pnpm install",
|
|
666
|
+
exec("pnpm install", join4(dest, "fastify"));
|
|
533
667
|
spinner5.stop("Fastify dependencies installed.");
|
|
534
668
|
} else {
|
|
535
669
|
spinner5.start("Installing Fastify dependencies (npm install)");
|
|
536
|
-
exec("npm install",
|
|
670
|
+
exec("npm install", join4(dest, "fastify"));
|
|
537
671
|
spinner5.stop("Fastify dependencies installed.");
|
|
538
672
|
}
|
|
539
673
|
break;
|
|
540
674
|
case "frontend":
|
|
541
675
|
spinner5.start("Installing Frontend dependencies (npm install)");
|
|
542
|
-
exec("npm install",
|
|
676
|
+
exec("npm install", join4(dest, "frontend"));
|
|
543
677
|
spinner5.stop("Frontend dependencies installed.");
|
|
544
678
|
break;
|
|
545
679
|
case "e2e":
|
|
546
680
|
spinner5.start("Installing E2E dependencies (npm install)");
|
|
547
|
-
exec("npm install",
|
|
681
|
+
exec("npm install", join4(dest, "e2e"));
|
|
548
682
|
spinner5.stop("E2E dependencies installed.");
|
|
549
683
|
break;
|
|
550
684
|
case "mobile":
|
|
551
685
|
if (hasCommand("flutter")) {
|
|
552
686
|
spinner5.start("Installing Flutter dependencies");
|
|
553
|
-
exec("flutter pub get",
|
|
687
|
+
exec("flutter pub get", join4(dest, "mobile"));
|
|
554
688
|
spinner5.stop("Flutter dependencies installed.");
|
|
555
689
|
} else {
|
|
556
|
-
|
|
557
|
-
"Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
|
|
558
|
-
);
|
|
690
|
+
p3.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
|
|
559
691
|
}
|
|
560
692
|
break;
|
|
561
693
|
case "infra":
|
|
@@ -568,9 +700,9 @@ async function installDeps(dest, components) {
|
|
|
568
700
|
}
|
|
569
701
|
function copyEnvExamples(dest, components) {
|
|
570
702
|
for (const component of components) {
|
|
571
|
-
const example =
|
|
572
|
-
const env =
|
|
573
|
-
if (
|
|
703
|
+
const example = join4(dest, component, ".env.example");
|
|
704
|
+
const env = join4(dest, component, ".env");
|
|
705
|
+
if (existsSync3(example) && !existsSync3(env)) {
|
|
574
706
|
try {
|
|
575
707
|
copyFileSync(example, env);
|
|
576
708
|
} catch {
|
|
@@ -580,66 +712,32 @@ function copyEnvExamples(dest, components) {
|
|
|
580
712
|
}
|
|
581
713
|
|
|
582
714
|
// src/update.ts
|
|
583
|
-
import { existsSync as
|
|
584
|
-
import { readFile as readFile4
|
|
585
|
-
import { execSync as
|
|
586
|
-
import { join as
|
|
587
|
-
import * as
|
|
588
|
-
var NEVER_OVERWRITE = [
|
|
589
|
-
/\.env$/,
|
|
590
|
-
/\.env\.(dev|staging|prod)$/,
|
|
591
|
-
/prisma\/migrations\//,
|
|
592
|
-
/src\/migrations\/versions\//,
|
|
593
|
-
/\.projx-component$/
|
|
594
|
-
];
|
|
595
|
-
var MERGE_DEPS = [
|
|
596
|
-
/^[^/]+\/package\.json$/,
|
|
597
|
-
/^[^/]+\/pyproject\.toml$/
|
|
598
|
-
];
|
|
599
|
-
function isGitRepo(cwd) {
|
|
600
|
-
try {
|
|
601
|
-
execSync2("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
|
|
602
|
-
return true;
|
|
603
|
-
} catch {
|
|
604
|
-
return false;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
function hasUncommittedChanges(cwd) {
|
|
608
|
-
try {
|
|
609
|
-
const status = execSync2("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
|
|
610
|
-
return status.length > 0;
|
|
611
|
-
} catch {
|
|
612
|
-
return false;
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
function branchExists(cwd, branch) {
|
|
616
|
-
try {
|
|
617
|
-
execSync2(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd, stdio: "pipe" });
|
|
618
|
-
return true;
|
|
619
|
-
} catch {
|
|
620
|
-
return false;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
function getCurrentBranch(cwd) {
|
|
624
|
-
return execSync2("git branch --show-current", { cwd, stdio: "pipe" }).toString().trim();
|
|
625
|
-
}
|
|
715
|
+
import { existsSync as existsSync4, readFileSync } from "fs";
|
|
716
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
717
|
+
import { execSync as execSync3 } from "child_process";
|
|
718
|
+
import { join as join5 } from "path";
|
|
719
|
+
import * as p4 from "@clack/prompts";
|
|
626
720
|
async function update(cwd, localRepo) {
|
|
627
|
-
|
|
721
|
+
p4.intro("projx update");
|
|
628
722
|
const isLocal = !!localRepo;
|
|
629
|
-
|
|
723
|
+
if (!isGitRepo(cwd)) {
|
|
724
|
+
p4.log.error(`projx update requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
if (hasUncommittedChanges(cwd)) {
|
|
728
|
+
p4.log.error("You have uncommitted changes. Commit or stash them first.");
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
731
|
+
const configPath = join5(cwd, ".projx");
|
|
630
732
|
let config;
|
|
631
|
-
if (
|
|
733
|
+
if (existsSync4(configPath)) {
|
|
632
734
|
config = JSON.parse(await readFile4(configPath, "utf-8"));
|
|
633
|
-
|
|
634
|
-
`Found .projx (v${config.version}, components: ${config.components.join(", ")})`
|
|
635
|
-
);
|
|
735
|
+
p4.log.info(`Found .projx (v${config.version}, components: ${config.components.join(", ")})`);
|
|
636
736
|
} else {
|
|
637
|
-
|
|
638
|
-
const detected = COMPONENTS.filter(
|
|
639
|
-
(c) => existsSync3(join4(cwd, c))
|
|
640
|
-
);
|
|
737
|
+
p4.log.warn("No .projx file found. Detecting components from directories.");
|
|
738
|
+
const detected = COMPONENTS.filter((c) => existsSync4(join5(cwd, c)));
|
|
641
739
|
if (detected.length === 0) {
|
|
642
|
-
|
|
740
|
+
p4.log.error("No projx components found. Run 'projx init' first.");
|
|
643
741
|
process.exit(1);
|
|
644
742
|
}
|
|
645
743
|
config = {
|
|
@@ -647,180 +745,93 @@ async function update(cwd, localRepo) {
|
|
|
647
745
|
components: detected,
|
|
648
746
|
createdAt: "unknown"
|
|
649
747
|
};
|
|
650
|
-
|
|
748
|
+
p4.log.info(`Detected: ${detected.join(", ")}`);
|
|
651
749
|
}
|
|
652
750
|
const componentPaths = await discoverComponentPaths(cwd, config.components);
|
|
653
751
|
const remapped = config.components.filter((c) => componentPaths[c] !== c);
|
|
654
752
|
if (remapped.length > 0) {
|
|
655
753
|
for (const c of remapped) {
|
|
656
|
-
|
|
754
|
+
p4.log.info(`${c} \u2192 ${componentPaths[c]}/`);
|
|
657
755
|
}
|
|
658
756
|
}
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
678
|
-
);
|
|
679
|
-
branchName = `projx/update-v${pkg.version}`;
|
|
680
|
-
if (branchExists(cwd, branchName)) {
|
|
681
|
-
let suffix = 1;
|
|
682
|
-
while (branchExists(cwd, `${branchName}-${suffix}`)) suffix++;
|
|
683
|
-
branchName = `${branchName}-${suffix}`;
|
|
757
|
+
const dlSpinner = p4.spinner();
|
|
758
|
+
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
759
|
+
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
760
|
+
dlSpinner.stop("Failed.");
|
|
761
|
+
p4.log.error(String(err));
|
|
762
|
+
process.exit(1);
|
|
763
|
+
});
|
|
764
|
+
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
765
|
+
try {
|
|
766
|
+
const pkg = JSON.parse(await readFile4(join5(repoDir, "cli/package.json"), "utf-8"));
|
|
767
|
+
const version = pkg.version;
|
|
768
|
+
const name = detectProjectName(cwd, config.components, componentPaths);
|
|
769
|
+
const vars = { projectName: name, components: config.components, paths: componentPaths };
|
|
770
|
+
if (!hasBaseline(cwd)) {
|
|
771
|
+
const rebuildSpinner = p4.spinner();
|
|
772
|
+
rebuildSpinner.start("Establishing baseline (first-time migration)");
|
|
773
|
+
await reconstructBaseline(cwd, repoDir, config.components, componentPaths, vars, config.version || version);
|
|
774
|
+
rebuildSpinner.stop("Baseline established.");
|
|
684
775
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
776
|
+
const updateSpinner = p4.spinner();
|
|
777
|
+
updateSpinner.start("Updating baseline to latest template");
|
|
778
|
+
const { changed } = await updateBaseline(cwd, repoDir, config.components, componentPaths, vars, version);
|
|
779
|
+
if (!changed) {
|
|
780
|
+
updateSpinner.stop("Already up to date.");
|
|
781
|
+
p4.outro("No template changes to apply.");
|
|
782
|
+
return;
|
|
692
783
|
}
|
|
693
|
-
|
|
694
|
-
|
|
784
|
+
updateSpinner.stop("Baseline updated.");
|
|
785
|
+
const mergeSpinner = p4.spinner();
|
|
786
|
+
mergeSpinner.start("Merging template changes");
|
|
787
|
+
const result = mergeBaseline(cwd, `projx: update to template v${version}`);
|
|
788
|
+
mergeSpinner.stop("Merge complete.");
|
|
789
|
+
if (result.status === "conflicts") {
|
|
790
|
+
p4.log.warn(`Merge conflicts in ${result.conflictedFiles.length} file(s):`);
|
|
791
|
+
for (const f of result.conflictedFiles) {
|
|
792
|
+
p4.log.message(` ${f}`);
|
|
793
|
+
}
|
|
794
|
+
p4.outro(
|
|
795
|
+
"Resolve conflicts, then:\n git add . && git commit\n\nOr abort:\n git merge --abort"
|
|
796
|
+
);
|
|
797
|
+
} else {
|
|
798
|
+
p4.outro(`Updated to template v${version}. All changes merged cleanly.`);
|
|
695
799
|
}
|
|
696
|
-
|
|
697
|
-
p3.outro(
|
|
698
|
-
`Updated on branch: ${branchName}
|
|
699
|
-
|
|
700
|
-
Review changes:
|
|
701
|
-
git diff ${originalBranch}...${branchName}
|
|
702
|
-
|
|
703
|
-
Switch back and merge:
|
|
704
|
-
git checkout ${originalBranch} && git merge ${branchName}`
|
|
705
|
-
);
|
|
706
|
-
} else {
|
|
707
|
-
const dlSpinner = p3.spinner();
|
|
708
|
-
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
709
|
-
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
710
|
-
dlSpinner.stop("Failed.");
|
|
711
|
-
p3.log.error(String(err));
|
|
712
|
-
process.exit(1);
|
|
713
|
-
});
|
|
714
|
-
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
715
|
-
const pkg = JSON.parse(
|
|
716
|
-
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
717
|
-
);
|
|
800
|
+
} catch (err) {
|
|
718
801
|
try {
|
|
719
|
-
|
|
720
|
-
}
|
|
721
|
-
await cleanupRepo(repoDir, isLocal);
|
|
802
|
+
execSync3("git merge --abort", { cwd, stdio: "pipe" });
|
|
803
|
+
} catch {
|
|
722
804
|
}
|
|
723
|
-
|
|
805
|
+
p4.log.error(`Update failed: ${err}`);
|
|
806
|
+
p4.log.info("Your code is safe. Run 'git merge --abort' if needed.");
|
|
807
|
+
process.exit(1);
|
|
808
|
+
} finally {
|
|
809
|
+
await cleanupRepo(repoDir, isLocal);
|
|
724
810
|
}
|
|
725
811
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
spinner6.start(`Updating ${targetDir}/ (${component})`);
|
|
741
|
-
const componentSrc = join4(repoDir, component);
|
|
742
|
-
if (!existsSync3(componentSrc)) {
|
|
743
|
-
spinner6.stop(`${component} template not found, skipping.`);
|
|
744
|
-
continue;
|
|
745
|
-
}
|
|
746
|
-
const tmpDest = join4(cwd, `.projx-tmp`);
|
|
747
|
-
const files = await copyComponent(repoDir, component, tmpDest);
|
|
748
|
-
for (const file of files) {
|
|
749
|
-
const src = join4(tmpDest, component, file);
|
|
750
|
-
const destRel = `${targetDir}/${file}`;
|
|
751
|
-
const dest = join4(cwd, destRel);
|
|
752
|
-
if (NEVER_OVERWRITE.some((re) => re.test(destRel))) continue;
|
|
753
|
-
const dir = dest.substring(0, dest.lastIndexOf("/"));
|
|
754
|
-
await mkdir3(dir, { recursive: true });
|
|
755
|
-
if (MERGE_DEPS.some((re) => re.test(destRel)) && existsSync3(dest)) {
|
|
756
|
-
const merged = await mergeDeps(dest, src);
|
|
757
|
-
if (merged) {
|
|
758
|
-
await writeFile3(dest, merged);
|
|
759
|
-
touchedFiles.push(destRel);
|
|
760
|
-
}
|
|
761
|
-
} else {
|
|
762
|
-
await cp2(src, dest, { force: true });
|
|
763
|
-
touchedFiles.push(destRel);
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
await rm2(tmpDest, { recursive: true, force: true });
|
|
767
|
-
if (!existsSync3(join4(cwd, targetDir, ".projx-component"))) {
|
|
768
|
-
await writeComponentMarker(join4(cwd, targetDir), component);
|
|
769
|
-
touchedFiles.push(`${targetDir}/.projx-component`);
|
|
770
|
-
}
|
|
771
|
-
spinner6.stop(`${targetDir}/ updated.`);
|
|
772
|
-
}
|
|
773
|
-
const spinner5 = p3.spinner();
|
|
774
|
-
spinner5.start("Updating shared files");
|
|
775
|
-
const hasBackend = config.components.includes("fastapi") || config.components.includes("fastify");
|
|
776
|
-
if (hasBackend || config.components.includes("frontend")) {
|
|
777
|
-
await writeFile3(join4(cwd, "docker-compose.yml"), await generateDockerCompose(vars));
|
|
778
|
-
touchedFiles.push("docker-compose.yml");
|
|
779
|
-
await writeFile3(join4(cwd, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
|
|
780
|
-
touchedFiles.push("docker-compose.dev.yml");
|
|
781
|
-
}
|
|
782
|
-
await mkdir3(join4(cwd, ".githooks"), { recursive: true });
|
|
783
|
-
await writeFile3(join4(cwd, ".githooks/pre-commit"), await generatePreCommit(vars));
|
|
784
|
-
await chmod2(join4(cwd, ".githooks/pre-commit"), 493);
|
|
785
|
-
touchedFiles.push(".githooks/pre-commit");
|
|
786
|
-
await mkdir3(join4(cwd, ".github/workflows"), { recursive: true });
|
|
787
|
-
await writeFile3(join4(cwd, ".github/workflows/ci.yml"), await generateCiYml(vars));
|
|
788
|
-
touchedFiles.push(".github/workflows/ci.yml");
|
|
789
|
-
await writeFile3(join4(cwd, "setup.sh"), await generateSetupSh(vars));
|
|
790
|
-
await chmod2(join4(cwd, "setup.sh"), 493);
|
|
791
|
-
touchedFiles.push("setup.sh");
|
|
792
|
-
await mkdir3(join4(cwd, ".vscode"), { recursive: true });
|
|
793
|
-
await writeFile3(join4(cwd, ".vscode/settings.json"), generateVscodeSettings(vars));
|
|
794
|
-
touchedFiles.push(".vscode/settings.json");
|
|
795
|
-
spinner5.stop("Shared files updated.");
|
|
796
|
-
if (config.components.includes("mobile")) {
|
|
797
|
-
const mobilePath = componentPaths.mobile ?? "mobile";
|
|
798
|
-
await replaceInDir(
|
|
799
|
-
join4(cwd, mobilePath),
|
|
800
|
-
"package:projx_mobile/",
|
|
801
|
-
`package:${nameSnake}_mobile/`,
|
|
802
|
-
".dart"
|
|
803
|
-
);
|
|
812
|
+
function isGitRepo(cwd) {
|
|
813
|
+
try {
|
|
814
|
+
execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
|
|
815
|
+
return true;
|
|
816
|
+
} catch {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
function hasUncommittedChanges(cwd) {
|
|
821
|
+
try {
|
|
822
|
+
const status = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
|
|
823
|
+
return status.length > 0;
|
|
824
|
+
} catch {
|
|
825
|
+
return false;
|
|
804
826
|
}
|
|
805
|
-
const updatedConfig = {
|
|
806
|
-
version,
|
|
807
|
-
components: config.components,
|
|
808
|
-
createdAt: config.createdAt,
|
|
809
|
-
paths: componentPaths
|
|
810
|
-
};
|
|
811
|
-
await writeFile3(join4(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
|
|
812
|
-
touchedFiles.push(".projx");
|
|
813
|
-
return touchedFiles;
|
|
814
827
|
}
|
|
815
828
|
function detectProjectName(cwd, components, componentPaths) {
|
|
816
829
|
for (const component of components) {
|
|
817
830
|
const dir = componentPaths[component] ?? component;
|
|
818
|
-
const pkgPath =
|
|
819
|
-
if (
|
|
831
|
+
const pkgPath = join5(cwd, dir, "package.json");
|
|
832
|
+
if (existsSync4(pkgPath)) {
|
|
820
833
|
try {
|
|
821
|
-
const pkg = JSON.parse(
|
|
822
|
-
readFileSync(pkgPath, "utf-8")
|
|
823
|
-
);
|
|
834
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
824
835
|
const n = pkg.name;
|
|
825
836
|
if (n && n.includes("-")) {
|
|
826
837
|
return n.substring(0, n.lastIndexOf("-"));
|
|
@@ -831,247 +842,136 @@ function detectProjectName(cwd, components, componentPaths) {
|
|
|
831
842
|
}
|
|
832
843
|
return toKebab(cwd.split("/").pop());
|
|
833
844
|
}
|
|
834
|
-
async function mergeDeps(existingPath, templatePath) {
|
|
835
|
-
if (existingPath.endsWith("package.json")) {
|
|
836
|
-
return mergePackageJson(existingPath, templatePath);
|
|
837
|
-
}
|
|
838
|
-
if (existingPath.endsWith("pyproject.toml")) {
|
|
839
|
-
return mergePyprojectToml(existingPath, templatePath);
|
|
840
|
-
}
|
|
841
|
-
return null;
|
|
842
|
-
}
|
|
843
|
-
async function mergePackageJson(existingPath, templatePath) {
|
|
844
|
-
const existingRaw = await readFileOrNull(existingPath);
|
|
845
|
-
const templateRaw = await readFileOrNull(templatePath);
|
|
846
|
-
if (!existingRaw || !templateRaw) return null;
|
|
847
|
-
try {
|
|
848
|
-
const existing = JSON.parse(existingRaw);
|
|
849
|
-
const template = JSON.parse(templateRaw);
|
|
850
|
-
if (template.dependencies) {
|
|
851
|
-
existing.dependencies = { ...template.dependencies, ...existing.dependencies };
|
|
852
|
-
}
|
|
853
|
-
if (template.devDependencies) {
|
|
854
|
-
existing.devDependencies = { ...template.devDependencies, ...existing.devDependencies };
|
|
855
|
-
}
|
|
856
|
-
if (template.scripts) {
|
|
857
|
-
existing.scripts = { ...template.scripts, ...existing.scripts };
|
|
858
|
-
}
|
|
859
|
-
return JSON.stringify(existing, null, 2) + "\n";
|
|
860
|
-
} catch {
|
|
861
|
-
return null;
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
async function mergePyprojectToml(existingPath, templatePath) {
|
|
865
|
-
const existingRaw = await readFileOrNull(existingPath);
|
|
866
|
-
const templateRaw = await readFileOrNull(templatePath);
|
|
867
|
-
if (!existingRaw || !templateRaw) return null;
|
|
868
|
-
const templateDeps = extractTomlDeps(templateRaw);
|
|
869
|
-
if (templateDeps.length === 0) return null;
|
|
870
|
-
const existingDeps = extractTomlDeps(existingRaw);
|
|
871
|
-
const existingNames = new Set(existingDeps.map((d) => d.replace(/[><=!~[].*/, "").trim().toLowerCase()));
|
|
872
|
-
const newDeps = templateDeps.filter((d) => {
|
|
873
|
-
const name = d.replace(/[><=!~[].*/, "").trim().toLowerCase();
|
|
874
|
-
return !existingNames.has(name);
|
|
875
|
-
});
|
|
876
|
-
if (newDeps.length === 0) return null;
|
|
877
|
-
const depsMatch = existingRaw.match(/^dependencies\s*=\s*\[([^\]]*)\]/m);
|
|
878
|
-
if (!depsMatch) return null;
|
|
879
|
-
const closingBracket = existingRaw.indexOf("]", depsMatch.index);
|
|
880
|
-
const before = existingRaw.slice(0, closingBracket);
|
|
881
|
-
const after = existingRaw.slice(closingBracket);
|
|
882
|
-
const indent = " ";
|
|
883
|
-
const newLines = newDeps.map((d) => `${indent}"${d}",`).join("\n");
|
|
884
|
-
return before.trimEnd() + "\n" + newLines + "\n" + after;
|
|
885
|
-
}
|
|
886
|
-
function extractTomlDeps(toml) {
|
|
887
|
-
const match = toml.match(/^dependencies\s*=\s*\[([\s\S]*?)\]/m);
|
|
888
|
-
if (!match) return [];
|
|
889
|
-
return match[1].split("\n").map((l) => l.trim()).filter((l) => l.startsWith('"') || l.startsWith("'")).map((l) => l.replace(/^["']|["'],?$/g, "").trim()).filter(Boolean);
|
|
890
|
-
}
|
|
891
845
|
|
|
892
846
|
// src/add.ts
|
|
893
|
-
import { copyFileSync as copyFileSync2, existsSync as
|
|
894
|
-
import {
|
|
895
|
-
import { join as
|
|
896
|
-
import * as
|
|
847
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
|
|
848
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
849
|
+
import { join as join6 } from "path";
|
|
850
|
+
import * as p5 from "@clack/prompts";
|
|
897
851
|
async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
898
|
-
|
|
852
|
+
p5.intro("projx add");
|
|
899
853
|
const isLocal = !!localRepo;
|
|
900
|
-
const configPath =
|
|
901
|
-
if (!
|
|
902
|
-
|
|
854
|
+
const configPath = join6(cwd, ".projx");
|
|
855
|
+
if (!existsSync5(configPath)) {
|
|
856
|
+
p5.log.error("No .projx file found. Run 'projx <name>' to create a project first.");
|
|
903
857
|
process.exit(1);
|
|
904
858
|
}
|
|
905
859
|
const config = JSON.parse(await readFile5(configPath, "utf-8"));
|
|
906
860
|
const existing = config.components;
|
|
907
861
|
const alreadyExists = newComponents.filter((c) => existing.includes(c));
|
|
908
862
|
if (alreadyExists.length > 0) {
|
|
909
|
-
|
|
863
|
+
p5.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
|
|
910
864
|
}
|
|
911
865
|
const toAdd = newComponents.filter((c) => !existing.includes(c));
|
|
912
866
|
if (toAdd.length === 0) {
|
|
913
|
-
|
|
867
|
+
p5.log.info("Nothing new to add.");
|
|
914
868
|
process.exit(0);
|
|
915
869
|
}
|
|
916
|
-
|
|
917
|
-
const dlSpinner =
|
|
870
|
+
p5.log.info(`Adding: ${toAdd.join(", ")}`);
|
|
871
|
+
const dlSpinner = p5.spinner();
|
|
918
872
|
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
919
873
|
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
920
874
|
dlSpinner.stop("Failed.");
|
|
921
|
-
|
|
875
|
+
p5.log.error(String(err));
|
|
922
876
|
process.exit(1);
|
|
923
877
|
});
|
|
924
878
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
925
879
|
try {
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
try {
|
|
971
|
-
copyFileSync2(example, env);
|
|
972
|
-
} catch {
|
|
880
|
+
const allComponents = [...existing, ...toAdd];
|
|
881
|
+
const existingPaths = await discoverComponentPaths(cwd, existing);
|
|
882
|
+
const paths = { ...existingPaths };
|
|
883
|
+
for (const c of toAdd) paths[c] = c;
|
|
884
|
+
const name = detectProjectName2(cwd, existing, paths);
|
|
885
|
+
const vars = { projectName: name, components: allComponents, paths };
|
|
886
|
+
const pkg = JSON.parse(await readFile5(join6(repoDir, "cli/package.json"), "utf-8"));
|
|
887
|
+
const version = pkg.version;
|
|
888
|
+
if (!hasBaseline(cwd)) {
|
|
889
|
+
const rebuildSpinner = p5.spinner();
|
|
890
|
+
rebuildSpinner.start("Establishing baseline");
|
|
891
|
+
await reconstructBaseline(
|
|
892
|
+
cwd,
|
|
893
|
+
repoDir,
|
|
894
|
+
existing,
|
|
895
|
+
existingPaths,
|
|
896
|
+
{ projectName: name, components: existing, paths: existingPaths },
|
|
897
|
+
config.version || version
|
|
898
|
+
);
|
|
899
|
+
rebuildSpinner.stop("Baseline established.");
|
|
900
|
+
}
|
|
901
|
+
const spinner5 = p5.spinner();
|
|
902
|
+
spinner5.start("Adding to baseline");
|
|
903
|
+
await addToBaseline(cwd, repoDir, toAdd, allComponents, paths, vars, version);
|
|
904
|
+
spinner5.stop("Baseline updated.");
|
|
905
|
+
const result = mergeBaseline(cwd, `projx: add ${toAdd.join(", ")} from template v${version}`);
|
|
906
|
+
if (result.status === "conflicts") {
|
|
907
|
+
p5.log.warn(`Merge conflicts in ${result.conflictedFiles.length} file(s):`);
|
|
908
|
+
for (const f of result.conflictedFiles) {
|
|
909
|
+
p5.log.message(` ${f}`);
|
|
910
|
+
}
|
|
911
|
+
p5.log.info("Resolve conflicts, then: git add . && git commit");
|
|
912
|
+
}
|
|
913
|
+
if (!skipInstall) {
|
|
914
|
+
await installDeps2(cwd, toAdd);
|
|
915
|
+
}
|
|
916
|
+
for (const component of toAdd) {
|
|
917
|
+
const example = join6(cwd, component, ".env.example");
|
|
918
|
+
const env = join6(cwd, component, ".env");
|
|
919
|
+
if (existsSync5(example) && !existsSync5(env)) {
|
|
920
|
+
try {
|
|
921
|
+
copyFileSync2(example, env);
|
|
922
|
+
} catch {
|
|
923
|
+
}
|
|
973
924
|
}
|
|
974
925
|
}
|
|
926
|
+
} finally {
|
|
927
|
+
await cleanupRepo(repoDir, isLocal);
|
|
975
928
|
}
|
|
976
|
-
|
|
977
|
-
await readFile5(join5(repoDir, "cli/package.json"), "utf-8")
|
|
978
|
-
);
|
|
979
|
-
const updatedConfig = {
|
|
980
|
-
version: pkg.version,
|
|
981
|
-
components: allComponents,
|
|
982
|
-
createdAt: config.createdAt,
|
|
983
|
-
paths
|
|
984
|
-
};
|
|
985
|
-
await writeFile4(join5(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
|
|
986
|
-
p4.outro(`Added ${toAdd.join(", ")}. Shared files updated for all ${allComponents.length} components.
|
|
929
|
+
p5.outro(`Added ${toAdd.join(", ")}.
|
|
987
930
|
|
|
988
931
|
Like projx? Star it: https://github.com/ukanhaupa/projx`);
|
|
989
932
|
}
|
|
990
|
-
async function substituteNames2(dest, components, name, nameSnake) {
|
|
991
|
-
if (components.includes("fastapi")) {
|
|
992
|
-
await replaceInFile(
|
|
993
|
-
join5(dest, "fastapi/pyproject.toml"),
|
|
994
|
-
"projx-fastapi",
|
|
995
|
-
`${name}-fastapi`
|
|
996
|
-
);
|
|
997
|
-
}
|
|
998
|
-
if (components.includes("fastify")) {
|
|
999
|
-
await replaceInFile(
|
|
1000
|
-
join5(dest, "fastify/package.json"),
|
|
1001
|
-
"projx-fastify",
|
|
1002
|
-
`${name}-fastify`
|
|
1003
|
-
);
|
|
1004
|
-
}
|
|
1005
|
-
if (components.includes("frontend")) {
|
|
1006
|
-
await replaceInFile(
|
|
1007
|
-
join5(dest, "frontend/package.json"),
|
|
1008
|
-
"projx-frontend",
|
|
1009
|
-
`${name}-frontend`
|
|
1010
|
-
);
|
|
1011
|
-
}
|
|
1012
|
-
if (components.includes("e2e")) {
|
|
1013
|
-
await replaceInFile(
|
|
1014
|
-
join5(dest, "e2e/package.json"),
|
|
1015
|
-
"projx-e2e",
|
|
1016
|
-
`${name}-e2e`
|
|
1017
|
-
);
|
|
1018
|
-
}
|
|
1019
|
-
if (components.includes("mobile")) {
|
|
1020
|
-
await replaceInFile(
|
|
1021
|
-
join5(dest, "mobile/pubspec.yaml"),
|
|
1022
|
-
"projx_mobile",
|
|
1023
|
-
`${nameSnake}_mobile`
|
|
1024
|
-
);
|
|
1025
|
-
await replaceInDir(
|
|
1026
|
-
join5(dest, "mobile"),
|
|
1027
|
-
"package:projx_mobile/",
|
|
1028
|
-
`package:${nameSnake}_mobile/`,
|
|
1029
|
-
".dart"
|
|
1030
|
-
);
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
933
|
async function installDeps2(dest, components) {
|
|
1034
934
|
for (const component of components) {
|
|
1035
|
-
const spinner5 =
|
|
935
|
+
const spinner5 = p5.spinner();
|
|
1036
936
|
try {
|
|
1037
937
|
switch (component) {
|
|
1038
938
|
case "fastapi":
|
|
1039
939
|
if (hasCommand("uv")) {
|
|
1040
940
|
spinner5.start("Installing FastAPI dependencies");
|
|
1041
|
-
exec("uv sync --all-extras",
|
|
941
|
+
exec("uv sync --all-extras", join6(dest, "fastapi"));
|
|
1042
942
|
spinner5.stop("FastAPI dependencies installed.");
|
|
1043
943
|
} else {
|
|
1044
|
-
|
|
944
|
+
p5.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
1045
945
|
}
|
|
1046
946
|
break;
|
|
1047
947
|
case "fastify":
|
|
1048
948
|
if (hasCommand("pnpm")) {
|
|
1049
949
|
spinner5.start("Installing Fastify dependencies");
|
|
1050
|
-
exec("pnpm install",
|
|
950
|
+
exec("pnpm install", join6(dest, "fastify"));
|
|
1051
951
|
spinner5.stop("Fastify dependencies installed.");
|
|
1052
952
|
} else {
|
|
1053
953
|
spinner5.start("Installing Fastify dependencies");
|
|
1054
|
-
exec("npm install",
|
|
954
|
+
exec("npm install", join6(dest, "fastify"));
|
|
1055
955
|
spinner5.stop("Fastify dependencies installed.");
|
|
1056
956
|
}
|
|
1057
957
|
break;
|
|
1058
958
|
case "frontend":
|
|
1059
959
|
spinner5.start("Installing Frontend dependencies");
|
|
1060
|
-
exec("npm install",
|
|
960
|
+
exec("npm install", join6(dest, "frontend"));
|
|
1061
961
|
spinner5.stop("Frontend dependencies installed.");
|
|
1062
962
|
break;
|
|
1063
963
|
case "e2e":
|
|
1064
964
|
spinner5.start("Installing E2E dependencies");
|
|
1065
|
-
exec("npm install",
|
|
965
|
+
exec("npm install", join6(dest, "e2e"));
|
|
1066
966
|
spinner5.stop("E2E dependencies installed.");
|
|
1067
967
|
break;
|
|
1068
968
|
case "mobile":
|
|
1069
969
|
if (hasCommand("flutter")) {
|
|
1070
970
|
spinner5.start("Installing Flutter dependencies");
|
|
1071
|
-
exec("flutter pub get",
|
|
971
|
+
exec("flutter pub get", join6(dest, "mobile"));
|
|
1072
972
|
spinner5.stop("Flutter dependencies installed.");
|
|
1073
973
|
} else {
|
|
1074
|
-
|
|
974
|
+
p5.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
|
|
1075
975
|
}
|
|
1076
976
|
break;
|
|
1077
977
|
case "infra":
|
|
@@ -1085,12 +985,10 @@ async function installDeps2(dest, components) {
|
|
|
1085
985
|
function detectProjectName2(cwd, components, paths) {
|
|
1086
986
|
for (const component of components) {
|
|
1087
987
|
const dir = paths[component] ?? component;
|
|
1088
|
-
const pkgPath =
|
|
1089
|
-
if (
|
|
988
|
+
const pkgPath = join6(cwd, dir, "package.json");
|
|
989
|
+
if (existsSync5(pkgPath)) {
|
|
1090
990
|
try {
|
|
1091
|
-
const pkg = JSON.parse(
|
|
1092
|
-
readFileSync2(pkgPath, "utf-8")
|
|
1093
|
-
);
|
|
991
|
+
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
1094
992
|
const n = pkg.name;
|
|
1095
993
|
if (n && n.includes("-")) {
|
|
1096
994
|
return n.substring(0, n.lastIndexOf("-"));
|
|
@@ -1103,22 +1001,22 @@ function detectProjectName2(cwd, components, paths) {
|
|
|
1103
1001
|
}
|
|
1104
1002
|
|
|
1105
1003
|
// src/init.ts
|
|
1106
|
-
import { existsSync as
|
|
1107
|
-
import { readFile as readFile6
|
|
1108
|
-
import { execSync as
|
|
1109
|
-
import { join as
|
|
1110
|
-
import * as
|
|
1004
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1005
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
1006
|
+
import { execSync as execSync4 } from "child_process";
|
|
1007
|
+
import { join as join8 } from "path";
|
|
1008
|
+
import * as p6 from "@clack/prompts";
|
|
1111
1009
|
|
|
1112
1010
|
// src/detect.ts
|
|
1113
|
-
import { existsSync as
|
|
1011
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1114
1012
|
import { readdir as readdir2 } from "fs/promises";
|
|
1115
|
-
import { join as
|
|
1013
|
+
import { join as join7 } from "path";
|
|
1116
1014
|
async function detectComponents(cwd) {
|
|
1117
1015
|
const results = [];
|
|
1118
1016
|
const entries = await readdir2(cwd, { withFileTypes: true });
|
|
1119
1017
|
const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)).map((e) => e.name);
|
|
1120
1018
|
for (const dir of dirs) {
|
|
1121
|
-
const full =
|
|
1019
|
+
const full = join7(cwd, dir);
|
|
1122
1020
|
const detections = await scanDirectory(full, dir);
|
|
1123
1021
|
results.push(...detections);
|
|
1124
1022
|
}
|
|
@@ -1126,7 +1024,7 @@ async function detectComponents(cwd) {
|
|
|
1126
1024
|
}
|
|
1127
1025
|
async function scanDirectory(dir, relPath) {
|
|
1128
1026
|
const results = [];
|
|
1129
|
-
const pyproject = await readFileOrNull(
|
|
1027
|
+
const pyproject = await readFileOrNull(join7(dir, "pyproject.toml"));
|
|
1130
1028
|
if (pyproject && /fastapi/i.test(pyproject)) {
|
|
1131
1029
|
results.push({
|
|
1132
1030
|
component: "fastapi",
|
|
@@ -1163,7 +1061,7 @@ async function scanDirectory(dir, relPath) {
|
|
|
1163
1061
|
});
|
|
1164
1062
|
}
|
|
1165
1063
|
}
|
|
1166
|
-
const pubspec = await readFileOrNull(
|
|
1064
|
+
const pubspec = await readFileOrNull(join7(dir, "pubspec.yaml"));
|
|
1167
1065
|
if (pubspec && /flutter:/i.test(pubspec)) {
|
|
1168
1066
|
results.push({
|
|
1169
1067
|
component: "mobile",
|
|
@@ -1172,7 +1070,7 @@ async function scanDirectory(dir, relPath) {
|
|
|
1172
1070
|
evidence: "pubspec.yaml has flutter dependency"
|
|
1173
1071
|
});
|
|
1174
1072
|
}
|
|
1175
|
-
const hasTf =
|
|
1073
|
+
const hasTf = existsSync6(join7(dir, "main.tf")) || existsSync6(join7(dir, "variables.tf")) || existsSync6(join7(dir, "stack/main.tf")) || existsSync6(join7(dir, "versions.tf"));
|
|
1176
1074
|
if (hasTf) {
|
|
1177
1075
|
results.push({
|
|
1178
1076
|
component: "infra",
|
|
@@ -1184,7 +1082,7 @@ async function scanDirectory(dir, relPath) {
|
|
|
1184
1082
|
return results;
|
|
1185
1083
|
}
|
|
1186
1084
|
async function readPkg(dir) {
|
|
1187
|
-
const content = await readFileOrNull(
|
|
1085
|
+
const content = await readFileOrNull(join7(dir, "package.json"));
|
|
1188
1086
|
if (!content) return null;
|
|
1189
1087
|
try {
|
|
1190
1088
|
return JSON.parse(content);
|
|
@@ -1193,83 +1091,23 @@ async function readPkg(dir) {
|
|
|
1193
1091
|
}
|
|
1194
1092
|
}
|
|
1195
1093
|
|
|
1196
|
-
// src/diff.ts
|
|
1197
|
-
function unifiedDiff(existing, template, label) {
|
|
1198
|
-
const a = existing.split("\n");
|
|
1199
|
-
const b = template.split("\n");
|
|
1200
|
-
const lines = [`--- existing ${label}`, `+++ template ${label}`];
|
|
1201
|
-
const lcs = computeLCS(a, b);
|
|
1202
|
-
let ai = 0;
|
|
1203
|
-
let bi = 0;
|
|
1204
|
-
for (const match of lcs) {
|
|
1205
|
-
while (ai < match.ai) lines.push(`\x1B[31m- ${a[ai++]}\x1B[0m`);
|
|
1206
|
-
while (bi < match.bi) lines.push(`\x1B[32m+ ${b[bi++]}\x1B[0m`);
|
|
1207
|
-
lines.push(` ${a[ai]}`);
|
|
1208
|
-
ai++;
|
|
1209
|
-
bi++;
|
|
1210
|
-
}
|
|
1211
|
-
while (ai < a.length) lines.push(`\x1B[31m- ${a[ai++]}\x1B[0m`);
|
|
1212
|
-
while (bi < b.length) lines.push(`\x1B[32m+ ${b[bi++]}\x1B[0m`);
|
|
1213
|
-
if (lines.length > 80) {
|
|
1214
|
-
return lines.slice(0, 80).join("\n") + `
|
|
1215
|
-
... (${lines.length - 80} more lines)`;
|
|
1216
|
-
}
|
|
1217
|
-
return lines.join("\n");
|
|
1218
|
-
}
|
|
1219
|
-
function computeLCS(a, b) {
|
|
1220
|
-
const m = a.length;
|
|
1221
|
-
const n = b.length;
|
|
1222
|
-
if (m * n > 1e5) {
|
|
1223
|
-
return simpleLCS(a, b);
|
|
1224
|
-
}
|
|
1225
|
-
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
1226
|
-
for (let i2 = m - 1; i2 >= 0; i2--) {
|
|
1227
|
-
for (let j2 = n - 1; j2 >= 0; j2--) {
|
|
1228
|
-
if (a[i2] === b[j2]) {
|
|
1229
|
-
dp[i2][j2] = dp[i2 + 1][j2 + 1] + 1;
|
|
1230
|
-
} else {
|
|
1231
|
-
dp[i2][j2] = Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
const matches = [];
|
|
1236
|
-
let i = 0;
|
|
1237
|
-
let j = 0;
|
|
1238
|
-
while (i < m && j < n) {
|
|
1239
|
-
if (a[i] === b[j]) {
|
|
1240
|
-
matches.push({ ai: i, bi: j });
|
|
1241
|
-
i++;
|
|
1242
|
-
j++;
|
|
1243
|
-
} else if (dp[i + 1]?.[j] ?? 0 >= (dp[i]?.[j + 1] ?? 0)) {
|
|
1244
|
-
i++;
|
|
1245
|
-
} else {
|
|
1246
|
-
j++;
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
return matches;
|
|
1250
|
-
}
|
|
1251
|
-
function simpleLCS(a, b) {
|
|
1252
|
-
const matches = [];
|
|
1253
|
-
let bi = 0;
|
|
1254
|
-
for (let ai = 0; ai < a.length && bi < b.length; ai++) {
|
|
1255
|
-
const idx = b.indexOf(a[ai], bi);
|
|
1256
|
-
if (idx !== -1) {
|
|
1257
|
-
matches.push({ ai, bi: idx });
|
|
1258
|
-
bi = idx + 1;
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
return matches;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
1094
|
// src/init.ts
|
|
1265
1095
|
async function init(cwd, localRepo) {
|
|
1266
|
-
|
|
1096
|
+
p6.intro("projx init");
|
|
1267
1097
|
const isLocal = !!localRepo;
|
|
1268
|
-
if (
|
|
1269
|
-
|
|
1098
|
+
if (existsSync7(join8(cwd, ".projx"))) {
|
|
1099
|
+
p6.log.error("This project is already initialized. Use 'projx update' or 'projx add' instead.");
|
|
1100
|
+
process.exit(1);
|
|
1101
|
+
}
|
|
1102
|
+
if (!isGitRepo2(cwd)) {
|
|
1103
|
+
p6.log.error(`projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
|
|
1270
1104
|
process.exit(1);
|
|
1271
1105
|
}
|
|
1272
|
-
|
|
1106
|
+
if (hasUncommittedChanges2(cwd)) {
|
|
1107
|
+
p6.log.error("You have uncommitted changes. Commit or stash them first.");
|
|
1108
|
+
process.exit(1);
|
|
1109
|
+
}
|
|
1110
|
+
const spinner5 = p6.spinner();
|
|
1273
1111
|
spinner5.start("Scanning for components");
|
|
1274
1112
|
const detected = await detectComponents(cwd);
|
|
1275
1113
|
spinner5.stop(
|
|
@@ -1282,7 +1120,7 @@ async function init(cwd, localRepo) {
|
|
|
1282
1120
|
confirmed = await manualSelect(cwd);
|
|
1283
1121
|
}
|
|
1284
1122
|
if (confirmed.length === 0) {
|
|
1285
|
-
|
|
1123
|
+
p6.log.warn("No components selected. Nothing to do.");
|
|
1286
1124
|
process.exit(0);
|
|
1287
1125
|
}
|
|
1288
1126
|
const components = confirmed.map((c) => c.component);
|
|
@@ -1291,55 +1129,51 @@ async function init(cwd, localRepo) {
|
|
|
1291
1129
|
);
|
|
1292
1130
|
const projectName = toKebab(cwd.split("/").pop());
|
|
1293
1131
|
const vars = { projectName, components, paths };
|
|
1294
|
-
const dlSpinner =
|
|
1132
|
+
const dlSpinner = p6.spinner();
|
|
1295
1133
|
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
1296
1134
|
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
1297
1135
|
dlSpinner.stop("Failed.");
|
|
1298
|
-
|
|
1136
|
+
p6.log.error(String(err));
|
|
1299
1137
|
process.exit(1);
|
|
1300
1138
|
});
|
|
1301
1139
|
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1302
1140
|
try {
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1141
|
+
const pkg = JSON.parse(await readFile6(join8(repoDir, "cli/package.json"), "utf-8"));
|
|
1142
|
+
const version = pkg.version;
|
|
1143
|
+
const baselineSpinner = p6.spinner();
|
|
1144
|
+
baselineSpinner.start("Creating template baseline");
|
|
1145
|
+
await createBaseline(cwd, repoDir, components, paths, vars, version, "init");
|
|
1146
|
+
baselineSpinner.stop("Baseline created.");
|
|
1147
|
+
const mergeSpinner = p6.spinner();
|
|
1148
|
+
mergeSpinner.start("Merging baseline (preserving your code)");
|
|
1149
|
+
mergeBaseline(
|
|
1150
|
+
cwd,
|
|
1151
|
+
`projx: adopt template v${version} as baseline`,
|
|
1152
|
+
true,
|
|
1153
|
+
true
|
|
1313
1154
|
);
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
components,
|
|
1317
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
1318
|
-
paths
|
|
1319
|
-
};
|
|
1320
|
-
await writeFile5(join7(cwd, ".projx"), JSON.stringify(projxConfig, null, 2));
|
|
1321
|
-
p5.log.success(".projx");
|
|
1322
|
-
if (isGitRepo2(cwd)) {
|
|
1155
|
+
mergeSpinner.stop("Baseline merged. Your code is preserved.");
|
|
1156
|
+
if (!existsSync7(join8(cwd, ".githooks"))) {
|
|
1323
1157
|
try {
|
|
1324
|
-
|
|
1325
|
-
|
|
1158
|
+
execSync4("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
|
|
1159
|
+
p6.log.success("Git hooks configured.");
|
|
1326
1160
|
} catch {
|
|
1327
|
-
|
|
1161
|
+
p6.log.warn("Failed to configure git hooks.");
|
|
1328
1162
|
}
|
|
1329
1163
|
}
|
|
1330
1164
|
} finally {
|
|
1331
1165
|
await cleanupRepo(repoDir, isLocal);
|
|
1332
1166
|
}
|
|
1333
|
-
|
|
1167
|
+
p6.outro("Project initialized. Run './setup.sh' to install dependencies.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
|
|
1334
1168
|
}
|
|
1335
1169
|
async function confirmDetections(detected) {
|
|
1336
1170
|
const confirmed = [];
|
|
1337
1171
|
for (const d of detected) {
|
|
1338
|
-
const yes = await
|
|
1172
|
+
const yes = await p6.confirm({
|
|
1339
1173
|
message: `Found ${LABELS[d.component].label} in ${d.directory}/ \u2014 register as "${d.component}"?`,
|
|
1340
1174
|
initialValue: true
|
|
1341
1175
|
});
|
|
1342
|
-
if (
|
|
1176
|
+
if (p6.isCancel(yes)) process.exit(0);
|
|
1343
1177
|
if (yes) {
|
|
1344
1178
|
confirmed.push({ component: d.component, directory: d.directory });
|
|
1345
1179
|
}
|
|
@@ -1347,7 +1181,7 @@ async function confirmDetections(detected) {
|
|
|
1347
1181
|
return confirmed;
|
|
1348
1182
|
}
|
|
1349
1183
|
async function manualSelect(cwd) {
|
|
1350
|
-
const selected = await
|
|
1184
|
+
const selected = await p6.multiselect({
|
|
1351
1185
|
message: "No components detected. Select manually:",
|
|
1352
1186
|
options: COMPONENTS.map((c) => ({
|
|
1353
1187
|
value: c,
|
|
@@ -1356,140 +1190,39 @@ async function manualSelect(cwd) {
|
|
|
1356
1190
|
})),
|
|
1357
1191
|
required: false
|
|
1358
1192
|
});
|
|
1359
|
-
if (
|
|
1193
|
+
if (p6.isCancel(selected)) process.exit(0);
|
|
1360
1194
|
const result = [];
|
|
1361
1195
|
for (const component of selected) {
|
|
1362
|
-
const dir = await
|
|
1196
|
+
const dir = await p6.text({
|
|
1363
1197
|
message: `Directory for ${LABELS[component].label}?`,
|
|
1364
1198
|
placeholder: component,
|
|
1365
1199
|
defaultValue: component
|
|
1366
1200
|
});
|
|
1367
|
-
if (
|
|
1368
|
-
if (!
|
|
1369
|
-
|
|
1201
|
+
if (p6.isCancel(dir)) process.exit(0);
|
|
1202
|
+
if (!existsSync7(join8(cwd, dir))) {
|
|
1203
|
+
p6.log.warn(`${dir}/ does not exist \u2014 skipping.`);
|
|
1370
1204
|
continue;
|
|
1371
1205
|
}
|
|
1372
1206
|
result.push({ component, directory: dir });
|
|
1373
1207
|
}
|
|
1374
1208
|
return result;
|
|
1375
1209
|
}
|
|
1376
|
-
async function generateSharedFiles(cwd, repoDir, vars) {
|
|
1377
|
-
const files = [];
|
|
1378
|
-
const hasBackend = vars.components.includes("fastapi") || vars.components.includes("fastify");
|
|
1379
|
-
if (hasBackend || vars.components.includes("frontend")) {
|
|
1380
|
-
files.push(
|
|
1381
|
-
{ path: "docker-compose.yml", content: await generateDockerCompose(vars) },
|
|
1382
|
-
{ path: "docker-compose.dev.yml", content: await generateDockerComposeDev(vars) }
|
|
1383
|
-
);
|
|
1384
|
-
}
|
|
1385
|
-
files.push(
|
|
1386
|
-
{ path: "README.md", content: await generateReadme(vars) },
|
|
1387
|
-
{ path: ".githooks/pre-commit", content: await generatePreCommit(vars), mode: 493 },
|
|
1388
|
-
{ path: ".github/workflows/ci.yml", content: await generateCiYml(vars) },
|
|
1389
|
-
{ path: "setup.sh", content: await generateSetupSh(vars), mode: 493 }
|
|
1390
|
-
);
|
|
1391
|
-
for (const file of files) {
|
|
1392
|
-
const dest = join7(cwd, file.path);
|
|
1393
|
-
const dir = dest.substring(0, dest.lastIndexOf("/"));
|
|
1394
|
-
if (dir !== cwd) await mkdir5(dir, { recursive: true });
|
|
1395
|
-
const existing = await readFileOrNull(dest);
|
|
1396
|
-
if (existing === null) {
|
|
1397
|
-
await writeFile5(dest, file.content);
|
|
1398
|
-
if (file.mode) await chmod4(dest, file.mode);
|
|
1399
|
-
p5.log.success(file.path);
|
|
1400
|
-
} else if (existing === file.content) {
|
|
1401
|
-
p5.log.info(`${file.path} \u2014 identical, skipped.`);
|
|
1402
|
-
} else {
|
|
1403
|
-
const action = await resolveConflict(file.path, existing, file.content);
|
|
1404
|
-
if (action === "overwrite") {
|
|
1405
|
-
await writeFile5(dest, file.content);
|
|
1406
|
-
if (file.mode) await chmod4(dest, file.mode);
|
|
1407
|
-
p5.log.success(`${file.path} \u2014 overwritten.`);
|
|
1408
|
-
} else {
|
|
1409
|
-
p5.log.info(`${file.path} \u2014 kept existing.`);
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
const statics = [".editorconfig"];
|
|
1414
|
-
for (const file of statics) {
|
|
1415
|
-
const src = join7(repoDir, file);
|
|
1416
|
-
const dest = join7(cwd, file);
|
|
1417
|
-
if (!existsSync6(src)) continue;
|
|
1418
|
-
if (!existsSync6(dest)) {
|
|
1419
|
-
await cp3(src, dest);
|
|
1420
|
-
p5.log.success(file);
|
|
1421
|
-
} else {
|
|
1422
|
-
const existing = await readFileOrNull(dest);
|
|
1423
|
-
const template = await readFileOrNull(src);
|
|
1424
|
-
if (existing === template) {
|
|
1425
|
-
p5.log.info(`${file} \u2014 identical, skipped.`);
|
|
1426
|
-
} else {
|
|
1427
|
-
const action = await resolveConflict(file, existing ?? "", template ?? "");
|
|
1428
|
-
if (action === "overwrite") {
|
|
1429
|
-
await cp3(src, dest, { force: true });
|
|
1430
|
-
p5.log.success(`${file} \u2014 overwritten.`);
|
|
1431
|
-
} else {
|
|
1432
|
-
p5.log.info(`${file} \u2014 kept existing.`);
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
const vscodeDest = join7(cwd, ".vscode");
|
|
1438
|
-
await mkdir5(vscodeDest, { recursive: true });
|
|
1439
|
-
const settingsPath = join7(vscodeDest, "settings.json");
|
|
1440
|
-
const settingsContent = generateVscodeSettings(vars);
|
|
1441
|
-
const existingSettings = await readFileOrNull(settingsPath);
|
|
1442
|
-
if (existingSettings === null) {
|
|
1443
|
-
await writeFile5(settingsPath, settingsContent);
|
|
1444
|
-
p5.log.success(".vscode/settings.json");
|
|
1445
|
-
} else if (existingSettings !== settingsContent) {
|
|
1446
|
-
const action = await resolveConflict(".vscode/settings.json", existingSettings, settingsContent);
|
|
1447
|
-
if (action === "overwrite") {
|
|
1448
|
-
await writeFile5(settingsPath, settingsContent);
|
|
1449
|
-
p5.log.success(".vscode/settings.json \u2014 overwritten.");
|
|
1450
|
-
} else {
|
|
1451
|
-
p5.log.info(".vscode/settings.json \u2014 kept existing.");
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
const extSrc = join7(repoDir, ".vscode/extensions.json");
|
|
1455
|
-
const extDest = join7(vscodeDest, "extensions.json");
|
|
1456
|
-
if (existsSync6(extSrc) && !existsSync6(extDest)) {
|
|
1457
|
-
await cp3(extSrc, extDest);
|
|
1458
|
-
p5.log.success(".vscode/extensions.json");
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
async function resolveConflict(filePath, existing, template) {
|
|
1462
|
-
let action = await p5.select({
|
|
1463
|
-
message: `${filePath} differs from projx template`,
|
|
1464
|
-
options: [
|
|
1465
|
-
{ value: "diff", label: "View diff" },
|
|
1466
|
-
{ value: "overwrite", label: "Overwrite with template" },
|
|
1467
|
-
{ value: "skip", label: "Skip (keep existing)" }
|
|
1468
|
-
]
|
|
1469
|
-
});
|
|
1470
|
-
if (p5.isCancel(action)) process.exit(0);
|
|
1471
|
-
if (action === "diff") {
|
|
1472
|
-
const diff = unifiedDiff(existing, template, filePath);
|
|
1473
|
-
p5.log.message(diff);
|
|
1474
|
-
action = await p5.select({
|
|
1475
|
-
message: `${filePath}`,
|
|
1476
|
-
options: [
|
|
1477
|
-
{ value: "overwrite", label: "Overwrite with template" },
|
|
1478
|
-
{ value: "skip", label: "Skip (keep existing)" }
|
|
1479
|
-
]
|
|
1480
|
-
});
|
|
1481
|
-
if (p5.isCancel(action)) process.exit(0);
|
|
1482
|
-
}
|
|
1483
|
-
return action;
|
|
1484
|
-
}
|
|
1485
1210
|
function isGitRepo2(cwd) {
|
|
1486
1211
|
try {
|
|
1487
|
-
|
|
1212
|
+
execSync4("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
|
|
1488
1213
|
return true;
|
|
1489
1214
|
} catch {
|
|
1490
1215
|
return false;
|
|
1491
1216
|
}
|
|
1492
1217
|
}
|
|
1218
|
+
function hasUncommittedChanges2(cwd) {
|
|
1219
|
+
try {
|
|
1220
|
+
const status = execSync4("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
|
|
1221
|
+
return status.length > 0;
|
|
1222
|
+
} catch {
|
|
1223
|
+
return false;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1493
1226
|
|
|
1494
1227
|
// src/index.ts
|
|
1495
1228
|
var args = process.argv.slice(2);
|
|
@@ -1615,7 +1348,7 @@ async function main() {
|
|
|
1615
1348
|
opts.install = options.install ?? opts.install;
|
|
1616
1349
|
}
|
|
1617
1350
|
const dest = resolve2(process.cwd(), opts.name);
|
|
1618
|
-
if (
|
|
1351
|
+
if (existsSync8(dest)) {
|
|
1619
1352
|
console.error(`Error: ${dest} already exists.`);
|
|
1620
1353
|
process.exit(1);
|
|
1621
1354
|
}
|