diff --git a/packages/twenty-cli/package.json b/packages/twenty-cli/package.json index d84657312d2..eb63f1a7604 100644 --- a/packages/twenty-cli/package.json +++ b/packages/twenty-cli/package.json @@ -43,6 +43,7 @@ "typescript": "^5.3.0" }, "engines": { - "node": ">=18.0.0" + "node": "^24.5.0", + "yarn": "^4.0.2" } } diff --git a/packages/twenty-cli/schemas/serverlessFunction.schema.json b/packages/twenty-cli/schemas/serverlessFunction.schema.json index adf55a69c50..7d52216f186 100644 --- a/packages/twenty-cli/schemas/serverlessFunction.schema.json +++ b/packages/twenty-cli/schemas/serverlessFunction.schema.json @@ -20,8 +20,8 @@ }, "name": { "type": "string", - "description": "Name singular for the serverless function", - "pattern": "^[a-zA-Z][a-zA-Z0-9]*$", + "description": "Name singular for the serverless function (eg: my-serverless-function)", + "pattern": "^[a-z0-9-]+$", "minLength": 1, "maxLength": 100 }, diff --git a/packages/twenty-cli/src/commands/app-delete.command.ts b/packages/twenty-cli/src/commands/app-delete.command.ts new file mode 100644 index 00000000000..8e782b57e24 --- /dev/null +++ b/packages/twenty-cli/src/commands/app-delete.command.ts @@ -0,0 +1,54 @@ +import inquirer from 'inquirer'; +import chalk from 'chalk'; +import { ApiService } from '../services/api.service'; +import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; +import { loadManifest } from '../utils/app-manifest-loader'; + +export class AppDeleteCommand { + private apiService = new ApiService(); + + async execute(): Promise { + try { + const appPath = CURRENT_EXECUTION_DIRECTORY; + + console.log(chalk.blue('🚀 Deleting Twenty Application')); + console.log(chalk.gray(`📁 App Path: ${appPath}`)); + console.log(''); + + if (!(await this.confirmationPrompt())) { + console.error(chalk.red('⛔️ Aborting deletion')); + process.exit(1); + } + + const { packageJson } = await loadManifest(appPath); + + const result = await this.apiService.deleteApplication(packageJson); + + if (!result.success) { + console.error(chalk.red('❌ Deletion failed:'), result.error); + process.exit(1); + } + + console.log(chalk.green('✅ Application deleted successfully')); + } catch (error) { + console.error( + chalk.red('Deletion failed:'), + error instanceof Error ? error.message : error, + ); + process.exit(1); + } + } + + private async confirmationPrompt(): Promise { + const { confirmation } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmation', + message: 'Are you sure you want to delete this application?', + default: false, + }, + ]); + + return confirmation; + } +} diff --git a/packages/twenty-cli/src/commands/app-dev.command.ts b/packages/twenty-cli/src/commands/app-dev.command.ts index ac3b01789c2..166e30b3142 100644 --- a/packages/twenty-cli/src/commands/app-dev.command.ts +++ b/packages/twenty-cli/src/commands/app-dev.command.ts @@ -1,8 +1,8 @@ import chalk from 'chalk'; import * as chokidar from 'chokidar'; import { ApiService } from '../services/api.service'; -import { syncApp } from '../utils/app-sync'; import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; +import { loadManifest } from '../utils/app-manifest-loader'; export class AppDevCommand { private apiService = new ApiService(); @@ -15,7 +15,13 @@ export class AppDevCommand { this.logStartupInfo(appPath, debounceMs); - await syncApp(appPath, this.apiService); + const { manifest, packageJson, yarnLock } = await loadManifest(appPath); + + await this.apiService.syncApplication({ + manifest, + packageJson, + yarnLock, + }); const watcher = this.setupFileWatcher(appPath, debounceMs); @@ -54,7 +60,15 @@ export class AppDevCommand { timeout = setTimeout(async () => { console.log(chalk.blue('🔄 Changes detected, syncing...')); - await syncApp(appPath, this.apiService); + + const { manifest, packageJson, yarnLock } = await loadManifest(appPath); + + await this.apiService.syncApplication({ + manifest, + packageJson, + yarnLock, + }); + console.log( chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'), ); diff --git a/packages/twenty-cli/src/commands/app-sync.command.ts b/packages/twenty-cli/src/commands/app-sync.command.ts index 98664632237..9b48676dda8 100644 --- a/packages/twenty-cli/src/commands/app-sync.command.ts +++ b/packages/twenty-cli/src/commands/app-sync.command.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { ApiService } from '../services/api.service'; -import { syncApp } from '../utils/app-sync'; import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; +import { loadManifest } from '../utils/app-manifest-loader'; export class AppSyncCommand { private apiService = new ApiService(); @@ -14,7 +14,13 @@ export class AppSyncCommand { console.log(chalk.gray(`📁 App Path: ${appPath}`)); console.log(''); - const result = await syncApp(appPath, this.apiService); + const { manifest, packageJson, yarnLock } = await loadManifest(appPath); + + const result = await this.apiService.syncApplication({ + manifest, + packageJson, + yarnLock, + }); if (!result.success) { console.error(chalk.red('❌ Sync failed:'), result.error); diff --git a/packages/twenty-cli/src/commands/app.command.ts b/packages/twenty-cli/src/commands/app.command.ts index 45a610a8a68..b5116684944 100644 --- a/packages/twenty-cli/src/commands/app.command.ts +++ b/packages/twenty-cli/src/commands/app.command.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { AppSyncCommand } from './app-sync.command'; import { AppDevCommand } from './app-dev.command'; import { AppInitCommand } from './app-init.command'; +import { AppDeleteCommand } from './app-delete.command'; import { AppAddCommand, isSyncableEntity, @@ -12,6 +13,7 @@ import chalk from 'chalk'; export class AppCommand { private devCommand = new AppDevCommand(); private syncCommand = new AppSyncCommand(); + private deleteCommand = new AppDeleteCommand(); private initCommand = new AppInitCommand(); private addCommand = new AppAddCommand(); @@ -34,6 +36,13 @@ export class AppCommand { await this.syncCommand.execute(); }); + appCommand + .command('delete') + .description('Delete application from Twenty') + .action(async () => { + await this.deleteCommand.execute(); + }); + appCommand .command('init [directory]') .description('Initialize a new Twenty application') diff --git a/packages/twenty-cli/src/services/api.service.ts b/packages/twenty-cli/src/services/api.service.ts index 6743b97b250..4ebd3ca6e93 100644 --- a/packages/twenty-cli/src/services/api.service.ts +++ b/packages/twenty-cli/src/services/api.service.ts @@ -72,7 +72,6 @@ export class ApiService { headers: { 'Content-Type': 'application/json', Accept: '*/*', - 'x-schema-version': '6', }, }, ); @@ -115,7 +114,6 @@ export class ApiService { headers: { 'Content-Type': 'application/json', Accept: '*/*', - 'x-schema-version': '6', }, }, ); @@ -143,4 +141,52 @@ export class ApiService { throw error; } } + + async deleteApplication(packageJson: PackageJson): Promise { + try { + const mutation = ` + mutation DeleteApplication($packageJson: JSON!) { + deleteApplication(packageJson: $packageJson) + } + `; + + const variables = { packageJson }; + + const response: AxiosResponse = await this.client.post( + '/metadata', + { + query: mutation, + variables, + }, + { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + }, + }, + ); + + if (response.data.errors) { + return { + success: false, + error: + response.data.errors[0]?.message || 'Failed to delete application', + }; + } + + return { + success: true, + data: response.data.data.deleteApplication, + message: `Successfully deleted application: ${packageJson.name}`, + }; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + return { + success: false, + error: error.response.data?.errors?.[0]?.message || error.message, + }; + } + throw error; + } + } } diff --git a/packages/twenty-cli/src/utils/app-sync.ts b/packages/twenty-cli/src/utils/app-sync.ts deleted file mode 100644 index e2bb9609bfe..00000000000 --- a/packages/twenty-cli/src/utils/app-sync.ts +++ /dev/null @@ -1,32 +0,0 @@ -import chalk from 'chalk'; -import { ApiService } from '../services/api.service'; -import { loadManifest } from './app-manifest-loader'; - -export const syncApp = async ( - appPath: string, - apiService: ApiService, -): Promise => { - const { manifest, packageJson, yarnLock } = await loadManifest(appPath); - - try { - const result = await apiService.syncApplication({ - manifest, - packageJson, - yarnLock, - }); - - if (result.success) { - console.log(chalk.green('✅ Application synced successfully')); - } else { - console.error(chalk.red('❌ Sync failed:'), result.error); - } - - return result; - } catch (error) { - console.error( - chalk.red('Sync error:'), - error instanceof Error ? error.message : error, - ); - throw error; - } -}; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 45df30ff69b..10ba0fee63e 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1694,6 +1694,7 @@ export type Mutation = { createWorkflowVersionEdge: WorkflowVersionStepChanges; createWorkflowVersionStep: WorkflowVersionStepChanges; deactivateWorkflowVersion: Scalars['Boolean']; + deleteApplication: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; deleteCoreView: Scalars['Boolean']; deleteCoreViewField: CoreViewField; @@ -2057,6 +2058,11 @@ export type MutationDeactivateWorkflowVersionArgs = { }; +export type MutationDeleteApplicationArgs = { + packageJson: Scalars['JSON']; +}; + + export type MutationDeleteApprovedAccessDomainArgs = { input: DeleteApprovedAccessDomainInput; }; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 435d5b4204d..71b035695fc 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1650,6 +1650,7 @@ export type Mutation = { createWorkflowVersionEdge: WorkflowVersionStepChanges; createWorkflowVersionStep: WorkflowVersionStepChanges; deactivateWorkflowVersion: Scalars['Boolean']; + deleteApplication: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; deleteCoreView: Scalars['Boolean']; deleteCoreViewField: CoreViewField; @@ -1993,6 +1994,11 @@ export type MutationDeactivateWorkflowVersionArgs = { }; +export type MutationDeleteApplicationArgs = { + packageJson: Scalars['JSON']; +}; + + export type MutationDeleteApprovedAccessDomainArgs = { input: DeleteApprovedAccessDomainInput; }; diff --git a/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts index 575dfc18f4d..026de3c7a78 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts @@ -6,12 +6,15 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { ApplicationSyncService } from 'src/engine/core-modules/application/application-sync.service'; import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input'; +import { DeleteApplicationInput } from 'src/engine/core-modules/application/dtos/deleteApplication.input'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; @UseGuards(WorkspaceAuthGuard) @Resolver() export class ApplicationResolver { constructor( private readonly applicationSyncService: ApplicationSyncService, + private readonly applicationService: ApplicationService, ) {} @Mutation(() => Boolean) @@ -28,4 +31,17 @@ export class ApplicationResolver { return true; } + + @Mutation(() => Boolean) + async deleteApplication( + @Args() { packageJson }: DeleteApplicationInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + await this.applicationService.delete( + packageJson.universalIdentifier, + workspaceId, + ); + + return true; + } } diff --git a/packages/twenty-server/src/engine/core-modules/application/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/application.service.ts index 766eb5e001e..b3fece99f30 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { PackageJson } from 'src/engine/core-modules/application/types/application.types'; @@ -70,4 +71,20 @@ export class ApplicationService { return updatedApplication; } + + async delete(universalIdentifier: string, workspaceId: string) { + const application = await this.findByUniversalIdentifier( + universalIdentifier, + workspaceId, + ); + + if (!isDefined(application)) { + throw new Error(`Application does not exist`); + } + + await this.applicationRepository.delete({ + universalIdentifier, + workspaceId, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/deleteApplication.input.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/deleteApplication.input.ts new file mode 100644 index 00000000000..1863c03f6ae --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/deleteApplication.input.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; + +import { PackageJson } from 'src/engine/core-modules/application/types/application.types'; + +@ArgsType() +export class DeleteApplicationInput { + @Field(() => GraphQLJSON, { nullable: false }) + packageJson: PackageJson; +} diff --git a/yarn.lock b/yarn.lock index 6078acd0dcc..918aca1d018 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21784,7 +21784,16 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.0.0, @types/node@npm:^20.3.1": +"@types/node@npm:^20.0.0": + version: 20.19.19 + resolution: "@types/node@npm:20.19.19" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10c0/488589d6d1d562ad4e8ed9bb36703a8f3bb7feed206bba867358eda0965e781a408f14ed7ff1ca334ae6f6823dcfe2eb2536ffe48e2ab02d47b4c3c5395d274a + languageName: node + linkType: hard + +"@types/node@npm:^20.3.1": version: 20.19.13 resolution: "@types/node@npm:20.19.13" dependencies: @@ -25015,7 +25024,18 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.6.0, axios@npm:^1.6.1, axios@npm:^1.7.7, axios@npm:^1.8.2, axios@npm:^1.8.3": +"axios@npm:^1.6.0": + version: 1.12.2 + resolution: "axios@npm:1.12.2" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.4" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/80b063e318cf05cd33a4d991cea0162f3573481946f9129efb7766f38fde4c061c34f41a93a9f9521f02b7c9565ccbc197c099b0186543ac84a24580017adfed + languageName: node + linkType: hard + +"axios@npm:^1.6.1, axios@npm:^1.7.7, axios@npm:^1.8.2, axios@npm:^1.8.3": version: 1.11.0 resolution: "axios@npm:1.11.0" dependencies: @@ -52246,7 +52266,7 @@ __metadata: languageName: node linkType: hard -"tsx@npm:^4.17.0, tsx@npm:^4.19.3, tsx@npm:^4.7.0": +"tsx@npm:^4.17.0, tsx@npm:^4.19.3": version: 4.20.5 resolution: "tsx@npm:4.20.5" dependencies: @@ -52262,6 +52282,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:^4.7.0": + version: 4.20.6 + resolution: "tsx@npm:4.20.6" + dependencies: + esbuild: "npm:~0.25.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10c0/07757a9bf62c271e0a00869b2008c5f2d6e648766536e4faf27d9d8027b7cde1ac8e4871f4bb570c99388bcee0018e6869dad98c07df809b8052f9c549cd216f + languageName: node + linkType: hard + "tty-browserify@npm:0.0.1": version: 0.0.1 resolution: "tty-browserify@npm:0.0.1"