create-better-t-stack 3.36.5 → 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,
@@ -2037,45 +2247,56 @@ async function navigableGroup(prompts, opts) {
2037
2247
  delete results[prevName];
2038
2248
  currentIndex--;
2039
2249
  };
2040
- while (currentIndex < promptNames.length) {
2041
- const name = promptNames[currentIndex];
2042
- const prompt = prompts[name];
2043
- setIsFirstPrompt$1(currentIndex === 0);
2044
- setLastPromptShownUI(false);
2045
- const result = await prompt({
2046
- results,
2047
- previousAnswer: previousAnswers[name]
2048
- })?.catch((e) => {
2049
- throw e;
2050
- });
2051
- if (isGoBack(result)) {
2052
- goingBack = true;
2053
- if (currentIndex > 0) {
2054
- 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;
2055
2277
  continue;
2056
2278
  }
2057
- goingBack = false;
2058
- continue;
2059
- }
2060
- if (isCancel$1(result)) {
2061
- if (typeof opts?.onCancel === "function") {
2062
- results[name] = "canceled";
2063
- 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;
2064
2285
  }
2065
- setIsFirstPrompt$1(false);
2066
- return results;
2067
- }
2068
- if (goingBack && !didLastPromptShowUI()) {
2069
- if (currentIndex > 0) {
2070
- stepBack();
2071
- continue;
2286
+ if (goingBack && !didLastPromptShowUI()) {
2287
+ if (currentIndex > 0) {
2288
+ stepBack();
2289
+ continue;
2290
+ }
2072
2291
  }
2292
+ goingBack = false;
2293
+ results[name] = result;
2294
+ currentIndex++;
2073
2295
  }
2074
- goingBack = false;
2075
- results[name] = result;
2076
- currentIndex++;
2296
+ } finally {
2297
+ setIsFirstPrompt$1(false);
2298
+ setPromptProgress(void 0);
2077
2299
  }
2078
- setIsFirstPrompt$1(false);
2079
2300
  return results;
2080
2301
  }
2081
2302
  //#endregion
@@ -3846,7 +4067,7 @@ async function installDependencies({ projectDir, packageManager }) {
3846
4067
  cause: e
3847
4068
  })
3848
4069
  });
3849
- if (result.isOk()) s.stop("Dependencies installed successfully");
4070
+ if (result.isOk()) s.stop("Dependencies installed");
3850
4071
  else s.stop(pc.red("Failed to install dependencies"));
3851
4072
  return result;
3852
4073
  }
@@ -3951,7 +4172,7 @@ async function addHandlerInternal(input) {
3951
4172
  }));
3952
4173
  if (!isSilent()) {
3953
4174
  renderTitle();
3954
- intro(pc.magenta("Add addons to your Better-T-Stack project"));
4175
+ intro(pc.magenta("Add to your project"));
3955
4176
  }
3956
4177
  const existingConfig = await detectProjectConfig(projectDir);
3957
4178
  if (!existingConfig) return Result.err(new CLIError({ message: `No Better-T-Stack project found in ${projectDir}. Make sure bts.jsonc exists.` }));
