@interopio/desktop-cli
Version:
CLI tool for setting up, building and packaging io.Connect Desktop projects
306 lines • 13.5 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"));
/**
* 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-cli 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);
// Process feature-specific files if features are selected
if (options.features && options.features.length > 0) {
await this.processFeatureFiles(options.targetDirectory, variables, options.features);
}
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'
]
});
}
}
/**
* 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.features || []),
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 processFeatureFiles(targetPath, variables, features) {
for (const feature of features) {
const featureTemplatePath = (0, node_path_1.join)(this.templatesPath, feature);
if (!(0, node_fs_1.existsSync)(featureTemplatePath)) {
this.logger.warn(`Feature template '${feature}' not found in templates directory`);
continue;
}
// Verify it's a feature template
const templateInfo = this.getTemplateInfo(feature);
if (templateInfo.type !== 'feature') {
this.logger.warn(`Template '${feature}' is not a feature template, skipping`);
continue;
}
this.logger.debug(`Processing feature template: ${feature}`);
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 feature templates with their metadata
*/
getAvailableFeatureTemplates() {
const allTemplates = this.getAvailableTemplates();
const featureTemplates = [];
for (const templateName of allTemplates) {
const templateInfo = this.getTemplateInfo(templateName);
if (templateInfo.type === 'feature') {
featureTemplates.push(templateInfo);
}
}
return featureTemplates;
}
}
exports.TemplateService = TemplateService;
//# sourceMappingURL=template.service.js.map