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:
91
src/cli/src/api-client.ts
Normal file
91
src/cli/src/api-client.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import http from 'node:http';
|
||||
|
||||
export interface ApiClientOptions {
|
||||
baseUrl: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
status: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: string,
|
||||
) {
|
||||
super(`API error ${status}: ${body}`);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function request<T>(method: string, url: string, timeout: number, body?: unknown): Promise<ApiResponse<T>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const opts: http.RequestOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method,
|
||||
timeout,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
|
||||
const req = http.request(opts, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
const status = res.statusCode ?? 0;
|
||||
if (status >= 400) {
|
||||
reject(new ApiError(status, raw));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve({ status, data: JSON.parse(raw) as T });
|
||||
} catch {
|
||||
resolve({ status, data: raw as unknown as T });
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Request to ${url} timed out`));
|
||||
});
|
||||
if (body !== undefined) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
|
||||
constructor(opts: ApiClientOptions) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||
this.timeout = opts.timeout ?? 10000;
|
||||
}
|
||||
|
||||
async get<T = unknown>(path: string): Promise<T> {
|
||||
const res = await request<T>('GET', `${this.baseUrl}${path}`, this.timeout);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async post<T = unknown>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await request<T>('POST', `${this.baseUrl}${path}`, this.timeout, body);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async put<T = unknown>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await request<T>('PUT', `${this.baseUrl}${path}`, this.timeout, body);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
await request('DELETE', `${this.baseUrl}${path}`, this.timeout);
|
||||
}
|
||||
}
|
||||
74
src/cli/src/commands/describe.ts
Normal file
74
src/cli/src/commands/describe.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Command } from 'commander';
|
||||
import { formatJson, formatYaml } from '../formatters/output.js';
|
||||
|
||||
export interface DescribeCommandDeps {
|
||||
fetchResource: (resource: string, id: string) => Promise<unknown>;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
const RESOURCE_ALIASES: Record<string, string> = {
|
||||
server: 'servers',
|
||||
srv: 'servers',
|
||||
profile: 'profiles',
|
||||
prof: 'profiles',
|
||||
project: 'projects',
|
||||
proj: 'projects',
|
||||
instance: 'instances',
|
||||
inst: 'instances',
|
||||
};
|
||||
|
||||
function resolveResource(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
return RESOURCE_ALIASES[lower] ?? lower;
|
||||
}
|
||||
|
||||
function formatDetail(obj: Record<string, unknown>, indent = 0): string {
|
||||
const pad = ' '.repeat(indent);
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === null || value === undefined) {
|
||||
lines.push(`${pad}${key}: -`);
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
lines.push(`${pad}${key}: []`);
|
||||
} else if (typeof value[0] === 'object') {
|
||||
lines.push(`${pad}${key}:`);
|
||||
for (const item of value) {
|
||||
lines.push(`${pad} - ${JSON.stringify(item)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${pad}${key}: ${value.join(', ')}`);
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
lines.push(`${pad}${key}:`);
|
||||
lines.push(formatDetail(value as Record<string, unknown>, indent + 1));
|
||||
} else {
|
||||
lines.push(`${pad}${key}: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
return new Command('describe')
|
||||
.description('Show detailed information about a resource')
|
||||
.argument('<resource>', 'resource type (server, profile, project, instance)')
|
||||
.argument('<id>', 'resource ID')
|
||||
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
|
||||
.action(async (resourceArg: string, id: string, opts: { output: string }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
const item = await deps.fetchResource(resource, id);
|
||||
|
||||
if (opts.output === 'json') {
|
||||
deps.log(formatJson(item));
|
||||
} else if (opts.output === 'yaml') {
|
||||
deps.log(formatYaml(item));
|
||||
} else {
|
||||
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1);
|
||||
deps.log(`--- ${typeName} ---`);
|
||||
deps.log(formatDetail(item as Record<string, unknown>));
|
||||
}
|
||||
});
|
||||
}
|
||||
122
src/cli/src/commands/get.ts
Normal file
122
src/cli/src/commands/get.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Command } from 'commander';
|
||||
import { formatTable } from '../formatters/table.js';
|
||||
import { formatJson, formatYaml } from '../formatters/output.js';
|
||||
import type { Column } from '../formatters/table.js';
|
||||
|
||||
export interface GetCommandDeps {
|
||||
fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
interface ServerRow {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: string;
|
||||
packageName: string | null;
|
||||
dockerImage: string | null;
|
||||
}
|
||||
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
name: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
interface InstanceRow {
|
||||
id: string;
|
||||
serverId: string;
|
||||
status: string;
|
||||
containerId: string | null;
|
||||
port: number | null;
|
||||
}
|
||||
|
||||
const RESOURCE_ALIASES: Record<string, string> = {
|
||||
server: 'servers',
|
||||
srv: 'servers',
|
||||
profile: 'profiles',
|
||||
prof: 'profiles',
|
||||
project: 'projects',
|
||||
proj: 'projects',
|
||||
instance: 'instances',
|
||||
inst: 'instances',
|
||||
};
|
||||
|
||||
function resolveResource(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
return RESOURCE_ALIASES[lower] ?? lower;
|
||||
}
|
||||
|
||||
const serverColumns: Column<ServerRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'TRANSPORT', key: 'transport', width: 16 },
|
||||
{ header: 'PACKAGE', key: (r) => r.packageName ?? '-' },
|
||||
{ header: 'IMAGE', key: (r) => r.dockerImage ?? '-' },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const profileColumns: Column<ProfileRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'SERVER ID', key: 'serverId' },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const projectColumns: Column<ProjectRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'DESCRIPTION', key: 'description', width: 40 },
|
||||
{ header: 'OWNER', key: 'ownerId' },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const instanceColumns: Column<InstanceRow>[] = [
|
||||
{ header: 'STATUS', key: 'status', width: 10 },
|
||||
{ header: 'SERVER ID', key: 'serverId' },
|
||||
{ header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 },
|
||||
{ header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
function getColumnsForResource(resource: string): Column<Record<string, unknown>>[] {
|
||||
switch (resource) {
|
||||
case 'servers':
|
||||
return serverColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'profiles':
|
||||
return profileColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'projects':
|
||||
return projectColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'instances':
|
||||
return instanceColumns as unknown as Column<Record<string, unknown>>[];
|
||||
default:
|
||||
return [
|
||||
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
||||
{ header: 'NAME', key: 'name' as keyof Record<string, unknown> },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function createGetCommand(deps: GetCommandDeps): Command {
|
||||
return new Command('get')
|
||||
.description('List resources (servers, profiles, projects, instances)')
|
||||
.argument('<resource>', 'resource type (servers, profiles, projects, instances)')
|
||||
.argument('[id]', 'specific resource ID')
|
||||
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
|
||||
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
const items = await deps.fetchResource(resource, id);
|
||||
|
||||
if (opts.output === 'json') {
|
||||
deps.log(formatJson(items.length === 1 ? items[0] : items));
|
||||
} else if (opts.output === 'yaml') {
|
||||
deps.log(formatYaml(items.length === 1 ? items[0] : items));
|
||||
} else {
|
||||
const columns = getColumnsForResource(resource);
|
||||
deps.log(formatTable(items as Record<string, unknown>[], columns));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { Command } from 'commander';
|
||||
import { APP_NAME, APP_VERSION } from '@mcpctl/shared';
|
||||
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 { ApiClient } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
|
||||
export function createProgram(): Command {
|
||||
const program = new Command()
|
||||
@@ -15,6 +19,33 @@ export function createProgram(): Command {
|
||||
program.addCommand(createConfigCommand());
|
||||
program.addCommand(createStatusCommand());
|
||||
|
||||
// Create API-backed commands
|
||||
const config = loadConfig();
|
||||
const daemonUrl = program.opts().daemonUrl ?? config.daemonUrl;
|
||||
const client = new ApiClient({ baseUrl: daemonUrl });
|
||||
|
||||
const fetchResource = async (resource: string, id?: string): Promise<unknown[]> => {
|
||||
if (id) {
|
||||
const item = await client.get(`/api/v1/${resource}/${id}`);
|
||||
return [item];
|
||||
}
|
||||
return client.get<unknown[]>(`/api/v1/${resource}`);
|
||||
};
|
||||
|
||||
const fetchSingleResource = async (resource: string, id: string): Promise<unknown> => {
|
||||
return client.get(`/api/v1/${resource}/${id}`);
|
||||
};
|
||||
|
||||
program.addCommand(createGetCommand({
|
||||
fetchResource,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createDescribeCommand({
|
||||
fetchResource: fetchSingleResource,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
|
||||
77
src/cli/tests/api-client.test.ts
Normal file
77
src/cli/tests/api-client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
85
src/cli/tests/commands/describe.test.ts
Normal file
85
src/cli/tests/commands/describe.test.ts
Normal 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: []');
|
||||
});
|
||||
});
|
||||
86
src/cli/tests/commands/get.test.ts
Normal file
86
src/cli/tests/commands/get.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user