fix(sdk): report the connected server's real version in dev version check (#22670)
## 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.
This commit is contained in:
+108
@@ -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<unknown> }>,
|
||||
) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -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<boolean> => {
|
||||
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<string | null> => {
|
||||
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<VersionInfo> => {
|
||||
const cliVersion = sdkPackageJson.version;
|
||||
|
||||
const [localServerVersion, publishedVersions] = await Promise.all([
|
||||
getLocalServerVersion(containerName),
|
||||
resolveLocalServerVersion(containerName),
|
||||
getPublishedServerVersions(),
|
||||
]);
|
||||
const latestServerVersion = publishedVersions[0]?.name ?? null;
|
||||
|
||||
Reference in New Issue
Block a user