mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
parent
394a3cef15
commit
2f095c8903
44 changed files with 861 additions and 371 deletions
|
|
@ -1,5 +1,5 @@
|
|||
.git
|
||||
.env
|
||||
node_modules
|
||||
**/node_modules
|
||||
.nx/cache
|
||||
packages/twenty-server/.env
|
||||
|
|
|
|||
|
|
@ -25,25 +25,25 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
|
|||
## Prerequisites
|
||||
|
||||
- Node.js 24+ (recommended) and Yarn 4
|
||||
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
|
||||
- Docker (for the local Twenty dev server)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Scaffold a new app — the CLI will offer to start a local Twenty server
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Get help and list all available commands
|
||||
yarn twenty help
|
||||
# The scaffolder can automatically:
|
||||
# 1. Start a local Twenty server (Docker)
|
||||
# 2. Open the browser to log in (tim@apple.dev / tim@apple.dev)
|
||||
# 3. Authenticate your app via OAuth
|
||||
|
||||
# Authenticate with your Twenty server
|
||||
yarn twenty remote add --local
|
||||
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty add
|
||||
# Or do it manually:
|
||||
yarn twenty server start # Start local Twenty server
|
||||
yarn twenty remote add --local # Authenticate via OAuth
|
||||
|
||||
# Start dev mode: watches, builds, and syncs local changes to your workspace
|
||||
# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built with the SDK — both available via `twenty-sdk/clients`)
|
||||
yarn twenty dev
|
||||
|
||||
# Watch your application's function logs
|
||||
|
|
@ -107,10 +107,24 @@ npx create-twenty-app@latest my-app -m
|
|||
- `skills/example-skill.ts` — Example AI agent skill definition
|
||||
- `__tests__/app-install.integration-test.ts` — Integration test that builds, installs, and verifies the app (includes `vitest.config.ts`, `tsconfig.spec.json`, and a setup file)
|
||||
|
||||
## Local server
|
||||
|
||||
The scaffolder can start a local Twenty dev server for you (all-in-one Docker image with PostgreSQL, Redis, server, and worker). You can also manage it manually:
|
||||
|
||||
```bash
|
||||
yarn twenty server start # Start (pulls image if needed)
|
||||
yarn twenty server status # Check if it's healthy
|
||||
yarn twenty server logs # Stream logs
|
||||
yarn twenty server stop # Stop (data is preserved)
|
||||
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`).
|
||||
|
||||
## Next steps
|
||||
|
||||
- Run `yarn twenty help` to see all available commands.
|
||||
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace.
|
||||
- Use `yarn twenty remote add --local` 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).
|
||||
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
|
||||
- `CoreApiClient` (for workspace data via `/graphql`) 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, MetadataApiClient } from 'twenty-sdk/clients'`.
|
||||
|
|
@ -153,8 +167,9 @@ Our team reviews contributions for quality, security, and reusability before mer
|
|||
|
||||
## Troubleshooting
|
||||
|
||||
- Auth prompts not appearing: run `yarn twenty remote add --local` again and verify the API key permissions.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto‑generates the typed client.
|
||||
- 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`.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.8.0-canary.0",
|
||||
"version": "0.8.0-canary.1",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
|
|
|||
|
|
@ -1,60 +1,11 @@
|
|||
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
Start development mode — it auto-connects to your local Twenty server at localhost:3000:
|
||||
|
||||
```bash
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
Open your Twenty instance and go to `/settings/applications` section to see the result.
|
||||
|
||||
## Available Commands
|
||||
|
||||
Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & authentication
|
||||
yarn twenty remote add --local # Connect to local Twenty server
|
||||
yarn twenty remote add <url> # Connect to a remote server (OAuth)
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
|
||||
# Development
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty build # Build the application
|
||||
yarn twenty deploy # Deploy to a Twenty server
|
||||
yarn twenty publish # Publish to npm
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty uninstall # Uninstall app from server
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
If your project includes the example integration test (`src/__tests__/app-install.integration-test.ts`), you can run it with:
|
||||
|
||||
```bash
|
||||
# Make sure a Twenty server is running at http://localhost:3000
|
||||
yarn test
|
||||
```
|
||||
|
||||
The test builds and installs the app, then verifies it appears in the applications list. Test configuration (API URL and API key) is defined in `vitest.config.ts`.
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
Main docs and pitfalls are available in LLMS.md file.
|
||||
Run `yarn twenty help` to list all available commands.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Twenty applications, take a look at the following resources:
|
||||
|
||||
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
|
||||
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/capabilities/apps)
|
||||
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
|
||||
- [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export class CreateAppCommand {
|
|||
|
||||
await fs.ensureDir(appDirectory);
|
||||
|
||||
console.log(chalk.gray(' Scaffolding project files...'));
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
|
|
@ -54,29 +55,20 @@ export class CreateAppCommand {
|
|||
exampleOptions,
|
||||
});
|
||||
|
||||
console.log(chalk.gray(' Installing dependencies...'));
|
||||
await install(appDirectory);
|
||||
|
||||
console.log(chalk.gray(' Initializing git repository...'));
|
||||
await tryGitInit(appDirectory);
|
||||
|
||||
let localResult: LocalInstanceResult = { running: false };
|
||||
|
||||
if (!options.skipLocalInstance) {
|
||||
const { needsLocalInstance } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'needsLocalInstance',
|
||||
message:
|
||||
'Do you need a local instance of Twenty? Recommended if you not have one already.',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
// Auto-detect a running server first
|
||||
localResult = await setupLocalInstance(appDirectory);
|
||||
|
||||
if (needsLocalInstance) {
|
||||
localResult = await setupLocalInstance();
|
||||
}
|
||||
|
||||
if (isDefined(localResult.apiKey)) {
|
||||
this.runAuthLogin(appDirectory, localResult.apiKey);
|
||||
if (localResult.running && localResult.serverUrl) {
|
||||
await this.connectToLocal(appDirectory, localResult.serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,23 +193,29 @@ export class CreateAppCommand {
|
|||
appDirectory: string;
|
||||
appName: string;
|
||||
}): void {
|
||||
console.log(chalk.blue('🎯 Creating Twenty Application'));
|
||||
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
|
||||
console.log(chalk.gray(`📝 Name: ${appName}`));
|
||||
console.log(chalk.blue('Creating Twenty Application'));
|
||||
console.log(chalk.gray(` Directory: ${appDirectory}`));
|
||||
console.log(chalk.gray(` Name: ${appName}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
private runAuthLogin(appDirectory: string, apiKey: string): void {
|
||||
private async connectToLocal(
|
||||
appDirectory: string,
|
||||
serverUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
execSync(
|
||||
`yarn twenty auth:login --api-key "${apiKey}" --api-url http://localhost:3000`,
|
||||
{ cwd: appDirectory, stdio: 'inherit' },
|
||||
`npx nx run twenty-sdk:start -- remote add ${serverUrl} --as local`,
|
||||
{
|
||||
cwd: appDirectory,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
console.log(chalk.green('✅ Authenticated with local Twenty instance.'));
|
||||
console.log(chalk.green('Authenticated with local Twenty instance.'));
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Auto auth:login failed. Run `yarn twenty auth:login` manually.',
|
||||
'Authentication skipped. Run `npx nx run twenty-sdk:start -- remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -229,27 +227,21 @@ export class CreateAppCommand {
|
|||
): void {
|
||||
const dirName = appDirectory.split('/').reverse()[0] ?? '';
|
||||
|
||||
console.log(chalk.green('✅ Application created!'));
|
||||
console.log(chalk.green('Application created!'));
|
||||
console.log('');
|
||||
console.log(chalk.blue('Next steps:'));
|
||||
console.log(chalk.gray(` cd ${dirName}`));
|
||||
|
||||
if (localResult.apiKey) {
|
||||
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
|
||||
} else if (localResult.running) {
|
||||
if (!localResult.running) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
' yarn twenty remote add --local # Authenticate with Twenty',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
' yarn twenty remote add --local # Authenticate with Twenty',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.gray(' yarn twenty dev # Start dev mode'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,194 +1,101 @@
|
|||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { platform } from 'node:os';
|
||||
|
||||
const INSTALL_SCRIPT_URL =
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/scripts/install.sh';
|
||||
const DEFAULT_PORT = 2020;
|
||||
|
||||
const SERVER_CONTAINER = 'twenty-server-1';
|
||||
const DB_CONTAINER = 'twenty-db-1';
|
||||
|
||||
const isDockerAvailable = (): boolean => {
|
||||
try {
|
||||
execSync('docker compose version', { stdio: 'ignore' });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isDockerRunning = (): boolean => {
|
||||
try {
|
||||
execSync('docker info', { stdio: 'ignore' });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isTwentyServerRunning = async (): Promise<boolean> => {
|
||||
try {
|
||||
// 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);
|
||||
|
||||
const response = await fetch('http://localhost:3000/healthz', {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/healthz`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
return body.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveWorkspaceId = (): string | null => {
|
||||
try {
|
||||
const result = execSync(
|
||||
`docker exec ${DB_CONTAINER} psql -U postgres -d default -t -c "SELECT id FROM core.workspace WHERE \\"activationStatus\\" = 'ACTIVE' LIMIT 1"`,
|
||||
{ encoding: 'utf-8' },
|
||||
).trim();
|
||||
|
||||
return result || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const generateApiKeyToken = (workspaceId: string): string | null => {
|
||||
try {
|
||||
const output = execSync(
|
||||
`docker exec -e NODE_ENV=development ${SERVER_CONTAINER} yarn command:prod workspace:generate-api-key -w ${workspaceId}`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
|
||||
const TOKEN_PREFIX = 'TOKEN:';
|
||||
const tokenLine = output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.find((line) => line.includes(TOKEN_PREFIX));
|
||||
|
||||
if (!tokenLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenStartIndex =
|
||||
tokenLine.indexOf(TOKEN_PREFIX) + TOKEN_PREFIX.length;
|
||||
|
||||
return tokenLine.slice(tokenStartIndex).trim();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
export type LocalInstanceResult = {
|
||||
running: boolean;
|
||||
apiKey?: string;
|
||||
serverUrl?: string;
|
||||
};
|
||||
|
||||
export const setupLocalInstance = async (): Promise<LocalInstanceResult> => {
|
||||
export const setupLocalInstance = async (
|
||||
appDirectory: string,
|
||||
): Promise<LocalInstanceResult> => {
|
||||
console.log('');
|
||||
console.log(chalk.blue('🐳 Setting up local Twenty instance...'));
|
||||
console.log(chalk.blue('Setting up local Twenty instance...'));
|
||||
|
||||
if (await isTwentyServerRunning()) {
|
||||
console.log(
|
||||
chalk.green('✅ Twenty server is already running on localhost:3000.'),
|
||||
);
|
||||
} else {
|
||||
if (!isDockerAvailable()) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Docker Compose is not installed. Please install Docker first.',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' See https://docs.docker.com/get-docker/'));
|
||||
if (await isServerReady(DEFAULT_PORT)) {
|
||||
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
|
||||
|
||||
return { running: false };
|
||||
console.log(chalk.green(`Twenty server detected on ${serverUrl}.`));
|
||||
|
||||
return { running: true, serverUrl };
|
||||
}
|
||||
|
||||
if (!isDockerRunning()) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Docker is not running. Please start Docker and try again.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: false };
|
||||
}
|
||||
// Delegate to `twenty server start` from the scaffolded app
|
||||
console.log(chalk.gray('Starting local Twenty server...'));
|
||||
|
||||
try {
|
||||
execSync(`bash <(curl -sL ${INSTALL_SCRIPT_URL})`, {
|
||||
execSync('yarn twenty server start', {
|
||||
cwd: appDirectory,
|
||||
stdio: 'inherit',
|
||||
shell: '/bin/bash',
|
||||
});
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow('⚠️ Local instance setup did not complete successfully.'),
|
||||
chalk.yellow(
|
||||
'Failed to start Twenty server. Run `yarn twenty server start` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: false };
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(chalk.gray('Waiting for Twenty to be ready...'));
|
||||
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = 180 * 1000;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (await isServerReady(DEFAULT_PORT)) {
|
||||
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
|
||||
|
||||
console.log(chalk.green(`Twenty server is running on ${serverUrl}.`));
|
||||
console.log(
|
||||
chalk.blue(
|
||||
'👉 Please create your workspace in the browser before continuing.',
|
||||
chalk.gray(
|
||||
'Workspace ready — login with tim@apple.dev / tim@apple.dev',
|
||||
),
|
||||
);
|
||||
|
||||
const { workspaceCreated } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'workspaceCreated',
|
||||
message: 'Have you finished creating your workspace?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
const openCommand = platform() === 'darwin' ? 'open' : 'xdg-open';
|
||||
|
||||
try {
|
||||
execSync(`${openCommand} ${serverUrl}`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Ignore if browser can't be opened
|
||||
}
|
||||
|
||||
return { running: true, serverUrl };
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
if (!workspaceCreated) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Skipping API key generation. Run `yarn twenty remote add --local` manually after creating your workspace.',
|
||||
'Twenty server did not become healthy in time. Check: yarn twenty server logs',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: true };
|
||||
}
|
||||
|
||||
console.log(chalk.blue('🔑 Generating API key for your workspace...'));
|
||||
|
||||
const workspaceId = getActiveWorkspaceId();
|
||||
|
||||
if (!isDefined(workspaceId)) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ No active workspace found. Make sure you completed the signup flow, then run `yarn twenty auth:login` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: true };
|
||||
}
|
||||
|
||||
const apiKey = generateApiKeyToken(workspaceId);
|
||||
|
||||
if (!isDefined(apiKey)) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Could not generate API key. Run `yarn twenty auth:login` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: true };
|
||||
}
|
||||
|
||||
console.log(chalk.green('✅ API key generated for your workspace.'));
|
||||
|
||||
return { running: true, apiKey };
|
||||
return { running: false };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ import * as os from 'os';
|
|||
import * as path from 'path';
|
||||
import { beforeAll } from 'vitest';
|
||||
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
|
||||
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
|
||||
|
||||
const assertServerIsReachable = async () => {
|
||||
|
|
|
|||
130
packages/twenty-docker/twenty-app-dev/Dockerfile
Normal file
130
packages/twenty-docker/twenty-app-dev/Dockerfile
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
ARG APP_VERSION
|
||||
|
||||
# === Stage 1: Common dependencies ===
|
||||
FROM node:22-alpine AS common-deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./tsconfig.base.json ./nx.json /app/
|
||||
COPY ./.yarn/releases /app/.yarn/releases
|
||||
COPY ./.yarn/patches /app/.yarn/patches
|
||||
|
||||
COPY ./packages/twenty-emails/package.json /app/packages/twenty-emails/
|
||||
COPY ./packages/twenty-server/package.json /app/packages/twenty-server/
|
||||
COPY ./packages/twenty-server/patches /app/packages/twenty-server/patches
|
||||
COPY ./packages/twenty-ui/package.json /app/packages/twenty-ui/
|
||||
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/
|
||||
COPY ./packages/twenty-front/package.json /app/packages/twenty-front/
|
||||
COPY ./packages/twenty-sdk/package.json /app/packages/twenty-sdk/
|
||||
|
||||
RUN yarn && yarn cache clean && npx nx reset
|
||||
|
||||
# === Stage 2: Build server from source ===
|
||||
FROM common-deps AS twenty-server-build
|
||||
|
||||
COPY ./packages/twenty-emails /app/packages/twenty-emails
|
||||
COPY ./packages/twenty-shared /app/packages/twenty-shared
|
||||
COPY ./packages/twenty-ui /app/packages/twenty-ui
|
||||
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
|
||||
COPY ./packages/twenty-server /app/packages/twenty-server
|
||||
|
||||
RUN npx nx run twenty-server:build
|
||||
RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-sdk twenty-server
|
||||
|
||||
# === Stage 3: Build frontend from source ===
|
||||
# Requires ~10GB Docker memory. To skip, pre-build on host first:
|
||||
# npx nx build twenty-front
|
||||
# The COPY below will pick up packages/twenty-front/build/ if it exists.
|
||||
FROM common-deps AS twenty-front-build
|
||||
COPY --from=twenty-server-build /app/package.json /tmp/.build-sentinel
|
||||
|
||||
COPY ./packages/twenty-front /app/packages/twenty-front
|
||||
COPY ./packages/twenty-ui /app/packages/twenty-ui
|
||||
COPY ./packages/twenty-shared /app/packages/twenty-shared
|
||||
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
|
||||
RUN if [ -d /app/packages/twenty-front/build ]; then \
|
||||
echo "Using pre-built frontend from host"; \
|
||||
else \
|
||||
NODE_OPTIONS="--max-old-space-size=8192" npx nx build twenty-front; \
|
||||
fi
|
||||
|
||||
# === Stage 4: s6-overlay ===
|
||||
FROM alpine:3.20 AS s6-fetch
|
||||
ARG S6_OVERLAY_VERSION=3.2.0.2
|
||||
ARG TARGETARCH
|
||||
RUN if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64" > /tmp/s6arch; \
|
||||
else echo "x86_64" > /tmp/s6arch; fi
|
||||
RUN S6_ARCH=$(cat /tmp/s6arch) && \
|
||||
wget -O /tmp/s6-overlay-noarch.tar.xz \
|
||||
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" && \
|
||||
wget -O /tmp/s6-overlay-noarch.tar.xz.sha256 \
|
||||
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz.sha256" && \
|
||||
wget -O /tmp/s6-overlay-arch.tar.xz \
|
||||
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" && \
|
||||
wget -O /tmp/s6-overlay-arch.tar.xz.sha256 \
|
||||
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz.sha256" && \
|
||||
cd /tmp && \
|
||||
NOARCH_SUM=$(awk '{print $1}' s6-overlay-noarch.tar.xz.sha256) && \
|
||||
ARCH_SUM=$(awk '{print $1}' s6-overlay-arch.tar.xz.sha256) && \
|
||||
echo "$NOARCH_SUM s6-overlay-noarch.tar.xz" | sha256sum -c - && \
|
||||
echo "$ARCH_SUM s6-overlay-arch.tar.xz" | sha256sum -c -
|
||||
|
||||
# === Stage 5: Final all-in-one image ===
|
||||
FROM node:22-alpine
|
||||
|
||||
# s6-overlay
|
||||
COPY --from=s6-fetch /tmp/s6-overlay-noarch.tar.xz /tmp/
|
||||
COPY --from=s6-fetch /tmp/s6-overlay-arch.tar.xz /tmp/
|
||||
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz \
|
||||
&& tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz \
|
||||
&& rm /tmp/s6-overlay-*.tar.xz
|
||||
|
||||
# Infrastructure: Postgres, Redis, and utilities
|
||||
RUN apk add --no-cache \
|
||||
postgresql16 postgresql16-contrib \
|
||||
redis \
|
||||
curl jq su-exec
|
||||
|
||||
# tsx for database setup scripts
|
||||
RUN npm install -g tsx
|
||||
|
||||
# Twenty application (both server and frontend built from source)
|
||||
COPY --from=twenty-server-build /app /app
|
||||
COPY --from=twenty-front-build /app/packages/twenty-front/build /app/packages/twenty-server/dist/front
|
||||
|
||||
# s6 service definitions
|
||||
COPY packages/twenty-docker/twenty-app-dev/rootfs/ /
|
||||
|
||||
# Data directories
|
||||
RUN mkdir -p /data/postgres /data/redis /app/.local-storage \
|
||||
&& chown -R postgres:postgres /data/postgres \
|
||||
&& chown 1000:1000 /data/redis /app/.local-storage
|
||||
|
||||
ARG REACT_APP_SERVER_BASE_URL
|
||||
ARG APP_VERSION=0.0.0
|
||||
|
||||
# Tell s6-overlay to preserve container environment variables
|
||||
ENV S6_KEEP_ENV=1
|
||||
|
||||
# Environment defaults
|
||||
ENV PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default \
|
||||
SERVER_URL=http://localhost:2020 \
|
||||
REDIS_URL=redis://localhost:6379 \
|
||||
STORAGE_TYPE=local \
|
||||
APP_SECRET=twenty-app-dev-secret-not-for-production \
|
||||
REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL \
|
||||
APP_VERSION=$APP_VERSION \
|
||||
NODE_ENV=development \
|
||||
NODE_PORT=3000 \
|
||||
DISABLE_DB_MIGRATIONS=true \
|
||||
DISABLE_CRON_JOBS_REGISTRATION=true \
|
||||
IS_BILLING_ENABLED=false \
|
||||
SIGN_IN_PREFILLED=true
|
||||
|
||||
EXPOSE 3000
|
||||
VOLUME ["/data/postgres", "/app/.local-storage"]
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
|
||||
LABEL org.opencontainers.image.description="All-in-one Twenty image for local development and SDK usage. Includes PostgreSQL, Redis, server, and worker."
|
||||
|
||||
ENTRYPOINT ["/init"]
|
||||
|
|
@ -0,0 +1 @@
|
|||
oneshot
|
||||
|
|
@ -0,0 +1 @@
|
|||
/bin/sh /etc/s6-overlay/scripts/init-db.sh
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
#!/bin/sh
|
||||
# Initialize PostgreSQL data directory if empty
|
||||
if [ ! -f /data/postgres/PG_VERSION ]; then
|
||||
echo "Initializing PostgreSQL data directory..."
|
||||
su-exec postgres initdb -D /data/postgres --auth=trust --encoding=UTF8
|
||||
# Allow local connections without password
|
||||
echo "host all all 127.0.0.1/32 trust" >> /data/postgres/pg_hba.conf
|
||||
echo "host all all ::1/128 trust" >> /data/postgres/pg_hba.conf
|
||||
fi
|
||||
|
||||
exec su-exec postgres postgres -D /data/postgres \
|
||||
-c listen_addresses=localhost \
|
||||
-c unix_socket_directories=/tmp
|
||||
|
|
@ -0,0 +1 @@
|
|||
longrun
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
#!/bin/sh
|
||||
exec redis-server \
|
||||
--dir /data/redis \
|
||||
--maxmemory-policy noeviction \
|
||||
--bind 127.0.0.1 \
|
||||
--protected-mode yes
|
||||
|
|
@ -0,0 +1 @@
|
|||
longrun
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
cd /app/packages/twenty-server
|
||||
exec yarn start:prod
|
||||
|
|
@ -0,0 +1 @@
|
|||
longrun
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
cd /app/packages/twenty-server
|
||||
exec yarn worker:prod
|
||||
|
|
@ -0,0 +1 @@
|
|||
longrun
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Wait for PostgreSQL to be ready (timeout after 60s)
|
||||
echo "Waiting for PostgreSQL..."
|
||||
TRIES=0
|
||||
until su-exec postgres pg_isready -h localhost; do
|
||||
TRIES=$((TRIES + 1))
|
||||
if [ "$TRIES" -ge 120 ]; then
|
||||
echo "ERROR: PostgreSQL did not become ready within 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
echo "PostgreSQL is ready."
|
||||
|
||||
# Create role if it doesn't exist
|
||||
su-exec postgres psql -h localhost -tc \
|
||||
"SELECT 1 FROM pg_roles WHERE rolname='twenty'" | grep -q 1 \
|
||||
|| su-exec postgres psql -h localhost -c "CREATE ROLE twenty WITH LOGIN PASSWORD 'twenty' SUPERUSER"
|
||||
|
||||
# Create database if it doesn't exist
|
||||
su-exec postgres psql -h localhost -tc \
|
||||
"SELECT 1 FROM pg_database WHERE datname='default'" | grep -q 1 \
|
||||
|| su-exec postgres createdb -h localhost -O twenty default
|
||||
|
||||
# Run Twenty database setup and migrations
|
||||
cd /app/packages/twenty-server
|
||||
|
||||
has_schema=$(PGPASSWORD=twenty psql -h localhost -U twenty -d default -tAc \
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'core')")
|
||||
|
||||
if [ "$has_schema" = "f" ]; then
|
||||
echo "Database appears to be empty, running initial setup..."
|
||||
NODE_OPTIONS="--max-old-space-size=1500" tsx ./scripts/setup-db.ts
|
||||
fi
|
||||
|
||||
# Always run migrations (idempotent — skips already-applied ones)
|
||||
yarn database:migrate:prod
|
||||
|
||||
yarn command:prod cache:flush
|
||||
yarn command:prod upgrade
|
||||
yarn command:prod cache:flush
|
||||
|
||||
# Only seed on first boot — check if the dev workspace already exists
|
||||
has_workspace=$(PGPASSWORD=twenty psql -h localhost -U twenty -d default -tAc \
|
||||
"SELECT EXISTS (SELECT 1 FROM core.workspace WHERE id = '20202020-1c25-4d02-bf25-6aeccf7ea419')")
|
||||
|
||||
if [ "$has_workspace" = "f" ]; then
|
||||
echo "Seeding app dev data..."
|
||||
yarn command:prod workspace:seed:dev --light || true
|
||||
else
|
||||
echo "Dev workspace already seeded, skipping."
|
||||
fi
|
||||
|
||||
echo "Database initialization complete."
|
||||
|
|
@ -20,19 +20,51 @@ Apps let you build and manage Twenty customizations **as code**. Instead of conf
|
|||
## Prerequisites
|
||||
|
||||
- Node.js 24+ and Yarn 4
|
||||
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
|
||||
- Docker (for the local Twenty dev server)
|
||||
|
||||
## Getting Started
|
||||
|
||||
Create a new app using the official scaffolder, then authenticate and start developing:
|
||||
Create a new app using the official scaffolder. It can automatically start a local Twenty instance for you:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Scaffold a new app (includes all examples by default)
|
||||
# Scaffold a new app — the CLI will offer to start a local Twenty server
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Start dev mode: automatically syncs local changes to your workspace
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Local Server Management
|
||||
|
||||
The SDK includes commands to manage a local Twenty dev server (all-in-one Docker image with PostgreSQL, Redis, server, and worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Start the local server (pulls the image if needed)
|
||||
yarn twenty server start
|
||||
|
||||
# Check server status
|
||||
yarn twenty server status
|
||||
|
||||
# Stream server logs
|
||||
yarn twenty server logs
|
||||
|
||||
# Stop the server
|
||||
yarn twenty server stop
|
||||
|
||||
# Reset all data and start fresh
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
The local server comes pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`), so you can start developing immediately without any manual setup.
|
||||
|
||||
### Authentication
|
||||
|
||||
Connect your app to the local server using OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
The scaffolder supports two modes for controlling which example files are included:
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
|
|||
## Prerequisites
|
||||
|
||||
- Node.js 24+ (recommended) and Yarn 4
|
||||
- A Twenty workspace and an API key. Generate one at https://app.twenty.com/settings/api-webhooks
|
||||
- Docker (for the local Twenty dev server) or a remote Twenty workspace
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -55,6 +55,7 @@ Commands:
|
|||
typecheck [appPath] Run TypeScript type checking on the application
|
||||
uninstall [appPath] Uninstall application from Twenty
|
||||
remote Manage remote Twenty servers
|
||||
server Manage a local Twenty server instance
|
||||
add [entityType] Add a new entity to your application
|
||||
exec [appPath] Execute a logic function with a JSON payload
|
||||
logs [appPath] Watch application function logs
|
||||
|
|
@ -69,6 +70,41 @@ In a scaffolded project (via `create-twenty-app`), use `yarn twenty <command>` i
|
|||
|
||||
## Commands
|
||||
|
||||
### Server
|
||||
|
||||
Manage a local Twenty dev server (all-in-one Docker image).
|
||||
|
||||
- `twenty server start` — Start the local server (pulls image if needed).
|
||||
- Options:
|
||||
- `-p, --port <port>`: HTTP port (default: `2020`).
|
||||
- `twenty server stop` — Stop the local server.
|
||||
- `twenty server logs` — Stream server logs.
|
||||
- Options:
|
||||
- `-n, --lines <lines>`: Number of initial lines to show (default: `50`).
|
||||
- `twenty server status` — Show server status (running/stopped/healthy).
|
||||
- `twenty server reset` — Delete all data and start fresh.
|
||||
|
||||
The server comes pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`).
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Start the local server
|
||||
twenty server start
|
||||
|
||||
# Check if it's ready
|
||||
twenty server status
|
||||
|
||||
# Follow logs during first startup
|
||||
twenty server logs
|
||||
|
||||
# Stop the server (data is preserved)
|
||||
twenty server stop
|
||||
|
||||
# Wipe everything and start over
|
||||
twenty server reset
|
||||
```
|
||||
|
||||
### Remote
|
||||
|
||||
Manage remote server connections and authentication.
|
||||
|
|
@ -79,7 +115,7 @@ Manage remote server connections and authentication.
|
|||
- `--token <token>`: API key for non-interactive auth.
|
||||
- `--url <url>`: Server URL (alternative to positional arg).
|
||||
- `--as <name>`: Name for this remote (otherwise derived from URL hostname).
|
||||
- `--local`: Connect to local development server (`http://localhost:3000`).
|
||||
- `--local`: Connect to local development server (`http://localhost:2020`) via OAuth.
|
||||
- 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.
|
||||
|
|
@ -270,7 +306,7 @@ Example configuration file:
|
|||
"defaultRemote": "production",
|
||||
"remotes": {
|
||||
"local": {
|
||||
"apiUrl": "http://localhost:3000",
|
||||
"apiUrl": "http://localhost:2020",
|
||||
"apiKey": "<your-api-key>"
|
||||
},
|
||||
"production": {
|
||||
|
|
@ -285,7 +321,7 @@ Example configuration file:
|
|||
|
||||
Notes:
|
||||
|
||||
- If a remote is missing, `apiUrl` defaults to `http://localhost:3000`.
|
||||
- If a remote is missing, `apiUrl` defaults to `http://localhost:2020`.
|
||||
- `twenty remote add` writes credentials for the active remote (OAuth tokens or API key).
|
||||
- `twenty remote add --as my-remote` saves under a custom name.
|
||||
- `twenty remote switch` sets the `defaultRemote` field, used when `-r` is not specified.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "0.8.0-canary.0",
|
||||
"version": "0.8.0-canary.1",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/sdk/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export const SERVER_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
|
||||
export const SERVER_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { LogicFunctionExecuteCommand } from './exec';
|
|||
import { LogicFunctionLogsCommand } from './logs';
|
||||
import { EntityAddCommand } from './add';
|
||||
import { registerRemoteCommands } from './remote';
|
||||
import { registerServerCommands } from './server';
|
||||
import { SyncableEntity } from 'twenty-shared/application';
|
||||
|
||||
export const registerCommands = (program: Command): void => {
|
||||
|
|
@ -92,6 +93,7 @@ export const registerCommands = (program: Command): void => {
|
|||
});
|
||||
|
||||
registerRemoteCommands(program);
|
||||
registerServerCommands(program);
|
||||
|
||||
program
|
||||
.command('add [entityType]')
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { authLogin } from '@/cli/operations/login';
|
|||
import { authLoginOAuth } from '@/cli/operations/login-oauth';
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
|
||||
import chalk from 'chalk';
|
||||
import type { Command } from 'commander';
|
||||
import inquirer from 'inquirer';
|
||||
|
|
@ -86,23 +87,21 @@ export const registerRemoteCommands = (program: Command): void => {
|
|||
|
||||
if (options.local) {
|
||||
const remoteName = options.as ?? 'local';
|
||||
const token =
|
||||
options.token ??
|
||||
(
|
||||
await inquirer.prompt<{ apiKey: string }>([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'apiKey',
|
||||
message: 'API Key for local server:',
|
||||
mask: '*',
|
||||
validate: (input: string) =>
|
||||
input.length > 0 || 'API key is required',
|
||||
},
|
||||
])
|
||||
).apiKey;
|
||||
const localUrl = 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('http://localhost:3000', token);
|
||||
await authenticate(localUrl, options.token);
|
||||
console.log(chalk.green(`✓ Authenticated remote "${remoteName}".`));
|
||||
|
||||
return;
|
||||
|
|
@ -127,7 +126,7 @@ export const registerRemoteCommands = (program: Command): void => {
|
|||
nameOrUrl ??
|
||||
options.url ??
|
||||
(options.token
|
||||
? 'http://localhost:3000'
|
||||
? ((await detectLocalServer()) ?? 'http://localhost:2020')
|
||||
: (
|
||||
await inquirer.prompt<{ apiUrl: string }>([
|
||||
{
|
||||
|
|
|
|||
243
packages/twenty-sdk/src/cli/commands/server.ts
Normal file
243
packages/twenty-sdk/src/cli/commands/server.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { checkServerHealth } from '@/cli/utilities/server/detect-local-server';
|
||||
import chalk from 'chalk';
|
||||
import type { Command } from 'commander';
|
||||
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 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 => {
|
||||
const server = program
|
||||
.command('server')
|
||||
.description('Manage a local Twenty server instance');
|
||||
|
||||
server
|
||||
.command('start')
|
||||
.description('Start a local Twenty server')
|
||||
.option('-p, --port <port>', 'HTTP port', String(DEFAULT_PORT))
|
||||
.action(async (options: { port: string }) => {
|
||||
let port = validatePort(options.port);
|
||||
|
||||
if (await checkServerHealth(port)) {
|
||||
console.log(
|
||||
chalk.green(`Twenty server is already running on localhost:${port}.`),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isContainerRunning()) {
|
||||
console.log(chalk.gray('Container is running but not healthy yet.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (containerExists()) {
|
||||
const existingPort = getContainerPort();
|
||||
|
||||
if (existingPort !== port) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`Existing container uses port ${existingPort}. Run 'yarn twenty server reset' first to change ports.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.gray('Starting existing container...'));
|
||||
execSync(`docker start ${CONTAINER_NAME}`, { stdio: 'ignore' });
|
||||
port = existingPort;
|
||||
} else {
|
||||
try {
|
||||
execSync('docker info', { stdio: 'ignore' });
|
||||
} catch {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'Docker is not running. Please start Docker and try again.',
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.gray(`Pulling ${IMAGE}...`));
|
||||
|
||||
try {
|
||||
execSync(`docker pull ${IMAGE}`, { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.log(chalk.gray('Pull failed, trying local image...'));
|
||||
}
|
||||
|
||||
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('Failed to start Twenty container.'));
|
||||
process.exit(runResult.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(`Twenty server starting on http://localhost:${port}`),
|
||||
);
|
||||
console.log(
|
||||
chalk.gray('Run `yarn twenty server logs` to follow startup progress.'),
|
||||
);
|
||||
});
|
||||
|
||||
server
|
||||
.command('stop')
|
||||
.description('Stop the local Twenty server')
|
||||
.action(() => {
|
||||
if (!containerExists()) {
|
||||
console.log(chalk.yellow('No Twenty server container found.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
execSync(`docker stop ${CONTAINER_NAME}`, { stdio: 'ignore' });
|
||||
console.log(chalk.green('Twenty server stopped.'));
|
||||
});
|
||||
|
||||
server
|
||||
.command('logs')
|
||||
.description('Stream Twenty server logs')
|
||||
.option('-n, --lines <lines>', 'Number of lines to show', '50')
|
||||
.action((options: { lines: string }) => {
|
||||
if (!containerExists()) {
|
||||
console.log(chalk.yellow('No Twenty server container found.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
spawnSync(
|
||||
'docker',
|
||||
['logs', '-f', '--tail', options.lines, CONTAINER_NAME],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
} catch {
|
||||
// User hit Ctrl-C
|
||||
}
|
||||
});
|
||||
|
||||
server
|
||||
.command('status')
|
||||
.description('Show Twenty server status')
|
||||
.action(async () => {
|
||||
if (!containerExists()) {
|
||||
console.log(` Status: ${chalk.gray('not created')}`);
|
||||
console.log(
|
||||
chalk.gray(" Run 'yarn twenty server start' to create one."),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const running = isContainerRunning();
|
||||
const port = running ? getContainerPort() : DEFAULT_PORT;
|
||||
const healthy = running ? await checkServerHealth(port) : false;
|
||||
|
||||
const statusText = healthy
|
||||
? chalk.green('running (healthy)')
|
||||
: running
|
||||
? chalk.yellow('running (starting...)')
|
||||
: chalk.gray('stopped');
|
||||
|
||||
console.log(` Status: ${statusText}`);
|
||||
console.log(` URL: http://localhost:${port}`);
|
||||
|
||||
if (healthy) {
|
||||
console.log(chalk.gray(' Login: tim@apple.dev / tim@apple.dev'));
|
||||
}
|
||||
});
|
||||
|
||||
server
|
||||
.command('reset')
|
||||
.description('Delete all data and start fresh')
|
||||
.action(() => {
|
||||
if (containerExists()) {
|
||||
execSync(`docker rm -f ${CONTAINER_NAME}`, { stdio: 'ignore' });
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(
|
||||
'docker volume rm twenty-app-dev-data twenty-app-dev-storage',
|
||||
{
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Volumes may not exist
|
||||
}
|
||||
|
||||
console.log(chalk.green('Twenty server data reset.'));
|
||||
console.log(
|
||||
chalk.gray("Run 'yarn twenty server start' to start a fresh instance."),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
@ -196,7 +196,7 @@ export class ConfigService {
|
|||
|
||||
private getDefaultConfig(): RemoteConfig {
|
||||
return {
|
||||
apiUrl: 'http://localhost:3000',
|
||||
apiUrl: 'http://localhost:2020',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ export class DevModeOrchestrator {
|
|||
message: 'Failed to create development application',
|
||||
status: 'error',
|
||||
},
|
||||
{ message: JSON.stringify(createResult, null, 2), status: 'error' },
|
||||
]);
|
||||
this.state.updatePipeline({ status: 'error' });
|
||||
|
||||
|
|
|
|||
|
|
@ -36,12 +36,11 @@ export class CheckServerOrchestratorStep {
|
|||
this.state.applyStepEvents([
|
||||
{
|
||||
message:
|
||||
'Cannot reach Twenty at localhost:3000.\n\n' +
|
||||
' Start a local server with Docker:\n' +
|
||||
' curl -sL https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/docker-compose.yml -o docker-compose.yml\n' +
|
||||
' docker compose up -d\n\n' +
|
||||
' Or from the monorepo:\n' +
|
||||
' yarn start\n\n' +
|
||||
'Cannot reach Twenty server.\n\n' +
|
||||
' Start a local server:\n' +
|
||||
' yarn twenty server start\n\n' +
|
||||
' Check server status:\n' +
|
||||
' yarn twenty server status\n\n' +
|
||||
' Waiting for server...',
|
||||
status: 'error',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
const LOCAL_PORTS = [2020, 3000];
|
||||
|
||||
export const checkServerHealth = async (port: number): Promise<boolean> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
export const detectLocalServer = async (): Promise<string | null> => {
|
||||
for (const port of LOCAL_PORTS) {
|
||||
if (await checkServerHealth(port)) {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,32 +1,52 @@
|
|||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import {
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
SEED_YCOMBINATOR_WORKSPACE_ID,
|
||||
SeededWorkspacesIds,
|
||||
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
|
||||
import { DevSeederService } from 'src/engine/workspace-manager/dev-seeder/services/dev-seeder.service';
|
||||
|
||||
type DataSeedWorkspaceOptions = {
|
||||
light?: boolean;
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'workspace:seed:dev',
|
||||
description:
|
||||
'Seed workspace with initial data. This command is intended for development only.',
|
||||
})
|
||||
export class DataSeedWorkspaceCommand extends CommandRunner {
|
||||
workspaceIds = [
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
SEED_YCOMBINATOR_WORKSPACE_ID,
|
||||
] as const;
|
||||
private readonly logger = new Logger(DataSeedWorkspaceCommand.name);
|
||||
|
||||
constructor(private readonly devSeederService: DevSeederService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
@Option({
|
||||
flags: '--light',
|
||||
description:
|
||||
'Light seed: skip demo custom objects (Pet, Survey, etc.) and limit records to 5 per object',
|
||||
})
|
||||
parseLight(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async run(
|
||||
_passedParams: string[],
|
||||
options: DataSeedWorkspaceOptions,
|
||||
): Promise<void> {
|
||||
const workspaceIds: SeededWorkspacesIds[] = options.light
|
||||
? [SEED_APPLE_WORKSPACE_ID]
|
||||
: [SEED_APPLE_WORKSPACE_ID, SEED_YCOMBINATOR_WORKSPACE_ID];
|
||||
|
||||
try {
|
||||
for (const workspaceId of this.workspaceIds) {
|
||||
await this.devSeederService.seedDev(workspaceId);
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await this.devSeederService.seedDev(workspaceId, {
|
||||
light: options.light,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
|
|
|
|||
|
|
@ -46,10 +46,12 @@ export class DevSeederPermissionsService {
|
|||
twentyStandardFlatApplication,
|
||||
workspaceCustomFlatApplication,
|
||||
workspaceId,
|
||||
light = false,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
twentyStandardFlatApplication: FlatApplication;
|
||||
workspaceCustomFlatApplication: FlatApplication;
|
||||
light?: boolean;
|
||||
}) {
|
||||
const adminRole = await this.roleRepository.findOne({
|
||||
where: {
|
||||
|
|
@ -80,13 +82,24 @@ export class DevSeederPermissionsService {
|
|||
let guestUserWorkspaceId: string | undefined;
|
||||
|
||||
if (workspaceId === SEED_APPLE_WORKSPACE_ID) {
|
||||
if (light) {
|
||||
// In light mode, Tim is admin (prefilled login user needs full
|
||||
// access for SDK development). No demo permission roles needed.
|
||||
adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM;
|
||||
memberUserWorkspaceIds = [
|
||||
USER_WORKSPACE_DATA_SEED_IDS.JANE,
|
||||
USER_WORKSPACE_DATA_SEED_IDS.JONY,
|
||||
USER_WORKSPACE_DATA_SEED_IDS.PHIL,
|
||||
...Object.values(RANDOM_USER_WORKSPACE_IDS),
|
||||
];
|
||||
} else {
|
||||
adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.JANE;
|
||||
limitedUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM;
|
||||
guestUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.PHIL;
|
||||
memberUserWorkspaceIds = [
|
||||
USER_WORKSPACE_DATA_SEED_IDS.JONY,
|
||||
...Object.values(RANDOM_USER_WORKSPACE_IDS),
|
||||
];
|
||||
guestUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.PHIL;
|
||||
|
||||
const guestRole = await this.roleService.createGuestRole({
|
||||
workspaceId,
|
||||
|
|
@ -99,6 +112,8 @@ export class DevSeederPermissionsService {
|
|||
roleId: guestRole.id,
|
||||
});
|
||||
|
||||
// The limited role restricts access to Pet and Rocket objects,
|
||||
// which are only created in full (non-light) mode
|
||||
const limitedRole = await this.createLimitedRoleForSeedWorkspace({
|
||||
workspaceId,
|
||||
ownerFlatApplication: workspaceCustomFlatApplication,
|
||||
|
|
@ -109,6 +124,7 @@ export class DevSeederPermissionsService {
|
|||
userWorkspaceIds: [limitedUserWorkspaceId],
|
||||
roleId: limitedRole.id,
|
||||
});
|
||||
}
|
||||
} else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) {
|
||||
adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME;
|
||||
memberUserWorkspaceIds = [
|
||||
|
|
|
|||
|
|
@ -300,10 +300,12 @@ export class DevSeederDataService {
|
|||
schemaName,
|
||||
workspaceId,
|
||||
featureFlags,
|
||||
light = false,
|
||||
}: {
|
||||
schemaName: string;
|
||||
workspaceId: string;
|
||||
featureFlags?: Record<FeatureFlagKey, boolean>;
|
||||
light?: boolean;
|
||||
}) {
|
||||
const objectMetadataItems =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
|
||||
|
|
@ -328,8 +330,10 @@ export class DevSeederDataService {
|
|||
attachmentSeeds,
|
||||
featureFlags,
|
||||
objectMetadataItems,
|
||||
light,
|
||||
});
|
||||
|
||||
if (!light) {
|
||||
await this.timelineActivitySeederService.seedTimelineActivities({
|
||||
entityManager,
|
||||
schemaName,
|
||||
|
|
@ -341,6 +345,7 @@ export class DevSeederDataService {
|
|||
entityManager,
|
||||
attachmentFileMeta,
|
||||
);
|
||||
}
|
||||
|
||||
await prefillWorkflows(
|
||||
entityManager,
|
||||
|
|
@ -359,6 +364,7 @@ export class DevSeederDataService {
|
|||
attachmentSeeds,
|
||||
featureFlags,
|
||||
objectMetadataItems,
|
||||
light = false,
|
||||
}: {
|
||||
entityManager: WorkspaceEntityManager;
|
||||
schemaName: string;
|
||||
|
|
@ -366,6 +372,7 @@ export class DevSeederDataService {
|
|||
attachmentSeeds: RecordSeedConfig['recordSeeds'];
|
||||
featureFlags?: Record<FeatureFlagKey, boolean>;
|
||||
objectMetadataItems: FlatObjectMetadata[];
|
||||
light?: boolean;
|
||||
}) {
|
||||
const batches = getRecordSeedsBatches(
|
||||
workspaceId,
|
||||
|
|
@ -378,6 +385,10 @@ export class DevSeederDataService {
|
|||
for (const batch of batches) {
|
||||
await Promise.all(
|
||||
batch.map(async (recordSeedsConfig) => {
|
||||
if (light && recordSeedsConfig.tableName.startsWith('_')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectMetadata = objectMetadataItems.find(
|
||||
(item) =>
|
||||
computeTableName(item.nameSingular, item.isCustom) ===
|
||||
|
|
|
|||
|
|
@ -51,6 +51,14 @@ type JunctionConfigSeed = {
|
|||
label?: string;
|
||||
};
|
||||
|
||||
type WorkspaceSeedConfig = {
|
||||
objects: { seed: ObjectMetadataSeed; fields?: FieldMetadataSeed[] }[];
|
||||
fields: { objectName: string; seeds: FieldMetadataSeed[] }[];
|
||||
morphRelations?: { objectName: string; seeds: MorphRelationSeed[] }[];
|
||||
junctionFields?: JunctionFieldSeed[];
|
||||
junctionConfigs?: JunctionConfigSeed[];
|
||||
};
|
||||
|
||||
type FlatMaps = {
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
|
|
@ -65,18 +73,7 @@ export class DevSeederMetadataService {
|
|||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
) {}
|
||||
|
||||
private readonly workspaceConfigs: Record<
|
||||
string,
|
||||
{
|
||||
objects: { seed: ObjectMetadataSeed; fields?: FieldMetadataSeed[] }[];
|
||||
fields: { objectName: string; seeds: FieldMetadataSeed[] }[];
|
||||
morphRelations?: { objectName: string; seeds: MorphRelationSeed[] }[];
|
||||
// Junction fields create relations to junction objects (inverses auto-created)
|
||||
junctionFields?: JunctionFieldSeed[];
|
||||
// Configure junction settings on fields after all relations exist
|
||||
junctionConfigs?: JunctionConfigSeed[];
|
||||
}
|
||||
> = {
|
||||
private readonly workspaceConfigs: Record<string, WorkspaceSeedConfig> = {
|
||||
[SEED_APPLE_WORKSPACE_ID]: {
|
||||
objects: [
|
||||
{ seed: ROCKET_CUSTOM_OBJECT_SEED },
|
||||
|
|
@ -178,13 +175,14 @@ export class DevSeederMetadataService {
|
|||
},
|
||||
};
|
||||
|
||||
public async seed({
|
||||
dataSourceMetadata,
|
||||
workspaceId,
|
||||
}: {
|
||||
dataSourceMetadata: DataSourceEntity;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
private getLightConfig(config: WorkspaceSeedConfig): WorkspaceSeedConfig {
|
||||
return {
|
||||
objects: [],
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
private getConfig(workspaceId: string, light: boolean): WorkspaceSeedConfig {
|
||||
const config = this.workspaceConfigs[workspaceId];
|
||||
|
||||
if (!config) {
|
||||
|
|
@ -193,6 +191,20 @@ export class DevSeederMetadataService {
|
|||
);
|
||||
}
|
||||
|
||||
return light ? this.getLightConfig(config) : config;
|
||||
}
|
||||
|
||||
public async seed({
|
||||
dataSourceMetadata,
|
||||
workspaceId,
|
||||
light = false,
|
||||
}: {
|
||||
dataSourceMetadata: DataSourceEntity;
|
||||
workspaceId: string;
|
||||
light?: boolean;
|
||||
}) {
|
||||
const config = this.getConfig(workspaceId, light);
|
||||
|
||||
for (const obj of config.objects) {
|
||||
await this.seedCustomObject({
|
||||
dataSourceId: dataSourceMetadata.id,
|
||||
|
|
@ -266,14 +278,14 @@ export class DevSeederMetadataService {
|
|||
});
|
||||
}
|
||||
|
||||
public async seedRelations({ workspaceId }: { workspaceId: string }) {
|
||||
const config = this.workspaceConfigs[workspaceId];
|
||||
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
`Workspace configuration not found for workspaceId: ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
public async seedRelations({
|
||||
workspaceId,
|
||||
light = false,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
light?: boolean;
|
||||
}) {
|
||||
const config = this.getConfig(workspaceId, light);
|
||||
|
||||
// 1. Seed morph relations (creates inverses on target objects)
|
||||
let maps = await this.getFreshFlatMaps(workspaceId);
|
||||
|
|
@ -287,7 +299,6 @@ export class DevSeederMetadataService {
|
|||
}
|
||||
|
||||
// 2. Seed junction fields (creates relations + inverses on junction objects)
|
||||
// Use same maps for all - matches original working behavior
|
||||
maps = await this.getFreshFlatMaps(workspaceId);
|
||||
|
||||
for (const field of config.junctionFields ?? []) {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ export class DevSeederService {
|
|||
private readonly coreDataSource: DataSource,
|
||||
) {}
|
||||
|
||||
public async seedDev(workspaceId: SeededWorkspacesIds): Promise<void> {
|
||||
public async seedDev(
|
||||
workspaceId: SeededWorkspacesIds,
|
||||
options?: { light?: boolean },
|
||||
): Promise<void> {
|
||||
const light = options?.light ?? false;
|
||||
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
|
||||
const appVersion = this.twentyConfigService.get('APP_VERSION');
|
||||
|
||||
|
|
@ -89,16 +93,19 @@ export class DevSeederService {
|
|||
await this.devSeederMetadataService.seed({
|
||||
dataSourceMetadata,
|
||||
workspaceId,
|
||||
light,
|
||||
});
|
||||
|
||||
await this.devSeederMetadataService.seedRelations({
|
||||
workspaceId,
|
||||
light,
|
||||
});
|
||||
|
||||
await this.devSeederPermissionsService.initPermissions({
|
||||
workspaceId,
|
||||
twentyStandardFlatApplication,
|
||||
workspaceCustomFlatApplication,
|
||||
light,
|
||||
});
|
||||
|
||||
await seedPageLayouts(
|
||||
|
|
@ -148,6 +155,7 @@ export class DevSeederService {
|
|||
schemaName: dataSourceMetadata.schema,
|
||||
workspaceId,
|
||||
featureFlags: featureFlagsMap,
|
||||
light,
|
||||
});
|
||||
|
||||
await this.workspaceCacheStorageService.flush(workspaceId, undefined);
|
||||
|
|
|
|||
Loading…
Reference in a new issue