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:
Charles Bochet
2026-02-17 18:45:52 +01:00
committed by GitHub
parent 0891886aa0
commit c0cc0689d6
72 changed files with 2419 additions and 1422 deletions
@@ -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 };
};
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
}