UNPKG

@mariozechner/create-app

Version:

Project scaffolding for TypeScript applications and libraries

342 lines 14.4 kB
import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import chalk from "chalk"; import { isText } from "istextorbinary"; import * as yaml from "js-yaml"; import prompts from "prompts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); function replaceTemplateVars(content, config) { let result = content; for (const [key, value] of Object.entries(config)) { const regex = new RegExp(`{{${key}}}`, "g"); result = result.replace(regex, String(value)); } return result; } export function discoverTemplates(templatesDir) { const templates = []; const templateDirs = fs .readdirSync(templatesDir, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .map((dirent) => dirent.name); for (const templateDir of templateDirs) { const templateJsonPath = path.join(templatesDir, templateDir, "template.json"); if (fs.existsSync(templateJsonPath)) { const templateJson = JSON.parse(fs.readFileSync(templateJsonPath, "utf8")); templates.push({ folderName: templateDir, ...templateJson, }); } } return templates; } function isTextFile(filePath) { const buffer = fs.readFileSync(filePath); const result = isText(filePath, buffer); return result === true; } function copyTemplateFiles(templateDir, parentTemplateDirs, destPath, config) { // First, copy all parent templates in order for (const parentDir of parentTemplateDirs) { copyTemplateFilesRecursive(parentDir, destPath, config); } // Then copy current template, overwriting any existing files copyTemplateFilesRecursive(templateDir, destPath, config); // Finally, handle deletions (files with _ prefix) handleDeletions(templateDir, destPath); } function copyTemplateFilesRecursive(templateDir, destPath, config) { function copyRecursive(srcDir, destDir, relativePath = "") { const items = fs.readdirSync(srcDir, { withFileTypes: true }); for (const item of items) { const srcPath = path.join(srcDir, item.name); const destFilePath = path.join(destDir, item.name); const itemRelativePath = path.join(relativePath, item.name); // Skip template.json if (item.name === "template.json") { continue; } // Skip deletion markers (files/dirs starting with -) if (item.name.startsWith("-")) { continue; } // Handle merge markers (files starting with +) if (item.name.startsWith("+")) { const targetName = item.name.slice(1); // Remove + prefix const targetPath = path.join(destDir, targetName); if (fs.existsSync(targetPath)) { mergeFile(srcPath, targetPath, config); continue; } else { // Target doesn't exist, treat as regular copy but without + prefix const newDestPath = path.join(destDir, targetName); const destFileDir = path.dirname(newDestPath); if (!fs.existsSync(destFileDir)) { fs.mkdirSync(destFileDir, { recursive: true }); } const srcStats = fs.statSync(srcPath); if (isTextFile(srcPath)) { let content = fs.readFileSync(srcPath, "utf8"); content = replaceTemplateVars(content, config); fs.writeFileSync(newDestPath, content); } else { fs.copyFileSync(srcPath, newDestPath); } fs.chmodSync(newDestPath, srcStats.mode); continue; } } if (item.isDirectory()) { if (!fs.existsSync(destFilePath)) { fs.mkdirSync(destFilePath, { recursive: true }); } copyRecursive(srcPath, destFilePath, itemRelativePath); } else { // Create directory if it doesn't exist const destFileDir = path.dirname(destFilePath); if (!fs.existsSync(destFileDir)) { fs.mkdirSync(destFileDir, { recursive: true }); } // Get source file permissions const srcStats = fs.statSync(srcPath); if (isTextFile(srcPath)) { // Text file - apply template substitution let content = fs.readFileSync(srcPath, "utf8"); content = replaceTemplateVars(content, config); fs.writeFileSync(destFilePath, content); } else { // Binary file - copy as-is fs.copyFileSync(srcPath, destFilePath); } // Always preserve file permissions from template fs.chmodSync(destFilePath, srcStats.mode); } } } copyRecursive(templateDir, destPath); } function mergeFile(srcPath, targetPath, config) { const ext = path.extname(srcPath).toLowerCase(); try { if (ext === ".json") { const srcContent = fs.readFileSync(srcPath, "utf8"); const processedSrc = replaceTemplateVars(srcContent, config); const srcObj = JSON.parse(processedSrc); const targetObj = JSON.parse(fs.readFileSync(targetPath, "utf8")); const merged = deepMerge(targetObj, srcObj); fs.writeFileSync(targetPath, JSON.stringify(merged, null, 2)); } else if (ext === ".yml" || ext === ".yaml") { const srcContent = fs.readFileSync(srcPath, "utf8"); const processedSrc = replaceTemplateVars(srcContent, config); const srcObj = yaml.load(processedSrc); const targetContent = fs.readFileSync(targetPath, "utf8"); const targetObj = yaml.load(targetContent); const merged = deepMerge(targetObj, srcObj); fs.writeFileSync(targetPath, yaml.dump(merged, { indent: 2, lineWidth: -1, noRefs: true, })); } else { console.warn(`Don't know how to merge file type: ${srcPath}`); } } catch (error) { console.error(`Error merging ${srcPath} into ${targetPath}:`, error); } } function deepMerge(target, source) { if (Array.isArray(target) && Array.isArray(source)) { return [...target, ...source]; } if (target && typeof target === "object" && source && typeof source === "object") { const targetObj = target; const sourceObj = source; const result = { ...targetObj }; for (const key in sourceObj) { if (Object.hasOwn(sourceObj, key)) { if (key in result) { result[key] = deepMerge(result[key], sourceObj[key]); } else { result[key] = sourceObj[key]; } } } return result; } return source; } function handleDeletions(templateDir, destPath) { function processDirectory(srcDir, destDir) { if (!fs.existsSync(srcDir)) return; const items = fs.readdirSync(srcDir, { withFileTypes: true }); for (const item of items) { if (item.name.startsWith("-") && item.name !== "-") { // This is a deletion marker const targetName = item.name.slice(1); // Remove - prefix const targetPath = path.join(destDir, targetName); if (fs.existsSync(targetPath)) { const stats = fs.statSync(targetPath); if (stats.isDirectory()) { fs.rmSync(targetPath, { recursive: true, force: true }); } else { fs.unlinkSync(targetPath); } } } else if (item.isDirectory() && !item.name.startsWith("-")) { // Recursively process subdirectories processDirectory(path.join(srcDir, item.name), path.join(destDir, item.name)); } } } processDirectory(templateDir, destPath); } function resolveTemplateInheritance(templates, templateName, visited = new Set()) { if (visited.has(templateName)) { throw new Error(`Circular dependency detected in template inheritance: ${templateName}`); } const template = templates.find((t) => t.folderName === templateName); if (!template) { throw new Error(`Template not found: ${templateName}`); } visited.add(templateName); const parentPaths = []; if (template.inherits) { for (const parentName of template.inherits) { // First get the parent's inheritance chain const parentChain = resolveTemplateInheritance(templates, parentName, new Set(visited)); parentPaths.push(...parentChain); // Then add the parent itself parentPaths.push(parentName); } } visited.delete(templateName); return parentPaths; } export async function createApp(projectName, options) { console.log(chalk.blue(`Creating app: ${projectName}`)); console.log(); // Check if directory already exists if (fs.existsSync(projectName)) { console.error(chalk.red(`Error: Directory ${projectName} already exists`)); process.exit(1); } // Discover templates const templatesDir = path.join(__dirname, "..", "templates"); const templates = discoverTemplates(templatesDir); if (templates.length === 0) { console.error(chalk.red("Error: No templates found")); process.exit(1); } // Select template let selectedTemplateFolderName; if (options?.template) { // Validate provided template const template = templates.find((t) => t.folderName === options.template); if (!template) { console.error(chalk.red(`Error: Template "${options.template}" not found`)); console.log(chalk.dim("Available templates:"), templates.map((t) => t.folderName).join(", ")); process.exit(1); } selectedTemplateFolderName = options.template; console.log(chalk.dim(`Using template: ${template.name}`)); } else { const templateChoice = await prompts({ type: "select", name: "templateFolder", message: "What type of app?", choices: templates.map((t) => ({ title: `${t.name} - ${t.description}`, value: t.folderName, })), }); if (!templateChoice.templateFolder) { console.log(chalk.dim("Cancelled")); process.exit(0); } selectedTemplateFolderName = templateChoice.templateFolder; } const template = templates.find((t) => t.folderName === selectedTemplateFolderName); if (!template) { console.error(chalk.red(`Error: Template "${selectedTemplateFolderName}" not found`)); process.exit(1); } // Start with project name const config = { name: projectName, }; // Add provided config values if (options?.config) { Object.assign(config, options.config); } // Convert template prompts to prompts format and run them if (template.prompts.length > 0) { // Filter out prompts that already have values from CLI args const remainingPrompts = template.prompts.filter((p) => !(p.name in config)); if (remainingPrompts.length > 0) { const promptsConfig = remainingPrompts.map((p) => ({ type: p.type === "bool" ? "confirm" : p.type, name: p.name, message: p.message, initial: p.initial, })); console.log(); const answers = await prompts(promptsConfig); if (Object.keys(answers).length !== remainingPrompts.length) { console.log(chalk.dim("Cancelled")); process.exit(0); } Object.assign(config, answers); } // Fill in any missing values with defaults for (const prompt of template.prompts) { if (!(prompt.name in config) && prompt.initial !== undefined) { config[prompt.name] = prompt.initial; } } } // Create project directory fs.mkdirSync(projectName, { recursive: true }); // Resolve template inheritance chain const parentTemplateNames = resolveTemplateInheritance(templates, template.folderName); const parentTemplateDirs = parentTemplateNames.map((name) => path.join(templatesDir, name)); // Copy template files with inheritance const templateDir = path.join(templatesDir, template.folderName); copyTemplateFiles(templateDir, parentTemplateDirs, projectName, config); console.log(); console.log(chalk.green(`✨ Created ${projectName}`)); console.log(); console.log("Next steps:"); console.log(chalk.dim(` cd ${projectName}`)); // Template-specific next steps if (template.folderName === "web-library" || template.folderName === "node-library") { console.log(chalk.dim(" npm install # Install dependencies")); console.log(chalk.dim(" npm run dev # Development mode")); console.log(chalk.dim(" npm run build # Build for production")); console.log(chalk.dim(" ./publish.sh # Publish to npm")); } else if (template.folderName === "static" || template.folderName === "spa-api") { console.log(chalk.dim(" ./run.sh dev # Local development")); console.log(chalk.dim(" ./run.sh deploy # Deploy to production")); } else { // Fallback for any future templates console.log(chalk.dim(" npm install # Install dependencies")); } console.log(); console.log(chalk.dim("Check README.md for detailed instructions")); } //# sourceMappingURL=index.js.map