feat(sdk): use config file as single source of truth, remove env var fallbacks (#19409)

## Summary

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

## Test plan

- [x] CI SDK (lint, typecheck, unit, integration, e2e) — all green
- [x] CI Example App Hello World — green
- [x] CI Example App Postcard — green
- [x] CI Create App E2E minimal — green
- [x] CI Front, CI Server, CI Shared — green
This commit is contained in:
Charles Bochet
2026-04-08 06:49:10 +02:00
committed by GitHub
parent af3423dc6f
commit 15eb3e7edc
31 changed files with 523 additions and 313 deletions

View File

@@ -0,0 +1,47 @@
name: Spawn Twenty App Dev Test
description: >
Starts a Twenty all-in-one test instance (server, worker, database, redis)
using the twentycrm/twenty-app-dev Docker image on port 2021.
The server is available at http://localhost:2021 with seeded demo data.
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag for twenty-app-dev (e.g., "latest" or "v1.20.0").'
required: false
default: 'latest'
outputs:
server-url:
description: 'URL where the Twenty test server can be reached'
value: http://localhost:2021
api-key:
description: 'API key for the Twenty test instance'
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4
runs:
using: 'composite'
steps:
- name: Start twenty-app-dev-test container
shell: bash
run: |
docker run -d \
--name twenty-app-dev-test \
-p 2021:2021 \
-e NODE_PORT=2021 \
-e SERVER_URL=http://localhost:2021 \
twentycrm/twenty-app-dev:${{ inputs.twenty-version }}
echo "Waiting for Twenty test instance to become healthy…"
TIMEOUT=180
ELAPSED=0
until curl -sf http://localhost:2021/healthz > /dev/null 2>&1; do
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
echo "::error::Twenty did not become healthy within ${TIMEOUT}s"
docker logs twenty-app-dev-test 2>&1 | tail -80
exit 1
fi
sleep 3
ELAPSED=$((ELAPSED + 3))
echo " … waited ${ELAPSED}s"
done
echo "Twenty test instance is ready at http://localhost:2021 (took ~${ELAPSED}s)"

View File

@@ -25,34 +25,15 @@ jobs:
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
create-app-e2e-hello-world:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
steps:
@@ -139,30 +120,14 @@ jobs:
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Build server
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Start server
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Spawn Twenty test instance
id: twenty
uses: ./.github/actions/spawn-twenty-app-dev-test
- name: Authenticate with twenty-server
env:
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
npx --no-install twenty remote add --api-key ${{ steps.twenty.outputs.api-key }} --api-url ${{ steps.twenty.outputs.server-url }}
- name: Deploy scaffolded app
run: |
@@ -190,7 +155,8 @@ jobs:
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test

View File

@@ -23,34 +23,15 @@ jobs:
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
create-app-e2e-minimal:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
steps:
@@ -133,30 +114,14 @@ jobs:
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Build server
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Start server
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Spawn Twenty test instance
id: twenty
uses: ./.github/actions/spawn-twenty-app-dev-test
- name: Authenticate with twenty-server
env:
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
npx --no-install twenty remote add --api-key ${{ steps.twenty.outputs.api-key }} --api-url ${{ steps.twenty.outputs.server-url }}
- name: Deploy scaffolded app
run: |
@@ -170,7 +135,8 @@ jobs:
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test

View File

@@ -25,34 +25,15 @@ jobs:
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
create-app-e2e-postcard:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
steps:
@@ -137,30 +118,14 @@ jobs:
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Build server
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Start server
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Spawn Twenty test instance
id: twenty
uses: ./.github/actions/spawn-twenty-app-dev-test
- name: Authenticate with twenty-server
env:
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
npx --no-install twenty remote add --api-key ${{ steps.twenty.outputs.api-key }} --api-url ${{ steps.twenty.outputs.server-url }}
- name: Deploy scaffolded app
run: |
@@ -188,7 +153,8 @@ jobs:
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test

View File

@@ -0,0 +1,64 @@
name: CI Example App Hello World
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-apps/examples/hello-world/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
example-app-hello-world:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK packages
run: npx nx build twenty-sdk
- name: Spawn Twenty test instance
id: twenty
uses: ./.github/actions/spawn-twenty-app-dev-test
- name: Run integration tests
working-directory: packages/twenty-apps/examples/hello-world
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
run: npx vitest run
ci-example-app-hello-world-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, example-app-hello-world]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1

View File

