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,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 = (
|
||||
|
||||
Reference in New Issue
Block a user