import { z } from "zod"; import { withAuth, getApiClient, getCommonParams } from "../../auth/index.js"; import { handleToolError, handleApiResponse, validateRequired } from "../helpers/errorHandler.js"; import { withAuthParams } from "../helpers/authSchema.js"; const COMMIT_HASH_REGEX = /\b[a-f0-9]{40}\b/i; const MAX_LOG_LIMIT = 20; const lastModulePathBySession = new Map(); const LAYOUT_CONTEXT_PATH = "/modulos/layout/"; const LAYOUT_ALIASES = new Set([ "layout", "header", "footer", "hookglobal", "hooksglobal", "globalhook", "globalhooks", "hooksglobales" ]); function normalizePath(path) { const trimmedPath = path.trim(); if (!trimmedPath) { return trimmedPath; } const cleanPath = trimmedPath.replace(/^\/+|\/+$/g, ""); if (!cleanPath) { return "/"; } const normalizedToken = cleanPath.toLowerCase().replace(/[\s_-]+/g, ""); if (LAYOUT_ALIASES.has(normalizedToken) || cleanPath.toLowerCase() === "modulos/layout") { return LAYOUT_CONTEXT_PATH; } // Common case: only module id/name provided (e.g. nameofthecustommodule_aeq9kl) if (!cleanPath.includes("/")) { return `/modulos/${cleanPath}/`; } // If "modulos/xxx" is provided, normalize with leading/trailing slash. if (cleanPath.startsWith("modulos/")) { return `/${cleanPath}/`; } // Fallback: normalize any other path preserving its segments. return `/${cleanPath}/`; } function parseGitLogResponse(logData) { const rawLog = logData?.log; if (typeof rawLog === "string") { return { entries: [], hasNoPreviousVersions: true, noPreviousVersionsMessage: rawLog }; } if (Array.isArray(rawLog)) { return { entries: rawLog, hasNoPreviousVersions: false, noPreviousVersionsMessage: null }; } return { entries: [], hasNoPreviousVersions: false, noPreviousVersionsMessage: null }; } function getWsPayload(response) { if (!response || typeof response !== "object") { return response; } // Prefer direct WS payload shape: { log: [...], result: ... } if ("log" in response || "result" in response || "error" in response || "success" in response) { return response; } // Fallback for axios shape: { data: {...} } if (response.data && typeof response.data === "object") { return response.data; } return response; } function extractCommitIdFromEntry(entry) { if (!entry) { return null; } if (Array.isArray(entry)) { const id = entry[1]; if (typeof id === "string" && id.trim()) { return id.trim(); } if (typeof id === "number") { return String(id); } return null; } if (typeof entry === "string") { return entry.trim(); } if (typeof entry !== "object") { return null; } const directId = entry.id || entry.commitId || entry.hash || entry.sha || entry.commit; if (typeof directId === "string" && directId.trim()) { return directId.trim(); } const serializedEntry = JSON.stringify(entry); const hashMatch = serializedEntry.match(COMMIT_HASH_REGEX); return hashMatch ? hashMatch[0] : null; } function formatLogEntries(logData, limit = MAX_LOG_LIMIT) { const { entries } = parseGitLogResponse(logData); const slicedEntries = entries.slice(0, limit); return slicedEntries.map((entry, index) => { const commitId = extractCommitIdFromEntry(entry); const date = Array.isArray(entry) ? entry[0] : (entry?.date || entry?.fecha || null); if (Array.isArray(entry)) { return { index: index + 1, date, id: commitId, raw: entry }; } if (typeof entry === "object" && entry !== null) { return { index: index + 1, date, id: commitId, ...entry }; } return { index: index + 1, id: commitId, raw: entry }; }); } function getTargetText(path) { return path ? `este módulo ${path}` : "todo"; } export function registerRecoverGitTools(server) { server.tool( "list_git_log", `List the latest git history entries (up to 20) so the user can choose the rollback id. Routing guidance: - If user says "haz rollback" or "recupera" without a commit id, use this tool first. - Show the returned commits and ask which id to use. - Do NOT choose an id automatically in that scenario.`, withAuthParams({ path: z.string().optional().describe("Optional module path OR module id/name. Example path: /modulos/nameofthecustommodule_aeq9kl/. Example id/name: nameofthecustommodule_aeq9kl. Global hooks/header/footer/layout are in /modulos/layout/ (aliases header/footer/layout/hooks globales auto-map). If omitted, returns global git history."), limit: z.number().int().min(1).max(MAX_LOG_LIMIT).optional().describe("Maximum number of entries to return (default 20, max 20)."), }), withAuth(async ({ path, limit = MAX_LOG_LIMIT }, extra) => { try { const normalizedPath = path ? normalizePath(path) : ''; if (normalizedPath) { lastModulePathBySession.set(extra.sessionId, normalizedPath); } const client = await getApiClient(extra.sessionId); const logResponse = await client.post( "/cms/lib/viewer_functions.php", await getCommonParams(extra.sessionId, { action_ws: "getGitLog", ...(normalizedPath ? { path: normalizedPath } : {}) }) ); const logPayload = getWsPayload(logResponse); const logError = handleApiResponse(logPayload, 'list_git_log:getGitLog'); if (logError) return logError; const parsedLog = parseGitLogResponse(logPayload); if (parsedLog.hasNoPreviousVersions) { return { content: [{ type: "text", text: JSON.stringify({ success: true, message: parsedLog.noPreviousVersionsMessage || "No hay versiones anteriores.", target: getTargetText(normalizedPath), path: normalizedPath, count: 0, commits: [] }, null, 2) }], }; } const list = formatLogEntries(logPayload, limit); return { content: [{ type: "text", text: JSON.stringify({ success: true, message: "Selecciona un id y llama a recover_git con ese id. Nota: el primer commit es la versión actual; el segundo es la versión anterior (último rollback sugerido).", target: getTargetText(normalizedPath), path: normalizedPath, count: list.length, commits: list }, null, 2) }], }; } catch (error) { return handleToolError(error, 'list_git_log', { path }); } }) ); server.tool( "recover_git", `Execute recoverGit using an explicit commit id chosen by the user. Routing guidance: - Use this only when a specific commit id is already provided by the user. - If user did not provide id, call list_git_log first. SAFETY: You must pass confirm=true to execute recovery.`, withAuthParams({ id: z.string().describe("Commit id selected by the user from list_git_log."), path: z.string().optional().describe("Optional module path OR module id/name. Example path: /modulos/nameofthecustommodule_aeq9kl/. Example id/name: nameofthecustommodule_aeq9kl. Global hooks/header/footer/layout are in /modulos/layout/ (aliases header/footer/layout/hooks globales auto-map). If omitted, rollback applies to todo."), confirm: z.boolean().optional().describe("Set true to confirm execution. If false/omitted, tool will not execute recoverGit."), }), withAuth(async ({ id, path, confirm = false }, extra) => { try { const validationError = validateRequired({ id }, ['id'], 'recover_git'); if (validationError) return validationError; const normalizedPath = path ? normalizePath(path) : ''; if (normalizedPath) { lastModulePathBySession.set(extra.sessionId, normalizedPath); } const target = getTargetText(normalizedPath); if (!confirm) { return { content: [{ type: "text", text: JSON.stringify({ success: false, requiresConfirmation: true, message: `Confirmación requerida para hacer rollback de ${target}. Ejecuta de nuevo con confirm=true.`, id, path: normalizedPath, target }, null, 2) }], isError: true, }; } const client = await getApiClient(extra.sessionId); const response = await client.post( "/cms/lib/viewer_functions.php", await getCommonParams(extra.sessionId, { action_ws: "recoverGit", ...(normalizedPath ? { path: normalizedPath } : {}), id }) ); const recoverPayload = getWsPayload(response); const apiError = handleApiResponse(recoverPayload, 'recover_git'); if (apiError) return apiError; return { content: [{ type: "text", text: JSON.stringify({ success: true, message: `Rollback ejecutado sobre ${target}.`, id, path: normalizedPath, target, response: recoverPayload }, null, 2) }], }; } catch (error) { return handleToolError(error, 'recover_git'); } }) ); server.tool( "recover_previous_git", `Rollback to the previous version (second commit in git log). Routing guidance: - Use this when user says "haz rollback a la versión anterior" or "regresa a la versión anterior". - This tool proposes the previous version id (second entry in getGitLog), but the user must either select an id or confirm using that suggested id. Rules: - If path is provided, use that module. - If path is omitted, use the last module path used in this session. - If there is no last module path, apply to todo (without path). - Always asks confirmation before executing rollback.`, withAuthParams({ path: z.string().optional().describe("Optional module path OR module id/name. Example path: /modulos/nameofthecustommodule_aeq9kl/. Example id/name: nameofthecustommodule_aeq9kl. Global hooks/header/footer/layout are in /modulos/layout/ (aliases header/footer/layout/hooks globales auto-map). If omitted, uses the last module path from this session; if none, applies to todo."), selectedId: z.string().optional().describe("Commit id selected by the user. If omitted, you must set confirmLatest=true to use the suggested previous version id."), confirmLatest: z.boolean().optional().describe("Set true to confirm using the suggested 'último' id (second commit in log)."), confirm: z.boolean().optional().describe("Set true to confirm execution. If false/omitted, tool returns selected id and target."), }), withAuth(async ({ path, selectedId, confirmLatest = false, confirm = false }, extra) => { try { const normalizedPath = path ? normalizePath(path) : (lastModulePathBySession.get(extra.sessionId) || null); if (normalizedPath) { lastModulePathBySession.set(extra.sessionId, normalizedPath); } const client = await getApiClient(extra.sessionId); const logResponse = await client.post( "/cms/lib/viewer_functions.php", await getCommonParams(extra.sessionId, { action_ws: "getGitLog", ...(normalizedPath ? { path: normalizedPath } : {}) }) ); const logPayload = getWsPayload(logResponse); const logError = handleApiResponse(logPayload, 'recover_previous_git:getGitLog'); if (logError) return logError; const parsedLog = parseGitLogResponse(logPayload); if (parsedLog.hasNoPreviousVersions) { return { content: [{ type: "text", text: JSON.stringify({ success: false, message: parsedLog.noPreviousVersionsMessage || "No hay versiones anteriores.", target: getTargetText(normalizedPath), path: normalizedPath }, null, 2) }], isError: true, }; } const entries = parsedLog.entries; if (entries.length < 2) { return { content: [{ type: "text", text: JSON.stringify({ success: false, message: "No hay suficiente historial para volver a la versión anterior. Se requieren al menos 2 commits.", target: getTargetText(normalizedPath), path: normalizedPath, entriesFound: entries.length }, null, 2) }], isError: true, }; } const target = getTargetText(normalizedPath); const suggestedPreviousId = extractCommitIdFromEntry(entries[1]); if (!suggestedPreviousId) { return { content: [{ type: "text", text: JSON.stringify({ success: false, requiresConfirmation: true, message: "No se pudo resolver el id sugerido de la versión anterior (segundo commit).", target, path: normalizedPath }, null, 2) }], isError: true, }; } const finalId = confirmLatest ? suggestedPreviousId : selectedId; if (!finalId) { const commits = formatLogEntries(logPayload, MAX_LOG_LIMIT); return { content: [{ type: "text", text: JSON.stringify({ success: false, requiresUserSelection: true, message: `Debes indicar selectedId o confirmar confirmLatest=true para usar el 'último' (segundo commit).`, note: "El primer commit es la versión actual; el segundo es la versión anterior.", target, path: normalizedPath, suggestedPreviousId, commits }, null, 2) }], isError: true, }; } if (!confirm) { return { content: [{ type: "text", text: JSON.stringify({ success: false, requiresConfirmation: true, message: `Confirmación requerida para hacer rollback en ${target}.`, target, path: normalizedPath, suggestedPreviousId, finalId, usedSuggestedPrevious: confirmLatest }, null, 2) }], isError: true, }; } const recoverResponse = await client.post( "/cms/lib/viewer_functions.php", await getCommonParams(extra.sessionId, { action_ws: "recoverGit", ...(normalizedPath ? { path: normalizedPath } : {}), id: finalId }) ); const recoverPayload = getWsPayload(recoverResponse); const recoverError = handleApiResponse(recoverPayload, 'recover_previous_git:recoverGit'); if (recoverError) return recoverError; return { content: [{ type: "text", text: JSON.stringify({ success: true, message: `Rollback ejecutado en ${target}.`, target, id: finalId, path: normalizedPath, suggestedPreviousId, usedSuggestedPrevious: confirmLatest, response: recoverPayload }, null, 2) }], }; } catch (error) { return handleToolError(error, 'recover_previous_git'); } }) ); }