@@ -0,0 +1,64 @@
name: CI Example App Postcard
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-apps/examples/postcard/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
example-app-postcard:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK packages
run: npx nx build twenty-sdk
- name: Spawn Twenty test instance
id: twenty
uses: ./.github/actions/spawn-twenty-app-dev-test
- name: Run integration tests
working-directory: packages/twenty-apps/examples/postcard
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
run: npx vitest run
ci-example-app-postcard-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, example-app-postcard]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1

View File

@@ -70,6 +70,8 @@ jobs:
- 6379:6379
env:
NODE_ENV: test
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4

View File

@@ -6,9 +6,16 @@ on:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
@@ -16,12 +23,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty instance
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@feature/sdk-config-file-source-of-truth
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
@@ -30,7 +36,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
@@ -39,4 +45,4 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}

View File

@@ -3,43 +3,51 @@ 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:2020';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
beforeAll(async () => {
const apiUrl = process.env.TWENTY_API_URL!;
const token = process.env.TWENTY_API_KEY!;
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
path.join(TEST_CONFIG_DIR, 'config.json'),
JSON.stringify(configFile, null, 2),
CONFIG_PATH,
JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey: token },
},
defaultRemote: 'local',
},
null,
2,
),
);
process.env.TWENTY_APP_ACCESS_TOKEN ??= token;
});

View File

@@ -14,8 +14,11 @@ export default defineConfig({
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
process.env.TWENTY_API_KEY ??
// Tim Apple (admin) access token for twenty-app-dev
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
},
});

View File

@@ -21,7 +21,7 @@ export const copyBaseApplicationProject = async ({
console.log(chalk.gray('Generating application project...'));
await fs.copy(join(__dirname, './constants/template'), appDirectory);
await renameGitignore({ appDirectory });
await renameDotfiles({ appDirectory });
await generateUniversalIdentifiers({
appDisplayName,
@@ -32,11 +32,20 @@ export const copyBaseApplicationProject = async ({
await updatePackageJson({ appName, appDirectory });
};
const renameGitignore = async ({ appDirectory }: { appDirectory: string }) => {
const gitignorePath = join(appDirectory, 'gitignore');
// npm strips dotfiles/dotdirs (.gitignore, .github/) from published packages,
// so we store them without the leading dot and rename after copying.
const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
const renames = [
{ from: 'gitignore', to: '.gitignore' },
{ from: 'github', to: '.github' },
];
if (await fs.pathExists(gitignorePath)) {
await fs.rename(gitignorePath, join(appDirectory, '.gitignore'));
for (const { from, to } of renames) {
const sourcePath = join(appDirectory, from);
if (await fs.pathExists(sourcePath)) {
await fs.rename(sourcePath, join(appDirectory, to));
}
}
};

View File

@@ -6,9 +6,16 @@ on:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
@@ -16,12 +23,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty instance
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@feature/sdk-config-file-source-of-truth
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
@@ -30,7 +36,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
@@ -39,4 +45,4 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}

View File

@@ -3,43 +3,51 @@ 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 TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
beforeAll(async () => {
const apiUrl = process.env.TWENTY_API_URL!;
const token = process.env.TWENTY_API_KEY!;
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
path.join(TEST_CONFIG_DIR, 'config.json'),
JSON.stringify(configFile, null, 2),
CONFIG_PATH,
JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey: token },
},
defaultRemote: 'local',
},
null,
2,
),
);
process.env.TWENTY_APP_ACCESS_TOKEN ??= token;
});

View File

@@ -14,9 +14,11 @@ export default defineConfig({
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
process.env.TWENTY_API_KEY ??
// Tim Apple (admin) access token for twenty-app-dev
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
},
});

View File

@@ -6,9 +6,16 @@ on:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
@@ -16,12 +23,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty instance
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@feature/sdk-config-file-source-of-truth
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
@@ -30,7 +36,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
@@ -39,4 +45,4 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}

View File

@@ -3,43 +3,51 @@ 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:2020';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
beforeAll(async () => {
const apiUrl = process.env.TWENTY_API_URL!;
const token = process.env.TWENTY_API_KEY!;
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
path.join(TEST_CONFIG_DIR, 'config.json'),
JSON.stringify(configFile, null, 2),
CONFIG_PATH,
JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey: token },
},
defaultRemote: 'local',
},
null,
2,
),
);
process.env.TWENTY_APP_ACCESS_TOKEN ??= token;
});

View File

