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:
Weiko
2026-07-01 14:26:28 +02:00
committed by GitHub
parent 2e6077383b
commit 1a475d0edd
18 changed files with 1254 additions and 110 deletions
@@ -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 = (
+1
View File
@@ -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;
}
@@ -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);
});
});
@@ -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,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)
);
};
@@ -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);