feat: add get and describe commands with API client

kubectl-style get (table/json/yaml) and describe commands for servers,
profiles, projects, instances. ApiClient for daemon communication.
118 CLI tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-21 04:55:45 +00:00
parent d1390313a3
commit 1b8b886995
7 changed files with 566 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import { ApiClient, ApiError } from '../src/api-client.js';
let server: http.Server;
let port: number;
beforeAll(async () => {
server = http.createServer((req, res) => {
if (req.url === '/api/v1/servers' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 'srv-1', name: 'slack' }]));
} else if (req.url === '/api/v1/servers/srv-1' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'srv-1', name: 'slack', transport: 'STDIO' }));
} else if (req.url === '/api/v1/servers' && req.method === 'POST') {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
const body = JSON.parse(Buffer.concat(chunks).toString());
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'srv-new', ...body }));
});
} else if (req.url === '/api/v1/missing' && req.method === 'GET') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
} else {
res.writeHead(404);
res.end();
}
});
await new Promise<void>((resolve) => {
server.listen(0, () => {
const addr = server.address();
if (addr && typeof addr === 'object') {
port = addr.port;
}
resolve();
});
});
});
afterAll(() => {
server.close();
});
describe('ApiClient', () => {
it('performs GET request for list', async () => {
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
const result = await client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
expect(result).toEqual([{ id: 'srv-1', name: 'slack' }]);
});
it('performs GET request for single item', async () => {
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
const result = await client.get<{ id: string; name: string }>('/api/v1/servers/srv-1');
expect(result.name).toBe('slack');
});
it('performs POST request', async () => {
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
const result = await client.post<{ id: string; name: string }>('/api/v1/servers', { name: 'github' });
expect(result.id).toBe('srv-new');
expect(result.name).toBe('github');
});
it('throws ApiError on 404', async () => {
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
await expect(client.get('/api/v1/missing')).rejects.toThrow(ApiError);
});
it('throws on connection error', async () => {
const client = new ApiClient({ baseUrl: 'http://localhost:1' });
await expect(client.get('/anything')).rejects.toThrow();
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from 'vitest';
import { createDescribeCommand } from '../../src/commands/describe.js';
import type { DescribeCommandDeps } from '../../src/commands/describe.js';
function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } {
const output: string[] = [];
return {
output,
fetchResource: vi.fn(async () => item),
log: (...args: string[]) => output.push(args.join(' ')),
};
}
describe('describe command', () => {
it('shows detailed server info', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'slack',
transport: 'STDIO',
packageName: '@slack/mcp',
dockerImage: null,
envTemplate: [],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
const text = deps.output.join('\n');
expect(text).toContain('--- Server ---');
expect(text).toContain('name: slack');
expect(text).toContain('transport: STDIO');
expect(text).toContain('dockerImage: -');
});
it('resolves resource aliases', async () => {
const deps = makeDeps({ id: 'p1' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'prof', 'p1']);
expect(deps.fetchResource).toHaveBeenCalledWith('profiles', 'p1');
});
it('outputs JSON format', async () => {
const deps = makeDeps({ id: 'srv-1', name: 'slack' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed.name).toBe('slack');
});
it('outputs YAML format', async () => {
const deps = makeDeps({ id: 'srv-1', name: 'slack' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: slack');
});
it('formats nested objects', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'slack',
metadata: { version: '1.0', nested: { deep: true } },
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
const text = deps.output.join('\n');
expect(text).toContain('metadata:');
expect(text).toContain('version: 1.0');
});
it('formats arrays correctly', async () => {
const deps = makeDeps({
id: 'srv-1',
permissions: ['read', 'write'],
envTemplate: [],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
const text = deps.output.join('\n');
expect(text).toContain('permissions: read, write');
expect(text).toContain('envTemplate: []');
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { createGetCommand } from '../../src/commands/get.js';
import type { GetCommandDeps } from '../../src/commands/get.js';
function makeDeps(items: unknown[] = []): GetCommandDeps & { output: string[] } {
const output: string[] = [];
return {
output,
fetchResource: vi.fn(async () => items),
log: (...args: string[]) => output.push(args.join(' ')),
};
}
describe('get command', () => {
it('lists servers in table format', async () => {
const deps = makeDeps([
{ id: 'srv-1', name: 'slack', transport: 'STDIO', packageName: '@slack/mcp', dockerImage: null },
{ id: 'srv-2', name: 'github', transport: 'SSE', packageName: null, dockerImage: 'ghcr.io/github-mcp' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('TRANSPORT');
expect(deps.output.join('\n')).toContain('slack');
expect(deps.output.join('\n')).toContain('github');
});
it('resolves resource aliases', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'srv']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined);
});
it('passes ID when provided', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', 'srv-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
});
it('outputs JSON format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed).toEqual({ id: 'srv-1', name: 'slack' });
});
it('outputs YAML format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: slack');
});
it('lists profiles with correct columns', async () => {
const deps = makeDeps([
{ id: 'p1', name: 'default', serverId: 'srv-1' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'profiles']);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('SERVER ID');
});
it('lists instances with correct columns', async () => {
const deps = makeDeps([
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'instances']);
expect(deps.output[0]).toContain('STATUS');
expect(deps.output.join('\n')).toContain('RUNNING');
});
it('shows no results message for empty list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.output[0]).toContain('No results');
});
});