@getstrata/starter 0.1.10 → 0.1.11

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 +1 -1
  2. package/dist/cli.js +434 -13
  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
 
package/dist/cli.js CHANGED
@@ -109,6 +109,15 @@ function usesTenantTable(tenancy) {
109
109
  function htmlAuthKit(auth) {
110
110
  return authUsesCookie(auth);
111
111
  }
112
+ function extraApplies(extra, auth) {
113
+ if (extra === "metrics") {
114
+ return true;
115
+ }
116
+ if (extra === "mfa") {
117
+ return htmlAuthKit(auth);
118
+ }
119
+ return authNeedsUsers(auth);
120
+ }
112
121
  function needsRedis(layers) {
113
122
  return layers.cache === "redis" || layers.queue === "redis";
114
123
  }
@@ -240,7 +249,7 @@ Options:
240
249
  --email-verification / --no-email-verification
241
250
  --scim / --no-scim
242
251
  --metrics / --no-metrics
243
- --extras Prompt (or enable) MFA, email verification, SCIM, metrics
252
+ --extras Interactive extras list (MFA, email verification, SCIM, metrics)
244
253
  --docker Write Docker Compose for every selected tool that needs a service
245
254
  --no-docker Skip docker-compose.yml; use installs already on this machine
246
255
  --docker-services Subset: postgres, mysql, redis, mailpit (comma-separated)
@@ -479,14 +488,355 @@ function layersFromFlags(flags) {
479
488
  // src/prompt.ts
480
489
  import { stdin as input, stdout as output } from "process";
481
490
  import { createInterface } from "readline/promises";
491
+
492
+ // src/selectPrompt.ts
493
+ class PromptCancelledError extends Error {
494
+ constructor() {
495
+ super("Cancelled");
496
+ this.name = "PromptCancelledError";
497
+ }
498
+ }
499
+ function consumeSelectKeys(buffer) {
500
+ const events = [];
501
+ let rest = buffer;
502
+ while (rest.length > 0) {
503
+ if (rest[0] === "\x1B") {
504
+ if (rest.length === 1) {
505
+ break;
506
+ }
507
+ if (rest.startsWith("\x1B[")) {
508
+ const end = rest.search(/[A-Za-z]/);
509
+ if (end < 2) {
510
+ break;
511
+ }
512
+ const command = rest[end];
513
+ rest = rest.slice(end + 1);
514
+ if (command === "A") {
515
+ events.push({ type: "up" });
516
+ } else if (command === "B") {
517
+ events.push({ type: "down" });
518
+ } else if (command === "C") {
519
+ events.push({ type: "right" });
520
+ } else if (command === "D") {
521
+ events.push({ type: "left" });
522
+ }
523
+ continue;
524
+ }
525
+ if (rest.startsWith("\x1BO")) {
526
+ if (rest.length < 3) {
527
+ break;
528
+ }
529
+ const command = rest[2];
530
+ rest = rest.slice(3);
531
+ if (command === "A") {
532
+ events.push({ type: "up" });
533
+ } else if (command === "B") {
534
+ events.push({ type: "down" });
535
+ } else if (command === "C") {
536
+ events.push({ type: "right" });
537
+ } else if (command === "D") {
538
+ events.push({ type: "left" });
539
+ }
540
+ continue;
541
+ }
542
+ rest = rest.slice(1);
543
+ events.push({ type: "abort" });
544
+ continue;
545
+ }
546
+ const next = rest[0] ?? "";
547
+ rest = rest.slice(1);
548
+ if (next === "\x03") {
549
+ events.push({ type: "abort" });
550
+ continue;
551
+ }
552
+ if (next === "\r" || next === `
553
+ `) {
554
+ events.push({ type: "submit" });
555
+ continue;
556
+ }
557
+ if (next === " ") {
558
+ events.push({ type: "toggle" });
559
+ continue;
560
+ }
561
+ if (next === "y" || next === "Y") {
562
+ events.push({ type: "yes" });
563
+ continue;
564
+ }
565
+ if (next === "n" || next === "N") {
566
+ events.push({ type: "no" });
567
+ continue;
568
+ }
569
+ if (next >= "1" && next <= "9") {
570
+ events.push({ type: "digit", value: Number(next) });
571
+ }
572
+ }
573
+ return { events, rest };
574
+ }
575
+ function moveSelectIndex(index, delta, length) {
576
+ if (length <= 0) {
577
+ return 0;
578
+ }
579
+ return (index + delta + length) % length;
580
+ }
581
+ function highlight(line, on) {
582
+ return on ? `\x1B[7m${line}\x1B[0m` : line;
583
+ }
584
+ function renderSelectLines(message, choices, index) {
585
+ return [
586
+ message,
587
+ ...choices.map((choice, choiceIndex) => {
588
+ const selected = choiceIndex === index;
589
+ const marker = selected ? ">" : " ";
590
+ return highlight(` ${marker} ${choiceIndex + 1}) ${choice.label}`, selected);
591
+ }),
592
+ ` \u2191/\u2193 and Enter, or 1-${choices.length}`
593
+ ];
594
+ }
595
+ function renderConfirmLines(message, yes) {
596
+ return [
597
+ message,
598
+ highlight(` ${yes ? ">" : " "} yes`, yes),
599
+ highlight(` ${yes ? " " : ">"} no`, !yes),
600
+ " \u2191/\u2193 and Enter, or y / n"
601
+ ];
602
+ }
603
+ function renderMultiSelectLines(message, choices, index) {
604
+ return [
605
+ message,
606
+ ...choices.map((choice, choiceIndex) => {
607
+ const focused = choiceIndex === index;
608
+ const box = choice.enabled ? "[x]" : "[ ]";
609
+ const marker = focused ? ">" : " ";
610
+ return highlight(` ${marker} ${box} ${choiceIndex + 1}) ${choice.label}`, focused);
611
+ }),
612
+ " \u2191/\u2193 move, Space or 1-9 toggle, Enter to continue"
613
+ ];
614
+ }
615
+ function writeLines(output, lines) {
616
+ for (const line of lines) {
617
+ output.write(`\x1B[2K${line}
618
+ `);
619
+ }
620
+ }
621
+ function clearDrawnLines(output, lineCount) {
622
+ if (lineCount <= 0) {
623
+ return;
624
+ }
625
+ output.write(`\x1B[${lineCount}F`);
626
+ for (let i = 0;i < lineCount; i += 1) {
627
+ output.write(`\x1B[2K
628
+ `);
629
+ }
630
+ output.write(`\x1B[${lineCount}F`);
631
+ }
632
+ function runRawPrompt(io, render, onEvent, summary) {
633
+ const { input, output } = io;
634
+ const wasRaw = Boolean(input.isTTY && typeof input.setRawMode === "function");
635
+ let buffer = "";
636
+ let lineCount = 0;
637
+ let settled = false;
638
+ const restore = () => {
639
+ output.write("\x1B[?25h");
640
+ if (wasRaw) {
641
+ input.setRawMode?.(false);
642
+ }
643
+ };
644
+ const paint = () => {
645
+ const lines = render();
646
+ if (lineCount > 0) {
647
+ output.write(`\x1B[${lineCount}F`);
648
+ }
649
+ writeLines(output, lines);
650
+ lineCount = lines.length;
651
+ };
652
+ input.setEncoding("utf8");
653
+ if (wasRaw) {
654
+ input.setRawMode?.(true);
655
+ }
656
+ if (typeof input.resume === "function") {
657
+ input.resume();
658
+ }
659
+ output.write("\x1B[?25l");
660
+ paint();
661
+ return new Promise((resolve, reject) => {
662
+ const cleanup = () => {
663
+ input.off("data", onData);
664
+ restore();
665
+ };
666
+ const succeed = (result) => {
667
+ if (settled) {
668
+ return;
669
+ }
670
+ settled = true;
671
+ cleanup();
672
+ clearDrawnLines(output, lineCount);
673
+ output.write(`${summary(result)}
674
+ `);
675
+ resolve(result);
676
+ };
677
+ const fail = (error) => {
678
+ if (settled) {
679
+ return;
680
+ }
681
+ settled = true;
682
+ cleanup();
683
+ clearDrawnLines(output, lineCount);
684
+ reject(error);
685
+ };
686
+ function onData(chunk) {
687
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
688
+ const consumed = consumeSelectKeys(buffer);
689
+ buffer = consumed.rest;
690
+ for (const event of consumed.events) {
691
+ const outcome = onEvent(event);
692
+ if (outcome === "abort") {
693
+ fail(new PromptCancelledError);
694
+ return;
695
+ }
696
+ if (outcome === "continue") {
697
+ paint();
698
+ continue;
699
+ }
700
+ succeed(outcome.done);
701
+ return;
702
+ }
703
+ }
704
+ input.on("data", onData);
705
+ });
706
+ }
707
+ async function promptSelect(message, choices, defaultValue, io) {
708
+ if (choices.length === 0) {
709
+ throw new Error(`No choices for "${message}".`);
710
+ }
711
+ const found = choices.findIndex((choice) => choice.value === defaultValue);
712
+ let index = found >= 0 ? found : 0;
713
+ return runRawPrompt(io, () => renderSelectLines(message, choices, index), (event) => {
714
+ if (event.type === "abort") {
715
+ return "abort";
716
+ }
717
+ if (event.type === "up") {
718
+ index = moveSelectIndex(index, -1, choices.length);
719
+ return "continue";
720
+ }
721
+ if (event.type === "down") {
722
+ index = moveSelectIndex(index, 1, choices.length);
723
+ return "continue";
724
+ }
725
+ if (event.type === "digit") {
726
+ if (event.value >= 1 && event.value <= choices.length) {
727
+ const choice = choices[event.value - 1];
728
+ if (choice) {
729
+ return { done: choice.value };
730
+ }
731
+ }
732
+ return "continue";
733
+ }
734
+ if (event.type === "submit") {
735
+ const choice = choices[index];
736
+ if (choice) {
737
+ return { done: choice.value };
738
+ }
739
+ }
740
+ return "continue";
741
+ }, (value) => `${message} ${choices.find((choice) => choice.value === value)?.label ?? value}`);
742
+ }
743
+ async function promptConfirm(message, defaultValue, io) {
744
+ let yes = defaultValue;
745
+ return runRawPrompt(io, () => renderConfirmLines(message, yes), (event) => {
746
+ if (event.type === "abort") {
747
+ return "abort";
748
+ }
749
+ if (event.type === "up" || event.type === "right" || event.type === "yes") {
750
+ if (event.type === "yes") {
751
+ return { done: true };
752
+ }
753
+ yes = true;
754
+ return "continue";
755
+ }
756
+ if (event.type === "down" || event.type === "left" || event.type === "no") {
757
+ if (event.type === "no") {
758
+ return { done: false };
759
+ }
760
+ yes = false;
761
+ return "continue";
762
+ }
763
+ if (event.type === "digit") {
764
+ if (event.value === 1) {
765
+ return { done: true };
766
+ }
767
+ if (event.value === 2) {
768
+ return { done: false };
769
+ }
770
+ }
771
+ if (event.type === "submit") {
772
+ return { done: yes };
773
+ }
774
+ return "continue";
775
+ }, (value) => `${message} ${value ? "yes" : "no"}`);
776
+ }
777
+ async function promptMultiSelect(message, choices, io) {
778
+ if (choices.length === 0) {
779
+ return [];
780
+ }
781
+ const selected = choices.map((choice) => choice.enabled);
782
+ let index = 0;
783
+ const enabledValues = () => choices.filter((_, choiceIndex) => selected[choiceIndex]).map((choice) => choice.value);
784
+ return runRawPrompt(io, () => renderMultiSelectLines(message, choices.map((choice, choiceIndex) => ({
785
+ ...choice,
786
+ enabled: Boolean(selected[choiceIndex])
787
+ })), index), (event) => {
788
+ if (event.type === "abort") {
789
+ return "abort";
790
+ }
791
+ if (event.type === "up") {
792
+ index = moveSelectIndex(index, -1, choices.length);
793
+ return "continue";
794
+ }
795
+ if (event.type === "down") {
796
+ index = moveSelectIndex(index, 1, choices.length);
797
+ return "continue";
798
+ }
799
+ if (event.type === "toggle") {
800
+ selected[index] = !selected[index];
801
+ return "continue";
802
+ }
803
+ if (event.type === "digit") {
804
+ if (event.value >= 1 && event.value <= choices.length) {
805
+ const target = event.value - 1;
806
+ selected[target] = !selected[target];
807
+ index = target;
808
+ }
809
+ return "continue";
810
+ }
811
+ if (event.type === "submit") {
812
+ return { done: enabledValues() };
813
+ }
814
+ return "continue";
815
+ }, (values) => {
816
+ const labels = choices.filter((choice) => values.includes(choice.value)).map((choice) => choice.value);
817
+ return `${message} ${labels.length > 0 ? labels.join(", ") : "none"}`;
818
+ });
819
+ }
820
+
821
+ // src/prompt.ts
822
+ var EXTRA_CHOICES = [
823
+ { value: "mfa", label: "mfa: authenticator challenge + setup pages" },
824
+ { value: "emailVerification", label: "email-verification: signed links + /email/verify" },
825
+ { value: "scim", label: "scim: /Users adapter" },
826
+ { value: "metrics", label: "metrics: Prometheus token" }
827
+ ];
482
828
  function isInteractive(flags) {
483
829
  if (flags.yes || flags.noInteractive) {
484
830
  return false;
485
831
  }
486
832
  return Boolean(input.isTTY && output.isTTY);
487
833
  }
834
+ function canUseRawKeys() {
835
+ return Boolean(input.isTTY && typeof input.setRawMode === "function");
836
+ }
488
837
  function createReadlinePrompter() {
489
838
  const rl = createInterface({ input, output });
839
+ const io = { input, output };
490
840
  return {
491
841
  async question(message, defaultValue) {
492
842
  const suffix = defaultValue ? ` [${defaultValue}]` : "";
@@ -494,6 +844,15 @@ function createReadlinePrompter() {
494
844
  return answer || defaultValue || "";
495
845
  },
496
846
  async confirm(message, defaultValue = false) {
847
+ if (canUseRawKeys()) {
848
+ rl.pause();
849
+ try {
850
+ return await promptConfirm(message, defaultValue, io);
851
+ } finally {
852
+ input.setRawMode?.(false);
853
+ rl.resume();
854
+ }
855
+ }
497
856
  const hint = defaultValue ? "Y/n" : "y/N";
498
857
  const answer = (await rl.question(`${message} (${hint}): `)).trim().toLowerCase();
499
858
  if (!answer) {
@@ -502,6 +861,15 @@ function createReadlinePrompter() {
502
861
  return answer === "y" || answer === "yes";
503
862
  },
504
863
  async select(message, choices, defaultValue) {
864
+ if (canUseRawKeys()) {
865
+ rl.pause();
866
+ try {
867
+ return await promptSelect(message, choices, defaultValue, io);
868
+ } finally {
869
+ input.setRawMode?.(false);
870
+ rl.resume();
871
+ }
872
+ }
505
873
  console.log(message);
506
874
  for (const [index, choice] of choices.entries()) {
507
875
  const marker = choice.value === defaultValue ? "*" : " ";
@@ -520,11 +888,66 @@ function createReadlinePrompter() {
520
888
  const match = choices.find((choice) => choice.value === answer || choice.label === answer);
521
889
  return match?.value ?? defaultValue;
522
890
  },
891
+ async multiSelect(message, choices) {
892
+ if (canUseRawKeys()) {
893
+ rl.pause();
894
+ try {
895
+ return await promptMultiSelect(message, choices, io);
896
+ } finally {
897
+ input.setRawMode?.(false);
898
+ rl.resume();
899
+ }
900
+ }
901
+ const enabled = new Set(choices.filter((choice) => choice.enabled).map((choice) => choice.value));
902
+ console.log(`${message} (yes/no each)`);
903
+ for (const choice of choices) {
904
+ const hint = enabled.has(choice.value) ? "Y/n" : "y/N";
905
+ const answer = (await rl.question(` ${choice.label} (${hint}): `)).trim().toLowerCase();
906
+ if (!answer) {
907
+ continue;
908
+ }
909
+ if (answer === "y" || answer === "yes") {
910
+ enabled.add(choice.value);
911
+ } else if (answer === "n" || answer === "no") {
912
+ enabled.delete(choice.value);
913
+ }
914
+ }
915
+ return [...enabled];
916
+ },
523
917
  close() {
524
918
  rl.close();
525
919
  }
526
920
  };
527
921
  }
922
+ function extrasStillToAsk(layers, flags) {
923
+ return EXTRA_CHOICES.filter((choice) => {
924
+ if (flags.extras[choice.value] !== undefined) {
925
+ return false;
926
+ }
927
+ return extraApplies(choice.value, layers.auth);
928
+ });
929
+ }
930
+ async function promptExtras(prompter, extras, layers, flags) {
931
+ const choices = extrasStillToAsk(layers, flags);
932
+ if (choices.length === 0) {
933
+ return extras;
934
+ }
935
+ const picked = new Set(await prompter.multiSelect("Extras", choices.map((choice) => ({
936
+ value: choice.value,
937
+ label: choice.label,
938
+ enabled: extras[choice.value]
939
+ }))));
940
+ const next = { ...extras };
941
+ for (const choice of choices) {
942
+ next[choice.value] = picked.has(choice.value);
943
+ }
944
+ for (const choice of EXTRA_CHOICES) {
945
+ if (!extraApplies(choice.value, layers.auth) && flags.extras[choice.value] === undefined) {
946
+ next[choice.value] = false;
947
+ }
948
+ }
949
+ return next;
950
+ }
528
951
  async function promptLayers(flags, prompter) {
529
952
  const layers = applyFlagOverrides(defaultLayers(), flags);
530
953
  layers.frontend = await prompter.select("Frontend", [
@@ -572,16 +995,10 @@ async function promptLayers(flags, prompter) {
572
995
  { value: "log", label: "log: print messages" },
573
996
  { value: "smtp", label: "smtp" }
574
997
  ], layers.mail);
575
- if (layers.frontend === "spa-react" || layers.frontend === "hybrid") {
998
+ if (flags.spaPrefix === undefined && (layers.frontend === "spa-react" || layers.frontend === "hybrid")) {
576
999
  layers.spaPrefix = await prompter.question("SPA prefix", layers.spaPrefix);
577
1000
  }
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
- }
1001
+ layers.extras = await promptExtras(prompter, layers.extras, layers, flags);
585
1002
  if (!dockerFlagsProvided(flags)) {
586
1003
  layers.docker = await promptDockerLayer(prompter, layers);
587
1004
  }
@@ -2102,6 +2519,8 @@ Cookie name is \`strata_session\`. Forms send CSRF as \`_token\`.
2102
2519
  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
2520
  ` : ""}${authUsesJwt(layers.auth) ? `
2104
2521
  JWT mint: \`POST /api/auth/token\` with email and password. Short-lived. Not a portal session.
2522
+ ` : ""}${layers.extras.metrics ? `
2523
+ Prometheus scrape: \`GET /metrics\`. Production requires \`Authorization: Bearer <METRICS_TOKEN>\`.
2105
2524
  ` : ""}
2106
2525
  ## Production
2107
2526
 
@@ -2781,6 +3200,10 @@ export { starterProviders };
2781
3200
  function renderCreateAppTs(layers) {
2782
3201
  const ensureLine = needsEnsure(layers) ? `import { ensureAppDatabase } from "./ensureDatabase.ts";
2783
3202
  ` : "";
3203
+ const metricsImport = layers.extras.metrics ? `import { createMetricsRoutes } from "@getstrata/bootstrap/metricsRoutes";
3204
+ ` : "";
3205
+ const metricsSpread = layers.extras.metrics ? `
3206
+ ...createMetricsRoutes(),` : "";
2784
3207
  return `import { join } from "node:path";
2785
3208
  import "./preload.ts";
2786
3209
  import { runProviderPhase } from "@getstrata/bootstrap/context";
@@ -2800,8 +3223,7 @@ import {
2800
3223
  ensureModulesLoaded,
2801
3224
  } from "@getstrata/bootstrap/discoverModules";
2802
3225
  import { createHealthRoutes } from "@getstrata/bootstrap/health";
2803
- import { createMetricsRoutes } from "@getstrata/bootstrap/metricsRoutes";
2804
- import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
3226
+ ${metricsImport}import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
2805
3227
  import { createWebServer } from "@getstrata/bootstrap/web/server";
2806
3228
  import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
2807
3229
  import { migrate } from "../db/migrate.ts";
@@ -2881,8 +3303,7 @@ ${needsEnsure(layers) ? ` await ensureAppDatabase();
2881
3303
 
2882
3304
  const routes = mergeSpaRoutes(context.dependencies, {
2883
3305
  ...createHealthRoutes(context.dependencies),
2884
- ...buildRoutes(context.dependencies),
2885
- ...createMetricsRoutes(),
3306
+ ...buildRoutes(context.dependencies),${metricsSpread}
2886
3307
  }, {
2887
3308
  distDirectory: join(import.meta.dir, "../../frontend/dist"),
2888
3309
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/starter",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Interactive create-strata wizard. Choose each layer; one database engine.",
5
5
  "type": "module",
6
6
  "license": "MIT",