feat: add instance lifecycle management with restart, inspect, and CLI commands

Adds restart/inspect methods to InstanceService, state validation for stop,
REST endpoints for restart and inspect, and full CLI command suite for
instance list/start/stop/restart/remove/logs/inspect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-21 05:11:48 +00:00
parent 7c07749580
commit 4d796e2aa7
7 changed files with 386 additions and 3 deletions

View File

@@ -0,0 +1,123 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
interface Instance {
id: string;
serverId: string;
status: string;
containerId: string | null;
port: number | null;
createdAt: string;
}
export interface InstanceCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createInstanceCommands(deps: InstanceCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('instance')
.alias('instances')
.alias('inst')
.description('Manage MCP server instances');
cmd
.command('list')
.alias('ls')
.description('List running instances')
.option('-s, --server <id>', 'Filter by server ID')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (opts: { server?: string; output: string }) => {
let url = '/api/v1/instances';
if (opts.server) {
url += `?serverId=${encodeURIComponent(opts.server)}`;
}
const instances = await client.get<Instance[]>(url);
if (opts.output === 'json') {
log(JSON.stringify(instances, null, 2));
return;
}
if (instances.length === 0) {
log('No instances found.');
return;
}
log('ID\tSERVER\tSTATUS\tPORT\tCONTAINER');
for (const inst of instances) {
const cid = inst.containerId ? inst.containerId.slice(0, 12) : '-';
const port = inst.port ?? '-';
log(`${inst.id}\t${inst.serverId}\t${inst.status}\t${port}\t${cid}`);
}
});
cmd
.command('start <serverId>')
.description('Start a new MCP server instance')
.option('-p, --port <port>', 'Host port to bind')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (serverId: string, opts: { port?: string; output: string }) => {
const body: Record<string, unknown> = { serverId };
if (opts.port !== undefined) {
body.hostPort = parseInt(opts.port, 10);
}
const instance = await client.post<Instance>('/api/v1/instances', body);
if (opts.output === 'json') {
log(JSON.stringify(instance, null, 2));
return;
}
log(`Instance ${instance.id} started (status: ${instance.status})`);
});
cmd
.command('stop <id>')
.description('Stop a running instance')
.action(async (id: string) => {
const instance = await client.post<Instance>(`/api/v1/instances/${id}/stop`);
log(`Instance ${id} stopped (status: ${instance.status})`);
});
cmd
.command('restart <id>')
.description('Restart an instance (stop, remove, start fresh)')
.action(async (id: string) => {
const instance = await client.post<Instance>(`/api/v1/instances/${id}/restart`);
log(`Instance restarted as ${instance.id} (status: ${instance.status})`);
});
cmd
.command('remove <id>')
.alias('rm')
.description('Remove an instance and its container')
.action(async (id: string) => {
await client.delete(`/api/v1/instances/${id}`);
log(`Instance ${id} removed.`);
});
cmd
.command('logs <id>')
.description('Get logs from an instance')
.option('-t, --tail <lines>', 'Number of lines to show')
.action(async (id: string, opts: { tail?: string }) => {
let url = `/api/v1/instances/${id}/logs`;
if (opts.tail) {
url += `?tail=${opts.tail}`;
}
const logs = await client.get<{ stdout: string; stderr: string }>(url);
if (logs.stdout) {
log(logs.stdout);
}
if (logs.stderr) {
process.stderr.write(logs.stderr);
}
});
cmd
.command('inspect <id>')
.description('Get detailed container info for an instance')
.action(async (id: string) => {
const info = await client.get(`/api/v1/instances/${id}/inspect`);
log(JSON.stringify(info, null, 2));
});
return cmd;
}

View File

@@ -5,6 +5,7 @@ import { createConfigCommand } from './commands/config.js';
import { createStatusCommand } from './commands/status.js';
import { createGetCommand } from './commands/get.js';
import { createDescribeCommand } from './commands/describe.js';
import { createInstanceCommands } from './commands/instances.js';
import { ApiClient } from './api-client.js';
import { loadConfig } from './config/index.js';
@@ -46,6 +47,11 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
program.addCommand(createInstanceCommands({
client,
log: (...args) => console.log(...args),
}));
return program;
}

View File

@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createInstanceCommands } from '../../src/commands/instances.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('instance commands', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
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"');
});
});
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 });
});
});
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');
});
});
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');
});
});
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');
});
});
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');
});
});
});