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