diff --git a/.dockerignore b/.dockerignore index 93efb8a60b4..d60b6596daa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,5 @@ .git .env -node_modules +**/node_modules .nx/cache packages/twenty-server/.env diff --git a/packages/create-twenty-app/README.md b/packages/create-twenty-app/README.md index ac995abf7cc..d367dc76e12 100644 --- a/packages/create-twenty-app/README.md +++ b/packages/create-twenty-app/README.md @@ -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 diff --git a/packages/create-twenty-app/package.json b/packages/create-twenty-app/package.json index e5d2f2a2ef4..aeea4cd7fa8 100644 --- a/packages/create-twenty-app/package.json +++ b/packages/create-twenty-app/package.json @@ -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", diff --git a/packages/create-twenty-app/src/constants/base-application/README.md b/packages/create-twenty-app/src/constants/base-application/README.md index 9dd96e23b25..bc272962d4a 100644 --- a/packages/create-twenty-app/src/constants/base-application/README.md +++ b/packages/create-twenty-app/src/constants/base-application/README.md @@ -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 # 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 # 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) diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index eed723a1d46..200800ac5d6 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -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 { 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'), + ); } } diff --git a/packages/create-twenty-app/src/utils/setup-local-instance.ts b/packages/create-twenty-app/src/utils/setup-local-instance.ts index 230431d69c2..1e47928dfa0 100644 --- a/packages/create-twenty-app/src/utils/setup-local-instance.ts +++ b/packages/create-twenty-app/src/utils/setup-local-instance.ts @@ -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'; +// Minimal health check — the full implementation lives in twenty-sdk +const isServerReady = async (port: number): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); -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 => { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); - - const response = await fetch('http://localhost:3000/healthz', { + 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 => { +export const setupLocalInstance = async ( + appDirectory: string, +): Promise => { 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}.`)); - if (!isDockerRunning()) { - console.log( - chalk.yellow( - '⚠️ Docker is not running. Please start Docker and try again.', - ), - ); - - return { running: false }; - } - - try { - execSync(`bash <(curl -sL ${INSTALL_SCRIPT_URL})`, { - stdio: 'inherit', - shell: '/bin/bash', - }); - } catch { - console.log( - chalk.yellow('⚠️ Local instance setup did not complete successfully.'), - ); - - return { running: false }; - } + return { running: true, serverUrl }; + } + + // Delegate to `twenty server start` from the scaffolded app + console.log(chalk.gray('Starting local Twenty server...')); + + try { + execSync('yarn twenty server start', { + cwd: appDirectory, + stdio: 'inherit', + }); + } catch { + console.log( + chalk.yellow( + 'Failed to start Twenty server. Run `yarn twenty server start` manually.', + ), + ); + + return { running: false }; + } + + 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.gray( + 'Workspace ready — login with tim@apple.dev / tim@apple.dev', + ), + ); + + 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)); } - console.log(''); console.log( - chalk.blue( - '👉 Please create your workspace in the browser before continuing.', + chalk.yellow( + 'Twenty server did not become healthy in time. Check: yarn twenty server logs', ), ); - const { workspaceCreated } = await inquirer.prompt([ - { - type: 'confirm', - name: 'workspaceCreated', - message: 'Have you finished creating your workspace?', - default: true, - }, - ]); - - if (!workspaceCreated) { - console.log( - chalk.yellow( - '⚠️ Skipping API key generation. Run `yarn twenty remote add --local` manually after creating your workspace.', - ), - ); - - 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 }; }; diff --git a/packages/create-twenty-app/src/utils/test-template.ts b/packages/create-twenty-app/src/utils/test-template.ts index 08001d0af32..46bd85d4505 100644 --- a/packages/create-twenty-app/src/utils/test-template.ts +++ b/packages/create-twenty-app/src/utils/test-template.ts @@ -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 () => { diff --git a/packages/twenty-docker/twenty-app-dev/Dockerfile b/packages/twenty-docker/twenty-app-dev/Dockerfile new file mode 100644 index 00000000000..258f4b437d4 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/Dockerfile @@ -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"] diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/init-db/dependencies.d/postgres b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/init-db/dependencies.d/postgres new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/init-db/type b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/init-db/type new file mode 100644 index 00000000000..bdd22a1850a --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/init-db/type @@ -0,0 +1 @@ +oneshot diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/init-db/up b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/init-db/up new file mode 100755 index 00000000000..c79c84f5c3a --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/init-db/up @@ -0,0 +1 @@ +/bin/sh /etc/s6-overlay/scripts/init-db.sh diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/postgres/run b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/postgres/run new file mode 100755 index 00000000000..29191370660 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/postgres/run @@ -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 diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/postgres/type b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/postgres/type new file mode 100644 index 00000000000..5883cff0cd1 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/postgres/type @@ -0,0 +1 @@ +longrun diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/redis/run b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/redis/run new file mode 100755 index 00000000000..507791bb65d --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/redis/run @@ -0,0 +1,6 @@ +#!/bin/sh +exec redis-server \ + --dir /data/redis \ + --maxmemory-policy noeviction \ + --bind 127.0.0.1 \ + --protected-mode yes diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/redis/type b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/redis/type new file mode 100644 index 00000000000..5883cff0cd1 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/redis/type @@ -0,0 +1 @@ +longrun diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-server/dependencies.d/init-db b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-server/dependencies.d/init-db new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-server/dependencies.d/redis b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-server/dependencies.d/redis new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-server/run b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-server/run new file mode 100755 index 00000000000..465ad607d92 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-server/run @@ -0,0 +1,3 @@ +#!/bin/sh +cd /app/packages/twenty-server +exec yarn start:prod diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-server/type b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-server/type new file mode 100644 index 00000000000..5883cff0cd1 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-server/type @@ -0,0 +1 @@ +longrun diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-worker/dependencies.d/twenty-server b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-worker/dependencies.d/twenty-server new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-worker/run b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-worker/run new file mode 100755 index 00000000000..d0477d45bb9 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-worker/run @@ -0,0 +1,3 @@ +#!/bin/sh +cd /app/packages/twenty-server +exec yarn worker:prod diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-worker/type b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-worker/type new file mode 100644 index 00000000000..5883cff0cd1 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/twenty-worker/type @@ -0,0 +1 @@ +longrun diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-db b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-db new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/postgres b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/postgres new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/redis b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/redis new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/twenty-server b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/twenty-server new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/twenty-worker b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/twenty-worker new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/init-db.sh b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/init-db.sh new file mode 100755 index 00000000000..451ae421950 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/init-db.sh @@ -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." diff --git a/packages/twenty-docs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/developers/extend/capabilities/apps.mdx index bb9caccabeb..5d76e79945e 100644 --- a/packages/twenty-docs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/developers/extend/capabilities/apps.mdx @@ -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: diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md index 81e4dcd1772..366c06bc3e8 100644 --- a/packages/twenty-sdk/README.md +++ b/packages/twenty-sdk/README.md @@ -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 ` 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 `: HTTP port (default: `2020`). +- `twenty server stop` — Stop the local server. +- `twenty server logs` — Stream server logs. + - Options: + - `-n, --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 `: API key for non-interactive auth. - `--url `: Server URL (alternative to positional arg). - `--as `: 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 ` — 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": "" }, "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. diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 31d47b5648b..88b5ba3571c 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -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", diff --git a/packages/twenty-sdk/src/cli/__tests__/constants/server-url.constant.ts b/packages/twenty-sdk/src/cli/__tests__/constants/server-url.constant.ts index ec22eaa8127..d9fa7c5add3 100644 --- a/packages/twenty-sdk/src/cli/__tests__/constants/server-url.constant.ts +++ b/packages/twenty-sdk/src/cli/__tests__/constants/server-url.constant.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'; diff --git a/packages/twenty-sdk/src/cli/commands/app-command.ts b/packages/twenty-sdk/src/cli/commands/app-command.ts index 2d986c0504c..c6566955732 100644 --- a/packages/twenty-sdk/src/cli/commands/app-command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-command.ts @@ -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]') diff --git a/packages/twenty-sdk/src/cli/commands/remote.ts b/packages/twenty-sdk/src/cli/commands/remote.ts index 3a3a1400395..6d12453baeb 100644 --- a/packages/twenty-sdk/src/cli/commands/remote.ts +++ b/packages/twenty-sdk/src/cli/commands/remote.ts @@ -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 }>([ { diff --git a/packages/twenty-sdk/src/cli/commands/server.ts b/packages/twenty-sdk/src/cli/commands/server.ts new file mode 100644 index 00000000000..902573fa728 --- /dev/null +++ b/packages/twenty-sdk/src/cli/commands/server.ts @@ -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 ', '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 ', '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."), + ); + }); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/config/config-service.ts b/packages/twenty-sdk/src/cli/utilities/config/config-service.ts index 0b10d80a95b..0e49d21f1be 100644 --- a/packages/twenty-sdk/src/cli/utilities/config/config-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/config/config-service.ts @@ -196,7 +196,7 @@ export class ConfigService { private getDefaultConfig(): RemoteConfig { return { - apiUrl: 'http://localhost:3000', + apiUrl: 'http://localhost:2020', }; } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts index 2f51dad42b2..c3bc220a344 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts @@ -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' }); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts index 0d4a75a15d4..dc3857d3945 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts @@ -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', }, diff --git a/packages/twenty-sdk/src/cli/utilities/server/detect-local-server.ts b/packages/twenty-sdk/src/cli/utilities/server/detect-local-server.ts new file mode 100644 index 00000000000..cfe4c8597a1 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/server/detect-local-server.ts @@ -0,0 +1,30 @@ +const LOCAL_PORTS = [2020, 3000]; + +export const checkServerHealth = async (port: number): Promise => { + 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 => { + for (const port of LOCAL_PORTS) { + if (await checkServerHealth(port)) { + return `http://localhost:${port}`; + } + } + + return null; +}; diff --git a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts index fdc8f22c952..7106fbb72a2 100644 --- a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts +++ b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts @@ -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 { + @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 { + 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); diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts index 5d4a0e3c21a..147e5e786ea 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts @@ -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,35 +82,49 @@ export class DevSeederPermissionsService { let guestUserWorkspaceId: string | undefined; if (workspaceId === SEED_APPLE_WORKSPACE_ID) { - adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.JANE; - limitedUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM; - memberUserWorkspaceIds = [ - USER_WORKSPACE_DATA_SEED_IDS.JONY, - ...Object.values(RANDOM_USER_WORKSPACE_IDS), - ]; - guestUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.PHIL; + 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), + ]; - const guestRole = await this.roleService.createGuestRole({ - workspaceId, - ownerFlatApplication: workspaceCustomFlatApplication, - }); + const guestRole = await this.roleService.createGuestRole({ + workspaceId, + ownerFlatApplication: workspaceCustomFlatApplication, + }); - await this.userRoleService.assignRoleToManyUserWorkspace({ - workspaceId, - userWorkspaceIds: [guestUserWorkspaceId], - roleId: guestRole.id, - }); + await this.userRoleService.assignRoleToManyUserWorkspace({ + workspaceId, + userWorkspaceIds: [guestUserWorkspaceId], + roleId: guestRole.id, + }); - const limitedRole = await this.createLimitedRoleForSeedWorkspace({ - workspaceId, - ownerFlatApplication: workspaceCustomFlatApplication, - }); + // 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, + }); - await this.userRoleService.assignRoleToManyUserWorkspace({ - workspaceId, - userWorkspaceIds: [limitedUserWorkspaceId], - roleId: limitedRole.id, - }); + await this.userRoleService.assignRoleToManyUserWorkspace({ + workspaceId, + userWorkspaceIds: [limitedUserWorkspaceId], + roleId: limitedRole.id, + }); + } } else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) { adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME; memberUserWorkspaceIds = [ diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts index c4e6d97a5fb..7b07b782c99 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts @@ -300,10 +300,12 @@ export class DevSeederDataService { schemaName, workspaceId, featureFlags, + light = false, }: { schemaName: string; workspaceId: string; featureFlags?: Record; + light?: boolean; }) { const objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspaceId); @@ -328,19 +330,22 @@ export class DevSeederDataService { attachmentSeeds, featureFlags, objectMetadataItems, + light, }); - await this.timelineActivitySeederService.seedTimelineActivities({ - entityManager, - schemaName, - workspaceId, - }); + if (!light) { + await this.timelineActivitySeederService.seedTimelineActivities({ + entityManager, + schemaName, + workspaceId, + }); - await this.seedAttachmentFiles( - workspaceId, - entityManager, - attachmentFileMeta, - ); + await this.seedAttachmentFiles( + workspaceId, + 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; 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) === diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts index 8003cec4c04..03f980c611d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts @@ -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; flatObjectMetadataMaps: FlatEntityMaps; @@ -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 = { [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 ?? []) { diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts index 2b7533163c8..6f85022bd5f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts @@ -43,7 +43,11 @@ export class DevSeederService { private readonly coreDataSource: DataSource, ) {} - public async seedDev(workspaceId: SeededWorkspacesIds): Promise { + public async seedDev( + workspaceId: SeededWorkspacesIds, + options?: { light?: boolean }, + ): Promise { + 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);