feat: kubectl-style CLI + Deployment/Pod model for servers/instances

Server = Deployment (defines what to run + desired replicas)
Instance = Pod (ephemeral, auto-created by reconciliation)

Backend:
- Add replicas field to McpServer schema
- Add reconcile() to InstanceService (scales instances to match replicas)
- Remove manual start/stop/restart - instances are auto-managed
- Cascade: deleting server stops all containers then cascades DB
- Server create/update auto-triggers reconciliation

CLI:
- Add top-level delete command (servers, instances, profiles, projects)
- Add top-level logs command
- Remove instance compound command (use get/delete/logs instead)
- Clean up project command (list/show/delete → top-level get/describe/delete)
- Enhance describe for instances with container inspect info
- Add replicas to apply command's ServerSpec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-22 13:30:46 +00:00
parent 87dce55b94
commit bd09ae9687
21 changed files with 638 additions and 764 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createInstanceCommands } from '../../src/commands/instances.js';
import { createDeleteCommand } from '../../src/commands/delete.js';
import { createLogsCommand } from '../../src/commands/logs.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
@@ -11,7 +12,7 @@ function mockClient(): ApiClient {
} as unknown as ApiClient;
}
describe('instance commands', () => {
describe('delete command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
@@ -21,107 +22,70 @@ describe('instance commands', () => {
output = [];
});
describe('list', () => {
it('shows no instances message when empty', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('No instances found');
});
it('shows instance table', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'ctr-abc123def', port: 3000, createdAt: '2025-01-01' },
]);
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('inst-1');
expect(output.join('\n')).toContain('RUNNING');
});
it('filters by server', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list', '-s', 'srv-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith(expect.stringContaining('serverId=srv-1'));
});
it('outputs json', async () => {
vi.mocked(client.get).mockResolvedValue([{ id: 'inst-1' }]);
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
expect(output[0]).toContain('"id"');
});
it('deletes an instance by ID', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
expect(output.join('\n')).toContain('deleted');
});
describe('start', () => {
it('starts an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['start', 'srv-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1' });
expect(output.join('\n')).toContain('started');
});
it('passes host port', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['start', 'srv-1', '-p', '8080'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1', hostPort: 8080 });
});
it('deletes a server by ID', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['server', 'srv-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
expect(output.join('\n')).toContain('deleted');
});
describe('stop', () => {
it('stops an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-1', status: 'STOPPED' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['stop', 'inst-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/stop');
expect(output.join('\n')).toContain('stopped');
});
it('resolves server name to ID', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-abc', name: 'ha-mcp' },
]);
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc');
});
describe('restart', () => {
it('restarts an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-2', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['restart', 'inst-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/restart');
expect(output.join('\n')).toContain('restarted');
});
it('deletes a profile', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['profile', 'prof-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/profiles/prof-1');
});
describe('remove', () => {
it('removes an instance', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['remove', 'inst-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
expect(output.join('\n')).toContain('removed');
});
it('deletes a project', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['project', 'proj-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
});
describe('logs', () => {
it('shows logs', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['logs', 'inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('hello world');
});
it('passes tail option', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['logs', 'inst-1', '-t', '50'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
});
});
describe('inspect', () => {
it('shows container info as json', async () => {
vi.mocked(client.get).mockResolvedValue({ containerId: 'ctr-abc', state: 'running' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['inspect', 'inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/inspect');
expect(output[0]).toContain('ctr-abc');
});
it('accepts resource aliases', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['srv', 'srv-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
});
});
describe('logs command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('shows logs', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('hello world');
});
it('passes tail option', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
});
});

View File

@@ -21,31 +21,6 @@ describe('project command', () => {
output = [];
});
describe('list', () => {
it('shows no projects message when empty', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('No projects found');
});
it('shows project table', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' },
]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('proj-1');
expect(output.join('\n')).toContain('dev');
});
it('outputs json', async () => {
vi.mocked(client.get).mockResolvedValue([{ id: 'proj-1', name: 'dev' }]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
expect(output[0]).toContain('"id"');
});
});
describe('create', () => {
it('creates a project', async () => {
const cmd = createProjectCommand({ client, log });
@@ -58,28 +33,6 @@ describe('project command', () => {
});
});
describe('delete', () => {
it('deletes a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['delete', 'proj-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
expect(output.join('\n')).toContain('deleted');
});
});
describe('show', () => {
it('shows project details', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url.endsWith('/profiles')) return [];
return { id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' };
});
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['show', 'proj-1'], { from: 'user' });
expect(output.join('\n')).toContain('Name: dev');
expect(output.join('\n')).toContain('ID: proj-1');
});
});
describe('profiles', () => {
it('lists profiles for a project', async () => {
vi.mocked(client.get).mockResolvedValue([

View File

@@ -16,26 +16,18 @@ describe('CLI command registration (e2e)', () => {
expect(commandNames).toContain('logout');
expect(commandNames).toContain('get');
expect(commandNames).toContain('describe');
expect(commandNames).toContain('instance');
expect(commandNames).toContain('delete');
expect(commandNames).toContain('logs');
expect(commandNames).toContain('apply');
expect(commandNames).toContain('setup');
expect(commandNames).toContain('claude');
expect(commandNames).toContain('project');
});
it('instance command has lifecycle subcommands', () => {
it('instance command is removed (use get/delete/logs instead)', () => {
const program = createProgram();
const instance = program.commands.find((c) => c.name() === 'instance');
expect(instance).toBeDefined();
const subcommands = instance!.commands.map((c) => c.name());
expect(subcommands).toContain('list');
expect(subcommands).toContain('start');
expect(subcommands).toContain('stop');
expect(subcommands).toContain('restart');
expect(subcommands).toContain('remove');
expect(subcommands).toContain('logs');
expect(subcommands).toContain('inspect');
const commandNames = program.commands.map((c) => c.name());
expect(commandNames).not.toContain('instance');
});
it('claude command has config management subcommands', () => {
@@ -50,18 +42,19 @@ describe('CLI command registration (e2e)', () => {
expect(subcommands).toContain('remove');
});
it('project command has CRUD subcommands', () => {
it('project command has action subcommands only', () => {
const program = createProgram();
const project = program.commands.find((c) => c.name() === 'project');
expect(project).toBeDefined();
const subcommands = project!.commands.map((c) => c.name());
expect(subcommands).toContain('list');
expect(subcommands).toContain('create');
expect(subcommands).toContain('delete');
expect(subcommands).toContain('show');
expect(subcommands).toContain('profiles');
expect(subcommands).toContain('set-profiles');
// list, show, delete are now top-level (get, describe, delete)
expect(subcommands).not.toContain('list');
expect(subcommands).not.toContain('show');
expect(subcommands).not.toContain('delete');
});
it('displays version', () => {