@monitoro/herd
Version:
Automate your browser, build AI web tools and MCP servers with Monitoro Herd
341 lines (340 loc) โข 14.7 kB
JavaScript
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"));
}
}