feat: Git-based backup system replacing JSON bundle backup/restore
DB is source of truth with git as downstream replica. SSH key generated on first start, all resource mutations committed as apply-compatible YAML. Supports manual commit import, conflict resolution (DB wins), disaster recovery (empty DB restores from git), and timeline branches on restore. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { Command } from 'commander';
|
||||
import fs from 'node:fs';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
export interface BackupDeps {
|
||||
@@ -7,24 +6,110 @@ export interface BackupDeps {
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
interface BackupStatus {
|
||||
enabled: boolean;
|
||||
repoUrl: 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('Backup mcpctl configuration to a JSON file')
|
||||
.option('-o, --output <path>', 'output file path', 'mcpctl-backup.json')
|
||||
.option('-p, --password <password>', 'encrypt sensitive values with password')
|
||||
.option('-r, --resources <types>', 'resource types to backup (comma-separated: servers,profiles,projects)')
|
||||
.action(async (options: { output: string; password?: string; resources?: string }) => {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (options.password) {
|
||||
body.password = options.password;
|
||||
}
|
||||
if (options.resources) {
|
||||
body.resources = options.resources.split(',').map((s) => s.trim());
|
||||
.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 (set MCPD_BACKUP_REPO to enable)');
|
||||
return;
|
||||
}
|
||||
|
||||
const bundle = await deps.client.post('/api/v1/backup', body);
|
||||
fs.writeFileSync(options.output, JSON.stringify(bundle, null, 2), 'utf-8');
|
||||
deps.log(`Backup saved to ${options.output}`);
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('key')
|
||||
.description('Show SSH public key for deploy key setup')
|
||||
.action(async () => {
|
||||
const { publicKey } = await deps.client.get<{ publicKey: string }>('/api/v1/backup/key');
|
||||
deps.log(publicKey);
|
||||
deps.log('');
|
||||
deps.log('Add this key as a deploy key (with write access) in your Git hosting provider.');
|
||||
});
|
||||
|
||||
return cmd;
|
||||
@@ -32,49 +117,135 @@ export function createBackupCommand(deps: BackupDeps): Command {
|
||||
|
||||
export function createRestoreCommand(deps: BackupDeps): Command {
|
||||
const cmd = new Command('restore')
|
||||
.description('Restore mcpctl configuration from a backup file')
|
||||
.option('-i, --input <path>', 'backup file path', 'mcpctl-backup.json')
|
||||
.option('-p, --password <password>', 'decryption password for encrypted backups')
|
||||
.option('-c, --conflict <strategy>', 'conflict resolution: skip, overwrite, fail', 'skip')
|
||||
.action(async (options: { input: string; password?: string; conflict: string }) => {
|
||||
if (!fs.existsSync(options.input)) {
|
||||
deps.log(`Error: File not found: ${options.input}`);
|
||||
.description('Restore mcpctl state from backup history');
|
||||
|
||||
cmd
|
||||
.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;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(options.input, 'utf-8');
|
||||
const bundle = JSON.parse(raw) as unknown;
|
||||
deps.log(
|
||||
'COMMIT'.padEnd(9) +
|
||||
'DATE'.padEnd(20) +
|
||||
'USER'.padEnd(15) +
|
||||
'MESSAGE',
|
||||
);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
bundle,
|
||||
conflictStrategy: options.conflict,
|
||||
};
|
||||
if (options.password) {
|
||||
body.password = options.password;
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.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`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.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<{
|
||||
serversCreated: number;
|
||||
serversSkipped: number;
|
||||
profilesCreated: number;
|
||||
profilesSkipped: number;
|
||||
projectsCreated: number;
|
||||
projectsSkipped: number;
|
||||
branchName: string;
|
||||
applied: number;
|
||||
deleted: number;
|
||||
errors: string[];
|
||||
}>('/api/v1/restore', body);
|
||||
}>('/api/v1/backup/restore', { commit });
|
||||
|
||||
deps.log('Restore complete:');
|
||||
deps.log(` Servers: ${result.serversCreated} created, ${result.serversSkipped} skipped`);
|
||||
deps.log(` Profiles: ${result.profilesCreated} created, ${result.profilesSkipped} skipped`);
|
||||
deps.log(` Projects: ${result.projectsCreated} created, ${result.projectsSkipped} skipped`);
|
||||
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:`);
|
||||
deps.log('Errors:');
|
||||
for (const err of result.errors) {
|
||||
deps.log(` - ${err}`);
|
||||
deps.log(` - ${err}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createBackupCommand, createRestoreCommand } from '../../src/commands/backup.js';
|
||||
|
||||
const mockClient = {
|
||||
@@ -16,61 +15,97 @@ describe('backup command', () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any created files
|
||||
try { fs.unlinkSync('test-backup.json'); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('creates backup command', () => {
|
||||
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||
expect(cmd.name()).toBe('backup');
|
||||
});
|
||||
|
||||
it('calls API and writes file', async () => {
|
||||
const bundle = { version: '1', servers: [], profiles: [], projects: [] };
|
||||
mockClient.post.mockResolvedValue(bundle);
|
||||
|
||||
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['-o', 'test-backup.json'], { from: 'user' });
|
||||
|
||||
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', {});
|
||||
expect(fs.existsSync('test-backup.json')).toBe(true);
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('test-backup.json'));
|
||||
});
|
||||
|
||||
it('passes password when provided', async () => {
|
||||
mockClient.post.mockResolvedValue({ version: '1', servers: [], profiles: [], projects: [] });
|
||||
|
||||
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['-o', 'test-backup.json', '-p', 'secret'], { from: 'user' });
|
||||
|
||||
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', { password: 'secret' });
|
||||
});
|
||||
|
||||
it('passes resource filter', async () => {
|
||||
mockClient.post.mockResolvedValue({ version: '1', servers: [], profiles: [], projects: [] });
|
||||
|
||||
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['-o', 'test-backup.json', '-r', 'servers,profiles'], { from: 'user' });
|
||||
|
||||
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', {
|
||||
resources: ['servers', 'profiles'],
|
||||
it('shows status when enabled', async () => {
|
||||
mockClient.get.mockResolvedValue({
|
||||
enabled: true,
|
||||
repoUrl: 'ssh://git@10.0.0.194:2222/michal/mcp-backup.git',
|
||||
gitReachable: true,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
lastPushAt: null,
|
||||
lastError: null,
|
||||
pendingCount: 0,
|
||||
});
|
||||
|
||||
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
|
||||
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/status');
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('ssh://git@10.0.0.194:2222/michal/mcp-backup.git'));
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('synced'));
|
||||
});
|
||||
|
||||
it('shows disabled when not configured', async () => {
|
||||
mockClient.get.mockResolvedValue({
|
||||
enabled: false,
|
||||
repoUrl: null,
|
||||
gitReachable: false,
|
||||
lastSyncAt: null,
|
||||
lastPushAt: null,
|
||||
lastError: null,
|
||||
pendingCount: 0,
|
||||
});
|
||||
|
||||
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('disabled'));
|
||||
});
|
||||
|
||||
it('shows pending count', async () => {
|
||||
mockClient.get.mockResolvedValue({
|
||||
enabled: true,
|
||||
repoUrl: 'ssh://git@host/repo.git',
|
||||
gitReachable: true,
|
||||
lastSyncAt: null,
|
||||
lastPushAt: null,
|
||||
lastError: null,
|
||||
pendingCount: 5,
|
||||
});
|
||||
|
||||
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('5 changes pending'));
|
||||
});
|
||||
|
||||
it('shows SSH public key', async () => {
|
||||
mockClient.get.mockResolvedValue({ publicKey: 'ssh-ed25519 AAAA... mcpd@mcpctl.local' });
|
||||
|
||||
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['key'], { from: 'user' });
|
||||
|
||||
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/key');
|
||||
expect(log).toHaveBeenCalledWith('ssh-ed25519 AAAA... mcpd@mcpctl.local');
|
||||
});
|
||||
|
||||
it('shows commit log', async () => {
|
||||
mockClient.get.mockResolvedValue({
|
||||
entries: [
|
||||
{ hash: 'abc1234567890', date: '2026-03-08T10:00:00Z', author: 'mcpd <mcpd@mcpctl.local>', message: 'Update server grafana', manual: false },
|
||||
{ hash: 'def4567890123', date: '2026-03-07T09:00:00Z', author: 'Michal <michal@test.com>', message: 'Manual fix', manual: true },
|
||||
],
|
||||
});
|
||||
|
||||
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['log'], { from: 'user' });
|
||||
|
||||
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/log?limit=20');
|
||||
// Header
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('COMMIT'));
|
||||
// Entries
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('abc1234'));
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('[manual]'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore command', () => {
|
||||
const testFile = 'test-restore-input.json';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
fs.writeFileSync(testFile, JSON.stringify({
|
||||
version: '1', servers: [], profiles: [], projects: [],
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.unlinkSync(testFile); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('creates restore command', () => {
|
||||
@@ -78,43 +113,105 @@ describe('restore command', () => {
|
||||
expect(cmd.name()).toBe('restore');
|
||||
});
|
||||
|
||||
it('reads file and calls API', async () => {
|
||||
mockClient.post.mockResolvedValue({
|
||||
serversCreated: 1, serversSkipped: 0,
|
||||
profilesCreated: 0, profilesSkipped: 0,
|
||||
projectsCreated: 0, projectsSkipped: 0,
|
||||
errors: [],
|
||||
it('lists restore points', async () => {
|
||||
mockClient.get.mockResolvedValue({
|
||||
entries: [
|
||||
{ hash: 'abc1234567890', date: '2026-03-08T10:00:00Z', author: 'mcpd <mcpd@mcpctl.local>', message: 'Sync' },
|
||||
],
|
||||
});
|
||||
|
||||
const cmd = createRestoreCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['-i', testFile], { from: 'user' });
|
||||
await cmd.parseAsync(['list'], { from: 'user' });
|
||||
|
||||
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/restore', expect.objectContaining({
|
||||
bundle: expect.objectContaining({ version: '1' }),
|
||||
conflictStrategy: 'skip',
|
||||
}));
|
||||
expect(log).toHaveBeenCalledWith('Restore complete:');
|
||||
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/log?limit=30');
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('abc1234'));
|
||||
});
|
||||
|
||||
it('reports errors from restore', async () => {
|
||||
it('shows restore diff preview', async () => {
|
||||
mockClient.post.mockResolvedValue({
|
||||
serversCreated: 0, serversSkipped: 0,
|
||||
profilesCreated: 0, profilesSkipped: 0,
|
||||
projectsCreated: 0, projectsSkipped: 0,
|
||||
errors: ['Server "x" already exists'],
|
||||
targetCommit: 'abc1234567890',
|
||||
targetDate: '2026-03-08T10:00:00Z',
|
||||
targetMessage: 'Snapshot',
|
||||
added: ['servers/new.yaml'],
|
||||
removed: ['servers/old.yaml'],
|
||||
modified: ['projects/default.yaml'],
|
||||
});
|
||||
|
||||
const cmd = createRestoreCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['-i', testFile], { from: 'user' });
|
||||
await cmd.parseAsync(['diff', 'abc1234'], { from: 'user' });
|
||||
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('Errors'));
|
||||
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore/preview', { commit: 'abc1234' });
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('+ servers/new.yaml'));
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('- servers/old.yaml'));
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('~ projects/default.yaml'));
|
||||
});
|
||||
|
||||
it('logs error for missing file', async () => {
|
||||
const cmd = createRestoreCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['-i', 'nonexistent.json'], { from: 'user' });
|
||||
it('requires --force for restore', async () => {
|
||||
mockClient.post.mockResolvedValue({
|
||||
targetCommit: 'abc1234567890',
|
||||
targetDate: '2026-03-08T10:00:00Z',
|
||||
targetMessage: 'Snapshot',
|
||||
added: ['servers/new.yaml'],
|
||||
removed: [],
|
||||
modified: [],
|
||||
});
|
||||
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(mockClient.post).not.toHaveBeenCalled();
|
||||
const cmd = createRestoreCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['to', 'abc1234'], { from: 'user' });
|
||||
|
||||
// Should show preview but NOT call restore endpoint
|
||||
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore/preview', { commit: 'abc1234' });
|
||||
expect(mockClient.post).not.toHaveBeenCalledWith('/api/v1/backup/restore', expect.anything());
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('--force'));
|
||||
});
|
||||
|
||||
it('executes restore with --force', async () => {
|
||||
// First call: preview, second call: restore
|
||||
mockClient.post
|
||||
.mockResolvedValueOnce({
|
||||
targetCommit: 'abc1234567890',
|
||||
targetDate: '2026-03-08T10:00:00Z',
|
||||
targetMessage: 'Snapshot',
|
||||
added: ['servers/new.yaml'],
|
||||
removed: [],
|
||||
modified: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
branchName: 'timeline/20260308-100000',
|
||||
applied: 1,
|
||||
deleted: 0,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
const cmd = createRestoreCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['to', 'abc1234', '--force'], { from: 'user' });
|
||||
|
||||
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore', { commit: 'abc1234' });
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('1 applied'));
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('timeline/20260308-100000'));
|
||||
});
|
||||
|
||||
it('reports restore errors', async () => {
|
||||
mockClient.post
|
||||
.mockResolvedValueOnce({
|
||||
targetCommit: 'abc1234567890',
|
||||
targetDate: '2026-03-08T10:00:00Z',
|
||||
targetMessage: 'Snapshot',
|
||||
added: [],
|
||||
removed: [],
|
||||
modified: ['servers/broken.yaml'],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
branchName: 'timeline/20260308-100000',
|
||||
applied: 0,
|
||||
deleted: 0,
|
||||
errors: ['Failed to apply servers/broken.yaml: invalid YAML'],
|
||||
});
|
||||
|
||||
const cmd = createRestoreCommand({ client: mockClient as never, log });
|
||||
await cmd.parseAsync(['to', 'abc1234', '--force'], { from: 'user' });
|
||||
|
||||
expect(log).toHaveBeenCalledWith('Errors:');
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('invalid YAML'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user