feat(twenty-sdk): terraform-style plan/apply for app metadata sync (#22372)
## What & why Syncing a Twenty app's metadata is destructive (removing a field/object drops the backing column/table), but the only preview was `dev --once --dry-run`, which collapsed every change into one line per entity — no before/after, no color, no destructive warning, and no confirmation before a real sync. This introduces a `terraform plan`-style flow. The server's `syncApplication(manifest, dryRun)` already returns a complete `SyncAction[]` (create/update/delete with per-attribute `before`/`after`), so this is a CLI-only change — **no server changes**. ## Command surface `plan` previews, `apply` applies; `dev` is the watch wrapper over the same engine. | Command | Behavior | | --- | --- | | `twenty plan [appPath]` | Render the full plan, read-only | | `twenty apply [appPath]` | Plan → confirm on destructive → apply | | `twenty dev --once` | **Deprecated** alias of `twenty apply` (still works, warns) | | `twenty dev --once --dry-run` | **Deprecated** alias of `twenty plan` (still works, warns) | | `twenty dev` (watch) | Compact summary; inline `[y/N]` confirm on destructive saves | | `-f, --force` | Skip the destructive gate (on `apply` and `dev`) | ## Plan output ``` Twenty will perform the following actions: # objectMetadata "rocket" will be created + nameSingular = "rocket" + labelSingular = "Rocket" # fieldMetadata "name" will be updated in-place ~ label = "Name" -> "Launch name" ~ isNullable = true -> false # fieldMetadata "legacyCode" will be destroyed - name = "legacyCode" Plan: 1 to add, 1 to change, 1 to destroy. Warning: 1 destructive change(s) will permanently delete data. - fieldMetadata "legacyCode" — drops the column and its data Destroys are irreversible. Review carefully before applying. ``` Grouped by metadata type, ordered create → update → destroy, `=` aligned per block. Internal keys (`id`, `workspaceId`, `*Id`, timestamps, nulls) are filtered; updates show only changed keys via the server `diff`. ## Destructive safety gate The server applies the manifest diff atomically, so every apply path computes the plan read-only first, then decides whether to apply: - **`twenty apply` / `dev --once`** — interactive `y/N` prompt when the plan deletes metadata; `--force` skips; **fails closed** (exit 1) in CI / non-TTY. - **`dev` (watch)** — creates/updates auto-apply with the compact summary; a save that deletes metadata shows an inline `y/N` prompt in the Ink UI. **Declining cleanly stops the watch** (exit 1) rather than leaving the session in a nagging/blocked state — since the atomic apply would otherwise also block the additive changes on every subsequent save until resolved. `dev --force` applies deletions without asking. ## Notes - `twenty apply` / `dev --once` now do one extra **read-only** dry-run before applying (to compute the plan + gate). `--force` skips it. - The watch sync step now skips API-client regeneration on any non-synced outcome (error or decline), avoiding a partial client write during shutdown. - The Ink watch UI keeps its existing compact summary; the full plan renders only on the plain-console surfaces — `dev` watch output is unchanged in the common case. ## Test plan - `npx nx typecheck twenty-sdk` ✓ - `npx nx lint twenty-sdk` ✓ - Unit tests (vitest): renderer (`format-sync-actions-plan.spec.ts`) + confirm gate (`confirm-destructive-apply.spec.ts`); existing summary / sync-step specs still green. - Manual against `simple-app` + a local server: `plan`, `apply` (destructive prompt + `--force` + non-TTY fail-closed), and the `dev` watch inline confirm (incl. decline → stop).
This commit is contained in:
@@ -1,18 +1,22 @@
|
||||
import { appDevOnce } from '@/cli/operations/dev-once';
|
||||
import { APP_ERROR_CODES } from '@/cli/types';
|
||||
import { ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
|
||||
import { confirmDestructiveApply } from '@/cli/utilities/dev/confirm-destructive-apply';
|
||||
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export type AppDevOnceCommandOptions = {
|
||||
appPath?: string;
|
||||
verbose?: boolean;
|
||||
dryRun?: boolean;
|
||||
apply?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export class AppDevOnceCommand {
|
||||
async execute(options: AppDevOnceCommandOptions): Promise<void> {
|
||||
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
|
||||
const apply = options.apply ?? false;
|
||||
|
||||
await checkSdkVersionCompatibility(appPath);
|
||||
|
||||
@@ -20,7 +24,7 @@ export class AppDevOnceCommand {
|
||||
|
||||
console.log(
|
||||
chalk.blue(
|
||||
`${options.dryRun ? 'Previewing application diff' : 'Syncing application'} on ${remoteName}...`,
|
||||
`${apply ? 'Applying application manifest' : 'Planning application apply'} on ${remoteName}...`,
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(`App path: ${appPath}\n`));
|
||||
@@ -28,19 +32,28 @@ export class AppDevOnceCommand {
|
||||
const result = await appDevOnce({
|
||||
appPath,
|
||||
verbose: options.verbose,
|
||||
dryRun: options.dryRun,
|
||||
apply,
|
||||
force: options.force,
|
||||
onProgress: (message) => console.log(chalk.gray(message)),
|
||||
onPlan: (text) => console.log(`\n${text}\n`),
|
||||
confirmApply: (deleteCount) =>
|
||||
confirmDestructiveApply(deleteCount, { force: options.force }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error.code === APP_ERROR_CODES.APPLY_ABORTED) {
|
||||
console.log(chalk.yellow(result.error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(chalk.red(result.error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
if (!apply) {
|
||||
console.log(
|
||||
chalk.green(
|
||||
`\n✓ Dry run complete for ${result.data.applicationDisplayName} — no changes were applied`,
|
||||
`\n✓ Plan complete for ${result.data.applicationDisplayName} — no changes were applied`,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ import { DevUiStateManager } from '@/cli/utilities/dev/ui/dev-ui-state-manager';
|
||||
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
|
||||
import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility';
|
||||
import { getVersionInfo } from '@/cli/utilities/version/get-version-info';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export type AppDevOptions = {
|
||||
appPath?: string;
|
||||
headless?: boolean;
|
||||
verbose?: boolean;
|
||||
debounceMs?: number;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export class AppDevCommand {
|
||||
@@ -72,6 +74,18 @@ export class AppDevCommand {
|
||||
state: orchestratorState,
|
||||
verbose: options.verbose,
|
||||
debounceMs: options.debounceMs,
|
||||
force: options.force,
|
||||
interactive: !options.headless && process.stdout.isTTY === true,
|
||||
onExit: ({ code, message }) => {
|
||||
if (options.headless) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.close().then(() => {
|
||||
console.log(chalk.yellow(`\n${message}`));
|
||||
process.exit(code);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await this.orchestrator.start();
|
||||
|
||||
@@ -6,10 +6,10 @@ import { EntityAddCommand } from './add';
|
||||
import { AppBuildCommand } from './build';
|
||||
import { AppDevCommand } from './dev';
|
||||
import { AppDevOnceCommand } from './dev-once';
|
||||
import { registerDevFunctionCommands } from './function';
|
||||
import { AppGenerateClientCommand } from './generate-client';
|
||||
import { AppI18nExtractCommand } from './i18n-extract';
|
||||
import { AppTypecheckCommand } from './typecheck';
|
||||
import { registerDevFunctionCommands } from './function';
|
||||
|
||||
export const registerDevCommands = (program: Command): void => {
|
||||
const buildCommand = new AppBuildCommand();
|
||||
@@ -28,34 +28,46 @@ export const registerDevCommands = (program: Command): void => {
|
||||
debug?: boolean;
|
||||
debounceMs?: string;
|
||||
dryRun?: boolean;
|
||||
force?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (options.dryRun && !options.once) {
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
'--dry-run only applies with --once. Ignoring it; run `yarn twenty dev --once --dry-run` to preview changes.',
|
||||
'--dry-run only applies with --once. Ignoring it; run `yarn twenty plan` to preview changes.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const commonOptions = {
|
||||
appPath: formatPath(appPath),
|
||||
verbose: options.verbose || options.debug,
|
||||
debounceMs: options.debounceMs
|
||||
? parseInt(options.debounceMs, 10)
|
||||
: undefined,
|
||||
};
|
||||
const verbose = options.verbose || options.debug;
|
||||
|
||||
if (options.once) {
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
options.dryRun
|
||||
? '⚠ `twenty dev --once --dry-run` is deprecated. Use `twenty plan` instead.'
|
||||
: '⚠ `twenty dev --once` is deprecated. Use `twenty apply` instead.',
|
||||
),
|
||||
);
|
||||
|
||||
await devOnceCommand.execute({
|
||||
...commonOptions,
|
||||
dryRun: options.dryRun,
|
||||
appPath: formatPath(appPath),
|
||||
verbose,
|
||||
apply: !options.dryRun,
|
||||
force: options.force,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await devCommand.execute(commonOptions);
|
||||
await devCommand.execute({
|
||||
appPath: formatPath(appPath),
|
||||
verbose,
|
||||
debounceMs: options.debounceMs
|
||||
? parseInt(options.debounceMs, 10)
|
||||
: undefined,
|
||||
force: options.force,
|
||||
});
|
||||
};
|
||||
|
||||
program
|
||||
@@ -63,17 +75,57 @@ export const registerDevCommands = (program: Command): void => {
|
||||
.description('Build and sync local changes')
|
||||
.option(
|
||||
'-o, --once',
|
||||
'Build and sync once, then exit (useful for CI, scripts, and pre-commit hooks)',
|
||||
'Build and sync once, then exit (deprecated: use `twenty apply`)',
|
||||
)
|
||||
.option(
|
||||
'--dry-run',
|
||||
'Preview the metadata changes without applying them (requires --once)',
|
||||
'Preview the metadata changes without applying them (deprecated: use `twenty plan`)',
|
||||
)
|
||||
.option(
|
||||
'-f, --force',
|
||||
'Apply destructive changes (deletes) without confirmation',
|
||||
)
|
||||
.option('--debounceMs <ms>', 'Debounce in ms (default: 1 000)')
|
||||
.option('-v, --verbose', 'Show detailed logs')
|
||||
.option('-d, --debug', 'Show detailed logs (alias for --verbose)')
|
||||
.action(devAction);
|
||||
|
||||
program
|
||||
.command('plan [appPath]')
|
||||
.description('Preview metadata changes without applying them')
|
||||
.option('-v, --verbose', 'Show detailed logs')
|
||||
.action(
|
||||
async (appPath: string | undefined, options: { verbose?: boolean }) => {
|
||||
await devOnceCommand.execute({
|
||||
appPath: formatPath(appPath),
|
||||
verbose: options.verbose,
|
||||
apply: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
program
|
||||
.command('apply [appPath]')
|
||||
.description('Apply local metadata changes after showing the plan')
|
||||
.option(
|
||||
'-f, --force',
|
||||
'Apply destructive changes (deletes) without confirmation',
|
||||
)
|
||||
.option('-v, --verbose', 'Show detailed logs')
|
||||
.action(
|
||||
async (
|
||||
appPath: string | undefined,
|
||||
options: { force?: boolean; verbose?: boolean },
|
||||
) => {
|
||||
await devOnceCommand.execute({
|
||||
appPath: formatPath(appPath),
|
||||
verbose: options.verbose,
|
||||
apply: true,
|
||||
force: options.force,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
program
|
||||
.command('dev:build [appPath]')
|
||||
.description('Build and generate API client')
|
||||
|
||||
Reference in New Issue
Block a user