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
@@ -24,6 +24,8 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/application.input';
import { CreateDevelopmentApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/create-development-application.input';
import { DevelopmentApplicationDTO } from 'src/engine/core-modules/application/application-development/dtos/development-application.dto';
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/application-development/dtos/generate-application-token.input';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input';
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
@@ -64,6 +66,44 @@ export class ApplicationDevelopmentResolver {
private readonly fileStorageService: FileStorageService,
) {}
@Mutation(() => DevelopmentApplicationDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async createDevelopmentApplication(
@Args() { universalIdentifier, name }: CreateDevelopmentApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<DevelopmentApplicationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
universalIdentifier,
workspaceId,
);
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier,
workspaceId,
});
if (existing) {
return {
id: existing.id,
universalIdentifier: existing.universalIdentifier,
};
}
const application = await this.applicationService.create({
universalIdentifier,
name,
sourcePath: universalIdentifier,
sourceType: ApplicationRegistrationSourceType.LOCAL,
applicationRegistrationId,
workspaceId,
});
return {
id: application.id,
universalIdentifier: application.universalIdentifier,
};
}
@Mutation(() => ApplicationTokenPairDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async generateApplicationToken(
@@ -87,13 +127,6 @@ export class ApplicationDevelopmentResolver {
workspaceId,
);
await this.ensureApplicationExists({
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
workspaceId,
applicationRegistrationId,
});
const workspaceMigration =
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
@@ -223,29 +256,4 @@ export class ApplicationDevelopmentResolver {
}
}
}
private async ensureApplicationExists(params: {
universalIdentifier: string;
name: string;
workspaceId: string;
applicationRegistrationId: string;
}): Promise<void> {
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier: params.universalIdentifier,
workspaceId: params.workspaceId,
});
if (existing) {
return;
}
await this.applicationService.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
sourcePath: params.universalIdentifier,
sourceType: ApplicationRegistrationSourceType.LOCAL,
applicationRegistrationId: params.applicationRegistrationId,
workspaceId: params.workspaceId,
});
}
}
@@ -1,36 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateApplicationInput {
@IsString()
@IsNotEmpty()
@Field()
universalIdentifier: string;
@IsString()
@IsNotEmpty()
@Field()
name: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsNotEmpty()
@Field()
version: string;
@IsString()
@IsNotEmpty()
@Field()
sourcePath: string;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
applicationRegistrationId?: string;
}
@@ -0,0 +1,10 @@
import { ArgsType, Field } from '@nestjs/graphql';
@ArgsType()
export class CreateDevelopmentApplicationInput {
@Field(() => String)
universalIdentifier: string;
@Field(() => String)
name: string;
}
@@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('DevelopmentApplication')
export class DevelopmentApplicationDTO {
@Field(() => String)
id: string;
@Field(() => String)
universalIdentifier: string;
}
@@ -1,134 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Repository } from 'typeorm';
import { z } from 'zod';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const npmPackageMetadataSchema = z.object({
version: z.string(),
});
@Injectable()
export class ApplicationUpgradeService {
private readonly logger = new Logger(ApplicationUpgradeService.name);
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly applicationInstallService: ApplicationInstallService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async checkForUpdates(
appRegistration: ApplicationRegistrationEntity,
): Promise<string | null> {
if (appRegistration.sourceType !== ApplicationRegistrationSourceType.NPM) {
return null;
}
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
if (!appRegistration.sourcePackage) {
return null;
}
try {
const encodedPackage = encodeURIComponent(appRegistration.sourcePackage);
const { data } = await axios.get(
`${registryUrl}/${encodedPackage}/latest`,
{
headers: { 'User-Agent': 'Twenty-AppUpgrade' },
timeout: 10_000,
},
);
const parsed = npmPackageMetadataSchema.safeParse(data);
if (!parsed.success) {
this.logger.warn(
`Unexpected response shape from registry for ${appRegistration.sourcePackage}`,
);
return null;
}
await this.appRegistrationRepository.update(appRegistration.id, {
latestAvailableVersion: parsed.data.version,
});
return parsed.data.version;
} catch (error) {
this.logger.warn(
`Failed to check updates for ${appRegistration.sourcePackage}: ${error}`,
);
return null;
}
}
async checkAllForUpdates(): Promise<void> {
const npmRegistrations = await this.appRegistrationRepository.find({
where: { sourceType: ApplicationRegistrationSourceType.NPM },
});
for (const registration of npmRegistrations) {
await this.checkForUpdates(registration);
}
}
async upgradeApplication(params: {
appRegistrationId: string;
targetVersion: string;
workspaceId: string;
}): Promise<boolean> {
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
where: { id: params.appRegistrationId },
});
if (
appRegistration.sourceType === ApplicationRegistrationSourceType.LOCAL ||
appRegistration.sourceType === ApplicationRegistrationSourceType.TARBALL
) {
throw new ApplicationException(
'Cannot upgrade an app installed from a tarball or local source',
ApplicationExceptionCode.UPGRADE_FAILED,
);
}
try {
return await this.applicationInstallService.installApplication({
appRegistrationId: params.appRegistrationId,
version: params.targetVersion,
workspaceId: params.workspaceId,
});
} catch (error) {
const appName =
appRegistration.sourcePackage ?? appRegistration.universalIdentifier;
this.logger.error(`Upgrade failed for ${appName}`, error);
if (error instanceof ApplicationException) {
throw error;
}
throw new ApplicationException(
`Upgrade failed for ${appName}`,
ApplicationExceptionCode.UPGRADE_FAILED,
);
}
}
}
@@ -1,37 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { APPLICATION_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-upgrade/crons/constants/application-version-check-cron-pattern.constant';
import { ApplicationUpgradeService } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@Injectable()
@Processor(MessageQueue.cronQueue)
export class ApplicationVersionCheckCronJob {
private readonly logger = new Logger(ApplicationVersionCheckCronJob.name);
constructor(
private readonly applicationUpgradeService: ApplicationUpgradeService,
) {}
@Process(ApplicationVersionCheckCronJob.name)
@SentryCronMonitor(
ApplicationVersionCheckCronJob.name,
APPLICATION_VERSION_CHECK_CRON_PATTERN,
)
async handle(): Promise<void> {
this.logger.log('Starting application version check...');
try {
await this.applicationUpgradeService.checkAllForUpdates();
this.logger.log('Application version check completed successfully');
} catch (error) {
this.logger.error(
`Application version check failed: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
}
}
@@ -1,33 +0,0 @@
import { Command, CommandRunner } from 'nest-commander';
import { APPLICATION_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-upgrade/crons/constants/application-version-check-cron-pattern.constant';
import { ApplicationVersionCheckCronJob } from 'src/engine/core-modules/application/application-upgrade/crons/application-version-check.cron.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@Command({
name: 'cron:app-version-check',
description:
'Starts a cron job to check for app version updates on npm registries',
})
export class ApplicationVersionCheckCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: ApplicationVersionCheckCronJob.name,
data: undefined,
options: {
repeat: {
pattern: APPLICATION_VERSION_CHECK_CRON_PATTERN,
},
},
});
}
}
@@ -1,2 +0,0 @@
// Every 6 hours
export const APPLICATION_VERSION_CHECK_CRON_PATTERN = '0 */6 * * *';
@@ -6,7 +6,6 @@ import { ApplicationRegistrationResolver } from 'src/engine/core-modules/applica
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module';
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -24,7 +23,6 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
WorkspaceEntity,
]),
ApplicationRegistrationVariableModule,
ApplicationPackageModule,
ApplicationModule,
FeatureFlagModule,
PermissionsModule,
@@ -13,7 +13,6 @@ import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/a
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input';
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
@@ -63,7 +62,6 @@ export class ApplicationRegistrationResolver {
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationTarballService: ApplicationTarballService,
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
private readonly fileUrlService: FileUrlService,
) {}
@@ -262,44 +260,6 @@ export class ApplicationRegistrationResolver {
);
}
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS),
)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
@Mutation(() => ApplicationRegistrationEntity)
async registerNpmPackage(
@Args('packageName') packageName: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ApplicationRegistrationEntity> {
const resolvedPackage =
await this.applicationPackageFetcherService.resolveNpmPackage(
packageName,
);
try {
const manifest = resolvedPackage.manifest.application;
return await this.applicationRegistrationService.createFromNpmPackage({
packageName,
universalIdentifier: manifest.universalIdentifier,
name: manifest.displayName,
description: manifest.description,
author: manifest.author,
logoUrl: manifest.logoUrl,
websiteUrl: manifest.websiteUrl,
termsUrl: manifest.termsUrl,
version: resolvedPackage.packageJson.version as string | undefined,
ownerWorkspaceId: workspaceId,
});
} finally {
await this.applicationPackageFetcherService.cleanupExtractedDir(
resolvedPackage.cleanupDir,
);
}
}
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
@@ -327,62 +327,6 @@ export class ApplicationRegistrationService {
await this.applicationRegistrationRepository.save(registration);
}
async createFromNpmPackage(params: {
packageName: string;
universalIdentifier: string;
name: string;
description?: string;
author?: string;
logoUrl?: string;
websiteUrl?: string;
termsUrl?: string;
version?: string;
ownerWorkspaceId: string;
}): Promise<ApplicationRegistrationEntity> {
const existingByUid = await this.findOneByUniversalIdentifier(
params.universalIdentifier,
);
if (isDefined(existingByUid)) {
throw new ApplicationRegistrationException(
`An app with universalIdentifier "${params.universalIdentifier}" is already registered`,
ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
);
}
const existingByPackage =
await this.applicationRegistrationRepository.findOne({
where: { sourcePackage: params.packageName },
});
if (isDefined(existingByPackage)) {
throw new ApplicationRegistrationException(
`Package "${params.packageName}" is already registered`,
ApplicationRegistrationExceptionCode.INVALID_INPUT,
);
}
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
description: params.description ?? null,
author: params.author ?? null,
logoUrl: params.logoUrl ?? null,
websiteUrl: params.websiteUrl ?? null,
termsUrl: params.termsUrl ?? null,
latestAvailableVersion: params.version ?? null,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: params.packageName,
isListed: true,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
return this.applicationRegistrationRepository.save(registration);
}
async findManyBySourceType(
sourceType: ApplicationRegistrationSourceType,
): Promise<ApplicationRegistrationEntity[]> {
@@ -212,12 +212,6 @@ export class ApplicationService {
});
}
async createOneApplication(
data: Partial<ApplicationEntity> & { workspaceId: string },
): Promise<ApplicationEntity> {
return this.create(data);
}
async findTwentyStandardApplicationOrThrow(workspaceId: string): Promise<{
application: ApplicationEntity;
workspace: WorkspaceEntity;