UNPKG

launch-express

Version:

CLI tool to setup a new Launch Express project

412 lines (408 loc) 17.1 kB
import chalk from 'chalk'; import { intro, outro, spinner, confirm } from '@clack/prompts'; import { execAsync } from '../utils.js'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); function parseBlocks(schema) { // First, normalize line endings and remove empty lines const normalizedSchema = schema.replace(/\r\n/g, '\n').trim(); // Split into potential blocks while preserving empty lines between models const rawBlocks = normalizedSchema.split(/\n(?=model|enum|datasource|generator)/); return rawBlocks .map((block) => block.trim()) .filter(Boolean) .map((block) => { if (block.startsWith('//')) { return { type: 'comment', content: block }; } const firstLine = block.split('\n')[0].trim(); let type; if (firstLine.startsWith('model')) type = 'model'; else if (firstLine.startsWith('enum')) type = 'enum'; else if (firstLine.startsWith('generator')) type = 'generator'; else if (firstLine.startsWith('datasource')) type = 'datasource'; else type = 'comment'; const nameMatch = firstLine.match(/(?:model|enum)\s+(\w+)/); return { type, name: nameMatch ? nameMatch[1] : undefined, content: block, }; }); } function processModelBlock(block) { const lines = block.split('\n'); const modelMatch = lines[0].match(/model\s+(\w+)\s*{/); if (!modelMatch) return block; const modelName = modelMatch[1]; const lowerModelName = modelName.toLowerCase(); // Keep track of model attributes and fields with their spacing const attributes = new Set(); const fields = new Map(); let currentIndentation = ''; let insideModel = false; let lastFieldIndex = -1; // First pass: collect all fields and attributes with their original formatting for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmedLine = line.trim(); if (trimmedLine.length === 0) continue; if (line.includes('{')) { insideModel = true; const indentMatch = line.match(/^\s*/); currentIndentation = indentMatch ? indentMatch[0] : ''; continue; } if (line.includes('}')) { insideModel = false; continue; } if (insideModel) { // Preserve original indentation const lineIndentation = line.match(/^\s*/); const indent = lineIndentation ? lineIndentation[0] : currentIndentation; if (trimmedLine.startsWith('@@')) { attributes.add(line); } else if (trimmedLine) { // Store field with its original formatting and spacing const fieldName = trimmedLine.split(/\s+/)[0]; fields.set(fieldName, { content: line, spacing: indent }); lastFieldIndex = i; } } } // Add required attributes if missing if (!Array.from(attributes).some((attr) => attr.includes('@@map'))) { attributes.add(`${currentIndentation}@@map("${lowerModelName}")`); } if (modelName === 'User' && !Array.from(attributes).some((attr) => attr.includes('@@unique([email])'))) { if (Array.from(fields.keys()).includes('email')) { attributes.add(`${currentIndentation}@@unique([email])`); } } // Reconstruct the model with proper formatting and spacing const modelLines = [ `model ${modelName} {`, ...Array.from(fields.values()).map((field) => field.content), '', // Preserve empty line between fields and attributes ...Array.from(attributes), '}', ]; return modelLines.join('\n'); } function mergeSchemas(betterAuthSchema, savedSchema) { // Split schemas into blocks const betterAuthBlocks = parseBlocks(betterAuthSchema); const savedBlocks = parseBlocks(savedSchema); // Get configuration blocks from saved schema const datasourceBlock = savedBlocks.find((b) => b.type === 'datasource')?.content; const generatorBlock = savedBlocks.find((b) => b.type === 'generator')?.content; if (!datasourceBlock || !generatorBlock) { throw new Error('Invalid schema format: missing datasource or generator configuration'); } // Create maps for both Better Auth and saved models const betterAuthModels = new Map(betterAuthBlocks.filter((b) => b.type === 'model' || b.type === 'enum').map((b) => [b.name, b.content])); const savedModelMap = new Map(savedBlocks.filter((b) => b.type === 'model' || b.type === 'enum').map((b) => [b.name, b.content])); // For models that exist in both, merge their contents for (const [name, content] of savedModelMap) { if (betterAuthModels.has(name)) { const betterAuthContent = betterAuthModels.get(name); if (betterAuthContent) { const mergedContent = mergeModelContents(betterAuthContent, content); betterAuthModels.set(name, mergedContent); } } } // Get saved models that don't exist in Better Auth const savedModels = Array.from(savedModelMap.entries()) .filter(([name]) => !betterAuthModels.has(name)) .map(([_, content]) => content); // Process all models const processedModels = Array.from(betterAuthModels.values()).map(processModelBlock); // Combine everything with proper spacing and sections return [generatorBlock, datasourceBlock, ...processedModels, ...savedModels].join('\n\n').trim() + '\n'; } function mergeModelContents(betterAuthContent, savedContent) { const betterAuthLines = betterAuthContent.split('\n'); const savedLines = savedContent.split('\n'); // Extract fields and attributes from both contents const fields = new Map(); const attributes = new Set(); let modelIndentation = ' '; // Default indentation function processLines(lines, isOriginal) { let insideModel = false; let lastWasField = false; for (const line of lines) { const trimmed = line.trim(); if (trimmed.includes('{')) { insideModel = true; const indentMatch = line.match(/^\s*/); if (indentMatch) modelIndentation = indentMatch[0] + ' '; continue; } if (trimmed.includes('}')) { insideModel = false; continue; } if (insideModel) { if (trimmed === '') { lastWasField = false; continue; } const lineIndentation = line.match(/^\s*/)?.[0] || modelIndentation; if (trimmed.startsWith('@@')) { attributes.add(line); lastWasField = false; } else if (trimmed) { const fieldName = trimmed.split(/\s+/)[0]; // Prefer original fields over Better Auth ones if (isOriginal || !fields.has(fieldName)) { fields.set(fieldName, { content: line, spacing: lineIndentation }); } lastWasField = true; } } } } // Process saved content first to preserve original fields processLines(savedLines, true); processLines(betterAuthLines, false); // Extract model name with type safety const getModelName = (line) => { const match = line.match(/model\s+(\w+)/); return match ? match[1] : null; }; const betterAuthModelName = betterAuthLines[0] ? getModelName(betterAuthLines[0]) : null; const savedModelName = savedLines[0] ? getModelName(savedLines[0]) : null; const modelName = betterAuthModelName || savedModelName || 'Unknown'; // Reconstruct the model with proper spacing return [ `model ${modelName} {`, ...Array.from(fields.values()).map((field) => field.content), '', // Add empty line between fields and attributes ...Array.from(attributes), '}', ].join('\n'); } function validateSchema(schema) { // Basic schema validation const requiredElements = ['datasource', 'generator', 'model']; const missingElements = requiredElements.filter((element) => !schema.includes(element)); if (missingElements.length > 0) { return { isValid: false, error: `Missing required elements: ${missingElements.join(', ')}`, }; } // Parse blocks const blocks = parseBlocks(schema); // Validate each block for (const block of blocks) { if (block.type === 'model') { const content = block.content; const modelName = block.name || 'Unknown'; // Check for opening bracket if (!content.includes('{')) { return { isValid: false, error: `Missing opening bracket in model ${modelName}`, }; } // Check for closing bracket if (!content.includes('}')) { return { isValid: false, error: `Missing closing bracket in model ${modelName}`, }; } // Count brackets const openCount = (content.match(/{/g) || []).length; const closeCount = (content.match(/}/g) || []).length; if (openCount !== closeCount) { return { isValid: false, error: `Unmatched brackets in model ${modelName}: ${openCount} opening vs ${closeCount} closing`, }; } } } return { isValid: true }; } export function showHelp() { console.log(` ${chalk.bold('Launch Express CLI - Generate Schema')} ${chalk.dim('Generate and merge Better Auth schema with your existing Prisma schema.')} ${chalk.yellow('Usage:')} npx launch-express generate-schema ${chalk.yellow('Description:')} This command will: 1. Save your current schema 2. Generate a fresh Better Auth schema 3. Merge both schemas preserving all your custom models and changes `); process.exit(0); } export async function execute() { intro(chalk.inverse(' Generate Schema ')); const s = await spinner(); s.start('Checking Git status'); const schemaPath = 'prisma/schema.prisma'; const backupPath = 'prisma/schema.prisma.backup'; const tempPath = 'prisma/current_schema_temp.prisma'; try { // Check if git is initialized let hasGitChanges = false; try { await execAsync('git status'); } catch (error) { s.stop(chalk.yellow('Git is not initialized in this project. Proceeding without Git checks.')); s.start('Preparing to generate schema'); // Continue execution if git is not initialized } // Check for uncommitted changes try { const gitStatus = (await execAsync('git status --porcelain')); hasGitChanges = gitStatus.trim().length > 0; if (hasGitChanges) { s.stop(chalk.red('⚠️ You have uncommitted changes in your repository.')); const shouldContinue = await confirm({ message: 'Do you want to proceed anyway? This might overwrite your changes.', }); if (!shouldContinue) { outro(chalk.dim('Operation cancelled. Please commit or stash your changes first.')); process.exit(0); } s.start('Preparing to generate schema'); } } catch (error) { // If git commands fail, we're probably not in a git repo // Already handled above, so we can continue } if (!hasGitChanges) { s.message('Preparing to generate schema'); } // Validate prisma directory and schema existence if (!fs.existsSync('prisma')) { throw new Error('Prisma directory not found. Please initialize Prisma first.'); } if (!fs.existsSync(schemaPath)) { throw new Error('schema.prisma not found. Please create a Prisma schema first.'); } // Read and validate current schema const currentSchema = fs.readFileSync(schemaPath, 'utf8'); const initialValidation = validateSchema(currentSchema); if (!initialValidation.isValid) { throw new Error(initialValidation.error || 'Invalid schema format'); } // Create backup fs.copyFileSync(schemaPath, backupPath); fs.writeFileSync(tempPath, currentSchema); // Clear schema file fs.writeFileSync(schemaPath, ''); s.message('Generating Better Auth schema'); // Find auth config const configPaths = ['./src/lib/auth/index.ts', './src/config/auth.ts', './src/auth/config.ts']; const configPath = configPaths.find((p) => fs.existsSync(p)); // Generate Better Auth schema try { if (configPath) { await execAsync(`npx @better-auth/cli@latest generate --config ${configPath} --y`); } else { await execAsync('npx @better-auth/cli@latest generate --y'); } } catch (genError) { throw new Error(`Failed to generate Better Auth schema: ${genError instanceof Error ? genError.message : 'Unknown error'}`); } s.message('Merging schemas'); // Read generated and saved schemas const betterAuthSchema = fs.readFileSync(schemaPath, 'utf8'); const savedSchema = fs.readFileSync(tempPath, 'utf8'); // Merge schemas const mergedSchema = mergeSchemas(betterAuthSchema, savedSchema); // Validate merged schema const validation = validateSchema(mergedSchema); if (!validation.isValid) { throw new Error(`Schema validation failed: ${validation.error}`); } // Write merged schema fs.writeFileSync(schemaPath, mergedSchema); s.message('Formatting schema'); // Format the schema using Prisma CLI try { await execAsync('npx prisma format'); } catch (formatError) { console.warn(chalk.yellow('Warning: Could not format schema. The schema is valid but might need manual formatting.')); } // Clean up fs.unlinkSync(tempPath); s.stop(chalk.green('✓ Schema generated and merged successfully!')); outro(chalk.dim('Backup of your original schema is saved as schema.prisma.backup')); console.log(chalk.yellow('\nNext steps:')); console.log('1. Review the changes in prisma/schema.prisma'); console.log('2. Run prisma generate to update the Prisma Client'); console.log('3. Run prisma db push or prisma migrate dev to apply the changes'); } catch (error) { s.stop(chalk.red('Error during schema generation:')); if (error instanceof Error) { console.error(chalk.red(error.message)); // Add debug information console.log(chalk.yellow('\nDebug Information:')); if (fs.existsSync(schemaPath)) { try { const currentSchema = fs.readFileSync(schemaPath, 'utf8'); console.log(chalk.dim('\nCurrent schema structure:')); console.log(currentSchema .split('\n\n') .map((block) => block.trim().split('\n')[0]) .join('\n')); } catch (debugError) { console.log(chalk.dim('Could not read current schema for debugging')); } } } else { console.error(chalk.red('An unexpected error occurred')); } // Attempt recovery if (fs.existsSync(backupPath)) { console.log(chalk.yellow('\nAttempting to restore from backup...')); try { fs.copyFileSync(backupPath, schemaPath); console.log(chalk.green('Successfully restored from backup.')); } catch (restoreError) { console.error(chalk.red('Failed to restore from backup. Your original schema is preserved in schema.prisma.backup')); } } // Clean up temp file if (fs.existsSync(tempPath)) { try { fs.unlinkSync(tempPath); } catch (cleanupError) { // Ignore cleanup errors } } process.exit(1); } }