diff --git a/.github/scripts/parse-logs.js b/.github/scripts/parse-logs.js index 235ea9e..693c9a9 100644 --- a/.github/scripts/parse-logs.js +++ b/.github/scripts/parse-logs.js @@ -1,5 +1,5 @@ -import fs from "fs"; -import path from "path"; +import fs from "node:fs"; +import path from "node:path"; const artifactsDir = "./artifacts"; const summaries = []; @@ -8,10 +8,7 @@ if (!fs.existsSync(artifactsDir)) { console.error(`āŒ Artifacts directory not found: ${artifactsDir}`); console.log("Creating empty summary..."); fs.mkdirSync(artifactsDir, { recursive: true }); - fs.writeFileSync( - path.join(artifactsDir, "parsed-summary.json"), - JSON.stringify([], null, 2) - ); + fs.writeFileSync(path.join(artifactsDir, "parsed-summary.json"), JSON.stringify([], null, 2)); process.exit(0); } diff --git a/README.md b/README.md index 934087f..aa96d61 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ +# create-next-quick +
create-next-quick logo

create-next-quick

-

The fastest and most configurable way to spin up a new Next.js project.

-

create-next-quick is a CLI tool that lets you instantly create a new Next.js project with a highly customized and clean setup.

+

The fastest and most configurable way to spin up a new Next.js project

+

A powerful CLI tool that lets you instantly create a new Next.js project with a highly customized and clean setup.

+

npm version license @@ -13,9 +16,100 @@

-## šŸ›  Usage +--- + +## Table of Contents + +- [Why create-next-quick?](#why-create-next-quick) +- [Features](#features) +- [Quick Start](#quick-start) +- [Usage](#usage) + - [Basic Usage](#basic-usage) + - [Interactive Mode](#interactive-mode-for-existing-projects) + - [Interactive Prompts](#interactive-prompts) + - [Example Walkthrough](#example-walkthrough) +- [Configuration Options](#configuration-options) +- [Advanced Features](#advanced-features) + - [Authentication Setup](#authentication-setup) + - [Testing Frameworks](#testing-frameworks) + - [Docker Support](#docker-support) +- [Prerequisites](#prerequisites) +- [Available Scripts](#available-scripts) +- [Testing](#testing) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [Show Your Support](#show-your-support) +- [License](#license) + +--- + +## Why create-next-quick? + +`create-next-quick` is a powerful, lightweight alternative to `create-next-app`, designed for developers who want **more control and speed** right from the start. + +### Comparison with create-next-app + +| Feature | `create-next-quick` | `create-next-app` | +| ------------------------------ | ---------------------------------------------- | ----------------- | +| **Multi-page Generation** | āœ… Yes (e.g., `home, about, contact`) | āŒ No | +| **ORM Support** | āœ… Yes (Prisma, Drizzle) | āŒ No | +| **Linter Choice** | āœ… Yes (ESLint, Biome) | āŒ ESLint only | +| **Shadcn UI Auto-Setup** | āœ… Yes (non-interactive) | āŒ No | +| **Authentication Integration** | āœ… Yes (NextAuth, Clerk, Lucia) | āŒ No | +| **Testing Framework Setup** | āœ… Yes (Vitest, Jest) | āŒ No | +| **Docker Configuration** | āœ… Yes (Dockerfile + .dockerignore) | āŒ No | +| **Clean Project Start** | āœ… Yes (removes boilerplate & default assets) | āŒ No | +| **Package Manager Detection** | āœ… Yes (npm, yarn, pnpm) | āœ… Yes | +| **Robust Error Handling** | āœ… Yes (retries, cleanup, clear tips) | āŒ Basic | +| **Interactive Mode** | āœ… Yes (add features to existing projects) | āŒ No | + +--- + +## Features + +### Project Scaffolding + +- **CLI Argument for Project Name**: Skip the project name prompt by passing it as an argument +- **Custom Page Generation**: Create multiple pages at once from the CLI +- **Clean Project Setup**: Automatically removes the default favicon, clears the public folder, and provides clean `page.tsx` and `layout.tsx` files +- **Safe Project Creation**: Prevents accidental overwrites by checking if the target directory is empty +- **Conditional API Route Deletion**: Removes the default `hello.js` API route when not needed -No global installation is needed. Run it instantly with `npx`: +### Tooling & Integrations + +- **Package Manager Detection**: Auto-detects `npm`, `yarn`, and `pnpm` +- **Linter Support**: Choose between ESLint, Biome, or no linter +- **ORM Support**: Integrated setup for Prisma or Drizzle (with TypeScript/JavaScript support) +- **Shadcn UI**: Automatically installs and configures Shadcn UI non-interactively +- **TypeScript CSS Module Support**: Generates `global.d.ts` to provide type declarations for CSS imports +- **Authentication**: Integrated setup for **NextAuth.js**, **Clerk**, or **Lucia** with middleware and API routes +- **Testing**: Ready-to-use configuration for **Vitest** or **Jest** with React Testing Library +- **Docker**: Auto-generated `Dockerfile` and `.dockerignore` for containerized deployments + +### Developer Experience + +- **Robust Error Handling**: Automatic retries for failed installs, intelligent cleanup of incomplete projects, and actionable troubleshooting tips +- **Automated CI/CD Feedback**: Get automated comments on the test status of your Pull Requests +- **Interactive Mode**: Add features to existing Next.js projects without starting from scratch +- **Zero External Dependencies**: Uses custom lightweight prompt implementation (no inquirer dependency) + +--- + +## Quick Start + +No global installation needed. Run it instantly with `npx`: + +```bash +npx create-next-quick my-app +cd my-app +pnpm run dev +``` + +--- + +## Usage + +### Basic Usage ```bash npx create-next-quick [project-name] @@ -28,13 +122,14 @@ If you omit `[project-name]`, the tool will prompt you for it. Use `.` to create Already have a Next.js project? Use the `-i` or `--interactive` flag to run `create-next-quick` on your existing project and add new features without starting from scratch. ```bash +cd my-existing-nextjs-project npx create-next-quick -i ``` When you run in interactive mode, the tool will: -1. **Detect Your Project's Setup**: It automatically detects if you're using TypeScript, the `src` directory, the `app` directory, and Tailwind CSS. -2. **Skip Project Creation**: It will not run `create-next-app` or remove any existing files. -3. **Prompt for Additions**: It will only ask you what you want to *add* to your project, such as new pages, a linter, an ORM, or Shadcn UI. +1. **Detect Your Project's Setup**: Automatically detects if you're using TypeScript, the `src` directory, the `app` directory, and Tailwind CSS +2. **Skip Project Creation**: It will not run `create-next-app` or remove any existing files +3. **Prompt for Additions**: It will only ask you what you want to *add* to your project, such as new pages, a linter, an ORM, or Shadcn UI This is perfect for when you want to add a new feature to your project and want to use the same streamlined setup process. @@ -44,15 +139,18 @@ The CLI will guide you through the following options: | Prompt | Description | Default | | --------------------- | -------------------------------------------------- | ------- | -| **Package Manager** | Auto-detects installed `npm`, `yarn`, `pnpm` | `pnpm` | -| **TypeScript** | Use TypeScript for type safety | `Yes` | -| **Tailwind CSS** | Use Tailwind CSS for styling | `Yes` | -| **`src/` Directory** | Use the `src/` directory for project structure | `Yes` | -| **App Router** | Use the Next.js App Router | `Yes` | -| **Pages** | Enter page names to create (comma-separated) | `none` | -| **Linter** | Choose a linter (`ESLint`, `Biome`) | `none` | -| **ORM** | Choose an ORM (`Prisma`, `Drizzle`) | `none` | -| **Shadcn UI** | Automatically install and set up Shadcn UI | `Yes` | +| **Package Manager** | Auto-detects installed `npm`, `yarn`, `pnpm` | `pnpm` | +| **TypeScript** | Use TypeScript for type safety | `Yes` | +| **Tailwind CSS** | Use Tailwind CSS for styling | `Yes` | +| **`src/` Directory** | Use the `src/` directory for project structure | `Yes` | +| **App Router** | Use the Next.js App Router | `Yes` | +| **Pages** | Enter page names to create (comma-separated) | `none` | +| **Linter** | Choose a linter (`ESLint`, `Biome`) | `none` | +| **ORM** | Choose an ORM (`Prisma`, `Drizzle`) | `none` | +| **Testing** | Choose a testing framework (`Vitest`, `Jest`) | `none` | +| **Authentication** | Choose auth solution (`NextAuth`, `Clerk`, `Lucia`)| `none` | +| **Shadcn UI** | Automatically install and set up Shadcn UI | `Yes` | +| **Docker** | Add Docker support with Dockerfile | `No` | ### Example Walkthrough @@ -67,46 +165,119 @@ $ npx create-next-quick my-portfolio ? Enter the names of the pages you want to create (comma-separated): home, about, contact āœ” Using default for linter: none āœ” Using default for ORM: none +āœ” Using default for testing: none +āœ” Using default for authentication: none āœ” Using default for Shadcn UI: Yes +āœ” Using default for Docker: No ``` -## Why create-next-quick? +--- -`create-next-quick` is a powerful, lightweight alternative to `create-next-app`, designed for developers who want more control and speed right from the start. +## Configuration Options -| Feature | `create-next-quick` | `create-next-app` | -| -------------------------------- | ------------------------------------------------------ | ----------------- | -| **Multi-page Generation** | āœ… Yes (e.g., `home, about, contact`) | āŒ No | -| **ORM Support** | āœ… Yes (Prisma, Drizzle) | āŒ No | -| **Linter Choice** | āœ… Yes (ESLint, Biome) | āŒ ESLint only | -| **Shadcn UI Auto-Setup** | āœ… Yes (non-interactive) | āŒ No | -| **Clean Project Start** | āœ… Yes (removes boilerplate & default assets) | āŒ No | -| **Package Manager Detection** | āœ… Yes (npm, yarn, pnpm) | āœ… Yes | -| **Robust Error Handling** | āœ… Yes (retries, cleanup, clear tips) | āŒ Basic | +### Linters -## Features +#### ESLint -### Project Scaffolding -- **CLI Argument for Project Name**: Skip the project name prompt by passing it as an argument. -- **Custom Page Generation**: Create multiple pages at once from the CLI. -- **Clean Project Setup**: Automatically removes the default favicon, clears the public folder, and provides a clean `page.tsx` and `layout.tsx`. -- **Safe Project Creation**: Prevents accidental overwrites by checking if the target directory is empty. -- **Conditional API Route Deletion**: Removes the default `hello.js` API route when not needed. +- Pre-configured with Next.js recommended rules +- Automatically installed if selected -### Tooling & Integrations -- **Package Manager Detection**: Auto-detects `npm`, `yarn`, and `pnpm`. -- **Linter Support**: Choose between ESLint, Biome, or no linter. -- **ORM Support**: Integrated setup for Prisma or Drizzle. -- **Shadcn UI**: Automatically installs and configures Shadcn UI non-interactively. -- **TypeScript CSS Module Support**: Generates `global.d.ts` to provide type declarations for CSS imports. +#### Biome -### Developer Experience -- **Robust Error Handling**: Automatic retries for failed installs, intelligent cleanup of incomplete projects, and actionable troubleshooting tips. -- **Automated CI/CD Feedback**: Get automated comments on the test status of your Pull Requests. +- Fast, modern linter and formatter +- Zero-config setup with `biome.json` +- Includes pre-commit hooks with Husky + +### ORMs + +#### Prisma + +- Automatically installs `prisma` and `@prisma/client` +- Initializes Prisma with `prisma init` +- Creates a singleton Prisma client in `src/lib/prisma.ts` (or `.js`) +- Generates `.env` file with `DATABASE_URL` placeholder +- Supports both TypeScript and JavaScript projects + +#### Drizzle + +- Installs `drizzle-orm`, `drizzle-kit`, and `@vercel/postgres` +- Creates `drizzle.config.ts` (or `.js`) with PostgreSQL dialect +- Generates example schema in `src/db/schema.ts` (or `.js`) +- Supports both TypeScript and JavaScript projects +- Includes `.env` file with `DATABASE_URL` placeholder + +--- + +## Advanced Features + +### Authentication Setup + +#### NextAuth.js (v5 Beta) + +```text +Automatically sets up: +- src/lib/auth.ts (or .js) with credentials provider +- src/app/api/auth/[...nextauth]/route.ts (or .js) +``` + +#### Clerk + +```text +Automatically sets up: +- middleware.ts (or .js) with Clerk middleware +- .env with NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY placeholders +``` + +#### Lucia + +```text +Installs core package +- Provides instructions for adapter setup +``` + +### Testing Frameworks + +#### Vitest + +```text +Automatically configures: +- vitest.config.ts (or .js) with React plugin +- vitest.setup.ts (or .js) with jest-dom +- package.json test script +- ES module compatible path resolution +``` + +#### Jest + +```text +Automatically configures: +- jest.config.js with Next.js integration +- jest.setup.js with jest-dom +- package.json test script +- TypeScript support (if enabled) +``` + +### Docker Support + +Generates production-ready Docker configuration: +- Multi-stage build for optimized image size +- Standalone output mode in `next.config.mjs` +- `.dockerignore` for efficient builds +- Node.js 20 Alpine base image + +--- ## Prerequisites -- **Node.js**: `v20.0.0` or higher. +- **Node.js**: `v20.0.0` or higher + +Check your Node.js version: + +```bash +node --version +``` + +--- ## Available Scripts @@ -114,45 +285,170 @@ Once your project is created, you can use the following commands: | `pnpm` | `npm` | `yarn` | Description | | --------------- | --------------- | --------------- | ------------------------------ | -| `pnpm dev` | `npm run dev` | `yarn dev` | Starts the development server. | -| `pnpm build` | `npm run build` | `yarn build` | Builds the app for production. | -| `pnpm start` | `npm start` | `yarn start` | Starts the production server. | +| `pnpm dev` | `npm run dev` | `yarn dev` | Starts the development server | +| `pnpm build` | `npm run build` | `yarn build` | Builds the app for production | +| `pnpm start` | `npm start` | `yarn start` | Starts the production server | +| `pnpm lint` | `npm run lint` | `yarn lint` | Runs the linter (if configured)| +| `pnpm test` | `npm test` | `yarn test` | Runs tests (if configured) | + +--- ## Testing The test suite is dynamically generated to ensure comprehensive coverage. -1. **Generate Test Cases:** - ```bash - npm run test:generate - ``` -2. **Run Tests:** - ```bash - npm test - ``` +### Generate Test Cases + +```bash +npm run test:generate +``` + +### Run Tests + +```bash +npm test +``` + +### Run Interactive Tests + +```bash +npm run test:interactive +``` + +--- + +## Troubleshooting + +### Common Issues + +#### Installation Fails + +```bash +# Check your internet connection +# Ensure package manager is up to date +npm install -g npm@latest +# or +pnpm add -g pnpm@latest +``` + +#### Permission Errors + +If you encounter permission errors, **avoid using `sudo`** as it can cause npm cache permission issues and create root-owned files in user directories. Instead, try these safer alternatives: + +```bash +# Option 1: Use a Node version manager (recommended) +# Install nvm (https://github.com/nvm-sh/nvm) or fnm (https://github.com/Schniz/fnm) +# Then reinstall Node.js through the version manager + +# Option 2: Fix npm permissions by setting a user-local prefix +mkdir -p ~/.npm-global +npm config set prefix '~/.npm-global' +# Add to your ~/.bashrc or ~/.zshrc: +# export PATH=~/.npm-global/bin:$PATH + +# Option 3: Ensure you're creating the project in a user-owned directory +cd ~/projects # or any directory you own +npx create-next-quick my-app +``` + +#### TypeScript Errors After Setup + +```bash +# Regenerate types +pnpm run build +``` + +#### Prisma Client Not Found + +```bash +# Generate Prisma client +npx prisma generate +``` + +#### Port Already in Use + +```bash +# Kill the process using port 3000 +# On macOS/Linux: +lsof -ti:3000 | xargs kill -9 +# On Windows: +netstat -ano | findstr :3000 +taskkill /PID /F +``` + +--- ## Contributing -Contributions are welcome! Please fork the repository, create a feature branch, and open a Pull Request. +Contributions are welcome! Please follow these steps: + +1. **Fork the repository** +2. **Create a new branch**: `git checkout -b feature/your-feature-name` +3. **Make your changes** and commit them: `git commit -m "feat: Add some amazing feature"` +4. **Push to the branch**: `git push origin feature/your-feature-name` +5. **Open a Pull Request** + +### Development Setup + +```bash +# Clone the repository +git clone https://github.com/gaureshpai/create-next-quick.git +cd create-next-quick + +# Install dependencies +pnpm install + +# Run tests +pnpm test -1. Fork the repository. -2. Create a new branch: `git checkout -b feature/your-feature-name` -3. Commit your changes: `git commit -m "feat: Add some amazing feature"` -4. Push to the branch: `git push origin feature/your-feature-name` -5. Open a Pull Request. +# Generate test cases +pnpm run test:generate + +# Lint code +pnpm run lint +``` + +### Commit Convention + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` New feature +- `fix:` Bug fix +- `docs:` Documentation changes +- `chore:` Maintenance tasks +- `refactor:` Code refactoring +- `test:` Test updates + +--- ## Show Your Support Give a ā­ļø if this project helped you! +[![Sponsor on Patreon](https://img.shields.io/badge/sponsor-Patreon-F96854.svg)](https://patreon.com/gaureshpai) + +--- + ## License This project is licensed under the **MIT License**. See the [LICENSE](./LICENSE) file for details. +--- + ## Contributors
- + Contributors +
+ +--- + +
+

