Add twenty sdk server upgrade command (#20158)

##
The command pulls the image, compares it against the one the container
was created from, and only recreates the container if the image actually
changed. Your data volumes are preserved — only the container is
replaced.
This commit is contained in:
martmull
2026-04-30 15:03:41 +02:00
committed by GitHub
parent 83db37d33f
commit c8e405cb4e
11 changed files with 301 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.1.0",
"version": "2.2.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",

View File

@@ -26,6 +26,7 @@ const program = new Command(packageJson.name)
'--skip-local-instance',
'Skip the local Twenty instance setup prompt',
)
.option('-y, --yes', 'Auto-confirm prompts (e.g. start existing container)')
.helpOption('-h, --help', 'Display this help message.')
.action(
async (
@@ -36,6 +37,7 @@ const program = new Command(packageJson.name)
displayName?: string;
description?: string;
skipLocalInstance?: boolean;
yes?: boolean;
},
) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
@@ -59,6 +61,7 @@ const program = new Command(packageJson.name)
displayName: options?.displayName,
description: options?.description,
skipLocalInstance: options?.skipLocalInstance,
yes: options?.yes,
});
},
);

View File

@@ -11,7 +11,9 @@ import * as path from 'path';
import { basename } from 'path';
import {
authLoginOAuth,
checkDockerRunning,
ConfigService,
containerExists,
detectLocalServer,
serverStart,
type ServerStartResult,
@@ -27,6 +29,7 @@ type CreateAppOptions = {
displayName?: string;
description?: string;
skipLocalInstance?: boolean;
yes?: boolean;
};
export class CreateAppCommand {
@@ -71,7 +74,7 @@ export class CreateAppCommand {
let serverResult: ServerStartResult | undefined;
if (!options.skipLocalInstance) {
const shouldStartServer = await this.shouldStartServer();
const shouldStartServer = await this.shouldStartServer(options.yes);
if (shouldStartServer) {
const startResult = await serverStart({
@@ -223,13 +226,35 @@ export class CreateAppCommand {
);
}
private async shouldStartServer(): Promise<boolean> {
private async shouldStartServer(autoConfirm?: boolean): Promise<boolean> {
const existingServerUrl = await detectLocalServer();
if (existingServerUrl) {
return true;
}
if (checkDockerRunning() && containerExists()) {
if (autoConfirm) {
return true;
}
const { startExisting } = await inquirer.prompt([
{
type: 'confirm',
name: 'startExisting',
message:
'An existing Twenty server container was found. Would you like to start it?',
default: true,
},
]);
return startExisting;
}
if (autoConfirm) {
return true;
}
const { startDocker } = await inquirer.prompt([
{
type: 'confirm',

View File

@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.1.0",
"version": "2.2.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {

View File

@@ -219,13 +219,31 @@ The scaffolder already started a local Twenty server for you. To manage it later
| `yarn twenty server start --port 3030` | Start on a custom port |
| `yarn twenty server start --test` | Start a separate test instance on port 2021 |
| `yarn twenty server stop` | Stop the server (preserves data) |
| `yarn twenty server status` | Show server status, URL, and credentials |
| `yarn twenty server status` | Show server status, URL, version, and credentials |
| `yarn twenty server logs` | Stream server logs |
| `yarn twenty server logs --lines 100` | Show the last 100 log lines |
| `yarn twenty server reset` | Delete all data and start fresh |
| `yarn twenty server upgrade` | Pull the latest `twenty-app-dev` image and recreate the container |
| `yarn twenty server upgrade 2.2.0` | Upgrade to a specific version |
Data is persisted across restarts in two Docker volumes (`twenty-app-dev-data` for PostgreSQL, `twenty-app-dev-storage` for files). Use `reset` to wipe everything and start fresh.
### Upgrading the server image
Use `yarn twenty server upgrade` to check for a newer `twenty-app-dev` Docker image and update the container. The command pulls the image, compares it against the one the container was created from, and only recreates the container if the image actually changed. Your data volumes are preserved — only the container is replaced.
```bash filename="Terminal"
# Upgrade to the latest version (skips recreation if already up to date)
yarn twenty server upgrade
# Upgrade to a specific version
yarn twenty server upgrade 2.2.0
```
If a newer image is available and the container was running, the upgrade command automatically starts a new container with the updated image. Run `yarn twenty server start` afterward to wait for it to become healthy. If the image hasn't changed, the container is left untouched.
You can verify the running version with `yarn twenty server status`, which displays the `APP_VERSION` from the container.
### Running a test instance
Pass `--test` to any `server` command to manage a second, fully isolated instance — useful for running integration tests or experimenting without touching your main dev data.
@@ -234,9 +252,10 @@ Pass `--test` to any `server` command to manage a second, fully isolated instanc
|---------|-------------|
| `yarn twenty server start --test` | Start the test instance (defaults to port 2021) |
| `yarn twenty server stop --test` | Stop the test instance |
| `yarn twenty server status --test` | Show test instance status, URL, and credentials |
| `yarn twenty server status --test` | Show test instance status, URL, version, and credentials |
| `yarn twenty server logs --test` | Stream test instance logs |
| `yarn twenty server reset --test` | Wipe test data and start fresh |
| `yarn twenty server upgrade --test` | Upgrade the test instance image |
The test instance runs in its own Docker container (`twenty-app-dev-test`) with dedicated volumes (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) and config, so it can run in parallel with your main instance without conflicts. Combine `--test` with `--port` to override the default 2021.

View File

@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
"version": "2.1.0",
"version": "2.2.0",
"sideEffects": false,
"bin": {
"twenty": "dist/cli.cjs"

View File

@@ -1,9 +1,11 @@
import { serverStart } from '@/cli/operations/server-start';
import { serverUpgrade } from '@/cli/operations/server-upgrade';
import {
CONTAINER_NAME,
containerExists,
DEFAULT_PORT,
DEFAULT_TEST_PORT,
getContainerEnvVar,
getContainerPort,
isContainerRunning,
TEST_CONTAINER_NAME,
@@ -115,9 +117,15 @@ export const registerServerCommands = (program: Command): void => {
? chalk.yellow('running (starting...)')
: chalk.gray('stopped');
const appVersion = getContainerEnvVar('APP_VERSION', containerName);
console.log(` Status: ${statusText}`);
console.log(` URL: http://localhost:${port}`);
if (appVersion) {
console.log(` Version: ${chalk.gray(appVersion)}`);
}
if (healthy) {
console.log(chalk.gray(' Login: tim@apple.dev / tim@apple.dev'));
}
@@ -155,4 +163,41 @@ export const registerServerCommands = (program: Command): void => {
),
);
});
server
.command('upgrade [version]')
.description('Upgrade the twenty-app-dev Docker image')
.option('--test', 'Upgrade the test instance')
.action(
async (version: string | undefined, options: { test?: boolean }) => {
const result = await serverUpgrade({
version: version ?? 'latest',
test: options.test,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
const { data } = result;
if (!data.imageUpdated) {
console.log(chalk.green(` Already up to date (${data.image}).`));
return;
}
console.log(chalk.green(` Upgraded to: ${data.image}`));
if (data.containerRecreated) {
console.log(
chalk.gray(
` Run 'yarn twenty server start${options.test ? ' --test' : ''}' to wait for the server to be ready.`,
),
);
}
},
);
};

View File

@@ -27,7 +27,19 @@ export type { FunctionExecuteOptions } from './execute';
// Server
export { serverStart } from './server-start';
export type { ServerStartOptions, ServerStartResult } from './server-start';
export { serverUpgrade } from './server-upgrade';
export type {
ServerUpgradeOptions,
ServerUpgradeResult,
} from './server-upgrade';
export { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
export {
checkDockerRunning,
containerExists,
getContainerDigest,
getImageDigest,
getImageForVersion,
} from '@/cli/utilities/server/docker-container';
// Config
export { ConfigService } from '@/cli/utilities/config/config-service';

View File

@@ -0,0 +1,144 @@
import { SERVER_ERROR_CODES, type CommandResult } from '@/cli/types';
import { runSafe } from '@/cli/utilities/run-safe';
import {
checkDockerRunning,
CONTAINER_NAME,
containerExists,
getContainerDigest,
getContainerPort,
getImageDigest,
getImageForVersion,
TEST_CONTAINER_NAME,
} from '@/cli/utilities/server/docker-container';
import { execSync, spawnSync } from 'node:child_process';
export type ServerUpgradeOptions = {
version?: string;
test?: boolean;
onProgress?: (message: string) => void;
};
export type ServerUpgradeResult = {
image: string;
imageUpdated: boolean;
containerRecreated: boolean;
};
const innerServerUpgrade = async (
options: ServerUpgradeOptions = {},
): Promise<CommandResult<ServerUpgradeResult>> => {
const { version = 'latest', test: isTest, onProgress } = options;
if (!checkDockerRunning()) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.DOCKER_NOT_RUNNING,
message: 'Docker is not running. Please start Docker and try again.',
},
};
}
const containerName = isTest ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const image = getImageForVersion(version);
const hasContainer = containerExists(containerName);
const previousDigest = hasContainer
? getContainerDigest(containerName)
: null;
onProgress?.(`Pulling ${image}...`);
const pullResult = spawnSync('docker', ['pull', image], {
stdio: 'inherit',
});
if (pullResult.status !== 0) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.IMAGE_UPGRADE_FAILED,
message: `Failed to pull image ${image}. Check that the version exists.`,
},
};
}
const pulledDigest = getImageDigest(image);
const imageUpdated = !previousDigest || previousDigest !== pulledDigest;
if (!hasContainer) {
onProgress?.('Image pulled. No existing container to upgrade.');
return {
success: true,
data: { image, imageUpdated, containerRecreated: false },
};
}
if (!imageUpdated) {
onProgress?.('Already up to date.');
return {
success: true,
data: { image, imageUpdated: false, containerRecreated: false },
};
}
const port = getContainerPort(containerName);
onProgress?.('Removing existing container...');
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
const volumeData = isTest
? 'twenty-app-dev-test-data'
: 'twenty-app-dev-data';
const volumeStorage = isTest
? 'twenty-app-dev-test-storage'
: 'twenty-app-dev-storage';
onProgress?.('Starting container with new image...');
const runResult = spawnSync(
'docker',
[
'run',
'-d',
'--name',
containerName,
'-p',
`${port}:${port}`,
'-e',
`NODE_PORT=${port}`,
'-e',
`SERVER_URL=http://localhost:${port}`,
'-v',
`${volumeData}:/data/postgres`,
'-v',
`${volumeStorage}:/app/packages/twenty-server/.local-storage`,
image,
],
{ stdio: 'inherit' },
);
if (runResult.status !== 0) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.IMAGE_UPGRADE_FAILED,
message: 'Failed to start container with new image.',
},
};
}
return {
success: true,
data: { image, imageUpdated: true, containerRecreated: true },
};
};
export const serverUpgrade = (
options?: ServerUpgradeOptions,
): Promise<CommandResult<ServerUpgradeResult>> =>
runSafe(
() => innerServerUpgrade(options),
SERVER_ERROR_CODES.IMAGE_UPGRADE_FAILED,
);

View File

@@ -31,6 +31,7 @@ export const SERVER_ERROR_CODES = {
DOCKER_NOT_RUNNING: 'DOCKER_NOT_RUNNING',
CONTAINER_START_FAILED: 'CONTAINER_START_FAILED',
HEALTH_TIMEOUT: 'HEALTH_TIMEOUT',
IMAGE_UPGRADE_FAILED: 'IMAGE_UPGRADE_FAILED',
} as const;
export const FUNCTION_ERROR_CODES = {

View File

@@ -49,6 +49,51 @@ export const containerExists = (containerName = CONTAINER_NAME): boolean => {
}
};
export const getImageForVersion = (version = 'latest'): string =>
`twentycrm/twenty-app-dev:${version}`;
export const getContainerDigest = (
containerName = CONTAINER_NAME,
): string | null => {
try {
return execSync(`docker inspect -f '{{.Image}}' ${containerName}`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
} catch {
return null;
}
};
export const getImageDigest = (image: string): string | null => {
try {
return execSync(`docker inspect -f '{{.Id}}' ${image}`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
} catch {
return null;
}
};
export const getContainerEnvVar = (
envVar: string,
containerName = CONTAINER_NAME,
): string | null => {
try {
const result = execSync(
`docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' ${containerName}`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
);
const match = result.match(new RegExp(`^${envVar}=(.+)$`, 'm'));
return match ? match[1] : null;
} catch {
return null;
}
};
export const checkDockerRunning = (): boolean => {
try {
execSync('docker info', { stdio: 'ignore' });