feat(app-dev): sync error hints, flatEntity labels, dev-mode summary UI, and docs (#21252)
Split out of #21240 — all remaining app-dev improvements. Stacked on #21251 (review/merge that first). - Actionable recovery hints on failed syncs; unified diff renderer; `--dry-run` guard. - Return `flatEntity` on update/delete sync actions and unify the diff label. - Summarize the dev-mode entity list unless `--verbose`. - Docs: syncing & recovery guide + dry-run + open-an-issue prompt. - Live execution mode for synced logic functions; clearer manifest warnings. <img width="1018" height="700" alt="image" src="https://github.com/user-attachments/assets/5e9ce19e-0f1d-4f99-8524-4e118bde932b" />
This commit is contained in:
@@ -45,7 +45,7 @@ export class AppDevCommand {
|
||||
|
||||
orchestratorState.onChange = () => uiStateManager.notify();
|
||||
|
||||
const { unmount } = await renderDevUI(uiStateManager);
|
||||
const { unmount } = await renderDevUI(uiStateManager, options.verbose);
|
||||
|
||||
this.unmountUI = unmount;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -25,6 +26,14 @@ export const registerDevCommands = (program: Command): void => {
|
||||
dryRun?: 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.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const commonOptions = {
|
||||
appPath: formatPath(appPath),
|
||||
verbose: options.verbose || options.debug,
|
||||
@@ -56,7 +65,7 @@ export const registerDevCommands = (program: Command): void => {
|
||||
'--dry-run',
|
||||
'Preview the metadata changes without applying them (requires --once)',
|
||||
)
|
||||
.option('--debounceMs <ms>', 'Debounce in ms (default: 2 000)')
|
||||
.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);
|
||||
|
||||
@@ -16,10 +16,12 @@ 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 { 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 chalk from 'chalk';
|
||||
|
||||
export type AppDevOnceOptions = {
|
||||
appPath: string;
|
||||
@@ -44,6 +46,15 @@ const reportMetadataChanges = (
|
||||
}
|
||||
};
|
||||
|
||||
const appendRecoveryHint = (
|
||||
message: string,
|
||||
errorMessage: string | undefined,
|
||||
): string => {
|
||||
const hint = getSyncErrorRecoveryHint(errorMessage);
|
||||
|
||||
return hint ? `${message}\n\n${hint}` : message;
|
||||
};
|
||||
|
||||
const innerAppDevOnce = async (
|
||||
options: AppDevOnceOptions,
|
||||
): Promise<CommandResult<AppDevOnceResult>> => {
|
||||
@@ -95,7 +106,7 @@ const innerAppDevOnce = async (
|
||||
}
|
||||
|
||||
for (const warning of manifestResult.warnings) {
|
||||
onProgress?.(`⚠ ${warning}`);
|
||||
onProgress?.(chalk.yellow(`⚠ ${warning}`));
|
||||
}
|
||||
|
||||
onProgress?.('Building application files...');
|
||||
@@ -148,13 +159,13 @@ const innerAppDevOnce = async (
|
||||
|
||||
const message = errorEvents
|
||||
? errorEvents.map((event) => event.message).join('\n')
|
||||
: `Dry run failed with error: ${serializeError(dryRunResult.error)}`;
|
||||
: `Dry run failed with error: ${dryRunResult.message ?? 'Unknown error'}`;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.SYNC_FAILED,
|
||||
message,
|
||||
message: appendRecoveryHint(message, dryRunResult.message),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -254,13 +265,13 @@ const innerAppDevOnce = async (
|
||||
|
||||
const message = errorEvents
|
||||
? errorEvents.map((event) => event.message).join('\n')
|
||||
: `Sync failed with error: ${serializeError(syncResult.error)}`;
|
||||
: `Sync failed with error: ${syncResult.message ?? 'Unknown error'}`;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.SYNC_FAILED,
|
||||
message,
|
||||
message: appendRecoveryHint(message, syncResult.message),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
|
||||
import { ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
|
||||
import { serializeError } from '@/cli/utilities/error/serialize-error';
|
||||
import { runSafe } from '@/cli/utilities/run-safe';
|
||||
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
|
||||
|
||||
@@ -40,7 +39,7 @@ const innerAppInstall = async (
|
||||
|
||||
const message = errorEvents
|
||||
? errorEvents.map((event) => event.message).join('\n')
|
||||
: `Install failed with error: ${serializeError(result.error)}`;
|
||||
: `Install failed with error: ${result.message ?? 'Unknown error'}`;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
type SuccessfulApiResponse<T = unknown> = {
|
||||
type SuccessfulApiResponse<TData = unknown> = {
|
||||
success: true;
|
||||
data: T;
|
||||
data: TData;
|
||||
message?: string;
|
||||
};
|
||||
type FailingApiResponse = {
|
||||
type FailingApiResponse<TError = unknown> = {
|
||||
success: false;
|
||||
error?: unknown;
|
||||
error?: TError;
|
||||
message?: string;
|
||||
};
|
||||
export type ApiResponse<T = unknown> =
|
||||
| SuccessfulApiResponse<T>
|
||||
| FailingApiResponse;
|
||||
export type ApiResponse<TData = unknown, TError = unknown> =
|
||||
| SuccessfulApiResponse<TData>
|
||||
| FailingApiResponse<TError>;
|
||||
|
||||
@@ -5,7 +5,10 @@ import { FileApi } from '@/cli/utilities/api/file-api';
|
||||
import { LogicFunctionApi } from '@/cli/utilities/api/logic-function-api';
|
||||
import { SchemaApi } from '@/cli/utilities/api/schema-api';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
import {
|
||||
type MetadataValidationErrorResponse,
|
||||
type SyncAction,
|
||||
} from 'twenty-shared/metadata';
|
||||
|
||||
type ApiServiceOptions = {
|
||||
disableInterceptors?: boolean;
|
||||
@@ -77,10 +80,13 @@ export class ApiService {
|
||||
manifest: Manifest,
|
||||
options?: { dryRun?: boolean },
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
}>
|
||||
ApiResponse<
|
||||
{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
},
|
||||
MetadataValidationErrorResponse
|
||||
>
|
||||
> {
|
||||
return this.applicationApi.syncApplication(manifest, options);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
|
||||
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
|
||||
import { serializeError } from '@/cli/utilities/error/serialize-error';
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
import {
|
||||
type MetadataValidationErrorResponse,
|
||||
type SyncAction,
|
||||
} from 'twenty-shared/metadata';
|
||||
|
||||
export class ApplicationApi {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
@@ -256,10 +260,13 @@ export class ApplicationApi {
|
||||
manifest: Manifest,
|
||||
options?: { dryRun?: boolean },
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
}>
|
||||
ApiResponse<
|
||||
{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
},
|
||||
MetadataValidationErrorResponse
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
@@ -273,7 +280,7 @@ export class ApplicationApi {
|
||||
|
||||
const variables = { manifest, dryRun: options?.dryRun ?? false };
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
const response = await this.client.post(
|
||||
'/metadata',
|
||||
{
|
||||
query: mutation,
|
||||
@@ -290,7 +297,8 @@ export class ApplicationApi {
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.data.errors[0],
|
||||
error: response.data.errors[0]?.extensions,
|
||||
message: response.data.errors[0]?.message,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -300,27 +308,9 @@ export class ApplicationApi {
|
||||
message: `Successfully synced application: ${manifest.application.displayName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
const graphqlErrors = error.response.data?.errors;
|
||||
|
||||
if (Array.isArray(graphqlErrors) && graphqlErrors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: graphqlErrors[0]?.message || error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error.response.data?.message ||
|
||||
`HTTP ${error.response.status}: ${error.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
message: serializeError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -337,7 +327,7 @@ export class ApplicationApi {
|
||||
|
||||
const variables = { universalIdentifier };
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
const response = await this.client.post(
|
||||
'/metadata',
|
||||
{
|
||||
query: mutation,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
|
||||
import { serializeError } from '@/cli/utilities/error/serialize-error';
|
||||
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata';
|
||||
import { type FileFolder } from 'twenty-shared/types';
|
||||
import { pascalCase } from 'twenty-shared/utils';
|
||||
|
||||
@@ -136,7 +138,7 @@ export class FileApi {
|
||||
universalIdentifier,
|
||||
}: {
|
||||
universalIdentifier: string;
|
||||
}): Promise<ApiResponse<boolean>> {
|
||||
}): Promise<ApiResponse<boolean, MetadataValidationErrorResponse>> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation InstallApplication($universalIdentifier: String!) {
|
||||
@@ -163,7 +165,9 @@ export class FileApi {
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.data.errors[0] || 'Failed to install application',
|
||||
error: response.data.errors[0]?.extensions,
|
||||
message:
|
||||
response.data.errors[0]?.message || 'Failed to install application',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,16 +176,9 @@ export class FileApi {
|
||||
data: response.data.data.installApplication,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response.data?.errors?.[0]?.message || error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
message: serializeError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+48
-6
@@ -149,9 +149,6 @@ describe('manifestValidate', () => {
|
||||
expect(result.errors).toContain(
|
||||
'Duplicate universal identifiers: 550e8400-e29b-41d4-a716-446655440001',
|
||||
);
|
||||
expect(result.warnings).toContain('No object defined');
|
||||
expect(result.warnings).toContain('No logic function defined');
|
||||
expect(result.warnings).toContain('No front component defined');
|
||||
});
|
||||
|
||||
it('should fail when extension field ID conflicts with object field ID', () => {
|
||||
@@ -192,9 +189,6 @@ describe('manifestValidate', () => {
|
||||
expect(result.errors).toContain(
|
||||
'Duplicate universal identifiers: 550e8400-e29b-41d4-a716-446655440001',
|
||||
);
|
||||
expect(result.warnings).not.toContain('No object defined');
|
||||
expect(result.warnings).toContain('No logic function defined');
|
||||
expect(result.warnings).toContain('No front component defined');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -366,6 +360,54 @@ describe('manifestValidate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent responseFormat validation', () => {
|
||||
it('should warn for each agent without a responseFormat', () => {
|
||||
const result = manifestValidate({
|
||||
...validManifest,
|
||||
agents: [
|
||||
{
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440040',
|
||||
name: 'agentWithoutFormat',
|
||||
label: 'Agent Without Format',
|
||||
prompt: 'Do something',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440041',
|
||||
name: 'anotherAgentWithoutFormat',
|
||||
label: 'Another Agent Without Format',
|
||||
prompt: 'Do something else',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.warnings).toContain(
|
||||
'Agent "agentWithoutFormat" has no responseFormat defined',
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
'Agent "anotherAgentWithoutFormat" has no responseFormat defined',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn for an agent that has a responseFormat', () => {
|
||||
const result = manifestValidate({
|
||||
...validManifest,
|
||||
agents: [
|
||||
{
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440042',
|
||||
name: 'agentWithFormat',
|
||||
label: 'Agent With Format',
|
||||
prompt: 'Do something',
|
||||
responseFormat: { type: 'text' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.warnings).not.toContain(
|
||||
'Agent "agentWithFormat" has no responseFormat defined',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UUID version validation', () => {
|
||||
it('should pass with UUID v4 identifiers', () => {
|
||||
const result = manifestValidate({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { validate as uuidValidate, version as uuidVersion } from 'uuid';
|
||||
|
||||
import { type FieldManifest, type Manifest } from 'twenty-shared/application';
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
import { isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const MIN_UUID_VERSION = 4;
|
||||
|
||||
@@ -150,20 +150,14 @@ export const manifestValidate = (manifest: Manifest) => {
|
||||
|
||||
if (invalidUniversalIdentifiers.length > 0) {
|
||||
errors.push(
|
||||
`Duplicate universal identifiers: ${invalidUniversalIdentifiers.join(', ')}`,
|
||||
`Invalid universal identifiers: ${invalidUniversalIdentifiers.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNonEmptyArray(manifest.objects)) {
|
||||
warnings.push('No object defined');
|
||||
}
|
||||
|
||||
if (!isNonEmptyArray(manifest.logicFunctions)) {
|
||||
warnings.push('No logic function defined');
|
||||
}
|
||||
|
||||
if (!isNonEmptyArray(manifest.frontComponents)) {
|
||||
warnings.push('No front component defined');
|
||||
for (const agent of manifest.agents) {
|
||||
if (!isDefined(agent.responseFormat)) {
|
||||
warnings.push(`Agent "${agent.name}" has no responseFormat defined`);
|
||||
}
|
||||
}
|
||||
|
||||
const allFields: Pick<
|
||||
|
||||
@@ -42,7 +42,7 @@ export class DevModeOrchestrator {
|
||||
private startWatchersStep: StartWatchersOrchestratorStep;
|
||||
|
||||
constructor(options: DevModeOrchestratorOptions) {
|
||||
this.debounceMs = options.debounceMs ?? 2_000;
|
||||
this.debounceMs = options.debounceMs ?? 1_000;
|
||||
this.state = options.state;
|
||||
this.verbose = options.verbose ?? false;
|
||||
|
||||
|
||||
+8
-2
@@ -9,7 +9,7 @@ import {
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
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 { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
export type SyncApplicationOrchestratorStepOutput = {
|
||||
@@ -95,11 +95,17 @@ export class SyncApplicationOrchestratorStep {
|
||||
});
|
||||
} else {
|
||||
events.push({
|
||||
message: `Sync failed with error: ${serializeError(syncResult.error)}`,
|
||||
message: `Sync failed with error: ${syncResult.message ?? 'Sync failed'}`,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
const recoveryHint = getSyncErrorRecoveryHint(syncResult.message);
|
||||
|
||||
if (recoveryHint) {
|
||||
events.push({ message: recoveryHint, status: 'info' });
|
||||
}
|
||||
|
||||
const summaryMessage = errorEvents ? errorEvents[0].message : 'Sync failed';
|
||||
|
||||
step.output = { syncStatus: 'error', error: summaryMessage };
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type OrchestratorStateEntityInfo } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { summarizeEntityStatuses } from '@/cli/utilities/dev/ui/dev-ui-constants';
|
||||
|
||||
const entity = (
|
||||
name: string,
|
||||
status: OrchestratorStateEntityInfo['status'],
|
||||
): OrchestratorStateEntityInfo => ({ name, path: name, status });
|
||||
|
||||
describe('summarizeEntityStatuses', () => {
|
||||
it('counts every status in display order', () => {
|
||||
const parts = summarizeEntityStatuses([
|
||||
entity('a', 'success'),
|
||||
entity('b', 'success'),
|
||||
entity('c', 'error'),
|
||||
entity('d', 'building'),
|
||||
]);
|
||||
|
||||
expect(parts).toEqual([
|
||||
{ status: 'success', count: 2, label: 'synced' },
|
||||
{ status: 'building', count: 1, label: 'building' },
|
||||
{ status: 'error', count: 1, label: 'error' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns only the non-zero statuses', () => {
|
||||
const parts = summarizeEntityStatuses([
|
||||
entity('a', 'success'),
|
||||
entity('b', 'success'),
|
||||
]);
|
||||
|
||||
expect(parts).toEqual([{ status: 'success', count: 2, label: 'synced' }]);
|
||||
});
|
||||
});
|
||||
+13
-6
@@ -15,6 +15,7 @@ import { useStatusIcon } from '@/cli/utilities/dev/ui/dev-ui-hooks';
|
||||
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
|
||||
import {
|
||||
DevUiEntitySection,
|
||||
DevUiEntitySummary,
|
||||
ENTITY_ORDER,
|
||||
} from '@/cli/utilities/dev/ui/components/dev-ui-entity-section';
|
||||
import { DevUiVersionRow } from '@/cli/utilities/dev/ui/components/dev-ui-version-row';
|
||||
@@ -62,8 +63,10 @@ export const DevUiStepStatusLabel = ({
|
||||
|
||||
export const DevUiApplicationPanel = ({
|
||||
state,
|
||||
verbose = false,
|
||||
}: {
|
||||
state: OrchestratorState;
|
||||
verbose?: boolean;
|
||||
}): React.ReactElement => {
|
||||
const { Box, Text } = useInk();
|
||||
const groupedEntities = groupEntitiesByType(state.entities);
|
||||
@@ -111,13 +114,17 @@ export const DevUiApplicationPanel = ({
|
||||
</Box>
|
||||
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{ENTITY_ORDER.map((type) => {
|
||||
const entities = groupedEntities.get(type) ?? [];
|
||||
{verbose ? (
|
||||
ENTITY_ORDER.map((type) => {
|
||||
const entities = groupedEntities.get(type) ?? [];
|
||||
|
||||
return (
|
||||
<DevUiEntitySection key={type} type={type} entities={entities} />
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<DevUiEntitySection key={type} type={type} entities={entities} />
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DevUiEntitySummary entities={Array.from(state.entities.values())} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
UPLOAD_FRAMES,
|
||||
mapFileStatusToDevUiStatus,
|
||||
shortenPath,
|
||||
summarizeEntityStatuses,
|
||||
} from '@/cli/utilities/dev/ui/dev-ui-constants';
|
||||
import { useStatusIcon } from '@/cli/utilities/dev/ui/dev-ui-hooks';
|
||||
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
|
||||
@@ -67,6 +68,35 @@ export const DevUiEntitySection = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const DevUiEntitySummary = ({
|
||||
entities,
|
||||
}: {
|
||||
entities: OrchestratorStateEntityInfo[];
|
||||
}): React.ReactElement | null => {
|
||||
const { Box, Text } = useInk();
|
||||
|
||||
if (entities.length === 0) return null;
|
||||
|
||||
const parts = summarizeEntityStatuses(entities);
|
||||
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text bold dimColor>
|
||||
Entities{' '}
|
||||
</Text>
|
||||
{parts.map((part, index) => (
|
||||
<Box key={part.status}>
|
||||
{index > 0 && <Text dimColor> · </Text>}
|
||||
<DevUiStatusIcon uiStatus={mapFileStatusToDevUiStatus(part.status)} />
|
||||
<Text>
|
||||
{part.count} {part.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const DevUiEntityLegend = (): React.ReactElement => {
|
||||
const { Box, Text } = useInk();
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ const SETTLE_DELAY_MS = 80;
|
||||
|
||||
const DevUI = ({
|
||||
uiStateManager,
|
||||
verbose,
|
||||
}: {
|
||||
uiStateManager: DevUiStateManager;
|
||||
verbose: boolean;
|
||||
}): React.ReactElement => {
|
||||
const { Box, Static } = useInk();
|
||||
|
||||
@@ -85,8 +87,8 @@ const DevUI = ({
|
||||
</Static>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<DevUiApplicationPanel state={state} />
|
||||
<DevUiEntityLegend />
|
||||
<DevUiApplicationPanel state={state} verbose={verbose} />
|
||||
{verbose && <DevUiEntityLegend />}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
@@ -94,15 +96,15 @@ const DevUI = ({
|
||||
|
||||
export const renderDevUI = async (
|
||||
uiStateManager: DevUiStateManager,
|
||||
verbose = false,
|
||||
): Promise<{ unmount: () => void }> => {
|
||||
const ink = await import('ink');
|
||||
const { render, Box, Text, Static } = ink;
|
||||
|
||||
const { unmount } = render(
|
||||
<InkProvider value={{ Box, Text, Static }}>
|
||||
<DevUI uiStateManager={uiStateManager} />
|
||||
<DevUI uiStateManager={uiStateManager} verbose={verbose} />
|
||||
</InkProvider>,
|
||||
{ incrementalRendering: true },
|
||||
);
|
||||
|
||||
return { unmount };
|
||||
|
||||
@@ -78,20 +78,9 @@ export const SYNC_STATUS_LABELS: Record<OrchestratorStateSyncStatus, string> = {
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
export const SPINNER_FRAMES = [
|
||||
'⠋',
|
||||
'⠙',
|
||||
'⠹',
|
||||
'⠸',
|
||||
'⠼',
|
||||
'⠴',
|
||||
'⠦',
|
||||
'⠧',
|
||||
'⠇',
|
||||
'⠏',
|
||||
];
|
||||
export const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
|
||||
|
||||
export const UPLOAD_FRAMES = ['↑', '⇡', '↟', '⤒'];
|
||||
export const UPLOAD_FRAMES = ['↑', '⇡', '↑', '⇡'];
|
||||
|
||||
export const ENTITY_LABELS: Record<SyncableEntity, string> = {
|
||||
[SyncableEntity.Object]: 'Objects',
|
||||
@@ -158,6 +147,43 @@ export const groupEntitiesByType = (
|
||||
return grouped;
|
||||
};
|
||||
|
||||
export type DevUiEntityStatusSummaryPart = {
|
||||
status: OrchestratorStateFileStatus;
|
||||
count: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const ENTITY_STATUS_SUMMARY_ORDER: {
|
||||
status: OrchestratorStateFileStatus;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ status: 'success', label: 'synced' },
|
||||
{ status: 'building', label: 'building' },
|
||||
{ status: 'uploading', label: 'uploading' },
|
||||
{ status: 'pending', label: 'pending' },
|
||||
{ status: 'error', label: 'error' },
|
||||
];
|
||||
|
||||
export const summarizeEntityStatuses = (
|
||||
entities: OrchestratorStateEntityInfo[],
|
||||
): DevUiEntityStatusSummaryPart[] => {
|
||||
const counts: Record<OrchestratorStateFileStatus, number> = {
|
||||
pending: 0,
|
||||
building: 0,
|
||||
uploading: 0,
|
||||
success: 0,
|
||||
error: 0,
|
||||
};
|
||||
|
||||
for (const entity of entities) {
|
||||
counts[entity.status] += 1;
|
||||
}
|
||||
|
||||
return ENTITY_STATUS_SUMMARY_ORDER.filter(
|
||||
({ status }) => counts[status] > 0,
|
||||
).map(({ status, label }) => ({ status, count: counts[status], label }));
|
||||
};
|
||||
|
||||
export const getApplicationUrl = (state: OrchestratorState): string | null => {
|
||||
const applicationId = state.steps.resolveApplication.output.applicationId;
|
||||
|
||||
|
||||
+136
-150
@@ -1,59 +1,42 @@
|
||||
import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata';
|
||||
|
||||
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
|
||||
|
||||
describe('formatManifestValidationErrors', () => {
|
||||
it('should return null for null input', () => {
|
||||
expect(formatManifestValidationErrors(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for undefined input', () => {
|
||||
expect(formatManifestValidationErrors(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-object input', () => {
|
||||
expect(formatManifestValidationErrors('string error')).toBeNull();
|
||||
expect(formatManifestValidationErrors(42)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when extensions is missing', () => {
|
||||
expect(formatManifestValidationErrors({ message: 'error' })).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when extensions.errors is missing', () => {
|
||||
it('should return null when errors or summary is missing', () => {
|
||||
expect(
|
||||
formatManifestValidationErrors({
|
||||
extensions: { summary: { totalErrors: 1 } },
|
||||
}),
|
||||
formatManifestValidationErrors({} as MetadataValidationErrorResponse),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when extensions.summary is missing', () => {
|
||||
expect(
|
||||
formatManifestValidationErrors({
|
||||
extensions: { errors: {} },
|
||||
}),
|
||||
summary: { totalErrors: 1 },
|
||||
} as MetadataValidationErrorResponse),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should format a single error', () => {
|
||||
const events = formatManifestValidationErrors({
|
||||
extensions: {
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'field-uuid-1',
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_NAME',
|
||||
message: 'Field name is invalid',
|
||||
},
|
||||
],
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
type: 'fieldMetadata',
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'field-uuid-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 1, totalErrors: 1 },
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_NAME',
|
||||
message: 'Field name is invalid',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 1, totalErrors: 1 },
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
@@ -73,24 +56,26 @@ describe('formatManifestValidationErrors', () => {
|
||||
|
||||
it('should format multiple errors across metadata types', () => {
|
||||
const events = formatManifestValidationErrors({
|
||||
extensions: {
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
errors: [
|
||||
{ code: 'ERR_1', message: 'First error' },
|
||||
{ code: 'ERR_2', message: 'Second error' },
|
||||
],
|
||||
},
|
||||
],
|
||||
objectMetadata: [
|
||||
{
|
||||
errors: [{ code: 'ERR_3', message: 'Third error' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 2, objectMetadata: 1, totalErrors: 3 },
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
type: 'fieldMetadata',
|
||||
flatEntityMinimalInformation: {},
|
||||
errors: [
|
||||
{ code: 'ERR_1', message: 'First error' },
|
||||
{ code: 'ERR_2', message: 'Second error' },
|
||||
],
|
||||
},
|
||||
],
|
||||
objectMetadata: [
|
||||
{
|
||||
type: 'objectMetadata',
|
||||
flatEntityMinimalInformation: {},
|
||||
errors: [{ code: 'ERR_3', message: 'Third error' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 2, objectMetadata: 1, totalErrors: 3 },
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
@@ -104,50 +89,51 @@ describe('formatManifestValidationErrors', () => {
|
||||
|
||||
it('should format errors with details for both objectMetadata and fieldMetadata', () => {
|
||||
const events = formatManifestValidationErrors({
|
||||
extensions: {
|
||||
errors: {
|
||||
objectMetadata: [
|
||||
{
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'obj-uuid-1',
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
code: 'DUPLICATE_NAME',
|
||||
message: 'An object with this name already exists',
|
||||
value: 'postCard',
|
||||
},
|
||||
],
|
||||
errors: {
|
||||
objectMetadata: [
|
||||
{
|
||||
type: 'objectMetadata',
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'obj-uuid-1',
|
||||
},
|
||||
],
|
||||
fieldMetadata: [
|
||||
{
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'field-uuid-1',
|
||||
errors: [
|
||||
{
|
||||
code: 'DUPLICATE_NAME',
|
||||
message: 'An object with this name already exists',
|
||||
value: 'postCard',
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_TYPE',
|
||||
message: 'Field type is not supported',
|
||||
value: 'UNKNOWN_TYPE',
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
fieldMetadata: [
|
||||
{
|
||||
type: 'fieldMetadata',
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'field-uuid-1',
|
||||
},
|
||||
{
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'field-uuid-2',
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_TYPE',
|
||||
message: 'Field type is not supported',
|
||||
value: 'UNKNOWN_TYPE',
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
code: 'MISSING_RELATION_TARGET',
|
||||
message: 'Relation target object not found',
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'fieldMetadata',
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'field-uuid-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { objectMetadata: 1, fieldMetadata: 2, totalErrors: 3 },
|
||||
errors: [
|
||||
{
|
||||
code: 'MISSING_RELATION_TARGET',
|
||||
message: 'Relation target object not found',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { objectMetadata: 1, fieldMetadata: 2, totalErrors: 3 },
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
@@ -171,22 +157,22 @@ describe('formatManifestValidationErrors', () => {
|
||||
|
||||
it('should include value in details when present', () => {
|
||||
const events = formatManifestValidationErrors({
|
||||
extensions: {
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_VALUE',
|
||||
message: 'Bad value',
|
||||
value: 'some-bad-value',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 1, totalErrors: 1 },
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
type: 'fieldMetadata',
|
||||
flatEntityMinimalInformation: {},
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_VALUE',
|
||||
message: 'Bad value',
|
||||
value: 'some-bad-value',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 1, totalErrors: 1 },
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
@@ -195,16 +181,16 @@ describe('formatManifestValidationErrors', () => {
|
||||
|
||||
it('should omit details suffix when no value or universalIdentifier', () => {
|
||||
const events = formatManifestValidationErrors({
|
||||
extensions: {
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
errors: [{ code: 'ERR', message: 'Something failed' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 1, totalErrors: 1 },
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
type: 'fieldMetadata',
|
||||
flatEntityMinimalInformation: {},
|
||||
errors: [{ code: 'ERR', message: 'Something failed' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 1, totalErrors: 1 },
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
@@ -213,19 +199,19 @@ describe('formatManifestValidationErrors', () => {
|
||||
|
||||
it('should fall back to entries.length when summary count is missing for a metadata type', () => {
|
||||
const events = formatManifestValidationErrors({
|
||||
extensions: {
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
errors: [
|
||||
{ code: 'ERR_1', message: 'Error one' },
|
||||
{ code: 'ERR_2', message: 'Error two' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { totalErrors: 2 },
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
type: 'fieldMetadata',
|
||||
flatEntityMinimalInformation: {},
|
||||
errors: [
|
||||
{ code: 'ERR_1', message: 'Error one' },
|
||||
{ code: 'ERR_2', message: 'Error two' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { totalErrors: 2 },
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
@@ -234,35 +220,35 @@ describe('formatManifestValidationErrors', () => {
|
||||
|
||||
it('should pluralize correctly for singular and plural counts', () => {
|
||||
const singleError = formatManifestValidationErrors({
|
||||
extensions: {
|
||||
errors: {
|
||||
objectMetadata: [
|
||||
{
|
||||
errors: [{ code: 'ERR', message: 'Error' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { objectMetadata: 1, totalErrors: 1 },
|
||||
errors: {
|
||||
objectMetadata: [
|
||||
{
|
||||
type: 'objectMetadata',
|
||||
flatEntityMinimalInformation: {},
|
||||
errors: [{ code: 'ERR', message: 'Error' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { objectMetadata: 1, totalErrors: 1 },
|
||||
});
|
||||
|
||||
expect(singleError?.[0].message).toBe('Sync failed with 1 error');
|
||||
expect(singleError?.[1].message).toBe('objectMetadata: 1 error');
|
||||
|
||||
const multipleErrors = formatManifestValidationErrors({
|
||||
extensions: {
|
||||
errors: {
|
||||
objectMetadata: [
|
||||
{
|
||||
errors: [
|
||||
{ code: 'ERR_1', message: 'Error 1' },
|
||||
{ code: 'ERR_2', message: 'Error 2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { objectMetadata: 5, totalErrors: 5 },
|
||||
errors: {
|
||||
objectMetadata: [
|
||||
{
|
||||
type: 'objectMetadata',
|
||||
flatEntityMinimalInformation: {},
|
||||
errors: [
|
||||
{ code: 'ERR_1', message: 'Error 1' },
|
||||
{ code: 'ERR_2', message: 'Error 2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { objectMetadata: 5, totalErrors: 5 },
|
||||
});
|
||||
|
||||
expect(multipleErrors?.[0].message).toBe('Sync failed with 5 errors');
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint';
|
||||
|
||||
describe('getSyncErrorRecoveryHint', () => {
|
||||
it('suggests an initial sync when the app is not installed', () => {
|
||||
const hint = getSyncErrorRecoveryHint(
|
||||
'Application "x" is not installed in workspace "y". Install it first.',
|
||||
);
|
||||
|
||||
expect(hint).toContain('yarn twenty dev --once');
|
||||
expect(hint).toContain('register');
|
||||
});
|
||||
|
||||
it('suggests previewing and reinstalling on a metadata conflict', () => {
|
||||
const hint = getSyncErrorRecoveryHint(
|
||||
"Migration action 'create' for 'fieldMetadata' (universalIdentifier: 2020) failed",
|
||||
);
|
||||
|
||||
expect(hint).toContain('yarn twenty dev --once --dry-run');
|
||||
expect(hint).toContain('yarn twenty app:uninstall -y');
|
||||
});
|
||||
|
||||
it('suggests previewing on an already-exists error', () => {
|
||||
const hint = getSyncErrorRecoveryHint(
|
||||
'Field with same universal identifier already exists in object',
|
||||
);
|
||||
|
||||
expect(hint).toContain('yarn twenty dev --once --dry-run');
|
||||
});
|
||||
|
||||
it('returns undefined for an unrecognized error', () => {
|
||||
expect(getSyncErrorRecoveryHint('Network request failed')).toBeUndefined();
|
||||
expect(getSyncErrorRecoveryHint(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +1,34 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
type AllMetadataName,
|
||||
type MetadataValidationErrorResponse,
|
||||
} from 'twenty-shared/metadata';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
|
||||
type SyncValidationEntry = {
|
||||
flatEntityMinimalInformation?: { universalIdentifier?: string };
|
||||
errors: { code: string; message: string; value?: string }[];
|
||||
};
|
||||
|
||||
type StructuredSyncError = {
|
||||
message?: string;
|
||||
extensions?: {
|
||||
code?: string;
|
||||
errors?: Record<string, SyncValidationEntry[]>;
|
||||
summary?: Record<string, number> & { totalErrors: number };
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const formatManifestValidationErrors = (
|
||||
error: unknown,
|
||||
error: MetadataValidationErrorResponse | undefined,
|
||||
): OrchestratorStateStepEvent[] | null => {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const syncError = error as StructuredSyncError;
|
||||
const extensions = syncError.extensions;
|
||||
|
||||
if (!extensions?.errors || !extensions?.summary) {
|
||||
if (!isDefined(error?.errors) || !isDefined(error?.summary)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const events: OrchestratorStateStepEvent[] = [];
|
||||
const totalErrors = extensions.summary.totalErrors;
|
||||
const totalErrors = error.summary.totalErrors;
|
||||
|
||||
events.push({
|
||||
message: `Sync failed with ${totalErrors} error${totalErrors !== 1 ? 's' : ''}`,
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
for (const [metadataName, entries] of Object.entries(extensions.errors)) {
|
||||
const count = extensions.summary[metadataName] ?? entries.length;
|
||||
for (const [metadataName, entries] of Object.entries(error.errors)) {
|
||||
if (!isDefined(entries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const count =
|
||||
error.summary[metadataName as AllMetadataName] ?? entries.length;
|
||||
|
||||
events.push({
|
||||
message: `${metadataName}: ${count} error${count !== 1 ? 's' : ''}`,
|
||||
@@ -54,11 +44,11 @@ export const formatManifestValidationErrors = (
|
||||
for (const entryError of entry.errors) {
|
||||
const details: string[] = [];
|
||||
|
||||
if (entryError.value) {
|
||||
details.push(`value: ${entryError.value}`);
|
||||
if (isDefined(entryError.value)) {
|
||||
details.push(`value: ${String(entryError.value)}`);
|
||||
}
|
||||
|
||||
if (universalIdentifier) {
|
||||
if (isNonEmptyString(universalIdentifier)) {
|
||||
details.push(`universalIdentifier: ${universalIdentifier}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const getSyncErrorRecoveryHint = (
|
||||
message: string | undefined,
|
||||
): string | undefined => {
|
||||
const normalizedMessage = (message ?? '').toLowerCase();
|
||||
|
||||
if (normalizedMessage.includes('not installed')) {
|
||||
return 'Hint: run `yarn twenty dev --once` to register the app in this workspace, then retry.';
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedMessage.includes('already exists') ||
|
||||
normalizedMessage.includes('universalidentifier') ||
|
||||
/migration action .* failed/.test(normalizedMessage)
|
||||
) {
|
||||
return 'Hint: a metadata conflict was detected. Preview the plan with `yarn twenty dev --once --dry-run`; if it persists, run `yarn twenty app:uninstall -y` then sync again.';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
Reference in New Issue
Block a user