Refactor dependency graph for SDK, client-sdk and create-app (#18963)

## Summary

### Externalize `twenty-client-sdk` from `twenty-sdk`

Previously, `twenty-client-sdk` was listed as a `devDependency` of
`twenty-sdk`, which caused Vite to bundle it inline into the dist
output. This meant end-user apps had two copies of `twenty-client-sdk`:
one hidden inside `twenty-sdk`'s bundle, and one installed explicitly in
their `node_modules`. These copies could drift apart since they weren't
guaranteed to be the same version.

**Change:** Moved `twenty-client-sdk` from `devDependencies` to
`dependencies` in `twenty-sdk/package.json`. Vite's `external` function
now recognizes it and keeps it as an external `require`/`import` in the
dist output. End users get a single deduplicated copy resolved by their
package manager.

### Externalize `twenty-sdk` from `create-twenty-app`

Similarly, `create-twenty-app` had `twenty-sdk` as a `devDependency`
(bundled inline). After refactoring `create-twenty-app` to
programmatically import operations from `twenty-sdk` (instead of
shelling out via `execSync`), it became a proper runtime dependency.

**Change:** Moved `twenty-sdk` from `devDependencies` to `dependencies`
in `create-twenty-app/package.json`.

### Switch E2E CI to `yarn npm publish`

The `workspace:*` protocol in `dependencies` is a Yarn-specific feature.
`npm publish` publishes it as-is (which breaks for consumers), while
`yarn npm publish` automatically replaces `workspace:*` with the
resolved version at publish time (e.g., `workspace:*` becomes `=1.2.3`).

**Change:** Replaced `npm publish` with `yarn npm publish` in
`.github/workflows/ci-create-app-e2e.yaml`.

### Replace `execSync` with programmatic SDK calls in
`create-twenty-app`

`create-twenty-app` was shelling out to `yarn twenty remote add` and
`yarn twenty server start` via `execSync`, which assumed the `twenty`
binary was already installed in the scaffolded app. This was fragile and
created an implicit circular dependency.

**Changes:**
- Replaced `execSync('yarn twenty remote add ...')` with a direct call
to `authLoginOAuth()` from `twenty-sdk/cli`
- Replaced `execSync('yarn twenty server start')` with a direct call to
`serverStart()` from `twenty-sdk/cli`
- Deleted the duplicated `setup-local-instance.ts` from
`create-twenty-app`

### Centralize `serverStart` as a dedicated operation

The Docker server start logic was previously inline in the `server
start` CLI command handler (`server.ts`), and `setup-local-instance.ts`
was shelling out to `yarn twenty server start` to invoke it -- meaning
`twenty-sdk` was calling itself via a child process.

**Changes:**
- Extracted the Docker container management logic into a new
`serverStart` operation (`cli/operations/server-start.ts`)
- Merged the detect-or-start flow from `setup-local-instance.ts` into
`serverStart` (detect across multiple ports, start Docker if needed,
poll for health)
- Deleted `setup-local-instance.ts` from `twenty-sdk`
- Added `onProgress` callback (consistent with other operations like
`appBuild`) instead of direct `console.log` calls
- Both the `server start` CLI command and `create-twenty-app` now call
`serverStart()` programmatically

related to https://github.com/twentyhq/twenty-infra/pull/525
This commit is contained in:
Paul Rastoin 2026-03-26 11:56:52 +01:00 committed by GitHub
parent b651a74b1f
commit 052aecccc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1276 additions and 1415 deletions

View file

@ -53,6 +53,8 @@ jobs:
image: redis image: redis
ports: ports:
- 6379:6379 - 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
steps: steps:
- name: Fetch custom Github Actions and base branch history - name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -66,13 +68,13 @@ jobs:
run: | run: |
CI_VERSION="0.0.0-ci.$(date +%s)" CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
npx nx run-many -t set-local-version -p twenty-sdk twenty-client-sdk create-twenty-app --releaseVersion=$CI_VERSION npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
- name: Build packages - name: Build packages
run: | run: |
npx nx build twenty-sdk for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build twenty-client-sdk npx nx build $pkg
npx nx build create-twenty-app done
- name: Install and start Verdaccio - name: Install and start Verdaccio
run: | run: |
@ -89,11 +91,13 @@ jobs:
- name: Publish packages to local registry - name: Publish packages to local registry
run: | run: |
npm set //localhost:4873/:_authToken "ci-auth-token" yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in twenty-sdk twenty-client-sdk create-twenty-app; do for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg cd packages/$pkg
npm publish --registry http://localhost:4873 --tag ci yarn npm publish --tag ci
cd ../.. cd ../..
done done

940
.yarn/releases/yarn-4.13.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,4 +6,4 @@ enableInlineHunks: true
nodeLinker: node-modules nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.2.cjs yarnPath: .yarn/releases/yarn-4.13.0.cjs

View file

@ -175,7 +175,7 @@
}, },
"license": "AGPL-3.0", "license": "AGPL-3.0",
"name": "twenty", "name": "twenty",
"packageManager": "yarn@4.9.2", "packageManager": "yarn@4.13.0",
"resolutions": { "resolutions": {
"graphql": "16.8.1", "graphql": "16.8.1",
"type-fest": "4.10.1", "type-fest": "4.10.1",

View file

@ -41,7 +41,7 @@ cd my-twenty-app
# Or do it manually: # Or do it manually:
yarn twenty server start # Start local Twenty server yarn twenty server start # Start local Twenty server
yarn twenty remote add --local # Authenticate via OAuth yarn twenty remote add http://localhost:2020 --as local # Authenticate via OAuth
# Start dev mode: watches, builds, and syncs local changes to your workspace # Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built — both available via `twenty-client-sdk`) # (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built — both available via `twenty-client-sdk`)
@ -122,18 +122,10 @@ yarn twenty server reset # Wipe all data and start fresh
The server is pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`). The server is pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`).
### How to use a local Twenty instance
If you're already running a local Twenty instance, you can connect to it instead of using Docker. Pass the port your local server is listening on (default: `3000`):
```bash
npx create-twenty-app@latest my-app --port 3000
```
## Next steps ## Next steps
- Run `yarn twenty help` to see all available commands. - Run `yarn twenty help` to see all available commands.
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace via OAuth. - Use `yarn twenty remote add <url>` to authenticate with your Twenty workspace via OAuth.
- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills). - Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time. - Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- `CoreApiClient` is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient } from 'twenty-client-sdk/core'` and `import { MetadataApiClient } from 'twenty-client-sdk/metadata'`. - `CoreApiClient` is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient } from 'twenty-client-sdk/core'` and `import { MetadataApiClient } from 'twenty-client-sdk/metadata'`.
@ -177,7 +169,7 @@ Our team reviews contributions for quality, security, and reusability before mer
## Troubleshooting ## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`. - Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add --local`. - Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add <url>`.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client. - Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing ## Contributing

