mirror of
https://github.com/twentyhq/twenty.git
synced 2026-06-12 01:46:39 -04:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"sideEffects": false,
|
||||
"bin": {
|
||||
"twenty": "dist/cli.cjs"
|
||||
|
||||
@@ -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';
|
||||
|
||||
144
packages/twenty-sdk/src/cli/operations/server-upgrade.ts
Normal file
144
packages/twenty-sdk/src/cli/operations/server-upgrade.ts
Normal 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,
|
||||
);
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user