From f38d72a4c24825ccb92fe070b9bfac4256176f53 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Tue, 17 Mar 2026 15:23:43 +0100 Subject: [PATCH] Setup local instance on app creation (#18184) Needs for `generate-api-key` command to be available on docker --- .github/workflows/ci-create-app-e2e.yaml | 2 +- packages/create-twenty-app/src/cli.ts | 6 + .../src/create-app.command.ts | 77 ++++++- .../src/utils/setup-local-instance.ts | 194 ++++++++++++++++++ 4 files changed, 268 insertions(+), 11 deletions(-) create mode 100644 packages/create-twenty-app/src/utils/setup-local-instance.ts diff --git a/.github/workflows/ci-create-app-e2e.yaml b/.github/workflows/ci-create-app-e2e.yaml index d7ce4719df4..c8bd18fe262 100644 --- a/.github/workflows/ci-create-app-e2e.yaml +++ b/.github/workflows/ci-create-app-e2e.yaml @@ -100,7 +100,7 @@ jobs: create-twenty-app --version mkdir -p /tmp/e2e-test-workspace cd /tmp/e2e-test-workspace - create-twenty-app test-app --exhaustive --display-name "Test App" --description "E2E test app" + create-twenty-app test-app --exhaustive --display-name "Test App" --description "E2E test app" --skip-local-instance - name: Install scaffolded app dependencies run: | diff --git a/packages/create-twenty-app/src/cli.ts b/packages/create-twenty-app/src/cli.ts index 9b03268d567..9737149cead 100644 --- a/packages/create-twenty-app/src/cli.ts +++ b/packages/create-twenty-app/src/cli.ts @@ -27,6 +27,10 @@ const program = new Command(packageJson.name) '--description ', 'Application description (skips prompt)', ) + .option( + '--skip-local-instance', + 'Skip the local Twenty instance setup prompt', + ) .helpOption('-h, --help', 'Display this help message.') .action( async ( @@ -37,6 +41,7 @@ const program = new Command(packageJson.name) name?: string; displayName?: string; description?: string; + skipLocalInstance?: boolean; }, ) => { const modeFlags = [options?.exhaustive, options?.minimal].filter(Boolean); @@ -72,6 +77,7 @@ const program = new Command(packageJson.name) name: options?.name, displayName: options?.displayName, description: options?.description, + skipLocalInstance: options?.skipLocalInstance, }); }, ); diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index b532f216910..eed723a1d46 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -1,11 +1,16 @@ import { copyBaseApplicationProject } from '@/utils/app-template'; import { convertToLabel } from '@/utils/convert-to-label'; import { install } from '@/utils/install'; +import { + type LocalInstanceResult, + setupLocalInstance, +} from '@/utils/setup-local-instance'; import { tryGitInit } from '@/utils/try-git-init'; import chalk from 'chalk'; import * as fs from 'fs-extra'; import inquirer from 'inquirer'; import kebabCase from 'lodash.kebabcase'; +import { execSync } from 'node:child_process'; import * as path from 'path'; import { isDefined } from 'twenty-shared/utils'; @@ -22,6 +27,7 @@ type CreateAppOptions = { name?: string; displayName?: string; description?: string; + skipLocalInstance?: boolean; }; export class CreateAppCommand { @@ -52,7 +58,29 @@ export class CreateAppCommand { await tryGitInit(appDirectory); - this.logSuccess(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, + }, + ]); + + if (needsLocalInstance) { + localResult = await setupLocalInstance(); + } + + if (isDefined(localResult.apiKey)) { + this.runAuthLogin(appDirectory, localResult.apiKey); + } + } + + this.logSuccess(appDirectory, localResult); } catch (error) { console.error( chalk.red('Initialization failed:'), @@ -179,20 +207,49 @@ export class CreateAppCommand { console.log(''); } - private logSuccess(appDirectory: string): void { + private runAuthLogin(appDirectory: string, apiKey: string): void { + try { + execSync( + `yarn twenty auth:login --api-key "${apiKey}" --api-url http://localhost:3000`, + { cwd: appDirectory, stdio: 'inherit' }, + ); + console.log(chalk.green('✅ Authenticated with local Twenty instance.')); + } catch { + console.log( + chalk.yellow( + '⚠️ Auto auth:login failed. Run `yarn twenty auth:login` manually.', + ), + ); + } + } + + private logSuccess( + appDirectory: string, + localResult: LocalInstanceResult, + ): void { const dirName = appDirectory.split('/').reverse()[0] ?? ''; console.log(chalk.green('✅ Application created!')); console.log(''); console.log(chalk.blue('Next steps:')); console.log(chalk.gray(` cd ${dirName}`)); - console.log( - chalk.gray( - ' yarn twenty remote add --local # Authenticate with Twenty', - ), - ); - console.log( - chalk.gray(' yarn twenty dev # Start dev mode'), - ); + + if (localResult.apiKey) { + console.log(chalk.gray(' yarn twenty app:dev # Start dev mode')); + } else 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')); + } } } diff --git a/packages/create-twenty-app/src/utils/setup-local-instance.ts b/packages/create-twenty-app/src/utils/setup-local-instance.ts new file mode 100644 index 00000000000..230431d69c2 --- /dev/null +++ b/packages/create-twenty-app/src/utils/setup-local-instance.ts @@ -0,0 +1,194 @@ +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { execSync } from 'node:child_process'; +import { isDefined } from 'twenty-shared/utils'; + +const INSTALL_SCRIPT_URL = + 'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/scripts/install.sh'; + +const SERVER_CONTAINER = 'twenty-server-1'; +const DB_CONTAINER = 'twenty-db-1'; + +const isDockerAvailable = (): boolean => { + try { + execSync('docker compose version', { stdio: 'ignore' }); + + return true; + } catch { + return false; + } +}; + +const isDockerRunning = (): boolean => { + try { + execSync('docker info', { stdio: 'ignore' }); + + return true; + } catch { + return false; + } +}; + +const isTwentyServerRunning = async (): Promise => { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + + const response = await fetch('http://localhost:3000/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; + } +}; + +export type LocalInstanceResult = { + running: boolean; + apiKey?: string; +}; + +export const setupLocalInstance = async (): Promise => { + console.log(''); + 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/')); + + return { running: false }; + } + + 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 }; + } + } + + console.log(''); + console.log( + chalk.blue( + '👉 Please create your workspace in the browser before continuing.', + ), + ); + + 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 }; +};