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 13:03:41 +00:00
committed by GitHub
parent 83db37d33f
commit c8e405cb4e
11 changed files with 301 additions and 7 deletions
@@ -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.`,
),
);
}
},
);
};
@@ -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';
@@ -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,
);
+1
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 = {
@@ -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' });