From cdabef6429ecdbabe5b236a5f53642e7f55a187f Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:24:35 +0200 Subject: [PATCH] fix(sdk): report the connected server's real version in dev version check (#22670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The `yarn twenty app dev` version row was reading the "local server" version from the `twenty-app-dev` Docker container's baked-in `APP_VERSION` env var. When the CLI is actually pointed at a separately running instance (or a stale `twenty-app-dev` container is lying around), this reported a version unrelated to the server serving requests — e.g. showing `Server v2.5.3` and a bogus "days behind" warning while the real instance was on `2.16.1`. This changes the version resolution to ask the server the CLI is connected to for its real version over HTTP, falling back to Docker inspection only when the server can't be reached. - Add `getServerVersionFromApi`, which reads the version from the public, no-auth `/.well-known/mcp/server-card.json` endpoint (its `version` is the server's `APP_VERSION`). Returns `null` gracefully on timeout, non-OK responses, or missing/`0.0.0`/non-semver values. - `getVersionInfo` now uses `getServerVersionFromApi() ?? getLocalServerVersion(containerName)`, running the API call in parallel with the Docker Hub published-versions fetch. All downstream logic (`isMinorOrMajorBehind`, `daysBehind`, the dev UI row, and the headless warning) now operates on the real version. --- .../get-server-version-from-api.test.ts | 108 ++++++++++++++++++ .../version/get-server-version-from-api.ts | 47 ++++++++ .../cli/utilities/version/get-version-info.ts | 60 +++++++++- 3 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/version/__tests__/get-server-version-from-api.test.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/get-server-version-from-api.ts 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;