// v2.8.0: Workflow file discovery — walks project-local and global workflow // directories to find runnable scripts. Built-in workflows from the catalog // are merged into the results (they take precedence over user-defined files). // All functions exported for testing. import { readdirSync, existsSync } from 'node:fs'; import { join, basename, extname } from 'node:path'; import { homedir } from 'node:os'; import { getBuiltinWorkflows, getBuiltinWorkflow } from './catalog.js'; /** * Sentinel prefix used in `sourceFile` for built-in workflows from the * catalog so callers (e.g. WorkflowManager) can detect and handle them * by calling `generateScript()` instead of reading a file from disk. */ const BUILTIN_PREFIX = 'builtin:'; /** * Metadata about a discovered workflow file (or built-in workflow). */ export interface WorkflowMeta { /** Workflow name (file stem without .js extension). */ name: string; /** Description loaded from the workflow module's `meta.description`. * Empty string until loadWorkflowMeta() resolves it. */ description: string; /** Absolute path to the .js file. * For built-in workflows this is `'builtin:'` — the caller * should use `getBuiltinWorkflow(name)` and `generateScript()` * instead of reading this path from disk. */ sourceFile: string; } /** * Test whether a `WorkflowMeta.sourceFile` points to a built-in workflow * (rather than a file on disk). * * @param meta - The workflow metadata to check. */ export function isBuiltinWorkflow(meta: WorkflowMeta): boolean { return meta.sourceFile.startsWith(BUILTIN_PREFIX); } /** * Find all workflow .js files in the standard search paths, merged with * built-in workflows from the catalog. * * Priority order (first match wins for same-named workflows): * 1. Built-in catalog (always takes precedence) * 2. /.boocode/workflows/ (project-local) * 3. ~/.boocode/workflows/ (global, per-user) * * @param projectRoot - Absolute path to the current project root. */ export function discoverWorkflows(projectRoot: string): WorkflowMeta[] { const seen = new Set(); const results: WorkflowMeta[] = []; // 1. Built-in workflows (highest priority) for (const builtin of getBuiltinWorkflows()) { seen.add(builtin.name); results.push({ name: builtin.name, description: builtin.description, sourceFile: `${BUILTIN_PREFIX}${builtin.name}`, }); } // 2. Project-local + global file-based workflows const dirs = [ join(projectRoot, '.boocode', 'workflows'), join(homedir(), '.boocode', 'workflows'), ]; for (const dir of dirs) { if (!existsSync(dir)) continue; try { const entries = readdirSync(dir); for (const f of entries) { if (!f.endsWith('.js')) continue; const name = basename(f, '.js'); if (seen.has(name)) continue; // built-in shadows project-local, // project-local shadows global seen.add(name); results.push({ name, description: '', sourceFile: join(dir, f), }); } } catch { // Permission error on directory — skip silently continue; } } return results; } /** * Find a single workflow by name across built-in catalog and search paths. * * Priority: built-in > project-local > global. * * @param name - Workflow name (without .js extension). * @param projectRoot - Absolute path to the current project root. */ export function findWorkflow( name: string, projectRoot: string, ): WorkflowMeta | undefined { // Check built-in catalog first const builtin = getBuiltinWorkflow(name); if (builtin) { return { name: builtin.name, description: builtin.description, sourceFile: `${BUILTIN_PREFIX}${builtin.name}`, }; } // Fall back to file-based discovery return discoverWorkflows(projectRoot).find((w) => w.name === name); } /** * Validate a candidate workflow file path. * Checks that the file exists and has a .js extension. * * @param filePath - Absolute path to check. */ export function isValidWorkflowPath(filePath: string): boolean { return extname(filePath) === '.js' && existsSync(filePath); }