UNPKG

@every-env/cli

Version:

Multi-agent orchestrator for AI-powered development workflows

226 lines 10.3 kB
import { Liquid } from 'liquidjs'; import { join, normalize, isAbsolute, dirname } from 'path'; import { readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { logger } from '../utils/logger.js'; export class LiquidEngine { baseDir; liquid; templateCache = new Map(); searchPaths; MAX_CACHE_SIZE = 100; constructor(baseDir = process.cwd()) { this.baseDir = baseDir; // Find the every-env package directory const packageDir = this.findPackageDir(); logger.debug(`LiquidEngine constructor - baseDir: ${baseDir}`); logger.debug(`LiquidEngine constructor - packageDir: ${packageDir}`); this.searchPaths = [ // New primary location for patterns and workflows join(baseDir, '.claude', 'commands', 'patterns'), join(baseDir, '.claude', 'commands', 'workflows'), // Legacy locations for backward compatibility join(baseDir, '.every-env', 'prompts'), join(baseDir, 'prompts'), baseDir ]; // If running from installed package, add package paths if (packageDir && packageDir !== baseDir) { this.searchPaths.push(join(packageDir, '.claude', 'commands', 'patterns'), join(packageDir, '.claude', 'commands', 'workflows'), join(packageDir, '.every-env', 'prompts'), join(packageDir, 'prompts')); } logger.debug(`LiquidEngine search paths: ${JSON.stringify(this.searchPaths, null, 2)}`); this.liquid = new Liquid({ root: this.searchPaths, extname: '.md', cache: process.env.NODE_ENV === 'production', }); this.registerCustomFilters(); } findPackageDir() { try { // Get the directory of this file when running from compiled dist const currentFileUrl = import.meta.url; const currentFilePath = fileURLToPath(currentFileUrl); logger.debug(`Current file path: ${currentFilePath}`); // Try multiple strategies to find the package root const possibleRoots = []; // IMPORTANT: Check the actual file path first // If we're running from a global install, use that path if (currentFilePath.includes('/node_modules/@every-env/cli/')) { // Extract the package root from the current file path const match = currentFilePath.match(/^(.*\/node_modules\/@every-env\/cli)\//); if (match) { possibleRoots.push(match[1]); logger.debug(`Detected global/node_modules install at: ${match[1]}`); } } // If running from dist/templates/liquid-engine.js if (currentFilePath.includes('/dist/templates/')) { // Go up from dist/templates to package root const packageRoot = dirname(dirname(dirname(currentFilePath))); // Only add if it's not already in possibleRoots if (!possibleRoots.includes(packageRoot)) { possibleRoots.push(packageRoot); } } // If running from src/templates/liquid-engine.ts (dev mode) if (currentFilePath.includes('/src/templates/')) { // Go up from src/templates to package root const packageRoot = dirname(dirname(dirname(currentFilePath))); if (!possibleRoots.includes(packageRoot)) { possibleRoots.push(packageRoot); } } // Also check common global node_modules paths as fallback const nodeModulePaths = [ '/usr/local/lib/node_modules/@every-env/cli', '/usr/lib/node_modules/@every-env/cli', join(process.env.HOME || '', '.npm-global/lib/node_modules/@every-env/cli'), join(process.env.HOME || '', '.nvm/versions/node', process.version, 'lib/node_modules/@every-env/cli') ]; // Only add paths that aren't already in possibleRoots for (const path of nodeModulePaths) { if (!possibleRoots.includes(path)) { possibleRoots.push(path); } } logger.debug(`Possible package roots: ${JSON.stringify(possibleRoots, null, 2)}`); for (const root of possibleRoots) { const planPath = join(root, '.claude', 'commands', 'workflows', 'plan.md'); logger.debug(`Checking for plan.md at: ${planPath}`); if (existsSync(planPath)) { logger.debug(`Found package directory at: ${root}`); return root; } } logger.debug('Could not find package directory with plan.md template'); } catch (error) { logger.debug('Failed to find package directory:', error); } return null; } async renderFile(templatePath, variables) { try { // Load template let template = await this.loadTemplate(templatePath); // Preprocess template to convert #$VARIABLE syntax to {{ VARIABLE }} template = this.preprocessTemplate(template); // Render with variables const rendered = await this.liquid.parseAndRender(template, variables); return rendered; } catch (error) { logger.error(`Failed to render template ${templatePath}:`, error); throw new Error(`Template rendering failed: ${error.message}`); } } preprocessTemplate(template) { // Convert #$VARIABLE syntax to {{ VARIABLE }} for Liquid compatibility // This supports the legacy #$VAR syntax used in some templates return template.replace(/#\$([A-Z_]+)/g, '{{ $1 }}'); } validatePath(templatePath) { const normalizedPath = normalize(templatePath); // Check for path traversal attempts if (normalizedPath.includes('..')) { throw new Error(`Invalid template path: ${templatePath} - path traversal detected`); } // If absolute path, ensure it's within allowed directories if (isAbsolute(normalizedPath)) { const isInAllowedPath = this.searchPaths.some(searchPath => normalizedPath.startsWith(normalize(searchPath))); if (!isInAllowedPath) { throw new Error(`Invalid template path: ${templatePath} - absolute paths must be within allowed directories`); } } } async loadTemplate(path) { // Check cache first if (this.templateCache.has(path)) { return this.templateCache.get(path); } // If it's an absolute path, validate and load directly if (isAbsolute(path)) { this.validatePath(path); try { const content = await readFile(path, 'utf-8'); // Evict oldest entry if cache is full if (this.templateCache.size >= this.MAX_CACHE_SIZE) { const firstKey = this.templateCache.keys().next().value; if (firstKey) { this.templateCache.delete(firstKey); logger.debug(`Template cache full, evicted: ${firstKey}`); } } this.templateCache.set(path, content); return content; } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Template not found: ${path}`); } throw error; } } // For relative paths, search through all search paths for (const searchPath of this.searchPaths) { const fullPath = join(searchPath, path); try { const content = await readFile(fullPath, 'utf-8'); // Cache the content if (this.templateCache.size >= this.MAX_CACHE_SIZE) { const firstKey = this.templateCache.keys().next().value; if (firstKey) { this.templateCache.delete(firstKey); logger.debug(`Template cache full, evicted: ${firstKey}`); } } this.templateCache.set(path, content); logger.debug(`Loaded template from: ${fullPath}`); return content; } catch (error) { // Continue searching in next path if (error.code !== 'ENOENT') { logger.debug(`Error loading from ${fullPath}:`, error.message); } } } // If we get here, template was not found in any search path throw new Error(`Template not found: ${path}. Searched in: ${this.searchPaths.join(', ')}`); } registerCustomFilters() { // Join array with separator this.liquid.registerFilter('join', (arr, sep = ', ') => { return Array.isArray(arr) ? arr.join(sep) : ''; }); // Capitalize string this.liquid.registerFilter('capitalize', (str) => { return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; }); // Relative path this.liquid.registerFilter('relative_path', (path) => { return path.replace(this.baseDir + '/', ''); }); // Format date this.liquid.registerFilter('date', (date) => { // Simple date formatting const d = new Date(date); return d.toLocaleDateString(); }); // Round number this.liquid.registerFilter('round', (num, decimals = 0) => { return Number(num.toFixed(decimals)); }); // Get size of array/object this.liquid.registerFilter('size', (obj) => { if (Array.isArray(obj)) return obj.length; if (typeof obj === 'object') return Object.keys(obj).length; return 0; }); } } //# sourceMappingURL=liquid-engine.js.map