Fix app:dev CLI by removing deleted createOneApplication mutation (#18460)

## Summary
- The `createOneApplication` GraphQL mutation was removed from the
server during the application architecture refactor (#18432), but the
SDK CLI (`app:dev`, `app:build --sync`) still called it, causing
failures.
- Simplified the SDK to use `syncApplication` (which now internally
creates the `ApplicationEntity` via `ensureApplicationExists`) instead
of a separate create step.
- On first run (clean install), the orchestrator now runs an initial
sync before initializing the file uploader, so file uploads can proceed
(they require the `ApplicationEntity` to exist).

## Test plan
- [x] Typecheck passes for both `twenty-sdk` and `twenty-server`
- [x] `app:dev` tested locally with existing app (finds app, uploads,
syncs)
- [x] `app:dev` tested locally after `app:uninstall` (creates app via
sync, uploads, syncs)
- [x] SDK unit tests pass (23/26 files, 3 pre-existing failures
unrelated)

Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-06 18:37:54 +01:00
committed by GitHub
parent 2c69102f15
commit 66d93c4d28
53 changed files with 1837 additions and 2170 deletions
@@ -1,7 +1,7 @@
import { resolve } from 'path';
import { vi } from 'vitest';
import { appBuild } from '@/cli/public-operations/app-build';
import { appGenerateClient } from '@/cli/public-operations/app-generate-client';
import { appUninstall } from '@/cli/public-operations/app-uninstall';
import { functionExecute } from '@/cli/public-operations/function-execute';
import { ADD_NUMBERS_UNIVERSAL_IDENTIFIER } from '../src/logic-functions/add-numbers.function';
@@ -10,15 +10,15 @@ const APP_PATH = resolve(__dirname, '../');
describe('functionExecute E2E', () => {
beforeAll(async () => {
const buildResult = await appBuild({ appPath: APP_PATH });
const generateResult = await appGenerateClient({ appPath: APP_PATH });
if (!buildResult.success) {
if (!generateResult.success) {
throw new Error(
`appBuild failed: ${buildResult.error.code} ${buildResult.error.message}`,
`appGenerateClient failed: ${generateResult.error.code} ${generateResult.error.message}`,
);
}
// Although appBuild uploads files before syncing the manifest, the server
// Although appGenerateClient uploads files before syncing the manifest, the server
// may need a moment to make them readable by the execution engine.
// Retry a dummy execution until the handler file becomes available.
await vi.waitFor(
@@ -7,10 +7,13 @@ export const defineEntitiesTests = (appPath: string): void => {
describe('logicFunctions', () => {
it('should have built logicFunctions preserving source path structure', async () => {
const files = await readdir(outputDir, { recursive: true });
const sortedFiles = files.map((f) => f.toString()).sort();
// api-client is generated post-sync and depends on server schema availability
const sortedFiles = files
.map((f) => f.toString())
.filter((f) => !f.startsWith('api-client'))
.sort();
expect(sortedFiles).toEqual([
'api-client',
'manifest.json',
'package.json',
'public',
@@ -19,7 +19,7 @@ export const runAppDevInProcess = async (options: {
const command = new AppDevCommand();
await command.execute({ appPath });
await command.execute({ appPath, headless: true });
const startTime = Date.now();
@@ -2,10 +2,6 @@ import { vi } from 'vitest';
const mockApiService = {
validateAuth: vi.fn().mockResolvedValue({ authValid: true, serverUp: true }),
findOneApplication: vi.fn().mockResolvedValue({ success: true, data: null }),
createApplication: vi
.fn()
.mockResolvedValue({ success: true, data: { id: 'mock-id' } }),
generateApplicationToken: vi.fn().mockResolvedValue({
success: true,
data: {
@@ -39,6 +35,10 @@ const mockApiService = {
clientSecret: 'mock-client-secret',
},
}),
createDevelopmentApplication: vi.fn().mockResolvedValue({
success: true,
data: { id: 'mock-app-id', universalIdentifier: 'mock-uid' },
}),
syncApplication: vi.fn().mockResolvedValue({ success: true, data: true }),
uploadFile: vi.fn().mockResolvedValue({ success: true, data: true }),
};
@@ -46,14 +46,13 @@ const mockApiService = {
vi.mock('@/cli/utilities/api/api-service', () => ({
ApiService: class {
validateAuth = mockApiService.validateAuth;
findOneApplication = mockApiService.findOneApplication;
createApplication = mockApiService.createApplication;
generateApplicationToken = mockApiService.generateApplicationToken;
renewApplicationToken = mockApiService.renewApplicationToken;
findApplicationRegistrationByUniversalIdentifier =
mockApiService.findApplicationRegistrationByUniversalIdentifier;
createApplicationRegistration =
mockApiService.createApplicationRegistration;
createDevelopmentApplication = mockApiService.createDevelopmentApplication;
syncApplication = mockApiService.syncApplication;
uploadFile = mockApiService.uploadFile;
},
@@ -2,9 +2,9 @@ import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { AppBuildCommand } from './app/app-build';
import { AppGenerateClientCommand } from './app/app-generate-client';
import { AppDevCommand } from './app/app-dev';
import { AppPackCommand } from './app/app-pack';
import { AppPushCommand } from './app/app-push';
import { AppPublishCommand } from './app/app-publish';
import { AppTypecheckCommand } from './app/app-typecheck';
import { AppUninstallCommand } from './app/app-uninstall';
import { AuthListCommand } from './auth/auth-list';
@@ -64,24 +64,15 @@ export const registerCommands = (program: Command): void => {
// App commands
const buildCommand = new AppBuildCommand();
const generateClientCommand = new AppGenerateClientCommand();
const devCommand = new AppDevCommand();
const packCommand = new AppPackCommand();
const pushCommand = new AppPushCommand();
const publishCommand = new AppPublishCommand();
const typecheckCommand = new AppTypecheckCommand();
const uninstallCommand = new AppUninstallCommand();
const addCommand = new EntityAddCommand();
const logsCommand = new LogicFunctionLogsCommand();
const executeCommand = new LogicFunctionExecuteCommand();
program
.command('app:build [appPath]')
.description('Build the application without watching for changes')
.action(async (appPath) => {
await buildCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('app:dev [appPath]')
.description('Watch and sync local application changes')
@@ -91,6 +82,47 @@ export const registerCommands = (program: Command): void => {
});
});
program
.command('app:generate-client [appPath]')
.description(
'Build, sync to local server, and generate the typed API client',
)
.action(async (appPath) => {
await generateClientCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('app:build [appPath]')
.description(
'Build the application into .twenty/output/ (no server needed)',
)
.option('--tarball', 'Also pack into a .tgz tarball')
.action(async (appPath, options) => {
await buildCommand.execute({
appPath: formatPath(appPath),
tarball: options.tarball,
});
});
program
.command('app:publish [appPath]')
.description(
'Build and publish to npm, or to a Twenty server with --server',
)
.option('--server <url>', 'Publish to a Twenty server instead of npm')
.option('--token <token>', 'Auth token for the server')
.option('--tag <tag>', 'npm dist-tag (e.g. beta, next)')
.action(async (appPath, options) => {
await publishCommand.execute({
appPath: formatPath(appPath),
server: options.server,
token: options.token,
tag: options.tag,
});
});
program
.command('app:typecheck [appPath]')
.description('Run TypeScript type checking on the application')
@@ -116,28 +148,6 @@ export const registerCommands = (program: Command): void => {
}
});
program
.command('app:pack [appPath]')
.description('Build and pack the application into a .tgz tarball')
.action(async (appPath) => {
await packCommand.execute({ appPath: formatPath(appPath) });
});
program
.command('app:push [appPath]')
.description(
'Build, upload, and install a local application on a Twenty server (for air-gapped/dev deployments)',
)
.option('--server <url>', 'Twenty server URL')
.option('--token <token>', 'Auth token for the server')
.action(async (appPath, options) => {
await pushCommand.execute({
appPath: formatPath(appPath),
server: options.server,
token: options.token,
});
});
program
.command('entity:add [entityType]')
.option('--path <path>', 'Path in which the entity should be created.')
@@ -4,18 +4,20 @@ import chalk from 'chalk';
export type AppBuildCommandOptions = {
appPath?: string;
tarball?: boolean;
};
export class AppBuildCommand {
async execute(options: AppBuildCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Building and syncing application...'));
console.log(chalk.blue('Building application...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const result = await appBuild({
appPath,
tarball: options.tarball,
onProgress: (message) => console.log(chalk.gray(message)),
});
@@ -26,8 +28,13 @@ export class AppBuildCommand {
console.log(
chalk.green(
`✓ Build and sync succeeded (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
`✓ Build succeeded (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
),
);
console.log(chalk.gray(`Output: ${result.data.outputDir}`));
if (result.data.tarballPath) {
console.log(chalk.gray(`Tarball: ${result.data.tarballPath}`));
}
}
}
@@ -6,6 +6,7 @@ import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orc
export type AppDevOptions = {
appPath?: string;
headless?: boolean;
};
export class AppDevCommand {
@@ -29,20 +30,25 @@ export class AppDevCommand {
frontendUrl: process.env.FRONTEND_URL,
});
const uiStateManager = new DevUiStateManager(orchestratorState);
if (!options.headless) {
const uiStateManager = new DevUiStateManager(orchestratorState);
orchestratorState.onChange = () => uiStateManager.notify();
orchestratorState.onChange = () => uiStateManager.notify();
const { unmount } = await renderDevUI(uiStateManager);
const { unmount } = await renderDevUI(uiStateManager);
this.unmountUI = unmount;
this.unmountUI = unmount;
}
this.orchestrator = new DevModeOrchestrator({
state: orchestratorState,
});
await this.orchestrator.start();
this.setupGracefulShutdown();
if (!options.headless) {
this.setupGracefulShutdown();
}
}
private setupGracefulShutdown(): void {
@@ -0,0 +1,33 @@
import { appGenerateClient } from '@/cli/public-operations/app-generate-client';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
export type AppGenerateClientCommandOptions = {
appPath?: string;
};
export class AppGenerateClientCommand {
async execute(options: AppGenerateClientCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Generating API client...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const result = await appGenerateClient({
appPath,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(
chalk.green(
`✓ Client generated (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
),
);
}
}
@@ -1,30 +0,0 @@
import { appPack } from '@/cli/public-operations/app-pack';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
export type AppPackCommandOptions = {
appPath?: string;
};
export class AppPackCommand {
async execute(options: AppPackCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Building and packing application...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const result = await appPack({
appPath,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(chalk.green('✓ Application packed successfully'));
console.log(chalk.gray(`Tarball: ${result.data.tarballPath}`));
}
}
@@ -0,0 +1,48 @@
import { appPublish } from '@/cli/public-operations/app-publish';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
export type AppPublishCommandOptions = {
appPath?: string;
server?: string;
token?: string;
tag?: string;
};
export class AppPublishCommand {
async execute(options: AppPublishCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
const isServerPublish = !!options.server;
console.log(
chalk.blue(
isServerPublish
? `Publishing to server ${options.server}...`
: 'Publishing to npm...',
),
);
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const result = await appPublish({
appPath,
server: options.server,
token: options.token,
npmTag: options.tag,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
if (result.data.target === 'npm') {
console.log(chalk.green('✓ Published to npm successfully'));
} else {
console.log(
chalk.green('✓ Published to server and installed successfully'),
);
}
}
}
@@ -1,62 +0,0 @@
import { appPack } from '@/cli/public-operations/app-pack';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { ApiService } from '@/cli/utilities/api/api-service';
import chalk from 'chalk';
import fs from 'fs';
export type AppPushCommandOptions = {
appPath?: string;
server?: string;
token?: string;
};
export class AppPushCommand {
async execute(options: AppPushCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Building, packing, and pushing application...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const packResult = await appPack({
appPath,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!packResult.success) {
console.error(chalk.red(packResult.error.message));
process.exit(1);
}
const { tarballPath } = packResult.data;
console.log(chalk.gray(`Uploading ${tarballPath}...`));
const tarballBuffer = fs.readFileSync(tarballPath);
const apiService = new ApiService({
serverUrl: options.server,
token: options.token,
});
const uploadResult = await apiService.uploadAppTarball({ tarballBuffer });
if (!uploadResult.success) {
console.error(chalk.red(`Upload failed: ${uploadResult.error}`));
process.exit(1);
}
console.log(chalk.gray('Installing application...'));
const installResult = await apiService.installTarballApp({
universalIdentifier: uploadResult.data.universalIdentifier,
});
if (!installResult.success) {
console.error(chalk.red(`Install failed: ${installResult.error}`));
process.exit(1);
}
console.log(chalk.green('✓ Application pushed and installed successfully'));
}
}
@@ -1,18 +1,25 @@
import { execSync } from 'child_process';
import path from 'path';
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { synchronizeBuiltApplication } from '@/cli/utilities/build/common/synchronize-built-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 { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from './types';
export type AppBuildOptions = {
appPath: string;
tarball?: boolean;
onProgress?: (message: string) => void;
};
export type AppBuildResult = {
outputDir: string;
fileCount: number;
tarballPath?: string;
};
const innerAppBuild = async (
@@ -39,33 +46,27 @@ const innerAppBuild = async (
for (const warning of manifestResult.warnings) {
onProgress?.(`${warning}`);
}
const clientService = new ClientService();
await clientService.ensureGeneratedClientStub({ appPath });
onProgress?.('Building application files...');
const firstBuildResult = await buildApplication({
const buildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Syncing application schema...');
onProgress?.('Updating manifest checksums...');
const firstSyncResult = await synchronizeBuiltApplication({
appPath,
const updatedManifest = manifestUpdateChecksums({
manifest,
builtFileInfos: firstBuildResult.builtFileInfos,
builtFileInfos: buildResult.builtFileInfos,
});
if (!firstSyncResult.success) {
return firstSyncResult;
}
onProgress?.('Generating API client...');
await clientService.generate({ appPath });
await writeManifestToOutput(appPath, updatedManifest);
onProgress?.('Running typecheck...');
@@ -86,35 +87,30 @@ const innerAppBuild = async (
};
}
onProgress?.('Rebuilding with generated client...');
const outputDir = path.join(appPath, '.twenty', 'output');
const finalBuildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
const result: AppBuildResult = {
outputDir,
fileCount: buildResult.builtFileInfos.size,
};
onProgress?.('Syncing built files...');
if (options.tarball) {
onProgress?.('Packing tarball...');
const finalSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: finalBuildResult.builtFileInfos,
});
const packOutput = execSync('npm pack --pack-destination .', {
cwd: outputDir,
encoding: 'utf-8',
}).trim();
if (!finalSyncResult.success) {
return finalSyncResult;
const tarballName = packOutput.split('\n').pop()!;
result.tarballPath = path.join(outputDir, tarballName);
}
return {
success: true,
data: {
fileCount: finalBuildResult.builtFileInfos.size,
},
};
return { success: true, data: result };
};
export const appBuild = (
options: AppBuildOptions,
): Promise<CommandResult<AppBuildResult>> =>
runSafe(() => innerAppBuild(options), APP_ERROR_CODES.SYNC_FAILED);
runSafe(() => innerAppBuild(options), APP_ERROR_CODES.BUILD_FAILED);
@@ -0,0 +1,100 @@
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { synchronizeBuiltApplication } from '@/cli/utilities/build/common/synchronize-built-application';
import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
import { ClientService } from '@/cli/utilities/client/client-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from './types';
export type AppGenerateClientOptions = {
appPath: string;
onProgress?: (message: string) => void;
};
export type AppGenerateClientResult = {
fileCount: number;
};
const innerAppGenerateClient = async (
options: AppGenerateClientOptions,
): Promise<CommandResult<AppGenerateClientResult>> => {
const { appPath, onProgress } = options;
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'),
},
};
}
const { manifest, filePaths } = manifestResult;
for (const warning of manifestResult.warnings) {
onProgress?.(`${warning}`);
}
const clientService = new ClientService();
await clientService.ensureGeneratedClientStub({ appPath });
onProgress?.('Building application files...');
const buildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Syncing application schema...');
const syncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: buildResult.builtFileInfos,
});
if (!syncResult.success) {
return syncResult;
}
onProgress?.('Generating API client...');
await clientService.generate({ appPath });
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')}`,
},
};
}
return {
success: true,
data: {
fileCount: buildResult.builtFileInfos.size,
},
};
};
export const appGenerateClient = (
options: AppGenerateClientOptions,
): Promise<CommandResult<AppGenerateClientResult>> =>
runSafe(() => innerAppGenerateClient(options), APP_ERROR_CODES.SYNC_FAILED);
@@ -1,39 +0,0 @@
import { execSync } from 'child_process';
import path from 'path';
import { appBuild, type AppBuildOptions } from './app-build';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from './types';
export type AppPackResult = {
tarballPath: string;
};
const innerAppPack = async (
options: AppBuildOptions,
): Promise<CommandResult<AppPackResult>> => {
const buildResult = await appBuild(options);
if (!buildResult.success) {
return buildResult;
}
options.onProgress?.('Packing tarball...');
const outputDir = path.join(options.appPath, '.twenty', 'output');
const packOutput = execSync('npm pack --pack-destination .', {
cwd: outputDir,
encoding: 'utf-8',
}).trim();
const tarballName = packOutput.split('\n').pop()!;
const tarballPath = path.join(outputDir, tarballName);
return { success: true, data: { tarballPath } };
};
export const appPack = (
options: AppBuildOptions,
): Promise<CommandResult<AppPackResult>> =>
runSafe(() => innerAppPack(options), APP_ERROR_CODES.SYNC_FAILED);
@@ -0,0 +1,146 @@
import { execSync } from 'child_process';
import fs from 'fs';
import { ApiService } from '@/cli/utilities/api/api-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { appBuild } from './app-build';
import { APP_ERROR_CODES, type CommandResult } from './types';
export type AppPublishOptions = {
appPath: string;
server?: string;
token?: string;
npmTag?: string;
onProgress?: (message: string) => void;
};
export type AppPublishResult = {
target: 'npm' | 'server';
universalIdentifier?: string;
};
const innerAppPublish = async (
options: AppPublishOptions,
): Promise<CommandResult<AppPublishResult>> => {
const { appPath, onProgress } = options;
const isServerPublish = !!options.server;
const buildResult = await appBuild({
appPath,
tarball: isServerPublish,
onProgress,
});
if (!buildResult.success) {
return buildResult;
}
if (isServerPublish) {
return publishToServer({
tarballPath: buildResult.data.tarballPath!,
server: options.server!,
token: options.token,
onProgress,
});
}
return publishToNpm({
outputDir: buildResult.data.outputDir,
npmTag: options.npmTag,
onProgress,
});
};
const publishToNpm = async ({
outputDir,
npmTag,
onProgress,
}: {
outputDir: string;
npmTag?: string;
onProgress?: (message: string) => void;
}): Promise<CommandResult<AppPublishResult>> => {
onProgress?.('Publishing to npm...');
const tagArg = npmTag ? ` --tag ${npmTag}` : '';
try {
execSync(`npm publish${tagArg}`, {
cwd: outputDir,
stdio: 'inherit',
});
} catch {
return {
success: false,
error: {
code: APP_ERROR_CODES.PUBLISH_FAILED,
message:
'npm publish failed. Make sure you are logged in (`npm login`) and the package name is available.',
},
};
}
return { success: true, data: { target: 'npm' } };
};
const publishToServer = async ({
tarballPath,
server,
token,
onProgress,
}: {
tarballPath: string;
server: string;
token?: string;
onProgress?: (message: string) => void;
}): Promise<CommandResult<AppPublishResult>> => {
onProgress?.(`Uploading ${tarballPath}...`);
const tarballBuffer = fs.readFileSync(tarballPath);
const apiService = new ApiService({
serverUrl: server,
token,
});
const uploadResult = await apiService.uploadAppTarball({ tarballBuffer });
if (!uploadResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.PUBLISH_FAILED,
message: `Upload failed: ${uploadResult.error}`,
},
};
}
onProgress?.('Installing application...');
const installResult = await apiService.installTarballApp({
universalIdentifier: uploadResult.data.universalIdentifier,
});
if (!installResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.PUBLISH_FAILED,
message: `Install failed: ${installResult.error}`,
},
};
}
return {
success: true,
data: {
target: 'server',
universalIdentifier: uploadResult.data.universalIdentifier,
},
};
};
export const appPublish = (
options: AppPublishOptions,
): Promise<CommandResult<AppPublishResult>> =>
runSafe(() => innerAppPublish(options), APP_ERROR_CODES.PUBLISH_FAILED);
@@ -25,7 +25,7 @@ const innerAppUninstall = async (
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message:
'Manifest not found. Run `app:build` or `app:dev` to generate it first.',
'Manifest not found. Run `app:build`, `app:generate-client`, or `app:dev` first.',
},
};
}
@@ -62,7 +62,7 @@ const innerFunctionExecute = async (
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message:
'Manifest not found. Run `app:build` or `app:dev` to generate it first.',
'Manifest not found. Run `app:build`, `app:generate-client`, or `app:dev` first.',
},
};
}
@@ -7,8 +7,13 @@ export type { AuthLogoutOptions } from './auth-logout';
// App
export { appBuild } from './app-build';
export type { AppBuildOptions, AppBuildResult } from './app-build';
export { appPack } from './app-pack';
export type { AppPackResult } from './app-pack';
export { appGenerateClient } from './app-generate-client';
export type {
AppGenerateClientOptions,
AppGenerateClientResult,
} from './app-generate-client';
export { appPublish } from './app-publish';
export type { AppPublishOptions, AppPublishResult } from './app-publish';
export { appUninstall } from './app-uninstall';
export type { AppUninstallOptions } from './app-uninstall';
@@ -17,6 +17,8 @@ export const AUTH_ERROR_CODES = {
export const APP_ERROR_CODES = {
MANIFEST_NOT_FOUND: 'MANIFEST_NOT_FOUND',
MANIFEST_BUILD_FAILED: 'MANIFEST_BUILD_FAILED',
BUILD_FAILED: 'BUILD_FAILED',
PUBLISH_FAILED: 'PUBLISH_FAILED',
UNINSTALL_FAILED: 'UNINSTALL_FAILED',
SYNC_FAILED: 'SYNC_FAILED',
TYPECHECK_FAILED: 'TYPECHECK_FAILED',
@@ -101,61 +101,6 @@ export class ApiService {
}
}
async findOneApplication(
universalIdentifier: string,
): Promise<ApiResponse<{ id: string; universalIdentifier: string } | null>> {
try {
const query = `
query FindOneApplication($universalIdentifier: UUID!) {
findOneApplication(universalIdentifier: $universalIdentifier) {
id
universalIdentifier
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
const isNotFound = response.data.errors.some(
(error: { extensions?: { code?: string } }) =>
error.extensions?.code === 'NOT_FOUND',
);
if (isNotFound) {
return { success: true, data: null };
}
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.findOneApplication,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async generateApplicationToken(applicationId: string): Promise<
ApiResponse<{
applicationAccessToken: { token: string; expiresAt: string };
@@ -383,40 +328,25 @@ export class ApiService {
}
}
async createApplication(
manifest: Manifest,
options?: { applicationRegistrationId?: string },
): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
async createDevelopmentApplication(input: {
universalIdentifier: string;
name: string;
}): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
try {
const mutation = `
mutation CreateOneApplication($input: CreateApplicationInput!) {
createOneApplication(input: $input) {
mutation CreateDevelopmentApplication($universalIdentifier: String!, $name: String!) {
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
id
universalIdentifier
}
}
`;
const input: Record<string, string> = {
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
version: '0.0.1',
sourcePath: 'cli-sync',
};
if (options?.applicationRegistrationId) {
input.applicationRegistrationId = options.applicationRegistrationId;
}
const variables = {
input,
};
const response: AxiosResponse = await this.client.post(
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables,
variables: input,
},
{
headers: {
@@ -435,8 +365,7 @@ export class ApiService {
return {
success: true,
data: response.data.data.createOneApplication,
message: `Successfully create application: ${manifest.application.displayName}`,
data: response.data.data.createDevelopmentApplication,
};
} catch (error) {
return {
@@ -1,80 +0,0 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { type Manifest } from 'twenty-shared/application';
export type EnsureApplicationSuccess = {
success: true;
applicationId: string;
universalIdentifier: string;
created: boolean;
};
export type EnsureApplicationFailure = {
success: false;
error: string;
};
export type EnsureApplicationResult =
| EnsureApplicationSuccess
| EnsureApplicationFailure;
export const findOrCreateApplication = async ({
apiService,
manifest,
applicationRegistrationId,
}: {
apiService: ApiService;
manifest: Manifest;
applicationRegistrationId?: string;
}): Promise<EnsureApplicationResult> => {
const universalIdentifier = manifest.application.universalIdentifier;
const findResult = await apiService.findOneApplication(universalIdentifier);
if (!findResult.success) {
return {
success: false,
error: `Failed to resolve application: ${serializeError(findResult.error)}`,
};
}
if (findResult.data) {
return {
success: true,
applicationId: findResult.data.id,
universalIdentifier: findResult.data.universalIdentifier,
created: false,
};
}
let registrationId = applicationRegistrationId;
if (!registrationId) {
const registerResult =
await apiService.findApplicationRegistrationByUniversalIdentifier(
universalIdentifier,
);
if (registerResult.success && registerResult.data) {
registrationId = registerResult.data.id;
}
}
const createResult = await apiService.createApplication(manifest, {
applicationRegistrationId: registrationId,
});
if (!createResult.success) {
return {
success: false,
error: `Failed to create application: ${serializeError(createResult.error)}`,
};
}
return {
success: true,
applicationId: createResult.data!.id,
universalIdentifier: createResult.data!.universalIdentifier,
created: true,
};
};
@@ -3,7 +3,6 @@ import {
type CommandResult,
} from '@/cli/public-operations/types';
import { ApiService } from '@/cli/utilities/api/api-service';
import { findOrCreateApplication } from '@/cli/utilities/application/find-or-create-application';
import { type BuiltFileInfo } from '@/cli/utilities/build/common/build-application';
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
@@ -16,6 +15,62 @@ export type AppSyncOptions = {
workspace?: string;
};
const ensureApplicationRegistrationExists = async (
apiService: ApiService,
manifest: Manifest,
): Promise<CommandResult> => {
const universalIdentifier = manifest.application.universalIdentifier;
const findResult =
await apiService.findApplicationRegistrationByUniversalIdentifier(
universalIdentifier,
);
if (findResult.success && findResult.data) {
return { success: true, data: undefined };
}
const createResult = await apiService.createApplicationRegistration({
name: manifest.application.displayName,
description: manifest.application.description,
universalIdentifier,
});
if (!createResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to create application registration: ${serializeError(createResult.error)}`,
},
};
}
return { success: true, data: undefined };
};
const ensureDevelopmentApplicationExists = async (
apiService: ApiService,
manifest: Manifest,
): Promise<CommandResult> => {
const result = await apiService.createDevelopmentApplication({
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
});
if (!result.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to create development application: ${serializeError(result.error)}`,
},
};
}
return { success: true, data: undefined };
};
export const synchronizeBuiltApplication = async ({
appPath,
manifest,
@@ -26,37 +81,40 @@ export const synchronizeBuiltApplication = async ({
builtFileInfos: Map<string, BuiltFileInfo>;
}): Promise<CommandResult> => {
const apiService = new ApiService();
const universalIdentifier = manifest.application.universalIdentifier;
const ensureResult = await findOrCreateApplication({
const registrationResult = await ensureApplicationRegistrationExists(
apiService,
manifest,
});
);
if (!ensureResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: ensureResult.error,
},
};
if (!registrationResult.success) {
return registrationResult;
}
const universalIdentifier = manifest.application.universalIdentifier;
const applicationResult = await ensureDevelopmentApplicationExists(
apiService,
manifest,
);
if (!applicationResult.success) {
return applicationResult;
}
const fileUploader = new FileUploader({
applicationUniversalIdentifier: universalIdentifier,
appPath,
});
const uploadPromises = Array.from(builtFileInfos.values()).map((fileInfo) =>
fileUploader.uploadFile({
builtPath: fileInfo.builtPath,
fileFolder: fileInfo.fileFolder,
}),
const uploadResults = await Promise.all(
[...builtFileInfos.values()].map((fileInfo) =>
fileUploader.uploadFile({
builtPath: fileInfo.builtPath,
fileFolder: fileInfo.fileFolder,
}),
),
);
const uploadResults = await Promise.all(uploadPromises);
const failedUploads = uploadResults.filter((result) => !result.success);
if (failedUploads.length > 0) {
@@ -1,7 +1,6 @@
import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
import { type BuildManifestOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/build-manifest-orchestrator-step';
import { type CheckServerOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step';
import { type ResolveApplicationOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/resolve-application-orchestrator-step';
import { type StartWatchersOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
import { type SyncApplicationOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
import { type UploadFilesOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
@@ -96,7 +95,10 @@ export class OrchestratorState {
steps: {
checkServer: OrchestratorStepState<CheckServerOrchestratorStepOutput>;
ensureValidTokens: OrchestratorStepState<Record<string, never>>;
resolveApplication: OrchestratorStepState<ResolveApplicationOrchestratorStepOutput>;
resolveApplication: OrchestratorStepState<{
applicationId: string | null;
universalIdentifier: string | null;
}>;
buildManifest: OrchestratorStepState<BuildManifestOrchestratorStepOutput>;
uploadFiles: OrchestratorStepState<UploadFilesOrchestratorStepOutput>;
generateApiClient: OrchestratorStepState<Record<string, never>>;
@@ -7,7 +7,6 @@ import { CheckServerOrchestratorStep } from '@/cli/utilities/dev/orchestrator/st
import { EnsureValidTokensOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step';
import { GenerateApiClientOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step';
import { RegisterAppOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step';
import { ResolveApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/resolve-application-orchestrator-step';
import {
StartWatchersOrchestratorStep,
type FileBuiltEvent,
@@ -30,13 +29,13 @@ export class DevModeOrchestrator {
private syncTimer: NodeJS.Timeout | null = null;
private serverCheckInterval: NodeJS.Timeout | null = null;
private apiService: ApiService;
private clientService: ClientService;
private skipTypecheck = true;
private checkServerStep: CheckServerOrchestratorStep;
private ensureValidTokensStep: EnsureValidTokensOrchestratorStep;
private buildManifestStep: BuildManifestOrchestratorStep;
private registerAppStep: RegisterAppOrchestratorStep;
private resolveApplicationStep: ResolveApplicationOrchestratorStep;
private uploadFilesStep: UploadFilesOrchestratorStep;
private generateApiClientStep: GenerateApiClientOrchestratorStep;
private syncApplicationStep: SyncApplicationOrchestratorStep;
@@ -46,7 +45,8 @@ export class DevModeOrchestrator {
this.debounceMs = options.debounceMs ?? 200;
this.state = options.state;
const apiService = new ApiService({ disableInterceptors: true });
this.apiService = new ApiService({ disableInterceptors: true });
const apiService = this.apiService;
const configService = new ConfigService();
this.clientService = new ClientService();
const stepDeps = { state: this.state, notify: () => this.state.notify() };
@@ -66,10 +66,6 @@ export class DevModeOrchestrator {
apiService,
configService,
});
this.resolveApplicationStep = new ResolveApplicationOrchestratorStep({
...stepDeps,
apiService,
});
this.uploadFilesStep = new UploadFilesOrchestratorStep(stepDeps);
this.generateApiClientStep = new GenerateApiClientOrchestratorStep({
...stepDeps,
@@ -223,20 +219,37 @@ export class DevModeOrchestrator {
}
private async initializePipeline(manifest: Manifest): Promise<boolean> {
const registerResult = await this.registerAppStep.execute({ manifest });
await this.registerAppStep.execute({ manifest });
const resolveResult = await this.resolveApplicationStep.execute({
manifest,
applicationRegistrationId:
registerResult.applicationRegistrationId ?? undefined,
const createResult = await this.apiService.createDevelopmentApplication({
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
});
if (!resolveResult.applicationId) {
if (!createResult.success || !createResult.data) {
this.state.applyStepEvents([
{
message: 'Failed to create development application',
status: 'error',
},
]);
this.state.updatePipeline({ status: 'error' });
return false;
}
this.state.steps.resolveApplication.output = {
applicationId: createResult.data.id,
universalIdentifier: createResult.data.universalIdentifier,
};
this.state.steps.resolveApplication.status = 'done';
this.state.applyStepEvents([
{ message: 'Application created', status: 'success' },
]);
await this.ensureValidTokensStep.exchangeTokens({
applicationId: resolveResult.applicationId,
applicationId: createResult.data.id,
});
this.uploadFilesStep.initialize({
@@ -1,74 +0,0 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { findOrCreateApplication } from '@/cli/utilities/application/find-or-create-application';
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { type Manifest } from 'twenty-shared/application';
export type ResolveApplicationOrchestratorStepOutput = {
applicationId: string | null;
universalIdentifier: string | null;
};
export class ResolveApplicationOrchestratorStep {
private apiService: ApiService;
private state: OrchestratorState;
private notify: () => void;
constructor({
apiService,
state,
notify,
}: {
apiService: ApiService;
state: OrchestratorState;
notify: () => void;
}) {
this.apiService = apiService;
this.state = state;
this.notify = notify;
}
async execute(input: {
manifest: Manifest;
applicationRegistrationId?: string;
}): Promise<ResolveApplicationOrchestratorStepOutput> {
const step = this.state.steps.resolveApplication;
step.status = 'in_progress';
this.notify();
const result = await findOrCreateApplication({
apiService: this.apiService,
manifest: input.manifest,
applicationRegistrationId: input.applicationRegistrationId,
});
if (!result.success) {
this.state.applyStepEvents([
{
message: result.error,
status: 'error',
},
]);
step.status = 'error';
this.state.updatePipeline({ status: 'error' });
return step.output;
}
if (result.created) {
this.state.applyStepEvents([
{ message: 'Creating application', status: 'info' },
{ message: 'Application created', status: 'success' },
]);
}
step.output = {
applicationId: result.applicationId,
universalIdentifier: result.universalIdentifier,
};
step.status = 'done';
this.notify();
return step.output;
}
}