Initial commit
This commit is contained in:
483
mcp-server/tools/remote_git/rollback.js
Normal file
483
mcp-server/tools/remote_git/rollback.js
Normal file
@@ -0,0 +1,483 @@
|
||||
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');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user