@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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +493 -21
  3. 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
- return neededDockerServices(layers).filter((name) => layers.docker.services[name]);
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
- return { enabled: true, services: enableDockerServices(needed) };
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 Prompt (or enable) MFA, email verification, SCIM, metrics
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 selected = flags.dockerServices.filter((name) => needed.includes(name));
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
- const askExtras = flags.extrasPrompt || await prompter.confirm("Configure extras (MFA, SCIM, metrics)?", false);
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 { createMetricsRoutes } from "@getstrata/bootstrap/metricsRoutes";
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 = neededTools.length === 0 ? "none" : dockerOn.length > 0 ? dockerOn.join("+") : "local";
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/starter",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Interactive create-strata wizard. Choose each layer; one database engine.",
5
5
  "type": "module",
6
6
  "license": "MIT",