create-projx 1.6.1 → 1.6.3

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,7 +9,7 @@ import {
9
9
  matchesSkip,
10
10
  saveBaselineRef,
11
11
  writeTemplateToDir
12
- } from "./chunk-TNI4XBVS.js";
12
+ } from "./chunk-OBYYB6PR.js";
13
13
  import {
14
14
  COMPONENTS,
15
15
  COMPONENT_MARKER,
@@ -33,7 +33,7 @@ import {
33
33
  toTitle,
34
34
  writeComponentMarker,
35
35
  writeProjxConfig
36
- } from "./chunk-FTHX7ILT.js";
36
+ } from "./chunk-LYPPFXGK.js";
37
37
 
38
38
  // src/index.ts
39
39
  import { existsSync as existsSync11 } from "fs";
@@ -76,7 +76,9 @@ async function runPrompts(nameArg) {
76
76
  if (components.length === 0) {
77
77
  p.log.warn("No components selected. Creating an empty project.");
78
78
  }
79
- const hasJs = components.some((c) => ["fastify", "frontend", "e2e"].includes(c));
79
+ const hasJs = components.some(
80
+ (c) => ["fastify", "frontend", "e2e"].includes(c)
81
+ );
80
82
  let packageManager = "npm";
81
83
  if (hasJs) {
82
84
  const pm = await p.select({
@@ -101,11 +103,18 @@ async function scaffold(opts, dest, localRepo) {
101
103
  const paths = Object.fromEntries(
102
104
  opts.components.map((c) => [c, c])
103
105
  );
104
- const vars = { projectName: name, components: opts.components, paths, pm: pmCommands(pm) };
106
+ const vars = {
107
+ projectName: name,
108
+ components: opts.components,
109
+ paths,
110
+ pm: pmCommands(pm)
111
+ };
105
112
  const isLocal = !!localRepo;
106
113
  await mkdir(dest, { recursive: true });
107
114
  const dlSpinner = p2.spinner();
108
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
115
+ dlSpinner.start(
116
+ isLocal ? "Using local templates" : "Downloading latest templates"
117
+ );
109
118
  const repoDir = await downloadRepo(localRepo).catch((err) => {
110
119
  dlSpinner.stop("Failed.");
111
120
  p2.log.error(String(err));
@@ -113,7 +122,9 @@ async function scaffold(opts, dest, localRepo) {
113
122
  });
114
123
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
115
124
  try {
116
- const pkg = JSON.parse(await readFile(join(repoDir, "cli/package.json"), "utf-8"));
125
+ const pkg = JSON.parse(
126
+ await readFile(join(repoDir, "cli/package.json"), "utf-8")
127
+ );
117
128
  const version = pkg.version;
118
129
  p2.log.info(`Scaffolding project in ${dest}`);
119
130
  if (opts.git) {
@@ -121,7 +132,17 @@ async function scaffold(opts, dest, localRepo) {
121
132
  }
122
133
  const spinner7 = p2.spinner();
123
134
  spinner7.start("Scaffolding project");
124
- await applyTemplate(dest, repoDir, opts.components, paths, vars, version, void 0, void 0, true);
135
+ await applyTemplate(
136
+ dest,
137
+ repoDir,
138
+ opts.components,
139
+ paths,
140
+ vars,
141
+ version,
142
+ void 0,
143
+ void 0,
144
+ true
145
+ );
125
146
  spinner7.stop("Scaffold complete.");
126
147
  if (opts.install) {
127
148
  await installDeps(dest, opts.components, pm);
@@ -139,12 +160,14 @@ async function scaffold(opts, dest, localRepo) {
139
160
  } finally {
140
161
  await cleanupRepo(repoDir, isLocal);
141
162
  }
142
- p2.outro(`Done! Next steps:
163
+ p2.outro(
164
+ `Done! Next steps:
143
165
 
144
166
  cd ${name}
145
167
  ./setup.sh
146
168
 
147
- Like projx? Star it: https://github.com/ukanhaupa/projx`);
169
+ Like projx? Star it: https://github.com/ukanhaupa/projx`
170
+ );
148
171
  }
149
172
  async function installDeps(dest, components, pm) {
150
173
  const cmds = pmCommands(pm);
@@ -168,7 +191,9 @@ async function installDeps(dest, components, pm) {
168
191
  exec(cmds.install, join(dest, "fastify"));
169
192
  spinner7.stop("Fastify dependencies installed.");
170
193
  } else {
171
- p2.log.warn(`${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`);
194
+ p2.log.warn(
195
+ `${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`
196
+ );
172
197
  }
173
198
  break;
174
199
  case "frontend":
@@ -177,7 +202,9 @@ async function installDeps(dest, components, pm) {
177
202
  exec(cmds.install, join(dest, "frontend"));
178
203
  spinner7.stop("Frontend dependencies installed.");
179
204
  } else {
180
- p2.log.warn(`${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`);
205
+ p2.log.warn(
206
+ `${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`
207
+ );
181
208
  }
182
209
  break;
183
210
  case "e2e":
@@ -186,7 +213,9 @@ async function installDeps(dest, components, pm) {
186
213
  exec(cmds.install, join(dest, "e2e"));
187
214
  spinner7.stop("E2E dependencies installed.");
188
215
  } else {
189
- p2.log.warn(`${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`);
216
+ p2.log.warn(
217
+ `${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`
218
+ );
190
219
  }
191
220
  break;
192
221
  case "mobile":
@@ -195,7 +224,9 @@ async function installDeps(dest, components, pm) {
195
224
  exec("flutter pub get", join(dest, "mobile"));
196
225
  spinner7.stop("Flutter dependencies installed.");
197
226
  } else {
198
- p2.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
227
+ p2.log.warn(
228
+ "Flutter not found \u2014 run 'cd mobile && flutter pub get' manually."
229
+ );
199
230
  }
200
231
  break;
201
232
  case "infra":
@@ -237,17 +268,33 @@ async function update(cwd, localRepo) {
237
268
  } catch {
238
269
  }
239
270
  const raw = await readProjxConfig(cwd);
240
- const { components, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
271
+ const {
272
+ components,
273
+ paths: componentPaths,
274
+ instances
275
+ } = await discoverComponentsFromMarkers(cwd);
276
+ const extraInstances = instances.filter(
277
+ (i) => componentPaths[i.type] !== i.path
278
+ );
241
279
  const pendingConflicts = findFilesWithConflictMarkers(cwd);
242
280
  if (pendingConflicts.length > 0) {
243
- p3.log.warn(`Found ${pendingConflicts.length} file(s) with unresolved conflict markers from a prior update:`);
281
+ p3.log.warn(
282
+ `Found ${pendingConflicts.length} file(s) with unresolved conflict markers from a prior update:`
283
+ );
244
284
  for (const f of pendingConflicts) p3.log.info(` ${f}`);
245
285
  p3.log.info("");
246
286
  const resumeVersion = String(raw.version ?? "unknown");
247
- const handled = await promptSkipLearning(cwd, componentPaths, resumeVersion, pendingConflicts);
287
+ const handled = await promptSkipLearning(
288
+ cwd,
289
+ componentPaths,
290
+ resumeVersion,
291
+ pendingConflicts
292
+ );
248
293
  if (!handled) {
249
294
  p3.log.info("");
250
- p3.log.info("Resolve manually with `git diff` then `git add` / `git checkout --`,");
295
+ p3.log.info(
296
+ "Resolve manually with `git diff` then `git add` / `git checkout --`,"
297
+ );
251
298
  p3.log.info("or re-run `npx create-projx update` to resume the prompt.");
252
299
  }
253
300
  return;
@@ -261,7 +308,9 @@ async function update(cwd, localRepo) {
261
308
  process.exit(1);
262
309
  }
263
310
  if (Object.keys(raw).length > 0) {
264
- p3.log.info(`Found .projx (v${raw.version ?? "unknown"}, components: ${components.join(", ")})`);
311
+ p3.log.info(
312
+ `Found .projx (v${raw.version ?? "unknown"}, components: ${components.join(", ")})`
313
+ );
265
314
  } else {
266
315
  p3.log.warn("No .projx file found. Detected components from markers.");
267
316
  p3.log.info(`Detected: ${components.join(", ")}`);
@@ -279,7 +328,9 @@ async function update(cwd, localRepo) {
279
328
  }
280
329
  }
281
330
  const dlSpinner = p3.spinner();
282
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
331
+ dlSpinner.start(
332
+ isLocal ? "Using local templates" : "Downloading latest templates"
333
+ );
283
334
  const repoDir = await downloadRepo(localRepo).catch((err) => {
284
335
  dlSpinner.stop("Failed.");
285
336
  p3.log.error(String(err));
@@ -287,43 +338,88 @@ async function update(cwd, localRepo) {
287
338
  });
288
339
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
289
340
  try {
290
- const pkg = JSON.parse(await readFile2(join2(repoDir, "cli/package.json"), "utf-8"));
341
+ const pkg = JSON.parse(
342
+ await readFile2(join2(repoDir, "cli/package.json"), "utf-8")
343
+ );
291
344
  const version = pkg.version;
292
345
  const name = detectProjectName(cwd, components, componentPaths);
293
346
  const recordedPm = raw.packageManager;
294
347
  const detectedPm = detectPackageManagerFromComponents(cwd, componentPaths);
295
348
  const pm = detectedPm ?? recordedPm ?? "npm";
296
349
  if (detectedPm && recordedPm && detectedPm !== recordedPm) {
297
- p3.log.warn(`packageManager mismatch: .projx says "${recordedPm}" but lockfile is "${detectedPm}". Using "${detectedPm}".`);
350
+ p3.log.warn(
351
+ `packageManager mismatch: .projx says "${recordedPm}" but lockfile is "${detectedPm}". Using "${detectedPm}".`
352
+ );
298
353
  await writeProjxConfig(cwd, { ...raw, packageManager: detectedPm });
299
354
  } else if (detectedPm && !recordedPm) {
300
355
  await writeProjxConfig(cwd, { ...raw, packageManager: detectedPm });
301
356
  }
302
- const nameOverrides = await detectPackageNameOverrides(cwd, components, componentPaths);
303
- const vars = { projectName: name, components, paths: componentPaths, pm: pmCommands(pm), nameOverrides };
357
+ const nameOverrides = await detectPackageNameOverrides(
358
+ cwd,
359
+ components,
360
+ componentPaths
361
+ );
362
+ const vars = {
363
+ projectName: name,
364
+ components,
365
+ paths: componentPaths,
366
+ instances,
367
+ pm: pmCommands(pm),
368
+ nameOverrides
369
+ };
304
370
  const spinner7 = p3.spinner();
305
371
  spinner7.start("Applying template update");
306
372
  const rootSkip = Array.isArray(raw.skip) ? raw.skip : [];
307
373
  const isLegacyMigration = !raw.defaultsApplied;
308
374
  if (isLegacyMigration) {
309
- p3.log.info("Legacy project detected \u2014 applying default skip patterns for user-owned files.");
375
+ p3.log.info(
376
+ "Legacy project detected \u2014 applying default skip patterns for user-owned files."
377
+ );
310
378
  }
311
- const result = await applyTemplate(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip, isLegacyMigration);
379
+ const result = await applyTemplate(
380
+ cwd,
381
+ repoDir,
382
+ components,
383
+ componentPaths,
384
+ vars,
385
+ version,
386
+ componentSkips,
387
+ rootSkip,
388
+ isLegacyMigration,
389
+ extraInstances
390
+ );
312
391
  spinner7.stop("Template applied.");
313
- const pinnedUpdates = await findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip);
392
+ const pinnedUpdates = await findPinnedFilesWithUpdates(
393
+ cwd,
394
+ repoDir,
395
+ components,
396
+ componentPaths,
397
+ vars,
398
+ version,
399
+ componentSkips,
400
+ rootSkip
401
+ );
314
402
  if (pinnedUpdates.length > 0) {
315
403
  p3.log.info("");
316
- p3.log.info(`${pinnedUpdates.length} pinned file(s) have template updates available:`);
404
+ p3.log.info(
405
+ `${pinnedUpdates.length} pinned file(s) have template updates available:`
406
+ );
317
407
  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.");
408
+ p3.log.info(
409
+ "Run `npx create-projx unpin <file> && npx create-projx update` to opt in."
410
+ );
319
411
  }
320
412
  if (result.status === "merged") {
321
413
  saveBaselineRef(cwd);
322
- p3.log.success(`${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`);
414
+ p3.log.success(
415
+ `${result.mergedFiles?.length ?? 0} file(s) merged cleanly.`
416
+ );
323
417
  p3.outro(`Updated to template v${version}.`);
324
418
  } else if (result.status === "conflicts") {
325
419
  if (result.mergedFiles && result.mergedFiles.length > 0) {
326
- p3.log.success(`${result.mergedFiles.length} file(s) merged cleanly and staged.`);
420
+ p3.log.success(
421
+ `${result.mergedFiles.length} file(s) merged cleanly and staged.`
422
+ );
327
423
  }
328
424
  const conflictCount = result.conflictedFiles?.length ?? 0;
329
425
  if (conflictCount > 0) {
@@ -332,13 +428,20 @@ async function update(cwd, localRepo) {
332
428
  p3.log.info(` ${f}`);
333
429
  }
334
430
  }
335
- const handled = await promptSkipLearning(cwd, componentPaths, version, result.conflictedFiles ?? []);
431
+ const handled = await promptSkipLearning(
432
+ cwd,
433
+ componentPaths,
434
+ version,
435
+ result.conflictedFiles ?? []
436
+ );
336
437
  if (!handled) {
337
438
  p3.log.info("");
338
439
  p3.log.info("Review: git diff");
339
440
  p3.log.info("Keep: git add <file>");
340
441
  p3.log.info("Discard: git checkout -- <file>");
341
- p3.log.info(`Commit: git add . && git commit -m "projx: update to v${version}"`);
442
+ p3.log.info(
443
+ `Commit: git add . && git commit -m "projx: update to v${version}"`
444
+ );
342
445
  p3.outro(`Template v${version} applied. Review with git diff.`);
343
446
  }
344
447
  } else {
@@ -371,7 +474,7 @@ function hasUncommittedChanges(cwd) {
371
474
  async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip) {
372
475
  const { mkdir: mkdir5, rm: rm2, readFile: readFile7 } = await import("fs/promises");
373
476
  const { tmpdir: tmpdir2 } = await import("os");
374
- const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-5XAJJ457.js");
477
+ const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-RXPDDEDD.js");
375
478
  const config = await readProjxConfig(cwd);
376
479
  const rootPinned = Array.isArray(config.skip) ? config.skip : [];
377
480
  const componentPinned = [];
@@ -388,11 +491,19 @@ async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPat
388
491
  void componentSkips;
389
492
  void rootSkip;
390
493
  try {
391
- await writeTemplateToDir2(tmpTemplate, repoDir, components, componentPaths, vars, version, {
392
- componentSkips: {},
393
- rootSkip: [],
394
- realCwd: tmpTemplate
395
- });
494
+ await writeTemplateToDir2(
495
+ tmpTemplate,
496
+ repoDir,
497
+ components,
498
+ componentPaths,
499
+ vars,
500
+ version,
501
+ {
502
+ componentSkips: {},
503
+ rootSkip: [],
504
+ realCwd: tmpTemplate
505
+ }
506
+ );
396
507
  const updates = [];
397
508
  for (const file of rootPinned) {
398
509
  const tmplPath = join2(tmpTemplate, file);
@@ -439,20 +550,33 @@ async function promptSkipLearning(cwd, componentPaths, version, conflictedFiles)
439
550
  });
440
551
  if (changedFiles.length === 0) return false;
441
552
  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.");
553
+ p3.log.info(
554
+ "Non-interactive: skipping prompt. Resolve conflicts manually with `git diff` then `git add`."
555
+ );
556
+ p3.log.info(
557
+ "Re-run `npx create-projx update` later to interactively decide which files to keep."
558
+ );
444
559
  return false;
445
560
  }
446
- const statusOutput = execSync("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
561
+ const statusOutput = execSync("git status --porcelain", {
562
+ cwd,
563
+ stdio: "pipe"
564
+ }).toString().trim();
447
565
  const entries = statusOutput.split("\n").filter(Boolean).map((line) => ({
448
566
  status: line.slice(0, 2).trim(),
449
567
  file: line.slice(3).trim()
450
568
  }));
451
569
  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.");
570
+ p3.log.info(
571
+ "Each file is currently in your working tree with conflict markers."
572
+ );
453
573
  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");
574
+ p3.log.info(
575
+ "CHECKED = keep your version, resolve markers manually, commit when ready"
576
+ );
577
+ p3.log.info(
578
+ "UNCHECKED = discard template's changes AND skip this file on future updates"
579
+ );
456
580
  p3.log.info("");
457
581
  const selected = await p3.multiselect({
458
582
  message: "Which files do you want to KEEP?",
@@ -484,10 +608,10 @@ async function promptSkipLearning(cwd, componentPaths, version, conflictedFiles)
484
608
  );
485
609
  }
486
610
  if (kept.size > 0) {
487
- p3.log.info(`${kept.size} file(s) kept with conflict markers \u2014 resolve and commit:`);
488
611
  p3.log.info(
489
- ` git add . && git commit -m "projx: update to v${version}"`
612
+ `${kept.size} file(s) kept with conflict markers \u2014 resolve and commit:`
490
613
  );
614
+ p3.log.info(` git add . && git commit -m "projx: update to v${version}"`);
491
615
  p3.outro(`Template v${version} applied.`);
492
616
  } else {
493
617
  p3.outro("All template changes discarded. Skip list updated.");
@@ -536,15 +660,38 @@ import { copyFileSync as copyFileSync2, existsSync as existsSync3 } from "fs";
536
660
  import { readFile as readFile3 } from "fs/promises";
537
661
  import { join as join3 } from "path";
538
662
  import * as p4 from "@clack/prompts";
539
- async function add(cwd, newComponents, localRepo, skipInstall = false) {
663
+ async function add(cwd, newComponents, localRepo, skipInstall = false, customName) {
540
664
  p4.intro("projx add");
541
665
  const isLocal = !!localRepo;
542
666
  if (!existsSync3(join3(cwd, ".projx"))) {
543
- p4.log.error("No .projx file found. Run 'npx create-projx <name>' to create a project first.");
667
+ p4.log.error(
668
+ "No .projx file found. Run 'npx create-projx <name>' to create a project first."
669
+ );
544
670
  process.exit(1);
545
671
  }
546
672
  const config = await readProjxConfig(cwd);
547
673
  const { components: existing } = await discoverComponentsFromMarkers(cwd);
674
+ if (customName) {
675
+ if (newComponents.length !== 1) {
676
+ throw new Error(
677
+ "--name can only be used when adding a single component type."
678
+ );
679
+ }
680
+ const targetDir = join3(cwd, customName);
681
+ if (existsSync3(targetDir)) {
682
+ throw new Error(`Directory '${customName}' already exists.`);
683
+ }
684
+ return await addInstance(
685
+ cwd,
686
+ newComponents[0],
687
+ customName,
688
+ config,
689
+ existing,
690
+ localRepo,
691
+ skipInstall,
692
+ isLocal
693
+ );
694
+ }
548
695
  const alreadyExists = newComponents.filter((c) => existing.includes(c));
549
696
  if (alreadyExists.length > 0) {
550
697
  p4.log.warn(`Already present: ${alreadyExists.join(", ")}. Skipping those.`);
@@ -556,7 +703,9 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
556
703
  }
557
704
  p4.log.info(`Adding: ${toAdd.join(", ")}`);
558
705
  const dlSpinner = p4.spinner();
559
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
706
+ dlSpinner.start(
707
+ isLocal ? "Using local templates" : "Downloading latest templates"
708
+ );
560
709
  const repoDir = await downloadRepo(localRepo).catch((err) => {
561
710
  dlSpinner.stop("Failed.");
562
711
  p4.log.error(String(err));
@@ -568,17 +717,42 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
568
717
  const existingPaths = await discoverComponentPaths(cwd, existing);
569
718
  const paths = { ...existingPaths };
570
719
  for (const c of toAdd) paths[c] = c;
720
+ const { instances: existingInstances } = await discoverComponentsFromMarkers(cwd);
721
+ const instances = [
722
+ ...existingInstances,
723
+ ...toAdd.map((c) => ({ type: c, path: c }))
724
+ ];
571
725
  const pm = config.packageManager ?? "npm";
572
726
  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"));
727
+ const vars = {
728
+ projectName: name,
729
+ components: allComponents,
730
+ paths,
731
+ instances,
732
+ pm: pmCommands(pm)
733
+ };
734
+ const pkg = JSON.parse(
735
+ await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
736
+ );
575
737
  const version = pkg.version;
576
738
  const spinner7 = p4.spinner();
577
739
  spinner7.start("Adding components");
578
- await writeTemplateToDir(cwd, repoDir, allComponents, paths, vars, version, { realCwd: cwd });
740
+ await writeTemplateToDir(
741
+ cwd,
742
+ repoDir,
743
+ allComponents,
744
+ paths,
745
+ vars,
746
+ version,
747
+ { realCwd: cwd }
748
+ );
579
749
  spinner7.stop("Components added.");
580
750
  if (!skipInstall) {
581
- await installDeps2(cwd, toAdd, pm);
751
+ await installDeps2(
752
+ cwd,
753
+ toAdd.map((c) => ({ type: c, path: c })),
754
+ pm
755
+ );
582
756
  }
583
757
  for (const component of toAdd) {
584
758
  const example = join3(cwd, component, ".env.example");
@@ -590,70 +764,175 @@ async function add(cwd, newComponents, localRepo, skipInstall = false) {
590
764
  }
591
765
  }
592
766
  }
593
- p4.outro(`Added ${toAdd.join(", ")}.
767
+ p4.outro(
768
+ `Added ${toAdd.join(", ")}.
594
769
 
595
- Like projx? Star it: https://github.com/ukanhaupa/projx`);
770
+ Like projx? Star it: https://github.com/ukanhaupa/projx`
771
+ );
596
772
  } finally {
597
773
  await cleanupRepo(repoDir, isLocal);
598
774
  }
599
775
  }
600
- async function installDeps2(dest, components, pm) {
776
+ async function addInstance(cwd, type, customName, config, existing, localRepo, skipInstall, isLocal) {
777
+ p4.log.info(`Adding ${type} instance at ${customName}/`);
778
+ const dlSpinner = p4.spinner();
779
+ dlSpinner.start(
780
+ isLocal ? "Using local templates" : "Downloading latest templates"
781
+ );
782
+ const repoDir = await downloadRepo(localRepo).catch((err) => {
783
+ dlSpinner.stop("Failed.");
784
+ throw err;
785
+ });
786
+ dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
787
+ try {
788
+ const existingPaths = await discoverComponentPaths(cwd, existing);
789
+ const paths = { ...existingPaths };
790
+ const { instances: existingInstances } = await discoverComponentsFromMarkers(cwd);
791
+ const newInstance = { type, path: customName };
792
+ const instances = [...existingInstances, newInstance];
793
+ const pm = config.packageManager ?? "npm";
794
+ const name = detectProjectName(cwd, existing, existingPaths);
795
+ const vars = {
796
+ projectName: name,
797
+ components: existing,
798
+ paths,
799
+ instances,
800
+ pm: pmCommands(pm)
801
+ };
802
+ const pkg = JSON.parse(
803
+ await readFile3(join3(repoDir, "cli/package.json"), "utf-8")
804
+ );
805
+ const version = pkg.version;
806
+ const INSTANCE_AWARE_ROOT = /* @__PURE__ */ new Set([
807
+ ".github/workflows/ci.yml",
808
+ ".githooks/pre-commit",
809
+ "setup.sh"
810
+ ]);
811
+ const rawSkip = Array.isArray(config.skip) ? config.skip : [];
812
+ const rootSkip = rawSkip.filter((p11) => !INSTANCE_AWARE_ROOT.has(p11));
813
+ const componentSkips = {};
814
+ for (const inst of existingInstances) {
815
+ const m = await readComponentMarker(join3(cwd, inst.path));
816
+ if (m?.skip && m.skip.length > 0) componentSkips[inst.type] = m.skip;
817
+ }
818
+ const spinner7 = p4.spinner();
819
+ spinner7.start(`Scaffolding ${customName}/`);
820
+ const result = await applyTemplate(
821
+ cwd,
822
+ repoDir,
823
+ existing,
824
+ paths,
825
+ vars,
826
+ version,
827
+ componentSkips,
828
+ rootSkip,
829
+ false,
830
+ [newInstance],
831
+ [newInstance]
832
+ );
833
+ spinner7.stop(`Scaffolded ${customName}/.`);
834
+ if (result.status === "merged") {
835
+ p4.log.success(
836
+ `${result.mergedFiles?.length ?? 0} root file(s) merged cleanly.`
837
+ );
838
+ } else if (result.status === "conflicts") {
839
+ const conflictCount = result.conflictedFiles?.length ?? 0;
840
+ if (conflictCount > 0) {
841
+ p4.log.warn(`${conflictCount} root file(s) need manual review:`);
842
+ for (const f of result.conflictedFiles) p4.log.info(` ${f}`);
843
+ p4.log.info("Review: git diff");
844
+ p4.log.info("Keep: git add <file>");
845
+ p4.log.info("Discard: git checkout -- <file>");
846
+ }
847
+ }
848
+ if (!skipInstall) {
849
+ await installDeps2(cwd, [{ type, path: customName }], pm);
850
+ }
851
+ const example = join3(cwd, customName, ".env.example");
852
+ const env = join3(cwd, customName, ".env");
853
+ if (existsSync3(example) && !existsSync3(env)) {
854
+ try {
855
+ copyFileSync2(example, env);
856
+ } catch {
857
+ }
858
+ }
859
+ p4.outro(`Added ${type} instance at ${customName}/.`);
860
+ } finally {
861
+ await cleanupRepo(repoDir, isLocal);
862
+ }
863
+ }
864
+ async function installDeps2(dest, instances, pm) {
601
865
  const cmds = pmCommands(pm);
602
866
  const pmBin = pm === "bun" ? "bun" : pm;
603
- for (const component of components) {
867
+ for (const { type, path } of instances) {
868
+ const dir = join3(dest, path);
604
869
  const spinner7 = p4.spinner();
605
870
  try {
606
- switch (component) {
871
+ switch (type) {
607
872
  case "fastapi":
608
873
  if (hasCommand("uv")) {
609
- spinner7.start("Installing FastAPI dependencies");
610
- exec("uv sync --all-extras", join3(dest, "fastapi"));
611
- spinner7.stop("FastAPI dependencies installed.");
874
+ spinner7.start(`Installing FastAPI dependencies (${path}/)`);
875
+ exec("uv sync --all-extras", dir);
876
+ spinner7.stop(`FastAPI dependencies installed (${path}/).`);
612
877
  } else {
613
- p4.log.warn("uv not found \u2014 run 'cd fastapi && uv sync' manually.");
878
+ p4.log.warn(`uv not found \u2014 run 'cd ${path} && uv sync' manually.`);
614
879
  }
615
880
  break;
616
881
  case "fastify":
617
882
  if (hasCommand(pmBin)) {
618
- spinner7.start(`Installing Fastify dependencies (${cmds.install})`);
619
- exec(cmds.install, join3(dest, "fastify"));
620
- spinner7.stop("Fastify dependencies installed.");
883
+ spinner7.start(
884
+ `Installing Fastify dependencies (${path}/, ${cmds.install})`
885
+ );
886
+ exec(cmds.install, dir);
887
+ spinner7.stop(`Fastify dependencies installed (${path}/).`);
621
888
  } else {
622
- p4.log.warn(`${pm} not found \u2014 run 'cd fastify && ${cmds.install}' manually.`);
889
+ p4.log.warn(
890
+ `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
891
+ );
623
892
  }
624
893
  break;
625
894
  case "frontend":
626
895
  if (hasCommand(pmBin)) {
627
- spinner7.start(`Installing Frontend dependencies (${cmds.install})`);
628
- exec(cmds.install, join3(dest, "frontend"));
629
- spinner7.stop("Frontend dependencies installed.");
896
+ spinner7.start(
897
+ `Installing Frontend dependencies (${path}/, ${cmds.install})`
898
+ );
899
+ exec(cmds.install, dir);
900
+ spinner7.stop(`Frontend dependencies installed (${path}/).`);
630
901
  } else {
631
- p4.log.warn(`${pm} not found \u2014 run 'cd frontend && ${cmds.install}' manually.`);
902
+ p4.log.warn(
903
+ `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
904
+ );
632
905
  }
633
906
  break;
634
907
  case "e2e":
635
908
  if (hasCommand(pmBin)) {
636
- spinner7.start(`Installing E2E dependencies (${cmds.install})`);
637
- exec(cmds.install, join3(dest, "e2e"));
638
- spinner7.stop("E2E dependencies installed.");
909
+ spinner7.start(
910
+ `Installing E2E dependencies (${path}/, ${cmds.install})`
911
+ );
912
+ exec(cmds.install, dir);
913
+ spinner7.stop(`E2E dependencies installed (${path}/).`);
639
914
  } else {
640
- p4.log.warn(`${pm} not found \u2014 run 'cd e2e && ${cmds.install}' manually.`);
915
+ p4.log.warn(
916
+ `${pm} not found \u2014 run 'cd ${path} && ${cmds.install}' manually.`
917
+ );
641
918
  }
642
919
  break;
643
920
  case "mobile":
644
921
  if (hasCommand("flutter")) {
645
- spinner7.start("Installing Flutter dependencies");
646
- exec("flutter pub get", join3(dest, "mobile"));
647
- spinner7.stop("Flutter dependencies installed.");
922
+ spinner7.start(`Installing Flutter dependencies (${path}/)`);
923
+ exec("flutter pub get", dir);
924
+ spinner7.stop(`Flutter dependencies installed (${path}/).`);
648
925
  } else {
649
- p4.log.warn("Flutter not found \u2014 run 'cd mobile && flutter pub get' manually.");
926
+ p4.log.warn(
927
+ `Flutter not found \u2014 run 'cd ${path} && flutter pub get' manually.`
928
+ );
650
929
  }
651
930
  break;
652
931
  case "infra":
653
932
  break;
654
933
  }
655
934
  } catch {
656
- spinner7.stop(`Failed to install ${component} dependencies.`);
935
+ spinner7.stop(`Failed to install ${type} dependencies (${path}/).`);
657
936
  }
658
937
  }
659
938
  }
@@ -672,7 +951,9 @@ import { join as join4 } from "path";
672
951
  async function detectComponents(cwd) {
673
952
  const results = [];
674
953
  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);
954
+ const dirs = entries.filter(
955
+ (e) => e.isDirectory() && !e.name.startsWith(".") && !EXCLUDE.has(e.name)
956
+ ).map((e) => e.name);
676
957
  for (const dir of dirs) {
677
958
  const full = join4(cwd, dir);
678
959
  const detections = await scanDirectory(full, dir);
@@ -754,11 +1035,15 @@ async function init(cwd, localRepo) {
754
1035
  p5.intro("projx init");
755
1036
  const isLocal = !!localRepo;
756
1037
  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.");
1038
+ p5.log.error(
1039
+ "This project is already initialized. Use 'npx create-projx update' or 'npx create-projx add' instead."
1040
+ );
758
1041
  process.exit(1);
759
1042
  }
760
1043
  if (!isGitRepo2(cwd)) {
761
- p5.log.error(`projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`);
1044
+ p5.log.error(
1045
+ `projx init requires a git repo. Run 'git init && git add -A && git commit -m "initial"' first.`
1046
+ );
762
1047
  process.exit(1);
763
1048
  }
764
1049
  if (hasUncommittedChanges2(cwd)) {
@@ -785,7 +1070,9 @@ async function init(cwd, localRepo) {
785
1070
  const paths = Object.fromEntries(
786
1071
  confirmed.map((c) => [c.component, c.directory])
787
1072
  );
788
- const hasJs = components.some((c) => ["fastify", "frontend", "e2e"].includes(c));
1073
+ const hasJs = components.some(
1074
+ (c) => ["fastify", "frontend", "e2e"].includes(c)
1075
+ );
789
1076
  let pm = "npm";
790
1077
  if (hasJs) {
791
1078
  const detected2 = detectPackageManager(cwd);
@@ -803,9 +1090,16 @@ async function init(cwd, localRepo) {
803
1090
  }
804
1091
  }
805
1092
  const projectName = toKebab(cwd.split("/").pop());
806
- const vars = { projectName, components, paths, pm: pmCommands(pm) };
1093
+ const vars = {
1094
+ projectName,
1095
+ components,
1096
+ paths,
1097
+ pm: pmCommands(pm)
1098
+ };
807
1099
  const dlSpinner = p5.spinner();
808
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
1100
+ dlSpinner.start(
1101
+ isLocal ? "Using local templates" : "Downloading latest templates"
1102
+ );
809
1103
  const repoDir = await downloadRepo(localRepo).catch((err) => {
810
1104
  dlSpinner.stop("Failed.");
811
1105
  p5.log.error(String(err));
@@ -813,11 +1107,23 @@ async function init(cwd, localRepo) {
813
1107
  });
814
1108
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
815
1109
  try {
816
- const pkg = JSON.parse(await readFile4(join5(repoDir, "cli/package.json"), "utf-8"));
1110
+ const pkg = JSON.parse(
1111
+ await readFile4(join5(repoDir, "cli/package.json"), "utf-8")
1112
+ );
817
1113
  const version = pkg.version;
818
1114
  const applySpinner = p5.spinner();
819
1115
  applySpinner.start("Applying template");
820
- const result = await applyTemplate(cwd, repoDir, components, paths, vars, version, void 0, void 0, true);
1116
+ const result = await applyTemplate(
1117
+ cwd,
1118
+ repoDir,
1119
+ components,
1120
+ paths,
1121
+ vars,
1122
+ version,
1123
+ void 0,
1124
+ void 0,
1125
+ true
1126
+ );
821
1127
  applySpinner.stop("Template applied.");
822
1128
  if (existsSync5(join5(cwd, ".githooks"))) {
823
1129
  try {
@@ -829,19 +1135,27 @@ async function init(cwd, localRepo) {
829
1135
  saveBaselineRef(cwd);
830
1136
  }
831
1137
  if (result.status === "conflicts") {
832
- p5.log.warn("Some template files differ from your code. Changes written directly.");
1138
+ p5.log.warn(
1139
+ "Some template files differ from your code. Changes written directly."
1140
+ );
833
1141
  p5.log.info("Review changes:");
834
1142
  p5.log.info(" git diff");
835
1143
  p5.log.info("");
836
1144
  p5.log.info("Keep a change: git add <file>");
837
1145
  p5.log.info("Discard a change: git checkout -- <file>");
838
- p5.log.info('Commit when ready: git add . && git commit -m "projx: init"');
1146
+ p5.log.info(
1147
+ 'Commit when ready: git add . && git commit -m "projx: init"'
1148
+ );
839
1149
  p5.log.info("");
840
1150
  p5.log.info("To skip files on future updates, add to .projx-component:");
841
1151
  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");
1152
+ p5.outro(
1153
+ "Template applied. Review with git diff.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx"
1154
+ );
843
1155
  } else {
844
- p5.outro("Project initialized.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx");
1156
+ p5.outro(
1157
+ "Project initialized.\n\n Like projx? Star it: https://github.com/ukanhaupa/projx"
1158
+ );
845
1159
  }
846
1160
  } finally {
847
1161
  await cleanupRepo(repoDir, isLocal);
@@ -940,7 +1254,10 @@ async function pin(cwd, patterns) {
940
1254
  p6.log.warn(`Cannot pin ${pattern} \u2014 config files are managed by projx.`);
941
1255
  continue;
942
1256
  }
943
- const { scope, component, relative } = classifyPattern(pattern, componentPaths);
1257
+ const { scope, component, relative } = classifyPattern(
1258
+ pattern,
1259
+ componentPaths
1260
+ );
944
1261
  if (scope === "component" && component) {
945
1262
  if (!componentAdds[component]) componentAdds[component] = [];
946
1263
  componentAdds[component].push(relative);
@@ -989,7 +1306,10 @@ async function unpin(cwd, patterns) {
989
1306
  const rootRemoves = [];
990
1307
  const componentRemoves = {};
991
1308
  for (const pattern of patterns) {
992
- const { scope, component, relative } = classifyPattern(pattern, componentPaths);
1309
+ const { scope, component, relative } = classifyPattern(
1310
+ pattern,
1311
+ componentPaths
1312
+ );
993
1313
  if (scope === "component" && component) {
994
1314
  if (!componentRemoves[component]) componentRemoves[component] = [];
995
1315
  componentRemoves[component].push(relative);
@@ -1089,7 +1409,11 @@ async function checkConfig(cwd) {
1089
1409
  });
1090
1410
  return { results };
1091
1411
  }
1092
- results.push({ name: ".projx exists", status: "pass", message: `v${rootConfig.version ?? "unknown"}` });
1412
+ results.push({
1413
+ name: ".projx exists",
1414
+ status: "pass",
1415
+ message: `v${rootConfig.version ?? "unknown"}`
1416
+ });
1093
1417
  if (!rootConfig.version) {
1094
1418
  results.push({
1095
1419
  name: ".projx fields",
@@ -1110,7 +1434,11 @@ async function checkComponents(cwd, components, componentPaths) {
1110
1434
  });
1111
1435
  return results;
1112
1436
  }
1113
- results.push({ name: "components", status: "pass", message: `${components.length} discovered from markers` });
1437
+ results.push({
1438
+ name: "components",
1439
+ status: "pass",
1440
+ message: `${components.length} discovered from markers`
1441
+ });
1114
1442
  for (const component of components) {
1115
1443
  const dir = componentPaths[component];
1116
1444
  const fullDir = join7(cwd, dir);
@@ -1133,7 +1461,11 @@ async function checkComponents(cwd, components, componentPaths) {
1133
1461
  continue;
1134
1462
  }
1135
1463
  const label = dir !== component ? `${dir}/ (${component})` : `${component}/`;
1136
- results.push({ name: `${component} marker`, status: "pass", message: label });
1464
+ results.push({
1465
+ name: `${component} marker`,
1466
+ status: "pass",
1467
+ message: label
1468
+ });
1137
1469
  }
1138
1470
  return results;
1139
1471
  }
@@ -1143,18 +1475,36 @@ function checkGit(cwd, fix) {
1143
1475
  execSync3("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
1144
1476
  results.push({ name: "git repo", status: "pass", message: "OK" });
1145
1477
  } catch {
1146
- results.push({ name: "git repo", status: "fail", message: "Not a git repository." });
1478
+ results.push({
1479
+ name: "git repo",
1480
+ status: "fail",
1481
+ message: "Not a git repository."
1482
+ });
1147
1483
  return results;
1148
1484
  }
1149
1485
  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) });
1486
+ const ref = execSync3(`git rev-parse --verify ${BASELINE_REF}`, {
1487
+ cwd,
1488
+ stdio: "pipe"
1489
+ }).toString().trim();
1490
+ results.push({
1491
+ name: "baseline ref",
1492
+ status: "pass",
1493
+ message: ref.slice(0, 8)
1494
+ });
1152
1495
  } catch {
1153
1496
  if (fix) {
1154
1497
  saveBaselineRef(cwd);
1155
1498
  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." });
1499
+ execSync3(`git rev-parse --verify ${BASELINE_REF}`, {
1500
+ cwd,
1501
+ stdio: "pipe"
1502
+ });
1503
+ results.push({
1504
+ name: "baseline ref",
1505
+ status: "pass",
1506
+ message: "Created from git history."
1507
+ });
1158
1508
  } catch {
1159
1509
  results.push({
1160
1510
  name: "baseline ref",
@@ -1173,12 +1523,19 @@ function checkGit(cwd, fix) {
1173
1523
  }
1174
1524
  }
1175
1525
  try {
1176
- const worktrees = execSync3("git worktree list --porcelain", { cwd, stdio: "pipe" }).toString();
1526
+ const worktrees = execSync3("git worktree list --porcelain", {
1527
+ cwd,
1528
+ stdio: "pipe"
1529
+ }).toString();
1177
1530
  const stale = worktrees.split("\n").filter((l) => l.includes("projx-wt-") || l.includes("projx/tmp-"));
1178
1531
  if (stale.length > 0) {
1179
1532
  if (fix) {
1180
1533
  execSync3("git worktree prune", { cwd, stdio: "pipe" });
1181
- results.push({ name: "worktrees", status: "pass", message: "Pruned stale worktrees." });
1534
+ results.push({
1535
+ name: "worktrees",
1536
+ status: "pass",
1537
+ message: "Pruned stale worktrees."
1538
+ });
1182
1539
  } else {
1183
1540
  results.push({
1184
1541
  name: "worktrees",
@@ -1198,7 +1555,11 @@ function checkGit(cwd, fix) {
1198
1555
  const status = execSync3("git status --porcelain", { cwd, stdio: "pipe" }).toString().trim();
1199
1556
  if (status) {
1200
1557
  const count = status.split("\n").length;
1201
- results.push({ name: "working tree", status: "warn", message: `${count} uncommitted change(s).` });
1558
+ results.push({
1559
+ name: "working tree",
1560
+ status: "warn",
1561
+ message: `${count} uncommitted change(s).`
1562
+ });
1202
1563
  } else {
1203
1564
  results.push({ name: "working tree", status: "pass", message: "Clean" });
1204
1565
  }
@@ -1236,7 +1597,11 @@ async function checkSkipPatterns(cwd, rootConfig, components, componentPaths) {
1236
1597
  }
1237
1598
  }
1238
1599
  if (results.length === 0 && (rootSkip.length > 0 || components.length > 0)) {
1239
- results.push({ name: "skip patterns", status: "pass", message: "All patterns match files." });
1600
+ results.push({
1601
+ name: "skip patterns",
1602
+ status: "pass",
1603
+ message: "All patterns match files."
1604
+ });
1240
1605
  }
1241
1606
  return results;
1242
1607
  }
@@ -1275,7 +1640,9 @@ async function doctor(cwd, fix = false) {
1275
1640
  const { components, paths: componentPaths } = await discoverComponentsFromMarkers(cwd);
1276
1641
  allResults.push(...await checkComponents(cwd, components, componentPaths));
1277
1642
  allResults.push(...checkGit(cwd, fix));
1278
- allResults.push(...await checkSkipPatterns(cwd, rootConfig, components, componentPaths));
1643
+ allResults.push(
1644
+ ...await checkSkipPatterns(cwd, rootConfig, components, componentPaths)
1645
+ );
1279
1646
  printReport(allResults);
1280
1647
  const passed = allResults.filter((r) => r.status === "pass").length;
1281
1648
  const warns = allResults.filter((r) => r.status === "warn").length;
@@ -1341,7 +1708,9 @@ async function diff(cwd, localRepo) {
1341
1708
  }
1342
1709
  const rootSkip = Array.isArray(raw.skip) ? raw.skip : [];
1343
1710
  const dlSpinner = p8.spinner();
1344
- dlSpinner.start(isLocal ? "Using local templates" : "Downloading latest templates");
1711
+ dlSpinner.start(
1712
+ isLocal ? "Using local templates" : "Downloading latest templates"
1713
+ );
1345
1714
  const repoDir = await downloadRepo(localRepo).catch((err) => {
1346
1715
  dlSpinner.stop("Failed.");
1347
1716
  p8.log.error(String(err));
@@ -1349,20 +1718,35 @@ async function diff(cwd, localRepo) {
1349
1718
  });
1350
1719
  dlSpinner.stop(isLocal ? "Local templates loaded." : "Templates downloaded.");
1351
1720
  try {
1352
- const pkg = JSON.parse(await readFile5(join8(repoDir, "cli/package.json"), "utf-8"));
1721
+ const pkg = JSON.parse(
1722
+ await readFile5(join8(repoDir, "cli/package.json"), "utf-8")
1723
+ );
1353
1724
  const version = pkg.version;
1354
1725
  p8.log.info(`Current: v${raw.version ?? "unknown"} \u2192 Template: v${version}`);
1355
1726
  const name = detectProjectName(cwd, components, componentPaths);
1356
- const vars = { projectName: name, components, paths: componentPaths, pm: pmCommands(raw.packageManager ?? "npm") };
1727
+ const vars = {
1728
+ projectName: name,
1729
+ components,
1730
+ paths: componentPaths,
1731
+ pm: pmCommands(raw.packageManager ?? "npm")
1732
+ };
1357
1733
  const spinner7 = p8.spinner();
1358
1734
  spinner7.start("Analyzing changes");
1359
1735
  const tmpTemplate = join8(tmpdir(), `projx-diff-${Date.now()}`);
1360
1736
  await mkdir2(tmpTemplate, { recursive: true });
1361
- await writeTemplateToDir(tmpTemplate, repoDir, components, componentPaths, vars, version, {
1362
- componentSkips,
1363
- rootSkip,
1364
- realCwd: cwd
1365
- });
1737
+ await writeTemplateToDir(
1738
+ tmpTemplate,
1739
+ repoDir,
1740
+ components,
1741
+ componentPaths,
1742
+ vars,
1743
+ version,
1744
+ {
1745
+ componentSkips,
1746
+ rootSkip,
1747
+ realCwd: cwd
1748
+ }
1749
+ );
1366
1750
  const baselineRef = getBaselineRef(cwd);
1367
1751
  const templateFiles = await collectAllFiles(tmpTemplate, tmpTemplate);
1368
1752
  const analyses = [];
@@ -1409,12 +1793,12 @@ async function diff(cwd, localRepo) {
1409
1793
  await rm(tmpTemplate, { recursive: true, force: true });
1410
1794
  spinner7.stop("Analysis complete.");
1411
1795
  const groups = {
1412
- "new": [],
1796
+ new: [],
1413
1797
  "clean-update": [],
1414
1798
  "needs-merge": [],
1415
1799
  "user-only": [],
1416
- "unchanged": [],
1417
- "skipped": []
1800
+ unchanged: [],
1801
+ skipped: []
1418
1802
  };
1419
1803
  for (const a of analyses) {
1420
1804
  groups[a.status].push(a);
@@ -1424,15 +1808,21 @@ async function diff(cwd, localRepo) {
1424
1808
  for (const a of groups["new"]) p8.log.info(` + ${a.file}`);
1425
1809
  }
1426
1810
  if (groups["clean-update"].length > 0) {
1427
- p8.log.success(`Clean updates \u2014 auto-merged (${groups["clean-update"].length}):`);
1811
+ p8.log.success(
1812
+ `Clean updates \u2014 auto-merged (${groups["clean-update"].length}):`
1813
+ );
1428
1814
  for (const a of groups["clean-update"]) p8.log.info(` ~ ${a.file}`);
1429
1815
  }
1430
1816
  if (groups["needs-merge"].length > 0) {
1431
- p8.log.warn(`Needs merge \u2014 both sides changed (${groups["needs-merge"].length}):`);
1817
+ p8.log.warn(
1818
+ `Needs merge \u2014 both sides changed (${groups["needs-merge"].length}):`
1819
+ );
1432
1820
  for (const a of groups["needs-merge"]) p8.log.info(` ! ${a.file}`);
1433
1821
  }
1434
1822
  if (groups["user-only"].length > 0) {
1435
- p8.log.info(`User-modified only \u2014 no template change (${groups["user-only"].length}):`);
1823
+ p8.log.info(
1824
+ `User-modified only \u2014 no template change (${groups["user-only"].length}):`
1825
+ );
1436
1826
  for (const a of groups["user-only"]) p8.log.info(` = ${a.file}`);
1437
1827
  }
1438
1828
  if (groups["skipped"].length > 0) {
@@ -1459,12 +1849,21 @@ import { existsSync as existsSync9 } from "fs";
1459
1849
  import { readFile as readFile6, writeFile, mkdir as mkdir3 } from "fs/promises";
1460
1850
  import { join as join9 } from "path";
1461
1851
  import * as p9 from "@clack/prompts";
1462
- var FIELD_TYPES = ["string", "number", "boolean", "date", "datetime", "text", "json"];
1852
+ var FIELD_TYPES = [
1853
+ "string",
1854
+ "number",
1855
+ "boolean",
1856
+ "date",
1857
+ "datetime",
1858
+ "text",
1859
+ "json"
1860
+ ];
1463
1861
  function toPascal(s) {
1464
1862
  return s.split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
1465
1863
  }
1466
1864
  function pluralize(s) {
1467
- if (s.endsWith("s") || s.endsWith("x") || s.endsWith("z") || s.endsWith("sh") || s.endsWith("ch")) return s + "es";
1865
+ if (s.endsWith("s") || s.endsWith("x") || s.endsWith("z") || s.endsWith("sh") || s.endsWith("ch"))
1866
+ return s + "es";
1468
1867
  if (s.endsWith("y") && !/[aeiou]y$/i.test(s)) return s.slice(0, -1) + "ies";
1469
1868
  return s + "s";
1470
1869
  }
@@ -1527,7 +1926,9 @@ async function promptEntityConfig(name) {
1527
1926
  p9.log.warn("No fields defined. Adding a default 'name' field.");
1528
1927
  fields.push({ name: "name", type: "string", required: true });
1529
1928
  }
1530
- const stringFields = fields.filter((f) => f.type === "string" || f.type === "text");
1929
+ const stringFields = fields.filter(
1930
+ (f) => f.type === "string" || f.type === "text"
1931
+ );
1531
1932
  let searchableFields = [];
1532
1933
  if (stringFields.length > 0) {
1533
1934
  const selected = await p9.multiselect({
@@ -1632,7 +2033,9 @@ function generateFastAPIModel(config) {
1632
2033
  lines.push("");
1633
2034
  for (const field of config.fields) {
1634
2035
  const nullable = field.required ? "nullable=False" : "nullable=True";
1635
- lines.push(` ${field.name} = Column(${sqlalchemyType(field.type)}, ${nullable})`);
2036
+ lines.push(
2037
+ ` ${field.name} = Column(${sqlalchemyType(field.type)}, ${nullable})`
2038
+ );
1636
2039
  }
1637
2040
  lines.push("");
1638
2041
  return lines.join("\n");
@@ -1726,7 +2129,10 @@ function generateFastifySchemas(config) {
1726
2129
  }
1727
2130
  lines.push(` created_at: Type.String({ format: 'date-time' }),`);
1728
2131
  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()]),`);
2132
+ if (config.softDelete)
2133
+ lines.push(
2134
+ ` deleted_at: Type.Union([Type.String({ format: 'date-time' }), Type.Null()]),`
2135
+ );
1730
2136
  lines.push(`});`);
1731
2137
  lines.push("");
1732
2138
  lines.push(`export type ${className} = Static<typeof ${className}Schema>;`);
@@ -1741,7 +2147,9 @@ function generateFastifySchemas(config) {
1741
2147
  }
1742
2148
  lines.push(`});`);
1743
2149
  lines.push("");
1744
- lines.push(`export type Create${className} = Static<typeof Create${className}Schema>;`);
2150
+ lines.push(
2151
+ `export type Create${className} = Static<typeof Create${className}Schema>;`
2152
+ );
1745
2153
  lines.push("");
1746
2154
  lines.push(`export const Update${className}Schema = Type.Object({`);
1747
2155
  for (const f of config.fields) {
@@ -1749,29 +2157,50 @@ function generateFastifySchemas(config) {
1749
2157
  }
1750
2158
  lines.push(`});`);
1751
2159
  lines.push("");
1752
- lines.push(`export type Update${className} = Static<typeof Update${className}Schema>;`);
2160
+ lines.push(
2161
+ `export type Update${className} = Static<typeof Update${className}Schema>;`
2162
+ );
1753
2163
  lines.push("");
1754
2164
  return lines.join("\n");
1755
2165
  }
1756
2166
  function generateFastifyIndex(config) {
1757
2167
  const className = toPascal(config.name);
1758
2168
  const camelConfig = className.charAt(0).toLowerCase() + className.slice(1) + "Config";
1759
- const allColumns = ["id", ...config.fields.map((f) => f.name), "created_at", "updated_at"];
2169
+ const allColumns = [
2170
+ "id",
2171
+ ...config.fields.map((f) => f.name),
2172
+ "created_at",
2173
+ "updated_at"
2174
+ ];
1760
2175
  if (config.softDelete) allColumns.push("deleted_at");
1761
2176
  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';`);
2177
+ lines.push(
2178
+ `import { EntityRegistry, type EntityConfig, type FieldMeta } from '../_base/index.js';`
2179
+ );
2180
+ lines.push(
2181
+ `import { ${className}Schema, Create${className}Schema, Update${className}Schema } from './schemas.js';`
2182
+ );
1764
2183
  lines.push("");
1765
2184
  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' },`);
2185
+ lines.push(
2186
+ ` { key: 'id', label: 'Id', type: 'str', nullable: false, is_auto: true, is_primary_key: true, filterable: true, has_foreign_key: false, field_type: 'text' },`
2187
+ );
1767
2188
  for (const f of config.fields) {
1768
2189
  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}' },`);
2190
+ lines.push(
2191
+ ` { 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}' },`
2192
+ );
1770
2193
  }
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' },`);
2194
+ lines.push(
2195
+ ` { 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' },`
2196
+ );
2197
+ lines.push(
2198
+ ` { 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' },`
2199
+ );
1773
2200
  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' },`);
2201
+ lines.push(
2202
+ ` { 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' },`
2203
+ );
1775
2204
  }
1776
2205
  lines.push(`];`);
1777
2206
  lines.push("");
@@ -1787,7 +2216,9 @@ function generateFastifyIndex(config) {
1787
2216
  lines.push(` bulkOperations: ${config.bulkOperations},`);
1788
2217
  lines.push(` columnNames: [${allColumns.map((c) => `'${c}'`).join(", ")}],`);
1789
2218
  if (config.searchableFields.length > 0) {
1790
- lines.push(` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`);
2219
+ lines.push(
2220
+ ` searchableFields: [${config.searchableFields.map((f) => `'${f}'`).join(", ")}],`
2221
+ );
1791
2222
  } else {
1792
2223
  lines.push(` searchableFields: [],`);
1793
2224
  }
@@ -1898,7 +2329,8 @@ function dartFromJson(fieldName, type, required) {
1898
2329
  const key = `json['${fieldName}']`;
1899
2330
  const isDate = type === "date" || type === "datetime";
1900
2331
  if (isDate && required) return `DateTime.parse(${key} as String)`;
1901
- if (isDate && !required) return `${key} != null ? DateTime.parse(${key} as String) : null`;
2332
+ if (isDate && !required)
2333
+ return `${key} != null ? DateTime.parse(${key} as String) : null`;
1902
2334
  if (type === "json" && !required) return `${key} as Map<String, dynamic>?`;
1903
2335
  if (type === "json") return `${key} as Map<String, dynamic>`;
1904
2336
  const dartT = (() => {
@@ -1918,14 +2350,22 @@ function dartFromJson(fieldName, type, required) {
1918
2350
  }
1919
2351
  function dartToJson(fieldName, camelName, type, required) {
1920
2352
  const isDate = type === "date" || type === "datetime";
1921
- if (isDate && required) return `'${fieldName}': ${camelName}.toIso8601String()`;
1922
- if (isDate && !required) return `'${fieldName}': ${camelName}?.toIso8601String()`;
2353
+ if (isDate && required)
2354
+ return `'${fieldName}': ${camelName}.toIso8601String()`;
2355
+ if (isDate && !required)
2356
+ return `'${fieldName}': ${camelName}?.toIso8601String()`;
1923
2357
  return `'${fieldName}': ${camelName}`;
1924
2358
  }
1925
2359
  function generateDartModel(config) {
1926
2360
  const className = toPascal(config.name);
1927
2361
  const allFields = [
1928
- { snake: "id", camel: "id", type: "String", required: true, fieldType: "string" },
2362
+ {
2363
+ snake: "id",
2364
+ camel: "id",
2365
+ type: "String",
2366
+ required: true,
2367
+ fieldType: "string"
2368
+ },
1929
2369
  ...config.fields.map((f) => ({
1930
2370
  snake: f.name,
1931
2371
  camel: toCamel(f.name),
@@ -1935,11 +2375,29 @@ function generateDartModel(config) {
1935
2375
  }))
1936
2376
  ];
1937
2377
  if (config.softDelete) {
1938
- allFields.push({ snake: "deleted_at", camel: "deletedAt", type: "DateTime?", required: false, fieldType: "datetime" });
2378
+ allFields.push({
2379
+ snake: "deleted_at",
2380
+ camel: "deletedAt",
2381
+ type: "DateTime?",
2382
+ required: false,
2383
+ fieldType: "datetime"
2384
+ });
1939
2385
  }
1940
2386
  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" }
2387
+ {
2388
+ snake: "created_at",
2389
+ camel: "createdAt",
2390
+ type: "DateTime",
2391
+ required: true,
2392
+ fieldType: "datetime"
2393
+ },
2394
+ {
2395
+ snake: "updated_at",
2396
+ camel: "updatedAt",
2397
+ type: "DateTime",
2398
+ required: true,
2399
+ fieldType: "datetime"
2400
+ }
1943
2401
  );
1944
2402
  const lines = [];
1945
2403
  lines.push(`class ${className} {`);
@@ -1960,7 +2418,9 @@ function generateDartModel(config) {
1960
2418
  lines.push(` factory ${className}.fromJson(Map<String, dynamic> json) {`);
1961
2419
  lines.push(` return ${className}(`);
1962
2420
  for (const f of allFields) {
1963
- lines.push(` ${f.camel}: ${dartFromJson(f.snake, f.fieldType, f.required)},`);
2421
+ lines.push(
2422
+ ` ${f.camel}: ${dartFromJson(f.snake, f.fieldType, f.required)},`
2423
+ );
1964
2424
  }
1965
2425
  lines.push(` );`);
1966
2426
  lines.push(` }`);
@@ -1968,7 +2428,9 @@ function generateDartModel(config) {
1968
2428
  lines.push(` Map<String, dynamic> toJson() {`);
1969
2429
  lines.push(` return {`);
1970
2430
  for (const f of allFields) {
1971
- lines.push(` ${dartToJson(f.snake, f.camel, f.fieldType, f.required)},`);
2431
+ lines.push(
2432
+ ` ${dartToJson(f.snake, f.camel, f.fieldType, f.required)},`
2433
+ );
1972
2434
  }
1973
2435
  lines.push(` };`);
1974
2436
  lines.push(` }`);
@@ -2070,11 +2532,15 @@ function generateFastapiTest(config) {
2070
2532
  }
2071
2533
  lines.push(` }`);
2072
2534
  const updateField = config.fields[0];
2073
- lines.push(` update_payload = {"${updateField.name}": ${pyHttpLiteral(updateField.type, "update")}}`);
2535
+ lines.push(
2536
+ ` update_payload = {"${updateField.name}": ${pyHttpLiteral(updateField.type, "update")}}`
2537
+ );
2074
2538
  lines.push(` invalid_payload: dict = {}`);
2075
2539
  lines.push(` filter_field = "${filterField.name}"`);
2076
2540
  lines.push(` filter_value = ${pyHttpLiteral(filterField.type, "create")}`);
2077
- lines.push(` other_filter_value = ${pyHttpLiteral(filterField.type, "alt")}`);
2541
+ lines.push(
2542
+ ` other_filter_value = ${pyHttpLiteral(filterField.type, "alt")}`
2543
+ );
2078
2544
  lines.push("");
2079
2545
  lines.push(` def make_model(self, index: int, **overrides):`);
2080
2546
  lines.push(` data = {`);
@@ -2092,7 +2558,9 @@ function generateFastifyTest(config) {
2092
2558
  const basePath = `/api/v1${config.apiPrefix}`;
2093
2559
  const updateField = config.fields[0];
2094
2560
  const lines = [];
2095
- lines.push(`import { describeCrudEntity } from '../helpers/crud-test-base.js';`);
2561
+ lines.push(
2562
+ `import { describeCrudEntity } from '../helpers/crud-test-base.js';`
2563
+ );
2096
2564
  lines.push("");
2097
2565
  lines.push(`describeCrudEntity({`);
2098
2566
  lines.push(` entityName: '${className}',`);
@@ -2104,7 +2572,9 @@ function generateFastifyTest(config) {
2104
2572
  }
2105
2573
  lines.push(` },`);
2106
2574
  lines.push(` updatePayload: {`);
2107
- lines.push(` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`);
2575
+ lines.push(
2576
+ ` ${updateField.name}: ${tsLiteral(updateField.type, "update")},`
2577
+ );
2108
2578
  lines.push(` },`);
2109
2579
  lines.push(`});`);
2110
2580
  lines.push("");
@@ -2150,7 +2620,12 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2150
2620
  p9.log.error("No backend component found. Need fastapi or fastify.");
2151
2621
  process.exit(1);
2152
2622
  }
2153
- const targetBackend = await resolvePrimaryBackend(cwd, hasFastapi, hasFastify, backendFlag);
2623
+ const targetBackend = await resolvePrimaryBackend(
2624
+ cwd,
2625
+ hasFastapi,
2626
+ hasFastify,
2627
+ backendFlag
2628
+ );
2154
2629
  const genFastapi = targetBackend === "fastapi" && hasFastapi;
2155
2630
  const genFastify = targetBackend === "fastify" && hasFastify;
2156
2631
  let config;
@@ -2177,11 +2652,19 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2177
2652
  const dir = componentPaths.fastapi;
2178
2653
  const entityDir = join9(cwd, dir, "src/entities", toSnake(config.name));
2179
2654
  if (existsSync9(entityDir)) {
2180
- p9.log.warn(`${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`);
2655
+ p9.log.warn(
2656
+ `${dir}/src/entities/${toSnake(config.name)}/ already exists. Skipping FastAPI.`
2657
+ );
2181
2658
  } else {
2182
2659
  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");
2660
+ await writeFile(
2661
+ join9(entityDir, "_model.py"),
2662
+ generateFastAPIModel(config)
2663
+ );
2664
+ await writeFile(
2665
+ join9(entityDir, "__init__.py"),
2666
+ "from ._model import *\n"
2667
+ );
2185
2668
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/_model.py`);
2186
2669
  generated.push(`${dir}/src/entities/${toSnake(config.name)}/__init__.py`);
2187
2670
  const testsDir = join9(cwd, dir, "tests");
@@ -2196,11 +2679,19 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2196
2679
  const dir = componentPaths.fastify;
2197
2680
  const moduleDir = join9(cwd, dir, "src/modules", toKebab(config.name));
2198
2681
  if (existsSync9(moduleDir)) {
2199
- p9.log.warn(`${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`);
2682
+ p9.log.warn(
2683
+ `${dir}/src/modules/${toKebab(config.name)}/ already exists. Skipping Fastify.`
2684
+ );
2200
2685
  } else {
2201
2686
  await mkdir3(moduleDir, { recursive: true });
2202
- await writeFile(join9(moduleDir, "schemas.ts"), generateFastifySchemas(config));
2203
- await writeFile(join9(moduleDir, "index.ts"), generateFastifyIndex(config));
2687
+ await writeFile(
2688
+ join9(moduleDir, "schemas.ts"),
2689
+ generateFastifySchemas(config)
2690
+ );
2691
+ await writeFile(
2692
+ join9(moduleDir, "index.ts"),
2693
+ generateFastifyIndex(config)
2694
+ );
2204
2695
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/schemas.ts`);
2205
2696
  generated.push(`${dir}/src/modules/${toKebab(config.name)}/index.ts`);
2206
2697
  const appPath = join9(cwd, dir, "src/app.ts");
@@ -2225,12 +2716,18 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2225
2716
  const modelName = `model ${toPascal(config.name)}`;
2226
2717
  if (!prismaContent.includes(modelName)) {
2227
2718
  const prismaModel = generatePrismaModel(config);
2228
- await writeFile(prismaPath, prismaContent.trimEnd() + "\n\n" + prismaModel + "\n");
2719
+ await writeFile(
2720
+ prismaPath,
2721
+ prismaContent.trimEnd() + "\n\n" + prismaModel + "\n"
2722
+ );
2229
2723
  generated.push(`${dir}/prisma/schema.prisma (model added)`);
2230
2724
  }
2231
2725
  }
2232
2726
  const testsModulesDir = join9(cwd, dir, "tests/modules");
2233
- const fastifyTestFile = join9(testsModulesDir, `${toKebab(config.name)}.test.ts`);
2727
+ const fastifyTestFile = join9(
2728
+ testsModulesDir,
2729
+ `${toKebab(config.name)}.test.ts`
2730
+ );
2234
2731
  if (existsSync9(testsModulesDir) && !existsSync9(fastifyTestFile)) {
2235
2732
  await writeFile(fastifyTestFile, generateFastifyTest(config));
2236
2733
  generated.push(`${dir}/tests/modules/${toKebab(config.name)}.test.ts`);
@@ -2243,7 +2740,9 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2243
2740
  const fileName = toKebab(config.name) + ".ts";
2244
2741
  const filePath = join9(typesDir, fileName);
2245
2742
  if (existsSync9(filePath)) {
2246
- p9.log.warn(`${dir}/src/types/${fileName} already exists. Skipping frontend types.`);
2743
+ p9.log.warn(
2744
+ `${dir}/src/types/${fileName} already exists. Skipping frontend types.`
2745
+ );
2247
2746
  } else {
2248
2747
  await mkdir3(typesDir, { recursive: true });
2249
2748
  await writeFile(filePath, generateFrontendInterface(config));
@@ -2253,7 +2752,10 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2253
2752
  if (existsSync9(barrelPath)) {
2254
2753
  const content = await readFile6(barrelPath, "utf-8");
2255
2754
  if (!content.includes(exportLine)) {
2256
- await writeFile(barrelPath, content.trimEnd() + "\n" + exportLine + "\n");
2755
+ await writeFile(
2756
+ barrelPath,
2757
+ content.trimEnd() + "\n" + exportLine + "\n"
2758
+ );
2257
2759
  }
2258
2760
  } else {
2259
2761
  await writeFile(barrelPath, exportLine + "\n");
@@ -2266,7 +2768,9 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2266
2768
  const entityDir = join9(cwd, dir, "lib/entities", toSnake(config.name));
2267
2769
  const modelPath = join9(entityDir, "model.dart");
2268
2770
  if (existsSync9(modelPath)) {
2269
- p9.log.warn(`${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`);
2771
+ p9.log.warn(
2772
+ `${dir}/lib/entities/${toSnake(config.name)}/model.dart already exists. Skipping mobile model.`
2773
+ );
2270
2774
  } else {
2271
2775
  await mkdir3(entityDir, { recursive: true });
2272
2776
  await writeFile(modelPath, generateDartModel(config));
@@ -2286,19 +2790,27 @@ async function gen(cwd, entityName, fieldsFlag, backendFlag) {
2286
2790
  if (genFastapi) {
2287
2791
  p9.log.info("");
2288
2792
  p9.log.info("FastAPI next steps:");
2289
- p9.log.info(` alembic revision --autogenerate -m "add ${config.tableName}"`);
2793
+ p9.log.info(
2794
+ ` alembic revision --autogenerate -m "add ${config.tableName}"`
2795
+ );
2290
2796
  p9.log.info(" alembic upgrade head");
2291
2797
  }
2292
2798
  if (genFastify) {
2293
2799
  p9.log.info("");
2294
2800
  p9.log.info("Fastify next steps:");
2295
- p9.log.info(` ${pm.prismaExec} migrate dev --name add_${toSnake(config.name)}`);
2801
+ p9.log.info(
2802
+ ` ${pm.prismaExec} migrate dev --name add_${toSnake(config.name)}`
2803
+ );
2296
2804
  }
2297
2805
  if (hasFrontend) {
2298
2806
  p9.log.info("");
2299
2807
  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}');`);
2808
+ p9.log.info(
2809
+ ` import type { ${className} } from '../types/${toKebab(config.name)}';`
2810
+ );
2811
+ p9.log.info(
2812
+ ` const { data } = await api.list<${className}>('${config.apiPrefix}');`
2813
+ );
2302
2814
  }
2303
2815
  if (hasMobile) {
2304
2816
  p9.log.info("");
@@ -2645,9 +3157,7 @@ function parseArgs() {
2645
3157
  if (arg === "--components") {
2646
3158
  const val = args[++i];
2647
3159
  if (val) {
2648
- options.components = val.split(",").filter(
2649
- (c) => COMPONENTS.includes(c)
2650
- );
3160
+ options.components = val.split(",").filter((c) => COMPONENTS.includes(c));
2651
3161
  }
2652
3162
  continue;
2653
3163
  }
@@ -2697,6 +3207,11 @@ function parseArgs() {
2697
3207
  if (val) extraArgs.push(`--fields=${val}`);
2698
3208
  continue;
2699
3209
  }
3210
+ if (arg === "--name") {
3211
+ const val = args[++i];
3212
+ if (val) extraArgs.push(`--name=${val}`);
3213
+ continue;
3214
+ }
2700
3215
  if (!arg.startsWith("-")) {
2701
3216
  if (command === "add" || command === "pin" || command === "unpin" || command === "gen") {
2702
3217
  extraArgs.push(arg);
@@ -2713,6 +3228,7 @@ function printHelp() {
2713
3228
  projx <name> [options] Create a new project
2714
3229
  projx init Adopt existing project into projx
2715
3230
  projx add <components...> Add components to existing project
3231
+ projx add <type> --name <dir> Add another instance of <type> at <dir>
2716
3232
  projx update Update scaffolding to latest
2717
3233
  projx diff Preview what update would change
2718
3234
  projx pin <patterns...> Skip files on future updates
@@ -2735,6 +3251,7 @@ function printHelp() {
2735
3251
  npx create-projx my-app --components fastapi,frontend,e2e
2736
3252
  npx create-projx my-app -y
2737
3253
  npx create-projx add frontend mobile
3254
+ npx create-projx add fastify --name email-ingestor
2738
3255
  npx create-projx@latest update
2739
3256
  npx create-projx diff
2740
3257
  npx create-projx pin backend/pyproject.toml
@@ -2758,10 +3275,25 @@ async function main() {
2758
3275
  (c) => COMPONENTS.includes(c)
2759
3276
  );
2760
3277
  if (components.length === 0) {
2761
- console.error(`Error: specify components to add. Available: ${COMPONENTS.join(", ")}`);
3278
+ console.error(
3279
+ `Error: specify components to add. Available: ${COMPONENTS.join(", ")}`
3280
+ );
2762
3281
  process.exit(1);
2763
3282
  }
2764
- await add(process.cwd(), components, localRepo, options.install === false);
3283
+ const customName = extraArgs.find((a) => a.startsWith("--name="))?.slice("--name=".length);
3284
+ if (customName && components.length > 1) {
3285
+ console.error(
3286
+ "Error: --name can only be used when adding a single component type."
3287
+ );
3288
+ process.exit(2);
3289
+ }
3290
+ await add(
3291
+ process.cwd(),
3292
+ components,
3293
+ localRepo,
3294
+ options.install === false,
3295
+ customName
3296
+ );
2765
3297
  return;
2766
3298
  }
2767
3299
  if (command === "pin") {
@@ -2774,7 +3306,9 @@ async function main() {
2774
3306
  }
2775
3307
  if (command === "unpin") {
2776
3308
  if (extraArgs.length === 0) {
2777
- console.error("Error: specify patterns to unpin. Usage: projx unpin <patterns...>");
3309
+ console.error(
3310
+ "Error: specify patterns to unpin. Usage: projx unpin <patterns...>"
3311
+ );
2778
3312
  process.exit(1);
2779
3313
  }
2780
3314
  await unpin(process.cwd(), extraArgs);
@@ -2797,7 +3331,9 @@ async function main() {
2797
3331
  if (command === "gen") {
2798
3332
  const subcommand = extraArgs[0];
2799
3333
  if (subcommand !== "entity" || !extraArgs[1]) {
2800
- console.error('Usage: projx gen entity <name> [--fields "name:string,amount:number"]');
3334
+ console.error(
3335
+ 'Usage: projx gen entity <name> [--fields "name:string,amount:number"]'
3336
+ );
2801
3337
  process.exit(1);
2802
3338
  }
2803
3339
  const entityName = extraArgs[1];