diff --git a/packages/twenty-sdk/src/cli/utilities/version/__tests__/get-server-version-from-api.test.ts b/packages/twenty-sdk/src/cli/utilities/version/__tests__/get-server-version-from-api.test.ts new file mode 100644 index 0000000000..ab312d0c37 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/__tests__/get-server-version-from-api.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getServerVersionFromApi } from '@/cli/utilities/version/get-server-version-from-api'; + +const { mockedGetConfig } = vi.hoisted(() => ({ + mockedGetConfig: vi.fn(), +})); + +vi.mock('@/cli/utilities/config/config-service', () => ({ + ConfigService: class { + getConfig = mockedGetConfig; + }, +})); + +const mockFetch = ( + impl: ( + input: string, + ) => Promise<{ ok: boolean; json: () => Promise }>, +) => { + global.fetch = vi.fn(impl as unknown as typeof fetch); +}; + +describe('getServerVersionFromApi', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + mockedGetConfig.mockResolvedValue({ apiUrl: 'http://localhost:2020' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + }); + + it('returns the version reported by the server card endpoint', async () => { + mockFetch(async () => ({ + ok: true, + json: async () => ({ version: '2.16.1' }), + })); + + expect(await getServerVersionFromApi()).toBe('2.16.1'); + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:2020/.well-known/mcp/server-card.json', + expect.anything(), + ); + }); + + it('uses the provided apiUrl over the config, stripping a trailing slash', async () => { + mockFetch(async () => ({ + ok: true, + json: async () => ({ version: 'v2.19.0' }), + })); + + expect(await getServerVersionFromApi('http://example.com/')).toBe('2.19.0'); + expect(mockedGetConfig).not.toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/.well-known/mcp/server-card.json', + expect.anything(), + ); + }); + + it('returns null for the 0.0.0 fallback version', async () => { + mockFetch(async () => ({ + ok: true, + json: async () => ({ version: '0.0.0' }), + })); + + expect(await getServerVersionFromApi()).toBeNull(); + }); + + it('returns null for a non-semver version', async () => { + mockFetch(async () => ({ + ok: true, + json: async () => ({ version: 'latest' }), + })); + + expect(await getServerVersionFromApi()).toBeNull(); + }); + + it('returns null when the version field is missing', async () => { + mockFetch(async () => ({ ok: true, json: async () => ({}) })); + + expect(await getServerVersionFromApi()).toBeNull(); + }); + + it('returns null on a non-ok response', async () => { + mockFetch(async () => ({ ok: false, json: async () => ({}) })); + + expect(await getServerVersionFromApi()).toBeNull(); + }); + + it('returns null when the request throws', async () => { + global.fetch = vi.fn(async () => { + throw new Error('network error'); + }) as unknown as typeof fetch; + + expect(await getServerVersionFromApi()).toBeNull(); + }); + + it('returns null when no apiUrl is configured', async () => { + mockedGetConfig.mockResolvedValue({ apiUrl: '' }); + global.fetch = vi.fn() as unknown as typeof fetch; + + expect(await getServerVersionFromApi()).toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/version/get-server-version-from-api.ts b/packages/twenty-sdk/src/cli/utilities/version/get-server-version-from-api.ts new file mode 100644 index 0000000000..2e1ef3e41d --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/get-server-version-from-api.ts @@ -0,0 +1,47 @@ +import { ConfigService } from '@/cli/utilities/config/config-service'; +import { parseSemver } from '@/cli/utilities/version/parse-semver'; + +const SERVER_CARD_PATH = '/.well-known/mcp/server-card.json'; +const FETCH_TIMEOUT_MS = 3000; + +export const getServerVersionFromApi = async ( + apiUrl?: string, +): Promise => { + const baseUrl = apiUrl ?? (await new ConfigService().getConfig()).apiUrl; + + if (!baseUrl) { + return null; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch( + `${baseUrl.replace(/\/$/, '')}${SERVER_CARD_PATH}`, + { signal: controller.signal }, + ); + + if (!response.ok) { + return null; + } + + const body = (await response.json()) as { version?: unknown }; + + if (typeof body.version !== 'string') { + return null; + } + + const version = body.version.trim().replace(/^v/, ''); + + if (version === '0.0.0' || parseSemver(version) === null) { + return null; + } + + return version; + } catch { + return null; + } finally { + clearTimeout(timeoutId); + } +}; diff --git a/packages/twenty-sdk/src/cli/utilities/version/get-version-info.ts b/packages/twenty-sdk/src/cli/utilities/version/get-version-info.ts index 24d485a737..5a11bc7039 100644 --- a/packages/twenty-sdk/src/cli/utilities/version/get-version-info.ts +++ b/packages/twenty-sdk/src/cli/utilities/version/get-version-info.ts @@ -1,11 +1,66 @@ -import { CONTAINER_NAME } from '@/cli/utilities/server/docker-container'; +import { ConfigService } from '@/cli/utilities/config/config-service'; +import { + CONTAINER_NAME, + getContainerPort, + isContainerRunning, +} from '@/cli/utilities/server/docker-container'; import { compareSemver } from '@/cli/utilities/version/compare-semver'; import { getLocalServerVersion } from '@/cli/utilities/version/get-local-server-version'; import { getPublishedServerVersions } from '@/cli/utilities/version/get-published-server-versions'; +import { getServerVersionFromApi } from '@/cli/utilities/version/get-server-version-from-api'; import { parseSemver } from '@/cli/utilities/version/parse-semver'; import { type VersionInfo } from '@/cli/utilities/version/version-info'; import sdkPackageJson from '../../../../package.json'; +const LOCAL_REMOTE_NAME = 'local'; + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0']); + +const isLoopbackHost = (hostname: string): boolean => + LOOPBACK_HOSTS.has(hostname); + +const isContainerServingApiUrl = async ( + containerName: string, +): Promise => { + if (ConfigService.getActiveRemote() !== LOCAL_REMOTE_NAME) { + return false; + } + + if (!isContainerRunning(containerName)) { + return false; + } + + try { + const { apiUrl } = await new ConfigService().getConfig(); + const { hostname, port: apiPort } = new URL(apiUrl); + + return ( + isLoopbackHost(hostname) && + apiPort !== '' && + String(getContainerPort(containerName)) === apiPort + ); + } catch { + return false; + } +}; + +const resolveLocalServerVersion = async ( + containerName: string, +): Promise => { + if (await isContainerServingApiUrl(containerName)) { + const dockerVersion = await getLocalServerVersion(containerName); + + if (dockerVersion !== null) { + return dockerVersion; + } + } + + return ( + (await getServerVersionFromApi()) ?? + (await getLocalServerVersion(containerName)) + ); +}; + const MS_PER_DAY = 24 * 60 * 60 * 1000; // Fallback for versions older than the most recent ~100 Docker Hub tags: @@ -17,8 +72,9 @@ export const getVersionInfo = async ( containerName: string = CONTAINER_NAME, ): Promise => { const cliVersion = sdkPackageJson.version; + const [localServerVersion, publishedVersions] = await Promise.all([ - getLocalServerVersion(containerName), + resolveLocalServerVersion(containerName), getPublishedServerVersions(), ]); const latestServerVersion = publishedVersions[0]?.name ?? null;