diff --git a/CLAUDE.md b/CLAUDE.md index 9e8a6b6..0c40113 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,7 @@ The Coolify OpenAPI docs are unreliable — always test against the real API. Kn - **Validation errors vary in format** — The `errors` field in API error responses can contain `string[]` or plain `string` values. The client handles both. - **`/deployments/applications/{uuid}` returns a wrapper, not an array** — OpenAPI claims `type: array`, but real Coolify returns `{ count, deployments: [...] }`. The client normalizes this in `listApplicationDeployments` (also accepts `{ data: [...] }` and bare arrays as fallbacks). See issue #24. - **`docker_compose_domains` for docker-compose Applications** — Applications using the dockercompose build pack cannot use `domains`/`fqdn`; they require `docker_compose_domains: [{name: "service-name", domain: "https://..."}]`. Passing `domains` returns 422 `The domains field cannot be used for dockercompose applications.`. This is distinct from Coolify Service type (which needs `docker_compose_raw` Traefik labels). See issue #36. +- **`/applications/{uuid}/logs` returns a wrapper, not a bare string** — OpenAPI/naming suggests the endpoint returns raw log text, but real Coolify returns `{ "logs": "..." }`. The client extracts `.logs` in `getApplicationLogs`. See issue #120. ## TypeScript Standards diff --git a/src/__tests__/coolify-client.test.ts b/src/__tests__/coolify-client.test.ts index d766290..b6723cc 100644 --- a/src/__tests__/coolify-client.test.ts +++ b/src/__tests__/coolify-client.test.ts @@ -1841,7 +1841,7 @@ describe('CoolifyClient', () => { }); it('should get application logs', async () => { - mockFetch.mockResolvedValueOnce(mockResponse('log line 1\nlog line 2')); + mockFetch.mockResolvedValueOnce(mockResponse({ logs: 'log line 1\nlog line 2' })); const result = await client.getApplicationLogs('app-uuid', 50); @@ -1852,6 +1852,22 @@ describe('CoolifyClient', () => { ); }); + it('accepts a bare string response for application logs (forward-compat)', async () => { + mockFetch.mockResolvedValueOnce(mockResponse('log line 1\nlog line 2')); + + const result = await client.getApplicationLogs('app-uuid', 50); + + expect(result).toBe('log line 1\nlog line 2'); + }); + + it('throws on unrecognized application logs response shape', async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ unexpected: true })); + + await expect(client.getApplicationLogs('app-uuid', 50)).rejects.toThrow( + /unrecognized response shape/, + ); + }); + it('should restart an application', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Restarted' })); @@ -3521,7 +3537,7 @@ describe('CoolifyClient', () => { it('should aggregate all application data successfully', async () => { mockFetch .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse(mockDeployments)); @@ -3553,7 +3569,7 @@ describe('CoolifyClient', () => { it('completes diagnosis when deployments come back as a wrapper object (issue #24)', async () => { mockFetch .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce( mockResponse({ count: mockDeployments.length, deployments: mockDeployments }), @@ -3572,7 +3588,7 @@ describe('CoolifyClient', () => { try { mockFetch .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse({ foo: 'bar' })); @@ -3591,7 +3607,7 @@ describe('CoolifyClient', () => { const unhealthyApp = { ...mockApp, status: 'exited:unhealthy' }; mockFetch .mockResolvedValueOnce(mockResponse(unhealthyApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse([])); @@ -3608,7 +3624,7 @@ describe('CoolifyClient', () => { ]; mockFetch .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse(failedDeployments)); @@ -3651,7 +3667,7 @@ describe('CoolifyClient', () => { mockFetch .mockResolvedValueOnce(mockResponse(mockApps)) // listApplications for lookup .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse(mockDeployments)); @@ -3671,7 +3687,7 @@ describe('CoolifyClient', () => { mockFetch .mockResolvedValueOnce(mockResponse(mockApps)) // listApplications for lookup .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse(mockDeployments)); diff --git a/src/lib/coolify-client.ts b/src/lib/coolify-client.ts index 98fcb41..b02b24b 100644 --- a/src/lib/coolify-client.ts +++ b/src/lib/coolify-client.ts @@ -374,6 +374,28 @@ function normalizeDeploymentsResponse(raw: unknown): Deployment[] { return []; } +// Coolify normally wraps logs as `{ logs: string }`, but also accepts a bare +// string for forward-compat with any endpoint variant that returns raw text. +function normalizeApplicationLogsResponse(raw: unknown): string { + if (typeof raw === 'string') return raw; + if ( + raw !== null && + typeof raw === 'object' && + typeof (raw as { logs?: unknown }).logs === 'string' + ) { + return (raw as { logs: string }).logs; + } + const summary = + raw === null + ? 'null' + : raw === undefined + ? 'undefined' + : typeof raw === 'object' + ? `object keys=${JSON.stringify(Object.keys(raw as object))}` + : typeof raw; + throw new Error(`[coolify-mcp] getApplicationLogs: unrecognized response shape (${summary})`); +} + function toDeploymentEssential(dep: Deployment): DeploymentEssential { return { uuid: dep.uuid, @@ -855,7 +877,10 @@ export class CoolifyClient { } async getApplicationLogs(uuid: string, lines: number = 200): Promise { - return this.request(`/applications/${encodeURIComponent(uuid)}/logs?lines=${lines}`); + const raw = await this.request( + `/applications/${encodeURIComponent(uuid)}/logs?lines=${lines}`, + ); + return normalizeApplicationLogsResponse(raw); } async startApplication(