484 lines
19 KiB
JavaScript
484 lines
19 KiB
JavaScript
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');
|
|
}
|
|
})
|
|
);
|
|
}
|