diff --git a/packages/twenty-docs/developers/extend/apps/getting-started/local-server.mdx b/packages/twenty-docs/developers/extend/apps/getting-started/local-server.mdx index ea5dd9e8d0..1c9f8f2672 100644 --- a/packages/twenty-docs/developers/extend/apps/getting-started/local-server.mdx +++ b/packages/twenty-docs/developers/extend/apps/getting-started/local-server.mdx @@ -11,6 +11,7 @@ Use `yarn twenty docker:*` to control the local Twenty container: | Command | What it does | |---------|--------------| | `yarn twenty docker:start` | Start the server (pulls the image if needed) | +| `yarn twenty docker:start 2.2.0` | Start a specific server version | | `yarn twenty docker:start --port 3030` | Start on a custom port | | `yarn twenty docker:stop` | Stop the server (preserves data) | | `yarn twenty docker:status` | Show URL, version, and login credentials | @@ -21,6 +22,20 @@ Use `yarn twenty docker:*` to control the local Twenty container: Data persists across restarts in two Docker volumes (`twenty-app-dev-data` for PostgreSQL, `twenty-app-dev-storage` for files). Use `reset` to wipe everything. +## Pinning the server version + +When no version is passed, `docker:start` resolves the version from your app's `engines.twenty` range in `package.json` — the same range the server validates against when your app is installed. It starts the newest published `twenty-app-dev` image that satisfies the range, falling back to `latest` when the field is absent or no published version matches: + +```json filename="package.json" +{ + "engines": { + "twenty": ">=2.2.0" + } +} +``` + +Pass a version explicitly to override the range for a single run: `yarn twenty docker:start 2.3.0`. If a container already exists on a different version, `docker:start` upgrades it in place (recreating the container while preserving your data volumes). + ## Upgrading the server image `yarn twenty docker:upgrade` pulls the latest image, compares digests, and only recreates the container if anything actually changed. Volumes are preserved — only the container is replaced. If a new image was pulled and the container was running, the upgrade automatically starts a new container; run `yarn twenty docker:start` afterward to wait for it to become healthy. diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 31536f3d97..e6b9a02253 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -85,6 +85,7 @@ "preact": "^10.28.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "semver": "7.6.3", "tinyglobby": "^0.2.15", "twenty-client-sdk": "workspace:*", "typescript": "^5.9.3", @@ -95,6 +96,7 @@ "@types/node": "^24.0.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "@types/semver": "^7.5.8", "@typescript/native-preview": "^7.0.0-dev.20260116.1", "@vitest/coverage-v8": "^4.0.18", "prettier": "^3.1.1", diff --git a/packages/twenty-sdk/src/cli/commands/docker/index.ts b/packages/twenty-sdk/src/cli/commands/docker/index.ts index 32b785f0d5..4b00d7bcc3 100644 --- a/packages/twenty-sdk/src/cli/commands/docker/index.ts +++ b/packages/twenty-sdk/src/cli/commands/docker/index.ts @@ -15,7 +15,10 @@ import chalk from 'chalk'; import type { Command } from 'commander'; import { execSync, spawnSync } from 'node:child_process'; -const startAction = async (options: { port?: string; test?: boolean }) => { +const startAction = async ( + version: string | undefined, + options: { port?: string; test?: boolean }, +) => { const defaultPort = options.test ? DEFAULT_TEST_PORT : DEFAULT_PORT; const port = options.port ? parseInt(options.port, 10) : defaultPort; @@ -27,6 +30,7 @@ const startAction = async (options: { port?: string; test?: boolean }) => { const result = await serverStart({ port, test: options.test, + version, onProgress: (message) => console.log(chalk.gray(message)), }); @@ -173,8 +177,10 @@ const upgradeAction = async ( export const registerServerCommands = (program: Command): void => { program - .command('docker:start') - .description('Start the local Twenty container') + .command('docker:start [version]') + .description( + 'Start the local Twenty container (version defaults to the app `engines.twenty` range, then `latest`)', + ) .option('-p, --port ', 'HTTP port') .option('--test', 'Start a separate test instance (port 2021)') .action(startAction); @@ -225,13 +231,18 @@ export const registerServerCommands = (program: Command): void => { ); server - .command('start') + .command('start [version]') .option('-p, --port ', 'HTTP port') .option('--test', 'Start a separate test instance (port 2021)') - .action(async (options: { port?: string; test?: boolean }) => { - deprecate('start', 'docker:start'); - await startAction(options); - }); + .action( + async ( + version: string | undefined, + options: { port?: string; test?: boolean }, + ) => { + deprecate('start', 'docker:start'); + await startAction(version, options); + }, + ); server .command('stop') diff --git a/packages/twenty-sdk/src/cli/operations/index.ts b/packages/twenty-sdk/src/cli/operations/index.ts index 8b49784fd1..cae2abc386 100644 --- a/packages/twenty-sdk/src/cli/operations/index.ts +++ b/packages/twenty-sdk/src/cli/operations/index.ts @@ -40,6 +40,8 @@ export { getImageDigest, getImageForVersion, } from '@/cli/utilities/server/docker-container'; +export { getEngineVersionRange } from '@/cli/utilities/version/get-engine-version-range'; +export { resolveHighestEngineVersion } from '@/cli/utilities/version/resolve-highest-engine-version'; // Config export { ConfigService } from '@/cli/utilities/config/config-service'; diff --git a/packages/twenty-sdk/src/cli/operations/server-start.ts b/packages/twenty-sdk/src/cli/operations/server-start.ts index 00b6733c34..ec23af6038 100644 --- a/packages/twenty-sdk/src/cli/operations/server-start.ts +++ b/packages/twenty-sdk/src/cli/operations/server-start.ts @@ -8,9 +8,10 @@ import { containerExists, DEFAULT_PORT, DEFAULT_TEST_PORT, + getContainerImageTag, getContainerPort, getDockerNotRunningMessage, - IMAGE, + getImageForVersion, isContainerRunning, TEST_CONTAINER_NAME, } from '@/cli/utilities/server/docker-container'; @@ -18,7 +19,9 @@ import { checkServerHealth, detectLocalServer, } from '@/cli/utilities/server/detect-local-server'; +import { serverUpgrade } from '@/cli/operations/server-upgrade'; import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility'; +import { resolveHighestEngineVersion } from '@/cli/utilities/version/resolve-highest-engine-version'; import { execSync, spawn, spawnSync } from 'node:child_process'; import chalk from 'chalk'; @@ -107,6 +110,7 @@ const waitForHealthy = async ( export type ServerStartOptions = { port?: number; test?: boolean; + version?: string; onProgress?: (message: string) => void; }; @@ -120,6 +124,9 @@ const innerServerStart = async ( ): Promise> => { const { onProgress, test: isTest } = options; + const version = await resolveHighestEngineVersion(options.version); + const image = getImageForVersion(version); + const containerName = isTest ? TEST_CONTAINER_NAME : CONTAINER_NAME; const defaultPort = isTest ? DEFAULT_TEST_PORT : DEFAULT_PORT; const volumeData = isTest @@ -129,6 +136,26 @@ const innerServerStart = async ( ? 'twenty-app-dev-test-storage' : 'twenty-app-dev-storage'; + if (checkDockerRunning() && containerExists(containerName)) { + const currentImage = getContainerImageTag(containerName); + + if (currentImage !== null && currentImage !== image) { + onProgress?.( + `Existing container runs ${currentImage}; upgrading to ${image}...`, + ); + + const upgradeResult = await serverUpgrade({ + version, + test: isTest, + onProgress, + }); + + if (!upgradeResult.success) { + return { success: false, error: upgradeResult.error }; + } + } + } + const existingUrl = await detectLocalServer(options.port ?? defaultPort); if (existingUrl) { @@ -150,9 +177,13 @@ const innerServerStart = async ( } if (!checkDockerRunning()) { - const retryCommand = isTest - ? 'yarn twenty docker:start --test' - : 'yarn twenty docker:start'; + const retryCommand = [ + 'yarn twenty docker:start', + version !== 'latest' ? version : null, + isTest ? '--test' : null, + ] + .filter(Boolean) + .join(' '); return { success: false, @@ -211,7 +242,9 @@ const innerServerStart = async ( onProgress?.('Starting existing container...'); execSync(`docker start ${containerName}`, { stdio: 'ignore' }); } else { - onProgress?.('Pulling Docker image and starting Twenty container...'); + onProgress?.( + `Pulling Docker image (${image}) and starting Twenty container...`, + ); const runResult = spawnSync( 'docker', @@ -230,7 +263,7 @@ const innerServerStart = async ( `${volumeData}:/data/postgres`, '-v', `${volumeStorage}:/app/packages/twenty-server/.local-storage`, - IMAGE, + image, ], { stdio: 'inherit' }, ); diff --git a/packages/twenty-sdk/src/cli/utilities/server/__tests__/get-image-for-version.test.ts b/packages/twenty-sdk/src/cli/utilities/server/__tests__/get-image-for-version.test.ts new file mode 100644 index 0000000000..a04e2608c8 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/server/__tests__/get-image-for-version.test.ts @@ -0,0 +1,29 @@ +import { getImageForVersion } from '@/cli/utilities/server/docker-container'; + +describe('getImageForVersion', () => { + it('defaults to the latest tag', () => { + expect(getImageForVersion()).toBe('twentycrm/twenty-app-dev:latest'); + }); + + it('keeps the latest tag as-is', () => { + expect(getImageForVersion('latest')).toBe( + 'twentycrm/twenty-app-dev:latest', + ); + }); + + it('prefixes a bare semver version with v to match published tags', () => { + expect(getImageForVersion('2.7.5')).toBe('twentycrm/twenty-app-dev:v2.7.5'); + }); + + it('does not double-prefix an already v-prefixed version', () => { + expect(getImageForVersion('v2.7.5')).toBe( + 'twentycrm/twenty-app-dev:v2.7.5', + ); + }); + + it('prefixes a prerelease semver version', () => { + expect(getImageForVersion('2.7.5-rc.1')).toBe( + 'twentycrm/twenty-app-dev:v2.7.5-rc.1', + ); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/server/docker-container.ts b/packages/twenty-sdk/src/cli/utilities/server/docker-container.ts index e7b928edf7..874cbc4d23 100644 --- a/packages/twenty-sdk/src/cli/utilities/server/docker-container.ts +++ b/packages/twenty-sdk/src/cli/utilities/server/docker-container.ts @@ -56,8 +56,11 @@ export const containerExists = (containerName = CONTAINER_NAME): boolean => { } }; -export const getImageForVersion = (version = 'latest'): string => - `twentycrm/twenty-app-dev:${version}`; +export const getImageForVersion = (version = 'latest'): string => { + const tag = /^\d+\.\d+\.\d+/.test(version) ? `v${version}` : version; + + return `twentycrm/twenty-app-dev:${tag}`; +}; export const getContainerDigest = ( containerName = CONTAINER_NAME, @@ -76,6 +79,23 @@ export const getContainerDigest = ( } }; +export const getContainerImageTag = ( + containerName = CONTAINER_NAME, +): string | null => { + try { + return execFileSync( + 'docker', + ['inspect', '-f', '{{.Config.Image}}', containerName], + { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'], + }, + ).trim(); + } catch { + return null; + } +}; + export const getImageDigest = (image: string): string | null => { try { return execFileSync('docker', ['inspect', '-f', '{{.Id}}', image], { diff --git a/packages/twenty-sdk/src/cli/utilities/version/__tests__/get-engine-version-range.test.ts b/packages/twenty-sdk/src/cli/utilities/version/__tests__/get-engine-version-range.test.ts new file mode 100644 index 0000000000..db31345cf4 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/__tests__/get-engine-version-range.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { getEngineVersionRange } from '@/cli/utilities/version/get-engine-version-range'; + +describe('getEngineVersionRange', () => { + const createdDirs: string[] = []; + + const seed = (contents: unknown): string => { + const dir = mkdtempSync(join(tmpdir(), 'twenty-app-server-range-')); + + writeFileSync(join(dir, 'package.json'), JSON.stringify(contents), 'utf-8'); + createdDirs.push(dir); + + return dir; + }; + + afterEach(() => { + while (createdDirs.length > 0) { + rmSync(createdDirs.pop() as string, { recursive: true, force: true }); + } + }); + + it('returns the engines.twenty range', () => { + const dir = seed({ engines: { twenty: '>=2.2.0' } }); + + expect(getEngineVersionRange(dir)).toBe('>=2.2.0'); + }); + + it('trims surrounding whitespace', () => { + const dir = seed({ engines: { twenty: ' ^2.2.0 ' } }); + + expect(getEngineVersionRange(dir)).toBe('^2.2.0'); + }); + + it('returns null when engines.twenty is missing', () => { + const dir = seed({ engines: { node: '^24.0.0' } }); + + expect(getEngineVersionRange(dir)).toBeNull(); + }); + + it('returns null when engines.twenty is empty', () => { + const dir = seed({ engines: { twenty: ' ' } }); + + expect(getEngineVersionRange(dir)).toBeNull(); + }); + + it('returns null when engines.twenty is not a string', () => { + const dir = seed({ engines: { twenty: 2 } }); + + expect(getEngineVersionRange(dir)).toBeNull(); + }); + + it('returns null when no package.json exists', () => { + const dir = mkdtempSync(join(tmpdir(), 'twenty-app-server-range-empty-')); + + createdDirs.push(dir); + + expect(getEngineVersionRange(dir)).toBeNull(); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/version/__tests__/resolve-highest-engine-version.test.ts b/packages/twenty-sdk/src/cli/utilities/version/__tests__/resolve-highest-engine-version.test.ts new file mode 100644 index 0000000000..ce6cc6c323 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/__tests__/resolve-highest-engine-version.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getEngineVersionRange } from '@/cli/utilities/version/get-engine-version-range'; +import { getPublishedServerVersions } from '@/cli/utilities/version/get-published-server-versions'; +import { resolveHighestEngineVersion } from '@/cli/utilities/version/resolve-highest-engine-version'; + +vi.mock('@/cli/utilities/version/get-engine-version-range'); +vi.mock('@/cli/utilities/version/get-published-server-versions'); + +const mockedGetRange = vi.mocked(getEngineVersionRange); +const mockedGetPublished = vi.mocked(getPublishedServerVersions); + +const publish = (...names: string[]) => + mockedGetPublished.mockResolvedValue( + names.map((name) => ({ name, lastUpdatedAt: new Date(0) })), + ); + +describe('resolveHighestEngineVersion', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedGetRange.mockReturnValue(null); + mockedGetPublished.mockResolvedValue([]); + }); + + it('returns the explicit version verbatim without resolving', async () => { + expect(await resolveHighestEngineVersion('2.3.1')).toBe('2.3.1'); + expect(mockedGetRange).not.toHaveBeenCalled(); + expect(mockedGetPublished).not.toHaveBeenCalled(); + }); + + it('trims the explicit version', async () => { + expect(await resolveHighestEngineVersion(' 2.3.1 ')).toBe('2.3.1'); + }); + + it('falls through to the app range when the explicit version is blank', async () => { + mockedGetRange.mockReturnValue('>=2.2.0'); + publish('2.2.0', '2.3.0'); + + expect(await resolveHighestEngineVersion(' ')).toBe('2.3.0'); + }); + + it('returns latest when the app declares no range', async () => { + mockedGetRange.mockReturnValue(null); + + expect(await resolveHighestEngineVersion()).toBe('latest'); + expect(mockedGetPublished).not.toHaveBeenCalled(); + }); + + it('returns latest when the range is not a valid semver range', async () => { + mockedGetRange.mockReturnValue('not-a-range'); + + expect(await resolveHighestEngineVersion()).toBe('latest'); + expect(mockedGetPublished).not.toHaveBeenCalled(); + }); + + it('returns the highest published version satisfying the range', async () => { + mockedGetRange.mockReturnValue('^2.2.0'); + publish('2.1.9', '2.2.0', '2.2.5', '2.3.0', '3.0.0', 'latest'); + + expect(await resolveHighestEngineVersion()).toBe('2.3.0'); + }); + + it('returns latest when no published version satisfies the range', async () => { + mockedGetRange.mockReturnValue('>=9.0.0'); + publish('2.2.0', '2.3.0'); + + expect(await resolveHighestEngineVersion()).toBe('latest'); + }); + + it('returns latest when no versions are published', async () => { + mockedGetRange.mockReturnValue('>=2.2.0'); + mockedGetPublished.mockResolvedValue([]); + + expect(await resolveHighestEngineVersion()).toBe('latest'); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/version/get-engine-version-range.ts b/packages/twenty-sdk/src/cli/utilities/version/get-engine-version-range.ts new file mode 100644 index 0000000000..c0edf0a8aa --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/get-engine-version-range.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; + +export const getEngineVersionRange = ( + cwd: string = CURRENT_EXECUTION_DIRECTORY, +): string | null => { + try { + const pkg = JSON.parse( + readFileSync(join(cwd, 'package.json'), 'utf-8'), + ) as { engines?: { twenty?: unknown } }; + + const range = pkg.engines?.twenty; + + return typeof range === 'string' && range.trim() !== '' + ? range.trim() + : null; + } catch { + return null; + } +}; diff --git a/packages/twenty-sdk/src/cli/utilities/version/resolve-highest-engine-version.ts b/packages/twenty-sdk/src/cli/utilities/version/resolve-highest-engine-version.ts new file mode 100644 index 0000000000..f5b2cb2e6a --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/version/resolve-highest-engine-version.ts @@ -0,0 +1,30 @@ +import semver from 'semver'; + +import { getEngineVersionRange } from '@/cli/utilities/version/get-engine-version-range'; +import { getPublishedServerVersions } from '@/cli/utilities/version/get-published-server-versions'; + +export const resolveHighestEngineVersion = async ( + explicitVersion?: string, +): Promise => { + const explicit = explicitVersion?.trim(); + + if (explicit) { + return explicit; + } + + const range = getEngineVersionRange(); + + if (range === null || semver.validRange(range) === null) { + return 'latest'; + } + + const published = await getPublishedServerVersions(); + + const bestMatch = published + .map((version) => version.name) + .filter((name) => semver.valid(name) !== null) + .filter((name) => semver.satisfies(name, range)) + .sort(semver.rcompare)[0]; + + return bestMatch ?? 'latest'; +}; diff --git a/yarn.lock b/yarn.lock index a6170073b6..401e5de835 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23192,6 +23192,13 @@ __metadata: languageName: node linkType: hard +"@types/semver@npm:^7.5.8": + version: 7.7.1 + resolution: "@types/semver@npm:7.7.1" + checksum: 10c0/c938aef3bf79a73f0f3f6037c16e2e759ff40c54122ddf0b2583703393d8d3127130823facb880e694caa324eb6845628186aac1997ee8b31dc2d18fafe26268 + languageName: node + linkType: hard + "@types/send@npm:*": version: 0.17.4 resolution: "@types/send@npm:0.17.4" @@ -53617,6 +53624,7 @@ __metadata: "@types/node": "npm:^24.0.0" "@types/react": "npm:^19.2.0" "@types/react-dom": "npm:^19.2.0" + "@types/semver": "npm:^7.5.8" "@typescript/native-preview": "npm:^7.0.0-dev.20260116.1" "@vitest/coverage-v8": "npm:^4.0.18" axios: "npm:^1.16.0" @@ -53634,6 +53642,7 @@ __metadata: react: "npm:^19.2.0" react-dom: "npm:^19.2.0" rollup-plugin-dts: "npm:^6.4.1" + semver: "npm:7.6.3" tinyglobby: "npm:^0.2.15" ts-morph: "npm:^25.0.0" tsc-alias: "npm:^1.8.16"