From c8e405cb4e6055fe74c30bd06d6efd1e503df57f Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 30 Apr 2026 15:03:41 +0200 Subject: [PATCH] Add twenty sdk server upgrade command (#20158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- packages/create-twenty-app/package.json | 2 +- packages/create-twenty-app/src/cli.ts | 3 + .../src/create-app.command.ts | 29 +++- packages/twenty-client-sdk/package.json | 2 +- .../extend/apps/getting-started.mdx | 23 ++- packages/twenty-sdk/package.json | 2 +- .../twenty-sdk/src/cli/commands/server.ts | 45 ++++++ .../twenty-sdk/src/cli/operations/index.ts | 12 ++ .../src/cli/operations/server-upgrade.ts | 144 ++++++++++++++++++ packages/twenty-sdk/src/cli/types.ts | 1 + .../cli/utilities/server/docker-container.ts | 45 ++++++ 11 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/operations/server-upgrade.ts diff --git a/packages/create-twenty-app/package.json b/packages/create-twenty-app/package.json index 5460b180834..1d660015bdd 100644 --- a/packages/create-twenty-app/package.json +++ b/packages/create-twenty-app/package.json @@ -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", diff --git a/packages/create-twenty-app/src/cli.ts b/packages/create-twenty-app/src/cli.ts index 5c802a4d96b..f73e71deb53 100644 --- a/packages/create-twenty-app/src/cli.ts +++ b/packages/create-twenty-app/src/cli.ts @@ -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, }); }, ); diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index 41ca6c7ac0a..bb49a086150 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -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 { + private async shouldStartServer(autoConfirm?: boolean): Promise { 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', diff --git a/packages/twenty-client-sdk/package.json b/packages/twenty-client-sdk/package.json index 453b3a46727..d6a6b068e1a 100644 --- a/packages/twenty-client-sdk/package.json +++ b/packages/twenty-client-sdk/package.json @@ -1,6 +1,6 @@ { "name": "twenty-client-sdk", - "version": "2.1.0", + "version": "2.2.0", "sideEffects": false, "license": "AGPL-3.0", "scripts": { diff --git a/packages/twenty-docs/developers/extend/apps/getting-started.mdx b/packages/twenty-docs/developers/extend/apps/getting-started.mdx index 22ba3d2cc6f..01babd73606 100644 --- a/packages/twenty-docs/developers/extend/apps/getting-started.mdx +++ b/packages/twenty-docs/developers/extend/apps/getting-started.mdx @@ -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. diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 319191d3721..fe35077ddef 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -1,6 +1,6 @@ { "name": "twenty-sdk", - "version": "2.1.0", + "version": "2.2.0", "sideEffects": false, "bin": { "twenty": "dist/cli.cjs" diff --git a/packages/twenty-sdk/src/cli/commands/server.ts b/packages/twenty-sdk/src/cli/commands/server.ts index 659fb5c12d7..390d9e815b8 100644 --- a/packages/twenty-sdk/src/cli/commands/server.ts +++ b/packages/twenty-sdk/src/cli/commands/server.ts @@ -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.`, + ), + ); + } + }, + ); }; diff --git a/packages/twenty-sdk/src/cli/operations/index.ts b/packages/twenty-sdk/src/cli/operations/index.ts index 6507282ef9c..bf0acb4d4bd 100644 --- a/packages/twenty-sdk/src/cli/operations/index.ts +++ b/packages/twenty-sdk/src/cli/operations/index.ts @@ -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'; diff --git a/packages/twenty-sdk/src/cli/operations/server-upgrade.ts b/packages/twenty-sdk/src/cli/operations/server-upgrade.ts new file mode 100644 index 00000000000..55e79e6216e --- /dev/null +++ b/packages/twenty-sdk/src/cli/operations/server-upgrade.ts @@ -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> => { + 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> => + runSafe( + () => innerServerUpgrade(options), + SERVER_ERROR_CODES.IMAGE_UPGRADE_FAILED, + ); diff --git a/packages/twenty-sdk/src/cli/types.ts b/packages/twenty-sdk/src/cli/types.ts index f8e268f139f..3acfef26dcd 100644 --- a/packages/twenty-sdk/src/cli/types.ts +++ b/packages/twenty-sdk/src/cli/types.ts @@ -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 = { 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 7285a8e5423..d48decc2d6b 100644 --- a/packages/twenty-sdk/src/cli/utilities/server/docker-container.ts +++ b/packages/twenty-sdk/src/cli/utilities/server/docker-container.ts @@ -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' });