martmull
2026-03-30 17:03:23 +02:00
committed by GitHub
parent 40abe1e6d0
commit 8985dfbc5d
21 changed files with 181 additions and 104 deletions

View File

@@ -110,7 +110,7 @@ npx create-twenty-app@latest my-app -m
## 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:
The scaffolder can start a local Twenty dev server for you (all-in-one Docker image with PostgreSQL, Redis, server, and worker on port 2020). These commands only apply to the Docker-based dev server — they do not manage a Twenty instance started from source (e.g. `npx nx start twenty-server` on port 3000). You can also manage it manually:
```bash
yarn twenty server start # Start (pulls image if needed)

View File

@@ -10,6 +10,7 @@ import * as path from 'path';
import { basename } from 'path';
import {
authLoginOAuth,
detectLocalServer,
serverStart,
type ServerStartResult,
} from 'twenty-sdk/cli';
@@ -62,15 +63,19 @@ export class CreateAppCommand {
let serverResult: ServerStartResult | undefined;
if (!options.skipLocalInstance) {
const startResult = await serverStart({
onProgress: (message: string) => console.log(chalk.gray(message)),
});
const shouldStartServer = await this.shouldStartServer();
if (startResult.success) {
serverResult = startResult.data;
await this.connectToLocal(serverResult.url);
} else {
console.log(chalk.yellow(`\n${startResult.error.message}`));
if (shouldStartServer) {
const startResult = await serverStart({
onProgress: (message: string) => console.log(chalk.gray(message)),
});
if (startResult.success) {
serverResult = startResult.data;
await this.promptConnectToLocal(serverResult.url);
} else {
console.log(chalk.yellow(`\n${startResult.error.message}`));
}
}
}
@@ -201,7 +206,46 @@ export class CreateAppCommand {
);
}
private async connectToLocal(serverUrl: string): Promise<void> {
private async shouldStartServer(): Promise<boolean> {
const existingServerUrl = await detectLocalServer();
if (existingServerUrl) {
return true;
}
const { startDocker } = await inquirer.prompt([
{
type: 'confirm',
name: 'startDocker',
message:
'No running Twenty instance found. Would you like to start one using Docker?',
default: true,
},
]);
return startDocker;
}
private async promptConnectToLocal(serverUrl: string): Promise<void> {
const { shouldAuthenticate } = await inquirer.prompt([
{
type: 'confirm',
name: 'shouldAuthenticate',
message: `Would you like to authenticate to the local Twenty instance (${serverUrl})?`,
default: true,
},
]);
if (!shouldAuthenticate) {
console.log(
chalk.gray(
'Authentication skipped. Run `yarn twenty remote add` manually.',
),
);
return;
}
try {
const result = await authLoginOAuth({
apiUrl: serverUrl,
@@ -211,14 +255,14 @@ export class CreateAppCommand {
if (!result.success) {
console.log(
chalk.yellow(
'Authentication skipped. Run `yarn twenty remote add` manually.',
'Authentication failed. Run `yarn twenty remote add` manually.',
),
);
}
} catch {
console.log(
chalk.yellow(
'Authentication skipped. Run `yarn twenty remote add` manually.',
'Authentication failed. Run `yarn twenty remote add` manually.',
),
);
}

View File

@@ -362,7 +362,7 @@ If you plan to [publish your app](/developers/extend/apps/publishing), these opt
| `category` | App category for marketplace filtering |
| `logoUrl` | Path to your app logo (relative to `./assets/`) |
| `screenshots` | Array of screenshot paths (relative to `./assets/`) |
| `aboutDescription` | Longer markdown description for the "About" tab |
| `aboutDescription` | Longer markdown description for the "About" tab. If omitted, the marketplace uses the package's `README.md` from npm |
| `websiteUrl` | Link to your website |
| `termsUrl` | Link to terms of service |
| `emailSupport` | Support email address |

View File

@@ -9,17 +9,18 @@ Apps are currently in alpha testing. The feature is functional but still evolvin
Apps let you extend Twenty with custom objects, fields, logic functions, AI skills, and UI components — all managed as code.
**What you can do today:**
- Define custom objects and fields as code (managed data model)
- Build logic functions with custom triggers (HTTP routes, cron, database events)
- Define skills for AI agents
- Build front components that render inside Twenty's UI
- Deploy the same app across multiple workspaces
**What you can build:**
- Custom objects, fields, views, and navigation items to shape your data model
- Logic functions triggered by HTTP routes, cron schedules, or database events
- Front components that render directly inside Twenty's UI
- Skills that extend Twenty's AI agents
- Deploy an app across multiple workspaces
## Prerequisites
- Node.js 24+ and Yarn 4
- Docker (for the local Twenty dev server)
- Node.js 24+
- Yarn 4
- Docker (or a running local Twenty instance)
## Getting Started
@@ -28,21 +29,9 @@ Create a new app using the official scaffolder, then authenticate and start deve
```bash filename="Terminal"
# Scaffold a new app (includes all examples by default)
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Start dev mode: automatically syncs local changes to your workspace
yarn twenty dev
```
The scaffolder supports two modes for controlling which example files are included:
```bash filename="Terminal"
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill, agent)
npx create-twenty-app@latest my-app
# Minimal: only core files (application-config.ts and default-role.ts)
npx create-twenty-app@latest my-app --minimal
```
> Use `--minimal` option to scaffold a minimal installation
From here you can:

View File

@@ -102,6 +102,10 @@ yarn twenty catalog-sync -r production
The metadata shown in the marketplace comes from your `defineApplication()` call in your app source code — fields like `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, and `termsUrl`.
<Note>
If your app does not define an `aboutDescription` in `defineApplication()`, the marketplace will automatically use your package's `README.md` from npm as the about page content. This means you can maintain a single README for both npm and the Twenty marketplace. If you want a different description in the marketplace, explicitly set `aboutDescription`.
</Note>
### CI publishing
The scaffolded project includes a GitHub Actions workflow that publishes on every release:

