diff --git a/packages/create-twenty-app/package.json b/packages/create-twenty-app/package.json
index 5fc011902b..7fc93acd2c 100644
--- a/packages/create-twenty-app/package.json
+++ b/packages/create-twenty-app/package.json
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
- "version": "0.8.0",
+ "version": "0.9.0-canary.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
diff --git a/packages/twenty-client-sdk/package.json b/packages/twenty-client-sdk/package.json
index 426f8dd47a..869c62ea5c 100644
--- a/packages/twenty-client-sdk/package.json
+++ b/packages/twenty-client-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
- "version": "0.8.0",
+ "version": "0.9.0-canary.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
diff --git a/packages/twenty-docs/developers/extend/apps/building.mdx b/packages/twenty-docs/developers/extend/apps/building.mdx
index 4a5644fbd7..de69582e03 100644
--- a/packages/twenty-docs/developers/extend/apps/building.mdx
+++ b/packages/twenty-docs/developers/extend/apps/building.mdx
@@ -756,7 +756,7 @@ export default defineFrontComponent({
});
```
-After syncing with `yarn twenty dev`, the quick action appears in the top-right corner of the page:
+After syncing with `yarn twenty dev` (or running a one-shot `yarn twenty dev --once`), the quick action appears in the top-right corner of the page:

@@ -1402,7 +1402,7 @@ export default defineFrontComponent({
### How bundling works
-The build step (`yarn twenty dev` or `yarn twenty build`) uses esbuild to produce a single self-contained file per logic function and per front component. All imported packages are inlined into the bundle.
+The build step uses esbuild to produce a single self-contained file per logic function and per front component. All imported packages are inlined into the bundle.
**Logic functions** run in a Node.js environment. Node built-in modules (`fs`, `path`, `crypto`, `http`, etc.) are available and do not need to be installed.
diff --git a/packages/twenty-docs/developers/extend/apps/getting-started.mdx b/packages/twenty-docs/developers/extend/apps/getting-started.mdx
index a2dbabc672..de240b822a 100644
--- a/packages/twenty-docs/developers/extend/apps/getting-started.mdx
+++ b/packages/twenty-docs/developers/extend/apps/getting-started.mdx
@@ -98,6 +98,21 @@ Dev mode is only available on Twenty instances running in development (`NODE_ENV
+#### One-shot sync with `yarn twenty dev --once`
+
+If you do not want a watcher running in the background (for example in a CI pipeline, a git hook, or a scripted workflow), pass the `--once` flag. It runs the same pipeline as `yarn twenty dev` — build manifest, bundle files, upload, sync, regenerate the typed API client — but **exits as soon as the sync completes**:
+
+```bash filename="Terminal"
+yarn twenty dev --once
+```
+
+| Command | Behavior | When to use |
+|---------|----------|-------------|
+| `yarn twenty dev` | Watches your source files and re-syncs on every change. Keeps running until you stop it. | Interactive local development — you want the live status panel and instant feedback loop. |
+| `yarn twenty dev --once` | Performs a single build + sync, then exits with code `0` on success or `1` on failure. | Scripts, CI, pre-commit hooks, AI agents, and any non-interactive workflow. |
+
+Both modes require a Twenty server running in development mode and an authenticated remote — the same prerequisites apply.
+
### See your app in Twenty
Open [http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer) in your browser. Navigate to **Settings > Apps** and select the **Developer** tab. You should see your app listed under **Your Apps**:
diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json
index e7e245e4e2..26d50f52d4 100644
--- a/packages/twenty-sdk/package.json
+++ b/packages/twenty-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
- "version": "0.8.0",
+ "version": "0.9.0-canary.0",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/sdk/index.d.ts",
diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev-once/app-dev-once.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev-once/app-dev-once.integration.spec.ts
new file mode 100644
index 0000000000..bd3b7450d7
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev-once/app-dev-once.integration.spec.ts
@@ -0,0 +1,111 @@
+import { readdir } from 'node:fs/promises';
+import { join } from 'path';
+import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
+
+import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
+import { EXPECTED_MANIFEST } from '@/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest';
+import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util';
+import { appDevOnce, type AppDevOnceResult } from '@/cli/operations/dev-once';
+import { type CommandResult } from '@/cli/types';
+import { pathExists, readJson, remove } from '@/cli/utilities/file/fs-utils';
+
+const OUTPUT_PATH = join(MINIMAL_APP_PATH, OUTPUT_DIR);
+const MANIFEST_PATH = join(OUTPUT_PATH, 'manifest.json');
+
+describe('minimal-app dev-once', () => {
+ let result: CommandResult;
+
+ beforeAll(async () => {
+ // Make sure we are starting from a clean slate so we know the
+ // generated files come from this run, not a previous one.
+ await remove(OUTPUT_PATH);
+
+ result = await appDevOnce({ appPath: MINIMAL_APP_PATH });
+
+ if (!result.success) {
+ throw new Error(
+ `appDevOnce did not succeed: ${result.error.code} - ${result.error.message}`,
+ );
+ }
+ }, 60000);
+
+ describe('result', () => {
+ it('should return success', () => {
+ expect(result.success).toBe(true);
+ });
+
+ it('should report the application display name from the manifest', () => {
+ if (!result.success) {
+ throw new Error('expected success');
+ }
+
+ expect(result.data.applicationDisplayName).toBe(
+ EXPECTED_MANIFEST.application.displayName,
+ );
+ });
+
+ it('should report the application universal identifier', () => {
+ if (!result.success) {
+ throw new Error('expected success');
+ }
+
+ expect(result.data.applicationUniversalIdentifier).toBe(
+ EXPECTED_MANIFEST.application.universalIdentifier,
+ );
+ });
+
+ it('should report a non-zero file count', () => {
+ if (!result.success) {
+ throw new Error('expected success');
+ }
+
+ expect(result.data.fileCount).toBeGreaterThan(0);
+ });
+
+ it('should report the output directory', () => {
+ if (!result.success) {
+ throw new Error('expected success');
+ }
+
+ expect(result.data.outputDir).toBe(OUTPUT_PATH);
+ });
+ });
+
+ describe('manifest', () => {
+ it('should have generated manifest.json', async () => {
+ expect(await pathExists(MANIFEST_PATH)).toBe(true);
+ });
+
+ it('should write the same manifest content as `dev`', async () => {
+ const manifest = normalizeManifestForComparison(
+ await readJson(MANIFEST_PATH),
+ );
+
+ expect(manifest).toEqual(
+ normalizeManifestForComparison(EXPECTED_MANIFEST),
+ );
+ });
+ });
+
+ describe('built files', () => {
+ it('should have built the logic function', async () => {
+ const files = (await readdir(OUTPUT_PATH, { recursive: true })).map(
+ (file) => file.toString(),
+ );
+
+ expect(
+ files.filter((file) => file.includes('.function.')).sort(),
+ ).toEqual(['my.function.mjs', 'my.function.mjs.map']);
+ });
+
+ it('should have built the front component', async () => {
+ const files = (await readdir(OUTPUT_PATH, { recursive: true })).map(
+ (file) => file.toString(),
+ );
+
+ expect(
+ files.filter((file) => file.includes('.front-component.')).sort(),
+ ).toEqual(['my.front-component.mjs', 'my.front-component.mjs.map']);
+ });
+ });
+});
diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts
index 402b506002..1b3001baea 100644
--- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts
+++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts
@@ -29,6 +29,9 @@ const mockApiService = {
}),
syncApplication: vi.fn().mockResolvedValue({ success: true, data: true }),
uploadFile: vi.fn().mockResolvedValue({ success: true, data: true }),
+ getSchema: vi
+ .fn()
+ .mockResolvedValue({ success: true, data: 'mock-core-schema' }),
};
vi.mock('@/cli/utilities/api/api-service', () => ({
@@ -43,6 +46,7 @@ vi.mock('@/cli/utilities/api/api-service', () => ({
createDevelopmentApplication = mockApiService.createDevelopmentApplication;
syncApplication = mockApiService.syncApplication;
uploadFile = mockApiService.uploadFile;
+ getSchema = mockApiService.getSchema;
},
}));
@@ -52,6 +56,12 @@ vi.mock('@/cli/utilities/file/file-uploader', () => ({
},
}));
+vi.mock('@/cli/utilities/client/client-service', () => ({
+ ClientService: class {
+ generateCoreClient = vi.fn().mockResolvedValue(undefined);
+ },
+}));
+
vi.mock('@/cli/utilities/dev/ui/components/dev-ui', () => ({
renderDevUI: vi.fn().mockResolvedValue({ unmount: vi.fn() }),
}));
diff --git a/packages/twenty-sdk/src/cli/commands/app-command.ts b/packages/twenty-sdk/src/cli/commands/app-command.ts
index 216cddb200..327dbbb0b0 100644
--- a/packages/twenty-sdk/src/cli/commands/app-command.ts
+++ b/packages/twenty-sdk/src/cli/commands/app-command.ts
@@ -13,12 +13,14 @@ import { LogicFunctionLogsCommand } from './logs';
import { AppPublishCommand } from './publish';
import { registerRemoteCommands } from './remote';
import { registerServerCommands } from './server';
+import { AppDevOnceCommand } from './dev-once';
import { AppTypecheckCommand } from './typecheck';
import { AppUninstallCommand } from './uninstall';
export const registerCommands = (program: Command): void => {
const buildCommand = new AppBuildCommand();
const devCommand = new AppDevCommand();
+ const devOnceCommand = new AppDevOnceCommand();
const installCommand = new AppInstallCommand();
const publishCommand = new AppPublishCommand();
const typecheckCommand = new AppTypecheckCommand();
@@ -31,14 +33,40 @@ export const registerCommands = (program: Command): void => {
program
.command('dev [appPath]')
- .description('Watch and sync local application changes')
+ .description(
+ 'Build and sync local application changes (watches by default; use --once for a one-shot sync)',
+ )
+ .option(
+ '-w, --watch',
+ 'Watch source files and re-sync on every change (default behavior)',
+ )
+ .option(
+ '-o, --once',
+ 'Build and sync once, then exit (useful for CI, scripts, and pre-commit hooks)',
+ )
.option('-v, --verbose', 'Show detailed logs')
.option('-d, --debug', 'Show detailed logs (alias for --verbose)')
.action(async (appPath, options) => {
- await devCommand.execute({
+ if (options.once && options.watch) {
+ console.error(
+ chalk.red(
+ 'Error: --once and --watch are mutually exclusive. Watch mode is the default.',
+ ),
+ );
+ process.exit(1);
+ }
+
+ const commonOptions = {
appPath: formatPath(appPath),
verbose: options.verbose || options.debug,
- });
+ };
+
+ if (options.once) {
+ await devOnceCommand.execute(commonOptions);
+ return;
+ }
+
+ await devCommand.execute(commonOptions);
});
program
diff --git a/packages/twenty-sdk/src/cli/commands/dev-once.ts b/packages/twenty-sdk/src/cli/commands/dev-once.ts
new file mode 100644
index 0000000000..5d335d4860
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/commands/dev-once.ts
@@ -0,0 +1,38 @@
+import { appDevOnce } from '@/cli/operations/dev-once';
+import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
+import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
+import chalk from 'chalk';
+
+export type AppDevOnceCommandOptions = {
+ appPath?: string;
+ verbose?: boolean;
+};
+
+export class AppDevOnceCommand {
+ async execute(options: AppDevOnceCommandOptions): Promise {
+ const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
+
+ await checkSdkVersionCompatibility(appPath);
+
+ console.log(chalk.blue('Syncing application...'));
+ console.log(chalk.gray(`App path: ${appPath}\n`));
+
+ const result = await appDevOnce({
+ appPath,
+ verbose: options.verbose,
+ onProgress: (message) => console.log(chalk.gray(message)),
+ });
+
+ if (!result.success) {
+ console.error(chalk.red(result.error.message));
+ process.exit(1);
+ }
+
+ console.log(
+ chalk.green(
+ `\n✓ Synced ${result.data.applicationDisplayName} (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
+ ),
+ );
+ console.log(chalk.gray(`Output: ${result.data.outputDir}`));
+ }
+}
diff --git a/packages/twenty-sdk/src/cli/operations/dev-once.ts b/packages/twenty-sdk/src/cli/operations/dev-once.ts
new file mode 100644
index 0000000000..d117795111
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/operations/dev-once.ts
@@ -0,0 +1,273 @@
+import path from 'path';
+import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application';
+
+import { ApiService } from '@/cli/utilities/api/api-service';
+import { buildApplication } from '@/cli/utilities/build/common/build-application';
+import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
+import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
+import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
+import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
+import { ClientService } from '@/cli/utilities/client/client-service';
+import { ConfigService } from '@/cli/utilities/config/config-service';
+import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events';
+import { serializeError } from '@/cli/utilities/error/serialize-error';
+import { FileUploader } from '@/cli/utilities/file/file-uploader';
+import { runSafe } from '@/cli/utilities/run-safe';
+import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
+
+export type AppDevOnceOptions = {
+ appPath: string;
+ verbose?: boolean;
+ onProgress?: (message: string) => void;
+};
+
+export type AppDevOnceResult = {
+ outputDir: string;
+ fileCount: number;
+ applicationDisplayName: string;
+ applicationUniversalIdentifier: string;
+};
+
+const innerAppDevOnce = async (
+ options: AppDevOnceOptions,
+): Promise> => {
+ const { appPath, onProgress, verbose } = options;
+
+ onProgress?.('Checking server...');
+
+ const apiService = new ApiService({ disableInterceptors: true });
+ const validateAuth = await apiService.validateAuth();
+
+ if (!validateAuth.serverUp) {
+ return {
+ success: false,
+ error: {
+ code: APP_ERROR_CODES.SYNC_FAILED,
+ message:
+ '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',
+ },
+ };
+ }
+
+ if (!validateAuth.authValid) {
+ return {
+ success: false,
+ error: {
+ code: APP_ERROR_CODES.SYNC_FAILED,
+ message:
+ 'Authentication failed. Run `yarn twenty remote add --local` to authenticate.',
+ },
+ };
+ }
+
+ onProgress?.('Building manifest...');
+
+ const manifestResult = await buildAndValidateManifest(appPath);
+
+ if (!manifestResult.success) {
+ return {
+ success: false,
+ error: {
+ code: APP_ERROR_CODES.MANIFEST_BUILD_FAILED,
+ message: manifestResult.errors.join('\n'),
+ },
+ };
+ }
+
+ for (const warning of manifestResult.warnings) {
+ onProgress?.(`⚠ ${warning}`);
+ }
+
+ onProgress?.('Building application files...');
+
+ const buildResult = await buildApplication({
+ appPath,
+ manifest: manifestResult.manifest,
+ filePaths: manifestResult.filePaths,
+ });
+
+ onProgress?.('Running typecheck...');
+
+ const typecheckErrors = await runTypecheck(appPath);
+
+ if (typecheckErrors.length > 0) {
+ const errorMessages = typecheckErrors.map(
+ (error) =>
+ `${error.file}(${error.line},${error.column + 1}): ${error.text}`,
+ );
+
+ return {
+ success: false,
+ error: {
+ code: APP_ERROR_CODES.TYPECHECK_FAILED,
+ message: `Typecheck failed:\n${errorMessages.join('\n')}`,
+ },
+ };
+ }
+
+ const manifest: Manifest = manifestUpdateChecksums({
+ manifest: manifestResult.manifest,
+ builtFileInfos: buildResult.builtFileInfos,
+ });
+
+ await writeManifestToOutput(appPath, manifest);
+
+ onProgress?.('Registering application...');
+
+ const configService = new ConfigService();
+ const registrationResult =
+ await apiService.findApplicationRegistrationByUniversalIdentifier(
+ manifest.application.universalIdentifier,
+ );
+
+ if (!registrationResult.success) {
+ return {
+ success: false,
+ error: {
+ code: APP_ERROR_CODES.SYNC_FAILED,
+ message: `Failed to check app registration: ${serializeError(registrationResult.error)}`,
+ },
+ };
+ }
+
+ if (!registrationResult.data) {
+ const createRegistrationResult =
+ await apiService.createApplicationRegistration({
+ name: manifest.application.displayName,
+ universalIdentifier: manifest.application.universalIdentifier,
+ });
+
+ if (!createRegistrationResult.success) {
+ return {
+ success: false,
+ error: {
+ code: APP_ERROR_CODES.SYNC_FAILED,
+ message: `Failed to create app registration: ${serializeError(createRegistrationResult.error)}`,
+ },
+ };
+ }
+
+ await configService.setConfig({
+ oauthClientId:
+ createRegistrationResult.data.applicationRegistration.oAuthClientId,
+ });
+ }
+
+ const createDevAppResult = await apiService.createDevelopmentApplication({
+ universalIdentifier: manifest.application.universalIdentifier,
+ name: manifest.application.displayName,
+ });
+
+ if (!createDevAppResult.success) {
+ return {
+ success: false,
+ error: {
+ code: APP_ERROR_CODES.SYNC_FAILED,
+ message: `Failed to create development application: ${serializeError(createDevAppResult.error)}`,
+ },
+ };
+ }
+
+ onProgress?.(
+ `Uploading ${buildResult.builtFileInfos.size} file${buildResult.builtFileInfos.size === 1 ? '' : 's'}...`,
+ );
+
+ const fileUploader = new FileUploader({
+ appPath,
+ applicationUniversalIdentifier: manifest.application.universalIdentifier,
+ });
+
+ const uploadErrors: string[] = [];
+
+ const uploadPromises = Array.from(buildResult.builtFileInfos.values()).map(
+ async (builtFileInfo) => {
+ if (verbose) {
+ onProgress?.(`Uploading ${builtFileInfo.builtPath}`);
+ }
+
+ const result = await fileUploader.uploadFile({
+ builtPath: builtFileInfo.builtPath,
+ fileFolder: builtFileInfo.fileFolder,
+ });
+
+ if (!result.success) {
+ uploadErrors.push(
+ `Failed to upload ${builtFileInfo.builtPath}: ${serializeError(result.error)}`,
+ );
+ }
+ },
+ );
+
+ await Promise.all(uploadPromises);
+
+ if (uploadErrors.length > 0) {
+ return {
+ success: false,
+ error: {
+ code: APP_ERROR_CODES.SYNC_FAILED,
+ message: uploadErrors.join('\n'),
+ },
+ };
+ }
+
+ onProgress?.('Syncing manifest...');
+
+ const syncResult = await apiService.syncApplication(manifest);
+
+ if (!syncResult.success) {
+ const errorEvents = verbose
+ ? null
+ : formatSyncErrorEvents(syncResult.error);
+
+ const message = errorEvents
+ ? errorEvents.map((event) => event.message).join('\n')
+ : `Sync failed with error: ${serializeError(syncResult.error)}`;
+
+ return {
+ success: false,
+ error: {
+ code: APP_ERROR_CODES.SYNC_FAILED,
+ message,
+ },
+ };
+ }
+
+ onProgress?.('Generating API client...');
+
+ try {
+ const config = await configService.getConfig();
+ const clientService = new ClientService();
+
+ await clientService.generateCoreClient({
+ appPath,
+ authToken: config.accessToken,
+ });
+ } catch (error) {
+ return {
+ success: false,
+ error: {
+ code: APP_ERROR_CODES.SYNC_FAILED,
+ message: `Failed to generate API client: ${serializeError(error)}`,
+ },
+ };
+ }
+
+ return {
+ success: true,
+ data: {
+ outputDir: path.join(appPath, OUTPUT_DIR),
+ fileCount: buildResult.builtFileInfos.size,
+ applicationDisplayName: manifest.application.displayName,
+ applicationUniversalIdentifier: manifest.application.universalIdentifier,
+ },
+ };
+};
+
+export const appDevOnce = (
+ options: AppDevOnceOptions,
+): Promise> =>
+ runSafe(() => innerAppDevOnce(options), APP_ERROR_CODES.SYNC_FAILED);
diff --git a/packages/twenty-sdk/src/cli/operations/index.ts b/packages/twenty-sdk/src/cli/operations/index.ts
index b69fb04af5..6507282ef9 100644
--- a/packages/twenty-sdk/src/cli/operations/index.ts
+++ b/packages/twenty-sdk/src/cli/operations/index.ts
@@ -11,6 +11,8 @@ export { appBuild } from './build';
export type { AppBuildOptions, AppBuildResult } from './build';
export { appDeploy } from './deploy';
export type { AppDeployOptions, AppDeployResult } from './deploy';
+export { appDevOnce } from './dev-once';
+export type { AppDevOnceOptions, AppDevOnceResult } from './dev-once';
export { appInstall } from './install';
export type { AppInstallOptions } from './install';
export { appPublish } from './publish';