refactor: consolidate restore under backup command

mcpctl backup restore list/diff/to instead of separate mcpctl restore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-08 01:17:03 +00:00
parent 7818cb2194
commit 98f3a3eda0
7 changed files with 32 additions and 85 deletions

View File

@@ -112,14 +112,11 @@ export function createBackupCommand(deps: BackupDeps): Command {
deps.log('Add this key as a deploy key (with write access) in your Git hosting provider.');
});
return cmd;
}
export function createRestoreCommand(deps: BackupDeps): Command {
const cmd = new Command('restore')
// ── Restore subcommand group ──
const restore = new Command('restore')
.description('Restore mcpctl state from backup history');
cmd
restore
.command('list')
.description('List available restore points')
.option('-n, --limit <count>', 'number of entries', '30')
@@ -156,7 +153,7 @@ export function createRestoreCommand(deps: BackupDeps): Command {
}
});
cmd
restore
.command('diff <commit>')
.description('Preview what restoring to a commit would change')
.action(async (commit: string) => {
@@ -186,7 +183,7 @@ export function createRestoreCommand(deps: BackupDeps): Command {
deps.log(`Total: ${preview.added.length} added, ${preview.modified.length} modified, ${preview.removed.length} removed`);
});
cmd
restore
.command('to <commit>')
.description('Restore to a specific commit')
.option('--force', 'skip confirmation', false)
@@ -236,6 +233,8 @@ export function createRestoreCommand(deps: BackupDeps): Command {
}
});
cmd.addCommand(restore);
return cmd;
}

View File

@@ -10,7 +10,7 @@ import { createLogsCommand } from './commands/logs.js';
import { createApplyCommand } from './commands/apply.js';
import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
import { createBackupCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
import { createMcpCommand } from './commands/mcp.js';
@@ -191,11 +191,6 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
program.addCommand(createRestoreCommand({
client,
log: (...args) => console.log(...args),
}));
const projectOpsDeps = {
client,
log: (...args: string[]) => console.log(...args),

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createBackupCommand, createRestoreCommand } from '../../src/commands/backup.js';
import { createBackupCommand } from '../../src/commands/backup.js';
const mockClient = {
get: vi.fn(),
@@ -10,14 +10,17 @@ const mockClient = {
const log = vi.fn();
function makeCmd() {
return createBackupCommand({ client: mockClient as never, log });
}
describe('backup command', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('creates backup command', () => {
const cmd = createBackupCommand({ client: mockClient as never, log });
expect(cmd.name()).toBe('backup');
expect(makeCmd().name()).toBe('backup');
});
it('shows status when enabled', async () => {
@@ -31,8 +34,7 @@ describe('backup command', () => {
pendingCount: 0,
});
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync([], { from: 'user' });
await makeCmd().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'));
@@ -50,8 +52,7 @@ describe('backup command', () => {
pendingCount: 0,
});
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync([], { from: 'user' });
await makeCmd().parseAsync([], { from: 'user' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('disabled'));
});
@@ -67,8 +68,7 @@ describe('backup command', () => {
pendingCount: 5,
});
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync([], { from: 'user' });
await makeCmd().parseAsync([], { from: 'user' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('5 changes pending'));
});
@@ -76,8 +76,7 @@ describe('backup command', () => {
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' });
await makeCmd().parseAsync(['key'], { from: 'user' });
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/key');
expect(log).toHaveBeenCalledWith('ssh-ed25519 AAAA... mcpd@mcpctl.local');
@@ -91,28 +90,20 @@ describe('backup command', () => {
],
});
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync(['log'], { from: 'user' });
await makeCmd().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', () => {
describe('backup restore subcommands', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('creates restore command', () => {
const cmd = createRestoreCommand({ client: mockClient as never, log });
expect(cmd.name()).toBe('restore');
});
it('lists restore points', async () => {
mockClient.get.mockResolvedValue({
entries: [
@@ -120,8 +111,7 @@ describe('restore command', () => {
],
});
const cmd = createRestoreCommand({ client: mockClient as never, log });
await cmd.parseAsync(['list'], { from: 'user' });
await makeCmd().parseAsync(['restore', 'list'], { from: 'user' });
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/log?limit=30');
expect(log).toHaveBeenCalledWith(expect.stringContaining('abc1234'));
@@ -137,8 +127,7 @@ describe('restore command', () => {
modified: ['projects/default.yaml'],
});
const cmd = createRestoreCommand({ client: mockClient as never, log });
await cmd.parseAsync(['diff', 'abc1234'], { from: 'user' });
await makeCmd().parseAsync(['restore', 'diff', 'abc1234'], { from: 'user' });
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore/preview', { commit: 'abc1234' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('+ servers/new.yaml'));
@@ -156,17 +145,14 @@ describe('restore command', () => {
modified: [],
});
const cmd = createRestoreCommand({ client: mockClient as never, log });
await cmd.parseAsync(['to', 'abc1234'], { from: 'user' });
await makeCmd().parseAsync(['restore', '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',
@@ -183,8 +169,7 @@ describe('restore command', () => {
errors: [],
});
const cmd = createRestoreCommand({ client: mockClient as never, log });
await cmd.parseAsync(['to', 'abc1234', '--force'], { from: 'user' });
await makeCmd().parseAsync(['restore', 'to', 'abc1234', '--force'], { from: 'user' });
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore', { commit: 'abc1234' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('1 applied'));
@@ -208,8 +193,7 @@ describe('restore command', () => {
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' });
await makeCmd().parseAsync(['restore', 'to', 'abc1234', '--force'], { from: 'user' });
expect(log).toHaveBeenCalledWith('Errors:');
expect(log).toHaveBeenCalledWith(expect.stringContaining('invalid YAML'));

View File

@@ -119,7 +119,7 @@ describe('fish completions', () => {
});
it('non-project commands do not show with --project', () => {
const nonProjectCmds = ['status', 'login', 'logout', 'config', 'apply', 'backup', 'restore'];
const nonProjectCmds = ['status', 'login', 'logout', 'config', 'apply', 'backup'];
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('-a '));
for (const cmd of nonProjectCmds) {

View File

@@ -22,7 +22,6 @@ describe('CLI command registration (e2e)', () => {
expect(commandNames).toContain('create');
expect(commandNames).toContain('edit');
expect(commandNames).toContain('backup');
expect(commandNames).toContain('restore');
});
it('old project and claude top-level commands are removed', () => {