Files
twenty/packages/twenty-sdk/src/cli/operations/server-upgrade.ts
T
Félix Malfait e50adaff2d feat(sdk): give Docker-not-running error an actionable next step (#20280)
## Summary

The current Docker-not-running message is unhelpful in two ways:

1. It doesn't tell users **how** to start Docker
2. "try again" is meaningless because a first-time user doesn't yet know
the command they just ran (they got here from `create-twenty-app`, not
from typing `yarn twenty server start` themselves)

**Before:**
```
Docker is not running. Please start Docker and try again.
```

**After (macOS example):**
```
Docker is not running.

Start Docker:
  Run: open -a Docker
  (or launch Docker Desktop from Applications)

Then retry:
  yarn twenty server start

Don't have Docker? Install from https://docs.docker.com/get-docker/
```

The platform-specific line is detected via `process.platform`:
- `darwin` → `open -a Docker` + Docker Desktop fallback
- `linux` → `sudo systemctl start docker` + Docker Desktop fallback
- `win32` → "Launch Docker Desktop from the Start menu"
- other → link to install docs

The retry command is computed at the call site so it preserves the
user's actual flags — `yarn twenty server start --test`, `yarn twenty
server upgrade 2.2.0 --test`, etc.

## Why

This came out of shadowing a first-time app developer who hit this error
during `npx create-twenty-app`. They were stuck — the CLI told them to
"try again" but they had only learned two commands so far
(`create-twenty-app` and `yarn dev`), neither of which was the right
one. Improving the message turns the error into a teaching moment.

## Test plan
- [x] `npx nx typecheck twenty-sdk` passes
- [x] `npx nx lint twenty-sdk` passes
- [x] Manually verified rendered output for both `server start` and
`server upgrade` flows on macOS
- [ ] Verify message renders correctly on Linux/Windows in practice

## Possible follow-ups (out of scope)

- Auto-launch Docker Desktop on macOS if installed (changes user state —
separate PR)
- Make the multi-line CLI error printer style only the first line in
red, so guidance reads as default text rather than red

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-05 15:24:12 +02:00

154 lines
3.7 KiB
TypeScript

import { SERVER_ERROR_CODES, type CommandResult } from '@/cli/types';
import { runSafe } from '@/cli/utilities/run-safe';
import {
checkDockerRunning,
CONTAINER_NAME,
containerExists,
getContainerDigest,
getContainerPort,
getDockerNotRunningMessage,
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()) {
const retryCommand = [
'yarn twenty server upgrade',
version !== 'latest' ? version : null,
isTest ? '--test' : null,
]
.filter(Boolean)
.join(' ');
return {
success: false,
error: {
code: SERVER_ERROR_CODES.DOCKER_NOT_RUNNING,
message: getDockerNotRunningMessage(retryCommand),
},
};
}
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,
);