create-macostack 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -364,14 +364,12 @@ for (const p of selected) {
364
364
  //////////////////////////////////////////////////////////////////
365
365
 
366
366
  mkdirSync(targetRoot, { recursive: true });
367
- const ready: Part[] = [];
368
367
 
368
+ // Phase 1 — download every selected template (no git history) and install.
369
+ // Nothing is asked yet: the questions come ONCE, after everything is on disk.
369
370
  for (const p of selected) {
370
371
  const dest = resolve(targetRoot, p.folder);
371
372
  const s = spinner();
372
-
373
- // Download the tarball — no git history comes with it, so each part starts
374
- // life fully detached from the template.
375
373
  s.start(`${p.label} — downloading ${owner}/${p.repo}@${ref}`);
376
374
  const tmp = mkdtempSync(join(tmpdir(), "create-macostack-"));
377
375
  try {
@@ -406,68 +404,147 @@ for (const p of selected) {
406
404
  : `${p.label} — bun install had issues (run it again inside ${displayRoot}/${p.folder})`,
407
405
  );
408
406
  }
407
+ }
408
+
409
+ // Phase 2 — ONE round of questions for the whole app. Each template still owns
410
+ // what it can be asked (`setup --questions`); we MERGE those declarations so the
411
+ // same feature is asked once and dispatched to every part that supports it. The
412
+ // meaning of a pick differs by design and lives in the templates, not here: the
413
+ // backend toggles it (code stays, dormant), the web app strips what you omit.
414
+ type PartSetup = {
415
+ part: Part;
416
+ dest: string;
417
+ hasSetup: boolean;
418
+ modules?: ModuleSpec;
419
+ runtime?: RuntimeSpec;
420
+ };
409
421
 
410
- // Configure the template. It OWNS its questions — we fetch them as data
411
- // (`setup --questions`), ask them in THIS wizard (nested interactive
412
- // prompts don't get keyboard control under `bun create`), and apply the
413
- // answers non-interactively (`setup --yes`).
422
+ const setups: PartSetup[] = [];
423
+ for (const p of selected) {
424
+ const dest = resolve(targetRoot, p.folder);
414
425
  const pkg = existsSync(resolve(dest, "package.json"))
415
426
  ? await Bun.file(resolve(dest, "package.json")).json()
416
427
  : null;
417
- if (!flags["skip-setup"] && pkg?.scripts?.setup) {
418
- let questions: { modules?: ModuleSpec; runtime?: RuntimeSpec } | null = null;
419
- if (!flags.yes) {
420
- const q = Bun.spawnSync(["bun", "run", "setup", "--questions"], {
421
- cwd: dest,
422
- stdout: "pipe",
423
- stderr: "pipe",
424
- });
425
- if (q.exitCode === 0) {
426
- try {
427
- questions = JSON.parse(q.stdout.toString().trim().split("\n").at(-1) ?? "");
428
- } catch {}
429
- }
428
+ const hasSetup = Boolean(pkg?.scripts?.setup);
429
+ let modules: ModuleSpec | undefined;
430
+ let runtime: RuntimeSpec | undefined;
431
+ if (hasSetup && !flags.yes && !flags["skip-setup"]) {
432
+ const q = Bun.spawnSync(["bun", "run", "setup", "--questions"], {
433
+ cwd: dest,
434
+ stdout: "pipe",
435
+ stderr: "pipe",
436
+ });
437
+ if (q.exitCode === 0) {
438
+ try {
439
+ const spec = JSON.parse(q.stdout.toString().trim().split("\n").at(-1) ?? "");
440
+ modules = spec.modules;
441
+ runtime = spec.runtime;
442
+ } catch {}
430
443
  }
444
+ }
445
+ setups.push({ part: p, dest, hasSetup, modules, runtime });
446
+ }
431
447
 
432
- const setupArgs = ["run", "setup", "--yes", "--name", appName];
448
+ // Deploy target only the backend declares one; asked once, before features
449
+ // (the stack is a bigger commitment than which features ship).
450
+ let runtimeChoice: string | null = null;
451
+ const runtimeSpec = setups.find((s) => s.runtime && s.runtime.options.length > 1)?.runtime;
452
+ if (runtimeSpec) {
453
+ runtimeChoice = answered(
454
+ await select({
455
+ message: "Backend — where will you deploy?",
456
+ options: runtimeSpec.options.map((o) => ({
457
+ value: o,
458
+ label: o,
459
+ hint: runtimeSpec.descriptions?.[o],
460
+ })),
461
+ initialValue: runtimeSpec.initial,
462
+ }),
463
+ ) as string;
464
+ }
433
465
 
434
- if (questions?.modules) {
435
- let chosen: string[];
436
- for (;;) {
437
- chosen = answered(
438
- await multiselect({
439
- message: `${p.label} which modules should start ON? (everything stays in the code; off = dormant, ready when you need it)`,
440
- options: questions.modules.options.map((m) => ({ value: m, label: m, hint: questions.modules?.descriptions?.[m] })),
441
- initialValues: questions.modules.initial,
442
- required: false,
443
- }),
444
- );
445
- const clash = (questions.modules.exclusive ?? []).find((pair) =>
446
- pair.every((m) => chosen.includes(m)),
447
- );
448
- if (!clash) break;
449
- note(`${clash.join(" and ")} can't both be on — keep one of the two.`, "One thing");
450
- }
451
- setupArgs.push("--modules", chosen.join(","));
452
- }
466
+ // Features — the single, merged question (union of what each part declares).
467
+ let chosenModules: string[] | null = null;
468
+ const moduleOptions: string[] = [];
469
+ const moduleDescriptions: Record<string, string> = {};
470
+ const exclusivePairs: string[][] = [];
471
+ const moduleInitial = new Set<string>();
472
+ for (const s of setups) {
473
+ if (!s.modules) continue;
474
+ for (const o of s.modules.options) if (!moduleOptions.includes(o)) moduleOptions.push(o);
475
+ for (const [k, v] of Object.entries(s.modules.descriptions ?? {}))
476
+ if (!(k in moduleDescriptions)) moduleDescriptions[k] = v;
477
+ for (const pair of s.modules.exclusive ?? []) exclusivePairs.push(pair);
478
+ for (const m of s.modules.initial) moduleInitial.add(m);
479
+ }
480
+ if (moduleOptions.length > 0) {
481
+ for (;;) {
482
+ chosenModules = answered(
483
+ await multiselect({
484
+ message:
485
+ "Features (one pick for the whole app — the backend keeps them as dormant code you can flip on; the web app drops the ones you leave out)",
486
+ options: moduleOptions.map((m) => ({ value: m, label: m, hint: moduleDescriptions[m] })),
487
+ initialValues: [...moduleInitial],
488
+ required: false,
489
+ }),
490
+ );
491
+ const clash = exclusivePairs.find((pair) => pair.every((m) => chosenModules!.includes(m)));
492
+ if (!clash) break;
493
+ note(`${clash.join(" and ")} can't both be on — keep one of the two.`, "One thing");
494
+ }
495
+ }
453
496
 
454
- if (questions?.runtime && questions.runtime.options.length > 1) {
455
- const rt = answered(
456
- await select({
457
- message: `${p.label} where will you deploy?`,
458
- options: questions.runtime.options.map((o) => ({
459
- value: o,
460
- label: o,
461
- hint: questions.runtime?.descriptions?.[o],
462
- })),
463
- initialValue: questions.runtime.initial,
497
+ // Optional .env prefill asked once, injected into whichever part uses it.
498
+ let domain: string | null = null;
499
+ let resendKey: string | null = null;
500
+ if (!flags.yes && !flags["skip-setup"]) {
501
+ const wantsServer = setups.some((s) => s.part.id === "server" && s.hasSetup);
502
+ domain =
503
+ (
504
+ answered(
505
+ await text({
506
+ message: "Primary domain? (optional — sets CORS + cookie scope and the web app's API URL; blank to skip)",
507
+ placeholder: "midominio.com",
508
+ validate: (v) =>
509
+ !v || /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v)
510
+ ? undefined
511
+ : "a bare domain like midominio.com (no https://)",
464
512
  }),
465
- );
466
- setupArgs.push("--runtime", rt as string);
513
+ ) as string
514
+ ).trim() || null;
515
+ if (wantsServer) {
516
+ resendKey =
517
+ (
518
+ answered(
519
+ await text({
520
+ message: "Resend API key? (optional — backend transactional email; blank to fill in .env later)",
521
+ placeholder: "re_…",
522
+ }),
523
+ ) as string
524
+ ).trim() || null;
525
+ }
526
+ }
527
+
528
+ // Phase 3 — apply. Each part gets ONLY the features it declared; the same click
529
+ // means "toggle" on the backend and "keep vs strip" on the web app.
530
+ const ready: Part[] = [];
531
+ for (const s of setups) {
532
+ const { part: p, dest, hasSetup } = s;
533
+
534
+ if (hasSetup && !flags["skip-setup"]) {
535
+ const setupArgs = ["run", "setup", "--yes", "--name", appName];
536
+ if (s.modules && chosenModules) {
537
+ const forPart = chosenModules.filter((m) => s.modules!.options.includes(m));
538
+ setupArgs.push("--modules", forPart.join(","));
539
+ }
540
+ if (s.runtime && s.runtime.options.length > 1 && runtimeChoice) {
541
+ setupArgs.push("--runtime", runtimeChoice);
467
542
  }
543
+ if (domain) setupArgs.push("--domain", domain);
544
+ if (resendKey && p.id === "server") setupArgs.push("--resend", resendKey);
468
545
 
469
546
  const s3 = spinner();
470
- s3.start(`${p.label} — applying your setup (modules, fresh secrets, git)`);
547
+ s3.start(`${p.label} — applying your setup (features, fresh secrets, git)`);
471
548
  const setup = Bun.spawnSync(["bun", ...setupArgs], { cwd: dest, stdout: "pipe", stderr: "pipe" });
472
549
  if (setup.exitCode === 0) {
473
550
  s3.stop(`${p.label} — configured`);
@@ -500,7 +577,7 @@ const server = ready.find((p) => p.id === "server");
500
577
  const client = ready.find((p) => p.id === "client");
501
578
  if (server) {
502
579
  steps.push(
503
- `Start the backend:\n cd ${displayRoot}/server\n docker compose up -d && bun run db:migrate && bun dev`,
580
+ `Start the backend:\n cd ${displayRoot}/server\n docker compose up -d && bun run db:generate && bun run db:migrate && bun dev`,
504
581
  );
505
582
  }
506
583
  if (client) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-macostack",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Private macostack scaffold orchestrator for detached server/client repos.",
5
5
  "type": "module",
6
6
  "bin": {