create-better-t-stack 3.36.4 → 3.37.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.
@@ -5,7 +5,7 @@ import { initTRPC } from "@trpc/server";
5
5
  import { Result, Result as Result$1, TaggedError } from "better-result";
6
6
  import { createCli } from "trpc-cli";
7
7
  import z from "zod";
8
- import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
8
+ import { box, cancel, confirm, intro, isCancel, limitOptions, log, outro, select, spinner, text } from "@clack/prompts";
9
9
  import pc from "picocolors";
10
10
  import path from "node:path";
11
11
  import envPaths from "env-paths";
@@ -22,6 +22,137 @@ import { ConfirmPrompt, GroupMultiSelectPrompt, MultiSelectPrompt, SelectPrompt,
22
22
  import { applyEdits, modify, parse } from "jsonc-parser";
23
23
  import os from "node:os";
24
24
  import { format } from "oxfmt";
25
+ //#region src/utils/display-config.ts
26
+ const VALUE_LABELS = {
27
+ none: "None",
28
+ "tanstack-router": "TanStack Router",
29
+ "react-router": "React Router",
30
+ "tanstack-start": "TanStack Start",
31
+ next: "Next.js",
32
+ nuxt: "Nuxt",
33
+ svelte: "SvelteKit",
34
+ solid: "SolidStart",
35
+ astro: "Astro",
36
+ "native-bare": "Expo (bare)",
37
+ "native-uniwind": "Expo + Uniwind",
38
+ "native-unistyles": "Expo + Unistyles",
39
+ hono: "Hono",
40
+ express: "Express",
41
+ fastify: "Fastify",
42
+ elysia: "Elysia",
43
+ convex: "Convex",
44
+ self: "Fullstack framework",
45
+ bun: "Bun",
46
+ node: "Node.js",
47
+ workers: "Cloudflare Workers",
48
+ trpc: "tRPC",
49
+ orpc: "oRPC",
50
+ sqlite: "SQLite",
51
+ postgres: "PostgreSQL",
52
+ mysql: "MySQL",
53
+ mongodb: "MongoDB",
54
+ drizzle: "Drizzle",
55
+ prisma: "Prisma",
56
+ mongoose: "Mongoose",
57
+ "better-auth": "Better Auth",
58
+ clerk: "Clerk",
59
+ polar: "Polar",
60
+ pwa: "PWA",
61
+ tauri: "Tauri",
62
+ electrobun: "Electrobun",
63
+ biome: "Biome",
64
+ oxlint: "Oxlint + Oxfmt",
65
+ ultracite: "Ultracite",
66
+ lefthook: "Lefthook",
67
+ husky: "Husky",
68
+ turborepo: "Turborepo",
69
+ nx: "Nx",
70
+ "vite-plus": "Vite+",
71
+ starlight: "Starlight",
72
+ fumadocs: "Fumadocs",
73
+ opentui: "OpenTUI",
74
+ wxt: "WXT",
75
+ skills: "Agent skills",
76
+ mcp: "MCP servers",
77
+ evlog: "evlog",
78
+ todo: "Todo app",
79
+ ai: "AI chat",
80
+ turso: "Turso",
81
+ neon: "Neon",
82
+ planetscale: "PlanetScale",
83
+ supabase: "Supabase",
84
+ "prisma-postgres": "Prisma Postgres",
85
+ "mongodb-atlas": "MongoDB Atlas",
86
+ d1: "Cloudflare D1",
87
+ docker: "Docker",
88
+ cloudflare: "Cloudflare",
89
+ vercel: "Vercel",
90
+ npm: "npm",
91
+ pnpm: "pnpm"
92
+ };
93
+ function formatConfigValue(value) {
94
+ if (typeof value === "boolean") return value ? "Yes" : "No";
95
+ if (Array.isArray(value)) return value.length > 0 ? value.map(formatConfigValue).join(", ") : "None";
96
+ const text = String(value);
97
+ return VALUE_LABELS[text] ?? text.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
98
+ }
99
+ function section(title, entries) {
100
+ const rows = entries.filter(([, value]) => value !== void 0).map(([label, value, format]) => ({
101
+ label,
102
+ value: format === "raw" ? String(value) : formatConfigValue(value)
103
+ }));
104
+ return rows.length > 0 ? {
105
+ title,
106
+ rows
107
+ } : void 0;
108
+ }
109
+ function getConfigSections(config) {
110
+ return [
111
+ section("Project", [[
112
+ "Name",
113
+ config.projectName,
114
+ "raw"
115
+ ], [
116
+ "Directory",
117
+ config.relativePath,
118
+ "raw"
119
+ ]]),
120
+ section("Application", [
121
+ ["Frontend", config.frontend],
122
+ ["Backend", config.backend],
123
+ ["Runtime", config.runtime],
124
+ ["API", config.api]
125
+ ]),
126
+ section("Data", [
127
+ ["Database", config.database],
128
+ ["ORM", config.orm],
129
+ ["Setup", config.dbSetup]
130
+ ]),
131
+ section("Product", [
132
+ ["Auth", config.auth],
133
+ ["Payments", config.payments],
134
+ ["Addons", config.addons],
135
+ ["Examples", config.examples]
136
+ ]),
137
+ section("Delivery", [
138
+ ["Web deploy", config.webDeploy],
139
+ ["Server deploy", config.serverDeploy],
140
+ ["Package manager", config.packageManager],
141
+ ["Git", config.git],
142
+ ["Install deps", config.install]
143
+ ])
144
+ ].filter((value) => value !== void 0);
145
+ }
146
+ function displayConfig(config) {
147
+ const sections = getConfigSections(config);
148
+ if (sections.length === 0) return pc.yellow("No configuration selected.");
149
+ return sections.map(({ title, rows }) => {
150
+ const labelWidth = Math.max(...rows.map(({ label }) => label.length));
151
+ const renderedRows = rows.map(({ label, value }) => ` ${pc.dim(label.padEnd(labelWidth))} ${value}`).join("\n");
152
+ return `${pc.magenta(pc.bold(title))}\n${renderedRows}`;
153
+ }).join("\n\n");
154
+ }
155
+ //#endregion
25
156
  //#region src/utils/get-package-manager.ts
26
157
  const getUserPkgManager = () => {
27
158
  const userAgent = process.env.npm_config_user_agent;
@@ -120,6 +251,9 @@ function isFirstPrompt() {
120
251
  function didLastPromptShowUI() {
121
252
  return getContext().navigation.lastPromptShownUI;
122
253
  }
254
+ function getPromptProgress() {
255
+ return getContext().navigation.promptProgress;
256
+ }
123
257
  function setIsFirstPrompt$1(value) {
124
258
  const ctx = tryGetContext();
125
259
  if (ctx) ctx.navigation.isFirstPrompt = value;
@@ -128,6 +262,10 @@ function setLastPromptShownUI(value) {
128
262
  const ctx = tryGetContext();
129
263
  if (ctx) ctx.navigation.lastPromptShownUI = value;
130
264
  }
265
+ function setPromptProgress(value) {
266
+ const ctx = tryGetContext();
267
+ if (ctx) ctx.navigation.promptProgress = value;
268
+ }
131
269
  async function runWithContextAsync(options, fn) {
132
270
  const ctx = {
133
271
  navigation: {
@@ -451,10 +589,10 @@ const renderTitle = () => {
451
589
  //#region src/commands/history.ts
452
590
  function formatStackSummary(entry) {
453
591
  const parts = [];
454
- if (entry.stack.frontend.length > 0 && !entry.stack.frontend.includes("none")) parts.push(entry.stack.frontend.join(", "));
455
- if (entry.stack.backend && entry.stack.backend !== "none") parts.push(entry.stack.backend);
456
- if (entry.stack.database && entry.stack.database !== "none") parts.push(entry.stack.database);
457
- if (entry.stack.orm && entry.stack.orm !== "none") parts.push(entry.stack.orm);
592
+ if (entry.stack.frontend.length > 0 && !entry.stack.frontend.includes("none")) parts.push(formatConfigValue(entry.stack.frontend));
593
+ if (entry.stack.backend && entry.stack.backend !== "none") parts.push(formatConfigValue(entry.stack.backend));
594
+ if (entry.stack.database && entry.stack.database !== "none") parts.push(formatConfigValue(entry.stack.database));
595
+ if (entry.stack.orm && entry.stack.orm !== "none") parts.push(formatConfigValue(entry.stack.orm));
458
596
  return parts.length > 0 ? parts.join(" + ") : "minimal";
459
597
  }
460
598
  function formatDate(isoString) {
@@ -466,6 +604,25 @@ function formatDate(isoString) {
466
604
  minute: "2-digit"
467
605
  });
468
606
  }
607
+ function formatHistoryEntry(entry, index) {
608
+ const rows = [
609
+ {
610
+ label: "Created",
611
+ value: formatDate(entry.createdAt)
612
+ },
613
+ {
614
+ label: "Location",
615
+ value: entry.projectDir
616
+ },
617
+ {
618
+ label: "Stack",
619
+ value: formatStackSummary(entry)
620
+ }
621
+ ];
622
+ const labelWidth = Math.max(...rows.map(({ label }) => label.length));
623
+ const details = rows.map(({ label, value }) => `${pc.dim(label.padEnd(labelWidth))} ${value}`).join("\n");
624
+ return `${pc.cyan(pc.bold(`${index + 1}. ${entry.projectName}`))}\n${details}\n${pc.dim("Recreate")}\n${pc.cyan(entry.reproducibleCommand)}`;
625
+ }
469
626
  async function historyHandler(input) {
470
627
  if (input.clear) {
471
628
  const clearResult = await clearHistory();
@@ -482,28 +639,18 @@ async function historyHandler(input) {
482
639
  return;
483
640
  }
484
641
  const entries = historyResult.value;
485
- if (entries.length === 0) {
486
- log.info(pc.dim("No projects in history yet."));
487
- log.info(pc.dim("Create a project with: create-better-t-stack my-app"));
488
- return;
489
- }
490
642
  if (input.json) {
491
643
  console.log(JSON.stringify(entries, null, 2));
492
644
  return;
493
645
  }
494
646
  renderTitle();
495
- intro(pc.magenta(`Project History (${entries.length} entries)`));
496
- for (const [index, entry] of entries.entries()) {
497
- const num = pc.dim(`${index + 1}.`);
498
- const name = pc.cyan(pc.bold(entry.projectName));
499
- const stack = pc.dim(formatStackSummary(entry));
500
- log.message(`${num} ${name}`);
501
- log.message(` ${pc.dim("Created:")} ${formatDate(entry.createdAt)}`);
502
- log.message(` ${pc.dim("Path:")} ${entry.projectDir}`);
503
- log.message(` ${pc.dim("Stack:")} ${stack}`);
504
- log.message(` ${pc.dim("Command:")} ${pc.dim(entry.reproducibleCommand)}`);
505
- log.message("");
647
+ intro(pc.magenta(`Project history · ${entries.length}`));
648
+ if (entries.length === 0) {
649
+ outro(`${pc.dim("No saved projects yet · create one with")} ${pc.cyan("create-better-t-stack my-app")}`);
650
+ return;
506
651
  }
652
+ log.message(entries.map(formatHistoryEntry).join("\n\n"));
653
+ outro(pc.dim("Run a command above to recreate that project"));
507
654
  }
508
655
  //#endregion
509
656
  //#region src/utils/open-url.ts
@@ -575,27 +722,32 @@ async function fetchSponsorsQuietly({ url = SPONSORS_JSON_URL, timeoutMs = 1500
575
722
  function displaySponsors(sponsors) {
576
723
  const { total_sponsors } = sponsors.summary;
577
724
  if (total_sponsors === 0) {
578
- log.info("No sponsors found. You can be the first one! ✨");
579
- outro(pc.cyan(`Visit ${GITHUB_SPONSOR_URL} to become a sponsor.`));
725
+ log.info("No sponsors found yet");
726
+ outro(`${pc.dim("Become the first sponsor ·")} ${pc.cyan(GITHUB_SPONSOR_URL)}`);
580
727
  return;
581
728
  }
582
729
  displaySponsorsBox(sponsors);
583
- if (total_sponsors - sponsors.specialSponsors.length > 0) log.message(pc.blue(`+${total_sponsors - sponsors.specialSponsors.length} more amazing sponsors.\n`));
584
- outro(pc.magenta(`Visit ${GITHUB_SPONSOR_URL} to become a sponsor.`));
730
+ if (total_sponsors - sponsors.specialSponsors.length > 0) log.message(pc.dim(`+${total_sponsors - sponsors.specialSponsors.length} more sponsors`));
731
+ outro(`${pc.dim("Become a sponsor ·")} ${pc.cyan(GITHUB_SPONSOR_URL)}`);
585
732
  }
586
733
  function displaySponsorsBox(sponsors) {
587
734
  if (sponsors.specialSponsors.length === 0) return;
588
- let output = `${pc.bold(pc.cyan("-> Special Sponsors"))}\n\n`;
589
- sponsors.specialSponsors.forEach((sponsor, idx) => {
590
- const displayName = sponsor.name ?? sponsor.githubId;
591
- const tier = sponsor.tierName ? ` ${pc.yellow(`(${sponsor.tierName})`)}` : "";
592
- output += `${pc.green(`• ${displayName}`)}${tier}\n`;
593
- output += ` ${pc.dim("GitHub:")} https://github.com/${sponsor.githubId}\n`;
594
- const website = sponsor.websiteUrl ?? sponsor.githubUrl;
595
- if (website) output += ` ${pc.dim("Website:")} ${website}\n`;
596
- if (idx < sponsors.specialSponsors.length - 1) output += "\n";
735
+ box(formatSpecialSponsorsDetails(sponsors), pc.bold("Special sponsors"), {
736
+ contentPadding: 2,
737
+ formatBorder: pc.dim,
738
+ rounded: true,
739
+ width: "auto"
597
740
  });
598
- cliConsola.box(output);
741
+ }
742
+ function formatSpecialSponsorsDetails(sponsors) {
743
+ return sponsors.specialSponsors.map((sponsor) => {
744
+ const displayName = sponsor.name ?? sponsor.githubId;
745
+ const tier = sponsor.tierName ? pc.dim(` · ${sponsor.tierName}`) : "";
746
+ const links = [];
747
+ if (sponsor.websiteUrl) links.push(`${pc.dim("Website")} ${pc.cyan(sponsor.websiteUrl)}`);
748
+ links.push(`${pc.dim("GitHub ")} ${pc.cyan(sponsor.githubUrl)}`);
749
+ return `${pc.bold(displayName)}${tier}\n${links.join("\n")}`;
750
+ }).join("\n\n");
599
751
  }
600
752
  function formatPostInstallSpecialSponsorsSection(sponsors) {
601
753
  if (sponsors.specialSponsors.length === 0) return "";
@@ -658,7 +810,7 @@ async function fetchSponsorsData({ url = SPONSORS_JSON_URL, withSpinner = false,
658
810
  cause: parseResult.error
659
811
  }));
660
812
  }
661
- if (s) s.stop("Sponsors fetched successfully!");
813
+ if (s) s.stop("Sponsors loaded");
662
814
  return Result.ok(parseResult.data);
663
815
  } catch (error) {
664
816
  const normalizedError = normalizeSponsorFetchError(error);
@@ -696,7 +848,7 @@ async function openExternalUrl(url, successMessage) {
696
848
  }
697
849
  async function showSponsorsCommand() {
698
850
  renderTitle();
699
- intro(pc.magenta("Better-T-Stack Sponsors"));
851
+ intro(pc.magenta("Sponsors"));
700
852
  const sponsorsResult = await fetchSponsors();
701
853
  if (sponsorsResult.isErr()) {
702
854
  displayError(sponsorsResult.error);
@@ -984,15 +1136,12 @@ function isGoBack(value) {
984
1136
  }
985
1137
  //#endregion
986
1138
  //#region src/prompts/navigable.ts
987
- /**
988
- * Navigable prompt wrappers using @clack/core
989
- * These prompts return GO_BACK_SYMBOL when 'b' is pressed (instead of canceling)
990
- */
991
1139
  const unicode = process.platform !== "win32";
992
1140
  const S_STEP_ACTIVE = unicode ? "◆" : "*";
993
1141
  const S_STEP_CANCEL = unicode ? "■" : "x";
994
1142
  const S_STEP_ERROR = unicode ? "▲" : "x";
995
1143
  const S_STEP_SUBMIT = unicode ? "◇" : "o";
1144
+ const S_STEP_BACK = unicode ? "↶" : "<";
996
1145
  const S_BAR = unicode ? "│" : "|";
997
1146
  const S_BAR_END = unicode ? "└" : "—";
998
1147
  const S_RADIO_ACTIVE = unicode ? "●" : ">";
@@ -1000,6 +1149,10 @@ const S_RADIO_INACTIVE = unicode ? "○" : " ";
1000
1149
  const S_CHECKBOX_ACTIVE = unicode ? "◻" : "[•]";
1001
1150
  const S_CHECKBOX_SELECTED = unicode ? "◼" : "[+]";
1002
1151
  const S_CHECKBOX_INACTIVE = unicode ? "◻" : "[ ]";
1152
+ const promptsNavigatingBack = /* @__PURE__ */ new WeakSet();
1153
+ function keycap(label) {
1154
+ return pc.inverse(` ${label} `);
1155
+ }
1003
1156
  function symbol(state) {
1004
1157
  switch (state) {
1005
1158
  case "initial":
@@ -1009,10 +1162,10 @@ function symbol(state) {
1009
1162
  case "submit": return pc.green(S_STEP_SUBMIT);
1010
1163
  }
1011
1164
  }
1012
- const KEYBOARD_HINT = pc.dim(`${pc.gray("↑/↓")} navigate • ${pc.gray("enter")} confirm • ${pc.gray("b")} back${pc.gray("ctrl+c")} cancel`);
1013
- const KEYBOARD_HINT_FIRST = pc.dim(`${pc.gray("↑/↓")} navigate • ${pc.gray("enter")} confirm • ${pc.gray("ctrl+c")} cancel`);
1014
- const KEYBOARD_HINT_MULTI = pc.dim(`${pc.gray("↑/↓")} navigate • ${pc.gray("space")} select • ${pc.gray("enter")} confirm • ${pc.gray("b")} back${pc.gray("ctrl+c")} cancel`);
1015
- const KEYBOARD_HINT_MULTI_FIRST = pc.dim(`${pc.gray("↑/↓")} navigate • ${pc.gray("space")} select • ${pc.gray("enter")} confirm • ${pc.gray("ctrl+c")} cancel`);
1165
+ const KEYBOARD_HINT = pc.dim(`${keycap("↑↓")} move ${keycap("enter")} choose ${keycap("b")} back ${keycap("^c")} cancel`);
1166
+ const KEYBOARD_HINT_FIRST = pc.dim(`${keycap("↑↓")} move ${keycap("enter")} choose ${keycap("^c")} cancel`);
1167
+ const KEYBOARD_HINT_MULTI = pc.dim(`${keycap("↑↓")} move ${keycap("space")} toggle ${keycap("enter")} choose ${keycap("b")} back ${keycap("^c")} cancel`);
1168
+ const KEYBOARD_HINT_MULTI_FIRST = pc.dim(`${keycap("↑↓")} move ${keycap("space")} toggle ${keycap("enter")} choose ${keycap("^c")} cancel`);
1016
1169
  const setIsFirstPrompt = setIsFirstPrompt$1;
1017
1170
  function getHint() {
1018
1171
  return isFirstPrompt() ? KEYBOARD_HINT_FIRST : KEYBOARD_HINT;
@@ -1020,6 +1173,19 @@ function getHint() {
1020
1173
  function getMultiHint() {
1021
1174
  return isFirstPrompt() ? KEYBOARD_HINT_MULTI_FIRST : KEYBOARD_HINT_MULTI;
1022
1175
  }
1176
+ function activePromptTitle(message, state = "active") {
1177
+ const progress = getPromptProgress();
1178
+ const eyebrow = progress ? `${pc.magenta(pc.bold(progress.section.toUpperCase()))} ${pc.dim(`· ${progress.current}/${progress.total}`)}` : pc.dim("SETUP");
1179
+ return `${pc.gray(S_BAR)} ${eyebrow}\n${symbol(state)} ${pc.bold(message)}\n`;
1180
+ }
1181
+ function resolvedPrompt(message, value, state) {
1182
+ const promptMessage = state === "cancel" ? pc.strikethrough(pc.dim(message)) : pc.dim(message);
1183
+ return `${symbol(state)} ${promptMessage} ${pc.dim("›")} ${value}`;
1184
+ }
1185
+ function canceledPrompt(prompt, message, value) {
1186
+ if (promptsNavigatingBack.has(prompt)) return `${pc.cyan(S_STEP_BACK)} ${pc.dim(message)}`;
1187
+ return resolvedPrompt(message, value, "cancel");
1188
+ }
1023
1189
  function normalizeValidationMessage(validationMessage) {
1024
1190
  return validationMessage instanceof Error ? validationMessage.message : validationMessage;
1025
1191
  }
@@ -1028,20 +1194,26 @@ async function runWithNavigation(prompt) {
1028
1194
  prompt.on("key", (char) => {
1029
1195
  if ((char === "b" || char === "B") && !isFirstPrompt()) {
1030
1196
  goBack = true;
1197
+ promptsNavigatingBack.add(prompt);
1031
1198
  prompt.state = "cancel";
1032
1199
  }
1033
1200
  });
1034
1201
  setLastPromptShownUI(true);
1035
- const result = await prompt.prompt();
1036
- return goBack ? GO_BACK_SYMBOL : result;
1202
+ try {
1203
+ const result = await prompt.prompt();
1204
+ return goBack ? GO_BACK_SYMBOL : result;
1205
+ } finally {
1206
+ promptsNavigatingBack.delete(prompt);
1207
+ }
1037
1208
  }
1038
1209
  async function navigableSelect(opts) {
1039
1210
  const opt = (option, state) => {
1211
+ if (!option) return pc.dim("none");
1040
1212
  const label = option.label ?? String(option.value);
1041
1213
  switch (state) {
1042
1214
  case "disabled": return `${pc.gray(S_RADIO_INACTIVE)} ${pc.gray(label)}${option.hint ? ` ${pc.dim(`(${option.hint ?? "disabled"})`)}` : ""}`;
1043
1215
  case "selected": return `${pc.dim(label)}`;
1044
- case "active": return `${pc.green(S_RADIO_ACTIVE)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`;
1216
+ case "active": return `${pc.cyan(S_RADIO_ACTIVE)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`;
1045
1217
  case "cancelled": return `${pc.strikethrough(pc.dim(label))}`;
1046
1218
  default: return `${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(label)}`;
1047
1219
  }
@@ -1049,15 +1221,25 @@ async function navigableSelect(opts) {
1049
1221
  return runWithNavigation(new SelectPrompt({
1050
1222
  options: opts.options,
1051
1223
  initialValue: opts.initialValue,
1224
+ signal: opts.signal,
1225
+ input: opts.input,
1226
+ output: opts.output,
1052
1227
  render() {
1053
- const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
1054
1228
  switch (this.state) {
1055
- case "submit": return `${title}${pc.gray(S_BAR)} ${opt(this.options[this.cursor], "selected")}`;
1056
- case "cancel": return `${title}${pc.gray(S_BAR)} ${opt(this.options[this.cursor], "cancelled")}\n${pc.gray(S_BAR)}`;
1229
+ case "submit": return resolvedPrompt(opts.message, opt(this.options[this.cursor], "selected"), "submit");
1230
+ case "cancel": return canceledPrompt(this, opts.message, opt(this.options[this.cursor], "cancelled"));
1057
1231
  default: {
1058
- const optionsText = this.options.map((option, i) => opt(option, option.disabled ? "disabled" : i === this.cursor ? "active" : "inactive")).join(`\n${pc.cyan(S_BAR)} `);
1059
- const hint = `\n${pc.gray(S_BAR)} ${getHint()}`;
1060
- return `${title}${pc.cyan(S_BAR)} ${optionsText}\n${pc.cyan(S_BAR_END)}${hint}\n`;
1232
+ const optionsText = limitOptions({
1233
+ output: opts.output,
1234
+ options: this.options,
1235
+ cursor: this.cursor,
1236
+ maxItems: opts.maxItems,
1237
+ columnPadding: 3,
1238
+ rowPadding: 5,
1239
+ style: (option, active) => opt(option, option.disabled ? "disabled" : active ? "active" : "inactive")
1240
+ }).join(`\n${pc.cyan(S_BAR)} `);
1241
+ const hint = `${pc.gray(S_BAR_END)} ${getHint()}`;
1242
+ return `${activePromptTitle(opts.message)}${pc.cyan(S_BAR)} ${optionsText}\n${hint}\n`;
1061
1243
  }
1062
1244
  }
1063
1245
  }
@@ -1078,13 +1260,16 @@ async function navigableMultiselect(opts) {
1078
1260
  return runWithNavigation(new MultiSelectPrompt({
1079
1261
  options: opts.options,
1080
1262
  initialValues: opts.initialValues,
1263
+ cursorAt: opts.cursorAt,
1081
1264
  required,
1265
+ signal: opts.signal,
1266
+ input: opts.input,
1267
+ output: opts.output,
1082
1268
  validate(selected) {
1083
1269
  if (required && (selected === void 0 || selected.length === 0)) return `Please select at least one option.\n${pc.reset(pc.dim(`Press ${pc.gray(pc.bgWhite(pc.inverse(" space ")))} to select, ${pc.gray(pc.bgWhite(pc.inverse(" enter ")))} to submit`))}`;
1084
1270
  return normalizeValidationMessage(opts.validate?.(selected));
1085
1271
  },
1086
1272
  render() {
1087
- const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
1088
1273
  const value = this.value ?? [];
1089
1274
  const styleOption = (option, active) => {
1090
1275
  if (option.disabled) return opt(option, "disabled");
@@ -1096,21 +1281,37 @@ async function navigableMultiselect(opts) {
1096
1281
  switch (this.state) {
1097
1282
  case "submit": {
1098
1283
  const submitText = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "submitted")).join(pc.dim(", ")) || pc.dim("none");
1099
- return `${title}${pc.gray(S_BAR)} ${submitText}`;
1284
+ return resolvedPrompt(opts.message, submitText, "submit");
1100
1285
  }
1101
1286
  case "cancel": {
1102
- const label = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "cancelled")).join(pc.dim(", "));
1103
- return `${title}${pc.gray(S_BAR)} ${label}\n${pc.gray(S_BAR)}`;
1287
+ const label = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "cancelled")).join(pc.dim(", ")) || pc.dim("none");
1288
+ return canceledPrompt(this, opts.message, label);
1104
1289
  }
1105
1290
  case "error": {
1106
1291
  const footer = this.error.split("\n").map((ln, i) => i === 0 ? `${pc.yellow(S_BAR_END)} ${pc.yellow(ln)}` : ` ${ln}`).join("\n");
1107
- const optionsText = this.options.map((option, i) => styleOption(option, i === this.cursor)).join(`\n${pc.yellow(S_BAR)} `);
1108
- return `${title}${pc.yellow(S_BAR)} ${optionsText}\n${footer}\n`;
1292
+ const optionsText = limitOptions({
1293
+ output: opts.output,
1294
+ options: this.options,
1295
+ cursor: this.cursor,
1296
+ maxItems: opts.maxItems,
1297
+ columnPadding: 3,
1298
+ rowPadding: footer.split("\n").length + 4,
1299
+ style: styleOption
1300
+ }).join(`\n${pc.yellow(S_BAR)} `);
1301
+ return `${activePromptTitle(opts.message, "error")}${pc.yellow(S_BAR)} ${optionsText}\n${footer}\n`;
1109
1302
  }
1110
1303
  default: {
1111
- const optionsText = this.options.map((option, i) => styleOption(option, i === this.cursor)).join(`\n${pc.cyan(S_BAR)} `);
1112
- const hint = `\n${pc.gray(S_BAR)} ${getMultiHint()}`;
1113
- return `${title}${pc.cyan(S_BAR)} ${optionsText}\n${pc.cyan(S_BAR_END)}${hint}\n`;
1304
+ const optionsText = limitOptions({
1305
+ output: opts.output,
1306
+ options: this.options,
1307
+ cursor: this.cursor,
1308
+ maxItems: opts.maxItems,
1309
+ columnPadding: 3,
1310
+ rowPadding: 5,
1311
+ style: styleOption
1312
+ }).join(`\n${pc.cyan(S_BAR)} `);
1313
+ const hint = `${pc.gray(S_BAR_END)} ${getMultiHint()}`;
1314
+ return `${activePromptTitle(opts.message)}${pc.cyan(S_BAR)} ${optionsText}\n${hint}\n`;
1114
1315
  }
1115
1316
  }
1116
1317
  }
@@ -1123,15 +1324,17 @@ async function navigableConfirm(opts) {
1123
1324
  active,
1124
1325
  inactive,
1125
1326
  initialValue: opts.initialValue ?? true,
1327
+ signal: opts.signal,
1328
+ input: opts.input,
1329
+ output: opts.output,
1126
1330
  render() {
1127
- const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
1128
1331
  const value = this.value ? active : inactive;
1129
1332
  switch (this.state) {
1130
- case "submit": return `${title}${pc.gray(S_BAR)} ${pc.dim(value)}`;
1131
- case "cancel": return `${title}${pc.gray(S_BAR)} ${pc.strikethrough(pc.dim(value))}\n${pc.gray(S_BAR)}`;
1333
+ case "submit": return resolvedPrompt(opts.message, pc.dim(value), "submit");
1334
+ case "cancel": return canceledPrompt(this, opts.message, pc.strikethrough(pc.dim(value)));
1132
1335
  default: {
1133
- const hint = `\n${pc.gray(S_BAR)} ${getHint()}`;
1134
- return `${title}${pc.cyan(S_BAR)} ${this.value ? `${pc.green(S_RADIO_ACTIVE)} ${active}` : `${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(active)}`} ${pc.dim("/")} ${!this.value ? `${pc.green(S_RADIO_ACTIVE)} ${inactive}` : `${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(inactive)}`}\n${pc.cyan(S_BAR_END)}${hint}\n`;
1336
+ const hint = `${pc.gray(S_BAR_END)} ${getHint()}`;
1337
+ return `${activePromptTitle(opts.message)}${pc.cyan(S_BAR)} ${this.value ? `${pc.cyan(S_RADIO_ACTIVE)} ${active}` : `${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(active)}`} ${pc.dim("/")} ${!this.value ? `${pc.cyan(S_RADIO_ACTIVE)} ${inactive}` : `${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(inactive)}`}\n${hint}\n`;
1135
1338
  }
1136
1339
  }
1137
1340
  }
@@ -1161,52 +1364,59 @@ async function navigableGroupMultiselect(opts) {
1161
1364
  return runWithNavigation(new GroupMultiSelectPrompt({
1162
1365
  options: opts.options,
1163
1366
  initialValues: opts.initialValues,
1367
+ cursorAt: opts.cursorAt,
1164
1368
  required,
1165
1369
  selectableGroups: true,
1370
+ signal: opts.signal,
1371
+ input: opts.input,
1372
+ output: opts.output,
1166
1373
  validate(selected) {
1167
1374
  if (required && (selected === void 0 || selected.length === 0)) return `Please select at least one option.\n${pc.reset(pc.dim(`Press ${pc.gray(pc.bgWhite(pc.inverse(" space ")))} to select, ${pc.gray(pc.bgWhite(pc.inverse(" enter ")))} to submit`))}`;
1168
1375
  return normalizeValidationMessage(opts.validate?.(selected));
1169
1376
  },
1170
1377
  render() {
1171
- const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
1172
1378
  const value = this.value ?? [];
1379
+ const styleOption = (option, active) => {
1380
+ const selected = value.includes(option.value) || option.group === true && this.isGroupSelected(`${option.value}`);
1381
+ if (!active && typeof option.group === "string" && this.options[this.cursor]?.value === option.group) return opt(option, selected ? "group-active-selected" : "group-active", this.options);
1382
+ if (active && selected) return opt(option, "active-selected", this.options);
1383
+ if (selected) return opt(option, "selected", this.options);
1384
+ return opt(option, active ? "active" : "inactive", this.options);
1385
+ };
1173
1386
  switch (this.state) {
1174
1387
  case "submit": {
1175
- const selectedOptions = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "submitted"));
1176
- const optionsText = selectedOptions.length === 0 ? "" : ` ${selectedOptions.join(pc.dim(", "))}`;
1177
- return `${title}${pc.gray(S_BAR)}${optionsText}`;
1388
+ const optionsText = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "submitted")).join(pc.dim(", ")) || pc.dim("none");
1389
+ return resolvedPrompt(opts.message, optionsText, "submit");
1178
1390
  }
1179
1391
  case "cancel": {
1180
- const label = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "cancelled")).join(pc.dim(", "));
1181
- return `${title}${pc.gray(S_BAR)} ${label.trim() ? `${label}\n${pc.gray(S_BAR)}` : ""}`;
1392
+ const label = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "cancelled")).join(pc.dim(", ")) || pc.dim("none");
1393
+ return canceledPrompt(this, opts.message, label);
1182
1394
  }
1183
1395
  case "error": {
1184
1396
  const footer = this.error.split("\n").map((ln, i) => i === 0 ? `${pc.yellow(S_BAR_END)} ${pc.yellow(ln)}` : ` ${ln}`).join("\n");
1185
- const optionsText = this.options.map((option, i, options) => {
1186
- const selected = value.includes(option.value) || option.group === true && this.isGroupSelected(`${option.value}`);
1187
- const active = i === this.cursor;
1188
- if (!active && typeof option.group === "string" && this.options[this.cursor].value === option.group) return opt(option, selected ? "group-active-selected" : "group-active", options);
1189
- if (active && selected) return opt(option, "active-selected", options);
1190
- if (selected) return opt(option, "selected", options);
1191
- return opt(option, active ? "active" : "inactive", options);
1397
+ const optionsText = limitOptions({
1398
+ output: opts.output,
1399
+ options: this.options,
1400
+ cursor: this.cursor,
1401
+ maxItems: opts.maxItems,
1402
+ columnPadding: 3,
1403
+ rowPadding: footer.split("\n").length + 4,
1404
+ style: styleOption
1192
1405
  }).join(`\n${pc.yellow(S_BAR)} `);
1193
- return `${title}${pc.yellow(S_BAR)} ${optionsText}\n${footer}\n`;
1406
+ return `${activePromptTitle(opts.message, "error")}${pc.yellow(S_BAR)} ${optionsText}\n${footer}\n`;
1194
1407
  }
1195
1408
  default: {
1196
- const optionsText = this.options.map((option, i, options) => {
1197
- const selected = value.includes(option.value) || option.group === true && this.isGroupSelected(`${option.value}`);
1198
- const active = i === this.cursor;
1199
- const groupActive = !active && typeof option.group === "string" && this.options[this.cursor].value === option.group;
1200
- let optionText = "";
1201
- if (groupActive) optionText = opt(option, selected ? "group-active-selected" : "group-active", options);
1202
- else if (active && selected) optionText = opt(option, "active-selected", options);
1203
- else if (selected) optionText = opt(option, "selected", options);
1204
- else optionText = opt(option, active ? "active" : "inactive", options);
1205
- return `${i !== 0 && !optionText.startsWith("\n") ? " " : ""}${optionText}`;
1206
- }).join(`\n${pc.cyan(S_BAR)}`);
1207
- const optionsPrefix = optionsText.startsWith("\n") ? "" : " ";
1208
- const hint = `\n${pc.gray(S_BAR)} ${getMultiHint()}`;
1209
- return `${title}${pc.cyan(S_BAR)}${optionsPrefix}${optionsText}\n${pc.cyan(S_BAR_END)}${hint}\n`;
1409
+ const optionsText = limitOptions({
1410
+ output: opts.output,
1411
+ options: this.options,
1412
+ cursor: this.cursor,
1413
+ maxItems: opts.maxItems,
1414
+ columnPadding: 3,
1415
+ rowPadding: 5,
1416
+ style: styleOption
1417
+ }).join(`\n${pc.cyan(S_BAR)} `);
1418
+ const hint = `${pc.gray(S_BAR_END)} ${getMultiHint()}`;
1419
+ return `${activePromptTitle(opts.message)}${pc.cyan(S_BAR)} ${optionsText}\n${hint}\n`;
1210
1420
  }
1211
1421
  }
1212
1422
  }
@@ -1372,7 +1582,7 @@ async function getAddonsChoice(addons, frontends, auth, backend, runtime, previo
1372
1582
  }
1373
1583
  sortAndPruneGroupedOptions(groupedOptions);
1374
1584
  const response = await navigableGroupMultiselect({
1375
- message: "Select addons",
1585
+ message: "Pick addons",
1376
1586
  options: groupedOptions,
1377
1587
  initialValues: (previousValue ?? DEFAULT_CONFIG.addons).filter((addonValue) => Object.values(groupedOptions).some((options) => options.some((opt) => opt.value === addonValue))),
1378
1588
  required: false,
@@ -1546,6 +1756,13 @@ function getAuthImportLine(config) {
1546
1756
  function getAuthExpression(config) {
1547
1757
  return usesCreateAuthFactory(config) ? "createAuth()" : "auth";
1548
1758
  }
1759
+ function addAiSdkEvlogTelemetry(content, loggerExpression) {
1760
+ let nextContent = addNamedImport(content, "evlog/ai", ["createAILogger", "createEvlogIntegration"]);
1761
+ if (!nextContent.includes("const ai = createAILogger(")) nextContent = nextContent.replace(/^(\s*)const model = wrapLanguageModel\({/m, (_match, indent) => `${indent}const ai = createAILogger(${loggerExpression});\n${indent}const model = wrapLanguageModel({`);
1762
+ if (!nextContent.includes("model: ai.wrap(model)")) nextContent = nextContent.replace(/(const result = streamText\({\n\s*)model,/, "$1model: ai.wrap(model),");
1763
+ if (!nextContent.includes("createEvlogIntegration(ai)")) nextContent = nextContent.replace(/^(\s*)(messages:\s*await convertToModelMessages\([^)]+\),?)/m, (_match, indent, messages) => `${indent}${messages.endsWith(",") ? messages : `${messages},`}\n${indent}telemetry: {\n${indent}\tisEnabled: true,\n${indent}\tintegrations: [createEvlogIntegration(ai)],\n${indent}},`);
1764
+ return nextContent;
1765
+ }
1549
1766
  function addEvlogBetterAuthServerSetup(content, backend, authExpression) {
1550
1767
  let nextContent = addNamedImport(content, "evlog/better-auth", ["createAuthMiddleware", "type BetterAuthInstance"]);
1551
1768
  const usesAuthFactory = authExpression.endsWith("()");
@@ -1558,15 +1775,17 @@ function addEvlogBetterAuthServerSetup(content, backend, authExpression) {
1558
1775
  return insertAfterOnce(nextContent, "app.use(evlog());", `\napp.use("*", async (c, next) => {${identifyUserSetup}\n\tawait identifyUser(c.get("log"), c.req.raw.headers, c.req.path);\n\tawait next();\n});`, "identifyUser(c.get(\"log\")");
1559
1776
  }
1560
1777
  if (backend === "express") {
1778
+ nextContent = addNamedImport(nextContent, "evlog/express", ["useLogger"]);
1561
1779
  nextContent = insertBeforeOnce(nextContent, "const app = express();", identifySnippet, "createAuthMiddleware(");
1562
- return insertAfterOnce(nextContent, "app.use(evlog());", `\napp.use(async (req, _res, next) => {${identifyUserSetup}\n\tawait identifyUser(req.log, req.headers, req.path);\n\tnext();\n});`, "identifyUser(req.log");
1780
+ return insertAfterOnce(nextContent, "app.use(evlog());", `\napp.use(async (req, _res, next) => {${identifyUserSetup}\n\tawait identifyUser(useLogger(), req.headers, req.path);\n\tnext();\n});`, "identifyUser(useLogger()");
1563
1781
  }
1564
1782
  if (backend === "fastify") {
1565
1783
  nextContent = addNamedImport(nextContent, "evlog/fastify", ["useLogger"]);
1566
1784
  nextContent = insertBeforeOnce(nextContent, "const fastify = Fastify", identifySnippet, "createAuthMiddleware(");
1567
1785
  return insertAfterOnce(nextContent, "fastify.register(evlog);", `\nfastify.addHook("preHandler", async (request) => {${identifyUserSetup}\n\tawait identifyUser(useLogger(), request.headers, request.url);\n});`, "identifyUser(useLogger()");
1568
1786
  }
1569
- nextContent = insertBeforeOnce(nextContent, "new Elysia", identifySnippet, "createAuthMiddleware(");
1787
+ const elysiaMarker = nextContent.includes("const app = new Elysia") ? "const app = new Elysia" : "new Elysia";
1788
+ nextContent = insertBeforeOnce(nextContent, elysiaMarker, identifySnippet, "createAuthMiddleware(");
1570
1789
  return insertAfterOnce(nextContent, ".use(evlog())", `\n\t.derive(async ({ request, log }) => {${identifyUserSetup.replace(/\n\t/g, "\n ")}\n\t\tawait identifyUser(log, request.headers, new URL(request.url).pathname);\n\t\treturn {};\n\t})`, "identifyUser(log");
1571
1790
  }
1572
1791
  function addEvlogServerSetup(content, backend, serviceName) {
@@ -1589,7 +1808,8 @@ function addEvlogServerSetup(content, backend, serviceName) {
1589
1808
  return insertBeforeOnce(nextContent, "fastify.register(fastifyCors", "fastify.register(evlog);\n", "fastify.register(evlog);");
1590
1809
  }
1591
1810
  let nextContent = prependMissingImports(content, ["import { initLogger } from \"evlog\";", "import { evlog } from \"evlog/elysia\";"]);
1592
- nextContent = insertBeforeOnce(nextContent, "new Elysia", initSnippet, "initLogger({");
1811
+ const elysiaMarker = nextContent.includes("const app = new Elysia") ? "const app = new Elysia" : "new Elysia";
1812
+ nextContent = insertBeforeOnce(nextContent, elysiaMarker, initSnippet, "initLogger({");
1593
1813
  for (const marker of ["new Elysia({ adapter: node() })", "new Elysia()"]) nextContent = insertAfterOnce(nextContent, marker, "\n .use(evlog())", ".use(evlog())");
1594
1814
  return nextContent;
1595
1815
  }
@@ -1671,12 +1891,27 @@ function addNextRouteWrappers(content) {
1671
1891
  return nextContent;
1672
1892
  }
1673
1893
  function addNextAiEvlogSetup(content) {
1674
- let nextContent = addNamedImport(content, "@/lib/evlog", ["withEvlog"]);
1894
+ let nextContent = addNamedImport(content, "@/lib/evlog", ["withEvlog", "useLogger"]);
1675
1895
  if (!nextContent.includes("withEvlog(async (req: Request)")) {
1676
1896
  nextContent = nextContent.replace("export async function POST(req: Request) {", "export const POST = withEvlog(async (req: Request) => {");
1677
1897
  if (nextContent.includes("export const POST = withEvlog(async (req: Request) => {")) nextContent = nextContent.replace(/\n}\s*$/, "\n});\n");
1678
1898
  }
1679
- return nextContent;
1899
+ return addAiSdkEvlogTelemetry(nextContent, "useLogger()");
1900
+ }
1901
+ function addNuxtAiEvlogSetup(content) {
1902
+ return addAiSdkEvlogTelemetry(addNamedImport(content, "evlog/nitro", ["useLogger"]), "useLogger(event)");
1903
+ }
1904
+ function addSvelteAiEvlogSetup(content) {
1905
+ return addAiSdkEvlogTelemetry(content.replace("export const POST: RequestHandler = async ({ request }) => {", "export const POST: RequestHandler = async ({ request, locals }) => {"), "locals.log");
1906
+ }
1907
+ function addTanstackStartAiEvlogSetup(content) {
1908
+ return addAiSdkEvlogTelemetry(prependMissingImports(content, ["import type { RequestLogger } from \"evlog\";", "import { useRequest } from \"nitro/context\";"]), "useRequest().context.log as RequestLogger");
1909
+ }
1910
+ function addBackendAiEvlogSetup(content, backend) {
1911
+ if (backend === "hono") return addAiSdkEvlogTelemetry(content, "c.get(\"log\")");
1912
+ if (backend === "express") return addAiSdkEvlogTelemetry(addNamedImport(content, "evlog/express", ["useLogger"]), "useLogger()");
1913
+ if (backend === "fastify") return addAiSdkEvlogTelemetry(addNamedImport(content, "evlog/fastify", ["useLogger"]), "useLogger()");
1914
+ return addAiSdkEvlogTelemetry(content, "context.log");
1680
1915
  }
1681
1916
  function addNextBetterAuthToRoute(content) {
1682
1917
  let nextContent = addNamedImport(content, "@/lib/evlog-auth", ["identifyEvlogUser"]);
@@ -1921,6 +2156,7 @@ async function setupNuxtEvlog(config, serviceName) {
1921
2156
  const authMiddlewarePath = path.join(webDir, "server/middleware/evlog-auth.ts");
1922
2157
  if (!await fs.pathExists(authMiddlewarePath)) await writeFileIfChanged(authMiddlewarePath, getNuxtEvlogAuthMiddlewareFile(config));
1923
2158
  }
2159
+ if (config.examples.includes("ai")) await updateFileIfExists(path.join(webDir, "server/api/ai.post.ts"), addNuxtAiEvlogSetup);
1924
2160
  }
1925
2161
  async function setupSvelteEvlog(config, serviceName) {
1926
2162
  const webDir = path.join(config.projectDir, "apps/web");
@@ -1933,6 +2169,7 @@ export const { handle, handleError } = createEvlogHooks();
1933
2169
  `);
1934
2170
  await updateFileIfExists(path.join(webDir, "src/app.d.ts"), addSvelteLocalsType);
1935
2171
  if (shouldIdentifyWebAuth(config)) await updateFileIfExists(path.join(webDir, "src/hooks.server.ts"), (content) => addSvelteBetterAuthEvlogSetup(content, config));
2172
+ if (config.examples.includes("ai")) await updateFileIfExists(path.join(webDir, "src/routes/api/ai/+server.ts"), addSvelteAiEvlogSetup);
1936
2173
  }
1937
2174
  async function setupTanstackStartEvlog(config, serviceName) {
1938
2175
  const webDir = path.join(config.projectDir, "apps/web");
@@ -1943,6 +2180,7 @@ async function setupTanstackStartEvlog(config, serviceName) {
1943
2180
  const authPluginPath = path.join(webDir, "server/plugins/evlog-auth.ts");
1944
2181
  if (!await fs.pathExists(authPluginPath)) await writeFileIfChanged(authPluginPath, getNitroEvlogAuthPluginFile(config));
1945
2182
  }
2183
+ if (config.examples.includes("ai")) await updateFileIfExists(path.join(webDir, "src/routes/api/ai/$.ts"), addTanstackStartAiEvlogSetup);
1946
2184
  }
1947
2185
  async function setupAstroEvlog(config, serviceName) {
1948
2186
  const webDir = path.join(config.projectDir, "apps/web");
@@ -1973,6 +2211,7 @@ async function setupEvlog(config) {
1973
2211
  const content = await fs.readFile(serverIndexPath, "utf-8");
1974
2212
  let nextContent = addEvlogServerSetup(content, config.backend, `${config.projectName}-server`);
1975
2213
  if (config.auth === "better-auth") nextContent = addEvlogBetterAuthServerSetup(nextContent, config.backend, getAuthExpression(config));
2214
+ if (config.examples.includes("ai")) nextContent = addBackendAiEvlogSetup(nextContent, config.backend);
1976
2215
  if (nextContent !== content) await fs.writeFile(serverIndexPath, nextContent);
1977
2216
  }
1978
2217
  }
@@ -2008,45 +2247,56 @@ async function navigableGroup(prompts, opts) {
2008
2247
  delete results[prevName];
2009
2248
  currentIndex--;
2010
2249
  };
2011
- while (currentIndex < promptNames.length) {
2012
- const name = promptNames[currentIndex];
2013
- const prompt = prompts[name];
2014
- setIsFirstPrompt$1(currentIndex === 0);
2015
- setLastPromptShownUI(false);
2016
- const result = await prompt({
2017
- results,
2018
- previousAnswer: previousAnswers[name]
2019
- })?.catch((e) => {
2020
- throw e;
2021
- });
2022
- if (isGoBack(result)) {
2023
- goingBack = true;
2024
- if (currentIndex > 0) {
2025
- stepBack();
2250
+ try {
2251
+ while (currentIndex < promptNames.length) {
2252
+ const name = promptNames[currentIndex];
2253
+ const prompt = prompts[name];
2254
+ const section = opts?.sections?.find(({ prompts: sectionPrompts }) => sectionPrompts.includes(name));
2255
+ const sectionPromptIndex = section?.prompts.indexOf(name) ?? -1;
2256
+ setPromptProgress({
2257
+ current: currentIndex + 1,
2258
+ total: promptNames.length,
2259
+ section: section?.label ?? "Setup",
2260
+ sectionCurrent: sectionPromptIndex + 1,
2261
+ sectionTotal: section?.prompts.length ?? promptNames.length
2262
+ });
2263
+ setIsFirstPrompt$1(currentIndex === 0);
2264
+ setLastPromptShownUI(false);
2265
+ const presetResult = opts?.preselected?.[name];
2266
+ const result = presetResult !== void 0 ? presetResult : await prompt({
2267
+ results,
2268
+ previousAnswer: previousAnswers[name]
2269
+ });
2270
+ if (isGoBack(result)) {
2271
+ goingBack = true;
2272
+ if (currentIndex > 0) {
2273
+ stepBack();
2274
+ continue;
2275
+ }
2276
+ goingBack = false;
2026
2277
  continue;
2027
2278
  }
2028
- goingBack = false;
2029
- continue;
2030
- }
2031
- if (isCancel$1(result)) {
2032
- if (typeof opts?.onCancel === "function") {
2033
- results[name] = "canceled";
2034
- opts.onCancel({ results });
2279
+ if (isCancel$1(result)) {
2280
+ if (typeof opts?.onCancel === "function") {
2281
+ results[name] = "canceled";
2282
+ opts.onCancel({ results });
2283
+ }
2284
+ return results;
2035
2285
  }
2036
- setIsFirstPrompt$1(false);
2037
- return results;
2038
- }
2039
- if (goingBack && !didLastPromptShowUI()) {
2040
- if (currentIndex > 0) {
2041
- stepBack();
2042
- continue;
2286
+ if (goingBack && !didLastPromptShowUI()) {
2287
+ if (currentIndex > 0) {
2288
+ stepBack();
2289
+ continue;
2290
+ }
2043
2291
  }
2292
+ goingBack = false;
2293
+ results[name] = result;
2294
+ currentIndex++;
2044
2295
  }
2045
- goingBack = false;
2046
- results[name] = result;
2047
- currentIndex++;
2296
+ } finally {
2297
+ setIsFirstPrompt$1(false);
2298
+ setPromptProgress(void 0);
2048
2299
  }
2049
- setIsFirstPrompt$1(false);
2050
2300
  return results;
2051
2301
  }
2052
2302
  //#endregion
@@ -3817,7 +4067,7 @@ async function installDependencies({ projectDir, packageManager }) {
3817
4067
  cause: e
3818
4068
  })
3819
4069
  });
3820
- if (result.isOk()) s.stop("Dependencies installed successfully");
4070
+ if (result.isOk()) s.stop("Dependencies installed");
3821
4071
  else s.stop(pc.red("Failed to install dependencies"));
3822
4072
  return result;
3823
4073
  }
@@ -3922,7 +4172,7 @@ async function addHandlerInternal(input) {
3922
4172
  }));
3923
4173
  if (!isSilent()) {
3924
4174
  renderTitle();
3925
- intro(pc.magenta("Add addons to your Better-T-Stack project"));
4175
+ intro(pc.magenta("Add to your project"));
3926
4176
  }
3927
4177
  const existingConfig = await detectProjectConfig(projectDir);
3928
4178
  if (!existingConfig) return Result.err(new CLIError({ message: `No Better-T-Stack project found in ${projectDir}. Make sure bts.jsonc exists.` }));
@@ -3931,7 +4181,10 @@ async function addHandlerInternal(input) {
3931
4181
  if (input.addons && input.addons.length > 0) {
3932
4182
  addonsToAdd = input.addons.filter((addon) => addon !== "none" && !existingConfig.addons.includes(addon));
3933
4183
  if (addonsToAdd.length === 0) {
3934
- if (!isSilent()) log.warn(pc.yellow("All specified addons are already installed or invalid."));
4184
+ if (!isSilent()) {
4185
+ log.warn(pc.yellow("Nothing to add — those addons are already installed"));
4186
+ outro(pc.dim("Project unchanged"));
4187
+ }
3935
4188
  return Result.ok({
3936
4189
  success: true,
3937
4190
  addedAddons: [],
@@ -3953,10 +4206,7 @@ async function addHandlerInternal(input) {
3953
4206
  if (promptResult.isErr()) return Result.err(promptResult.error);
3954
4207
  const selectedAddons = promptResult.value;
3955
4208
  if (selectedAddons.length === 0) {
3956
- if (!isSilent()) {
3957
- log.info(pc.dim("No addons selected."));
3958
- outro(pc.magenta("Nothing to add."));
3959
- }
4209
+ if (!isSilent()) outro(pc.dim("Nothing selected · project unchanged"));
3960
4210
  return Result.ok({
3961
4211
  success: true,
3962
4212
  addedAddons: [],
@@ -3968,7 +4218,7 @@ async function addHandlerInternal(input) {
3968
4218
  const updatedAddons = [...existingConfig.addons, ...addonsToAdd];
3969
4219
  const addonsValidationResult = validateAddonsAgainstConfig(updatedAddons, existingConfig);
3970
4220
  if (addonsValidationResult.isErr()) return Result.err(new CLIError({ message: addonsValidationResult.error.message }));
3971
- if (!isSilent()) log.info(pc.cyan(`Adding addons: ${addonsToAdd.join(", ")}`));
4221
+ if (!isSilent()) log.info(`${pc.dim("Adding")} ${pc.cyan(formatConfigValue(addonsToAdd))}`);
3972
4222
  const mergedAddonOptions = mergeAddonOptions(existingConfig.addonOptions, input.addonOptions);
3973
4223
  const config = {
3974
4224
  projectName: existingConfig.projectName,
@@ -3996,7 +4246,7 @@ async function addHandlerInternal(input) {
3996
4246
  ...config,
3997
4247
  addons: updatedAddons
3998
4248
  };
3999
- if (!isSilent()) log.info(pc.dim("Installing addon files..."));
4249
+ if (!isSilent()) log.info(pc.dim("Preparing addon files"));
4000
4250
  const vfs = new VirtualFileSystem();
4001
4251
  for (const pkgPath of ADD_PACKAGE_JSON_PATHS) {
4002
4252
  const fullPath = path.join(projectDir, pkgPath);
@@ -4028,9 +4278,9 @@ async function addHandlerInternal(input) {
4028
4278
  };
4029
4279
  if (input.dryRun) {
4030
4280
  if (!isSilent()) {
4031
- log.success(pc.green("Dry run validation passed. No addon files were written."));
4032
- log.info(pc.dim(`Planned addon files: ${vfs.getFileCount()}`));
4033
- outro(pc.magenta("Dry run complete."));
4281
+ log.success(pc.green("Dry run passed · no files written"));
4282
+ log.message(pc.dim(`${vfs.getFileCount()} addon files planned`));
4283
+ outro(pc.dim("Project unchanged"));
4034
4284
  }
4035
4285
  return Result.ok({
4036
4286
  success: true,
@@ -4061,17 +4311,17 @@ async function addHandlerInternal(input) {
4061
4311
  addons: updatedAddons,
4062
4312
  addonOptions: updatedConfig.addonOptions
4063
4313
  });
4064
- if (input.install) {
4065
- if (!isSilent()) log.info(pc.dim("Installing dependencies..."));
4066
- await installDependencies({
4067
- projectDir,
4068
- packageManager: config.packageManager
4069
- });
4070
- }
4314
+ if (input.install) await installDependencies({
4315
+ projectDir,
4316
+ packageManager: config.packageManager
4317
+ });
4071
4318
  if (!isSilent()) {
4072
- log.success(pc.green(`Successfully added: ${addonsToAdd.join(", ")}`));
4073
- if (!input.install) log.info(pc.yellow(`Run '${config.packageManager === "npm" ? "npm install" : `${config.packageManager} install`}' to install new dependencies.`));
4074
- outro(pc.magenta("Addons added successfully!"));
4319
+ log.success(pc.green(`Added ${formatConfigValue(addonsToAdd)}`));
4320
+ if (!input.install) {
4321
+ const installCommand = config.packageManager === "npm" ? "npm install" : `${config.packageManager} install`;
4322
+ log.message(`${pc.dim("Next step")}\n${pc.cyan(installCommand)}`);
4323
+ }
4324
+ outro(pc.magenta("Project updated"));
4075
4325
  }
4076
4326
  return Result.ok({
4077
4327
  success: true,
@@ -4104,7 +4354,7 @@ async function getApiChoice(Api, frontend, backend, previousValue) {
4104
4354
  hint: "No API layer (e.g. for full-stack frameworks like Next.js with Route Handlers)"
4105
4355
  });
4106
4356
  const apiType = await navigableSelect({
4107
- message: "Select API type",
4357
+ message: "Choose an API layer",
4108
4358
  options: apiOptions,
4109
4359
  initialValue: preferValidInitial(apiOptions, previousValue, apiOptions[0].value)
4110
4360
  });
@@ -4156,7 +4406,7 @@ async function getAuthChoice(auth, backend, frontend = [], previousValue) {
4156
4406
  }
4157
4407
  });
4158
4408
  const response = await navigableSelect({
4159
- message: "Select authentication provider",
4409
+ message: "Choose authentication",
4160
4410
  options,
4161
4411
  initialValue: preferValidInitial(options, previousValue, options.some((option) => option.value === DEFAULT_CONFIG.auth) ? DEFAULT_CONFIG.auth : "none")
4162
4412
  });
@@ -4210,7 +4460,7 @@ async function getBackendFrameworkChoice(backendFramework, frontends, previousVa
4210
4460
  hint: "No backend server"
4211
4461
  });
4212
4462
  const response = await navigableSelect({
4213
- message: "Select backend",
4463
+ message: "Choose a backend",
4214
4464
  options: backendOptions,
4215
4465
  initialValue: preferValidInitial(backendOptions, previousValue, hasFullstackFrontend ? "self" : DEFAULT_CONFIG.backend)
4216
4466
  });
@@ -4250,7 +4500,7 @@ async function getDatabaseChoice(database, backend, runtime, previousValue) {
4250
4500
  hint: "open-source NoSQL database that stores data in JSON-like documents called BSON"
4251
4501
  });
4252
4502
  const response = await navigableSelect({
4253
- message: "Select database",
4503
+ message: "Choose a database",
4254
4504
  options: databaseOptions,
4255
4505
  initialValue: preferValidInitial(databaseOptions, previousValue, DEFAULT_CONFIG.database)
4256
4506
  });
@@ -4349,7 +4599,7 @@ async function getDBSetupChoice(databaseType, dbSetup, _orm, backend, runtime, p
4349
4599
  ];
4350
4600
  else return "none";
4351
4601
  const response = await navigableSelect({
4352
- message: `Select ${databaseType} setup option`,
4602
+ message: `Choose a ${databaseType} setup`,
4353
4603
  options,
4354
4604
  initialValue: preferValidInitial(options, previousValue, "none")
4355
4605
  });
@@ -4375,7 +4625,7 @@ async function getExamplesChoice(examples, database, frontends, backend, api, pr
4375
4625
  });
4376
4626
  if (options.length === 0) return [];
4377
4627
  response = await navigableMultiselect({
4378
- message: "Include examples",
4628
+ message: "Include starter examples?",
4379
4629
  options,
4380
4630
  required: false,
4381
4631
  initialValues: (previousValue ?? DEFAULT_CONFIG.examples)?.filter((ex) => options.some((o) => o.value === ex))
@@ -4402,7 +4652,7 @@ async function getFrontendChoice(frontendOptions, backend, auth, previousValue)
4402
4652
  while (true) {
4403
4653
  const wasFirstPrompt = isFirstPrompt();
4404
4654
  const frontendTypes = await navigableMultiselect({
4405
- message: "Select project type",
4655
+ message: "What are you building?",
4406
4656
  options: [{
4407
4657
  value: "web",
4408
4658
  label: "Web",
@@ -4464,7 +4714,7 @@ async function getFrontendChoice(frontendOptions, backend, auth, previousValue)
4464
4714
  }
4465
4715
  ].filter((option) => isFrontendAllowedWithBackend(option.value, backend, auth));
4466
4716
  const webFramework = await navigableSelect({
4467
- message: "Choose web",
4717
+ message: "Choose a web framework",
4468
4718
  options: webOptions,
4469
4719
  initialValue: preferValidInitial(webOptions, previousWeb, DEFAULT_CONFIG.frontend[0])
4470
4720
  });
@@ -4478,7 +4728,7 @@ async function getFrontendChoice(frontendOptions, backend, auth, previousValue)
4478
4728
  }
4479
4729
  if (frontendTypes.includes("native")) {
4480
4730
  const nativeFramework = await navigableSelect({
4481
- message: "Choose native",
4731
+ message: "Choose a native setup",
4482
4732
  options: [
4483
4733
  {
4484
4734
  value: "native-bare",
@@ -4518,7 +4768,7 @@ async function getFrontendChoice(frontendOptions, backend, auth, previousValue)
4518
4768
  async function getGitChoice(git, previousValue) {
4519
4769
  if (git !== void 0) return git;
4520
4770
  const response = await navigableConfirm({
4521
- message: "Initialize git repository?",
4771
+ message: "Initialize a Git repository?",
4522
4772
  initialValue: previousValue ?? DEFAULT_CONFIG.git
4523
4773
  });
4524
4774
  if (isCancel$1(response)) throw new UserCancelledError({ message: "Operation cancelled" });
@@ -4770,7 +5020,7 @@ async function getORMChoice(orm, hasDatabase, database, backend, runtime, previo
4770
5020
  }
4771
5021
  const options = database === "mongodb" ? [ormOptions.prisma, ormOptions.mongoose] : [ormOptions.drizzle, ormOptions.prisma];
4772
5022
  const response = await navigableSelect({
4773
- message: "Select ORM",
5023
+ message: "Choose an ORM",
4774
5024
  options,
4775
5025
  initialValue: preferValidInitial(options, previousValue, database === "mongodb" ? "prisma" : runtime === "workers" ? "drizzle" : DEFAULT_CONFIG.orm)
4776
5026
  });
@@ -4822,7 +5072,7 @@ async function getPaymentsChoice(payments, auth, backend, _frontends, previousVa
4822
5072
  hint: "No payments integration"
4823
5073
  }];
4824
5074
  const response = await navigableSelect({
4825
- message: "Select payments provider",
5075
+ message: "Add payments?",
4826
5076
  options,
4827
5077
  initialValue: preferValidInitial(options, previousValue, DEFAULT_CONFIG.payments)
4828
5078
  });
@@ -4849,7 +5099,7 @@ async function getRuntimeChoice(runtime, backend, previousValue) {
4849
5099
  hint: "Edge runtime on Cloudflare's global network"
4850
5100
  });
4851
5101
  const response = await navigableSelect({
4852
- message: "Select runtime",
5102
+ message: "Choose a runtime",
4853
5103
  options: runtimeOptions,
4854
5104
  initialValue: preferValidInitial(runtimeOptions, previousValue, DEFAULT_CONFIG.runtime)
4855
5105
  });
@@ -4903,7 +5153,7 @@ async function getServerDeploymentChoice(deployment, runtime, backend, _webDeplo
4903
5153
  };
4904
5154
  });
4905
5155
  const response = await navigableSelect({
4906
- message: "Select server deployment",
5156
+ message: "Choose server deployment",
4907
5157
  options,
4908
5158
  initialValue: preferValidInitial(options, previousValue, DEFAULT_CONFIG.serverDeploy)
4909
5159
  });
@@ -4951,7 +5201,7 @@ async function getDeploymentChoice(deployment, _runtime, backend, frontend = [],
4951
5201
  };
4952
5202
  });
4953
5203
  const response = await navigableSelect({
4954
- message: "Select web deployment",
5204
+ message: "Choose web deployment",
4955
5205
  options,
4956
5206
  initialValue: preferValidInitial(options, previousValue, DEFAULT_CONFIG.webDeploy)
4957
5207
  });
@@ -4960,7 +5210,7 @@ async function getDeploymentChoice(deployment, _runtime, backend, frontend = [],
4960
5210
  }
4961
5211
  //#endregion
4962
5212
  //#region src/prompts/config-prompts.ts
4963
- async function gatherConfig(flags, projectName, projectDir, relativePath) {
5213
+ async function gatherConfig(flags, projectName, projectDir, relativePath, options = {}) {
4964
5214
  if (isSilent()) return {
4965
5215
  projectName,
4966
5216
  projectDir,
@@ -4988,22 +5238,63 @@ async function gatherConfig(flags, projectName, projectDir, relativePath) {
4988
5238
  frontend: ({ previousAnswer }) => getFrontendChoice(flags.frontend, flags.backend, flags.auth, previousAnswer),
4989
5239
  backend: ({ results, previousAnswer }) => getBackendFrameworkChoice(flags.backend, results.frontend, previousAnswer),
4990
5240
  runtime: ({ results, previousAnswer }) => getRuntimeChoice(flags.runtime, results.backend, previousAnswer),
5241
+ api: ({ results, previousAnswer }) => getApiChoice(flags.api, results.frontend, results.backend, previousAnswer),
4991
5242
  database: ({ results, previousAnswer }) => getDatabaseChoice(flags.database, results.backend, results.runtime, previousAnswer),
4992
5243
  orm: ({ results, previousAnswer }) => getORMChoice(flags.orm, results.database !== "none", results.database, results.backend, results.runtime, previousAnswer),
4993
- api: ({ results, previousAnswer }) => getApiChoice(flags.api, results.frontend, results.backend, previousAnswer),
5244
+ dbSetup: ({ results, previousAnswer }) => getDBSetupChoice(results.database ?? "none", flags.dbSetup, results.orm, results.backend, results.runtime, previousAnswer),
4994
5245
  auth: ({ results, previousAnswer }) => getAuthChoice(flags.auth, results.backend, results.frontend, previousAnswer),
4995
5246
  payments: ({ results, previousAnswer }) => getPaymentsChoice(flags.payments, results.auth, results.backend, results.frontend, previousAnswer),
4996
5247
  addons: ({ results, previousAnswer }) => getAddonsChoice(flags.addons, results.frontend, results.auth, results.backend, results.runtime, previousAnswer),
4997
5248
  examples: ({ results, previousAnswer }) => getExamplesChoice(flags.examples, results.database, results.frontend, results.backend, results.api, previousAnswer),
4998
- dbSetup: ({ results, previousAnswer }) => getDBSetupChoice(results.database ?? "none", flags.dbSetup, results.orm, results.backend, results.runtime, previousAnswer),
4999
5249
  webDeploy: ({ results, previousAnswer }) => getDeploymentChoice(flags.webDeploy, results.runtime, results.backend, results.frontend, results.dbSetup, previousAnswer),
5000
5250
  serverDeploy: ({ results, previousAnswer }) => getServerDeploymentChoice(flags.serverDeploy, results.runtime, results.backend, results.webDeploy, previousAnswer),
5001
5251
  git: ({ previousAnswer }) => getGitChoice(flags.git, previousAnswer),
5002
5252
  packageManager: ({ previousAnswer }) => getPackageManagerChoice(flags.packageManager, previousAnswer),
5003
5253
  install: ({ previousAnswer }) => getinstallChoice(flags.install, previousAnswer)
5004
- }, { onCancel: () => {
5005
- throw new UserCancelledError({ message: "Operation cancelled" });
5006
- } });
5254
+ }, {
5255
+ preselected: options.skipCompatibilityChecks ? flags : void 0,
5256
+ sections: [
5257
+ {
5258
+ label: "App",
5259
+ prompts: [
5260
+ "frontend",
5261
+ "backend",
5262
+ "runtime",
5263
+ "api"
5264
+ ]
5265
+ },
5266
+ {
5267
+ label: "Data",
5268
+ prompts: [
5269
+ "database",
5270
+ "orm",
5271
+ "dbSetup"
5272
+ ]
5273
+ },
5274
+ {
5275
+ label: "Product",
5276
+ prompts: [
5277
+ "auth",
5278
+ "payments",
5279
+ "addons",
5280
+ "examples"
5281
+ ]
5282
+ },
5283
+ {
5284
+ label: "Ship",
5285
+ prompts: [
5286
+ "webDeploy",
5287
+ "serverDeploy",
5288
+ "git",
5289
+ "packageManager",
5290
+ "install"
5291
+ ]
5292
+ }
5293
+ ],
5294
+ onCancel: () => {
5295
+ throw new UserCancelledError({ message: "Operation cancelled" });
5296
+ }
5297
+ });
5007
5298
  return {
5008
5299
  projectName,
5009
5300
  projectDir,
@@ -5052,13 +5343,22 @@ async function getProjectName(initialName) {
5052
5343
  let projectPath = "";
5053
5344
  let defaultName = DEFAULT_CONFIG.projectName;
5054
5345
  let counter = 1;
5055
- while (await fs.pathExists(path.resolve(process.cwd(), defaultName)) && (await fs.readdir(path.resolve(process.cwd(), defaultName))).length > 0) {
5346
+ while (true) {
5347
+ const defaultPath = path.resolve(process.cwd(), defaultName);
5348
+ let stats;
5349
+ try {
5350
+ stats = await fs.lstat(defaultPath);
5351
+ } catch (error) {
5352
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") break;
5353
+ throw error;
5354
+ }
5355
+ if (stats.isDirectory() && (await fs.readdir(defaultPath)).length === 0) break;
5056
5356
  defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`;
5057
5357
  counter++;
5058
5358
  }
5059
5359
  while (!isValid) {
5060
5360
  const response = await text({
5061
- message: "Enter your project name or path (relative to current directory)",
5361
+ message: "Where should we create your project?",
5062
5362
  placeholder: defaultName,
5063
5363
  initialValue: initialName,
5064
5364
  defaultValue: defaultName,
@@ -5119,90 +5419,83 @@ async function trackProjectCreation(config, disableAnalytics = false) {
5119
5419
  });
5120
5420
  }
5121
5421
  //#endregion
5122
- //#region src/utils/display-config.ts
5123
- function displayConfig(config) {
5124
- const configDisplay = [];
5125
- if (config.projectName) configDisplay.push(`${pc.blue("Project Name:")} ${config.projectName}`);
5126
- if (config.frontend !== void 0) {
5127
- const frontend = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
5128
- const frontendText = frontend.length > 0 && frontend[0] !== void 0 ? frontend.join(", ") : "none";
5129
- configDisplay.push(`${pc.blue("Frontend:")} ${frontendText}`);
5130
- }
5131
- if (config.backend !== void 0) configDisplay.push(`${pc.blue("Backend:")} ${String(config.backend)}`);
5132
- if (config.runtime !== void 0) configDisplay.push(`${pc.blue("Runtime:")} ${String(config.runtime)}`);
5133
- if (config.api !== void 0) configDisplay.push(`${pc.blue("API:")} ${String(config.api)}`);
5134
- if (config.database !== void 0) configDisplay.push(`${pc.blue("Database:")} ${String(config.database)}`);
5135
- if (config.orm !== void 0) configDisplay.push(`${pc.blue("ORM:")} ${String(config.orm)}`);
5136
- if (config.auth !== void 0) configDisplay.push(`${pc.blue("Auth:")} ${String(config.auth)}`);
5137
- if (config.payments !== void 0) configDisplay.push(`${pc.blue("Payments:")} ${String(config.payments)}`);
5138
- if (config.addons !== void 0) {
5139
- const addons = Array.isArray(config.addons) ? config.addons : [config.addons];
5140
- const addonsText = addons.length > 0 && addons[0] !== void 0 ? addons.join(", ") : "none";
5141
- configDisplay.push(`${pc.blue("Addons:")} ${addonsText}`);
5142
- }
5143
- if (config.examples !== void 0) {
5144
- const examples = Array.isArray(config.examples) ? config.examples : [config.examples];
5145
- const examplesText = examples.length > 0 && examples[0] !== void 0 ? examples.join(", ") : "none";
5146
- configDisplay.push(`${pc.blue("Examples:")} ${examplesText}`);
5147
- }
5148
- if (config.git !== void 0) {
5149
- const gitText = typeof config.git === "boolean" ? config.git ? "Yes" : "No" : String(config.git);
5150
- configDisplay.push(`${pc.blue("Git Init:")} ${gitText}`);
5151
- }
5152
- if (config.packageManager !== void 0) configDisplay.push(`${pc.blue("Package Manager:")} ${String(config.packageManager)}`);
5153
- if (config.install !== void 0) {
5154
- const installText = typeof config.install === "boolean" ? config.install ? "Yes" : "No" : String(config.install);
5155
- configDisplay.push(`${pc.blue("Install Dependencies:")} ${installText}`);
5156
- }
5157
- if (config.dbSetup !== void 0) configDisplay.push(`${pc.blue("Database Setup:")} ${String(config.dbSetup)}`);
5158
- if (config.webDeploy !== void 0) configDisplay.push(`${pc.blue("Web Deployment:")} ${String(config.webDeploy)}`);
5159
- if (config.serverDeploy !== void 0) configDisplay.push(`${pc.blue("Server Deployment:")} ${String(config.serverDeploy)}`);
5160
- if (configDisplay.length === 0) return pc.yellow("No configuration selected.");
5161
- return configDisplay.join("\n");
5422
+ //#region src/utils/cli-invocation.ts
5423
+ function getCliSubcommandCommand(subcommand, fallbackPackageManager, userAgent = process.env.npm_config_user_agent) {
5424
+ const normalizedUserAgent = userAgent?.toLowerCase();
5425
+ return getPackageExecutionCommand(normalizedUserAgent?.startsWith("bun") ? "bun" : normalizedUserAgent?.startsWith("pnpm") ? "pnpm" : normalizedUserAgent?.startsWith("npm") ? "npm" : fallbackPackageManager, `create-better-t-stack@latest ${subcommand}`);
5162
5426
  }
5163
5427
  //#endregion
5164
5428
  //#region src/utils/project-directory.ts
5165
5429
  async function handleDirectoryConflict(currentPathInput) {
5166
5430
  while (true) {
5167
- const resolvedPath = path.resolve(process.cwd(), currentPathInput);
5168
- if (!(await fs.pathExists(resolvedPath) && (await fs.readdir(resolvedPath)).length > 0)) return {
5431
+ const pathStateResult = await inspectProjectPath(path.resolve(process.cwd(), currentPathInput));
5432
+ if (pathStateResult.isErr()) throw pathStateResult.error;
5433
+ const pathState = pathStateResult.value;
5434
+ if (pathState === "missing" || pathState === "empty-directory") return {
5169
5435
  finalPathInput: currentPathInput,
5170
5436
  shouldClearDirectory: false
5171
5437
  };
5172
- if (isSilent()) throw new CLIError({ message: `Directory "${currentPathInput}" already exists and is not empty. In silent mode, please provide a different project name or clear the directory manually.` });
5173
- log.warn(`Directory "${pc.yellow(currentPathInput)}" already exists and is not empty.`);
5438
+ if (isSilent()) throw new CLIError({ message: `Project path "${currentPathInput}" is unavailable. In silent mode, provide a different project path or an explicit directoryConflict strategy.` });
5439
+ if (pathState === "symbolic-link") log.warn(`Project path "${pc.yellow(currentPathInput)}" is a symbolic link.`);
5440
+ else if (pathState === "non-directory") log.warn(`Project path "${pc.yellow(currentPathInput)}" exists and is not a directory.`);
5441
+ else log.warn(`Directory "${pc.yellow(currentPathInput)}" already exists and is not empty.`);
5442
+ let incrementedPath;
5443
+ if (currentPathInput !== ".") {
5444
+ const incrementResult = await findAvailableIncrementedPath(currentPathInput);
5445
+ if (incrementResult.isErr()) throw incrementResult.error;
5446
+ incrementedPath = incrementResult.value;
5447
+ }
5448
+ const options = [];
5449
+ if (incrementedPath) options.push({
5450
+ value: "increment",
5451
+ label: `Create as "${incrementedPath}"`,
5452
+ hint: "Keep the existing path untouched"
5453
+ });
5454
+ options.push({
5455
+ value: "rename",
5456
+ label: "Choose another path",
5457
+ hint: "Enter a different project directory"
5458
+ });
5459
+ if (pathState === "non-empty-directory") options.push({
5460
+ value: "merge",
5461
+ label: "Merge into this directory",
5462
+ hint: "Keep unrelated files; replace conflicts"
5463
+ }, {
5464
+ value: "overwrite",
5465
+ label: "Delete and overwrite",
5466
+ hint: "Permanently remove existing contents"
5467
+ });
5468
+ options.push({
5469
+ value: "cancel",
5470
+ label: "Cancel",
5471
+ hint: "Leave everything unchanged"
5472
+ });
5174
5473
  const action = await select({
5175
- message: "What would you like to do?",
5176
- options: [
5177
- {
5178
- value: "overwrite",
5179
- label: "Overwrite",
5180
- hint: "Empty the directory and create the project"
5181
- },
5182
- {
5183
- value: "merge",
5184
- label: "Merge",
5185
- hint: "Create project files inside, potentially overwriting conflicts"
5186
- },
5187
- {
5188
- value: "rename",
5189
- label: "Choose a different name/path",
5190
- hint: "Keep the existing directory and create a new one"
5191
- },
5192
- {
5193
- value: "cancel",
5194
- label: "Cancel",
5195
- hint: "Abort the process"
5196
- }
5197
- ],
5198
- initialValue: "rename"
5474
+ message: "How should we continue?",
5475
+ options,
5476
+ initialValue: incrementedPath ? "increment" : "rename"
5199
5477
  });
5200
5478
  if (isCancel(action)) throw new UserCancelledError({ message: "Operation cancelled." });
5201
5479
  switch (action) {
5202
- case "overwrite": return {
5203
- finalPathInput: currentPathInput,
5204
- shouldClearDirectory: true
5480
+ case "increment": return {
5481
+ finalPathInput: incrementedPath,
5482
+ shouldClearDirectory: false
5205
5483
  };
5484
+ case "overwrite": {
5485
+ const confirmed = await confirm({
5486
+ message: `Permanently delete every file in "${currentPathInput}"?`,
5487
+ initialValue: false
5488
+ });
5489
+ if (isCancel(confirmed)) throw new UserCancelledError({ message: "Operation cancelled." });
5490
+ if (!confirmed) {
5491
+ log.info("Nothing was deleted. Choose another option.");
5492
+ continue;
5493
+ }
5494
+ return {
5495
+ finalPathInput: currentPathInput,
5496
+ shouldClearDirectory: true
5497
+ };
5498
+ }
5206
5499
  case "merge":
5207
5500
  log.info(`Proceeding into existing directory "${pc.yellow(currentPathInput)}". Files may be overwritten.`);
5208
5501
  return {
@@ -5210,8 +5503,8 @@ async function handleDirectoryConflict(currentPathInput) {
5210
5503
  shouldClearDirectory: false
5211
5504
  };
5212
5505
  case "rename":
5213
- log.info("Please choose a different project name or path.");
5214
- return await handleDirectoryConflict(await getProjectName(void 0));
5506
+ currentPathInput = await getProjectName(void 0);
5507
+ continue;
5215
5508
  case "cancel": throw new UserCancelledError({ message: "Operation cancelled." });
5216
5509
  }
5217
5510
  }
@@ -5226,9 +5519,11 @@ async function setupProjectDirectory(finalPathInput, shouldClearDirectory) {
5226
5519
  finalResolvedPath = path.resolve(process.cwd(), finalPathInput);
5227
5520
  finalBaseName = path.basename(finalResolvedPath);
5228
5521
  }
5522
+ const pathSafetyResult = await validateSafeProjectDirectoryPath(finalPathInput);
5523
+ if (pathSafetyResult.isErr()) throw pathSafetyResult.error;
5229
5524
  if (shouldClearDirectory) {
5230
- const s = spinner();
5231
- s.start(`Clearing directory "${finalResolvedPath}"...`);
5525
+ const s = isSilent() ? void 0 : spinner();
5526
+ s?.start(`Clearing directory "${finalResolvedPath}"...`);
5232
5527
  const clearResult = await Result.tryPromise({
5233
5528
  try: () => fs.emptyDir(finalResolvedPath),
5234
5529
  catch: (error) => new CLIError({
@@ -5237,16 +5532,78 @@ async function setupProjectDirectory(finalPathInput, shouldClearDirectory) {
5237
5532
  })
5238
5533
  });
5239
5534
  if (clearResult.isErr()) {
5240
- s.stop(pc.red(`Failed to clear directory "${finalResolvedPath}".`));
5535
+ s?.stop(pc.red(`Failed to clear directory "${finalResolvedPath}".`));
5241
5536
  throw clearResult.error;
5242
5537
  }
5243
- s.stop(`Directory "${finalResolvedPath}" cleared.`);
5538
+ s?.stop(`Directory "${finalResolvedPath}" cleared.`);
5244
5539
  } else await fs.ensureDir(finalResolvedPath);
5245
5540
  return {
5246
5541
  finalResolvedPath,
5247
5542
  finalBaseName
5248
5543
  };
5249
5544
  }
5545
+ async function validateSafeProjectDirectoryPath(finalPathInput) {
5546
+ return Result.tryPromise({
5547
+ try: async () => {
5548
+ const cwd = path.resolve(process.cwd());
5549
+ const targetPath = finalPathInput === "." ? cwd : path.resolve(cwd, finalPathInput);
5550
+ const relativeTarget = path.relative(cwd, targetPath);
5551
+ if (relativeTarget === ".." || relativeTarget.startsWith(`..${path.sep}`) || path.isAbsolute(relativeTarget)) throw new CLIError({ message: `Project path "${finalPathInput}" resolves outside the current working directory.` });
5552
+ const pathSegments = relativeTarget.split(path.sep).filter(Boolean);
5553
+ let nearestExistingPath = cwd;
5554
+ for (const segment of pathSegments) {
5555
+ const candidatePath = path.join(nearestExistingPath, segment);
5556
+ let stats;
5557
+ try {
5558
+ stats = await fs.lstat(candidatePath);
5559
+ } catch (error) {
5560
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") break;
5561
+ throw error;
5562
+ }
5563
+ if (stats.isSymbolicLink()) throw new CLIError({ message: `Project path "${finalPathInput}" passes through symbolic link "${path.relative(cwd, candidatePath)}". Choose a real directory within the current working directory.` });
5564
+ if (!stats.isDirectory()) throw new CLIError({ message: `Project path "${finalPathInput}" passes through "${path.relative(cwd, candidatePath)}", which is not a directory.` });
5565
+ nearestExistingPath = candidatePath;
5566
+ }
5567
+ const [realCwd, realExistingPath] = await Promise.all([fs.realpath(cwd), fs.realpath(nearestExistingPath)]);
5568
+ const relativeRealPath = path.relative(realCwd, realExistingPath);
5569
+ if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) throw new CLIError({ message: `Project path "${finalPathInput}" resolves outside the current working directory.` });
5570
+ },
5571
+ catch: (error) => CLIError.is(error) ? error : new CLIError({
5572
+ message: `Unable to validate project path "${finalPathInput}".`,
5573
+ cause: error
5574
+ })
5575
+ });
5576
+ }
5577
+ async function inspectProjectPath(targetPath) {
5578
+ return Result.tryPromise({
5579
+ try: async () => {
5580
+ let stats;
5581
+ try {
5582
+ stats = await fs.lstat(targetPath);
5583
+ } catch (error) {
5584
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "missing";
5585
+ throw error;
5586
+ }
5587
+ if (stats.isSymbolicLink()) return "symbolic-link";
5588
+ if (!stats.isDirectory()) return "non-directory";
5589
+ return (await fs.readdir(targetPath)).length === 0 ? "empty-directory" : "non-empty-directory";
5590
+ },
5591
+ catch: (error) => new CLIError({
5592
+ message: `Unable to inspect project path "${targetPath}".`,
5593
+ cause: error
5594
+ })
5595
+ });
5596
+ }
5597
+ async function findAvailableIncrementedPath(currentPathInput) {
5598
+ let counter = 1;
5599
+ while (true) {
5600
+ const candidate = `${currentPathInput}-${counter}`;
5601
+ const candidateStateResult = await inspectProjectPath(path.resolve(process.cwd(), candidate));
5602
+ if (candidateStateResult.isErr()) return Result.err(candidateStateResult.error);
5603
+ if (candidateStateResult.value === "missing" || candidateStateResult.value === "empty-directory") return Result.ok(candidate);
5604
+ counter++;
5605
+ }
5606
+ }
5250
5607
  //#endregion
5251
5608
  //#region src/utils/project-name-validation.ts
5252
5609
  function validateProjectName(name) {
@@ -6956,7 +7313,7 @@ async function displayPostInstallInstructions(config) {
6956
7313
  const polarInstructions = config.payments === "polar" && config.auth === "better-auth" ? getPolarInstructions(backend, packageManager) : "";
6957
7314
  const bunWebNativeWarning = packageManager === "bun" && hasNative && hasWeb ? getBunWebNativeWarning() : "";
6958
7315
  const noOrmWarning = !isConvex && database !== "none" && orm === "none" ? getNoOrmWarning() : "";
6959
- let output = `${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd}\n`;
7316
+ let output = `${pc.cyan("1.")} ${cdCmd}\n`;
6960
7317
  let stepCounter = 2;
6961
7318
  if (!depsInstalled) output += `${pc.cyan(`${stepCounter++}.`)} ${packageManager} install\n`;
6962
7319
  if (database === "sqlite" && dbSetup !== "d1") output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} db:local\n${pc.dim(" (optional - starts local SQLite database)")}\n`;
@@ -6974,19 +7331,44 @@ async function displayPostInstallInstructions(config) {
6974
7331
  }
6975
7332
  const hasStandaloneBackend = backend !== "none";
6976
7333
  if (hasWeb || hasStandaloneBackend || addons?.includes("starlight") || addons?.includes("fumadocs")) {
6977
- output += `${pc.bold("Your project will be available at:")}\n`;
6978
- if (hasWeb) output += `${pc.cyan("")} Frontend: http://localhost:${webPort}\n`;
6979
- else if (!hasNative && !addons?.includes("starlight")) output += `${pc.yellow("NOTE:")} You are creating a backend-only app\n (no frontend selected)\n`;
7334
+ const localServices = [];
7335
+ let localDevelopmentNote = "";
7336
+ if (hasWeb) localServices.push({
7337
+ label: "Frontend",
7338
+ url: `http://localhost:${webPort}`
7339
+ });
7340
+ else if (!hasNative && !addons?.includes("starlight")) localDevelopmentNote = "Backend-only app — no frontend selected";
6980
7341
  if (!isConvex && !isBackendSelf && hasStandaloneBackend) {
6981
- output += `${pc.cyan("•")} Backend API: http://localhost:3000\n`;
6982
- if (api === "orpc") output += `${pc.cyan("•")} OpenAPI (Scalar UI): http://localhost:3000/api-reference\n`;
7342
+ localServices.push({
7343
+ label: "API",
7344
+ url: "http://localhost:3000"
7345
+ });
7346
+ if (api === "orpc") localServices.push({
7347
+ label: "API reference",
7348
+ url: "http://localhost:3000/api-reference"
7349
+ });
6983
7350
  }
6984
7351
  if (isBackendSelf && api === "orpc") {
6985
7352
  const rpcPath = frontend?.includes("next") || frontend?.includes("tanstack-start") ? "/api/rpc" : "/rpc";
6986
- output += `${pc.cyan("•")} OpenAPI (Scalar UI): http://localhost:${webPort}${rpcPath}/api-reference\n`;
7353
+ localServices.push({
7354
+ label: "API reference",
7355
+ url: `http://localhost:${webPort}${rpcPath}/api-reference`
7356
+ });
7357
+ }
7358
+ if (addons?.includes("starlight")) localServices.push({
7359
+ label: "Docs",
7360
+ url: "http://localhost:4321"
7361
+ });
7362
+ if (addons?.includes("fumadocs")) localServices.push({
7363
+ label: "Fumadocs",
7364
+ url: "http://localhost:4000"
7365
+ });
7366
+ output += `\n${pc.bold("Local development")}\n`;
7367
+ if (localDevelopmentNote) output += `${pc.dim(localDevelopmentNote)}\n`;
7368
+ if (localServices.length > 0) {
7369
+ const labelWidth = Math.max(...localServices.map(({ label }) => label.length));
7370
+ for (const { label, url } of localServices) output += `${pc.dim(label.padEnd(labelWidth))} ${pc.cyan(url)}\n`;
6987
7371
  }
6988
- if (addons?.includes("starlight")) output += `${pc.cyan("•")} Docs: http://localhost:4321\n`;
6989
- if (addons?.includes("fumadocs")) output += `${pc.cyan("•")} Fumadocs: http://localhost:4000\n`;
6990
7372
  }
6991
7373
  if (nativeInstructions) output += `\n${nativeInstructions.trim()}\n`;
6992
7374
  if (databaseInstructions) output += `\n${databaseInstructions.trim()}\n`;
@@ -7006,10 +7388,15 @@ async function displayPostInstallInstructions(config) {
7006
7388
  if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`;
7007
7389
  const sponsorsResult = await fetchSponsorsQuietly();
7008
7390
  const specialSponsorsSection = sponsorsResult.isOk() ? formatPostInstallSpecialSponsorsSection(sponsorsResult.value) : "";
7009
- if (specialSponsorsSection) output += `\n${specialSponsorsSection.trim()}\n`;
7010
- output += `\n${pc.bold("Like Better-T-Stack?")} Please consider giving us a star\n on GitHub:\n`;
7011
- output += pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack");
7012
- cliConsola.box(output);
7391
+ log.message([], { spacing: 1 });
7392
+ box(output.trimEnd(), pc.bold("Next steps"), {
7393
+ contentPadding: 2,
7394
+ formatBorder: pc.dim,
7395
+ rounded: true,
7396
+ width: "auto"
7397
+ });
7398
+ if (specialSponsorsSection) cliLog.message(specialSponsorsSection);
7399
+ cliLog.message(`${pc.bold("Like Better T Stack?")} ${pc.dim("Star the project on GitHub")}\n${pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack")}`);
7013
7400
  }
7014
7401
  function getNativeInstructions(isConvex, isBackendSelf, frontend, runCmd) {
7015
7402
  const envVar = isConvex ? "EXPO_PUBLIC_CONVEX_URL" : "EXPO_PUBLIC_SERVER_URL";
@@ -7036,43 +7423,82 @@ function getVitePlusNativeHooksInstructions(runCmd) {
7036
7423
  return `${pc.bold("Vite+ native Git hooks:")}\n${pc.cyan("•")} Optional hook setup: ${`${runCmd} hooks:setup`}\n${pc.dim(" (runs vp config; hooks install into .vite-hooks and use vp staged)")}\n`;
7037
7424
  }
7038
7425
  async function getDatabaseInstructions(database, orm, runCmd, _runtime, dbSetup, webDeploy, serverDeploy, backend) {
7039
- const instructions = [];
7426
+ const notes = [];
7427
+ const commands = [];
7040
7428
  const isD1Alchemy = dbSetup === "d1" && (serverDeploy === "cloudflare" || backend === "self" && webDeploy === "cloudflare");
7041
7429
  if (dbSetup === "docker") {
7042
7430
  const dockerStatus = await getDockerStatus(database);
7043
- if (dockerStatus.message) {
7044
- instructions.push(dockerStatus.message);
7045
- instructions.push("");
7046
- }
7431
+ if (dockerStatus.message) notes.push(dockerStatus.message);
7047
7432
  }
7048
7433
  if (isD1Alchemy) {
7049
- if (orm === "drizzle") instructions.push(`${pc.cyan("•")} Generate migrations: ${`${runCmd} db:generate`}`);
7434
+ if (orm === "drizzle") commands.push({
7435
+ label: "Generate migrations",
7436
+ command: `${runCmd} db:generate`
7437
+ });
7050
7438
  else if (orm === "prisma") {
7051
- instructions.push(`${pc.cyan("•")} Generate Prisma client: ${`${runCmd} db:generate`}`);
7052
- instructions.push(`${pc.cyan("•")} Apply migrations: ${`${runCmd} db:migrate`}`);
7439
+ commands.push({
7440
+ label: "Generate client",
7441
+ command: `${runCmd} db:generate`
7442
+ });
7443
+ commands.push({
7444
+ label: "Apply migrations",
7445
+ command: `${runCmd} db:migrate`
7446
+ });
7053
7447
  }
7054
7448
  }
7055
7449
  if (dbSetup === "planetscale") {
7056
- if (database === "mysql" && orm === "drizzle") instructions.push(`${pc.yellow("NOTE:")} Enable foreign key constraints in PlanetScale database settings`);
7057
- if (database === "mysql" && orm === "prisma") instructions.push(`${pc.yellow("NOTE:")} How to handle Prisma migrations with PlanetScale:\n https://github.com/prisma/prisma/issues/7292`);
7450
+ if (database === "mysql" && orm === "drizzle") notes.push(`${pc.yellow("NOTE:")} Enable foreign key constraints in PlanetScale database settings`);
7451
+ if (database === "mysql" && orm === "prisma") notes.push(`${pc.yellow("NOTE:")} How to handle Prisma migrations with PlanetScale:\n https://github.com/prisma/prisma/issues/7292`);
7058
7452
  }
7059
- if (dbSetup === "turso" && orm === "prisma") instructions.push(`${pc.yellow("NOTE:")} Follow Turso's Prisma guide for migrations via the Turso CLI:\n https://docs.turso.tech/sdk/ts/orm/prisma`);
7453
+ if (dbSetup === "turso" && orm === "prisma") notes.push(`${pc.yellow("NOTE:")} Follow Turso's Prisma guide for migrations via the Turso CLI:\n https://docs.turso.tech/sdk/ts/orm/prisma`);
7060
7454
  if (orm === "prisma") {
7061
- if (database === "mongodb" && dbSetup === "docker") instructions.push(`${pc.yellow("WARNING:")} Prisma + MongoDB + Docker combination\n may not work.`);
7062
- if (dbSetup === "docker") instructions.push(`${pc.cyan("•")} Start docker container: ${`${runCmd} db:start`}`);
7455
+ if (database === "mongodb" && dbSetup === "docker") notes.push(`${pc.yellow("WARNING:")} Prisma + MongoDB + Docker combination\n may not work.`);
7456
+ if (dbSetup === "docker") commands.push({
7457
+ label: "Start database",
7458
+ command: `${runCmd} db:start`
7459
+ });
7063
7460
  if (!isD1Alchemy) {
7064
- instructions.push(`${pc.cyan("•")} Generate Prisma Client: ${`${runCmd} db:generate`}`);
7065
- instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`);
7461
+ commands.push({
7462
+ label: "Generate client",
7463
+ command: `${runCmd} db:generate`
7464
+ });
7465
+ commands.push({
7466
+ label: "Apply schema",
7467
+ command: `${runCmd} db:push`
7468
+ });
7066
7469
  }
7067
- if (!isD1Alchemy) instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`);
7470
+ if (!isD1Alchemy) commands.push({
7471
+ label: "Open studio",
7472
+ command: `${runCmd} db:studio`
7473
+ });
7068
7474
  } else if (orm === "drizzle") {
7069
- if (dbSetup === "docker") instructions.push(`${pc.cyan("•")} Start docker container: ${`${runCmd} db:start`}`);
7070
- if (!isD1Alchemy) instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`);
7071
- if (!isD1Alchemy) instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`);
7475
+ if (dbSetup === "docker") commands.push({
7476
+ label: "Start database",
7477
+ command: `${runCmd} db:start`
7478
+ });
7479
+ if (!isD1Alchemy) commands.push({
7480
+ label: "Apply schema",
7481
+ command: `${runCmd} db:push`
7482
+ });
7483
+ if (!isD1Alchemy) commands.push({
7484
+ label: "Open studio",
7485
+ command: `${runCmd} db:studio`
7486
+ });
7072
7487
  } else if (orm === "mongoose") {
7073
- if (dbSetup === "docker") instructions.push(`${pc.cyan("•")} Start docker container: ${`${runCmd} db:start`}`);
7074
- } else if (orm === "none") instructions.push(`${pc.yellow("NOTE:")} Manual database schema setup\n required.`);
7075
- return instructions.length ? `${pc.bold("Database commands:")}\n${instructions.join("\n")}` : "";
7488
+ if (dbSetup === "docker") commands.push({
7489
+ label: "Start database",
7490
+ command: `${runCmd} db:start`
7491
+ });
7492
+ } else if (orm === "none") notes.push(`${pc.yellow("NOTE:")} Manual database schema setup required.`);
7493
+ if (notes.length === 0 && commands.length === 0) return "";
7494
+ const sections = [pc.bold("Database")];
7495
+ if (notes.length > 0) sections.push(notes.join("\n"));
7496
+ if (commands.length > 0) {
7497
+ const labelWidth = Math.max(...commands.map(({ label }) => label.length));
7498
+ const commandRows = commands.map(({ label, command }) => `${pc.dim(label.padEnd(labelWidth))} ${pc.cyan(command)}`);
7499
+ sections.push(commandRows.join("\n"));
7500
+ }
7501
+ return sections.join("\n");
7076
7502
  }
7077
7503
  function getTauriInstructions(runCmd, frontend) {
7078
7504
  const staticBuildNote = getDesktopStaticBuildNote(frontend);
@@ -7239,7 +7665,7 @@ async function createProject(options, cliInput = {}) {
7239
7665
  })
7240
7666
  }));
7241
7667
  yield* Result.await(formatProject(projectDir));
7242
- if (!isSilent()) log.success("Project template successfully scaffolded!");
7668
+ if (!isSilent()) log.success("Project scaffolded");
7243
7669
  if (options.install) yield* Result.await(installDependencies({
7244
7670
  projectDir,
7245
7671
  packageManager: options.packageManager
@@ -7334,19 +7760,16 @@ async function createProjectHandler(input, options = {}) {
7334
7760
  async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7335
7761
  return Result.gen(async function* () {
7336
7762
  if (!isSilent() && input.renderTitle !== false) renderTitle();
7337
- if (!isSilent()) intro(pc.magenta("Creating a new Better-T-Stack project"));
7338
- if (!isSilent() && input.yolo) cliConsola.fatal("YOLO mode enabled - skipping checks. Things may break!");
7763
+ if (!isSilent()) intro(pc.magenta("Configure your new project"));
7764
+ if (!isSilent() && input.yolo) log.warn(pc.yellow("YOLO mode enabled compatibility checks are disabled."));
7339
7765
  let currentPathInput;
7340
7766
  if (isSilent()) currentPathInput = yield* Result.await(resolveProjectNameForSilent(input));
7341
7767
  else if (input.yes && input.projectName) currentPathInput = input.projectName;
7342
7768
  else if (input.yes) {
7343
7769
  const defaultConfig = getDefaultConfig();
7344
7770
  let defaultName = defaultConfig.relativePath;
7345
- let counter = 1;
7346
- while (await fs.pathExists(path.resolve(process.cwd(), defaultName)) && (await fs.readdir(path.resolve(process.cwd(), defaultName))).length > 0) {
7347
- defaultName = `${defaultConfig.projectName}-${counter}`;
7348
- counter++;
7349
- }
7771
+ const defaultPathState = yield* Result.await(inspectProjectPath(path.resolve(process.cwd(), defaultName)));
7772
+ if (defaultPathState !== "missing" && defaultPathState !== "empty-directory") defaultName = yield* Result.await(findAvailableIncrementedPath(defaultConfig.projectName));
7350
7773
  currentPathInput = defaultName;
7351
7774
  } else currentPathInput = yield* Result.await(Result.tryPromise({
7352
7775
  try: async () => getProjectName(input.projectName),
@@ -7365,6 +7788,7 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7365
7788
  finalPathInput = conflictResult.finalPathInput;
7366
7789
  shouldClearDirectory = conflictResult.shouldClearDirectory;
7367
7790
  yield* validateResolvedProjectPathInput(finalPathInput);
7791
+ yield* Result.await(validateSafeProjectDirectoryPath(finalPathInput));
7368
7792
  let finalResolvedPath;
7369
7793
  let finalBaseName;
7370
7794
  if (input.dryRun) {
@@ -7395,10 +7819,7 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7395
7819
  if (templateConfig) {
7396
7820
  const templateName = input.template.toUpperCase();
7397
7821
  const templateDescription = getTemplateDescription(input.template);
7398
- if (!isSilent()) {
7399
- log.message(pc.bold(pc.cyan(`Using template: ${pc.white(templateName)}`)));
7400
- log.message(pc.dim(` ${templateDescription}`));
7401
- }
7822
+ if (!isSilent()) log.info(`${pc.dim("Template")} ${pc.bold(pc.cyan(templateName))}\n${pc.dim(templateDescription)}`);
7402
7823
  const userOverrides = {};
7403
7824
  for (const [key, value] of Object.entries(originalInput)) if (value !== void 0) userOverrides[key] = value;
7404
7825
  cliInput = {
@@ -7429,10 +7850,7 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7429
7850
  message: validationResult.error.message,
7430
7851
  cause: validationResult.error
7431
7852
  }));
7432
- if (!isSilent()) {
7433
- log.info(pc.yellow("Using default/flag options (config prompts skipped):"));
7434
- log.message(displayConfig(config));
7435
- }
7853
+ if (!isSilent()) log.info(pc.dim("Quick setup selected — using defaults and provided flags."));
7436
7854
  } else {
7437
7855
  const flagConfigResult = processAndValidateFlags(cliInput, providedFlags, finalBaseName);
7438
7856
  if (flagConfigResult.isErr()) return Result.err(new CLIError({
@@ -7441,13 +7859,10 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7441
7859
  }));
7442
7860
  const flagConfig = flagConfigResult.value;
7443
7861
  const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig;
7444
- if (!isSilent() && Object.keys(otherFlags).length > 0) {
7445
- log.info(pc.yellow("Using these pre-selected options:"));
7446
- log.message(displayConfig(otherFlags));
7447
- log.message("");
7448
- }
7862
+ const isTemplateSetup = input.template && input.template !== "none";
7863
+ if (!isSilent() && !isTemplateSetup && Object.keys(otherFlags).length > 0) log.info(pc.dim("Command-line options applied."));
7449
7864
  config = yield* Result.await(Result.tryPromise({
7450
- try: async () => gatherConfig(flagConfig, finalBaseName, finalResolvedPath, finalPathInput),
7865
+ try: async () => gatherConfig(flagConfig, finalBaseName, finalResolvedPath, finalPathInput, { skipCompatibilityChecks: cliInput.yolo }),
7451
7866
  catch: (e) => {
7452
7867
  if (e instanceof UserCancelledError) return e;
7453
7868
  return new CLIError({
@@ -7469,12 +7884,16 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7469
7884
  const addonsValidationResult = validateAddonsAgainstFrontends(config.addons, config.frontend, config.auth, config.backend, config.runtime);
7470
7885
  if (addonsValidationResult.isErr()) return Result.err(new CLIError({ message: addonsValidationResult.error.message }));
7471
7886
  }
7887
+ if (!isSilent()) {
7888
+ log.info(pc.magenta(pc.bold("Stack ready")));
7889
+ log.message(displayConfig(config));
7890
+ }
7472
7891
  const reproducibleCommand = generateReproducibleCommand(config);
7473
7892
  if (input.dryRun) {
7474
7893
  const elapsedTimeMs = Date.now() - startTime;
7475
7894
  if (!isSilent()) {
7476
7895
  if (shouldClearDirectory) log.warn(pc.yellow(`Dry run: directory "${finalPathInput}" would be cleared due to overwrite strategy.`));
7477
- log.success(pc.green("Dry run validation passed. No files were written."));
7896
+ log.success(pc.green("Configuration ready. No files were written."));
7478
7897
  log.message(pc.dim(`Target directory: ${finalResolvedPath}`));
7479
7898
  log.message(pc.dim(`Run without --dry-run to create the project.`));
7480
7899
  outro(pc.magenta("Dry run complete."));
@@ -7493,14 +7912,19 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7493
7912
  manualDb: cliInput.manualDb ?? input.manualDb,
7494
7913
  dbSetupOptions: effectiveDbSetupOptions
7495
7914
  }));
7496
- if (!isSilent()) log.success(pc.blue(`You can reproduce this setup with the following command:\n${reproducibleCommand}`));
7497
7915
  await trackProjectCreation(config, input.disableAnalytics);
7498
7916
  const historyResult = await addToHistory(config, reproducibleCommand);
7499
- if (historyResult.isErr() && !isSilent()) log.warn(pc.yellow(historyResult.error.message));
7917
+ if (historyResult.isErr() && !isSilent()) {
7918
+ log.warn(pc.yellow(historyResult.error.message));
7919
+ log.message(`${pc.dim("Recreate this stack")}\n${pc.cyan(reproducibleCommand)}`);
7920
+ } else if (!isSilent()) {
7921
+ const historyCommand = getCliSubcommandCommand("history", config.packageManager);
7922
+ log.message(`${pc.dim("Setup saved to history")}\n${pc.cyan(historyCommand)}`);
7923
+ }
7500
7924
  const elapsedTimeMs = Date.now() - startTime;
7501
7925
  if (!isSilent()) {
7502
- const elapsedTimeInSeconds = (elapsedTimeMs / 1e3).toFixed(2);
7503
- outro(pc.magenta(`Project created successfully in ${pc.bold(elapsedTimeInSeconds)} seconds!`));
7926
+ const elapsedTimeInSeconds = (elapsedTimeMs / 1e3).toFixed(1);
7927
+ outro(pc.magenta(`Project ready in ${pc.bold(`${elapsedTimeInSeconds}s`)}`));
7504
7928
  }
7505
7929
  return Result.ok({
7506
7930
  success: true,
@@ -7553,15 +7977,23 @@ async function handleDirectoryConflictResult(currentPathInput, strategy) {
7553
7977
  });
7554
7978
  }
7555
7979
  async function handleDirectoryConflictProgrammatically(currentPathInput, strategy) {
7556
- const currentPath = path.resolve(process.cwd(), currentPathInput);
7557
- if (!await fs.pathExists(currentPath)) return Result.ok({
7558
- finalPathInput: currentPathInput,
7559
- shouldClearDirectory: false
7560
- });
7561
- if (!((await fs.readdir(currentPath)).length > 0)) return Result.ok({
7980
+ const pathStateResult = await inspectProjectPath(path.resolve(process.cwd(), currentPathInput));
7981
+ if (pathStateResult.isErr()) return Result.err(pathStateResult.error);
7982
+ const pathState = pathStateResult.value;
7983
+ if (pathState === "missing" || pathState === "empty-directory") return Result.ok({
7562
7984
  finalPathInput: currentPathInput,
7563
7985
  shouldClearDirectory: false
7564
7986
  });
7987
+ if (strategy === "increment") {
7988
+ const incrementResult = await findAvailableIncrementedPath(currentPathInput);
7989
+ if (incrementResult.isErr()) return Result.err(incrementResult.error);
7990
+ return Result.ok({
7991
+ finalPathInput: incrementResult.value,
7992
+ shouldClearDirectory: false
7993
+ });
7994
+ }
7995
+ if (pathState === "symbolic-link") return Result.err(new CLIError({ message: `Project path "${currentPathInput}" is a symbolic link. Choose a real directory or use directoryConflict: "increment".` }));
7996
+ if (pathState === "non-directory") return Result.err(new CLIError({ message: `Project path "${currentPathInput}" exists and is not a directory. Choose a different path or use directoryConflict: "increment".` }));
7565
7997
  switch (strategy) {
7566
7998
  case "overwrite": return Result.ok({
7567
7999
  finalPathInput: currentPathInput,
@@ -7571,19 +8003,6 @@ async function handleDirectoryConflictProgrammatically(currentPathInput, strateg
7571
8003
  finalPathInput: currentPathInput,
7572
8004
  shouldClearDirectory: false
7573
8005
  });
7574
- case "increment": {
7575
- let counter = 1;
7576
- const baseName = currentPathInput;
7577
- let finalPathInput = `${baseName}-${counter}`;
7578
- while (await fs.pathExists(path.resolve(process.cwd(), finalPathInput)) && (await fs.readdir(path.resolve(process.cwd(), finalPathInput))).length > 0) {
7579
- counter++;
7580
- finalPathInput = `${baseName}-${counter}`;
7581
- }
7582
- return Result.ok({
7583
- finalPathInput,
7584
- shouldClearDirectory: false
7585
- });
7586
- }
7587
8006
  case "error": return Result.err(new DirectoryConflictError({ directory: currentPathInput }));
7588
8007
  default: return Result.err(new DirectoryConflictError({ directory: currentPathInput }));
7589
8008
  }
@@ -7618,6 +8037,10 @@ const SchemaNameSchema = z.enum([
7618
8037
  "betterTStackConfigFile",
7619
8038
  "initResult"
7620
8039
  ]).default("all");
8040
+ const CreateVirtualInputSchema = types_exports.ProjectConfigSchema.omit({
8041
+ projectDir: true,
8042
+ relativePath: true
8043
+ }).partial().strict();
7621
8044
  const t = initTRPC.meta().create();
7622
8045
  function getCliSchemaJson() {
7623
8046
  return createCli({
@@ -7673,7 +8096,7 @@ const router = t.router({
7673
8096
  projectName,
7674
8097
  ...options
7675
8098
  });
7676
- if (options.verbose || options.dryRun) return result;
8099
+ if (options.verbose) return result;
7677
8100
  }),
7678
8101
  createJson: t.procedure.meta({
7679
8102
  description: "Create a project from a raw JSON payload (agent-friendly)",
@@ -7692,7 +8115,8 @@ const router = t.router({
7692
8115
  addons: z.array(types_exports.AddonsSchema).optional().describe("Addons to add"),
7693
8116
  install: z.boolean().optional().default(false).describe("Install dependencies after adding"),
7694
8117
  packageManager: types_exports.PackageManagerSchema.optional().describe("Package manager to use"),
7695
- projectDir: z.string().optional().describe("Project directory (defaults to current)")
8118
+ projectDir: z.string().optional().describe("Project directory (defaults to current)"),
8119
+ dryRun: z.boolean().optional().default(false).describe("Preview addon changes without writing files")
7696
8120
  })).mutation(async ({ input }) => {
7697
8121
  await addHandler(input);
7698
8122
  }),
@@ -7720,6 +8144,12 @@ function createBtsCli() {
7720
8144
  version: getLatestCLIVersion()
7721
8145
  });
7722
8146
  }
8147
+ function formatInputValidationError(label, error) {
8148
+ return `Invalid ${label} input: ${error.issues.map((issue) => {
8149
+ const field = issue.path.join(".");
8150
+ return field ? `${field}: ${issue.message}` : issue.message;
8151
+ }).join("; ")}`;
8152
+ }
7723
8153
  /**
7724
8154
  * Programmatic API to create a new Better-T-Stack project.
7725
8155
  * Returns a Result type - no console output, no interactive prompts.
@@ -7746,13 +8176,21 @@ function createBtsCli() {
7746
8176
  * ```
7747
8177
  */
7748
8178
  async function create(projectName, options) {
7749
- const input = {
8179
+ const rawInput = options === void 0 || typeof options === "object" && options !== null ? {
7750
8180
  ...options,
7751
- projectName,
8181
+ projectName
8182
+ } : options;
8183
+ const parsedInput = types_exports.CreateInputSchema.safeParse(rawInput);
8184
+ if (!parsedInput.success) return Result.err(new CLIError({
8185
+ message: formatInputValidationError("create", parsedInput.error),
8186
+ cause: parsedInput.error
8187
+ }));
8188
+ const input = {
8189
+ ...parsedInput.data,
7752
8190
  renderTitle: false,
7753
8191
  verbose: true,
7754
- disableAnalytics: options?.disableAnalytics ?? true,
7755
- directoryConflict: options?.directoryConflict ?? "error"
8192
+ disableAnalytics: parsedInput.data.disableAnalytics ?? true,
8193
+ directoryConflict: parsedInput.data.directoryConflict ?? "error"
7756
8194
  };
7757
8195
  return Result.tryPromise({
7758
8196
  try: async () => {
@@ -7805,28 +8243,35 @@ async function builder() {
7805
8243
  * ```
7806
8244
  */
7807
8245
  async function createVirtual(options) {
8246
+ const parsedInput = CreateVirtualInputSchema.safeParse(options);
8247
+ if (!parsedInput.success) return Result.err(new GeneratorError({
8248
+ message: formatInputValidationError("virtual create", parsedInput.error),
8249
+ phase: "validation",
8250
+ cause: parsedInput.error
8251
+ }));
8252
+ const virtualOptions = parsedInput.data;
7808
8253
  const config = {
7809
- projectName: options.projectName || "my-project",
8254
+ projectName: virtualOptions.projectName || "my-project",
7810
8255
  projectDir: "/virtual",
7811
8256
  relativePath: "./virtual",
7812
- addonOptions: options.addonOptions,
7813
- dbSetupOptions: options.dbSetupOptions,
7814
- database: options.database || "none",
7815
- orm: options.orm || "none",
7816
- backend: options.backend || "hono",
7817
- runtime: options.runtime || "bun",
7818
- frontend: options.frontend || ["tanstack-router"],
7819
- addons: options.addons || [],
7820
- examples: options.examples || [],
7821
- auth: options.auth || "none",
7822
- payments: options.payments || "none",
7823
- git: options.git ?? false,
7824
- packageManager: options.packageManager || "bun",
8257
+ addonOptions: virtualOptions.addonOptions,
8258
+ dbSetupOptions: virtualOptions.dbSetupOptions,
8259
+ database: virtualOptions.database || "none",
8260
+ orm: virtualOptions.orm || "none",
8261
+ backend: virtualOptions.backend || "hono",
8262
+ runtime: virtualOptions.runtime || "bun",
8263
+ frontend: virtualOptions.frontend || ["tanstack-router"],
8264
+ addons: virtualOptions.addons || [],
8265
+ examples: virtualOptions.examples || [],
8266
+ auth: virtualOptions.auth || "none",
8267
+ payments: virtualOptions.payments || "none",
8268
+ git: virtualOptions.git ?? false,
8269
+ packageManager: virtualOptions.packageManager || "bun",
7825
8270
  install: false,
7826
- dbSetup: options.dbSetup || "none",
7827
- api: options.api || "trpc",
7828
- webDeploy: options.webDeploy || "none",
7829
- serverDeploy: options.serverDeploy || "none"
8271
+ dbSetup: virtualOptions.dbSetup || "none",
8272
+ api: virtualOptions.api || "trpc",
8273
+ webDeploy: virtualOptions.webDeploy || "none",
8274
+ serverDeploy: virtualOptions.serverDeploy || "none"
7830
8275
  };
7831
8276
  const validationResult = validateConfigCompatibility(config, new Set([
7832
8277
  "database",
@@ -7865,13 +8310,25 @@ async function createVirtual(options) {
7865
8310
  * install: true,
7866
8311
  * });
7867
8312
  *
7868
- * if (result?.success) {
8313
+ * if (result.success) {
7869
8314
  * console.log(`Added: ${result.addedAddons.join(", ")}`);
7870
8315
  * }
7871
8316
  * ```
7872
8317
  */
7873
8318
  async function add(options = {}) {
7874
- return addHandler(options, { silent: true });
8319
+ const parsedInput = types_exports.AddInputSchema.safeParse(options);
8320
+ if (!parsedInput.success) return {
8321
+ success: false,
8322
+ addedAddons: [],
8323
+ projectDir: "",
8324
+ error: formatInputValidationError("add", parsedInput.error)
8325
+ };
8326
+ return await addHandler(parsedInput.data, { silent: true }) ?? {
8327
+ success: false,
8328
+ addedAddons: [],
8329
+ projectDir: parsedInput.data.projectDir ?? "",
8330
+ error: "Operation cancelled"
8331
+ };
7875
8332
  }
7876
8333
  //#endregion
7877
8334
  export { ProjectCreationError as C, DirectoryConflictError as S, ValidationError as T, types_exports as _, TEMPLATE_COUNT as a, CompatibilityError as b, builder as c, createVirtual as d, docs as f, sponsors as g, router as h, SchemaNameSchema as i, create as l, getSchemaResult as m, GeneratorError$1 as n, VirtualFileSystem$1 as o, generate$1 as p, Result$1 as r, add as s, EMBEDDED_TEMPLATES$1 as t, createBtsCli as u, getLatestCLIVersion as v, UserCancelledError as w, DatabaseSetupError as x, CLIError as y };