feat(sdk): let docker:start choose the server version (#21690)

## What

Makes `yarn twenty docker:start` version-selectable. Same core feature
as #21686 — but here scaffolded apps default to `latest` (pinning is
**opt-in**) rather than being pinned to the scaffolder's version.

> Alternative to #21686. Pick one; the difference is only the scaffolded
default.

Two layers of resolution:

1. **Explicit flag** — `yarn twenty docker:start [version]`, mirroring
the existing `docker:upgrade [version]`.
2. **App-pinned default** — when no version is passed, `docker:start`
reads `twenty.serverVersion` from the app's `package.json`, falling back
to `latest`.

Generated apps ship `twenty.serverVersion: "latest"`, so default
behavior is unchanged. To make the local server reproducible as code,
set a version:

```json filename="package.json"
{
  "twenty": {
    "serverVersion": "2.2.0"
  }
}
```

## Changes

- `twenty-sdk`: new `getAppServerVersion()` util reads
`twenty.serverVersion` from the cwd's `package.json`; `serverStart`
gains a `version` option and resolves `option → app pin → latest`,
building the image via `getImageForVersion()`; `docker:start [version]`
(and the deprecated `server start [version]` alias) wired up.
- `create-twenty-app`: template `package.json` ships
`twenty.serverVersion: "latest"`. (`create-app` and the scaffolder are
otherwise untouched.)
- Docs: `local-server.mdx` documents version selection and the opt-in
pin.

## Behavior notes

- Default with no pin and no flag is `latest` — same as today.
- Version only matters when **creating** a fresh container — an existing
container keeps its image until `docker:upgrade` / `docker:reset`.

## Testing

- New unit tests for `getAppServerVersion` (5 cases).
- Extended the `app-template` scaffolding test to assert the `latest`
default.
- `twenty-sdk` cli vitest suite (273) and `create-twenty-app` jest suite
(9) pass; oxlint + oxfmt clean on changed files.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21690?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Charles Bochet
2026-06-17 10:55:02 +02:00
committed by GitHub
parent eeed998c9e
commit 257f130fff
12 changed files with 327 additions and 16 deletions
@@ -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.
+2
View File
@@ -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",
@@ -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 <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 <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')
@@ -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';
@@ -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<CommandResult<ServerStartResult>> => {
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' },
);
@@ -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',
);
});
});
@@ -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], {
@@ -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();
});
});
@@ -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');
});
});
@@ -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;
}
};
@@ -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<string> => {
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';
};
+9
View File
@@ -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"