View File

@@ -37,7 +37,7 @@ 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):
The SDK includes commands to manage a local Twenty dev server (all-in-one Docker image with PostgreSQL, Redis, server, and worker on port 2020). These commands only apply to the Docker-based dev server — they do not manage a Twenty instance started from source (e.g. `npx nx start twenty-server` on port 3000):
```bash filename="Terminal"
# Start the local server (pulls the image if needed)

View File

@@ -17,6 +17,7 @@ import {
IconAlertTriangle,
IconBook,
IconBox,
IconBrandNpm,
IconCheck,
IconCommand,
IconDownload,
@@ -221,6 +222,11 @@ export const SettingsAvailableApplicationDetails = () => {
const sourceType = detail?.sourceType;
const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM;
const registrationId = detail?.id;
const sourcePackage = detail?.sourcePackage;
const sourcePackageUrl =
isNpmApp && detail?.sourcePackage
? `https://www.npmjs.com/package/${detail.sourcePackage}`
: undefined;
const isUnlisted = isDefined(detail) && !detail.isListed;
const installedApp = applicationData?.findOneApplication;
@@ -519,6 +525,16 @@ export const SettingsAvailableApplicationDetails = () => {
{t`Report and issue`}
</StyledLink>
)}
{sourcePackageUrl && (
<StyledLink
href={sourcePackageUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconBrandNpm size={16} />
{t`Npm package`}
</StyledLink>
)}
</StyledSidebarSection>
)}
</StyledSidebar>

View File

