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
text/typescript
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();