Scaffold light twenty app dev container (#18734)

as title
This commit is contained in:
martmull 2026-03-18 20:10:54 +01:00 committed by GitHub
parent 394a3cef15
commit 2f095c8903
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 861 additions and 371 deletions

View file

@ -1,5 +1,5 @@
.git
.env
node_modules
**/node_modules
.nx/cache
packages/twenty-server/.env

View file

@ -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 autogenerates 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

View file

@ -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",

View file

@ -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)

View file

@ -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'),
);
}
}

View file

@ -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 };
};

View file

@ -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 () => {

View 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"]

View file

@ -0,0 +1 @@
/bin/sh /etc/s6-overlay/scripts/init-db.sh

View file

@ -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

View file

@ -0,0 +1,6 @@
#!/bin/sh
exec redis-server \
--dir /data/redis \
--maxmemory-policy noeviction \
--bind 127.0.0.1 \
--protected-mode yes

View file

@ -0,0 +1,3 @@
#!/bin/sh
cd /app/packages/twenty-server
exec yarn start:prod

View file

@ -0,0 +1,3 @@
#!/bin/sh
cd /app/packages/twenty-server
exec yarn worker:prod

View file

@ -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."

View file

@ -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:

View file

@ -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.

View file

@ -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",

View file

@ -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';

View file

@ -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]')

View file

@ -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 }>([
{

View 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."),
);
});
};

View file

@ -196,7 +196,7 @@ export class ConfigService {
private getDefaultConfig(): RemoteConfig {
return {
apiUrl: 'http://localhost:3000',
apiUrl: 'http://localhost:2020',
};
}

View file

@ -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' });

View file

@ -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',
},

View file

@ -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;
};

View file

@ -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);

View file

@ -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 = [

View file

@ -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) ===

View file

@ -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 ?? []) {

View file

@ -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);