// Automation script loader — discovers and loads .ts automation files import { existsSync, readdirSync, mkdirSync } from 'fs'; import path from 'path'; import os from 'os'; import { fileURLToPath } from 'url'; import { resolveAutomationGuardrails } from './guardrails.js'; import type { AutomationFactory, AutomationConfig, AutomationGuardrailContext, AutomationGuardrailsExport, LoadedAutomation, } from './types.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const AUTOMATIONS_DIR = path.join(os.homedir(), '.openbroker', 'automations'); const EXAMPLES_DIR = path.join(__dirname, 'examples'); /** Resolve a script path from a name or path */ export function resolveScriptPath(nameOrPath: string): string { // Absolute path if (path.isAbsolute(nameOrPath)) { if (!existsSync(nameOrPath)) { throw new Error(`Automation script not found: ${nameOrPath}`); } return nameOrPath; } // Relative to the user's original cwd (not the openbroker package root that // `bin/openbroker.js` chdirs to before spawning tsx). const userCwd = process.env.OPENBROKER_CWD || process.cwd(); const cwdPath = path.resolve(userCwd, nameOrPath); if (existsSync(cwdPath)) return cwdPath; // Relative to ~/.openbroker/automations/ const globalPath = path.join(AUTOMATIONS_DIR, nameOrPath); if (existsSync(globalPath)) return globalPath; // Try appending .ts const withExt = path.join(AUTOMATIONS_DIR, `${nameOrPath}.ts`); if (existsSync(withExt)) return withExt; throw new Error( `Automation script not found: ${nameOrPath}\n` + `Searched:\n ${cwdPath}\n ${globalPath}\n ${withExt}`, ); } /** Resolve a bundled example by name */ export function resolveExamplePath(name: string): string { const examplePath = path.join(EXAMPLES_DIR, `${name}.ts`); if (!existsSync(examplePath)) { const available = listExamples().map(e => e.name).join(', '); throw new Error(`Unknown example: ${name}\nAvailable: ${available}`); } return examplePath; } /** List bundled example automations */ export function listExamples(): Array<{ name: string; path: string }> { if (!existsSync(EXAMPLES_DIR)) return []; return readdirSync(EXAMPLES_DIR) .filter(f => f.endsWith('.ts') && !f.startsWith('.')) .map(f => ({ name: f.replace(/\.ts$/, ''), path: path.join(EXAMPLES_DIR, f), })); } /** Load config metadata from all bundled examples */ export async function loadExampleConfigs(): Promise> { const examples = listExamples(); const configs: Record = {}; for (const example of examples) { try { const mod = await import(example.path); const config = resolveAutomationConfig(mod); if (config && typeof config === 'object' && config.description) { configs[example.name] = config; } } catch { // Skip examples that fail to load } } return configs; } function resolveAutomationFactory(mod: Record): AutomationFactory | null { const candidates = [ mod.default, (mod.default as Record | undefined)?.default, mod["module.exports"], ((mod["module.exports"] as Record | undefined)?.default) ]; for (const candidate of candidates) { if (typeof candidate === "function") { return candidate as AutomationFactory; } } return null; } function resolveAutomationConfig(mod: Record): AutomationConfig | null { const candidates = [ mod.config, (mod.default as Record | undefined)?.config, (mod["module.exports"] as Record | undefined)?.config ]; for (const candidate of candidates) { if (candidate && typeof candidate === "object" && "description" in candidate) { return candidate as AutomationConfig; } } return null; } function resolveGuardrailsExport(mod: Record): AutomationGuardrailsExport | null { const candidates = [ mod.guardrails, (mod.default as Record | undefined)?.guardrails, (mod["module.exports"] as Record | undefined)?.guardrails, ]; for (const candidate of candidates) { if (candidate && (typeof candidate === 'object' || typeof candidate === 'function')) { return candidate as AutomationGuardrailsExport; } } return null; } /** Load an automation module and validate its factory plus required guardrails export. */ export async function loadAutomation( scriptPath: string, context: AutomationGuardrailContext = { config: {} }, ): Promise { const absolutePath = path.resolve(scriptPath); // Dynamic import — tsx handles TypeScript transpilation const mod = await import(absolutePath); const factory = resolveAutomationFactory(mod as Record); if (typeof factory !== 'function') { throw new Error( `Automation script must export a default function.\n` + `Got: ${typeof factory} from ${scriptPath}`, ); } const guardrailsExport = resolveGuardrailsExport(mod as Record); if (!guardrailsExport) { throw new Error( `Automation script must export "guardrails".\n` + `Use { mode: "read-only" } for monitoring-only scripts or a validated trading policy.`, ); } return { factory: factory as AutomationFactory, guardrails: resolveAutomationGuardrails(guardrailsExport, context), }; } /** List available automation scripts in ~/.openbroker/automations/ */ export function listAutomations(): Array<{ name: string; path: string }> { if (!existsSync(AUTOMATIONS_DIR)) return []; return readdirSync(AUTOMATIONS_DIR) .filter(f => f.endsWith('.ts') && !f.startsWith('.')) .map(f => ({ name: f.replace(/\.ts$/, ''), path: path.join(AUTOMATIONS_DIR, f), })); } /** Ensure the automations directory exists */ export function ensureAutomationsDir(): void { mkdirSync(AUTOMATIONS_DIR, { recursive: true }); }