launch-express
Version:
CLI tool to setup a new Launch Express project
412 lines (408 loc) • 17.1 kB
JavaScript
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);
}
}