@interopio/iocd-cli
Version:
CLI tool for setting up, building and packaging io.Connect Desktop platforms
354 lines • 16.1 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TemplateService = void 0;
const node_path_1 = require("node:path");
const node_fs_1 = require("node:fs");
const logger_1 = require("../utils/logger");
const error_handler_1 = require("../utils/error.handler");
const fs_extra_1 = __importDefault(require("fs-extra"));
const config_service_1 = require("./config/config.service");
const path_1 = require("../utils/path");
/**
* TemplateService handles the generation of io.Connect Desktop projects using a modular template system.
*
* This service supports two types of templates:
* - Base templates: Core project structure and essential files (e.g., ioconnect-desktop)
* - Feature templates: Optional functionality modules that extend the base template
*
* The service processes templates by:
* 1. Copying base template files to the target directory
* 2. Applying variable substitutions ({{variable}} patterns)
* 3. Conditionally applying feature templates based on user selection:
* - Validates that requested features exist as separate template directories
* - Only processes features that are explicitly selected by the user
* - Each feature template contains its own template.json with type: "feature"
* - Features are independent and can be combined in any combination
* 4. Merging feature-specific files into the project structure:
* - Feature templates overlay their files onto the base project structure
* - Files from features are copied to their corresponding paths in the target
* - Directory structures are preserved (e.g., modifications/iocd/assets/feature/)
* - No conflicts occur since features target different subdirectories
* - Variable substitution is applied to feature files just like base template files
*
* Key capabilities:
* - Template discovery and validation from filesystem
* - Variable interpolation for dynamic content generation
* - Modular feature system for extensible project setup
* - Comprehensive error handling and logging
* - Support for nested directory structures and file copying
*/
class TemplateService {
logger = logger_1.Logger.getInstance();
templatesPath;
constructor() {
this.templatesPath = (0, node_path_1.join)(__dirname, '../templates');
}
/**
* Get the path to embedded templates
*/
getTemplatesPath() {
return this.templatesPath;
}
/**
* List available templates
*/
getAvailableTemplates() {
try {
if (!(0, node_fs_1.existsSync)(this.templatesPath)) {
this.logger.warn('Templates directory does not exist:', this.templatesPath);
return [];
}
return (0, node_fs_1.readdirSync)(this.templatesPath, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
}
catch (error) {
this.logger.error('Failed to list templates:', error);
throw new error_handler_1.CLIError('Failed to list available templates', {
code: error_handler_1.ErrorCode.FILE_SYSTEM_ERROR,
cause: error,
suggestions: [
'Check if the CLI installation is complete',
'Try reinstalling the CLI tool'
]
});
}
}
/**
* Check if a template exists
*/
templateExists(templateName) {
const templatePath = (0, node_path_1.join)(this.templatesPath, templateName);
return (0, node_fs_1.existsSync)(templatePath) && (0, node_fs_1.statSync)(templatePath).isDirectory();
}
/**
* Generate a project from a template
*/
async generateProject(templateName, options) {
this.logger.info(`Generating project from template: ${templateName}`);
this.logger.debug(`Generating project with options:\n`, options);
// Validate template exists
if (!this.templateExists(templateName)) {
throw new error_handler_1.CLIError(`Template '${templateName}' not found`, {
code: error_handler_1.ErrorCode.NOT_FOUND,
suggestions: [
'Check available templates with: iocd templates list',
'Verify the template name is spelled correctly'
]
});
}
// Ensure only base templates can be used for project generation
const templateInfo = this.getTemplateInfo(templateName);
if (templateInfo.type !== 'base') {
throw new error_handler_1.CLIError(`Template '${templateName}' is not a base template`, {
code: error_handler_1.ErrorCode.VALIDATION_ERROR,
suggestions: [
'Use a base template like "ioconnect-desktop"',
'Feature templates are applied automatically through the features option'
]
});
}
// Check if target directory already exists
if ((0, node_fs_1.existsSync)(options.targetDirectory)) {
throw new error_handler_1.CLIError(`Directory '${options.targetDirectory}' already exists`, {
code: error_handler_1.ErrorCode.FILE_SYSTEM_ERROR,
suggestions: [
'Choose a different project name',
'Remove the existing directory',
'Use a different target location'
]
});
}
const templatePath = (0, node_path_1.join)(this.templatesPath, templateName);
try {
// Create target directory
(0, node_fs_1.mkdirSync)(options.targetDirectory, { recursive: true });
this.logger.debug(`Created target directory: ${options.targetDirectory}`);
// Prepare template variables
const variables = this.prepareTemplateVariables(options);
// Copy base template files with variable replacement
await this.copyTemplateFiles(templatePath, options.targetDirectory, variables);
// add components configuration to package.json
await this.updateComponentsConfig(options.targetDirectory, options.initialComponents);
// Process template application files if selected
if (options.templateApplications && options.templateApplications.length > 0) {
await this.processApplicationsTemplates(options.targetDirectory, variables, options.templateApplications);
}
this.logger.info(`Project generated successfully at: ${options.targetDirectory}`);
}
catch (error) {
this.logger.error('Failed to generate project:', error);
throw new error_handler_1.CLIError('Failed to generate project from template', {
code: error_handler_1.ErrorCode.FILE_SYSTEM_ERROR,
cause: error,
suggestions: [
'Check write permissions for the target directory',
'Ensure sufficient disk space is available',
'Try running with --verbose for more details'
]
});
}
}
/** Add a new template application to an already created seed project */
async addTemplateApplication(appName) {
this.logger.info(`Adding template application: ${appName}`);
const config = config_service_1.ConfigManager.config;
// Implementation goes here
const variables = this.prepareTemplateVariables({
productSlug: config.productSlug,
productName: config.productName,
company: config.company,
copyright: config.copyright,
version: config.version,
folderName: config.productSlug,
targetDirectory: path_1.PathUtils.getRootDir(),
templateApplications: [appName],
initialComponents: []
});
await this.processApplicationsTemplates('./', variables, [appName]);
this.logger.info(`Template application ${appName} added successfully`);
}
/** Adds initialComponents to the iocd.cli.config.json -> components.list object in the target directory */
updateComponentsConfig(targetDirectory, initialComponents) {
const configPath = (0, node_path_1.join)(targetDirectory, 'iocd.cli.config.json');
this.logger.debug(`Updating components configuration in ${configPath} to include:`, initialComponents);
if (!(0, node_fs_1.existsSync)(configPath)) {
this.logger.warn(`iocd.cli.config.json not found in target directory, skipping components configuration`);
return;
}
const configContent = (0, node_fs_1.readFileSync)(configPath, 'utf-8');
const config = JSON.parse(configContent);
// Ensure the components structure exists
if (!config.components) {
config.components = {};
}
if (!config.components.list) {
config.components.list = {};
}
// Add initial components, avoiding duplicates
for (const component of initialComponents) {
config.components.list[component] = "latest";
}
// write back the updated config
(0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2), 'utf-8');
this.logger.debug(`Updated components configuration in iocd.cli.config.json`);
}
/**
* Prepare template variables for replacement
*/
prepareTemplateVariables(options) {
return [
{
key: '{{PRODUCT_NAME}}',
value: options.productName,
description: 'The product name for the application'
},
{
key: '{{FOLDER_NAME}}',
value: options.folderName,
description: 'The folder name for the project'
},
{
key: '{{PRODUCT_SLUG}}',
value: this.generatePackageName(options.productSlug),
description: 'NPM-compatible package name'
},
{
key: '{{FEATURES}}',
value: JSON.stringify(options.templateApplications || []),
description: 'Selected features array'
},
{
key: '{{YEAR}}',
value: new Date().getFullYear().toString(),
description: 'Current year'
}
];
}
/**
* Generate NPM-compatible package name from product name
*/
generatePackageName(productName) {
return productName
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Recursively copy template files with variable replacement
*/
async copyTemplateFiles(sourcePath, targetPath, variables) {
const items = (0, node_fs_1.readdirSync)(sourcePath, { withFileTypes: true });
for (const item of items) {
const sourceItemPath = (0, node_path_1.join)(sourcePath, item.name);
const targetItemPath = (0, node_path_1.join)(targetPath, item.name);
// Skip template.json files - they are metadata for the template system
if (item.isFile() && item.name === 'template.json') {
continue;
}
if (item.isDirectory()) {
// Create directory and recurse
(0, node_fs_1.mkdirSync)(targetItemPath, { recursive: true });
await this.copyTemplateFiles(sourceItemPath, targetItemPath, variables);
}
else if (item.isFile()) {
// the logic here is to check the extension and only replace known text-based extensions
// the rest should be copied as-is (e.g. images, binaries), otherwise they may get corrupted
// the check is a bit complicated because of the .json.merge and .json.merge-<<suffix>> files
const ext = (0, node_path_1.extname)(sourceItemPath);
const allowedExtensions = ['.env', '.json', '.merge'];
const isAllowedExtension = allowedExtensions.some(e => ext.startsWith(e) || ext === e);
if (ext && isAllowedExtension) {
await this.copyFileWithReplacement(sourceItemPath, targetItemPath, variables);
}
else {
await this.copyFileNoReplacement(sourceItemPath, targetItemPath);
}
}
}
}
/**
* Copy a single file with variable replacement
*/
async copyFileWithReplacement(sourcePath, targetPath, variables) {
const sourceContent = (0, node_fs_1.readFileSync)(sourcePath, 'utf-8');
// Replace template variables
let targetContent = sourceContent;
for (const variable of variables) {
const regex = new RegExp(this.escapeRegExp(variable.key), 'g');
targetContent = targetContent.replace(regex, variable.value);
}
(0, node_fs_1.writeFileSync)(targetPath, targetContent, 'utf-8');
this.logger.debug(`Copied and processed: ${(0, node_path_1.relative)(process.cwd(), targetPath)}`);
}
copyFileNoReplacement(source, destination) {
fs_extra_1.default.copyFile(source, destination);
}
/**
* Process feature-specific templates
*/
async processApplicationsTemplates(targetPath, variables, applications) {
for (const application of applications) {
const featureTemplatePath = (0, node_path_1.join)(this.templatesPath, application);
if (!(0, node_fs_1.existsSync)(featureTemplatePath)) {
this.logger.warn(`Application template '${application}' not found in templates directory`);
continue;
}
// Verify it's a application template
const templateInfo = this.getTemplateInfo(application);
if (templateInfo.type !== 'application') {
this.logger.warn(`Template '${application}' is not a application template, skipping`);
continue;
}
this.logger.debug(`Processing application template: ${application}`);
await this.copyTemplateFiles(featureTemplatePath, targetPath, variables);
}
}
/**
* Escape special regex characters
*/
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Get template information
*/
getTemplateInfo(templateName) {
const templatePath = (0, node_path_1.join)(this.templatesPath, templateName);
const templateInfoPath = (0, node_path_1.join)(templatePath, 'template.json');
if ((0, node_fs_1.existsSync)(templateInfoPath)) {
try {
return JSON.parse((0, node_fs_1.readFileSync)(templateInfoPath, 'utf-8'));
}
catch (error) {
this.logger.warn(`Failed to parse template.json for ${templateName}:`, error);
}
}
return {
name: templateName,
description: `${templateName} template`,
version: '1.0.0',
type: 'base' // Default fallback
};
}
/**
* Get available application templates with their metadata
*/
getAvailableApplicationTemplates() {
const allTemplates = this.getAvailableTemplates();
const applicationTemplates = [];
for (const templateName of allTemplates) {
const templateInfo = this.getTemplateInfo(templateName);
if (templateInfo.type === 'application') {
applicationTemplates.push(templateInfo);
}
}
return applicationTemplates;
}
}
exports.TemplateService = TemplateService;
//# sourceMappingURL=template.service.js.map
;