@@ -14,8 +14,11 @@ export default defineConfig({
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
process.env.TWENTY_API_KEY ??
// Tim Apple (admin) access token for twenty-app-dev
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
},
});

View File

@@ -1,11 +1,17 @@
import axios from 'axios';
import { SERVER_URL } from '@/cli/__tests__/constants/server-url.constant';
import { getServerUrl } from '@/cli/__tests__/constants/server-url.constant';
describe('Twenty Server Health Check (E2E)', () => {
const HEALTH_ENDPOINT = `${SERVER_URL}/healthz`;
let healthEndpoint: string;
beforeAll(async () => {
const serverUrl = await getServerUrl();
healthEndpoint = `${serverUrl}/healthz`;
});
it('should return 200 for health', async () => {
const response = await axios.get(HEALTH_ENDPOINT);
const response = await axios.get(healthEndpoint);
expect(response.status).toBe(200);
expect(response.data).toBeDefined();
});

View File

@@ -1 +1,7 @@
export const SERVER_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
import { ConfigService } from '@/cli/utilities/config/config-service';
export const getServerUrl = async (): Promise<string> => {
const config = await new ConfigService().getConfig();
return config.apiUrl;
};

View File

@@ -5,20 +5,44 @@ import { beforeAll } from 'vitest';
import { ensureDir } from '@/cli/utilities/file/fs-utils';
import { getConfigPath } from '@/cli/utilities/config/get-config-path';
const testConfigPath = getConfigPath();
const testConfigPath = getConfigPath(true);
beforeAll(async () => {
const apiUrl = process.env.TWENTY_API_URL;
const token = process.env.TWENTY_API_KEY;
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Run: twenty server start --test\n' +
'Or set them in vitest env config.',
);
}
const response = await fetch(`${apiUrl}/healthz`).catch(() => null);
if (!response?.ok) {
throw new Error(
`Twenty server not reachable at ${apiUrl}. ` +
'Run: twenty server start --test',
);
}
await ensureDir(path.dirname(testConfigPath));
const configFile = {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
await writeFile(
testConfigPath,
JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey: token },
},
defaultRemote: 'local',
},
},
defaultRemote: 'local',
};
null,
2,
),
);
await writeFile(testConfigPath, JSON.stringify(configFile, null, 2));
process.env.TWENTY_APP_ACCESS_TOKEN ??= token;
});

View File

