diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce9afd..042ae2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Refactored codebase for better maintainability and organization +- Extracted `copyRecursive` and validation logic into `src/utils.ts` +- Extracted git initialization logic into `src/git.ts` +- Extracted template setup logic into `src/template.ts` +- Centralized constants and configuration into `src/constants.ts` +- Reduced main function complexity from 296 to 60 lines +- Added JSDoc comments to all exported functions +- Improved code reusability and testability + ## [0.1.2] - 2026-01-03 ### Added diff --git a/README.md b/README.md index b7f2662..07984d4 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,58 @@ # create-ely -Scaffold ElysiaJS projects with ease using [Bun](https://bun.sh). +[![Lint](https://github.com/truehazker/create-ely/actions/workflows/lint.yml/badge.svg)](https://github.com/truehazker/create-ely/actions/workflows/lint.yml) +[![npm version](https://img.shields.io/npm/v/create-ely.svg)](https://www.npmjs.com/package/create-ely) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -## Usage +[![Bun](https://img.shields.io/badge/Bun-000000?logo=bun)](https://bun.sh) +[![ElysiaJS](https://img.shields.io/badge/ElysiaJS-6366f1?logo=elysia&logoColor=white)](https://elysiajs.com) +[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?logo=postgresql&logoColor=white)](https://www.postgresql.org/) -Create a new ElysiaJS project: +The fastest way to scaffold production-ready [ElysiaJS](https://elysiajs.com) projects with [Bun](https://bun.sh). + +![Demo](https://github.com/user-attachments/assets/67386464-eb39-4b71-8b9b-34039e2861d0) + +## Quick Start + +Create a new project: ```bash -bunx create-ely my-project +bun create ely ``` -You'll be prompted to choose between: +Or with a project name: -- **Backend only** - ElysiaJS API with PostgreSQL, Drizzle ORM -- **Monorepo** - Backend + Frontend (React with TanStack Router) +```bash +bun create ely my-project +``` -## Templates +You'll be prompted to choose: -### Backend Only +- **Backend Only** - API-first ElysiaJS backend with PostgreSQL, Drizzle ORM, and OpenAPI docs +- **Monorepo** - Full-stack setup with React frontend, TanStack Router, and shared workspace -A production-ready ElysiaJS backend with: +## What's Included -- PostgreSQL database with Drizzle ORM -- Type-safe API with OpenAPI documentation -- Global error handling -- Structured logging with Pino -- Docker support -- Environment validation +**Backend Template:** -### Monorepo +- PostgreSQL + Drizzle ORM for type-safe database access +- OpenAPI documentation out of the box +- Global error handling and structured logging (Pino) +- Docker support for development and production +- Environment validation with type safety -Full-stack setup with: +**Monorepo Template:** -- Backend: Everything from Backend Only template -- Frontend: React + TanStack Router + Vite -- Workspace configuration with Bun +- Everything from Backend template +- React frontend with TanStack Router and Vite +- Bun workspaces for seamless monorepo management -## Development +## Contributing -To test the CLI locally: +> **⚠️ Important:** This project uses Git submodules for templates. Make sure to clone with `git clone --recurse-submodules` or run `git submodule update --init --recursive` after cloning. -```bash -bun link -bunx create-ely -``` +See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and guidelines. ## License diff --git a/assets/demo.mp4 b/assets/demo.mp4 new file mode 100644 index 0000000..c720d9c Binary files /dev/null and b/assets/demo.mp4 differ diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..f24a1ba --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,20 @@ +export const TEMPLATE_TYPES = { + BACKEND: 'backend', + MONOREPO: 'monorepo', +} as const; + +export const DEFAULT_PROJECT_NAME = 'my-ely-app'; + +export const PROJECT_NAME_REGEX = /^[a-z0-9-]+$/; + +export const EXCLUDED_COPY_PATTERNS = ['node_modules', '.git']; + +export const PORTS = { + BACKEND: 3000, + FRONTEND: 5173, +} as const; + +export const TEMPLATE_PATHS = { + BACKEND_BIOME_TEMPLATE: 'apps/backend-biome.json.template', + BACKEND_BIOME_TARGET: 'apps/backend/biome.json', +} as const; diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..26ccdf6 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,95 @@ +import * as clack from '@clack/prompts'; + +/** + * Initializes a git repository in the target directory and creates an initial commit + * @param targetDir - The directory to initialize git in + * @returns Promise that resolves when git initialization is complete + */ +export async function initializeGit(targetDir: string): Promise { + const initGit = await clack.confirm({ + message: 'Initialize git repository?', + initialValue: true, + }); + + if (clack.isCancel(initGit)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + if (!initGit) { + return; + } + + // Check if git is available + const gitCheckProc = Bun.spawn(['git', '--version'], { + stdout: 'pipe', + stderr: 'pipe', + }); + await gitCheckProc.exited; + + if (gitCheckProc.exitCode !== 0) { + clack.log.warn( + 'Git is not installed or not available. Skipping git initialization.', + ); + return; + } + + const gitSpinner = clack.spinner(); + gitSpinner.start('Initializing git repository...'); + + try { + const gitInitProc = Bun.spawn(['git', 'init'], { + cwd: targetDir, + stdout: 'pipe', + stderr: 'pipe', + }); + await gitInitProc.exited; + + if (gitInitProc.exitCode !== 0) { + gitSpinner.stop('Failed to initialize git'); + clack.log.warn( + 'Git initialization failed. You can initialize manually later.', + ); + return; + } + + gitSpinner.stop('Git repository initialized'); + + // Make initial commit + gitSpinner.start('Creating initial commit...'); + + const gitAddProc = Bun.spawn(['git', 'add', '.'], { + cwd: targetDir, + stdout: 'pipe', + stderr: 'pipe', + }); + await gitAddProc.exited; + + if (gitAddProc.exitCode !== 0) { + gitSpinner.stop('Git initialized (add failed)'); + clack.log.warn('Failed to stage files. You can add them manually later.'); + return; + } + + const gitCommitProc = Bun.spawn(['git', 'commit', '-m', 'Initial commit'], { + cwd: targetDir, + stdout: 'pipe', + stderr: 'pipe', + }); + await gitCommitProc.exited; + + if (gitCommitProc.exitCode === 0) { + gitSpinner.stop('Initial commit created'); + } else { + gitSpinner.stop('Git initialized (commit failed)'); + clack.log.warn( + 'Failed to create initial commit. You can commit manually later.', + ); + } + } catch { + gitSpinner.stop('Git initialization failed'); + clack.log.warn( + 'An error occurred during git initialization. You can initialize manually later.', + ); + } +} diff --git a/src/index.ts b/src/index.ts index ae75516..c5748be 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,117 @@ #!/usr/bin/env bun -import { - copyFileSync, - existsSync, - mkdirSync, - readdirSync, - rmSync, - statSync, -} from 'node:fs'; -import { dirname, join } from 'node:path'; +import { existsSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; import * as clack from '@clack/prompts'; +import { + DEFAULT_PROJECT_NAME, + PORTS, + PROJECT_NAME_REGEX, + TEMPLATE_TYPES, +} from './constants.ts'; +import { initializeGit } from './git.ts'; +import { setupTemplate } from './template.ts'; +import { validateProjectName } from './utils.ts'; + +/** + * Gets the project name from command line arguments or prompts the user + * @param args - Command line arguments + * @returns Promise resolving to the project name + */ +async function getProjectName(args: string[]): Promise { + const projectNameArg = args[0]; + + // If project name was provided as argument and is valid, use it + if (projectNameArg && PROJECT_NAME_REGEX.test(projectNameArg)) { + return projectNameArg; + } + + // Otherwise, prompt for it with default value + const projectName = await clack.text({ + message: 'Project name:', + placeholder: DEFAULT_PROJECT_NAME, + initialValue: DEFAULT_PROJECT_NAME, + validate: validateProjectName, + }); + + if (clack.isCancel(projectName)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + // Trim and normalize the project name + return projectName.trim(); +} + +/** + * Handles existing directory by prompting user to overwrite if not empty + * @param targetDir - The target directory path + * @param projectName - The project name + * @returns Promise that resolves when directory is ready + */ +async function handleExistingDirectory( + targetDir: string, + projectName: string, +): Promise { + if (!existsSync(targetDir)) { + return; + } + + const dirContents = readdirSync(targetDir); + const isEmpty = dirContents.length === 0; + + if (isEmpty) { + return; + } + + const shouldOverwrite = await clack.confirm({ + message: `Directory "${projectName}" already exists and is not empty. Overwrite it?`, + initialValue: false, + }); + + if (clack.isCancel(shouldOverwrite) || !shouldOverwrite) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + rmSync(targetDir, { recursive: true, force: true }); +} + +/** + * Generates the next steps message based on project type + * @param projectType - The type of project created + * @param projectName - The project name + * @param targetDir - The target directory path + * @returns Formatted message with next steps + */ +function getNextStepsMessage( + projectType: string, + projectName: string, + targetDir: string, +): string { + const projectTypeLabel = + projectType === TEMPLATE_TYPES.MONOREPO ? 'monorepo' : 'backend'; + + const nextSteps = + projectType === TEMPLATE_TYPES.MONOREPO + ? ` cd ${projectName} + bun run dev:backend # Start backend on http://localhost:${PORTS.BACKEND} + bun run dev:frontend # Start frontend on http://localhost:${PORTS.FRONTEND}` + : ` cd ${projectName} + bun run dev # Start backend on http://localhost:${PORTS.BACKEND}`; + + return ` +✨ Success! Your ElysiaJS ${projectTypeLabel} project is ready. + +📁 Project created at: ${targetDir} + +🚀 Next steps: +${nextSteps} + +📚 Check out the README.md for more information. + +Happy coding! 🎉 + `; +} async function main() { console.clear(); @@ -17,19 +120,18 @@ async function main() { // Check if project name was passed as argument const args = process.argv.slice(2); - const projectNameArg = args[0]; // Step 1: Select project type const projectType = await clack.select({ message: 'What would you like to create?', options: [ { - value: 'backend', + value: TEMPLATE_TYPES.BACKEND, label: 'Backend only', hint: 'ElysiaJS API with PostgreSQL', }, { - value: 'monorepo', + value: TEMPLATE_TYPES.MONOREPO, label: 'Monorepo', hint: 'Backend + Frontend (React + TanStack Router)', }, @@ -42,252 +144,22 @@ async function main() { } // Step 2: Get project name - let projectName: string | symbol; - - // If project name was provided as argument and is valid, use it - if (projectNameArg && /^[a-z0-9-]+$/.test(projectNameArg)) { - projectName = projectNameArg; - } else { - // Otherwise, prompt for it with default value - projectName = await clack.text({ - message: 'Project name:', - placeholder: 'my-ely-app', - initialValue: 'my-ely-app', - validate: (value) => { - if (!value || value.trim() === '') { - return 'Project name is required'; - } - const trimmed = value.trim(); - if (!/^[a-z0-9-]+$/.test(trimmed)) { - return 'Project name must contain only lowercase letters, numbers, and hyphens'; - } - if (trimmed.startsWith('-') || trimmed.endsWith('-')) { - return 'Project name cannot start or end with a hyphen'; - } - return undefined; - }, - }); - - if (clack.isCancel(projectName)) { - clack.cancel('Operation cancelled'); - process.exit(0); - } - - // Trim and normalize the project name - projectName = (projectName as string).trim(); - } - - const targetDir = join(process.cwd(), projectName as string); + const projectName = await getProjectName(args); + const targetDir = join(process.cwd(), projectName); // Step 3: Check if directory exists and handle it - if (existsSync(targetDir)) { - const dirContents = readdirSync(targetDir); - const isEmpty = dirContents.length === 0; - - if (!isEmpty) { - const shouldOverwrite = await clack.confirm({ - message: `Directory "${String(projectName)}" already exists and is not empty. Overwrite it?`, - initialValue: false, - }); - - if (clack.isCancel(shouldOverwrite) || !shouldOverwrite) { - clack.cancel('Operation cancelled'); - process.exit(0); - } - - rmSync(targetDir, { recursive: true, force: true }); - } - } - - const spinner = clack.spinner(); - spinner.start('Creating project...'); + await handleExistingDirectory(targetDir, projectName); try { - const templateDir = join( - import.meta.dir, - '..', - 'templates', - projectType as string, - ); - - // Cross-platform recursive copy function - function copyRecursive(src: string, dest: string, exclude: string[] = []) { - const stats = statSync(src); - - if (stats.isDirectory()) { - if (!existsSync(dest)) { - mkdirSync(dest, { recursive: true }); - } - - for (const entry of readdirSync(src)) { - if (exclude.includes(entry) || entry.endsWith('.template')) continue; - - const srcPath = join(src, entry); - const destPath = join(dest, entry); - copyRecursive(srcPath, destPath, exclude); - } - } else { - const destDir = dirname(dest); - if (!existsSync(destDir)) { - mkdirSync(destDir, { recursive: true }); - } - copyFileSync(src, dest); - } - } - - copyRecursive(templateDir, targetDir, ['node_modules', '.git']); - - // Handle template files: copy them to their intended locations - if (projectType === 'monorepo') { - const backendBiomeTemplate = join( - templateDir, - 'apps', - 'backend-biome.json.template', - ); - const backendBiomeTarget = join( - targetDir, - 'apps', - 'backend', - 'biome.json', - ); - - if (existsSync(backendBiomeTemplate)) { - copyFileSync(backendBiomeTemplate, backendBiomeTarget); - } - } - - spinner.stop('Project structure created!'); - - const s = clack.spinner(); - s.start('Installing dependencies...'); - - // Install dependencies - const installProc = Bun.spawn(['bun', 'install'], { - cwd: targetDir, - stdout: 'inherit', - stderr: 'inherit', - }); - await installProc.exited; - - if (installProc.exitCode !== 0) { - throw new Error('Failed to install dependencies'); - } + // Step 4: Setup template + await setupTemplate(projectType, targetDir); - s.stop('Dependencies installed!'); + // Step 5: Initialize git + await initializeGit(targetDir); - // Step 4: Ask if user wants to initialize git - const initGit = await clack.confirm({ - message: 'Initialize git repository?', - initialValue: true, - }); - - if (clack.isCancel(initGit)) { - clack.cancel('Operation cancelled'); - process.exit(0); - } - - if (initGit) { - // Check if git is available - const gitCheckProc = Bun.spawn(['git', '--version'], { - stdout: 'pipe', - stderr: 'pipe', - }); - await gitCheckProc.exited; - - if (gitCheckProc.exitCode !== 0) { - clack.log.warn( - 'Git is not installed or not available. Skipping git initialization.', - ); - } else { - const gitSpinner = clack.spinner(); - gitSpinner.start('Initializing git repository...'); - - try { - const gitInitProc = Bun.spawn(['git', 'init'], { - cwd: targetDir, - stdout: 'pipe', - stderr: 'pipe', - }); - await gitInitProc.exited; - - if (gitInitProc.exitCode === 0) { - gitSpinner.stop('Git repository initialized'); - - // Make initial commit - gitSpinner.start('Creating initial commit...'); - - const gitAddProc = Bun.spawn(['git', 'add', '.'], { - cwd: targetDir, - stdout: 'pipe', - stderr: 'pipe', - }); - await gitAddProc.exited; - - if (gitAddProc.exitCode === 0) { - const gitCommitProc = Bun.spawn( - ['git', 'commit', '-m', 'Initial commit'], - { - cwd: targetDir, - stdout: 'pipe', - stderr: 'pipe', - }, - ); - await gitCommitProc.exited; - - if (gitCommitProc.exitCode === 0) { - gitSpinner.stop('Initial commit created'); - } else { - gitSpinner.stop('Git initialized (commit failed)'); - clack.log.warn( - 'Failed to create initial commit. You can commit manually later.', - ); - } - } else { - gitSpinner.stop('Git initialized (add failed)'); - clack.log.warn( - 'Failed to stage files. You can add them manually later.', - ); - } - } else { - gitSpinner.stop('Failed to initialize git'); - clack.log.warn( - 'Git initialization failed. You can initialize manually later.', - ); - } - } catch { - gitSpinner.stop('Git initialization failed'); - clack.log.warn( - 'An error occurred during git initialization. You can initialize manually later.', - ); - } - } - } - - // Prepare next steps message - const projectTypeLabel = - projectType === 'monorepo' ? 'monorepo' : 'backend'; - const nextSteps = - projectType === 'monorepo' - ? ` cd ${String(projectName)} - bun run dev:backend # Start backend on http://localhost:3000 - bun run dev:frontend # Start frontend on http://localhost:5173` - : ` cd ${String(projectName)} - bun run dev # Start backend on http://localhost:3000`; - - clack.outro(` -✨ Success! Your ElysiaJS ${projectTypeLabel} project is ready. - -📁 Project created at: ${targetDir} - -🚀 Next steps: -${nextSteps} - -📚 Check out the README.md for more information. - -Happy coding! 🎉 - `); + // Step 6: Show success message + clack.outro(getNextStepsMessage(projectType, projectName, targetDir)); } catch (error) { - spinner.stop('Failed to create project'); clack.cancel( error instanceof Error ? error.message : 'Unknown error occurred', ); diff --git a/src/template.ts b/src/template.ts new file mode 100644 index 0000000..195e233 --- /dev/null +++ b/src/template.ts @@ -0,0 +1,62 @@ +import { copyFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import * as clack from '@clack/prompts'; +import { + EXCLUDED_COPY_PATTERNS, + TEMPLATE_PATHS, + TEMPLATE_TYPES, +} from './constants.ts'; +import { copyRecursive } from './utils.ts'; + +/** + * Sets up the project template by copying files and installing dependencies + * @param projectType - The type of project template to use ('backend' or 'monorepo') + * @param targetDir - The directory to create the project in + * @returns Promise that resolves when template setup is complete + */ +export async function setupTemplate( + projectType: string, + targetDir: string, +): Promise { + const spinner = clack.spinner(); + spinner.start('Creating project...'); + + const templateDir = join(import.meta.dir, '..', 'templates', projectType); + + copyRecursive(templateDir, targetDir, EXCLUDED_COPY_PATTERNS); + + // Handle template files: copy them to their intended locations + if (projectType === TEMPLATE_TYPES.MONOREPO) { + const backendBiomeTemplate = join( + templateDir, + TEMPLATE_PATHS.BACKEND_BIOME_TEMPLATE, + ); + const backendBiomeTarget = join( + targetDir, + TEMPLATE_PATHS.BACKEND_BIOME_TARGET, + ); + + if (existsSync(backendBiomeTemplate)) { + copyFileSync(backendBiomeTemplate, backendBiomeTarget); + } + } + + spinner.stop('Project structure created!'); + + const s = clack.spinner(); + s.start('Installing dependencies...'); + + // Install dependencies + const installProc = Bun.spawn(['bun', 'install'], { + cwd: targetDir, + stdout: 'inherit', + stderr: 'inherit', + }); + await installProc.exited; + + if (installProc.exitCode !== 0) { + throw new Error('Failed to install dependencies'); + } + + s.stop('Dependencies installed!'); +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..bf8a6a3 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,64 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + statSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; + +/** + * Recursively copies files and directories from source to destination + * @param src - Source path to copy from + * @param dest - Destination path to copy to + * @param exclude - Array of file/directory names to exclude from copying + */ +export function copyRecursive( + src: string, + dest: string, + exclude: string[] = [], +): void { + const stats = statSync(src); + + if (stats.isDirectory()) { + if (!existsSync(dest)) { + mkdirSync(dest, { recursive: true }); + } + + for (const entry of readdirSync(src)) { + if (exclude.includes(entry) || entry.endsWith('.template')) continue; + + const srcPath = join(src, entry); + const destPath = join(dest, entry); + copyRecursive(srcPath, destPath, exclude); + } + } else { + const destDir = dirname(dest); + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }); + } + copyFileSync(src, dest); + } +} + +/** + * Validates a project name according to npm package naming rules + * @param value - The project name to validate + * @returns Error message if invalid, undefined if valid + */ +export function validateProjectName(value: string): string | undefined { + if (!value || value.trim() === '') { + return 'Project name is required'; + } + + const trimmed = value.trim(); + if (!/^[a-z0-9-]+$/.test(trimmed)) { + return 'Project name must contain only lowercase letters, numbers, and hyphens'; + } + + if (trimmed.startsWith('-') || trimmed.endsWith('-')) { + return 'Project name cannot start or end with a hyphen'; + } + + return undefined; +}