UNPKG

@toolprint/hypertool-mcp

Version:

HyperTool MCP proxy server for routing requests between clients and multiple underlying MCP servers

328 lines • 13.3 kB
/** * Persona Installer * * This module handles the installation of personas from both folder paths and .htp archives * into the standard personas directory. It provides atomic operations with rollback * capabilities and comprehensive validation. * * @fileoverview Persona installation and management functionality */ import { promises as fs } from "fs"; import { join, basename, resolve, extname } from "path"; import { getPersonaDirectorySync } from "../config/personaConfig.js"; import { PersonaErrorCode } from "./types.js"; import { PersonaError, createFileSystemError } from "./errors.js"; import { validatePersona } from "./validator.js"; import { unpackPersona, isHtpArchive } from "./archive.js"; import { discoverPersonas } from "./discovery.js"; import { createChildLogger } from "../utils/logging.js"; const logger = createChildLogger({ module: "persona-installer" }); /** * Installation source type detection */ export var SourceType; (function (SourceType) { SourceType["FOLDER"] = "folder"; SourceType["ARCHIVE"] = "archive"; })(SourceType || (SourceType = {})); /** * Get the configured personas installation directory */ export function getStandardPersonasDir() { return getPersonaDirectorySync(); } /** * Analyze installation source to determine type and basic info */ export async function analyzeSource(sourcePath) { const resolvedPath = resolve(sourcePath); const sourceInfo = { path: resolvedPath, type: SourceType.FOLDER, accessible: false, }; try { // Check if path exists and is accessible const stats = await fs.stat(resolvedPath); sourceInfo.accessible = true; if (stats.isFile()) { if (isHtpArchive(resolvedPath)) { sourceInfo.type = SourceType.ARCHIVE; // Extract persona name from archive filename sourceInfo.personaName = basename(resolvedPath, extname(resolvedPath)); } else { throw new PersonaError(PersonaErrorCode.FILE_SYSTEM_ERROR, `File must be a .htp archive: ${resolvedPath}`, { details: { path: resolvedPath } }); } } else if (stats.isDirectory()) { sourceInfo.type = SourceType.FOLDER; // Try to extract persona name from persona.yaml if available try { const configPath = join(resolvedPath, "persona.yaml"); const configContent = await fs.readFile(configPath, "utf8"); const { parsePersonaYAML } = await import("./parser.js"); const parseResult = parsePersonaYAML(configContent, configPath); if (parseResult.success && parseResult.data) { const config = parseResult.data; sourceInfo.personaName = config.name; } else { // Parsing failed, fallback to folder name sourceInfo.personaName = basename(resolvedPath); } } catch { // If we can't read config, use folder name sourceInfo.personaName = basename(resolvedPath); } } else { throw new PersonaError(PersonaErrorCode.FILE_SYSTEM_ERROR, `Source must be a directory or .htp archive: ${resolvedPath}`, { details: { path: resolvedPath } }); } } catch (error) { if (error instanceof PersonaError) { throw error; } // Path doesn't exist or can't be accessed logger.debug(`Cannot access source: ${resolvedPath}`, { error }); } return sourceInfo; } /** * Check if a persona with the given name already exists */ export async function checkPersonaExists(personaName, installDir = getStandardPersonasDir()) { try { // First check if directory exists const personaPath = join(installDir, personaName); await fs.access(personaPath); // Also check if it's discoverable as a valid persona const discovery = await discoverPersonas({ searchPaths: [installDir], includeArchives: false, }); return discovery.personas.some((p) => p.name === personaName); } catch { return false; } } /** * Create backup of existing persona */ async function createPersonaBackup(personaPath) { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupPath = `${personaPath}.backup.${timestamp}`; try { await copyDirectory(personaPath, backupPath); logger.debug(`Created backup: ${backupPath}`); return backupPath; } catch (error) { throw createFileSystemError("create backup", personaPath, error); } } /** * Install persona from folder source */ async function installFromFolder(sourcePath, installPath, options) { try { // Validate source if not skipping validation if (!options.skipValidation) { const validationResult = await validatePersona(sourcePath); if (!validationResult.isValid) { throw new PersonaError(PersonaErrorCode.VALIDATION_FAILED, `Source persona is invalid: ${validationResult.errors.map((e) => e.message).join(", ")}`, { details: { path: sourcePath, errors: validationResult.errors } }); } } // Copy the entire directory structure await copyDirectory(sourcePath, installPath); logger.debug(`Successfully copied persona folder: ${sourcePath} -> ${installPath}`); } catch (error) { throw new PersonaError(PersonaErrorCode.FILE_SYSTEM_ERROR, `Failed to install from folder: ${error instanceof Error ? error.message : String(error)}`, { details: { sourcePath, installPath } }); } } /** * Install persona from archive source */ async function installFromArchive(archivePath, installPath, options) { try { // Extract archive to install location const extractResult = await unpackPersona(archivePath, installPath, { force: true, // We handle conflicts at a higher level preservePermissions: true, }); if (!extractResult.success) { throw new PersonaError(PersonaErrorCode.ARCHIVE_EXTRACTION_FAILED, `Failed to extract archive: ${extractResult.errors?.join(", ") || "Unknown error"}`, { details: { archivePath, installPath, errors: extractResult.errors } }); } // Validate extracted content if not skipping validation if (!options.skipValidation) { const validationResult = await validatePersona(installPath); if (!validationResult.isValid) { // Clean up invalid extraction await fs.rm(installPath, { recursive: true, force: true }); throw new PersonaError(PersonaErrorCode.VALIDATION_FAILED, `Extracted persona is invalid: ${validationResult.errors.map((e) => e.message).join(", ")}`, { details: { path: installPath, errors: validationResult.errors } }); } } logger.debug(`Successfully extracted persona archive: ${archivePath} -> ${installPath}`); } catch (error) { // Ensure cleanup on any error try { await fs.rm(installPath, { recursive: true, force: true }); } catch { // Ignore cleanup errors } if (error instanceof PersonaError) { throw error; } throw new PersonaError(PersonaErrorCode.ARCHIVE_EXTRACTION_FAILED, `Failed to install from archive: ${error instanceof Error ? error.message : String(error)}`, { details: { archivePath, installPath } }); } } /** * Install a persona from a source path (folder or archive) */ export async function installPersona(sourcePath, options = {}) { const result = { success: false, warnings: [], errors: [], wasOverwrite: false, }; try { logger.debug(`Starting persona installation from: ${sourcePath}`); // Analyze the source const sourceInfo = await analyzeSource(sourcePath); if (!sourceInfo.accessible) { throw createFileSystemError("access source path", sourcePath, new Error("ENOENT")); } if (!sourceInfo.personaName) { throw new PersonaError(PersonaErrorCode.VALIDATION_FAILED, `Cannot determine persona name from source: ${sourcePath}`, { details: { path: sourcePath } }); } // Determine installation directory and path const installDir = options.installDir || getStandardPersonasDir(); const installPath = join(installDir, sourceInfo.personaName); // Ensure installation directory exists await fs.mkdir(installDir, { recursive: true }); // Check for existing persona const personaExists = await checkPersonaExists(sourceInfo.personaName, installDir); if (personaExists && !options.force) { throw new PersonaError(PersonaErrorCode.DUPLICATE_PERSONA_NAME, `Persona '${sourceInfo.personaName}' already exists. Use --force to overwrite.`, { details: { personaName: sourceInfo.personaName, installPath } }); } // Create backup if requested and persona exists let backupPath; if (personaExists && options.backup) { backupPath = await createPersonaBackup(installPath); } // Remove existing persona if we're overwriting if (personaExists) { await fs.rm(installPath, { recursive: true, force: true }); result.wasOverwrite = true; } // Install based on source type if (sourceInfo.type === SourceType.FOLDER) { await installFromFolder(sourceInfo.path, installPath, options); } else { await installFromArchive(sourceInfo.path, installPath, options); } // Final validation of installed persona if (!options.skipValidation) { const finalValidation = await validatePersona(installPath); if (!finalValidation.isValid) { result.warnings.push("Installed persona has validation warnings"); for (const error of finalValidation.errors) { result.warnings.push(` - ${error.message}`); } } } result.success = true; result.personaName = sourceInfo.personaName; result.installPath = installPath; result.backupPath = backupPath; logger.debug(`Successfully installed persona '${sourceInfo.personaName}' to: ${installPath}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.errors.push(errorMessage); logger.error(`Failed to install persona: ${errorMessage}`, { sourcePath }); } return result; } /** * List all installed personas */ export async function listInstalledPersonas(installDir = getStandardPersonasDir()) { try { const discovery = await discoverPersonas({ searchPaths: [installDir], includeArchives: false, }); return discovery.personas; } catch (error) { logger.error(`Failed to list installed personas: ${error instanceof Error ? error.message : String(error)}`); return []; } } /** * Uninstall a persona by name */ export async function uninstallPersona(personaName, installDir = getStandardPersonasDir(), createBackup = true) { const result = { success: false, warnings: [], errors: [], wasOverwrite: false, }; try { const installPath = join(installDir, personaName); // Check if persona exists const exists = await checkPersonaExists(personaName, installDir); if (!exists) { throw new PersonaError(PersonaErrorCode.PERSONA_NOT_FOUND, `Persona '${personaName}' is not installed`, { details: { personaName, installPath } }); } // Create backup if requested let backupPath; if (createBackup) { backupPath = await createPersonaBackup(installPath); } // Remove the persona directory await fs.rm(installPath, { recursive: true, force: true }); result.success = true; result.personaName = personaName; result.installPath = installPath; result.backupPath = backupPath; logger.debug(`Successfully uninstalled persona '${personaName}' from: ${installPath}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.errors.push(errorMessage); logger.error(`Failed to uninstall persona: ${errorMessage}`, { personaName, }); } return result; } /** * Copy directory recursively */ async function copyDirectory(src, dest) { const stats = await fs.stat(src); if (stats.isDirectory()) { await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(src); for (const entry of entries) { const srcPath = join(src, entry); const destPath = join(dest, entry); await copyDirectory(srcPath, destPath); } } else { await fs.copyFile(src, dest); } } //# sourceMappingURL=installer.js.map