Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 24 additions & 8 deletions src/__tests__/coolify-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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' }));

Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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 }),
Expand All @@ -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' }));

Expand All @@ -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([]));

Expand All @@ -3608,7 +3624,7 @@ describe('CoolifyClient', () => {
];
mockFetch
.mockResolvedValueOnce(mockResponse(mockApp))
.mockResolvedValueOnce(mockResponse(mockLogs))
.mockResolvedValueOnce(mockResponse({ logs: mockLogs }))
.mockResolvedValueOnce(mockResponse(mockEnvVars))
.mockResolvedValueOnce(mockResponse(failedDeployments));

Expand Down Expand Up @@ -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));

Expand All @@ -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));

Expand Down
27 changes: 26 additions & 1 deletion src/lib/coolify-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -855,7 +877,10 @@ export class CoolifyClient {
}

async getApplicationLogs(uuid: string, lines: number = 200): Promise<string> {
return this.request<string>(`/applications/${encodeURIComponent(uuid)}/logs?lines=${lines}`);
const raw = await this.request<unknown>(
`/applications/${encodeURIComponent(uuid)}/logs?lines=${lines}`,
);
return normalizeApplicationLogsResponse(raw);
}

async startApplication(
Expand Down
Loading