create-projx 1.6.2 → 1.6.4

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 CHANGED
@@ -9,10 +9,11 @@ import {
9
9
  matchesSkip,
10
10
  saveBaselineRef,
11
11
  writeTemplateToDir
12
- } from "./chunk-D33FXCNT.js";
12
+ } from "./chunk-XQ7FE4U3.js";
13
13
  import {
14
14
  COMPONENTS,
15
15
  COMPONENT_MARKER,
16
+ DEFAULT_ROOT_SKIP_PATTERNS,
16
17
  EXCLUDE,
17
18
  PACKAGE_MANAGERS,
18
19
  cleanupRepo,
@@ -33,7 +34,7 @@ import {
33
34
  toTitle,
34
35
  writeComponentMarker,
35
36
  writeProjxConfig
36
- } from "./chunk-LTIJPVRZ.js";
37
+ } from "./chunk-6YRBHJ2V.js";
37
38
 
38
39
  // src/index.ts
39
40
  import { existsSync as existsSync11 } from "fs";
@@ -76,7 +77,9 @@ async function runPrompts(nameArg) {
76
77
  if (components.length === 0) {
77
78
  p.log.warn("No components selected. Creating an empty project.");
78
79
  }
79
- const hasJs = components.some((c) => ["fastify", "frontend", "e2e"].includes(c));
80
+ const hasJs = components.some(
81
+ (c) => ["fastify", "frontend", "e2e"].includes(c)
82
+ );
80
83
  let packageManager = "npm";
81
84
  if (hasJs) {
82
85
  const pm = await p.select({
@@ -101,11 +104,18 @@ async function scaffold(opts, dest, localRepo) {
101
104
  const paths = Object.fromEntries(
102
105
  opts.components.map((c) => [c, c])
103
106
  );
104
- const vars = { projectName: name, components: opts.components, paths, pm: pmCommands(pm) };
107
+ const vars = {
108
+ projectName: name,
109
+ components: opts.components,
110
+ paths,
111
+ pm: pmCommands(pm)
112
+ };
105
113
  const isLocal = !!localRepo;
106
114
  await mkdir(dest, { recursive: true });
107
115
  const dlSpinner = p2.spinner();
108
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
116
+ dlSpinner.start(
117
+ isLocal ? "Using local templates" : "Downloading latest templates"
118
+ );
109
119
  const repoDir = await downloadRepo(localRepo).catch((err) => {
110
120
  dlSpinner.stop("Failed.");
111
121
  p2.log.error(String(err));
@@ -113,7 +123,9 @@ async function scaffold(opts, dest, localRepo) {
113
123
  });
114
124
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
115
125
  try {
116
- const pkg = JSON.parse(await readFile(join(repoDir, "cli/package.json"), "utf-8"));
126
+ const pkg = JSON.parse(
127
+ await readFile(join(repoDir, "cli/package.json"), "utf-8")
128
+ );
117
129
  const version = pkg.version;
118
130
  p2.log.info(`Scaffolding project in ${dest}`);
119
131
  if (opts.git) {
@@ -121,7 +133,17 @@ async function scaffold(opts, dest, localRepo) {
121
133
  }
122
134
  const spinner7 = p2.spinner();
123
135
  spinner7.start("Scaffolding project");
124
- await applyTemplate(dest, repoDir, opts.components, paths, vars, version, void 0, void 0, true);
136
+ await applyTemplate(
137
+ dest,
138
+ repoDir,
139
+ opts.components,
140
+ paths,
141
+ vars,
142
+ version,
143
+ void 0,
144
+ void 0,
145
+ true
146
+ );
125
147
  spinner7.stop("Scaffold complete.");
126
148
  if (opts.install) {
127
149
  await installDeps(dest, opts.components, pm);
@@ -139,12 +161,14 @@ async function scaffold(opts, dest, localRepo) {
139
161
  } finally {
140
162
  await cleanupRepo(repoDir, isLocal);
141
163
  }
142
- p2.outro(`Done! Next steps:
164
+ p2.outro(
165
+ `Done! Next steps:
143
166
 
144
167
  cd ${name}
145
- ./setup.sh
168
+ ./scripts/setup.sh
146
169
 
147
- Like projx? Star it: https://github.com/ukanhaupa/projx`);
170
+ Like projx? Star it: https://github.com/ukanhaupa/projx`
171
+ );
148
172
  }
149
173
  async function installDeps(dest, components, pm) {
150
174
  const cmds = pmCommands(pm);
@@ -168,7 +192,9 @@ async function installDeps(dest, components, pm) {
168
192
  exec(cmds.install, join(dest, "fastify"));
169
193
  spinner7.stop("Fastify dependencies installed.");
170
194
  } else {
171
- p2.log.warn(`${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`);
195
+ p2.log.warn(
196
+ `${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`
197
+ );
172
198
  }
173
199
  break;
174
200
  case "frontend":
@@ -177,7 +203,9 @@ async function installDeps(dest, components, pm) {
177
203
  exec(cmds.install, join(dest, "frontend"));
178
204
  spinner7.stop("Frontend dependencies installed.");
179
205
  } else {
180
- p2.log.warn(`${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`);
206
+ p2.log.warn(
207
+ `${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`
208
+ );
181
209
  }
182
210
  break;
183
211
  case "e2e":
@@ -186,7 +214,9 @@ async function installDeps(dest, components, pm) {
186
214
  exec(cmds.install, join(dest, "e2e"));
187
215
  spinner7.stop("E2E dependencies installed.");
188
216
  } else {
189
- p2.log.warn(`${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`);
217
+ p2.log.warn(
218
+ `${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`
219
+ );
190
220
  }
191
221
  break;
192
222
  case "mobile":
@@ -195,7 +225,9 @@ async function installDeps(dest, components, pm) {
195
225
  exec("flutter pub get", join(dest, "mobile"));
196
226
  spinner7.stop("Flutter dependencies installed.");
197
227
  } else {
198
- p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
228
+ p2.log.warn(
229
+ "Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
230
+ );
199
231
  }
200
232
  break;
201
233
  case "infra":
@@ -237,17 +269,33 @@ async function update(cwd, localRepo) {
237
269
  } catch {
238
270
  }
239
271
  const raw = await readProjxConfig(cwd);
240
- const { components, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
272
+ const {
273
+ components,
274
+ paths: componentPaths,
275
+ instances
276
+ } = await discoverComponentsFromMarkers(cwd);
277
+ const extraInstances = instances.filter(
278
+ (i) => componentPaths[i.type] !== i.path
279
+ );
241
280
  const pendingConflicts = findFilesWithConflictMarkers(cwd);
242
281
  if (pendingConflicts.length > 0) {
243
- p3.log.warn(`Found ${pendingConflicts.length} file(s) with unresolved conflict markers from a prior update:`);
282
+ p3.log.warn(
283
+ `Found ${pendingConflicts.length} file(s) with unresolved conflict markers from a prior update:`
284
+ );
244
285
  for (const f of pendingConflicts) p3.log.info(` ${f}`);
245
286
  p3.log.info("");
246
287
  const resumeVersion = String(raw.version ?? "unknown");
247
- const handled = await promptSkipLearning(cwd, componentPaths, resumeVersion, pendingConflicts);
288
+ const handled = await promptSkipLearning(
289
+ cwd,
290
+ componentPaths,
291
+ resumeVersion,
292
+ pendingConflicts
293
+ );
248
294
  if (!handled) {
249
295
  p3.log.info("");
250
- p3.log.info("Resolve manually with `git diff` then `git add` / `git checkout --`,");
296
+ p3.log.info(
297
+ "Resolve manually with `git diff` then `git add` / `git checkout --`,"
298
+ );
251
299
  p3.log.info("or re-run `npx create-projx update` to resume the prompt.");
252
300
  }
253
301
  return;
@@ -261,7 +309,9 @@ async function update(cwd, localRepo) {
261
309
  process.exit(1);
262
310
  }
263
311
  if (Object.keys(raw).length > 0) {
264
- p3.log.info(`Found .projx (v${raw.version ?? "unknown"}, components: ${components.join(", ")})`);
312
+ p3.log.info(
313
+ `Found .projx (v${raw.version ?? "unknown"}, components: ${components.join(", ")})`
314
+ );
265
315
  } else {
266
316
  p3.log.warn("No .projx file found. Detected components from markers.");
267
317
  p3.log.info(`Detected: ${components.join(", ")}`);
@@ -279,7 +329,9 @@ async function update(cwd, localRepo) {
279
329
  }
280
330
  }
281
331
  const dlSpinner = p3.spinner();
282
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
332
+ dlSpinner.start(
333
+ isLocal ? "Using local templates" : "Downloading latest templates"
334
+ );
283
335
  const repoDir = await downloadRepo(localRepo).catch((err) => {
284
336
  dlSpinner.stop("Failed.");
285
337
  p3.log.error(String(err));
@@ -287,43 +339,88 @@ async function update(cwd, localRepo) {
287
339
  });
288
340
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
289
341
  try {
290
- const pkg = JSON.parse(await readFile2(join2(repoDir, "cli/package.json"), "utf-8"));
342
+ const pkg = JSON.parse(
343
+ await readFile2(join2(repoDir, "cli/package.json"), "utf-8")
344
+ );
291
345
  const version = pkg.version;
292
346
  const name = detectProjectName(cwd, components, componentPaths);
293
347
  const recordedPm = raw.packageManager;
294
348
  const detectedPm = detectPackageManagerFromComponents(cwd, componentPaths);
295
349
  const pm = detectedPm ?? recordedPm ?? "npm";
296
350
  if (detectedPm && recordedPm && detectedPm !== recordedPm) {
297
- p3.log.warn(`packageManager mismatch: .projx says "${recordedPm}" but lockfile is "${detectedPm}". Using "${detectedPm}".`);
351
+ p3.log.warn(
352
+ `packageManager mismatch: .projx says "${recordedPm}" but lockfile is "${detectedPm}". Using "${detectedPm}".`
353
+ );
298
354
  await writeProjxConfig(cwd, { ...raw, packageManager: detectedPm });
299
355
  } else if (detectedPm && !recordedPm) {
300
356
  await writeProjxConfig(cwd, { ...raw, packageManager: detectedPm });
301
357
  }
302
- const nameOverrides = await detectPackageNameOverrides(cwd, components, componentPaths);
303
- const vars = { projectName: name, components, paths: componentPaths, pm: pmCommands(pm), nameOverrides };
358
+ const nameOverrides = await detectPackageNameOverrides(
359
+ cwd,
360
+ components,
361
+ componentPaths
362
+ );
363
+ const vars = {
364
+ projectName: name,
365
+ components,
366
+ paths: componentPaths,
367
+ instances,
368
+ pm: pmCommands(pm),
369
+ nameOverrides
370
+ };
304
371
  const spinner7 = p3.spinner();
305
372
  spinner7.start("Applying template update");
306
373
  const rootSkip = Array.isArray(raw.skip) ? raw.skip : [];
307
374
  const isLegacyMigration = !raw.defaultsApplied;
308
375
  if (isLegacyMigration) {
309
- p3.log.info("Legacy project detected \u2014 applying default skip patterns for user-owned files.");
376
+ p3.log.info(
377
+ "Legacy project detected \u2014 applying default skip patterns for user-owned files."
378
+ );
310
379
  }
311
- const result = await applyTemplate(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip, isLegacyMigration);
380
+ const result = await applyTemplate(
381
+ cwd,
382
+ repoDir,
383
+ components,
384
+ componentPaths,
385
+ vars,
386
+ version,
387
+ componentSkips,
388
+ rootSkip,
389
+ isLegacyMigration,
390
+ extraInstances
391
+ );
312
392
  spinner7.stop("Template applied.");
313
- const pinnedUpdates = await findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip);
393
+ const pinnedUpdates = await findPinnedFilesWithUpdates(
394
+ cwd,
395
+ repoDir,
396
+ components,
397
+ componentPaths,
398
+ vars,
399
+ version,
400
+ componentSkips,
401
+ rootSkip
402
+ );
314
403
  if (pinnedUpdates.length > 0) {
315
404
  p3.log.info("");
316
- p3.log.info(`${pinnedUpdates.length} pinned file(s) have template updates available:`);
405
+ p3.log.info(
406
+ `${pinnedUpdates.length} pinned file(s) have template updates available:`
407
+ );
317
408
  for (const f of pinnedUpdates) p3.log.info(` ${f}`);
318
- p3.log.info("Run `npx create-projx unpin <file> && npx create-projx update` to opt in.");
409
+ p3.log.info(
410
+ "Run `npx create-projx unpin <file> && npx create-projx update` to opt in."
411
+ );
319
412
  }
320
413
  if (result.status === "merged") {
321
414
  saveBaselineRef(cwd);
322
- p3.log.success(`${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`);
415
+ p3.log.success(
416
+ `${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`
417
+ );
323
418
  p3.outro(`Updated to template v${version}.`);
324
419
  } else if (result.status === "conflicts") {
325
420
  if (result.mergedFiles && result.mergedFiles.length > 0) {
326
- p3.log.success(`${result.mergedFiles.length} file(s) merged cleanly and staged.`);
421
+ p3.log.success(
422
+ `${result.mergedFiles.length} file(s) merged cleanly and staged.`
423
+ );
327
424
  }
328
425
  const conflictCount = result.conflictedFiles?.length ?? 0;
329
426
  if (conflictCount > 0) {
@@ -332,13 +429,20 @@ async function update(cwd, localRepo) {
332
429
  p3.log.info(` ${f}`);
333
430
  }
334
431
  }
335
- const handled = await promptSkipLearning(cwd, componentPaths, version, result.conflictedFiles ?? []);
432
+ const handled = await promptSkipLearning(
433
+ cwd,
434
+ componentPaths,
435
+ version,
436
+ result.conflictedFiles ?? []
437
+ );
336
438
  if (!handled) {
337
439
  p3.log.info("");
338
440
  p3.log.info("Review: git diff");
339
441
  p3.log.info("Keep: git add <file>");
340
442
  p3.log.info("Discard: git checkout -- <file>");
341
- p3.log.info(`Commit: git add . && git commit -m "projx: update to v${version}"`);
443
+ p3.log.info(
444
+ `Commit: git add . && git commit -m "projx: update to v${version}"`
445
+ );
342
446
  p3.outro(`Template v${version} applied. Review with git diff.`);
343
447
  }
344
448
  } else {
@@ -371,7 +475,7 @@ function hasUncommittedChanges(cwd) {
371
475
  async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip) {
372
476
  const { mkdir: mkdir5, rm: rm2, readFile: readFile7 } = await import("fs/promises");
373
477
  const { tmpdir: tmpdir2 } = await import("os");
374
- const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-KTCFW2FK.js");
478
+ const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-PZM4KJJW.js");
375
479
  const config = await readProjxConfig(cwd);
376
480
  const rootPinned = Array.isArray(config.skip) ? config.skip : [];
377
481
  const componentPinned = [];
@@ -388,11 +492,19 @@ async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPat
388
492
  void componentSkips;
389
493
  void rootSkip;
390
494
  try {
391
- await writeTemplateToDir2(tmpTemplate, repoDir, components, componentPaths, vars, version, {
392
- componentSkips: {},
393
- rootSkip: [],
394
- realCwd: tmpTemplate
395
- });
495
+ await writeTemplateToDir2(
496
+ tmpTemplate,
497
+ repoDir,
498
+ components,
499
+ componentPaths,
500
+ vars,
501
+ version,
502
+ {
503
+ componentSkips: {},
504
+ rootSkip: [],
505
+ realCwd: tmpTemplate
506
+ }
507
+ );
396
508
  const updates = [];
397
509
  for (const file of rootPinned) {
398
510
  const tmplPath = join2(tmpTemplate, file);
@@ -439,20 +551,33 @@ async function promptSkipLearning(cwd, componentPaths, version, conflictedFiles)
439
551
  });
440
552
  if (changedFiles.length === 0) return false;
441
553
  if (!process.stdin.isTTY) {
442
- p3.log.info("Non-interactive: skipping prompt. Resolve conflicts manually with `git diff` then `git add`.");
443
- p3.log.info("Re-run `npx create-projx update` later to interactively decide which files to keep.");
554
+ p3.log.info(
555
+ "Non-interactive: skipping prompt. Resolve conflicts manually with `git diff` then `git add`."
556
+ );
557
+ p3.log.info(
558
+ "Re-run `npx create-projx update` later to interactively decide which files to keep."
559
+ );
444
560
  return false;
445
561
  }
446
- const statusOutput = execSync("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
562
+ const statusOutput = execSync("git status --porcelain", {
563
+ cwd,
564
+ stdio: "pipe"
565
+ }).toString().trim();
447
566
  const entries = statusOutput.split("\n").filter(Boolean).map((line) => ({
448
567
  status: line.slice(0, 2).trim(),
449
568
  file: line.slice(3).trim()
450
569
  }));
451
570
  p3.log.warn(`${changedFiles.length} file(s) have conflicts to resolve.`);
452
- p3.log.info("Each file is currently in your working tree with conflict markers.");
571
+ p3.log.info(
572
+ "Each file is currently in your working tree with conflict markers."
573
+ );
453
574
  p3.log.info("");
454
- p3.log.info("CHECKED = keep your version, resolve markers manually, commit when ready");
455
- p3.log.info("UNCHECKED = discard template's changes AND skip this file on future updates");
575
+ p3.log.info(
576
+ "CHECKED = keep your version, resolve markers manually, commit when ready"
577
+ );
578
+ p3.log.info(
579
+ "UNCHECKED = discard template's changes AND skip this file on future updates"
580
+ );
456
581
  p3.log.info("");
457
582
  const selected = await p3.multiselect({
458
583
  message: "Which files do you want to KEEP?",
@@ -484,10 +609,10 @@ async function promptSkipLearning(cwd, componentPaths, version, conflictedFiles)
484
609
  );
485
610
  }
486
611
  if (kept.size > 0) {
487
- p3.log.info(`${kept.size} file(s) kept with conflict markers \u2014 resolve and commit:`);
488
612
  p3.log.info(
489
- ` git add . && git commit -m "projx: update to v${version}"`
613
+ `${kept.size} file(s) kept with conflict markers \u2014 resolve and commit:`
490
614
  );
615
+ p3.log.info(` git add . && git commit -m "projx: update to v${version}"`);
491
616
  p3.outro(`Template v${version} applied.`);
492
617
  } else {
493
618
  p3.outro("All template changes discarded. Skip list updated.");
@@ -536,15 +661,38 @@ import { copyFileSync as copyFileSync2, existsSync as existsSync3 } from "fs";
536
661
  import { readFile as readFile3 } from "fs/promises";
537
662
  import { join as join3 } from "path";
538
663
  import * as p4 from "@clack/prompts";
539
- async function add(cwd, newComponents, localRepo, skipInstall = false) {
664
+ async function add(cwd, newComponents, localRepo, skipInstall = false, customName) {
540
665
  p4.intro("projx add");
541
666
  const isLocal = !!localRepo;
542
667
  if (!existsSync3(join3(cwd, ".projx"))) {
543
- p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
668
+ p4.log.error(
669
+ "No .projx file found. Run 'npx create-projx <name>' to create a project first."
670
+ );
544
671
  process.exit(1);
545
672
  }
546
673
  const config = await readProjxConfig(cwd);
547
674
  const { components: existing } = await discoverComponentsFromMarkers(cwd);
675
+ if (customName) {
676
+ if (newComponents.length !== 1) {
677
+ throw new Error(
678
+ "--name can only be used when adding a single component type."
679
+ );
680
+ }
681
+ const targetDir = join3(cwd, customName);
682
+ if (existsSync3(targetDir)) {
683
+ throw new Error(`Directory '${customName}' already exists.`);
684
+ }
685
+ return await addInstance(
686
+ cwd,
687
+ newComponents[0],
688
+ customName,
689
+ config,
690
+ existing,
691
+ localRepo,
692
+ skipInstall,
693
+ isLocal
694
+ );
695
+ }
548
696
  const alreadyExists = newComponents.filter((c) => existing.includes(c));
549
697
  if (alreadyExists.length > 0) {
550
698
  p4.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
@@ -556,7 +704,9 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
556
704
  }
557
705
  p4.log.info(`Adding: ${toAdd.join(", ")}`);
558
706
  const dlSpinner = p4.spinner();
559
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
707
+ dlSpinner.start(
708
+ isLocal ? "Using local templates" : "Downloading latest templates"
709
+ );
560
710
  const repoDir = await downloadRepo(localRepo).catch((err) => {
561
711
  dlSpinner.stop("Failed.");
562
712
  p4.log.error(String(err));
@@ -568,17 +718,42 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
568
718
  const existingPaths = await discoverComponentPaths(cwd, existing);
569
719
  const paths = { ...existingPaths };
570
720
  for (const c of toAdd) paths[c] = c;
721
+ const { instances: existingInstances } = await discoverComponentsFromMarkers(cwd);
722
+ const instances = [
723
+ ...existingInstances,
724
+ ...toAdd.map((c) => ({ type: c, path: c }))
725
+ ];
571
726
  const pm = config.packageManager ?? "npm";
572
727
  const name = detectProjectName(cwd, existing, paths);
573
- const vars = { projectName: name, components: allComponents, paths, pm: pmCommands(pm) };
574
- const pkg = JSON.parse(await readFile3(join3(repoDir, "cli/package.json"), "utf-8"));
728
+ const vars = {
729
+ projectName: name,
730
+ components: allComponents,
731
+ paths,
732
+ instances,
733
+ pm: pmCommands(pm)
734
+ };
735
+ const pkg = JSON.parse(
736
+ await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
737
+ );
575
738
  const version = pkg.version;
576
739
  const spinner7 = p4.spinner();
577
740
  spinner7.start("Adding components");
578
- await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, { realCwd: cwd });
741
+ await writeTemplateToDir(
742
+ cwd,
743
+ repoDir,
744
+ allComponents,
745
+ paths,
746
+ vars,
747
+ version,
748
+ { realCwd: cwd }
749
+ );
579
750
  spinner7.stop("Components added.");
580
751
  if (!skipInstall) {
581
- await installDeps2(cwd, toAdd, pm);
752
+ await installDeps2(
753
+ cwd,
754
+ toAdd.map((c) => ({ type: c, path: c })),
755
+ pm
756
+ );
582
757
  }
583
758
  for (const component of toAdd) {
584
759
  const example = join3(cwd, component, ".env.example");
@@ -590,70 +765,177 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
590
765
  }
591
766
  }
592
767
  }
593
- p4.outro(`Added ${toAdd.join(", ")}.
768
+ p4.outro(
769
+ `Added ${toAdd.join(", ")}.
594
770
 
595
- Like projx? Star it: https://github.com/ukanhaupa/projx`);
771
+ Like projx? Star it: https://github.com/ukanhaupa/projx`
772
+ );
773
+ } finally {
774
+ await cleanupRepo(repoDir, isLocal);
775
+ }
776
+ }
777
+ async function addInstance(cwd, type, customName, config, existing, localRepo, skipInstall, isLocal) {
778
+ p4.log.info(`Adding ${type} instance at ${customName}/`);
779
+ const dlSpinner = p4.spinner();
780
+ dlSpinner.start(
781
+ isLocal ? "Using local templates" : "Downloading latest templates"
782
+ );
783
+ const repoDir = await downloadRepo(localRepo).catch((err) => {
784
+ dlSpinner.stop("Failed.");
785
+ throw err;
786
+ });
787
+ dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
788
+ try {
789
+ const existingPaths = await discoverComponentPaths(cwd, existing);
790
+ const paths = { ...existingPaths };
791
+ const { instances: existingInstances } = await discoverComponentsFromMarkers(cwd);
792
+ const newInstance = { type, path: customName };
793
+ const instances = [...existingInstances, newInstance];
794
+ const pm = config.packageManager ?? "npm";
795
+ const name = detectProjectName(cwd, existing, existingPaths);
796
+ const vars = {
797
+ projectName: name,
798
+ components: existing,
799
+ paths,
800
+ instances,
801
+ pm: pmCommands(pm)
802
+ };
803
+ const pkg = JSON.parse(
804
+ await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
805
+ );
806
+ const version = pkg.version;
807
+ const INSTANCE_AWARE_ROOT = /* @__PURE__ */ new Set([
808
+ ".github/workflows/ci.yml",
809
+ ".githooks/pre-commit",
810
+ "scripts/setup.sh",
811
+ "docker-compose.yml",
812
+ "docker-compose.dev.yml"
813
+ ]);
814
+ const rawSkip = Array.isArray(config.skip) ? config.skip : [];
815
+ const rootSkip = rawSkip.filter((p11) => !INSTANCE_AWARE_ROOT.has(p11));
816
+ const componentSkips = {};
817
+ for (const inst of existingInstances) {
818
+ const m = await readComponentMarker(join3(cwd, inst.path));
819
+ if (m?.skip && m.skip.length > 0) componentSkips[inst.type] = m.skip;
820
+ }
821
+ const spinner7 = p4.spinner();
822
+ spinner7.start(`Scaffolding ${customName}/`);
823
+ const result = await applyTemplate(
824
+ cwd,
825
+ repoDir,
826
+ existing,
827
+ paths,
828
+ vars,
829
+ version,
830
+ componentSkips,
831
+ rootSkip,
832
+ false,
833
+ [newInstance],
834
+ [newInstance]
835
+ );
836
+ spinner7.stop(`Scaffolded ${customName}/.`);
837
+ if (result.status === "merged") {
838
+ p4.log.success(
839
+ `${result.mergedFiles?.length ?? 0} root file(s) merged cleanly.`
840
+ );
841
+ } else if (result.status === "conflicts") {
842
+ const conflictCount = result.conflictedFiles?.length ?? 0;
843
+ if (conflictCount > 0) {
844
+ p4.log.warn(`${conflictCount} root file(s) need manual review:`);
845
+ for (const f of result.conflictedFiles) p4.log.info(` ${f}`);
846
+ p4.log.info("Review: git diff");
847
+ p4.log.info("Keep: git add <file>");
848
+ p4.log.info("Discard: git checkout -- <file>");
849
+ }
850
+ }
851
+ if (!skipInstall) {
852
+ await installDeps2(cwd, [{ type, path: customName }], pm);
853
+ }
854
+ const example = join3(cwd, customName, ".env.example");
855
+ const env = join3(cwd, customName, ".env");
856
+ if (existsSync3(example) && !existsSync3(env)) {
857
+ try {
858
+ copyFileSync2(example, env);
859
+ } catch {
860
+ }
861
+ }
862
+ p4.outro(`Added ${type} instance at ${customName}/.`);
596
863
  } finally {
597
864
  await cleanupRepo(repoDir, isLocal);
598
865
  }
599
866
  }
600
- async function installDeps2(dest, components, pm) {
867
+ async function installDeps2(dest, instances, pm) {
601
868
  const cmds = pmCommands(pm);
602
869
  const pmBin = pm === "bun" ? "bun" : pm;
603
- for (const component of components) {
870
+ for (const { type, path } of instances) {
871
+ const dir = join3(dest, path);
604
872
  const spinner7 = p4.spinner();
605
873
  try {
606
- switch (component) {
874
+ switch (type) {
607
875
  case "fastapi":
608
876
  if (hasCommand("uv")) {
609
- spinner7.start("Installing FastAPI dependencies");
610
- exec("uv sync --all-extras", join3(dest, "fastapi"));
611
- spinner7.stop("FastAPI dependencies installed.");
877
+ spinner7.start(`Installing FastAPI dependencies (${path}/)`);
878
+ exec("uv sync --all-extras", dir);
879
+ spinner7.stop(`FastAPI dependencies installed (${path}/).`);
612
880
  } else {
613
- p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
881
+ p4.log.warn(`uv not found \u2014 run 'cd ${path} && uv sync' manually.`);
614
882
  }
615
883
  break;
616
884
  case "fastify":
617
885
  if (hasCommand(pmBin)) {
618
- spinner7.start(`Installing Fastify dependencies (${cmds.install})`);
619
- exec(cmds.install, join3(dest, "fastify"));
620
- spinner7.stop("Fastify dependencies installed.");
886
+ spinner7.start(
887
+ `Installing Fastify dependencies (${path}/, ${cmds.install})`
888
+ );
889
+ exec(cmds.install, dir);
890
+ spinner7.stop(`Fastify dependencies installed (${path}/).`);
621
891
  } else {
622
- p4.log.warn(`${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`);
892
+ p4.log.warn(
893
+ `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
894
+ );
623
895
  }
624
896
  break;
625
897
  case "frontend":
626
898
  if (hasCommand(pmBin)) {
627
- spinner7.start(`Installing Frontend dependencies (${cmds.install})`);
628
- exec(cmds.install, join3(dest, "frontend"));
629
- spinner7.stop("Frontend dependencies installed.");
899
+ spinner7.start(
900
+ `Installing Frontend dependencies (${path}/, ${cmds.install})`
901
+ );
902
+ exec(cmds.install, dir);
903
+ spinner7.stop(`Frontend dependencies installed (${path}/).`);
630
904
  } else {
631
- p4.log.warn(`${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`);
905
+ p4.log.warn(
906
+ `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
907
+ );
632
908
  }
633
909
  break;
634
910
  case "e2e":
635
911
  if (hasCommand(pmBin)) {
636
- spinner7.start(`Installing E2E dependencies (${cmds.install})`);
637
- exec(cmds.install, join3(dest, "e2e"));
638
- spinner7.stop("E2E dependencies installed.");
912
+ spinner7.start(
913
+ `Installing E2E dependencies (${path}/, ${cmds.install})`
914
+ );
915
+ exec(cmds.install, dir);
916
+ spinner7.stop(`E2E dependencies installed (${path}/).`);
639
917
  } else {
640
- p4.log.warn(`${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`);
918
+ p4.log.warn(
919
+ `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
920
+ );
641
921
  }
642
922
  break;
643
923
  case "mobile":
644
924
  if (hasCommand("flutter")) {
645
- spinner7.start("Installing Flutter dependencies");
646
- exec("flutter pub get", join3(dest, "mobile"));
647
- spinner7.stop("Flutter dependencies installed.");
925
+ spinner7.start(`Installing Flutter dependencies (${path}/)`);
926
+ exec("flutter pub get", dir);
927
+ spinner7.stop(`Flutter dependencies installed (${path}/).`);
648
928
  } else {
649
- p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
929
+ p4.log.warn(
930
+ `Flutter not found \u2014 run 'cd ${path} && flutter pub get' manually.`
931
+ );
650
932
  }
651
933
  break;
652
934
  case "infra":
653
935
  break;
654
936
  }
655
937
  } catch {
656
- spinner7.stop(`Failed to install ${component} dependencies.`);
938
+ spinner7.stop(`Failed to install ${type} dependencies (${path}/).`);
657
939
  }
658
940
  }
659
941
  }
@@ -672,7 +954,9 @@ import { join as join4 } from "path";
672
954
  async function detectComponents(cwd) {
673
955
  const results = [];
674
956
  const entries = await readdir(cwd, { withFileTypes: true });
675
- const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)).map((e) => e.name);
957
+ const dirs = entries.filter(
958
+ (e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)
959
+ ).map((e) => e.name);
676
960
  for (const dir of dirs) {
677
961
  const full = join4(cwd, dir);
678
962
  const detections = await scanDirectory(full, dir);
@@ -754,11 +1038,15 @@ async function init(cwd, localRepo) {
754
1038
  p5.intro("projx init");
755
1039
  const isLocal = !!localRepo;
756
1040
  if (existsSync5(join5(cwd, ".projx"))) {
757
- p5.log.error("This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead.");
1041
+ p5.log.error(
1042
+ "This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead."
1043
+ );
758
1044
  process.exit(1);
759
1045
  }
760
1046
  if (!isGitRepo2(cwd)) {
761
- p5.log.error(`projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
1047
+ p5.log.error(
1048
+ `projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`
1049
+ );
762
1050
  process.exit(1);
763
1051
  }
764
1052
  if (hasUncommittedChanges2(cwd)) {
@@ -771,12 +1059,14 @@ async function init(cwd, localRepo) {
771
1059
  spinner7.stop(
772
1060
  detected.length > 0 ? `Found ${detected.length} component(s).` : "No components detected."
773
1061
  );
774
- let confirmed;
775
- if (detected.length > 0) {
776
- confirmed = await confirmDetections(detected);
777
- } else {
778
- confirmed = await manualSelect(cwd);
1062
+ if (detected.length === 0) {
1063
+ await writeBareProjx(cwd, localRepo, isLocal, detectPackageManager(cwd));
1064
+ p5.outro(
1065
+ "Initialized empty .projx. Add components with 'npx create-projx add <component>'."
1066
+ );
1067
+ return;
779
1068
  }
1069
+ const confirmed = await confirmDetections(detected);
780
1070
  if (confirmed.length === 0) {
781
1071
  p5.log.warn("No components selected. Nothing to do.");
782
1072
  process.exit(0);
@@ -785,7 +1075,9 @@ async function init(cwd, localRepo) {
785
1075
  const paths = Object.fromEntries(
786
1076
  confirmed.map((c) => [c.component, c.directory])
787
1077
  );
788
- const hasJs = components.some((c) => ["fastify", "frontend", "e2e"].includes(c));
1078
+ const hasJs = components.some(
1079
+ (c) => ["fastify", "frontend", "e2e"].includes(c)
1080
+ );
789
1081
  let pm = "npm";
790
1082
  if (hasJs) {
791
1083
  const detected2 = detectPackageManager(cwd);
@@ -803,9 +1095,16 @@ async function init(cwd, localRepo) {
803
1095
  }
804
1096
  }
805
1097
  const projectName = toKebab(cwd.split("/").pop());
806
- const vars = { projectName, components, paths, pm: pmCommands(pm) };
1098
+ const vars = {
1099
+ projectName,
1100
+ components,
1101
+ paths,
1102
+ pm: pmCommands(pm)
1103
+ };
807
1104
  const dlSpinner = p5.spinner();
808
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
1105
+ dlSpinner.start(
1106
+ isLocal ? "Using local templates" : "Downloading latest templates"
1107
+ );
809
1108
  const repoDir = await downloadRepo(localRepo).catch((err) => {
810
1109
  dlSpinner.stop("Failed.");
811
1110
  p5.log.error(String(err));
@@ -813,11 +1112,23 @@ async function init(cwd, localRepo) {
813
1112
  });
814
1113
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
815
1114
  try {
816
- const pkg = JSON.parse(await readFile4(join5(repoDir, "cli/package.json"), "utf-8"));
1115
+ const pkg = JSON.parse(
1116
+ await readFile4(join5(repoDir, "cli/package.json"), "utf-8")
1117
+ );
817
1118
  const version = pkg.version;
818
1119
  const applySpinner = p5.spinner();
819
1120
  applySpinner.start("Applying template");
820
- const result = await applyTemplate(cwd, repoDir, components, paths, vars, version, void 0, void 0, true);
1121
+ const result = await applyTemplate(
1122
+ cwd,
1123
+ repoDir,
1124
+ components,
1125
+ paths,
1126
+ vars,
1127
+ version,
1128
+ void 0,
1129
+ void 0,
1130
+ true
1131
+ );
821
1132
  applySpinner.stop("Template applied.");
822
1133
  if (existsSync5(join5(cwd, ".githooks"))) {
823
1134
  try {
@@ -829,24 +1140,62 @@ async function init(cwd, localRepo) {
829
1140
  saveBaselineRef(cwd);
830
1141
  }
831
1142
  if (result.status === "conflicts") {
832
- p5.log.warn("Some template files differ from your code. Changes written directly.");
1143
+ p5.log.warn(
1144
+ "Some template files differ from your code. Changes written directly."
1145
+ );
833
1146
  p5.log.info("Review changes:");
834
1147
  p5.log.info(" git diff");
835
1148
  p5.log.info("");
836
1149
  p5.log.info("Keep a change: git add <file>");
837
1150
  p5.log.info("Discard a change: git checkout -- <file>");
838
- p5.log.info('Commit when ready: git add . && git commit -m "projx: init"');
1151
+ p5.log.info(
1152
+ 'Commit when ready: git add . && git commit -m "projx: init"'
1153
+ );
839
1154
  p5.log.info("");
840
1155
  p5.log.info("To skip files on future updates, add to .projx-component:");
841
1156
  p5.log.info(' { "skip": ["src/**", "tests/**"] }');
842
- p5.outro("Template applied. Review with git diff.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1157
+ p5.outro(
1158
+ "Template applied. Review with git diff.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx"
1159
+ );
843
1160
  } else {
844
- p5.outro("Project initialized.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1161
+ p5.outro(
1162
+ "Project initialized.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx"
1163
+ );
845
1164
  }
846
1165
  } finally {
847
1166
  await cleanupRepo(repoDir, isLocal);
848
1167
  }
849
1168
  }
1169
+ async function writeBareProjx(cwd, localRepo, isLocal, pm) {
1170
+ const dlSpinner = p5.spinner();
1171
+ dlSpinner.start(
1172
+ isLocal ? "Using local templates" : "Downloading latest templates"
1173
+ );
1174
+ const repoDir = await downloadRepo(localRepo).catch((err) => {
1175
+ dlSpinner.stop("Failed.");
1176
+ p5.log.error(String(err));
1177
+ process.exit(1);
1178
+ });
1179
+ dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1180
+ try {
1181
+ const pkg = JSON.parse(
1182
+ await readFile4(join5(repoDir, "cli/package.json"), "utf-8")
1183
+ );
1184
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1185
+ const config = {
1186
+ version: pkg.version,
1187
+ createdAt: today,
1188
+ updatedAt: today,
1189
+ skip: [...DEFAULT_ROOT_SKIP_PATTERNS],
1190
+ defaultsApplied: true
1191
+ };
1192
+ if (pm) config.packageManager = pm;
1193
+ await writeProjxConfig(cwd, config);
1194
+ saveBaselineRef(cwd);
1195
+ } finally {
1196
+ await cleanupRepo(repoDir, isLocal);
1197
+ }
1198
+ }
850
1199
  async function confirmDetections(detected) {
851
1200
  const confirmed = [];
852
1201
  for (const d of detected) {
@@ -861,33 +1210,6 @@ async function confirmDetections(detected) {
861
1210
  }
862
1211
  return confirmed;
863
1212
  }
864
- async function manualSelect(cwd) {
865
- const selected = await p5.multiselect({
866
- message: "No components detected. Select manually:",
867
- options: COMPONENTS.map((c) => ({
868
- value: c,
869
- label: LABELS[c].label,
870
- hint: LABELS[c].hint
871
- })),
872
- required: false
873
- });
874
- if (p5.isCancel(selected)) process.exit(0);
875
- const result = [];
876
- for (const component of selected) {
877
- const dir = await p5.text({
878
- message: `Directory for ${LABELS[component].label}?`,
879
- placeholder: component,
880
- defaultValue: component
881
- });
882
- if (p5.isCancel(dir)) process.exit(0);
883
- if (!existsSync5(join5(cwd, dir))) {
884
- p5.log.warn(`${dir}/ does not exist \u2014 skipping.`);
885
- continue;
886
- }
887
- result.push({ component, directory: dir });
888
- }
889
- return result;
890
- }
891
1213
  function isGitRepo2(cwd) {
892
1214
  try {
893
1215
  execSync2("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
@@ -940,7 +1262,10 @@ async function pin(cwd, patterns) {
940
1262
  p6.log.warn(`Cannot pin ${pattern} \u2014 config files are managed by projx.`);
941
1263
  continue;
942
1264
  }
943
- const { scope, component, relative } = classifyPattern(pattern, componentPaths);
1265
+ const { scope, component, relative } = classifyPattern(
1266
+ pattern,
1267
+ componentPaths
1268
+ );
944
1269
  if (scope === "component" && component) {
945
1270
  if (!componentAdds[component]) componentAdds[component] = [];
946
1271
  componentAdds[component].push(relative);
@@ -989,7 +1314,10 @@ async function unpin(cwd, patterns) {
989
1314
  const rootRemoves = [];
990
1315
  const componentRemoves = {};
991
1316
  for (const pattern of patterns) {
992
- const { scope, component, relative } = classifyPattern(pattern, componentPaths);
1317
+ const { scope, component, relative } = classifyPattern(
1318
+ pattern,
1319
+ componentPaths
1320
+ );
993
1321
  if (scope === "component" && component) {
994
1322
  if (!componentRemoves[component]) componentRemoves[component] = [];
995
1323
  componentRemoves[component].push(relative);
@@ -1089,7 +1417,11 @@ async function checkConfig(cwd) {
1089
1417
  });
1090
1418
  return { results };
1091
1419
  }
1092
- results.push({ name: ".projx exists", status: "pass", message: `v${rootConfig.version ?? "unknown"}` });
1420
+ results.push({
1421
+ name: ".projx exists",
1422
+ status: "pass",
1423
+ message: `v${rootConfig.version ?? "unknown"}`
1424
+ });
1093
1425
  if (!rootConfig.version) {
1094
1426
  results.push({
1095
1427
  name: ".projx fields",
@@ -1110,7 +1442,11 @@ async function checkComponents(cwd, components, componentPaths) {
1110
1442
  });
1111
1443
  return results;
1112
1444
  }
1113
- results.push({ name: "components", status: "pass", message: `${components.length} discovered from markers` });
1445
+ results.push({
1446
+ name: "components",
1447
+ status: "pass",
1448
+ message: `${components.length} discovered from markers`
1449
+ });
1114
1450
  for (const component of components) {
1115
1451
  const dir = componentPaths[component];
1116
1452
  const fullDir = join7(cwd, dir);
@@ -1133,7 +1469,11 @@ async function checkComponents(cwd, components, componentPaths) {
1133
1469
  continue;
1134
1470
  }
1135
1471
  const label = dir !== component ? `${dir}/ (${component})` : `${component}/`;
1136
- results.push({ name: `${component} marker`, status: "pass", message: label });
1472
+ results.push({
1473
+ name: `${component} marker`,
1474
+ status: "pass",
1475
+ message: label
1476
+ });
1137
1477
  }
1138
1478
  return results;
1139
1479
  }
@@ -1143,18 +1483,36 @@ function checkGit(cwd, fix) {
1143
1483
  execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1144
1484
  results.push({ name: "git repo", status: "pass", message: "OK" });
1145
1485
  } catch {
1146
- results.push({ name: "git repo", status: "fail", message: "Not a git repository." });
1486
+ results.push({
1487
+ name: "git repo",
1488
+ status: "fail",
1489
+ message: "Not a git repository."
1490
+ });
1147
1491
  return results;
1148
1492
  }
1149
1493
  try {
1150
- const ref = execSync3(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
1151
- results.push({ name: "baseline ref", status: "pass", message: ref.slice(0, 8) });
1494
+ const ref = execSync3(`git rev-parse --verify ${BASELINE_REF}`, {
1495
+ cwd,
1496
+ stdio: "pipe"
1497
+ }).toString().trim();
1498
+ results.push({
1499
+ name: "baseline ref",
1500
+ status: "pass",
1501
+ message: ref.slice(0, 8)
1502
+ });
1152
1503
  } catch {
1153
1504
  if (fix) {
1154
1505
  saveBaselineRef(cwd);
1155
1506
  try {
1156
- execSync3(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" });
1157
- results.push({ name: "baseline ref", status: "pass", message: "Created from git history." });
1507
+ execSync3(`git rev-parse --verify ${BASELINE_REF}`, {
1508
+ cwd,
1509
+ stdio: "pipe"
1510
+ });
1511
+ results.push({
1512
+ name: "baseline ref",
1513
+ status: "pass",
1514
+ message: "Created from git history."
1515
+ });
1158
1516
  } catch {
1159
1517
  results.push({
1160
1518
  name: "baseline ref",
@@ -1173,12 +1531,19 @@ function checkGit(cwd, fix) {
1173
1531
  }
1174
1532
  }
1175
1533
  try {
1176
- const worktrees = execSync3("git worktree list --porcelain", { cwd, stdio: "pipe" }).toString();
1534
+ const worktrees = execSync3("git worktree list --porcelain", {
1535
+ cwd,
1536
+ stdio: "pipe"
1537
+ }).toString();
1177
1538
  const stale = worktrees.split("\n").filter((l) => l.includes("projx-wt-") || l.includes("projx/tmp-"));
1178
1539
  if (stale.length > 0) {
1179
1540
  if (fix) {
1180
1541
  execSync3("git worktree prune", { cwd, stdio: "pipe" });
1181
- results.push({ name: "worktrees", status: "pass", message: "Pruned stale worktrees." });
1542
+ results.push({
1543
+ name: "worktrees",
1544
+ status: "pass",
1545
+ message: "Pruned stale worktrees."
1546
+ });
1182
1547
  } else {
1183
1548
  results.push({
1184
1549
  name: "worktrees",
@@ -1198,7 +1563,11 @@ function checkGit(cwd, fix) {
1198
1563
  const status = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1199
1564
  if (status) {
1200
1565
  const count = status.split("\n").length;
1201
- results.push({ name: "working tree", status: "warn", message: `${count} uncommitted change(s).` });
1566
+ results.push({
1567
+ name: "working tree",
1568
+ status: "warn",
1569
+ message: `${count} uncommitted change(s).`
1570
+ });
1202
1571
  } else {
1203
1572
  results.push({ name: "working tree", status: "pass", message: "Clean" });
1204
1573
  }
@@ -1236,7 +1605,11 @@ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
1236
1605
  }
1237
1606
  }
1238
1607
  if (results.length === 0 && (rootSkip.length > 0 || components.length > 0)) {
1239
- results.push({ name: "skip patterns", status: "pass", message: "All patterns match files." });
1608
+ results.push({
1609
+ name: "skip patterns",
1610
+ status: "pass",
1611
+ message: "All patterns match files."
1612
+ });
1240
1613
  }
1241
1614
  return results;
1242
1615
  }
@@ -1275,7 +1648,9 @@ async function doctor(cwd, fix = false) {
1275
1648
  const { components, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
1276
1649
  allResults.push(...await checkComponents(cwd, components, componentPaths));
1277
1650
  allResults.push(...checkGit(cwd, fix));
1278
- allResults.push(...await checkSkipPatterns(cwd, rootConfig, components, componentPaths));
1651
+ allResults.push(
1652
+ ...await checkSkipPatterns(cwd, rootConfig, components, componentPaths)
1653
+ );
1279
1654
  printReport(allResults);
1280
1655
  const passed = allResults.filter((r) => r.status === "pass").length;
1281
1656
  const warns = allResults.filter((r) => r.status === "warn").length;
@@ -1341,7 +1716,9 @@ async function diff(cwd, localRepo) {
1341
1716
  }
1342
1717
  const rootSkip = Array.isArray(raw.skip) ? raw.skip : [];
1343
1718
  const dlSpinner = p8.spinner();
1344
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
1719
+ dlSpinner.start(
1720
+ isLocal ? "Using local templates" : "Downloading latest templates"
1721
+ );
1345
1722
  const repoDir = await downloadRepo(localRepo).catch((err) => {
1346
1723
  dlSpinner.stop("Failed.");
1347
1724
  p8.log.error(String(err));
@@ -1349,20 +1726,35 @@ async function diff(cwd, localRepo) {
1349
1726
  });
1350
1727
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1351
1728
  try {
1352
- const pkg = JSON.parse(await readFile5(join8(repoDir, "cli/package.json"), "utf-8"));
1729
+ const pkg = JSON.parse(
1730
+ await readFile5(join8(repoDir, "cli/package.json"), "utf-8")
1731
+ );
1353
1732
  const version = pkg.version;
1354
1733
  p8.log.info(`Current: v${raw.version ?? "unknown"} \u2192 Template: v${version}`);
1355
1734
  const name = detectProjectName(cwd, components, componentPaths);
1356
- const vars = { projectName: name, components, paths: componentPaths, pm: pmCommands(raw.packageManager ?? "npm") };
1735
+ const vars = {
1736
+ projectName: name,
1737
+ components,
1738
+ paths: componentPaths,
1739
+ pm: pmCommands(raw.packageManager ?? "npm")
1740
+ };
1357
1741
  const spinner7 = p8.spinner();
1358
1742
  spinner7.start("Analyzing changes");
1359
1743
  const tmpTemplate = join8(tmpdir(), `projx-diff-${Date.now()}`);
1360
1744
  await mkdir2(tmpTemplate, { recursive: true });
1361
- await writeTemplateToDir(tmpTemplate, repoDir, components, componentPaths, vars, version, {
1362
- componentSkips,
1363
- rootSkip,
1364
- realCwd: cwd
1365
- });
1745
+ await writeTemplateToDir(
1746
+ tmpTemplate,
1747
+ repoDir,
1748
+ components,
1749
+ componentPaths,
1750
+ vars,
1751
+ version,
1752
+ {
1753
+ componentSkips,
1754
+ rootSkip,
1755
+ realCwd: cwd
1756
+ }
1757
+ );
1366
1758
  const baselineRef = getBaselineRef(cwd);
1367
1759
  const templateFiles = await collectAllFiles(tmpTemplate, tmpTemplate);
1368
1760
  const analyses = [];
@@ -1409,12 +1801,12 @@ async function diff(cwd, localRepo) {
1409
1801
  await rm(tmpTemplate, { recursive: true, force: true });
1410
1802
  spinner7.stop("Analysis complete.");
1411
1803
  const groups = {
1412
- "new": [],
1804
+ new: [],
1413
1805
  "clean-update": [],
1414
1806
  "needs-merge": [],
1415
1807
  "user-only": [],
1416
- "unchanged": [],
1417
- "skipped": []
1808
+ unchanged: [],
1809
+ skipped: []
1418
1810
  };
1419
1811
  for (const a of analyses) {
1420
1812
  groups[a.status].push(a);
@@ -1424,15 +1816,21 @@ async function diff(cwd, localRepo) {
1424
1816
  for (const a of groups["new"]) p8.log.info(` + ${a.file}`);
1425
1817
  }
1426
1818
  if (groups["clean-update"].length > 0) {
1427
- p8.log.success(`Clean updates \u2014 auto-merged (${groups["clean-update"].length}):`);
1819
+ p8.log.success(
1820
+ `Clean updates \u2014 auto-merged (${groups["clean-update"].length}):`
1821
+ );
1428
1822
  for (const a of groups["clean-update"]) p8.log.info(` ~ ${a.file}`);
1429
1823
  }
1430
1824
  if (groups["needs-merge"].length > 0) {
1431
- p8.log.warn(`Needs merge \u2014 both sides changed (${groups["needs-merge"].length}):`);
1825
+ p8.log.warn(
1826
+ `Needs merge \u2014 both sides changed (${groups["needs-merge"].length}):`
1827
+ );
1432
1828
  for (const a of groups["needs-merge"]) p8.log.info(` ! ${a.file}`);
1433
1829
  }
1434
1830
  if (groups["user-only"].length > 0) {
1435
- p8.log.info(`User-modified only \u2014 no template change (${groups["user-only"].length}):`);
1831
+ p8.log.info(
1832
+ `User-modified only \u2014 no template change (${groups["user-only"].length}):`
1833
+ );
1436
1834
  for (const a of groups["user-only"]) p8.log.info(` = ${a.file}`);
1437
1835
  }
1438
1836
  if (groups["skipped"].length > 0) {
@@ -1459,12 +1857,21 @@ import { existsSync as existsSync9 } from "fs";
1459
1857
  import { readFile as readFile6, writeFile, mkdir as mkdir3 } from "fs/promises";
1460
1858
  import { join as join9 } from "path";
1461
1859
  import * as p9 from "@clack/prompts";
1462
- var FIELD_TYPES = ["string", "number", "boolean", "date", "datetime", "text", "json"];
1860
+ var FIELD_TYPES = [
1861
+ "string",
1862
+ "number",
1863
+ "boolean",
1864
+ "date",
1865
+ "datetime",
1866
+ "text",
1867
+ "json"
1868
+ ];
1463
1869
  function toPascal(s) {
1464
1870
  return s.split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
1465
1871
  }
1466
1872
  function pluralize(s) {
1467
- if (s.endsWith("s") || s.endsWith("x") || s.endsWith("z") || s.endsWith("sh") || s.endsWith("ch")) return s + "es";
1873
+ if (s.endsWith("s") || s.endsWith("x") || s.endsWith("z") || s.endsWith("sh") || s.endsWith("ch"))
1874
+ return s + "es";
1468
1875
  if (s.endsWith("y") && !/[aeiou]y$/i.test(s)) return s.slice(0, -1) + "ies";
1469
1876
  return s + "s";
1470
1877
  }
@@ -1527,7 +1934,9 @@ async function promptEntityConfig(name) {
1527
1934
  p9.log.warn("No fields defined. Adding a default 'name' field.");
1528
1935
  fields.push({ name: "name", type: "string", required: true });
1529
1936
  }
1530
- const stringFields = fields.filter((f) => f.type === "string" || f.type === "text");
1937
+ const stringFields = fields.filter(
1938
+ (f) => f.type === "string" || f.type === "text"
1939
+ );
1531
1940
  let searchableFields = [];
1532
1941
  if (stringFields.length > 0) {
1533
1942
  const selected = await p9.multiselect({
@@ -1632,7 +2041,9 @@ function generateFastAPIModel(config) {
1632
2041
  lines.push("");
1633
2042
  for (const field of config.fields) {
1634
2043
  const nullable = field.required ? "nullable=False" : "nullable=True";
1635
- lines.push(` ${field.name} = Column(${sqlalchemyType(field.type)}, ${nullable})`);
2044
+ lines.push(
2045
+ ` ${field.name} = Column(${sqlalchemyType(field.type)}, ${nullable})`
2046
+ );
1636
2047
  }
1637
2048
  lines.push("");
1638
2049
  return lines.join("\n");
@@ -1726,7 +2137,10 @@ function generateFastifySchemas(config) {
1726
2137
  }
1727
2138
  lines.push(` created_at: Type.String({ format: 'date-time' }),`);
1728
2139
  lines.push(` updated_at: Type.String({ format: 'date-time' }),`);
1729
- if (config.softDelete) lines.push(` deleted_at: Type.Union([Type.String({ format: 'date-time' }), Type.Null()]),`);
2140
+ if (config.softDelete)
2141
+ lines.push(
2142
+ ` deleted_at: Type.Union([Type.String({ format: 'date-time' }), Type.Null()]),`
2143
+ );
1730
2144
  lines.push(`});`);
1731
2145
  lines.push("");
1732
2146
  lines.push(`export type ${className} = Static<typeof ${className}Schema>;`);
@@ -1741,7 +2155,9 @@ function generateFastifySchemas(config) {
1741
2155
  }
1742
2156
  lines.push(`});`);
1743
2157
  lines.push("");
1744
- lines.push(`export type Create${className} = Static<typeof Create${className}Schema>;`);
2158
+ lines.push(
2159
+ `export type Create${className} = Static<typeof Create${className}Schema>;`
2160
+ );
1745
2161
  lines.push("");
1746
2162
  lines.push(`export const Update${className}Schema = Type.Object({`);
1747
2163
  for (const f of config.fields) {
@@ -1749,29 +2165,50 @@ function generateFastifySchemas(config) {
1749
2165
  }
1750
2166
  lines.push(`});`);
1751
2167
  lines.push("");
1752
- lines.push(`export type Update${className} = Static<typeof Update${className}Schema>;`);
2168
+ lines.push(
2169
+ `export type Update${className} = Static<typeof Update${className}Schema>;`
2170
+ );
1753
2171
  lines.push("");
1754
2172
  return lines.join("\n");
1755
2173
  }
1756
2174
  function generateFastifyIndex(config) {
1757
2175
  const className = toPascal(config.name);
1758
2176
  const camelConfig = className.charAt(0).toLowerCase() + className.slice(1) + "Config";
1759
- const allColumns = ["id", ...config.fields.map((f) => f.name), "created_at", "updated_at"];
2177
+ const allColumns = [
2178
+ "id",
2179
+ ...config.fields.map((f) => f.name),
2180
+ "created_at",
2181
+ "updated_at"
2182
+ ];
1760
2183
  if (config.softDelete) allColumns.push("deleted_at");
1761
2184
  const lines = [];
1762
- lines.push(`import { EntityRegistry, type EntityConfig, type FieldMeta } from '../_base/index.js';`);
1763
- lines.push(`import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`);
2185
+ lines.push(
2186
+ `import { EntityRegistry, type EntityConfig, type FieldMeta } from '../_base/index.js';`
2187
+ );
2188
+ lines.push(
2189
+ `import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`
2190
+ );
1764
2191
  lines.push("");
1765
2192
  lines.push(`const fields: FieldMeta[] = [`);
1766
- lines.push(` { key: 'id', label: 'Id', type: 'str', nullable: false, is_auto: true, is_primary_key: true, filterable: true, has_foreign_key: false, field_type: 'text' },`);
2193
+ lines.push(
2194
+ ` { key: 'id', label: 'Id', type: 'str', nullable: false, is_auto: true, is_primary_key: true, filterable: true, has_foreign_key: false, field_type: 'text' },`
2195
+ );
1767
2196
  for (const f of config.fields) {
1768
2197
  const meta = fieldMetaType(f.type);
1769
- lines.push(` { key: '${f.name}', label: '${toTitle(f.name)}', type: '${meta.type}', nullable: ${!f.required}, is_auto: false, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: '${meta.fieldType}' },`);
2198
+ lines.push(
2199
+ ` { key: '${f.name}', label: '${toTitle(f.name)}', type: '${meta.type}', nullable: ${!f.required}, is_auto: false, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: '${meta.fieldType}' },`
2200
+ );
1770
2201
  }
1771
- lines.push(` { key: 'created_at', label: 'Created At', type: 'datetime', nullable: false, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`);
1772
- lines.push(` { key: 'updated_at', label: 'Updated At', type: 'datetime', nullable: false, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`);
2202
+ lines.push(
2203
+ ` { key: 'created_at', label: 'Created At', type: 'datetime', nullable: false, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`
2204
+ );
2205
+ lines.push(
2206
+ ` { key: 'updated_at', label: 'Updated At', type: 'datetime', nullable: false, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`
2207
+ );
1773
2208
  if (config.softDelete) {
1774
- lines.push(` { key: 'deleted_at', label: 'Deleted At', type: 'datetime', nullable: true, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`);
2209
+ lines.push(
2210
+ ` { key: 'deleted_at', label: 'Deleted At', type: 'datetime', nullable: true, is_auto: true, is_primary_key: false, filterable: true, has_foreign_key: false, field_type: 'datetime' },`
2211
+ );
1775
2212
  }
1776
2213
  lines.push(`];`);
1777
2214
  lines.push("");
@@ -1787,7 +2224,9 @@ function generateFastifyIndex(config) {
1787
2224
  lines.push(` bulkOperations: ${config.bulkOperations},`);
1788
2225
  lines.push(` columnNames: [${allColumns.map((c) => `'${c}'`).join(", ")}],`);
1789
2226
  if (config.searchableFields.length > 0) {
1790
- lines.push(` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`);
2227
+ lines.push(
2228
+ ` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`
2229
+ );
1791
2230
  } else {
1792
2231
  lines.push(` searchableFields: [],`);
1793
2232
  }
@@ -1898,7 +2337,8 @@ function dartFromJson(fieldName, type, required) {
1898
2337
  const key = `json['${fieldName}']`;
1899
2338
  const isDate = type === "date" || type === "datetime";
1900
2339
  if (isDate && required) return `DateTime.parse(${key} as String)`;
1901
- if (isDate && !required) return `${key} != null ? DateTime.parse(${key} as String) : null`;
2340
+ if (isDate && !required)
2341
+ return `${key} != null ? DateTime.parse(${key} as String) : null`;
1902
2342
  if (type === "json" && !required) return `${key} as Map<String, dynamic>?`;
1903
2343
  if (type === "json") return `${key} as Map<String, dynamic>`;
1904
2344
  const dartT = (() => {
@@ -1918,14 +2358,22 @@ function dartFromJson(fieldName, type, required) {
1918
2358
  }
1919
2359
  function dartToJson(fieldName, camelName, type, required) {
1920
2360
  const isDate = type === "date" || type === "datetime";
1921
- if (isDate && required) return `'${fieldName}': ${camelName}.toIso8601String()`;
1922
- if (isDate && !required) return `'${fieldName}': ${camelName}?.toIso8601String()`;
2361
+ if (isDate && required)
2362
+ return `'${fieldName}': ${camelName}.toIso8601String()`;
2363
+ if (isDate && !required)
2364
+ return `'${fieldName}': ${camelName}?.toIso8601String()`;
1923
2365
  return `'${fieldName}': ${camelName}`;
1924
2366
  }
1925
2367
  function generateDartModel(config) {
1926
2368
  const className = toPascal(config.name);
1927
2369
  const allFields = [
1928
- { snake: "id", camel: "id", type: "String", required: true, fieldType: "string" },
2370
+ {
2371
+ snake: "id",
2372
+ camel: "id",
2373
+ type: "String",
2374
+ required: true,
2375
+ fieldType: "string"
2376
+ },
1929
2377
  ...config.fields.map((f) => ({
1930
2378
  snake: f.name,
1931
2379
  camel: toCamel(f.name),
@@ -1935,11 +2383,29 @@ function generateDartModel(config) {
1935
2383
  }))
1936
2384
  ];
1937
2385
  if (config.softDelete) {
1938
- allFields.push({ snake: "deleted_at", camel: "deletedAt", type: "DateTime?", required: false, fieldType: "datetime" });
2386
+ allFields.push({
2387
+ snake: "deleted_at",
2388
+ camel: "deletedAt",
2389
+ type: "DateTime?",
2390
+ required: false,
2391
+ fieldType: "datetime"
2392
+ });
1939
2393
  }
1940
2394
  allFields.push(
1941
- { snake: "created_at", camel: "createdAt", type: "DateTime", required: true, fieldType: "datetime" },
1942
- { snake: "updated_at", camel: "updatedAt", type: "DateTime", required: true, fieldType: "datetime" }
2395
+ {
2396
+ snake: "created_at",
2397
+ camel: "createdAt",
2398
+ type: "DateTime",
2399
+ required: true,
2400
+ fieldType: "datetime"
2401
+ },
2402
+ {
2403
+ snake: "updated_at",
2404
+ camel: "updatedAt",
2405
+ type: "DateTime",
2406
+ required: true,
2407
+ fieldType: "datetime"
2408
+ }
1943
2409
  );
1944
2410
  const lines = [];
1945
2411
  lines.push(`class ${className} {`);
@@ -1960,7 +2426,9 @@ function generateDartModel(config) {
1960
2426
  lines.push(` factory ${className}.fromJson(Map<String, dynamic> json) {`);
1961
2427
  lines.push(` return ${className}(`);
1962
2428
  for (const f of allFields) {
1963
- lines.push(` ${f.camel}: ${dartFromJson(f.snake, f.fieldType, f.required)},`);
2429
+ lines.push(
2430
+ ` ${f.camel}: ${dartFromJson(f.snake, f.fieldType, f.required)},`
2431
+ );
1964
2432
  }
1965
2433
  lines.push(` );`);
1966
2434
  lines.push(` }`);
@@ -1968,7 +2436,9 @@ function generateDartModel(config) {
1968
2436
  lines.push(` Map<String, dynamic> toJson() {`);
1969
2437
  lines.push(` return {`);
1970
2438
  for (const f of allFields) {
1971
- lines.push(` ${dartToJson(f.snake, f.camel, f.fieldType, f.required)},`);
2439
+ lines.push(
2440
+ ` ${dartToJson(f.snake, f.camel, f.fieldType, f.required)},`
2441
+ );
1972
2442
  }
1973
2443
  lines.push(` };`);
1974
2444
  lines.push(` }`);
@@ -2070,11 +2540,15 @@ function generateFastapiTest(config) {
2070
2540
  }
2071
2541
  lines.push(` }`);
2072
2542
  const updateField = config.fields[0];
2073
- lines.push(` update_payload = {"${updateField.name}": ${pyHttpLiteral(updateField.type, "update")}}`);
2543
+ lines.push(
2544
+ ` update_payload = {"${updateField.name}": ${pyHttpLiteral(updateField.type, "update")}}`
2545
+ );
2074
2546
  lines.push(` invalid_payload: dict = {}`);
2075
2547
  lines.push(` filter_field = "${filterField.name}"`);
2076
2548
  lines.push(` filter_value = ${pyHttpLiteral(filterField.type, "create")}`);
2077
- lines.push(` other_filter_value = ${pyHttpLiteral(filterField.type, "alt")}`);
2549
+ lines.push(
2550
+ ` other_filter_value = ${pyHttpLiteral(filterField.type, "alt")}`
2551
+ );
2078
2552
  lines.push("");
2079
2553
  lines.push(` def make_model(self, index: int, **overrides):`);
2080
2554
  lines.push(` data = {`);
@@ -2092,7 +2566,9 @@ function generateFastifyTest(config) {
2092
2566
  const basePath = `/api/v1${config.apiPrefix}`;
2093
2567
  const updateField = config.fields[0];
2094
2568
  const lines = [];
2095
- lines.push(`import { describeCrudEntity } from '../helpers/crud-test-base.js';`);
2569
+ lines.push(
2570
+ `import { describeCrudEntity } from '../helpers/crud-test-base.js';`
2571
+ );
2096
2572
  lines.push("");
2097
2573
  lines.push(`describeCrudEntity({`);
2098
2574
  lines.push(` entityName: '${className}',`);
@@ -2104,7 +2580,9 @@ function generateFastifyTest(config) {
2104
2580
  }
2105
2581
  lines.push(` },`);
2106
2582
  lines.push(` updatePayload: {`);
2107
- lines.push(` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`);
2583
+ lines.push(
2584
+ ` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`
2585
+ );
2108
2586
  lines.push(` },`);
2109
2587
  lines.push(`});`);
2110
2588
  lines.push("");
@@ -2150,7 +2628,12 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2150
2628
  p9.log.error("No backend component found. Need fastapi or fastify.");
2151
2629
  process.exit(1);
2152
2630
  }
2153
- const targetBackend = await resolvePrimaryBackend(cwd, hasFastapi, hasFastify, backendFlag);
2631
+ const targetBackend = await resolvePrimaryBackend(
2632
+ cwd,
2633
+ hasFastapi,
2634
+ hasFastify,
2635
+ backendFlag
2636
+ );
2154
2637
  const genFastapi = targetBackend === "fastapi" && hasFastapi;
2155
2638
  const genFastify = targetBackend === "fastify" && hasFastify;
2156
2639
  let config;
@@ -2177,11 +2660,19 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2177
2660
  const dir = componentPaths.fastapi;
2178
2661
  const entityDir = join9(cwd, dir, "src/entities", toSnake(config.name));
2179
2662
  if (existsSync9(entityDir)) {
2180
- p9.log.warn(`${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`);
2663
+ p9.log.warn(
2664
+ `${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`
2665
+ );
2181
2666
  } else {
2182
2667
  await mkdir3(entityDir, { recursive: true });
2183
- await writeFile(join9(entityDir, "_model.py"), generateFastAPIModel(config));
2184
- await writeFile(join9(entityDir, "__init__.py"), "from ._model import *\n");
2668
+ await writeFile(
2669
+ join9(entityDir, "_model.py"),
2670
+ generateFastAPIModel(config)
2671
+ );
2672
+ await writeFile(
2673
+ join9(entityDir, "__init__.py"),
2674
+ "from ._model import *\n"
2675
+ );
2185
2676
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/_model.py`);
2186
2677
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/__init__.py`);
2187
2678
  const testsDir = join9(cwd, dir, "tests");
@@ -2196,11 +2687,19 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2196
2687
  const dir = componentPaths.fastify;
2197
2688
  const moduleDir = join9(cwd, dir, "src/modules", toKebab(config.name));
2198
2689
  if (existsSync9(moduleDir)) {
2199
- p9.log.warn(`${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`);
2690
+ p9.log.warn(
2691
+ `${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`
2692
+ );
2200
2693
  } else {
2201
2694
  await mkdir3(moduleDir, { recursive: true });
2202
- await writeFile(join9(moduleDir, "schemas.ts"), generateFastifySchemas(config));
2203
- await writeFile(join9(moduleDir, "index.ts"), generateFastifyIndex(config));
2695
+ await writeFile(
2696
+ join9(moduleDir, "schemas.ts"),
2697
+ generateFastifySchemas(config)
2698
+ );
2699
+ await writeFile(
2700
+ join9(moduleDir, "index.ts"),
2701
+ generateFastifyIndex(config)
2702
+ );
2204
2703
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
2205
2704
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
2206
2705
  const appPath = join9(cwd, dir, "src/app.ts");
@@ -2225,12 +2724,18 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2225
2724
  const modelName = `model ${toPascal(config.name)}`;
2226
2725
  if (!prismaContent.includes(modelName)) {
2227
2726
  const prismaModel = generatePrismaModel(config);
2228
- await writeFile(prismaPath, prismaContent.trimEnd() + "\n\n" + prismaModel + "\n");
2727
+ await writeFile(
2728
+ prismaPath,
2729
+ prismaContent.trimEnd() + "\n\n" + prismaModel + "\n"
2730
+ );
2229
2731
  generated.push(`${dir}/prisma/schema.prisma (model added)`);
2230
2732
  }
2231
2733
  }
2232
2734
  const testsModulesDir = join9(cwd, dir, "tests/modules");
2233
- const fastifyTestFile = join9(testsModulesDir, `${toKebab(config.name)}.test.ts`);
2735
+ const fastifyTestFile = join9(
2736
+ testsModulesDir,
2737
+ `${toKebab(config.name)}.test.ts`
2738
+ );
2234
2739
  if (existsSync9(testsModulesDir) && !existsSync9(fastifyTestFile)) {
2235
2740
  await writeFile(fastifyTestFile, generateFastifyTest(config));
2236
2741
  generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
@@ -2243,7 +2748,9 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2243
2748
  const fileName = toKebab(config.name) + ".ts";
2244
2749
  const filePath = join9(typesDir, fileName);
2245
2750
  if (existsSync9(filePath)) {
2246
- p9.log.warn(`${dir}/src/types/${fileName} already exists. Skipping frontend types.`);
2751
+ p9.log.warn(
2752
+ `${dir}/src/types/${fileName} already exists. Skipping frontend types.`
2753
+ );
2247
2754
  } else {
2248
2755
  await mkdir3(typesDir, { recursive: true });
2249
2756
  await writeFile(filePath, generateFrontendInterface(config));
@@ -2253,7 +2760,10 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2253
2760
  if (existsSync9(barrelPath)) {
2254
2761
  const content = await readFile6(barrelPath, "utf-8");
2255
2762
  if (!content.includes(exportLine)) {
2256
- await writeFile(barrelPath, content.trimEnd() + "\n" + exportLine + "\n");
2763
+ await writeFile(
2764
+ barrelPath,
2765
+ content.trimEnd() + "\n" + exportLine + "\n"
2766
+ );
2257
2767
  }
2258
2768
  } else {
2259
2769
  await writeFile(barrelPath, exportLine + "\n");
@@ -2266,7 +2776,9 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2266
2776
  const entityDir = join9(cwd, dir, "lib/entities", toSnake(config.name));
2267
2777
  const modelPath = join9(entityDir, "model.dart");
2268
2778
  if (existsSync9(modelPath)) {
2269
- p9.log.warn(`${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`);
2779
+ p9.log.warn(
2780
+ `${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`
2781
+ );
2270
2782
  } else {
2271
2783
  await mkdir3(entityDir, { recursive: true });
2272
2784
  await writeFile(modelPath, generateDartModel(config));
@@ -2286,19 +2798,27 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2286
2798
  if (genFastapi) {
2287
2799
  p9.log.info("");
2288
2800
  p9.log.info("FastAPI next steps:");
2289
- p9.log.info(` alembic revision --autogenerate -m "add ${config.tableName}"`);
2801
+ p9.log.info(
2802
+ ` alembic revision --autogenerate -m "add ${config.tableName}"`
2803
+ );
2290
2804
  p9.log.info(" alembic upgrade head");
2291
2805
  }
2292
2806
  if (genFastify) {
2293
2807
  p9.log.info("");
2294
2808
  p9.log.info("Fastify next steps:");
2295
- p9.log.info(` ${pm.prismaExec} migrate dev --name add_${toSnake(config.name)}`);
2809
+ p9.log.info(
2810
+ ` ${pm.prismaExec} migrate dev --name add_${toSnake(config.name)}`
2811
+ );
2296
2812
  }
2297
2813
  if (hasFrontend) {
2298
2814
  p9.log.info("");
2299
2815
  p9.log.info("Frontend usage:");
2300
- p9.log.info(` import type { ${className} } from '../types/${toKebab(config.name)}';`);
2301
- p9.log.info(` const { data } = await api.list<${className}>('${config.apiPrefix}');`);
2816
+ p9.log.info(
2817
+ ` import type { ${className} } from '../types/${toKebab(config.name)}';`
2818
+ );
2819
+ p9.log.info(
2820
+ ` const { data } = await api.list<${className}>('${config.apiPrefix}');`
2821
+ );
2302
2822
  }
2303
2823
  if (hasMobile) {
2304
2824
  p9.log.info("");
@@ -2645,9 +3165,7 @@ function parseArgs() {
2645
3165
  if (arg === "--components") {
2646
3166
  const val = args[++i];
2647
3167
  if (val) {
2648
- options.components = val.split(",").filter(
2649
- (c) => COMPONENTS.includes(c)
2650
- );
3168
+ options.components = val.split(",").filter((c) => COMPONENTS.includes(c));
2651
3169
  }
2652
3170
  continue;
2653
3171
  }
@@ -2697,6 +3215,11 @@ function parseArgs() {
2697
3215
  if (val) extraArgs.push(`--fields=${val}`);
2698
3216
  continue;
2699
3217
  }
3218
+ if (arg === "--name") {
3219
+ const val = args[++i];
3220
+ if (val) extraArgs.push(`--name=${val}`);
3221
+ continue;
3222
+ }
2700
3223
  if (!arg.startsWith("-")) {
2701
3224
  if (command === "add" || command === "pin" || command === "unpin" || command === "gen") {
2702
3225
  extraArgs.push(arg);
@@ -2713,6 +3236,7 @@ function printHelp() {
2713
3236
  projx <name> [options] Create a new project
2714
3237
  projx init Adopt existing project into projx
2715
3238
  projx add <components...> Add components to existing project
3239
+ projx add <type> --name <dir> Add another instance of <type> at <dir>
2716
3240
  projx update Update scaffolding to latest
2717
3241
  projx diff Preview what update would change
2718
3242
  projx pin <patterns...> Skip files on future updates
@@ -2735,6 +3259,7 @@ function printHelp() {
2735
3259
  npx create-projx my-app --components fastapi,frontend,e2e
2736
3260
  npx create-projx my-app -y
2737
3261
  npx create-projx add frontend mobile
3262
+ npx create-projx add fastify --name email-ingestor
2738
3263
  npx create-projx@latest update
2739
3264
  npx create-projx diff
2740
3265
  npx create-projx pin backend/pyproject.toml
@@ -2758,10 +3283,25 @@ async function main() {
2758
3283
  (c) => COMPONENTS.includes(c)
2759
3284
  );
2760
3285
  if (components.length === 0) {
2761
- console.error(`Error: specify components to add. Available: ${COMPONENTS.join(", ")}`);
3286
+ console.error(
3287
+ `Error: specify components to add. Available: ${COMPONENTS.join(", ")}`
3288
+ );
2762
3289
  process.exit(1);
2763
3290
  }
2764
- await add(process.cwd(), components, localRepo, options.install === false);
3291
+ const customName = extraArgs.find((a) => a.startsWith("--name="))?.slice("--name=".length);
3292
+ if (customName && components.length > 1) {
3293
+ console.error(
3294
+ "Error: --name can only be used when adding a single component type."
3295
+ );
3296
+ process.exit(2);
3297
+ }
3298
+ await add(
3299
+ process.cwd(),
3300
+ components,
3301
+ localRepo,
3302
+ options.install === false,
3303
+ customName
3304
+ );
2765
3305
  return;
2766
3306
  }
2767
3307
  if (command === "pin") {
@@ -2774,7 +3314,9 @@ async function main() {
2774
3314
  }
2775
3315
  if (command === "unpin") {
2776
3316
  if (extraArgs.length === 0) {
2777
- console.error("Error: specify patterns to unpin. Usage: projx unpin <patterns...>");
3317
+ console.error(
3318
+ "Error: specify patterns to unpin. Usage: projx unpin <patterns...>"
3319
+ );
2778
3320
  process.exit(1);
2779
3321
  }
2780
3322
  await unpin(process.cwd(), extraArgs);
@@ -2797,7 +3339,9 @@ async function main() {
2797
3339
  if (command === "gen") {
2798
3340
  const subcommand = extraArgs[0];
2799
3341
  if (subcommand !== "entity" || !extraArgs[1]) {
2800
- console.error('Usage: projx gen entity <name> [--fields "name:string,amount:number"]');
3342
+ console.error(
3343
+ 'Usage: projx gen entity <name> [--fields "name:string,amount:number"]'
3344
+ );
2801
3345
  process.exit(1);
2802
3346
  }
2803
3347
  const entityName = extraArgs[1];