Skip to main content
Glama
rollback.ts14.8 kB
/** * Sync Rollback Module * * Implements pre-sync snapshot creation and rollback capability for * safe WordPress synchronization. * * @package WP_Navigator_MCP * @since 1.2.0 */ import * as fs from 'fs'; import * as path from 'path'; import type { DiffResult, WordPressPage, WordPressPlugin } from './diff.js'; import { SNAPSHOT_VERSION } from './snapshots/types.js'; // ============================================================================= // Types // ============================================================================= /** * Pre-sync snapshot for rollback */ export interface PreSyncSnapshot { /** Schema version */ snapshot_version: typeof SNAPSHOT_VERSION; /** ISO timestamp when snapshot was captured */ captured_at: string; /** Identifier for this sync operation */ sync_id: string; /** Pages that will be modified */ pages: PreSyncPageState[]; /** Plugins that will be modified */ plugins: PreSyncPluginState[]; /** Summary of planned operations */ planned_operations: { page_creates: number; page_updates: number; page_deletes: number; plugin_activations: number; plugin_deactivations: number; }; } /** * Pre-sync page state (for rollback) */ export interface PreSyncPageState { /** WordPress page ID */ wpId: number; /** Page slug */ slug: string; /** Page title before sync */ title: string; /** Page status before sync */ status: string; /** Page template before sync */ template: string; /** Parent page ID */ parent: number; /** Menu order */ menu_order: number; /** Operation that will be performed */ planned_operation: 'create' | 'update' | 'delete'; } /** * Pre-sync plugin state (for rollback) */ export interface PreSyncPluginState { /** Plugin slug */ slug: string; /** Plugin name */ name: string; /** Active status before sync */ wasActive: boolean; /** Operation that will be performed */ planned_operation: 'activate' | 'deactivate'; } /** * Rollback result */ export interface RollbackResult { /** Whether rollback succeeded */ success: boolean; /** Timestamp of rollback */ timestamp: string; /** Number of pages restored */ pagesRestored: number; /** Number of plugins restored */ pluginsRestored: number; /** Errors encountered */ errors: string[]; } /** * Snapshot file info */ export interface SnapshotFileInfo { /** File path */ path: string; /** Filename */ filename: string; /** Sync ID */ syncId: string; /** Capture timestamp */ capturedAt: Date; /** Summary of operations */ summary: { pages: number; plugins: number; }; } // ============================================================================= // Constants // ============================================================================= const PRE_SYNC_DIR = 'snapshots/pre-sync'; const PRE_SYNC_PREFIX = 'pre-sync-'; // ============================================================================= // Pre-Sync Snapshot Creation // ============================================================================= /** * Generate a unique sync ID */ export function generateSyncId(): string { const now = new Date(); const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); const random = Math.random().toString(36).substring(2, 6); return `${timestamp}-${random}`; } /** * Get path to pre-sync snapshots directory */ export function getPreSyncDir(projectDir: string): string { return path.join(projectDir, PRE_SYNC_DIR); } /** * Get path for a new pre-sync snapshot file */ export function getPreSyncSnapshotPath(projectDir: string, syncId: string): string { return path.join(getPreSyncDir(projectDir), `${PRE_SYNC_PREFIX}${syncId}.json`); } /** * Create a pre-sync snapshot capturing current WordPress state for affected resources */ export function createPreSyncSnapshot( diff: DiffResult, wpPages: WordPressPage[], wpPlugins: WordPressPlugin[], syncId: string ): PreSyncSnapshot { const pages: PreSyncPageState[] = []; const plugins: PreSyncPluginState[] = []; let pageCreates = 0; let pageUpdates = 0; let pageDeletes = 0; let pluginActivations = 0; let pluginDeactivations = 0; // Capture state for pages that will be modified for (const pageDiff of diff.pages) { if (pageDiff.change === 'match') continue; if (pageDiff.change === 'add') { // New page will be created - no existing state to capture pageCreates++; pages.push({ wpId: 0, // Will be assigned on creation slug: pageDiff.slug, title: pageDiff.title, status: 'draft', template: '', parent: 0, menu_order: 0, planned_operation: 'create', }); } else if (pageDiff.change === 'modify' && pageDiff.wpId) { // Existing page will be updated - capture current state pageUpdates++; const currentPage = wpPages.find(p => p.id === pageDiff.wpId); if (currentPage) { pages.push({ wpId: currentPage.id, slug: currentPage.slug, title: currentPage.title, status: currentPage.status, template: currentPage.template || '', parent: currentPage.parent || 0, menu_order: currentPage.menu_order || 0, planned_operation: 'update', }); } } else if (pageDiff.change === 'remove' && pageDiff.wpId) { // Page will be deleted - capture current state pageDeletes++; const currentPage = wpPages.find(p => p.id === pageDiff.wpId); if (currentPage) { pages.push({ wpId: currentPage.id, slug: currentPage.slug, title: currentPage.title, status: currentPage.status, template: currentPage.template || '', parent: currentPage.parent || 0, menu_order: currentPage.menu_order || 0, planned_operation: 'delete', }); } } } // Capture state for plugins that will be modified for (const pluginDiff of diff.plugins) { if (pluginDiff.change === 'match') continue; if (pluginDiff.change === 'modify') { const currentPlugin = wpPlugins.find(p => p.slug === pluginDiff.slug); if (currentPlugin) { const operation = pluginDiff.expectedEnabled ? 'activate' : 'deactivate'; if (operation === 'activate') pluginActivations++; else pluginDeactivations++; plugins.push({ slug: pluginDiff.slug, name: pluginDiff.name, wasActive: currentPlugin.active, planned_operation: operation, }); } } } return { snapshot_version: SNAPSHOT_VERSION, captured_at: new Date().toISOString(), sync_id: syncId, pages, plugins, planned_operations: { page_creates: pageCreates, page_updates: pageUpdates, page_deletes: pageDeletes, plugin_activations: pluginActivations, plugin_deactivations: pluginDeactivations, }, }; } /** * Save pre-sync snapshot to file */ export function savePreSyncSnapshot( projectDir: string, snapshot: PreSyncSnapshot ): string { const snapshotDir = getPreSyncDir(projectDir); const snapshotPath = getPreSyncSnapshotPath(projectDir, snapshot.sync_id); // Create directory if it doesn't exist if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); } // Write snapshot fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf-8'); return snapshotPath; } // ============================================================================= // Rollback Operations // ============================================================================= /** * List available pre-sync snapshots */ export function listPreSyncSnapshots(projectDir: string): SnapshotFileInfo[] { const snapshotDir = getPreSyncDir(projectDir); if (!fs.existsSync(snapshotDir)) { return []; } const files = fs.readdirSync(snapshotDir); const snapshots: SnapshotFileInfo[] = []; for (const filename of files) { if (!filename.startsWith(PRE_SYNC_PREFIX) || !filename.endsWith('.json')) { continue; } const filePath = path.join(snapshotDir, filename); try { const content = fs.readFileSync(filePath, 'utf-8'); const snapshot = JSON.parse(content) as PreSyncSnapshot; snapshots.push({ path: filePath, filename, syncId: snapshot.sync_id, capturedAt: new Date(snapshot.captured_at), summary: { pages: snapshot.pages.length, plugins: snapshot.plugins.length, }, }); } catch { // Skip invalid files } } // Sort by capture time, most recent first snapshots.sort((a, b) => b.capturedAt.getTime() - a.capturedAt.getTime()); return snapshots; } /** * Load a pre-sync snapshot by sync ID */ export function loadPreSyncSnapshot( projectDir: string, syncId: string ): PreSyncSnapshot | null { const snapshotPath = getPreSyncSnapshotPath(projectDir, syncId); if (!fs.existsSync(snapshotPath)) { return null; } try { const content = fs.readFileSync(snapshotPath, 'utf-8'); return JSON.parse(content) as PreSyncSnapshot; } catch { return null; } } /** * WordPress API request function signature */ export type WpRequestFn = ( endpoint: string, options?: RequestInit ) => Promise<unknown>; /** * Execute rollback from a pre-sync snapshot */ export async function executeRollback( snapshot: PreSyncSnapshot, wpRequest: WpRequestFn, options: { dryRun?: boolean } = {} ): Promise<RollbackResult> { const { dryRun = false } = options; const errors: string[] = []; let pagesRestored = 0; let pluginsRestored = 0; // Restore pages for (const page of snapshot.pages) { try { if (page.planned_operation === 'create') { // Page was created - need to delete it to rollback // We need to find the page by slug first if (!dryRun) { const pagesResponse = await wpRequest(`/wp/v2/pages?slug=${encodeURIComponent(page.slug)}&status=any`) as Array<{ id: number }>; if (pagesResponse.length > 0) { await wpRequest(`/wp/v2/pages/${pagesResponse[0].id}?force=true`, { method: 'DELETE' }); pagesRestored++; } } else { pagesRestored++; } } else if (page.planned_operation === 'update' && page.wpId > 0) { // Page was updated - restore previous values if (!dryRun) { await wpRequest(`/wp/v2/pages/${page.wpId}`, { method: 'POST', body: JSON.stringify({ title: page.title, status: page.status, template: page.template, parent: page.parent, menu_order: page.menu_order, }), }); } pagesRestored++; } else if (page.planned_operation === 'delete' && page.wpId > 0) { // Page was deleted - need to recreate it // Note: We can't fully restore deleted pages without full content backup // This creates a stub page with the original metadata if (!dryRun) { await wpRequest('/wp/v2/pages', { method: 'POST', body: JSON.stringify({ slug: page.slug, title: page.title, status: page.status, template: page.template, parent: page.parent, menu_order: page.menu_order, }), }); } pagesRestored++; } } catch (err) { errors.push(`Failed to restore page '${page.slug}': ${err instanceof Error ? err.message : String(err)}`); } } // Restore plugins for (const plugin of snapshot.plugins) { try { const pluginFile = `${plugin.slug}/${plugin.slug}.php`; const targetStatus = plugin.wasActive ? 'active' : 'inactive'; if (!dryRun) { await wpRequest(`/wp/v2/plugins/${encodeURIComponent(pluginFile)}`, { method: 'POST', body: JSON.stringify({ status: targetStatus }), }); } pluginsRestored++; } catch (err) { errors.push(`Failed to restore plugin '${plugin.slug}': ${err instanceof Error ? err.message : String(err)}`); } } return { success: errors.length === 0, timestamp: new Date().toISOString(), pagesRestored, pluginsRestored, errors, }; } /** * Delete a pre-sync snapshot */ export function deletePreSyncSnapshot(projectDir: string, syncId: string): boolean { const snapshotPath = getPreSyncSnapshotPath(projectDir, syncId); try { if (fs.existsSync(snapshotPath)) { fs.unlinkSync(snapshotPath); return true; } } catch { // Ignore errors } return false; } /** * Clean up old pre-sync snapshots (keep most recent N) */ export function cleanupOldSnapshots(projectDir: string, keepCount: number = 10): number { const snapshots = listPreSyncSnapshots(projectDir); if (snapshots.length <= keepCount) { return 0; } // Delete oldest snapshots beyond keepCount const toDelete = snapshots.slice(keepCount); let deleted = 0; for (const snapshot of toDelete) { if (deletePreSyncSnapshot(projectDir, snapshot.syncId)) { deleted++; } } return deleted; } // ============================================================================= // Output Formatting // ============================================================================= /** * Format rollback result for human-readable output */ export function formatRollbackText(result: RollbackResult, dryRun: boolean = false): string { const lines: string[] = []; lines.push(''); if (dryRun) { lines.push('━━━ Rollback Preview (Dry Run) ━━━'); } else { lines.push('━━━ Rollback Results ━━━'); } lines.push(''); // Summary const restoredCount = result.pagesRestored + result.pluginsRestored; if (dryRun) { lines.push(`${restoredCount} resource(s) would be restored`); } else { lines.push(`✔ ${result.pagesRestored} page(s) restored`); lines.push(`✔ ${result.pluginsRestored} plugin(s) restored`); } lines.push(''); // Errors if (result.errors.length > 0) { lines.push('Errors:'); for (const error of result.errors) { lines.push(` ✖ ${error}`); } lines.push(''); } // Final status if (!dryRun) { if (result.success) { lines.push('✔ Rollback completed successfully'); } else { lines.push('✖ Rollback completed with errors'); } lines.push(''); } return lines.join('\n'); } /** * Format rollback result as JSON */ export function formatRollbackJson(result: RollbackResult): string { return JSON.stringify(result, null, 2); }

Latest Blog Posts

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/littlebearapps/wp-navigator-mcp'

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