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