diff --git a/src/cli/src/commands/logs.ts b/src/cli/src/commands/logs.ts index 8454426..2ea75be 100644 --- a/src/cli/src/commands/logs.ts +++ b/src/cli/src/commands/logs.ts @@ -6,31 +6,65 @@ export interface LogsCommandDeps { log: (...args: unknown[]) => void; } +interface InstanceInfo { + id: string; + status: string; + containerId: string | null; +} + /** * Resolve a name/ID to an instance ID. * Accepts: instance ID, server name, or server ID. - * For servers, picks the first RUNNING instance. + * For servers with multiple replicas, picks by --instance index or first RUNNING. */ -async function resolveInstanceId(client: ApiClient, nameOrId: string): Promise { +async function resolveInstance( + client: ApiClient, + nameOrId: string, + instanceIndex?: number, +): Promise<{ instanceId: string; serverName?: string; replicaInfo?: string }> { // Try as instance ID first try { await client.get(`/api/v1/instances/${nameOrId}`); - return nameOrId; + return { instanceId: nameOrId }; } catch { // Not a valid instance ID } - // Try as server name → find its instances + // Try as server name/ID → find its instances const servers = await client.get>('/api/v1/servers'); const server = servers.find((s) => s.name === nameOrId || s.id === nameOrId); - if (server) { - const instances = await client.get>(`/api/v1/instances?serverId=${server.id}`); - const running = instances.find((i) => i.status === 'RUNNING') ?? instances[0]; - if (running) return running.id; - throw new Error(`No instances found for server '${nameOrId}'`); + if (!server) { + throw new Error(`Instance or server '${nameOrId}' not found`); } - throw new Error(`Instance or server '${nameOrId}' not found`); + const instances = await client.get(`/api/v1/instances?serverId=${server.id}`); + if (instances.length === 0) { + throw new Error(`No instances found for server '${server.name}'`); + } + + // Select by index or pick first running + let selected: InstanceInfo | undefined; + if (instanceIndex !== undefined) { + if (instanceIndex < 0 || instanceIndex >= instances.length) { + throw new Error(`Instance index ${instanceIndex} out of range (server '${server.name}' has ${instances.length} instance${instances.length > 1 ? 's' : ''})`); + } + selected = instances[instanceIndex]; + } else { + selected = instances.find((i) => i.status === 'RUNNING') ?? instances[0]; + } + + if (!selected) { + throw new Error(`No instances found for server '${server.name}'`); + } + + const result: { instanceId: string; serverName?: string; replicaInfo?: string } = { + instanceId: selected.id, + serverName: server.name, + }; + if (instances.length > 1) { + result.replicaInfo = `instance ${instances.indexOf(selected) + 1}/${instances.length}`; + } + return result; } export function createLogsCommand(deps: LogsCommandDeps): Command { @@ -40,8 +74,15 @@ export function createLogsCommand(deps: LogsCommandDeps): Command { .description('Get logs from an MCP server instance') .argument('', 'Server name, server ID, or instance ID') .option('-t, --tail ', 'Number of lines to show') - .action(async (nameOrId: string, opts: { tail?: string }) => { - const instanceId = await resolveInstanceId(client, nameOrId); + .option('-i, --instance ', 'Instance/replica index (0-based, for servers with multiple replicas)') + .action(async (nameOrId: string, opts: { tail?: string; instance?: string }) => { + const instanceIndex = opts.instance !== undefined ? parseInt(opts.instance, 10) : undefined; + const { instanceId, serverName, replicaInfo } = await resolveInstance(client, nameOrId, instanceIndex); + + if (replicaInfo) { + process.stderr.write(`Showing logs for ${serverName} (${replicaInfo})\n`); + } + let url = `/api/v1/instances/${instanceId}/logs`; if (opts.tail) { url += `?tail=${opts.tail}`; diff --git a/src/cli/tests/commands/instances.test.ts b/src/cli/tests/commands/instances.test.ts index ba36851..1778c47 100644 --- a/src/cli/tests/commands/instances.test.ts +++ b/src/cli/tests/commands/instances.test.ts @@ -68,16 +68,79 @@ describe('logs command', () => { output = []; }); - it('shows logs', async () => { - vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' }); + it('shows logs by instance ID', async () => { + vi.mocked(client.get) + .mockResolvedValueOnce({ id: 'inst-1', status: 'RUNNING' } as never) // instance lookup + .mockResolvedValueOnce({ stdout: 'hello world\n', stderr: '' } as never); // logs const cmd = createLogsCommand({ client, log }); await cmd.parseAsync(['inst-1'], { from: 'user' }); + expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1'); expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs'); expect(output.join('\n')).toContain('hello world'); }); + it('resolves server name to instance ID', async () => { + vi.mocked(client.get) + .mockRejectedValueOnce(new Error('not found')) // instance lookup fails + .mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list + .mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING', containerId: 'abc' }] as never) // instances for server + .mockResolvedValueOnce({ stdout: 'grafana logs\n', stderr: '' } as never); // logs + const cmd = createLogsCommand({ client, log }); + await cmd.parseAsync(['my-grafana'], { from: 'user' }); + expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs'); + expect(output.join('\n')).toContain('grafana logs'); + }); + + it('picks RUNNING instance over others', async () => { + vi.mocked(client.get) + .mockRejectedValueOnce(new Error('not found')) + .mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never) + .mockResolvedValueOnce([ + { id: 'inst-err', status: 'ERROR', containerId: null }, + { id: 'inst-ok', status: 'RUNNING', containerId: 'abc' }, + ] as never) + .mockResolvedValueOnce({ stdout: 'running instance\n', stderr: '' } as never); + const cmd = createLogsCommand({ client, log }); + await cmd.parseAsync(['ha-mcp'], { from: 'user' }); + expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-ok/logs'); + }); + + it('selects specific replica with --instance', async () => { + vi.mocked(client.get) + .mockRejectedValueOnce(new Error('not found')) + .mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never) + .mockResolvedValueOnce([ + { id: 'inst-0', status: 'RUNNING', containerId: 'a' }, + { id: 'inst-1', status: 'RUNNING', containerId: 'b' }, + ] as never) + .mockResolvedValueOnce({ stdout: 'replica 1\n', stderr: '' } as never); + const cmd = createLogsCommand({ client, log }); + await cmd.parseAsync(['ha-mcp', '-i', '1'], { from: 'user' }); + expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs'); + }); + + it('throws on out-of-range --instance index', async () => { + vi.mocked(client.get) + .mockRejectedValueOnce(new Error('not found')) + .mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never) + .mockResolvedValueOnce([{ id: 'inst-0', status: 'RUNNING' }] as never); + const cmd = createLogsCommand({ client, log }); + await expect(cmd.parseAsync(['ha-mcp', '-i', '5'], { from: 'user' })).rejects.toThrow('out of range'); + }); + + it('throws when server has no instances', async () => { + vi.mocked(client.get) + .mockRejectedValueOnce(new Error('not found')) + .mockResolvedValueOnce([{ id: 'srv-1', name: 'empty-srv' }] as never) + .mockResolvedValueOnce([] as never); + const cmd = createLogsCommand({ client, log }); + await expect(cmd.parseAsync(['empty-srv'], { from: 'user' })).rejects.toThrow('No instances found'); + }); + it('passes tail option', async () => { - vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' }); + vi.mocked(client.get) + .mockResolvedValueOnce({ id: 'inst-1' } as never) + .mockResolvedValueOnce({ stdout: '', stderr: '' } as never); 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');