feat: add create/edit commands, apply-compatible output, better describe

- `create server/profile/project` with all CLI flags (kubectl parity)
- `edit server/profile/project` opens $EDITOR for in-flight editing
- `get -o yaml/json` now outputs apply-compatible format (strips internal fields, wraps in resource key)
- `describe` shows visually clean sectioned output with aligned columns
- Extract shared utilities (resolveResource, resolveNameOrId, stripInternalFields)
- Instances are immutable (no create/edit, like pods)
- Full test coverage for create, edit, and updated describe/get

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-22 14:33:25 +00:00
parent ae1055c4ae
commit e3aba76cc8
14 changed files with 905 additions and 141 deletions

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createCreateCommand } from '../../src/commands/create.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('create command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
describe('create server', () => {
it('creates a server with minimal flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'my-server'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
name: 'my-server',
transport: 'STDIO',
replicas: 1,
}));
expect(output.join('\n')).toContain("server 'test' created");
});
it('creates a server with all flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'server', 'ha-mcp',
'-d', 'Home Assistant MCP',
'--docker-image', 'ghcr.io/ha-mcp:latest',
'--transport', 'STREAMABLE_HTTP',
'--external-url', 'http://localhost:8086/mcp',
'--container-port', '3000',
'--replicas', '2',
'--command', 'python',
'--command', '-c',
'--command', 'print("hello")',
'--env-template', 'API_KEY:API key:true',
'--env-template', 'BASE_URL:Base URL:false',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
name: 'ha-mcp',
description: 'Home Assistant MCP',
dockerImage: 'ghcr.io/ha-mcp:latest',
transport: 'STREAMABLE_HTTP',
externalUrl: 'http://localhost:8086/mcp',
containerPort: 3000,
replicas: 2,
command: ['python', '-c', 'print("hello")'],
envTemplate: [
{ name: 'API_KEY', description: 'API key', isSecret: true },
{ name: 'BASE_URL', description: 'Base URL', isSecret: false },
],
});
});
it('defaults transport to STDIO', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
transport: 'STDIO',
}));
});
});
describe('create profile', () => {
it('creates a profile resolving server name', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-abc', name: 'ha-mcp' },
]);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['profile', 'production', '--server', 'ha-mcp'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
name: 'production',
serverId: 'srv-abc',
}));
});
it('parses --env KEY=value entries', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-1', name: 'test' },
]);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'profile', 'dev',
'--server', 'test',
'--env', 'FOO=bar',
'--env', 'SECRET=s3cr3t',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
}));
});
it('passes permissions', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-1', name: 'test' },
]);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'profile', 'admin',
'--server', 'test',
'--permissions', 'read',
'--permissions', 'write',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
permissions: ['read', 'write'],
}));
});
});
describe('create project', () => {
it('creates a project', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-project', '-d', 'A test project'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project',
description: 'A test project',
});
expect(output.join('\n')).toContain("project 'test' created");
});
it('creates a project with no description', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'minimal'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'minimal',
description: '',
});
});
});
});

View File