View file

@ -1,6 +1,6 @@
{ {
"name": "create-twenty-app", "name": "create-twenty-app",
"version": "0.8.0-canary.2", "version": "0.8.0-canary.3",
"description": "Command-line interface to create Twenty application", "description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs", "main": "dist/cli.cjs",
"bin": "dist/cli.cjs", "bin": "dist/cli.cjs",
@ -36,6 +36,7 @@
"lodash.camelcase": "^4.3.0", "lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1", "lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0", "lodash.startcase": "^4.4.0",
"twenty-sdk": "workspace:*",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -45,7 +46,6 @@
"@types/lodash.kebabcase": "^4.1.7", "@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4", "@types/lodash.startcase": "^4",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"twenty-sdk": "workspace:*",
"twenty-shared": "workspace:*", "twenty-shared": "workspace:*",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.0.0", "vite": "^7.0.0",

View file

@ -31,10 +31,6 @@ const program = new Command(packageJson.name)
'--skip-local-instance', '--skip-local-instance',
'Skip the local Twenty instance setup prompt', 'Skip the local Twenty instance setup prompt',
) )
.option(
'-p, --port <port>',
'Port of an existing Twenty server (skips Docker setup)',
)
.helpOption('-h, --help', 'Display this help message.') .helpOption('-h, --help', 'Display this help message.')
.action( .action(
async ( async (
@ -46,7 +42,6 @@ const program = new Command(packageJson.name)
displayName?: string; displayName?: string;
description?: string; description?: string;
skipLocalInstance?: boolean; skipLocalInstance?: boolean;
port?: string;
}, },
) => { ) => {
const modeFlags = [options?.exhaustive, options?.minimal].filter(Boolean); const modeFlags = [options?.exhaustive, options?.minimal].filter(Boolean);
@ -76,8 +71,6 @@ const program = new Command(packageJson.name)
const mode: ScaffoldingMode = options?.minimal ? 'minimal' : 'exhaustive'; const mode: ScaffoldingMode = options?.minimal ? 'minimal' : 'exhaustive';
const port = options?.port ? parseInt(options.port, 10) : undefined;
await new CreateAppCommand().execute({ await new CreateAppCommand().execute({
directory, directory,
mode, mode,
@ -85,7 +78,6 @@ const program = new Command(packageJson.name)
displayName: options?.displayName, displayName: options?.displayName,
description: options?.description, description: options?.description,
skipLocalInstance: options?.skipLocalInstance, skipLocalInstance: options?.skipLocalInstance,
port,
}); });
}, },
); );