@@ -1,3 +1,4 @@
import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { DevModeOrchestrator } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator';
import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
@@ -29,9 +30,11 @@ export class AppDevCommand {
await checkSdkVersionCompatibility(appPath);
const config = await new ConfigService().getConfig();
const orchestratorState = new OrchestratorState({
appPath,
frontendUrl: process.env.FRONTEND_URL,
frontendUrl: config.apiUrl,
});
if (!options.headless) {

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 { getConfigPath } from '@/cli/utilities/config/get-config-path';
import { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
import chalk from 'chalk';
import type { Command } from 'commander';
@@ -72,14 +73,19 @@ export const registerRemoteCommands = (program: Command): void => {
.option('--api-key <apiKey>', 'API key for non-interactive auth')
.option('--api-url <apiUrl>', 'Server URL')
.option('--local', 'Connect to a local Twenty server (auto-detect)')
.option('--test', 'Write to config.test.json (for integration tests)')
.action(
async (options: {
as?: string;
apiKey?: string;
apiUrl?: string;
local?: boolean;
test?: boolean;
}) => {
const configService = new ConfigService();
const configPath = options.test ? getConfigPath(true) : undefined;
const configService = new ConfigService(
configPath ? { configPath } : undefined,
);
const existingRemotes = await configService.getRemotes();
if (options.as !== undefined && existingRemotes.includes(options.as)) {

View File

@@ -3,8 +3,10 @@ import {
CONTAINER_NAME,
containerExists,
DEFAULT_PORT,
DEFAULT_TEST_PORT,
getContainerPort,
isContainerRunning,
TEST_CONTAINER_NAME,
} from '@/cli/utilities/server/docker-container';
import { checkServerHealth } from '@/cli/utilities/server/detect-local-server';
import chalk from 'chalk';
@@ -19,9 +21,11 @@ export const registerServerCommands = (program: Command): void => {
server
.command('start')
.description('Start a local Twenty server')
.option('-p, --port <port>', 'HTTP port', String(DEFAULT_PORT))
.action(async (options: { port: string }) => {
const port = parseInt(options.port, 10);
.option('-p, --port <port>', 'HTTP port')
.option('--test', 'Start a separate test instance (port 2021)')
.action(async (options: { port?: string; test?: boolean }) => {
const defaultPort = options.test ? DEFAULT_TEST_PORT : DEFAULT_PORT;
const port = options.port ? parseInt(options.port, 10) : defaultPort;
if (isNaN(port) || port < 1 || port > 65535) {
console.error(chalk.red('Invalid port number.'));
@@ -30,6 +34,7 @@ export const registerServerCommands = (program: Command): void => {
const result = await serverStart({
port,
test: options.test,
onProgress: (message) => console.log(chalk.gray(message)),
});
@@ -42,14 +47,17 @@ export const registerServerCommands = (program: Command): void => {
server
.command('stop')
.description('Stop the local Twenty server')
.action(() => {
if (!containerExists()) {
.option('--test', 'Stop the test instance')
.action((options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
if (!containerExists(containerName)) {
console.log(chalk.yellow('No Twenty server container found.'));
return;
}
execSync(`docker stop ${CONTAINER_NAME}`, { stdio: 'ignore' });
execSync(`docker stop ${containerName}`, { stdio: 'ignore' });
console.log(chalk.green('Twenty server stopped.'));
});
@@ -57,8 +65,11 @@ export const registerServerCommands = (program: Command): void => {
.command('logs')
.description('Stream Twenty server logs')
.option('-n, --lines <lines>', 'Number of lines to show', '50')
.action((options: { lines: string }) => {
if (!containerExists()) {
.option('--test', 'Show logs for the test instance')
.action((options: { lines: string; test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
if (!containerExists(containerName)) {
console.log(chalk.yellow('No Twenty server container found.'));
return;
@@ -67,7 +78,7 @@ export const registerServerCommands = (program: Command): void => {
try {
spawnSync(
'docker',
['logs', '-f', '--tail', options.lines, CONTAINER_NAME],
['logs', '-f', '--tail', options.lines, containerName],
{ stdio: 'inherit' },
);
} catch {
@@ -78,18 +89,24 @@ export const registerServerCommands = (program: Command): void => {
server
.command('status')
.description('Show Twenty server status')
.action(async () => {
if (!containerExists()) {
.option('--test', 'Show status of the test instance')
.action(async (options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const defaultPort = options.test ? DEFAULT_TEST_PORT : DEFAULT_PORT;
if (!containerExists(containerName)) {
console.log(` Status: ${chalk.gray('not created')}`);
console.log(
chalk.gray(" Run 'yarn twenty server start' to create one."),
chalk.gray(
` Run 'yarn twenty server start${options.test ? ' --test' : ''}' to create one.`,
),
);
return;
}
const running = isContainerRunning();
const port = running ? getContainerPort() : DEFAULT_PORT;
const running = isContainerRunning(containerName);
const port = running ? getContainerPort(containerName) : defaultPort;
const healthy = running ? await checkServerHealth(port) : false;
const statusText = healthy
@@ -109,25 +126,33 @@ export const registerServerCommands = (program: Command): void => {
server
.command('reset')
.description('Delete all data and start fresh')
.action(() => {
if (containerExists()) {
execSync(`docker rm -f ${CONTAINER_NAME}`, { stdio: 'ignore' });
.option('--test', 'Reset the test instance')
.action((options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const volumeData = options.test
? 'twenty-app-dev-test-data'
: 'twenty-app-dev-data';
const volumeStorage = options.test
? 'twenty-app-dev-test-storage'
: 'twenty-app-dev-storage';
if (containerExists(containerName)) {
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
}
try {
execSync(
'docker volume rm twenty-app-dev-data twenty-app-dev-storage',
{
stdio: 'ignore',
},
);
execSync(`docker volume rm ${volumeData} ${volumeStorage}`, {
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."),
chalk.gray(
`Run 'yarn twenty server start${options.test ? ' --test' : ''}' to start a fresh instance.`,
),
);
});
};

View File

@@ -1,14 +1,17 @@
import { SERVER_ERROR_CODES, type CommandResult } from '@/cli/types';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { getConfigPath } from '@/cli/utilities/config/get-config-path';
import { runSafe } from '@/cli/utilities/run-safe';
import {
checkDockerRunning,
CONTAINER_NAME,
containerExists,
DEFAULT_PORT,
DEFAULT_TEST_PORT,
getContainerPort,
IMAGE,
isContainerRunning,
TEST_CONTAINER_NAME,
} from '@/cli/utilities/server/docker-container';
import {
checkServerHealth,
@@ -22,14 +25,17 @@ const HEALTH_TIMEOUT_MS = 180 * 1000;
const MILESTONE_START = '==> START ';
const MILESTONE_DONE = '==> DONE';
const waitForHealthy = async (port: number): Promise<boolean> => {
const waitForHealthy = async (
port: number,
containerName: string,
): Promise<boolean> => {
const startTime = Date.now();
const onProgress = (message: string) =>
process.stdout.write(chalk.gray(message));
const logStream = spawn(
'docker',
['logs', '-f', '--since', '1s', CONTAINER_NAME],
['logs', '-f', '--since', '1s', containerName],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
@@ -98,6 +104,7 @@ const waitForHealthy = async (port: number): Promise<boolean> => {
export type ServerStartOptions = {
port?: number;
test?: boolean;
onProgress?: (message: string) => void;
};
@@ -109,12 +116,23 @@ export type ServerStartResult = {
const innerServerStart = async (
options: ServerStartOptions = {},
): Promise<CommandResult<ServerStartResult>> => {
const { onProgress } = options;
const { onProgress, test: isTest } = options;
const existingUrl = await detectLocalServer(options.port);
const containerName = isTest ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const defaultPort = isTest ? DEFAULT_TEST_PORT : DEFAULT_PORT;
const volumeData = isTest
? 'twenty-app-dev-test-data'
: 'twenty-app-dev-data';
const volumeStorage = isTest
? 'twenty-app-dev-test-storage'
: 'twenty-app-dev-storage';
const existingUrl = await detectLocalServer(options.port ?? defaultPort);
if (existingUrl) {
const configService = new ConfigService();
const configService = new ConfigService(
isTest ? { configPath: getConfigPath(true) } : undefined,
);
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: existingUrl });
@@ -139,12 +157,12 @@ const innerServerStart = async (
};
}
if (isContainerRunning()) {
const port = getContainerPort();
if (isContainerRunning(containerName)) {
const port = getContainerPort(containerName);
onProgress?.('Container is running, waiting for it to become healthy...');
const healthy = await waitForHealthy(port);
const healthy = await waitForHealthy(port, containerName);
if (!healthy) {
return {
@@ -159,7 +177,9 @@ const innerServerStart = async (
}
const url = `http://localhost:${port}`;
const configService = new ConfigService();
const configService = new ConfigService(
isTest ? { configPath: getConfigPath(true) } : undefined,
);
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: url });
@@ -169,21 +189,21 @@ const innerServerStart = async (
return { success: true, data: { port, url } };
}
let port = options.port ?? DEFAULT_PORT;
let port = options.port ?? defaultPort;
if (containerExists()) {
const existingPort = getContainerPort();
if (containerExists(containerName)) {
const existingPort = getContainerPort(containerName);
if (existingPort !== port) {
onProgress?.(
`Existing container uses port ${existingPort}. Run 'yarn twenty server reset' first to change ports.`,
`Existing container uses port ${existingPort}. Run 'yarn twenty server reset${isTest ? ' --test' : ''}' first to change ports.`,
);
}
port = existingPort;
onProgress?.('Starting existing container...');
execSync(`docker start ${CONTAINER_NAME}`, { stdio: 'ignore' });
execSync(`docker start ${containerName}`, { stdio: 'ignore' });
} else {
onProgress?.('Starting Twenty container...');
@@ -193,7 +213,7 @@ const innerServerStart = async (
'run',
'-d',
'--name',
CONTAINER_NAME,
containerName,
'-p',
`${port}:${port}`,
'-e',
@@ -201,9 +221,9 @@ const innerServerStart = async (
'-e',
`SERVER_URL=http://localhost:${port}`,
'-v',
'twenty-app-dev-data:/data/postgres',
`${volumeData}:/data/postgres`,
'-v',
'twenty-app-dev-storage:/app/packages/twenty-server/.local-storage',
`${volumeStorage}:/app/packages/twenty-server/.local-storage`,
IMAGE,
],
{ stdio: 'inherit' },
@@ -222,7 +242,7 @@ const innerServerStart = async (
onProgress?.('Waiting for Twenty to be ready...');
const healthy = await waitForHealthy(port);
const healthy = await waitForHealthy(port, containerName);
if (!healthy) {
return {
@@ -237,7 +257,9 @@ const innerServerStart = async (
}
const url = `http://localhost:${port}`;
const configService = new ConfigService();
const configService = new ConfigService(
isTest ? { configPath: getConfigPath(true) } : undefined,
);
ConfigService.setActiveRemote('local');
await configService.setConfig({ apiUrl: url });

View File

@@ -145,12 +145,6 @@ export class ApiClient {
return this.tokenOverride;
}
const envToken = process.env.TWENTY_TOKEN;
if (envToken) {
return envToken;
}
const config = await this.configService.getConfig();
const accessToken = config.accessToken;

View File

@@ -27,8 +27,8 @@ export class ConfigService {
private readonly configPath: string;
private static activeRemote = DEFAULT_REMOTE_NAME;
constructor() {
this.configPath = getConfigPath();
constructor(options?: { configPath?: string }) {
this.configPath = options?.configPath ?? getConfigPath();
}
static setActiveRemote(name?: string) {
@@ -127,13 +127,6 @@ export class ConfigService {
}
async getConfig(): Promise<RemoteConfig> {
if (process.env.TWENTY_TOKEN && process.env.TWENTY_API_URL) {
return {
apiUrl: process.env.TWENTY_API_URL,
accessToken: process.env.TWENTY_TOKEN,
};
}
return this.getConfigForRemote(this.getActiveRemoteName());
}

View File

@@ -1,12 +1,12 @@
import * as os from 'os';
import * as path from 'path';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const TWENTY_DIR = path.join(os.homedir(), '.twenty');
export const getConfigPath = (): string => {
if (process.env.NODE_ENV === 'test') {
return path.join(TEST_CONFIG_DIR, 'config.json');
export const getConfigPath = (test = false): string => {
if (test || process.env.NODE_ENV === 'test') {
return path.join(TWENTY_DIR, 'config.test.json');
}
return path.join(os.homedir(), '.twenty', 'config.json');
return path.join(TWENTY_DIR, 'config.json');
};

View File

@@ -1,13 +1,15 @@
import { execSync } from 'node:child_process';
export const CONTAINER_NAME = 'twenty-app-dev';
export const TEST_CONTAINER_NAME = 'twenty-app-dev-test';
export const IMAGE = 'twentycrm/twenty-app-dev:latest';
export const DEFAULT_PORT = 2020;
export const DEFAULT_TEST_PORT = 2021;
export const isContainerRunning = (): boolean => {
export const isContainerRunning = (containerName = CONTAINER_NAME): boolean => {
try {
const result = execSync(
`docker inspect -f '{{.State.Running}}' ${CONTAINER_NAME}`,
`docker inspect -f '{{.State.Running}}' ${containerName}`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
).trim();
@@ -17,24 +19,27 @@ export const isContainerRunning = (): boolean => {
}
};
export const getContainerPort = (): number => {
export const getContainerPort = (containerName = CONTAINER_NAME): number => {
const defaultPort =
containerName === TEST_CONTAINER_NAME ? DEFAULT_TEST_PORT : DEFAULT_PORT;
try {
const result = execSync(
`docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' ${CONTAINER_NAME}`,
`docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' ${containerName}`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
);
const match = result.match(/^NODE_PORT=(\d+)$/m);
return match ? parseInt(match[1], 10) : DEFAULT_PORT;
return match ? parseInt(match[1], 10) : defaultPort;
} catch {
return DEFAULT_PORT;
return defaultPort;
}
};
export const containerExists = (): boolean => {
export const containerExists = (containerName = CONTAINER_NAME): boolean => {
try {
execSync(`docker inspect ${CONTAINER_NAME}`, {
execSync(`docker inspect ${containerName}`, {
stdio: ['pipe', 'pipe', 'ignore'],
});

View File

@@ -25,9 +25,10 @@ export default defineConfig({
concurrent: false,
},
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2021',
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
setupFiles: ['src/cli/__tests__/constants/setupTest.ts'],
globalSetup: undefined,

View File

@@ -18,14 +18,6 @@ export default defineConfig({
truncateThreshold: 0,
},
fileParallelism: false,
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
},
setupFiles: [
'src/cli/__tests__/constants/setupTest.ts',
'src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts',
],
setupFiles: ['src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts'],
},
});

View File

@@ -12,7 +12,6 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -21,7 +20,7 @@ import { MessageQueueService } from 'src/engine/core-modules/message-queue/servi
@MetadataResolver()
@UseFilters(ApplicationRegistrationExceptionFilter)
@UseGuards(UserAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
export class MarketplaceResolver {
constructor(
private readonly marketplaceQueryService: MarketplaceQueryService,