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>
253 lines
7.6 KiB
TypeScript
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`;
|
|
}
|