UNPKG

create-better-t-stack

Version:

A modern CLI tool for scaffolding end-to-end type-safe TypeScript projects with best practices and customizable configurations

1,306 lines (1,283 loc) 230 kB
#!/usr/bin/env node import { t as __reExport } from "./chunk-CHc3S52W.mjs"; import { createRouterClient, os } from "@orpc/server"; import { Result, Result as Result$1, TaggedError } from "better-result"; import { createCli } from "trpc-cli"; import z from "zod"; import { autocompleteMultiselect, cancel, confirm, group, intro, isCancel, log, multiselect, outro, select, spinner, text } from "@clack/prompts"; import pc from "picocolors"; import envPaths from "env-paths"; import fs from "fs-extra"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { EMBEDDED_TEMPLATES, EMBEDDED_TEMPLATES as EMBEDDED_TEMPLATES$1, GeneratorError, GeneratorError as GeneratorError$1, TEMPLATE_COUNT, VirtualFileSystem, VirtualFileSystem as VirtualFileSystem$1, dependencyVersionMap, generate, generate as generate$1, generateReproducibleCommand, processAddonTemplates, processAddonsDeps } from "@better-t-stack/template-generator"; import consola, { consola as consola$1 } from "consola"; import gradient from "gradient-string"; import { $, execa } from "execa"; import { writeTree } from "@better-t-stack/template-generator/fs-writer"; import { ConfirmPrompt, GroupMultiSelectPrompt, MultiSelectPrompt, SelectPrompt, isCancel as isCancel$1 } from "@clack/core"; import { AsyncLocalStorage } from "node:async_hooks"; import { applyEdits, modify, parse } from "jsonc-parser"; import os$1 from "node:os"; import { format } from "oxfmt"; //#region src/utils/get-package-manager.ts const getUserPkgManager = () => { const userAgent = process.env.npm_config_user_agent; if (userAgent?.startsWith("pnpm")) return "pnpm"; if (userAgent?.startsWith("bun")) return "bun"; return "npm"; }; //#endregion //#region src/constants.ts const __filename = fileURLToPath(import.meta.url); const distPath = path.dirname(__filename); const PKG_ROOT = path.join(distPath, "../"); const DEFAULT_CONFIG_BASE = { projectName: "my-better-t-app", relativePath: "my-better-t-app", frontend: ["tanstack-router"], database: "sqlite", orm: "drizzle", auth: "better-auth", payments: "none", addons: ["turborepo"], examples: [], git: true, install: true, dbSetup: "none", backend: "hono", runtime: "bun", api: "trpc", webDeploy: "none", serverDeploy: "none" }; function getDefaultConfig() { return { ...DEFAULT_CONFIG_BASE, projectDir: path.resolve(process.cwd(), DEFAULT_CONFIG_BASE.projectName), packageManager: getUserPkgManager(), frontend: [...DEFAULT_CONFIG_BASE.frontend], addons: [...DEFAULT_CONFIG_BASE.addons], examples: [...DEFAULT_CONFIG_BASE.examples] }; } const DEFAULT_CONFIG = getDefaultConfig(); const ADDON_COMPATIBILITY = { pwa: [ "tanstack-router", "react-router", "solid", "next" ], tauri: [ "tanstack-router", "react-router", "nuxt", "svelte", "solid", "next" ], biome: [], husky: [], lefthook: [], turborepo: [], starlight: [], ultracite: [], ruler: [], mcp: [], oxlint: [], fumadocs: [], opentui: [], wxt: [], skills: [], none: [] }; //#endregion //#region src/utils/errors.ts /** * User cancelled the operation (e.g., Ctrl+C in prompts) */ var UserCancelledError = class extends TaggedError("UserCancelledError")() { constructor(args) { super({ message: args?.message ?? "Operation cancelled" }); } }; /** * General CLI error for validation failures, invalid flags, etc. */ var CLIError = class extends TaggedError("CLIError")() {}; /** * Validation error for config/flag validation failures */ var ValidationError = class extends TaggedError("ValidationError")() { constructor(args) { super(args); } }; /** * Compatibility error for incompatible option combinations */ var CompatibilityError = class extends TaggedError("CompatibilityError")() { constructor(args) { super(args); } }; /** * Directory conflict error when target directory exists and is not empty */ var DirectoryConflictError = class extends TaggedError("DirectoryConflictError")() { constructor(args) { super({ directory: args.directory, message: `Directory "${args.directory}" already exists and is not empty. Use directoryConflict: "overwrite", "merge", or "increment" to handle this.` }); } }; /** * Project creation error for failures during scaffolding */ var ProjectCreationError = class extends TaggedError("ProjectCreationError")() { constructor(args) { super(args); } }; /** * Database setup error for failures during database configuration */ var DatabaseSetupError = class extends TaggedError("DatabaseSetupError")() { constructor(args) { super(args); } }; /** * Addon setup error for failures during addon configuration */ var AddonSetupError = class extends TaggedError("AddonSetupError")() { constructor(args) { super(args); } }; /** * Create a user cancelled error Result */ function userCancelled(message) { return Result.err(new UserCancelledError({ message })); } /** * Create a database setup error Result */ function databaseSetupError(provider, message, cause) { return Result.err(new DatabaseSetupError({ provider, message, cause })); } /** * Display an error to the user (for CLI mode) */ function displayError(error) { if (UserCancelledError.is(error)) cancel(pc.red(error.message)); else consola.error(pc.red(error.message)); } //#endregion //#region src/utils/get-latest-cli-version.ts function getLatestCLIVersionResult() { const packageJsonPath = path.join(PKG_ROOT, "package.json"); return Result.try({ try: () => { return fs.readJSONSync(packageJsonPath).version; }, catch: (e) => new CLIError({ message: `Failed to read CLI version from package.json: ${e instanceof Error ? e.message : String(e)}`, cause: e }) }); } function getLatestCLIVersion() { return getLatestCLIVersionResult().unwrapOr("1.0.0"); } //#endregion //#region src/utils/project-history.ts const paths = envPaths("better-t-stack", { suffix: "" }); const HISTORY_FILE = "history.json"; var HistoryError = class extends TaggedError("HistoryError")() {}; function getHistoryDir() { return paths.data; } function getHistoryPath() { return path.join(paths.data, HISTORY_FILE); } function generateId() { return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } function emptyHistory() { return { version: 1, entries: [] }; } async function ensureHistoryDir() { return Result.tryPromise({ try: async () => { await fs.ensureDir(getHistoryDir()); }, catch: (e) => new HistoryError({ message: `Failed to create history directory: ${e instanceof Error ? e.message : String(e)}`, cause: e }) }); } async function readHistory() { const historyPath = getHistoryPath(); const existsResult = await Result.tryPromise({ try: async () => await fs.pathExists(historyPath), catch: (e) => new HistoryError({ message: `Failed to check history file: ${e instanceof Error ? e.message : String(e)}`, cause: e }) }); if (existsResult.isErr()) return existsResult; if (!existsResult.value) return Result.ok(emptyHistory()); const readResult = await Result.tryPromise({ try: async () => await fs.readJson(historyPath), catch: (e) => new HistoryError({ message: `Failed to read history file: ${e instanceof Error ? e.message : String(e)}`, cause: e }) }); if (readResult.isErr()) return Result.ok(emptyHistory()); return Result.ok(readResult.value); } async function writeHistory(history) { const ensureDirResult = await ensureHistoryDir(); if (ensureDirResult.isErr()) return ensureDirResult; return Result.tryPromise({ try: async () => { await fs.writeJson(getHistoryPath(), history, { spaces: 2 }); }, catch: (e) => new HistoryError({ message: `Failed to write history file: ${e instanceof Error ? e.message : String(e)}`, cause: e }) }); } async function addToHistory(config, reproducibleCommand) { const historyResult = await readHistory(); if (historyResult.isErr()) return historyResult; const history = historyResult.value; const entry = { id: generateId(), projectName: config.projectName, projectDir: config.projectDir, createdAt: (/* @__PURE__ */ new Date()).toISOString(), stack: { frontend: config.frontend, backend: config.backend, database: config.database, orm: config.orm, runtime: config.runtime, auth: config.auth, payments: config.payments, api: config.api, addons: config.addons, examples: config.examples, dbSetup: config.dbSetup, packageManager: config.packageManager }, cliVersion: getLatestCLIVersion(), reproducibleCommand }; history.entries.unshift(entry); if (history.entries.length > 100) history.entries = history.entries.slice(0, 100); return await writeHistory(history); } async function getHistory(limit = 10) { const historyResult = await readHistory(); if (historyResult.isErr()) return historyResult; return Result.ok(historyResult.value.entries.slice(0, limit)); } async function clearHistory() { const historyPath = getHistoryPath(); return Result.tryPromise({ try: async () => { if (await fs.pathExists(historyPath)) await fs.remove(historyPath); }, catch: (e) => new HistoryError({ message: `Failed to clear history: ${e instanceof Error ? e.message : String(e)}`, cause: e }) }); } //#endregion //#region src/utils/render-title.ts const TITLE_TEXT = ` ██████╗ ███████╗████████╗████████╗███████╗██████╗ ██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ ██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝ ██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗ ██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗ ╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝ ██║ ███████╗ ██║ ███████║██║ █████╔╝ ██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ ██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ `; const catppuccinTheme = { pink: "#F5C2E7", mauve: "#CBA6F7", red: "#F38BA8", maroon: "#E78284", peach: "#FAB387", yellow: "#F9E2AF", green: "#A6E3A1", teal: "#94E2D5", sky: "#89DCEB", sapphire: "#74C7EC", lavender: "#B4BEFE" }; const renderTitle = () => { const terminalWidth = process.stdout.columns || 80; const titleLines = TITLE_TEXT.split("\n"); if (terminalWidth < Math.max(...titleLines.map((line) => line.length))) console.log(gradient(Object.values(catppuccinTheme)).multiline(`Better T Stack`)); else console.log(gradient(Object.values(catppuccinTheme)).multiline(TITLE_TEXT)); }; //#endregion //#region src/commands/history.ts function formatStackSummary(entry) { const parts = []; if (entry.stack.frontend.length > 0 && !entry.stack.frontend.includes("none")) parts.push(entry.stack.frontend.join(", ")); if (entry.stack.backend && entry.stack.backend !== "none") parts.push(entry.stack.backend); if (entry.stack.database && entry.stack.database !== "none") parts.push(entry.stack.database); if (entry.stack.orm && entry.stack.orm !== "none") parts.push(entry.stack.orm); return parts.length > 0 ? parts.join(" + ") : "minimal"; } function formatDate(isoString) { return new Date(isoString).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } async function historyHandler(input) { if (input.clear) { const clearResult = await clearHistory(); if (clearResult.isErr()) { log.warn(pc.yellow(clearResult.error.message)); return; } log.success(pc.green("Project history cleared.")); return; } const historyResult = await getHistory(input.limit); if (historyResult.isErr()) { log.warn(pc.yellow(historyResult.error.message)); return; } const entries = historyResult.value; if (entries.length === 0) { log.info(pc.dim("No projects in history yet.")); log.info(pc.dim("Create a project with: create-better-t-stack my-app")); return; } if (input.json) { console.log(JSON.stringify(entries, null, 2)); return; } renderTitle(); intro(pc.magenta(`Project History (${entries.length} entries)`)); for (const [index, entry] of entries.entries()) { const num = pc.dim(`${index + 1}.`); const name = pc.cyan(pc.bold(entry.projectName)); const stack = pc.dim(formatStackSummary(entry)); log.message(`${num} ${name}`); log.message(` ${pc.dim("Created:")} ${formatDate(entry.createdAt)}`); log.message(` ${pc.dim("Path:")} ${entry.projectDir}`); log.message(` ${pc.dim("Stack:")} ${stack}`); log.message(` ${pc.dim("Command:")} ${pc.dim(entry.reproducibleCommand)}`); log.message(""); } } //#endregion //#region src/utils/open-url.ts async function openUrl(url) { const platform = process.platform; if (platform === "darwin") { await $({ stdio: "ignore" })`open ${url}`; return; } if (platform === "win32") { const escapedUrl = url.replace(/&/g, "^&"); await $({ stdio: "ignore" })`cmd /c start "" ${escapedUrl}`; return; } await $({ stdio: "ignore" })`xdg-open ${url}`; } //#endregion //#region src/utils/sponsors.ts const SPONSORS_JSON_URL = "https://sponsors.better-t-stack.dev/sponsors.json"; async function fetchSponsors(url = SPONSORS_JSON_URL) { const s = spinner(); s.start("Fetching sponsors…"); const response = await fetch(url); if (!response.ok) { s.stop(pc.red(`Failed to fetch sponsors: ${response.statusText}`)); throw new Error(`Failed to fetch sponsors: ${response.statusText}`); } const sponsors = await response.json(); s.stop("Sponsors fetched successfully!"); return sponsors; } function displaySponsors(sponsors) { const { total_sponsors } = sponsors.summary; if (total_sponsors === 0) { log.info("No sponsors found. You can be the first one! ✨"); outro(pc.cyan("Visit https://github.com/sponsors/AmanVarshney01 to become a sponsor.")); return; } displaySponsorsBox(sponsors); if (total_sponsors - sponsors.specialSponsors.length > 0) log.message(pc.blue(`+${total_sponsors - sponsors.specialSponsors.length} more amazing sponsors.\n`)); outro(pc.magenta("Visit https://github.com/sponsors/AmanVarshney01 to become a sponsor.")); } function displaySponsorsBox(sponsors) { if (sponsors.specialSponsors.length === 0) return; let output = `${pc.bold(pc.cyan("-> Special Sponsors"))}\n\n`; sponsors.specialSponsors.forEach((sponsor, idx) => { const displayName = sponsor.name ?? sponsor.githubId; const tier = sponsor.tierName ? ` ${pc.yellow(`(${sponsor.tierName})`)}` : ""; output += `${pc.green(`• ${displayName}`)}${tier}\n`; output += ` ${pc.dim("GitHub:")} https://github.com/${sponsor.githubId}\n`; const website = sponsor.websiteUrl ?? sponsor.githubUrl; if (website) output += ` ${pc.dim("Website:")} ${website}\n`; if (idx < sponsors.specialSponsors.length - 1) output += "\n"; }); consola$1.box(output); } //#endregion //#region src/commands/meta.ts const DOCS_URL = "https://better-t-stack.dev/docs"; const BUILDER_URL = "https://better-t-stack.dev/new"; async function openExternalUrl(url, successMessage) { if ((await Result.tryPromise({ try: () => openUrl(url), catch: () => null })).isOk()) log.success(pc.blue(successMessage)); else log.message(`Please visit ${url}`); } async function showSponsorsCommand() { const result = await Result.tryPromise({ try: async () => { renderTitle(); intro(pc.magenta("Better-T-Stack Sponsors")); displaySponsors(await fetchSponsors()); }, catch: (error) => new CLIError({ message: error instanceof Error ? error.message : "Failed to display sponsors", cause: error }) }); if (result.isErr()) { displayError(result.error); process.exit(1); } } async function openDocsCommand() { await openExternalUrl(DOCS_URL, "Opened docs in your default browser."); } async function openBuilderCommand() { await openExternalUrl(BUILDER_URL, "Opened builder in your default browser."); } //#endregion //#region src/types.ts var types_exports = {}; import * as import__better_t_stack_types from "@better-t-stack/types"; __reExport(types_exports, import__better_t_stack_types); //#endregion //#region src/utils/compatibility.ts const WEB_FRAMEWORKS = [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid", "astro" ]; //#endregion //#region src/utils/compatibility-rules.ts function validationErr$1(message) { return Result.err(new ValidationError({ message })); } function isWebFrontend(value) { return WEB_FRAMEWORKS.includes(value); } function splitFrontends(values = []) { return { web: values.filter((f) => isWebFrontend(f)), native: values.filter((f) => f === "native-bare" || f === "native-uniwind" || f === "native-unistyles") }; } function ensureSingleWebAndNative(frontends) { const { web, native } = splitFrontends(frontends); if (web.length > 1) return validationErr$1("Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next, nuxt, svelte, solid"); if (native.length > 1) return validationErr$1("Cannot select multiple native frameworks. Choose only one of: native-bare, native-uniwind, native-unistyles"); return Result.ok(void 0); } const FULLSTACK_FRONTENDS$1 = [ "next", "tanstack-start", "nuxt", "astro" ]; function validateSelfBackendCompatibility(providedFlags, options, config) { const backend = config.backend || options.backend; const frontends = config.frontend || options.frontend || []; if (backend === "self") { const { web, native } = splitFrontends(frontends); if (!(web.length === 1 && FULLSTACK_FRONTENDS$1.includes(web[0]))) return validationErr$1("Backend 'self' (fullstack) currently only supports Next.js, TanStack Start, Nuxt, and Astro frontends. Please use --frontend next, --frontend tanstack-start, --frontend nuxt, or --frontend astro. Support for SvelteKit will be added in a future update."); if (native.length > 1) return validationErr$1("Cannot select multiple native frameworks. Choose only one of: native-bare, native-uniwind, native-unistyles"); } const hasFullstackFrontend = frontends.some((f) => FULLSTACK_FRONTENDS$1.includes(f)); if (providedFlags.has("backend") && !hasFullstackFrontend && backend === "self") return validationErr$1("Backend 'self' (fullstack) currently only supports Next.js, TanStack Start, Nuxt, and Astro frontends. Please use --frontend next, --frontend tanstack-start, --frontend nuxt, --frontend astro, or choose a different backend. Support for SvelteKit will be added in a future update."); return Result.ok(void 0); } function validateWorkersCompatibility(providedFlags, options, config) { if (providedFlags.has("runtime") && options.runtime === "workers" && config.backend && config.backend !== "hono") return validationErr$1(`Cloudflare Workers runtime (--runtime workers) is only supported with Hono backend (--backend hono). Current backend: ${config.backend}. Please use '--backend hono' or choose a different runtime.`); if (providedFlags.has("backend") && config.backend && config.backend !== "hono" && config.runtime === "workers") return validationErr$1(`Backend '${config.backend}' is not compatible with Cloudflare Workers runtime. Cloudflare Workers runtime is only supported with Hono backend. Please use '--backend hono' or choose a different runtime.`); if (providedFlags.has("runtime") && options.runtime === "workers" && config.database === "mongodb") return validationErr$1("Cloudflare Workers runtime (--runtime workers) is not compatible with MongoDB database. MongoDB requires Prisma or Mongoose ORM, but Workers runtime only supports Drizzle or Prisma ORM. Please use a different database or runtime."); if (providedFlags.has("runtime") && options.runtime === "workers" && config.dbSetup === "docker") return validationErr$1("Cloudflare Workers runtime (--runtime workers) is not compatible with Docker setup. Workers runtime uses serverless databases (D1) and doesn't support local Docker containers. Please use '--db-setup d1' for SQLite or choose a different runtime."); if (providedFlags.has("database") && config.database === "mongodb" && config.runtime === "workers") return validationErr$1("MongoDB database is not compatible with Cloudflare Workers runtime. MongoDB requires Prisma or Mongoose ORM, but Workers runtime only supports Drizzle or Prisma ORM. Please use a different database or runtime."); return Result.ok(void 0); } function validateApiFrontendCompatibility(api, frontends = []) { const includesNuxt = frontends.includes("nuxt"); const includesSvelte = frontends.includes("svelte"); const includesSolid = frontends.includes("solid"); const includesAstro = frontends.includes("astro"); if ((includesNuxt || includesSvelte || includesSolid || includesAstro) && api === "trpc") return validationErr$1(`tRPC API is not supported with '${includesNuxt ? "nuxt" : includesSvelte ? "svelte" : includesSolid ? "solid" : "astro"}' frontend. Please use --api orpc or --api none or remove '${includesNuxt ? "nuxt" : includesSvelte ? "svelte" : includesSolid ? "solid" : "astro"}' from --frontend.`); return Result.ok(void 0); } function isFrontendAllowedWithBackend(frontend, backend, auth) { if (backend === "convex" && (frontend === "solid" || frontend === "astro")) return false; if (auth === "clerk" && backend === "convex") { if ([ "nuxt", "svelte", "solid", "astro" ].includes(frontend)) return false; } return true; } function allowedApisForFrontends(frontends = []) { const includesNuxt = frontends.includes("nuxt"); const includesSvelte = frontends.includes("svelte"); const includesSolid = frontends.includes("solid"); const includesAstro = frontends.includes("astro"); const base = [ "trpc", "orpc", "none" ]; if (includesNuxt || includesSvelte || includesSolid || includesAstro) return ["orpc", "none"]; return base; } function isExampleTodoAllowed(backend, database, api) { if (backend === "convex") return true; if (database === "none" || api === "none") return false; return true; } function isExampleAIAllowed(backend, frontends = []) { const includesSolid = frontends.includes("solid"); const includesAstro = frontends.includes("astro"); if (includesSolid || includesAstro) return false; if (backend === "convex") { const includesNuxt = frontends.includes("nuxt"); const includesSvelte = frontends.includes("svelte"); if (includesNuxt || includesSvelte) return false; } return true; } function validateWebDeployRequiresWebFrontend(webDeploy, hasWebFrontendFlag) { if (webDeploy && webDeploy !== "none" && !hasWebFrontendFlag) return validationErr$1("'--web-deploy' requires a web frontend. Please select a web frontend or set '--web-deploy none'."); return Result.ok(void 0); } function validateServerDeployRequiresBackend(serverDeploy, backend) { if (serverDeploy && serverDeploy !== "none" && (!backend || backend === "none")) return validationErr$1("'--server-deploy' requires a backend. Please select a backend or set '--server-deploy none'."); return Result.ok(void 0); } function validateAddonCompatibility(addon, frontend, _auth) { const compatibleFrontends = ADDON_COMPATIBILITY[addon]; if (compatibleFrontends.length > 0) { if (!frontend.some((f) => compatibleFrontends.includes(f))) return { isCompatible: false, reason: `${addon} addon requires one of these frontends: ${compatibleFrontends.join(", ")}` }; } return { isCompatible: true }; } function getCompatibleAddons(allAddons, frontend, existingAddons = [], auth) { return allAddons.filter((addon) => { if (existingAddons.includes(addon)) return false; if (addon === "none") return false; const { isCompatible } = validateAddonCompatibility(addon, frontend, auth); return isCompatible; }); } function validateAddonsAgainstFrontends(addons = [], frontends = [], auth) { for (const addon of addons) { if (addon === "none") continue; const { isCompatible, reason } = validateAddonCompatibility(addon, frontends, auth); if (!isCompatible) return validationErr$1(`Incompatible addon/frontend combination: ${reason}`); } return Result.ok(void 0); } function validatePaymentsCompatibility(payments, auth, _backend, frontends = []) { if (!payments || payments === "none") return Result.ok(void 0); if (payments === "polar") { if (!auth || auth === "none" || auth !== "better-auth") return validationErr$1("Polar payments requires Better Auth. Please use '--auth better-auth' or choose a different payments provider."); const { web } = splitFrontends(frontends); if (web.length === 0 && frontends.length > 0) return validationErr$1("Polar payments requires a web frontend or no frontend. Please select a web frontend or choose a different payments provider."); } return Result.ok(void 0); } function validateExamplesCompatibility(examples, backend, database, frontend, api) { const examplesArr = examples ?? []; if (examplesArr.length === 0 || examplesArr.includes("none")) return Result.ok(void 0); if (examplesArr.includes("todo") && backend !== "convex") { if (database === "none") return validationErr$1("The 'todo' example requires a database. Cannot use --examples todo when database is 'none'."); if (api === "none") return validationErr$1("The 'todo' example requires an API layer (tRPC or oRPC). Cannot use --examples todo when api is 'none'."); } if (examplesArr.includes("ai") && (frontend ?? []).includes("solid")) return validationErr$1("The 'ai' example is not compatible with the Solid frontend."); if (examplesArr.includes("ai") && backend === "convex") { const frontendArr = frontend ?? []; const includesNuxt = frontendArr.includes("nuxt"); const includesSvelte = frontendArr.includes("svelte"); if (includesNuxt || includesSvelte) return validationErr$1("The 'ai' example with Convex backend only supports React-based frontends (Next.js, TanStack Router, TanStack Start, React Router). Svelte and Nuxt are not supported with Convex AI."); } return Result.ok(void 0); } //#endregion //#region src/utils/context.ts const cliStorage = new AsyncLocalStorage(); function defaultContext() { return { navigation: { isFirstPrompt: false, lastPromptShownUI: false }, silent: false, verbose: false }; } function getContext() { const ctx = cliStorage.getStore(); if (!ctx) return defaultContext(); return ctx; } function tryGetContext() { return cliStorage.getStore(); } function isSilent() { return getContext().silent; } function isFirstPrompt() { return getContext().navigation.isFirstPrompt; } function didLastPromptShowUI() { return getContext().navigation.lastPromptShownUI; } function setIsFirstPrompt$1(value) { const ctx = tryGetContext(); if (ctx) ctx.navigation.isFirstPrompt = value; } function setLastPromptShownUI(value) { const ctx = tryGetContext(); if (ctx) ctx.navigation.lastPromptShownUI = value; } async function runWithContextAsync(options, fn) { const ctx = { navigation: { isFirstPrompt: false, lastPromptShownUI: false }, silent: options.silent ?? false, verbose: options.verbose ?? false, projectDir: options.projectDir, projectName: options.projectName, packageManager: options.packageManager }; return cliStorage.run(ctx, fn); } //#endregion //#region src/utils/navigation.ts const GO_BACK_SYMBOL = Symbol("clack:goBack"); function isGoBack(value) { return value === GO_BACK_SYMBOL; } //#endregion //#region src/prompts/navigable.ts /** * Navigable prompt wrappers using @clack/core * These prompts return GO_BACK_SYMBOL when 'b' is pressed (instead of canceling) */ const unicode = process.platform !== "win32"; const S_STEP_ACTIVE = unicode ? "◆" : "*"; const S_STEP_CANCEL = unicode ? "■" : "x"; const S_STEP_ERROR = unicode ? "▲" : "x"; const S_STEP_SUBMIT = unicode ? "◇" : "o"; const S_BAR = unicode ? "│" : "|"; const S_BAR_END = unicode ? "└" : "—"; const S_RADIO_ACTIVE = unicode ? "●" : ">"; const S_RADIO_INACTIVE = unicode ? "○" : " "; const S_CHECKBOX_ACTIVE = unicode ? "◻" : "[•]"; const S_CHECKBOX_SELECTED = unicode ? "◼" : "[+]"; const S_CHECKBOX_INACTIVE = unicode ? "◻" : "[ ]"; function symbol(state) { switch (state) { case "initial": case "active": return pc.cyan(S_STEP_ACTIVE); case "cancel": return pc.red(S_STEP_CANCEL); case "error": return pc.yellow(S_STEP_ERROR); case "submit": return pc.green(S_STEP_SUBMIT); } } const KEYBOARD_HINT = pc.dim(`${pc.gray("↑/↓")} navigate • ${pc.gray("enter")} confirm • ${pc.gray("b")} back • ${pc.gray("ctrl+c")} cancel`); const KEYBOARD_HINT_FIRST = pc.dim(`${pc.gray("↑/↓")} navigate • ${pc.gray("enter")} confirm • ${pc.gray("ctrl+c")} cancel`); 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`); const KEYBOARD_HINT_MULTI_FIRST = pc.dim(`${pc.gray("↑/↓")} navigate • ${pc.gray("space")} select • ${pc.gray("enter")} confirm • ${pc.gray("ctrl+c")} cancel`); const setIsFirstPrompt = setIsFirstPrompt$1; function getHint() { return isFirstPrompt() ? KEYBOARD_HINT_FIRST : KEYBOARD_HINT; } function getMultiHint() { return isFirstPrompt() ? KEYBOARD_HINT_MULTI_FIRST : KEYBOARD_HINT_MULTI; } async function runWithNavigation(prompt) { let goBack = false; prompt.on("key", (char) => { if (char === "b" && !isFirstPrompt()) { goBack = true; prompt.state = "cancel"; } }); setLastPromptShownUI(true); const result = await prompt.prompt(); return goBack ? GO_BACK_SYMBOL : result; } async function navigableSelect(opts) { const opt = (option, state) => { const label = option.label ?? String(option.value); switch (state) { case "disabled": return `${pc.gray(S_RADIO_INACTIVE)} ${pc.gray(label)}${option.hint ? ` ${pc.dim(`(${option.hint ?? "disabled"})`)}` : ""}`; case "selected": return `${pc.dim(label)}`; case "active": return `${pc.green(S_RADIO_ACTIVE)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; case "cancelled": return `${pc.strikethrough(pc.dim(label))}`; default: return `${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(label)}`; } }; return runWithNavigation(new SelectPrompt({ options: opts.options, initialValue: opts.initialValue, render() { const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; switch (this.state) { case "submit": return `${title}${pc.gray(S_BAR)} ${opt(this.options[this.cursor], "selected")}`; case "cancel": return `${title}${pc.gray(S_BAR)} ${opt(this.options[this.cursor], "cancelled")}\n${pc.gray(S_BAR)}`; default: { const optionsText = this.options.map((option, i) => opt(option, option.disabled ? "disabled" : i === this.cursor ? "active" : "inactive")).join(`\n${pc.cyan(S_BAR)} `); const hint = `\n${pc.gray(S_BAR)} ${getHint()}`; return `${title}${pc.cyan(S_BAR)} ${optionsText}\n${pc.cyan(S_BAR_END)}${hint}\n`; } } } })); } async function navigableMultiselect(opts) { const required = opts.required ?? true; const opt = (option, state) => { const label = option.label ?? String(option.value); if (state === "disabled") return `${pc.gray(S_CHECKBOX_INACTIVE)} ${pc.strikethrough(pc.gray(label))}${option.hint ? ` ${pc.dim(`(${option.hint ?? "disabled"})`)}` : ""}`; if (state === "active") return `${pc.cyan(S_CHECKBOX_ACTIVE)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; if (state === "selected") return `${pc.green(S_CHECKBOX_SELECTED)} ${pc.dim(label)}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; if (state === "cancelled") return `${pc.strikethrough(pc.dim(label))}`; if (state === "active-selected") return `${pc.green(S_CHECKBOX_SELECTED)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; if (state === "submitted") return `${pc.dim(label)}`; return `${pc.dim(S_CHECKBOX_INACTIVE)} ${pc.dim(label)}`; }; return runWithNavigation(new MultiSelectPrompt({ options: opts.options, initialValues: opts.initialValues, required, validate(selected) { 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`))}`; }, render() { const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; const value = this.value ?? []; const styleOption = (option, active) => { if (option.disabled) return opt(option, "disabled"); const selected = value.includes(option.value); if (active && selected) return opt(option, "active-selected"); if (selected) return opt(option, "selected"); return opt(option, active ? "active" : "inactive"); }; switch (this.state) { case "submit": { const submitText = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "submitted")).join(pc.dim(", ")) || pc.dim("none"); return `${title}${pc.gray(S_BAR)} ${submitText}`; } case "cancel": { const label = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "cancelled")).join(pc.dim(", ")); return `${title}${pc.gray(S_BAR)} ${label}\n${pc.gray(S_BAR)}`; } case "error": { const footer = this.error.split("\n").map((ln, i) => i === 0 ? `${pc.yellow(S_BAR_END)} ${pc.yellow(ln)}` : ` ${ln}`).join("\n"); const optionsText = this.options.map((option, i) => styleOption(option, i === this.cursor)).join(`\n${pc.yellow(S_BAR)} `); return `${title}${pc.yellow(S_BAR)} ${optionsText}\n${footer}\n`; } default: { const optionsText = this.options.map((option, i) => styleOption(option, i === this.cursor)).join(`\n${pc.cyan(S_BAR)} `); const hint = `\n${pc.gray(S_BAR)} ${getMultiHint()}`; return `${title}${pc.cyan(S_BAR)} ${optionsText}\n${pc.cyan(S_BAR_END)}${hint}\n`; } } } })); } async function navigableConfirm(opts) { const active = opts.active ?? "Yes"; const inactive = opts.inactive ?? "No"; return runWithNavigation(new ConfirmPrompt({ active, inactive, initialValue: opts.initialValue ?? true, render() { const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; const value = this.value ? active : inactive; switch (this.state) { case "submit": return `${title}${pc.gray(S_BAR)} ${pc.dim(value)}`; case "cancel": return `${title}${pc.gray(S_BAR)} ${pc.strikethrough(pc.dim(value))}\n${pc.gray(S_BAR)}`; default: { const hint = `\n${pc.gray(S_BAR)} ${getHint()}`; 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`; } } } })); } async function navigableGroupMultiselect(opts) { const required = opts.required ?? true; const opt = (option, state, options = []) => { const label = option.label ?? String(option.value); const isItem = typeof option.group === "string"; const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); const isLast = isItem && next && next.group === true; const prefix = isItem ? `${isLast ? S_BAR_END : S_BAR} ` : ""; if (state === "active") return `${pc.dim(prefix)}${pc.cyan(S_CHECKBOX_ACTIVE)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; if (state === "group-active") return `${prefix}${pc.cyan(S_CHECKBOX_ACTIVE)} ${pc.dim(label)}`; if (state === "group-active-selected") return `${prefix}${pc.green(S_CHECKBOX_SELECTED)} ${pc.dim(label)}`; if (state === "selected") { const selectedCheckbox = isItem ? pc.green(S_CHECKBOX_SELECTED) : ""; return `${pc.dim(prefix)}${selectedCheckbox} ${pc.dim(label)}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; } if (state === "cancelled") return `${pc.strikethrough(pc.dim(label))}`; if (state === "active-selected") return `${pc.dim(prefix)}${pc.green(S_CHECKBOX_SELECTED)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; if (state === "submitted") return `${pc.dim(label)}`; const unselectedCheckbox = isItem ? pc.dim(S_CHECKBOX_INACTIVE) : ""; return `${pc.dim(prefix)}${unselectedCheckbox} ${pc.dim(label)}`; }; return runWithNavigation(new GroupMultiSelectPrompt({ options: opts.options, initialValues: opts.initialValues, required, selectableGroups: true, validate(selected) { 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`))}`; }, render() { const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; const value = this.value ?? []; switch (this.state) { case "submit": { const selectedOptions = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "submitted")); const optionsText = selectedOptions.length === 0 ? "" : ` ${selectedOptions.join(pc.dim(", "))}`; return `${title}${pc.gray(S_BAR)}${optionsText}`; } case "cancel": { const label = this.options.filter(({ value: optionValue }) => value.includes(optionValue)).map((option) => opt(option, "cancelled")).join(pc.dim(", ")); return `${title}${pc.gray(S_BAR)} ${label.trim() ? `${label}\n${pc.gray(S_BAR)}` : ""}`; } case "error": { const footer = this.error.split("\n").map((ln, i) => i === 0 ? `${pc.yellow(S_BAR_END)} ${pc.yellow(ln)}` : ` ${ln}`).join("\n"); const optionsText = this.options.map((option, i, options) => { const selected = value.includes(option.value) || option.group === true && this.isGroupSelected(`${option.value}`); const active = i === this.cursor; if (!active && typeof option.group === "string" && this.options[this.cursor].value === option.group) return opt(option, selected ? "group-active-selected" : "group-active", options); if (active && selected) return opt(option, "active-selected", options); if (selected) return opt(option, "selected", options); return opt(option, active ? "active" : "inactive", options); }).join(`\n${pc.yellow(S_BAR)} `); return `${title}${pc.yellow(S_BAR)} ${optionsText}\n${footer}\n`; } default: { const optionsText = this.options.map((option, i, options) => { const selected = value.includes(option.value) || option.group === true && this.isGroupSelected(`${option.value}`); const active = i === this.cursor; const groupActive = !active && typeof option.group === "string" && this.options[this.cursor].value === option.group; let optionText = ""; if (groupActive) optionText = opt(option, selected ? "group-active-selected" : "group-active", options); else if (active && selected) optionText = opt(option, "active-selected", options); else if (selected) optionText = opt(option, "selected", options); else optionText = opt(option, active ? "active" : "inactive", options); return `${i !== 0 && !optionText.startsWith("\n") ? " " : ""}${optionText}`; }).join(`\n${pc.cyan(S_BAR)}`); const optionsPrefix = optionsText.startsWith("\n") ? "" : " "; const hint = `\n${pc.gray(S_BAR)} ${getMultiHint()}`; return `${title}${pc.cyan(S_BAR)}${optionsPrefix}${optionsText}\n${pc.cyan(S_BAR_END)}${hint}\n`; } } } })); } //#endregion //#region src/prompts/addons.ts function getAddonDisplay(addon) { let label; let hint; switch (addon) { case "turborepo": label = "Turborepo"; hint = "High-performance build system"; break; case "pwa": label = "PWA"; hint = "Make your app installable and work offline"; break; case "tauri": label = "Tauri"; hint = "Build native desktop apps from your web frontend"; break; case "biome": label = "Biome"; hint = "Format, lint, and more"; break; case "oxlint": label = "Oxlint"; hint = "Oxlint + Oxfmt (linting & formatting)"; break; case "ultracite": label = "Ultracite"; hint = "Zero-config Biome preset with AI integration"; break; case "ruler": label = "Ruler"; hint = "Centralize your AI rules"; break; case "lefthook": label = "Lefthook"; hint = "Fast and powerful Git hooks manager"; break; case "husky": label = "Husky"; hint = "Modern native Git hooks made easy"; break; case "starlight": label = "Starlight"; hint = "Build stellar docs with astro"; break; case "fumadocs": label = "Fumadocs"; hint = "Build excellent documentation site"; break; case "opentui": label = "OpenTUI"; hint = "Build terminal user interfaces"; break; case "wxt": label = "WXT"; hint = "Build browser extensions"; break; case "skills": label = "Skills"; hint = "AI coding agent skills for your stack"; break; case "mcp": label = "MCP"; hint = "Install MCP servers (docs, databases, SaaS) via add-mcp"; break; default: label = addon; hint = `Add ${addon}`; } return { label, hint }; } const ADDON_GROUPS = { Tooling: [ "turborepo", "biome", "oxlint", "ultracite", "husky", "lefthook" ], Documentation: ["starlight", "fumadocs"], Extensions: [ "pwa", "tauri", "opentui", "wxt" ], AI: [ "ruler", "skills", "mcp" ] }; async function getAddonsChoice(addons, frontends, auth) { if (addons !== void 0) return addons; const allAddons = types_exports.AddonsSchema.options.filter((addon) => addon !== "none"); const groupedOptions = { Tooling: [], Documentation: [], Extensions: [], AI: [] }; const frontendsArray = frontends || []; for (const addon of allAddons) { const { isCompatible } = validateAddonCompatibility(addon, frontendsArray, auth); if (!isCompatible) continue; const { label, hint } = getAddonDisplay(addon); const option = { value: addon, label, hint }; if (ADDON_GROUPS.Tooling.includes(addon)) groupedOptions.Tooling.push(option); else if (ADDON_GROUPS.Documentation.includes(addon)) groupedOptions.Documentation.push(option); else if (ADDON_GROUPS.Extensions.includes(addon)) groupedOptions.Extensions.push(option); else if (ADDON_GROUPS.AI.includes(addon)) groupedOptions.AI.push(option); } Object.keys(groupedOptions).forEach((group) => { if (groupedOptions[group].length === 0) delete groupedOptions[group]; else { const groupOrder = ADDON_GROUPS[group] || []; groupedOptions[group].sort((a, b) => { return groupOrder.indexOf(a.value) - groupOrder.indexOf(b.value); }); } }); const response = await navigableGroupMultiselect({ message: "Select addons", options: groupedOptions, initialValues: DEFAULT_CONFIG.addons.filter((addonValue) => Object.values(groupedOptions).some((options) => options.some((opt) => opt.value === addonValue))), required: false }); if (isCancel$1(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } async function getAddonsToAdd(frontend, existingAddons = [], auth) { const groupedOptions = { Tooling: [], Documentation: [], Extensions: [], AI: [] }; const frontendArray = frontend || []; const compatibleAddons = getCompatibleAddons(types_exports.AddonsSchema.options.filter((addon) => addon !== "none"), frontendArray, existingAddons, auth); for (const addon of compatibleAddons) { const { label, hint } = getAddonDisplay(addon); const option = { value: addon, label, hint }; if (ADDON_GROUPS.Tooling.includes(addon)) groupedOptions.Tooling.push(option); else if (ADDON_GROUPS.Documentation.includes(addon)) groupedOptions.Documentation.push(option); else if (ADDON_GROUPS.Extensions.includes(addon)) groupedOptions.Extensions.push(option); else if (ADDON_GROUPS.AI.includes(addon)) groupedOptions.AI.push(option); } Object.keys(groupedOptions).forEach((group) => { if (groupedOptions[group].length === 0) delete groupedOptions[group]; else { const groupOrder = ADDON_GROUPS[group] || []; groupedOptions[group].sort((a, b) => { return groupOrder.indexOf(a.value) - groupOrder.indexOf(b.value); }); } }); if (Object.keys(groupedOptions).length === 0) return []; const response = await navigableGroupMultiselect({ message: "Select addons to add", options: groupedOptions, required: false }); if (isCancel$1(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } //#endregion //#region src/utils/bts-config.ts const BTS_CONFIG_FILE = "bts.jsonc"; /** * Reads the BTS configuration file from the project directory. */ async function readBtsConfig(projectDir) { try { const configPath = path.join(projectDir, BTS_CONFIG_FILE); if (!await fs.pathExists(configPath)) return null; return parse(await fs.readFile(configPath, "utf-8")); } catch { return null; } } /** * Updates specific fields in the BTS configuration file. */ async function updateBtsConfig(projectDir, updates) { try { const configPath = path.join(projectDir, BTS_CONFIG_FILE); if (!await fs.pathExists(configPath)) return; let content = await fs.readFile(configPath, "utf-8"); for (const [key, value] of Object.entries(updates)) { const edits = modify(content, [key], value, { formattingOptions: { tabSize: 2 } }); content = applyEdits(content, edits); } await fs.writeFile(configPath, content, "utf-8"); } catch {} } //#endregion //#region src/utils/add-package-deps.ts const addPackageDependency = async (opts) => { const { dependencies = [], devDependencies = [], customDependencies = {}, customDevDependencies = {}, projectDir } = opts; const pkgJsonPath = path.join(projectDir, "package.json"); const pkgJson = await fs.readJson(pkgJsonPath); if (!pkgJson.dependencies) pkgJson.dependencies = {}; if (!pkgJson.devDependencies) pkgJson.devDependencies = {}; for (const pkgName of dependencies) { const version = dependencyVersionMap[pkgName]; if (version) pkgJson.dependencies[pkgName] = version; else console.warn(`Warning: Dependency ${pkgName} not found in version map.`); } for (const pkgName of devDependencies) { const version = dependencyVersionMap[pkgName]; if (version) pkgJson.devDependencies[pkgName] = version; else console.warn(`Warning: Dev dependency ${pkgName} not found in version map.`); } for (const [pkgName, version] of Object.entries(customDependencies)) pkgJson.dependencies[pkgName] = version; for (const [pkgName, version] of Object.entries(customDevDependencies)) pkgJson.devDependencies[pkgName] = version; await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); }; //#endregion //#region src/utils/external-commands.ts function shouldSkipExternalCommands() { return process.env.BTS_SKIP_EXTERNAL_COMMANDS === "1" || process.env.BTS_TEST_MODE === "1"; } //#endregion //#region src/utils/package-runner.ts function splitCommandArgs(commandWithArgs) { const args = []; let current = ""; let quote = null; for (let i = 0; i < commandWithArgs.length; i += 1) { const char = commandWithArgs[i]; if (quote) { if (char === quote) { quote = null; continue; } if (char === "\\" && i + 1 < commandWithArgs.length) { const nextChar = commandWithArgs[i + 1]; if (nextChar === quote || nextChar === "\\") { current += nextChar; i += 1; continue; } } current += char; continue; } if (char === "\"" || char === "'") { quote = char; continue; } if (/\s/.test(char)) { if (current.length > 0) { args.push(current); current = ""; } continue; } current += char; } if (current.length > 0) args.push(current); return args; } /** * Returns the appropriate command for running a package without installing it globally, * based on the selected package manager. * * @param packageManager - The selected package manager (e.g., 'npm', 'yarn', 'pnpm', 'bun'). * @param commandWithArgs - The command to run, including arguments (e.g., "prisma generate --schema=./prisma/schema.prisma"). * @returns The full command string (e.g., "npx prisma generate --schema=./prisma/schema.prisma"). */ function getPackageExecutionCommand(packageManager, commandWithArgs) { switch (packageManager) { case "pnpm": return `pnpm dlx ${commandWithArgs}`; case "bun": return `bunx ${commandWithArgs}`; default: return `npx ${commandWithArgs}`; } } /** * Returns the command and arguments as an array for use with execa's $ template syntax. * This avoids the need for shell: true and provides better escaping. * * @param packageManager - The selected package manager (e.g., 'npm', 'yarn', 'pnpm', 'bun'). * @param commandWithArgs - The command to run, including arguments (e.g., "prisma generate"). * @returns An array of [command, ...args] (e.g., ["npx", "prisma", "generate"]). */ function getPackageExecutionArgs(packageManager, commandWithArgs) { const args = splitCommandArgs(commandWithArgs); switch (packageManager) { case "pnpm": return [ "pnpm", "dlx", ...args ]; case "bun": return ["bunx", ...args]; default: return ["npx", ...args]; } } /** * Returns just the runner prefix as an array, for when you already have args built. * Use this when you have complex arguments that shouldn't be split by spaces. * * @param packageManager - The selected package manager. * @returns The runner prefix as an array (e.g., ["npx"] or ["pnpm", "dlx"]). * * @example * const prefix = getPackageRunnerPrefix("bun"); * const args = ["@tauri-apps/cli@latest", "init", "--app-name=foo"]; * await $`${[...prefix, ...args]}`; */ function getPackageRunnerPrefix(packageManager) { switch (packageManager) { case "pnpm": return ["pnpm", "dlx"]; case "bun": return ["bunx"]; default: return ["npx"]; } } //#endregion //#region src/helpers/addons/fumadocs-setup.ts const TEMPLATES$2 = { "next-mdx": { label: "Next.js: Fumadocs MDX", hint: "Recommended template with MDX support", value: "+next+fuma-do