Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccdaa31273 | ||
|
|
f4f7ccd0e1 | ||
|
|
0d27a255a9 | ||
|
|
ef2a113a16 | ||
|
|
e0e25eac2b | ||
|
|
8ab8f80687 | ||
|
|
a370a26b79 | ||
|
|
2f095c8903 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
.git
|
||||
.env
|
||||
node_modules
|
||||
**/node_modules
|
||||
.nx/cache
|
||||
packages/twenty-server/.env
|
||||
|
||||
@@ -10,8 +10,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
e2e-test:
|
||||
|
||||
@@ -25,25 +25,25 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24+ (recommended) and Yarn 4
|
||||
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
|
||||
- Docker (for the local Twenty dev server)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Scaffold a new app — the CLI will offer to start a local Twenty server
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Get help and list all available commands
|
||||
yarn twenty help
|
||||
# The scaffolder can automatically:
|
||||
# 1. Start a local Twenty server (Docker)
|
||||
# 2. Open the browser to log in (tim@apple.dev / tim@apple.dev)
|
||||
# 3. Authenticate your app via OAuth
|
||||
|
||||
# Authenticate with your Twenty server
|
||||
yarn twenty remote add --local
|
||||
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty add
|
||||
# Or do it manually:
|
||||
yarn twenty server start # Start local Twenty server
|
||||
yarn twenty remote add --local # Authenticate via OAuth
|
||||
|
||||
# Start dev mode: watches, builds, and syncs local changes to your workspace
|
||||
# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built with the SDK — both available via `twenty-sdk/clients`)
|
||||
yarn twenty dev
|
||||
|
||||
# Watch your application's function logs
|
||||
@@ -107,10 +107,24 @@ npx create-twenty-app@latest my-app -m
|
||||
- `skills/example-skill.ts` — Example AI agent skill definition
|
||||
- `__tests__/app-install.integration-test.ts` — Integration test that builds, installs, and verifies the app (includes `vitest.config.ts`, `tsconfig.spec.json`, and a setup file)
|
||||
|
||||
## Local server
|
||||
|
||||
The scaffolder can start a local Twenty dev server for you (all-in-one Docker image with PostgreSQL, Redis, server, and worker). You can also manage it manually:
|
||||
|
||||
```bash
|
||||
yarn twenty server start # Start (pulls image if needed)
|
||||
yarn twenty server status # Check if it's healthy
|
||||
yarn twenty server logs # Stream logs
|
||||
yarn twenty server stop # Stop (data is preserved)
|
||||
yarn twenty server reset # Wipe all data and start fresh
|
||||
```
|
||||
|
||||
The server is pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`).
|
||||
|
||||
## Next steps
|
||||
|
||||
- Run `yarn twenty help` to see all available commands.
|
||||
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace.
|
||||
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace via OAuth.
|
||||
- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
|
||||
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
|
||||
- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`.
|
||||
@@ -153,8 +167,9 @@ Our team reviews contributions for quality, security, and reusability before mer
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Auth prompts not appearing: run `yarn twenty remote add --local` again and verify the API key permissions.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto‑generates the typed client.
|
||||
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
|
||||
- Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add --local`.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.8.0-canary.0",
|
||||
"version": "0.8.0-canary.1",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -1,60 +1,11 @@
|
||||
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
Start development mode — it auto-connects to your local Twenty server at localhost:3000:
|
||||
|
||||
```bash
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
Open your Twenty instance and go to `/settings/applications` section to see the result.
|
||||
|
||||
## Available Commands
|
||||
|
||||
Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & authentication
|
||||
yarn twenty remote add --local # Connect to local Twenty server
|
||||
yarn twenty remote add <url> # Connect to a remote server (OAuth)
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
|
||||
# Development
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty build # Build the application
|
||||
yarn twenty deploy # Deploy to a Twenty server
|
||||
yarn twenty publish # Publish to npm
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty uninstall # Uninstall app from server
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
If your project includes the example integration test (`src/__tests__/app-install.integration-test.ts`), you can run it with:
|
||||
|
||||
```bash
|
||||
# Make sure a Twenty server is running at http://localhost:3000
|
||||
yarn test
|
||||
```
|
||||
|
||||
The test builds and installs the app, then verifies it appears in the applications list. Test configuration (API URL and API key) is defined in `vitest.config.ts`.
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
Main docs and pitfalls are available in LLMS.md file.
|
||||
Run `yarn twenty help` to list all available commands.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Twenty applications, take a look at the following resources:
|
||||
|
||||
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
|
||||
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/capabilities/apps)
|
||||
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
|
||||
- [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
@@ -46,6 +46,7 @@ export class CreateAppCommand {
|
||||
|
||||
await fs.ensureDir(appDirectory);
|
||||
|
||||
console.log(chalk.gray(' Scaffolding project files...'));
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
@@ -54,29 +55,20 @@ export class CreateAppCommand {
|
||||
exampleOptions,
|
||||
});
|
||||
|
||||
console.log(chalk.gray(' Installing dependencies...'));
|
||||
await install(appDirectory);
|
||||
|
||||
console.log(chalk.gray(' Initializing git repository...'));
|
||||
await tryGitInit(appDirectory);
|
||||
|
||||
let localResult: LocalInstanceResult = { running: false };
|
||||
|
||||
if (!options.skipLocalInstance) {
|
||||
const { needsLocalInstance } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'needsLocalInstance',
|
||||
message:
|
||||
'Do you need a local instance of Twenty? Recommended if you not have one already.',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
// Auto-detect a running server first
|
||||
localResult = await setupLocalInstance(appDirectory);
|
||||
|
||||
if (needsLocalInstance) {
|
||||
localResult = await setupLocalInstance();
|
||||
}
|
||||
|
||||
if (isDefined(localResult.apiKey)) {
|
||||
this.runAuthLogin(appDirectory, localResult.apiKey);
|
||||
if (localResult.running && localResult.serverUrl) {
|
||||
await this.connectToLocal(appDirectory, localResult.serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,23 +193,29 @@ export class CreateAppCommand {
|
||||
appDirectory: string;
|
||||
appName: string;
|
||||
}): void {
|
||||
console.log(chalk.blue('🎯 Creating Twenty Application'));
|
||||
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
|
||||
console.log(chalk.gray(`📝 Name: ${appName}`));
|
||||
console.log(chalk.blue('Creating Twenty Application'));
|
||||
console.log(chalk.gray(` Directory: ${appDirectory}`));
|
||||
console.log(chalk.gray(` Name: ${appName}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
private runAuthLogin(appDirectory: string, apiKey: string): void {
|
||||
private async connectToLocal(
|
||||
appDirectory: string,
|
||||
serverUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
execSync(
|
||||
`yarn twenty auth:login --api-key "${apiKey}" --api-url http://localhost:3000`,
|
||||
{ cwd: appDirectory, stdio: 'inherit' },
|
||||
`npx nx run twenty-sdk:start -- remote add ${serverUrl} --as local`,
|
||||
{
|
||||
cwd: appDirectory,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
console.log(chalk.green('✅ Authenticated with local Twenty instance.'));
|
||||
console.log(chalk.green('Authenticated with local Twenty instance.'));
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Auto auth:login failed. Run `yarn twenty auth:login` manually.',
|
||||
'Authentication skipped. Run `npx nx run twenty-sdk:start -- remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -229,27 +227,21 @@ export class CreateAppCommand {
|
||||
): void {
|
||||
const dirName = appDirectory.split('/').reverse()[0] ?? '';
|
||||
|
||||
console.log(chalk.green('✅ Application created!'));
|
||||
console.log(chalk.green('Application created!'));
|
||||
console.log('');
|
||||
console.log(chalk.blue('Next steps:'));
|
||||
console.log(chalk.gray(` cd ${dirName}`));
|
||||
|
||||
if (localResult.apiKey) {
|
||||
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
|
||||
} else if (localResult.running) {
|
||||
if (!localResult.running) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
' yarn twenty remote add --local # Authenticate with Twenty',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
' yarn twenty remote add --local # Authenticate with Twenty',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.gray(' yarn twenty dev # Start dev mode'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,194 +1,101 @@
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { platform } from 'node:os';
|
||||
|
||||
const INSTALL_SCRIPT_URL =
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/scripts/install.sh';
|
||||
const DEFAULT_PORT = 2020;
|
||||
|
||||
const SERVER_CONTAINER = 'twenty-server-1';
|
||||
const DB_CONTAINER = 'twenty-db-1';
|
||||
// Minimal health check — the full implementation lives in twenty-sdk
|
||||
const isServerReady = async (port: number): Promise<boolean> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const isDockerAvailable = (): boolean => {
|
||||
try {
|
||||
execSync('docker compose version', { stdio: 'ignore' });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isDockerRunning = (): boolean => {
|
||||
try {
|
||||
execSync('docker info', { stdio: 'ignore' });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isTwentyServerRunning = async (): Promise<boolean> => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch('http://localhost:3000/healthz', {
|
||||
const response = await fetch(`http://localhost:${port}/healthz`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
return body.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveWorkspaceId = (): string | null => {
|
||||
try {
|
||||
const result = execSync(
|
||||
`docker exec ${DB_CONTAINER} psql -U postgres -d default -t -c "SELECT id FROM core.workspace WHERE \\"activationStatus\\" = 'ACTIVE' LIMIT 1"`,
|
||||
{ encoding: 'utf-8' },
|
||||
).trim();
|
||||
|
||||
return result || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const generateApiKeyToken = (workspaceId: string): string | null => {
|
||||
try {
|
||||
const output = execSync(
|
||||
`docker exec -e NODE_ENV=development ${SERVER_CONTAINER} yarn command:prod workspace:generate-api-key -w ${workspaceId}`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
|
||||
const TOKEN_PREFIX = 'TOKEN:';
|
||||
const tokenLine = output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.find((line) => line.includes(TOKEN_PREFIX));
|
||||
|
||||
if (!tokenLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenStartIndex =
|
||||
tokenLine.indexOf(TOKEN_PREFIX) + TOKEN_PREFIX.length;
|
||||
|
||||
return tokenLine.slice(tokenStartIndex).trim();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
export type LocalInstanceResult = {
|
||||
running: boolean;
|
||||
apiKey?: string;
|
||||
serverUrl?: string;
|
||||
};
|
||||
|
||||
export const setupLocalInstance = async (): Promise<LocalInstanceResult> => {
|
||||
export const setupLocalInstance = async (
|
||||
appDirectory: string,
|
||||
): Promise<LocalInstanceResult> => {
|
||||
console.log('');
|
||||
console.log(chalk.blue('🐳 Setting up local Twenty instance...'));
|
||||
console.log(chalk.blue('Setting up local Twenty instance...'));
|
||||
|
||||
if (await isTwentyServerRunning()) {
|
||||
console.log(
|
||||
chalk.green('✅ Twenty server is already running on localhost:3000.'),
|
||||
);
|
||||
} else {
|
||||
if (!isDockerAvailable()) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Docker Compose is not installed. Please install Docker first.',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' See https://docs.docker.com/get-docker/'));
|
||||
if (await isServerReady(DEFAULT_PORT)) {
|
||||
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
|
||||
|
||||
return { running: false };
|
||||
}
|
||||
console.log(chalk.green(`Twenty server detected on ${serverUrl}.`));
|
||||
|
||||
if (!isDockerRunning()) {
|
||||
return { running: true, serverUrl };
|
||||
}
|
||||
|
||||
// Delegate to `twenty server start` from the scaffolded app
|
||||
console.log(chalk.gray('Starting local Twenty server...'));
|
||||
|
||||
try {
|
||||
execSync('yarn twenty server start', {
|
||||
cwd: appDirectory,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Failed to start Twenty server. Run `yarn twenty server start` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: false };
|
||||
}
|
||||
|
||||
console.log(chalk.gray('Waiting for Twenty to be ready...'));
|
||||
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = 180 * 1000;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (await isServerReady(DEFAULT_PORT)) {
|
||||
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
|
||||
|
||||
console.log(chalk.green(`Twenty server is running on ${serverUrl}.`));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Docker is not running. Please start Docker and try again.',
|
||||
chalk.gray(
|
||||
'Workspace ready — login with tim@apple.dev / tim@apple.dev',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: false };
|
||||
}
|
||||
const openCommand = platform() === 'darwin' ? 'open' : 'xdg-open';
|
||||
|
||||
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.'),
|
||||
);
|
||||
try {
|
||||
execSync(`${openCommand} ${serverUrl}`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Ignore if browser can't be opened
|
||||
}
|
||||
|
||||
return { running: false };
|
||||
return { running: true, serverUrl };
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(
|
||||
chalk.blue(
|
||||
'👉 Please create your workspace in the browser before continuing.',
|
||||
chalk.yellow(
|
||||
'Twenty server did not become healthy in time. Check: yarn twenty server logs',
|
||||
),
|
||||
);
|
||||
|
||||
const { workspaceCreated } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'workspaceCreated',
|
||||
message: 'Have you finished creating your workspace?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!workspaceCreated) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Skipping API key generation. Run `yarn twenty remote add --local` manually after creating your workspace.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: true };
|
||||
}
|
||||
|
||||
console.log(chalk.blue('🔑 Generating API key for your workspace...'));
|
||||
|
||||
const workspaceId = getActiveWorkspaceId();
|
||||
|
||||
if (!isDefined(workspaceId)) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ No active workspace found. Make sure you completed the signup flow, then run `yarn twenty auth:login` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: true };
|
||||
}
|
||||
|
||||
const apiKey = generateApiKeyToken(workspaceId);
|
||||
|
||||
if (!isDefined(apiKey)) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Could not generate API key. Run `yarn twenty auth:login` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: true };
|
||||
}
|
||||
|
||||
console.log(chalk.green('✅ API key generated for your workspace.'));
|
||||
|
||||
return { running: true, apiKey };
|
||||
return { running: false };
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { beforeAll } from 'vitest';
|
||||
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
|
||||
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
|
||||
|
||||
const assertServerIsReachable = async () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
- Node.js 24+ and Yarn 4
|
||||
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
|
||||
- Docker (for the local Twenty dev server)
|
||||
|
||||
## Getting Started
|
||||
|
||||
Create a new app using the official scaffolder, then authenticate and start developing:
|
||||
Create a new app using the official scaffolder. It can automatically start a local Twenty instance for you:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Scaffold a new app (includes all examples by default)
|
||||
# Scaffold a new app — the CLI will offer to start a local Twenty server
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Start dev mode: automatically syncs local changes to your workspace
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Local Server Management
|
||||
|
||||
The SDK includes commands to manage a local Twenty dev server (all-in-one Docker image with PostgreSQL, Redis, server, and worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Start the local server (pulls the image if needed)
|
||||
yarn twenty server start
|
||||
|
||||
# Check server status
|
||||
yarn twenty server status
|
||||
|
||||
# Stream server logs
|
||||
yarn twenty server logs
|
||||
|
||||
# Stop the server
|
||||
yarn twenty server stop
|
||||
|
||||
# Reset all data and start fresh
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
The local server comes pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`), so you can start developing immediately without any manual setup.
|
||||
|
||||
### Authentication
|
||||
|
||||
Connect your app to the local server using OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
The scaffolder supports two modes for controlling which example files are included:
|
||||
|
||||
@@ -21,19 +21,51 @@ description: أنشئ وأدِر تخصيصات Twenty على هيئة كود.
|
||||
## المتطلبات الأساسية
|
||||
|
||||
* Node.js 24+ وYarn 4
|
||||
* مساحة عمل Twenty ومفتاح واجهة برمجة التطبيقات (أنشئ واحدًا على https://app.twenty.com/settings/api-webhooks)
|
||||
* Docker (لخادم تطوير Twenty المحلي)
|
||||
|
||||
## البدء
|
||||
|
||||
أنشئ تطبيقًا جديدًا باستخدام المُهيئ الرسمي، ثم قم بالمصادقة وابدأ التطوير:
|
||||
أنشئ تطبيقًا جديدًا باستخدام المولّد الرسمي. يمكنه بدء مثيل محلي من Twenty تلقائيًا لك:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# إنشاء تطبيق جديد (يتضمن جميع الأمثلة افتراضيًا)
|
||||
# إنشاء تطبيق جديد — ستعرض واجهة سطر الأوامر خيار بدء خادم Twenty محلي
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# ابدأ وضع التطوير: يُزامن التغييرات المحلية تلقائيًا مع مساحة العمل الخاصة بك
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### إدارة الخادم المحلي
|
||||
|
||||
يتضمن SDK أوامر لإدارة خادم تطوير Twenty محلي (صورة Docker متكاملة تتضمن PostgreSQL وRedis والخادم والعامل):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# ابدأ الخادم المحلي (يسحب الصورة إذا لزم الأمر)
|
||||
yarn twenty server start
|
||||
|
||||
# تحقّق من حالة الخادم
|
||||
yarn twenty server status
|
||||
|
||||
# بثّ سجلات الخادم
|
||||
yarn twenty server logs
|
||||
|
||||
# أوقف الخادم
|
||||
yarn twenty server stop
|
||||
|
||||
# أعد ضبط جميع البيانات وابدأ من جديد
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
يأتي الخادم المحلي مهيأً مسبقًا بمساحة عمل ومستخدم (`tim@apple.dev` / `tim@apple.dev`)، بحيث يمكنك البدء في التطوير فورًا دون أي إعداد يدوي.
|
||||
|
||||
### المصادقة
|
||||
|
||||
وصّل تطبيقك بالخادم المحلي باستخدام OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# المصادقة عبر OAuth (يفتح المتصفح)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
يدعم المُنشئ وضعين للتحكم في ملفات الأمثلة التي سيتم تضمينها:
|
||||
|
||||
@@ -21,19 +21,51 @@ Mit Apps können Sie Twenty-Anpassungen **als Code** erstellen und verwalten. An
|
||||
## Voraussetzungen
|
||||
|
||||
* Node.js 24+ und Yarn 4
|
||||
* Ein Twenty-Workspace und ein API-Schlüssel (unter https://app.twenty.com/settings/api-webhooks erstellen)
|
||||
* Docker (für den lokalen Twenty-Dev-Server)
|
||||
|
||||
## Erste Schritte
|
||||
|
||||
Erstellen Sie mit dem offiziellen Scaffolder eine neue App, authentifizieren Sie sich und beginnen Sie mit der Entwicklung:
|
||||
Erstelle eine neue App mit dem offiziellen Scaffolder. Der Scaffolder kann für dich automatisch eine lokale Twenty-Instanz starten:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Eine neue App erstellen (enthält standardmäßig alle Beispiele)
|
||||
# Eine neue App erstellen — die CLI bietet an, einen lokalen Twenty-Server zu starten
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Dev-Modus starten: synchronisiert lokale Änderungen automatisch mit deinem Arbeitsbereich
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Lokale Serververwaltung
|
||||
|
||||
Das SDK enthält Befehle zur Verwaltung eines lokalen Twenty-Dev-Servers (All-in-One-Docker-Image mit PostgreSQL, Redis, Server und Worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Den lokalen Server starten (lädt das Image bei Bedarf herunter)
|
||||
yarn twenty server start
|
||||
|
||||
# Serverstatus prüfen
|
||||
yarn twenty server status
|
||||
|
||||
# Serverprotokolle streamen
|
||||
yarn twenty server logs
|
||||
|
||||
# Server stoppen
|
||||
yarn twenty server stop
|
||||
|
||||
# Alle Daten zurücksetzen und neu starten
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
Der lokale Server ist bereits mit einem Arbeitsbereich und einem Benutzer (`tim@apple.dev` / `tim@apple.dev`) vorbefüllt, sodass Sie ohne manuelle Einrichtung sofort mit der Entwicklung beginnen können.
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
Verbinden Sie Ihre App mithilfe von OAuth mit dem lokalen Server:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
Das Scaffolding-Tool unterstützt zwei Modi, um zu steuern, welche Beispieldateien enthalten sind:
|
||||
|
||||
@@ -21,19 +21,51 @@ Le app ti consentono di creare e gestire le personalizzazioni di Twenty **come c
|
||||
## Prerequisiti
|
||||
|
||||
* Node.js 24+ e Yarn 4
|
||||
* Uno spazio di lavoro Twenty e una chiave API (creane una su https://app.twenty.com/settings/api-webhooks)
|
||||
* Docker (per il server di sviluppo locale di Twenty)
|
||||
|
||||
## Per iniziare
|
||||
|
||||
Crea una nuova app utilizzando lo scaffolder ufficiale, quindi autenticati e inizia a sviluppare:
|
||||
Crea una nuova app utilizzando lo scaffolder ufficiale. Può avviare automaticamente un'istanza locale di Twenty per te:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Crea lo scaffold di una nuova app (include tutti gli esempi per impostazione predefinita)
|
||||
# Crea lo scaffold di una nuova app — la CLI offrirà di avviare un server locale di Twenty
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Avvia la modalità di sviluppo: sincronizza automaticamente le modifiche locali con il tuo workspace
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Gestione del server locale
|
||||
|
||||
L'SDK include comandi per gestire un server di sviluppo locale di Twenty (immagine Docker all-in-one con PostgreSQL, Redis, server e worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Avvia il server locale (scarica l'immagine se necessario)
|
||||
yarn twenty server start
|
||||
|
||||
# Verifica lo stato del server
|
||||
yarn twenty server status
|
||||
|
||||
# Segui i log del server
|
||||
yarn twenty server logs
|
||||
|
||||
# Arresta il server
|
||||
yarn twenty server stop
|
||||
|
||||
# Reimposta tutti i dati e riparti da zero
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
Il server locale è preconfigurato con uno spazio di lavoro e un utente (`tim@apple.dev` / `tim@apple.dev`), così puoi iniziare a sviluppare immediatamente senza alcuna configurazione manuale.
|
||||
|
||||
### Autenticazione
|
||||
|
||||
Collega la tua app al server locale tramite OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Autenticati tramite OAuth (apre il browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
Lo strumento di scaffolding supporta due modalità per controllare quali file di esempio vengono inclusi:
|
||||
|
||||
@@ -21,19 +21,51 @@ Os aplicativos permitem criar e gerenciar personalizações do Twenty **como có
|
||||
## Pré-requisitos
|
||||
|
||||
* Node.js 24+ e Yarn 4
|
||||
* Um espaço de trabalho do Twenty e uma chave de API (crie uma em https://app.twenty.com/settings/api-webhooks)
|
||||
* Docker (para o servidor de desenvolvimento local do Twenty)
|
||||
|
||||
## Primeiros passos
|
||||
|
||||
Crie um novo aplicativo usando o gerador oficial, depois autentique-se e comece a desenvolver:
|
||||
Crie um novo app usando o gerador oficial de estrutura. Ele pode iniciar automaticamente uma instância local do Twenty para você:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Criar a estrutura de um novo app (inclui todos os exemplos por padrão)
|
||||
# Criar a estrutura de um novo app — a CLI oferecerá iniciar um servidor local do Twenty
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Iniciar modo de desenvolvimento: sincroniza automaticamente as alterações locais com seu workspace
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Gerenciamento do Servidor Local
|
||||
|
||||
O SDK inclui comandos para gerenciar um servidor de desenvolvimento local do Twenty (imagem Docker all-in-one com PostgreSQL, Redis, servidor e worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Iniciar o servidor local (faz pull da imagem se necessário)
|
||||
yarn twenty server start
|
||||
|
||||
# Verificar o status do servidor
|
||||
yarn twenty server status
|
||||
|
||||
# Transmitir os logs do servidor
|
||||
yarn twenty server logs
|
||||
|
||||
# Parar o servidor
|
||||
yarn twenty server stop
|
||||
|
||||
# Redefinir todos os dados e começar do zero
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
O servidor local já vem pré-configurado com um espaço de trabalho e um usuário (`tim@apple.dev` / `tim@apple.dev`), para que você possa começar a desenvolver imediatamente, sem qualquer configuração manual.
|
||||
|
||||
### Autenticação
|
||||
|
||||
Conecte seu aplicativo ao servidor local usando OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
O gerador de estrutura oferece suporte a dois modos para controlar quais arquivos de exemplo são incluídos:
|
||||
|
||||
@@ -21,19 +21,51 @@ Aplicațiile vă permit să construiți și să gestionați personalizările Twe
|
||||
## Cerințe
|
||||
|
||||
* Node.js 24+ și Yarn 4
|
||||
* Un spațiu de lucru Twenty și o cheie API (creați una la https://app.twenty.com/settings/api-webhooks)
|
||||
* Docker (pentru serverul local de dezvoltare Twenty)
|
||||
|
||||
## Începeți
|
||||
|
||||
Creați o aplicație nouă folosind generatorul oficial, apoi autentificați-vă și începeți să dezvoltați:
|
||||
Creează o aplicație nouă folosind generatorul oficial. Poate porni automat o instanță Twenty locală pentru tine:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Creează scheletul unei aplicații noi (include toate exemplele în mod implicit)
|
||||
# Creează scheletul unei aplicații noi — CLI-ul îți va oferi opțiunea de a porni un server Twenty local
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Pornește modul de dezvoltare: sincronizează automat modificările locale cu spațiul tău de lucru
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Gestionarea serverului local
|
||||
|
||||
SDK-ul include comenzi pentru a gestiona un server local de dezvoltare Twenty (imagine Docker all-in-one cu PostgreSQL, Redis, server și worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Pornește serverul local (descarcă imaginea dacă este necesar)
|
||||
yarn twenty server start
|
||||
|
||||
# Verifică starea serverului
|
||||
yarn twenty server status
|
||||
|
||||
# Afișează în timp real jurnalele serverului
|
||||
yarn twenty server logs
|
||||
|
||||
# Oprește serverul
|
||||
yarn twenty server stop
|
||||
|
||||
# Resetează toate datele și pornește de la zero
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
Serverul local vine preconfigurat cu un spațiu de lucru și un utilizator (`tim@apple.dev` / `tim@apple.dev`), astfel încât să poți începe să dezvolți imediat, fără nicio configurare manuală.
|
||||
|
||||
### Autentificare
|
||||
|
||||
Conectează-ți aplicația la serverul local folosind OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Autentifică-te prin OAuth (se deschide browserul)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
Generatorul de schelet acceptă două moduri pentru a controla ce fișiere de exemplu sunt incluse:
|
||||
|
||||
@@ -21,19 +21,51 @@ description: Создавайте и управляйте настройками
|
||||
## Требования
|
||||
|
||||
* Node.js 24+ и Yarn 4
|
||||
* Рабочее пространство Twenty и ключ API (создайте его на https://app.twenty.com/settings/api-webhooks)
|
||||
* Docker (для локального сервера разработки Twenty)
|
||||
|
||||
## Начало работы
|
||||
|
||||
Создайте новое приложение с помощью официального генератора, затем выполните аутентификацию и начните разработку:
|
||||
Создайте новое приложение с помощью официального генератора каркаса. Он может автоматически запустить локальный экземпляр Twenty:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Создать каркас нового приложения (по умолчанию включает все примеры)
|
||||
# Создать каркас нового приложения — CLI предложит запустить локальный сервер Twenty
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Запустить режим разработки: автоматически синхронизирует локальные изменения с вашим рабочим пространством
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Управление локальным сервером
|
||||
|
||||
SDK включает команды для управления локальным сервером разработки Twenty (универсальный образ Docker с PostgreSQL, Redis, сервером и воркером):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Запустить локальный сервер (при необходимости будет загружен образ)
|
||||
yarn twenty server start
|
||||
|
||||
# Проверить статус сервера
|
||||
yarn twenty server status
|
||||
|
||||
# Просмотр логов сервера в реальном времени
|
||||
yarn twenty server logs
|
||||
|
||||
# Остановить сервер
|
||||
yarn twenty server stop
|
||||
|
||||
# Сбросить все данные и начать с нуля
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
Локальный сервер уже содержит рабочее пространство и пользователя (`tim@apple.dev` / `tim@apple.dev`), так что вы можете сразу начать разработку без какой-либо ручной настройки.
|
||||
|
||||
### Аутентификация
|
||||
|
||||
Подключите своё приложение к локальному серверу с помощью OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
Генератор каркаса поддерживает два режима для управления тем, какие файлы-примеры включаются:
|
||||
|
||||
@@ -21,19 +21,51 @@ Uygulamalar, Twenty özelleştirmelerini **kod olarak** oluşturup yönetmenizi
|
||||
## Ön Gereksinimler
|
||||
|
||||
* Node.js 24+ ve Yarn 4
|
||||
* Bir Twenty çalışma alanı ve bir API anahtarı (https://app.twenty.com/settings/api-webhooks adresinde oluşturun)
|
||||
* Docker (yerel Twenty geliştirme sunucusu için)
|
||||
|
||||
## Başlarken
|
||||
|
||||
Resmi scaffolder aracını kullanarak yeni bir uygulama oluşturun, ardından kimlik doğrulaması yapıp geliştirmeye başlayın:
|
||||
Resmi iskelet oluşturucusunu kullanarak yeni bir uygulama oluşturun. Sizin için otomatik olarak yerel bir Twenty örneğini başlatabilir:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Yeni bir uygulamanın iskeletini oluşturun (varsayılan olarak tüm örnekleri içerir)
|
||||
# Yeni bir uygulamanın iskeletini oluşturun — CLI yerel bir Twenty sunucusunu başlatmayı önerecektir
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Geliştirme modunu başlatın: yerel değişiklikleri otomatik olarak çalışma alanınızla senkronize eder
|
||||
yarn twenty app:dev
|
||||
# Geliştirme modunu başlatın: yerel değişiklikleri çalışma alanınızla otomatik olarak senkronize eder
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Yerel Sunucu Yönetimi
|
||||
|
||||
SDK, yerel bir Twenty geliştirme sunucusunu yönetmek için komutlar içerir (PostgreSQL, Redis, sunucu ve worker içeren hepsi bir arada Docker imajı):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Yerel sunucuyu başlatın (gerekirse imajı indirir)
|
||||
yarn twenty server start
|
||||
|
||||
# Sunucu durumunu kontrol edin
|
||||
yarn twenty server status
|
||||
|
||||
# Sunucu günlüklerini akış olarak görüntüleyin
|
||||
yarn twenty server logs
|
||||
|
||||
# Sunucuyu durdurun
|
||||
yarn twenty server stop
|
||||
|
||||
# Tüm verileri sıfırlayın ve temiz bir başlangıç yapın
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
Yerel sunucu, bir çalışma alanı ve kullanıcıyla (`tim@apple.dev` / `tim@apple.dev`) önceden yapılandırılmış olarak gelir; böylece herhangi bir manuel kurulum gerektirmeden hemen geliştirmeye başlayabilirsiniz.
|
||||
|
||||
### Kimlik Doğrulama
|
||||
|
||||
Uygulamanızı OAuth kullanarak yerel sunucuya bağlayın:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
İskelet oluşturucu, hangi örnek dosyaların dahil edileceğini kontrol etmek için iki modu destekler:
|
||||
|
||||
@@ -21,19 +21,51 @@ description: 以代码的形式构建并管理 Twenty 自定义项。
|
||||
## 先决条件
|
||||
|
||||
* Node.js 24+ 和 Yarn 4
|
||||
* 一个 Twenty 工作空间和一个 API 密钥(在 https://app.twenty.com/settings/api-webhooks 创建)
|
||||
* Docker (用于本地 Twenty 开发服务器)
|
||||
|
||||
## 开始使用
|
||||
|
||||
使用官方脚手架创建一个新应用,然后进行身份验证并开始开发:
|
||||
使用官方脚手架创建一个新应用。 它可以为你自动启动一个本地 Twenty 实例:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# 创建一个新应用脚手架(默认包含所有示例)
|
||||
# Scaffold a new app — the CLI will offer to start a local Twenty server
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# 启动开发模式:会将本地更改自动同步到你的工作区
|
||||
yarn twenty app:dev
|
||||
# Start dev mode: automatically syncs local changes to your workspace
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### 本地服务器管理
|
||||
|
||||
该 SDK 包含用于管理本地 Twenty 开发服务器的命令(该服务器是一体化 Docker 镜像,内含 PostgreSQL、Redis、服务器和工作进程):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
本地服务器预置了一个工作区和用户 (`tim@apple.dev` / `tim@apple.dev`),因此你可以无需任何手动设置即可立即开始开发。
|
||||
|
||||
### 身份验证
|
||||
|
||||
使用 OAuth 将你的应用连接到本地服务器:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# 通过 OAuth 进行身份验证 (将打开浏览器)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
脚手架工具支持两种模式,用于控制包含哪些示例文件:
|
||||
|
||||
@@ -25,7 +25,7 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24+ (recommended) and Yarn 4
|
||||
- A Twenty workspace and an API key. Generate one at https://app.twenty.com/settings/api-webhooks
|
||||
- Docker (for the local Twenty dev server) or a remote Twenty workspace
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -55,6 +55,7 @@ Commands:
|
||||
typecheck [appPath] Run TypeScript type checking on the application
|
||||
uninstall [appPath] Uninstall application from Twenty
|
||||
remote Manage remote Twenty servers
|
||||
server Manage a local Twenty server instance
|
||||
add [entityType] Add a new entity to your application
|
||||
exec [appPath] Execute a logic function with a JSON payload
|
||||
logs [appPath] Watch application function logs
|
||||
@@ -69,6 +70,41 @@ In a scaffolded project (via `create-twenty-app`), use `yarn twenty <command>` i
|
||||
|
||||
## Commands
|
||||
|
||||
### Server
|
||||
|
||||
Manage a local Twenty dev server (all-in-one Docker image).
|
||||
|
||||
- `twenty server start` — Start the local server (pulls image if needed).
|
||||
- Options:
|
||||
- `-p, --port <port>`: HTTP port (default: `2020`).
|
||||
- `twenty server stop` — Stop the local server.
|
||||
- `twenty server logs` — Stream server logs.
|
||||
- Options:
|
||||
- `-n, --lines <lines>`: Number of initial lines to show (default: `50`).
|
||||
- `twenty server status` — Show server status (running/stopped/healthy).
|
||||
- `twenty server reset` — Delete all data and start fresh.
|
||||
|
||||
The server comes pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`).
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Start the local server
|
||||
twenty server start
|
||||
|
||||
# Check if it's ready
|
||||
twenty server status
|
||||
|
||||
# Follow logs during first startup
|
||||
twenty server logs
|
||||
|
||||
# Stop the server (data is preserved)
|
||||
twenty server stop
|
||||
|
||||
# Wipe everything and start over
|
||||
twenty server reset
|
||||
```
|
||||
|
||||
### Remote
|
||||
|
||||
Manage remote server connections and authentication.
|
||||
@@ -79,7 +115,7 @@ Manage remote server connections and authentication.
|
||||
- `--token <token>`: API key for non-interactive auth.
|
||||
- `--url <url>`: Server URL (alternative to positional arg).
|
||||
- `--as <name>`: Name for this remote (otherwise derived from URL hostname).
|
||||
- `--local`: Connect to local development server (`http://localhost:3000`).
|
||||
- `--local`: Connect to local development server (`http://localhost:2020`) via OAuth.
|
||||
- Behavior: If `nameOrUrl` matches an existing remote name, re-authenticates it. Otherwise, creates a new remote and authenticates via OAuth (with API key fallback).
|
||||
|
||||
- `twenty remote remove <name>` — Remove a remote and its credentials.
|
||||
@@ -270,7 +306,7 @@ Example configuration file:
|
||||
"defaultRemote": "production",
|
||||
"remotes": {
|
||||
"local": {
|
||||
"apiUrl": "http://localhost:3000",
|
||||
"apiUrl": "http://localhost:2020",
|
||||
"apiKey": "<your-api-key>"
|
||||
},
|
||||
"production": {
|
||||
@@ -285,7 +321,7 @@ Example configuration file:
|
||||
|
||||
Notes:
|
||||
|
||||
- If a remote is missing, `apiUrl` defaults to `http://localhost:3000`.
|
||||
- If a remote is missing, `apiUrl` defaults to `http://localhost:2020`.
|
||||
- `twenty remote add` writes credentials for the active remote (OAuth tokens or API key).
|
||||
- `twenty remote add --as my-remote` saves under a custom name.
|
||||
- `twenty remote switch` sets the `defaultRemote` field, used when `-r` is not specified.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "0.8.0-canary.0",
|
||||
"version": "0.8.0-canary.1",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/sdk/index.d.ts",
|
||||
|
||||
@@ -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 { EntityAddCommand } from './add';
|
||||
import { registerRemoteCommands } from './remote';
|
||||
import { registerServerCommands } from './server';
|
||||
import { SyncableEntity } from 'twenty-shared/application';
|
||||
|
||||
export const registerCommands = (program: Command): void => {
|
||||
@@ -92,6 +93,7 @@ export const registerCommands = (program: Command): void => {
|
||||
});
|
||||
|
||||
registerRemoteCommands(program);
|
||||
registerServerCommands(program);
|
||||
|
||||
program
|
||||
.command('add [entityType]')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { authLogin } from '@/cli/operations/login';
|
||||
import { authLoginOAuth } from '@/cli/operations/login-oauth';
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
|
||||
import chalk from 'chalk';
|
||||
import type { Command } from 'commander';
|
||||
import inquirer from 'inquirer';
|
||||
@@ -86,23 +87,21 @@ export const registerRemoteCommands = (program: Command): void => {
|
||||
|
||||
if (options.local) {
|
||||
const remoteName = options.as ?? 'local';
|
||||
const token =
|
||||
options.token ??
|
||||
(
|
||||
await inquirer.prompt<{ apiKey: string }>([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'apiKey',
|
||||
message: 'API Key for local server:',
|
||||
mask: '*',
|
||||
validate: (input: string) =>
|
||||
input.length > 0 || 'API key is required',
|
||||
},
|
||||
])
|
||||
).apiKey;
|
||||
const localUrl = await detectLocalServer();
|
||||
|
||||
if (!localUrl) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'No local Twenty server found on ports 2020 or 3000.\n' +
|
||||
'Start one with: yarn twenty server start',
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.gray(`Found server at ${localUrl}`));
|
||||
ConfigService.setActiveRemote(remoteName);
|
||||
await authenticate('http://localhost:3000', token);
|
||||
await authenticate(localUrl, options.token);
|
||||
console.log(chalk.green(`✓ Authenticated remote "${remoteName}".`));
|
||||
|
||||
return;
|
||||
@@ -127,7 +126,7 @@ export const registerRemoteCommands = (program: Command): void => {
|
||||
nameOrUrl ??
|
||||
options.url ??
|
||||
(options.token
|
||||
? 'http://localhost:3000'
|
||||
? ((await detectLocalServer()) ?? 'http://localhost:2020')
|
||||
: (
|
||||
await inquirer.prompt<{ apiUrl: string }>([
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
return {
|
||||
apiUrl: 'http://localhost:3000',
|
||||
apiUrl: 'http://localhost:2020',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ export class DevModeOrchestrator {
|
||||
message: 'Failed to create development application',
|
||||
status: 'error',
|
||||
},
|
||||
{ message: JSON.stringify(createResult, null, 2), status: 'error' },
|
||||
]);
|
||||
this.state.updatePipeline({ status: 'error' });
|
||||
|
||||
|
||||
+5
-6
@@ -36,12 +36,11 @@ export class CheckServerOrchestratorStep {
|
||||
this.state.applyStepEvents([
|
||||
{
|
||||
message:
|
||||
'Cannot reach Twenty at localhost:3000.\n\n' +
|
||||
' Start a local server with Docker:\n' +
|
||||
' curl -sL https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/docker-compose.yml -o docker-compose.yml\n' +
|
||||
' docker compose up -d\n\n' +
|
||||
' Or from the monorepo:\n' +
|
||||
' yarn start\n\n' +
|
||||
'Cannot reach Twenty server.\n\n' +
|
||||
' Start a local server:\n' +
|
||||
' yarn twenty server start\n\n' +
|
||||
' Check server status:\n' +
|
||||
' yarn twenty server status\n\n' +
|
||||
' Waiting for server...',
|
||||
status: 'error',
|
||||
},
|
||||
|
||||
@@ -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 { Command, CommandRunner } from 'nest-commander';
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import {
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
SEED_YCOMBINATOR_WORKSPACE_ID,
|
||||
SeededWorkspacesIds,
|
||||
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
|
||||
import { DevSeederService } from 'src/engine/workspace-manager/dev-seeder/services/dev-seeder.service';
|
||||
|
||||
type DataSeedWorkspaceOptions = {
|
||||
light?: boolean;
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'workspace:seed:dev',
|
||||
description:
|
||||
'Seed workspace with initial data. This command is intended for development only.',
|
||||
})
|
||||
export class DataSeedWorkspaceCommand extends CommandRunner {
|
||||
workspaceIds = [
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
SEED_YCOMBINATOR_WORKSPACE_ID,
|
||||
] as const;
|
||||
private readonly logger = new Logger(DataSeedWorkspaceCommand.name);
|
||||
|
||||
constructor(private readonly devSeederService: DevSeederService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
@Option({
|
||||
flags: '--light',
|
||||
description:
|
||||
'Light seed: skip demo custom objects (Pet, Survey, etc.) and limit records to 5 per object',
|
||||
})
|
||||
parseLight(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async run(
|
||||
_passedParams: string[],
|
||||
options: DataSeedWorkspaceOptions,
|
||||
): Promise<void> {
|
||||
const workspaceIds: SeededWorkspacesIds[] = options.light
|
||||
? [SEED_APPLE_WORKSPACE_ID]
|
||||
: [SEED_APPLE_WORKSPACE_ID, SEED_YCOMBINATOR_WORKSPACE_ID];
|
||||
|
||||
try {
|
||||
for (const workspaceId of this.workspaceIds) {
|
||||
await this.devSeederService.seedDev(workspaceId);
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await this.devSeederService.seedDev(workspaceId, {
|
||||
light: options.light,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
|
||||
+6
@@ -59,6 +59,12 @@ export const handler = async (event) => {
|
||||
const builtCode = await fs.readFile(outFilePath, 'utf-8');
|
||||
|
||||
return { builtCode };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
category: 'USER_ERROR',
|
||||
errorMessage: error.message,
|
||||
};
|
||||
} finally {
|
||||
await fs.rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
+8
-1
@@ -118,10 +118,17 @@ export const handler = async (event) => {
|
||||
await copyYarnEngine(nodejsDir);
|
||||
await runYarnInstall(nodejsDir);
|
||||
await createZip(buildDir, zipPath);
|
||||
|
||||
await uploadToPresignedUrl(zipPath, presignedUploadUrl);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const isYarnInstallError = error.message?.startsWith('yarn install failed');
|
||||
|
||||
return {
|
||||
success: false,
|
||||
category: isYarnInstallError ? 'USER_ERROR' : 'INFRA_ERROR',
|
||||
errorMessage: error.message,
|
||||
};
|
||||
} finally {
|
||||
await fs.rm(buildDir, { recursive: true, force: true });
|
||||
await fs.rm(zipPath, { force: true });
|
||||
|
||||
+39
-9
@@ -81,6 +81,8 @@ type LambdaDriverExecutorPayload = {
|
||||
handlerName: string;
|
||||
};
|
||||
|
||||
export type LambdaErrorCategory = 'USER_ERROR' | 'INFRA_ERROR';
|
||||
|
||||
export type YarnInstallLambdaPayload = {
|
||||
action: 'createLayer';
|
||||
packageJson: string;
|
||||
@@ -88,9 +90,13 @@ export type YarnInstallLambdaPayload = {
|
||||
presignedUploadUrl: string;
|
||||
};
|
||||
|
||||
export type YarnInstallLambdaResult = {
|
||||
success: boolean;
|
||||
};
|
||||
export type YarnInstallLambdaResult =
|
||||
| { success: true }
|
||||
| {
|
||||
success: false;
|
||||
category: LambdaErrorCategory;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export type BuilderLambdaPayload = {
|
||||
action: 'transpile';
|
||||
@@ -99,9 +105,13 @@ export type BuilderLambdaPayload = {
|
||||
builtFileName: string;
|
||||
};
|
||||
|
||||
export type BuilderLambdaResult = {
|
||||
builtCode: string;
|
||||
};
|
||||
export type BuilderLambdaResult =
|
||||
| { builtCode: string }
|
||||
| {
|
||||
success: false;
|
||||
category: LambdaErrorCategory;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export interface LambdaDriverOptions extends LambdaClientConfig {
|
||||
logicFunctionResourceService: LogicFunctionResourceService;
|
||||
@@ -395,10 +405,18 @@ export class LambdaDriver implements LogicFunctionDriver {
|
||||
|
||||
const parsedResult: YarnInstallLambdaResult = result.Payload
|
||||
? JSON.parse(result.Payload.transformToString())
|
||||
: {};
|
||||
: { success: false, category: 'INFRA_ERROR', errorMessage: 'Empty payload' };
|
||||
|
||||
if (!parsedResult.success) {
|
||||
throw new Error('Yarn install Lambda did not report success');
|
||||
const exceptionCode =
|
||||
parsedResult.category === 'USER_ERROR'
|
||||
? LogicFunctionExceptionCode.LOGIC_FUNCTION_BUILD_USER_ERROR
|
||||
: LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED;
|
||||
|
||||
throw new LogicFunctionException(
|
||||
`Yarn install Lambda failed: ${parsedResult.errorMessage}`,
|
||||
exceptionCode,
|
||||
);
|
||||
}
|
||||
|
||||
return parsedResult;
|
||||
@@ -496,7 +514,19 @@ export class LambdaDriver implements LogicFunctionDriver {
|
||||
? JSON.parse(result.Payload.transformToString())
|
||||
: {};
|
||||
|
||||
if (!parsedResult.builtCode) {
|
||||
if ('success' in parsedResult && parsedResult.success === false) {
|
||||
const exceptionCode =
|
||||
parsedResult.category === 'USER_ERROR'
|
||||
? LogicFunctionExceptionCode.LOGIC_FUNCTION_BUILD_USER_ERROR
|
||||
: LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED;
|
||||
|
||||
throw new LogicFunctionException(
|
||||
`Builder Lambda failed: ${parsedResult.errorMessage}`,
|
||||
exceptionCode,
|
||||
);
|
||||
}
|
||||
|
||||
if (!('builtCode' in parsedResult) || !parsedResult.builtCode) {
|
||||
throw new Error('Builder Lambda did not return builtCode');
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ export enum MetricsKeys {
|
||||
WorkflowRunStartedWebhookTrigger = 'workflow-run/started/webhook-trigger',
|
||||
WorkflowRunStartedManualTrigger = 'workflow-run/started/manual-trigger',
|
||||
WorkflowRunCompleted = 'workflow-run/completed',
|
||||
WorkflowRunFailed = 'workflow-run/failed',
|
||||
WorkflowRunFailedUserError = 'workflow-run/failed/user-error',
|
||||
WorkflowRunFailedSystemError = 'workflow-run/failed/system-error',
|
||||
WorkflowRunStopped = 'workflow-run/stopped',
|
||||
WorkflowRunThrottled = 'workflow-run/throttled',
|
||||
WorkflowRunFailedToEnqueue = 'workflow-run/failed/to-enqueue',
|
||||
|
||||
+3
@@ -12,6 +12,7 @@ export enum LogicFunctionExceptionCode {
|
||||
LOGIC_FUNCTION_CODE_UNCHANGED = 'LOGIC_FUNCTION_CODE_UNCHANGED',
|
||||
LOGIC_FUNCTION_EXECUTION_LIMIT_REACHED = 'LOGIC_FUNCTION_EXECUTION_LIMIT_REACHED',
|
||||
LOGIC_FUNCTION_CREATE_FAILED = 'LOGIC_FUNCTION_CREATE_FAILED',
|
||||
LOGIC_FUNCTION_BUILD_USER_ERROR = 'LOGIC_FUNCTION_BUILD_USER_ERROR',
|
||||
LOGIC_FUNCTION_EXECUTION_TIMEOUT = 'LOGIC_FUNCTION_EXECUTION_TIMEOUT',
|
||||
LOGIC_FUNCTION_DISABLED = 'LOGIC_FUNCTION_DISABLED',
|
||||
LOGIC_FUNCTION_INVALID_SEED_PROJECT = 'LOGIC_FUNCTION_INVALID_SEED_PROJECT',
|
||||
@@ -35,6 +36,8 @@ const getLogicFunctionExceptionUserFriendlyMessage = (
|
||||
return msg`Function execution limit reached.`;
|
||||
case LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED:
|
||||
return msg`Failed to create function.`;
|
||||
case LogicFunctionExceptionCode.LOGIC_FUNCTION_BUILD_USER_ERROR:
|
||||
return msg`Function build failed due to invalid code or dependencies.`;
|
||||
case LogicFunctionExceptionCode.LOGIC_FUNCTION_EXECUTION_TIMEOUT:
|
||||
return msg`Function execution timed out.`;
|
||||
case LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED:
|
||||
|
||||
+41
-25
@@ -46,10 +46,12 @@ export class DevSeederPermissionsService {
|
||||
twentyStandardFlatApplication,
|
||||
workspaceCustomFlatApplication,
|
||||
workspaceId,
|
||||
light = false,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
twentyStandardFlatApplication: FlatApplication;
|
||||
workspaceCustomFlatApplication: FlatApplication;
|
||||
light?: boolean;
|
||||
}) {
|
||||
const adminRole = await this.roleRepository.findOne({
|
||||
where: {
|
||||
@@ -80,35 +82,49 @@ export class DevSeederPermissionsService {
|
||||
let guestUserWorkspaceId: string | undefined;
|
||||
|
||||
if (workspaceId === SEED_APPLE_WORKSPACE_ID) {
|
||||
adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.JANE;
|
||||
limitedUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM;
|
||||
memberUserWorkspaceIds = [
|
||||
USER_WORKSPACE_DATA_SEED_IDS.JONY,
|
||||
...Object.values(RANDOM_USER_WORKSPACE_IDS),
|
||||
];
|
||||
guestUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.PHIL;
|
||||
if (light) {
|
||||
// In light mode, Tim is admin (prefilled login user needs full
|
||||
// access for SDK development). No demo permission roles needed.
|
||||
adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM;
|
||||
memberUserWorkspaceIds = [
|
||||
USER_WORKSPACE_DATA_SEED_IDS.JANE,
|
||||
USER_WORKSPACE_DATA_SEED_IDS.JONY,
|
||||
USER_WORKSPACE_DATA_SEED_IDS.PHIL,
|
||||
...Object.values(RANDOM_USER_WORKSPACE_IDS),
|
||||
];
|
||||
} else {
|
||||
adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.JANE;
|
||||
limitedUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM;
|
||||
guestUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.PHIL;
|
||||
memberUserWorkspaceIds = [
|
||||
USER_WORKSPACE_DATA_SEED_IDS.JONY,
|
||||
...Object.values(RANDOM_USER_WORKSPACE_IDS),
|
||||
];
|
||||
|
||||
const guestRole = await this.roleService.createGuestRole({
|
||||
workspaceId,
|
||||
ownerFlatApplication: workspaceCustomFlatApplication,
|
||||
});
|
||||
const guestRole = await this.roleService.createGuestRole({
|
||||
workspaceId,
|
||||
ownerFlatApplication: workspaceCustomFlatApplication,
|
||||
});
|
||||
|
||||
await this.userRoleService.assignRoleToManyUserWorkspace({
|
||||
workspaceId,
|
||||
userWorkspaceIds: [guestUserWorkspaceId],
|
||||
roleId: guestRole.id,
|
||||
});
|
||||
await this.userRoleService.assignRoleToManyUserWorkspace({
|
||||
workspaceId,
|
||||
userWorkspaceIds: [guestUserWorkspaceId],
|
||||
roleId: guestRole.id,
|
||||
});
|
||||
|
||||
const limitedRole = await this.createLimitedRoleForSeedWorkspace({
|
||||
workspaceId,
|
||||
ownerFlatApplication: workspaceCustomFlatApplication,
|
||||
});
|
||||
// The limited role restricts access to Pet and Rocket objects,
|
||||
// which are only created in full (non-light) mode
|
||||
const limitedRole = await this.createLimitedRoleForSeedWorkspace({
|
||||
workspaceId,
|
||||
ownerFlatApplication: workspaceCustomFlatApplication,
|
||||
});
|
||||
|
||||
await this.userRoleService.assignRoleToManyUserWorkspace({
|
||||
workspaceId,
|
||||
userWorkspaceIds: [limitedUserWorkspaceId],
|
||||
roleId: limitedRole.id,
|
||||
});
|
||||
await this.userRoleService.assignRoleToManyUserWorkspace({
|
||||
workspaceId,
|
||||
userWorkspaceIds: [limitedUserWorkspaceId],
|
||||
roleId: limitedRole.id,
|
||||
});
|
||||
}
|
||||
} else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) {
|
||||
adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME;
|
||||
memberUserWorkspaceIds = [
|
||||
|
||||
+21
-10
@@ -300,10 +300,12 @@ export class DevSeederDataService {
|
||||
schemaName,
|
||||
workspaceId,
|
||||
featureFlags,
|
||||
light = false,
|
||||
}: {
|
||||
schemaName: string;
|
||||
workspaceId: string;
|
||||
featureFlags?: Record<FeatureFlagKey, boolean>;
|
||||
light?: boolean;
|
||||
}) {
|
||||
const objectMetadataItems =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
|
||||
@@ -328,19 +330,22 @@ export class DevSeederDataService {
|
||||
attachmentSeeds,
|
||||
featureFlags,
|
||||
objectMetadataItems,
|
||||
light,
|
||||
});
|
||||
|
||||
await this.timelineActivitySeederService.seedTimelineActivities({
|
||||
entityManager,
|
||||
schemaName,
|
||||
workspaceId,
|
||||
});
|
||||
if (!light) {
|
||||
await this.timelineActivitySeederService.seedTimelineActivities({
|
||||
entityManager,
|
||||
schemaName,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await this.seedAttachmentFiles(
|
||||
workspaceId,
|
||||
entityManager,
|
||||
attachmentFileMeta,
|
||||
);
|
||||
await this.seedAttachmentFiles(
|
||||
workspaceId,
|
||||
entityManager,
|
||||
attachmentFileMeta,
|
||||
);
|
||||
}
|
||||
|
||||
await prefillWorkflows(
|
||||
entityManager,
|
||||
@@ -359,6 +364,7 @@ export class DevSeederDataService {
|
||||
attachmentSeeds,
|
||||
featureFlags,
|
||||
objectMetadataItems,
|
||||
light = false,
|
||||
}: {
|
||||
entityManager: WorkspaceEntityManager;
|
||||
schemaName: string;
|
||||
@@ -366,6 +372,7 @@ export class DevSeederDataService {
|
||||
attachmentSeeds: RecordSeedConfig['recordSeeds'];
|
||||
featureFlags?: Record<FeatureFlagKey, boolean>;
|
||||
objectMetadataItems: FlatObjectMetadata[];
|
||||
light?: boolean;
|
||||
}) {
|
||||
const batches = getRecordSeedsBatches(
|
||||
workspaceId,
|
||||
@@ -378,6 +385,10 @@ export class DevSeederDataService {
|
||||
for (const batch of batches) {
|
||||
await Promise.all(
|
||||
batch.map(async (recordSeedsConfig) => {
|
||||
if (light && recordSeedsConfig.tableName.startsWith('_')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectMetadata = objectMetadataItems.find(
|
||||
(item) =>
|
||||
computeTableName(item.nameSingular, item.isCustom) ===
|
||||
|
||||
+39
-28
@@ -51,6 +51,14 @@ type JunctionConfigSeed = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type WorkspaceSeedConfig = {
|
||||
objects: { seed: ObjectMetadataSeed; fields?: FieldMetadataSeed[] }[];
|
||||
fields: { objectName: string; seeds: FieldMetadataSeed[] }[];
|
||||
morphRelations?: { objectName: string; seeds: MorphRelationSeed[] }[];
|
||||
junctionFields?: JunctionFieldSeed[];
|
||||
junctionConfigs?: JunctionConfigSeed[];
|
||||
};
|
||||
|
||||
type FlatMaps = {
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
@@ -65,18 +73,7 @@ export class DevSeederMetadataService {
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
) {}
|
||||
|
||||
private readonly workspaceConfigs: Record<
|
||||
string,
|
||||
{
|
||||
objects: { seed: ObjectMetadataSeed; fields?: FieldMetadataSeed[] }[];
|
||||
fields: { objectName: string; seeds: FieldMetadataSeed[] }[];
|
||||
morphRelations?: { objectName: string; seeds: MorphRelationSeed[] }[];
|
||||
// Junction fields create relations to junction objects (inverses auto-created)
|
||||
junctionFields?: JunctionFieldSeed[];
|
||||
// Configure junction settings on fields after all relations exist
|
||||
junctionConfigs?: JunctionConfigSeed[];
|
||||
}
|
||||
> = {
|
||||
private readonly workspaceConfigs: Record<string, WorkspaceSeedConfig> = {
|
||||
[SEED_APPLE_WORKSPACE_ID]: {
|
||||
objects: [
|
||||
{ seed: ROCKET_CUSTOM_OBJECT_SEED },
|
||||
@@ -178,13 +175,14 @@ export class DevSeederMetadataService {
|
||||
},
|
||||
};
|
||||
|
||||
public async seed({
|
||||
dataSourceMetadata,
|
||||
workspaceId,
|
||||
}: {
|
||||
dataSourceMetadata: DataSourceEntity;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
private getLightConfig(config: WorkspaceSeedConfig): WorkspaceSeedConfig {
|
||||
return {
|
||||
objects: [],
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
private getConfig(workspaceId: string, light: boolean): WorkspaceSeedConfig {
|
||||
const config = this.workspaceConfigs[workspaceId];
|
||||
|
||||
if (!config) {
|
||||
@@ -193,6 +191,20 @@ export class DevSeederMetadataService {
|
||||
);
|
||||
}
|
||||
|
||||
return light ? this.getLightConfig(config) : config;
|
||||
}
|
||||
|
||||
public async seed({
|
||||
dataSourceMetadata,
|
||||
workspaceId,
|
||||
light = false,
|
||||
}: {
|
||||
dataSourceMetadata: DataSourceEntity;
|
||||
workspaceId: string;
|
||||
light?: boolean;
|
||||
}) {
|
||||
const config = this.getConfig(workspaceId, light);
|
||||
|
||||
for (const obj of config.objects) {
|
||||
await this.seedCustomObject({
|
||||
dataSourceId: dataSourceMetadata.id,
|
||||
@@ -266,14 +278,14 @@ export class DevSeederMetadataService {
|
||||
});
|
||||
}
|
||||
|
||||
public async seedRelations({ workspaceId }: { workspaceId: string }) {
|
||||
const config = this.workspaceConfigs[workspaceId];
|
||||
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
`Workspace configuration not found for workspaceId: ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
public async seedRelations({
|
||||
workspaceId,
|
||||
light = false,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
light?: boolean;
|
||||
}) {
|
||||
const config = this.getConfig(workspaceId, light);
|
||||
|
||||
// 1. Seed morph relations (creates inverses on target objects)
|
||||
let maps = await this.getFreshFlatMaps(workspaceId);
|
||||
@@ -287,7 +299,6 @@ export class DevSeederMetadataService {
|
||||
}
|
||||
|
||||
// 2. Seed junction fields (creates relations + inverses on junction objects)
|
||||
// Use same maps for all - matches original working behavior
|
||||
maps = await this.getFreshFlatMaps(workspaceId);
|
||||
|
||||
for (const field of config.junctionFields ?? []) {
|
||||
|
||||
+9
-1
@@ -43,7 +43,11 @@ export class DevSeederService {
|
||||
private readonly coreDataSource: DataSource,
|
||||
) {}
|
||||
|
||||
public async seedDev(workspaceId: SeededWorkspacesIds): Promise<void> {
|
||||
public async seedDev(
|
||||
workspaceId: SeededWorkspacesIds,
|
||||
options?: { light?: boolean },
|
||||
): Promise<void> {
|
||||
const light = options?.light ?? false;
|
||||
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
|
||||
const appVersion = this.twentyConfigService.get('APP_VERSION');
|
||||
|
||||
@@ -89,16 +93,19 @@ export class DevSeederService {
|
||||
await this.devSeederMetadataService.seed({
|
||||
dataSourceMetadata,
|
||||
workspaceId,
|
||||
light,
|
||||
});
|
||||
|
||||
await this.devSeederMetadataService.seedRelations({
|
||||
workspaceId,
|
||||
light,
|
||||
});
|
||||
|
||||
await this.devSeederPermissionsService.initPermissions({
|
||||
workspaceId,
|
||||
twentyStandardFlatApplication,
|
||||
workspaceCustomFlatApplication,
|
||||
light,
|
||||
});
|
||||
|
||||
await seedPageLayouts(
|
||||
@@ -148,6 +155,7 @@ export class DevSeederService {
|
||||
schemaName: dataSourceMetadata.schema,
|
||||
workspaceId,
|
||||
featureFlags: featureFlagsMap,
|
||||
light,
|
||||
});
|
||||
|
||||
await this.workspaceCacheStorageService.flush(workspaceId, undefined);
|
||||
|
||||
+3
@@ -1,6 +1,9 @@
|
||||
import { type WorkflowFailureReason } from 'src/modules/workflow/workflow-executor/types/workflow-failure-reason.type';
|
||||
|
||||
export type WorkflowActionOutput = {
|
||||
result?: object;
|
||||
error?: string;
|
||||
failureReason?: WorkflowFailureReason;
|
||||
pendingEvent?: boolean;
|
||||
shouldEndWorkflowRun?: boolean;
|
||||
shouldRemainRunning?: boolean;
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type WorkflowFailureReason = 'USER_ERROR' | 'SYSTEM_ERROR';
|
||||
+28
-9
@@ -5,6 +5,10 @@ import { resolveInput } from 'twenty-shared/utils';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
|
||||
|
||||
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
|
||||
import {
|
||||
LogicFunctionException,
|
||||
LogicFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
|
||||
import {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
@@ -46,16 +50,31 @@ export class CodeWorkflowAction implements WorkflowAction {
|
||||
|
||||
const { workspaceId } = runInfo;
|
||||
|
||||
const result = await this.logicFunctionExecutorService.execute({
|
||||
logicFunctionId: workflowActionInput.logicFunctionId,
|
||||
workspaceId,
|
||||
payload: workflowActionInput.logicFunctionInput,
|
||||
});
|
||||
try {
|
||||
const result = await this.logicFunctionExecutorService.execute({
|
||||
logicFunctionId: workflowActionInput.logicFunctionId,
|
||||
workspaceId,
|
||||
payload: workflowActionInput.logicFunctionInput,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return { error: result.error.errorMessage };
|
||||
if (result.error) {
|
||||
return { error: result.error.errorMessage };
|
||||
}
|
||||
|
||||
return { result: result.data || {} };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LogicFunctionException &&
|
||||
error.code ===
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_BUILD_USER_ERROR
|
||||
) {
|
||||
throw new WorkflowStepExecutorException(
|
||||
error.message,
|
||||
WorkflowStepExecutorExceptionCode.INVALID_STEP_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { result: result.data || {} };
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -9,6 +9,10 @@ import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
|
||||
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
|
||||
import { type WorkflowSendEmailActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-input.type';
|
||||
@@ -39,13 +43,19 @@ export class ToolExecutorWorkflowAction implements WorkflowAction {
|
||||
const step = steps.find((step) => step.id === currentStepId);
|
||||
|
||||
if (!step) {
|
||||
throw new Error('Step not found');
|
||||
throw new WorkflowStepExecutorException(
|
||||
'Step not found',
|
||||
WorkflowStepExecutorExceptionCode.STEP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const tool = this.toolsByActionType.get(step.type);
|
||||
|
||||
if (!tool) {
|
||||
throw new Error(`No tool found for workflow action type: ${step.type}`);
|
||||
throw new WorkflowStepExecutorException(
|
||||
`No tool found for workflow action type: ${step.type}`,
|
||||
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
let toolInput = step.settings.input;
|
||||
|
||||
+29
-7
@@ -28,6 +28,7 @@ import {
|
||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory';
|
||||
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
|
||||
import { type WorkflowFailureReason } from 'src/modules/workflow/workflow-executor/types/workflow-failure-reason.type';
|
||||
import {
|
||||
type WorkflowBranchExecutorInput,
|
||||
type WorkflowExecutorInput,
|
||||
@@ -68,9 +69,9 @@ export class WorkflowExecutorWorkspaceService {
|
||||
shouldComputeWorkflowRunStatus = true,
|
||||
executedStepsCount = 0,
|
||||
}: WorkflowExecutorInput) {
|
||||
await Promise.all(
|
||||
const reasons = await Promise.all(
|
||||
stepIds.map(async (stepIdToExecute) => {
|
||||
await this.executeFromStep({
|
||||
return this.executeFromStep({
|
||||
stepId: stepIdToExecute,
|
||||
workflowRunId,
|
||||
workspaceId,
|
||||
@@ -80,9 +81,16 @@ export class WorkflowExecutorWorkspaceService {
|
||||
);
|
||||
|
||||
if (shouldComputeWorkflowRunStatus) {
|
||||
const failureReason = reasons.some(
|
||||
(reason) => reason === 'SYSTEM_ERROR',
|
||||
)
|
||||
? 'SYSTEM_ERROR'
|
||||
: reasons.find((reason) => reason === 'USER_ERROR') ?? undefined;
|
||||
|
||||
await this.computeWorkflowRunStatus({
|
||||
workflowRunId,
|
||||
workspaceId,
|
||||
failureReason,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -92,7 +100,7 @@ export class WorkflowExecutorWorkspaceService {
|
||||
workflowRunId,
|
||||
workspaceId,
|
||||
executedStepsCount,
|
||||
}: WorkflowBranchExecutorInput) {
|
||||
}: WorkflowBranchExecutorInput): Promise<WorkflowFailureReason | undefined> {
|
||||
const workflowRun =
|
||||
await this.workflowRunWorkspaceService.getWorkflowRunOrFail({
|
||||
workflowRunId,
|
||||
@@ -113,7 +121,7 @@ export class WorkflowExecutorWorkspaceService {
|
||||
error: 'Step not found',
|
||||
});
|
||||
|
||||
return;
|
||||
return 'SYSTEM_ERROR';
|
||||
}
|
||||
|
||||
let actionOutput: WorkflowActionOutput;
|
||||
@@ -165,7 +173,7 @@ export class WorkflowExecutorWorkspaceService {
|
||||
shouldSkipStepExecution: true,
|
||||
};
|
||||
} else {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isError =
|
||||
@@ -183,7 +191,11 @@ export class WorkflowExecutorWorkspaceService {
|
||||
});
|
||||
|
||||
if (!shouldProcessNextSteps) {
|
||||
return;
|
||||
if (isError) {
|
||||
return actionOutput.failureReason ?? 'SYSTEM_ERROR';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const shouldRunAnotherJob =
|
||||
@@ -196,7 +208,7 @@ export class WorkflowExecutorWorkspaceService {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { nextStepIdsToExecute, nextStepIdsToSkip, nextStepIdsToFailSafely } =
|
||||
@@ -225,6 +237,8 @@ export class WorkflowExecutorWorkspaceService {
|
||||
executedStepsCount: (executedStepsCount ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getNextStepIdsToExecute({
|
||||
@@ -300,9 +314,11 @@ export class WorkflowExecutorWorkspaceService {
|
||||
private async computeWorkflowRunStatus({
|
||||
workflowRunId,
|
||||
workspaceId,
|
||||
failureReason,
|
||||
}: {
|
||||
workflowRunId: string;
|
||||
workspaceId: string;
|
||||
failureReason?: WorkflowFailureReason;
|
||||
}) {
|
||||
const workflowRun =
|
||||
await this.workflowRunWorkspaceService.getWorkflowRunOrFail({
|
||||
@@ -332,6 +348,7 @@ export class WorkflowExecutorWorkspaceService {
|
||||
workspaceId,
|
||||
status: WorkflowRunStatus.FAILED,
|
||||
error: 'WorkflowRun failed',
|
||||
failureReason,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -502,8 +519,13 @@ export class WorkflowExecutorWorkspaceService {
|
||||
});
|
||||
}
|
||||
|
||||
const failureReason: WorkflowFailureReason = isUserError
|
||||
? 'USER_ERROR'
|
||||
: 'SYSTEM_ERROR';
|
||||
|
||||
return {
|
||||
error: error.message ?? 'Execution result error, no data or error',
|
||||
failureReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+17
-4
@@ -187,11 +187,13 @@ export class WorkflowRunWorkspaceService {
|
||||
workspaceId,
|
||||
status,
|
||||
error,
|
||||
failureReason,
|
||||
}: {
|
||||
workflowRunId: string;
|
||||
workspaceId: string;
|
||||
status: Extract<WorkflowRunStatus, 'COMPLETED' | 'FAILED' | 'STOPPED'>;
|
||||
error?: string;
|
||||
failureReason?: 'USER_ERROR' | 'SYSTEM_ERROR';
|
||||
}) {
|
||||
const workflowRunToUpdate = await this.getWorkflowRunOrFail({
|
||||
workflowRunId,
|
||||
@@ -222,8 +224,9 @@ export class WorkflowRunWorkspaceService {
|
||||
? MetricsKeys.WorkflowRunCompleted
|
||||
: status === WorkflowRunStatus.STOPPED
|
||||
? MetricsKeys.WorkflowRunStopped
|
||||
: MetricsKeys.WorkflowRunFailed,
|
||||
: this.getMetricKeyFromFailureReason(failureReason),
|
||||
eventId: workflowRunId,
|
||||
attributes: { workspace_id: workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,7 +253,7 @@ export class WorkflowRunWorkspaceService {
|
||||
stepInfos: {
|
||||
...workflowRunToUpdate.state?.stepInfos,
|
||||
[stepId]: {
|
||||
...(workflowRunToUpdate.state?.stepInfos[stepId] || {}),
|
||||
...workflowRunToUpdate.state?.stepInfos[stepId],
|
||||
result: stepInfo?.result,
|
||||
error: stepInfo?.error,
|
||||
status: stepInfo.status,
|
||||
@@ -283,7 +286,7 @@ export class WorkflowRunWorkspaceService {
|
||||
|
||||
for (const [stepId, info] of Object.entries(stepInfos)) {
|
||||
mergedStepInfos[stepId] = {
|
||||
...(existingStepInfos[stepId] || {}),
|
||||
...existingStepInfos[stepId],
|
||||
...info,
|
||||
};
|
||||
}
|
||||
@@ -335,7 +338,7 @@ export class WorkflowRunWorkspaceService {
|
||||
state: {
|
||||
...workflowRunToUpdate.state,
|
||||
flow: {
|
||||
...(workflowRunToUpdate.state?.flow ?? {}),
|
||||
...workflowRunToUpdate.state?.flow,
|
||||
steps: updatedSteps,
|
||||
},
|
||||
},
|
||||
@@ -492,4 +495,14 @@ export class WorkflowRunWorkspaceService {
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
|
||||
private getMetricKeyFromFailureReason(
|
||||
failureReason: 'USER_ERROR' | 'SYSTEM_ERROR' | undefined,
|
||||
): MetricsKeys {
|
||||
if (failureReason === 'USER_ERROR') {
|
||||
return MetricsKeys.WorkflowRunFailedUserError;
|
||||
}
|
||||
|
||||
return MetricsKeys.WorkflowRunFailedSystemError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,14 +102,14 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/mistral@npm:^3.0.20":
|
||||
version: 3.0.20
|
||||
resolution: "@ai-sdk/mistral@npm:3.0.20"
|
||||
version: 3.0.25
|
||||
resolution: "@ai-sdk/mistral@npm:3.0.25"
|
||||
dependencies:
|
||||
"@ai-sdk/provider": "npm:3.0.8"
|
||||
"@ai-sdk/provider-utils": "npm:4.0.15"
|
||||
"@ai-sdk/provider-utils": "npm:4.0.20"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
checksum: 10c0/32caf651dae8a0fb7b7faceb8706e711404a32bc21aea94fe6bcbbe6adb09c31fc6563efd28547bdcbbfe99165145ec73fe83f4b54bd75f290b6020cc9834bc7
|
||||
checksum: 10c0/4ea732fc807e9e8f143ecbc3168e103dbb0c43f611ab9d9fdfe532510059c18aa8b44a61c7eafda4d9a91977e7d83fa94022c4aa39be21ad035b186f8471384f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -163,6 +163,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/provider-utils@npm:4.0.20":
|
||||
version: 4.0.20
|
||||
resolution: "@ai-sdk/provider-utils@npm:4.0.20"
|
||||
dependencies:
|
||||
"@ai-sdk/provider": "npm:3.0.8"
|
||||
"@standard-schema/spec": "npm:^1.1.0"
|
||||
eventsource-parser: "npm:^3.0.6"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
checksum: 10c0/749d9f91b2b098f716246bbb92a801d0f8a952fed4481e48b7d36a1c026cfe96b9d956f34b95270bc4628048ad57edd7b19026c167e8ded288e741aa289d4cdd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/provider@npm:2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "@ai-sdk/provider@npm:2.0.1"
|
||||
@@ -4117,16 +4130,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dagrejs/dagre@npm:^1.1.2":
|
||||
version: 1.1.3
|
||||
resolution: "@dagrejs/dagre@npm:1.1.3"
|
||||
dependencies:
|
||||
"@dagrejs/graphlib": "npm:2.2.2"
|
||||
checksum: 10c0/c81e142ed55d23bda02968624bd96987eeb545dbc860b0dc22aafd606f8bd8b1b18914e2b986c46e3250a6cfcc2bc16a944bb8df301da9adffd738936679db44
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dagrejs/dagre@npm:^1.1.8":
|
||||
"@dagrejs/dagre@npm:^1.1.2, @dagrejs/dagre@npm:^1.1.8":
|
||||
version: 1.1.8
|
||||
resolution: "@dagrejs/dagre@npm:1.1.8"
|
||||
dependencies:
|
||||
@@ -4135,13 +4139,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dagrejs/graphlib@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@dagrejs/graphlib@npm:2.2.2"
|
||||
checksum: 10c0/2e79a4f5c6c402054b7ef42e786459645495934476170999f13867a55a00072636a23914772cce6bc03ce51eef70de589058860b8f034c1d70804fb61e01fcfc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dagrejs/graphlib@npm:2.2.4":
|
||||
version: 2.2.4
|
||||
resolution: "@dagrejs/graphlib@npm:2.2.4"
|
||||
@@ -46875,14 +46872,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nodemailer@npm:7.0.11, nodemailer@npm:^7.0.11":
|
||||
"nodemailer@npm:7.0.11":
|
||||
version: 7.0.11
|
||||
resolution: "nodemailer@npm:7.0.11"
|
||||
checksum: 10c0/208f108fdb4c5dd0e3a2f013578d53dad505cf1b9c7a084f6d22fc9d6f3912daafb4a23793ca568ff848afc35f15f4eb24382d3f6f9fb8ede4a8410d4ca63618
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nodemailer@npm:7.0.13":
|
||||
"nodemailer@npm:7.0.13, nodemailer@npm:^7.0.11":
|
||||
version: 7.0.13
|
||||
resolution: "nodemailer@npm:7.0.13"
|
||||
checksum: 10c0/b26aa5b9fa4a033bbc1e1c16ef75ee2a9c8641fd290c00a8361d6a251b3c1b8bad545a23efa627f59cb266340a448891ea8aa49d8a9307c767b8505219d95079
|
||||
|
||||
Reference in New Issue
Block a user