Files
mcpctl/src/cli/src/commands/backup.ts
Michal af4b3fb702 feat: store backup config in DB secret instead of env var
Move backup SSH keys and repo URL from MCPD_BACKUP_REPO env var to a
"backup-ssh" secret in the database. Keys are auto-generated on first
init and stored back into the secret. Also fix ERR_HTTP_HEADERS_SENT
crash caused by reply.send() without return in routes when onSend hook
is registered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:53:12 +00:00

253 lines
7.6 KiB
TypeScript

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<BackupStatus>('/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 <count>', '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 <count>', '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 <commit>')
.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 <commit>')
.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`;
}