create-projx 1.0.1 → 1.1.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/dist/index.js +616 -125
- package/package.json +14 -2
- package/src/templates/README.md.ejs +13 -9
- package/src/templates/ci.yml.ejs +81 -9
- package/src/templates/docker-compose.dev.yml.ejs +8 -8
- package/src/templates/docker-compose.yml.ejs +7 -7
- package/src/templates/pre-commit.ejs +31 -31
- package/src/templates/setup.sh.ejs +5 -5
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 existsSync7 } from "fs";
|
|
5
5
|
import { resolve as resolve2 } from "path";
|
|
6
6
|
|
|
7
7
|
// src/utils.ts
|
|
@@ -122,7 +122,7 @@ async function copyComponent(repoDir, component, dest) {
|
|
|
122
122
|
async function copyStaticFiles(repoDir, dest) {
|
|
123
123
|
const manifest = [];
|
|
124
124
|
const tpl = repoDir;
|
|
125
|
-
const statics = [".editorconfig"
|
|
125
|
+
const statics = [".editorconfig"];
|
|
126
126
|
for (const file of statics) {
|
|
127
127
|
const src = join(tpl, file);
|
|
128
128
|
if (existsSync(src)) {
|
|
@@ -135,10 +135,11 @@ async function copyStaticFiles(repoDir, dest) {
|
|
|
135
135
|
await cp(gitignore, join(dest, ".gitignore"));
|
|
136
136
|
manifest.push(".gitignore");
|
|
137
137
|
}
|
|
138
|
-
const
|
|
139
|
-
if (existsSync(
|
|
140
|
-
await
|
|
141
|
-
|
|
138
|
+
const extensionsJson = join(tpl, ".vscode/extensions.json");
|
|
139
|
+
if (existsSync(extensionsJson)) {
|
|
140
|
+
await mkdir(join(dest, ".vscode"), { recursive: true });
|
|
141
|
+
await cp(extensionsJson, join(dest, ".vscode/extensions.json"));
|
|
142
|
+
manifest.push(".vscode/extensions.json");
|
|
142
143
|
}
|
|
143
144
|
const scripts = join(tpl, "scripts");
|
|
144
145
|
if (existsSync(scripts)) {
|
|
@@ -176,6 +177,65 @@ async function replaceInDir(dir, find, replace, ext) {
|
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
}
|
|
180
|
+
var COMPONENT_MARKER = ".projx-component";
|
|
181
|
+
async function readFileOrNull(path) {
|
|
182
|
+
try {
|
|
183
|
+
return await readFile(path, "utf-8");
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function writeComponentMarker(dir, component) {
|
|
189
|
+
const markerPath = join(dir, COMPONENT_MARKER);
|
|
190
|
+
let components = [component];
|
|
191
|
+
const existing = await readFileOrNull(markerPath);
|
|
192
|
+
if (existing) {
|
|
193
|
+
try {
|
|
194
|
+
const data = JSON.parse(existing);
|
|
195
|
+
const prev = data.components ?? (data.component ? [data.component] : []);
|
|
196
|
+
if (!prev.includes(component)) {
|
|
197
|
+
components = [...prev, component];
|
|
198
|
+
} else {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await writeFile(
|
|
205
|
+
markerPath,
|
|
206
|
+
JSON.stringify({ components }, null, 2) + "\n"
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
async function discoverComponentPaths(cwd, components) {
|
|
210
|
+
const paths = {};
|
|
211
|
+
const scan = async (dir) => {
|
|
212
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (!entry.isDirectory()) continue;
|
|
215
|
+
if (EXCLUDE.has(entry.name)) continue;
|
|
216
|
+
if (entry.name.startsWith(".")) continue;
|
|
217
|
+
const full = join(dir, entry.name);
|
|
218
|
+
const marker = join(full, COMPONENT_MARKER);
|
|
219
|
+
if (existsSync(marker)) {
|
|
220
|
+
try {
|
|
221
|
+
const data = JSON.parse(await readFile(marker, "utf-8"));
|
|
222
|
+
const markerComponents = data.components ?? (data.component ? [data.component] : []);
|
|
223
|
+
for (const mc of markerComponents) {
|
|
224
|
+
if (components.includes(mc)) {
|
|
225
|
+
paths[mc] = entry.name;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
await scan(cwd);
|
|
234
|
+
for (const c of components) {
|
|
235
|
+
if (!paths[c]) paths[c] = c;
|
|
236
|
+
}
|
|
237
|
+
return paths;
|
|
238
|
+
}
|
|
179
239
|
function render(template, vars) {
|
|
180
240
|
const components = vars.components;
|
|
181
241
|
const projectName = vars.projectName;
|
|
@@ -201,8 +261,15 @@ function render(template, vars) {
|
|
|
201
261
|
}
|
|
202
262
|
if (stack.length > 0 && stack.some((v) => !v)) continue;
|
|
203
263
|
const replaced = line.replace(
|
|
204
|
-
/<%=\s*(\w+)\s*%>/g,
|
|
205
|
-
(_,
|
|
264
|
+
/<%=\s*([\w.]+)\s*%>/g,
|
|
265
|
+
(_, expr) => {
|
|
266
|
+
const parts = expr.split(".");
|
|
267
|
+
let val = vars;
|
|
268
|
+
for (const p6 of parts) {
|
|
269
|
+
val = val?.[p6];
|
|
270
|
+
}
|
|
271
|
+
return String(val ?? "");
|
|
272
|
+
}
|
|
206
273
|
);
|
|
207
274
|
output.push(replaced);
|
|
208
275
|
}
|
|
@@ -283,12 +350,45 @@ async function generateCiYml(vars) {
|
|
|
283
350
|
async function generateReadme(vars) {
|
|
284
351
|
return renderShared("README.md.ejs", vars);
|
|
285
352
|
}
|
|
353
|
+
function generateVscodeSettings(vars) {
|
|
354
|
+
const settings = {};
|
|
355
|
+
if (vars.components.includes("fastapi")) {
|
|
356
|
+
settings["[python]"] = {
|
|
357
|
+
"editor.defaultFormatter": "charliermarsh.ruff",
|
|
358
|
+
"editor.codeActionsOnSave": { "source.fixAll.ruff": "explicit" }
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
settings["[typescript]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
|
|
362
|
+
settings["[typescriptreact]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
|
|
363
|
+
settings["[javascript]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
|
|
364
|
+
settings["[json]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
|
|
365
|
+
settings["[css]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
|
|
366
|
+
settings["[yaml]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
|
|
367
|
+
settings["editor.formatOnSave"] = true;
|
|
368
|
+
settings["editor.codeActionsOnSave"] = { "source.fixAll.eslint": "explicit" };
|
|
369
|
+
settings["eslint.useFlatConfig"] = true;
|
|
370
|
+
const prettierComponent = ["frontend", "fastify", "e2e"].find(
|
|
371
|
+
(c) => vars.components.includes(c)
|
|
372
|
+
);
|
|
373
|
+
if (prettierComponent) {
|
|
374
|
+
settings["prettier.configPath"] = `${vars.paths[prettierComponent]}/.prettierrc`;
|
|
375
|
+
}
|
|
376
|
+
if (vars.components.includes("fastapi")) {
|
|
377
|
+
settings["ruff.lineLength"] = 120;
|
|
378
|
+
settings["python.analysis.extraPaths"] = [`${vars.paths.fastapi}/src`];
|
|
379
|
+
settings["python.analysis.importFormat"] = "absolute";
|
|
380
|
+
}
|
|
381
|
+
return JSON.stringify(settings, null, 2) + "\n";
|
|
382
|
+
}
|
|
286
383
|
|
|
287
384
|
// src/scaffold.ts
|
|
288
385
|
async function scaffold(opts, dest, localRepo) {
|
|
289
386
|
const name = toKebab(opts.name);
|
|
290
387
|
const nameSnake = toSnake(opts.name);
|
|
291
|
-
const
|
|
388
|
+
const paths = Object.fromEntries(
|
|
389
|
+
opts.components.map((c) => [c, c])
|
|
390
|
+
);
|
|
391
|
+
const vars = { projectName: name, components: opts.components, paths };
|
|
292
392
|
const isLocal = !!localRepo;
|
|
293
393
|
await mkdir2(dest, { recursive: true });
|
|
294
394
|
const dlSpinner = p2.spinner();
|
|
@@ -307,42 +407,30 @@ async function scaffold(opts, dest, localRepo) {
|
|
|
307
407
|
}
|
|
308
408
|
async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
|
|
309
409
|
p2.log.info(`Scaffolding project in ${dest}`);
|
|
310
|
-
const manifest = [];
|
|
311
410
|
for (const component of opts.components) {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
411
|
+
const spinner5 = p2.spinner();
|
|
412
|
+
spinner5.start(`Copying ${component}/`);
|
|
413
|
+
await copyComponent(repoDir, component, dest);
|
|
414
|
+
await writeComponentMarker(join3(dest, component), component);
|
|
415
|
+
spinner5.stop(`${component}/`);
|
|
317
416
|
}
|
|
318
417
|
await substituteNames(dest, opts.components, name, nameSnake);
|
|
319
418
|
const hasBackend = opts.components.includes("fastapi") || opts.components.includes("fastify");
|
|
320
419
|
if (hasBackend || opts.components.includes("frontend")) {
|
|
321
|
-
|
|
322
|
-
await writeFile2(join3(dest, "docker-compose.yml"),
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
await writeFile2(join3(dest, "docker-compose.dev.yml"), dcDev);
|
|
326
|
-
manifest.push("docker-compose.dev.yml");
|
|
327
|
-
}
|
|
328
|
-
const readme = await generateReadme(vars);
|
|
329
|
-
await writeFile2(join3(dest, "README.md"), readme);
|
|
330
|
-
manifest.push("README.md");
|
|
420
|
+
await writeFile2(join3(dest, "docker-compose.yml"), await generateDockerCompose(vars));
|
|
421
|
+
await writeFile2(join3(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
|
|
422
|
+
}
|
|
423
|
+
await writeFile2(join3(dest, "README.md"), await generateReadme(vars));
|
|
331
424
|
await mkdir2(join3(dest, ".githooks"), { recursive: true });
|
|
332
|
-
|
|
333
|
-
await writeFile2(join3(dest, ".githooks/pre-commit"), preCommit);
|
|
425
|
+
await writeFile2(join3(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
|
|
334
426
|
await chmod(join3(dest, ".githooks/pre-commit"), 493);
|
|
335
|
-
manifest.push(".githooks/pre-commit");
|
|
336
427
|
await mkdir2(join3(dest, ".github/workflows"), { recursive: true });
|
|
337
|
-
|
|
338
|
-
await writeFile2(join3(dest, ".
|
|
339
|
-
manifest.push(".github/workflows/ci.yml");
|
|
340
|
-
const setupSh = await generateSetupSh(vars);
|
|
341
|
-
await writeFile2(join3(dest, "setup.sh"), setupSh);
|
|
428
|
+
await writeFile2(join3(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
|
|
429
|
+
await writeFile2(join3(dest, "setup.sh"), await generateSetupSh(vars));
|
|
342
430
|
await chmod(join3(dest, "setup.sh"), 493);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
431
|
+
await copyStaticFiles(repoDir, dest);
|
|
432
|
+
await mkdir2(join3(dest, ".vscode"), { recursive: true });
|
|
433
|
+
await writeFile2(join3(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
|
|
346
434
|
const pkg = JSON.parse(
|
|
347
435
|
await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
|
|
348
436
|
);
|
|
@@ -350,7 +438,7 @@ async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
|
|
|
350
438
|
version: pkg.version,
|
|
351
439
|
components: opts.components,
|
|
352
440
|
createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
353
|
-
|
|
441
|
+
paths: vars.paths
|
|
354
442
|
};
|
|
355
443
|
await writeFile2(join3(dest, ".projx"), JSON.stringify(projxConfig, null, 2));
|
|
356
444
|
if (opts.git) {
|
|
@@ -377,7 +465,9 @@ async function doScaffold(opts, dest, repoDir, name, nameSnake, vars) {
|
|
|
377
465
|
p2.outro(`Done! Next steps:
|
|
378
466
|
|
|
379
467
|
cd ${name}
|
|
380
|
-
./setup.sh
|
|
468
|
+
./setup.sh
|
|
469
|
+
|
|
470
|
+
Like projx? Star it: https://github.com/ukanhaupa/projx`);
|
|
381
471
|
}
|
|
382
472
|
async function substituteNames(dest, components, name, nameSnake) {
|
|
383
473
|
if (components.includes("fastapi")) {
|
|
@@ -424,44 +514,44 @@ async function substituteNames(dest, components, name, nameSnake) {
|
|
|
424
514
|
}
|
|
425
515
|
async function installDeps(dest, components) {
|
|
426
516
|
for (const component of components) {
|
|
427
|
-
const
|
|
517
|
+
const spinner5 = p2.spinner();
|
|
428
518
|
try {
|
|
429
519
|
switch (component) {
|
|
430
520
|
case "fastapi":
|
|
431
521
|
if (hasCommand("uv")) {
|
|
432
|
-
|
|
522
|
+
spinner5.start("Installing FastAPI dependencies (uv sync)");
|
|
433
523
|
exec("uv sync --all-extras", join3(dest, "fastapi"));
|
|
434
|
-
|
|
524
|
+
spinner5.stop("FastAPI dependencies installed.");
|
|
435
525
|
} else {
|
|
436
526
|
p2.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
437
527
|
}
|
|
438
528
|
break;
|
|
439
529
|
case "fastify":
|
|
440
530
|
if (hasCommand("pnpm")) {
|
|
441
|
-
|
|
531
|
+
spinner5.start("Installing Fastify dependencies (pnpm install)");
|
|
442
532
|
exec("pnpm install", join3(dest, "fastify"));
|
|
443
|
-
|
|
533
|
+
spinner5.stop("Fastify dependencies installed.");
|
|
444
534
|
} else {
|
|
445
|
-
|
|
535
|
+
spinner5.start("Installing Fastify dependencies (npm install)");
|
|
446
536
|
exec("npm install", join3(dest, "fastify"));
|
|
447
|
-
|
|
537
|
+
spinner5.stop("Fastify dependencies installed.");
|
|
448
538
|
}
|
|
449
539
|
break;
|
|
450
540
|
case "frontend":
|
|
451
|
-
|
|
541
|
+
spinner5.start("Installing Frontend dependencies (npm install)");
|
|
452
542
|
exec("npm install", join3(dest, "frontend"));
|
|
453
|
-
|
|
543
|
+
spinner5.stop("Frontend dependencies installed.");
|
|
454
544
|
break;
|
|
455
545
|
case "e2e":
|
|
456
|
-
|
|
546
|
+
spinner5.start("Installing E2E dependencies (npm install)");
|
|
457
547
|
exec("npm install", join3(dest, "e2e"));
|
|
458
|
-
|
|
548
|
+
spinner5.stop("E2E dependencies installed.");
|
|
459
549
|
break;
|
|
460
550
|
case "mobile":
|
|
461
551
|
if (hasCommand("flutter")) {
|
|
462
|
-
|
|
552
|
+
spinner5.start("Installing Flutter dependencies");
|
|
463
553
|
exec("flutter pub get", join3(dest, "mobile"));
|
|
464
|
-
|
|
554
|
+
spinner5.stop("Flutter dependencies installed.");
|
|
465
555
|
} else {
|
|
466
556
|
p2.log.warn(
|
|
467
557
|
"Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
|
|
@@ -472,7 +562,7 @@ async function installDeps(dest, components) {
|
|
|
472
562
|
break;
|
|
473
563
|
}
|
|
474
564
|
} catch {
|
|
475
|
-
|
|
565
|
+
spinner5.stop(`Failed to install ${component} dependencies.`);
|
|
476
566
|
}
|
|
477
567
|
}
|
|
478
568
|
}
|
|
@@ -499,7 +589,8 @@ var NEVER_OVERWRITE = [
|
|
|
499
589
|
/\.env$/,
|
|
500
590
|
/\.env\.(dev|staging|prod)$/,
|
|
501
591
|
/prisma\/migrations\//,
|
|
502
|
-
/src\/migrations\/versions
|
|
592
|
+
/src\/migrations\/versions\//,
|
|
593
|
+
/\.projx-component$/
|
|
503
594
|
];
|
|
504
595
|
function isGitRepo(cwd) {
|
|
505
596
|
try {
|
|
@@ -550,11 +641,17 @@ async function update(cwd, localRepo) {
|
|
|
550
641
|
config = {
|
|
551
642
|
version: "0.0.0",
|
|
552
643
|
components: detected,
|
|
553
|
-
createdAt: "unknown"
|
|
554
|
-
files: []
|
|
644
|
+
createdAt: "unknown"
|
|
555
645
|
};
|
|
556
646
|
p3.log.info(`Detected: ${detected.join(", ")}`);
|
|
557
647
|
}
|
|
648
|
+
const componentPaths = await discoverComponentPaths(cwd, config.components);
|
|
649
|
+
const remapped = config.components.filter((c) => componentPaths[c] !== c);
|
|
650
|
+
if (remapped.length > 0) {
|
|
651
|
+
for (const c of remapped) {
|
|
652
|
+
p3.log.info(`${c} \u2192 ${componentPaths[c]}/`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
558
655
|
const useGitBranch = isGitRepo(cwd);
|
|
559
656
|
let branchName;
|
|
560
657
|
let originalBranch;
|
|
@@ -584,7 +681,7 @@ async function update(cwd, localRepo) {
|
|
|
584
681
|
execSync2(`git checkout -b ${branchName}`, { cwd, stdio: "pipe" });
|
|
585
682
|
p3.log.info(`Created branch: ${branchName}`);
|
|
586
683
|
try {
|
|
587
|
-
await doUpdate(cwd, config, repoDir, pkg.version);
|
|
684
|
+
await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
|
|
588
685
|
} finally {
|
|
589
686
|
await cleanupRepo(repoDir, isLocal);
|
|
590
687
|
}
|
|
@@ -612,76 +709,75 @@ async function update(cwd, localRepo) {
|
|
|
612
709
|
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
613
710
|
);
|
|
614
711
|
try {
|
|
615
|
-
await doUpdate(cwd, config, repoDir, pkg.version);
|
|
712
|
+
await doUpdate(cwd, config, repoDir, pkg.version, componentPaths);
|
|
616
713
|
} finally {
|
|
617
714
|
await cleanupRepo(repoDir, isLocal);
|
|
618
715
|
}
|
|
619
716
|
p3.outro(`Updated to v${pkg.version}. Review changes before committing.`);
|
|
620
717
|
}
|
|
621
718
|
}
|
|
622
|
-
async function doUpdate(cwd, config, repoDir, version) {
|
|
623
|
-
const name = detectProjectName(cwd, config.components);
|
|
719
|
+
async function doUpdate(cwd, config, repoDir, version, componentPaths) {
|
|
720
|
+
const name = detectProjectName(cwd, config.components, componentPaths);
|
|
624
721
|
const nameSnake = toSnake(name);
|
|
625
|
-
const vars = { projectName: name, components: config.components };
|
|
626
|
-
const newManifest = [];
|
|
722
|
+
const vars = { projectName: name, components: config.components, paths: componentPaths };
|
|
627
723
|
for (const component of config.components) {
|
|
628
|
-
const
|
|
629
|
-
|
|
724
|
+
const targetDir = componentPaths[component];
|
|
725
|
+
const spinner6 = p3.spinner();
|
|
726
|
+
spinner6.start(`Updating ${targetDir}/ (${component})`);
|
|
630
727
|
const componentSrc = join4(repoDir, component);
|
|
631
728
|
if (!existsSync3(componentSrc)) {
|
|
632
|
-
|
|
729
|
+
spinner6.stop(`${component} template not found, skipping.`);
|
|
633
730
|
continue;
|
|
634
731
|
}
|
|
635
732
|
const tmpDest = join4(cwd, `.projx-tmp`);
|
|
636
733
|
const files = await copyComponent(repoDir, component, tmpDest);
|
|
637
734
|
for (const file of files) {
|
|
638
|
-
const rel = `${component}/${file}`;
|
|
639
735
|
const src = join4(tmpDest, component, file);
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
if (
|
|
736
|
+
const destRel = `${targetDir}/${file}`;
|
|
737
|
+
const dest = join4(cwd, destRel);
|
|
738
|
+
if (NEVER_OVERWRITE.some((re) => re.test(destRel))) continue;
|
|
643
739
|
const dir = dest.substring(0, dest.lastIndexOf("/"));
|
|
644
740
|
await mkdir3(dir, { recursive: true });
|
|
645
741
|
await cp2(src, dest, { force: true });
|
|
646
|
-
newManifest.push(rel);
|
|
647
742
|
}
|
|
648
743
|
await rm2(tmpDest, { recursive: true, force: true });
|
|
649
|
-
|
|
744
|
+
if (!existsSync3(join4(cwd, targetDir, ".projx-component"))) {
|
|
745
|
+
await writeComponentMarker(join4(cwd, targetDir), component);
|
|
746
|
+
}
|
|
747
|
+
spinner6.stop(`${targetDir}/ updated.`);
|
|
650
748
|
}
|
|
651
|
-
const
|
|
652
|
-
|
|
749
|
+
const spinner5 = p3.spinner();
|
|
750
|
+
spinner5.start("Updating shared files");
|
|
653
751
|
const hasBackend = config.components.includes("fastapi") || config.components.includes("fastify");
|
|
654
752
|
if (hasBackend || config.components.includes("frontend")) {
|
|
655
753
|
await writeFile3(
|
|
656
754
|
join4(cwd, "docker-compose.yml"),
|
|
657
755
|
await generateDockerCompose(vars)
|
|
658
756
|
);
|
|
659
|
-
newManifest.push("docker-compose.yml");
|
|
660
757
|
await writeFile3(
|
|
661
758
|
join4(cwd, "docker-compose.dev.yml"),
|
|
662
759
|
await generateDockerComposeDev(vars)
|
|
663
760
|
);
|
|
664
|
-
newManifest.push("docker-compose.dev.yml");
|
|
665
761
|
}
|
|
666
762
|
await mkdir3(join4(cwd, ".githooks"), { recursive: true });
|
|
667
763
|
const preCommit = await generatePreCommit(vars);
|
|
668
764
|
await writeFile3(join4(cwd, ".githooks/pre-commit"), preCommit);
|
|
669
765
|
await chmod2(join4(cwd, ".githooks/pre-commit"), 493);
|
|
670
|
-
newManifest.push(".githooks/pre-commit");
|
|
671
766
|
await mkdir3(join4(cwd, ".github/workflows"), { recursive: true });
|
|
672
767
|
await writeFile3(
|
|
673
768
|
join4(cwd, ".github/workflows/ci.yml"),
|
|
674
769
|
await generateCiYml(vars)
|
|
675
770
|
);
|
|
676
|
-
newManifest.push(".github/workflows/ci.yml");
|
|
677
771
|
const setupSh = await generateSetupSh(vars);
|
|
678
772
|
await writeFile3(join4(cwd, "setup.sh"), setupSh);
|
|
679
773
|
await chmod2(join4(cwd, "setup.sh"), 493);
|
|
680
|
-
|
|
681
|
-
|
|
774
|
+
await mkdir3(join4(cwd, ".vscode"), { recursive: true });
|
|
775
|
+
await writeFile3(join4(cwd, ".vscode/settings.json"), generateVscodeSettings(vars));
|
|
776
|
+
spinner5.stop("Shared files updated.");
|
|
682
777
|
if (config.components.includes("mobile")) {
|
|
778
|
+
const mobilePath = componentPaths.mobile ?? "mobile";
|
|
683
779
|
await replaceInDir(
|
|
684
|
-
join4(cwd,
|
|
780
|
+
join4(cwd, mobilePath),
|
|
685
781
|
"package:projx_mobile/",
|
|
686
782
|
`package:${nameSnake}_mobile/`,
|
|
687
783
|
".dart"
|
|
@@ -691,13 +787,14 @@ async function doUpdate(cwd, config, repoDir, version) {
|
|
|
691
787
|
version,
|
|
692
788
|
components: config.components,
|
|
693
789
|
createdAt: config.createdAt,
|
|
694
|
-
|
|
790
|
+
paths: componentPaths
|
|
695
791
|
};
|
|
696
792
|
await writeFile3(join4(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
|
|
697
793
|
}
|
|
698
|
-
function detectProjectName(cwd, components) {
|
|
794
|
+
function detectProjectName(cwd, components, componentPaths) {
|
|
699
795
|
for (const component of components) {
|
|
700
|
-
const
|
|
796
|
+
const dir = componentPaths[component] ?? component;
|
|
797
|
+
const pkgPath = join4(cwd, dir, "package.json");
|
|
701
798
|
if (existsSync3(pkgPath)) {
|
|
702
799
|
try {
|
|
703
800
|
const pkg = JSON.parse(
|
|
@@ -754,44 +851,37 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
|
|
|
754
851
|
}
|
|
755
852
|
}
|
|
756
853
|
async function doAdd(cwd, config, toAdd, repoDir, skipInstall) {
|
|
757
|
-
const name = detectProjectName2(cwd, config.components);
|
|
758
|
-
const nameSnake = toSnake(name);
|
|
759
854
|
const allComponents = [...config.components, ...toAdd];
|
|
760
|
-
const
|
|
761
|
-
const
|
|
855
|
+
const existingPaths = await discoverComponentPaths(cwd, config.components);
|
|
856
|
+
const paths = { ...existingPaths };
|
|
857
|
+
for (const c of toAdd) paths[c] = c;
|
|
858
|
+
const name = detectProjectName2(cwd, config.components, paths);
|
|
859
|
+
const nameSnake = toSnake(name);
|
|
860
|
+
const vars = { projectName: name, components: allComponents, paths };
|
|
762
861
|
for (const component of toAdd) {
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
862
|
+
const spinner6 = p4.spinner();
|
|
863
|
+
spinner6.start(`Adding ${component}/`);
|
|
864
|
+
await copyComponent(repoDir, component, cwd);
|
|
865
|
+
await writeComponentMarker(join5(cwd, component), component);
|
|
866
|
+
spinner6.stop(`${component}/`);
|
|
768
867
|
}
|
|
769
868
|
await substituteNames2(cwd, toAdd, name, nameSnake);
|
|
770
|
-
const
|
|
771
|
-
|
|
869
|
+
const spinner5 = p4.spinner();
|
|
870
|
+
spinner5.start("Regenerating shared files");
|
|
772
871
|
const hasBackend = allComponents.includes("fastapi") || allComponents.includes("fastify");
|
|
773
872
|
if (hasBackend || allComponents.includes("frontend")) {
|
|
774
|
-
await writeFile4(
|
|
775
|
-
|
|
776
|
-
await generateDockerCompose(vars)
|
|
777
|
-
);
|
|
778
|
-
await writeFile4(
|
|
779
|
-
join5(cwd, "docker-compose.dev.yml"),
|
|
780
|
-
await generateDockerComposeDev(vars)
|
|
781
|
-
);
|
|
873
|
+
await writeFile4(join5(cwd, "docker-compose.yml"), await generateDockerCompose(vars));
|
|
874
|
+
await writeFile4(join5(cwd, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
|
|
782
875
|
}
|
|
783
876
|
await writeFile4(join5(cwd, "README.md"), await generateReadme(vars));
|
|
784
877
|
await mkdir4(join5(cwd, ".githooks"), { recursive: true });
|
|
785
878
|
await writeFile4(join5(cwd, ".githooks/pre-commit"), await generatePreCommit(vars));
|
|
786
879
|
await chmod3(join5(cwd, ".githooks/pre-commit"), 493);
|
|
787
880
|
await mkdir4(join5(cwd, ".github/workflows"), { recursive: true });
|
|
788
|
-
await writeFile4(
|
|
789
|
-
join5(cwd, ".github/workflows/ci.yml"),
|
|
790
|
-
await generateCiYml(vars)
|
|
791
|
-
);
|
|
881
|
+
await writeFile4(join5(cwd, ".github/workflows/ci.yml"), await generateCiYml(vars));
|
|
792
882
|
await writeFile4(join5(cwd, "setup.sh"), await generateSetupSh(vars));
|
|
793
883
|
await chmod3(join5(cwd, "setup.sh"), 493);
|
|
794
|
-
|
|
884
|
+
spinner5.stop("Shared files regenerated.");
|
|
795
885
|
if (!skipInstall) {
|
|
796
886
|
await installDeps2(cwd, toAdd);
|
|
797
887
|
}
|
|
@@ -812,10 +902,12 @@ async function doAdd(cwd, config, toAdd, repoDir, skipInstall) {
|
|
|
812
902
|
version: pkg.version,
|
|
813
903
|
components: allComponents,
|
|
814
904
|
createdAt: config.createdAt,
|
|
815
|
-
|
|
905
|
+
paths
|
|
816
906
|
};
|
|
817
907
|
await writeFile4(join5(cwd, ".projx"), JSON.stringify(updatedConfig, null, 2));
|
|
818
|
-
p4.outro(`Added ${toAdd.join(", ")}. Shared files updated for all ${allComponents.length} components
|
|
908
|
+
p4.outro(`Added ${toAdd.join(", ")}. Shared files updated for all ${allComponents.length} components.
|
|
909
|
+
|
|
910
|
+
Like projx? Star it: https://github.com/ukanhaupa/projx`);
|
|
819
911
|
}
|
|
820
912
|
async function substituteNames2(dest, components, name, nameSnake) {
|
|
821
913
|
if (components.includes("fastapi")) {
|
|
@@ -862,44 +954,44 @@ async function substituteNames2(dest, components, name, nameSnake) {
|
|
|
862
954
|
}
|
|
863
955
|
async function installDeps2(dest, components) {
|
|
864
956
|
for (const component of components) {
|
|
865
|
-
const
|
|
957
|
+
const spinner5 = p4.spinner();
|
|
866
958
|
try {
|
|
867
959
|
switch (component) {
|
|
868
960
|
case "fastapi":
|
|
869
961
|
if (hasCommand("uv")) {
|
|
870
|
-
|
|
962
|
+
spinner5.start("Installing FastAPI dependencies");
|
|
871
963
|
exec("uv sync --all-extras", join5(dest, "fastapi"));
|
|
872
|
-
|
|
964
|
+
spinner5.stop("FastAPI dependencies installed.");
|
|
873
965
|
} else {
|
|
874
966
|
p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
|
|
875
967
|
}
|
|
876
968
|
break;
|
|
877
969
|
case "fastify":
|
|
878
970
|
if (hasCommand("pnpm")) {
|
|
879
|
-
|
|
971
|
+
spinner5.start("Installing Fastify dependencies");
|
|
880
972
|
exec("pnpm install", join5(dest, "fastify"));
|
|
881
|
-
|
|
973
|
+
spinner5.stop("Fastify dependencies installed.");
|
|
882
974
|
} else {
|
|
883
|
-
|
|
975
|
+
spinner5.start("Installing Fastify dependencies");
|
|
884
976
|
exec("npm install", join5(dest, "fastify"));
|
|
885
|
-
|
|
977
|
+
spinner5.stop("Fastify dependencies installed.");
|
|
886
978
|
}
|
|
887
979
|
break;
|
|
888
980
|
case "frontend":
|
|
889
|
-
|
|
981
|
+
spinner5.start("Installing Frontend dependencies");
|
|
890
982
|
exec("npm install", join5(dest, "frontend"));
|
|
891
|
-
|
|
983
|
+
spinner5.stop("Frontend dependencies installed.");
|
|
892
984
|
break;
|
|
893
985
|
case "e2e":
|
|
894
|
-
|
|
986
|
+
spinner5.start("Installing E2E dependencies");
|
|
895
987
|
exec("npm install", join5(dest, "e2e"));
|
|
896
|
-
|
|
988
|
+
spinner5.stop("E2E dependencies installed.");
|
|
897
989
|
break;
|
|
898
990
|
case "mobile":
|
|
899
991
|
if (hasCommand("flutter")) {
|
|
900
|
-
|
|
992
|
+
spinner5.start("Installing Flutter dependencies");
|
|
901
993
|
exec("flutter pub get", join5(dest, "mobile"));
|
|
902
|
-
|
|
994
|
+
spinner5.stop("Flutter dependencies installed.");
|
|
903
995
|
} else {
|
|
904
996
|
p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
|
|
905
997
|
}
|
|
@@ -908,13 +1000,14 @@ async function installDeps2(dest, components) {
|
|
|
908
1000
|
break;
|
|
909
1001
|
}
|
|
910
1002
|
} catch {
|
|
911
|
-
|
|
1003
|
+
spinner5.stop(`Failed to install ${component} dependencies.`);
|
|
912
1004
|
}
|
|
913
1005
|
}
|
|
914
1006
|
}
|
|
915
|
-
function detectProjectName2(cwd, components) {
|
|
1007
|
+
function detectProjectName2(cwd, components, paths) {
|
|
916
1008
|
for (const component of components) {
|
|
917
|
-
const
|
|
1009
|
+
const dir = paths[component] ?? component;
|
|
1010
|
+
const pkgPath = join5(cwd, dir, "package.json");
|
|
918
1011
|
if (existsSync4(pkgPath)) {
|
|
919
1012
|
try {
|
|
920
1013
|
const pkg = JSON.parse(
|
|
@@ -931,6 +1024,395 @@ function detectProjectName2(cwd, components) {
|
|
|
931
1024
|
return toKebab(cwd.split("/").pop());
|
|
932
1025
|
}
|
|
933
1026
|
|
|
1027
|
+
// src/init.ts
|
|
1028
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1029
|
+
import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5, chmod as chmod4, cp as cp3 } from "fs/promises";
|
|
1030
|
+
import { execSync as execSync3 } from "child_process";
|
|
1031
|
+
import { join as join7 } from "path";
|
|
1032
|
+
import * as p5 from "@clack/prompts";
|
|
1033
|
+
|
|
1034
|
+
// src/detect.ts
|
|
1035
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1036
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
1037
|
+
import { join as join6 } from "path";
|
|
1038
|
+
async function detectComponents(cwd) {
|
|
1039
|
+
const results = [];
|
|
1040
|
+
const entries = await readdir2(cwd, { withFileTypes: true });
|
|
1041
|
+
const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)).map((e) => e.name);
|
|
1042
|
+
for (const dir of dirs) {
|
|
1043
|
+
const full = join6(cwd, dir);
|
|
1044
|
+
const detections = await scanDirectory(full, dir);
|
|
1045
|
+
results.push(...detections);
|
|
1046
|
+
}
|
|
1047
|
+
return results;
|
|
1048
|
+
}
|
|
1049
|
+
async function scanDirectory(dir, relPath) {
|
|
1050
|
+
const results = [];
|
|
1051
|
+
const pyproject = await readFileOrNull(join6(dir, "pyproject.toml"));
|
|
1052
|
+
if (pyproject && /fastapi/i.test(pyproject)) {
|
|
1053
|
+
results.push({
|
|
1054
|
+
component: "fastapi",
|
|
1055
|
+
directory: relPath,
|
|
1056
|
+
confidence: "high",
|
|
1057
|
+
evidence: "pyproject.toml has fastapi dependency"
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
const pkg = await readPkg(dir);
|
|
1061
|
+
if (pkg) {
|
|
1062
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1063
|
+
if (allDeps.fastify) {
|
|
1064
|
+
results.push({
|
|
1065
|
+
component: "fastify",
|
|
1066
|
+
directory: relPath,
|
|
1067
|
+
confidence: "high",
|
|
1068
|
+
evidence: "package.json has fastify dependency"
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
if (allDeps.react || allDeps["react-dom"]) {
|
|
1072
|
+
results.push({
|
|
1073
|
+
component: "frontend",
|
|
1074
|
+
directory: relPath,
|
|
1075
|
+
confidence: "high",
|
|
1076
|
+
evidence: "package.json has react dependency"
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
if (allDeps["@playwright/test"] || allDeps.playwright) {
|
|
1080
|
+
results.push({
|
|
1081
|
+
component: "e2e",
|
|
1082
|
+
directory: relPath,
|
|
1083
|
+
confidence: "high",
|
|
1084
|
+
evidence: "package.json has playwright dependency"
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
const pubspec = await readFileOrNull(join6(dir, "pubspec.yaml"));
|
|
1089
|
+
if (pubspec && /flutter:/i.test(pubspec)) {
|
|
1090
|
+
results.push({
|
|
1091
|
+
component: "mobile",
|
|
1092
|
+
directory: relPath,
|
|
1093
|
+
confidence: "high",
|
|
1094
|
+
evidence: "pubspec.yaml has flutter dependency"
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
const hasTf = existsSync5(join6(dir, "main.tf")) || existsSync5(join6(dir, "variables.tf")) || existsSync5(join6(dir, "stack/main.tf")) || existsSync5(join6(dir, "versions.tf"));
|
|
1098
|
+
if (hasTf) {
|
|
1099
|
+
results.push({
|
|
1100
|
+
component: "infra",
|
|
1101
|
+
directory: relPath,
|
|
1102
|
+
confidence: "high",
|
|
1103
|
+
evidence: "Terraform .tf files found"
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
return results;
|
|
1107
|
+
}
|
|
1108
|
+
async function readPkg(dir) {
|
|
1109
|
+
const content = await readFileOrNull(join6(dir, "package.json"));
|
|
1110
|
+
if (!content) return null;
|
|
1111
|
+
try {
|
|
1112
|
+
return JSON.parse(content);
|
|
1113
|
+
} catch {
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/diff.ts
|
|
1119
|
+
function unifiedDiff(existing, template, label) {
|
|
1120
|
+
const a = existing.split("\n");
|
|
1121
|
+
const b = template.split("\n");
|
|
1122
|
+
const lines = [`--- existing ${label}`, `+++ template ${label}`];
|
|
1123
|
+
const lcs = computeLCS(a, b);
|
|
1124
|
+
let ai = 0;
|
|
1125
|
+
let bi = 0;
|
|
1126
|
+
for (const match of lcs) {
|
|
1127
|
+
while (ai < match.ai) lines.push(`\x1B[31m- ${a[ai++]}\x1B[0m`);
|
|
1128
|
+
while (bi < match.bi) lines.push(`\x1B[32m+ ${b[bi++]}\x1B[0m`);
|
|
1129
|
+
lines.push(` ${a[ai]}`);
|
|
1130
|
+
ai++;
|
|
1131
|
+
bi++;
|
|
1132
|
+
}
|
|
1133
|
+
while (ai < a.length) lines.push(`\x1B[31m- ${a[ai++]}\x1B[0m`);
|
|
1134
|
+
while (bi < b.length) lines.push(`\x1B[32m+ ${b[bi++]}\x1B[0m`);
|
|
1135
|
+
if (lines.length > 80) {
|
|
1136
|
+
return lines.slice(0, 80).join("\n") + `
|
|
1137
|
+
... (${lines.length - 80} more lines)`;
|
|
1138
|
+
}
|
|
1139
|
+
return lines.join("\n");
|
|
1140
|
+
}
|
|
1141
|
+
function computeLCS(a, b) {
|
|
1142
|
+
const m = a.length;
|
|
1143
|
+
const n = b.length;
|
|
1144
|
+
if (m * n > 1e5) {
|
|
1145
|
+
return simpleLCS(a, b);
|
|
1146
|
+
}
|
|
1147
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
1148
|
+
for (let i2 = m - 1; i2 >= 0; i2--) {
|
|
1149
|
+
for (let j2 = n - 1; j2 >= 0; j2--) {
|
|
1150
|
+
if (a[i2] === b[j2]) {
|
|
1151
|
+
dp[i2][j2] = dp[i2 + 1][j2 + 1] + 1;
|
|
1152
|
+
} else {
|
|
1153
|
+
dp[i2][j2] = Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
const matches = [];
|
|
1158
|
+
let i = 0;
|
|
1159
|
+
let j = 0;
|
|
1160
|
+
while (i < m && j < n) {
|
|
1161
|
+
if (a[i] === b[j]) {
|
|
1162
|
+
matches.push({ ai: i, bi: j });
|
|
1163
|
+
i++;
|
|
1164
|
+
j++;
|
|
1165
|
+
} else if (dp[i + 1]?.[j] ?? 0 >= (dp[i]?.[j + 1] ?? 0)) {
|
|
1166
|
+
i++;
|
|
1167
|
+
} else {
|
|
1168
|
+
j++;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return matches;
|
|
1172
|
+
}
|
|
1173
|
+
function simpleLCS(a, b) {
|
|
1174
|
+
const matches = [];
|
|
1175
|
+
let bi = 0;
|
|
1176
|
+
for (let ai = 0; ai < a.length && bi < b.length; ai++) {
|
|
1177
|
+
const idx = b.indexOf(a[ai], bi);
|
|
1178
|
+
if (idx !== -1) {
|
|
1179
|
+
matches.push({ ai, bi: idx });
|
|
1180
|
+
bi = idx + 1;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return matches;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/init.ts
|
|
1187
|
+
async function init(cwd, localRepo) {
|
|
1188
|
+
p5.intro("projx init");
|
|
1189
|
+
const isLocal = !!localRepo;
|
|
1190
|
+
if (existsSync6(join7(cwd, ".projx"))) {
|
|
1191
|
+
p5.log.error("This project is already initialized. Use 'projx update' or 'projx add' instead.");
|
|
1192
|
+
process.exit(1);
|
|
1193
|
+
}
|
|
1194
|
+
const spinner5 = p5.spinner();
|
|
1195
|
+
spinner5.start("Scanning for components");
|
|
1196
|
+
const detected = await detectComponents(cwd);
|
|
1197
|
+
spinner5.stop(
|
|
1198
|
+
detected.length > 0 ? `Found ${detected.length} component(s).` : "No components detected."
|
|
1199
|
+
);
|
|
1200
|
+
let confirmed;
|
|
1201
|
+
if (detected.length > 0) {
|
|
1202
|
+
confirmed = await confirmDetections(detected);
|
|
1203
|
+
} else {
|
|
1204
|
+
confirmed = await manualSelect(cwd);
|
|
1205
|
+
}
|
|
1206
|
+
if (confirmed.length === 0) {
|
|
1207
|
+
p5.log.warn("No components selected. Nothing to do.");
|
|
1208
|
+
process.exit(0);
|
|
1209
|
+
}
|
|
1210
|
+
const components = confirmed.map((c) => c.component);
|
|
1211
|
+
const paths = Object.fromEntries(
|
|
1212
|
+
confirmed.map((c) => [c.component, c.directory])
|
|
1213
|
+
);
|
|
1214
|
+
const projectName = toKebab(cwd.split("/").pop());
|
|
1215
|
+
const vars = { projectName, components, paths };
|
|
1216
|
+
const dlSpinner = p5.spinner();
|
|
1217
|
+
dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
|
|
1218
|
+
const repoDir = await downloadRepo(localRepo).catch((err) => {
|
|
1219
|
+
dlSpinner.stop("Failed.");
|
|
1220
|
+
p5.log.error(String(err));
|
|
1221
|
+
process.exit(1);
|
|
1222
|
+
});
|
|
1223
|
+
dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
|
|
1224
|
+
try {
|
|
1225
|
+
for (const { component, directory } of confirmed) {
|
|
1226
|
+
const dir = join7(cwd, directory);
|
|
1227
|
+
if (existsSync6(dir)) {
|
|
1228
|
+
await writeComponentMarker(dir, component);
|
|
1229
|
+
p5.log.success(`${directory}/.projx-component`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
await generateSharedFiles(cwd, repoDir, vars);
|
|
1233
|
+
const pkg = JSON.parse(
|
|
1234
|
+
await readFile6(join7(repoDir, "cli/package.json"), "utf-8")
|
|
1235
|
+
);
|
|
1236
|
+
const projxConfig = {
|
|
1237
|
+
version: pkg.version,
|
|
1238
|
+
components,
|
|
1239
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
1240
|
+
paths
|
|
1241
|
+
};
|
|
1242
|
+
await writeFile5(join7(cwd, ".projx"), JSON.stringify(projxConfig, null, 2));
|
|
1243
|
+
p5.log.success(".projx");
|
|
1244
|
+
if (isGitRepo2(cwd)) {
|
|
1245
|
+
try {
|
|
1246
|
+
execSync3("git config core.hooksPath .githooks", { cwd, stdio: "pipe" });
|
|
1247
|
+
p5.log.success("Git hooks configured.");
|
|
1248
|
+
} catch {
|
|
1249
|
+
p5.log.warn("Failed to configure git hooks.");
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
} finally {
|
|
1253
|
+
await cleanupRepo(repoDir, isLocal);
|
|
1254
|
+
}
|
|
1255
|
+
p5.outro("Project initialized. Run './setup.sh' to install dependencies.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
|
|
1256
|
+
}
|
|
1257
|
+
async function confirmDetections(detected) {
|
|
1258
|
+
const confirmed = [];
|
|
1259
|
+
for (const d of detected) {
|
|
1260
|
+
const yes = await p5.confirm({
|
|
1261
|
+
message: `Found ${LABELS[d.component].label} in ${d.directory}/ \u2014 register as "${d.component}"?`,
|
|
1262
|
+
initialValue: true
|
|
1263
|
+
});
|
|
1264
|
+
if (p5.isCancel(yes)) process.exit(0);
|
|
1265
|
+
if (yes) {
|
|
1266
|
+
confirmed.push({ component: d.component, directory: d.directory });
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return confirmed;
|
|
1270
|
+
}
|
|
1271
|
+
async function manualSelect(cwd) {
|
|
1272
|
+
const selected = await p5.multiselect({
|
|
1273
|
+
message: "No components detected. Select manually:",
|
|
1274
|
+
options: COMPONENTS.map((c) => ({
|
|
1275
|
+
value: c,
|
|
1276
|
+
label: LABELS[c].label,
|
|
1277
|
+
hint: LABELS[c].hint
|
|
1278
|
+
})),
|
|
1279
|
+
required: false
|
|
1280
|
+
});
|
|
1281
|
+
if (p5.isCancel(selected)) process.exit(0);
|
|
1282
|
+
const result = [];
|
|
1283
|
+
for (const component of selected) {
|
|
1284
|
+
const dir = await p5.text({
|
|
1285
|
+
message: `Directory for ${LABELS[component].label}?`,
|
|
1286
|
+
placeholder: component,
|
|
1287
|
+
defaultValue: component
|
|
1288
|
+
});
|
|
1289
|
+
if (p5.isCancel(dir)) process.exit(0);
|
|
1290
|
+
if (!existsSync6(join7(cwd, dir))) {
|
|
1291
|
+
p5.log.warn(`${dir}/ does not exist \u2014 skipping.`);
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
result.push({ component, directory: dir });
|
|
1295
|
+
}
|
|
1296
|
+
return result;
|
|
1297
|
+
}
|
|
1298
|
+
async function generateSharedFiles(cwd, repoDir, vars) {
|
|
1299
|
+
const files = [];
|
|
1300
|
+
const hasBackend = vars.components.includes("fastapi") || vars.components.includes("fastify");
|
|
1301
|
+
if (hasBackend || vars.components.includes("frontend")) {
|
|
1302
|
+
files.push(
|
|
1303
|
+
{ path: "docker-compose.yml", content: await generateDockerCompose(vars) },
|
|
1304
|
+
{ path: "docker-compose.dev.yml", content: await generateDockerComposeDev(vars) }
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
files.push(
|
|
1308
|
+
{ path: "README.md", content: await generateReadme(vars) },
|
|
1309
|
+
{ path: ".githooks/pre-commit", content: await generatePreCommit(vars), mode: 493 },
|
|
1310
|
+
{ path: ".github/workflows/ci.yml", content: await generateCiYml(vars) },
|
|
1311
|
+
{ path: "setup.sh", content: await generateSetupSh(vars), mode: 493 }
|
|
1312
|
+
);
|
|
1313
|
+
for (const file of files) {
|
|
1314
|
+
const dest = join7(cwd, file.path);
|
|
1315
|
+
const dir = dest.substring(0, dest.lastIndexOf("/"));
|
|
1316
|
+
if (dir !== cwd) await mkdir5(dir, { recursive: true });
|
|
1317
|
+
const existing = await readFileOrNull(dest);
|
|
1318
|
+
if (existing === null) {
|
|
1319
|
+
await writeFile5(dest, file.content);
|
|
1320
|
+
if (file.mode) await chmod4(dest, file.mode);
|
|
1321
|
+
p5.log.success(file.path);
|
|
1322
|
+
} else if (existing === file.content) {
|
|
1323
|
+
p5.log.info(`${file.path} \u2014 identical, skipped.`);
|
|
1324
|
+
} else {
|
|
1325
|
+
const action = await resolveConflict(file.path, existing, file.content);
|
|
1326
|
+
if (action === "overwrite") {
|
|
1327
|
+
await writeFile5(dest, file.content);
|
|
1328
|
+
if (file.mode) await chmod4(dest, file.mode);
|
|
1329
|
+
p5.log.success(`${file.path} \u2014 overwritten.`);
|
|
1330
|
+
} else {
|
|
1331
|
+
p5.log.info(`${file.path} \u2014 kept existing.`);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
const statics = [".editorconfig"];
|
|
1336
|
+
for (const file of statics) {
|
|
1337
|
+
const src = join7(repoDir, file);
|
|
1338
|
+
const dest = join7(cwd, file);
|
|
1339
|
+
if (!existsSync6(src)) continue;
|
|
1340
|
+
if (!existsSync6(dest)) {
|
|
1341
|
+
await cp3(src, dest);
|
|
1342
|
+
p5.log.success(file);
|
|
1343
|
+
} else {
|
|
1344
|
+
const existing = await readFileOrNull(dest);
|
|
1345
|
+
const template = await readFileOrNull(src);
|
|
1346
|
+
if (existing === template) {
|
|
1347
|
+
p5.log.info(`${file} \u2014 identical, skipped.`);
|
|
1348
|
+
} else {
|
|
1349
|
+
const action = await resolveConflict(file, existing ?? "", template ?? "");
|
|
1350
|
+
if (action === "overwrite") {
|
|
1351
|
+
await cp3(src, dest, { force: true });
|
|
1352
|
+
p5.log.success(`${file} \u2014 overwritten.`);
|
|
1353
|
+
} else {
|
|
1354
|
+
p5.log.info(`${file} \u2014 kept existing.`);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const vscodeDest = join7(cwd, ".vscode");
|
|
1360
|
+
await mkdir5(vscodeDest, { recursive: true });
|
|
1361
|
+
const settingsPath = join7(vscodeDest, "settings.json");
|
|
1362
|
+
const settingsContent = generateVscodeSettings(vars);
|
|
1363
|
+
const existingSettings = await readFileOrNull(settingsPath);
|
|
1364
|
+
if (existingSettings === null) {
|
|
1365
|
+
await writeFile5(settingsPath, settingsContent);
|
|
1366
|
+
p5.log.success(".vscode/settings.json");
|
|
1367
|
+
} else if (existingSettings !== settingsContent) {
|
|
1368
|
+
const action = await resolveConflict(".vscode/settings.json", existingSettings, settingsContent);
|
|
1369
|
+
if (action === "overwrite") {
|
|
1370
|
+
await writeFile5(settingsPath, settingsContent);
|
|
1371
|
+
p5.log.success(".vscode/settings.json \u2014 overwritten.");
|
|
1372
|
+
} else {
|
|
1373
|
+
p5.log.info(".vscode/settings.json \u2014 kept existing.");
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
const extSrc = join7(repoDir, ".vscode/extensions.json");
|
|
1377
|
+
const extDest = join7(vscodeDest, "extensions.json");
|
|
1378
|
+
if (existsSync6(extSrc) && !existsSync6(extDest)) {
|
|
1379
|
+
await cp3(extSrc, extDest);
|
|
1380
|
+
p5.log.success(".vscode/extensions.json");
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
async function resolveConflict(filePath, existing, template) {
|
|
1384
|
+
let action = await p5.select({
|
|
1385
|
+
message: `${filePath} differs from projx template`,
|
|
1386
|
+
options: [
|
|
1387
|
+
{ value: "diff", label: "View diff" },
|
|
1388
|
+
{ value: "overwrite", label: "Overwrite with template" },
|
|
1389
|
+
{ value: "skip", label: "Skip (keep existing)" }
|
|
1390
|
+
]
|
|
1391
|
+
});
|
|
1392
|
+
if (p5.isCancel(action)) process.exit(0);
|
|
1393
|
+
if (action === "diff") {
|
|
1394
|
+
const diff = unifiedDiff(existing, template, filePath);
|
|
1395
|
+
p5.log.message(diff);
|
|
1396
|
+
action = await p5.select({
|
|
1397
|
+
message: `${filePath}`,
|
|
1398
|
+
options: [
|
|
1399
|
+
{ value: "overwrite", label: "Overwrite with template" },
|
|
1400
|
+
{ value: "skip", label: "Skip (keep existing)" }
|
|
1401
|
+
]
|
|
1402
|
+
});
|
|
1403
|
+
if (p5.isCancel(action)) process.exit(0);
|
|
1404
|
+
}
|
|
1405
|
+
return action;
|
|
1406
|
+
}
|
|
1407
|
+
function isGitRepo2(cwd) {
|
|
1408
|
+
try {
|
|
1409
|
+
execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
|
|
1410
|
+
return true;
|
|
1411
|
+
} catch {
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
934
1416
|
// src/index.ts
|
|
935
1417
|
var args = process.argv.slice(2);
|
|
936
1418
|
function parseArgs() {
|
|
@@ -949,6 +1431,10 @@ function parseArgs() {
|
|
|
949
1431
|
command = "add";
|
|
950
1432
|
continue;
|
|
951
1433
|
}
|
|
1434
|
+
if (arg === "init" && !name) {
|
|
1435
|
+
command = "init";
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
952
1438
|
if (arg === "--components") {
|
|
953
1439
|
const val = args[++i];
|
|
954
1440
|
if (val) {
|
|
@@ -992,6 +1478,7 @@ function printHelp() {
|
|
|
992
1478
|
console.log(`
|
|
993
1479
|
Usage:
|
|
994
1480
|
projx <name> [options] Create a new project
|
|
1481
|
+
projx init Adopt existing project into projx
|
|
995
1482
|
projx add <components...> Add components to existing project
|
|
996
1483
|
projx update Update scaffolding to latest
|
|
997
1484
|
|
|
@@ -1013,6 +1500,10 @@ function printHelp() {
|
|
|
1013
1500
|
}
|
|
1014
1501
|
async function main() {
|
|
1015
1502
|
const { command, name, options, localRepo, extraArgs } = parseArgs();
|
|
1503
|
+
if (command === "init") {
|
|
1504
|
+
await init(process.cwd(), localRepo);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1016
1507
|
if (command === "update") {
|
|
1017
1508
|
await update(process.cwd(), localRepo);
|
|
1018
1509
|
return;
|
|
@@ -1046,7 +1537,7 @@ async function main() {
|
|
|
1046
1537
|
opts.install = options.install ?? opts.install;
|
|
1047
1538
|
}
|
|
1048
1539
|
const dest = resolve2(process.cwd(), opts.name);
|
|
1049
|
-
if (
|
|
1540
|
+
if (existsSync7(dest)) {
|
|
1050
1541
|
console.error(`Error: ${dest} already exists.`);
|
|
1051
1542
|
process.exit(1);
|
|
1052
1543
|
}
|