Skip to main content
Glama

hypertool-mcp

archive.tsโ€ข14 kB
/** * Persona Archive Handler * * This module handles the creation and extraction of .htp archive files. * Archives use tar.gz format internally and contain the complete persona structure * including configuration, assets, and metadata. * * @fileoverview Persona archive pack/unpack functionality */ import { promises as fs } from "fs"; import { createReadStream, createWriteStream } from "fs"; import { join, basename, dirname, extname } from "path"; import { pipeline } from "stream/promises"; import * as tar from "tar"; import { createGzip, createGunzip } from "zlib"; import type { PersonaConfig } from "./types.js"; import { PersonaErrorCode } from "./types.js"; import { PersonaError, createFileSystemError, createArchiveExtractionError, } from "./errors.js"; import { validatePersona } from "./validator.js"; import { createChildLogger } from "../utils/logging.js"; const logger = createChildLogger({ module: "persona-archive" }); /** * Archive operation options */ export interface ArchiveOptions { /** Whether to overwrite existing files */ force?: boolean; /** Compression level (0-9) */ compressionLevel?: number; /** Whether to preserve permissions */ preservePermissions?: boolean; } /** * Archive metadata embedded in the tar header */ export interface ArchiveMetadata { /** Archive format version */ version: string; /** When archive was created */ createdAt: string; /** Persona name from config */ personaName: string; /** Optional description */ description?: string; } /** * Result of archive operations */ export interface ArchiveResult { /** Operation success status */ success: boolean; /** Path to created archive or extracted directory */ path: string; /** Any warnings encountered */ warnings?: string[]; /** Errors encountered */ errors?: string[]; /** Archive metadata if available */ metadata?: ArchiveMetadata; } /** * Constants for archive handling */ const ARCHIVE_CONSTANTS = { /** Current archive format version */ FORMAT_VERSION: "1.0", /** Supported archive extension */ EXTENSION: ".htp", /** Default compression level */ DEFAULT_COMPRESSION: 6, /** Metadata file name within archive */ METADATA_FILE: ".htp-metadata", } as const; /** * Check if a file path has the correct .htp extension */ export function isHtpArchive(filePath: string): boolean { return extname(filePath).toLowerCase() === ARCHIVE_CONSTANTS.EXTENSION; } /** * Validate that a path can be used for archive creation */ async function validateSourcePath(sourcePath: string): Promise<void> { try { const stats = await fs.stat(sourcePath); if (!stats.isDirectory()) { throw new PersonaError( PersonaErrorCode.FILE_SYSTEM_ERROR, `Source path must be a directory: ${sourcePath}`, { details: { path: sourcePath } } ); } } catch (error) { if (error instanceof PersonaError) { throw error; } throw createFileSystemError( "access source path", sourcePath, error as Error ); } } /** * Validate persona structure before archiving */ async function validatePersonaStructure( sourcePath: string ): Promise<PersonaConfig> { try { const validationResult = await validatePersona(sourcePath); if (!validationResult.isValid) { throw new PersonaError( PersonaErrorCode.VALIDATION_FAILED, `Invalid persona structure: ${validationResult.errors.map((e) => e.message).join(", ")}`, { details: { path: sourcePath, errors: validationResult.errors } } ); } // Load the persona config to get metadata const configPath = join(sourcePath, "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) { throw new PersonaError( PersonaErrorCode.YAML_PARSE_ERROR, `Failed to parse persona config: ${parseResult.errors.map((e) => e.message).join(", ")}`, { details: { path: configPath, errors: parseResult.errors } } ); } return parseResult.data; } catch (error) { if (error instanceof PersonaError) { throw error; } throw new PersonaError( PersonaErrorCode.VALIDATION_FAILED, `Failed to validate persona structure: ${error instanceof Error ? error.message : String(error)}`, { details: { path: sourcePath } } ); } } /** * Create archive metadata */ function createArchiveMetadata(personaConfig: PersonaConfig): ArchiveMetadata { return { version: ARCHIVE_CONSTANTS.FORMAT_VERSION, createdAt: new Date().toISOString(), personaName: personaConfig.name, description: personaConfig.description, }; } /** * Write metadata file to the archive */ async function writeArchiveMetadata( tempDir: string, metadata: ArchiveMetadata ): Promise<void> { const metadataPath = join(tempDir, ARCHIVE_CONSTANTS.METADATA_FILE); await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); } /** * Read metadata from extracted archive */ async function readArchiveMetadata( extractPath: string ): Promise<ArchiveMetadata | undefined> { try { const metadataPath = join(extractPath, ARCHIVE_CONSTANTS.METADATA_FILE); const metadataContent = await fs.readFile(metadataPath, "utf8"); return JSON.parse(metadataContent) as ArchiveMetadata; } catch { // Metadata file is optional for backwards compatibility return undefined; } } /** * Create a .htp archive from a persona directory */ export async function packPersona( sourcePath: string, archivePath: string, options: ArchiveOptions = {} ): Promise<ArchiveResult> { const result: ArchiveResult = { success: false, path: archivePath, warnings: [], errors: [], }; try { logger.info( `Starting persona archive creation: ${sourcePath} -> ${archivePath}` ); // Validate inputs if (!isHtpArchive(archivePath)) { throw new PersonaError( PersonaErrorCode.FILE_SYSTEM_ERROR, `Archive must have ${ARCHIVE_CONSTANTS.EXTENSION} extension`, { details: { path: archivePath } } ); } await validateSourcePath(sourcePath); const personaConfig = await validatePersonaStructure(sourcePath); // Check if output file already exists if (!options.force) { try { await fs.access(archivePath); throw new PersonaError( PersonaErrorCode.FILE_SYSTEM_ERROR, `Archive already exists: ${archivePath}. Use force option to overwrite.`, { details: { path: archivePath } } ); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } // File doesn't exist, continue } } // Ensure output directory exists await fs.mkdir(dirname(archivePath), { recursive: true }); // Create temporary staging area const tempDir = join( dirname(archivePath), `.tmp-${basename(archivePath, ARCHIVE_CONSTANTS.EXTENSION)}-${Date.now()}` ); await fs.mkdir(tempDir, { recursive: true }); try { // Copy source files to temp directory await copyDirectory(sourcePath, tempDir); // Create and write metadata const metadata = createArchiveMetadata(personaConfig); await writeArchiveMetadata(tempDir, metadata); // Create the tar.gz archive await tar.create( { gzip: true, file: archivePath, cwd: tempDir, filter: (path, stat) => { // Allow the root directory if (path === ".") return true; // Exclude hidden files (starting with .) except our metadata file const basename = path.split("/").pop() || ""; const include = !basename.startsWith(".") || basename === ARCHIVE_CONSTANTS.METADATA_FILE; return include; }, ...(options.preservePermissions !== false && { preservePaths: true }), }, ["."] // Archive everything in temp directory ); result.success = true; result.metadata = metadata; logger.info(`Successfully created persona archive: ${archivePath}`); } finally { // Clean up temporary directory await fs.rm(tempDir, { recursive: true, force: true }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.errors?.push(errorMessage); logger.error(`Failed to create persona archive: ${errorMessage}`, { sourcePath, archivePath, }); // Clean up partial archive on failure try { await fs.unlink(archivePath); } catch { // Ignore cleanup errors } } return result; } /** * Extract a .htp archive to a directory */ export async function unpackPersona( archivePath: string, extractPath: string, options: ArchiveOptions = {} ): Promise<ArchiveResult> { const result: ArchiveResult = { success: false, path: extractPath, warnings: [], errors: [], }; try { logger.info( `Starting persona archive extraction: ${archivePath} -> ${extractPath}` ); // Validate inputs if (!isHtpArchive(archivePath)) { throw new PersonaError( PersonaErrorCode.FILE_SYSTEM_ERROR, `File must have ${ARCHIVE_CONSTANTS.EXTENSION} extension`, { details: { path: archivePath } } ); } // Check if archive exists and is readable try { await fs.access(archivePath, fs.constants.R_OK); } catch (error) { throw createFileSystemError( "read archive file", archivePath, error as Error ); } // Check if extract path already exists if (!options.force) { try { await fs.access(extractPath); throw new PersonaError( PersonaErrorCode.FILE_SYSTEM_ERROR, `Extract path already exists: ${extractPath}. Use force option to overwrite.`, { details: { path: extractPath } } ); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } // Path doesn't exist, continue } } // Create extract directory (clean it first if using force) if (options.force) { try { await fs.rm(extractPath, { recursive: true, force: true }); } catch { // Ignore errors if directory doesn't exist } } await fs.mkdir(extractPath, { recursive: true }); try { // Extract the archive await tar.extract({ file: archivePath, cwd: extractPath, ...(options.preservePermissions !== false && { preservePaths: true }), }); // Read metadata if available const metadata = await readArchiveMetadata(extractPath); if (metadata) { result.metadata = metadata; // Clean up metadata file (it's not part of the persona structure) await fs.unlink(join(extractPath, ARCHIVE_CONSTANTS.METADATA_FILE)); } // Validate extracted persona structure try { await validatePersonaStructure(extractPath); } catch (error) { result.warnings?.push( `Extracted persona may be invalid: ${error instanceof Error ? error.message : String(error)}` ); } result.success = true; logger.info(`Successfully extracted persona archive: ${extractPath}`); } catch (error) { // Clean up partial extraction on failure await fs.rm(extractPath, { recursive: true, force: true }); throw error; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.errors?.push(errorMessage); logger.error(`Failed to extract persona archive: ${errorMessage}`, { archivePath, extractPath, }); } return result; } /** * List contents of a .htp archive without extracting */ export async function listArchiveContents( archivePath: string ): Promise<string[]> { try { if (!isHtpArchive(archivePath)) { throw new PersonaError( PersonaErrorCode.FILE_SYSTEM_ERROR, `File must have ${ARCHIVE_CONSTANTS.EXTENSION} extension`, { details: { path: archivePath } } ); } const contents: string[] = []; await tar.list({ file: archivePath, onentry: (entry) => { // Normalize path by removing ./ prefix let normalizedPath = entry.path; if (normalizedPath.startsWith("./")) { normalizedPath = normalizedPath.substring(2); } // Skip the metadata file from the listing if (normalizedPath !== ARCHIVE_CONSTANTS.METADATA_FILE) { // Skip directory entries (they end with /) if (normalizedPath && !normalizedPath.endsWith("/")) { contents.push(normalizedPath); } } }, }); return contents; } catch (error) { // Re-throw PersonaErrors as-is to preserve their error codes if (error instanceof PersonaError) { throw error; } throw new PersonaError( PersonaErrorCode.ARCHIVE_EXTRACTION_FAILED, `Failed to list archive contents: ${error instanceof Error ? error.message : String(error)}`, { details: { path: archivePath } } ); } } /** * Copy directory recursively with proper error handling */ async function copyDirectory(src: string, dest: string): Promise<void> { 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); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/toolprint/hypertool-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server