import { Command } from 'commander'; import type { ApiClient } from '../api-client.js'; export interface BackupDeps { client: ApiClient; log: (...args: unknown[]) => void; } interface BackupStatus { enabled: boolean; repoUrl: string | null; publicKey: string | null; gitReachable: boolean; lastSyncAt: string | null; lastPushAt: string | null; lastError: string | null; pendingCount: number; } interface LogEntry { hash: string; date: string; author: string; message: string; manual: boolean; } export function createBackupCommand(deps: BackupDeps): Command { const cmd = new Command('backup') .description('Git-based backup status and management') .action(async () => { const status = await deps.client.get('/api/v1/backup/status'); if (!status.enabled) { deps.log('Backup: disabled'); deps.log(''); deps.log('To enable, create a backup-ssh secret:'); deps.log(' mcpctl create secret backup-ssh --data repoUrl=ssh://git@host/repo.git'); deps.log(''); deps.log('After creating the secret, restart mcpd. An SSH keypair will be'); deps.log('auto-generated and stored in the secret. Run mcpctl backup to see'); deps.log('the public key, then add it as a deploy key in your git host.'); return; } deps.log(`Repo: ${status.repoUrl}`); if (status.gitReachable) { if (status.pendingCount === 0) { deps.log('Status: synced'); } else { deps.log(`Status: ${status.pendingCount} changes pending`); } } else { deps.log('Status: disconnected'); } if (status.lastSyncAt) { const ago = timeAgo(status.lastSyncAt); deps.log(`Last sync: ${ago}`); } if (status.lastPushAt) { const ago = timeAgo(status.lastPushAt); deps.log(`Last push: ${ago}`); } if (status.lastError) { deps.log(`Error: ${status.lastError}`); } if (status.publicKey) { deps.log(''); deps.log(`SSH key: ${status.publicKey}`); } }); cmd .command('log') .description('Show backup commit history') .option('-n, --limit ', 'number of commits to show', '20') .action(async (opts: { limit: string }) => { const { entries } = await deps.client.get<{ entries: LogEntry[] }>( `/api/v1/backup/log?limit=${opts.limit}`, ); if (entries.length === 0) { deps.log('No backup history'); return; } // Header const hashW = 9; const dateW = 20; const authorW = 15; deps.log( 'COMMIT'.padEnd(hashW) + 'DATE'.padEnd(dateW) + 'AUTHOR'.padEnd(authorW) + 'MESSAGE', ); for (const e of entries) { const hash = e.hash.slice(0, 7); const date = new Date(e.date).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); const author = e.author.replace(/<.*>/, '').trim(); const marker = e.manual ? ' [manual]' : ''; deps.log( hash.padEnd(hashW) + date.padEnd(dateW) + author.slice(0, authorW - 1).padEnd(authorW) + e.message + marker, ); } }); // ── Restore subcommand group ── const restore = new Command('restore') .description('Restore mcpctl state from backup history'); restore .command('list') .description('List available restore points') .option('-n, --limit ', 'number of entries', '30') .action(async (opts: { limit: string }) => { const { entries } = await deps.client.get<{ entries: LogEntry[] }>( `/api/v1/backup/log?limit=${opts.limit}`, ); if (entries.length === 0) { deps.log('No restore points available'); return; } deps.log( 'COMMIT'.padEnd(9) + 'DATE'.padEnd(20) + 'USER'.padEnd(15) + 'MESSAGE', ); for (const e of entries) { const hash = e.hash.slice(0, 7); const date = new Date(e.date).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); const author = e.author.replace(/<.*>/, '').trim(); deps.log( hash.padEnd(9) + date.padEnd(20) + author.slice(0, 14).padEnd(15) + e.message, ); } }); restore .command('diff ') .description('Preview what restoring to a commit would change') .action(async (commit: string) => { const preview = await deps.client.post<{ targetCommit: string; targetDate: string; targetMessage: string; added: string[]; removed: string[]; modified: string[]; }>('/api/v1/backup/restore/preview', { commit }); deps.log(`Target: ${preview.targetCommit.slice(0, 7)} — ${preview.targetMessage}`); deps.log(`Date: ${new Date(preview.targetDate).toLocaleString()}`); deps.log(''); if (preview.added.length === 0 && preview.removed.length === 0 && preview.modified.length === 0) { deps.log('No changes — already at this state.'); return; } for (const f of preview.added) deps.log(` + ${f}`); for (const f of preview.modified) deps.log(` ~ ${f}`); for (const f of preview.removed) deps.log(` - ${f}`); deps.log(''); deps.log(`Total: ${preview.added.length} added, ${preview.modified.length} modified, ${preview.removed.length} removed`); }); restore .command('to ') .description('Restore to a specific commit') .option('--force', 'skip confirmation', false) .action(async (commit: string, opts: { force: boolean }) => { // Show preview first const preview = await deps.client.post<{ targetCommit: string; targetDate: string; targetMessage: string; added: string[]; removed: string[]; modified: string[]; }>('/api/v1/backup/restore/preview', { commit }); const totalChanges = preview.added.length + preview.removed.length + preview.modified.length; if (totalChanges === 0) { deps.log('No changes — already at this state.'); return; } deps.log(`Restoring to ${preview.targetCommit.slice(0, 7)} — ${preview.targetMessage}`); deps.log(` ${preview.added.length} added, ${preview.modified.length} modified, ${preview.removed.length} removed`); if (!opts.force) { deps.log(''); deps.log('Use --force to proceed. Current state will be saved as a timeline branch.'); return; } const result = await deps.client.post<{ branchName: string; applied: number; deleted: number; errors: string[]; }>('/api/v1/backup/restore', { commit }); deps.log(''); deps.log(`Restored: ${result.applied} applied, ${result.deleted} deleted`); deps.log(`Previous state saved as branch '${result.branchName}'`); if (result.errors.length > 0) { deps.log('Errors:'); for (const err of result.errors) { deps.log(` - ${err}`); } } }); cmd.addCommand(restore); return cmd; } function timeAgo(iso: string): string { const ms = Date.now() - new Date(iso).getTime(); const secs = Math.floor(ms / 1000); if (secs < 60) return `${secs}s ago`; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; }