Files
twenty/packages/twenty-sdk/src/cli/commands/dev/index.ts
T
Weiko 1a475d0edd 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).
2026-07-01 14:26:28 +02:00

196 lines
5.8 KiB
TypeScript

import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { SyncableEntity } from 'twenty-shared/application';
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';
export const registerDevCommands = (program: Command): void => {
const buildCommand = new AppBuildCommand();
const devCommand = new AppDevCommand();
const devOnceCommand = new AppDevOnceCommand();
const typecheckCommand = new AppTypecheckCommand();
const addCommand = new EntityAddCommand();
const generateClientCommand = new AppGenerateClientCommand();
const i18nExtractCommand = new AppI18nExtractCommand();
const devAction = async (
appPath: string | undefined,
options: {
once?: boolean;
verbose?: boolean;
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 plan` to preview changes.',
),
);
}
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({
appPath: formatPath(appPath),
verbose,
apply: !options.dryRun,
force: options.force,
});
return;
}
await devCommand.execute({
appPath: formatPath(appPath),
verbose,
debounceMs: options.debounceMs
? parseInt(options.debounceMs, 10)
: undefined,
force: options.force,
});
};
program
.command('dev [appPath]')
.description('Build and sync local changes')
.option(
'-o, --once',
'Build and sync once, then exit (deprecated: use `twenty apply`)',
)
.option(
'--dry-run',
'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')
.option('--tarball', 'Also pack into a .tgz tarball')
.action(async (appPath, options) => {
await buildCommand.execute({
appPath: formatPath(appPath),
tarball: options.tarball,
});
});
program
.command('dev:typecheck [appPath]')
.description('Run TypeScript type checking')
.action(async (appPath) => {
await typecheckCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('dev:add [entityType]')
.description(
`Scaffold a new entity (${Object.values(SyncableEntity).join('|')})`,
)
.option('--path <path>', 'Path in which the entity should be created.')
.action(async (entityType?: string, options?: { path?: string }) => {
await addCommand.execute(entityType as SyncableEntity, options?.path);
});
program
.command('dev:catalog-sync')
.description('Trigger marketplace catalog sync')
.option('-r, --remote <name>', 'Sync on a specific remote')
.action(async (options: { remote?: string }) => {
const { CatalogSyncCommand } = await import('./catalog-sync');
const cmd = new CatalogSyncCommand();
await cmd.execute({ remote: options.remote });
});
program
.command('dev:generate-client [appPath]')
.description(
'Generate the typed API client from the active remote (no app definition required)',
)
.action(async (appPath) => {
await generateClientCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('dev:i18n-extract [appPath]')
.description('Extract translatable strings into locales/ catalogs')
.option(
'--locale <locale>',
'Scaffold an empty catalog for a target locale (e.g. fr-FR)',
)
.action(async (appPath, options) => {
await i18nExtractCommand.execute({
appPath: formatPath(appPath),
locale: options.locale,
});
});
registerDevFunctionCommands(program);
};