View file

@ -1,18 +1,18 @@
import { basename } from 'path';
import { copyBaseApplicationProject } from '@/utils/app-template'; import { copyBaseApplicationProject } from '@/utils/app-template';
import { convertToLabel } from '@/utils/convert-to-label'; import { convertToLabel } from '@/utils/convert-to-label';
import { install } from '@/utils/install'; import { install } from '@/utils/install';
import {
type LocalInstanceResult,
setupLocalInstance,
} from '@/utils/setup-local-instance';
import { tryGitInit } from '@/utils/try-git-init'; import { tryGitInit } from '@/utils/try-git-init';
import chalk from 'chalk'; import chalk from 'chalk';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import kebabCase from 'lodash.kebabcase'; import kebabCase from 'lodash.kebabcase';
import { execSync } from 'node:child_process';
import * as path from 'path'; import * as path from 'path';
import { basename } from 'path';
import {
authLoginOAuth,
serverStart,
type ServerStartResult,
} from 'twenty-sdk/cli';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { import {
@ -29,7 +29,6 @@ type CreateAppOptions = {
displayName?: string; displayName?: string;
description?: string; description?: string;
skipLocalInstance?: boolean; skipLocalInstance?: boolean;
port?: number;
}; };
export class CreateAppCommand { export class CreateAppCommand {
@ -60,17 +59,22 @@ export class CreateAppCommand {
await tryGitInit(appDirectory); await tryGitInit(appDirectory);
let localResult: LocalInstanceResult = { running: false }; let serverResult: ServerStartResult | undefined;
if (!options.skipLocalInstance) { if (!options.skipLocalInstance) {
localResult = await setupLocalInstance(appDirectory, options.port); const startResult = await serverStart({
onProgress: (message: string) => console.log(chalk.gray(message)),
});
if (localResult.running && localResult.serverUrl) { if (startResult.success) {
await this.connectToLocal(appDirectory, localResult.serverUrl); serverResult = startResult.data;
await this.connectToLocal(serverResult.url);
} else {
console.log(chalk.yellow(`\n${startResult.error.message}`));
} }
} }
this.logSuccess(appDirectory, localResult); this.logSuccess(appDirectory, serverResult);
} catch (error) { } catch (error) {
console.error( console.error(
chalk.red('\nCreate application failed:'), chalk.red('\nCreate application failed:'),
@ -197,15 +201,20 @@ export class CreateAppCommand {
); );
} }
private async connectToLocal( private async connectToLocal(serverUrl: string): Promise<void> {
appDirectory: string,
serverUrl: string,
): Promise<void> {
try { try {
execSync(`yarn twenty remote add ${serverUrl} --as local`, { const result = await authLoginOAuth({
cwd: appDirectory, apiUrl: serverUrl,
stdio: 'inherit', remote: 'local',
}); });
if (!result.success) {
console.log(
chalk.yellow(
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
),
);
}
} catch { } catch {
console.log( console.log(
chalk.yellow( chalk.yellow(
@ -217,14 +226,14 @@ export class CreateAppCommand {
private logSuccess( private logSuccess(
appDirectory: string, appDirectory: string,
localResult: LocalInstanceResult, serverResult?: ServerStartResult,
): void { ): void {
const dirName = basename(appDirectory); const dirName = basename(appDirectory);
console.log(chalk.blue('\nApplication created. Next steps:')); console.log(chalk.blue('\nApplication created. Next steps:'));
console.log(chalk.gray(`- cd ${dirName}`)); console.log(chalk.gray(`- cd ${dirName}`));
if (!localResult.running) { if (!serverResult) {
console.log( console.log(
chalk.gray( chalk.gray(
'- yarn twenty remote add --local # Authenticate with Twenty', '- yarn twenty remote add --local # Authenticate with Twenty',

View file

@ -1,106 +0,0 @@
import chalk from 'chalk';
import { execSync } from 'node:child_process';
const LOCAL_PORTS = [2020, 3000];
// Minimal health check — the full implementation lives in twenty-sdk
const isServerReady = async (port: number): Promise<boolean> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(`http://localhost:${port}/healthz`, {
signal: controller.signal,
});
const body = await response.json();
return body.status === 'ok';
} catch {
return false;
} finally {
clearTimeout(timeoutId);
}
};
const detectRunningServer = async (
preferredPort?: number,
): Promise<number | null> => {
const ports = preferredPort ? [preferredPort] : LOCAL_PORTS;
for (const port of ports) {
if (await isServerReady(port)) {
return port;
}
}
return null;
};
export type LocalInstanceResult = {
running: boolean;
serverUrl?: string;
};
export const setupLocalInstance = async (
appDirectory: string,
preferredPort?: number,
): Promise<LocalInstanceResult> => {
const detectedPort = await detectRunningServer(preferredPort);
if (detectedPort) {
const serverUrl = `http://localhost:${detectedPort}`;
console.log(chalk.green(`Twenty server detected on ${serverUrl}.\n`));
return { running: true, serverUrl };
}
if (preferredPort) {
console.log(
chalk.yellow(
`No Twenty server found on port ${preferredPort}.\n` +
'Start your server and run `yarn twenty remote add --local` manually.\n',
),
);
return { running: false };
}
console.log(chalk.blue('Setting up local Twenty instance...\n'));
try {
execSync('yarn twenty server start', {
cwd: appDirectory,
stdio: 'inherit',
});
} catch {
return { running: false };
}
console.log(chalk.gray('Waiting for Twenty to be ready...\n'));
const startTime = Date.now();
const timeoutMs = 180 * 1000;
while (Date.now() - startTime < timeoutMs) {
if (await isServerReady(LOCAL_PORTS[0])) {
const serverUrl = `http://localhost:${LOCAL_PORTS[0]}`;
console.log(chalk.green(`Server running on '${serverUrl}'\n`));
return { running: true, serverUrl };
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
console.log(
chalk.yellow(
'Twenty server did not become healthy in time.\n',
"Check: 'yarn twenty server logs'\n",
),
);
return { running: false };
};

View file

@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace: First, authenticate to your workspace:
```bash ```bash
yarn twenty remote add --local yarn twenty remote add http://localhost:2020 --as local
``` ```
Then, start development mode to sync your app and watch for changes: Then, start development mode to sync your app and watch for changes:
@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash ```bash
# Remotes & Authentication # Remotes & Authentication
yarn twenty remote add --local # Authenticate with Twenty yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes yarn twenty remote list # List all configured remotes

View file

@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace: First, authenticate to your workspace:
```bash ```bash
yarn twenty remote add --local yarn twenty remote add http://localhost:2020 --as local
``` ```
Then, start development mode to sync your app and watch for changes: Then, start development mode to sync your app and watch for changes:
@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash ```bash
# Remotes & Authentication # Remotes & Authentication
yarn twenty remote add --local # Authenticate with Twenty yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes yarn twenty remote list # List all configured remotes

View file

@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace: First, authenticate to your workspace:
```bash ```bash
yarn twenty remote add --local yarn twenty remote add http://localhost:2020 --as local
``` ```
Then, start development mode to sync your app and watch for changes: Then, start development mode to sync your app and watch for changes:
@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash ```bash
# Remotes & Authentication # Remotes & Authentication
yarn twenty remote add --local # Authenticate with Twenty yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes yarn twenty remote list # List all configured remotes

View file

@ -1,6 +1,6 @@
{ {
"name": "twenty-client-sdk", "name": "twenty-client-sdk",
"version": "0.7.0-canary.0", "version": "0.8.0-canary.3",
"sideEffects": false, "sideEffects": false,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {

View file

@ -124,8 +124,6 @@ Manage remote server connections and authentication.
- `--token <token>`: API key for non-interactive auth. - `--token <token>`: API key for non-interactive auth.
- `--url <url>`: Server URL (alternative to positional arg). - `--url <url>`: Server URL (alternative to positional arg).
- `--as <name>`: Name for this remote (otherwise derived from URL hostname). - `--as <name>`: Name for this remote (otherwise derived from URL hostname).
- `--local`: Connect to local development server (`http://localhost:2020`) via OAuth.
- `--port <port>`: Port for local server (use with `--local`).
- Behavior: If `nameOrUrl` matches an existing remote name, re-authenticates it. Otherwise, creates a new remote and authenticates via OAuth (with API key fallback). - Behavior: If `nameOrUrl` matches an existing remote name, re-authenticates it. Otherwise, creates a new remote and authenticates via OAuth (with API key fallback).
- `twenty remote remove <name>` — Remove a remote and its credentials. - `twenty remote remove <name>` — Remove a remote and its credentials.
@ -147,9 +145,6 @@ twenty remote add
# Provide values in flags (non-interactive, for CI) # Provide values in flags (non-interactive, for CI)
twenty remote add https://api.twenty.com --token $TWENTY_API_KEY twenty remote add https://api.twenty.com --token $TWENTY_API_KEY
# Add a local development remote
twenty remote add --local
# Name a remote explicitly # Name a remote explicitly
twenty remote add https://api.twenty.com --as production twenty remote add https://api.twenty.com --as production
@ -339,14 +334,10 @@ Notes:
## How to use a local Twenty instance ## How to use a local Twenty instance
If you're already running a local Twenty instance, you can connect to it instead of using Docker. Pass the port your local server is listening on (default: `3000`): If you're already running a local Twenty instance, you can connect to it instead of using Docker:
```bash ```bash
# During scaffolding twenty remote add http://localhost:3000 --as local
npx create-twenty-app@latest my-app --port 3000
# Or after scaffolding
twenty remote add --local --port 3000
``` ```
## Troubleshooting ## Troubleshooting

View file

@ -1,6 +1,6 @@
{ {
"name": "twenty-sdk", "name": "twenty-sdk",
"version": "0.8.0-canary.2", "version": "0.8.0-canary.3",
"main": "dist/index.cjs", "main": "dist/index.cjs",
"module": "dist/index.mjs", "module": "dist/index.mjs",
"types": "dist/sdk/index.d.ts", "types": "dist/sdk/index.d.ts",
@ -75,6 +75,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"twenty-client-sdk": "workspace:*",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"vite": "^7.0.0", "vite": "^7.0.0",
@ -96,7 +97,6 @@
"storybook": "^10.2.13", "storybook": "^10.2.13",
"ts-morph": "^25.0.0", "ts-morph": "^25.0.0",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"twenty-client-sdk": "workspace:*",
"twenty-shared": "workspace:*", "twenty-shared": "workspace:*",
"twenty-ui": "workspace:*", "twenty-ui": "workspace:*",
"vite-plugin-dts": "^4.5.4", "vite-plugin-dts": "^4.5.4",

View file

@ -69,8 +69,6 @@ export const registerRemoteCommands = (program: Command): void => {
.command('add [nameOrUrl]') .command('add [nameOrUrl]')
.description('Add a new remote or re-authenticate an existing one') .description('Add a new remote or re-authenticate an existing one')
.option('--as <name>', 'Name for this remote') .option('--as <name>', 'Name for this remote')
.option('--local', 'Connect to local development server')
.option('--port <port>', 'Port for local server (use with --local)')
.option('--token <token>', 'API key for non-interactive auth') .option('--token <token>', 'API key for non-interactive auth')
.option('--url <url>', 'Server URL (alternative to positional arg)') .option('--url <url>', 'Server URL (alternative to positional arg)')
.action( .action(
@ -78,8 +76,6 @@ export const registerRemoteCommands = (program: Command): void => {
nameOrUrl: string | undefined, nameOrUrl: string | undefined,
options: { options: {
as?: string; as?: string;
local?: boolean;
port?: string;
token?: string; token?: string;
url?: string; url?: string;
}, },
@ -87,32 +83,6 @@ export const registerRemoteCommands = (program: Command): void => {
const configService = new ConfigService(); const configService = new ConfigService();
const existingRemotes = await configService.getRemotes(); const existingRemotes = await configService.getRemotes();
if (options.local) {
const remoteName = options.as ?? 'local';
const preferredPort = options.port
? parseInt(options.port, 10)
: undefined;
const localUrl = preferredPort
? `http://localhost:${preferredPort}`
: await detectLocalServer();
if (!localUrl) {
console.error(
chalk.red(
'No local Twenty server found on ports 2020 or 3000.\n' +
'Start one with: yarn twenty server start',
),
);
process.exit(1);
}
console.log(chalk.gray(`Found server at ${localUrl}`));
ConfigService.setActiveRemote(remoteName);
await authenticate(localUrl, options.token);
return;
}
// Re-authenticate an existing remote by name // Re-authenticate an existing remote by name
const isExistingRemote = const isExistingRemote =
nameOrUrl !== undefined && existingRemotes.includes(nameOrUrl); nameOrUrl !== undefined && existingRemotes.includes(nameOrUrl);

View file

@ -1,76 +1,16 @@
import { ConfigService } from '@/cli/utilities/config/config-service'; import { serverStart } from '@/cli/operations/server-start';
import {
CONTAINER_NAME,
containerExists,
DEFAULT_PORT,
getContainerPort,
isContainerRunning,
} from '@/cli/utilities/server/docker-container';
import { checkServerHealth } from '@/cli/utilities/server/detect-local-server'; import { checkServerHealth } from '@/cli/utilities/server/detect-local-server';
import chalk from 'chalk'; import chalk from 'chalk';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { execSync, spawnSync } from 'node:child_process'; import { execSync, spawnSync } from 'node:child_process';
const CONTAINER_NAME = 'twenty-app-dev';
const IMAGE = 'twentycrm/twenty-app-dev:latest';
const DEFAULT_PORT = 2020;
const isContainerRunning = (): boolean => {
try {
const result = execSync(
`docker inspect -f '{{.State.Running}}' ${CONTAINER_NAME}`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
).trim();
return result === 'true';
} catch {
return false;
}
};
const getContainerPort = (): number => {
try {
const result = execSync(
`docker inspect -f '{{(index (index .NetworkSettings.Ports "3000/tcp") 0).HostPort}}' ${CONTAINER_NAME}`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
).trim();
return parseInt(result, 10) || DEFAULT_PORT;
} catch {
return DEFAULT_PORT;
}
};
const containerExists = (): boolean => {
try {
execSync(`docker inspect ${CONTAINER_NAME}`, {
stdio: ['pipe', 'pipe', 'ignore'],
});
return true;
} catch {
return false;
}
};
const checkDockerRunning = (): boolean => {
try {
execSync('docker info', { stdio: 'ignore' });
return true;
} catch {
console.error(
chalk.red('Docker is not running. Please start Docker and try again.'),
);
return false;
}
};
const validatePort = (value: string): number => {
const port = parseInt(value, 10);
if (isNaN(port) || port < 1 || port > 65535) {
console.error(chalk.red('Invalid port number.'));
process.exit(1);
}
return port;
};
export const registerServerCommands = (program: Command): void => { export const registerServerCommands = (program: Command): void => {
const server = program const server = program
.command('server') .command('server')
@ -81,81 +21,26 @@ export const registerServerCommands = (program: Command): void => {
.description('Start a local Twenty server') .description('Start a local Twenty server')
.option('-p, --port <port>', 'HTTP port', String(DEFAULT_PORT)) .option('-p, --port <port>', 'HTTP port', String(DEFAULT_PORT))
.action(async (options: { port: string }) => { .action(async (options: { port: string }) => {
let port = validatePort(options.port); const port = parseInt(options.port, 10);
if (await checkServerHealth(port)) { if (isNaN(port) || port < 1 || port > 65535) {
const localUrl = `http://localhost:${port}`; console.error(chalk.red('Invalid port number.'));
const configService = new ConfigService();
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: localUrl });
console.log(
chalk.green(`Twenty server is already running on localhost:${port}.`),
);
return;
}
if (!checkDockerRunning()) {
process.exit(1); process.exit(1);
} }
if (isContainerRunning()) { const result = await serverStart({
console.log(chalk.gray('Container is running but not healthy yet.')); port,
onProgress: (message) => console.log(chalk.gray(message)),
});
return; if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
} }
if (containerExists()) { console.log(
const existingPort = getContainerPort(); chalk.green(`\nLocal remote configured → ${result.data.url}`),
);
if (existingPort !== port) {
console.log(
chalk.yellow(
`Existing container uses port ${existingPort}. Run 'yarn twenty server reset' first to change ports.`,
),
);
}
port = existingPort;
console.log(chalk.gray('Starting existing container...'));
execSync(`docker start ${CONTAINER_NAME}`, { stdio: 'ignore' });
} else {
console.log(chalk.gray('Starting Twenty container...'));
const runResult = spawnSync(
'docker',
[
'run',
'-d',
'--name',
CONTAINER_NAME,
'-p',
`${port}:3000`,
'-v',
'twenty-app-dev-data:/data/postgres',
'-v',
'twenty-app-dev-storage:/app/.local-storage',
IMAGE,
],
{ stdio: 'inherit' },
);
if (runResult.status !== 0) {
console.error(chalk.red('\nFailed to start Twenty container.'));
process.exit(runResult.status ?? 1);
}
}
const localUrl = `http://localhost:${port}`;
const configService = new ConfigService();
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: localUrl });
console.log(chalk.green(`\nLocal remote configured → ${localUrl}`));
}); });
server server

View file

@ -22,11 +22,16 @@ export type { AppUninstallOptions } from './uninstall';
export { functionExecute } from './execute'; export { functionExecute } from './execute';
export type { FunctionExecuteOptions } from './execute'; export type { FunctionExecuteOptions } from './execute';
// Server
export { serverStart } from './server-start';
export type { ServerStartOptions, ServerStartResult } from './server-start';
// Shared types and error codes // Shared types and error codes
export { export {
APP_ERROR_CODES, APP_ERROR_CODES,
AUTH_ERROR_CODES, AUTH_ERROR_CODES,
FUNCTION_ERROR_CODES, FUNCTION_ERROR_CODES,
SERVER_ERROR_CODES,
} from '@/cli/types'; } from '@/cli/types';
export type { export type {
AuthListRemote, AuthListRemote,

View file

@ -0,0 +1,191 @@
import { SERVER_ERROR_CODES, type CommandResult } from '@/cli/types';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import {
checkDockerRunning,
CONTAINER_NAME,
containerExists,
DEFAULT_PORT,
getContainerPort,
IMAGE,
isContainerRunning,
} from '@/cli/utilities/server/docker-container';
import {
checkServerHealth,
detectLocalServer,
} from '@/cli/utilities/server/detect-local-server';
import { execSync, spawnSync } from 'node:child_process';
const HEALTH_POLL_INTERVAL_MS = 2000;
const HEALTH_TIMEOUT_MS = 180 * 1000;
const waitForHealthy = async (port: number): Promise<boolean> => {
const startTime = Date.now();
while (Date.now() - startTime < HEALTH_TIMEOUT_MS) {
if (await checkServerHealth(port)) {
return true;
}
await new Promise((resolve) =>
setTimeout(resolve, HEALTH_POLL_INTERVAL_MS),
);
}
return false;
};
export type ServerStartOptions = {
port?: number;
onProgress?: (message: string) => void;
};
export type ServerStartResult = {
port: number;
url: string;
};
const innerServerStart = async (
options: ServerStartOptions = {},
): Promise<CommandResult<ServerStartResult>> => {
const { onProgress } = options;
const existingUrl = await detectLocalServer(options.port);
if (existingUrl) {
const configService = new ConfigService();
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()) {
const port = getContainerPort();
onProgress?.('Container is running, waiting for it to become healthy...');
const healthy = await waitForHealthy(port);
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();
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: url });
onProgress?.(`Server running on ${url}`);
return { success: true, data: { port, url } };
}
let port = options.port ?? DEFAULT_PORT;
if (containerExists()) {
const existingPort = getContainerPort();
if (existingPort !== port) {
onProgress?.(
`Existing container uses port ${existingPort}. Run 'yarn twenty server reset' first to change ports.`,
);
}
port = existingPort;
onProgress?.('Starting existing container...');
execSync(`docker start ${CONTAINER_NAME}`, { stdio: 'ignore' });
} else {
onProgress?.('Starting Twenty container...');
const runResult = spawnSync(
'docker',
[
'run',
'-d',
'--name',
CONTAINER_NAME,
'-p',
`${port}:3000`,
'-v',
'twenty-app-dev-data:/data/postgres',
'-v',
'twenty-app-dev-storage:/app/.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);
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();
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,
);

View file

@ -27,6 +27,12 @@ export const APP_ERROR_CODES = {
DEPLOY_FAILED: 'DEPLOY_FAILED', DEPLOY_FAILED: 'DEPLOY_FAILED',
} as const; } as const;
export const SERVER_ERROR_CODES = {
DOCKER_NOT_RUNNING: 'DOCKER_NOT_RUNNING',
CONTAINER_START_FAILED: 'CONTAINER_START_FAILED',
HEALTH_TIMEOUT: 'HEALTH_TIMEOUT',
} as const;
export const FUNCTION_ERROR_CODES = { export const FUNCTION_ERROR_CODES = {
FETCH_FUNCTIONS_FAILED: 'FETCH_FUNCTIONS_FAILED', FETCH_FUNCTIONS_FAILED: 'FETCH_FUNCTIONS_FAILED',
FUNCTION_NOT_FOUND: 'FUNCTION_NOT_FOUND', FUNCTION_NOT_FOUND: 'FUNCTION_NOT_FOUND',

View file

@ -58,7 +58,7 @@ export class CheckServerOrchestratorStep {
this.state.applyStepEvents([ this.state.applyStepEvents([
{ {
message: message:
'Authentication failed. Run `twenty remote add --local` to authenticate.', 'Authentication failed. Run `twenty remote add <url>` to authenticate.',
status: 'error', status: 'error',
}, },
]); ]);

View file

@ -0,0 +1,53 @@
import { execSync } from 'node:child_process';
export const CONTAINER_NAME = 'twenty-app-dev';
export const IMAGE = 'twentycrm/twenty-app-dev:latest';
export const DEFAULT_PORT = 2020;
export const isContainerRunning = (): boolean => {
try {
const result = execSync(
`docker inspect -f '{{.State.Running}}' ${CONTAINER_NAME}`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
).trim();
return result === 'true';
} catch {
return false;
}
};
export const getContainerPort = (): number => {
try {
const result = execSync(
`docker inspect -f '{{(index (index .NetworkSettings.Ports "3000/tcp") 0).HostPort}}' ${CONTAINER_NAME}`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
).trim();
return parseInt(result, 10) || DEFAULT_PORT;
} catch {
return DEFAULT_PORT;
}
};
export const containerExists = (): boolean => {
try {
execSync(`docker inspect ${CONTAINER_NAME}`, {
stdio: ['pipe', 'pipe', 'ignore'],
});
return true;
} catch {
return false;
}
};
export const checkDockerRunning = (): boolean => {
try {
execSync('docker info', { stdio: 'ignore' });
return true;
} catch {
return false;
}
};

View file

@ -1,74 +0,0 @@
import {
checkServerHealth,
detectLocalServer,
} from '@/cli/utilities/server/detect-local-server';
import chalk from 'chalk';
import { execSync } from 'node:child_process';
const LOCAL_PORTS = [2020, 3000];
export type LocalInstanceResult = {
running: boolean;
serverUrl?: string;
};
export const setupLocalInstance = async (
appDirectory: string,
preferredPort?: number,
): Promise<LocalInstanceResult> => {
const serverUrl = await detectLocalServer(preferredPort);
if (serverUrl) {
console.log(chalk.green(`Twenty server detected on ${serverUrl}.\n`));
return { running: true, serverUrl };
}
if (preferredPort) {
console.log(
chalk.yellow(
`No Twenty server found on port ${preferredPort}.\n` +
'Start your server and run `yarn twenty remote add --local` manually.\n',
),
);
return { running: false };
}
console.log(chalk.blue('Setting up local Twenty instance...\n'));
try {
execSync('yarn twenty server start', {
cwd: appDirectory,
stdio: 'inherit',
});
} catch {
return { running: false };
}
console.log(chalk.gray('Waiting for Twenty to be ready...\n'));
const startTime = Date.now();
const timeoutMs = 180 * 1000;
while (Date.now() - startTime < timeoutMs) {
if (await checkServerHealth(LOCAL_PORTS[0])) {
const serverUrl = `http://localhost:${LOCAL_PORTS[0]}`;
console.log(chalk.green(`Server running on '${serverUrl}'\n`));
return { running: true, serverUrl };
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
console.log(
chalk.yellow(
'Twenty server did not become healthy in time.\n',
"Check: 'yarn twenty server logs'\n",
),
);
return { running: false };
};

View file

@ -1,28 +0,0 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
const verifyBuildPackagesBundleTwentyDependencies = () => {
const distPath = resolve('dist/cli.cjs');
if (!existsSync(distPath)) {
console.error(`Build check failed: ${distPath} does not exist`);
process.exit(1);
}
const content = readFileSync(distPath, 'utf8');
const filePath = resolve('package.json');
const pkg = JSON.parse(readFileSync(filePath, 'utf8'));
if (/require\("twenty/.test(content)) {
console.error(
`Build check failed: ${pkg.name}/dist/cli.cjs contains a require("twenty...) import. Workspace packages should be bundled, not externalized.`,
);
process.exit(1);
} else {
console.log(`${pkg.name}/dist/cli.cjs: OK`);
}
};
verifyBuildPackagesBundleTwentyDependencies();

View file

@ -1,27 +0,0 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';
const packages = ['twenty-sdk', 'create-twenty-app', 'twenty-client-sdk'];
const verifyUniquePackageVersion = () => {
const packageVersions = packages.map((pkg) => {
const packageJsonPath = resolve('packages', pkg, 'package.json');
const packageJsonFile = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
return packageJsonFile.version as string;
});
if (new Set(packageVersions).size !== 1) {
console.error(
`Build check failed: "${packages.join('", "')}" should have the same package.json version. Got ${packageVersions.join(', ')}`,
);
process.exit(1);
return;
}
console.log(
`"${packages.join('", "')}" share the same version ${packageVersions[0]}: OK`,
);
};
verifyUniquePackageVersion();