cfn-forge
Version:
CloudFormation deployment automation tool with git-based workflows
336 lines (281 loc) • 9.78 kB
JavaScript
/**
* Template Engine for CFN-Forge
*
* Manages project templates, including listing, copying, and customizing
*/
const fs = require('fs').promises;
const path = require('path');
const yaml = require('js-yaml');
const { promisify } = require('util');
const ncp = promisify(require('ncp').ncp);
class TemplateEngine {
constructor() {
this.templatesDir = process.env.CFN_FORGE_TEMPLATES
|| path.join(__dirname, '../../../templates');
const homeDir = process.env.CFN_FORGE_HOME
|| path.join(require('os').homedir(), '.cfn-forge');
this.userTemplatesDir = path.join(homeDir, 'templates');
}
/**
* List all available templates
*/
async listTemplates() {
const templates = [];
// Load built-in templates
const builtInTemplates = await this.loadTemplatesFromDir(this.templatesDir);
templates.push(...builtInTemplates.map((t) => ({ ...t, source: 'built-in' })));
// Load user templates
try {
const userTemplates = await this.loadTemplatesFromDir(this.userTemplatesDir);
templates.push(...userTemplates.map((t) => ({ ...t, source: 'user' })));
} catch {
// User templates directory might not exist
}
// Sort by name
templates.sort((a, b) => a.name.localeCompare(b.name));
return templates;
}
/**
* Load templates from a directory
*/
async loadTemplatesFromDir(dir) {
const templates = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const templatePath = path.join(dir, entry.name);
const metadataPath = path.join(templatePath, 'template.yaml');
try {
const metadata = await this.loadTemplateMetadata(metadataPath);
templates.push({
name: entry.name,
path: templatePath,
...metadata,
});
} catch {
// Template without metadata - use defaults
templates.push({
name: entry.name,
path: templatePath,
description: `${entry.name} template`,
version: '1.0.0',
author: 'unknown',
});
}
}
}
} catch (error) {
console.error(`Failed to load templates from ${dir}: ${error.message}`);
}
return templates;
}
/**
* Load template metadata
*/
async loadTemplateMetadata(metadataPath) {
const content = await fs.readFile(metadataPath, 'utf8');
return yaml.load(content);
}
/**
* Copy template to destination
*/
async copyTemplate(templateName, destination, variables = {}) {
// Find template
const templates = await this.listTemplates();
const template = templates.find((t) => t.name === templateName);
if (!template) {
throw new Error(`Template '${templateName}' not found`);
}
// Copy files
await this.copyTemplateFiles(template.path, destination, variables);
// Run post-install hooks if any
if (template.hooks?.postInstall) {
await this.runPostInstallHooks(template.hooks.postInstall, destination, variables);
}
return template;
}
/**
* Copy template files with variable substitution
*/
async copyTemplateFiles(source, destination, variables) {
const entries = await fs.readdir(source, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(source, entry.name);
const destPath = path.join(destination, entry.name);
// Skip template metadata file
if (entry.name === 'template.yaml') {
continue;
}
if (entry.isDirectory()) {
await fs.mkdir(destPath, { recursive: true });
await this.copyTemplateFiles(sourcePath, destPath, variables);
} else {
await this.copyTemplateFile(sourcePath, destPath, variables);
}
}
}
/**
* Copy a single template file with variable substitution
*/
async copyTemplateFile(source, destination, variables) {
const ext = path.extname(source).toLowerCase();
const textExtensions = ['.yaml', '.yml', '.json', '.js', '.ts', '.md', '.txt', '.sh', '.env'];
if (textExtensions.includes(ext)) {
// Process text files
let content = await fs.readFile(source, 'utf8');
// Replace variables
content = this.replaceVariables(content, variables);
await fs.writeFile(destination, content);
} else {
// Copy binary files as-is
await fs.copyFile(source, destination);
}
// Preserve file permissions
const stats = await fs.stat(source);
await fs.chmod(destination, stats.mode);
}
/**
* Replace template variables
*/
replaceVariables(content, variables) {
// Replace {{variable}} syntax
return content.replace(/\{\{(\w+)\}\}/g, (match, varName) => variables[varName] || match);
}
/**
* Run post-install hooks
*/
async runPostInstallHooks(hooks, destination, variables) {
const { execSync } = require('child_process');
for (const hook of hooks) {
if (typeof hook === 'string') {
// Simple command
const command = this.replaceVariables(hook, variables);
execSync(command, { cwd: destination, stdio: 'inherit' });
} else if (hook.type === 'npm' && hook.command) {
// npm command
execSync(`npm ${hook.command}`, { cwd: destination, stdio: 'inherit' });
} else if (hook.type === 'file' && hook.path) {
// Run file
const filePath = path.join(destination, hook.path);
execSync(`node ${filePath}`, { cwd: destination, stdio: 'inherit' });
}
}
}
/**
* Download template from remote repository
*/
async downloadTemplate(repoUrl, templateName) {
const tempDir = path.join(require('os').tmpdir(), `cfn-forge-template-${Date.now()}`);
const { execSync } = require('child_process');
try {
// Clone repository
execSync(`git clone --depth 1 ${repoUrl} ${tempDir}`, { stdio: 'ignore' });
// Copy to user templates
const destDir = path.join(this.userTemplatesDir, templateName);
await fs.mkdir(this.userTemplatesDir, { recursive: true });
await ncp(tempDir, destDir);
// Clean up
await fs.rm(tempDir, { recursive: true, force: true });
return destDir;
} catch (error) {
// Clean up on error
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {}
throw error;
}
}
/**
* Create a new template from existing project
*/
async createTemplate(projectPath, templateName, metadata = {}) {
const templateDir = path.join(this.userTemplatesDir, templateName);
// Create template directory
await fs.mkdir(templateDir, { recursive: true });
// Copy project files
const excludes = [
'node_modules',
'.git',
'.env',
'dist',
'build',
'.cfn-forge',
'deploy/.env*',
];
await this.copyProjectAsTemplate(projectPath, templateDir, excludes);
// Create template metadata
const templateMetadata = {
name: templateName,
description: metadata.description || `${templateName} template`,
version: metadata.version || '1.0.0',
author: metadata.author || 'unknown',
created: new Date().toISOString(),
cfnForgeVersion: require('../../../package.json').version,
...metadata,
};
const metadataPath = path.join(templateDir, 'template.yaml');
await fs.writeFile(metadataPath, yaml.dump(templateMetadata));
return templateDir;
}
/**
* Copy project as template, excluding certain files
*/
async copyProjectAsTemplate(source, destination, excludes) {
const entries = await fs.readdir(source, { withFileTypes: true });
for (const entry of entries) {
// Skip excluded files/directories
if (excludes.some((exclude) => entry.name.match(exclude))) {
continue;
}
const sourcePath = path.join(source, entry.name);
const destPath = path.join(destination, entry.name);
if (entry.isDirectory()) {
await fs.mkdir(destPath, { recursive: true });
await this.copyProjectAsTemplate(sourcePath, destPath, excludes);
} else {
// Template-ize certain files
if (entry.name === '.cfn-forge.yaml') {
await this.templateizeConfigFile(sourcePath, destPath);
} else {
await fs.copyFile(sourcePath, destPath);
}
}
}
}
/**
* Convert project config to template config
*/
async templateizeConfigFile(source, destination) {
const content = await fs.readFile(source, 'utf8');
const config = yaml.load(content);
// Replace specific values with template variables
if (config.project?.name) {
config.project.name = '{{projectName}}';
}
if (config.project?.description) {
config.project.description = '{{description}}';
}
if (config.environments) {
for (const env of Object.values(config.environments)) {
if (env.stackName) {
env.stackName = env.stackName.replace(
config.project?.name || '',
'{{projectName}}',
);
}
}
}
await fs.writeFile(destination, yaml.dump(config));
}
}
// Create singleton instance
const engine = new TemplateEngine();
// Export functions for backward compatibility
module.exports = {
engine,
listTemplates: () => engine.listTemplates(),
copyTemplate: (name, dest, vars) => engine.copyTemplate(name, dest, vars),
downloadTemplate: (url, name) => engine.downloadTemplate(url, name),
createTemplate: (path, name, meta) => engine.createTemplate(path, name, meta),
};