2162 Add asset watcher in twenty-sdk dev mode (#17513)

- assets are pushed in .twenty/output
- assets are uploaded in FileFolder.Assets
- not handled yet by the sync-manifest endpoint
This commit is contained in:
martmull
2026-01-29 10:08:44 +01:00
committed by GitHub
parent 1cc08b84c4
commit 3412992e99
42 changed files with 414 additions and 135 deletions
@@ -10,7 +10,6 @@ import { type FileFolder } from 'twenty-shared/types';
import { validateManifest } from '@/cli/utilities/build/manifest/manifest-validate';
import type { Location } from 'esbuild';
import { type DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager';
import { relative } from 'path';
import { type EventName } from 'chokidar/handler.js';
export type DevModeOrchestratorOptions = {
@@ -42,8 +41,8 @@ export class DevModeOrchestrator {
private syncTimer: NodeJS.Timeout | null = null;
private isSyncing = false;
private uiStateManager: DevUiStateManager;
private serverChecked = false;
private serverCheckedLogged = false;
private serverReady = false;
private serverErrorLogged = false;
private handleManifestBuilt: (
result: ManifestBuildResult,
@@ -57,41 +56,59 @@ export class DevModeOrchestrator {
}
private async checkServer(): Promise<void> {
this.serverChecked = await this.apiService.validateAuth();
const validateAuth = await this.apiService.validateAuth();
if (!this.serverChecked && !this.serverCheckedLogged) {
this.uiStateManager.addEvent({
message:
'Please check your server is up and your credentials are correct: "yarn auth:login"',
status: 'error',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
});
this.serverCheckedLogged = true;
if (!validateAuth.serverUp) {
if (!this.serverErrorLogged) {
this.uiStateManager.addEvent({
message: 'Cannot reach server',
status: 'error',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
error: 'Cannot connect to Twenty server. Is it running?',
});
this.serverErrorLogged = true;
}
return;
}
if (!validateAuth.authValid) {
if (!this.serverErrorLogged) {
this.uiStateManager.addEvent({
message: 'Authentication failed',
status: 'error',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
error:
'Cannot authenticate. Check your credentials are correct with "yarn auth:login"',
});
this.serverErrorLogged = true;
}
return;
}
this.serverErrorLogged = false;
this.serverReady = true;
}
async handleChangeDetected(sourcePath: string, event: EventName) {
if (!this.serverChecked) {
if (!this.serverReady) {
await this.checkServer();
}
if (!this.serverChecked) {
if (!this.serverReady) {
return;
}
const normalizedSourcePath = this.normalizeFilePath(sourcePath);
this.uiStateManager.addEvent({
message: `Change detected: ${normalizedSourcePath}`,
message: `Change detected: ${sourcePath}`,
status: 'info',
});
if (event === 'unlink') {
this.uiStateManager.removeEntity(normalizedSourcePath);
this.uiStateManager.removeEntity(sourcePath);
} else {
this.uiStateManager.updateFileStatus(normalizedSourcePath, 'building');
this.uiStateManager.updateFileStatus(sourcePath, 'building');
}
this.scheduleSync();
@@ -142,10 +159,6 @@ export class DevModeOrchestrator {
this.scheduleSync();
}
private normalizeFilePath(filePath: string): string {
return relative(this.appPath, filePath);
}
private uploadFile(
builtPath: string,
sourcePath: string,
@@ -295,6 +308,7 @@ export class DevModeOrchestrator {
manifest: result.manifest,
builtFileInfos: this.builtFileInfos,
});
this.uiStateManager.addEvent({
message: 'Manifest checksums set',
status: 'info',
@@ -311,9 +325,11 @@ export class DevModeOrchestrator {
message: 'Syncing manifest',
status: 'info',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'syncing',
});
const syncResult = await this.apiService.syncApplication(manifest);
this.uiStateManager.updateAllFilesStatus('success');
@@ -10,6 +10,13 @@ import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-bu
const MAX_EVENT_NUMBER = 200;
const FILE_STATUS_TRANSITION_MATRIX: Record<FileStatus, FileStatus[]> = {
pending: ['building', 'uploading', 'success'],
building: ['pending', 'uploading', 'success'],
uploading: ['pending', 'success'],
success: ['pending', 'building', 'uploading'],
};
export class DevUiStateManager {
private state: DevUiState;
private eventIdCounter = 0;
@@ -75,14 +82,17 @@ export class DevUiStateManager {
updateManifestState({
manifestStatus,
appName,
error,
}: {
manifestStatus?: ManifestStatus;
appName?: string;
error?: string;
}): void {
this.state = {
...this.state,
...(manifestStatus ? { manifestStatus } : {}),
...(appName ? { appName } : {}),
...(error ? { error } : {}),
};
this.notify();
@@ -102,6 +112,8 @@ export class DevUiStateManager {
return SyncableEntity.FrontComponent;
case 'roles':
return SyncableEntity.Role;
case 'assets':
return SyncableEntity.PublicAsset;
default:
return;
}
@@ -164,11 +176,27 @@ export class DevUiStateManager {
updateFileStatus(filePath: string, status: FileStatus): void {
const entities = new Map(this.state.entities);
entities.set(filePath, {
name: filePath,
path: filePath,
status: status,
});
const entity = entities.get(filePath);
if (
entity?.status &&
!FILE_STATUS_TRANSITION_MATRIX[entity.status].find(
(nextStatus) => nextStatus === status,
)
) {
return;
}
entities.set(
filePath,
entity
? { ...entity, status }
: {
name: filePath,
path: filePath,
status,
},
);
this.state = { ...this.state, entities };
@@ -30,6 +30,7 @@ export type DevUiState = {
appUniversalIdentifier: string | null;
frontendUrl?: string | null;
manifestStatus: ManifestStatus;
error?: string | null;
entities: Map<string, EntityInfo>;
events: UiEvent[];
};
@@ -30,6 +30,7 @@ const ENTITY_LABELS: Record<SyncableEntity, string> = {
[SyncableEntity.Function]: 'Functions',
[SyncableEntity.FrontComponent]: 'Front Components',
[SyncableEntity.Role]: 'Roles',
[SyncableEntity.PublicAsset]: 'Public Assets',
};
const ENTITY_ORDER = Object.keys(ENTITY_LABELS) as SyncableEntity[];
@@ -176,10 +177,10 @@ export const renderDevUI = async (
const MANIFEST_STATUS_CONFIG = {
synced: { color: 'green', icon: '✓', text: 'Synced' },
building: { color: 'yellow', icon: null, text: 'Building...' },
syncing: { color: 'yellow', icon: null, text: 'Syncing...' },
error: { color: 'red', icon: 'x', text: 'Error' },
idle: { color: 'gray', icon: 'o', text: 'Idle' },
building: { color: 'yellow', icon: 'spinner', text: 'Building...' },
syncing: { color: 'yellow', icon: 'spinner', text: 'Syncing...' },
error: { color: 'red', icon: null, text: 'Error' },
idle: { color: 'gray', icon: null, text: 'Idle' },
} as const;
const UnifiedStatusIndicator = ({
@@ -189,11 +190,13 @@ export const renderDevUI = async (
}): React.ReactElement => {
const spinnerFrame = useSpinner(SPINNER_FRAMES, 80);
const config = MANIFEST_STATUS_CONFIG[snapshot.manifestStatus];
const icon = config.icon ?? spinnerFrame;
const icon = config.icon === 'spinner' ? spinnerFrame : config.icon;
return (
<Text color={config.color}>
{icon} {config.text}
{icon ?? ''}
{config.text}
{snapshot.error && `: ${snapshot.error}`}
</Text>
);
};