UNPKG

@vfarcic/dot-ai

Version:

AI-powered development productivity platform that enhances software development workflows through intelligent automation and AI-driven assistance

381 lines (380 loc) 14.7 kB
"use strict"; /** * User Prompts Loader * * Loads user-defined prompts from a git repository. * Supports any git provider (GitHub, GitLab, Gitea, Forgejo, Bitbucket, etc.) * * Environment variables: * - DOT_AI_USER_PROMPTS_REPO: Git repository URL (required to enable) * - DOT_AI_USER_PROMPTS_BRANCH: Branch to use (default: main) * - DOT_AI_USER_PROMPTS_PATH: Subdirectory within repo (default: root) * - DOT_AI_GIT_TOKEN: Authentication token (optional) * - DOT_AI_USER_PROMPTS_CACHE_TTL: Cache TTL in seconds (default: 86400 = 24h) */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.getUserPromptsConfig = getUserPromptsConfig; exports.getCacheDirectory = getCacheDirectory; exports.sanitizeUrlForLogging = sanitizeUrlForLogging; exports.loadUserPrompts = loadUserPrompts; exports.clearUserPromptsCache = clearUserPromptsCache; exports.getUserPromptsCacheState = getUserPromptsCacheState; const fs = __importStar(require("fs")); const path = __importStar(require("path")); const os = __importStar(require("os")); const git_utils_1 = require("./git-utils"); const prompts_1 = require("../tools/prompts"); // In-memory cache state (persists across requests within same process) let cacheState = null; /** * Read user prompts configuration from environment variables * Returns null if DOT_AI_USER_PROMPTS_REPO is not set */ function getUserPromptsConfig() { const repoUrl = process.env.DOT_AI_USER_PROMPTS_REPO; if (!repoUrl) { return null; } // Validate cache TTL - fallback to default if invalid or negative const parsedTtl = parseInt(process.env.DOT_AI_USER_PROMPTS_CACHE_TTL || '86400', 10); const cacheTtlSeconds = Number.isNaN(parsedTtl) || parsedTtl < 0 ? 86400 : parsedTtl; return { repoUrl, branch: process.env.DOT_AI_USER_PROMPTS_BRANCH || 'main', subPath: process.env.DOT_AI_USER_PROMPTS_PATH || '', gitToken: process.env.DOT_AI_GIT_TOKEN, cacheTtlSeconds, }; } /** * Get the cache directory for user prompts * Tries project-relative tmp first, falls back to system temp */ function getCacheDirectory() { // Try project-relative tmp directory first const projectTmp = path.join(process.cwd(), 'tmp', 'user-prompts'); try { // Ensure parent tmp directory exists const parentTmp = path.join(process.cwd(), 'tmp'); if (!fs.existsSync(parentTmp)) { fs.mkdirSync(parentTmp, { recursive: true }); } // Test if we can write to it const testFile = path.join(parentTmp, '.write-test'); fs.writeFileSync(testFile, 'test'); fs.unlinkSync(testFile); return projectTmp; } catch { // Fall back to system temp (works in Docker/K8s) return path.join(os.tmpdir(), 'dot-ai-user-prompts'); } } /** * Sanitize URL for logging (remove credentials) */ function sanitizeUrlForLogging(url) { try { const parsed = new URL(url); if (parsed.username) parsed.username = '***'; if (parsed.password) parsed.password = '***'; return parsed.toString(); } catch { // If URL parsing fails, do basic sanitization return url.replace(/\/\/[^@]+@/, '//***@'); } } /** * Validate git branch name to prevent command injection * Allows alphanumeric characters, hyphens, underscores, slashes, and dots */ function isValidGitBranch(branch) { return /^[a-zA-Z0-9_.\-/]+$/.test(branch); } /** * Clone the user prompts repository */ async function cloneRepository(config, localPath, logger) { // Validate branch name as defense-in-depth if (!isValidGitBranch(config.branch)) { throw new Error(`Invalid branch name: ${config.branch}`); } const sanitizedUrl = sanitizeUrlForLogging(config.repoUrl); logger.info('Cloning user prompts repository', { url: sanitizedUrl, branch: config.branch, localPath, }); try { // Ensure parent directory exists const parentDir = path.dirname(localPath); if (!fs.existsSync(parentDir)) { fs.mkdirSync(parentDir, { recursive: true }); } // Remove existing directory if it exists (clean clone) if (fs.existsSync(localPath)) { fs.rmSync(localPath, { recursive: true, force: true }); } await (0, git_utils_1.cloneRepo)(config.repoUrl, localPath, { branch: config.branch, depth: 1, }); logger.info('Successfully cloned user prompts repository', { url: sanitizedUrl, branch: config.branch, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const sanitizedError = (0, git_utils_1.scrubCredentials)(config.gitToken ? errorMessage.replaceAll(config.gitToken, '***') : errorMessage); logger.error('Failed to clone user prompts repository', new Error(sanitizedError), { url: sanitizedUrl, branch: config.branch, }); throw new Error(`Failed to clone user prompts repository: ${sanitizedError}`, { cause: error }); } } /** * Pull latest changes from the user prompts repository */ async function pullRepository(config, localPath, logger) { const sanitizedUrl = sanitizeUrlForLogging(config.repoUrl); logger.debug('Pulling user prompts repository', { url: sanitizedUrl, localPath, }); try { await (0, git_utils_1.pullRepo)(localPath); logger.debug('Successfully pulled user prompts repository', { url: sanitizedUrl, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const sanitizedError = (0, git_utils_1.scrubCredentials)(config.gitToken ? errorMessage.replaceAll(config.gitToken, '***') : errorMessage); logger.warn('Failed to pull user prompts repository, using cached version', { url: sanitizedUrl, error: sanitizedError, }); // Don't throw - use cached version } } /** * Ensure the repository is cloned and up-to-date * Returns the path to the prompts directory within the repository */ async function ensureRepository(config, logger, forceRefresh = false) { const localPath = getCacheDirectory(); const now = Date.now(); const ttlMs = config.cacheTtlSeconds * 1000; // Check if we need to clone or pull if (!cacheState || !fs.existsSync(cacheState.localPath)) { // First time or cache directory was deleted - clone await cloneRepository(config, localPath, logger); cacheState = { lastPullTime: now, localPath }; } else if (forceRefresh || now - cacheState.lastPullTime >= ttlMs) { // Cache expired or force refresh - pull await pullRepository(config, localPath, logger); cacheState.lastPullTime = now; } else { logger.debug('Using cached user prompts repository', { localPath, cacheAge: Math.round((now - cacheState.lastPullTime) / 1000), ttl: config.cacheTtlSeconds, }); } // Return path to prompts directory (with optional subPath) return config.subPath ? path.join(localPath, config.subPath) : localPath; } const SKILL_FILE_MAX_BYTES = 5 * 1024 * 1024; // 5 MB per file (before base64 encoding) const SKILL_FILENAME = 'SKILL.md'; /** * Recursively collect all files in a skill folder (excluding SKILL.md at root), * returning them as base64-encoded PromptFile objects. */ function collectSkillFiles(dirPath, basePath, logger) { const files = []; const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { if (entry.name.startsWith('.')) continue; const fullPath = path.join(dirPath, entry.name); const relativePath = path.relative(basePath, fullPath); if (entry.isDirectory()) { files.push(...collectSkillFiles(fullPath, basePath, logger)); } else if (entry.isFile()) { if (entry.name === SKILL_FILENAME && dirPath === basePath) continue; const stat = fs.statSync(fullPath); if (stat.size > SKILL_FILE_MAX_BYTES) { logger.warn('Skill file exceeds size limit, skipping', { file: relativePath, size: stat.size, limit: SKILL_FILE_MAX_BYTES, }); continue; } const content = fs.readFileSync(fullPath); files.push({ path: relativePath, content: content.toString('base64'), }); } } return files; } /** * Load a skill folder (directory containing SKILL.md) as a Prompt with supporting files. * Returns null if the directory does not contain SKILL.md. */ function loadSkillFolder(dirPath, dirName, logger) { const skillMdPath = path.join(dirPath, SKILL_FILENAME); if (!fs.existsSync(skillMdPath)) { return null; } const prompt = (0, prompts_1.loadPromptFile)(skillMdPath, 'user', dirName); const supportingFiles = collectSkillFiles(dirPath, dirPath, logger); if (supportingFiles.length > 0) { prompt.files = supportingFiles; } return prompt; } /** * Load user prompts from the configured git repository * Returns empty array if not configured or on error */ async function loadUserPrompts(logger, forceRefresh = false) { const config = getUserPromptsConfig(); if (!config) { logger.debug('User prompts not configured (DOT_AI_USER_PROMPTS_REPO not set)'); return []; } try { const promptsDir = await ensureRepository(config, logger, forceRefresh); if (!fs.existsSync(promptsDir)) { logger.warn('User prompts directory not found in repository', { path: promptsDir, subPath: config.subPath, }); return []; } // Load flat .md files and skill folders from the prompts directory const entries = fs.readdirSync(promptsDir, { withFileTypes: true }); const prompts = []; const loadedNames = new Set(); // 1. Load flat .md files (existing behavior) const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md')); for (const entry of mdFiles) { try { const filePath = path.join(promptsDir, entry.name); const prompt = (0, prompts_1.loadPromptFile)(filePath, 'user'); prompts.push(prompt); loadedNames.add(prompt.name); logger.debug('Loaded user prompt', { name: prompt.name, file: entry.name }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.warn('Failed to load user prompt file, skipping', { file: entry.name, error: errorMessage, }); } } // 2. Load skill folders (directories containing SKILL.md) const directories = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')); for (const dir of directories) { try { const dirPath = path.join(promptsDir, dir.name); const prompt = loadSkillFolder(dirPath, dir.name, logger); if (prompt) { if (loadedNames.has(prompt.name)) { logger.warn('Skill folder name collision with existing prompt, skipping', { name: prompt.name, dir: dir.name, }); continue; } prompts.push(prompt); loadedNames.add(prompt.name); logger.debug('Loaded user skill folder', { name: prompt.name, dir: dir.name, filesCount: prompt.files?.length ?? 0, }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.warn('Failed to load skill folder, skipping', { dir: dir.name, error: errorMessage, }); } } logger.info('Loaded user prompts from repository', { total: prompts.length, url: sanitizeUrlForLogging(config.repoUrl), }); return prompts; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('Failed to load user prompts, falling back to built-in only', new Error(errorMessage)); return []; } } /** * Clear the cache state (useful for testing) */ function clearUserPromptsCache() { cacheState = null; } /** * Get current cache state (for testing/debugging) */ function getUserPromptsCacheState() { return cacheState ? { ...cacheState } : null; }