#!/usr/bin/env node import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; import { execSync } from 'child_process'; import inquirer from 'inquirer'; import chalk from 'chalk'; import fs from 'fs-extra'; import { parseArgs } from 'node:util'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const TEMPLATES_DIR = join(__dirname, 'templates'); // Validate cloud name format function isValidCloudName(name) { return /^[a-z0-9_-]+$/.test(name) && name.length > 0; } // Validate project name function isValidProjectName(name) { return /^[a-z0-9_-]+$/i.test(name) && name.length > 0; } async function main() { let answers = {}; if (process.argv.includes('--headless')) { const { values, positionals } = parseArgs({ options: { headless: { type: 'boolean' }, projectName: { type: 'string', default: 'my-cloudinary-app' }, cloudName: { type: 'string' }, hasUploadPreset: { type: 'boolean', default: false }, uploadPreset: { type: 'string' }, aiTools: { type: 'string', multiple: true, default: ['cursor'] }, installDeps: { type: 'boolean', default: true }, startDev: { type: 'boolean', default: false } } }); Object.assign(answers, values); } else { console.log(chalk.cyan.bold('\n🚀 Cloudinary React + Vite\n')); console.log(chalk.gray('💡 Need a Cloudinary account? Sign up for free: https://cld.media/reactregister\n')); const questions = [ { type: 'input', name: 'projectName', message: 'What’s your project’s name?\n', default: 'my-cloudinary-app', validate: (input) => { if (!input.trim()) { return 'Project name cannot be empty'; } if (!isValidProjectName(input)) { return 'Project name can only contain letters, numbers, hyphens, and underscores'; } if (existsSync(input)) { return `Directory "${input}" already exists. Please choose a different name.`; } return true; }, }, { type: 'input', name: 'cloudName', message: 'What’s your Cloudinary cloud name?\n' + chalk.gray(' → Find your cloud name: https://console.cloudinary.com/app/home/dashboard') + '\n', validate: (input) => { if (!input.trim()) { return chalk.yellow( 'Cloud name is required.\n' + ' → Sign up: https://cld.media/reactregister\n' + ' → Find your cloud name: https://console.cloudinary.com/app/home/dashboard' ); } if (!isValidCloudName(input)) { return 'Cloud name can only contain lowercase letters, numbers, hyphens, and underscores'; } return true; }, }, { type: 'confirm', name: 'hasUploadPreset', message: 'Do you have an unsigned upload preset?\n' + chalk.gray(' → You’ll need one if you want to upload new images to Cloudinary,\n but not if you only want to transform or deliver existing images.') + '\n' + chalk.gray(' → Create one here: https://console.cloudinary.com/app/settings/upload/presets') + '\n', default: false, }, { type: 'input', name: 'uploadPreset', message: 'What’s your unsigned upload preset’s name?\n', when: (answers) => answers.hasUploadPreset, validate: (input) => { if (!input.trim()) { return 'Upload preset name cannot be empty'; } return true; }, }, { type: 'checkbox', name: 'aiTools', message: 'Which AI coding assistant(s) are you using? (Select all that apply)\n' + chalk.gray(' We’ll add local instruction files so your assistant knows Cloudinary patterns.\n'), choices: [ { name: 'Cursor', value: 'cursor' }, { name: 'GitHub Copilot', value: 'copilot' }, { name: 'Claude Code', value: 'claude' }, { name: 'Other / Generic AI tools', value: 'generic' }, ], default: ['cursor'], }, { type: 'confirm', name: 'installDeps', message: 'Install dependencies now?\n', default: true, }, { type: 'confirm', name: 'startDev', message: 'Start development server?\n', default: false, when: (answers) => answers.installDeps, }, ]; answers = await inquirer.prompt(questions); } const { projectName, cloudName, uploadPreset, aiTools, installDeps, startDev } = answers; console.log(chalk.blue('\n📦 Creating project...\n')); // Create project directory const projectPath = join(process.cwd(), projectName); mkdirSync(projectPath, { recursive: true }); // Template replacement function function replaceTemplate(content, vars) { let result = content; Object.keys(vars).forEach((key) => { const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); result = result.replace(regex, vars[key]); }); return result; } // Template variables const templateVars = { PROJECT_NAME: projectName, CLOUD_NAME: cloudName, UPLOAD_PRESET: uploadPreset || '', UPLOAD_PRESET_ENV_LINE: uploadPreset ? `- \`VITE_CLOUDINARY_UPLOAD_PRESET\`: ${uploadPreset}` : '- `VITE_CLOUDINARY_UPLOAD_PRESET`: (not set - add one for uploads)', }; // Function to copy template file function copyTemplate(relativePath, outputPath = null) { const templatePath = join(TEMPLATES_DIR, relativePath); const finalPath = outputPath || join(projectPath, relativePath.replace('.template', '')); // Create directory if needed const finalDir = dirname(finalPath); if (!existsSync(finalDir)) { mkdirSync(finalDir, { recursive: true }); } if (existsSync(templatePath)) { const content = readFileSync(templatePath, 'utf-8'); const processed = replaceTemplate(content, templateVars); writeFileSync(finalPath, processed); } } // Copy all template files const filesToCopy = [ 'package.json.template', 'vite.config.ts.template', 'tsconfig.json.template', 'tsconfig.app.json.template', 'tsconfig.node.json.template', 'eslint.config.js.template', '.gitignore.template', '.env.template', 'index.html.template', 'README.md.template', 'src/cloudinary/config.ts.template', 'src/cloudinary/UploadWidget.tsx.template', 'src/App.tsx.template', 'src/main.tsx.template', 'src/index.css.template', 'src/App.css.template', ]; filesToCopy.forEach((file) => { copyTemplate(file); }); // Create AI rules based on user's tool selection const aiRulesTemplatePath = join(TEMPLATES_DIR, '.cursorrules.template'); if (existsSync(aiRulesTemplatePath) && aiTools && aiTools.length > 0) { const aiRulesContent = replaceTemplate( readFileSync(aiRulesTemplatePath, 'utf-8'), templateVars ); // Generate files based on selected tools if (aiTools.includes('cursor')) { writeFileSync(join(projectPath, '.cursorrules'), aiRulesContent); } if (aiTools.includes('copilot')) { const githubDir = join(projectPath, '.github'); mkdirSync(githubDir, { recursive: true }); writeFileSync(join(githubDir, 'copilot-instructions.md'), aiRulesContent); } if (aiTools.includes('claude')) { writeFileSync(join(projectPath, 'CLAUDE.md'), aiRulesContent); } if (aiTools.includes('generic')) { writeFileSync(join(projectPath, 'AI_INSTRUCTIONS.md'), aiRulesContent); writeFileSync(join(projectPath, 'PROMPT.md'), aiRulesContent); } // Generate MCP configuration: Cursor uses .cursor/mcp.json, Claude Code uses .mcp.json in project root const mcpTemplatePath = join(TEMPLATES_DIR, '.cursor/mcp.json.template'); if (existsSync(mcpTemplatePath)) { const mcpContent = replaceTemplate( readFileSync(mcpTemplatePath, 'utf-8'), templateVars ); if (aiTools.includes('cursor')) { const cursorDir = join(projectPath, '.cursor'); mkdirSync(cursorDir, { recursive: true }); writeFileSync(join(cursorDir, 'mcp.json'), mcpContent); } if (aiTools.includes('claude')) { writeFileSync(join(projectPath, '.mcp.json'), mcpContent); } } } // Copy vite.svg to public directory const viteSvgPath = join(projectPath, 'public', 'vite.svg'); mkdirSync(join(projectPath, 'public'), { recursive: true }); const viteSvg = ''; writeFileSync(viteSvgPath, viteSvg); console.log(chalk.green('✅ Project created successfully!\n')); if (aiTools && aiTools.length > 0) { console.log(chalk.cyan('📋 AI assistant files created:')); if (aiTools.includes('cursor')) console.log(chalk.gray(' • Cursor: .cursorrules')); if (aiTools.includes('copilot')) console.log(chalk.gray(' • GitHub Copilot: .github/copilot-instructions.md')); if (aiTools.includes('claude')) console.log(chalk.gray(' • Claude: CLAUDE.md')); if (aiTools.includes('generic')) console.log(chalk.gray(' • Generic: AI_INSTRUCTIONS.md, PROMPT.md')); if (aiTools.includes('cursor')) console.log(chalk.gray(' • MCP (Cursor): .cursor/mcp.json')); if (aiTools.includes('claude')) console.log(chalk.gray(' • MCP (Claude Code): .mcp.json')); console.log(''); } if (!answers.hasUploadPreset) { console.log(chalk.yellow('\n📝 Note: Upload preset not configured')); console.log(chalk.gray(' • Transformations will work with sample images')); console.log(chalk.gray(' • Uploads require an unsigned upload preset')); console.log(chalk.cyan('\n To enable uploads:')); console.log(chalk.cyan(' 1. Go to https://console.cloudinary.com/app/settings/upload/presets')); console.log(chalk.cyan(' 2. Click "Add upload preset"')); console.log(chalk.cyan(' 3. Set it to "Unsigned" mode')); console.log(chalk.cyan(' 4. Add the preset name to your .env file')); console.log(chalk.cyan(' 5. Save the file and restart the dev server so it loads correctly\n')); } if (installDeps) { console.log(chalk.blue('📦 Installing dependencies...\n')); try { process.chdir(projectPath); execSync('npm install', { stdio: 'inherit' }); console.log(chalk.green('\n✅ Dependencies installed!\n')); if (startDev) { console.log(chalk.blue('🚀 Starting development server...\n')); execSync('npm run dev', { stdio: 'inherit' }); } else { console.log(chalk.cyan(`\n📁 Project created at: ${projectPath}`)); console.log(chalk.cyan(`\nNext steps:`)); console.log(chalk.cyan(` cd ${projectName}`)); console.log(chalk.cyan(` npm run dev\n`)); } } catch (error) { console.error(chalk.red('\n❌ Error installing dependencies:'), error.message); console.log(chalk.cyan(`\nYou can install manually:`)); console.log(chalk.cyan(` cd ${projectName}`)); console.log(chalk.cyan(` npm install\n`)); } } else { console.log(chalk.cyan(`\n📁 Project created at: ${projectPath}`)); console.log(chalk.cyan(`\nNext steps:`)); console.log(chalk.cyan(` cd ${projectName}`)); console.log(chalk.cyan(` npm install`)); console.log(chalk.cyan(` npm run dev\n`)); } } main().catch((error) => { console.error(chalk.red('❌ Error:'), error.message); process.exit(1); });