+ Documentation • + Report Bug • + Request Feature +

\ No newline at end of file diff --git a/biome.json b/biome.json index 7726b22..9761e33 100644 --- a/biome.json +++ b/biome.json @@ -5,9 +5,6 @@ "clientKind": "git", "useIgnoreFile": true }, - "files": { - "includes": ["src"] - }, "formatter": { "enabled": true, "indentStyle": "space", diff --git a/docs/index.html b/docs/index.html index f544699..00bdf95 100644 --- a/docs/index.html +++ b/docs/index.html @@ -327,6 +327,21 @@

Interactive Prompts

Automatically install and set up Shadcn UI Yes + + Testing + Choose a testing framework (Vitest, Jest) + none + + + Authentication + Choose auth solution (NextAuth, Clerk, Lucia) + none + + + Docker + Add Docker support with Dockerfile and .dockerignore + No + diff --git a/docs/script.js b/docs/script.js index dc5f69b..59422d5 100644 --- a/docs/script.js +++ b/docs/script.js @@ -1,61 +1,61 @@ -const hamburgerMenu = document.getElementById("hamburger-menu") -const mainNav = document.getElementById("main-nav") -const sidebar = document.getElementById("sidebar") -const copyButtons = document.querySelectorAll(".copy-btn") +const hamburgerMenu = document.getElementById("hamburger-menu"); +const mainNav = document.getElementById("main-nav"); +const sidebar = document.getElementById("sidebar"); +const copyButtons = document.querySelectorAll(".copy-btn"); hamburgerMenu.addEventListener("click", () => { - hamburgerMenu.classList.toggle("active") - mainNav.classList.toggle("active") -}) + hamburgerMenu.classList.toggle("active"); + mainNav.classList.toggle("active"); +}); document.querySelectorAll("#main-nav a").forEach((link) => { - link.addEventListener("click", () => { - hamburgerMenu.classList.remove("active") - mainNav.classList.remove("active") - }) -}) + link.addEventListener("click", () => { + hamburgerMenu.classList.remove("active"); + mainNav.classList.remove("active"); + }); +}); hamburgerMenu.addEventListener("click", () => { - sidebar.classList.toggle("active") -}) + sidebar.classList.toggle("active"); +}); document.querySelectorAll(".sidebar a").forEach((link) => { - link.addEventListener("click", () => { - if (window.innerWidth <= 768) { - sidebar.classList.remove("active") - hamburgerMenu.classList.remove("active") - } - }) -}) + link.addEventListener("click", () => { + if (window.innerWidth <= 768) { + sidebar.classList.remove("active"); + hamburgerMenu.classList.remove("active"); + } + }); +}); copyButtons.forEach((button) => { - button.addEventListener("click", () => { - const textToCopy = button.getAttribute("data-code") - const originalText = button.innerHTML - - navigator.clipboard - .writeText(textToCopy) - .then(() => { - button.classList.add("copied") - button.innerHTML = 'āœ“Copied!' - - setTimeout(() => { - button.classList.remove("copied") - button.innerHTML = originalText - }, 2000) - }) - .catch(() => { - button.innerHTML = 'āœ—Failed' - setTimeout(() => { - button.innerHTML = originalText - }, 2000) - }) - }) -}) + button.addEventListener("click", () => { + const textToCopy = button.getAttribute("data-code"); + const originalText = button.innerHTML; + + navigator.clipboard + .writeText(textToCopy) + .then(() => { + button.classList.add("copied"); + button.innerHTML = 'āœ“Copied!'; + + setTimeout(() => { + button.classList.remove("copied"); + button.innerHTML = originalText; + }, 2000); + }) + .catch(() => { + button.innerHTML = 'āœ—Failed'; + setTimeout(() => { + button.innerHTML = originalText; + }, 2000); + }); + }); +}); document.addEventListener("click", (e) => { - if (!e.target.closest("header")) { - hamburgerMenu.classList.remove("active") - mainNav.classList.remove("active") - } -}) + if (!e.target.closest("header")) { + hamburgerMenu.classList.remove("active"); + mainNav.classList.remove("active"); + } +}); diff --git a/docs/site.webmanifest b/docs/site.webmanifest index 45dc8a2..3ad408b 100644 --- a/docs/site.webmanifest +++ b/docs/site.webmanifest @@ -1 +1,19 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "create-next-quick", + "short_name": "create-next-quick", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/docs/style.css b/docs/style.css index 263e629..abbe681 100644 --- a/docs/style.css +++ b/docs/style.css @@ -1,523 +1,523 @@ :root { - --primary: #cb3837; - --primary-hover: #a83534; - --primary-light: #fff5f5; - --text-primary: #1a1a1a; - --text-secondary: #4a4a4a; - --text-tertiary: #6f6f6f; - --bg-primary: #ffffff; - --bg-secondary: #f5f5f5; - --bg-tertiary: #fafafa; - --border-color: #e0e0e0; - --code-bg: #2d2d2d; - --code-text: #f8f8f2; - --success: #10b981; + --primary: #cb3837; + --primary-hover: #a83534; + --primary-light: #fff5f5; + --text-primary: #1a1a1a; + --text-secondary: #4a4a4a; + --text-tertiary: #6f6f6f; + --bg-primary: #ffffff; + --bg-secondary: #f5f5f5; + --bg-tertiary: #fafafa; + --border-color: #e0e0e0; + --code-bg: #2d2d2d; + --code-text: #f8f8f2; + --success: #10b981; } * { - box-sizing: border-box; + box-sizing: border-box; } html { - scroll-behavior: smooth; + scroll-behavior: smooth; } body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - line-height: 1.6; - color: var(--text-primary); - background-color: var(--bg-secondary); - margin: 0; - padding: 0; - min-height: 100vh; + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.6; + color: var(--text-primary); + background-color: var(--bg-secondary); + margin: 0; + padding: 0; + min-height: 100vh; } .header { - background-color: var(--bg-primary); - border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: 100; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + background-color: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .header-content { - max-width: 1400px; - margin: 0 auto; - padding: 1rem 2rem; - display: flex; - justify-content: space-between; - align-items: center; + max-width: 1400px; + margin: 0 auto; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; } .logo-section { - display: flex; - align-items: center; - gap: 0.75rem; - text-decoration: none; - color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.75rem; + text-decoration: none; + color: var(--text-primary); } .logo-image { - height: 40px; - width: auto; - object-fit: contain; + height: 40px; + width: auto; + object-fit: contain; } .logo-text { - font-size: 1.25rem; - font-weight: 700; - color: var(--text-primary); - letter-spacing: -0.3px; + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.3px; } .hamburger-menu { - display: none; - flex-direction: column; - justify-content: space-around; - width: 28px; - height: 24px; - background: transparent; - border: none; - cursor: pointer; - padding: 0; - z-index: 1001; + display: none; + flex-direction: column; + justify-content: space-around; + width: 28px; + height: 24px; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + z-index: 1001; } .hamburger-menu span { - display: block; - width: 100%; - height: 2px; - background-color: var(--text-primary); - border-radius: 1px; - transition: all 0.3s ease-in-out; + display: block; + width: 100%; + height: 2px; + background-color: var(--text-primary); + border-radius: 1px; + transition: all 0.3s ease-in-out; } .hamburger-menu.active span:nth-child(1) { - transform: rotate(45deg) translate(8px, 8px); + transform: rotate(45deg) translate(8px, 8px); } .hamburger-menu.active span:nth-child(2) { - opacity: 0; + opacity: 0; } .hamburger-menu.active span:nth-child(3) { - transform: rotate(-45deg) translate(7px, -7px); + transform: rotate(-45deg) translate(7px, -7px); } .header-nav ul { - list-style: none; - margin: 0; - padding: 0; - display: flex; - gap: 2.5rem; + list-style: none; + margin: 0; + padding: 0; + display: flex; + gap: 2.5rem; } .header-nav ul li a { - color: var(--text-secondary); - font-weight: 500; - font-size: 0.95rem; - text-decoration: none; - transition: color 0.2s ease; - position: relative; + color: var(--text-secondary); + font-weight: 500; + font-size: 0.95rem; + text-decoration: none; + transition: color 0.2s ease; + position: relative; } .header-nav ul li a:hover { - color: var(--primary); + color: var(--primary); } .main-wrapper { - display: flex; - max-width: 1400px; - margin: 0 auto; - width: 100%; - min-height: calc(100vh - 73px); + display: flex; + max-width: 1400px; + margin: 0 auto; + width: 100%; + min-height: calc(100vh - 73px); } .sidebar { - width: 280px; - background-color: var(--bg-tertiary); - border-right: 1px solid var(--border-color); - padding: 2rem 0; - overflow-y: auto; - flex-shrink: 0; - height: calc(100vh - 73px); - position: sticky; - top: 73px; + width: 280px; + background-color: var(--bg-tertiary); + border-right: 1px solid var(--border-color); + padding: 2rem 0; + overflow-y: auto; + flex-shrink: 0; + height: calc(100vh - 73px); + position: sticky; + top: 73px; } .sidebar-nav ul { - list-style: none; - margin: 0; - padding: 0; + list-style: none; + margin: 0; + padding: 0; } .sidebar-nav ul li { - margin: 0; + margin: 0; } .sidebar-nav ul li a { - display: block; - color: var(--text-secondary); - text-decoration: none; - padding: 0.75rem 1.5rem; - font-size: 0.95rem; - font-weight: 500; - transition: all 0.2s ease; - border-left: 3px solid transparent; + display: block; + color: var(--text-secondary); + text-decoration: none; + padding: 0.75rem 1.5rem; + font-size: 0.95rem; + font-weight: 500; + transition: all 0.2s ease; + border-left: 3px solid transparent; } .sidebar-nav ul li a:hover { - background-color: var(--primary-light); - color: var(--primary); - border-left-color: var(--primary); + background-color: var(--primary-light); + color: var(--primary); + border-left-color: var(--primary); } .content { - flex: 1; - padding: 3rem; - overflow-y: auto; - background-color: var(--bg-primary); + flex: 1; + padding: 3rem; + overflow-y: auto; + background-color: var(--bg-primary); } .hero-section { - margin-bottom: 3rem; - padding-bottom: 2rem; - border-bottom: 1px solid var(--border-color); + margin-bottom: 3rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--border-color); } .hero-section h1 { - font-size: 3rem; - color: var(--primary); - margin: 0 0 0.5rem 0; - font-weight: 700; + font-size: 3rem; + color: var(--primary); + margin: 0 0 0.5rem 0; + font-weight: 700; } table, th, td { - border: 1px solid black; + border: 1px solid black; } .subtitle { - font-size: 1.1rem; - color: #666; + font-size: 1.1rem; + color: #666; } .contributors-section { - text-align: center; + text-align: center; } .contributors-section img { - max-width: 100%; - height: auto; + max-width: 100%; + height: auto; } h2 { - font-size: 1.75rem; - color: var(--text-primary); - margin: 2.5rem 0 1.5rem 0; - font-weight: 700; - padding-bottom: 0.75rem; - border-bottom: 2px solid var(--border-color); + font-size: 1.75rem; + color: var(--text-primary); + margin: 2.5rem 0 1.5rem 0; + font-weight: 700; + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--border-color); } h3 { - font-size: 1.25rem; - color: var(--text-primary); - margin: 1.5rem 0 1rem 0; - font-weight: 600; + font-size: 1.25rem; + color: var(--text-primary); + margin: 1.5rem 0 1rem 0; + font-weight: 600; } p { - color: var(--text-secondary); - line-height: 1.8; - margin: 0 0 1.25rem 0; - font-size: 1rem; + color: var(--text-secondary); + line-height: 1.8; + margin: 0 0 1.25rem 0; + font-size: 1rem; } code { - background-color: var(--primary-light); - color: var(--primary); - padding: 0.3rem 0.6rem; - border-radius: 4px; - font-family: "Fira Code", monospace; - font-size: 0.9em; - font-weight: 500; + background-color: var(--primary-light); + color: var(--primary); + padding: 0.3rem 0.6rem; + border-radius: 4px; + font-family: "Fira Code", monospace; + font-size: 0.9em; + font-weight: 500; } a { - color: var(--primary); - text-decoration: none; - transition: color 0.2s ease; + color: var(--primary); + text-decoration: none; + transition: color 0.2s ease; } a:hover { - color: var(--primary-hover); - text-decoration: underline; + color: var(--primary-hover); + text-decoration: underline; } ul, ol { - margin: 0 0 1.5rem 0; - padding-left: 1.75rem; - color: var(--text-secondary); + margin: 0 0 1.5rem 0; + padding-left: 1.75rem; + color: var(--text-secondary); } ul li, ol li { - margin-bottom: 0.75rem; - line-height: 1.8; + margin-bottom: 0.75rem; + line-height: 1.8; } ul li strong, ol li strong { - color: var(--text-primary); + color: var(--text-primary); } section { - margin-bottom: 2.5rem; + margin-bottom: 2.5rem; } .code-block { - background-color: var(--code-bg); - color: var(--code-text); - padding: 1.5rem; - border-radius: 6px; - margin: 1.5rem 0; - overflow-x: auto; - font-family: "Fira Code", monospace; - font-size: 0.95rem; - line-height: 1.6; - border: 1px solid rgba(255, 255, 255, 0.1); - position: relative; - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; + background-color: var(--code-bg); + color: var(--code-text); + padding: 1.5rem; + border-radius: 6px; + margin: 1.5rem 0; + overflow-x: auto; + font-family: "Fira Code", monospace; + font-size: 0.95rem; + line-height: 1.6; + border: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; } .code-block code { - background-color: transparent; - color: inherit; - padding: 0; - flex: 1; - overflow-x: auto; + background-color: transparent; + color: inherit; + padding: 0; + flex: 1; + overflow-x: auto; } .copy-btn { - background-color: var(--primary); - color: white; - border: none; - padding: 0.6rem 1.2rem; - border-radius: 4px; - cursor: pointer; - font-size: 0.9rem; - font-weight: 600; - transition: all 0.2s ease; - white-space: nowrap; - flex-shrink: 0; - display: flex; - align-items: center; - gap: 0.5rem; + background-color: var(--primary); + color: white; + border: none; + padding: 0.6rem 1.2rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + transition: all 0.2s ease; + white-space: nowrap; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.5rem; } .copy-btn:hover { - background-color: var(--primary-hover); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(203, 56, 55, 0.2); + background-color: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(203, 56, 55, 0.2); } .copy-btn:active { - transform: translateY(0); + transform: translateY(0); } .copy-btn.copied { - background-color: var(--success); + background-color: var(--success); } .copy-icon { - font-size: 1rem; + font-size: 1rem; } @media (max-width: 1024px) { - .sidebar { - width: 240px; - padding: 1.5rem 0; - } + .sidebar { + width: 240px; + padding: 1.5rem 0; + } - .sidebar-nav ul li a { - padding: 0.7rem 1.25rem; - font-size: 0.9rem; - } + .sidebar-nav ul li a { + padding: 0.7rem 1.25rem; + font-size: 0.9rem; + } - .content { - padding: 2.5rem 2rem; - } + .content { + padding: 2.5rem 2rem; + } - .hero-section h1 { - font-size: 2.5rem; - } + .hero-section h1 { + font-size: 2.5rem; + } - h2 { - font-size: 1.5rem; - } + h2 { + font-size: 1.5rem; + } } @media (max-width: 768px) { - .header-content { - padding: 1rem 1.5rem; - } - - .hamburger-menu { - display: flex; - } - - .header-nav { - display: none; - position: absolute; - top: 73px; - left: 0; - right: 0; - background-color: var(--bg-primary); - border-bottom: 1px solid var(--border-color); - z-index: 999; - } - - .header-nav.active { - display: block; - } - - .header-nav ul { - flex-direction: column; - gap: 0; - padding: 1rem 0; - } - - .header-nav ul li { - margin: 0; - border-bottom: 1px solid var(--border-color); - } - - .header-nav ul li a { - display: block; - padding: 1rem 1.5rem; - text-align: left; - } - - .main-wrapper { - flex-direction: column; - min-height: auto; - } - - .sidebar { - width: 100%; - border-right: none; - border-bottom: 1px solid var(--border-color); - padding: 0; - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease-in-out; - position: static; - height: auto; - } - - .sidebar.active { - max-height: 500px; - padding: 1.5rem 0; - } - - .sidebar-nav ul li a { - padding: 0.75rem 1.5rem; - } - - .content { - padding: 2rem 1.5rem; - } - - .hero-section h1 { - font-size: 2rem; - } - - h2 { - font-size: 1.4rem; - } - - .code-block { - flex-direction: column; - align-items: flex-start; - } - - .copy-btn { - width: 100%; - justify-content: center; - } + .header-content { + padding: 1rem 1.5rem; + } + + .hamburger-menu { + display: flex; + } + + .header-nav { + display: none; + position: absolute; + top: 73px; + left: 0; + right: 0; + background-color: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + z-index: 999; + } + + .header-nav.active { + display: block; + } + + .header-nav ul { + flex-direction: column; + gap: 0; + padding: 1rem 0; + } + + .header-nav ul li { + margin: 0; + border-bottom: 1px solid var(--border-color); + } + + .header-nav ul li a { + display: block; + padding: 1rem 1.5rem; + text-align: left; + } + + .main-wrapper { + flex-direction: column; + min-height: auto; + } + + .sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--border-color); + padding: 0; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-in-out; + position: static; + height: auto; + } + + .sidebar.active { + max-height: 500px; + padding: 1.5rem 0; + } + + .sidebar-nav ul li a { + padding: 0.75rem 1.5rem; + } + + .content { + padding: 2rem 1.5rem; + } + + .hero-section h1 { + font-size: 2rem; + } + + h2 { + font-size: 1.4rem; + } + + .code-block { + flex-direction: column; + align-items: flex-start; + } + + .copy-btn { + width: 100%; + justify-content: center; + } } @media (max-width: 480px) { - .header-content { - padding: 0.75rem 1rem; - } + .header-content { + padding: 0.75rem 1rem; + } - .logo-image { - height: 32px; - } + .logo-image { + height: 32px; + } - .logo-text { - font-size: 1.1rem; - } + .logo-text { + font-size: 1.1rem; + } - .content { - padding: 1.5rem 1rem; - } + .content { + padding: 1.5rem 1rem; + } - .hero-section h1 { - font-size: 1.75rem; - } + .hero-section h1 { + font-size: 1.75rem; + } - .subtitle { - font-size: 1.1rem; - } + .subtitle { + font-size: 1.1rem; + } - h2 { - font-size: 1.25rem; - } + h2 { + font-size: 1.25rem; + } - p { - font-size: 0.95rem; - } + p { + font-size: 0.95rem; + } - .code-block { - padding: 1rem; - font-size: 0.85rem; - margin: 1rem 0; - } + .code-block { + padding: 1rem; + font-size: 0.85rem; + margin: 1rem 0; + } - .copy-btn { - padding: 0.5rem 1rem; - font-size: 0.85rem; - } + .copy-btn { + padding: 0.5rem 1rem; + font-size: 0.85rem; + } - ul, - ol { - padding-left: 1.5rem; - } + ul, + ol { + padding-left: 1.5rem; + } } ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { - background: var(--bg-secondary); + background: var(--bg-secondary); } ::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 4px; + background: var(--border-color); + border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: var(--text-tertiary); -} \ No newline at end of file + background: var(--text-tertiary); +} diff --git a/src/index.js b/src/index.js index ad8962f..006b393 100644 --- a/src/index.js +++ b/src/index.js @@ -1,27 +1,33 @@ #!/usr/bin/env node import inquirer from "./lib/prompts.js"; -import path from "path"; -import fs from "fs"; +import path from "node:path"; +import fs from "node:fs"; import chalk from "./lib/colors.js"; -import { run, deleteFolder, createFolder, deleteFile, fileExists, writeFile } from './lib/utils.js'; -import { createPages, createLayout } from './lib/templates.js'; +import { run, deleteFolder, createFolder, deleteFile, fileExists, writeFile } from "./lib/utils.js"; +import { createPages, createLayout } from "./lib/templates.js"; const MIN_NODE_VERSION = 20; const currentNodeVersion = process.versions.node; -if (parseInt(currentNodeVersion.split('.')[0]) < MIN_NODE_VERSION) { - console.error(chalk.red.bold(`\nError: create-next-quick requires Node.js version ${MIN_NODE_VERSION} or higher.`)); +if (parseInt(currentNodeVersion.split(".")[0], 10) < MIN_NODE_VERSION) { + console.error( + chalk.red.bold( + `\nError: create-next-quick requires Node.js version ${MIN_NODE_VERSION} or higher.`, + ), + ); console.error(chalk.red.bold(`You are currently using Node.js ${currentNodeVersion}.`)); console.error(chalk.red.bold(`Please upgrade your Node.js version to proceed.`)); process.exit(1); } const args = process.argv.slice(2); -const isInteractiveMode = args.includes('-i') || args.includes('--interactive'); -let appName = args.find(arg => !arg.startsWith('-')); +const isInteractiveMode = args.includes("-i") || args.includes("--interactive"); +const appName = args.find((arg) => !arg.startsWith("-")); if (isInteractiveMode && appName) { - console.error(chalk.red.bold("Error: Project name should not be provided in interactive mode (-i).")); + console.error( + chalk.red.bold("Error: Project name should not be provided in interactive mode (-i)."), + ); console.error(chalk.red.bold("Please run the command in the project's root directory.")); process.exit(1); } @@ -30,15 +36,14 @@ let projectPath = null; let createdProjectDir = false; (async () => { - const availablePackageManagers = ["npm"]; - const yarnCheck = run("yarn --version", process.cwd(), true); + const yarnCheck = await run("yarn --version", process.cwd(), true); if (yarnCheck.success) { availablePackageManagers.push("yarn"); } - const pnpmCheck = run("pnpm --version", process.cwd(), true); + const pnpmCheck = await run("pnpm --version", process.cwd(), true); if (pnpmCheck.success) { availablePackageManagers.push("pnpm"); } @@ -51,12 +56,16 @@ let createdProjectDir = false; if (!isInteractiveMode) { const files = fs.readdirSync(process.cwd()); if (files.length > 0) { - return chalk.red.bold("The current directory is not empty. Please use a different project name."); + return chalk.red.bold( + "The current directory is not empty. Please use a different project name.", + ); } } } else { if (fs.existsSync(input)) { - return chalk.red.bold(`A directory named "${chalk.white(input)}" already exists. Please use a different project name.`); + return chalk.red.bold( + `A directory named "${chalk.white(input)}" already exists. Please use a different project name.`, + ); } } return true; @@ -66,39 +75,48 @@ let createdProjectDir = false; console.log(); console.log(chalk.bold.cyan("╔═══════════════════════════════════════════╗")); - console.log(chalk.bold.cyan("ā•‘") + chalk.bold.white(" šŸš€ Create Next Quick CLI Tool ") + chalk.bold.cyan(" ā•‘")); + console.log( + chalk.bold.cyan("ā•‘") + + chalk.bold.white(" šŸš€ Create Next Quick CLI Tool ") + + chalk.bold.cyan(" ā•‘"), + ); console.log(chalk.bold.cyan("ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•")); console.log(); if (isInteractiveMode) { - answers.projectName = '.'; - console.log(chalk.bold.cyan("Running in interactive configuration mode on the current project.")); - - answers.useTypeScript = fileExists(path.join(process.cwd(), 'tsconfig.json')); - answers.useSrcDir = fs.existsSync(path.join(process.cwd(), 'src')); - answers.useAppDir = fs.existsSync(path.join(process.cwd(), 'app')) || (answers.useSrcDir && fs.existsSync(path.join(process.cwd(), 'src', 'app'))); - if (fs.existsSync(path.join(process.cwd(), 'yarn.lock'))) { - answers.packageManager = 'yarn'; - } else if (fs.existsSync(path.join(process.cwd(), 'pnpm-lock.yaml'))) { - answers.packageManager = 'pnpm'; + answers.projectName = "."; + console.log( + chalk.bold.cyan("Running in interactive configuration mode on the current project."), + ); + + answers.useTypeScript = fileExists(path.join(process.cwd(), "tsconfig.json")); + answers.useSrcDir = fileExists(path.join(process.cwd(), "src")); + answers.useAppDir = + fileExists(path.join(process.cwd(), "app")) || + (answers.useSrcDir && fileExists(path.join(process.cwd(), "src", "app"))); + if (fileExists(path.join(process.cwd(), "yarn.lock"))) { + answers.packageManager = "yarn"; + } else if (fileExists(path.join(process.cwd(), "pnpm-lock.yaml"))) { + answers.packageManager = "pnpm"; } else { - answers.packageManager = 'npm'; + answers.packageManager = "npm"; } let useTailwind = false; try { - const packageJsonPath = path.join(process.cwd(), 'package.json'); + const packageJsonPath = path.join(process.cwd(), "package.json"); if (fileExists(packageJsonPath)) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - useTailwind = (packageJson.dependencies && packageJson.dependencies.tailwindcss) || (packageJson.devDependencies && packageJson.devDependencies.tailwindcss); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + useTailwind = + packageJson.dependencies?.tailwindcss || packageJson.devDependencies?.tailwindcss; } - } catch (error) { } + } catch (_error) {} answers.useTailwind = useTailwind; console.log(chalk.bold("Detected project configuration:")); - console.log(` - TypeScript: ${answers.useTypeScript ? chalk.green('Yes') : chalk.red('No')}`); - console.log(` - src directory: ${answers.useSrcDir ? chalk.green('Yes') : chalk.red('No')}`); - console.log(` - App directory: ${answers.useAppDir ? chalk.green('Yes') : chalk.red('No')}`); - console.log(` - Tailwind CSS: ${answers.useTailwind ? chalk.green('Yes') : chalk.red('No')}`); + console.log(` - TypeScript: ${answers.useTypeScript ? chalk.green("Yes") : chalk.red("No")}`); + console.log(` - src directory: ${answers.useSrcDir ? chalk.green("Yes") : chalk.red("No")}`); + console.log(` - App directory: ${answers.useAppDir ? chalk.green("Yes") : chalk.red("No")}`); + console.log(` - Tailwind CSS: ${answers.useTailwind ? chalk.green("Yes") : chalk.red("No")}`); console.log(` - Package manager: ${chalk.cyan(answers.packageManager)}`); console.log(); @@ -108,51 +126,54 @@ let createdProjectDir = false; name: "pages", message: "Enter pages to create (comma-separated, default: none):", default: "", - filter: (input) => input.split(',').map((page) => page.trim()).filter(page => page !== '') + filter: (input) => + input + .split(",") + .map((page) => page.trim()) + .filter((page) => page !== ""), }, { type: "list", name: "linter", message: "Choose a linter to add:", choices: ["none", "eslint", "biome"], - default: "none" + default: "none", }, { type: "list", name: "orm", message: "Choose an ORM to add:", choices: ["none", "prisma", "drizzle"], - default: "none" + default: "none", }, { type: "confirm", name: "useShadcn", message: "Do you want to add Shadcn UI?", - default: false + default: false, }, { type: "list", name: "testing", message: "Choose a testing framework:", choices: ["none", "vitest", "jest"], - default: "none" + default: "none", }, { type: "list", name: "auth", message: "Choose an authentication solution:", choices: ["none", "next-auth", "clerk", "lucia"], - default: "none" + default: "none", }, { type: "confirm", name: "docker", message: "Do you want to add Docker support?", - default: false - } + default: false, + }, ]); Object.assign(answers, interactiveAnswers); - } else { if (appName) { const validationResult = validateProjectName(appName); @@ -170,9 +191,9 @@ let createdProjectDir = false; type: "input", name: "projectName", message: "Enter project name:", - filter: (input) => input.trim() === '' ? '.' : input.trim(), - validate: validateProjectName - } + filter: (input) => (input.trim() === "" ? "." : input.trim()), + validate: validateProjectName, + }, ]); answers.projectName = appNameAnswers.projectName; } @@ -183,89 +204,111 @@ let createdProjectDir = false; name: "packageManager", message: "Choose a package manager:", choices: availablePackageManagers, - default: availablePackageManagers.includes("pnpm") ? "pnpm" : (availablePackageManagers.includes("yarn") ? "yarn" : "npm"), + default: availablePackageManagers.includes("pnpm") + ? "pnpm" + : availablePackageManagers.includes("yarn") + ? "yarn" + : "npm", }, { type: "confirm", name: "useTypeScript", message: "Do you want to use TypeScript? (default: Yes)", - default: true + default: true, }, { type: "confirm", name: "useTailwind", message: "Do you want to use Tailwind CSS? (default: Yes)", - default: true + default: true, }, { type: "confirm", name: "useSrcDir", message: "Do you want to use src directory? (default: Yes)", - default: true + default: true, }, { type: "confirm", name: "useAppDir", message: "Do you want to use the app directory? (default: Yes)", - default: true + default: true, }, { type: "input", name: "pages", message: "Enter pages (comma-separated, default: none):", default: "", - filter: (input) => input.split(',').map((page) => page.trim()).filter(page => page !== '') + filter: (input) => + input + .split(",") + .map((page) => page.trim()) + .filter((page) => page !== ""), }, { type: "list", name: "linter", message: "Choose a linter:", choices: ["none", "eslint", "biome"], - default: "none" + default: "none", }, { type: "list", name: "orm", message: "Choose an ORM:", choices: ["none", "prisma", "drizzle"], - default: "none" + default: "none", }, { type: "confirm", name: "useShadcn", message: "Do you want to use Shadcn UI? (default: Yes)", - default: true + default: true, }, { type: "list", name: "testing", message: "Choose a testing framework:", choices: ["none", "vitest", "jest"], - default: "none" + default: "none", }, { type: "list", name: "auth", message: "Choose an authentication solution:", choices: ["none", "next-auth", "clerk", "lucia"], - default: "none" + default: "none", }, { type: "confirm", name: "docker", message: "Do you want to add Docker support?", - default: false - } + default: false, + }, ]); Object.assign(answers, otherAnswers); } - const { projectName, packageManager, useTypeScript, useTailwind, useAppDir, useSrcDir, pages, linter, orm, useShadcn, testing, auth, docker } = answers; - - const displayName = projectName === '.' ? path.basename(process.cwd()) : projectName; + const { + projectName, + packageManager, + useTypeScript, + useTailwind, + useAppDir, + useSrcDir, + pages, + linter, + orm, + useShadcn, + testing, + auth, + docker, + } = answers; + + const displayName = projectName === "." ? path.basename(process.cwd()) : projectName; createdProjectDir = false; - if (projectName === '.') { + if (projectName === ".") { projectPath = process.cwd(); } else { projectPath = path.join(process.cwd(), projectName); @@ -306,7 +349,7 @@ let createdProjectDir = false; console.log(chalk.cyan(`Installing dependencies with ${chalk.bold(packageManager)}...`)); - const installResult = run(command, process.cwd(), false, 3, 2000); + const installResult = await run(command, process.cwd(), false, 3, 2000); if (!installResult.success) { console.error(chalk.bold.red("Failed to install dependencies.")); @@ -316,7 +359,11 @@ let createdProjectDir = false; } console.error(chalk.yellow("Troubleshooting tips:")); console.error(chalk.yellow(" - Check your internet connection.")); - console.error(chalk.yellow(" - Ensure your package manager (npm, yarn, or pnpm) is installed and up to date.")); + console.error( + chalk.yellow( + " - Ensure your package manager (npm, yarn, or pnpm) is installed and up to date.", + ), + ); console.error(chalk.yellow(" - Try running the command again.")); throw new Error("Dependency installation failed."); } @@ -347,8 +394,12 @@ let createdProjectDir = false; } const pagesPath = useAppDir - ? (useSrcDir ? path.join(projectPath, "src", "app") : path.join(projectPath, "app")) - : (useSrcDir ? path.join(projectPath, "src", "pages") : path.join(projectPath, "pages")); + ? useSrcDir + ? path.join(projectPath, "src", "app") + : path.join(projectPath, "app") + : useSrcDir + ? path.join(projectPath, "src", "pages") + : path.join(projectPath, "pages"); if (pages && pages.length > 0) { console.log(chalk.magenta("Creating pages...")); @@ -361,8 +412,12 @@ let createdProjectDir = false; if (!isInteractiveMode) { const faviconPathInAppOrSrc = useAppDir - ? (useSrcDir ? path.join(projectPath, "src", "app", "favicon.ico") : path.join(projectPath, "app", "favicon.ico")) - : (useSrcDir ? path.join(projectPath, "src", "favicon.ico") : path.join(projectPath, "favicon.ico")); + ? useSrcDir + ? path.join(projectPath, "src", "app", "favicon.ico") + : path.join(projectPath, "app", "favicon.ico") + : useSrcDir + ? path.join(projectPath, "src", "favicon.ico") + : path.join(projectPath, "favicon.ico"); if (fileExists(faviconPathInAppOrSrc)) { deleteFile(faviconPathInAppOrSrc); @@ -394,9 +449,13 @@ let createdProjectDir = false; if (useTypeScript) { let globalCssPath; if (useAppDir) { - globalCssPath = useSrcDir ? path.join(projectPath, "src", "app") : path.join(projectPath, "app"); + globalCssPath = useSrcDir + ? path.join(projectPath, "src", "app") + : path.join(projectPath, "app"); } else { - globalCssPath = useSrcDir ? path.join(projectPath, "src", "styles") : path.join(projectPath, "styles"); + globalCssPath = useSrcDir + ? path.join(projectPath, "src", "styles") + : path.join(projectPath, "styles"); } const globalDtsPath = path.join(globalCssPath, "global.d.ts"); const globalDtsContent = `declare module '*.css' {\n interface CSSModule {\n [className: string]: string\n }\n const cssModule: CSSModule\n export default cssModule\n}`; @@ -406,14 +465,23 @@ let createdProjectDir = false; console.log(chalk.bold.green("Layout and pages created")); if (linter === "biome") { - const packageJsonPath = path.join(projectPath, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if ((packageJson.dependencies && packageJson.dependencies['@biomejs/biome']) || (packageJson.devDependencies && packageJson.devDependencies['@biomejs/biome'])) { + const packageJsonPath = path.join(projectPath, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if ( + packageJson.dependencies?.["@biomejs/biome"] || + packageJson.devDependencies?.["@biomejs/biome"] + ) { console.log(chalk.yellow("Biome is already installed. Skipping setup.")); } else { console.log(chalk.blue("Setting up Biome linter...")); - const biomeInstallResult = run(`${packageManager} install --save-dev @biomejs/biome`, projectPath, false, 3, 2000); + const biomeInstallResult = await run( + `${packageManager} install --save-dev @biomejs/biome`, + projectPath, + false, + 3, + 2000, + ); if (!biomeInstallResult.success) { console.error(chalk.bold.red("Failed to install Biome dependencies.")); if (biomeInstallResult.stderr) { @@ -423,7 +491,7 @@ let createdProjectDir = false; throw new Error("Biome installation failed."); } - const biomeInitResult = run(`npx @biomejs/biome init`, projectPath, false, 3, 2000); + const biomeInitResult = await run(`npx @biomejs/biome init`, projectPath, false, 3, 2000); if (!biomeInitResult.success) { console.error(chalk.bold.red("Failed to initialize Biome.")); if (biomeInitResult.stderr) { @@ -438,14 +506,20 @@ let createdProjectDir = false; } if (orm === "prisma") { - const packageJsonPath = path.join(projectPath, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if ((packageJson.dependencies && packageJson.dependencies['prisma']) || (packageJson.devDependencies && packageJson.devDependencies['prisma'])) { + const packageJsonPath = path.join(projectPath, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (packageJson.dependencies?.prisma || packageJson.devDependencies?.prisma) { console.log(chalk.yellow("Prisma is already installed. Skipping setup.")); } else { console.log(chalk.blue("Setting up Prisma ORM...")); - const prismaInstallDevResult = run(`${packageManager} install --save-dev prisma`, projectPath, false, 3, 2000); + const prismaInstallDevResult = await run( + `${packageManager} install --save-dev prisma`, + projectPath, + false, + 3, + 2000, + ); if (!prismaInstallDevResult.success) { console.error(chalk.bold.red("Failed to install Prisma dev dependencies.")); if (prismaInstallDevResult.stderr) { @@ -455,7 +529,13 @@ let createdProjectDir = false; throw new Error("Prisma dev dependency installation failed."); } - const prismaInstallClientResult = run(`${packageManager} install @prisma/client`, projectPath, false, 3, 2000); + const prismaInstallClientResult = await run( + `${packageManager} install @prisma/client`, + projectPath, + false, + 3, + 2000, + ); if (!prismaInstallClientResult.success) { console.error(chalk.bold.red("Failed to install Prisma client dependencies.")); if (prismaInstallClientResult.stderr) { @@ -465,7 +545,7 @@ let createdProjectDir = false; throw new Error("Prisma client dependency installation failed."); } - const prismaInitResult = run(`npx prisma init`, projectPath, false, 3, 2000); + const prismaInitResult = await run(`npx prisma init`, projectPath, false, 3, 2000); if (!prismaInitResult.success) { console.error(chalk.bold.red("Failed to initialize Prisma.")); if (prismaInitResult.stderr) { @@ -475,7 +555,7 @@ let createdProjectDir = false; throw new Error("Prisma initialization failed."); } - const prismaGenerateResult = run(`npx prisma generate`, projectPath, false, 3, 2000); + const prismaGenerateResult = await run(`npx prisma generate`, projectPath, false, 3, 2000); if (!prismaGenerateResult.success) { console.error(chalk.bold.red("Failed to generate Prisma client.")); if (prismaGenerateResult.stderr) { @@ -483,10 +563,16 @@ let createdProjectDir = false; console.error(chalk.red(prismaGenerateResult.stderr)); } // We don't throw here, as it might just need a schema definition first, but it's good practice to try. - console.warn(chalk.yellow("Prisma generate failed, likely due to empty schema. You may need to run 'npx prisma generate' after defining your models.")); + console.warn( + chalk.yellow( + "Prisma generate failed, likely due to empty schema. You may need to run 'npx prisma generate' after defining your models.", + ), + ); } - const prismaLibDir = useSrcDir ? path.join(projectPath, "src", "lib") : path.join(projectPath, "lib"); + const prismaLibDir = useSrcDir + ? path.join(projectPath, "src", "lib") + : path.join(projectPath, "lib"); createFolder(prismaLibDir); const prismaContent = useTypeScript @@ -527,14 +613,20 @@ if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma } if (orm === "drizzle") { - const packageJsonPath = path.join(projectPath, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if ((packageJson.dependencies && packageJson.dependencies['drizzle-orm']) || (packageJson.devDependencies && packageJson.devDependencies['drizzle-orm'])) { + const packageJsonPath = path.join(projectPath, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (packageJson.dependencies?.["drizzle-orm"] || packageJson.devDependencies?.["drizzle-orm"]) { console.log(chalk.yellow("Drizzle is already installed. Skipping setup.")); } else { console.log(chalk.blue("Setting up Drizzle ORM...")); - const drizzleInstallResult = run(`${packageManager} install drizzle-orm @vercel/postgres`, projectPath, false, 3, 2000); + const drizzleInstallResult = await run( + `${packageManager} install drizzle-orm @vercel/postgres`, + projectPath, + false, + 3, + 2000, + ); if (!drizzleInstallResult.success) { console.error(chalk.bold.red("Failed to install Drizzle ORM dependencies.")); if (drizzleInstallResult.stderr) { @@ -543,7 +635,13 @@ if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma } throw new Error("Drizzle ORM dependency installation failed."); } - const drizzleKitInstallResult = run(`${packageManager} install --save-dev drizzle-kit`, projectPath, false, 3, 2000); + const drizzleKitInstallResult = await run( + `${packageManager} install --save-dev drizzle-kit`, + projectPath, + false, + 3, + 2000, + ); if (!drizzleKitInstallResult.success) { console.error(chalk.bold.red("Failed to install Drizzle Kit dev dependencies.")); if (drizzleKitInstallResult.stderr) { @@ -553,9 +651,11 @@ if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma throw new Error("Drizzle Kit dev dependency installation failed."); } - const schemaPath = useSrcDir ? './src/db/schema.ts' : './db/schema.ts'; + const schemaExt = useTypeScript ? ".ts" : ".js"; + const schemaPath = useSrcDir ? `./src/db/schema${schemaExt}` : `./db/schema${schemaExt}`; - const drizzleConfigContent = `import type { Config } from 'drizzle-kit'; + const drizzleConfigContent = useTypeScript + ? `import type { Config } from 'drizzle-kit'; export default { schema: '${schemaPath}', @@ -564,8 +664,18 @@ export default { dbCredentials: { url: process.env.DATABASE_URL!, }, -} satisfies Config;`; - writeFile(path.join(projectPath, "drizzle.config.ts"), drizzleConfigContent); +} satisfies Config;` + : `/** @type {import('drizzle-kit').Config} */ +export default { + schema: '${schemaPath}', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL, + }, +};`; + const drizzleConfigFile = useTypeScript ? "drizzle.config.ts" : "drizzle.config.js"; + writeFile(path.join(projectPath, drizzleConfigFile), drizzleConfigContent); const dbDir = useSrcDir ? path.join(projectPath, "src", "db") : path.join(projectPath, "db"); createFolder(dbDir); @@ -576,27 +686,43 @@ export const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name').notNull(), });`; - writeFile(path.join(dbDir, "schema.ts"), schemaContent); + const schemaFile = useTypeScript ? "schema.ts" : "schema.js"; + writeFile(path.join(dbDir, schemaFile), schemaContent); console.log(chalk.bold.green("Drizzle ORM configured")); } } if (useShadcn) { - const packageJsonPath = path.join(projectPath, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if ((packageJson.dependencies && packageJson.dependencies['tailwindcss-animate']) || (packageJson.devDependencies && packageJson.devDependencies['tailwindcss-animate'])) { + const packageJsonPath = path.join(projectPath, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if ( + packageJson.dependencies?.["tailwindcss-animate"] || + packageJson.devDependencies?.["tailwindcss-animate"] + ) { console.log(chalk.yellow("Shadcn UI seems to be already installed. Skipping setup.")); } else { console.log(chalk.magenta("Setting up Shadcn UI...")); - const cvaInstallResult = run(`${packageManager} install class-variance-authority`, projectPath, false, 3, 2000); + const cvaInstallResult = await run( + `${packageManager} install class-variance-authority`, + projectPath, + false, + 3, + 2000, + ); if (!cvaInstallResult.success) { console.error(chalk.bold.red("Failed to install class-variance-authority.")); throw new Error("class-variance-authority installation failed."); } - const shadcnInstallResult = run(`${packageManager} install --save-dev tailwindcss-animate`, projectPath, false, 3, 2000); + const shadcnInstallResult = await run( + `${packageManager} install --save-dev tailwindcss-animate`, + projectPath, + false, + 3, + 2000, + ); if (!shadcnInstallResult.success) { console.error(chalk.bold.red("Failed to install Shadcn UI dependencies.")); if (shadcnInstallResult.stderr) { @@ -606,7 +732,13 @@ export const users = pgTable('users', { throw new Error("Shadcn UI dependency installation failed."); } - const shadcnInitResult = run(`npx shadcn@latest init --yes`, projectPath, false, 3, 2000); + const shadcnInitResult = await run( + `npx shadcn@latest init --yes`, + projectPath, + false, + 3, + 2000, + ); if (!shadcnInitResult.success) { console.error(chalk.bold.red("Failed to initialize Shadcn UI.")); if (shadcnInitResult.stderr) { @@ -623,24 +755,32 @@ export const users = pgTable('users', { } const customComponentsJsonContent = { - "style": "default", - "rsc": useAppDir, - "tsx": useTypeScript, - "tailwind": { - "config": useTypeScript ? "tailwind.config.ts" : "tailwind.config.js", - "css": useAppDir - ? (useSrcDir ? "src/app/globals.css" : "app/globals.css") - : (useSrcDir ? "src/styles/globals.css" : "styles/globals.css"), - "baseColor": "slate", - "cssVariables": true + style: "default", + rsc: useAppDir, + tsx: useTypeScript, + tailwind: { + config: useTypeScript ? "tailwind.config.ts" : "tailwind.config.js", + css: useAppDir + ? useSrcDir + ? "src/app/globals.css" + : "app/globals.css" + : useSrcDir + ? "src/styles/globals.css" + : "styles/globals.css", + baseColor: "slate", + cssVariables: true, + }, + aliases: { + components: "@/components", + utils: "@/lib/utils", }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils" - } }; - const mergedComponentsJsonContent = Object.assign({}, existingComponentsJson, customComponentsJsonContent); + const mergedComponentsJsonContent = Object.assign( + {}, + existingComponentsJson, + customComponentsJsonContent, + ); writeFile(componentsJsonPath, JSON.stringify(mergedComponentsJsonContent, null, 2)); console.log(chalk.bold.green("Shadcn UI configured")); @@ -652,8 +792,8 @@ export const users = pgTable('users', { const dbUrlLine = `DATABASE_URL="your_db_url"`; if (fs.existsSync(envPath)) { - const existingEnv = fs.readFileSync(envPath, 'utf8'); - if (!existingEnv.includes('DATABASE_URL')) { + const existingEnv = fs.readFileSync(envPath, "utf8"); + if (!existingEnv.includes("DATABASE_URL")) { fs.appendFileSync(envPath, `\n${dbUrlLine}\n`); } else { console.warn(chalk.yellow("DATABASE_URL already exists in .env, skipping append.")); @@ -749,12 +889,19 @@ README.md const nextConfigPath = path.join(projectPath, "next.config.mjs"); if (fileExists(nextConfigPath)) { let nextConfig = fs.readFileSync(nextConfigPath, "utf8"); - if (!nextConfig.includes("output: \"standalone\"")) { + if (!nextConfig.includes('output: "standalone"')) { if (nextConfig.includes("nextConfig = {")) { - nextConfig = nextConfig.replace("nextConfig = {", "nextConfig = {\n output: \"standalone\","); + nextConfig = nextConfig.replace( + "nextConfig = {", + 'nextConfig = {\n output: "standalone",', + ); } else { // Fallback for simple configs, though create-next-app usually gives the above - console.warn(chalk.yellow("Could not automatically update next.config.mjs for standalone output. Please add 'output: \"standalone\"' manually.")); + console.warn( + chalk.yellow( + "Could not automatically update next.config.mjs for standalone output. Please add 'output: \"standalone\"' manually.", + ), + ); } writeFile(nextConfigPath, nextConfig); } @@ -765,29 +912,52 @@ README.md // Testing Setup if (testing === "vitest") { console.log(chalk.blue("Setting up Vitest...")); - const deps = ["vitest", "@vitejs/plugin-react", "jsdom", "@testing-library/react", "@testing-library/dom"]; + const deps = [ + "vitest", + "@vitejs/plugin-react", + "jsdom", + "@testing-library/react", + "@testing-library/dom", + ]; const installCmd = `${packageManager} install --save-dev ${deps.join(" ")}`; - run(installCmd, projectPath, false); + const vitestInstallResult = await run(installCmd, projectPath, false, 3, 2000); + if (!vitestInstallResult.success) { + console.error(chalk.bold.red("Failed to install Vitest dependencies.")); + if (vitestInstallResult.stderr) { + console.error(chalk.red("Error details:")); + console.error(chalk.red(vitestInstallResult.stderr)); + } + throw new Error("Vitest dependency installation failed."); + } const vitestConfigContent = `import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' -import { resolve } from 'path' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, - setupFiles: './vitest.setup.${useTypeScript ? 'ts' : 'js'}', + setupFiles: './vitest.setup.${useTypeScript ? "ts" : "js"}', }, resolve: { alias: { - '@': resolve(__dirname, './src') + '@': path.resolve(__dirname, '${useSrcDir ? "./src" : "."}') }, }, })`; - writeFile(path.join(projectPath, useTypeScript ? "vitest.config.ts" : "vitest.config.js"), vitestConfigContent); - writeFile(path.join(projectPath, useTypeScript ? "vitest.setup.ts" : "vitest.setup.js"), "import '@testing-library/jest-dom'"); + writeFile( + path.join(projectPath, useTypeScript ? "vitest.config.ts" : "vitest.config.js"), + vitestConfigContent, + ); + writeFile( + path.join(projectPath, useTypeScript ? "vitest.setup.ts" : "vitest.setup.js"), + "import '@testing-library/jest-dom'", + ); // Update package.json scripts const pkgPath = path.join(projectPath, "package.json"); @@ -798,11 +968,30 @@ export default defineConfig({ console.log(chalk.bold.green("Vitest configured")); } else if (testing === "jest") { console.log(chalk.blue("Setting up Jest...")); - const deps = ["jest", "jest-environment-jsdom", "@testing-library/react", "@testing-library/jest-dom"]; + const deps = [ + "jest", + "jest-environment-jsdom", + "@testing-library/react", + "@testing-library/jest-dom", + ]; if (useTypeScript) { deps.push("@types/jest", "ts-node"); } - run(`${packageManager} install --save-dev ${deps.join(" ")}`, projectPath, false); + const jestInstallResult = await run( + `${packageManager} install --save-dev ${deps.join(" ")}`, + projectPath, + false, + 3, + 2000, + ); + if (!jestInstallResult.success) { + console.error(chalk.bold.red("Failed to install Jest dependencies.")); + if (jestInstallResult.stderr) { + console.error(chalk.red("Error details:")); + console.error(chalk.red(jestInstallResult.stderr)); + } + throw new Error("Jest dependency installation failed."); + } // Skip interactive init, write config manually const jestConfig = `const nextJest = require('next/jest') @@ -836,7 +1025,21 @@ module.exports = createJestConfig(customJestConfig)`; if (auth !== "none") { console.log(chalk.blue(`Setting up Authentication (${auth})...`)); if (auth === "next-auth") { - run(`${packageManager} install next-auth@beta`, projectPath, false); + const nextAuthInstallResult = await run( + `${packageManager} install next-auth@beta`, + projectPath, + false, + 3, + 2000, + ); + if (!nextAuthInstallResult.success) { + console.error(chalk.bold.red("Failed to install NextAuth.js.")); + if (nextAuthInstallResult.stderr) { + console.error(chalk.red("Error details:")); + console.error(chalk.red(nextAuthInstallResult.stderr)); + } + throw new Error("NextAuth.js installation failed."); + } const authContent = `import NextAuth from "next-auth" import Credentials from "next-auth/providers/credentials" @@ -869,7 +1072,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }), ], })`; - const libDir = useSrcDir ? path.join(projectPath, "src", "lib") : path.join(projectPath, "lib"); + const libDir = useSrcDir + ? path.join(projectPath, "src", "lib") + : path.join(projectPath, "lib"); if (!fs.existsSync(libDir)) createFolder(libDir); writeFile(path.join(libDir, useTypeScript ? "auth.ts" : "auth.js"), authContent); @@ -883,18 +1088,35 @@ export default auth((req) => { export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], }`; - const middlewarePath = useSrcDir ? path.join(projectPath, "src", useTypeScript ? "middleware.ts" : "middleware.js") : path.join(projectPath, useTypeScript ? "middleware.ts" : "middleware.js"); + const middlewarePath = useSrcDir + ? path.join(projectPath, "src", useTypeScript ? "middleware.ts" : "middleware.js") + : path.join(projectPath, useTypeScript ? "middleware.ts" : "middleware.js"); writeFile(middlewarePath, middlewareContent); - const routePath = useSrcDir ? path.join(projectPath, "src", "app", "api", "auth", "[...nextauth]") : path.join(projectPath, "app", "api", "auth", "[...nextauth]"); + const routePath = useSrcDir + ? path.join(projectPath, "src", "app", "api", "auth", "[...nextauth]") + : path.join(projectPath, "app", "api", "auth", "[...nextauth]"); createFolder(routePath); const routeContent = `import { handlers } from "@/lib/auth" export const { GET, POST } = handlers`; writeFile(path.join(routePath, useTypeScript ? "route.ts" : "route.js"), routeContent); - } else if (auth === "clerk") { - run(`${packageManager} install @clerk/nextjs`, projectPath, false); + const clerkInstallResult = await run( + `${packageManager} install @clerk/nextjs`, + projectPath, + false, + 3, + 2000, + ); + if (!clerkInstallResult.success) { + console.error(chalk.bold.red("Failed to install Clerk.")); + if (clerkInstallResult.stderr) { + console.error(chalk.red("Error details:")); + console.error(chalk.red(clerkInstallResult.stderr)); + } + throw new Error("Clerk installation failed."); + } const middlewareContent = `import { clerkMiddleware } from "@clerk/nextjs/server"; @@ -908,18 +1130,41 @@ export const config = { '/(api|trpc)(.*)', ], };`; - const middlewarePath = useSrcDir ? path.join(projectPath, "src", useTypeScript ? "middleware.ts" : "middleware.js") : path.join(projectPath, useTypeScript ? "middleware.ts" : "middleware.js"); + const middlewarePath = useSrcDir + ? path.join(projectPath, "src", useTypeScript ? "middleware.ts" : "middleware.js") + : path.join(projectPath, useTypeScript ? "middleware.ts" : "middleware.js"); writeFile(middlewarePath, middlewareContent); console.log(chalk.yellow("\nāš ļø Action Required for Clerk:")); console.log(chalk.white("1. Creates an account at https://clerk.com")); - console.log(chalk.white("2. Add NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY to your .env")); + console.log( + chalk.white("2. Add NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY to your .env"), + ); console.log(chalk.white("3. Wrap your root layout with ")); - } else if (auth === "lucia") { - console.log(chalk.yellow("Lucia Auth requires a database adapter. Installing core package...")); - run(`${packageManager} install lucia`, projectPath, false); - console.log(chalk.yellow("Please follow the docs to set up your specific adapter: https://lucia-auth.com/getting-started/")); + console.log( + chalk.yellow("Lucia Auth requires a database adapter. Installing core package..."), + ); + const luciaInstallResult = await run( + `${packageManager} install lucia`, + projectPath, + false, + 3, + 2000, + ); + if (!luciaInstallResult.success) { + console.error(chalk.bold.red("Failed to install Lucia.")); + if (luciaInstallResult.stderr) { + console.error(chalk.red("Error details:")); + console.error(chalk.red(luciaInstallResult.stderr)); + } + throw new Error("Lucia installation failed."); + } + console.log( + chalk.yellow( + "Please follow the docs to set up your specific adapter: https://lucia-auth.com/getting-started/", + ), + ); } console.log(chalk.bold.green("Auth dependencies installed")); } @@ -930,7 +1175,7 @@ export const config = { console.log(chalk.bold.hex("#23f0bcff")("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")); console.log(); console.log(chalk.bold.white("-> Next steps:")); - if (projectName !== '.') { + if (projectName !== ".") { console.log(chalk.cyan(` cd ${chalk.bold.white(projectName)}`)); } console.log(chalk.cyan(` ${packageManager} ${chalk.bold.white(`run dev`)}`)); @@ -941,11 +1186,20 @@ export const config = { process.exit(0); })().catch((error) => { console.error(chalk.bold.red(`\nAn unexpected error occurred: ${error.message}`)); - if (projectPath && fs.existsSync(projectPath) && createdProjectDir && projectPath !== process.cwd()) { + if ( + projectPath && + fs.existsSync(projectPath) && + createdProjectDir && + projectPath !== process.cwd() + ) { console.error(chalk.yellow(`Cleaning up incomplete project directory: ${projectPath}`)); deleteFolder(projectPath); } else { - console.error(chalk.yellow(`Cleanup skipped or not needed (project directory not created by this process or is CWD).`)); + console.error( + chalk.yellow( + `Cleanup skipped or not needed (project directory not created by this process or is CWD).`, + ), + ); } process.exit(1); }); diff --git a/src/lib/colors.js b/src/lib/colors.js index 9335391..9070dce 100644 --- a/src/lib/colors.js +++ b/src/lib/colors.js @@ -58,7 +58,7 @@ const createBuilder = (currentStyles = []) => { // Basic hex-to-ANSI approximation builder.hex = (hex) => { // Remove hash if present - const cleanHex = hex.replace(/^#/, ''); + const cleanHex = hex.replace(/^#/, ""); // Parse r, g, b let r, g, b; @@ -80,9 +80,8 @@ const createBuilder = (currentStyles = []) => { const b6 = Math.round((b / 255) * 5); const ansiCode = 16 + 36 * r6 + 6 * g6 + b6; - const open = `\x1b[38;5;${ansiCode}m`; - - return createBuilder([...currentStyles, `\x1b[38;5;${ansiCode}m`]); + const _open = `\x1b[38;5;${ansiCode}m`; + return createBuilder([...currentStyles, _open]); }; return builder; diff --git a/src/lib/prompts.js b/src/lib/prompts.js index 3251a08..97df66c 100644 --- a/src/lib/prompts.js +++ b/src/lib/prompts.js @@ -1,126 +1,129 @@ -import readline from 'readline'; -import chalk from './colors.js'; +import readline from "node:readline"; +import chalk from "./colors.js"; let rl; const getRl = () => { - if (!rl) { - rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - } - return rl; + if (!rl) { + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + } + return rl; }; const ask = (query) => { - return new Promise((resolve) => getRl().question(query, resolve)); + return new Promise((resolve) => getRl().question(query, resolve)); }; const processQuestion = async (question) => { - let answer; - const { type, name, message, default: defaultVal, choices, validate, filter } = question; - - const displayMessage = `${message}${defaultVal !== undefined ? ` (default: ${type === 'confirm' ? (defaultVal ? 'Yes' : 'No') : defaultVal})` : ''} `; - - const confirmHint = defaultVal ? '(Y/n)' : '(y/N)'; - if (type === 'confirm') { - while (true) { - const input = await ask(`${chalk.green('?')} ${chalk.bold(message)} ${chalk.dim(confirmHint)} `); - const trimmed = input.trim().toLowerCase(); - if (trimmed === '') { - answer = defaultVal !== undefined ? defaultVal : false; - break; - } - if (trimmed === 'y' || trimmed === 'yes') { - answer = true; - break; - } - if (trimmed === 'n' || trimmed === 'no') { - answer = false; - break; - } - } - } else if (type === 'list') { - console.log(`${chalk.green('?')} ${chalk.bold(message)}`); - choices.forEach((choice, index) => { - const isDefault = choice === defaultVal; - console.log(` ${index + 1}) ${choice} ${isDefault ? chalk.dim('(default)') : ''}`); - }); - - while (true) { - const input = await ask(` ${chalk.dim('Answer:')} `); - const trimmed = input.trim(); - - if (trimmed === '' && defaultVal !== undefined) { - // defaultVal might be the value string, finding its index - const defaultIndex = choices.indexOf(defaultVal); - if (defaultIndex !== -1) { - answer = defaultVal; - break; - } - } - - const num = parseInt(trimmed, 10); - if (!isNaN(num) && num > 0 && num <= choices.length) { - answer = choices[num - 1]; - break; - } - - // Try to match by string content - const matchedChoice = choices.find(c => c.toLowerCase() === trimmed.toLowerCase()); - if (matchedChoice) { - answer = matchedChoice; - break; - } - - console.log(chalk.red(` Please enter a number between 1 and ${choices.length} or the option name`)); - } - - } else { - // type === 'input' or default - while (true) { - const input = await ask(`${chalk.green('?')} ${chalk.bold(displayMessage)}`); - let val = input.trim(); - - if (val === '' && defaultVal !== undefined) { - val = defaultVal; - } - - if (filter) { - val = filter(val); - } - - const validated = validate ? validate(val) : true; - if (validated === true) { - answer = val; - break; - } else { - console.log(typeof validated === 'string' ? validated : chalk.red('Invalid input')); - } + let answer; + const { type, name, message, default: defaultVal, choices, validate, filter } = question; + + const displayMessage = `${message}${defaultVal !== undefined ? ` (default: ${type === "confirm" ? (defaultVal ? "Yes" : "No") : defaultVal})` : ""} `; + + const confirmHint = defaultVal ? "(Y/n)" : "(y/N)"; + if (type === "confirm") { + while (true) { + const input = await ask( + `${chalk.green("?")} ${chalk.bold(message)} ${chalk.dim(confirmHint)} `, + ); + const trimmed = input.trim().toLowerCase(); + if (trimmed === "") { + answer = defaultVal !== undefined ? defaultVal : false; + break; + } + if (trimmed === "y" || trimmed === "yes") { + answer = true; + break; + } + if (trimmed === "n" || trimmed === "no") { + answer = false; + break; + } + } + } else if (type === "list") { + console.log(`${chalk.green("?")} ${chalk.bold(message)}`); + choices.forEach((choice, index) => { + const isDefault = choice === defaultVal; + console.log(` ${index + 1}) ${choice} ${isDefault ? chalk.dim("(default)") : ""}`); + }); + + while (true) { + const input = await ask(` ${chalk.dim("Answer:")} `); + const trimmed = input.trim(); + + if (trimmed === "" && defaultVal !== undefined) { + // defaultVal might be the value string, finding its index + const defaultIndex = choices.indexOf(defaultVal); + if (defaultIndex !== -1) { + answer = defaultVal; + break; } + } + + const num = parseInt(trimmed, 10); + if (!Number.isNaN(num) && num > 0 && num <= choices.length) { + answer = choices[num - 1]; + break; + } + + // Try to match by string content + const matchedChoice = choices.find((c) => c.toLowerCase() === trimmed.toLowerCase()); + if (matchedChoice) { + answer = matchedChoice; + break; + } + + console.log( + chalk.red(` Please enter a number between 1 and ${choices.length} or the option name`), + ); + } + } else { + // type === 'input' or default + while (true) { + const input = await ask(`${chalk.green("?")} ${chalk.bold(displayMessage)}`); + let val = input.trim(); + + if (val === "" && defaultVal !== undefined) { + val = defaultVal; + } + + if (filter) { + val = filter(val); + } + + const validated = validate ? validate(val) : true; + if (validated === true) { + answer = val; + break; + } else { + console.log(typeof validated === "string" ? validated : chalk.red("Invalid input")); + } } + } - return { [name]: answer }; + return { [name]: answer }; }; const prompt = async (questions) => { - const answers = {}; - for (const question of questions) { - const answer = await processQuestion(question); - Object.assign(answers, answer); - } - return answers; + const answers = {}; + for (const question of questions) { + const answer = await processQuestion(question); + Object.assign(answers, answer); + } + return answers; }; const close = () => { - if (rl) { - rl.close(); - rl = null; - } + if (rl) { + rl.close(); + rl = null; + } }; export default { - prompt, - close + prompt, + close, }; diff --git a/src/lib/templates.js b/src/lib/templates.js index ac3180b..61706ef 100644 --- a/src/lib/templates.js +++ b/src/lib/templates.js @@ -1,13 +1,12 @@ -import { writeFile, createFolder } from './utils.js'; -import path from 'path'; +import { writeFile, createFolder } from "./utils.js"; +import path from "node:path"; export const createPages = (pagesPath, pages, useTypeScript, useAppDir) => { - const extension = useTypeScript ? '.tsx' : '.js'; + const extension = useTypeScript ? ".tsx" : ".js"; pages.forEach((page) => { const pageName = page.charAt(0).toUpperCase() + page.slice(1); - const pageContent = - `export default function ${pageName}() { + const pageContent = `export default function ${pageName}() { return (

${pageName}

@@ -17,7 +16,7 @@ export const createPages = (pagesPath, pages, useTypeScript, useAppDir) => { let pagePath; if (useAppDir) { - if (page.toLowerCase() === 'index' || page.toLowerCase() === 'home') { + if (page.toLowerCase() === "index" || page.toLowerCase() === "home") { pagePath = path.join(pagesPath, `page${extension}`); } else { const pageDir = path.join(pagesPath, page.toLowerCase()); @@ -25,7 +24,7 @@ export const createPages = (pagesPath, pages, useTypeScript, useAppDir) => { pagePath = path.join(pageDir, `page${extension}`); } } else { - if (page.toLowerCase() === 'index' || page.toLowerCase() === 'home') { + if (page.toLowerCase() === "index" || page.toLowerCase() === "home") { pagePath = path.join(pagesPath, `index${extension}`); } else { pagePath = path.join(pagesPath, `${page.toLowerCase()}${extension}`); @@ -88,5 +87,4 @@ export default function RootLayout({ children }) { createFolder(appDir); writeFile(layoutPath, layoutContent); - -}; \ No newline at end of file +}; diff --git a/src/lib/utils.js b/src/lib/utils.js index 1363404..2788650 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -1,58 +1,72 @@ -import { execSync } from "child_process"; -import fs from "fs"; -import path from "path"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; import chalk from "./colors.js"; -const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); - -export const run = (cmd, cwd = process.cwd(), silent = false, retries = 0, retryDelay = 1000) => { - for (let i = 0; i <= retries; i++) { - try { - if (!silent) { - console.log(`\nRunning: ${cmd}`); - } - const output = execSync(cmd, { stdio: silent ? "pipe" : "inherit", cwd }); - return { success: true, stdout: output ? output.toString() : "" }; - } catch (error) { - if (i < retries) { - console.log(chalk.yellow(`Command failed. Retrying in ${retryDelay / 1000}s... (Attempt ${i + 1}/${retries})`)); - sleep(retryDelay); - } else { - return { success: false, error: error.message, stdout: error.stdout ? error.stdout.toString() : "", stderr: error.stderr ? error.stderr.toString() : "" }; - } - } +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const run = async ( + cmd, + cwd = process.cwd(), + silent = false, + retries = 0, + retryDelay = 1000, +) => { + for (let i = 0; i <= retries; i++) { + try { + if (!silent) { + console.log(`\nRunning: ${cmd}`); + } + const output = execSync(cmd, { stdio: silent ? "pipe" : "inherit", cwd }); + return { success: true, stdout: output ? output.toString() : "" }; + } catch (error) { + if (i < retries) { + console.log( + chalk.yellow( + `Command failed. Retrying in ${retryDelay / 1000}s... (Attempt ${i + 1}/${retries})`, + ), + ); + await sleep(retryDelay); + } else { + return { + success: false, + error: error.message, + stdout: error.stdout ? error.stdout.toString() : "", + stderr: error.stderr ? error.stderr.toString() : "", + }; + } } + } }; - export const writeFile = (filePath, content) => { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(filePath, content); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, content); }; export const readFile = (filePath) => { - return fs.readFileSync(filePath, "utf-8"); + return fs.readFileSync(filePath, "utf-8"); }; export const fileExists = (filePath) => { - return fs.existsSync(filePath); + return fs.existsSync(filePath); }; export const createFolder = (folderPath) => { - fs.mkdirSync(folderPath, { recursive: true }); + fs.mkdirSync(folderPath, { recursive: true }); }; export const deleteFile = (filePath) => { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } }; export const deleteFolder = (folderPath) => { - if (fs.existsSync(folderPath)) { - fs.rmSync(folderPath, { recursive: true, force: true }); - } -}; \ No newline at end of file + if (fs.existsSync(folderPath)) { + fs.rmSync(folderPath, { recursive: true, force: true }); + } +}; diff --git a/src/test/generate-tests.js b/src/test/generate-tests.js index b2b946b..4a824b8 100644 --- a/src/test/generate-tests.js +++ b/src/test/generate-tests.js @@ -1,21 +1,20 @@ -import fs from 'fs'; -import path from 'path'; +import fs from "node:fs"; +import path from "node:path"; -const indexJsPath = path.join(process.cwd(), 'src', 'index.js'); -const indexJsContent = fs.readFileSync(indexJsPath, 'utf-8'); +const indexJsPath = path.join(process.cwd(), "src", "index.js"); +const indexJsContent = fs.readFileSync(indexJsPath, "utf-8"); const extractedPrompts = []; const inquirerPromptRegex = /inquirer\.prompt\(\[([\s\S]*?)\]\)/g; -let promptArrayMatch; - -while ((promptArrayMatch = inquirerPromptRegex.exec(indexJsContent)) !== null) { +let promptArrayMatch = inquirerPromptRegex.exec(indexJsContent); +while (promptArrayMatch !== null) { const promptArrayString = promptArrayMatch[1]; - const promptObjectRegex = /{\s*type:\s*"(.*?)",\s*name:\s*"(.*?)",\s*message:\s*"(.*?)"(?:,\s*choices:\s*\[([\s\S]*?)\])?(?:,\s*default:\s*(.*?))?[\s\S]*?}/g; - let promptMatch; - - while ((promptMatch = promptObjectRegex.exec(promptArrayString)) !== null) { + const promptObjectRegex = + /{\s*type:\s*"(.*?)",\s*name:\s*"(.*?)",\s*message:\s*"(.*?)"(?:,\s*choices:\s*\[([\s\S]*?)\])?(?:,\s*default:\s*(.*?))?[\s\S]*?}/g; + let promptMatch = promptObjectRegex.exec(promptArrayString); + while (promptMatch !== null) { const prompt = { type: promptMatch[1], name: promptMatch[2], @@ -25,43 +24,73 @@ while ((promptArrayMatch = inquirerPromptRegex.exec(indexJsContent)) !== null) { }; if (promptMatch[4]) { - prompt.choices = promptMatch[4].split(',').map(c => c.trim().replace(/['"]/g, '')).filter(c => c !== ''); + prompt.choices = promptMatch[4] + .split(",") + .map((c) => c.trim().replace(/['"]/g, "")) + .filter((c) => c !== ""); } if (promptMatch[5]) { const defaultValue = promptMatch[5].trim(); - if (defaultValue === 'true') { + if (defaultValue === "true") { prompt.default = true; - } else if (defaultValue === 'false') { + } else if (defaultValue === "false") { prompt.default = false; - } else if (defaultValue === '' || defaultValue === '""') { - prompt.default = ''; - } else if (defaultValue !== '' && !isNaN(defaultValue)) { - prompt.default = Number(defaultValue); + } else if (defaultValue === "" || defaultValue === '""') { + prompt.default = ""; } else { - prompt.default = defaultValue.replace(/['"]/g, ''); + const parsed = Number(defaultValue); + if (defaultValue !== "" && !Number.isNaN(parsed)) { + prompt.default = parsed; + } else { + prompt.default = defaultValue.replace(/['"]/g, ""); + } } } extractedPrompts.push(prompt); + promptMatch = promptObjectRegex.exec(promptArrayString); } + promptArrayMatch = inquirerPromptRegex.exec(indexJsContent); +} + +// Validation: Count expected prompts in src/index.js and warn if mismatch +const expectedPromptCount = (indexJsContent.match(/type:\s*["'](confirm|list|input)["']/g) || []) + .length; +if (extractedPrompts.length < expectedPromptCount) { + console.warn( + `āš ļø Warning: Extracted ${extractedPrompts.length} prompts but found ${expectedPromptCount} prompt definitions in src/index.js.`, + ); + console.warn( + ` This may indicate that the promptObjectRegex is missing some prompts due to property reordering.`, + ); + console.warn( + ` Consider updating the regex to be order-agnostic or reviewing the prompt definitions in src/index.js.`, + ); } const testCases = []; +// Helper function to resolve prompt default values +const getPromptDefault = (prompt) => { + if (prompt.default !== undefined) { + return prompt.default; + } + return prompt.type === "confirm" ? false : ""; +}; + for (const prompt of extractedPrompts) { - if (prompt.name === 'projectName') continue; + if (prompt.name === "projectName") continue; - if (prompt.type === 'confirm') { + if (prompt.type === "confirm") { const enableTestCase = { description: `should create a project with ${prompt.name} enabled`, options: {}, expectedFiles: [], - assertions: "assert.ok(fs.existsSync(projectPath), 'Project directory should exist');", }; - extractedPrompts.forEach(p => { - if (p.name !== 'projectName') { - enableTestCase.options[p.name] = p.default !== undefined ? p.default : (p.type === 'confirm' ? false : ''); + extractedPrompts.forEach((p) => { + if (p.name !== "projectName") { + enableTestCase.options[p.name] = getPromptDefault(p); } }); enableTestCase.options[prompt.name] = true; @@ -72,62 +101,64 @@ for (const prompt of extractedPrompts) { description: `should create a project with ${prompt.name} disabled`, options: {}, expectedFiles: [], - assertions: "assert.ok(fs.existsSync(projectPath), 'Project directory should exist');", }; - extractedPrompts.forEach(p => { - if (p.name !== 'projectName') { - disableTestCase.options[p.name] = p.default !== undefined ? p.default : (p.type === 'confirm' ? false : ''); + extractedPrompts.forEach((p) => { + if (p.name !== "projectName") { + disableTestCase.options[p.name] = getPromptDefault(p); } }); disableTestCase.options[prompt.name] = false; testCases.push(disableTestCase); } - } else if (prompt.type === 'list') { - prompt.choices.forEach(choice => { + } else if (prompt.type === "list") { + prompt.choices.forEach((choice) => { const listTestCase = { description: `should create a project with ${prompt.name} set to ${choice}`, options: {}, expectedFiles: [], - assertions: "assert.ok(fs.existsSync(projectPath), 'Project directory should exist');", }; - extractedPrompts.forEach(p => { - if (p.name !== 'projectName') { - listTestCase.options[p.name] = p.default !== undefined ? p.default : (p.type === 'confirm' ? false : ''); + extractedPrompts.forEach((p) => { + if (p.name !== "projectName") { + listTestCase.options[p.name] = getPromptDefault(p); } }); listTestCase.options[prompt.name] = choice; testCases.push(listTestCase); }); - } else if (prompt.type === 'input') { + } else if (prompt.type === "input") { const inputTestCase = { description: `should create a project with custom ${prompt.name}`, options: {}, expectedFiles: [], - assertions: "assert.ok(fs.existsSync(projectPath), 'Project directory should exist');", }; - extractedPrompts.forEach(p => { - if (p.name !== 'projectName') { - inputTestCase.options[p.name] = p.default !== undefined ? p.default : (p.type === 'confirm' ? false : ''); + extractedPrompts.forEach((p) => { + if (p.name !== "projectName") { + inputTestCase.options[p.name] = getPromptDefault(p); } }); - inputTestCase.options[prompt.name] = prompt.default || (prompt.name === 'pages' ? 'testpage' : 'custom'); + inputTestCase.options[prompt.name] = + prompt.default || (prompt.name === "pages" ? "testpage" : "custom"); testCases.push(inputTestCase); } } -const generatedTestCasesPath = path.join(process.cwd(), 'src', 'test', 'generated-test-cases.js'); +const generatedTestCasesPath = path.join(process.cwd(), "src", "test", "generated-test-cases.js"); const fileContent = `// This file is auto-generated by test/generate-tests.js // Do not edit this file directly. Run 'npm run test:generate' to regenerate. -import { strict as assert } from 'assert'; -import fs from 'fs'; -import path from 'path'; +import { strict as assert } from 'node:assert'; +import fs from 'node:fs'; + +// Default assertion function for all test cases +const defaultAssertion = (projectPath) => { + assert.ok(fs.existsSync(projectPath), 'Project directory should exist'); +}; export const testCases = ${JSON.stringify(testCases, null, 2)}.map(tc => ({ ...tc, - assertions: (projectPath) => { eval(tc.assertions) } + assertions: defaultAssertion })); `; fs.writeFileSync(generatedTestCasesPath, fileContent); -console.log(`Generated ${testCases.length} test cases in ${generatedTestCasesPath}`); \ No newline at end of file +console.log(`Generated ${testCases.length} test cases in ${generatedTestCasesPath}`); diff --git a/src/test/interactive.test.js b/src/test/interactive.test.js index e2c7cf8..f57039f 100644 --- a/src/test/interactive.test.js +++ b/src/test/interactive.test.js @@ -1,30 +1,30 @@ -import { spawn } from 'child_process'; -import { strict as assert } from 'assert'; -import { fileURLToPath } from 'url'; -import path from 'path'; -import fs from 'fs'; -import { deleteFolder, createFolder, writeFile, readFile } from '../lib/utils.js'; +import { spawn } from "node:child_process"; +import { strict as assert } from "node:assert"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import fs from "node:fs"; +import { deleteFolder, readFile } from "../lib/utils.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const cliPath = path.join(__dirname, '..', 'index.js'); +const cliPath = path.join(__dirname, "..", "index.js"); -describe('create-next-quick interactive mode', function () { +describe("create-next-quick interactive mode", function () { this.timeout(300000); let currentProjectName; let currentProjectPath; - beforeEach(function (done) { + beforeEach((done) => { currentProjectName = `test-interactive-project-${Date.now()}`; currentProjectPath = path.join(process.cwd(), currentProjectName); const command = `npx create-next-app@latest ${currentProjectName} --ts --tailwind --src-dir --app --use-npm --yes`; - const child = spawn(command, { shell: true, stdio: 'inherit' }); + const child = spawn(command, { shell: true, stdio: "inherit" }); - child.on('close', (code) => { - assert.strictEqual(code, 0, 'create-next-app should exit with code 0'); + child.on("close", (code) => { + assert.strictEqual(code, 0, "create-next-app should exit with code 0"); done(); }); }); @@ -37,19 +37,14 @@ describe('create-next-quick interactive mode', function () { } }); - it('should add a new page in interactive mode', (done) => { - const answers = [ - 'about', - '', - '', - 'n', - ]; + it("should add a new page in interactive mode", (done) => { + const answers = ["about", "", "", "n"]; - const child = spawn('node', [cliPath, '-i'], { cwd: currentProjectPath }); + const child = spawn("node", [cliPath, "-i"], { cwd: currentProjectPath }); let i = 0; const interval = setInterval(() => { if (i < answers.length) { - child.stdin.write(answers[i] + '\n'); + child.stdin.write(`${answers[i]}\n`); i++; } else { clearInterval(interval); @@ -57,30 +52,25 @@ describe('create-next-quick interactive mode', function () { } }, 8000); - child.on('close', (code) => { - assert.strictEqual(code, 0, 'CLI should exit with code 0'); + child.on("close", (code) => { + assert.strictEqual(code, 0, "CLI should exit with code 0"); - const newPagePath = path.join(currentProjectPath, 'src', 'app', 'about', 'page.tsx'); - assert.ok(fs.existsSync(newPagePath), 'New page should be created'); + const newPagePath = path.join(currentProjectPath, "src", "app", "about", "page.tsx"); + assert.ok(fs.existsSync(newPagePath), "New page should be created"); done(); }); }); - it('should add biome linter in interactive mode', (done) => { - const answers = [ - '', - '3', - '', - 'n', - ]; + it("should add biome linter in interactive mode", (done) => { + const answers = ["", "3", "", "n"]; - const child = spawn('node', [cliPath, '-i'], { cwd: currentProjectPath }); + const child = spawn("node", [cliPath, "-i"], { cwd: currentProjectPath }); let i = 0; const interval = setInterval(() => { if (i < answers.length) { - child.stdin.write(answers[i] + '\n'); + child.stdin.write(`${answers[i]}\n`); i++; } else { clearInterval(interval); @@ -88,31 +78,32 @@ describe('create-next-quick interactive mode', function () { } }, 8000); - child.on('close', (code) => { - assert.strictEqual(code, 0, 'CLI should exit with code 0'); + child.on("close", (code) => { + assert.strictEqual(code, 0, "CLI should exit with code 0"); - const packageJson = JSON.parse(readFile(path.join(currentProjectPath, 'package.json'))); - assert.ok(packageJson.devDependencies['@biomejs/biome'], 'Biome should be in devDependencies'); - assert.ok(fs.existsSync(path.join(currentProjectPath, 'biome.json')), 'biome.json should be created'); + const packageJson = JSON.parse(readFile(path.join(currentProjectPath, "package.json"))); + assert.ok( + packageJson.devDependencies["@biomejs/biome"], + "Biome should be in devDependencies", + ); + assert.ok( + fs.existsSync(path.join(currentProjectPath, "biome.json")), + "biome.json should be created", + ); done(); }); }); - it('should add prisma orm in interactive mode', (done) => { - const answers = [ - '', - '', - '2', - 'n', - ]; + it("should add prisma orm in interactive mode", (done) => { + const answers = ["", "", "2", "n"]; - const child = spawn('node', [cliPath, '-i'], { cwd: currentProjectPath }); + const child = spawn("node", [cliPath, "-i"], { cwd: currentProjectPath }); let i = 0; const interval = setInterval(() => { if (i < answers.length) { - child.stdin.write(answers[i] + '\n'); + child.stdin.write(`${answers[i]}\n`); i++; } else { clearInterval(interval); @@ -120,32 +111,33 @@ describe('create-next-quick interactive mode', function () { } }, 8000); - child.on('close', (code) => { - assert.strictEqual(code, 0, 'CLI should exit with code 0'); + child.on("close", (code) => { + assert.strictEqual(code, 0, "CLI should exit with code 0"); - const packageJson = JSON.parse(readFile(path.join(currentProjectPath, 'package.json'))); - assert.ok(packageJson.devDependencies['prisma'], 'Prisma should be in devDependencies'); - assert.ok(packageJson.dependencies['@prisma/client'], 'Prisma client should be in dependencies'); - assert.ok(fs.existsSync(path.join(currentProjectPath, 'prisma', 'schema.prisma')), 'prisma/schema.prisma should be created'); + const packageJson = JSON.parse(readFile(path.join(currentProjectPath, "package.json"))); + assert.ok(packageJson.devDependencies.prisma, "Prisma should be in devDependencies"); + assert.ok( + packageJson.dependencies["@prisma/client"], + "Prisma client should be in dependencies", + ); + assert.ok( + fs.existsSync(path.join(currentProjectPath, "prisma", "schema.prisma")), + "prisma/schema.prisma should be created", + ); done(); }); }); - it('should add shadcn ui in interactive mode', (done) => { - const answers = [ - '', - '', - '', - 'y', - ]; + it("should add shadcn ui in interactive mode", (done) => { + const answers = ["", "", "", "y"]; - const child = spawn('node', [cliPath, '-i'], { cwd: currentProjectPath }); + const child = spawn("node", [cliPath, "-i"], { cwd: currentProjectPath }); let i = 0; const interval = setInterval(() => { if (i < answers.length) { - child.stdin.write(answers[i] + '\n'); + child.stdin.write(`${answers[i]}\n`); i++; } else { clearInterval(interval); @@ -153,32 +145,36 @@ describe('create-next-quick interactive mode', function () { } }, 8000); - child.on('close', (code) => { - assert.strictEqual(code, 0, 'CLI should exit with code 0'); - - const packageJson = JSON.parse(readFile(path.join(currentProjectPath, 'package.json'))); - assert.ok(packageJson.devDependencies['tailwindcss-animate'], 'tailwindcss-animate should be in devDependencies'); - assert.ok(packageJson.devDependencies['class-variance-authority'], 'class-variance-authority should be in devDependencies'); - assert.ok(fs.existsSync(path.join(currentProjectPath, 'components.json')), 'components.json should be created'); + child.on("close", (code) => { + assert.strictEqual(code, 0, "CLI should exit with code 0"); + + const packageJson = JSON.parse(readFile(path.join(currentProjectPath, "package.json"))); + assert.ok( + packageJson.devDependencies["tailwindcss-animate"], + "tailwindcss-animate should be in devDependencies", + ); + assert.ok( + packageJson.devDependencies["class-variance-authority"], + "class-variance-authority should be in devDependencies", + ); + assert.ok( + fs.existsSync(path.join(currentProjectPath, "components.json")), + "components.json should be created", + ); done(); }); }); - it('should skip installation if feature already exists', (done) => { - const firstRunAnswers = [ - '', - '3', - '', - 'n', - ]; + it("should skip installation if feature already exists", (done) => { + const firstRunAnswers = ["", "3", "", "n"]; - const firstRun = spawn('node', [cliPath, '-i'], { cwd: currentProjectPath }); + const firstRun = spawn("node", [cliPath, "-i"], { cwd: currentProjectPath }); let i = 0; const firstInterval = setInterval(() => { if (i < firstRunAnswers.length) { - firstRun.stdin.write(firstRunAnswers[i] + '\n'); + firstRun.stdin.write(`${firstRunAnswers[i]}\n`); i++; } else { clearInterval(firstInterval); @@ -186,27 +182,22 @@ describe('create-next-quick interactive mode', function () { } }, 8000); - firstRun.on('close', (code) => { - assert.strictEqual(code, 0, 'First run should exit with code 0'); + firstRun.on("close", (code) => { + assert.strictEqual(code, 0, "First run should exit with code 0"); - const secondRunAnswers = [ - '', - '3', - '', - 'n', - ]; + const secondRunAnswers = ["", "3", "", "n"]; - const secondRun = spawn('node', [cliPath, '-i'], { cwd: currentProjectPath }); - let stdout = ''; + const secondRun = spawn("node", [cliPath, "-i"], { cwd: currentProjectPath }); + let stdout = ""; - secondRun.stdout.on('data', (data) => { + secondRun.stdout.on("data", (data) => { stdout += data.toString(); }); let j = 0; const secondInterval = setInterval(() => { if (j < secondRunAnswers.length) { - secondRun.stdin.write(secondRunAnswers[j] + '\n'); + secondRun.stdin.write(`${secondRunAnswers[j]}\n`); j++; } else { clearInterval(secondInterval); @@ -214,11 +205,14 @@ describe('create-next-quick interactive mode', function () { } }, 8000); - secondRun.on('close', (secondCode) => { - assert.strictEqual(secondCode, 0, 'Second run should exit with code 0'); - assert.ok(stdout.includes('Biome is already installed. Skipping setup.'), 'Should show "already installed" message'); + secondRun.on("close", (secondCode) => { + assert.strictEqual(secondCode, 0, "Second run should exit with code 0"); + assert.ok( + stdout.includes("Biome is already installed. Skipping setup."), + 'Should show "already installed" message', + ); done(); }); }); }); -}); \ No newline at end of file +}); diff --git a/src/test/test-cases.js b/src/test/test-cases.js index b599d3a..594bec9 100644 --- a/src/test/test-cases.js +++ b/src/test/test-cases.js @@ -1,87 +1,101 @@ - -import { strict as assert } from 'assert'; -import fs from 'fs'; -import path from 'path'; +import { strict as assert } from "node:assert"; +import fs from "node:fs"; +import path from "node:path"; export const testCases = [ { - description: 'should create a new project with default options', + description: "should create a new project with default options", options: { useTypeScript: true, useTailwind: true, useSrcDir: true, useAppDir: true, - pages: '', - linter: 'none', - orm: 'none', + pages: "", + linter: "none", + orm: "none", useShadcn: false, - testing: 'none', - auth: 'none', + testing: "none", + auth: "none", docker: false, }, assertions: (projectPath) => { - assert.ok(fs.existsSync(path.join(projectPath, 'package.json')), 'package.json should exist'); - assert.ok(!fs.existsSync(path.join(projectPath, 'app')), 'app folder should not exist at root when using src'); - assert.ok(fs.existsSync(path.join(projectPath, 'src', 'app')), 'app folder should exist in src'); + assert.ok(fs.existsSync(path.join(projectPath, "package.json")), "package.json should exist"); + assert.ok( + !fs.existsSync(path.join(projectPath, "app")), + "app folder should not exist at root when using src", + ); + assert.ok( + fs.existsSync(path.join(projectPath, "src", "app")), + "app folder should exist in src", + ); }, }, { - description: 'should create a new project with TypeScript disabled', + description: "should create a new project with TypeScript disabled", options: { useTypeScript: false, useTailwind: true, useSrcDir: true, useAppDir: true, - pages: '', - linter: 'none', - orm: 'none', + pages: "", + linter: "none", + orm: "none", useShadcn: false, - testing: 'none', - auth: 'none', + testing: "none", + auth: "none", docker: false, }, assertions: (projectPath) => { - assert.ok(fs.existsSync(path.join(projectPath, 'jsconfig.json')), 'jsconfig.json should exist'); + assert.ok( + fs.existsSync(path.join(projectPath, "jsconfig.json")), + "jsconfig.json should exist", + ); }, }, { - description: 'should create a new project with a specific page', + description: "should create a new project with a specific page", options: { useTypeScript: true, useTailwind: true, useSrcDir: true, useAppDir: true, - pages: 'about', - linter: 'none', - orm: 'none', + pages: "about", + linter: "none", + orm: "none", useShadcn: false, - testing: 'none', - auth: 'none', + testing: "none", + auth: "none", docker: false, }, assertions: (projectPath) => { - assert.ok(fs.existsSync(path.join(projectPath, 'src', 'app', 'about', 'page.tsx')), 'about page should exist'); + assert.ok( + fs.existsSync(path.join(projectPath, "src", "app", "about", "page.tsx")), + "about page should exist", + ); }, }, { - description: 'should create a new project without src directory', + description: "should create a new project without src directory", options: { useTypeScript: true, useTailwind: true, useSrcDir: false, useAppDir: true, - pages: '', - linter: 'none', - orm: 'none', + pages: "", + linter: "none", + orm: "none", useShadcn: false, - testing: 'none', - auth: 'none', + testing: "none", + auth: "none", docker: false, }, assertions: (projectPath) => { - assert.ok(fs.existsSync(path.join(projectPath, 'package.json')), 'package.json should exist'); - assert.ok(fs.existsSync(path.join(projectPath, 'app')), 'app folder should exist at root'); - assert.ok(!fs.existsSync(path.join(projectPath, 'src', 'app')), 'app folder should not exist in src'); + assert.ok(fs.existsSync(path.join(projectPath, "package.json")), "package.json should exist"); + assert.ok(fs.existsSync(path.join(projectPath, "app")), "app folder should exist at root"); + assert.ok( + !fs.existsSync(path.join(projectPath, "src", "app")), + "app folder should not exist in src", + ); }, }, ]; diff --git a/src/test/test.js b/src/test/test.js index 362c987..eead730 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -1,18 +1,16 @@ -import { spawn } from 'child_process'; -import { strict as assert } from 'assert'; -import { fileURLToPath } from 'url'; -import path from 'path'; -import fs from 'fs'; -import { deleteFolder } from '../lib/utils.js'; +import { spawn } from "node:child_process"; +import { strict as assert } from "node:assert"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import fs from "node:fs"; +import { deleteFolder } from "../lib/utils.js"; +import { testCases } from "./test-cases.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const cliPath = path.join(__dirname, "..", "index.js"); -const cliPath = path.join(__dirname, '..', 'index.js'); - -import { testCases } from './test-cases.js'; - -describe('create-next-quick', function () { +describe("create-next-quick", function () { this.timeout(0); let currentProjectName; @@ -27,64 +25,129 @@ describe('create-next-quick', function () { deleteFolder(currentProjectPath); }); + /** + * Maps a desired boolean value to the appropriate confirm prompt answer string. + * For confirm prompts: + * - If desiredValue matches defaultValue: return "" (press Enter to accept default) + * - If desiredValue is true and defaultValue is false: return "y" + * - If desiredValue is false and defaultValue is true: return "n" + */ + const mapConfirmAnswer = (desiredValue, defaultValue) => { + if (desiredValue === defaultValue) { + return ""; + } + return desiredValue ? "y" : "n"; + }; + const runTest = (answers, assertions, done) => { deleteFolder(currentProjectPath); - const child = spawn('node', [cliPath]); - let stdout = ''; - let stderr = ''; + const child = spawn("node", [cliPath]); + let _stdout = ""; + let _stderr = ""; + let interval = null; - child.stdout.on('data', (data) => { + child.stdout.on("data", (data) => { console.log(data.toString()); - stdout += data.toString(); + _stdout += data.toString(); }); - child.stderr.on('data', (data) => { + child.stderr.on("data", (data) => { console.error(data.toString()); - stderr += data.toString(); + _stderr += data.toString(); + }); + + // Error handler for child process + child.on("error", (err) => { + if (interval) clearInterval(interval); + console.error("Child process error:", err); + done(err); + }); + + // Error handler for stdin + child.stdin.on("error", (err) => { + if (interval) clearInterval(interval); + console.error("Child stdin error:", err); }); let i = 0; - const interval = setInterval(() => { + interval = setInterval(() => { if (i < answers.length) { - child.stdin.write(answers[i] + '\n'); - i++; + // Check if child is still alive and stdin is writable + if (!child.killed && child.stdin && child.stdin.writable) { + try { + child.stdin.write(`${answers[i]}\n`); + i++; + } catch (err) { + console.error("Error writing to stdin:", err); + clearInterval(interval); + } + } else { + clearInterval(interval); + } } else { clearInterval(interval); - child.stdin.end(); + child.stdin?.end(); } }, 3000); - child.on('close', (code) => { - assert.strictEqual(code, 0, 'CLI should exit with code 0'); + child.on("close", (code) => { + if (interval) clearInterval(interval); + if (code !== 0) { + console.log("Full stdout:", _stdout); + console.error("Full stderr:", _stderr); + } + assert.strictEqual(code, 0, "CLI should exit with code 0"); assertions(); done(); }); + + child.on("exit", () => { + if (interval) clearInterval(interval); + }); }; testCases.forEach((testCase) => { it(testCase.description, (done) => { const answers = [ currentProjectName, - '', // Package manager (default variables) - testCase.options.useTypeScript ? '' : 'n', - testCase.options.useTailwind ? '' : 'n', - testCase.options.useSrcDir ? '' : 'n', - testCase.options.useAppDir ? '' : 'n', + "", // Package manager (default variables) + mapConfirmAnswer(testCase.options.useTypeScript, true), + mapConfirmAnswer(testCase.options.useTailwind, true), + mapConfirmAnswer(testCase.options.useSrcDir, true), + mapConfirmAnswer(testCase.options.useAppDir, true), testCase.options.pages, - (testCase.options.linter === 'none' ? '' : (testCase.options.linter === 'eslint' ? '\u001b[B' : '\u001b[B\u001b[B')), // Linter: none (default), eslint (1 down), biome (2 down) - (testCase.options.orm === 'none' ? '' : (testCase.options.orm === 'prisma' ? '\u001b[B' : '\u001b[B\u001b[B')), // ORM: none (default), prisma (1 down), drizzle (2 down) - testCase.options.useShadcn ? '' : 'n', - testCase.options.testing === 'none' ? '' : (testCase.options.testing === 'vitest' ? '\u001b[B' : '\u001b[B\u001b[B'), // testing - testCase.options.auth === 'none' ? '' : (testCase.options.auth === 'next-auth' ? '\u001b[B' : (testCase.options.auth === 'clerk' ? '\u001b[B\u001b[B' : '\u001b[B\u001b[B\u001b[B')), // auth - testCase.options.docker ? 'y' : '', // docker (default false, so enter is false. but to be safe, explicit? No, confirm default false means Enter -> false.) + testCase.options.linter === "none" + ? "" + : testCase.options.linter === "eslint" + ? "\u001b[B" + : "\u001b[B\u001b[B", // Linter: none (default), eslint (1 down), biome (2 down) + testCase.options.orm === "none" + ? "" + : testCase.options.orm === "prisma" + ? "\u001b[B" + : "\u001b[B\u001b[B", // ORM: none (default), prisma (1 down), drizzle (2 down) + mapConfirmAnswer(testCase.options.useShadcn, true), + testCase.options.testing === "none" + ? "" + : testCase.options.testing === "vitest" + ? "\u001b[B" + : "\u001b[B\u001b[B", // testing + testCase.options.auth === "none" + ? "" + : testCase.options.auth === "next-auth" + ? "\u001b[B" + : testCase.options.auth === "clerk" + ? "\u001b[B\u001b[B" + : "\u001b[B\u001b[B\u001b[B", // auth + mapConfirmAnswer(testCase.options.docker, false), ]; const assertions = () => { - assert.ok(fs.existsSync(currentProjectPath), 'Project directory should exist'); + assert.ok(fs.existsSync(currentProjectPath), "Project directory should exist"); testCase.assertions(currentProjectPath); }; runTest(answers, assertions, done); }); }); -}); \ No newline at end of file +});