diff --git a/.changeset/sharp-days-lead.md b/.changeset/sharp-days-lead.md new file mode 100644 index 0000000..82343cc --- /dev/null +++ b/.changeset/sharp-days-lead.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": patch +--- + +refractor files diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2b9347e..e745b5b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -36,6 +36,8 @@ jobs: with: publish: bun run publish-packages env: - MODE: "prod" + TELEMETRY: "true" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} diff --git a/apps/cli/src/helpers/database-providers/neon-setup.ts b/apps/cli/src/helpers/database-providers/neon-setup.ts index a419a68..af57de9 100644 --- a/apps/cli/src/helpers/database-providers/neon-setup.ts +++ b/apps/cli/src/helpers/database-providers/neon-setup.ts @@ -5,7 +5,7 @@ import { execa } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; import type { PackageManager, ProjectConfig } from "../../types"; -import { getPackageExecutionCommand } from "../../utils/get-package-execution-command"; +import { getPackageExecutionCommand } from "../../utils/package-runner"; import { addEnvVariablesToFile, type EnvVariable, diff --git a/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts b/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts index 7e9a715..55412b2 100644 --- a/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts +++ b/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts @@ -6,7 +6,7 @@ import fs from "fs-extra"; import pc from "picocolors"; import type { PackageManager } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; -import { getPackageExecutionCommand } from "../../utils/get-package-execution-command"; +import { getPackageExecutionCommand } from "../../utils/package-runner"; import { addEnvVariablesToFile, type EnvVariable, diff --git a/apps/cli/src/helpers/database-providers/supabase-setup.ts b/apps/cli/src/helpers/database-providers/supabase-setup.ts index 9d945a0..8c48a16 100644 --- a/apps/cli/src/helpers/database-providers/supabase-setup.ts +++ b/apps/cli/src/helpers/database-providers/supabase-setup.ts @@ -5,7 +5,7 @@ import { type ExecaError, execa } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; import type { PackageManager, ProjectConfig } from "../../types"; -import { getPackageExecutionCommand } from "../../utils/get-package-execution-command"; +import { getPackageExecutionCommand } from "../../utils/package-runner"; import { addEnvVariablesToFile, type EnvVariable, diff --git a/apps/cli/src/helpers/project-generation/command-handlers.ts b/apps/cli/src/helpers/project-generation/command-handlers.ts new file mode 100644 index 0000000..74a9887 --- /dev/null +++ b/apps/cli/src/helpers/project-generation/command-handlers.ts @@ -0,0 +1,181 @@ +import path from "node:path"; +import { cancel, intro, log, outro } from "@clack/prompts"; +import fs from "fs-extra"; +import pc from "picocolors"; +import { DEFAULT_CONFIG } from "../../constants"; +import { getAddonsToAdd } from "../../prompts/addons"; +import { gatherConfig } from "../../prompts/config-prompts"; +import { getProjectName } from "../../prompts/project-name"; +import type { AddInput, CreateInput, ProjectConfig } from "../../types"; +import { trackProjectCreation } from "../../utils/analytics"; +import { displayConfig } from "../../utils/display-config"; +import { generateReproducibleCommand } from "../../utils/generate-reproducible-command"; +import { + handleDirectoryConflict, + setupProjectDirectory, +} from "../../utils/project-directory"; +import { renderTitle } from "../../utils/render-title"; +import { getProvidedFlags, processAndValidateFlags } from "../../validation"; +import { addAddonsToProject } from "./add-addons"; +import { createProject } from "./create-project"; +import { detectProjectConfig } from "./detect-project-config"; + +export async function createProjectHandler( + input: CreateInput & { projectName?: string }, +) { + const startTime = Date.now(); + + try { + renderTitle(); + intro(pc.magenta("Creating a new Better-T Stack project")); + + let currentPathInput: string; + if (input.yes && input.projectName) { + currentPathInput = input.projectName; + } else if (input.yes) { + let defaultName = DEFAULT_CONFIG.relativePath; + let counter = 1; + while ( + fs.pathExistsSync(path.resolve(process.cwd(), defaultName)) && + fs.readdirSync(path.resolve(process.cwd(), defaultName)).length > 0 + ) { + defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`; + counter++; + } + currentPathInput = defaultName; + } else { + currentPathInput = await getProjectName(input.projectName); + } + + const { finalPathInput, shouldClearDirectory } = + await handleDirectoryConflict(currentPathInput); + + const { finalResolvedPath, finalBaseName } = await setupProjectDirectory( + finalPathInput, + shouldClearDirectory, + ); + + const cliInput = { + ...input, + projectDirectory: input.projectName, + }; + + const providedFlags = getProvidedFlags(cliInput); + const flagConfig = processAndValidateFlags( + cliInput, + providedFlags, + finalBaseName, + ); + const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig; + + if (!input.yes && Object.keys(otherFlags).length > 0) { + log.info(pc.yellow("Using these pre-selected options:")); + log.message(displayConfig(otherFlags)); + log.message(""); + } + + let config: ProjectConfig; + if (input.yes) { + config = { + ...DEFAULT_CONFIG, + ...flagConfig, + projectName: finalBaseName, + projectDir: finalResolvedPath, + relativePath: finalPathInput, + }; + + if (config.backend === "convex") { + log.info( + "Due to '--backend convex' flag, the following options have been automatically set: auth=false, database=none, orm=none, api=none, runtime=none, dbSetup=none, examples=todo", + ); + } else if (config.backend === "none") { + log.info( + "Due to '--backend none', the following options have been automatically set: --auth=false, --database=none, --orm=none, --api=none, --runtime=none, --db-setup=none, --examples=none", + ); + } + + log.info( + pc.yellow("Using default/flag options (config prompts skipped):"), + ); + log.message(displayConfig(config)); + log.message(""); + } else { + config = await gatherConfig( + flagConfig, + finalBaseName, + finalResolvedPath, + finalPathInput, + ); + } + + await createProject(config); + + const reproducibleCommand = generateReproducibleCommand(config); + log.success( + pc.blue( + `You can reproduce this setup with the following command:\n${reproducibleCommand}`, + ), + ); + + await trackProjectCreation(config); + + const elapsedTimeInSeconds = ((Date.now() - startTime) / 1000).toFixed(2); + outro( + pc.magenta( + `Project created successfully in ${pc.bold( + elapsedTimeInSeconds, + )} seconds!`, + ), + ); + } catch (error) { + console.error(error); + process.exit(1); + } +} + +export async function addAddonsHandler(input: AddInput): Promise { + try { + if (!input.addons || input.addons.length === 0) { + const projectDir = input.projectDir || process.cwd(); + const detectedConfig = await detectProjectConfig(projectDir); + + if (!detectedConfig) { + cancel( + pc.red( + "Could not detect project configuration. Please ensure this is a valid Better-T Stack project.", + ), + ); + process.exit(1); + } + + const addonsPrompt = await getAddonsToAdd( + detectedConfig.frontend || [], + detectedConfig.addons || [], + ); + + if (addonsPrompt.length === 0) { + outro( + pc.yellow( + "No addons to add or all compatible addons are already present.", + ), + ); + return; + } + + input.addons = addonsPrompt; + } + + if (!input.addons || input.addons.length === 0) { + outro(pc.yellow("No addons specified to add.")); + return; + } + + await addAddonsToProject({ + ...input, + addons: input.addons, + }); + } catch (error) { + console.error(error); + process.exit(1); + } +} diff --git a/apps/cli/src/helpers/project-generation/post-installation.ts b/apps/cli/src/helpers/project-generation/post-installation.ts index 16748f5..3e71773 100644 --- a/apps/cli/src/helpers/project-generation/post-installation.ts +++ b/apps/cli/src/helpers/project-generation/post-installation.ts @@ -7,7 +7,7 @@ import type { ProjectConfig, Runtime, } from "../../types"; -import { getPackageExecutionCommand } from "../../utils/get-package-execution-command"; +import { getPackageExecutionCommand } from "../../utils/package-runner"; export function displayPostInstallInstructions( config: ProjectConfig & { depsInstalled: boolean }, diff --git a/apps/cli/src/helpers/setup/starlight-setup.ts b/apps/cli/src/helpers/setup/starlight-setup.ts index 868fd3a..f8e8589 100644 --- a/apps/cli/src/helpers/setup/starlight-setup.ts +++ b/apps/cli/src/helpers/setup/starlight-setup.ts @@ -4,7 +4,7 @@ import consola from "consola"; import { execa } from "execa"; import pc from "picocolors"; import type { ProjectConfig } from "../../types"; -import { getPackageExecutionCommand } from "../../utils/get-package-execution-command"; +import { getPackageExecutionCommand } from "../../utils/package-runner"; export async function setupStarlight(config: ProjectConfig): Promise { const { packageManager, projectDir } = config; diff --git a/apps/cli/src/helpers/setup/tauri-setup.ts b/apps/cli/src/helpers/setup/tauri-setup.ts index 18367ac..981ca0b 100644 --- a/apps/cli/src/helpers/setup/tauri-setup.ts +++ b/apps/cli/src/helpers/setup/tauri-setup.ts @@ -6,7 +6,7 @@ import fs from "fs-extra"; import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; -import { getPackageExecutionCommand } from "../../utils/get-package-execution-command"; +import { getPackageExecutionCommand } from "../../utils/package-runner"; export async function setupTauri(config: ProjectConfig): Promise { const { packageManager, frontend, projectDir } = config; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index e560eb5..843ba7f 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,25 +1,11 @@ -import path from "node:path"; -import { - cancel, - intro, - isCancel, - log, - outro, - select, - spinner, -} from "@clack/prompts"; +import { intro, log } from "@clack/prompts"; import { consola } from "consola"; -import fs from "fs-extra"; import pc from "picocolors"; import { createCli, trpcServer, zod as z } from "trpc-cli"; -import { DEFAULT_CONFIG } from "./constants"; -import { addAddonsToProject } from "./helpers/project-generation/add-addons"; -import { createProject } from "./helpers/project-generation/create-project"; -import { detectProjectConfig } from "./helpers/project-generation/detect-project-config"; -import { getAddonsToAdd } from "./prompts/addons"; -import { gatherConfig } from "./prompts/config-prompts"; -import { getProjectName } from "./prompts/project-name"; -import type { AddInput, CreateInput, ProjectConfig } from "./types"; +import { + addAddonsHandler, + createProjectHandler, +} from "./helpers/project-generation/command-handlers"; import { AddonsSchema, APISchema, @@ -33,282 +19,13 @@ import { ProjectNameSchema, RuntimeSchema, } from "./types"; -import { trackProjectCreation } from "./utils/analytics"; -import { displayConfig } from "./utils/display-config"; -import { generateReproducibleCommand } from "./utils/generate-reproducible-command"; import { getLatestCLIVersion } from "./utils/get-latest-cli-version"; import { openUrl } from "./utils/open-url"; import { renderTitle } from "./utils/render-title"; import { displaySponsors, fetchSponsors } from "./utils/sponsors"; -import { getProvidedFlags, processAndValidateFlags } from "./validation"; const t = trpcServer.initTRPC.create(); -async function handleDirectoryConflict(currentPathInput: string): Promise<{ - finalPathInput: string; - shouldClearDirectory: boolean; -}> { - while (true) { - const resolvedPath = path.resolve(process.cwd(), currentPathInput); - const dirExists = fs.pathExistsSync(resolvedPath); - const dirIsNotEmpty = dirExists && fs.readdirSync(resolvedPath).length > 0; - - if (!dirIsNotEmpty) { - return { finalPathInput: currentPathInput, shouldClearDirectory: false }; - } - - log.warn( - `Directory "${pc.yellow( - currentPathInput, - )}" already exists and is not empty.`, - ); - - const action = await select<"overwrite" | "merge" | "rename" | "cancel">({ - message: "What would you like to do?", - options: [ - { - value: "overwrite", - label: "Overwrite", - hint: "Empty the directory and create the project", - }, - { - value: "merge", - label: "Merge", - hint: "Create project files inside, potentially overwriting conflicts", - }, - { - value: "rename", - label: "Choose a different name/path", - hint: "Keep the existing directory and create a new one", - }, - { value: "cancel", label: "Cancel", hint: "Abort the process" }, - ], - initialValue: "rename", - }); - - if (isCancel(action)) { - cancel(pc.red("Operation cancelled.")); - process.exit(0); - } - - switch (action) { - case "overwrite": - return { finalPathInput: currentPathInput, shouldClearDirectory: true }; - case "merge": - log.info( - `Proceeding into existing directory "${pc.yellow( - currentPathInput, - )}". Files may be overwritten.`, - ); - return { - finalPathInput: currentPathInput, - shouldClearDirectory: false, - }; - case "rename": { - log.info("Please choose a different project name or path."); - const newPathInput = await getProjectName(undefined); - return await handleDirectoryConflict(newPathInput); - } - case "cancel": - cancel(pc.red("Operation cancelled.")); - process.exit(0); - } - } -} - -async function setupProjectDirectory( - finalPathInput: string, - shouldClearDirectory: boolean, -): Promise<{ finalResolvedPath: string; finalBaseName: string }> { - let finalResolvedPath: string; - let finalBaseName: string; - - if (finalPathInput === ".") { - finalResolvedPath = process.cwd(); - finalBaseName = path.basename(finalResolvedPath); - } else { - finalResolvedPath = path.resolve(process.cwd(), finalPathInput); - finalBaseName = path.basename(finalResolvedPath); - } - - if (shouldClearDirectory) { - const s = spinner(); - s.start(`Clearing directory "${finalResolvedPath}"...`); - try { - await fs.emptyDir(finalResolvedPath); - s.stop(`Directory "${finalResolvedPath}" cleared.`); - } catch (error) { - s.stop(pc.red(`Failed to clear directory "${finalResolvedPath}".`)); - consola.error(error); - process.exit(1); - } - } else { - await fs.ensureDir(finalResolvedPath); - } - - return { finalResolvedPath, finalBaseName }; -} - -async function createProjectHandler( - input: CreateInput & { projectName?: string }, -) { - const startTime = Date.now(); - - try { - renderTitle(); - intro(pc.magenta("Creating a new Better-T Stack project")); - - let currentPathInput: string; - if (input.yes && input.projectName) { - currentPathInput = input.projectName; - } else if (input.yes) { - let defaultName = DEFAULT_CONFIG.relativePath; - let counter = 1; - while ( - fs.pathExistsSync(path.resolve(process.cwd(), defaultName)) && - fs.readdirSync(path.resolve(process.cwd(), defaultName)).length > 0 - ) { - defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`; - counter++; - } - currentPathInput = defaultName; - } else { - currentPathInput = await getProjectName(input.projectName); - } - - const { finalPathInput, shouldClearDirectory } = - await handleDirectoryConflict(currentPathInput); - - const { finalResolvedPath, finalBaseName } = await setupProjectDirectory( - finalPathInput, - shouldClearDirectory, - ); - - const cliInput = { - ...input, - projectDirectory: input.projectName, - }; - - const providedFlags = getProvidedFlags(cliInput); - const flagConfig = processAndValidateFlags( - cliInput, - providedFlags, - finalBaseName, - ); - const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig; - - if (!input.yes && Object.keys(otherFlags).length > 0) { - log.info(pc.yellow("Using these pre-selected options:")); - log.message(displayConfig(otherFlags)); - log.message(""); - } - - let config: ProjectConfig; - if (input.yes) { - config = { - ...DEFAULT_CONFIG, - ...flagConfig, - projectName: finalBaseName, - projectDir: finalResolvedPath, - relativePath: finalPathInput, - }; - - if (config.backend === "convex") { - log.info( - "Due to '--backend convex' flag, the following options have been automatically set: auth=false, database=none, orm=none, api=none, runtime=none, dbSetup=none, examples=todo", - ); - } else if (config.backend === "none") { - log.info( - "Due to '--backend none', the following options have been automatically set: --auth=false, --database=none, --orm=none, --api=none, --runtime=none, --db-setup=none, --examples=none", - ); - } - - log.info( - pc.yellow("Using default/flag options (config prompts skipped):"), - ); - log.message(displayConfig(config)); - log.message(""); - } else { - config = await gatherConfig( - flagConfig, - finalBaseName, - finalResolvedPath, - finalPathInput, - ); - } - - await createProject(config); - - const reproducibleCommand = generateReproducibleCommand(config); - log.success( - pc.blue( - `You can reproduce this setup with the following command:\n${reproducibleCommand}`, - ), - ); - - await trackProjectCreation(config); - - const elapsedTimeInSeconds = ((Date.now() - startTime) / 1000).toFixed(2); - outro( - pc.magenta( - `Project created successfully in ${pc.bold( - elapsedTimeInSeconds, - )} seconds!`, - ), - ); - } catch (error) { - console.error(error); - process.exit(1); - } -} - -async function addAddonsHandler(input: AddInput): Promise { - try { - if (!input.addons || input.addons.length === 0) { - const projectDir = input.projectDir || process.cwd(); - const detectedConfig = await detectProjectConfig(projectDir); - - if (!detectedConfig) { - cancel( - pc.red( - "Could not detect project configuration. Please ensure this is a valid Better-T Stack project.", - ), - ); - process.exit(1); - } - - const addonsPrompt = await getAddonsToAdd( - detectedConfig.frontend || [], - detectedConfig.addons || [], - ); - - if (addonsPrompt.length === 0) { - outro( - pc.yellow( - "No addons to add or all compatible addons are already present.", - ), - ); - return; - } - - input.addons = addonsPrompt; - } - - if (!input.addons || input.addons.length === 0) { - outro(pc.yellow("No addons specified to add.")); - return; - } - - await addAddonsToProject({ - ...input, - addons: input.addons, - }); - } catch (error) { - console.error(error); - process.exit(1); - } -} - const router = t.router({ init: t.procedure .meta({ diff --git a/apps/cli/src/utils/analytics.ts b/apps/cli/src/utils/analytics.ts index db5a37c..a86c68b 100644 --- a/apps/cli/src/utils/analytics.ts +++ b/apps/cli/src/utils/analytics.ts @@ -14,7 +14,7 @@ export async function trackProjectCreation( flushInterval: 0, privacyMode: true, disableGeoip: true, - disabled: process.env.MODE !== "prod", + disabled: process.env.TELEMETRY !== "true", }); try { diff --git a/apps/cli/src/utils/get-package-execution-command.ts b/apps/cli/src/utils/package-runner.ts similarity index 100% rename from apps/cli/src/utils/get-package-execution-command.ts rename to apps/cli/src/utils/package-runner.ts diff --git a/apps/cli/src/utils/project-directory.ts b/apps/cli/src/utils/project-directory.ts new file mode 100644 index 0000000..e380ce7 --- /dev/null +++ b/apps/cli/src/utils/project-directory.ts @@ -0,0 +1,113 @@ +import path from "node:path"; +import { cancel, isCancel, log, select, spinner } from "@clack/prompts"; +import { consola } from "consola"; +import fs from "fs-extra"; +import pc from "picocolors"; +import { getProjectName } from "../prompts/project-name"; + +export async function handleDirectoryConflict( + currentPathInput: string, +): Promise<{ + finalPathInput: string; + shouldClearDirectory: boolean; +}> { + while (true) { + const resolvedPath = path.resolve(process.cwd(), currentPathInput); + const dirExists = fs.pathExistsSync(resolvedPath); + const dirIsNotEmpty = dirExists && fs.readdirSync(resolvedPath).length > 0; + + if (!dirIsNotEmpty) { + return { finalPathInput: currentPathInput, shouldClearDirectory: false }; + } + + log.warn( + `Directory "${pc.yellow( + currentPathInput, + )}" already exists and is not empty.`, + ); + + const action = await select<"overwrite" | "merge" | "rename" | "cancel">({ + message: "What would you like to do?", + options: [ + { + value: "overwrite", + label: "Overwrite", + hint: "Empty the directory and create the project", + }, + { + value: "merge", + label: "Merge", + hint: "Create project files inside, potentially overwriting conflicts", + }, + { + value: "rename", + label: "Choose a different name/path", + hint: "Keep the existing directory and create a new one", + }, + { value: "cancel", label: "Cancel", hint: "Abort the process" }, + ], + initialValue: "rename", + }); + + if (isCancel(action)) { + cancel(pc.red("Operation cancelled.")); + process.exit(0); + } + + switch (action) { + case "overwrite": + return { finalPathInput: currentPathInput, shouldClearDirectory: true }; + case "merge": + log.info( + `Proceeding into existing directory "${pc.yellow( + currentPathInput, + )}". Files may be overwritten.`, + ); + return { + finalPathInput: currentPathInput, + shouldClearDirectory: false, + }; + case "rename": { + log.info("Please choose a different project name or path."); + const newPathInput = await getProjectName(undefined); + return await handleDirectoryConflict(newPathInput); + } + case "cancel": + cancel(pc.red("Operation cancelled.")); + process.exit(0); + } + } +} + +export async function setupProjectDirectory( + finalPathInput: string, + shouldClearDirectory: boolean, +): Promise<{ finalResolvedPath: string; finalBaseName: string }> { + let finalResolvedPath: string; + let finalBaseName: string; + + if (finalPathInput === ".") { + finalResolvedPath = process.cwd(); + finalBaseName = path.basename(finalResolvedPath); + } else { + finalResolvedPath = path.resolve(process.cwd(), finalPathInput); + finalBaseName = path.basename(finalResolvedPath); + } + + if (shouldClearDirectory) { + const s = spinner(); + s.start(`Clearing directory "${finalResolvedPath}"...`); + try { + await fs.emptyDir(finalResolvedPath); + s.stop(`Directory "${finalResolvedPath}" cleared.`); + } catch (error) { + s.stop(pc.red(`Failed to clear directory "${finalResolvedPath}".`)); + consola.error(error); + process.exit(1); + } + } else { + await fs.ensureDir(finalResolvedPath); + } + + return { finalResolvedPath, finalBaseName }; +} diff --git a/apps/cli/tsdown.config.ts b/apps/cli/tsdown.config.ts index a18adde..dbbee7f 100644 --- a/apps/cli/tsdown.config.ts +++ b/apps/cli/tsdown.config.ts @@ -10,8 +10,8 @@ export default defineConfig({ banner: "#!/usr/bin/env node", }, env: { - POSTHOG_API_KEY: "phc_8ZUxEwwfKMajJLvxz1daGd931dYbQrwKNficBmsdIrs", - POSTHOG_HOST: "https://us.i.posthog.com", - MODE: process.env.MODE || "dev", // wierd trick i know + POSTHOG_API_KEY: process.env.POSTHOG_API_KEY || "lol", + POSTHOG_HOST: process.env.POSTHOG_HOST || "lool", + TELEMETRY: process.env.TELEMETRY || "false", // wierd trick i know }, });