From 0f8ee5714f47398a829de9e389a7e032f45796fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 8 May 2026 14:11:34 +0200 Subject: [PATCH] feat(sdk): warn when local server image is behind latest (#20352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/twenty-sdk/src/cli/commands/dev.ts | 10 ++ .../twenty-sdk/src/cli/commands/install.ts | 2 + .../src/cli/operations/server-start.ts | 16 +- .../dev-mode-orchestrator-state.ts | 10 ++ .../components/dev-ui-application-panel.tsx | 2 + .../dev/ui/components/dev-ui-version-row.tsx | 60 +++++++ .../version/__tests__/compare-semver.test.ts | 19 +++ .../version/__tests__/parse-semver.test.ts | 23 +++ .../check-server-version-compatibility.ts | 30 ++++ .../cli/utilities/version/compare-semver.ts | 8 + .../version/get-local-server-version.ts | 33 ++++ .../version/get-published-server-versions.ts | 154 ++++++++++++++++++ .../cli/utilities/version/get-version-info.ts | 70 ++++++++ .../src/cli/utilities/version/parse-semver.ts | 16 ++ .../version/published-server-version.ts | 4 + .../src/cli/utilities/version/semver-tuple.ts | 1 + .../src/cli/utilities/version/version-info.ts | 7 + 17 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-version-row.tsx create mode 100644 packages/twenty-sdk/src/cli/utilities/version/__tests__/compare-semver.test.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/__tests__/parse-semver.test.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/check-server-version-compatibility.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/compare-semver.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/get-local-server-version.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/get-published-server-versions.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/get-version-info.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/parse-semver.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/published-server-version.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/semver-tuple.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/version/version-info.ts diff --git a/packages/twenty-sdk/src/cli/commands/dev.ts b/packages/twenty-sdk/src/cli/commands/dev.ts index 3c7a29ce51..80ea9a5df9 100644 --- a/packages/twenty-sdk/src/cli/commands/dev.ts +++ b/packages/twenty-sdk/src/cli/commands/dev.ts @@ -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({ diff --git a/packages/twenty-sdk/src/cli/commands/install.ts b/packages/twenty-sdk/src/cli/commands/install.ts index 359f3349bb..b9dee11bdb 100644 --- a/packages/twenty-sdk/src/cli/commands/install.ts +++ b/packages/twenty-sdk/src/cli/commands/install.ts @@ -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`)); diff --git a/packages/twenty-sdk/src/cli/operations/server-start.ts b/packages/twenty-sdk/src/cli/operations/server-start.ts index d06feb5ffb..cadf5d1b0d 100644 --- a/packages/twenty-sdk/src/cli/operations/server-start.ts +++ b/packages/twenty-sdk/src/cli/operations/server-start.ts @@ -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> => - runSafe( +): Promise> => { + 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; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts index 96b04a098f..b721e452d5 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts @@ -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; 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?.(); } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-application-panel.tsx b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-application-panel.tsx index a89ef8858e..ac1c75db06 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-application-panel.tsx +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-application-panel.tsx @@ -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 = ({ )} + diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-version-row.tsx b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-version-row.tsx new file mode 100644 index 0000000000..6d015b0bc0 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-version-row.tsx @@ -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 ( + + Versions: + CLI + v{cliVersion} + {localServerVersion !== null && ( + <> + • Server + + v{localServerVersion} + + {isUpToDate && ✓ latest} + {isStale && latestServerVersion !== null && ( + <> + → v{latestServerVersion} + {daysBehind !== null && ( + ({daysBehind}d behind) + )} + + )} + + )} + + ); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/version/__tests__/compare-semver.test.ts b/packages/twenty-sdk/src/cli/utilities/version/__tests__/compare-semver.test.ts new file mode 100644 index 0000000000..9c2700d7c6 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/__tests__/compare-semver.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/version/__tests__/parse-semver.test.ts b/packages/twenty-sdk/src/cli/utilities/version/__tests__/parse-semver.test.ts new file mode 100644 index 0000000000..08d6b383e8 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/__tests__/parse-semver.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/version/check-server-version-compatibility.ts b/packages/twenty-sdk/src/cli/utilities/version/check-server-version-compatibility.ts new file mode 100644 index 0000000000..95e64828cc --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/check-server-version-compatibility.ts @@ -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 => { + 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(''); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/version/compare-semver.ts b/packages/twenty-sdk/src/cli/utilities/version/compare-semver.ts new file mode 100644 index 0000000000..c13f6b8717 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/compare-semver.ts @@ -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]; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/version/get-local-server-version.ts b/packages/twenty-sdk/src/cli/utilities/version/get-local-server-version.ts new file mode 100644 index 0000000000..a1047300b1 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/get-local-server-version.ts @@ -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 => { + 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; + } +}; diff --git a/packages/twenty-sdk/src/cli/utilities/version/get-published-server-versions.ts b/packages/twenty-sdk/src/cli/utilities/version/get-published-server-versions.ts new file mode 100644 index 0000000000..1e574c0adf --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/get-published-server-versions.ts @@ -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 => { + 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 => { + 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; +}; 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 new file mode 100644 index 0000000000..24d485a737 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/get-version-info.ts @@ -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 => { + 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, + }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/version/parse-semver.ts b/packages/twenty-sdk/src/cli/utilities/version/parse-semver.ts new file mode 100644 index 0000000000..7c08ae0070 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/parse-semver.ts @@ -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), + ]; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/version/published-server-version.ts b/packages/twenty-sdk/src/cli/utilities/version/published-server-version.ts new file mode 100644 index 0000000000..2894638045 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/published-server-version.ts @@ -0,0 +1,4 @@ +export type PublishedServerVersion = { + name: string; + lastUpdatedAt: Date; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/version/semver-tuple.ts b/packages/twenty-sdk/src/cli/utilities/version/semver-tuple.ts new file mode 100644 index 0000000000..87e45c4dc1 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/semver-tuple.ts @@ -0,0 +1 @@ +export type SemverTuple = [number, number, number]; diff --git a/packages/twenty-sdk/src/cli/utilities/version/version-info.ts b/packages/twenty-sdk/src/cli/utilities/version/version-info.ts new file mode 100644 index 0000000000..1d7e49caed --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/version-info.ts @@ -0,0 +1,7 @@ +export type VersionInfo = { + cliVersion: string; + localServerVersion: string | null; + latestServerVersion: string | null; + isMinorOrMajorBehind: boolean; + daysBehind: number | null; +};