@every-env/cli
Version:
Multi-agent orchestrator for AI-powered development workflows
226 lines • 10.3 kB
JavaScript
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