@@ -81,7 +81,7 @@ In a project created with `create-twenty-app` (recommended), use `yarn twenty <c
### Server
Manage a local Twenty dev server (all-in-one Docker image).
Manage a local Twenty dev server (all-in-one Docker image on port 2020). These commands only apply to the Docker-based dev server — they do not manage a Twenty instance started from source (e.g. `npx nx start twenty-server` on port 3000).
- `twenty server start` — Start the local server (pulls image if needed). Automatically configures the `local` remote.
- Options:

View File

@@ -40,6 +40,9 @@ registerCommands(program);
program.exitOverride();
const isExitPromptError = (error: unknown): boolean =>
error instanceof Error && error.name === 'ExitPromptError';
try {
program.parse();
} catch (error) {

View File

@@ -108,7 +108,7 @@ export const registerRemoteCommands = (program: Command): void => {
if (!detectedUrl) {
console.error(
chalk.red(
'No local Twenty server found on ports 2020 or 3000.\n' +
'No local Twenty server found.\n' +
'Start one with: yarn twenty server start',
),
);

View File

@@ -37,10 +37,6 @@ export const registerServerCommands = (program: Command): void => {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(
chalk.green(`\nLocal remote configured → ${result.data.url}`),
);
});
server

View File

@@ -25,6 +25,7 @@ export type { FunctionExecuteOptions } from './execute';
// Server
export { serverStart } from './server-start';
export type { ServerStartOptions, ServerStartResult } from './server-start';
export { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
// Shared types and error codes
export {

View File

@@ -126,6 +126,8 @@ const innerServerStart = async (
} else {
onProgress?.('Starting Twenty container...');
const serverUrl = `http://localhost:${port}`;
const runResult = spawnSync(
'docker',
[
@@ -133,6 +135,8 @@ const innerServerStart = async (
'-d',
'--name',
CONTAINER_NAME,
'-e',
`SERVER_URL=${serverUrl}`,
'-p',
`${port}:3000`,
'-v',

View File

@@ -51,7 +51,7 @@ export class ApiClient {
if (error.response?.status === 401) {
console.error(
chalk.red(
'Authentication failed. Run `twenty remote add` to authenticate.',
'Authentication failed. Run `yarn twenty remote add` to authenticate.',
),
);
} else if (error.response?.status === 403) {

View File

@@ -1,5 +1,7 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
export type CheckServerOrchestratorStepOutput = {
isReady: boolean;
@@ -25,11 +27,24 @@ export class CheckServerOrchestratorStep {
this.notify = notify;
}
private hasRetried = false;
async execute(): Promise<boolean> {
const step = this.state.steps.checkServer;
const validateAuth = await this.apiService.validateAuth();
if (!validateAuth.serverUp) {
const detectedUrl = await detectLocalServer();
if (detectedUrl && !this.hasRetried) {
this.hasRetried = true;
const configService = new ConfigService();
await configService.setConfig({ apiUrl: detectedUrl });
return this.execute();
}
if (!step.output.errorLogged) {
step.output = { isReady: false, errorLogged: true };
step.status = 'error';
@@ -58,7 +73,7 @@ export class CheckServerOrchestratorStep {
this.state.applyStepEvents([
{
message:
'Authentication failed. Run `twenty remote add <url>` to authenticate.',
'Authentication failed. Run `yarn twenty remote add` to authenticate.',
status: 'error',
},
]);

View File

@@ -1,29 +0,0 @@
export type CuratedAppEntry = {
universalIdentifier: string;
sourcePackage: string;
isFeatured: boolean;
name: string;
description: string;
author: string;
logoUrl?: string;
websiteUrl?: string;
termsUrl?: string;
latestAvailableVersion?: string;
};
const MOCK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744"><ellipse cx="38" cy="20" rx="28" ry="10"/><rect x="10" y="20" width="56" height="50"/><ellipse cx="38" cy="70" rx="28" ry="10"/><ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><circle cx="72" cy="62" r="22" fill="#1a2744"/><circle cx="72" cy="62" r="18" fill="#fff"/><path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
const ENCODED_MOCK_LOGO = `data:image/svg+xml,${encodeURIComponent(MOCK_LOGO_SVG)}`;
export const MARKETPLACE_CATALOG_INDEX: CuratedAppEntry[] = [
{
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000001',
sourcePackage: '@twentyhq/app-data-enrichment',
isFeatured: true,
name: 'Data Enrichment',
description: 'Enrich your data easily. Choose your provider.',
author: 'Twenty',
logoUrl: ENCODED_MOCK_LOGO,
websiteUrl: 'https://twenty.com',
latestAvailableVersion: '1.0.0',
},
];

View File

@@ -0,0 +1,4 @@
export const MARKETPLACE_CURATED_APPLICATIONS: {
universalIdentifier: string;
position?: number;
}[] = [];

View File

@@ -2,11 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-catalog-index.constant';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { MARKETPLACE_CURATED_APPLICATIONS } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-curated-applications.constant';
@Injectable()
export class MarketplaceCatalogSyncService {
@@ -19,59 +19,54 @@ export class MarketplaceCatalogSyncService {
) {}
async syncCatalog(): Promise<void> {
await this.syncCuratedApps();
await this.syncRegistryApps();
this.logger.log('Marketplace catalog sync completed');
}
private async syncCuratedApps(): Promise<void> {
for (const entry of MARKETPLACE_CATALOG_INDEX) {
try {
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier: entry.universalIdentifier,
name: entry.name,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: entry.sourcePackage,
latestAvailableVersion: entry.latestAvailableVersion ?? null,
isListed: true,
isFeatured: entry.isFeatured,
manifest: null,
ownerWorkspaceId: null,
});
} catch (error) {
this.logger.error(
`Failed to sync curated app "${entry.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
private async syncRegistryApps(): Promise<void> {
const packages = await this.marketplaceService.fetchAppsFromRegistry();
const curatedIdentifiers = new Set(
MARKETPLACE_CATALOG_INDEX.map((entry) => entry.universalIdentifier),
MARKETPLACE_CURATED_APPLICATIONS.map(
(entry) => entry.universalIdentifier,
),
);
for (const pkg of packages) {
try {
const manifest =
const fetchedManifest =
await this.marketplaceService.fetchManifestFromRegistryCdn(
pkg.name,
pkg.version,
);
if (!manifest) {
if (!fetchedManifest) {
this.logger.debug(`Skipping ${pkg.name}: no manifest found on CDN`);
continue;
}
const universalIdentifier = manifest.application.universalIdentifier;
const universalIdentifier =
fetchedManifest.application.universalIdentifier;
if (curatedIdentifiers.has(universalIdentifier)) {
continue;
}
const isFeatured = curatedIdentifiers.has(universalIdentifier);
const aboutDescription =
fetchedManifest.application.aboutDescription ??
(await this.marketplaceService.fetchReadmeFromRegistryCdn(
pkg.name,
pkg.version,
));
const manifest = aboutDescription
? {
...fetchedManifest,
application: {
...fetchedManifest.application,
aboutDescription,
},
}
: fetchedManifest;
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
@@ -93,7 +88,7 @@ export class MarketplaceCatalogSyncService {
sourcePackage: pkg.name,
latestAvailableVersion: pkg.version ?? null,
isListed: true,
isFeatured: false,
isFeatured,
manifest: manifestWithResolvedUrls,
ownerWorkspaceId: null,
});

View File

@@ -73,6 +73,39 @@ export class MarketplaceService {
}
}
async fetchReadmeFromRegistryCdn(
packageName: string,
version: string,
): Promise<string | null> {
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
const url = buildRegistryCdnUrl({
cdnBaseUrl,
packageName,
version,
filePath: 'README.md',
});
try {
const { data } = await axios.get(url, {
headers: { 'User-Agent': 'Twenty-Marketplace' },
timeout: 5_000,
responseType: 'text',
});
if (!data || data.trim().length === 0) {
return null;
}
return data;
} catch {
this.logger.debug(
`Could not fetch README from CDN for ${packageName}@${version}`,
);
return null;
}
}
async fetchAppsFromRegistry(): Promise<RegistryPackageInfo[]> {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');

View File

@@ -48,6 +48,7 @@ export {
IconBrandGraphql,
IconBrandLinkedin,
IconBrandOpenai,
IconBrandNpm,
IconBrandX,
IconBriefcase,
IconBroadcast,

View File

@@ -126,6 +126,7 @@ export {
IconBrandGraphql,
IconBrandLinkedin,
IconBrandOpenai,
IconBrandNpm,
IconBrandX,
IconBriefcase,
IconBroadcast,