Files
twenty/packages/twenty-sdk/src/cli/operations/server-start.ts
T
Charles BochetandGitHub 15eb3e7edc feat(sdk): use config file as single source of truth, remove env var fallbacks (#19409)
## Summary

- **Config as source of truth**: `~/.twenty/config.json` is now the
single source of truth for SDK authentication — env var fallbacks have
been removed from the config resolution chain.
- **Test instance support**: `twenty server start --test` spins up a
dedicated Docker instance on port 2021 with its own config
(`config.test.json`), so integration tests don't interfere with the dev
environment.
- **API key auth for marketplace**: Removed `UserAuthGuard` from
`MarketplaceResolver` so API key tokens (workspace-scoped) can call
`installMarketplaceApp`.
- **CI for example apps**: Added monorepo CI workflows for `hello-world`
and `postcard` example apps to catch regressions.
- **Simplified CI**: All `ci-create-app-e2e` and example app workflows
now use a shared `spawn-twenty-app-dev-test` action (Docker-based)
instead of building the server from source. Consolidated auth env vars
to `TWENTY_API_URL` + `TWENTY_API_KEY`.
- **Template publishing fix**: `create-twenty-app` template now
correctly preserves `.github/` and `.gitignore` through npm publish
(stored without leading dot, renamed after copy).

## Test plan

- [x] CI SDK (lint, typecheck, unit, integration, e2e) — all green
- [x] CI Example App Hello World — green
- [x] CI Example App Postcard — green
- [x] CI Create App E2E minimal — green
- [x] CI Front, CI Server, CI Shared — green
2026-04-08 06:49:10 +02:00

279 lines
6.9 KiB
TypeScript

import { SERVER_ERROR_CODES, type CommandResult } from '@/cli/types';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { getConfigPath } from '@/cli/utilities/config/get-config-path';
import { runSafe } from '@/cli/utilities/run-safe';
import {
checkDockerRunning,
CONTAINER_NAME,
containerExists,
DEFAULT_PORT,
DEFAULT_TEST_PORT,
getContainerPort,
IMAGE,
isContainerRunning,
TEST_CONTAINER_NAME,
} from '@/cli/utilities/server/docker-container';
import {
checkServerHealth,
detectLocalServer,
} from '@/cli/utilities/server/detect-local-server';
import { execSync, spawn, spawnSync } from 'node:child_process';
import chalk from 'chalk';
const HEALTH_POLL_INTERVAL_MS = 2000;
const HEALTH_TIMEOUT_MS = 180 * 1000;
const MILESTONE_START = '==> START ';
const MILESTONE_DONE = '==> DONE';
const waitForHealthy = async (
port: number,
containerName: string,
): Promise<boolean> => {
const startTime = Date.now();
const onProgress = (message: string) =>
process.stdout.write(chalk.gray(message));
const logStream = spawn(
'docker',
['logs', '-f', '--since', '1s', containerName],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
logStream.on('error', () => {});
let hasPendingStep = false;
const handleLogLine = (line: string) => {
const trimmed = line.trim();
const startIndex = trimmed.indexOf(MILESTONE_START);
const doneIndex = trimmed.indexOf(MILESTONE_DONE);
if (startIndex !== -1) {
if (hasPendingStep) {
onProgress('Done\n');
}
const message = trimmed.slice(startIndex + MILESTONE_START.length);
onProgress(`==> ${message}... `);
hasPendingStep = true;
} else if (doneIndex !== -1 && hasPendingStep) {
onProgress('Done\n');
hasPendingStep = false;
}
};
let logBuffer = '';
const onData = (chunk: Buffer) => {
logBuffer += chunk.toString();
const lines = logBuffer.split('\n');
logBuffer = lines.pop() ?? '';
lines.forEach(handleLogLine);
};
logStream.stdout?.on('data', onData);
logStream.stderr?.on('data', onData);
try {
while (Date.now() - startTime < HEALTH_TIMEOUT_MS) {
if (await checkServerHealth(port)) {
if (hasPendingStep) {
onProgress('Done\n');
}
return true;
}
await new Promise((resolve) =>
setTimeout(resolve, HEALTH_POLL_INTERVAL_MS),
);
}
if (hasPendingStep) {
onProgress('Failed\n');
}
return false;
} finally {
logStream.kill();
}
};
export type ServerStartOptions = {
port?: number;
test?: boolean;
onProgress?: (message: string) => void;
};
export type ServerStartResult = {
port: number;
url: string;
};
const innerServerStart = async (
options: ServerStartOptions = {},
): Promise<CommandResult<ServerStartResult>> => {
const { onProgress, test: isTest } = options;
const containerName = isTest ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const defaultPort = isTest ? DEFAULT_TEST_PORT : DEFAULT_PORT;
const volumeData = isTest
? 'twenty-app-dev-test-data'
: 'twenty-app-dev-data';
const volumeStorage = isTest
? 'twenty-app-dev-test-storage'
: 'twenty-app-dev-storage';
const existingUrl = await detectLocalServer(options.port ?? defaultPort);
if (existingUrl) {
const configService = new ConfigService(
isTest ? { configPath: getConfigPath(true) } : undefined,
);
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: existingUrl });
const port = new URL(existingUrl).port;
onProgress?.(`Twenty server detected on ${existingUrl}`);
return {
success: true,
data: { port: parseInt(port, 10), url: existingUrl },
};
}
if (!checkDockerRunning()) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.DOCKER_NOT_RUNNING,
message: 'Docker is not running. Please start Docker and try again.',
},
};
}
if (isContainerRunning(containerName)) {
const port = getContainerPort(containerName);
onProgress?.('Container is running, waiting for it to become healthy...');
const healthy = await waitForHealthy(port, containerName);
if (!healthy) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.HEALTH_TIMEOUT,
message:
'Twenty server did not become healthy in time.\n' +
"Check: 'yarn twenty server logs'",
},
};
}
const url = `http://localhost:${port}`;
const configService = new ConfigService(
isTest ? { configPath: getConfigPath(true) } : undefined,
);
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: url });
onProgress?.(`Server running on ${url}`);
return { success: true, data: { port, url } };
}
let port = options.port ?? defaultPort;
if (containerExists(containerName)) {
const existingPort = getContainerPort(containerName);
if (existingPort !== port) {
onProgress?.(
`Existing container uses port ${existingPort}. Run 'yarn twenty server reset${isTest ? ' --test' : ''}' first to change ports.`,
);
}
port = existingPort;
onProgress?.('Starting existing container...');
execSync(`docker start ${containerName}`, { stdio: 'ignore' });
} else {
onProgress?.('Starting Twenty container...');
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.CONTAINER_START_FAILED,
message: 'Failed to start Twenty container.',
},
};
}
}
onProgress?.('Waiting for Twenty to be ready...');
const healthy = await waitForHealthy(port, containerName);
if (!healthy) {
return {
success: false,
error: {
code: SERVER_ERROR_CODES.HEALTH_TIMEOUT,
message:
'Twenty server did not become healthy in time.\n' +
"Check: 'yarn twenty server logs'",
},
};
}
const url = `http://localhost:${port}`;
const configService = new ConfigService(
isTest ? { configPath: getConfigPath(true) } : undefined,
);
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: url });
onProgress?.(`Server running on ${url}`);
return { success: true, data: { port, url } };
};
export const serverStart = (
options?: ServerStartOptions,
): Promise<CommandResult<ServerStartResult>> =>
runSafe(
() => innerServerStart(options),
SERVER_ERROR_CODES.CONTAINER_START_FAILED,
);