create-macostack 0.3.0 → 0.5.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.
@@ -45,6 +45,24 @@ type Part = {
45
45
  hint: string;
46
46
  };
47
47
 
48
+ // The module question a template exposes via `setup --questions`. The CLI stays
49
+ // dumb: it renders whatever the template declares (options + optional one-line
50
+ // descriptions), never hardcoding which modules exist.
51
+ type ModuleSpec = {
52
+ options: string[];
53
+ initial: string[];
54
+ descriptions?: Record<string, string>;
55
+ exclusive?: string[][];
56
+ };
57
+
58
+ // Optional deploy-target question a template may expose (e.g. vps vs cloudflare
59
+ // workers). Same dumb-CLI contract as modules: rendered from what setup declares.
60
+ type RuntimeSpec = {
61
+ options: string[];
62
+ initial: string;
63
+ descriptions?: Record<string, string>;
64
+ };
65
+
48
66
  const PARTS: Part[] = [
49
67
  {
50
68
  id: "server",
@@ -204,7 +222,7 @@ const adjustModules = async (part: Part) => {
204
222
  stdout: "pipe",
205
223
  stderr: "pipe",
206
224
  });
207
- let spec: { modules?: { options: string[]; initial: string[]; exclusive?: string[][] } } | null = null;
225
+ let spec: { modules?: ModuleSpec } | null = null;
208
226
  if (q.exitCode === 0) {
209
227
  try {
210
228
  spec = JSON.parse(q.stdout.toString().trim().split("\n").at(-1) ?? "");
@@ -221,7 +239,7 @@ const adjustModules = async (part: Part) => {
221
239
  chosen = answered(
222
240
  await multiselect({
223
241
  message: `${part.label} — which modules should be ON? (your current setup is preselected)`,
224
- options: mods.options.map((m) => ({ value: m, label: m })),
242
+ options: mods.options.map((m) => ({ value: m, label: m, hint: mods.descriptions?.[m] })),
225
243
  initialValues: mods.initial,
226
244
  required: false,
227
245
  }),
@@ -346,14 +364,12 @@ for (const p of selected) {
346
364
  //////////////////////////////////////////////////////////////////
347
365
 
348
366
  mkdirSync(targetRoot, { recursive: true });
349
- const ready: Part[] = [];
350
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.
351
370
  for (const p of selected) {
352
371
  const dest = resolve(targetRoot, p.folder);
353
372
  const s = spinner();
354
-
355
- // Download the tarball — no git history comes with it, so each part starts
356
- // life fully detached from the template.
357
373
  s.start(`${p.label} — downloading ${owner}/${p.repo}@${ref}`);
358
374
  const tmp = mkdtempSync(join(tmpdir(), "create-macostack-"));
359
375
  try {
@@ -388,53 +404,147 @@ for (const p of selected) {
388
404
  : `${p.label} — bun install had issues (run it again inside ${displayRoot}/${p.folder})`,
389
405
  );
390
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
+ };
391
421
 
392
- // Configure the template. It OWNS its questions — we fetch them as data
393
- // (`setup --questions`), ask them in THIS wizard (nested interactive
394
- // prompts don't get keyboard control under `bun create`), and apply the
395
- // answers non-interactively (`setup --yes`).
422
+ const setups: PartSetup[] = [];
423
+ for (const p of selected) {
424
+ const dest = resolve(targetRoot, p.folder);
396
425
  const pkg = existsSync(resolve(dest, "package.json"))
397
426
  ? await Bun.file(resolve(dest, "package.json")).json()
398
427
  : null;
399
- if (!flags["skip-setup"] && pkg?.scripts?.setup) {
400
- let questions: { modules?: { options: string[]; initial: string[]; exclusive?: string[][] } } | null = null;
401
- if (!flags.yes) {
402
- const q = Bun.spawnSync(["bun", "run", "setup", "--questions"], {
403
- cwd: dest,
404
- stdout: "pipe",
405
- stderr: "pipe",
406
- });
407
- if (q.exitCode === 0) {
408
- try {
409
- questions = JSON.parse(q.stdout.toString().trim().split("\n").at(-1) ?? "");
410
- } catch {}
411
- }
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 {}
412
443
  }
444
+ }
445
+ setups.push({ part: p, dest, hasSetup, modules, runtime });
446
+ }
413
447
 
414
- 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
+ }
465
+
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
+ }
415
496
 
416
- if (questions?.modules) {
417
- let chosen: string[];
418
- for (;;) {
419
- chosen = answered(
420
- await multiselect({
421
- message: `${p.label} — which modules should start ON? (everything stays in the code; off = dormant, ready when you need it)`,
422
- options: questions.modules.options.map((m) => ({ value: m, label: m })),
423
- initialValues: questions.modules.initial,
424
- required: false,
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://)",
512
+ }),
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_…",
425
522
  }),
426
- );
427
- const clash = (questions.modules.exclusive ?? []).find((pair) =>
428
- pair.every((m) => chosen.includes(m)),
429
- );
430
- if (!clash) break;
431
- note(`${clash.join(" and ")} can't both be on keep one of the two.`, "One thing");
432
- }
433
- setupArgs.push("--modules", chosen.join(","));
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);
434
542
  }
543
+ if (domain) setupArgs.push("--domain", domain);
544
+ if (resendKey && p.id === "server") setupArgs.push("--resend", resendKey);
435
545
 
436
546
  const s3 = spinner();
437
- s3.start(`${p.label} — applying your setup (modules, fresh secrets, git)`);
547
+ s3.start(`${p.label} — applying your setup (features, fresh secrets, git)`);
438
548
  const setup = Bun.spawnSync(["bun", ...setupArgs], { cwd: dest, stdout: "pipe", stderr: "pipe" });
439
549
  if (setup.exitCode === 0) {
440
550
  s3.stop(`${p.label} — configured`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-macostack",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Private macostack scaffold orchestrator for detached server/client repos.",
5
5
  "type": "module",
6
6
  "bin": {