Add Client Api generation (#17961)
## Add API client generation to SDK dev mode and refactor orchestrator into step-based pipeline ### Why The SDK dev mode lacked typed API client generation, forcing developers to work without auto-generated GraphQL types when building applications. Additionally, the orchestrator was a monolithic class that mixed watcher management, token handling, and sync logic — making it difficult to extend with new steps like client generation. ### How - **Refactored the orchestrator** into a step-based pipeline with dedicated classes: `CheckServer`, `EnsureValidTokens`, `ResolveApplication`, `BuildManifest`, `UploadFiles`, `GenerateApiClient`, `SyncApplication`, and `StartWatchers`. Each step has typed input/output/status, managed by a new `OrchestratorState` class. - **Added `GenerateApiClientOrchestratorStep`** that detects object/field schema changes and regenerates a typed GraphQL client (via `@genql/cli`) into `node_modules/twenty-sdk/generated` for seamless imports. - **Replaced `checkApplicationExist`** with `findOneApplication` on both server resolver and SDK API service, returning the entity data instead of a boolean. - **Added application token pair mutations** (`generateApplicationToken`, `renewApplicationToken`) to the API service, with the server now returning `ApplicationTokenPairDTO` containing both access and refresh tokens. - **Restructured the dev UI** into `dev/ui/components/` with dedicated panel, section, and event log components. - **Simplified `AppDevCommand`** from ~180 lines of watcher management down to ~40 lines that delegate entirely to the orchestrator.
This commit is contained in:
@@ -1,415 +0,0 @@
|
||||
import {
|
||||
type ManifestBuildResult,
|
||||
manifestUpdateChecksums,
|
||||
} from '@/cli/utilities/build/manifest/manifest-update-checksums';
|
||||
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { FileUploader } from '@/cli/utilities/file/file-uploader';
|
||||
import { type FileFolder } from 'twenty-shared/types';
|
||||
import type { Location } from 'esbuild';
|
||||
import { type DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager';
|
||||
import { type EventName } from 'chokidar/handler.js';
|
||||
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate';
|
||||
|
||||
export type DevModeOrchestratorOptions = {
|
||||
appPath: string;
|
||||
debounceMs?: number;
|
||||
handleManifestBuilt: (result: ManifestBuildResult) => void | Promise<void>;
|
||||
uiStateManager: DevUiStateManager;
|
||||
};
|
||||
|
||||
export class DevModeOrchestrator {
|
||||
private appPath: string;
|
||||
private debounceMs: number;
|
||||
|
||||
private builtFileInfos = new Map<
|
||||
string,
|
||||
{
|
||||
checksum: string;
|
||||
builtPath: string;
|
||||
sourcePath: string;
|
||||
fileFolder: FileFolder;
|
||||
}
|
||||
>();
|
||||
|
||||
private fileUploader: FileUploader | null = null;
|
||||
private apiService = new ApiService({ disableInterceptors: true });
|
||||
|
||||
private activeUploads = new Set<Promise<void>>();
|
||||
|
||||
private syncTimer: NodeJS.Timeout | null = null;
|
||||
private isSyncing = false;
|
||||
private uiStateManager: DevUiStateManager;
|
||||
private serverReady = false;
|
||||
private serverErrorLogged = false;
|
||||
|
||||
private handleManifestBuilt: (
|
||||
result: ManifestBuildResult,
|
||||
) => void | Promise<void>;
|
||||
|
||||
constructor(options: DevModeOrchestratorOptions) {
|
||||
this.appPath = options.appPath;
|
||||
this.debounceMs = options.debounceMs ?? 200;
|
||||
this.handleManifestBuilt = options.handleManifestBuilt;
|
||||
this.uiStateManager = options.uiStateManager;
|
||||
}
|
||||
|
||||
private async checkServer(): Promise<void> {
|
||||
const validateAuth = await this.apiService.validateAuth();
|
||||
|
||||
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.serverReady) {
|
||||
await this.checkServer();
|
||||
}
|
||||
|
||||
if (!this.serverReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uiStateManager.addEvent({
|
||||
message: `Change detected: ${sourcePath}`,
|
||||
status: 'info',
|
||||
});
|
||||
|
||||
if (event === 'unlink') {
|
||||
this.uiStateManager.removeEntity(sourcePath);
|
||||
} else {
|
||||
this.uiStateManager.updateFileStatus(sourcePath, 'building');
|
||||
}
|
||||
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
handleFileBuildError(
|
||||
errors: { error: string; location: Location | null }[],
|
||||
): void {
|
||||
this.uiStateManager.addEvent({
|
||||
message: 'Build failed:',
|
||||
status: 'error',
|
||||
});
|
||||
for (const error of errors) {
|
||||
this.uiStateManager.addEvent({
|
||||
message: error.error,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleFileBuilt({
|
||||
fileFolder,
|
||||
builtPath,
|
||||
sourcePath,
|
||||
checksum,
|
||||
}: {
|
||||
fileFolder: FileFolder;
|
||||
builtPath: string;
|
||||
sourcePath: string;
|
||||
checksum: string;
|
||||
}): void {
|
||||
this.uiStateManager.addEvent({
|
||||
message: `Successfully built ${builtPath}`,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
this.builtFileInfos.set(builtPath, {
|
||||
checksum,
|
||||
builtPath,
|
||||
sourcePath,
|
||||
fileFolder,
|
||||
});
|
||||
|
||||
if (this.fileUploader) {
|
||||
this.uploadFile(builtPath, sourcePath, fileFolder);
|
||||
}
|
||||
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
private uploadFile(
|
||||
builtPath: string,
|
||||
sourcePath: string,
|
||||
fileFolder: FileFolder,
|
||||
): void {
|
||||
this.uiStateManager.addEvent({
|
||||
message: `Uploading ${builtPath}`,
|
||||
status: 'info',
|
||||
});
|
||||
this.uiStateManager.updateFileStatus(sourcePath, 'uploading');
|
||||
const uploadPromise = this.fileUploader!.uploadFile({
|
||||
builtPath,
|
||||
fileFolder,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
this.uiStateManager.addEvent({
|
||||
message: `Successfully uploaded ${builtPath}`,
|
||||
status: 'success',
|
||||
});
|
||||
this.uiStateManager.updateFileStatus(sourcePath, 'success');
|
||||
} else {
|
||||
this.uiStateManager.addEvent({
|
||||
message: `Failed to upload ${builtPath}: ${result.error}`,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.uiStateManager.addEvent({
|
||||
message: `Upload failed for ${builtPath}: ${error}`,
|
||||
status: 'error',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.activeUploads.delete(uploadPromise);
|
||||
});
|
||||
|
||||
this.activeUploads.add(uploadPromise);
|
||||
}
|
||||
|
||||
private cancelPendingSync(): void {
|
||||
if (this.syncTimer) {
|
||||
clearTimeout(this.syncTimer);
|
||||
this.syncTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
this.cancelPendingSync();
|
||||
|
||||
this.syncTimer = setTimeout(() => {
|
||||
this.syncTimer = null;
|
||||
void this.performSync();
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
private async performSync(): Promise<void> {
|
||||
if (this.isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
this.uiStateManager.addEvent({
|
||||
message: 'Building manifest',
|
||||
status: 'info',
|
||||
});
|
||||
this.uiStateManager.updateManifestState({
|
||||
manifestStatus: 'building',
|
||||
});
|
||||
|
||||
const result = await buildManifest(this.appPath);
|
||||
|
||||
if (result.errors.length > 0 || !result.manifest) {
|
||||
for (const error of result.errors) {
|
||||
this.uiStateManager.addEvent({
|
||||
message: error,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
this.uiStateManager.updateManifestState({
|
||||
manifestStatus: 'error',
|
||||
error: result.errors[result.errors.length - 1],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = manifestValidate(result.manifest);
|
||||
|
||||
if (!validation.isValid) {
|
||||
for (const e of validation.errors) {
|
||||
this.uiStateManager.addEvent({
|
||||
message: e,
|
||||
status: 'error',
|
||||
});
|
||||
this.uiStateManager.updateManifestState({
|
||||
manifestStatus: 'error',
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.uiStateManager.updateManifestState({
|
||||
appName: result.manifest.application.displayName,
|
||||
});
|
||||
|
||||
this.uiStateManager.updateAllFilesTypes({
|
||||
manifestFilePaths: result.filePaths,
|
||||
});
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
for (const warning of validation.warnings) {
|
||||
this.uiStateManager.addEvent({
|
||||
message: `⚠ ${warning}`,
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.uiStateManager.addEvent({
|
||||
message: 'Successfully built manifest',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
await this.handleManifestBuilt(result);
|
||||
|
||||
if (!this.fileUploader) {
|
||||
const checkApplicationExistResult =
|
||||
await this.apiService.checkApplicationExist(
|
||||
result.manifest.application.universalIdentifier,
|
||||
);
|
||||
|
||||
if (!checkApplicationExistResult.success) {
|
||||
this.uiStateManager.addEvent({
|
||||
message: `Failed to check if application ${result.manifest.application.universalIdentifier} already exists`,
|
||||
status: 'error',
|
||||
});
|
||||
this.uiStateManager.updateManifestState({
|
||||
manifestStatus: 'error',
|
||||
error: `Failed to check if application already exists`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const applicationExists = checkApplicationExistResult.data;
|
||||
|
||||
if (!applicationExists) {
|
||||
this.uiStateManager.addEvent({
|
||||
message: 'Creating application',
|
||||
status: 'info',
|
||||
});
|
||||
|
||||
const createApplicationResult =
|
||||
await this.apiService.createApplication(result.manifest);
|
||||
|
||||
if (createApplicationResult.success) {
|
||||
this.uiStateManager.addEvent({
|
||||
message: 'Application created',
|
||||
status: 'success',
|
||||
});
|
||||
} else {
|
||||
this.uiStateManager.addEvent({
|
||||
message: `Application creation failed with error ${JSON.stringify(createApplicationResult.error, null, 2)}`,
|
||||
status: 'error',
|
||||
});
|
||||
this.uiStateManager.updateManifestState({
|
||||
manifestStatus: 'error',
|
||||
error: `Application creation failed with error ${JSON.stringify(createApplicationResult.error, null, 2)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.fileUploader = new FileUploader({
|
||||
appPath: this.appPath,
|
||||
applicationUniversalIdentifier:
|
||||
result.manifest.application.universalIdentifier,
|
||||
});
|
||||
|
||||
for (const [
|
||||
builtPath,
|
||||
{ fileFolder, sourcePath },
|
||||
] of this.builtFileInfos.entries()) {
|
||||
this.uploadFile(builtPath, sourcePath, fileFolder);
|
||||
}
|
||||
}
|
||||
|
||||
while (this.activeUploads.size > 0) {
|
||||
await Promise.all(this.activeUploads);
|
||||
}
|
||||
|
||||
const manifest = manifestUpdateChecksums({
|
||||
manifest: result.manifest,
|
||||
builtFileInfos: this.builtFileInfos,
|
||||
});
|
||||
|
||||
this.uiStateManager.addEvent({
|
||||
message: 'Manifest checksums set',
|
||||
status: 'info',
|
||||
});
|
||||
|
||||
await writeManifestToOutput(this.appPath, manifest);
|
||||
|
||||
this.uiStateManager.addEvent({
|
||||
message: 'Manifest saved to output directory',
|
||||
status: 'info',
|
||||
});
|
||||
|
||||
this.uiStateManager.addEvent({
|
||||
message: 'Syncing manifest',
|
||||
status: 'info',
|
||||
});
|
||||
|
||||
this.uiStateManager.updateManifestState({
|
||||
manifestStatus: 'syncing',
|
||||
});
|
||||
|
||||
const syncResult = await this.apiService.syncApplication(manifest);
|
||||
|
||||
this.uiStateManager.updateAllFilesStatus('success');
|
||||
|
||||
if (syncResult.success) {
|
||||
this.uiStateManager.addEvent({
|
||||
message: '✓ Synced',
|
||||
status: 'success',
|
||||
});
|
||||
this.uiStateManager.updateManifestState({
|
||||
manifestStatus: 'synced',
|
||||
});
|
||||
} else {
|
||||
this.uiStateManager.addEvent({
|
||||
message: `Sync failed with error ${JSON.stringify(syncResult.error, null, 2)}`,
|
||||
status: 'error',
|
||||
});
|
||||
this.uiStateManager.updateManifestState({
|
||||
manifestStatus: 'error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.uiStateManager.addEvent({
|
||||
message: `Sync failed with error ${JSON.stringify(error, null, 2)}`,
|
||||
status: 'error',
|
||||
});
|
||||
this.uiStateManager.updateManifestState({
|
||||
manifestStatus: 'error',
|
||||
});
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { SyncableEntity } from 'twenty-shared/application';
|
||||
import {
|
||||
type FileStatus,
|
||||
type Listener,
|
||||
type ManifestStatus,
|
||||
type UiEvent,
|
||||
type DevUiState,
|
||||
} from '@/cli/utilities/dev/dev-ui-state';
|
||||
import type { EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
|
||||
|
||||
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;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
constructor({
|
||||
appPath,
|
||||
frontendUrl,
|
||||
}: {
|
||||
appPath: string;
|
||||
frontendUrl?: string;
|
||||
}) {
|
||||
this.state = {
|
||||
appPath,
|
||||
frontendUrl,
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
appUniversalIdentifier: null,
|
||||
manifestStatus: 'idle',
|
||||
entities: new Map(),
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(): DevUiState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getSnapshot());
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
addEvent({
|
||||
message,
|
||||
status = 'info',
|
||||
}: {
|
||||
message: string;
|
||||
status: UiEvent['status'];
|
||||
}): void {
|
||||
const event: UiEvent = {
|
||||
id: ++this.eventIdCounter,
|
||||
timestamp: new Date(),
|
||||
message: message.slice(0, 5_000),
|
||||
status,
|
||||
};
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
events: [...this.state.events.slice(-MAX_EVENT_NUMBER - 1), event],
|
||||
};
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
updateManifestState({
|
||||
manifestStatus,
|
||||
appName,
|
||||
error,
|
||||
}: {
|
||||
manifestStatus?: ManifestStatus;
|
||||
appName?: string;
|
||||
error?: string;
|
||||
}): void {
|
||||
this.state = {
|
||||
...this.state,
|
||||
...(manifestStatus ? { manifestStatus } : {}),
|
||||
...(appName ? { appName } : {}),
|
||||
...(error ? { error: error.slice(0, 5_000) } : { error: undefined }),
|
||||
};
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
convertEntityTypeToSyncableEntity(
|
||||
entityType: string,
|
||||
): SyncableEntity | undefined {
|
||||
switch (entityType) {
|
||||
case 'objects':
|
||||
return SyncableEntity.Object;
|
||||
case 'fields':
|
||||
return SyncableEntity.Field;
|
||||
case 'logicFunctions':
|
||||
return SyncableEntity.LogicFunction;
|
||||
case 'frontComponents':
|
||||
return SyncableEntity.FrontComponent;
|
||||
case 'roles':
|
||||
return SyncableEntity.Role;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updateAllFilesTypes({
|
||||
manifestFilePaths,
|
||||
}: {
|
||||
manifestFilePaths: EntityFilePaths;
|
||||
}): void {
|
||||
const entityMaps = new Map<string, SyncableEntity>();
|
||||
|
||||
(Object.entries(manifestFilePaths) as [SyncableEntity, string[]][]).forEach(
|
||||
([entityType, filePaths]) => {
|
||||
filePaths.forEach((filePath) => {
|
||||
const syncableEntity =
|
||||
this.convertEntityTypeToSyncableEntity(entityType);
|
||||
|
||||
if (!syncableEntity) {
|
||||
return;
|
||||
}
|
||||
entityMaps.set(filePath, syncableEntity);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const entities = new Map(this.state.entities);
|
||||
|
||||
for (const [filePath, entity] of entities) {
|
||||
entities.set(filePath, {
|
||||
...entity,
|
||||
type: entityMaps.get(filePath),
|
||||
});
|
||||
}
|
||||
this.state = { ...this.state, entities };
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
updateAllFilesStatus(status: FileStatus): void {
|
||||
const entities = new Map(this.state.entities);
|
||||
|
||||
for (const [filePath, entity] of entities) {
|
||||
entities.set(filePath, {
|
||||
...entity,
|
||||
status: status,
|
||||
});
|
||||
}
|
||||
this.state = { ...this.state, entities };
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
removeEntity(filePath: string) {
|
||||
const entities = new Map(this.state.entities);
|
||||
entities.delete(filePath);
|
||||
this.state = { ...this.state, entities };
|
||||
}
|
||||
|
||||
updateFileStatus(filePath: string, status: FileStatus): void {
|
||||
const entities = new Map(this.state.entities);
|
||||
|
||||
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 };
|
||||
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { type SyncableEntity } from 'twenty-shared/application';
|
||||
|
||||
export type UiEvent = {
|
||||
id: number;
|
||||
timestamp: Date;
|
||||
message: string;
|
||||
status: 'info' | 'success' | 'error' | 'warning';
|
||||
};
|
||||
|
||||
export type ManifestStatus =
|
||||
| 'idle'
|
||||
| 'building'
|
||||
| 'syncing'
|
||||
| 'synced'
|
||||
| 'error';
|
||||
|
||||
export type FileStatus = 'pending' | 'building' | 'uploading' | 'success';
|
||||
|
||||
export type EntityInfo = {
|
||||
name: string;
|
||||
path: string;
|
||||
type?: SyncableEntity;
|
||||
status: FileStatus;
|
||||
};
|
||||
|
||||
export type DevUiState = {
|
||||
appPath: string;
|
||||
appName: string | null;
|
||||
appDescription: string | null;
|
||||
appUniversalIdentifier: string | null;
|
||||
frontendUrl?: string | null;
|
||||
manifestStatus: ManifestStatus;
|
||||
error?: string | null;
|
||||
entities: Map<string, EntityInfo>;
|
||||
events: UiEvent[];
|
||||
};
|
||||
|
||||
export type Listener = (state: DevUiState) => void;
|
||||
@@ -1,295 +0,0 @@
|
||||
import {
|
||||
type UiEvent,
|
||||
type DevUiState,
|
||||
type FileStatus,
|
||||
type EntityInfo,
|
||||
} from '@/cli/utilities/dev/dev-ui-state';
|
||||
import { SyncableEntity } from 'twenty-shared/application';
|
||||
import { type DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager';
|
||||
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
const UPLOAD_FRAMES = ['↑', '⇡', '↟', '⤒'];
|
||||
|
||||
const STATUS_ICONS: Record<FileStatus, string> = {
|
||||
pending: '○',
|
||||
building: '◐',
|
||||
uploading: '↑',
|
||||
success: '✓',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<FileStatus, string> = {
|
||||
pending: 'gray',
|
||||
building: 'yellow',
|
||||
uploading: 'cyan',
|
||||
success: 'green',
|
||||
};
|
||||
|
||||
const ENTITY_LABELS: Record<SyncableEntity, string> = {
|
||||
[SyncableEntity.Object]: 'Objects',
|
||||
[SyncableEntity.Field]: 'Fields',
|
||||
[SyncableEntity.LogicFunction]: 'Logic functions',
|
||||
[SyncableEntity.FrontComponent]: 'Front components',
|
||||
[SyncableEntity.Role]: 'Roles',
|
||||
};
|
||||
|
||||
const ENTITY_ORDER = Object.keys(ENTITY_LABELS) as SyncableEntity[];
|
||||
|
||||
const EVENT_COLORS: Record<UiEvent['status'], string> = {
|
||||
info: 'gray',
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
warning: 'yellow',
|
||||
};
|
||||
|
||||
const groupEntitiesByType = (
|
||||
entities: Map<string, EntityInfo>,
|
||||
): Map<SyncableEntity, EntityInfo[]> => {
|
||||
const grouped = new Map<SyncableEntity, EntityInfo[]>();
|
||||
|
||||
for (const type of ENTITY_ORDER) {
|
||||
grouped.set(type, []);
|
||||
}
|
||||
|
||||
for (const entity of entities.values()) {
|
||||
if (!entity.type) {
|
||||
continue;
|
||||
}
|
||||
const list = grouped.get(entity.type) ?? [];
|
||||
list.push(entity);
|
||||
grouped.set(entity.type, list);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const shortenPath = (path: string, maxLength = 40): string => {
|
||||
if (path.length <= maxLength) return path;
|
||||
const parts = path.split('/');
|
||||
if (parts.length <= 2) return path;
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
};
|
||||
|
||||
const getApplicationUrl = (snapshot: DevUiState): string | null => {
|
||||
if (!snapshot.frontendUrl || !snapshot.appUniversalIdentifier) {
|
||||
return null;
|
||||
}
|
||||
return `${snapshot.frontendUrl}/settings/applications`;
|
||||
};
|
||||
|
||||
export const renderDevUI = async (
|
||||
uiStateManager: DevUiStateManager,
|
||||
): Promise<{ unmount: () => void }> => {
|
||||
const [React, ink] = await Promise.all([import('react'), import('ink')]);
|
||||
|
||||
const { useState, useEffect } = React;
|
||||
const { render, Box, Text, Static } = ink;
|
||||
|
||||
const useSpinner = (frames: string[], interval = 80): string => {
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setFrameIndex((prev) => (prev + 1) % frames.length);
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [frames.length, interval]);
|
||||
|
||||
return frames[frameIndex];
|
||||
};
|
||||
|
||||
const EventItem = ({ event }: { event: UiEvent }): React.ReactElement => {
|
||||
const color = EVENT_COLORS[event.status];
|
||||
const time = formatTime(event.timestamp);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{time} </Text>
|
||||
<Text color={color}>{event.message}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusIcon = ({
|
||||
status,
|
||||
}: {
|
||||
status: FileStatus;
|
||||
}): React.ReactElement => {
|
||||
const buildingFrame = useSpinner(SPINNER_FRAMES, 200);
|
||||
const uploadingFrame = useSpinner(UPLOAD_FRAMES, 200);
|
||||
|
||||
const iconByStatus: Record<FileStatus, string> = {
|
||||
building: buildingFrame,
|
||||
uploading: uploadingFrame,
|
||||
pending: STATUS_ICONS.pending,
|
||||
success: STATUS_ICONS.success,
|
||||
};
|
||||
|
||||
return <Text color={STATUS_COLORS[status]}>{iconByStatus[status]} </Text>;
|
||||
};
|
||||
|
||||
const EntityRow = ({
|
||||
entity,
|
||||
}: {
|
||||
entity: EntityInfo;
|
||||
}): React.ReactElement => {
|
||||
return (
|
||||
<Box>
|
||||
<StatusIcon status={entity.status} />
|
||||
<Text>{entity.name}</Text>
|
||||
{entity.path !== entity.name && (
|
||||
<Text dimColor> ({shortenPath(entity.path)})</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const EntitySection = ({
|
||||
type,
|
||||
entities,
|
||||
}: {
|
||||
type: SyncableEntity;
|
||||
entities: EntityInfo[];
|
||||
}): React.ReactElement | null => {
|
||||
if (entities.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold dimColor>
|
||||
{ENTITY_LABELS[type]}
|
||||
</Text>
|
||||
{entities.map((entity) => (
|
||||
<EntityRow key={entity.path} entity={entity} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const MANIFEST_STATUS_CONFIG = {
|
||||
synced: { color: 'green', icon: '✓', text: 'Synced' },
|
||||
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 = ({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: DevUiState;
|
||||
}): React.ReactElement => {
|
||||
const spinnerFrame = useSpinner(SPINNER_FRAMES, 80);
|
||||
const config = MANIFEST_STATUS_CONFIG[snapshot.manifestStatus];
|
||||
const icon = config.icon === 'spinner' ? spinnerFrame : config.icon;
|
||||
|
||||
return (
|
||||
<Text color={config.color}>
|
||||
{icon ? `${icon} ` : ''}
|
||||
{config.text}
|
||||
{snapshot.error && `: ${snapshot.error}`}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const ApplicationPanel = ({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: DevUiState;
|
||||
}): React.ReactElement => {
|
||||
const groupedEntities = groupEntitiesByType(snapshot.entities);
|
||||
const appUrl = getApplicationUrl(snapshot);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="classic"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Application
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>Name: </Text>
|
||||
<Text bold>{snapshot.appName ?? 'Loading...'}</Text>
|
||||
</Box>
|
||||
{snapshot.appDescription && (
|
||||
<Box>
|
||||
<Text dimColor>Description: </Text>
|
||||
<Text>{snapshot.appDescription}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Text dimColor>Status: </Text>
|
||||
<UnifiedStatusIndicator snapshot={snapshot} />
|
||||
</Box>
|
||||
{appUrl && (
|
||||
<Box>
|
||||
<Text dimColor>Open:</Text>
|
||||
<Text bold color="cyan">
|
||||
{' '}
|
||||
{appUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{ENTITY_ORDER.map((type) => {
|
||||
const entities = groupedEntities.get(type) ?? [];
|
||||
return <EntitySection key={type} type={type} entities={entities} />;
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const Legend = (): React.ReactElement => (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
<Text color={STATUS_COLORS.pending}>{STATUS_ICONS.pending}</Text>{' '}
|
||||
pending <Text color={STATUS_COLORS.building}>{SPINNER_FRAMES[0]}</Text>{' '}
|
||||
building <Text color={STATUS_COLORS.uploading}>{UPLOAD_FRAMES[0]}</Text>{' '}
|
||||
uploading{' '}
|
||||
<Text color={STATUS_COLORS.success}>{STATUS_ICONS.success}</Text>{' '}
|
||||
success
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const DevUI = (): React.ReactElement => {
|
||||
const [snapshot, setSnapshot] = useState<DevUiState>(
|
||||
uiStateManager.getSnapshot(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return uiStateManager.subscribe(setSnapshot);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Static items={snapshot.events}>
|
||||
{(event: UiEvent) => <EventItem key={event.id} event={event} />}
|
||||
</Static>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<ApplicationPanel snapshot={snapshot} />
|
||||
<Legend />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const { unmount } = render(<DevUI />);
|
||||
return { unmount };
|
||||
};
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
|
||||
import { type BuildManifestOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/build-manifest-orchestrator-step';
|
||||
import { type CheckServerOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step';
|
||||
import { type ResolveApplicationOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/resolve-application-orchestrator-step';
|
||||
import { type StartWatchersOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
|
||||
import { type SyncApplicationOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
|
||||
import { type UploadFilesOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
|
||||
import { type Manifest, SyncableEntity } from 'twenty-shared/application';
|
||||
import { type FileFolder } from 'twenty-shared/types';
|
||||
|
||||
export type OrchestratorStateStepEvent = {
|
||||
message: string;
|
||||
status: 'info' | 'success' | 'error' | 'warning';
|
||||
};
|
||||
|
||||
export type OrchestratorStateEvent = OrchestratorStateStepEvent & {
|
||||
id: number;
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
export type OrchestratorStateSyncStatus =
|
||||
| 'idle'
|
||||
| 'building'
|
||||
| 'syncing'
|
||||
| 'synced'
|
||||
| 'error';
|
||||
|
||||
export type OrchestratorStateStepStatus =
|
||||
| 'idle'
|
||||
| 'in_progress'
|
||||
| 'done'
|
||||
| 'error';
|
||||
|
||||
export type OrchestratorStepState<TOutput> = {
|
||||
output: TOutput;
|
||||
status: OrchestratorStateStepStatus;
|
||||
};
|
||||
|
||||
export type OrchestratorStateFileStatus =
|
||||
| 'pending'
|
||||
| 'building'
|
||||
| 'uploading'
|
||||
| 'success';
|
||||
|
||||
export type OrchestratorStateEntityInfo = {
|
||||
name: string;
|
||||
path: string;
|
||||
type?: SyncableEntity;
|
||||
status: OrchestratorStateFileStatus;
|
||||
};
|
||||
|
||||
export type OrchestratorStateBuiltFileInfo = {
|
||||
checksum: string;
|
||||
builtPath: string;
|
||||
sourcePath: string;
|
||||
fileFolder: FileFolder;
|
||||
};
|
||||
|
||||
export type OrchestratorStatePipeline = {
|
||||
status: OrchestratorStateSyncStatus;
|
||||
isSyncing: boolean;
|
||||
error: string | null;
|
||||
appName: string | null;
|
||||
};
|
||||
|
||||
const ENTITY_TYPE_TO_SYNCABLE: Record<string, SyncableEntity | undefined> = {
|
||||
objects: SyncableEntity.Object,
|
||||
fields: SyncableEntity.Field,
|
||||
logicFunctions: SyncableEntity.LogicFunction,
|
||||
frontComponents: SyncableEntity.FrontComponent,
|
||||
roles: SyncableEntity.Role,
|
||||
};
|
||||
|
||||
const MAX_EVENT_COUNT = 200;
|
||||
|
||||
const FILE_STATUS_TRANSITION_MATRIX: Record<
|
||||
OrchestratorStateFileStatus,
|
||||
OrchestratorStateFileStatus[]
|
||||
> = {
|
||||
pending: ['building', 'uploading', 'success'],
|
||||
building: ['pending', 'uploading', 'success'],
|
||||
uploading: ['pending', 'success'],
|
||||
success: ['pending', 'building', 'uploading'],
|
||||
};
|
||||
|
||||
export class OrchestratorState {
|
||||
appPath: string;
|
||||
frontendUrl?: string;
|
||||
|
||||
steps: {
|
||||
checkServer: OrchestratorStepState<CheckServerOrchestratorStepOutput>;
|
||||
ensureValidTokens: OrchestratorStepState<Record<string, never>>;
|
||||
resolveApplication: OrchestratorStepState<ResolveApplicationOrchestratorStepOutput>;
|
||||
buildManifest: OrchestratorStepState<BuildManifestOrchestratorStepOutput>;
|
||||
uploadFiles: OrchestratorStepState<UploadFilesOrchestratorStepOutput>;
|
||||
generateApiClient: OrchestratorStepState<Record<string, never>>;
|
||||
syncApplication: OrchestratorStepState<SyncApplicationOrchestratorStepOutput>;
|
||||
startWatchers: OrchestratorStepState<StartWatchersOrchestratorStepOutput>;
|
||||
};
|
||||
|
||||
previousObjectsFieldsFingerprint: string | null;
|
||||
|
||||
pipeline: OrchestratorStatePipeline;
|
||||
|
||||
entities: Map<string, OrchestratorStateEntityInfo>;
|
||||
events: OrchestratorStateEvent[];
|
||||
|
||||
private eventIdCounter = 0;
|
||||
onChange?: () => void;
|
||||
|
||||
constructor(options: { appPath: string; frontendUrl?: string }) {
|
||||
this.appPath = options.appPath;
|
||||
this.frontendUrl = options.frontendUrl;
|
||||
|
||||
this.previousObjectsFieldsFingerprint = null;
|
||||
|
||||
this.steps = {
|
||||
checkServer: {
|
||||
output: { isReady: false, errorLogged: false },
|
||||
status: 'idle',
|
||||
},
|
||||
ensureValidTokens: {
|
||||
output: {},
|
||||
status: 'idle',
|
||||
},
|
||||
resolveApplication: {
|
||||
output: { applicationId: null, universalIdentifier: null },
|
||||
status: 'idle',
|
||||
},
|
||||
buildManifest: {
|
||||
output: { result: null },
|
||||
status: 'idle',
|
||||
},
|
||||
uploadFiles: {
|
||||
output: {
|
||||
fileUploader: null,
|
||||
builtFileInfos: new Map(),
|
||||
activeUploads: new Set(),
|
||||
},
|
||||
status: 'idle',
|
||||
},
|
||||
generateApiClient: {
|
||||
output: {},
|
||||
status: 'idle',
|
||||
},
|
||||
syncApplication: {
|
||||
output: { syncStatus: 'idle', error: null },
|
||||
status: 'idle',
|
||||
},
|
||||
startWatchers: {
|
||||
output: { watchersStarted: false },
|
||||
status: 'idle',
|
||||
},
|
||||
};
|
||||
|
||||
this.pipeline = {
|
||||
status: 'idle',
|
||||
isSyncing: false,
|
||||
error: null,
|
||||
appName: null,
|
||||
};
|
||||
|
||||
this.entities = new Map();
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
notify(): void {
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
updatePipeline(update: Partial<OrchestratorStatePipeline>): void {
|
||||
Object.assign(this.pipeline, update);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
applyStepEvents(stepEvents: OrchestratorStateStepEvent[]): void {
|
||||
const enrichedEvents: OrchestratorStateEvent[] = stepEvents.map(
|
||||
(stepEvent) => {
|
||||
this.eventIdCounter += 1;
|
||||
|
||||
return {
|
||||
...stepEvent,
|
||||
id: this.eventIdCounter,
|
||||
timestamp: new Date(),
|
||||
message: stepEvent.message.slice(0, 5_000),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
this.events = [
|
||||
...this.events.slice(-(MAX_EVENT_COUNT - enrichedEvents.length)),
|
||||
...enrichedEvents,
|
||||
];
|
||||
}
|
||||
|
||||
addEvent(event: OrchestratorStateStepEvent): void {
|
||||
this.applyStepEvents([event]);
|
||||
}
|
||||
|
||||
updateEntityStatus(
|
||||
filePath: string,
|
||||
status: OrchestratorStateFileStatus,
|
||||
): void {
|
||||
const entities = new Map(this.entities);
|
||||
const entity = entities.get(filePath);
|
||||
|
||||
if (
|
||||
entity?.status &&
|
||||
!FILE_STATUS_TRANSITION_MATRIX[entity.status].includes(status)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
entities.set(
|
||||
filePath,
|
||||
entity
|
||||
? { ...entity, status }
|
||||
: { name: filePath, path: filePath, status },
|
||||
);
|
||||
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
removeEntity(filePath: string): void {
|
||||
const entities = new Map(this.entities);
|
||||
|
||||
entities.delete(filePath);
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
updateAllEntitiesStatus(status: OrchestratorStateFileStatus): void {
|
||||
const entities = new Map(this.entities);
|
||||
|
||||
for (const [filePath, entity] of entities) {
|
||||
entities.set(filePath, { ...entity, status });
|
||||
}
|
||||
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
updateEntitiesFromManifest(manifestFilePaths: EntityFilePaths): void {
|
||||
const entityTypeMap = new Map<string, SyncableEntity>();
|
||||
|
||||
for (const [entityType, filePaths] of Object.entries(manifestFilePaths)) {
|
||||
const syncableEntity = ENTITY_TYPE_TO_SYNCABLE[entityType];
|
||||
|
||||
if (!syncableEntity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const filePath of filePaths as string[]) {
|
||||
entityTypeMap.set(filePath, syncableEntity);
|
||||
}
|
||||
}
|
||||
|
||||
const entities = new Map(this.entities);
|
||||
|
||||
for (const [filePath, entity] of entities) {
|
||||
entities.set(filePath, {
|
||||
...entity,
|
||||
type: entityTypeMap.get(filePath),
|
||||
});
|
||||
}
|
||||
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
hasObjectsOrFieldsChanged(manifest: Manifest): boolean {
|
||||
const fingerprint = JSON.stringify({
|
||||
objects: manifest.objects,
|
||||
fields: manifest.fields,
|
||||
});
|
||||
|
||||
const changed = fingerprint !== this.previousObjectsFieldsFingerprint;
|
||||
|
||||
this.previousObjectsFieldsFingerprint = fingerprint;
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { ClientService } from '@/cli/utilities/client/client-service';
|
||||
import { ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { BuildManifestOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/build-manifest-orchestrator-step';
|
||||
import { CheckServerOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step';
|
||||
import { EnsureValidTokensOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step';
|
||||
import { GenerateApiClientOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step';
|
||||
import { ResolveApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/resolve-application-orchestrator-step';
|
||||
import { StartWatchersOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
|
||||
import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
|
||||
import { UploadFilesOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application';
|
||||
|
||||
export type DevModeOrchestratorOptions = {
|
||||
state: OrchestratorState;
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
export class DevModeOrchestrator {
|
||||
private state: OrchestratorState;
|
||||
private debounceMs: number;
|
||||
private syncTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private checkServerStep: CheckServerOrchestratorStep;
|
||||
private ensureValidTokensStep: EnsureValidTokensOrchestratorStep;
|
||||
private buildManifestStep: BuildManifestOrchestratorStep;
|
||||
private resolveApplicationStep: ResolveApplicationOrchestratorStep;
|
||||
private uploadFilesStep: UploadFilesOrchestratorStep;
|
||||
private generateApiClientStep: GenerateApiClientOrchestratorStep;
|
||||
private syncApplicationStep: SyncApplicationOrchestratorStep;
|
||||
private startWatchersStep: StartWatchersOrchestratorStep;
|
||||
|
||||
constructor(options: DevModeOrchestratorOptions) {
|
||||
this.debounceMs = options.debounceMs ?? 200;
|
||||
this.state = options.state;
|
||||
|
||||
const apiService = new ApiService({ disableInterceptors: true });
|
||||
const configService = new ConfigService();
|
||||
const clientService = new ClientService();
|
||||
const stepDeps = { state: this.state, notify: () => this.state.notify() };
|
||||
|
||||
this.checkServerStep = new CheckServerOrchestratorStep({
|
||||
...stepDeps,
|
||||
apiService,
|
||||
});
|
||||
this.ensureValidTokensStep = new EnsureValidTokensOrchestratorStep({
|
||||
...stepDeps,
|
||||
apiService,
|
||||
configService,
|
||||
});
|
||||
this.buildManifestStep = new BuildManifestOrchestratorStep(stepDeps);
|
||||
this.resolveApplicationStep = new ResolveApplicationOrchestratorStep({
|
||||
...stepDeps,
|
||||
apiService,
|
||||
});
|
||||
this.uploadFilesStep = new UploadFilesOrchestratorStep(stepDeps);
|
||||
this.generateApiClientStep = new GenerateApiClientOrchestratorStep({
|
||||
...stepDeps,
|
||||
clientService,
|
||||
configService,
|
||||
});
|
||||
this.syncApplicationStep = new SyncApplicationOrchestratorStep({
|
||||
...stepDeps,
|
||||
apiService,
|
||||
});
|
||||
this.startWatchersStep = new StartWatchersOrchestratorStep({
|
||||
...stepDeps,
|
||||
scheduleSync: this.scheduleSync.bind(this),
|
||||
uploadFilesStep: this.uploadFilesStep,
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const outputDir = path.join(this.state.appPath, OUTPUT_DIR);
|
||||
|
||||
await fs.ensureDir(outputDir);
|
||||
await fs.emptyDir(outputDir);
|
||||
|
||||
await this.startWatchersStep.start();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.startWatchersStep.close();
|
||||
}
|
||||
|
||||
getState(): OrchestratorState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
if (this.syncTimer) {
|
||||
clearTimeout(this.syncTimer);
|
||||
}
|
||||
|
||||
this.syncTimer = setTimeout(() => {
|
||||
this.syncTimer = null;
|
||||
void this.performSync();
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
private async performSync(): Promise<void> {
|
||||
if (this.state.pipeline.isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.updatePipeline({ isSyncing: true });
|
||||
|
||||
try {
|
||||
await this.runSyncPipeline();
|
||||
} catch (error) {
|
||||
this.state.addEvent({
|
||||
message: `Sync failed with error ${JSON.stringify(error, null, 2)}`,
|
||||
status: 'error',
|
||||
});
|
||||
this.state.updatePipeline({ status: 'error' });
|
||||
} finally {
|
||||
this.state.updatePipeline({ isSyncing: false });
|
||||
}
|
||||
}
|
||||
|
||||
private async runSyncPipeline(): Promise<void> {
|
||||
const isReady = await this.checkServerStep.execute();
|
||||
|
||||
if (!isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ensureValidTokensStep.execute({
|
||||
applicationId: this.state.steps.resolveApplication.output.applicationId,
|
||||
});
|
||||
|
||||
const buildResult = await this.buildManifestStep.execute({
|
||||
appPath: this.state.appPath,
|
||||
});
|
||||
|
||||
if (!buildResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startWatchersStep.handleWatcherRestarts(buildResult);
|
||||
|
||||
if (!this.uploadFilesStep.isInitialized) {
|
||||
const initialized = await this.initializePipeline(buildResult.manifest!);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.hasObjectsOrFieldsChanged(buildResult.manifest!)) {
|
||||
await this.generateApiClientStep.execute({
|
||||
appPath: this.state.appPath,
|
||||
});
|
||||
}
|
||||
|
||||
await this.uploadFilesStep.waitForUploads();
|
||||
|
||||
await this.syncApplicationStep.execute({
|
||||
manifest: buildResult.manifest!,
|
||||
builtFileInfos: this.state.steps.uploadFiles.output.builtFileInfos,
|
||||
appPath: this.state.appPath,
|
||||
});
|
||||
}
|
||||
|
||||
private async initializePipeline(manifest: Manifest): Promise<boolean> {
|
||||
const resolveResult = await this.resolveApplicationStep.execute({
|
||||
manifest,
|
||||
});
|
||||
|
||||
if (!resolveResult.applicationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.ensureValidTokensStep.exchangeTokens({
|
||||
applicationId: resolveResult.applicationId,
|
||||
});
|
||||
|
||||
this.uploadFilesStep.initialize({
|
||||
appPath: this.state.appPath,
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-update-checksums';
|
||||
import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate';
|
||||
import {
|
||||
type OrchestratorState,
|
||||
type OrchestratorStateStepEvent,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
|
||||
export type BuildManifestOrchestratorStepOutput = {
|
||||
result: ManifestBuildResult | null;
|
||||
};
|
||||
|
||||
export class BuildManifestOrchestratorStep {
|
||||
private state: OrchestratorState;
|
||||
private notify: () => void;
|
||||
|
||||
constructor({
|
||||
state,
|
||||
notify,
|
||||
}: {
|
||||
state: OrchestratorState;
|
||||
notify: () => void;
|
||||
}) {
|
||||
this.state = state;
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
async execute(input: {
|
||||
appPath: string;
|
||||
}): Promise<ManifestBuildResult | null> {
|
||||
const step = this.state.steps.buildManifest;
|
||||
|
||||
step.status = 'in_progress';
|
||||
this.state.updatePipeline({ status: 'building' });
|
||||
|
||||
const events: OrchestratorStateStepEvent[] = [
|
||||
{ message: 'Building manifest', status: 'info' },
|
||||
];
|
||||
|
||||
const result = await buildManifest(input.appPath);
|
||||
|
||||
if (result.errors.length > 0 || !result.manifest) {
|
||||
for (const error of result.errors) {
|
||||
events.push({ message: error, status: 'error' });
|
||||
}
|
||||
|
||||
step.output = { result: null };
|
||||
step.status = 'error';
|
||||
this.state.updatePipeline({ status: 'error' });
|
||||
this.state.applyStepEvents(events);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const validation = manifestValidate(result.manifest);
|
||||
|
||||
if (!validation.isValid) {
|
||||
for (const validationError of validation.errors) {
|
||||
events.push({ message: validationError, status: 'error' });
|
||||
}
|
||||
|
||||
step.output = { result: null };
|
||||
step.status = 'error';
|
||||
this.state.updatePipeline({ status: 'error' });
|
||||
this.state.applyStepEvents(events);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
for (const warning of validation.warnings) {
|
||||
events.push({ message: `⚠ ${warning}`, status: 'warning' });
|
||||
}
|
||||
}
|
||||
|
||||
events.push({
|
||||
message: 'Successfully built manifest',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
step.output = { result };
|
||||
step.status = 'done';
|
||||
this.state.updatePipeline({
|
||||
appName: result.manifest.application.displayName,
|
||||
});
|
||||
this.state.updateEntitiesFromManifest(result.filePaths);
|
||||
this.state.applyStepEvents(events);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { type ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
|
||||
export type CheckServerOrchestratorStepOutput = {
|
||||
isReady: boolean;
|
||||
errorLogged: boolean;
|
||||
};
|
||||
|
||||
export class CheckServerOrchestratorStep {
|
||||
private apiService: ApiService;
|
||||
private state: OrchestratorState;
|
||||
private notify: () => void;
|
||||
|
||||
constructor({
|
||||
apiService,
|
||||
state,
|
||||
notify,
|
||||
}: {
|
||||
apiService: ApiService;
|
||||
state: OrchestratorState;
|
||||
notify: () => void;
|
||||
}) {
|
||||
this.apiService = apiService;
|
||||
this.state = state;
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
async execute(): Promise<boolean> {
|
||||
const step = this.state.steps.checkServer;
|
||||
const validateAuth = await this.apiService.validateAuth();
|
||||
|
||||
if (!validateAuth.serverUp) {
|
||||
if (!step.output.errorLogged) {
|
||||
step.output = { isReady: false, errorLogged: true };
|
||||
step.status = 'error';
|
||||
this.state.updatePipeline({ status: 'error' });
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'Cannot reach server', status: 'error' },
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validateAuth.authValid) {
|
||||
if (!step.output.errorLogged) {
|
||||
step.output = { isReady: false, errorLogged: true };
|
||||
step.status = 'error';
|
||||
this.state.updatePipeline({ status: 'error' });
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'Authentication failed', status: 'error' },
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
step.output = { isReady: true, errorLogged: false };
|
||||
step.status = 'done';
|
||||
this.notify();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { type ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { type ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
|
||||
export class EnsureValidTokensOrchestratorStep {
|
||||
private apiService: ApiService;
|
||||
private configService: ConfigService;
|
||||
private state: OrchestratorState;
|
||||
private notify: () => void;
|
||||
|
||||
constructor({
|
||||
apiService,
|
||||
configService,
|
||||
state,
|
||||
notify,
|
||||
}: {
|
||||
apiService: ApiService;
|
||||
configService: ConfigService;
|
||||
state: OrchestratorState;
|
||||
notify: () => void;
|
||||
}) {
|
||||
this.apiService = apiService;
|
||||
this.configService = configService;
|
||||
this.state = state;
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
async execute(input: { applicationId: string | null }): Promise<void> {
|
||||
if (!input.applicationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = this.state.steps.ensureValidTokens;
|
||||
|
||||
step.status = 'in_progress';
|
||||
this.notify();
|
||||
|
||||
const config = await this.configService.getConfig();
|
||||
|
||||
if (
|
||||
config.applicationAccessToken &&
|
||||
!this.isTokenExpired(config.applicationAccessToken)
|
||||
) {
|
||||
step.status = 'done';
|
||||
this.notify();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
config.applicationRefreshToken &&
|
||||
!this.isTokenExpired(config.applicationRefreshToken)
|
||||
) {
|
||||
const renewResult = await this.apiService.renewApplicationToken(
|
||||
config.applicationRefreshToken,
|
||||
);
|
||||
|
||||
if (renewResult.success) {
|
||||
await this.configService.setConfig({
|
||||
applicationAccessToken: renewResult.data.applicationAccessToken.token,
|
||||
applicationRefreshToken:
|
||||
renewResult.data.applicationRefreshToken.token,
|
||||
});
|
||||
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'Renewing application tokens', status: 'info' },
|
||||
{ message: 'Application tokens renewed', status: 'success' },
|
||||
]);
|
||||
step.status = 'done';
|
||||
this.notify();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'Renewing application tokens', status: 'info' },
|
||||
{
|
||||
message: `Failed to renew application tokens: ${JSON.stringify(renewResult.error, null, 2)}`,
|
||||
status: 'error',
|
||||
},
|
||||
]);
|
||||
|
||||
await this.exchangeTokens({ applicationId: input.applicationId });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.exchangeTokens({ applicationId: input.applicationId });
|
||||
}
|
||||
|
||||
async exchangeTokens(input: { applicationId: string }): Promise<void> {
|
||||
const tokenResult = await this.apiService.generateApplicationToken(
|
||||
input.applicationId,
|
||||
);
|
||||
|
||||
if (!tokenResult.success) {
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'Generating application tokens', status: 'info' },
|
||||
{
|
||||
message: `Failed to generate application tokens: ${JSON.stringify(tokenResult.error, null, 2)}`,
|
||||
status: 'error',
|
||||
},
|
||||
]);
|
||||
this.state.steps.ensureValidTokens.status = 'error';
|
||||
this.notify();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.configService.setConfig({
|
||||
applicationAccessToken: tokenResult.data.applicationAccessToken.token,
|
||||
applicationRefreshToken: tokenResult.data.applicationRefreshToken.token,
|
||||
});
|
||||
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'Generating application tokens', status: 'info' },
|
||||
{ message: 'Application tokens stored in config', status: 'success' },
|
||||
]);
|
||||
this.state.steps.ensureValidTokens.status = 'done';
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(token.split('.')[1], 'base64').toString(),
|
||||
);
|
||||
|
||||
return Date.now() >= payload.exp * 1000 - 60_000;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { type ClientService } from '@/cli/utilities/client/client-service';
|
||||
import { type ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
|
||||
export class GenerateApiClientOrchestratorStep {
|
||||
private clientService: ClientService;
|
||||
private configService: ConfigService;
|
||||
private state: OrchestratorState;
|
||||
private notify: () => void;
|
||||
|
||||
constructor({
|
||||
clientService,
|
||||
configService,
|
||||
state,
|
||||
notify,
|
||||
}: {
|
||||
clientService: ClientService;
|
||||
configService: ConfigService;
|
||||
state: OrchestratorState;
|
||||
notify: () => void;
|
||||
}) {
|
||||
this.clientService = clientService;
|
||||
this.configService = configService;
|
||||
this.state = state;
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
async execute(input: { appPath: string }): Promise<void> {
|
||||
const step = this.state.steps.generateApiClient;
|
||||
|
||||
step.status = 'in_progress';
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
const config = await this.configService.getConfig();
|
||||
|
||||
await this.clientService.generate({
|
||||
appPath: input.appPath,
|
||||
authToken: config.applicationAccessToken,
|
||||
});
|
||||
|
||||
step.status = 'done';
|
||||
} catch (error) {
|
||||
this.state.applyStepEvents([
|
||||
{
|
||||
message: `Failed to generate API client: ${error instanceof Error ? error.message : String(error)}`,
|
||||
status: 'error',
|
||||
},
|
||||
]);
|
||||
step.status = 'error';
|
||||
}
|
||||
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { type ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
export type ResolveApplicationOrchestratorStepOutput = {
|
||||
applicationId: string | null;
|
||||
universalIdentifier: string | null;
|
||||
};
|
||||
|
||||
export class ResolveApplicationOrchestratorStep {
|
||||
private apiService: ApiService;
|
||||
private state: OrchestratorState;
|
||||
private notify: () => void;
|
||||
|
||||
constructor({
|
||||
apiService,
|
||||
state,
|
||||
notify,
|
||||
}: {
|
||||
apiService: ApiService;
|
||||
state: OrchestratorState;
|
||||
notify: () => void;
|
||||
}) {
|
||||
this.apiService = apiService;
|
||||
this.state = state;
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
async execute(input: {
|
||||
manifest: Manifest;
|
||||
}): Promise<ResolveApplicationOrchestratorStepOutput> {
|
||||
const step = this.state.steps.resolveApplication;
|
||||
|
||||
step.status = 'in_progress';
|
||||
this.notify();
|
||||
|
||||
const universalIdentifier = input.manifest.application.universalIdentifier;
|
||||
|
||||
const findResult =
|
||||
await this.apiService.findOneApplication(universalIdentifier);
|
||||
|
||||
if (!findResult.success) {
|
||||
this.state.applyStepEvents([
|
||||
{
|
||||
message: `Failed to find application ${universalIdentifier}`,
|
||||
status: 'error',
|
||||
},
|
||||
]);
|
||||
step.status = 'error';
|
||||
this.state.updatePipeline({ status: 'error' });
|
||||
|
||||
return step.output;
|
||||
}
|
||||
|
||||
if (findResult.data) {
|
||||
step.output = {
|
||||
applicationId: findResult.data.id,
|
||||
universalIdentifier: findResult.data.universalIdentifier,
|
||||
};
|
||||
step.status = 'done';
|
||||
this.notify();
|
||||
|
||||
return step.output;
|
||||
}
|
||||
|
||||
const createResult = await this.apiService.createApplication(
|
||||
input.manifest,
|
||||
);
|
||||
|
||||
if (!createResult.success) {
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'Creating application', status: 'info' },
|
||||
{
|
||||
message: `Application creation failed with error ${JSON.stringify(createResult.error, null, 2)}`,
|
||||
status: 'error',
|
||||
},
|
||||
]);
|
||||
step.status = 'error';
|
||||
this.state.updatePipeline({ status: 'error' });
|
||||
|
||||
return step.output;
|
||||
}
|
||||
|
||||
step.output = {
|
||||
applicationId: createResult.data!.id,
|
||||
universalIdentifier: createResult.data!.universalIdentifier,
|
||||
};
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'Creating application', status: 'info' },
|
||||
{ message: 'Application created', status: 'success' },
|
||||
]);
|
||||
step.status = 'done';
|
||||
this.notify();
|
||||
|
||||
return step.output;
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
createFrontComponentsWatcher,
|
||||
createLogicFunctionsWatcher,
|
||||
type EsbuildWatcher,
|
||||
} from '@/cli/utilities/build/common/esbuild-watcher';
|
||||
import { FileUploadWatcher } from '@/cli/utilities/build/common/file-upload-watcher';
|
||||
import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-update-checksums';
|
||||
import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { type UploadFilesOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
|
||||
import type { Location } from 'esbuild';
|
||||
import { type EventName } from 'chokidar/handler.js';
|
||||
import { ASSETS_DIR } from 'twenty-shared/application';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
export type StartWatchersOrchestratorStepOutput = {
|
||||
watchersStarted: boolean;
|
||||
};
|
||||
|
||||
export class StartWatchersOrchestratorStep {
|
||||
private state: OrchestratorState;
|
||||
private scheduleSync: () => void;
|
||||
private notify: () => void;
|
||||
private uploadFilesStep: UploadFilesOrchestratorStep;
|
||||
|
||||
private manifestWatcher: ManifestWatcher | null = null;
|
||||
private logicFunctionsWatcher: EsbuildWatcher | null = null;
|
||||
private frontComponentsWatcher: EsbuildWatcher | null = null;
|
||||
private assetWatcher: FileUploadWatcher | null = null;
|
||||
private dependencyWatcher: FileUploadWatcher | null = null;
|
||||
|
||||
constructor(options: {
|
||||
state: OrchestratorState;
|
||||
scheduleSync: () => void;
|
||||
notify: () => void;
|
||||
uploadFilesStep: UploadFilesOrchestratorStep;
|
||||
}) {
|
||||
this.state = options.state;
|
||||
this.scheduleSync = options.scheduleSync;
|
||||
this.notify = options.notify;
|
||||
this.uploadFilesStep = options.uploadFilesStep;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.state.steps.startWatchers.status = 'in_progress';
|
||||
this.notify();
|
||||
|
||||
this.manifestWatcher = new ManifestWatcher({
|
||||
appPath: this.state.appPath,
|
||||
handleChangeDetected: this.handleChangeDetected.bind(this),
|
||||
});
|
||||
|
||||
await this.manifestWatcher.start();
|
||||
}
|
||||
|
||||
async handleWatcherRestarts(result: ManifestBuildResult): Promise<void> {
|
||||
const { logicFunctions, frontComponents } = result.filePaths;
|
||||
|
||||
if (!this.state.steps.startWatchers.output.watchersStarted) {
|
||||
this.state.steps.startWatchers.output.watchersStarted = true;
|
||||
this.state.steps.startWatchers.status = 'done';
|
||||
await this.startFileWatchers(logicFunctions, frontComponents);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.logicFunctionsWatcher?.shouldRestart(logicFunctions)) {
|
||||
await this.logicFunctionsWatcher.restart(logicFunctions);
|
||||
}
|
||||
|
||||
if (this.frontComponentsWatcher?.shouldRestart(frontComponents)) {
|
||||
await this.frontComponentsWatcher.restart(frontComponents);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.manifestWatcher?.close(),
|
||||
this.logicFunctionsWatcher?.close(),
|
||||
this.frontComponentsWatcher?.close(),
|
||||
this.assetWatcher?.close(),
|
||||
this.dependencyWatcher?.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
private handleChangeDetected(sourcePath: string, event: EventName): void {
|
||||
this.state.addEvent({
|
||||
message: `Change detected: ${sourcePath}`,
|
||||
status: 'info',
|
||||
});
|
||||
|
||||
if (event === 'unlink') {
|
||||
this.state.removeEntity(sourcePath);
|
||||
} else {
|
||||
this.state.updateEntityStatus(sourcePath, 'building');
|
||||
}
|
||||
|
||||
this.notify();
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
private handleFileBuildError(
|
||||
errors: { error: string; location: Location | null }[],
|
||||
): void {
|
||||
this.state.addEvent({
|
||||
message: 'Build failed:',
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
for (const error of errors) {
|
||||
this.state.addEvent({
|
||||
message: error.error,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private handleFileBuilt({
|
||||
fileFolder,
|
||||
builtPath,
|
||||
sourcePath,
|
||||
checksum,
|
||||
}: {
|
||||
fileFolder: FileFolder;
|
||||
builtPath: string;
|
||||
sourcePath: string;
|
||||
checksum: string;
|
||||
}): void {
|
||||
this.state.addEvent({
|
||||
message: `Successfully built ${builtPath}`,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
this.state.steps.uploadFiles.output.builtFileInfos.set(builtPath, {
|
||||
checksum,
|
||||
builtPath,
|
||||
sourcePath,
|
||||
fileFolder,
|
||||
});
|
||||
|
||||
if (this.state.steps.uploadFiles.output.fileUploader) {
|
||||
this.uploadFilesStep.uploadFile(builtPath, sourcePath, fileFolder);
|
||||
}
|
||||
|
||||
this.notify();
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
private async startFileWatchers(
|
||||
logicFunctions: string[],
|
||||
frontComponents: string[],
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
this.startLogicFunctionsWatcher(logicFunctions),
|
||||
this.startFrontComponentsWatcher(frontComponents),
|
||||
this.startAssetWatcher(),
|
||||
this.startDependencyWatcher(),
|
||||
]);
|
||||
}
|
||||
|
||||
private async startLogicFunctionsWatcher(
|
||||
sourcePaths: string[],
|
||||
): Promise<void> {
|
||||
this.logicFunctionsWatcher = createLogicFunctionsWatcher({
|
||||
appPath: this.state.appPath,
|
||||
sourcePaths,
|
||||
handleBuildError: this.handleFileBuildError.bind(this),
|
||||
handleFileBuilt: this.handleFileBuilt.bind(this),
|
||||
});
|
||||
|
||||
await this.logicFunctionsWatcher.start();
|
||||
}
|
||||
|
||||
private async startFrontComponentsWatcher(
|
||||
sourcePaths: string[],
|
||||
): Promise<void> {
|
||||
this.frontComponentsWatcher = createFrontComponentsWatcher({
|
||||
appPath: this.state.appPath,
|
||||
sourcePaths,
|
||||
handleBuildError: this.handleFileBuildError.bind(this),
|
||||
handleFileBuilt: this.handleFileBuilt.bind(this),
|
||||
});
|
||||
|
||||
await this.frontComponentsWatcher.start();
|
||||
}
|
||||
|
||||
private async startAssetWatcher(): Promise<void> {
|
||||
this.assetWatcher = new FileUploadWatcher({
|
||||
appPath: this.state.appPath,
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
watchPaths: [ASSETS_DIR],
|
||||
handleFileBuilt: this.handleFileBuilt.bind(this),
|
||||
});
|
||||
|
||||
await this.assetWatcher.start();
|
||||
}
|
||||
|
||||
private async startDependencyWatcher(): Promise<void> {
|
||||
this.dependencyWatcher = new FileUploadWatcher({
|
||||
appPath: this.state.appPath,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
watchPaths: ['package.json', 'yarn.lock'],
|
||||
handleFileBuilt: this.handleFileBuilt.bind(this),
|
||||
});
|
||||
|
||||
this.dependencyWatcher.start();
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { type ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
|
||||
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
|
||||
import {
|
||||
type OrchestratorState,
|
||||
type OrchestratorStateBuiltFileInfo,
|
||||
type OrchestratorStateStepEvent,
|
||||
type OrchestratorStateSyncStatus,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
export type SyncApplicationOrchestratorStepOutput = {
|
||||
syncStatus: OrchestratorStateSyncStatus;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export class SyncApplicationOrchestratorStep {
|
||||
private apiService: ApiService;
|
||||
private state: OrchestratorState;
|
||||
private notify: () => void;
|
||||
|
||||
constructor({
|
||||
apiService,
|
||||
state,
|
||||
notify,
|
||||
}: {
|
||||
apiService: ApiService;
|
||||
state: OrchestratorState;
|
||||
notify: () => void;
|
||||
}) {
|
||||
this.apiService = apiService;
|
||||
this.state = state;
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
async execute(input: {
|
||||
manifest: Manifest;
|
||||
builtFileInfos: Map<string, OrchestratorStateBuiltFileInfo>;
|
||||
appPath: string;
|
||||
}): Promise<void> {
|
||||
const step = this.state.steps.syncApplication;
|
||||
|
||||
step.status = 'in_progress';
|
||||
this.state.updatePipeline({ status: 'syncing' });
|
||||
|
||||
const events: OrchestratorStateStepEvent[] = [];
|
||||
|
||||
const manifest = manifestUpdateChecksums({
|
||||
manifest: input.manifest,
|
||||
builtFileInfos: input.builtFileInfos,
|
||||
});
|
||||
|
||||
events.push({ message: 'Manifest checksums set', status: 'info' });
|
||||
|
||||
await writeManifestToOutput(input.appPath, manifest);
|
||||
|
||||
events.push({
|
||||
message: 'Manifest saved to output directory',
|
||||
status: 'info',
|
||||
});
|
||||
events.push({ message: 'Syncing manifest', status: 'info' });
|
||||
|
||||
const syncResult = await this.apiService.syncApplication(manifest);
|
||||
|
||||
if (syncResult.success) {
|
||||
events.push({ message: '✓ Synced', status: 'success' });
|
||||
step.output = { syncStatus: 'synced', error: null };
|
||||
step.status = 'done';
|
||||
this.state.updatePipeline({ status: 'synced', error: null });
|
||||
this.state.updateAllEntitiesStatus('success');
|
||||
this.state.applyStepEvents(events);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = `Sync failed with error ${JSON.stringify(syncResult.error, null, 2)}`;
|
||||
|
||||
events.push({ message: errorMessage, status: 'error' });
|
||||
step.output = { syncStatus: 'error', error: errorMessage };
|
||||
step.status = 'error';
|
||||
this.state.updatePipeline({ status: 'error', error: errorMessage });
|
||||
this.state.applyStepEvents(events);
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
type OrchestratorState,
|
||||
type OrchestratorStateBuiltFileInfo,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { FileUploader } from '@/cli/utilities/file/file-uploader';
|
||||
import { type FileFolder } from 'twenty-shared/types';
|
||||
|
||||
export type UploadFilesOrchestratorStepOutput = {
|
||||
fileUploader: FileUploader | null;
|
||||
builtFileInfos: Map<string, OrchestratorStateBuiltFileInfo>;
|
||||
activeUploads: Set<Promise<void>>;
|
||||
};
|
||||
|
||||
export class UploadFilesOrchestratorStep {
|
||||
private state: OrchestratorState;
|
||||
private notify: () => void;
|
||||
|
||||
constructor({
|
||||
state,
|
||||
notify,
|
||||
}: {
|
||||
state: OrchestratorState;
|
||||
notify: () => void;
|
||||
}) {
|
||||
this.state = state;
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return this.state.steps.uploadFiles.output.fileUploader !== null;
|
||||
}
|
||||
|
||||
initialize(input: { appPath: string; universalIdentifier: string }): void {
|
||||
const step = this.state.steps.uploadFiles;
|
||||
|
||||
step.output = {
|
||||
...step.output,
|
||||
fileUploader: new FileUploader({
|
||||
appPath: input.appPath,
|
||||
applicationUniversalIdentifier: input.universalIdentifier,
|
||||
}),
|
||||
};
|
||||
step.status = 'in_progress';
|
||||
this.notify();
|
||||
|
||||
this.uploadPendingFiles();
|
||||
}
|
||||
|
||||
uploadFile(
|
||||
builtPath: string,
|
||||
sourcePath: string,
|
||||
fileFolder: FileFolder,
|
||||
): void {
|
||||
const step = this.state.steps.uploadFiles;
|
||||
|
||||
if (!step.output.fileUploader) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.addEvent({
|
||||
message: `Uploading ${builtPath}`,
|
||||
status: 'info',
|
||||
});
|
||||
this.state.updateEntityStatus(sourcePath, 'uploading');
|
||||
|
||||
const uploadPromise = step.output.fileUploader
|
||||
.uploadFile({ builtPath, fileFolder })
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
this.state.addEvent({
|
||||
message: `Successfully uploaded ${builtPath}`,
|
||||
status: 'success',
|
||||
});
|
||||
this.state.updateEntityStatus(sourcePath, 'success');
|
||||
} else {
|
||||
this.state.addEvent({
|
||||
message: `Failed to upload ${builtPath}: ${result.error}`,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.state.addEvent({
|
||||
message: `Upload failed for ${builtPath}: ${error}`,
|
||||
status: 'error',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
step.output.activeUploads.delete(uploadPromise);
|
||||
});
|
||||
|
||||
step.output.activeUploads.add(uploadPromise);
|
||||
}
|
||||
|
||||
async waitForUploads(): Promise<void> {
|
||||
const step = this.state.steps.uploadFiles;
|
||||
|
||||
while (step.output.activeUploads.size > 0) {
|
||||
await Promise.all(step.output.activeUploads);
|
||||
}
|
||||
|
||||
step.status = 'done';
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private uploadPendingFiles(): void {
|
||||
for (const [
|
||||
builtPath,
|
||||
{ fileFolder, sourcePath },
|
||||
] of this.state.steps.uploadFiles.output.builtFileInfos.entries()) {
|
||||
this.uploadFile(builtPath, sourcePath, fileFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
type OrchestratorState,
|
||||
type OrchestratorStateStepStatus,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import {
|
||||
DEV_UI_STATUS_CONFIG,
|
||||
SYNC_STATUS_LABELS,
|
||||
getApplicationUrl,
|
||||
getPipelineRows,
|
||||
groupEntitiesByType,
|
||||
mapStepStatusToDevUiStatus,
|
||||
mapSyncStatusToDevUiStatus,
|
||||
} 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';
|
||||
import {
|
||||
DevUiEntitySection,
|
||||
ENTITY_ORDER,
|
||||
} from '@/cli/utilities/dev/ui/components/dev-ui-entity-section';
|
||||
import React from 'react';
|
||||
|
||||
export const DevUiSyncStatusIndicator = ({
|
||||
state,
|
||||
}: {
|
||||
state: OrchestratorState;
|
||||
}): React.ReactElement => {
|
||||
const { Text } = useInk();
|
||||
const uiStatus = mapSyncStatusToDevUiStatus(state.pipeline.status);
|
||||
const icon = useStatusIcon(uiStatus);
|
||||
const config = DEV_UI_STATUS_CONFIG[uiStatus];
|
||||
const label = SYNC_STATUS_LABELS[state.pipeline.status];
|
||||
|
||||
return (
|
||||
<Text color={config.color}>
|
||||
{icon} {label}
|
||||
{state.pipeline.error && `: ${state.pipeline.error}`}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export const DevUiStepStatusLabel = ({
|
||||
label,
|
||||
status,
|
||||
}: {
|
||||
label: string;
|
||||
status: OrchestratorStateStepStatus;
|
||||
}): React.ReactElement => {
|
||||
const { Box, Text } = useInk();
|
||||
const uiStatus = mapStepStatusToDevUiStatus(status);
|
||||
const icon = useStatusIcon(uiStatus);
|
||||
const config = DEV_UI_STATUS_CONFIG[uiStatus];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{label}: </Text>
|
||||
<Text color={config.color}>
|
||||
{icon} {status.replace('_', ' ')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const DevUiApplicationPanel = ({
|
||||
state,
|
||||
}: {
|
||||
state: OrchestratorState;
|
||||
}): React.ReactElement => {
|
||||
const { Box, Text } = useInk();
|
||||
const groupedEntities = groupEntitiesByType(state.entities);
|
||||
const appUrl = getApplicationUrl(state);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="classic"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Application
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>Name: </Text>
|
||||
<Text bold>{state.pipeline.appName ?? 'Loading...'}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>Overall Status: </Text>
|
||||
<DevUiSyncStatusIndicator state={state} />
|
||||
</Box>
|
||||
{appUrl && (
|
||||
<Box>
|
||||
<Text dimColor>Open:</Text>
|
||||
<Text bold color="cyan">
|
||||
{' '}
|
||||
{appUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginLeft={2} flexDirection="column" marginTop={1}>
|
||||
{getPipelineRows(state).map((row) => (
|
||||
<DevUiStepStatusLabel
|
||||
key={row.label}
|
||||
label={row.label}
|
||||
status={row.status}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{ENTITY_ORDER.map((type) => {
|
||||
const entities = groupedEntities.get(type) ?? [];
|
||||
|
||||
return (
|
||||
<DevUiEntitySection
|
||||
key={type}
|
||||
type={type}
|
||||
entities={entities}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
type OrchestratorStateEntityInfo,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import {
|
||||
type DevUiStatus,
|
||||
DEV_UI_STATUS_CONFIG,
|
||||
ENTITY_LABELS,
|
||||
ENTITY_ORDER,
|
||||
SPINNER_FRAMES,
|
||||
UPLOAD_FRAMES,
|
||||
mapFileStatusToDevUiStatus,
|
||||
shortenPath,
|
||||
} 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';
|
||||
import React from 'react';
|
||||
import { type SyncableEntity } from 'twenty-shared/application';
|
||||
|
||||
export const DevUiStatusIcon = ({
|
||||
uiStatus,
|
||||
}: {
|
||||
uiStatus: DevUiStatus;
|
||||
}): React.ReactElement => {
|
||||
const { Text } = useInk();
|
||||
const icon = useStatusIcon(uiStatus);
|
||||
const config = DEV_UI_STATUS_CONFIG[uiStatus];
|
||||
|
||||
return <Text color={config.color}>{icon} </Text>;
|
||||
};
|
||||
|
||||
export const DevUiEntityRow = ({
|
||||
entity,
|
||||
}: {
|
||||
entity: OrchestratorStateEntityInfo;
|
||||
}): React.ReactElement => {
|
||||
const { Box, Text } = useInk();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<DevUiStatusIcon
|
||||
uiStatus={mapFileStatusToDevUiStatus(entity.status)}
|
||||
/>
|
||||
<Text>{entity.name}</Text>
|
||||
{entity.path !== entity.name && (
|
||||
<Text dimColor> ({shortenPath(entity.path)})</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const DevUiEntitySection = ({
|
||||
type,
|
||||
entities,
|
||||
}: {
|
||||
type: SyncableEntity;
|
||||
entities: OrchestratorStateEntityInfo[];
|
||||
}): React.ReactElement | null => {
|
||||
const { Box, Text } = useInk();
|
||||
|
||||
if (entities.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold dimColor>
|
||||
{ENTITY_LABELS[type]}
|
||||
</Text>
|
||||
{entities.map((entity) => (
|
||||
<DevUiEntityRow key={entity.path} entity={entity} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const DevUiEntityLegend = (): React.ReactElement => {
|
||||
const { Box, Text } = useInk();
|
||||
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
<Text color={DEV_UI_STATUS_CONFIG.idle.color}>
|
||||
{DEV_UI_STATUS_CONFIG.idle.icon}
|
||||
</Text>{' '}
|
||||
pending{' '}
|
||||
<Text color={DEV_UI_STATUS_CONFIG.in_progress.color}>
|
||||
{SPINNER_FRAMES[0]}
|
||||
</Text>{' '}
|
||||
building{' '}
|
||||
<Text color={DEV_UI_STATUS_CONFIG.uploading.color}>
|
||||
{UPLOAD_FRAMES[0]}
|
||||
</Text>{' '}
|
||||
uploading{' '}
|
||||
<Text color={DEV_UI_STATUS_CONFIG.done.color}>
|
||||
{DEV_UI_STATUS_CONFIG.done.icon}
|
||||
</Text>{' '}
|
||||
success
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { ENTITY_ORDER };
|
||||
@@ -0,0 +1,24 @@
|
||||
import { type OrchestratorStateEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import {
|
||||
EVENT_COLORS,
|
||||
formatTime,
|
||||
} from '@/cli/utilities/dev/ui/dev-ui-constants';
|
||||
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
|
||||
import React from 'react';
|
||||
|
||||
export const DevUiEventItem = ({
|
||||
event,
|
||||
}: {
|
||||
event: OrchestratorStateEvent;
|
||||
}): React.ReactElement => {
|
||||
const { Box, Text } = useInk();
|
||||
const color = EVENT_COLORS[event.status];
|
||||
const time = formatTime(event.timestamp);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{time} </Text>
|
||||
<Text color={color}>{event.message}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type OrchestratorStateEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { DevUiApplicationPanel } from '@/cli/utilities/dev/ui/components/dev-ui-application-panel';
|
||||
import { DevUiEntityLegend } from '@/cli/utilities/dev/ui/components/dev-ui-entity-section';
|
||||
import { DevUiEventItem } from '@/cli/utilities/dev/ui/components/dev-ui-event-log';
|
||||
import { InkProvider } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
|
||||
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
|
||||
import { type DevUiStateManager } from '@/cli/utilities/dev/ui/dev-ui-state-manager';
|
||||
import React, { useReducer, useEffect } from 'react';
|
||||
|
||||
const DevUI = ({
|
||||
uiStateManager,
|
||||
}: {
|
||||
uiStateManager: DevUiStateManager;
|
||||
}): React.ReactElement => {
|
||||
const { Box, Static } = useInk();
|
||||
|
||||
const [, forceRender] = useReducer((tick: number) => tick + 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
return uiStateManager.subscribe(() => forceRender());
|
||||
}, [uiStateManager]);
|
||||
|
||||
const state = uiStateManager.getSnapshot();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Static items={state.events}>
|
||||
{(event: OrchestratorStateEvent) => (
|
||||
<DevUiEventItem key={event.id} event={event} />
|
||||
)}
|
||||
</Static>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<DevUiApplicationPanel state={state} />
|
||||
<DevUiEntityLegend />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const renderDevUI = async (
|
||||
uiStateManager: DevUiStateManager,
|
||||
): 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} />
|
||||
</InkProvider>,
|
||||
);
|
||||
|
||||
return { unmount };
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
type OrchestratorState,
|
||||
type OrchestratorStateEvent,
|
||||
type OrchestratorStateFileStatus,
|
||||
type OrchestratorStateStepStatus,
|
||||
type OrchestratorStateSyncStatus,
|
||||
type OrchestratorStateEntityInfo,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { SyncableEntity } from 'twenty-shared/application';
|
||||
|
||||
export type DevUiStatus =
|
||||
| 'idle'
|
||||
| 'in_progress'
|
||||
| 'uploading'
|
||||
| 'done'
|
||||
| 'error';
|
||||
|
||||
export type DevUiStatusConfig = {
|
||||
color: string;
|
||||
icon: 'spinner' | 'upload' | string;
|
||||
};
|
||||
|
||||
export const DEV_UI_STATUS_CONFIG: Record<DevUiStatus, DevUiStatusConfig> = {
|
||||
idle: { color: 'gray', icon: '○' },
|
||||
in_progress: { color: 'yellow', icon: 'spinner' },
|
||||
uploading: { color: 'cyan', icon: 'upload' },
|
||||
done: { color: 'green', icon: '✓' },
|
||||
error: { color: 'red', icon: '✗' },
|
||||
};
|
||||
|
||||
export const mapStepStatusToDevUiStatus = (
|
||||
status: OrchestratorStateStepStatus,
|
||||
): DevUiStatus => {
|
||||
const mapping: Record<OrchestratorStateStepStatus, DevUiStatus> = {
|
||||
idle: 'idle',
|
||||
in_progress: 'in_progress',
|
||||
done: 'done',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
return mapping[status];
|
||||
};
|
||||
|
||||
export const mapFileStatusToDevUiStatus = (
|
||||
status: OrchestratorStateFileStatus,
|
||||
): DevUiStatus => {
|
||||
const mapping: Record<OrchestratorStateFileStatus, DevUiStatus> = {
|
||||
pending: 'idle',
|
||||
building: 'in_progress',
|
||||
uploading: 'uploading',
|
||||
success: 'done',
|
||||
};
|
||||
|
||||
return mapping[status];
|
||||
};
|
||||
|
||||
export const mapSyncStatusToDevUiStatus = (
|
||||
status: OrchestratorStateSyncStatus,
|
||||
): DevUiStatus => {
|
||||
const mapping: Record<OrchestratorStateSyncStatus, DevUiStatus> = {
|
||||
idle: 'idle',
|
||||
building: 'in_progress',
|
||||
syncing: 'in_progress',
|
||||
synced: 'done',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
return mapping[status];
|
||||
};
|
||||
|
||||
export const SYNC_STATUS_LABELS: Record<OrchestratorStateSyncStatus, string> = {
|
||||
idle: 'Idle',
|
||||
building: 'Building...',
|
||||
syncing: 'Syncing...',
|
||||
synced: 'Synced',
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
export const SPINNER_FRAMES = [
|
||||
'⠋',
|
||||
'⠙',
|
||||
'⠹',
|
||||
'⠸',
|
||||
'⠼',
|
||||
'⠴',
|
||||
'⠦',
|
||||
'⠧',
|
||||
'⠇',
|
||||
'⠏',
|
||||
];
|
||||
|
||||
export const UPLOAD_FRAMES = ['↑', '⇡', '↟', '⤒'];
|
||||
|
||||
export const ENTITY_LABELS: Record<SyncableEntity, string> = {
|
||||
[SyncableEntity.Object]: 'Objects',
|
||||
[SyncableEntity.Field]: 'Fields',
|
||||
[SyncableEntity.LogicFunction]: 'Logic functions',
|
||||
[SyncableEntity.FrontComponent]: 'Front components',
|
||||
[SyncableEntity.Role]: 'Roles',
|
||||
};
|
||||
|
||||
export const ENTITY_ORDER = Object.keys(ENTITY_LABELS) as SyncableEntity[];
|
||||
|
||||
export const EVENT_COLORS: Record<OrchestratorStateEvent['status'], string> = {
|
||||
info: 'gray',
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
warning: 'yellow',
|
||||
};
|
||||
|
||||
export const formatTime = (date: Date): string => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export const shortenPath = (path: string, maxLength = 40): string => {
|
||||
if (path.length <= maxLength) return path;
|
||||
const parts = path.split('/');
|
||||
if (parts.length <= 2) return path;
|
||||
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
};
|
||||
|
||||
export const groupEntitiesByType = (
|
||||
entities: Map<string, OrchestratorStateEntityInfo>,
|
||||
): Map<SyncableEntity, OrchestratorStateEntityInfo[]> => {
|
||||
const grouped = new Map<SyncableEntity, OrchestratorStateEntityInfo[]>();
|
||||
|
||||
for (const type of ENTITY_ORDER) {
|
||||
grouped.set(type, []);
|
||||
}
|
||||
|
||||
for (const entity of entities.values()) {
|
||||
if (!entity.type) {
|
||||
continue;
|
||||
}
|
||||
const list = grouped.get(entity.type) ?? [];
|
||||
|
||||
list.push(entity);
|
||||
grouped.set(entity.type, list);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
export const getApplicationUrl = (state: OrchestratorState): string | null => {
|
||||
if (
|
||||
!state.frontendUrl ||
|
||||
!state.steps.resolveApplication.output.universalIdentifier
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${state.frontendUrl}/settings/applications`;
|
||||
};
|
||||
|
||||
export const mergeStepStatuses = (
|
||||
statuses: OrchestratorStateStepStatus[],
|
||||
): OrchestratorStateStepStatus => {
|
||||
if (statuses.some((status) => status === 'error')) return 'error';
|
||||
if (statuses.some((status) => status === 'in_progress')) return 'in_progress';
|
||||
if (statuses.every((status) => status === 'done')) return 'done';
|
||||
|
||||
return 'idle';
|
||||
};
|
||||
|
||||
export type DevUiPipelineRow = {
|
||||
label: string;
|
||||
status: OrchestratorStateStepStatus;
|
||||
};
|
||||
|
||||
export const getPipelineRows = (
|
||||
state: OrchestratorState,
|
||||
): DevUiPipelineRow[] => {
|
||||
const entities = [...state.entities.values()];
|
||||
|
||||
const isBuilding = entities.some((entity) => entity.status === 'building');
|
||||
const allUploaded =
|
||||
entities.length > 0 &&
|
||||
entities.every(
|
||||
(entity) => entity.status === 'uploading' || entity.status === 'success',
|
||||
);
|
||||
|
||||
const resourcesBuildStatus: OrchestratorStateStepStatus = isBuilding
|
||||
? 'in_progress'
|
||||
: allUploaded
|
||||
? 'done'
|
||||
: 'idle';
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Application Initialization',
|
||||
status: mergeStepStatuses([
|
||||
state.steps.checkServer.status,
|
||||
state.steps.ensureValidTokens.status,
|
||||
state.steps.resolveApplication.status,
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Resources Build',
|
||||
status: resourcesBuildStatus,
|
||||
},
|
||||
{
|
||||
label: 'Resources Upload',
|
||||
status: state.steps.uploadFiles.status,
|
||||
},
|
||||
{
|
||||
label: 'Manifest Build',
|
||||
status: state.steps.buildManifest.status,
|
||||
},
|
||||
{
|
||||
label: 'Application Synchronization',
|
||||
status: state.steps.syncApplication.status,
|
||||
},
|
||||
{
|
||||
label: 'Api Client Generation',
|
||||
status: state.steps.generateApiClient.status,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
type DevUiStatus,
|
||||
DEV_UI_STATUS_CONFIG,
|
||||
SPINNER_FRAMES,
|
||||
UPLOAD_FRAMES,
|
||||
} from '@/cli/utilities/dev/ui/dev-ui-constants';
|
||||
|
||||
export const useAnimatedFrame = (frames: string[], interval = 80): string => {
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [frames, interval]);
|
||||
|
||||
return frames[frameIndex];
|
||||
};
|
||||
|
||||
export const useStatusIcon = (uiStatus: DevUiStatus): string => {
|
||||
const spinnerFrame = useAnimatedFrame(SPINNER_FRAMES, 80);
|
||||
const uploadFrame = useAnimatedFrame(UPLOAD_FRAMES, 200);
|
||||
const config = DEV_UI_STATUS_CONFIG[uiStatus];
|
||||
|
||||
if (config.icon === 'spinner') return spinnerFrame;
|
||||
if (config.icon === 'upload') return uploadFrame;
|
||||
|
||||
return config.icon;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import type { Box, Text, Static } from 'ink';
|
||||
|
||||
type InkComponents = {
|
||||
Box: typeof Box;
|
||||
Text: typeof Text;
|
||||
Static: typeof Static;
|
||||
};
|
||||
|
||||
const InkContext = React.createContext<InkComponents | null>(null);
|
||||
|
||||
export const InkProvider = InkContext.Provider;
|
||||
|
||||
export const useInk = (): InkComponents => {
|
||||
const context = React.useContext(InkContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useInk must be used within InkProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
|
||||
export type DevUiStateListener = (state: OrchestratorState) => void;
|
||||
|
||||
export class DevUiStateManager {
|
||||
private orchestratorState: OrchestratorState;
|
||||
private listeners = new Set<DevUiStateListener>();
|
||||
|
||||
constructor(orchestratorState: OrchestratorState) {
|
||||
this.orchestratorState = orchestratorState;
|
||||
}
|
||||
|
||||
getSnapshot(): OrchestratorState {
|
||||
return this.orchestratorState;
|
||||
}
|
||||
|
||||
subscribe(listener: DevUiStateListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.orchestratorState);
|
||||
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
notify(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(this.orchestratorState);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user