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