diff --git a/packages/twenty-sdk/src/cli/commands/dev/dev-once.ts b/packages/twenty-sdk/src/cli/commands/dev/dev-once.ts index 43fd5bbe98..ad05b6c933 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/dev-once.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/dev-once.ts @@ -1,18 +1,22 @@ import { appDevOnce } from '@/cli/operations/dev-once'; +import { APP_ERROR_CODES } from '@/cli/types'; import { ConfigService } from '@/cli/utilities/config/config-service'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; +import { confirmDestructiveApply } from '@/cli/utilities/dev/confirm-destructive-apply'; import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility'; import chalk from 'chalk'; export type AppDevOnceCommandOptions = { appPath?: string; verbose?: boolean; - dryRun?: boolean; + apply?: boolean; + force?: boolean; }; export class AppDevOnceCommand { async execute(options: AppDevOnceCommandOptions): Promise { const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; + const apply = options.apply ?? false; await checkSdkVersionCompatibility(appPath); @@ -20,7 +24,7 @@ export class AppDevOnceCommand { console.log( chalk.blue( - `${options.dryRun ? 'Previewing application diff' : 'Syncing application'} on ${remoteName}...`, + `${apply ? 'Applying application manifest' : 'Planning application apply'} on ${remoteName}...`, ), ); console.log(chalk.gray(`App path: ${appPath}\n`)); @@ -28,19 +32,28 @@ export class AppDevOnceCommand { const result = await appDevOnce({ appPath, verbose: options.verbose, - dryRun: options.dryRun, + apply, + force: options.force, onProgress: (message) => console.log(chalk.gray(message)), + onPlan: (text) => console.log(`\n${text}\n`), + confirmApply: (deleteCount) => + confirmDestructiveApply(deleteCount, { force: options.force }), }); if (!result.success) { + if (result.error.code === APP_ERROR_CODES.APPLY_ABORTED) { + console.log(chalk.yellow(result.error.message)); + process.exit(1); + } + console.error(chalk.red(result.error.message)); process.exit(1); } - if (options.dryRun) { + if (!apply) { console.log( chalk.green( - `\n✓ Dry run complete for ${result.data.applicationDisplayName} — no changes were applied`, + `\n✓ Plan complete for ${result.data.applicationDisplayName} — no changes were applied`, ), ); diff --git a/packages/twenty-sdk/src/cli/commands/dev/dev.ts b/packages/twenty-sdk/src/cli/commands/dev/dev.ts index 709f727f88..78a4ee0d9f 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/dev.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/dev.ts @@ -9,12 +9,14 @@ import { DevUiStateManager } from '@/cli/utilities/dev/ui/dev-ui-state-manager'; import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility'; import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility'; import { getVersionInfo } from '@/cli/utilities/version/get-version-info'; +import chalk from 'chalk'; export type AppDevOptions = { appPath?: string; headless?: boolean; verbose?: boolean; debounceMs?: number; + force?: boolean; }; export class AppDevCommand { @@ -72,6 +74,18 @@ export class AppDevCommand { state: orchestratorState, verbose: options.verbose, debounceMs: options.debounceMs, + force: options.force, + interactive: !options.headless && process.stdout.isTTY === true, + onExit: ({ code, message }) => { + if (options.headless) { + return; + } + + void this.close().then(() => { + console.log(chalk.yellow(`\n${message}`)); + process.exit(code); + }); + }, }); await this.orchestrator.start(); diff --git a/packages/twenty-sdk/src/cli/commands/dev/index.ts b/packages/twenty-sdk/src/cli/commands/dev/index.ts index 55557abb54..4067b61863 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/index.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/index.ts @@ -6,10 +6,10 @@ import { EntityAddCommand } from './add'; import { AppBuildCommand } from './build'; import { AppDevCommand } from './dev'; import { AppDevOnceCommand } from './dev-once'; +import { registerDevFunctionCommands } from './function'; import { AppGenerateClientCommand } from './generate-client'; import { AppI18nExtractCommand } from './i18n-extract'; import { AppTypecheckCommand } from './typecheck'; -import { registerDevFunctionCommands } from './function'; export const registerDevCommands = (program: Command): void => { const buildCommand = new AppBuildCommand(); @@ -28,34 +28,46 @@ export const registerDevCommands = (program: Command): void => { debug?: boolean; debounceMs?: string; dryRun?: boolean; + force?: boolean; }, ) => { if (options.dryRun && !options.once) { console.warn( chalk.yellow( - '--dry-run only applies with --once. Ignoring it; run `yarn twenty dev --once --dry-run` to preview changes.', + '--dry-run only applies with --once. Ignoring it; run `yarn twenty plan` to preview changes.', ), ); } - const commonOptions = { - appPath: formatPath(appPath), - verbose: options.verbose || options.debug, - debounceMs: options.debounceMs - ? parseInt(options.debounceMs, 10) - : undefined, - }; + const verbose = options.verbose || options.debug; if (options.once) { + console.warn( + chalk.yellow( + options.dryRun + ? '⚠ `twenty dev --once --dry-run` is deprecated. Use `twenty plan` instead.' + : '⚠ `twenty dev --once` is deprecated. Use `twenty apply` instead.', + ), + ); + await devOnceCommand.execute({ - ...commonOptions, - dryRun: options.dryRun, + appPath: formatPath(appPath), + verbose, + apply: !options.dryRun, + force: options.force, }); return; } - await devCommand.execute(commonOptions); + await devCommand.execute({ + appPath: formatPath(appPath), + verbose, + debounceMs: options.debounceMs + ? parseInt(options.debounceMs, 10) + : undefined, + force: options.force, + }); }; program @@ -63,17 +75,57 @@ export const registerDevCommands = (program: Command): void => { .description('Build and sync local changes') .option( '-o, --once', - 'Build and sync once, then exit (useful for CI, scripts, and pre-commit hooks)', + 'Build and sync once, then exit (deprecated: use `twenty apply`)', ) .option( '--dry-run', - 'Preview the metadata changes without applying them (requires --once)', + 'Preview the metadata changes without applying them (deprecated: use `twenty plan`)', + ) + .option( + '-f, --force', + 'Apply destructive changes (deletes) without confirmation', ) .option('--debounceMs ', 'Debounce in ms (default: 1 000)') .option('-v, --verbose', 'Show detailed logs') .option('-d, --debug', 'Show detailed logs (alias for --verbose)') .action(devAction); + program + .command('plan [appPath]') + .description('Preview metadata changes without applying them') + .option('-v, --verbose', 'Show detailed logs') + .action( + async (appPath: string | undefined, options: { verbose?: boolean }) => { + await devOnceCommand.execute({ + appPath: formatPath(appPath), + verbose: options.verbose, + apply: false, + }); + }, + ); + + program + .command('apply [appPath]') + .description('Apply local metadata changes after showing the plan') + .option( + '-f, --force', + 'Apply destructive changes (deletes) without confirmation', + ) + .option('-v, --verbose', 'Show detailed logs') + .action( + async ( + appPath: string | undefined, + options: { force?: boolean; verbose?: boolean }, + ) => { + await devOnceCommand.execute({ + appPath: formatPath(appPath), + verbose: options.verbose, + apply: true, + force: options.force, + }); + }, + ); + program .command('dev:build [appPath]') .description('Build and generate API client') diff --git a/packages/twenty-sdk/src/cli/constants/destructive-metadata-names.ts b/packages/twenty-sdk/src/cli/constants/destructive-metadata-names.ts new file mode 100644 index 0000000000..eafb92429f --- /dev/null +++ b/packages/twenty-sdk/src/cli/constants/destructive-metadata-names.ts @@ -0,0 +1,4 @@ +export const DESTRUCTIVE_METADATA_NAMES = new Set([ + 'objectMetadata', + 'fieldMetadata', +]); diff --git a/packages/twenty-sdk/src/cli/operations/dev-once.ts b/packages/twenty-sdk/src/cli/operations/dev-once.ts index 35f583f0b5..c37032196a 100644 --- a/packages/twenty-sdk/src/cli/operations/dev-once.ts +++ b/packages/twenty-sdk/src/cli/operations/dev-once.ts @@ -1,6 +1,7 @@ import path from 'path'; import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application'; -import { type SyncAction } from 'twenty-shared/metadata'; +import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata'; +import { isPlainObject } from 'twenty-shared/utils'; import { ApiService } from '@/cli/utilities/api/api-service'; import { @@ -15,20 +16,31 @@ import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest 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 { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary'; +import { + countDestructiveActions, + formatSyncActionsPlan, + hasDestructiveActions, +} from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-plan'; import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors'; import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint'; 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'; +import { + APP_ERROR_CODES, + type CommandError, + type CommandResult, +} from '@/cli/types'; import chalk from 'chalk'; export type AppDevOnceOptions = { appPath: string; verbose?: boolean; - dryRun?: boolean; + apply?: boolean; + force?: boolean; onProgress?: (message: string) => void; + onPlan?: (text: string) => void; + confirmApply?: (deleteCount: number) => Promise; }; export type AppDevOnceResult = { @@ -36,15 +48,7 @@ export type AppDevOnceResult = { fileCount: number; applicationDisplayName: string; applicationUniversalIdentifier: string; -}; - -const reportMetadataChanges = ( - data: { actions: SyncAction[] }, - onProgress?: (message: string) => void, -): void => { - for (const event of formatSyncActionsSummary(data.actions)) { - onProgress?.(event.message); - } + applied: boolean; }; const appendRecoveryHint = ( @@ -56,10 +60,64 @@ const appendRecoveryHint = ( return hint ? `${message}\n\n${hint}` : message; }; +const NOT_INSTALLED_SUB_CODES = new Set([ + 'APP_NOT_INSTALLED', + 'APPLICATION_NOT_FOUND', +]); + +const isAppNotInstalledError = (result: { + error?: MetadataValidationErrorResponse; + message?: string; +}): boolean => { + const extensions = result.error; + + if ( + isPlainObject(extensions) && + NOT_INSTALLED_SUB_CODES.has( + (extensions as { subCode?: string }).subCode ?? '', + ) + ) { + return true; + } + + const message = (result.message ?? '').toLowerCase(); + + return ( + message.includes('not installed in workspace') || + message.includes('not found in workspace') + ); +}; + +const buildSyncError = ( + result: { error?: MetadataValidationErrorResponse; message?: string }, + verbose: boolean, +): CommandError => { + const errorEvents = verbose + ? null + : formatManifestValidationErrors(result.error); + + const message = errorEvents + ? errorEvents.map((event) => event.message).join('\n') + : `Sync failed with error: ${result.message ?? 'Unknown error'}`; + + return { + code: APP_ERROR_CODES.SYNC_FAILED, + message: appendRecoveryHint(message, result.message), + }; +}; + const innerAppDevOnce = async ( options: AppDevOnceOptions, ): Promise> => { - const { appPath, onProgress, verbose, dryRun } = options; + const { + appPath, + onProgress, + onPlan, + confirmApply, + verbose = false, + apply = true, + force = false, + } = options; onProgress?.('Checking server...'); @@ -150,45 +208,63 @@ const innerAppDevOnce = async ( await writeManifestToOutput(appPath, manifest); - if (dryRun) { + const makeData = (): AppDevOnceResult => ({ + outputDir: path.join(appPath, OUTPUT_DIR), + fileCount: buildResult.builtFileInfos.size, + applicationDisplayName: manifest.application.displayName, + applicationUniversalIdentifier: manifest.application.universalIdentifier, + applied: apply, + }); + + if (!apply) { onProgress?.( - 'Computing metadata diff (dry run, nothing will be applied)...', + 'Computing metadata plan (read-only, nothing will be applied)...', ); - const dryRunResult = await apiService.syncApplication(manifest, { + const planResult = await apiService.syncApplication(manifest, { dryRun: true, }); - if (!dryRunResult.success) { - const errorEvents = verbose - ? null - : formatManifestValidationErrors(dryRunResult.error); - - const message = errorEvents - ? errorEvents.map((event) => event.message).join('\n') - : `Dry run failed with error: ${dryRunResult.message ?? 'Unknown error'}`; - - return { - success: false, - error: { - code: APP_ERROR_CODES.SYNC_FAILED, - message: appendRecoveryHint(message, dryRunResult.message), - }, - }; + if (!planResult.success) { + return { success: false, error: buildSyncError(planResult, verbose) }; } - reportMetadataChanges(dryRunResult.data, onProgress); + onPlan?.(formatSyncActionsPlan(planResult.data.actions)); - return { - success: true, - data: { - outputDir: path.join(appPath, OUTPUT_DIR), - fileCount: buildResult.builtFileInfos.size, - applicationDisplayName: manifest.application.displayName, - applicationUniversalIdentifier: - manifest.application.universalIdentifier, - }, - }; + return { success: true, data: makeData() }; + } + + let planRendered = false; + + if (!force) { + onProgress?.('Computing metadata plan...'); + + const planResult = await apiService.syncApplication(manifest, { + dryRun: true, + }); + + if (planResult.success) { + onPlan?.(formatSyncActionsPlan(planResult.data.actions)); + planRendered = true; + + if (hasDestructiveActions(planResult.data.actions)) { + const approved = confirmApply + ? await confirmApply(countDestructiveActions(planResult.data.actions)) + : false; + + if (!approved) { + return { + success: false, + error: { + code: APP_ERROR_CODES.APPLY_ABORTED, + message: 'Apply cancelled — no changes were made.', + }, + }; + } + } + } else if (!isAppNotInstalledError(planResult)) { + return { success: false, error: buildSyncError(planResult, verbose) }; + } } onProgress?.('Registering application...'); @@ -266,24 +342,12 @@ const innerAppDevOnce = async ( const syncResult = await apiService.syncApplication(manifest); if (!syncResult.success) { - const errorEvents = verbose - ? null - : formatManifestValidationErrors(syncResult.error); - - const message = errorEvents - ? errorEvents.map((event) => event.message).join('\n') - : `Sync failed with error: ${syncResult.message ?? 'Unknown error'}`; - - return { - success: false, - error: { - code: APP_ERROR_CODES.SYNC_FAILED, - message: appendRecoveryHint(message, syncResult.message), - }, - }; + return { success: false, error: buildSyncError(syncResult, verbose) }; } - reportMetadataChanges(syncResult.data, onProgress); + if (!planRendered) { + onPlan?.(formatSyncActionsPlan(syncResult.data.actions)); + } onProgress?.('Generating API client...'); @@ -309,15 +373,7 @@ const innerAppDevOnce = async ( }; } - return { - success: true, - data: { - outputDir: path.join(appPath, OUTPUT_DIR), - fileCount: buildResult.builtFileInfos.size, - applicationDisplayName: manifest.application.displayName, - applicationUniversalIdentifier: manifest.application.universalIdentifier, - }, - }; + return { success: true, data: makeData() }; }; export const appDevOnce = ( diff --git a/packages/twenty-sdk/src/cli/types.ts b/packages/twenty-sdk/src/cli/types.ts index 2ad9acafbd..47e8aeb33e 100644 --- a/packages/twenty-sdk/src/cli/types.ts +++ b/packages/twenty-sdk/src/cli/types.ts @@ -23,6 +23,7 @@ export const APP_ERROR_CODES = { INSTALL_FAILED: 'INSTALL_FAILED', UNINSTALL_FAILED: 'UNINSTALL_FAILED', SYNC_FAILED: 'SYNC_FAILED', + APPLY_ABORTED: 'APPLY_ABORTED', TYPECHECK_FAILED: 'TYPECHECK_FAILED', DEPLOY_FAILED: 'DEPLOY_FAILED', } as const; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/__tests__/confirm-destructive-apply.spec.ts b/packages/twenty-sdk/src/cli/utilities/dev/__tests__/confirm-destructive-apply.spec.ts new file mode 100644 index 0000000000..cd24d6af54 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/__tests__/confirm-destructive-apply.spec.ts @@ -0,0 +1,67 @@ +import inquirer from 'inquirer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { confirmDestructiveApply } from '@/cli/utilities/dev/confirm-destructive-apply'; + +vi.mock('inquirer', () => ({ + default: { prompt: vi.fn() }, +})); + +const mockedPrompt = vi.mocked(inquirer.prompt); + +const setIsTTY = (value: boolean): void => { + Object.defineProperty(process.stdout, 'isTTY', { + value, + configurable: true, + }); +}; + +const originalIsTTY = process.stdout.isTTY; + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); +}); + +afterEach(() => { + setIsTTY(originalIsTTY as boolean); + vi.restoreAllMocks(); + mockedPrompt.mockReset(); +}); + +describe('confirmDestructiveApply', () => { + it('should return true without prompting when force is set', async () => { + const result = await confirmDestructiveApply(3, { force: true }); + + expect(result).toBe(true); + expect(mockedPrompt).not.toHaveBeenCalled(); + }); + + it('should fail closed without prompting when not a TTY', async () => { + setIsTTY(false); + + const result = await confirmDestructiveApply(2, { force: false }); + + expect(result).toBe(false); + expect(mockedPrompt).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalled(); + }); + + it('should return true when the user confirms in a TTY', async () => { + setIsTTY(true); + mockedPrompt.mockResolvedValue({ confirmed: true }); + + const result = await confirmDestructiveApply(1, { force: false }); + + expect(result).toBe(true); + expect(mockedPrompt).toHaveBeenCalledTimes(1); + }); + + it('should return false when the user declines in a TTY', async () => { + setIsTTY(true); + mockedPrompt.mockResolvedValue({ confirmed: false }); + + const result = await confirmDestructiveApply(1, { force: false }); + + expect(result).toBe(false); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/confirm-destructive-apply.ts b/packages/twenty-sdk/src/cli/utilities/dev/confirm-destructive-apply.ts new file mode 100644 index 0000000000..8982e84bff --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/confirm-destructive-apply.ts @@ -0,0 +1,32 @@ +import chalk from 'chalk'; +import inquirer from 'inquirer'; + +export const confirmDestructiveApply = async ( + deleteCount: number, + { force }: { force?: boolean }, +): Promise => { + if (force) { + return true; + } + + if (!process.stdout.isTTY) { + console.error( + chalk.red( + `${deleteCount} destructive change(s) detected. Run \`yarn twenty plan\` to review them, or re-run with --force to apply non-interactively.`, + ), + ); + + return false; + } + + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Twenty will DESTROY ${deleteCount} metadata entity(ies) (run \`yarn twenty plan\` for details). Do you want to perform these actions?`, + default: false, + }, + ]); + + return confirmed; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts index 62db735771..49c0a60772 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts @@ -122,6 +122,9 @@ export class OrchestratorState { entities: Map; events: OrchestratorStateEvent[]; + pendingConfirmation: { deleteCount: number } | null; + + private confirmationResolver: ((approved: boolean) => void) | null = null; private eventIdCounter = 0; onChange?: () => void; @@ -129,6 +132,7 @@ export class OrchestratorState { this.appPath = options.appPath; this.previousObjectsFieldsFingerprint = null; + this.pendingConfirmation = null; this.steps = { checkServer: { @@ -191,6 +195,24 @@ export class OrchestratorState { this.onChange?.(); } + requestDestructiveConfirmation(deleteCount: number): Promise { + return new Promise((resolve) => { + this.pendingConfirmation = { deleteCount }; + this.confirmationResolver = resolve; + this.notify(); + }); + } + + resolveDestructiveConfirmation(approved: boolean): void { + const resolver = this.confirmationResolver; + + this.pendingConfirmation = null; + this.confirmationResolver = null; + this.notify(); + + resolver?.(approved); + } + updatePipeline(update: Partial): void { Object.assign(this.pipeline, update); this.notify(); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts index d5b46733a1..5ff8f048a7 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts @@ -21,6 +21,9 @@ export type DevModeOrchestratorOptions = { state: OrchestratorState; debounceMs?: number; verbose?: boolean; + force?: boolean; + interactive?: boolean; + onExit?: (params: { code: number; message: string }) => void; }; export class DevModeOrchestrator { @@ -75,6 +78,9 @@ export class DevModeOrchestrator { ...stepDeps, apiService, verbose: this.verbose, + force: options.force ?? false, + interactive: options.interactive ?? false, + onExit: options.onExit, }); this.startWatchersStep = new StartWatchersOrchestratorStep({ ...stepDeps, @@ -214,7 +220,7 @@ export class DevModeOrchestrator { appPath: this.state.appPath, }); - if (this.state.steps.syncApplication.status === 'error') { + if (this.state.steps.syncApplication.output.syncStatus !== 'synced') { return; } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-actions-plan.spec.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-actions-plan.spec.ts new file mode 100644 index 0000000000..6f23e5c8fb --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-actions-plan.spec.ts @@ -0,0 +1,425 @@ +import chalk from 'chalk'; +import { type SyncAction } from 'twenty-shared/metadata'; +import { describe, expect, it } from 'vitest'; + +import { + countDestructiveActions, + formatSyncActionsPlan, + formatValue, + hasDestructiveActions, + selectEntityAttributes, +} from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-plan'; + +chalk.level = 0; + +describe('formatSyncActionsPlan', () => { + it('should report no changes when actions are empty', () => { + expect(formatSyncActionsPlan([])).toBe( + 'No changes. Twenty metadata matches your manifest.', + ); + }); + + it('should report no changes when actions are undefined', () => { + expect(formatSyncActionsPlan(undefined)).toBe( + 'No changes. Twenty metadata matches your manifest.', + ); + }); + + it('should render the whole plan for a create block', () => { + const plan = formatSyncActionsPlan([ + { + type: 'create', + metadataName: 'objectMetadata', + flatEntity: { + icon: 'IconRocket', + namePlural: 'rockets', + nameSingular: 'rocket', + labelSingular: 'Rocket', + }, + }, + ]); + + expect(plan).toMatchInlineSnapshot(` + "Twenty will perform the following actions: + + # objectMetadata "rocket" will be created + + icon = "IconRocket" + + labelSingular = "Rocket" + + namePlural = "rockets" + + nameSingular = "rocket" + + Plan: 1 to add, 0 to change, 0 to destroy." + `); + }); + + it('should filter internal keys and null values from create blocks', () => { + const plan = formatSyncActionsPlan([ + { + type: 'create', + metadataName: 'fieldMetadata', + flatEntity: { + id: 'uuid', + workspaceId: 'ws', + createdAt: '2026-01-01', + objectMetadataId: 'obj', + universalIdentifier: 'field-name', + description: null, + name: 'name', + label: 'Name', + }, + }, + ]); + + expect(plan).not.toContain('workspaceId'); + expect(plan).not.toContain('createdAt'); + expect(plan).not.toContain('objectMetadataId'); + expect(plan).not.toContain('description'); + expect(plan).toContain('+ name'); + expect(plan).toContain('+ label'); + }); + + it('should render only changed keys for updates as before -> after', () => { + const plan = formatSyncActionsPlan([ + { + type: 'update', + metadataName: 'fieldMetadata', + universalIdentifier: 'field-stage', + flatEntity: { name: 'stage' }, + diff: { + label: { before: 'Stage', after: 'Launch stage' }, + isNullable: { before: true, after: false }, + }, + }, + ]); + + expect(plan).toContain('# fieldMetadata "stage" will be updated in-place'); + expect(plan).toContain('~ label = "Stage" -> "Launch stage"'); + expect(plan).toContain('~ isNullable = true -> false'); + expect(plan).toContain('Plan: 0 to add, 1 to change, 0 to destroy.'); + }); + + it('should JSON-encode object and array values in update diffs', () => { + const plan = formatSyncActionsPlan([ + { + type: 'update', + metadataName: 'fieldMetadata', + universalIdentifier: 'field-options', + diff: { + options: { + before: [{ value: 'A' }], + after: [{ value: 'A' }, { value: 'B' }], + }, + }, + }, + ]); + + expect(plan).toContain('[{"value":"A"}] -> [{"value":"A"},{"value":"B"}]'); + }); + + it('should skip an update block when its diff is empty but still count it', () => { + const plan = formatSyncActionsPlan([ + { + type: 'update', + metadataName: 'fieldMetadata', + universalIdentifier: 'field-noop', + diff: {}, + }, + ]); + + expect(plan).not.toContain('updated in-place'); + expect(plan).toContain('Plan: 0 to add, 1 to change, 0 to destroy.'); + }); + + it('should warn that an object delete drops the table', () => { + const plan = formatSyncActionsPlan([ + { + type: 'delete', + metadataName: 'objectMetadata', + universalIdentifier: 'obj-old', + flatEntity: { nameSingular: 'oldThing' }, + }, + ]); + + expect(plan).toContain('# objectMetadata "oldThing" will be destroyed'); + expect(plan).toContain('drops the table and all its rows'); + expect(plan).toContain('Destroys are irreversible'); + }); + + it('should warn that a field delete drops the column', () => { + const plan = formatSyncActionsPlan([ + { + type: 'delete', + metadataName: 'fieldMetadata', + universalIdentifier: 'field-legacy', + flatEntity: { name: 'legacyCode', label: 'Legacy code' }, + }, + ]); + + expect(plan).toContain('drops the column and its data'); + }); + + it('should not render a destructive warning when there are no deletes', () => { + const plan = formatSyncActionsPlan([ + { + type: 'create', + metadataName: 'fieldMetadata', + flatEntity: { name: 'name' }, + }, + ]); + + expect(plan).not.toContain('Warning:'); + }); + + it('should show a non-object/field delete without a data-loss warning', () => { + const plan = formatSyncActionsPlan([ + { + type: 'delete', + metadataName: 'view', + universalIdentifier: 'v', + flatEntity: { name: 'My view' }, + }, + ]); + + expect(plan).toContain('# view "My view" will be destroyed'); + expect(plan).toContain('Plan: 0 to add, 0 to change, 1 to destroy.'); + expect(plan).not.toContain('Warning:'); + }); + + it('should group by type ordered by first appearance, create before delete within a group', () => { + const plan = formatSyncActionsPlan([ + { + type: 'delete', + metadataName: 'objectMetadata', + universalIdentifier: 'obj-old', + flatEntity: { nameSingular: 'oldObject' }, + }, + { + type: 'create', + metadataName: 'fieldMetadata', + flatEntity: { name: 'newField' }, + }, + { + type: 'create', + metadataName: 'objectMetadata', + flatEntity: { nameSingular: 'newObject' }, + }, + ]); + + const newObjectIndex = plan.indexOf('"newObject"'); + const oldObjectIndex = plan.indexOf('"oldObject"'); + const newFieldIndex = plan.indexOf('"newField"'); + + expect(newObjectIndex).toBeLessThan(oldObjectIndex); + expect(oldObjectIndex).toBeLessThan(newFieldIndex); + expect(plan).toContain('Plan: 2 to add, 0 to change, 1 to destroy.'); + }); + + it('should align the equals signs within a block', () => { + const plan = formatSyncActionsPlan([ + { + type: 'create', + metadataName: 'fieldMetadata', + flatEntity: { name: 'name', isNullable: true }, + }, + ]); + + const equalsColumns = plan + .split('\n') + .filter((line) => line.includes(' = ')) + .map((line) => line.indexOf('=')); + + expect(new Set(equalsColumns).size).toBe(1); + }); + + it('should render unknown metadata names without throwing', () => { + const plan = formatSyncActionsPlan([ + { + type: 'create', + metadataName: 'somethingNew', + flatEntity: { name: 'thing' }, + } as unknown as SyncAction, + ]); + + expect(plan).toContain('# somethingNew "thing" will be created'); + }); + + it('should render unknown as the name when flatEntity is missing on create', () => { + const plan = formatSyncActionsPlan([ + { type: 'create', metadataName: 'fieldMetadata' }, + ]); + + expect(plan).toContain('# fieldMetadata "unknown" will be created'); + }); + + it('should mask the value of a secret application variable', () => { + const plan = formatSyncActionsPlan([ + { + type: 'create', + metadataName: 'applicationVariable', + flatEntity: { name: 'API_KEY', value: 'super-secret', isSecret: true }, + }, + ]); + + expect(plan).toContain('value'); + expect(plan).toContain('(secret)'); + expect(plan).not.toContain('super-secret'); + }); + + it('should show the value of a non-secret application variable', () => { + const plan = formatSyncActionsPlan([ + { + type: 'create', + metadataName: 'applicationVariable', + flatEntity: { + name: 'BASE_URL', + value: 'https://example.com', + isSecret: false, + }, + }, + ]); + + expect(plan).toContain('"https://example.com"'); + }); + + it('should mask secret application variable values in update diffs', () => { + const plan = formatSyncActionsPlan([ + { + type: 'update', + metadataName: 'applicationVariable', + universalIdentifier: 'var-1', + flatEntity: { name: 'API_KEY', isSecret: true }, + diff: { value: { before: 'old-secret', after: 'new-secret' } }, + }, + ]); + + expect(plan).toContain('~ value = (secret) -> (secret)'); + expect(plan).not.toContain('old-secret'); + expect(plan).not.toContain('new-secret'); + }); + + it('should fail closed and mask application variable values when secrecy is unknown', () => { + const plan = formatSyncActionsPlan([ + { + type: 'create', + metadataName: 'applicationVariable', + flatEntity: { name: 'API_KEY', value: 'maybe-secret' }, + }, + ]); + + expect(plan).toContain('(secret)'); + expect(plan).not.toContain('maybe-secret'); + }); + + it('should fail closed for application variable value updates missing isSecret metadata', () => { + const plan = formatSyncActionsPlan([ + { + type: 'update', + metadataName: 'applicationVariable', + universalIdentifier: 'var-1', + diff: { value: { before: 'old-secret', after: 'new-secret' } }, + }, + ]); + + expect(plan).toContain('~ value = (secret) -> (secret)'); + expect(plan).not.toContain('old-secret'); + expect(plan).not.toContain('new-secret'); + }); + + it('should show the value when an update marks the variable non-secret', () => { + const plan = formatSyncActionsPlan([ + { + type: 'update', + metadataName: 'applicationVariable', + universalIdentifier: 'var-1', + diff: { + value: { before: 'a', after: 'b' }, + isSecret: { before: true, after: false }, + }, + }, + ]); + + expect(plan).toContain('"a" -> "b"'); + expect(plan).not.toContain('(secret)'); + }); +}); + +describe('formatValue', () => { + it('should quote strings', () => { + expect(formatValue('rocket')).toBe('"rocket"'); + }); + + it('should render booleans and numbers without quotes', () => { + expect(formatValue(true)).toBe('true'); + expect(formatValue(42)).toBe('42'); + }); + + it('should render null and undefined as null', () => { + expect(formatValue(null)).toBe('null'); + expect(formatValue(undefined)).toBe('null'); + }); + + it('should compactly JSON-encode arrays', () => { + expect(formatValue([{ value: 'A' }])).toBe('[{"value":"A"}]'); + }); + + it('should truncate long values', () => { + const long = 'x'.repeat(200); + const rendered = formatValue(long); + + expect(rendered.length).toBeLessThanOrEqual(81); + expect(rendered.endsWith('…')).toBe(true); + }); +}); + +describe('selectEntityAttributes', () => { + it('should return an empty array when flatEntity is missing', () => { + expect(selectEntityAttributes(undefined)).toEqual([]); + }); +}); + +describe('hasDestructiveActions and countDestructiveActions', () => { + const actions: SyncAction[] = [ + { + type: 'create', + metadataName: 'fieldMetadata', + flatEntity: { name: 'a' }, + }, + { + type: 'delete', + metadataName: 'fieldMetadata', + universalIdentifier: 'b', + flatEntity: { name: 'b' }, + }, + { + type: 'delete', + metadataName: 'objectMetadata', + universalIdentifier: 'c', + flatEntity: { nameSingular: 'c' }, + }, + ]; + + const viewDeleteOnly: SyncAction[] = [ + { + type: 'delete', + metadataName: 'view', + universalIdentifier: 'v', + flatEntity: { name: 'v' }, + }, + ]; + + it('should detect object and field deletes as destructive', () => { + expect(hasDestructiveActions(actions)).toBe(true); + expect(hasDestructiveActions([])).toBe(false); + expect(hasDestructiveActions(undefined)).toBe(false); + }); + + it('should not treat non-object/field deletes as destructive', () => { + expect(hasDestructiveActions(viewDeleteOnly)).toBe(false); + expect(countDestructiveActions(viewDeleteOnly)).toBe(0); + }); + + it('should count only object and field deletes', () => { + expect(countDestructiveActions(actions)).toBe(2); + expect(countDestructiveActions(undefined)).toBe(0); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-plan.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-plan.ts new file mode 100644 index 0000000000..0f9043fee0 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-plan.ts @@ -0,0 +1,297 @@ +import { DESTRUCTIVE_METADATA_NAMES } from '@/cli/constants/destructive-metadata-names'; +import { getFlatEntityName } from '@/cli/utilities/dev/orchestrator/steps/get-flat-entity-name'; +import chalk from 'chalk'; +import { type SyncAction } from 'twenty-shared/metadata'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; + +const MAX_VALUE_LENGTH = 80; + +const DENIED_ATTRIBUTE_KEYS = new Set([ + 'id', + 'createdAt', + 'updatedAt', + 'deletedAt', + 'universalIdentifier', + '__typename', +]); + +const ACTION_TYPE_ORDER = { create: 0, update: 1, delete: 2 } as const; + +const SIGN_BY_TYPE = { create: '+', update: '~', delete: '-' } as const; + +const VERB_BY_TYPE = { + create: 'will be created', + update: 'will be updated in-place', + delete: 'will be destroyed', +} as const; + +const isDestructiveAction = (action: SyncAction): boolean => + action.type === 'delete' && + DESTRUCTIVE_METADATA_NAMES.has(action.metadataName); + +export const hasDestructiveActions = ( + actions: SyncAction[] | undefined, +): boolean => (actions ?? []).some(isDestructiveAction); + +export const countDestructiveActions = ( + actions: SyncAction[] | undefined, +): number => (actions ?? []).filter(isDestructiveAction).length; + +const truncate = (value: string): string => + value.length > MAX_VALUE_LENGTH + ? `${value.slice(0, MAX_VALUE_LENGTH - 1)}…` + : value; + +export const formatValue = (value: unknown): string => { + if (!isDefined(value)) { + return 'null'; + } + + if (typeof value === 'boolean' || typeof value === 'number') { + return String(value); + } + + return truncate(JSON.stringify(value)); +}; + +const MASKED_VALUE = '(secret)'; + +const getApplicationVariableSecrecy = ( + action: SyncAction, +): boolean | undefined => { + const fromFlatEntity = action.flatEntity?.isSecret; + + if (typeof fromFlatEntity === 'boolean') { + return fromFlatEntity; + } + + if (action.type === 'update') { + const fromDiff = action.diff?.isSecret?.after; + + if (typeof fromDiff === 'boolean') { + return fromDiff; + } + } + + return undefined; +}; + +const shouldMaskValue = (action: SyncAction, key: string): boolean => { + if (action.metadataName !== 'applicationVariable' || key !== 'value') { + return false; + } + + return getApplicationVariableSecrecy(action) !== false; +}; + +export const selectEntityAttributes = ( + flatEntity: SyncAction['flatEntity'], +): [string, unknown][] => { + if (!isDefined(flatEntity)) { + return []; + } + + return Object.entries(flatEntity) + .filter( + ([key, value]) => + !DENIED_ATTRIBUTE_KEYS.has(key) && + !key.endsWith('Id') && + isDefined(value), + ) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)); +}; + +const getActionEntityName = (action: SyncAction): string => + getFlatEntityName(action.flatEntity) ?? + (action.type === 'create' ? 'unknown' : action.universalIdentifier); + +const sortActionsByGroupThenType = (actions: SyncAction[]): SyncAction[] => { + const firstAppearanceByMetadataName = new Map(); + + actions.forEach((action, position) => { + if (!firstAppearanceByMetadataName.has(action.metadataName)) { + firstAppearanceByMetadataName.set(action.metadataName, position); + } + }); + + return [...actions].sort((a, b) => { + const groupA = firstAppearanceByMetadataName.get(a.metadataName) ?? 0; + const groupB = firstAppearanceByMetadataName.get(b.metadataName) ?? 0; + + if (groupA !== groupB) { + return groupA - groupB; + } + + const typeA = ACTION_TYPE_ORDER[a.type]; + const typeB = ACTION_TYPE_ORDER[b.type]; + + if (typeA !== typeB) { + return typeA - typeB; + } + + return getActionEntityName(a).localeCompare(getActionEntityName(b)); + }); +}; + +const colorizeByType = (type: SyncAction['type'], text: string): string => { + if (type === 'create') { + return chalk.green(text); + } + + if (type === 'update') { + return chalk.yellow(text); + } + + return chalk.red(text); +}; + +const formatBlockHeader = (action: SyncAction): string => + chalk.bold( + ` # ${action.metadataName} "${getActionEntityName(action)}" ${VERB_BY_TYPE[action.type]}`, + ); + +const formatEntityAttributeLines = ( + action: Extract, +): string[] => { + const entries = selectEntityAttributes(action.flatEntity); + + if (entries.length === 0) { + if (action.type === 'delete') { + return [ + colorizeByType( + 'delete', + ` - name = ${formatValue(getActionEntityName(action))}`, + ), + ]; + } + + return [colorizeByType('create', ' + (no attributes to display)')]; + } + + const padding = Math.max(...entries.map(([key]) => key.length)); + const sign = SIGN_BY_TYPE[action.type]; + + return entries.map(([key, value]) => + colorizeByType( + action.type, + ` ${sign} ${key.padEnd(padding)} = ${ + shouldMaskValue(action, key) ? MASKED_VALUE : formatValue(value) + }`, + ), + ); +}; + +const formatUpdateAttributeLines = ( + action: Extract, +): string[] => { + const diff = action.diff; + + if (!isDefined(diff)) { + return []; + } + + const keys = Object.keys(diff).sort((a, b) => a.localeCompare(b)); + + if (keys.length === 0) { + return []; + } + + const padding = Math.max(...keys.map((key) => key.length)); + + return keys.map((key) => { + const masked = shouldMaskValue(action, key); + const before = masked ? MASKED_VALUE : formatValue(diff[key].before); + const after = masked ? MASKED_VALUE : formatValue(diff[key].after); + + return ` ${chalk.yellow('~')} ${chalk.yellow(key.padEnd(padding))} = ${chalk.red(before)} ${chalk.dim('->')} ${chalk.green(after)}`; + }); +}; + +const formatBlock = (action: SyncAction): string | null => { + if (action.type === 'update') { + const attributeLines = formatUpdateAttributeLines(action); + + if (attributeLines.length === 0) { + return null; + } + + return [formatBlockHeader(action), ...attributeLines].join('\n'); + } + + return [ + formatBlockHeader(action), + ...formatEntityAttributeLines(action), + ].join('\n'); +}; + +const formatFooter = (counts: { + create: number; + update: number; + delete: number; +}): string => + `Plan: ${chalk.green(`${counts.create} to add`)}, ${chalk.yellow( + `${counts.update} to change`, + )}, ${chalk.red(`${counts.delete} to destroy`)}.`; + +const formatDestructiveWarning = (actions: SyncAction[]): string => { + const deletes = actions.filter(isDestructiveAction); + + const lines = [ + chalk.red.bold( + `Warning: ${deletes.length} destructive change(s) will permanently delete data.`, + ), + ]; + + for (const action of deletes) { + const detail = + action.metadataName === 'objectMetadata' + ? 'drops the table and all its rows' + : action.metadataName === 'fieldMetadata' + ? 'drops the column and its data' + : 'will be removed'; + + lines.push( + chalk.red( + ` - ${action.metadataName} "${getActionEntityName(action)}" — ${detail}`, + ), + ); + } + + lines.push( + chalk.red('Destroys are irreversible. Review carefully before applying.'), + ); + + return lines.join('\n'); +}; + +export const formatSyncActionsPlan = ( + actions: SyncAction[] | undefined, +): string => { + if (!isNonEmptyArray(actions)) { + return 'No changes. Twenty metadata matches your manifest.'; + } + + const counts = { create: 0, update: 0, delete: 0 }; + + for (const action of actions) { + counts[action.type] += 1; + } + + const blocks = sortActionsByGroupThenType(actions) + .map(formatBlock) + .filter(isDefined); + + const sections = [ + chalk.bold('Twenty will perform the following actions:'), + '', + blocks.join('\n\n'), + '', + formatFooter(counts), + ]; + + if (hasDestructiveActions(actions)) { + sections.push('', formatDestructiveWarning(actions)); + } + + return sections.join('\n'); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary.ts index fa1352bd7e..ef4ea60ee5 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary.ts @@ -1,4 +1,5 @@ import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; +import { getFlatEntityName } from '@/cli/utilities/dev/orchestrator/steps/get-flat-entity-name'; import { type SyncAction } from 'twenty-shared/metadata'; import { isDefined } from 'twenty-shared/utils'; @@ -10,18 +11,6 @@ const VERB_BY_TYPE = { delete: 'deleted', } as const; -const getFlatEntityName = ( - flatEntity: SyncAction['flatEntity'], -): string | null => { - const universalIdentifier = flatEntity?.universalIdentifier; - - return ( - flatEntity?.name ?? - flatEntity?.nameSingular ?? - (typeof universalIdentifier === 'string' ? universalIdentifier : null) - ); -}; - const getEntityLabel = (action: SyncAction): string => { if (action.type === 'create') { return getFlatEntityName(action.flatEntity) ?? 'unknown'; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/get-flat-entity-name.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/get-flat-entity-name.ts new file mode 100644 index 0000000000..f77b64b60f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/get-flat-entity-name.ts @@ -0,0 +1,13 @@ +import { type SyncAction } from 'twenty-shared/metadata'; + +export const getFlatEntityName = ( + flatEntity: SyncAction['flatEntity'], +): string | null => { + const universalIdentifier = flatEntity?.universalIdentifier; + + return ( + flatEntity?.name ?? + flatEntity?.nameSingular ?? + (typeof universalIdentifier === 'string' ? universalIdentifier : null) + ); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts index 59f6dd3eb5..75f5aa5169 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts @@ -7,10 +7,15 @@ import { type OrchestratorStateStepEvent, type OrchestratorStateSyncStatus, } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; +import { + countDestructiveActions, + hasDestructiveActions, +} from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-plan'; import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary'; import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors'; import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint'; import { type Manifest } from 'twenty-shared/application'; +import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata'; export type SyncApplicationOrchestratorStepOutput = { syncStatus: OrchestratorStateSyncStatus; @@ -22,22 +27,34 @@ export class SyncApplicationOrchestratorStep { private state: OrchestratorState; private notify: () => void; private verbose: boolean; + private force: boolean; + private interactive: boolean; + private onExit?: (params: { code: number; message: string }) => void; constructor({ apiService, state, notify, verbose, + force, + interactive, + onExit, }: { apiService: ApiService; state: OrchestratorState; notify: () => void; verbose?: boolean; + force?: boolean; + interactive?: boolean; + onExit?: (params: { code: number; message: string }) => void; }) { this.apiService = apiService; this.state = state; this.notify = notify; this.verbose = verbose ?? false; + this.force = force ?? false; + this.interactive = interactive ?? false; + this.onExit = onExit; } async execute(input: { @@ -65,14 +82,38 @@ export class SyncApplicationOrchestratorStep { message: 'Manifest saved to output directory', status: 'info', }); + + if (!this.force) { + events.push({ message: 'Computing metadata plan', status: 'info' }); + + const planResult = await this.apiService.syncApplication(manifest, { + dryRun: true, + }); + + if (!planResult.success) { + this.applyFailure(planResult, events); + + return; + } + + if (hasDestructiveActions(planResult.data.actions)) { + const stopped = await this.gateDestructiveChange( + countDestructiveActions(planResult.data.actions), + events, + ); + + if (stopped) { + return; + } + } + } + events.push({ message: 'Syncing manifest', status: 'info' }); const syncResult = await this.apiService.syncApplication(manifest); if (syncResult.success) { - const syncData = syncResult.data; - - events.push(...formatSyncActionsSummary(syncData.actions)); + events.push(...formatSyncActionsSummary(syncResult.data.actions)); events.push({ message: '✓ Synced', status: 'success' }); step.output = { syncStatus: 'synced', error: null }; step.status = 'done'; @@ -83,9 +124,60 @@ export class SyncApplicationOrchestratorStep { return; } + this.applyFailure(syncResult, events); + } + + private async gateDestructiveChange( + deleteCount: number, + events: OrchestratorStateStepEvent[], + ): Promise { + const step = this.state.steps.syncApplication; + + const stop = (eventMessage: string, exitMessage: string): void => { + events.push({ message: eventMessage, status: 'warning' }); + step.output = { syncStatus: 'idle', error: null }; + step.status = 'done'; + this.state.updatePipeline({ status: 'idle', error: null }); + this.state.applyStepEvents(events); + this.onExit?.({ code: 1, message: exitMessage }); + }; + + if (!this.interactive) { + stop( + `${deleteCount} destructive change(s) require --force`, + `Stopping: ${deleteCount} destructive change(s) need confirmation. Re-run with \`yarn twenty dev --force\` to apply deletions.`, + ); + + return true; + } + + this.state.applyStepEvents(events); + events.length = 0; + + const approved = + await this.state.requestDestructiveConfirmation(deleteCount); + + if (!approved) { + stop( + `Declined ${deleteCount} destructive change(s)`, + `Stopping: declined ${deleteCount} destructive change(s). Re-run with \`yarn twenty dev --force\` to apply deletions or \`yarn twenty plan\` to preview changes.`, + ); + + return true; + } + + return false; + } + + private applyFailure( + result: { error?: MetadataValidationErrorResponse; message?: string }, + events: OrchestratorStateStepEvent[], + ): void { + const step = this.state.steps.syncApplication; + const errorEvents = this.verbose ? null - : formatManifestValidationErrors(syncResult.error); + : formatManifestValidationErrors(result.error); if (errorEvents) { events.push(...errorEvents); @@ -95,12 +187,12 @@ export class SyncApplicationOrchestratorStep { }); } else { events.push({ - message: `Sync failed with error: ${syncResult.message ?? 'Sync failed'}`, + message: `Sync failed with error: ${result.message ?? 'Sync failed'}`, status: 'error', }); } - const recoveryHint = getSyncErrorRecoveryHint(syncResult.message); + const recoveryHint = getSyncErrorRecoveryHint(result.message); if (recoveryHint) { events.push({ message: recoveryHint, status: 'info' }); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-confirm.tsx b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-confirm.tsx new file mode 100644 index 0000000000..35b99e4811 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-confirm.tsx @@ -0,0 +1,57 @@ +import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; +import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context'; +import React from 'react'; + +export const DevUiConfirm = ({ + state, +}: { + state: OrchestratorState; +}): React.ReactElement | null => { + const { Box, Text, useInput } = useInk(); + const pending = state.pendingConfirmation; + + useInput((input, key) => { + if (!state.pendingConfirmation) { + return; + } + + if (input.toLowerCase() === 'y') { + state.resolveDestructiveConfirmation(true); + + return; + } + + if (input.toLowerCase() === 'n' || key.escape) { + state.resolveDestructiveConfirmation(false); + } + }); + + if (!pending) { + return null; + } + + return ( + + + ⚠ {pending.deleteCount} destructive change(s) detected + + + Apply and DESTROY {pending.deleteCount} metadata entity(ies)? Press{' '} + + y + {' '} + to apply,{' '} + + n + {' '} + to cancel and stop watching. + + + ); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui.tsx b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui.tsx index a089e33ad6..3cb8d38d23 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui.tsx +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui.tsx @@ -3,6 +3,7 @@ import { type OrchestratorStateSyncStatus, } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; import { DevUiApplicationPanel } from '@/cli/utilities/dev/ui/components/dev-ui-application-panel'; +import { DevUiConfirm } from '@/cli/utilities/dev/ui/components/dev-ui-confirm'; import { DevUiEntityLegend } from '@/cli/utilities/dev/ui/components/dev-ui-entity-section'; import { DevUiEventItem } from '@/cli/utilities/dev/ui/components/dev-ui-event-log'; import { InkProvider, useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context'; @@ -90,6 +91,8 @@ const DevUI = ({ {verbose && } + + {state.pendingConfirmation && } ); }; @@ -99,10 +102,10 @@ export const renderDevUI = async ( verbose = false, ): Promise<{ unmount: () => void }> => { const ink = await import('ink'); - const { render, Box, Text, Static } = ink; + const { render, Box, Text, Static, useInput } = ink; const { unmount } = render( - + , ); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-ink-context.tsx b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-ink-context.tsx index 3d743bb99d..c69b86c366 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-ink-context.tsx +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-ink-context.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import type { Box, Text, Static } from 'ink'; +import type { Box, Text, Static, useInput } from 'ink'; type InkComponents = { Box: typeof Box; Text: typeof Text; Static: typeof Static; + useInput: typeof useInput; }; const InkContext = React.createContext(null);