create-macostack 0.4.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.
- package/bin/create-macostack.ts +131 -54
- package/package.json +1 -1
package/bin/create-macostack.ts
CHANGED
|
@@ -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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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 (
|
|
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`);
|