@@ -1,18 +1,29 @@
import { describe, it, expect, vi } from 'vitest';
import { createDescribeCommand } from '../../src/commands/describe.js';
import type { DescribeCommandDeps } from '../../src/commands/describe.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;
}
function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } {
const output: string[] = [];
return {
output,
client: mockClient(),
fetchResource: vi.fn(async () => item),
log: (...args: string[]) => output.push(args.join(' ')),
};
}
describe('describe command', () => {
it('shows detailed server info', async () => {
it('shows detailed server info with sections', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'slack',
@@ -20,16 +31,22 @@ describe('describe command', () => {
packageName: '@slack/mcp',
dockerImage: null,
envTemplate: [],
createdAt: '2025-01-01',
});
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: -');
expect(text).toContain('=== Server: slack ===');
expect(text).toContain('Name:');
expect(text).toContain('slack');
expect(text).toContain('Transport:');
expect(text).toContain('STDIO');
expect(text).toContain('Package:');
expect(text).toContain('@slack/mcp');
expect(text).toContain('Metadata:');
expect(text).toContain('ID:');
});
it('resolves resource aliases', async () => {
@@ -55,31 +72,58 @@ describe('describe command', () => {
expect(deps.output[0]).toContain('name: slack');
});
it('formats nested objects', async () => {
it('shows profile with permissions and env overrides', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'slack',
metadata: { version: '1.0', nested: { deep: true } },
id: 'p1',
name: 'production',
serverId: 'srv-1',
permissions: ['read', 'write'],
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
await cmd.parseAsync(['node', 'test', 'profile', 'p1']);
const text = deps.output.join('\n');
expect(text).toContain('metadata:');
expect(text).toContain('version: 1.0');
expect(text).toContain('=== Profile: production ===');
expect(text).toContain('read, write');
expect(text).toContain('Environment Overrides:');
expect(text).toContain('FOO');
expect(text).toContain('bar');
});
it('formats arrays correctly', async () => {
it('shows project detail', async () => {
const deps = makeDeps({
id: 'srv-1',
permissions: ['read', 'write'],
envTemplate: [],
id: 'proj-1',
name: 'my-project',
description: 'A test project',
ownerId: 'user-1',
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
const text = deps.output.join('\n');
expect(text).toContain('permissions: read, write');
expect(text).toContain('envTemplate: []');
expect(text).toContain('=== Project: my-project ===');
expect(text).toContain('A test project');
expect(text).toContain('user-1');
});
it('shows instance detail with container info', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
status: 'RUNNING',
containerId: 'abc123',
port: 3000,
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Instance: inst-1 ===');
expect(text).toContain('RUNNING');
expect(text).toContain('abc123');
});
});

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { readFileSync, writeFileSync } from 'node:fs';
import yaml from 'js-yaml';
import { createEditCommand } from '../../src/commands/edit.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('edit command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('fetches server, opens editor, applies changes on save', async () => {
// GET /api/v1/servers returns list for resolveNameOrId
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') {
return [{ id: 'srv-1', name: 'ha-mcp' }];
}
// GET /api/v1/servers/srv-1 returns full server
return {
id: 'srv-1',
name: 'ha-mcp',
description: 'Old desc',
transport: 'STDIO',
replicas: 1,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
// Simulate user editing the file
const content = readFileSync(filePath, 'utf-8');
const modified = content
.replace('Old desc', 'New desc')
.replace('replicas: 1', 'replicas: 3');
writeFileSync(filePath, modified, 'utf-8');
},
});
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
description: 'New desc',
replicas: 3,
}));
expect(output.join('\n')).toContain("server 'ha-mcp' updated");
});
it('detects no changes and skips PUT', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return {
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: () => {
// Don't modify the file
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain("unchanged");
});
it('handles empty file as cancel', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return { id: 'srv-1', name: 'test', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 };
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
writeFileSync(filePath, '', 'utf-8');
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('cancelled');
});
it('strips read-only fields from editor content', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return {
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
let editorContent = '';
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
editorContent = readFileSync(filePath, 'utf-8');
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
// The editor content should NOT contain read-only fields
expect(editorContent).not.toContain('id:');
expect(editorContent).not.toContain('createdAt');
expect(editorContent).not.toContain('updatedAt');
expect(editorContent).not.toContain('version');
// But should contain editable fields
expect(editorContent).toContain('name:');
});
it('rejects edit instance with error message', async () => {
const cmd = createEditCommand({ client, log });
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
expect(client.get).not.toHaveBeenCalled();
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('immutable');
});
it('edits a profile', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/profiles') return [{ id: 'prof-1', name: 'production' }];
return {
id: 'prof-1', name: 'production', serverId: 'srv-1',
permissions: ['read'], envOverrides: { FOO: 'bar' },
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
const content = readFileSync(filePath, 'utf-8');
const modified = content.replace('FOO: bar', 'FOO: baz');
writeFileSync(filePath, modified, 'utf-8');
},
});
await cmd.parseAsync(['profile', 'production'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/profiles/prof-1', expect.objectContaining({
envOverrides: { FOO: 'baz' },
}));
});
});

View File

@@ -41,20 +41,30 @@ describe('get command', () => {
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
});
it('outputs JSON format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
it('outputs apply-compatible JSON format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
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' });
// Wrapped in resource key, internal fields stripped
expect(parsed).toHaveProperty('servers');
expect(parsed.servers[0].name).toBe('slack');
expect(parsed.servers[0]).not.toHaveProperty('id');
expect(parsed.servers[0]).not.toHaveProperty('createdAt');
expect(parsed.servers[0]).not.toHaveProperty('updatedAt');
expect(parsed.servers[0]).not.toHaveProperty('version');
});
it('outputs YAML format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
it('outputs apply-compatible YAML format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: slack');
const text = deps.output[0];
expect(text).toContain('servers:');
expect(text).toContain('name: slack');
expect(text).not.toContain('id:');
expect(text).not.toContain('createdAt:');
});
it('lists profiles with correct columns', async () => {
@@ -81,6 +91,6 @@ describe('get command', () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.output[0]).toContain('No results');
expect(deps.output[0]).toContain('No servers found');
});
});

View File

@@ -21,18 +21,6 @@ describe('project command', () => {
output = [];
});
describe('create', () => {
it('creates a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['create', 'my-project', '-d', 'A test project'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project',
description: 'A test project',
});
expect(output.join('\n')).toContain("Project 'my-project' created");
});
});
describe('profiles', () => {
it('lists profiles for a project', async () => {
vi.mocked(client.get).mockResolvedValue([

View File

@@ -19,9 +19,13 @@ describe('CLI command registration (e2e)', () => {
expect(commandNames).toContain('delete');
expect(commandNames).toContain('logs');
expect(commandNames).toContain('apply');
expect(commandNames).toContain('create');
expect(commandNames).toContain('edit');
expect(commandNames).toContain('setup');
expect(commandNames).toContain('claude');
expect(commandNames).toContain('project');
expect(commandNames).toContain('backup');
expect(commandNames).toContain('restore');
});
it('instance command is removed (use get/delete/logs instead)', () => {
@@ -48,10 +52,10 @@ describe('CLI command registration (e2e)', () => {
expect(project).toBeDefined();
const subcommands = project!.commands.map((c) => c.name());
expect(subcommands).toContain('create');
expect(subcommands).toContain('profiles');
expect(subcommands).toContain('set-profiles');
// list, show, delete are now top-level (get, describe, delete)
// create is now top-level (mcpctl create project)
expect(subcommands).not.toContain('create');
expect(subcommands).not.toContain('list');
expect(subcommands).not.toContain('show');
expect(subcommands).not.toContain('delete');