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
@@ -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);