@@ -3960,7 +4181,10 @@ async function addHandlerInternal(input) {
3960
4181
  if (input.addons && input.addons.length > 0) {
3961
4182
  addonsToAdd = input.addons.filter((addon) => addon !== "none" && !existingConfig.addons.includes(addon));
3962
4183
  if (addonsToAdd.length === 0) {
3963
- 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
+ }
3964
4188
  return Result.ok({
3965
4189
  success: true,
3966
4190
  addedAddons: [],
@@ -3982,10 +4206,7 @@ async function addHandlerInternal(input) {
3982
4206
  if (promptResult.isErr()) return Result.err(promptResult.error);
3983
4207
  const selectedAddons = promptResult.value;
3984
4208
  if (selectedAddons.length === 0) {
3985
- if (!isSilent()) {
3986
- log.info(pc.dim("No addons selected."));
3987
- outro(pc.magenta("Nothing to add."));
3988
- }
4209
+ if (!isSilent()) outro(pc.dim("Nothing selected · project unchanged"));
3989
4210
  return Result.ok({
3990
4211
  success: true,
3991
4212
  addedAddons: [],
@@ -3997,7 +4218,7 @@ async function addHandlerInternal(input) {
3997
4218
  const updatedAddons = [...existingConfig.addons, ...addonsToAdd];
3998
4219
  const addonsValidationResult = validateAddonsAgainstConfig(updatedAddons, existingConfig);
3999
4220
  if (addonsValidationResult.isErr()) return Result.err(new CLIError({ message: addonsValidationResult.error.message }));
4000
- if (!isSilent()) log.info(pc.cyan(`Adding addons: ${addonsToAdd.join(", ")}`));
4221
+ if (!isSilent()) log.info(`${pc.dim("Adding")} ${pc.cyan(formatConfigValue(addonsToAdd))}`);
4001
4222
  const mergedAddonOptions = mergeAddonOptions(existingConfig.addonOptions, input.addonOptions);
4002
4223
  const config = {
4003
4224
  projectName: existingConfig.projectName,
@@ -4025,7 +4246,7 @@ async function addHandlerInternal(input) {
4025
4246
  ...config,
4026
4247
  addons: updatedAddons
4027
4248
  };
4028
- if (!isSilent()) log.info(pc.dim("Installing addon files..."));
4249
+ if (!isSilent()) log.info(pc.dim("Preparing addon files"));
4029
4250
  const vfs = new VirtualFileSystem();
4030
4251
  for (const pkgPath of ADD_PACKAGE_JSON_PATHS) {
4031
4252
  const fullPath = path.join(projectDir, pkgPath);
@@ -4057,9 +4278,9 @@ async function addHandlerInternal(input) {
4057
4278
  };
4058
4279
  if (input.dryRun) {
4059
4280
  if (!isSilent()) {
4060
- log.success(pc.green("Dry run validation passed. No addon files were written."));
4061
- log.info(pc.dim(`Planned addon files: ${vfs.getFileCount()}`));
4062
- 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"));
4063
4284
  }
4064
4285
  return Result.ok({
4065
4286
  success: true,
@@ -4090,17 +4311,17 @@ async function addHandlerInternal(input) {
4090
4311
  addons: updatedAddons,
4091
4312
  addonOptions: updatedConfig.addonOptions
4092
4313
  });
4093
- if (input.install) {
4094
- if (!isSilent()) log.info(pc.dim("Installing dependencies..."));
4095
- await installDependencies({
4096
- projectDir,
4097
- packageManager: config.packageManager
4098
- });
4099
- }
4314
+ if (input.install) await installDependencies({
4315
+ projectDir,
4316
+ packageManager: config.packageManager
4317
+ });
4100
4318
  if (!isSilent()) {
4101
- log.success(pc.green(`Successfully added: ${addonsToAdd.join(", ")}`));
4102
- if (!input.install) log.info(pc.yellow(`Run '${config.packageManager === "npm" ? "npm install" : `${config.packageManager} install`}' to install new dependencies.`));
4103
- 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"));
4104
4325
  }
4105
4326
  return Result.ok({
4106
4327
  success: true,
@@ -4133,7 +4354,7 @@ async function getApiChoice(Api, frontend, backend, previousValue) {
4133
4354
  hint: "No API layer (e.g. for full-stack frameworks like Next.js with Route Handlers)"
4134
4355
  });
4135
4356
  const apiType = await navigableSelect({
4136
- message: "Select API type",
4357
+ message: "Choose an API layer",
4137
4358
  options: apiOptions,
4138
4359
  initialValue: preferValidInitial(apiOptions, previousValue, apiOptions[0].value)
4139
4360
  });
@@ -4185,7 +4406,7 @@ async function getAuthChoice(auth, backend, frontend = [], previousValue) {
4185
4406
  }
4186
4407
  });
4187
4408
  const response = await navigableSelect({
4188
- message: "Select authentication provider",
4409
+ message: "Choose authentication",
4189
4410
  options,
4190
4411
  initialValue: preferValidInitial(options, previousValue, options.some((option) => option.value === DEFAULT_CONFIG.auth) ? DEFAULT_CONFIG.auth : "none")
4191
4412
  });
@@ -4239,7 +4460,7 @@ async function getBackendFrameworkChoice(backendFramework, frontends, previousVa
4239
4460
  hint: "No backend server"
4240
4461
  });
4241
4462
  const response = await navigableSelect({
4242
- message: "Select backend",
4463
+ message: "Choose a backend",
4243
4464
  options: backendOptions,
4244
4465
  initialValue: preferValidInitial(backendOptions, previousValue, hasFullstackFrontend ? "self" : DEFAULT_CONFIG.backend)
4245
4466
  });
@@ -4279,7 +4500,7 @@ async function getDatabaseChoice(database, backend, runtime, previousValue) {
4279
4500
  hint: "open-source NoSQL database that stores data in JSON-like documents called BSON"
4280
4501
  });
4281
4502
  const response = await navigableSelect({
4282
- message: "Select database",
4503
+ message: "Choose a database",
4283
4504
  options: databaseOptions,
4284
4505
  initialValue: preferValidInitial(databaseOptions, previousValue, DEFAULT_CONFIG.database)
4285
4506
  });
@@ -4378,7 +4599,7 @@ async function getDBSetupChoice(databaseType, dbSetup, _orm, backend, runtime, p
4378
4599
  ];
4379
4600
  else return "none";
4380
4601
  const response = await navigableSelect({
4381
- message: `Select ${databaseType} setup option`,
4602
+ message: `Choose a ${databaseType} setup`,
4382
4603
  options,
4383
4604
  initialValue: preferValidInitial(options, previousValue, "none")
4384
4605
  });
@@ -4404,7 +4625,7 @@ async function getExamplesChoice(examples, database, frontends, backend, api, pr
4404
4625
  });
4405
4626
  if (options.length === 0) return [];
4406
4627
  response = await navigableMultiselect({
4407
- message: "Include examples",
4628
+ message: "Include starter examples?",
4408
4629
  options,
4409
4630
  required: false,
4410
4631
  initialValues: (previousValue ?? DEFAULT_CONFIG.examples)?.filter((ex) => options.some((o) => o.value === ex))
@@ -4431,7 +4652,7 @@ async function getFrontendChoice(frontendOptions, backend, auth, previousValue)
4431
4652
  while (true) {
4432
4653
  const wasFirstPrompt = isFirstPrompt();
4433
4654
  const frontendTypes = await navigableMultiselect({
4434
- message: "Select project type",
4655
+ message: "What are you building?",
4435
4656
  options: [{
4436
4657
  value: "web",
4437
4658
  label: "Web",
@@ -4493,7 +4714,7 @@ async function getFrontendChoice(frontendOptions, backend, auth, previousValue)
4493
4714
  }
4494
4715
  ].filter((option) => isFrontendAllowedWithBackend(option.value, backend, auth));
4495
4716
  const webFramework = await navigableSelect({
4496
- message: "Choose web",
4717
+ message: "Choose a web framework",
4497
4718
  options: webOptions,
4498
4719
  initialValue: preferValidInitial(webOptions, previousWeb, DEFAULT_CONFIG.frontend[0])
4499
4720
  });
@@ -4507,7 +4728,7 @@ async function getFrontendChoice(frontendOptions, backend, auth, previousValue)
4507
4728
  }
4508
4729
  if (frontendTypes.includes("native")) {
4509
4730
  const nativeFramework = await navigableSelect({
4510
- message: "Choose native",
4731
+ message: "Choose a native setup",
4511
4732
  options: [
4512
4733
  {
4513
4734
  value: "native-bare",
@@ -4547,7 +4768,7 @@ async function getFrontendChoice(frontendOptions, backend, auth, previousValue)
4547
4768
  async function getGitChoice(git, previousValue) {
4548
4769
  if (git !== void 0) return git;
4549
4770
  const response = await navigableConfirm({
4550
- message: "Initialize git repository?",
4771
+ message: "Initialize a Git repository?",
4551
4772
  initialValue: previousValue ?? DEFAULT_CONFIG.git
4552
4773
  });
4553
4774
  if (isCancel$1(response)) throw new UserCancelledError({ message: "Operation cancelled" });
@@ -4799,7 +5020,7 @@ async function getORMChoice(orm, hasDatabase, database, backend, runtime, previo
4799
5020
  }
4800
5021
  const options = database === "mongodb" ? [ormOptions.prisma, ormOptions.mongoose] : [ormOptions.drizzle, ormOptions.prisma];
4801
5022
  const response = await navigableSelect({
4802
- message: "Select ORM",
5023
+ message: "Choose an ORM",
4803
5024
  options,
4804
5025
  initialValue: preferValidInitial(options, previousValue, database === "mongodb" ? "prisma" : runtime === "workers" ? "drizzle" : DEFAULT_CONFIG.orm)
4805
5026
  });
@@ -4851,7 +5072,7 @@ async function getPaymentsChoice(payments, auth, backend, _frontends, previousVa
4851
5072
  hint: "No payments integration"
4852
5073
  }];
4853
5074
  const response = await navigableSelect({
4854
- message: "Select payments provider",
5075
+ message: "Add payments?",
4855
5076
  options,
4856
5077
  initialValue: preferValidInitial(options, previousValue, DEFAULT_CONFIG.payments)
4857
5078
  });
@@ -4878,7 +5099,7 @@ async function getRuntimeChoice(runtime, backend, previousValue) {
4878
5099
  hint: "Edge runtime on Cloudflare's global network"
4879
5100
  });
4880
5101
  const response = await navigableSelect({
4881
- message: "Select runtime",
5102
+ message: "Choose a runtime",
4882
5103
  options: runtimeOptions,
4883
5104
  initialValue: preferValidInitial(runtimeOptions, previousValue, DEFAULT_CONFIG.runtime)
4884
5105
  });
@@ -4932,7 +5153,7 @@ async function getServerDeploymentChoice(deployment, runtime, backend, _webDeplo
4932
5153
  };
4933
5154
  });
4934
5155
  const response = await navigableSelect({
4935
- message: "Select server deployment",
5156
+ message: "Choose server deployment",
4936
5157
  options,
4937
5158
  initialValue: preferValidInitial(options, previousValue, DEFAULT_CONFIG.serverDeploy)
4938
5159
  });
@@ -4980,7 +5201,7 @@ async function getDeploymentChoice(deployment, _runtime, backend, frontend = [],
4980
5201
  };
4981
5202
  });
4982
5203
  const response = await navigableSelect({
4983
- message: "Select web deployment",
5204
+ message: "Choose web deployment",
4984
5205
  options,
4985
5206
  initialValue: preferValidInitial(options, previousValue, DEFAULT_CONFIG.webDeploy)
4986
5207
  });
@@ -4989,7 +5210,7 @@ async function getDeploymentChoice(deployment, _runtime, backend, frontend = [],
4989
5210
  }
4990
5211
  //#endregion
4991
5212
  //#region src/prompts/config-prompts.ts
4992
- async function gatherConfig(flags, projectName, projectDir, relativePath) {
5213
+ async function gatherConfig(flags, projectName, projectDir, relativePath, options = {}) {
4993
5214
  if (isSilent()) return {
4994
5215
  projectName,
4995
5216
  projectDir,
@@ -5017,22 +5238,63 @@ async function gatherConfig(flags, projectName, projectDir, relativePath) {
5017
5238
  frontend: ({ previousAnswer }) => getFrontendChoice(flags.frontend, flags.backend, flags.auth, previousAnswer),
5018
5239
  backend: ({ results, previousAnswer }) => getBackendFrameworkChoice(flags.backend, results.frontend, previousAnswer),
5019
5240
  runtime: ({ results, previousAnswer }) => getRuntimeChoice(flags.runtime, results.backend, previousAnswer),
5241
+ api: ({ results, previousAnswer }) => getApiChoice(flags.api, results.frontend, results.backend, previousAnswer),
5020
5242
  database: ({ results, previousAnswer }) => getDatabaseChoice(flags.database, results.backend, results.runtime, previousAnswer),
5021
5243
  orm: ({ results, previousAnswer }) => getORMChoice(flags.orm, results.database !== "none", results.database, results.backend, results.runtime, previousAnswer),
5022
- 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),
5023
5245
  auth: ({ results, previousAnswer }) => getAuthChoice(flags.auth, results.backend, results.frontend, previousAnswer),
5024
5246
  payments: ({ results, previousAnswer }) => getPaymentsChoice(flags.payments, results.auth, results.backend, results.frontend, previousAnswer),
5025
5247
  addons: ({ results, previousAnswer }) => getAddonsChoice(flags.addons, results.frontend, results.auth, results.backend, results.runtime, previousAnswer),
5026
5248
  examples: ({ results, previousAnswer }) => getExamplesChoice(flags.examples, results.database, results.frontend, results.backend, results.api, previousAnswer),
5027
- dbSetup: ({ results, previousAnswer }) => getDBSetupChoice(results.database ?? "none", flags.dbSetup, results.orm, results.backend, results.runtime, previousAnswer),
5028
5249
  webDeploy: ({ results, previousAnswer }) => getDeploymentChoice(flags.webDeploy, results.runtime, results.backend, results.frontend, results.dbSetup, previousAnswer),
5029
5250
  serverDeploy: ({ results, previousAnswer }) => getServerDeploymentChoice(flags.serverDeploy, results.runtime, results.backend, results.webDeploy, previousAnswer),
5030
5251
  git: ({ previousAnswer }) => getGitChoice(flags.git, previousAnswer),
5031
5252
  packageManager: ({ previousAnswer }) => getPackageManagerChoice(flags.packageManager, previousAnswer),
5032
5253
  install: ({ previousAnswer }) => getinstallChoice(flags.install, previousAnswer)
5033
- }, { onCancel: () => {
5034
- throw new UserCancelledError({ message: "Operation cancelled" });
5035
- } });
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
+ });
5036
5298
  return {
5037
5299
  projectName,
5038
5300
  projectDir,
@@ -5081,13 +5343,22 @@ async function getProjectName(initialName) {
5081
5343
  let projectPath = "";
5082
5344
  let defaultName = DEFAULT_CONFIG.projectName;
5083
5345
  let counter = 1;
5084
- 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;
5085
5356
  defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`;
5086
5357
  counter++;
5087
5358
  }
5088
5359
  while (!isValid) {
5089
5360
  const response = await text({
5090
- message: "Enter your project name or path (relative to current directory)",
5361
+ message: "Where should we create your project?",
5091
5362
  placeholder: defaultName,
5092
5363
  initialValue: initialName,
5093
5364
  defaultValue: defaultName,
@@ -5148,90 +5419,83 @@ async function trackProjectCreation(config, disableAnalytics = false) {
5148
5419
  });
5149
5420
  }
5150
5421
  //#endregion
5151
- //#region src/utils/display-config.ts
5152
- function displayConfig(config) {
5153
- const configDisplay = [];
5154
- if (config.projectName) configDisplay.push(`${pc.blue("Project Name:")} ${config.projectName}`);
5155
- if (config.frontend !== void 0) {
5156
- const frontend = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
5157
- const frontendText = frontend.length > 0 && frontend[0] !== void 0 ? frontend.join(", ") : "none";
5158
- configDisplay.push(`${pc.blue("Frontend:")} ${frontendText}`);
5159
- }
5160
- if (config.backend !== void 0) configDisplay.push(`${pc.blue("Backend:")} ${String(config.backend)}`);
5161
- if (config.runtime !== void 0) configDisplay.push(`${pc.blue("Runtime:")} ${String(config.runtime)}`);
5162
- if (config.api !== void 0) configDisplay.push(`${pc.blue("API:")} ${String(config.api)}`);
5163
- if (config.database !== void 0) configDisplay.push(`${pc.blue("Database:")} ${String(config.database)}`);
5164
- if (config.orm !== void 0) configDisplay.push(`${pc.blue("ORM:")} ${String(config.orm)}`);
5165
- if (config.auth !== void 0) configDisplay.push(`${pc.blue("Auth:")} ${String(config.auth)}`);
5166
- if (config.payments !== void 0) configDisplay.push(`${pc.blue("Payments:")} ${String(config.payments)}`);
5167
- if (config.addons !== void 0) {
5168
- const addons = Array.isArray(config.addons) ? config.addons : [config.addons];
5169
- const addonsText = addons.length > 0 && addons[0] !== void 0 ? addons.join(", ") : "none";
5170
- configDisplay.push(`${pc.blue("Addons:")} ${addonsText}`);
5171
- }
5172
- if (config.examples !== void 0) {
5173
- const examples = Array.isArray(config.examples) ? config.examples : [config.examples];
5174
- const examplesText = examples.length > 0 && examples[0] !== void 0 ? examples.join(", ") : "none";
5175
- configDisplay.push(`${pc.blue("Examples:")} ${examplesText}`);
5176
- }
5177
- if (config.git !== void 0) {
5178
- const gitText = typeof config.git === "boolean" ? config.git ? "Yes" : "No" : String(config.git);
5179
- configDisplay.push(`${pc.blue("Git Init:")} ${gitText}`);
5180
- }
5181
- if (config.packageManager !== void 0) configDisplay.push(`${pc.blue("Package Manager:")} ${String(config.packageManager)}`);
5182
- if (config.install !== void 0) {
5183
- const installText = typeof config.install === "boolean" ? config.install ? "Yes" : "No" : String(config.install);
5184
- configDisplay.push(`${pc.blue("Install Dependencies:")} ${installText}`);
5185
- }
5186
- if (config.dbSetup !== void 0) configDisplay.push(`${pc.blue("Database Setup:")} ${String(config.dbSetup)}`);
5187
- if (config.webDeploy !== void 0) configDisplay.push(`${pc.blue("Web Deployment:")} ${String(config.webDeploy)}`);
5188
- if (config.serverDeploy !== void 0) configDisplay.push(`${pc.blue("Server Deployment:")} ${String(config.serverDeploy)}`);
5189
- if (configDisplay.length === 0) return pc.yellow("No configuration selected.");
5190
- 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}`);
5191
5426
  }
5192
5427
  //#endregion
5193
5428
  //#region src/utils/project-directory.ts
5194
5429
  async function handleDirectoryConflict(currentPathInput) {
5195
5430
  while (true) {
5196
- const resolvedPath = path.resolve(process.cwd(), currentPathInput);
5197
- 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 {
5198
5435
  finalPathInput: currentPathInput,
5199
5436
  shouldClearDirectory: false
5200
5437
  };
5201
- 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.` });
5202
- 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
+ });
5203
5473
  const action = await select({
5204
- message: "What would you like to do?",
5205
- options: [
5206
- {
5207
- value: "overwrite",
5208
- label: "Overwrite",
5209
- hint: "Empty the directory and create the project"
5210
- },
5211
- {
5212
- value: "merge",
5213
- label: "Merge",
5214
- hint: "Create project files inside, potentially overwriting conflicts"
5215
- },
5216
- {
5217
- value: "rename",
5218
- label: "Choose a different name/path",
5219
- hint: "Keep the existing directory and create a new one"
5220
- },
5221
- {
5222
- value: "cancel",
5223
- label: "Cancel",
5224
- hint: "Abort the process"
5225
- }
5226
- ],
5227
- initialValue: "rename"
5474
+ message: "How should we continue?",
5475
+ options,
5476
+ initialValue: incrementedPath ? "increment" : "rename"
5228
5477
  });
5229
5478
  if (isCancel(action)) throw new UserCancelledError({ message: "Operation cancelled." });
5230
5479
  switch (action) {
5231
- case "overwrite": return {
5232
- finalPathInput: currentPathInput,
5233
- shouldClearDirectory: true
5480
+ case "increment": return {
5481
+ finalPathInput: incrementedPath,
5482
+ shouldClearDirectory: false
5234
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
+ }
5235
5499
  case "merge":
5236
5500
  log.info(`Proceeding into existing directory "${pc.yellow(currentPathInput)}". Files may be overwritten.`);
5237
5501
  return {
@@ -5239,8 +5503,8 @@ async function handleDirectoryConflict(currentPathInput) {
5239
5503
  shouldClearDirectory: false
5240
5504
  };
5241
5505
  case "rename":
5242
- log.info("Please choose a different project name or path.");
5243
- return await handleDirectoryConflict(await getProjectName(void 0));
5506
+ currentPathInput = await getProjectName(void 0);
5507
+ continue;
5244
5508
  case "cancel": throw new UserCancelledError({ message: "Operation cancelled." });
5245
5509
  }
5246
5510
  }
@@ -5255,9 +5519,11 @@ async function setupProjectDirectory(finalPathInput, shouldClearDirectory) {
5255
5519
  finalResolvedPath = path.resolve(process.cwd(), finalPathInput);
5256
5520
  finalBaseName = path.basename(finalResolvedPath);
5257
5521
  }
5522
+ const pathSafetyResult = await validateSafeProjectDirectoryPath(finalPathInput);
5523
+ if (pathSafetyResult.isErr()) throw pathSafetyResult.error;
5258
5524
  if (shouldClearDirectory) {
5259
- const s = spinner();
5260
- s.start(`Clearing directory "${finalResolvedPath}"...`);
5525
+ const s = isSilent() ? void 0 : spinner();
5526
+ s?.start(`Clearing directory "${finalResolvedPath}"...`);
5261
5527
  const clearResult = await Result.tryPromise({
5262
5528
  try: () => fs.emptyDir(finalResolvedPath),
5263
5529
  catch: (error) => new CLIError({
@@ -5266,16 +5532,78 @@ async function setupProjectDirectory(finalPathInput, shouldClearDirectory) {
5266
5532
  })
5267
5533
  });
5268
5534
  if (clearResult.isErr()) {
5269
- s.stop(pc.red(`Failed to clear directory "${finalResolvedPath}".`));
5535
+ s?.stop(pc.red(`Failed to clear directory "${finalResolvedPath}".`));
5270
5536
  throw clearResult.error;
5271
5537
  }
5272
- s.stop(`Directory "${finalResolvedPath}" cleared.`);
5538
+ s?.stop(`Directory "${finalResolvedPath}" cleared.`);
5273
5539
  } else await fs.ensureDir(finalResolvedPath);
5274
5540
  return {
5275
5541
  finalResolvedPath,
5276
5542
  finalBaseName
5277
5543
  };
5278
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
+ }
5279
5607
  //#endregion
5280
5608
  //#region src/utils/project-name-validation.ts
5281
5609
  function validateProjectName(name) {
@@ -6985,7 +7313,7 @@ async function displayPostInstallInstructions(config) {
6985
7313
  const polarInstructions = config.payments === "polar" && config.auth === "better-auth" ? getPolarInstructions(backend, packageManager) : "";
6986
7314
  const bunWebNativeWarning = packageManager === "bun" && hasNative && hasWeb ? getBunWebNativeWarning() : "";
6987
7315
  const noOrmWarning = !isConvex && database !== "none" && orm === "none" ? getNoOrmWarning() : "";
6988
- let output = `${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd}\n`;
7316
+ let output = `${pc.cyan("1.")} ${cdCmd}\n`;
6989
7317
  let stepCounter = 2;
6990
7318
  if (!depsInstalled) output += `${pc.cyan(`${stepCounter++}.`)} ${packageManager} install\n`;
6991
7319
  if (database === "sqlite" && dbSetup !== "d1") output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} db:local\n${pc.dim(" (optional - starts local SQLite database)")}\n`;
@@ -7003,19 +7331,44 @@ async function displayPostInstallInstructions(config) {
7003
7331
  }
7004
7332
  const hasStandaloneBackend = backend !== "none";
7005
7333
  if (hasWeb || hasStandaloneBackend || addons?.includes("starlight") || addons?.includes("fumadocs")) {
7006
- output += `${pc.bold("Your project will be available at:")}\n`;
7007
- if (hasWeb) output += `${pc.cyan("")} Frontend: http://localhost:${webPort}\n`;
7008
- 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";
7009
7341
  if (!isConvex && !isBackendSelf && hasStandaloneBackend) {
7010
- output += `${pc.cyan("•")} Backend API: http://localhost:3000\n`;
7011
- 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
+ });
7012
7350
  }
7013
7351
  if (isBackendSelf && api === "orpc") {
7014
7352
  const rpcPath = frontend?.includes("next") || frontend?.includes("tanstack-start") ? "/api/rpc" : "/rpc";
7015
- 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`;
7016
7371
  }
7017
- if (addons?.includes("starlight")) output += `${pc.cyan("•")} Docs: http://localhost:4321\n`;
7018
- if (addons?.includes("fumadocs")) output += `${pc.cyan("•")} Fumadocs: http://localhost:4000\n`;
7019
7372
  }
7020
7373
  if (nativeInstructions) output += `\n${nativeInstructions.trim()}\n`;
7021
7374
  if (databaseInstructions) output += `\n${databaseInstructions.trim()}\n`;
@@ -7035,10 +7388,15 @@ async function displayPostInstallInstructions(config) {
7035
7388
  if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`;
7036
7389
  const sponsorsResult = await fetchSponsorsQuietly();
7037
7390
  const specialSponsorsSection = sponsorsResult.isOk() ? formatPostInstallSpecialSponsorsSection(sponsorsResult.value) : "";
7038
- if (specialSponsorsSection) output += `\n${specialSponsorsSection.trim()}\n`;
7039
- output += `\n${pc.bold("Like Better-T-Stack?")} Please consider giving us a star\n on GitHub:\n`;
7040
- output += pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack");
7041
- 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")}`);
7042
7400
  }
7043
7401
  function getNativeInstructions(isConvex, isBackendSelf, frontend, runCmd) {
7044
7402
  const envVar = isConvex ? "EXPO_PUBLIC_CONVEX_URL" : "EXPO_PUBLIC_SERVER_URL";
@@ -7065,43 +7423,82 @@ function getVitePlusNativeHooksInstructions(runCmd) {
7065
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`;
7066
7424
  }
7067
7425
  async function getDatabaseInstructions(database, orm, runCmd, _runtime, dbSetup, webDeploy, serverDeploy, backend) {
7068
- const instructions = [];
7426
+ const notes = [];
7427
+ const commands = [];
7069
7428
  const isD1Alchemy = dbSetup === "d1" && (serverDeploy === "cloudflare" || backend === "self" && webDeploy === "cloudflare");
7070
7429
  if (dbSetup === "docker") {
7071
7430
  const dockerStatus = await getDockerStatus(database);
7072
- if (dockerStatus.message) {
7073
- instructions.push(dockerStatus.message);
7074
- instructions.push("");
7075
- }
7431
+ if (dockerStatus.message) notes.push(dockerStatus.message);
7076
7432
  }
7077
7433
  if (isD1Alchemy) {
7078
- 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
+ });
7079
7438
  else if (orm === "prisma") {
7080
- instructions.push(`${pc.cyan("•")} Generate Prisma client: ${`${runCmd} db:generate`}`);
7081
- 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
+ });
7082
7447
  }
7083
7448
  }
7084
7449
  if (dbSetup === "planetscale") {
7085
- if (database === "mysql" && orm === "drizzle") instructions.push(`${pc.yellow("NOTE:")} Enable foreign key constraints in PlanetScale database settings`);
7086
- 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`);
7087
7452
  }
7088
- 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`);
7089
7454
  if (orm === "prisma") {
7090
- if (database === "mongodb" && dbSetup === "docker") instructions.push(`${pc.yellow("WARNING:")} Prisma + MongoDB + Docker combination\n may not work.`);
7091
- 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
+ });
7092
7460
  if (!isD1Alchemy) {
7093
- instructions.push(`${pc.cyan("•")} Generate Prisma Client: ${`${runCmd} db:generate`}`);
7094
- 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
+ });
7095
7469
  }
7096
- 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
+ });
7097
7474
  } else if (orm === "drizzle") {
7098
- if (dbSetup === "docker") instructions.push(`${pc.cyan("•")} Start docker container: ${`${runCmd} db:start`}`);
7099
- if (!isD1Alchemy) instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`);
7100
- 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
+ });
7101
7487
  } else if (orm === "mongoose") {
7102
- if (dbSetup === "docker") instructions.push(`${pc.cyan("•")} Start docker container: ${`${runCmd} db:start`}`);
7103
- } else if (orm === "none") instructions.push(`${pc.yellow("NOTE:")} Manual database schema setup\n required.`);
7104
- 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");
7105
7502
  }
7106
7503
  function getTauriInstructions(runCmd, frontend) {
7107
7504
  const staticBuildNote = getDesktopStaticBuildNote(frontend);
@@ -7268,7 +7665,7 @@ async function createProject(options, cliInput = {}) {
7268
7665
  })
7269
7666
  }));
7270
7667
  yield* Result.await(formatProject(projectDir));
7271
- if (!isSilent()) log.success("Project template successfully scaffolded!");
7668
+ if (!isSilent()) log.success("Project scaffolded");
7272
7669
  if (options.install) yield* Result.await(installDependencies({
7273
7670
  projectDir,
7274
7671
  packageManager: options.packageManager
@@ -7363,19 +7760,16 @@ async function createProjectHandler(input, options = {}) {
7363
7760
  async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7364
7761
  return Result.gen(async function* () {
7365
7762
  if (!isSilent() && input.renderTitle !== false) renderTitle();
7366
- if (!isSilent()) intro(pc.magenta("Creating a new Better-T-Stack project"));
7367
- 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."));
7368
7765
  let currentPathInput;
7369
7766
  if (isSilent()) currentPathInput = yield* Result.await(resolveProjectNameForSilent(input));
7370
7767
  else if (input.yes && input.projectName) currentPathInput = input.projectName;
7371
7768
  else if (input.yes) {
7372
7769
  const defaultConfig = getDefaultConfig();
7373
7770
  let defaultName = defaultConfig.relativePath;
7374
- let counter = 1;
7375
- while (await fs.pathExists(path.resolve(process.cwd(), defaultName)) && (await fs.readdir(path.resolve(process.cwd(), defaultName))).length > 0) {
7376
- defaultName = `${defaultConfig.projectName}-${counter}`;
7377
- counter++;
7378
- }
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));
7379
7773
  currentPathInput = defaultName;
7380
7774
  } else currentPathInput = yield* Result.await(Result.tryPromise({
7381
7775
  try: async () => getProjectName(input.projectName),
@@ -7394,6 +7788,7 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7394
7788
  finalPathInput = conflictResult.finalPathInput;
7395
7789
  shouldClearDirectory = conflictResult.shouldClearDirectory;
7396
7790
  yield* validateResolvedProjectPathInput(finalPathInput);
7791
+ yield* Result.await(validateSafeProjectDirectoryPath(finalPathInput));
7397
7792
  let finalResolvedPath;
7398
7793
  let finalBaseName;
7399
7794
  if (input.dryRun) {
@@ -7424,10 +7819,7 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7424
7819
  if (templateConfig) {
7425
7820
  const templateName = input.template.toUpperCase();
7426
7821
  const templateDescription = getTemplateDescription(input.template);
7427
- if (!isSilent()) {
7428
- log.message(pc.bold(pc.cyan(`Using template: ${pc.white(templateName)}`)));
7429
- log.message(pc.dim(` ${templateDescription}`));
7430
- }
7822
+ if (!isSilent()) log.info(`${pc.dim("Template")} ${pc.bold(pc.cyan(templateName))}\n${pc.dim(templateDescription)}`);
7431
7823
  const userOverrides = {};
7432
7824
  for (const [key, value] of Object.entries(originalInput)) if (value !== void 0) userOverrides[key] = value;
7433
7825
  cliInput = {
@@ -7458,10 +7850,7 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7458
7850
  message: validationResult.error.message,
7459
7851
  cause: validationResult.error
7460
7852
  }));
7461
- if (!isSilent()) {
7462
- log.info(pc.yellow("Using default/flag options (config prompts skipped):"));
7463
- log.message(displayConfig(config));
7464
- }
7853
+ if (!isSilent()) log.info(pc.dim("Quick setup selected — using defaults and provided flags."));
7465
7854
  } else {
7466
7855
  const flagConfigResult = processAndValidateFlags(cliInput, providedFlags, finalBaseName);
7467
7856
  if (flagConfigResult.isErr()) return Result.err(new CLIError({
@@ -7470,13 +7859,10 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7470
7859
  }));
7471
7860
  const flagConfig = flagConfigResult.value;
7472
7861
  const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig;
7473
- if (!isSilent() && Object.keys(otherFlags).length > 0) {
7474
- log.info(pc.yellow("Using these pre-selected options:"));
7475
- log.message(displayConfig(otherFlags));
7476
- log.message("");
7477
- }
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."));
7478
7864
  config = yield* Result.await(Result.tryPromise({
7479
- try: async () => gatherConfig(flagConfig, finalBaseName, finalResolvedPath, finalPathInput),
7865
+ try: async () => gatherConfig(flagConfig, finalBaseName, finalResolvedPath, finalPathInput, { skipCompatibilityChecks: cliInput.yolo }),
7480
7866
  catch: (e) => {
7481
7867
  if (e instanceof UserCancelledError) return e;
7482
7868
  return new CLIError({
@@ -7498,12 +7884,16 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7498
7884
  const addonsValidationResult = validateAddonsAgainstFrontends(config.addons, config.frontend, config.auth, config.backend, config.runtime);
7499
7885
  if (addonsValidationResult.isErr()) return Result.err(new CLIError({ message: addonsValidationResult.error.message }));
7500
7886
  }
7887
+ if (!isSilent()) {
7888
+ log.info(pc.magenta(pc.bold("Stack ready")));
7889
+ log.message(displayConfig(config));
7890
+ }
7501
7891
  const reproducibleCommand = generateReproducibleCommand(config);
7502
7892
  if (input.dryRun) {
7503
7893
  const elapsedTimeMs = Date.now() - startTime;
7504
7894
  if (!isSilent()) {
7505
7895
  if (shouldClearDirectory) log.warn(pc.yellow(`Dry run: directory "${finalPathInput}" would be cleared due to overwrite strategy.`));
7506
- log.success(pc.green("Dry run validation passed. No files were written."));
7896
+ log.success(pc.green("Configuration ready. No files were written."));
7507
7897
  log.message(pc.dim(`Target directory: ${finalResolvedPath}`));
7508
7898
  log.message(pc.dim(`Run without --dry-run to create the project.`));
7509
7899
  outro(pc.magenta("Dry run complete."));
@@ -7522,14 +7912,19 @@ async function createProjectHandlerInternal(input, startTime, timeScaffolded) {
7522
7912
  manualDb: cliInput.manualDb ?? input.manualDb,
7523
7913
  dbSetupOptions: effectiveDbSetupOptions
7524
7914
  }));
7525
- if (!isSilent()) log.success(pc.blue(`You can reproduce this setup with the following command:\n${reproducibleCommand}`));
7526
7915
  await trackProjectCreation(config, input.disableAnalytics);
7527
7916
  const historyResult = await addToHistory(config, reproducibleCommand);
7528
- 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
+ }
7529
7924
  const elapsedTimeMs = Date.now() - startTime;
7530
7925
  if (!isSilent()) {
7531
- const elapsedTimeInSeconds = (elapsedTimeMs / 1e3).toFixed(2);
7532
- 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`)}`));
7533
7928
  }
7534
7929
  return Result.ok({
7535
7930
  success: true,
@@ -7582,15 +7977,23 @@ async function handleDirectoryConflictResult(currentPathInput, strategy) {
7582
7977
  });
7583
7978
  }
7584
7979
  async function handleDirectoryConflictProgrammatically(currentPathInput, strategy) {
7585
- const currentPath = path.resolve(process.cwd(), currentPathInput);
7586
- if (!await fs.pathExists(currentPath)) return Result.ok({
7587
- finalPathInput: currentPathInput,
7588
- shouldClearDirectory: false
7589
- });
7590
- 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({
7591
7984
  finalPathInput: currentPathInput,
7592
7985
  shouldClearDirectory: false
7593
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".` }));
7594
7997
  switch (strategy) {
7595
7998
  case "overwrite": return Result.ok({
7596
7999
  finalPathInput: currentPathInput,
@@ -7600,19 +8003,6 @@ async function handleDirectoryConflictProgrammatically(currentPathInput, strateg
7600
8003
  finalPathInput: currentPathInput,
7601
8004
  shouldClearDirectory: false
7602
8005
  });
7603
- case "increment": {
7604
- let counter = 1;
7605
- const baseName = currentPathInput;
7606
- let finalPathInput = `${baseName}-${counter}`;
7607
- while (await fs.pathExists(path.resolve(process.cwd(), finalPathInput)) && (await fs.readdir(path.resolve(process.cwd(), finalPathInput))).length > 0) {
7608
- counter++;
7609
- finalPathInput = `${baseName}-${counter}`;
7610
- }
7611
- return Result.ok({
7612
- finalPathInput,
7613
- shouldClearDirectory: false
7614
- });
7615
- }
7616
8006
  case "error": return Result.err(new DirectoryConflictError({ directory: currentPathInput }));
7617
8007
  default: return Result.err(new DirectoryConflictError({ directory: currentPathInput }));
7618
8008
  }
@@ -7647,6 +8037,10 @@ const SchemaNameSchema = z.enum([
7647
8037
  "betterTStackConfigFile",
7648
8038
  "initResult"
7649
8039
  ]).default("all");
8040
+ const CreateVirtualInputSchema = types_exports.ProjectConfigSchema.omit({
8041
+ projectDir: true,
8042
+ relativePath: true
8043
+ }).partial().strict();
7650
8044
  const t = initTRPC.meta().create();
7651
8045
  function getCliSchemaJson() {
7652
8046
  return createCli({
@@ -7702,7 +8096,7 @@ const router = t.router({
7702
8096
  projectName,
7703
8097
  ...options
7704
8098
  });
7705
- if (options.verbose || options.dryRun) return result;
8099
+ if (options.verbose) return result;
7706
8100
  }),
7707
8101
  createJson: t.procedure.meta({
7708
8102
  description: "Create a project from a raw JSON payload (agent-friendly)",
@@ -7721,7 +8115,8 @@ const router = t.router({
7721
8115
  addons: z.array(types_exports.AddonsSchema).optional().describe("Addons to add"),
7722
8116
  install: z.boolean().optional().default(false).describe("Install dependencies after adding"),
7723
8117
  packageManager: types_exports.PackageManagerSchema.optional().describe("Package manager to use"),
7724
- 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")
7725
8120
  })).mutation(async ({ input }) => {
7726
8121
  await addHandler(input);
7727
8122
  }),
@@ -7749,6 +8144,12 @@ function createBtsCli() {
7749
8144
  version: getLatestCLIVersion()
7750
8145
  });
7751
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
+ }
7752
8153
  /**
7753
8154
  * Programmatic API to create a new Better-T-Stack project.
7754
8155
  * Returns a Result type - no console output, no interactive prompts.
@@ -7775,13 +8176,21 @@ function createBtsCli() {
7775
8176
  * ```
7776
8177
  */
7777
8178
  async function create(projectName, options) {
7778
- const input = {
8179
+ const rawInput = options === void 0 || typeof options === "object" && options !== null ? {
7779
8180
  ...options,
7780
- 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,
7781
8190
  renderTitle: false,
7782
8191
  verbose: true,
7783
- disableAnalytics: options?.disableAnalytics ?? true,
7784
- directoryConflict: options?.directoryConflict ?? "error"
8192
+ disableAnalytics: parsedInput.data.disableAnalytics ?? true,
8193
+ directoryConflict: parsedInput.data.directoryConflict ?? "error"
7785
8194
  };
7786
8195
  return Result.tryPromise({
7787
8196
  try: async () => {
@@ -7834,28 +8243,35 @@ async function builder() {
7834
8243
  * ```
7835
8244
  */
7836
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;
7837
8253
  const config = {
7838
- projectName: options.projectName || "my-project",
8254
+ projectName: virtualOptions.projectName || "my-project",
7839
8255
  projectDir: "/virtual",
7840
8256
  relativePath: "./virtual",
7841
- addonOptions: options.addonOptions,
7842
- dbSetupOptions: options.dbSetupOptions,
7843
- database: options.database || "none",
7844
- orm: options.orm || "none",
7845
- backend: options.backend || "hono",
7846
- runtime: options.runtime || "bun",
7847
- frontend: options.frontend || ["tanstack-router"],
7848
- addons: options.addons || [],
7849
- examples: options.examples || [],
7850
- auth: options.auth || "none",
7851
- payments: options.payments || "none",
7852
- git: options.git ?? false,
7853
- 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",
7854
8270
  install: false,
7855
- dbSetup: options.dbSetup || "none",
7856
- api: options.api || "trpc",
7857
- webDeploy: options.webDeploy || "none",
7858
- serverDeploy: options.serverDeploy || "none"
8271
+ dbSetup: virtualOptions.dbSetup || "none",
8272
+ api: virtualOptions.api || "trpc",
8273
+ webDeploy: virtualOptions.webDeploy || "none",
8274
+ serverDeploy: virtualOptions.serverDeploy || "none"
7859
8275
  };
7860
8276
  const validationResult = validateConfigCompatibility(config, new Set([
7861
8277
  "database",
@@ -7894,13 +8310,25 @@ async function createVirtual(options) {
7894
8310
  * install: true,
7895
8311
  * });
7896
8312
  *
7897
- * if (result?.success) {
8313
+ * if (result.success) {
7898
8314
  * console.log(`Added: ${result.addedAddons.join(", ")}`);
7899
8315
  * }
7900
8316
  * ```
7901
8317
  */
7902
8318
  async function add(options = {}) {
7903
- 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
+ };
7904
8332
  }
7905
8333
  //#endregion
7906
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 };