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:
@@ -14,6 +14,7 @@ const ServerSpecSchema = z.object({
|
||||
externalUrl: z.string().url().optional(),
|
||||
command: z.array(z.string()).optional(),
|
||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
envTemplate: z.array(z.object({
|
||||
name: z.string(),
|
||||
description: z.string().default(''),
|
||||
|
||||
54
src/cli/src/commands/delete.ts
Normal file
54
src/cli/src/commands/delete.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface DeleteCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createDeleteCommand(deps: DeleteCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
return new Command('delete')
|
||||
.description('Delete a resource (server, instance, profile, project)')
|
||||
.argument('<resource>', 'resource type')
|
||||
.argument('<id>', 'resource ID or name')
|
||||
.action(async (resourceArg: string, idOrName: string) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
|
||||
// Try to resolve name → ID for servers
|
||||
let id = idOrName;
|
||||
if (resource === 'servers' && !idOrName.match(/^c[a-z0-9]{24}/)) {
|
||||
try {
|
||||
const servers = await client.get<Array<{ id: string; name: string }>>(`/api/v1/${resource}`);
|
||||
const match = servers.find((s) => s.name === idOrName);
|
||||
if (match) {
|
||||
id = match.id;
|
||||
}
|
||||
} catch {
|
||||
// Fall through with original id
|
||||
}
|
||||
}
|
||||
|
||||
await client.delete(`/api/v1/${resource}/${id}`);
|
||||
|
||||
const singular = resource.replace(/s$/, '');
|
||||
log(`${singular} '${idOrName}' deleted.`);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { formatJson, formatYaml } from '../formatters/output.js';
|
||||
|
||||
export interface DescribeCommandDeps {
|
||||
fetchResource: (resource: string, id: string) => Promise<unknown>;
|
||||
fetchInspect?: (id: string) => Promise<unknown>;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
@@ -59,7 +60,17 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
.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);
|
||||
const item = await deps.fetchResource(resource, id) as Record<string, unknown>;
|
||||
|
||||
// Enrich instances with container inspect data
|
||||
if (resource === 'instances' && deps.fetchInspect && item.containerId) {
|
||||
try {
|
||||
const inspect = await deps.fetchInspect(id);
|
||||
item.containerInspect = inspect;
|
||||
} catch {
|
||||
// Container may not be available
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.output === 'json') {
|
||||
deps.log(formatJson(item));
|
||||
@@ -68,7 +79,7 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
} 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>));
|
||||
deps.log(formatDetail(item));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
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;
|
||||
}
|
||||
29
src/cli/src/commands/logs.ts
Normal file
29
src/cli/src/commands/logs.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
export interface LogsCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createLogsCommand(deps: LogsCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
return new Command('logs')
|
||||
.description('Get logs from an MCP server instance')
|
||||
.argument('<instance-id>', 'Instance ID')
|
||||
.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -24,30 +24,8 @@ export function createProjectCommand(deps: ProjectCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('project')
|
||||
.alias('projects')
|
||||
.alias('proj')
|
||||
.description('Manage mcpctl projects');
|
||||
|
||||
cmd
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List all projects')
|
||||
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
||||
.action(async (opts: { output: string }) => {
|
||||
const projects = await client.get<Project[]>('/api/v1/projects');
|
||||
if (opts.output === 'json') {
|
||||
log(JSON.stringify(projects, null, 2));
|
||||
return;
|
||||
}
|
||||
if (projects.length === 0) {
|
||||
log('No projects found.');
|
||||
return;
|
||||
}
|
||||
log('ID\tNAME\tDESCRIPTION');
|
||||
for (const p of projects) {
|
||||
log(`${p.id}\t${p.name}\t${p.description || '-'}`);
|
||||
}
|
||||
});
|
||||
.description('Project-specific actions (use "get projects" to list, "delete project" to remove)');
|
||||
|
||||
cmd
|
||||
.command('create <name>')
|
||||
@@ -61,41 +39,6 @@ export function createProjectCommand(deps: ProjectCommandDeps): Command {
|
||||
log(`Project '${project.name}' created (id: ${project.id})`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('delete <id>')
|
||||
.alias('rm')
|
||||
.description('Delete a project')
|
||||
.action(async (id: string) => {
|
||||
await client.delete(`/api/v1/projects/${id}`);
|
||||
log(`Project '${id}' deleted.`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('show <id>')
|
||||
.description('Show project details')
|
||||
.action(async (id: string) => {
|
||||
const project = await client.get<Project>(`/api/v1/projects/${id}`);
|
||||
log(`Name: ${project.name}`);
|
||||
log(`ID: ${project.id}`);
|
||||
log(`Description: ${project.description || '-'}`);
|
||||
log(`Owner: ${project.ownerId}`);
|
||||
log(`Created: ${project.createdAt}`);
|
||||
|
||||
try {
|
||||
const profiles = await client.get<Profile[]>(`/api/v1/projects/${id}/profiles`);
|
||||
if (profiles.length > 0) {
|
||||
log('\nProfiles:');
|
||||
for (const p of profiles) {
|
||||
log(` - ${p.name} (id: ${p.id})`);
|
||||
}
|
||||
} else {
|
||||
log('\nNo profiles assigned.');
|
||||
}
|
||||
} catch {
|
||||
// Profiles endpoint may not be available
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('profiles <id>')
|
||||
.description('List profiles assigned to a project')
|
||||
|
||||
@@ -5,7 +5,8 @@ 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 { createDeleteCommand } from './commands/delete.js';
|
||||
import { createLogsCommand } from './commands/logs.js';
|
||||
import { createApplyCommand } from './commands/apply.js';
|
||||
import { createSetupCommand } from './commands/setup.js';
|
||||
import { createClaudeCommand } from './commands/claude.js';
|
||||
@@ -64,10 +65,16 @@ export function createProgram(): Command {
|
||||
|
||||
program.addCommand(createDescribeCommand({
|
||||
fetchResource: fetchSingleResource,
|
||||
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createInstanceCommands({
|
||||
program.addCommand(createDeleteCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createLogsCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user