feat(twenty-sdk): terraform-style plan/apply for app metadata sync (#22372)
## What & why Syncing a Twenty app's metadata is destructive (removing a field/object drops the backing column/table), but the only preview was `dev --once --dry-run`, which collapsed every change into one line per entity — no before/after, no color, no destructive warning, and no confirmation before a real sync. This introduces a `terraform plan`-style flow. The server's `syncApplication(manifest, dryRun)` already returns a complete `SyncAction[]` (create/update/delete with per-attribute `before`/`after`), so this is a CLI-only change — **no server changes**. ## Command surface `plan` previews, `apply` applies; `dev` is the watch wrapper over the same engine. | Command | Behavior | | --- | --- | | `twenty plan [appPath]` | Render the full plan, read-only | | `twenty apply [appPath]` | Plan → confirm on destructive → apply | | `twenty dev --once` | **Deprecated** alias of `twenty apply` (still works, warns) | | `twenty dev --once --dry-run` | **Deprecated** alias of `twenty plan` (still works, warns) | | `twenty dev` (watch) | Compact summary; inline `[y/N]` confirm on destructive saves | | `-f, --force` | Skip the destructive gate (on `apply` and `dev`) | ## Plan output ``` Twenty will perform the following actions: # objectMetadata "rocket" will be created + nameSingular = "rocket" + labelSingular = "Rocket" # fieldMetadata "name" will be updated in-place ~ label = "Name" -> "Launch name" ~ isNullable = true -> false # fieldMetadata "legacyCode" will be destroyed - name = "legacyCode" Plan: 1 to add, 1 to change, 1 to destroy. Warning: 1 destructive change(s) will permanently delete data. - fieldMetadata "legacyCode" — drops the column and its data Destroys are irreversible. Review carefully before applying. ``` Grouped by metadata type, ordered create → update → destroy, `=` aligned per block. Internal keys (`id`, `workspaceId`, `*Id`, timestamps, nulls) are filtered; updates show only changed keys via the server `diff`. ## Destructive safety gate The server applies the manifest diff atomically, so every apply path computes the plan read-only first, then decides whether to apply: - **`twenty apply` / `dev --once`** — interactive `y/N` prompt when the plan deletes metadata; `--force` skips; **fails closed** (exit 1) in CI / non-TTY. - **`dev` (watch)** — creates/updates auto-apply with the compact summary; a save that deletes metadata shows an inline `y/N` prompt in the Ink UI. **Declining cleanly stops the watch** (exit 1) rather than leaving the session in a nagging/blocked state — since the atomic apply would otherwise also block the additive changes on every subsequent save until resolved. `dev --force` applies deletions without asking. ## Notes - `twenty apply` / `dev --once` now do one extra **read-only** dry-run before applying (to compute the plan + gate). `--force` skips it. - The watch sync step now skips API-client regeneration on any non-synced outcome (error or decline), avoiding a partial client write during shutdown. - The Ink watch UI keeps its existing compact summary; the full plan renders only on the plain-console surfaces — `dev` watch output is unchanged in the common case. ## Test plan - `npx nx typecheck twenty-sdk` ✓ - `npx nx lint twenty-sdk` ✓ - Unit tests (vitest): renderer (`format-sync-actions-plan.spec.ts`) + confirm gate (`confirm-destructive-apply.spec.ts`); existing summary / sync-step specs still green. - Manual against `simple-app` + a local server: `plan`, `apply` (destructive prompt + `--force` + non-TTY fail-closed), and the `dev` watch inline confirm (incl. decline → stop).
This commit is contained in:
@@ -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<void> {
|
||||
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`,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <ms>', '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')
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const DESTRUCTIVE_METADATA_NAMES = new Set<string>([
|
||||
'objectMetadata',
|
||||
'fieldMetadata',
|
||||
]);
|
||||
@@ -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<boolean>;
|
||||
};
|
||||
|
||||
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<CommandResult<AppDevOnceResult>> => {
|
||||
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 = (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
export const confirmDestructiveApply = async (
|
||||
deleteCount: number,
|
||||
{ force }: { force?: boolean },
|
||||
): Promise<boolean> => {
|
||||
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;
|
||||
};
|
||||
@@ -122,6 +122,9 @@ export class OrchestratorState {
|
||||
entities: Map<string, OrchestratorStateEntityInfo>;
|
||||
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<boolean> {
|
||||
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<OrchestratorStatePipeline>): void {
|
||||
Object.assign(this.pipeline, update);
|
||||
this.notify();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+425
@@ -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);
|
||||
});
|
||||
});
|
||||
+297
@@ -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<string, number>();
|
||||
|
||||
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<SyncAction, { type: 'create' | 'delete' }>,
|
||||
): 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<SyncAction, { type: 'update' }>,
|
||||
): 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');
|
||||
};
|
||||
+1
-12
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
+98
-6
@@ -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<boolean> {
|
||||
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' });
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
borderStyle="classic"
|
||||
borderColor="red"
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color="red" bold>
|
||||
⚠ {pending.deleteCount} destructive change(s) detected
|
||||
</Text>
|
||||
<Text>
|
||||
Apply and DESTROY {pending.deleteCount} metadata entity(ies)? Press{' '}
|
||||
<Text color="green" bold>
|
||||
y
|
||||
</Text>{' '}
|
||||
to apply,{' '}
|
||||
<Text color="red" bold>
|
||||
n
|
||||
</Text>{' '}
|
||||
to cancel and stop watching.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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 = ({
|
||||
<DevUiApplicationPanel state={state} verbose={verbose} />
|
||||
{verbose && <DevUiEntityLegend />}
|
||||
</Box>
|
||||
|
||||
{state.pendingConfirmation && <DevUiConfirm state={state} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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(
|
||||
<InkProvider value={{ Box, Text, Static }}>
|
||||
<InkProvider value={{ Box, Text, Static, useInput }}>
|
||||
<DevUI uiStateManager={uiStateManager} verbose={verbose} />
|
||||
</InkProvider>,
|
||||
);
|
||||
|
||||
@@ -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<InkComponents | null>(null);
|
||||
|
||||
Reference in New Issue
Block a user