feat(app-dev): add dry-run preview to dev sync (#21251)
Split out of #21240. Stacked on #21250 (review/merge that first). `yarn twenty dev --once --dry-run` computes the migration plan and prints the diff **without applying anything** (no migration, no app-record update, no SDK generation). Also renders the diff on a normal `dev --once` sync. <img width="646" height="179" alt="image" src="https://github.com/user-attachments/assets/59f3ddcd-2a5b-4b8a-b21a-c659abe16af0" />
This commit is contained in:
@@ -7,6 +7,7 @@ import chalk from 'chalk';
|
||||
export type AppDevOnceCommandOptions = {
|
||||
appPath?: string;
|
||||
verbose?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export class AppDevOnceCommand {
|
||||
@@ -17,12 +18,17 @@ export class AppDevOnceCommand {
|
||||
|
||||
const remoteName = ConfigService.getActiveRemote();
|
||||
|
||||
console.log(chalk.blue(`Syncing application on ${remoteName}...`));
|
||||
console.log(
|
||||
chalk.blue(
|
||||
`${options.dryRun ? 'Previewing application diff' : 'Syncing application'} on ${remoteName}...`,
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(`App path: ${appPath}\n`));
|
||||
|
||||
const result = await appDevOnce({
|
||||
appPath,
|
||||
verbose: options.verbose,
|
||||
dryRun: options.dryRun,
|
||||
onProgress: (message) => console.log(chalk.gray(message)),
|
||||
});
|
||||
|
||||
@@ -31,6 +37,16 @@ export class AppDevOnceCommand {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(
|
||||
chalk.green(
|
||||
`\n✓ Dry run complete for ${result.data.applicationDisplayName} — no changes were applied`,
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
`\n✓ Synced ${result.data.applicationDisplayName} (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const registerDevCommands = (program: Command): void => {
|
||||
verbose?: boolean;
|
||||
debug?: boolean;
|
||||
debounceMs?: string;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
) => {
|
||||
const commonOptions = {
|
||||
@@ -33,7 +34,10 @@ export const registerDevCommands = (program: Command): void => {
|
||||
};
|
||||
|
||||
if (options.once) {
|
||||
await devOnceCommand.execute(commonOptions);
|
||||
await devOnceCommand.execute({
|
||||
...commonOptions,
|
||||
dryRun: options.dryRun,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -48,6 +52,10 @@ export const registerDevCommands = (program: Command): void => {
|
||||
'-o, --once',
|
||||
'Build and sync once, then exit (useful for CI, scripts, and pre-commit hooks)',
|
||||
)
|
||||
.option(
|
||||
'--dry-run',
|
||||
'Preview the metadata changes without applying them (requires --once)',
|
||||
)
|
||||
.option('--debounceMs <ms>', 'Debounce in ms (default: 2 000)')
|
||||
.option('-v, --verbose', 'Show detailed logs')
|
||||
.option('-d, --debug', 'Show detailed logs (alias for --verbose)')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'path';
|
||||
import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ 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 { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
|
||||
import { serializeError } from '@/cli/utilities/error/serialize-error';
|
||||
import { FileUploader } from '@/cli/utilities/file/file-uploader';
|
||||
@@ -22,6 +24,7 @@ import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
|
||||
export type AppDevOnceOptions = {
|
||||
appPath: string;
|
||||
verbose?: boolean;
|
||||
dryRun?: boolean;
|
||||
onProgress?: (message: string) => void;
|
||||
};
|
||||
|
||||
@@ -32,10 +35,19 @@ export type AppDevOnceResult = {
|
||||
applicationUniversalIdentifier: string;
|
||||
};
|
||||
|
||||
const reportMetadataChanges = (
|
||||
data: { actions: SyncAction[] },
|
||||
onProgress?: (message: string) => void,
|
||||
): void => {
|
||||
for (const event of formatSyncActionsSummary(data.actions)) {
|
||||
onProgress?.(event.message);
|
||||
}
|
||||
};
|
||||
|
||||
const innerAppDevOnce = async (
|
||||
options: AppDevOnceOptions,
|
||||
): Promise<CommandResult<AppDevOnceResult>> => {
|
||||
const { appPath, onProgress, verbose } = options;
|
||||
const { appPath, onProgress, verbose, dryRun } = options;
|
||||
|
||||
onProgress?.('Checking server...');
|
||||
|
||||
@@ -120,6 +132,47 @@ const innerAppDevOnce = async (
|
||||
|
||||
await writeManifestToOutput(appPath, manifest);
|
||||
|
||||
if (dryRun) {
|
||||
onProgress?.(
|
||||
'Computing metadata diff (dry run, nothing will be applied)...',
|
||||
);
|
||||
|
||||
const dryRunResult = 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: ${serializeError(dryRunResult.error)}`;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.SYNC_FAILED,
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
reportMetadataChanges(dryRunResult.data, onProgress);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
outputDir: path.join(appPath, OUTPUT_DIR),
|
||||
fileCount: buildResult.builtFileInfos.size,
|
||||
applicationDisplayName: manifest.application.displayName,
|
||||
applicationUniversalIdentifier:
|
||||
manifest.application.universalIdentifier,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onProgress?.('Registering application...');
|
||||
|
||||
const configService = new ConfigService();
|
||||
@@ -212,6 +265,8 @@ const innerAppDevOnce = async (
|
||||
};
|
||||
}
|
||||
|
||||
reportMetadataChanges(syncResult.data, onProgress);
|
||||
|
||||
onProgress?.('Generating API client...');
|
||||
|
||||
try {
|
||||
|
||||
@@ -73,13 +73,16 @@ export class ApiService {
|
||||
return this.applicationApi.createDevelopmentApplication(...args);
|
||||
}
|
||||
|
||||
syncApplication(manifest: Manifest): Promise<
|
||||
syncApplication(
|
||||
manifest: Manifest,
|
||||
options?: { dryRun?: boolean },
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
}>
|
||||
> {
|
||||
return this.applicationApi.syncApplication(manifest);
|
||||
return this.applicationApi.syncApplication(manifest, options);
|
||||
}
|
||||
|
||||
uninstallApplication(universalIdentifier: string): Promise<ApiResponse> {
|
||||
|
||||
@@ -252,7 +252,10 @@ export class ApplicationApi {
|
||||
}
|
||||
}
|
||||
|
||||
async syncApplication(manifest: Manifest): Promise<
|
||||
async syncApplication(
|
||||
manifest: Manifest,
|
||||
options?: { dryRun?: boolean },
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
@@ -260,15 +263,15 @@ export class ApplicationApi {
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation SyncApplication($manifest: JSON!) {
|
||||
syncApplication(manifest: $manifest) {
|
||||
mutation SyncApplication($manifest: JSON!, $dryRun: Boolean) {
|
||||
syncApplication(manifest: $manifest, dryRun: $dryRun) {
|
||||
applicationUniversalIdentifier
|
||||
actions
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = { manifest };
|
||||
const variables = { manifest, dryRun: options?.dryRun ?? false };
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
'/metadata',
|
||||
|
||||
+6
@@ -10,6 +10,12 @@ describe('formatSyncActionsSummary', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports no changes when actions are missing from the response', () => {
|
||||
expect(formatSyncActionsSummary(undefined)).toEqual([
|
||||
{ message: 'No metadata changes', status: 'info' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('summarizes created, updated and deleted actions with their identifiers', () => {
|
||||
const events = formatSyncActionsSummary([
|
||||
{
|
||||
|
||||
+7
-5
@@ -24,15 +24,17 @@ const getActionLabel = (action: SyncAction): string => {
|
||||
};
|
||||
|
||||
export const formatSyncActionsSummary = (
|
||||
actions: SyncAction[],
|
||||
actions: SyncAction[] | undefined,
|
||||
): OrchestratorStateStepEvent[] => {
|
||||
if (actions.length === 0) {
|
||||
const definedActions = actions ?? [];
|
||||
|
||||
if (definedActions.length === 0) {
|
||||
return [{ message: 'No metadata changes', status: 'info' }];
|
||||
}
|
||||
|
||||
const counts = { create: 0, update: 0, delete: 0 };
|
||||
|
||||
for (const action of actions) {
|
||||
for (const action of definedActions) {
|
||||
counts[action.type] += 1;
|
||||
}
|
||||
|
||||
@@ -46,7 +48,7 @@ export const formatSyncActionsSummary = (
|
||||
{ message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' },
|
||||
];
|
||||
|
||||
const visibleActions = actions.slice(0, MAX_DETAIL_LINES);
|
||||
const visibleActions = definedActions.slice(0, MAX_DETAIL_LINES);
|
||||
|
||||
for (const action of visibleActions) {
|
||||
events.push({
|
||||
@@ -55,7 +57,7 @@ export const formatSyncActionsSummary = (
|
||||
});
|
||||
}
|
||||
|
||||
const hiddenCount = actions.length - visibleActions.length;
|
||||
const hiddenCount = definedActions.length - visibleActions.length;
|
||||
|
||||
if (hiddenCount > 0) {
|
||||
events.push({
|
||||
|
||||
Reference in New Issue
Block a user