feat(sdk): warn when local server image is behind latest (#20352)

Closes #20328.

## Summary
- Adds a CLI-side check that warns when `twenty-app-dev` is older than
the latest published Docker Hub tag.
- Reads `APP_VERSION` from the running container via `docker inspect` —
no server endpoint, no version exposed publicly. (`APP_VERSION` is
already baked in by `packages/twenty-docker/twenty/Dockerfile` for both
`twenty` and `twenty-app-dev` targets.)
- Fetches latest semver tag from Docker Hub (same API the admin panel
already uses) and caches the result for 24h in
`~/.twenty/version-check-cache.json`.
- Wired into `twenty dev`, `twenty install`, and `twenty server start`.
- Best-effort: silent on container-missing / docker / network errors,
never blocks a command.

## Why CLI-side instead of a `/healthz` extension
The original issue suggested comparing the running server version
against Docker Hub. Exposing the running version on a public endpoint
has a small but real security cost (helps attackers fingerprint
vulnerable deployments), and the version is already inside the image —
so the CLI can read it directly without ever calling the server.

## Test plan
- [x] `nx run twenty-sdk:test` — added unit tests for `parseSemver` /
`compareSemver`
- [x] `nx run twenty-sdk:typecheck`
- [x] `nx run twenty-sdk:lint`
- [ ] Manual: with an old `twenty-app-dev` image running, run `yarn
twenty install` → see warning
- [ ] Manual: with an up-to-date image, run `yarn twenty dev` → no
warning, cache file written
- [ ] Manual: no container at all → no warning, no error
This commit is contained in:
Félix Malfait
2026-05-08 14:11:34 +02:00
committed by GitHub
parent 7f4f2e932c
commit 0f8ee5714f
17 changed files with 462 additions and 3 deletions
@@ -5,6 +5,8 @@ import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orc
import { renderDevUI } from '@/cli/utilities/dev/ui/components/dev-ui';
import { DevUiStateManager } from '@/cli/utilities/dev/ui/dev-ui-state-manager';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility';
import { getVersionInfo } from '@/cli/utilities/version/get-version-info';
export type AppDevOptions = {
appPath?: string;
@@ -30,6 +32,10 @@ export class AppDevCommand {
await checkSdkVersionCompatibility(appPath);
if (options.headless) {
await checkServerVersionCompatibility();
}
const config = await new ConfigService().getConfig();
const orchestratorState = new OrchestratorState({
@@ -45,6 +51,10 @@ export class AppDevCommand {
const { unmount } = await renderDevUI(uiStateManager);
this.unmountUI = unmount;
void getVersionInfo().then((info) =>
orchestratorState.setVersionInfo(info),
);
}
this.orchestrator = new DevModeOrchestrator({
@@ -1,6 +1,7 @@
import { appInstall } from '@/cli/operations/install';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility';
import chalk from 'chalk';
export type AppInstallCommandOptions = {
@@ -13,6 +14,7 @@ export class AppInstallCommand {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
await checkServerVersionCompatibility();
console.log(chalk.blue('Installing application...'));
console.log(chalk.gray(`App path: ${appPath}\n`));
@@ -18,6 +18,7 @@ import {
checkServerHealth,
detectLocalServer,
} from '@/cli/utilities/server/detect-local-server';
import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility';
import { execSync, spawn, spawnSync } from 'node:child_process';
import chalk from 'chalk';
@@ -274,10 +275,19 @@ const innerServerStart = async (
return { success: true, data: { port, url } };
};
export const serverStart = (
export const serverStart = async (
options?: ServerStartOptions,
): Promise<CommandResult<ServerStartResult>> =>
runSafe(
): Promise<CommandResult<ServerStartResult>> => {
const result = await runSafe(
() => innerServerStart(options),
SERVER_ERROR_CODES.CONTAINER_START_FAILED,
);
if (result.success) {
const containerName = options?.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
await checkServerVersionCompatibility(containerName);
}
return result;
};
@@ -4,6 +4,7 @@ import { type CheckServerOrchestratorStepOutput } from '@/cli/utilities/dev/orch
import { type StartWatchersOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
import { type SyncApplicationOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
import { type UploadFilesOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
import { type VersionInfo } from '@/cli/utilities/version/version-info';
import { type Manifest, SyncableEntity } from 'twenty-shared/application';
import { type FileFolder } from 'twenty-shared/types';
@@ -114,6 +115,8 @@ export class OrchestratorState {
pipeline: OrchestratorStatePipeline;
versionInfo: VersionInfo | null;
entities: Map<string, OrchestratorStateEntityInfo>;
events: OrchestratorStateEvent[];
@@ -172,10 +175,17 @@ export class OrchestratorState {
appName: null,
};
this.versionInfo = null;
this.entities = new Map();
this.events = [];
}
setVersionInfo(versionInfo: VersionInfo): void {
this.versionInfo = versionInfo;
this.notify();
}
notify(): void {
this.onChange?.();
}
@@ -17,6 +17,7 @@ import {
DevUiEntitySection,
ENTITY_ORDER,
} from '@/cli/utilities/dev/ui/components/dev-ui-entity-section';
import { DevUiVersionRow } from '@/cli/utilities/dev/ui/components/dev-ui-version-row';
import React from 'react';
export const DevUiSyncStatusIndicator = ({
@@ -96,6 +97,7 @@ export const DevUiApplicationPanel = ({
</Text>
</Box>
)}
<DevUiVersionRow versionInfo={state.versionInfo} />
</Box>
<Box marginLeft={2} flexDirection="column" marginTop={1}>
@@ -0,0 +1,60 @@
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
import { type VersionInfo } from '@/cli/utilities/version/version-info';
import React from 'react';
const STALE_THRESHOLD_DAYS = 7;
export const DevUiVersionRow = ({
versionInfo,
}: {
versionInfo: VersionInfo | null;
}): React.ReactElement | null => {
const { Box, Text } = useInk();
if (versionInfo === null) {
return null;
}
const {
cliVersion,
localServerVersion,
latestServerVersion,
isMinorOrMajorBehind,
daysBehind,
} = versionInfo;
const isStale =
isMinorOrMajorBehind &&
daysBehind !== null &&
daysBehind > STALE_THRESHOLD_DAYS;
const isUpToDate =
localServerVersion !== null &&
latestServerVersion !== null &&
localServerVersion === latestServerVersion;
return (
<Box>
<Text dimColor>Versions: </Text>
<Text dimColor>CLI </Text>
<Text>v{cliVersion}</Text>
{localServerVersion !== null && (
<>
<Text dimColor> Server </Text>
<Text color={isStale ? 'yellow' : undefined}>
v{localServerVersion}
</Text>
{isUpToDate && <Text color="green"> latest</Text>}
{isStale && latestServerVersion !== null && (
<>
<Text color="yellow"> v{latestServerVersion}</Text>
{daysBehind !== null && (
<Text dimColor> ({daysBehind}d behind)</Text>
)}
</>
)}
</>
)}
</Box>
);
};
@@ -0,0 +1,19 @@
import { compareSemver } from '@/cli/utilities/version/compare-semver';
describe('compareSemver', () => {
it('returns 0 for equal versions', () => {
expect(compareSemver([2, 4, 0], [2, 4, 0])).toBe(0);
});
it('returns positive when a > b on major', () => {
expect(compareSemver([3, 0, 0], [2, 99, 99])).toBeGreaterThan(0);
});
it('returns negative when a < b on minor', () => {
expect(compareSemver([2, 3, 9], [2, 4, 0])).toBeLessThan(0);
});
it('returns negative when a < b on patch', () => {
expect(compareSemver([2, 4, 0], [2, 4, 1])).toBeLessThan(0);
});
});
@@ -0,0 +1,23 @@
import { parseSemver } from '@/cli/utilities/version/parse-semver';
describe('parseSemver', () => {
it('parses a plain semver', () => {
expect(parseSemver('1.2.3')).toEqual([1, 2, 3]);
});
it('strips a leading v', () => {
expect(parseSemver('v2.4.0')).toEqual([2, 4, 0]);
});
it('returns null for "latest"', () => {
expect(parseSemver('latest')).toBeNull();
});
it('returns null for a release-candidate tag', () => {
expect(parseSemver('v2.4.0-rc')).toBeNull();
});
it('returns null for empty input', () => {
expect(parseSemver('')).toBeNull();
});
});
@@ -0,0 +1,30 @@
import chalk from 'chalk';
import { CONTAINER_NAME } from '@/cli/utilities/server/docker-container';
import { getVersionInfo } from '@/cli/utilities/version/get-version-info';
const STALE_THRESHOLD_DAYS = 7;
export const checkServerVersionCompatibility = async (
containerName: string = CONTAINER_NAME,
): Promise<void> => {
const info = await getVersionInfo(containerName);
if (
info.localServerVersion === null ||
info.latestServerVersion === null ||
!info.isMinorOrMajorBehind ||
info.daysBehind === null ||
info.daysBehind <= STALE_THRESHOLD_DAYS
) {
return;
}
console.warn(
chalk.yellow(
`⚠ Local Twenty server is v${info.localServerVersion} (${info.daysBehind} days behind v${info.latestServerVersion}).`,
),
);
console.warn(chalk.dim(' Update with: yarn twenty server upgrade'));
console.warn('');
};
@@ -0,0 +1,8 @@
import { type SemverTuple } from '@/cli/utilities/version/semver-tuple';
export const compareSemver = (a: SemverTuple, b: SemverTuple): number => {
if (a[0] !== b[0]) return a[0] - b[0];
if (a[1] !== b[1]) return a[1] - b[1];
return a[2] - b[2];
};
@@ -0,0 +1,33 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { CONTAINER_NAME } from '@/cli/utilities/server/docker-container';
const execFileAsync = promisify(execFile);
export const getLocalServerVersion = async (
containerName: string = CONTAINER_NAME,
): Promise<string | null> => {
try {
const { stdout } = await execFileAsync(
'docker',
[
'inspect',
'-f',
'{{range .Config.Env}}{{println .}}{{end}}',
containerName,
],
{ encoding: 'utf-8' },
);
const match = stdout.match(/^APP_VERSION=(.+)$/m);
if (!match || match[1] === '0.0.0' || match[1] === '') {
return null;
}
return match[1].trim().replace(/^v/, '');
} catch {
return null;
}
};
@@ -0,0 +1,154 @@
import { readFile, writeFile } from 'node:fs/promises';
import * as os from 'os';
import * as path from 'path';
import { ensureDir } from '@/cli/utilities/file/fs-utils';
import { compareSemver } from '@/cli/utilities/version/compare-semver';
import { parseSemver } from '@/cli/utilities/version/parse-semver';
import { type PublishedServerVersion } from '@/cli/utilities/version/published-server-version';
const CACHE_FILE = path.join(
os.homedir(),
'.twenty',
'version-check-cache.json',
);
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 3000;
const DOCKER_HUB_TAGS_URL =
'https://hub.docker.com/v2/repositories/twentycrm/twenty-app-dev/tags?page_size=100';
type CacheFile = {
fetchedAt: number;
versions: Array<{ name: string; lastUpdatedAt: string }>;
};
const readCache = async (): Promise<PublishedServerVersion[] | null> => {
try {
const content = await readFile(CACHE_FILE, 'utf-8');
const parsed = JSON.parse(content) as CacheFile;
if (
typeof parsed.fetchedAt !== 'number' ||
!Array.isArray(parsed.versions)
) {
return null;
}
if (Date.now() - parsed.fetchedAt > CACHE_TTL_MS) {
return null;
}
return parsed.versions.map((entry) => ({
name: entry.name,
lastUpdatedAt: new Date(entry.lastUpdatedAt),
}));
} catch {
return null;
}
};
const writeCache = async (
versions: PublishedServerVersion[],
): Promise<void> => {
try {
await ensureDir(path.dirname(CACHE_FILE));
await writeFile(
CACHE_FILE,
JSON.stringify({
fetchedAt: Date.now(),
versions: versions.map(({ name, lastUpdatedAt }) => ({
name,
lastUpdatedAt: lastUpdatedAt.toISOString(),
})),
} satisfies CacheFile),
);
} catch {
// Cache write failures shouldn't break the CLI
}
};
const fetchFromDockerHub = async (): Promise<
PublishedServerVersion[] | null
> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(DOCKER_HUB_TAGS_URL, {
signal: controller.signal,
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
results?: Array<{ name?: unknown; last_updated?: unknown }>;
};
if (!Array.isArray(body.results)) {
return null;
}
const versions: PublishedServerVersion[] = [];
for (const tag of body.results) {
if (
typeof tag.name !== 'string' ||
typeof tag.last_updated !== 'string' ||
tag.name === 'latest' ||
parseSemver(tag.name) === null
) {
continue;
}
const lastUpdatedAt = new Date(tag.last_updated);
if (Number.isNaN(lastUpdatedAt.getTime())) {
continue;
}
versions.push({
name: tag.name.replace(/^v/, ''),
lastUpdatedAt,
});
}
versions.sort((a, b) => {
const aParsed = parseSemver(a.name);
const bParsed = parseSemver(b.name);
if (aParsed === null || bParsed === null) {
return 0;
}
return compareSemver(bParsed, aParsed);
});
return versions;
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
};
export const getPublishedServerVersions = async (): Promise<
PublishedServerVersion[]
> => {
const cached = await readCache();
if (cached !== null) {
return cached;
}
const fresh = await fetchFromDockerHub();
if (fresh === null) {
return [];
}
await writeCache(fresh);
return fresh;
};
@@ -0,0 +1,70 @@
import { CONTAINER_NAME } 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 { parseSemver } from '@/cli/utilities/version/parse-semver';
import { type VersionInfo } from '@/cli/utilities/version/version-info';
import sdkPackageJson from '../../../../package.json';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// Fallback for versions older than the most recent ~100 Docker Hub tags:
// they can't be matched by name, so we report a sentinel age that always
// exceeds any reasonable staleness threshold.
const UNKNOWN_AGE_DAYS_BEHIND = 365;
export const getVersionInfo = async (
containerName: string = CONTAINER_NAME,
): Promise<VersionInfo> => {
const cliVersion = sdkPackageJson.version;
const [localServerVersion, publishedVersions] = await Promise.all([
getLocalServerVersion(containerName),
getPublishedServerVersions(),
]);
const latestServerVersion = publishedVersions[0]?.name ?? null;
const localParsed = localServerVersion
? parseSemver(localServerVersion)
: null;
const latestParsed = latestServerVersion
? parseSemver(latestServerVersion)
: null;
const isMinorOrMajorBehind =
localParsed !== null &&
latestParsed !== null &&
(localParsed[0] < latestParsed[0] ||
(localParsed[0] === latestParsed[0] && localParsed[1] < latestParsed[1]));
const localPublishedAt =
localParsed === null
? undefined
: publishedVersions.find((entry) => {
const entryParsed = parseSemver(entry.name);
return (
entryParsed !== null &&
compareSemver(entryParsed, localParsed) === 0
);
})?.lastUpdatedAt;
const latestPublishedAt = publishedVersions[0]?.lastUpdatedAt;
let daysBehind: number | null = null;
if (latestPublishedAt && localPublishedAt) {
daysBehind = Math.floor(
(latestPublishedAt.getTime() - localPublishedAt.getTime()) / MS_PER_DAY,
);
} else if (latestPublishedAt && isMinorOrMajorBehind) {
daysBehind = UNKNOWN_AGE_DAYS_BEHIND;
}
return {
cliVersion,
localServerVersion,
latestServerVersion,
isMinorOrMajorBehind,
daysBehind,
};
};
@@ -0,0 +1,16 @@
import { type SemverTuple } from '@/cli/utilities/version/semver-tuple';
export const parseSemver = (raw: string): SemverTuple | null => {
const trimmed = raw.replace(/^v/, '').trim();
const match = trimmed.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
return null;
}
return [
parseInt(match[1], 10),
parseInt(match[2], 10),
parseInt(match[3], 10),
];
};
@@ -0,0 +1,4 @@
export type PublishedServerVersion = {
name: string;
lastUpdatedAt: Date;
};
@@ -0,0 +1 @@
export type SemverTuple = [number, number, number];
@@ -0,0 +1,7 @@
export type VersionInfo = {
cliVersion: string;
localServerVersion: string | null;
latestServerVersion: string | null;
isMinorOrMajorBehind: boolean;
daysBehind: number | null;
};