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.
- package/bin/create-macostack.ts +152 -42
- package/package.json +1 -1
package/bin/create-macostack.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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 (
|
|
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`);
|