UNPKG

@monitoro/herd

Version:

Automate your browser, build AI web tools and MCP servers with Monitoro Herd

341 lines (340 loc) โ€ข 14.7 kB
import { loadTrail } from "./trails/loadTrail.js"; import { buildTrail } from "./trails/build.js"; import { downloadTrail } from "./trails/download.js"; import { CacheManager } from "./trails/CacheManager.js"; import * as fs from 'fs'; import * as path from 'path'; // --- Helper methods need to be defined within the class --- /** * Checks if a string is a remote trail identifier (organization/name format) */ export function isRemoteTrailIdentifier(identifier) { // Check for org/name pattern with optional leading @ and optional version return !!identifier.match(/^@?[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(?:@[a-zA-Z0-9_.-]+)?$/); } export function parseTrailIdentifier(trailIdentifier) { // Extract version if present const versionMatch = trailIdentifier.match(/^(@?[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:@([a-zA-Z0-9_.-]+))?$/); if (!versionMatch) { throw new Error(`Invalid trail identifier format: ${trailIdentifier}`); } const basePath = versionMatch[1]; let version = versionMatch[2] || undefined; if (version === 'latest') { version = undefined; } const org = basePath.split('/')[0].replace('@', ''); const trail = basePath.split('/')[1]; return { org, trail, version }; } export class TrailEngine { constructor(client, options = {}) { this.client = client; this.autoBuild = options.autoBuild !== false; this.silent = options.silent || false; this.cacheEnabled = options.cacheEnabled !== false; // Initialize cache manager this.cacheManager = new CacheManager(client, { cacheDirectory: options.cacheDirectory }); } /** * Loads a trail from a path or remote source * @param trailIdentifier Local path or organization/trail name * @param version Optional version for remote trails */ async loadTrail(trailIdentifier) { // Extract version from trailPath if present (middle @, but don't be confused by the leading @ in the trail name) const isRemoteTrail = isRemoteTrailIdentifier(trailIdentifier); let org, trail, version; if (isRemoteTrail) { ({ org, trail, version } = parseTrailIdentifier(trailIdentifier)); } else { org = undefined; trail = trailIdentifier; version = undefined; } let trailPath = `${org ? `@${org}` : ''}/${trail}`; // Determine if this is a remote trail or a local path if (isRemoteTrail) { if (!this.silent) { console.log(`๐Ÿ” Looking for remote trail: ${trailIdentifier}`); } // Initialize client if needed if (!this.client.isInitialized()) { await this.client.initialize(); } // Download the trail with caching managed by downloadTrail function trailPath = await downloadTrail(this.client, trailPath, { cacheEnabled: this.cacheEnabled, version, silent: this.silent }); } else { // Local trail path trailPath = trailIdentifier; // Build the local trail if auto-build is enabled if (this.autoBuild && this.isLocalTrailDirectory(trailPath)) { if (!this.silent) { console.log(`๐Ÿ—๏ธ Building local trail at: ${trailPath}`); } await buildTrail(trailPath, { silent: this.silent }); } } // Load the trail const { actions, resources } = await loadTrail(trailPath); return { actions, resources, trailPath, version }; } /** * Runs a trail action * @param trailIdentifier Local path or organization/trail name * @param options Runtime options including action name and parameters */ async runTrail(trailIdentifier, options = {}) { const { actionName, params = {}, silent = this.silent, device, actions: preloadedActions, resources: preloadedResources } = options; try { // Use pre-loaded actions and resources if provided, otherwise load the trail let actions; let resources; let trailPath; if (preloadedActions && preloadedResources) { actions = preloadedActions; resources = preloadedResources; trailPath = typeof trailIdentifier === 'string' ? trailIdentifier : ''; } else { // Load the trail const loaded = await this.loadTrail(trailIdentifier); actions = loaded.actions; resources = loaded.resources; trailPath = loaded.trailPath; } // Determine which action to run let resolvedActionName = actionName; if (!resolvedActionName) { const availableActions = Object.keys(actions); if (availableActions.length === 1) { resolvedActionName = availableActions[0]; if (!silent) { console.log(`โ„น๏ธ No action specified, using the only available action: ${resolvedActionName}`); } } else { throw new Error('โŒ No action name provided. Please specify an action to run.'); } } if (!actions[resolvedActionName]) { const availableActions = Object.keys(actions).join(', '); throw new Error(`โŒ Action '${resolvedActionName}' not found. Available actions: ${availableActions}`); } // Get the action to run const action = actions[resolvedActionName]; if (!action?.run) { throw new Error(`โŒ Action is missing the 'run' method. Please make sure your action class implements the required interface.`); } // Get a device or use the provided one const runDevice = device || (await this.client.listDevices())[0]; if (!runDevice) { throw new Error('โŒ No devices found. Please make sure you have a device connected.'); } if (!silent) { console.log(`๐Ÿš€ Running action: ${resolvedActionName}`); } // Run the action const results = await action.run(runDevice, params, resources); if (!silent) { console.log('โœจ Action completed successfully'); console.log('๐Ÿ“Š Results:', JSON.stringify(results, null, 2)); } return results; } catch (error) { if (!silent) { console.error('โŒ Error running trail:', error.message); } throw error; } } /** * Clear the trail cache */ async clearCache() { await this.cacheManager.clear(); if (!this.silent) { console.log('๐Ÿงน Trail cache cleared'); } } /** * Get the current cache directory */ getCacheDirectory() { return this.cacheManager.getCacheDirectory(); } /** * Publishes a trail to the registry * @param trailPath Path to the trail directory * @param organizationId Organization ID to publish under * @param options Publishing options */ async publishTrail(trailPath, organizationTag, options = {}) { const { isPublic = true, silent = this.silent } = options; // Validate trail directory exists before proceeding if (!this.isLocalTrailDirectory(trailPath)) { throw new Error(`โŒ Not a valid trail directory: ${trailPath}. Missing one or more required files (actions.ts, urls.ts, selectors.ts).`); } // Ensure client is initialized if (!this.client.isInitialized()) { await this.client.initialize(); } if (!silent) { console.log(`๐Ÿ—๏ธ Building trail for publishing...`); } // Build the trail const outDir = await buildTrail(trailPath, { silent }); // Read package.json const packageJsonPath = path.join(trailPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { throw new Error('โŒ No package.json found in trail directory'); } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); // Validate package.json essentials if (!packageJson.name) throw new Error('โŒ package.json must include a name'); if (!packageJson.version) throw new Error('โŒ package.json must include a version'); if (!packageJson.description) throw new Error('โŒ package.json must include a description'); // Determine organization ID // Prefer organizationTag if provided, else try to parse from package name, else error let resolvedOrgId = organizationTag; if (!resolvedOrgId && packageJson.name.includes('/')) { resolvedOrgId = packageJson.name.split('/')[0].replace('@', ''); } // TODO: Consider validating organizationTag against user permissions here or on the server more robustly if (!resolvedOrgId) { throw new Error('โŒ Organization tag not provided and could not be inferred from package name (e.g., @org/trail-name). Use the -o flag.'); } // Read built files const builtUrlsPath = path.join(outDir, 'urls.js'); const builtSelectorsPath = path.join(outDir, 'selectors.js'); const builtActionsPath = path.join(outDir, 'actions.js'); if (!fs.existsSync(builtUrlsPath) || !fs.existsSync(builtSelectorsPath) || !fs.existsSync(builtActionsPath)) { throw new Error('โŒ Trail build incomplete. Make sure all required files (urls.js, selectors.js, actions.js) exist in the build output directory.'); } const urlsContent = fs.readFileSync(builtUrlsPath, 'utf8'); const selectorsContent = fs.readFileSync(builtSelectorsPath, 'utf8'); const actionsContent = fs.readFileSync(builtActionsPath, 'utf8'); if (!silent) { console.log(`๐Ÿ“ฆ Publishing trail: ${packageJson.name}@${packageJson.version}`); } const uniqueTags = [...new Set([...(packageJson.keywords || []), ...new Set(packageJson.tags || [])])]; // --- BEGIN Manifest Generation --- let detailedManifest = { // Start with basic package.json info as fallback name: packageJson.name, description: packageJson.description, version: packageJson.version, author: packageJson.author, license: packageJson.license, keywords: uniqueTags, tags: uniqueTags, }; try { // Load the trail const { urls, selectors, actions } = await loadTrail(trailPath); // URLs: Expect default export of TrailUrl[] detailedManifest.urls = urls.reduce((acc, url) => { if (url.id) { acc[url.id] = { description: url.description || '', params: url.params || {} }; } return acc; }, {}); // Selectors: Expect default export of TrailSelector[] detailedManifest.selectors = selectors.reduce((acc, selector) => { if (selector.id) { acc[selector.id] = { description: selector.description || '' }; } return acc; }, {}); detailedManifest.actions = Object.values((actions || {})) .reduce((acc, action) => { if (action.manifest.name) { acc[action.manifest.name] = action.manifest; } return acc; }, {}); if (!silent) { console.log(`โœ“ Generated detailed manifest from built code.`); } } catch (error) { if (!silent) { console.warn(`โš ๏ธ Warning: Could not dynamically load built files to generate detailed manifest. Using basic manifest. Error:`, error); } // Keep the basic manifest populated initially } // --- END Manifest Generation --- // Prepare request data for the API const publishData = { name: packageJson.name, description: packageJson.description, // Use top-level description from package.json version: packageJson.version, tags: packageJson.tags || [], // Use keywords as tags organizationId: resolvedOrgId, // Send the resolved org tag/ID isPublic, files: { urls: urlsContent, selectors: selectorsContent, actions: actionsContent }, manifest: detailedManifest // Send the generated detailed manifest }; // Send publish request to the server const response = await this.client.request('/api/registry/publish', { method: 'POST', body: JSON.stringify(publishData), }); if (!response.ok) { let errorMsg = `Failed to publish trail (${response.status} ${response.statusText})`; try { const error = await response.json(); errorMsg = `${errorMsg}: ${error.error?.code} - ${error.error?.message || 'Unknown server error'}`; if (error.error?.details) { errorMsg += ` Details: ${JSON.stringify(error.error.details)}`; } } catch (e) { } throw new Error(errorMsg); } const result = await response.json(); // Contains { id, name, description, version, origins, publishedAt } if (!silent) { console.log(`โœจ Trail published successfully!`); } return result; // Return the successful response data } /** * Checks if a path points to a directory containing source trail files. */ isLocalTrailDirectory(dirPath) { // Check for the existence of the core .ts files return fs.existsSync(path.join(dirPath, "actions.ts")) && fs.existsSync(path.join(dirPath, "urls.ts")) && fs.existsSync(path.join(dirPath, "selectors.ts")); } }