@getstrata/starter 0.1.10 → 0.1.12
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/README.md +2 -2
- package/dist/cli.js +493 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# create-strata
|
|
2
2
|
|
|
3
|
-
Interactive starter for [Strata](https://github.com/EyK-26/strata). The wizard always asks each layer. SQLite APIs and Postgres HTML apps use the same generator.
|
|
3
|
+
Interactive starter for [Strata](https://github.com/EyK-26/strata). The wizard always asks each layer. Extras (MFA, email verification, SCIM, metrics) are toggled one by one. SQLite APIs and Postgres HTML apps use the same generator.
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ bunx create-strata html --frontend server-htmx --database postgres --auth cookie
|
|
|
12
12
|
|
|
13
13
|
Layer flags: `--frontend`, `--database`, `--auth`, `--tenancy`, `--cache`, `--queue`, `--mail`, `--spa-prefix`, plus extras (`--mfa`, `--scim`, `--metrics`, ...).
|
|
14
14
|
|
|
15
|
-
Pick **one** database engine. Docker Compose is optional and only includes services for the tools you selected (`--docker`, `--no-docker`, `--docker-services=postgres,redis`).
|
|
15
|
+
Pick **one** database engine. Docker Compose is optional and only includes services for the tools you selected (`--docker`, `--no-docker`, `--docker-services=postgres,redis`). `--docker` with Postgres or MySQL also writes Adminer.
|
|
16
16
|
|
|
17
17
|
## What you get
|
|
18
18
|
|
package/dist/cli.js
CHANGED
|
@@ -84,12 +84,13 @@ var TENANCY_DRIVERS = ["none", "column", "rls"];
|
|
|
84
84
|
var CACHE_DRIVERS = ["array", "redis"];
|
|
85
85
|
var QUEUE_DRIVERS = ["sync", "redis"];
|
|
86
86
|
var MAIL_DRIVERS = ["log", "smtp"];
|
|
87
|
-
var DOCKER_SERVICE_NAMES = ["postgres", "mysql", "redis", "mailpit"];
|
|
87
|
+
var DOCKER_SERVICE_NAMES = ["postgres", "mysql", "redis", "mailpit", "adminer"];
|
|
88
88
|
var DOCKER_SERVICE_LABELS = {
|
|
89
89
|
postgres: "Postgres",
|
|
90
90
|
mysql: "MySQL",
|
|
91
91
|
redis: "Redis",
|
|
92
|
-
mailpit: "SMTP (Mailpit)"
|
|
92
|
+
mailpit: "SMTP (Mailpit)",
|
|
93
|
+
adminer: "Adminer (database UI)"
|
|
93
94
|
};
|
|
94
95
|
function authUsesCookie(auth) {
|
|
95
96
|
return auth === "cookie" || auth.startsWith("cookie-");
|
|
@@ -109,11 +110,26 @@ function usesTenantTable(tenancy) {
|
|
|
109
110
|
function htmlAuthKit(auth) {
|
|
110
111
|
return authUsesCookie(auth);
|
|
111
112
|
}
|
|
113
|
+
function extraApplies(extra, auth) {
|
|
114
|
+
if (extra === "metrics") {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
if (extra === "mfa") {
|
|
118
|
+
return htmlAuthKit(auth);
|
|
119
|
+
}
|
|
120
|
+
return authNeedsUsers(auth);
|
|
121
|
+
}
|
|
112
122
|
function needsRedis(layers) {
|
|
113
123
|
return layers.cache === "redis" || layers.queue === "redis";
|
|
114
124
|
}
|
|
115
125
|
function emptyDockerServices() {
|
|
116
|
-
return { postgres: false, mysql: false, redis: false, mailpit: false };
|
|
126
|
+
return { postgres: false, mysql: false, redis: false, mailpit: false, adminer: false };
|
|
127
|
+
}
|
|
128
|
+
function dockerDatabaseService(database) {
|
|
129
|
+
if (database === "postgres" || database === "mysql") {
|
|
130
|
+
return database;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
117
133
|
}
|
|
118
134
|
function enableDockerServices(names) {
|
|
119
135
|
const services = emptyDockerServices();
|
|
@@ -142,7 +158,12 @@ function selectedDockerServices(layers) {
|
|
|
142
158
|
if (!layers.docker.enabled) {
|
|
143
159
|
return [];
|
|
144
160
|
}
|
|
145
|
-
|
|
161
|
+
const selected = neededDockerServices(layers).filter((name) => layers.docker.services[name]);
|
|
162
|
+
const databaseService = dockerDatabaseService(layers.database);
|
|
163
|
+
if (databaseService && selected.includes(databaseService) && layers.docker.services.adminer) {
|
|
164
|
+
selected.push("adminer");
|
|
165
|
+
}
|
|
166
|
+
return selected;
|
|
146
167
|
}
|
|
147
168
|
function reconcileDocker(layers) {
|
|
148
169
|
const selected = selectedDockerServices(layers);
|
|
@@ -159,7 +180,11 @@ function dockerLayerForNeeded(layers, enabled) {
|
|
|
159
180
|
if (!enabled || needed.length === 0) {
|
|
160
181
|
return { enabled: false, services: emptyDockerServices() };
|
|
161
182
|
}
|
|
162
|
-
|
|
183
|
+
const names = [...needed];
|
|
184
|
+
if (dockerDatabaseService(layers.database)) {
|
|
185
|
+
names.push("adminer");
|
|
186
|
+
}
|
|
187
|
+
return { enabled: true, services: enableDockerServices(names) };
|
|
163
188
|
}
|
|
164
189
|
|
|
165
190
|
// src/presets.ts
|
|
@@ -240,10 +265,10 @@ Options:
|
|
|
240
265
|
--email-verification / --no-email-verification
|
|
241
266
|
--scim / --no-scim
|
|
242
267
|
--metrics / --no-metrics
|
|
243
|
-
--extras
|
|
268
|
+
--extras Interactive extras list (MFA, email verification, SCIM, metrics)
|
|
244
269
|
--docker Write Docker Compose for every selected tool that needs a service
|
|
245
270
|
--no-docker Skip docker-compose.yml; use installs already on this machine
|
|
246
|
-
--docker-services Subset: postgres, mysql, redis, mailpit (comma-separated)
|
|
271
|
+
--docker-services Subset: postgres, mysql, redis, mailpit, adminer (comma-separated)
|
|
247
272
|
--force Replace an existing directory
|
|
248
273
|
--yes, --no-interactive
|
|
249
274
|
-h, --help
|
|
@@ -437,7 +462,13 @@ function applyDockerFlags(layers, flags) {
|
|
|
437
462
|
};
|
|
438
463
|
}
|
|
439
464
|
if (flags.dockerServices) {
|
|
440
|
-
const
|
|
465
|
+
const databaseService = dockerDatabaseService(layers.database);
|
|
466
|
+
const selected = flags.dockerServices.filter((name) => {
|
|
467
|
+
if (needed.includes(name)) {
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
return name === "adminer" && databaseService !== null && flags.dockerServices?.includes(databaseService) === true;
|
|
471
|
+
});
|
|
441
472
|
return {
|
|
442
473
|
...layers,
|
|
443
474
|
docker: {
|
|
@@ -479,14 +510,355 @@ function layersFromFlags(flags) {
|
|
|
479
510
|
// src/prompt.ts
|
|
480
511
|
import { stdin as input, stdout as output } from "process";
|
|
481
512
|
import { createInterface } from "readline/promises";
|
|
513
|
+
|
|
514
|
+
// src/selectPrompt.ts
|
|
515
|
+
class PromptCancelledError extends Error {
|
|
516
|
+
constructor() {
|
|
517
|
+
super("Cancelled");
|
|
518
|
+
this.name = "PromptCancelledError";
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function consumeSelectKeys(buffer) {
|
|
522
|
+
const events = [];
|
|
523
|
+
let rest = buffer;
|
|
524
|
+
while (rest.length > 0) {
|
|
525
|
+
if (rest[0] === "\x1B") {
|
|
526
|
+
if (rest.length === 1) {
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
if (rest.startsWith("\x1B[")) {
|
|
530
|
+
const end = rest.search(/[A-Za-z]/);
|
|
531
|
+
if (end < 2) {
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
const command = rest[end];
|
|
535
|
+
rest = rest.slice(end + 1);
|
|
536
|
+
if (command === "A") {
|
|
537
|
+
events.push({ type: "up" });
|
|
538
|
+
} else if (command === "B") {
|
|
539
|
+
events.push({ type: "down" });
|
|
540
|
+
} else if (command === "C") {
|
|
541
|
+
events.push({ type: "right" });
|
|
542
|
+
} else if (command === "D") {
|
|
543
|
+
events.push({ type: "left" });
|
|
544
|
+
}
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (rest.startsWith("\x1BO")) {
|
|
548
|
+
if (rest.length < 3) {
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
const command = rest[2];
|
|
552
|
+
rest = rest.slice(3);
|
|
553
|
+
if (command === "A") {
|
|
554
|
+
events.push({ type: "up" });
|
|
555
|
+
} else if (command === "B") {
|
|
556
|
+
events.push({ type: "down" });
|
|
557
|
+
} else if (command === "C") {
|
|
558
|
+
events.push({ type: "right" });
|
|
559
|
+
} else if (command === "D") {
|
|
560
|
+
events.push({ type: "left" });
|
|
561
|
+
}
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
rest = rest.slice(1);
|
|
565
|
+
events.push({ type: "abort" });
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const next = rest[0] ?? "";
|
|
569
|
+
rest = rest.slice(1);
|
|
570
|
+
if (next === "\x03") {
|
|
571
|
+
events.push({ type: "abort" });
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (next === "\r" || next === `
|
|
575
|
+
`) {
|
|
576
|
+
events.push({ type: "submit" });
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (next === " ") {
|
|
580
|
+
events.push({ type: "toggle" });
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (next === "y" || next === "Y") {
|
|
584
|
+
events.push({ type: "yes" });
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (next === "n" || next === "N") {
|
|
588
|
+
events.push({ type: "no" });
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (next >= "1" && next <= "9") {
|
|
592
|
+
events.push({ type: "digit", value: Number(next) });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { events, rest };
|
|
596
|
+
}
|
|
597
|
+
function moveSelectIndex(index, delta, length) {
|
|
598
|
+
if (length <= 0) {
|
|
599
|
+
return 0;
|
|
600
|
+
}
|
|
601
|
+
return (index + delta + length) % length;
|
|
602
|
+
}
|
|
603
|
+
function highlight(line, on) {
|
|
604
|
+
return on ? `\x1B[7m${line}\x1B[0m` : line;
|
|
605
|
+
}
|
|
606
|
+
function renderSelectLines(message, choices, index) {
|
|
607
|
+
return [
|
|
608
|
+
message,
|
|
609
|
+
...choices.map((choice, choiceIndex) => {
|
|
610
|
+
const selected = choiceIndex === index;
|
|
611
|
+
const marker = selected ? ">" : " ";
|
|
612
|
+
return highlight(` ${marker} ${choiceIndex + 1}) ${choice.label}`, selected);
|
|
613
|
+
}),
|
|
614
|
+
` \u2191/\u2193 and Enter, or 1-${choices.length}`
|
|
615
|
+
];
|
|
616
|
+
}
|
|
617
|
+
function renderConfirmLines(message, yes) {
|
|
618
|
+
return [
|
|
619
|
+
message,
|
|
620
|
+
highlight(` ${yes ? ">" : " "} yes`, yes),
|
|
621
|
+
highlight(` ${yes ? " " : ">"} no`, !yes),
|
|
622
|
+
" \u2191/\u2193 and Enter, or y / n"
|
|
623
|
+
];
|
|
624
|
+
}
|
|
625
|
+
function renderMultiSelectLines(message, choices, index) {
|
|
626
|
+
return [
|
|
627
|
+
message,
|
|
628
|
+
...choices.map((choice, choiceIndex) => {
|
|
629
|
+
const focused = choiceIndex === index;
|
|
630
|
+
const box = choice.enabled ? "[x]" : "[ ]";
|
|
631
|
+
const marker = focused ? ">" : " ";
|
|
632
|
+
return highlight(` ${marker} ${box} ${choiceIndex + 1}) ${choice.label}`, focused);
|
|
633
|
+
}),
|
|
634
|
+
" \u2191/\u2193 move, Space or 1-9 toggle, Enter to continue"
|
|
635
|
+
];
|
|
636
|
+
}
|
|
637
|
+
function writeLines(output, lines) {
|
|
638
|
+
for (const line of lines) {
|
|
639
|
+
output.write(`\x1B[2K${line}
|
|
640
|
+
`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function clearDrawnLines(output, lineCount) {
|
|
644
|
+
if (lineCount <= 0) {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
output.write(`\x1B[${lineCount}F`);
|
|
648
|
+
for (let i = 0;i < lineCount; i += 1) {
|
|
649
|
+
output.write(`\x1B[2K
|
|
650
|
+
`);
|
|
651
|
+
}
|
|
652
|
+
output.write(`\x1B[${lineCount}F`);
|
|
653
|
+
}
|
|
654
|
+
function runRawPrompt(io, render, onEvent, summary) {
|
|
655
|
+
const { input, output } = io;
|
|
656
|
+
const wasRaw = Boolean(input.isTTY && typeof input.setRawMode === "function");
|
|
657
|
+
let buffer = "";
|
|
658
|
+
let lineCount = 0;
|
|
659
|
+
let settled = false;
|
|
660
|
+
const restore = () => {
|
|
661
|
+
output.write("\x1B[?25h");
|
|
662
|
+
if (wasRaw) {
|
|
663
|
+
input.setRawMode?.(false);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
const paint = () => {
|
|
667
|
+
const lines = render();
|
|
668
|
+
if (lineCount > 0) {
|
|
669
|
+
output.write(`\x1B[${lineCount}F`);
|
|
670
|
+
}
|
|
671
|
+
writeLines(output, lines);
|
|
672
|
+
lineCount = lines.length;
|
|
673
|
+
};
|
|
674
|
+
input.setEncoding("utf8");
|
|
675
|
+
if (wasRaw) {
|
|
676
|
+
input.setRawMode?.(true);
|
|
677
|
+
}
|
|
678
|
+
if (typeof input.resume === "function") {
|
|
679
|
+
input.resume();
|
|
680
|
+
}
|
|
681
|
+
output.write("\x1B[?25l");
|
|
682
|
+
paint();
|
|
683
|
+
return new Promise((resolve, reject) => {
|
|
684
|
+
const cleanup = () => {
|
|
685
|
+
input.off("data", onData);
|
|
686
|
+
restore();
|
|
687
|
+
};
|
|
688
|
+
const succeed = (result) => {
|
|
689
|
+
if (settled) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
settled = true;
|
|
693
|
+
cleanup();
|
|
694
|
+
clearDrawnLines(output, lineCount);
|
|
695
|
+
output.write(`${summary(result)}
|
|
696
|
+
`);
|
|
697
|
+
resolve(result);
|
|
698
|
+
};
|
|
699
|
+
const fail = (error) => {
|
|
700
|
+
if (settled) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
settled = true;
|
|
704
|
+
cleanup();
|
|
705
|
+
clearDrawnLines(output, lineCount);
|
|
706
|
+
reject(error);
|
|
707
|
+
};
|
|
708
|
+
function onData(chunk) {
|
|
709
|
+
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
710
|
+
const consumed = consumeSelectKeys(buffer);
|
|
711
|
+
buffer = consumed.rest;
|
|
712
|
+
for (const event of consumed.events) {
|
|
713
|
+
const outcome = onEvent(event);
|
|
714
|
+
if (outcome === "abort") {
|
|
715
|
+
fail(new PromptCancelledError);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (outcome === "continue") {
|
|
719
|
+
paint();
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
succeed(outcome.done);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
input.on("data", onData);
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
async function promptSelect(message, choices, defaultValue, io) {
|
|
730
|
+
if (choices.length === 0) {
|
|
731
|
+
throw new Error(`No choices for "${message}".`);
|
|
732
|
+
}
|
|
733
|
+
const found = choices.findIndex((choice) => choice.value === defaultValue);
|
|
734
|
+
let index = found >= 0 ? found : 0;
|
|
735
|
+
return runRawPrompt(io, () => renderSelectLines(message, choices, index), (event) => {
|
|
736
|
+
if (event.type === "abort") {
|
|
737
|
+
return "abort";
|
|
738
|
+
}
|
|
739
|
+
if (event.type === "up") {
|
|
740
|
+
index = moveSelectIndex(index, -1, choices.length);
|
|
741
|
+
return "continue";
|
|
742
|
+
}
|
|
743
|
+
if (event.type === "down") {
|
|
744
|
+
index = moveSelectIndex(index, 1, choices.length);
|
|
745
|
+
return "continue";
|
|
746
|
+
}
|
|
747
|
+
if (event.type === "digit") {
|
|
748
|
+
if (event.value >= 1 && event.value <= choices.length) {
|
|
749
|
+
const choice = choices[event.value - 1];
|
|
750
|
+
if (choice) {
|
|
751
|
+
return { done: choice.value };
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return "continue";
|
|
755
|
+
}
|
|
756
|
+
if (event.type === "submit") {
|
|
757
|
+
const choice = choices[index];
|
|
758
|
+
if (choice) {
|
|
759
|
+
return { done: choice.value };
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return "continue";
|
|
763
|
+
}, (value) => `${message} ${choices.find((choice) => choice.value === value)?.label ?? value}`);
|
|
764
|
+
}
|
|
765
|
+
async function promptConfirm(message, defaultValue, io) {
|
|
766
|
+
let yes = defaultValue;
|
|
767
|
+
return runRawPrompt(io, () => renderConfirmLines(message, yes), (event) => {
|
|
768
|
+
if (event.type === "abort") {
|
|
769
|
+
return "abort";
|
|
770
|
+
}
|
|
771
|
+
if (event.type === "up" || event.type === "right" || event.type === "yes") {
|
|
772
|
+
if (event.type === "yes") {
|
|
773
|
+
return { done: true };
|
|
774
|
+
}
|
|
775
|
+
yes = true;
|
|
776
|
+
return "continue";
|
|
777
|
+
}
|
|
778
|
+
if (event.type === "down" || event.type === "left" || event.type === "no") {
|
|
779
|
+
if (event.type === "no") {
|
|
780
|
+
return { done: false };
|
|
781
|
+
}
|
|
782
|
+
yes = false;
|
|
783
|
+
return "continue";
|
|
784
|
+
}
|
|
785
|
+
if (event.type === "digit") {
|
|
786
|
+
if (event.value === 1) {
|
|
787
|
+
return { done: true };
|
|
788
|
+
}
|
|
789
|
+
if (event.value === 2) {
|
|
790
|
+
return { done: false };
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (event.type === "submit") {
|
|
794
|
+
return { done: yes };
|
|
795
|
+
}
|
|
796
|
+
return "continue";
|
|
797
|
+
}, (value) => `${message} ${value ? "yes" : "no"}`);
|
|
798
|
+
}
|
|
799
|
+
async function promptMultiSelect(message, choices, io) {
|
|
800
|
+
if (choices.length === 0) {
|
|
801
|
+
return [];
|
|
802
|
+
}
|
|
803
|
+
const selected = choices.map((choice) => choice.enabled);
|
|
804
|
+
let index = 0;
|
|
805
|
+
const enabledValues = () => choices.filter((_, choiceIndex) => selected[choiceIndex]).map((choice) => choice.value);
|
|
806
|
+
return runRawPrompt(io, () => renderMultiSelectLines(message, choices.map((choice, choiceIndex) => ({
|
|
807
|
+
...choice,
|
|
808
|
+
enabled: Boolean(selected[choiceIndex])
|
|
809
|
+
})), index), (event) => {
|
|
810
|
+
if (event.type === "abort") {
|
|
811
|
+
return "abort";
|
|
812
|
+
}
|
|
813
|
+
if (event.type === "up") {
|
|
814
|
+
index = moveSelectIndex(index, -1, choices.length);
|
|
815
|
+
return "continue";
|
|
816
|
+
}
|
|
817
|
+
if (event.type === "down") {
|
|
818
|
+
index = moveSelectIndex(index, 1, choices.length);
|
|
819
|
+
return "continue";
|
|
820
|
+
}
|
|
821
|
+
if (event.type === "toggle") {
|
|
822
|
+
selected[index] = !selected[index];
|
|
823
|
+
return "continue";
|
|
824
|
+
}
|
|
825
|
+
if (event.type === "digit") {
|
|
826
|
+
if (event.value >= 1 && event.value <= choices.length) {
|
|
827
|
+
const target = event.value - 1;
|
|
828
|
+
selected[target] = !selected[target];
|
|
829
|
+
index = target;
|
|
830
|
+
}
|
|
831
|
+
return "continue";
|
|
832
|
+
}
|
|
833
|
+
if (event.type === "submit") {
|
|
834
|
+
return { done: enabledValues() };
|
|
835
|
+
}
|
|
836
|
+
return "continue";
|
|
837
|
+
}, (values) => {
|
|
838
|
+
const labels = choices.filter((choice) => values.includes(choice.value)).map((choice) => choice.value);
|
|
839
|
+
return `${message} ${labels.length > 0 ? labels.join(", ") : "none"}`;
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// src/prompt.ts
|
|
844
|
+
var EXTRA_CHOICES = [
|
|
845
|
+
{ value: "mfa", label: "mfa: authenticator challenge + setup pages" },
|
|
846
|
+
{ value: "emailVerification", label: "email-verification: signed links + /email/verify" },
|
|
847
|
+
{ value: "scim", label: "scim: /Users adapter" },
|
|
848
|
+
{ value: "metrics", label: "metrics: Prometheus token" }
|
|
849
|
+
];
|
|
482
850
|
function isInteractive(flags) {
|
|
483
851
|
if (flags.yes || flags.noInteractive) {
|
|
484
852
|
return false;
|
|
485
853
|
}
|
|
486
854
|
return Boolean(input.isTTY && output.isTTY);
|
|
487
855
|
}
|
|
856
|
+
function canUseRawKeys() {
|
|
857
|
+
return Boolean(input.isTTY && typeof input.setRawMode === "function");
|
|
858
|
+
}
|
|
488
859
|
function createReadlinePrompter() {
|
|
489
860
|
const rl = createInterface({ input, output });
|
|
861
|
+
const io = { input, output };
|
|
490
862
|
return {
|
|
491
863
|
async question(message, defaultValue) {
|
|
492
864
|
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
@@ -494,6 +866,15 @@ function createReadlinePrompter() {
|
|
|
494
866
|
return answer || defaultValue || "";
|
|
495
867
|
},
|
|
496
868
|
async confirm(message, defaultValue = false) {
|
|
869
|
+
if (canUseRawKeys()) {
|
|
870
|
+
rl.pause();
|
|
871
|
+
try {
|
|
872
|
+
return await promptConfirm(message, defaultValue, io);
|
|
873
|
+
} finally {
|
|
874
|
+
input.setRawMode?.(false);
|
|
875
|
+
rl.resume();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
497
878
|
const hint = defaultValue ? "Y/n" : "y/N";
|
|
498
879
|
const answer = (await rl.question(`${message} (${hint}): `)).trim().toLowerCase();
|
|
499
880
|
if (!answer) {
|
|
@@ -502,6 +883,15 @@ function createReadlinePrompter() {
|
|
|
502
883
|
return answer === "y" || answer === "yes";
|
|
503
884
|
},
|
|
504
885
|
async select(message, choices, defaultValue) {
|
|
886
|
+
if (canUseRawKeys()) {
|
|
887
|
+
rl.pause();
|
|
888
|
+
try {
|
|
889
|
+
return await promptSelect(message, choices, defaultValue, io);
|
|
890
|
+
} finally {
|
|
891
|
+
input.setRawMode?.(false);
|
|
892
|
+
rl.resume();
|
|
893
|
+
}
|
|
894
|
+
}
|
|
505
895
|
console.log(message);
|
|
506
896
|
for (const [index, choice] of choices.entries()) {
|
|
507
897
|
const marker = choice.value === defaultValue ? "*" : " ";
|
|
@@ -520,11 +910,66 @@ function createReadlinePrompter() {
|
|
|
520
910
|
const match = choices.find((choice) => choice.value === answer || choice.label === answer);
|
|
521
911
|
return match?.value ?? defaultValue;
|
|
522
912
|
},
|
|
913
|
+
async multiSelect(message, choices) {
|
|
914
|
+
if (canUseRawKeys()) {
|
|
915
|
+
rl.pause();
|
|
916
|
+
try {
|
|
917
|
+
return await promptMultiSelect(message, choices, io);
|
|
918
|
+
} finally {
|
|
919
|
+
input.setRawMode?.(false);
|
|
920
|
+
rl.resume();
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const enabled = new Set(choices.filter((choice) => choice.enabled).map((choice) => choice.value));
|
|
924
|
+
console.log(`${message} (yes/no each)`);
|
|
925
|
+
for (const choice of choices) {
|
|
926
|
+
const hint = enabled.has(choice.value) ? "Y/n" : "y/N";
|
|
927
|
+
const answer = (await rl.question(` ${choice.label} (${hint}): `)).trim().toLowerCase();
|
|
928
|
+
if (!answer) {
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
if (answer === "y" || answer === "yes") {
|
|
932
|
+
enabled.add(choice.value);
|
|
933
|
+
} else if (answer === "n" || answer === "no") {
|
|
934
|
+
enabled.delete(choice.value);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return [...enabled];
|
|
938
|
+
},
|
|
523
939
|
close() {
|
|
524
940
|
rl.close();
|
|
525
941
|
}
|
|
526
942
|
};
|
|
527
943
|
}
|
|
944
|
+
function extrasStillToAsk(layers, flags) {
|
|
945
|
+
return EXTRA_CHOICES.filter((choice) => {
|
|
946
|
+
if (flags.extras[choice.value] !== undefined) {
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
return extraApplies(choice.value, layers.auth);
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
async function promptExtras(prompter, extras, layers, flags) {
|
|
953
|
+
const choices = extrasStillToAsk(layers, flags);
|
|
954
|
+
if (choices.length === 0) {
|
|
955
|
+
return extras;
|
|
956
|
+
}
|
|
957
|
+
const picked = new Set(await prompter.multiSelect("Extras", choices.map((choice) => ({
|
|
958
|
+
value: choice.value,
|
|
959
|
+
label: choice.label,
|
|
960
|
+
enabled: extras[choice.value]
|
|
961
|
+
}))));
|
|
962
|
+
const next = { ...extras };
|
|
963
|
+
for (const choice of choices) {
|
|
964
|
+
next[choice.value] = picked.has(choice.value);
|
|
965
|
+
}
|
|
966
|
+
for (const choice of EXTRA_CHOICES) {
|
|
967
|
+
if (!extraApplies(choice.value, layers.auth) && flags.extras[choice.value] === undefined) {
|
|
968
|
+
next[choice.value] = false;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return next;
|
|
972
|
+
}
|
|
528
973
|
async function promptLayers(flags, prompter) {
|
|
529
974
|
const layers = applyFlagOverrides(defaultLayers(), flags);
|
|
530
975
|
layers.frontend = await prompter.select("Frontend", [
|
|
@@ -572,16 +1017,10 @@ async function promptLayers(flags, prompter) {
|
|
|
572
1017
|
{ value: "log", label: "log: print messages" },
|
|
573
1018
|
{ value: "smtp", label: "smtp" }
|
|
574
1019
|
], layers.mail);
|
|
575
|
-
if (layers.frontend === "spa-react" || layers.frontend === "hybrid") {
|
|
1020
|
+
if (flags.spaPrefix === undefined && (layers.frontend === "spa-react" || layers.frontend === "hybrid")) {
|
|
576
1021
|
layers.spaPrefix = await prompter.question("SPA prefix", layers.spaPrefix);
|
|
577
1022
|
}
|
|
578
|
-
|
|
579
|
-
if (askExtras) {
|
|
580
|
-
layers.extras.mfa = await prompter.confirm("Authenticator MFA (cookie challenge + setup pages)?", layers.extras.mfa);
|
|
581
|
-
layers.extras.emailVerification = await prompter.confirm("Email verification (signed links + /email/verify)?", layers.extras.emailVerification);
|
|
582
|
-
layers.extras.scim = await prompter.confirm("SCIM /Users adapter?", layers.extras.scim);
|
|
583
|
-
layers.extras.metrics = await prompter.confirm("Metrics token?", layers.extras.metrics);
|
|
584
|
-
}
|
|
1023
|
+
layers.extras = await promptExtras(prompter, layers.extras, layers, flags);
|
|
585
1024
|
if (!dockerFlagsProvided(flags)) {
|
|
586
1025
|
layers.docker = await promptDockerLayer(prompter, layers);
|
|
587
1026
|
}
|
|
@@ -608,7 +1047,14 @@ async function promptDockerLayer(prompter, layers) {
|
|
|
608
1047
|
for (const name of needed) {
|
|
609
1048
|
services[name] = await prompter.confirm(`Docker Compose for ${DOCKER_SERVICE_LABELS[name]}?`, true);
|
|
610
1049
|
}
|
|
1050
|
+
const databaseService = dockerDatabaseService(layers.database);
|
|
1051
|
+
if (databaseService && services[databaseService]) {
|
|
1052
|
+
services.adminer = await prompter.confirm(`Docker Compose for ${DOCKER_SERVICE_LABELS.adminer}?`, true);
|
|
1053
|
+
}
|
|
611
1054
|
const selected = needed.filter((name) => services[name]);
|
|
1055
|
+
if (services.adminer) {
|
|
1056
|
+
selected.push("adminer");
|
|
1057
|
+
}
|
|
612
1058
|
return {
|
|
613
1059
|
enabled: selected.length > 0,
|
|
614
1060
|
services
|
|
@@ -1910,6 +2356,17 @@ function renderDockerCompose(projectName, layers) {
|
|
|
1910
2356
|
volumes:
|
|
1911
2357
|
- mysqldata:/var/lib/mysql`);
|
|
1912
2358
|
}
|
|
2359
|
+
if (selectedSet.has("adminer")) {
|
|
2360
|
+
const server = selectedSet.has("mysql") ? "mysql" : "postgres";
|
|
2361
|
+
services.push(` adminer:
|
|
2362
|
+
image: adminer:5.4.2
|
|
2363
|
+
environment:
|
|
2364
|
+
ADMINER_DEFAULT_SERVER: ${server}
|
|
2365
|
+
depends_on:
|
|
2366
|
+
- ${server}
|
|
2367
|
+
ports:
|
|
2368
|
+
- "8080:8080"`);
|
|
2369
|
+
}
|
|
1913
2370
|
if (selectedSet.has("redis")) {
|
|
1914
2371
|
services.push(` redis:
|
|
1915
2372
|
image: redis:7-alpine
|
|
@@ -2030,6 +2487,14 @@ function renderSupportingToolsReadme(layers) {
|
|
|
2030
2487
|
if (dockerOn.length > 0) {
|
|
2031
2488
|
const names = dockerOn.map((name) => DOCKER_SERVICE_LABELS[name]).join(", ");
|
|
2032
2489
|
lines.push(`Docker Compose includes ${names}.`, "", "```bash", "docker compose up -d", "```", "");
|
|
2490
|
+
if (dockerSet.has("adminer")) {
|
|
2491
|
+
const mysql = layers.database === "mysql";
|
|
2492
|
+
const system = mysql ? "MySQL" : "PostgreSQL";
|
|
2493
|
+
const server = mysql ? "mysql" : "postgres";
|
|
2494
|
+
const username = mysql ? "root" : "postgres";
|
|
2495
|
+
const password = mysql ? "root" : "postgres";
|
|
2496
|
+
lines.push(`Adminer: http://localhost:8080 (${system}, server \`${server}\`, username \`${username}\`, password \`${password}\`).`, "");
|
|
2497
|
+
}
|
|
2033
2498
|
}
|
|
2034
2499
|
if (localOn.length > 0) {
|
|
2035
2500
|
const names = localOn.map((name) => DOCKER_SERVICE_LABELS[name]).join(", ");
|
|
@@ -2102,6 +2567,8 @@ Cookie name is \`strata_session\`. Forms send CSRF as \`_token\`.
|
|
|
2102
2567
|
Opaque token login: \`POST /api/v1/auth/login\` with \`{ "email", "password" }\`. Register: \`POST /api/v1/auth/register\`. Forgot/reset: \`POST /api/v1/auth/forgot-password\` and signed \`POST /api/v1/auth/reset-password\`. Send \`Authorization: Bearer\` after login.
|
|
2103
2568
|
` : ""}${authUsesJwt(layers.auth) ? `
|
|
2104
2569
|
JWT mint: \`POST /api/auth/token\` with email and password. Short-lived. Not a portal session.
|
|
2570
|
+
` : ""}${layers.extras.metrics ? `
|
|
2571
|
+
Prometheus scrape: \`GET /metrics\`. Production requires \`Authorization: Bearer <METRICS_TOKEN>\`.
|
|
2105
2572
|
` : ""}
|
|
2106
2573
|
## Production
|
|
2107
2574
|
|
|
@@ -2781,6 +3248,10 @@ export { starterProviders };
|
|
|
2781
3248
|
function renderCreateAppTs(layers) {
|
|
2782
3249
|
const ensureLine = needsEnsure(layers) ? `import { ensureAppDatabase } from "./ensureDatabase.ts";
|
|
2783
3250
|
` : "";
|
|
3251
|
+
const metricsImport = layers.extras.metrics ? `import { createMetricsRoutes } from "@getstrata/bootstrap/metricsRoutes";
|
|
3252
|
+
` : "";
|
|
3253
|
+
const metricsSpread = layers.extras.metrics ? `
|
|
3254
|
+
...createMetricsRoutes(),` : "";
|
|
2784
3255
|
return `import { join } from "node:path";
|
|
2785
3256
|
import "./preload.ts";
|
|
2786
3257
|
import { runProviderPhase } from "@getstrata/bootstrap/context";
|
|
@@ -2800,8 +3271,7 @@ import {
|
|
|
2800
3271
|
ensureModulesLoaded,
|
|
2801
3272
|
} from "@getstrata/bootstrap/discoverModules";
|
|
2802
3273
|
import { createHealthRoutes } from "@getstrata/bootstrap/health";
|
|
2803
|
-
import {
|
|
2804
|
-
import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
|
|
3274
|
+
${metricsImport}import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
|
|
2805
3275
|
import { createWebServer } from "@getstrata/bootstrap/web/server";
|
|
2806
3276
|
import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
|
|
2807
3277
|
import { migrate } from "../db/migrate.ts";
|
|
@@ -2881,8 +3351,7 @@ ${needsEnsure(layers) ? ` await ensureAppDatabase();
|
|
|
2881
3351
|
|
|
2882
3352
|
const routes = mergeSpaRoutes(context.dependencies, {
|
|
2883
3353
|
...createHealthRoutes(context.dependencies),
|
|
2884
|
-
...buildRoutes(context.dependencies)
|
|
2885
|
-
...createMetricsRoutes(),
|
|
3354
|
+
...buildRoutes(context.dependencies),${metricsSpread}
|
|
2886
3355
|
}, {
|
|
2887
3356
|
distDirectory: join(import.meta.dir, "../../frontend/dist"),
|
|
2888
3357
|
});
|
|
@@ -3404,7 +3873,7 @@ function printNextSteps(projectName, layers, compose) {
|
|
|
3404
3873
|
const dockerOn = selectedDockerServices(layers);
|
|
3405
3874
|
const neededTools = neededDockerServices(layers);
|
|
3406
3875
|
const localOn = neededTools.filter((name) => !dockerOn.includes(name));
|
|
3407
|
-
const dockerSummary =
|
|
3876
|
+
const dockerSummary = dockerOn.length > 0 ? dockerOn.join("+") : neededTools.length === 0 ? "none" : "local";
|
|
3408
3877
|
console.log(`
|
|
3409
3878
|
Created Strata app in ${projectName}/
|
|
3410
3879
|
`);
|
|
@@ -3416,6 +3885,9 @@ Next steps:`);
|
|
|
3416
3885
|
if (compose) {
|
|
3417
3886
|
console.log(" docker compose up -d");
|
|
3418
3887
|
}
|
|
3888
|
+
if (dockerOn.includes("adminer")) {
|
|
3889
|
+
console.log(" Adminer: http://localhost:8080");
|
|
3890
|
+
}
|
|
3419
3891
|
if (localOn.length > 0) {
|
|
3420
3892
|
console.log(` Point env at local ${localOn.join(", ")} (see README).`);
|
|
3421
3893
|
}
|