UNPKG

@tsdiapi/cli

Version:

A command-line tool for creating and managing scalable TSDIAPI projects with built-in plugin integration and configuration support.

576 lines 27.2 kB
import path from "path"; import chalk from "chalk"; import inquirer from "inquirer"; import fs from "fs-extra"; import { glob } from "glob"; import ora from 'ora'; import { getPackageName } from '../config.js'; import { toKebabCase, toLowerCase, toPascalCase } from "../utils/format.js"; import { applyTransform, convertWhenToFunction, validateInput } from '../utils/inquirer.js'; import { isDirectoryPath, isValidRequiredPath, replacePlaceholdersInPath, resolveTargetDirectory } from '../utils/cwd.js'; import { fileModifications } from '../utils/modifications.js'; import { installNpmDependencies, runPostInstall } from '../utils/npm.js'; import Handlebars, { buildHandlebarsTemplate, buildHandlebarsTemplateWithPath } from '../utils/handlebars.js'; import { isPackageInstalled } from '../utils/is-plg-installed.js'; import { findTSDIAPIServerProject } from '../utils/app-finder.js'; import { getPluginMetadata, getPluginMetaDataFromPath } from '../utils/plg-metadata.js'; import { checkPrismaExist } from "../utils/check-prisma-exists.js"; import { applyPrismaScripts } from "../utils/apply-prisma-scripts.js"; import { updateAllEnvFilesWithVariable } from "../utils/env.js"; import { addAppConfigParams } from "../utils/app.config.js"; export async function generate(pluginName, fileName, generatorName, toFeature) { try { const args = { name: fileName }; if (args.name) { const validName = /^(?!.*\.\.)([a-zA-Z0-9-_\/]+)$/; if (!validName.test(args.name)) { return console.log(chalk.red(`Invalid name: ${args.name}! Name must be a valid directory path containing only letters, numbers, hyphens, underscores, and slashes.`)); } } if (pluginName === 'feature') { if (!fileName) { console.log(chalk.bgRed.white.bold("⚠️ ERROR ") + chalk.red(` Feature name is required!\n\n`) + chalk.yellow(`Usage: tsdiapi generate feature <name>\n`) + chalk.cyan(`Example: tsdiapi generate feature user`)); process.exit(1); } return generateFeature(fileName); } const currentDirectory = await findTSDIAPIServerProject(); if (!currentDirectory) { return console.log(chalk.red(`Not found package.json or maybe you are not using @tsdiapi/server!`)); } if ((pluginName === 'module' || pluginName === 'service')) { if (!fileName) { console.log(chalk.bgRed.white.bold("⚠️ ERROR ") + chalk.red(` Generator name is required!\n\n`) + chalk.yellow(`Usage: tsdiapi generate ${pluginName} <name>\n`) + chalk.cyan(`Example: tsdiapi generate ${pluginName} user`)); process.exit(1); } const output = toFeature ? path.join(currentDirectory, 'src/api/features', toFeature) : fileName; return safeGenerate(pluginName, output, toFeature ? fileName : undefined); } const pluginNameIsPath = pluginName.includes('/') || pluginName.includes('.'); const localPackagePath = pluginNameIsPath ? pluginName : null; let packageName = !pluginNameIsPath ? getPackageName(pluginName) : null; let config = null; if (pluginNameIsPath) { config = await getPluginMetaDataFromPath(pluginName); if (!config) { return console.log(chalk.red(`Plugin ${pluginName} is not installed!`)); } if (!config.name) { return console.log(chalk.red(`Plugin name is not defined in the plugin configuration!`)); } packageName = config.name; } if (!packageName) { return console.log(chalk.red(`Plugin ${pluginName} is not installed!`)); } if (!pluginNameIsPath) { const isInstalled = isPackageInstalled(currentDirectory, packageName); if (!isInstalled) { return console.log(chalk.red(`Plugin ${pluginName} is not installed!`)); } } if (!config) { config = await getPluginMetadata(currentDirectory, packageName); } if (!config) { return console.log(chalk.red(`Plugin ${pluginName} is not installed!`)); } const generators = config?.generators || []; if (!generators.length) { return console.log(chalk.red(`Plugin ${pluginName} does not have any generators!`)); } const generatorByName = generatorName ? generators.find((g) => g.name === generatorName) : null; if (generatorName && !generatorByName) { if (generators?.length > 1) { return console.log(chalk.red(`Generator ${generatorName} not found in plugin ${pluginName}!`)); } else { const selectedGeneratorName = generators[0].name; console.log(chalk.yellow(`Generator ${generatorName} not found in plugin ${pluginName}! Using ${selectedGeneratorName} instead.`)); } } let currentGenerator = generatorByName; if (!generatorByName && generators?.length > 1) { const generatorNames = generators.map(g => g.name); try { const answer = await inquirer.prompt([ { type: 'list', name: 'generator', message: 'Select a generator:', choices: generatorNames } ]); currentGenerator = generators.find(g => g.name === answer.generator); } catch (e) { console.log(chalk.red('Operation canceled!')); return; } } else if (!generatorByName && generators?.length === 1) { currentGenerator = generators[0]; } if (!currentGenerator) { return console.log(chalk.red(`Generator ${generatorName} not found in plugin ${pluginName}!`)); } if (currentGenerator?.preMessages && currentGenerator.preMessages.length) { for (const message of currentGenerator.preMessages) { try { const msg = Handlebars.compile(message)(args); console.log(chalk.gray(`- ${msg}`)); } catch (error) { console.error(chalk.red(`❌ Handlebars error: ${error.message}`)); } } } // Check prisma required scripts if (currentGenerator?.prismaScripts?.length) { const prismaExists = await checkPrismaExist(currentDirectory); console.log(chalk.yellowBright(`⚠️ The generator ${pluginName} requires Prisma and will extend it by executing the following scripts:`)); const prismaScripts = currentGenerator.prismaScripts; for (const script of prismaScripts) { console.log(chalk.yellow(`- ${script.description}`)); } console.log(chalk.blue(`Checking required prisma dependencies for plugin ${pluginName}...`)); for (const message of prismaExists?.results) { console.log(`- ${message}`); } if (!prismaExists.prismaExist) { console.log(chalk.red(`Prisma is required for generator ${currentGenerator.name}!`)); return console.log(chalk.red(`Please install Prisma via ${chalk.cyan('tsdiapi plugins add @tsdiapi/prisma')} or manually and run ${chalk.cyan('prisma init')}`)); } } const dependencies = currentGenerator?.dependencies || []; if (dependencies?.length) { console.log(chalk.blue(`Checking required packages for generator ${currentGenerator.name}...`)); } try { if (dependencies?.length) { const toInstall = dependencies; await installNpmDependencies(currentDirectory, toInstall); } } catch (error) { console.error(chalk.red(`❌ Error installing required packages: ${error.message}`)); } if (currentGenerator.requiredPackages?.length) { console.log(chalk.blue(`Checking required packages for generator ${currentGenerator.name}...`)); for (const packageName of currentGenerator.requiredPackages) { const isInstalled = isPackageInstalled(currentDirectory, packageName); if (!isInstalled) { return console.log(chalk.red(`Plugin ${packageName} is required for generator ${currentGenerator.name}!`)); } else { console.log(chalk.green(`✅ Required plugin ${packageName} is present in the project!`)); } } } if (currentGenerator?.requiredPaths?.length) { console.log(chalk.blue(`Checking required paths for generator ${currentGenerator.name}...`)); for (const requiredPath of currentGenerator.requiredPaths) { if (!isValidRequiredPath(requiredPath)) { console.log(chalk.red(`Invalid required path: ${requiredPath}!`)); console.log(chalk.red(`Invalid required path: ${requiredPath}! Path should be relative to the root of the project and point to a specific file. Please check your plugin configuration.`)); return; } const fullPath = path.join(currentDirectory, requiredPath); if (!fs.existsSync(fullPath)) { console.log(chalk.bgYellow.white.bold(" ⚠️ DENIED ") + chalk.red(` Required path not found: ${requiredPath}! This file is required to start the generation process.`)); return; } } } if (currentGenerator.description) { console.log(chalk.green(`Selected generator: ${currentGenerator.name} - ${currentGenerator.description}`)); } else { console.log(chalk.green(`Selected generator: ${currentGenerator.name}`)); } let defaultObj = args || {}; const plugArgs = currentGenerator?.args || []; const plugFiles = currentGenerator?.files || []; if (!plugFiles.length) { return console.log(chalk.red(`Generator ${currentGenerator.name} does not have any files!`)); } const isConfigurable = plugArgs.length > 0; const nameIsUndefined = !defaultObj.name; if (isConfigurable || nameIsUndefined) { const questions = plugArgs.filter(a => a.inquirer && a.name !== 'name').map((arg) => { const defaultValue = args?.[arg.name] || arg.inquirer?.default || ""; return { ...arg.inquirer, name: arg.name, default: defaultValue, message: arg?.inquirer?.message || arg.description || '', validate: arg.validate ? validateInput(arg.validate) : undefined, filter: arg.transform ? applyTransform(arg.transform) : undefined, when: convertWhenToFunction(arg.when) }; }); if (nameIsUndefined) { questions.unshift({ type: 'input', name: 'name', message: 'Enter the name:', validate: (value) => { const validName = /^(?!.*\.\.)([a-zA-Z0-9-_\/]+)$/; const pass = validName .test(value); if (pass) { return true; } }, required: true, }); } if (questions?.length) { try { const result = await inquirer.prompt(questions); const params = []; for (const question of questions) { const key = question.name; const value = result[key]; const arg = plugArgs.find(a => a.name === key); if (arg?.saveEnv) { updateAllEnvFilesWithVariable(currentDirectory, key, value); const types = ['string', 'number', 'boolean']; const currentType = arg.type && types.includes(arg.type) ? arg.type : 'string'; params.push({ key, type: currentType || 'string' }); } } if (params.length) { await addAppConfigParams(currentDirectory, params); } if (result) { defaultObj = { ...defaultObj, ...result }; } } catch (e) { console.log(chalk.red('Operation canceled!')); return; } } } if (!defaultObj.name) { console.log(chalk.red('Name is required!')); return; } const name = defaultObj.name; const baseName = path.basename(name); const pascalCaseName = toPascalCase(baseName); const kebabCaseName = toKebabCase(baseName); defaultObj = { ...defaultObj, name, pluginName: config?.name, basename: kebabCaseName, baseName: kebabCaseName, classname: pascalCaseName, className: pascalCaseName, packageName: packageName, packagename: packageName, }; // Add alias values to the default object for (const key in defaultObj) { const sourceArg = plugArgs.find(a => a.name === key); if (sourceArg?.alias && !(sourceArg.alias in defaultObj)) { defaultObj[sourceArg.alias] = defaultObj[key]; } } if (currentGenerator?.prismaScripts?.length) { try { const result = await applyPrismaScripts(currentDirectory, currentGenerator.prismaScripts, defaultObj); if (!result) { console.error(chalk.red(`Some Prisma scripts were not applied. Please check and apply them manually.`)); } } catch (e) { return console.error(chalk.red(`Error applying Prisma scripts: ${e.message}`)); } } const _fileModifications = currentGenerator?.fileModifications || []; try { if (fileModifications.length) { await fileModifications(pluginName, currentDirectory, _fileModifications, defaultObj); } } catch (error) { console.error(`❌ ${currentGenerator.name} generator error: ${error.message}`); } const packagePath = localPackagePath || path.join(currentDirectory, 'node_modules', packageName); await generateFiles(currentGenerator, defaultObj, currentDirectory, plugFiles, packagePath); if (currentGenerator.afterGenerate) { const spinner = ora().start(); try { try { const cond = currentGenerator.afterGenerate?.when ? convertWhenToFunction(currentGenerator.afterGenerate?.when)(defaultObj) : true; if (cond) { spinner.text = chalk.blue(`⚙️ Running after-generate script...`); await runPostInstall(pluginName, currentDirectory, currentGenerator.afterGenerate?.command); spinner.succeed(chalk.green(`Completed after-generate script!`)); } } catch (e) { spinner.fail(chalk.red(`Error running after-generate script: ${e.message}`)); } } catch (error) { spinner.fail(chalk.red(`Error running after-setup script: ${error.message}`)); } } const prismaScripts = currentGenerator.prismaScripts, afterGenerate = currentGenerator?.afterGenerate?.command || ''; if ((prismaScripts?.length) && !afterGenerate.includes('prisma')) { const command = 'npm run prisma:generate'; console.log(chalk.blueBright(`⚙️ Generating Prisma client...`)); await runPostInstall(pluginName, currentDirectory, command); console.log(chalk.green(`✅ Prisma client generated.`)); } if (currentGenerator.postMessages && currentGenerator.postMessages.length) { for (const message of currentGenerator.postMessages) { try { const msg = Handlebars.compile(message)(defaultObj); console.log(chalk.gray(`- ${msg}`)); } catch (error) { console.error(chalk.red(`❌ Handlebars error: ${error.message}`)); } } } } catch (e) { console.error(chalk.red('Error generating plugin:', e)); } } function getRootDirectory(filePath) { if (!filePath.includes('/') && !filePath.includes('\\')) { return null; } const normalizedPath = filePath.replace(/\\/g, '/'); const trimmedPath = normalizedPath.replace(/^\/+/, ''); const segments = trimmedPath.split('/'); return segments[0] || null; } export async function generateFiles(currentGenerator, defaultObj, currentDirectory, plugFiles, packagePath) { try { const filesToGenerate = []; const { packageName } = defaultObj; const cwd = process.cwd(); for (const { source, destination, overwrite = false, isHandlebarsTemplate, isRoot } of plugFiles) { const toCwd = isRoot ? currentDirectory : cwd; const filename = path.basename(defaultObj.name); const hasDirname = getRootDirectory(defaultObj.name); const dirname = hasDirname ? path.dirname(defaultObj.name) : ''; const destinationPrepared = destination.replace(/{{name}}/g, filename); const resolvedDest = path.resolve(toCwd, replacePlaceholdersInPath(destinationPrepared, defaultObj, dirname)); const files = glob.sync(source, { cwd: packagePath }); if (files.length === 0) { continue; } for (const file of files) { const sourceFile = path.resolve(packagePath, file); const fileName = path.basename(file); const targetPath = isDirectoryPath(resolvedDest) ? path.join(resolvedDest, fileName) : resolvedDest; const outputPath = replacePlaceholdersInPath(targetPath, defaultObj, dirname); if (fs.existsSync(outputPath)) { console.log(chalk.yellow(`⚠️ Skipping: File already exists: ${outputPath}`)); continue; } filesToGenerate.push({ sourceFile, outputPath, overwrite, hbsRequired: isHandlebarsTemplate }); } } if (filesToGenerate.length === 0) { //console.log(chalk.red(`❌ No files found to generate!`)); return; } console.log(chalk.blue(`\n🔹 The following files will be generated:\n`)); for (const { outputPath } of filesToGenerate) { console.log(`${chalk.green('🟡 (new)')} ${chalk.gray(outputPath)}`); } console.log('\n'); const { accepted } = await inquirer.prompt([ { type: 'confirm', name: 'accepted', message: chalk.cyan(`${chalk.bgBlue('Do you want')} to generate ${filesToGenerate.length} files?`), default: true, }, ]); if (!accepted) { console.log(chalk.yellow(`❌ Operation cancelled by the user.`)); return; } console.log(chalk.blue(`\n📂 Generating files...\n`)); for (const { sourceFile, outputPath, overwrite, hbsRequired } of filesToGenerate) { if (hbsRequired) { const content = buildHandlebarsTemplateWithPath(sourceFile, defaultObj); await fs.outputFile(outputPath, content || ''); } else { fs.copySync(sourceFile, outputPath, { overwrite: overwrite }); } console.log(chalk.green(`✅ Created: ${outputPath}`)); } console.log(chalk.green(`\n🎉 ${plugFiles.length} files have been successfully generated!\n`)); } catch (error) { console.error(`❌ ${currentGenerator.name} generator error: ${error.message}`); } } export async function generateFeature(name, projectDir, getstarted) { try { console.log(chalk.cyan.bold("\n🚀 Generating New Feature\n")); const currentDirectory = projectDir || await findTSDIAPIServerProject(); if (!currentDirectory) { return console.log(chalk.bgRed.white.bold(" ⚠ ERROR ") + chalk.red(" Not found package.json or maybe you are not using @tsdiapi/server!\n")); } const featurePath = path.join(currentDirectory, "src/api/features", name); if (fs.existsSync(featurePath)) { console.log(chalk.bgRed.white.bold(" ⚠ ERROR ") + chalk.red(` The feature ${chalk.bold(name)} already exists in the project.\n`)); process.exit(1); } console.log(chalk.blue(`📌 Feature will be created at: ${chalk.yellow(featurePath)}\n`)); if (!projectDir) { const { accepted } = await inquirer.prompt([ { type: "confirm", name: "accepted", message: chalk.cyan(`${chalk.bgBlue('Do you want')} to generate a new feature named ${chalk.bold(name)}?`), default: true, }, ]); if (!accepted) { console.log(chalk.yellow("❌ Operation cancelled by the user.\n")); return; } } console.log(chalk.cyan("📂 Creating feature structure...\n")); await fs.mkdirp(featurePath); const service = await generateNewService(name, featurePath, getstarted); const controller = await generateNewModule(name, featurePath, service, getstarted); console.log(chalk.green.bold("\n✅ Feature successfully generated! 🎉\n")); console.log(chalk.yellow("🗂️ Created files:")); const relFeaturePath = path.relative(currentDirectory, featurePath); console.log(` 📂 Feature Path: ${chalk.blue(relFeaturePath)}`); if (service) { const relPath = path.relative(currentDirectory, service.path); console.log(` 📄 Service: ${chalk.green(relPath)}`); } if (controller) { const relPath = path.relative(currentDirectory, controller.path); console.log(` 📄 Controller: ${chalk.green(relPath)}`); } console.log(chalk.green.bold(`🚀 Feature ${chalk.bold(relFeaturePath)} is ready to use!\n`)); } catch (e) { console.error(chalk.bgRed.white.bold(" ❌ ERROR ") + chalk.red(` An error occurred while generating the feature:\n${e.message}\n`)); } } export async function safeGenerate(pluginName, output, fName) { const cwd = path.isAbsolute(output) ? output : resolveTargetDirectory(process.cwd(), output); const name = fName || path.basename(output); try { const { accepted } = await inquirer.prompt([ { type: 'confirm', name: 'accepted', message: chalk.cyan(`Do you want to generate ${chalk.blue.bold(`${name} ${pluginName}`)} in ${cwd}?`), default: true, }, ]); if (!accepted) { console.log(chalk.yellow(`❌ Operation cancelled by the user.`)); return; } if (pluginName === 'module') { await generateNewModule(name, cwd); } else if (pluginName === 'service') { await generateNewService(name, cwd); } } catch (e) { console.error(chalk.red('Error generating plugin:', e)); } } export async function generateNewService(name, dir, getstarted) { try { const pascalCase = toPascalCase(name); const kebabCaseName = toKebabCase(name); const filename = `${kebabCaseName}.service.ts`; const className = `${pascalCase}Service`; const targetPath = path.join(dir, filename); if (fs.existsSync(targetPath)) { console.log(chalk.red(`⚠️ The service ${chalk.bold(filename)} already exists in the target directory.`)); return null; } const template = getstarted ? 'generator/service-getstarted' : 'generator/service'; const content = buildHandlebarsTemplate(template, { className: className, }); await fs.outputFile(targetPath, content); console.log(chalk.green(`✅ Service generated at: ${targetPath}`)); return { className, path: targetPath, filename: `${kebabCaseName}.service.js` }; } catch (e) { console.error(chalk.red('Error generating service:', e)); return null; } } export async function generateNewModule(name, dir, service, getstarted) { try { const kebabCaseName = toKebabCase(name); const filename = `${kebabCaseName}.module.ts`; const pascalCase = toPascalCase(name); const className = `${pascalCase}Module`; const targetPath = path.join(dir, filename); if (fs.existsSync(targetPath)) { console.log(chalk.red(`⚠️ The service ${chalk.bold(filename)} already exists in the target directory.`)); return null; } const template = getstarted ? 'generator/module-getstarted' : 'generator/module'; const content = buildHandlebarsTemplate(template, { className, serviceClassName: service?.className, serviceFilename: service?.filename, name: toLowerCase(name) }); await fs.outputFile(targetPath, content); console.log(chalk.green(`✅ Service generated at: ${targetPath}`)); return { className, path: targetPath, filename: `${kebabCaseName}.module.js` }; } catch (e) { console.error(chalk.red('Error generating service:', e)); return null; } } //# sourceMappingURL=generate.js.map