UNPKG

go-meow

Version:

A modular microservice template built with TypeScript, Express, and Prisma (MongoDB). It includes service scaffolding tools, consistent query utilities with data grouping, Zod validation, structured logging, comprehensive seeding system, and Swagger/OpenA

642 lines (581 loc) 27.6 kB
import fs from "fs"; import path from "path"; const rootDir = path.resolve(__dirname, "../.."); const appDir = path.join(process.cwd(), "app"); const testsDir = path.join(process.cwd(), "tests"); const constantsFile = path.join(process.cwd(), "config", "constant.ts"); const zodDir = path.join(process.cwd(), "zod"); const prismaSchemaDir = path.join(process.cwd(), "prisma", "schema"); const prismaSeedsDir = path.join(process.cwd(), "prisma", "seeds"); const indexFile = path.join(process.cwd(), "index.ts"); const templatesDir = path.join(rootDir, "templates"); function toPascalCase(input: string): string { return input .replace(/[-_\s]+(.)?/g, (_: string, c: string) => (c ? c.toUpperCase() : "")) .replace(/^(.)/, (m) => m.toUpperCase()); } function toCamelCase(input: string): string { const pascal = toPascalCase(input); return pascal.charAt(0).toLowerCase() + pascal.slice(1); } function replaceAllIdentifiers(content: string, name: string, source: string): string { const lower = name.toLowerCase(); const pascal = toPascalCase(name); const camel = toCamelCase(name); const upper = name.toUpperCase(); // remove plural handling to always map to singular identifiers const sourceLower = source.toLowerCase(); const sourcePascal = toPascalCase(source); const sourceUpper = source.toUpperCase(); // Helper function to preserve casing patterns function preserveCasing(text: string, sourceWord: string, targetWord: string): string { // If source is camelCase (like userProfile), preserve camelCase in target if ( sourceWord !== sourceLower && sourceWord !== sourcePascal && sourceWord !== sourceUpper ) { // Check if it's camelCase (starts with lowercase, has uppercase later) if (/^[a-z][a-zA-Z]*$/.test(sourceWord) && sourceWord !== sourceWord.toLowerCase()) { return toCamelCase(targetWord); } // Check if it's kebab-case (like user-profile) if (sourceWord.includes("-")) { return targetWord.toLowerCase().replace(/\s+/g, "-"); } // Check if it's snake_case if (sourceWord.includes("_")) { return targetWord.toLowerCase().replace(/\s+/g, "_"); } } return targetWord; } // Create target variants that preserve the original source casing patterns const targetPreserveCamel = preserveCasing(source, source, name); const targetPreserveKebab = source.includes("-") ? name.toLowerCase().replace(/\s+/g, "-") : name; const targetPreserveSnake = source.includes("_") ? name.toLowerCase().replace(/\s+/g, "_") : name; return ( content // Handle kebab-case first (before word boundary replacements) .replace(new RegExp(`${source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g"), name) .replace( new RegExp( `${source.replace(/-/g, "_").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g", ), name.replace(/-/g, "_"), ) .replace( new RegExp( `${source.replace(/_/g, "-").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g", ), name.replace(/_/g, "-"), ) // Preserve original casing patterns .replace(new RegExp(`\\b${source}\\b`, "g"), targetPreserveCamel) .replace(new RegExp(`\\b${source.replace(/-/g, "_")}\\b`, "g"), targetPreserveSnake) .replace(new RegExp(`\\b${source.replace(/_/g, "-")}\\b`, "g"), targetPreserveKebab) // Handle import statements - convert to lowercase for file names .replace( new RegExp(`from\\s+["']\\.\\/${source}\\b`, "g"), `from "./${name.toLowerCase()}`, ) .replace( new RegExp(`from\\s+["']\\.\\/${sourcePascal}\\b`, "g"), `from "./${name.toLowerCase()}`, ) .replace( new RegExp(`from\\s+["']\\.\\/${sourceLower}\\b`, "g"), `from "./${name.toLowerCase()}`, ) // Handle import statements with different casing patterns .replace( new RegExp(`from\\s+["']\\.\\/${toCamelCase(source)}\\b`, "g"), `from "./${name.toLowerCase()}`, ) .replace( new RegExp(`from\\s+["']\\.\\/${toPascalCase(source)}\\b`, "g"), `from "./${name.toLowerCase()}`, ) // Handle specific import patterns for controller and router .replace( new RegExp(`from\\s+["']\\.\\/${toCamelCase(source)}\\.controller\\b`, "g"), `from "./${name.toLowerCase()}.controller`, ) .replace( new RegExp(`from\\s+["']\\.\\/${toCamelCase(source)}\\.router\\b`, "g"), `from "./${name.toLowerCase()}.router`, ) // Handle the case where source is already lowercase but gets converted to camelCase .replace( new RegExp( `from\\s+["']\\.\\/${toCamelCase(source.toLowerCase())}\\.controller\\b`, "g", ), `from "./${name.toLowerCase()}.controller`, ) .replace( new RegExp( `from\\s+["']\\.\\/${toCamelCase(source.toLowerCase())}\\.router\\b`, "g", ), `from "./${name.toLowerCase()}.router`, ) // Then handle standard cases .replace(new RegExp(`\\b${sourceUpper}\\b`, "g"), upper) .replace(new RegExp(`\\b${sourcePascal}\\b`, "g"), pascal) .replace(new RegExp(`\\b${sourceLower}\\b`, "g"), lower) // Fix constant naming - convert hyphens to underscores for constants .replace(new RegExp(`\\b${upper.replace(/-/g, "_")}\\b`, "g"), upper.replace(/-/g, "_")) // Common identifiers in code .replace(new RegExp(`${sourceLower}Module`, "g"), `${camel}Service`) .replace(new RegExp(`${sourcePascal}Schema`, "g"), `${pascal}Schema`) // Seeder function names .replace(new RegExp(`seed${sourcePascal}`, "g"), `seed${pascal}`) .replace(new RegExp(`seed${sourceLower}`, "g"), `seed${pascal}`) .replace(new RegExp(`${sourceLower}Data`, "g"), `${camel}Data`) // Fix pluralization in seeder function names .replace(new RegExp(`seed${pascal}s`, "g"), `seed${pascal}`) // Prisma model names should be lowercase .replace(new RegExp(`prisma\\.${pascal}`, "g"), `prisma.${lower}`) .replace(new RegExp(`prisma\\.${sourcePascal}`, "g"), `prisma.${lower}`) // Variable names in tests .replace(new RegExp(`${sourceLower}Controller`, "g"), `${camel}Controller`) .replace(new RegExp(`mock${sourcePascal}`, "g"), `mock${pascal}`) // Prisma types .replace(new RegExp(`${sourcePascal}FindManyArgs`, "g"), `${pascal}FindManyArgs`) .replace(new RegExp(`${sourcePascal}CountArgs`, "g"), `${pascal}CountArgs`) .replace(new RegExp(`${sourcePascal}FindFirstArgs`, "g"), `${pascal}FindFirstArgs`) .replace(new RegExp(`${sourcePascal}FindUniqueArgs`, "g"), `${pascal}FindUniqueArgs`) .replace(new RegExp(`${sourcePascal}CreateArgs`, "g"), `${pascal}CreateArgs`) .replace(new RegExp(`${sourcePascal}UpdateArgs`, "g"), `${pascal}UpdateArgs`) .replace(new RegExp(`${sourcePascal}DeleteArgs`, "g"), `${pascal}DeleteArgs`) // Route segments like source/source -> name/name .replace(new RegExp(`${sourceLower}/${sourceLower}`, "g"), `${lower}/${lower}`) // Activity/Audit log keys often used directly in code .replace(new RegExp(`CREATE_${sourceUpper}`, "g"), `CREATE_${upper}`) .replace(new RegExp(`GET_ALL_${sourceUpper}S`, "g"), `GET_ALL_${upper}`) .replace(new RegExp(`GET_ALL_${sourceUpper}`, "g"), `GET_ALL_${upper}`) .replace(new RegExp(`GET_${sourceUpper}`, "g"), `GET_${upper}`) .replace(new RegExp(`UPDATE_${sourceUpper}`, "g"), `UPDATE_${upper}`) .replace(new RegExp(`DELETE_${sourceUpper}`, "g"), `DELETE_${upper}`) .replace(new RegExp(`${sourceUpper}_CREATED`, "g"), `${upper}_CREATED`) .replace(new RegExp(`${sourceUpper}_UPDATED`, "g"), `${upper}_UPDATED`) .replace(new RegExp(`${sourceUpper}_DELETED`, "g"), `${upper}_DELETED`) .replace(new RegExp(`${sourceUpper}_RETRIEVED`, "g"), `${upper}_RETRIEVED`) .replace(new RegExp(`${sourceUpper}S_RETRIEVED`, "g"), `${upper}_RETRIEVED`) // Activity/Audit log PAGE keys .replace(new RegExp(`${sourceUpper}_CREATION`, "g"), `${upper}_CREATION`) .replace(new RegExp(`${sourceUpper}_UPDATE`, "g"), `${upper}_UPDATE`) .replace(new RegExp(`${sourceUpper}_DELETION`, "g"), `${upper}_DELETION`) .replace(new RegExp(`${sourceUpper}_DETAILS`, "g"), `${upper}_DETAILS`) .replace(new RegExp(`${sourceUpper}_LIST`, "g"), `${upper}_LIST`) // Final fix for import statements - use the correct casing for file names .replace( new RegExp(`from\\s+["']\\.\\/${toCamelCase(name)}\\.controller\\b`, "g"), `from "./${toCamelCase(name)}.controller`, ) .replace( new RegExp(`from\\s+["']\\.\\/${toCamelCase(name)}\\.router\\b`, "g"), `from "./${toCamelCase(name)}.router`, ) // Handle kebab-case imports (booking-app -> booking-app) .replace( new RegExp(`from\\s+["']\\.\\/${name}\\.controller\\b`, "g"), `from "./${name}.controller`, ) .replace( new RegExp(`from\\s+["']\\.\\/${name}\\.router\\b`, "g"), `from "./${name}.router`, ) ); } function copyAndTransform(src: string, dest: string, name: string, source: string) { const stats = fs.statSync(src); if (stats.isDirectory()) { if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); for (const entry of fs.readdirSync(src)) { const srcPath = path.join(src, entry); // Helper function to preserve casing in file names function preserveFileNameCasing( fileName: string, sourceWord: string, targetWord: string, ): string { // Check if it's camelCase (like userProfile) if ( /^[a-z][a-zA-Z]*$/.test(sourceWord) && sourceWord !== sourceWord.toLowerCase() ) { return fileName.replace(new RegExp(sourceWord, "g"), toCamelCase(targetWord)); } // Check if target is kebab-case (like booking-app) - retain hyphens for file names if (targetWord.includes("-")) { return fileName.replace(new RegExp(sourceWord, "g"), targetWord); } // Check if it's kebab-case source if (sourceWord.includes("-")) { return fileName.replace(new RegExp(sourceWord, "g"), targetWord); } // Check if it's snake_case if (sourceWord.includes("_")) { return fileName.replace( new RegExp(sourceWord, "g"), targetWord.toLowerCase().replace(/\s+/g, "_"), ); } // For other cases, preserve the original casing pattern of the target return fileName.replace(new RegExp(sourceWord, "g"), targetWord); } let renamed = preserveFileNameCasing(entry, source, name); // Additional handling for kebab-case targets if (name.includes("-")) { renamed = renamed.replace(new RegExp(toPascalCase(source), "g"), name); } else { renamed = renamed.replace( new RegExp(toPascalCase(source), "g"), toPascalCase(name), ); } const destPath = path.join(dest, renamed); copyAndTransform(srcPath, destPath, name, source); } } else { const buffer = fs.readFileSync(src, "utf8"); const transformed = replaceAllIdentifiers(buffer, name, source); fs.writeFileSync(dest, transformed, "utf8"); } } function pluralizeLower(word: string): string { const lower = word.toLowerCase(); if (lower.endsWith("s")) return lower; return `${lower}s`; } function updateConstantsForService(name: string) { if (!fs.existsSync(constantsFile)) return; let content = fs.readFileSync(constantsFile, "utf8"); const UPPER = name.toUpperCase().replace(/-/g, "_"); // Convert hyphens to underscores for constants const Pascal = toPascalCase(name); const lower = name.toLowerCase(); const pluralLower = pluralizeLower(name); if (new RegExp(`\\b${UPPER}\\b`).test(content)) { return; } // Add to ERROR section if (content.includes("ERROR:")) { content = content.replace( /(ERROR:\s*\{[\s\S]*?)(\}\s*,\s*\n?\s*SUCCESS:)/, (_m, p1, p2) => { if (p1.includes(`${UPPER}:`)) return _m; // Already exists const block = `\n\t\t${UPPER}: {\n\t\t\tVALIDATION_FAILED: "${Pascal} validation failed",\n\t\t\tINVALID_ID_FORMAT: "Invalid ${lower} ID format",\n\t\t\tNOT_FOUND: "${Pascal} not found",\n\t\t\tCREATE_FAILED: "Error creating ${lower}",\n\t\t\tUPDATE_FAILED: "Error updating ${lower}",\n\t\t\tDELETE_FAILED: "Error deleting ${lower}",\n\t\t\tGET_FAILED: "Error getting ${lower}",\n\t\t\tGET_ALL_FAILED: "Error getting ${pluralLower}",\n\t\t},`; return p1 + block + p2; }, ); } // Add to SUCCESS section if (content.includes("SUCCESS:")) { // Find the SUCCESS section and add at the top level, not inside nested objects if (!new RegExp(`SUCCESS:\\s*\\{[\\s\\S]*?\\n\\t\\t${UPPER}\\s*:`).test(content)) { // Find the end of SUCCESS section before the next top-level section content = content.replace(/(SUCCESS:\s*\{)([\s\S]*?)(\n\t\},)/, (_m, p1, p2, p3) => { const block = `\n\t\t${UPPER}: {\n\t\t\tCREATED: "${Pascal} created successfully",\n\t\t\tUPDATED: "${Pascal} updated successfully",\n\t\t\tDELETED: "${Pascal} deleted successfully",\n\t\t\tRETRIEVED: "${Pascal} retrieved successfully",\n\t\t\tRETRIEVED_ALL: "${Pascal}s retrieved successfully",\n\t\t},`; return p1 + p2 + block + p3; }); } } // Create ACTIVITY_LOG section if it doesn't exist if (!content.includes("ACTIVITY_LOG:")) { const activityLogSection = `\n\n\tACTIVITY_LOG: {\n\t\t${UPPER}: {\n\t\t\tACTIONS: {\n\t\t\t\tCREATE_${UPPER}: "CREATE_${UPPER}",\n\t\t\t\tGET_ALL_${UPPER}: "GET_ALL_${UPPER}",\n\t\t\t\tGET_${UPPER}: "GET_${UPPER}",\n\t\t\t\tUPDATE_${UPPER}: "UPDATE_${UPPER}",\n\t\t\t\tDELETE_${UPPER}: "DELETE_${UPPER}",\n\t\t\t},\n\t\t\tDESCRIPTIONS: {\n\t\t\t\t${UPPER}_CREATED: "Created new ${lower}",\n\t\t\t\t${UPPER}_UPDATED: "Updated ${lower}",\n\t\t\t\t${UPPER}_DELETED: "Deleted ${lower}",\n\t\t\t\t${UPPER}_RETRIEVED: "Retrieved ${lower} details",\n\t\t\t},\n\t\t\tPAGES: {\n\t\t\t\t${UPPER}_CREATION: "${Pascal} Creation",\n\t\t\t\t${UPPER}_UPDATE: "${Pascal} Update",\n\t\t\t\t${UPPER}_DELETION: "${Pascal} Deletion",\n\t\t\t\t${UPPER}_DETAILS: "${Pascal} Details",\n\t\t\t\t${UPPER}_LIST: "${Pascal} List",\n\t\t\t},\n\t\t},\n\t},`; // Insert before closing brace of main config object content = content.replace(/(\n};?\s*$)/, activityLogSection + "$1"); } else { // Add to existing ACTIVITY_LOG section if (!new RegExp(`ACTIVITY_LOG:\\s*\\{[\\s\\S]*?\\b${UPPER}\\s*:`).test(content)) { const activityGroup = `\n\t\t${UPPER}: {\n\t\t\tACTIONS: {\n\t\t\t\tCREATE_${UPPER}: "CREATE_${UPPER}",\n\t\t\t\tGET_ALL_${UPPER}: "GET_ALL_${UPPER}",\n\t\t\t\tGET_${UPPER}: "GET_${UPPER}",\n\t\t\t\tUPDATE_${UPPER}: "UPDATE_${UPPER}",\n\t\t\t\tDELETE_${UPPER}: "DELETE_${UPPER}",\n\t\t\t},\n\t\t\tDESCRIPTIONS: {\n\t\t\t\t${UPPER}_CREATED: "Created new ${lower}",\n\t\t\t\t${UPPER}_UPDATED: "Updated ${lower}",\n\t\t\t\t${UPPER}_DELETED: "Deleted ${lower}",\n\t\t\t\t${UPPER}_RETRIEVED: "Retrieved ${lower} details",\n\t\t\t},\n\t\t\tPAGES: {\n\t\t\t\t${UPPER}_CREATION: "${Pascal} Creation",\n\t\t\t\t${UPPER}_UPDATE: "${Pascal} Update",\n\t\t\t\t${UPPER}_DELETION: "${Pascal} Deletion",\n\t\t\t\t${UPPER}_DETAILS: "${Pascal} Details",\n\t\t\t\t${UPPER}_LIST: "${Pascal} List",\n\t\t\t},\n\t\t},`; content = content.replace(/(ACTIVITY_LOG:\s*\{)/, (_m, p1) => p1 + activityGroup); } } // Create AUDIT_LOG section if it doesn't exist if (!content.includes("AUDIT_LOG:")) { const auditLogSection = `\n\n\tAUDIT_LOG: {\n\t\tACTIONS: {\n\t\t\tCREATE: "CREATE",\n\t\t\tREAD: "READ",\n\t\t\tUPDATE: "UPDATE",\n\t\t\tDELETE: "DELETE",\n\t\t\tLOGIN: "LOGIN",\n\t\t\tLOGOUT: "LOGOUT",\n\t\t\tREGISTER: "REGISTER",\n\t\t},\n\t\tRESOURCES: {\n\t\t\t${UPPER}: "${lower}",\n\t\t},\n\t\tSEVERITY: {\n\t\t\tLOW: "LOW" as const,\n\t\t\tMEDIUM: "MEDIUM" as const,\n\t\t\tHIGH: "HIGH" as const,\n\t\t\tCRITICAL: "CRITICAL" as const,\n\t\t},\n\t\tENTITY_TYPES: {\n\t\t\t${UPPER}: "${lower}",\n\t\t},\n\t\t${UPPER}: {\n\t\t\tDESCRIPTIONS: {\n\t\t\t\t${UPPER}_CREATED: "Created new ${lower}",\n\t\t\t\t${UPPER}_UPDATED: "Updated ${lower}",\n\t\t\t\t${UPPER}_DELETED: "Deleted ${lower}",\n\t\t\t},\n\t\t},\n\t},`; // Insert before closing brace of main config object content = content.replace(/(\n};?\s*$)/, auditLogSection + "$1"); } else { // Add to existing AUDIT_LOG sections // Add to AUDIT_LOG.RESOURCES section if (content.includes("RESOURCES:")) { content = content.replace( /(RESOURCES:\s*\{[\s\S]*?)(\}\s*,\s*\n?\s*(\w+:|$))/, (_m, p1, p2) => { if (p1.includes(`${UPPER}: "${lower}"`)) return _m; // Already exists const block = `\n\t\t\t${UPPER}: "${lower}",`; return p1 + block + p2; }, ); } // Add to AUDIT_LOG.ENTITY_TYPES section if (content.includes("ENTITY_TYPES:")) { content = content.replace( /(ENTITY_TYPES:\s*\{[\s\S]*?)(\}\s*,\s*\n?\s*(\w+:|$))/, (_m, p1, p2) => { if (p1.includes(`${UPPER}: "${lower}"`)) return _m; // Already exists const block = `\n\t\t\t${UPPER}: "${lower}",`; return p1 + block + p2; }, ); } // Add resource-specific AUDIT_LOG section if (!new RegExp(`AUDIT_LOG:\\s*\\{[\\s\\S]*?\n\\t\\t${UPPER}\\s*:`).test(content)) { const auditGroup = `\n\t\t${UPPER}: {\n\t\t\tDESCRIPTIONS: {\n\t\t\t\t${UPPER}_CREATED: "Created new ${lower}",\n\t\t\t\t${UPPER}_UPDATED: "Updated ${lower}",\n\t\t\t\t${UPPER}_DELETED: "Deleted ${lower}",\n\t\t\t},\n\t\t},`; // Insert after ENTITY_TYPES section or before closing brace if (content.includes("ENTITY_TYPES:")) { content = content.replace( /(ENTITY_TYPES:\s*\{[\s\S]*?\n\t\t\},)/, "$1" + auditGroup, ); } else { content = content.replace( /(AUDIT_LOG:\s*\{[\s\S]*?)(\n\t\},)/, "$1" + auditGroup + "$2", ); } } } // Normalize any accidental double 'S' in GET_ALL_* action keys/values content = content.replace(/\bGET_ALL_([A-Z0-9]+)SS\b/g, "GET_ALL_$1S"); fs.writeFileSync(constantsFile, content, "utf8"); } function scaffoldZodForService(name: string, source: string) { if (!fs.existsSync(zodDir)) return; const lower = name.toLowerCase(); const pascal = toPascalCase(name); const sourceZodFile = source.toLowerCase() === "template" ? path.join(templatesDir, "zod", `${source.toLowerCase()}.zod.ts`) : path.join(zodDir, `${source.toLowerCase()}.zod.ts`); const targetZodFile = path.join(zodDir, `${name}.zod.ts`); if (!fs.existsSync(sourceZodFile)) return; if (fs.existsSync(targetZodFile)) return; const content = fs.readFileSync(sourceZodFile, "utf8"); const replaced = replaceAllIdentifiers(content, name, source); fs.writeFileSync(targetZodFile, replaced, "utf8"); } function scaffoldPrismaForService(name: string, source: string) { if (!fs.existsSync(prismaSchemaDir)) return; const sourcePrismaFile = source.toLowerCase() === "template" ? path.join(templatesDir, "schema", `${source.toLowerCase()}.prisma`) : path.join(prismaSchemaDir, `${source.toLowerCase()}.prisma`); const targetPrismaFile = path.join(prismaSchemaDir, `${name}.prisma`); if (!fs.existsSync(sourcePrismaFile)) return; if (fs.existsSync(targetPrismaFile)) return; const content = fs.readFileSync(sourcePrismaFile, "utf8"); const replaced = replaceAllIdentifiers(content, name, source); fs.writeFileSync(targetPrismaFile, replaced, "utf8"); } function scaffoldSeederForService(name: string, source: string) { if (!fs.existsSync(prismaSeedsDir)) return; const sourceSeederFile = path.join(prismaSeedsDir, `${source.toLowerCase()}Seeder.ts`); const targetSeederFile = path.join(prismaSeedsDir, `${name.toLowerCase()}Seeder.ts`); if (!fs.existsSync(sourceSeederFile)) return; if (fs.existsSync(targetSeederFile)) return; const content = fs.readFileSync(sourceSeederFile, "utf8"); const replaced = replaceAllIdentifiers(content, name, source); fs.writeFileSync(targetSeederFile, replaced, "utf8"); console.log(`✔ Created seeder file: ${targetSeederFile}`); } function updateSeedFile(name: string) { const seedFile = path.join(rootDir, "prisma", "seed.ts"); if (!fs.existsSync(seedFile)) return; let content = fs.readFileSync(seedFile, "utf8"); const lower = name.toLowerCase(); const pascal = toPascalCase(name); // Skip if already added if (content.includes(`seed${pascal}`)) return; // Add import statement const importLine = `import { seed${pascal} } from "./seeds/${lower}Seeder";`; // Find the last import statement and add after it const importRegex = /import\s+.*?from\s+["'][^"']*["'];?\s*$/gm; let lastImport: RegExpExecArray | null = null; let match: RegExpExecArray | null; while ((match = importRegex.exec(content)) !== null) { lastImport = match; } if (lastImport) { const insertPos = lastImport.index + lastImport[0].length; content = content.slice(0, insertPos) + "\n" + importLine + content.slice(insertPos); } else { // If no imports found, add after the first line const firstLineEnd = content.indexOf("\n"); if (firstLineEnd !== -1) { content = content.slice(0, firstLineEnd + 1) + importLine + "\n" + content.slice(firstLineEnd + 1); } else { content = importLine + "\n" + content; } } // Add seeder call in main function const seederCall = `\t// Seed ${lower} data\n\tawait seed${pascal}();`; // Find the main function and add the seeder call before the final console.log const mainFunctionRegex = /async function main\(\)\s*\{([\s\S]*?)\n\}/; const mainMatch = content.match(mainFunctionRegex); if (mainMatch) { const mainContent = mainMatch[1]; // Check if seeder call already exists if (!mainContent.includes(`seed${pascal}`)) { // Add before the final console.log const updatedMainContent = mainContent.replace( /(\s*console\.log\("Seeding completed successfully!"\);)/, `${seederCall}\n$1`, ); content = content.replace( mainFunctionRegex, `async function main() {${updatedMainContent}\n}`, ); } } fs.writeFileSync(seedFile, content, "utf8"); console.log(`✔ Added ${name} seeder to prisma/seed.ts`); } function updateIndexFile(name: string) { if (!fs.existsSync(indexFile)) return; let content = fs.readFileSync(indexFile, "utf8"); const lower = name.toLowerCase(); // Skip if already added if (content.includes(`./app/${lower}`)) return; const requireLine = `const ${lower} = require("./app/${lower}")(prisma);`; const useLine = `app.use(config.baseApiPath, ${lower});`; // Insert require after the last existing require("./app/..."), or after Prisma init const requireRegex = /const\s+\w+\s*=\s*require\("\.\/app\/[^\)]*\)\(prisma\);/g; let lastRequire: RegExpExecArray | null = null; let match: RegExpExecArray | null; while ((match = requireRegex.exec(content)) !== null) { lastRequire = match; } if (lastRequire) { const insertPos = lastRequire.index + lastRequire[0].length; content = content.slice(0, insertPos) + "\n" + requireLine + content.slice(insertPos); } else { const prismaInitRegex = /(const\s+prisma\s*=\s*new\s+PrismaClient\(\);)/; if (prismaInitRegex.test(content)) { content = content.replace(prismaInitRegex, `$1\n${requireLine}`); } else { // If Prisma init not found, prepend near top as a fallback content = requireLine + "\n" + content; } } // Insert app.use after the last app.use(config.baseApiPath, ...), or before server.listen const useRegex = /app\.use\(config\.baseApiPath,\s*\w+\);/g; let lastUse: RegExpExecArray | null = null; while ((match = useRegex.exec(content)) !== null) { lastUse = match; } if (lastUse) { const insertPos = lastUse.index + lastUse[0].length; content = content.slice(0, insertPos) + "\n" + useLine + content.slice(insertPos); } else { const serverListenRegex = /(server\.listen\()/; if (serverListenRegex.test(content)) { content = content.replace(serverListenRegex, `${useLine}\n\n$1`); } else { content += `\n${useLine}\n`; } } fs.writeFileSync(indexFile, content, "utf8"); console.log(`✔ Added ${name} to index.ts`); } function scaffoldTestForService(name: string, source: string) { if (!fs.existsSync(testsDir)) return; const lower = name.toLowerCase(); const pascal = toPascalCase(name); // For template source, use templates directory, otherwise use tests directory const sourceTestFile = source === "template" ? path.join(templatesDir, "tests", `${source.toLowerCase()}.controller.spec.ts`) : path.join(testsDir, `${source.toLowerCase()}.controller.spec.ts`); const targetTestFile = path.join(testsDir, `${name}.controller.spec.ts`); if (!fs.existsSync(sourceTestFile)) { console.log(`⚠ Test template not found at ${sourceTestFile}, skipping test generation`); return; } if (fs.existsSync(targetTestFile)) return; const content = fs.readFileSync(sourceTestFile, "utf8"); const replaced = replaceAllIdentifiers(content, name, source); fs.writeFileSync(targetTestFile, replaced, "utf8"); console.log(`✔ Created test file: ${targetTestFile}`); } // Parse command line arguments with named flags function parseArguments(): { from?: string; to?: string } { const args = process.argv.slice(2); const result: { from?: string; to?: string } = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg.startsWith("--from=")) { result.from = arg.split("=")[1]; } else if (arg === "--from" && i + 1 < args.length) { result.from = args[i + 1]; i++; // Skip next argument since we consumed it } else if (arg.startsWith("--to=")) { result.to = arg.split("=")[1]; } else if (arg === "--to" && i + 1 < args.length) { result.to = args[i + 1]; i++; // Skip next argument since we consumed it } } return result; } function main() { const { from, to } = parseArguments(); let source = from; let name = to; // Fallback to positional args if flags were stripped by npm if (!source || !name) { const argv = process.argv.slice(2).filter((a) => !a.startsWith("--")); if (!source && argv[0]) source = argv[0]; if (!name && argv[1]) name = argv[1]; } if (!source || !name) { console.error("Usage: npm run copy-template -- --from=<source> --to=<name>"); console.error(" or: npm run copy-template -- --from <source> --to <name>"); console.error(" or: npm run copy-service -- template newApp"); process.exit(1); } // Check if source is 'template', use templates directory, otherwise use app directory const sourceDir = source.toLowerCase() === "template" ? path.join(templatesDir, source.toLowerCase()) : path.join(appDir, source.toLowerCase()); if (!fs.existsSync(sourceDir)) { console.error(`Missing source directory at ${sourceDir}`); if (source.toLowerCase() === "template") { console.error( `Make sure you're running this command from a project that has the template structure.`, ); } process.exit(1); } const targetDir = path.join(appDir, name); if (fs.existsSync(targetDir)) { console.error(`Target already exists: ${targetDir}. Aborting to avoid overwrite.`); process.exit(1); } fs.mkdirSync(targetDir, { recursive: true }); copyAndTransform(sourceDir, targetDir, name, source); updateConstantsForService(name); scaffoldZodForService(name, source); scaffoldPrismaForService(name, source); scaffoldSeederForService(name, source); scaffoldTestForService(name, source); updateIndexFile(name); updateSeedFile(name); console.log(`✔ Scaffolded service at app/${name.toLowerCase()} from ${source}`); } main();