From f4ca69a47479f6dbb589407d931d86b4d95befe7 Mon Sep 17 00:00:00 2001 From: martmull Date: Tue, 27 Jan 2026 16:34:28 +0100 Subject: [PATCH] Implement dev mode nice UI (#17471) Implement a nice terminal UI for dev mode using INK image --- .github/workflows/ci-sdk.yaml | 103 +++--- packages/twenty-sdk/package.json | 4 +- packages/twenty-sdk/project.json | 2 +- .../app-dev/app-dev.integration.spec.ts | 1 - .../app-dev/tests/console-output.tests.ts | 25 +- .../app-dev/app-dev.integration.spec.ts | 1 - .../app-dev/tests/console-output.tests.ts | 25 +- .../integration/utils/run-app-dev.util.ts | 2 +- .../integration/utils/run-cli-command.util.ts | 1 - .../integration/utils/sanitize-ansi.util.ts | 2 + .../src/cli/commands/app/app-dev.ts | 42 +-- .../src/cli/utilities/api/api-service.ts | 7 +- .../utilities/build/common/esbuild-watcher.ts | 9 +- .../src/cli/utilities/build/common/logger.ts | 45 --- .../common/restartable-watcher-interface.ts | 5 +- .../build/manifest/entities/application.ts | 9 - .../manifest/entities/entity-interface.ts | 1 - .../manifest/entities/front-component.ts | 15 - .../build/manifest/entities/function.ts | 15 - .../manifest/entities/object-extension.ts | 4 - .../build/manifest/entities/object.ts | 7 - .../utilities/build/manifest/entities/role.ts | 7 - .../build/manifest/manifest-watcher.ts | 5 +- .../utilities/dev/dev-mode-orchestrator.ts | 223 ++++++++++--- .../cli/utilities/dev/dev-ui-state-manager.ts | 177 +++++++++++ .../src/cli/utilities/dev/dev-ui-state.ts | 37 +++ .../src/cli/utilities/dev/dev-ui.tsx | 293 ++++++++++++++++++ packages/twenty-sdk/tsconfig.json | 1 + .../enums/syncable-entities.enum.ts | 7 + .../twenty-shared/src/application/index.ts | 1 + yarn.lock | 93 +++++- 31 files changed, 911 insertions(+), 258 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/__tests__/integration/utils/sanitize-ansi.util.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/logger.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state-manager.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/dev-ui.tsx create mode 100644 packages/twenty-shared/src/application/enums/syncable-entities.enum.ts diff --git a/.github/workflows/ci-sdk.yaml b/.github/workflows/ci-sdk.yaml index df58b3fb2f..189fd98bef 100644 --- a/.github/workflows/ci-sdk.yaml +++ b/.github/workflows/ci-sdk.yaml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - task: [lint, typecheck, test:unit, test:integration] + task: [lint, typecheck, test:unit] steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@0.11.0 @@ -44,54 +44,59 @@ jobs: with: tag: scope:sdk tasks: ${{ matrix.task }} - # TODO: Re-enable sdk-e2e-test once application sync is stable - # sdk-e2e-test: - # timeout-minutes: 30 - # runs-on: depot-ubuntu-24.04-8 - # needs: [changed-files-check, sdk-test] - # if: needs.changed-files-check.outputs.any_changed == 'true' - # services: - # postgres: - # image: twentycrm/twenty-postgres-spilo - # env: - # PGUSER_SUPERUSER: postgres - # PGPASSWORD_SUPERUSER: postgres - # ALLOW_NOSSL: 'true' - # SPILO_PROVIDER: 'local' - # ports: - # - 5432:5432 - # options: >- - # --health-cmd pg_isready - # --health-interval 10s - # --health-timeout 5s - # --health-retries 5 - # redis: - # image: redis - # ports: - # - 6379:6379 - # env: - # NODE_ENV: test - # steps: - # - name: Fetch custom Github Actions and base branch history - # uses: actions/checkout@v4 - # with: - # fetch-depth: 0 - # - name: Install dependencies - # uses: ./.github/actions/yarn-install - # - name: Server / Append billing config to .env.test - # working-directory: packages/twenty-server - # run: | - # echo "" >> .env.test - # echo "IS_BILLING_ENABLED=true" >> .env.test - # echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test - # echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test - # echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test - # echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test - # - name: Server / Create Test DB - # run: | - # PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";' - # - name: SDK / Run E2E Tests - # run: npx nx test:e2e twenty-sdk + sdk-e2e-integration-test: + timeout-minutes: 30 + runs-on: depot-ubuntu-24.04-8 + needs: [changed-files-check, sdk-test] + strategy: + matrix: + task: [test:integration, test:e2e] + if: needs.changed-files-check.outputs.any_changed == 'true' + services: + postgres: + image: twentycrm/twenty-postgres-spilo + env: + PGUSER_SUPERUSER: postgres + PGPASSWORD_SUPERUSER: postgres + ALLOW_NOSSL: 'true' + SPILO_PROVIDER: 'local' + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + env: + NODE_ENV: test + steps: + - name: Fetch custom Github Actions and base branch history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install dependencies + uses: ./.github/actions/yarn-install + - name: Server / Append billing config to .env.test + working-directory: packages/twenty-server + run: | + echo "" >> .env.test + echo "IS_BILLING_ENABLED=true" >> .env.test + echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test + echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test + echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test + echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test + - name: Server / Create Test DB + run: | + PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";' + - name: SDK / Run ${{ matrix.task }} Tests + uses: ./.github/actions/nx-affected + with: + tag: scope:sdk + tasks: ${{ matrix.task }} ci-sdk-status-check: if: always() && !cancelled() timeout-minutes: 5 diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index a144b3199c..1a2ea3b26e 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -43,10 +43,11 @@ "fs-extra": "^11.2.0", "graphql": "^16.8.1", "graphql-sse": "^2.5.4", + "ink": "^6.6.0", "inquirer": "^10.0.0", "jsonc-parser": "^3.2.0", "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", + "react": "^19.0.0", "typescript": "^5.9.2", "uuid": "^13.0.0", "vite": "^7.0.0", @@ -57,7 +58,6 @@ "@types/fs-extra": "^11.0.0", "@types/inquirer": "^9.0.0", "@types/lodash.camelcase": "^4.3.7", - "@types/lodash.kebabcase": "^4.1.7", "@types/node": "^24.0.0", "@types/react": "^19.0.2", "tsx": "^4.7.0", diff --git a/packages/twenty-sdk/project.json b/packages/twenty-sdk/project.json index fc1b50e645..7952425fdf 100644 --- a/packages/twenty-sdk/project.json +++ b/packages/twenty-sdk/project.json @@ -97,7 +97,7 @@ "executor": "nx:run-commands", "options": { "cwd": "packages/twenty-sdk", - "command": "npx vitest run --config vitest.integration.config.ts" + "command": "npx wait-on http://localhost:3000/healthz --timeout 600000 --interval 1000 --log && npx vitest run --config vitest.integration.config.ts" } }, "test:e2e": { diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/app-dev.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/app-dev.integration.spec.ts index ddfa038d51..52a092ba31 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/app-dev.integration.spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/app-dev.integration.spec.ts @@ -13,7 +13,6 @@ describe('rich-app app:dev', () => { beforeAll(async () => { result = await runAppDev({ appPath: APP_PATH }); - expect(result.success).toBe(true); }, 60000); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts index 2744d2cdfb..cd3841737f 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts @@ -1,36 +1,35 @@ -import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util'; import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util'; +import { sanitizeAnsi } from '@/cli/__tests__/integration/utils/sanitize-ansi.util'; export const defineConsoleOutputTests = ( getResult: () => RunCliCommandResult, ): void => { describe('console output', () => { it('should contain init messages', () => { - const output = getOutputByPrefix(getResult().output, 'init'); + const output = sanitizeAnsi(getResult().output); - expect(output).toContain( - '[init] ๐Ÿš€ Starting Twenty Application Development Mode', - ); - expect(output).toContain('[init] ๐Ÿ“ App Path:'); + expect(output).toContain('Application'); + expect(output).toContain('Name: Loading...'); + expect(output).toContain('Status: o Idle'); }); it('should contain dev-mode build messages', () => { - const output = getOutputByPrefix(getResult().output, 'dev-mode'); + const output = sanitizeAnsi(getResult().output); - expect(output).toContain('[dev-mode] Building manifest...'); - expect(output).toContain('[dev-mode] Successfully built manifest'); + expect(output).toContain('Building manifest'); + expect(output).toContain('Successfully built manifest'); }); it('should contain dev-mode function build messages', () => { - const output = getOutputByPrefix(getResult().output, 'dev-mode'); + const output = sanitizeAnsi(getResult().output); - expect(output).toContain('[dev-mode] โœ“ Successfully built'); + expect(output).toContain('Successfully built'); }); it('should contain dev-mode sync messages', () => { - const output = getOutputByPrefix(getResult().output, 'dev-mode'); + const output = sanitizeAnsi(getResult().output); - expect(output).toContain('[dev-mode] โœ“ Synced'); + expect(output).toContain('โœ“ Synced'); }); }); }; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/app-dev.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/app-dev.integration.spec.ts index 214b974278..1576ec63cb 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/app-dev.integration.spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/app-dev.integration.spec.ts @@ -14,7 +14,6 @@ describe('root-app app:dev', () => { beforeAll(async () => { result = await runAppDev({ appPath: APP_PATH }); - expect(result.success).toBe(true); }, 60000); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts index 2744d2cdfb..cd3841737f 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts @@ -1,36 +1,35 @@ -import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util'; import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util'; +import { sanitizeAnsi } from '@/cli/__tests__/integration/utils/sanitize-ansi.util'; export const defineConsoleOutputTests = ( getResult: () => RunCliCommandResult, ): void => { describe('console output', () => { it('should contain init messages', () => { - const output = getOutputByPrefix(getResult().output, 'init'); + const output = sanitizeAnsi(getResult().output); - expect(output).toContain( - '[init] ๐Ÿš€ Starting Twenty Application Development Mode', - ); - expect(output).toContain('[init] ๐Ÿ“ App Path:'); + expect(output).toContain('Application'); + expect(output).toContain('Name: Loading...'); + expect(output).toContain('Status: o Idle'); }); it('should contain dev-mode build messages', () => { - const output = getOutputByPrefix(getResult().output, 'dev-mode'); + const output = sanitizeAnsi(getResult().output); - expect(output).toContain('[dev-mode] Building manifest...'); - expect(output).toContain('[dev-mode] Successfully built manifest'); + expect(output).toContain('Building manifest'); + expect(output).toContain('Successfully built manifest'); }); it('should contain dev-mode function build messages', () => { - const output = getOutputByPrefix(getResult().output, 'dev-mode'); + const output = sanitizeAnsi(getResult().output); - expect(output).toContain('[dev-mode] โœ“ Successfully built'); + expect(output).toContain('Successfully built'); }); it('should contain dev-mode sync messages', () => { - const output = getOutputByPrefix(getResult().output, 'dev-mode'); + const output = sanitizeAnsi(getResult().output); - expect(output).toContain('[dev-mode] โœ“ Synced'); + expect(output).toContain('โœ“ Synced'); }); }); }; diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts index fdf6c7c8cb..82a8a0d9b2 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts @@ -16,7 +16,7 @@ export const runAppDev = ( return runCliCommand({ command: 'app:dev', args: [appPath], - waitForOutput: ['[dev-mode] โœ“ Synced'], + waitForOutput: ['โœ“ Synced'], timeout, }); }; diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-cli-command.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-cli-command.util.ts index 6346d07392..a4d189a245 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-cli-command.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-cli-command.util.ts @@ -36,7 +36,6 @@ export const runCliCommand = ( env: { ...process.env, FORCE_COLOR: '0', - TWENTY_SKIP_SERVER_CHECK: 'true', }, }, ); diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/sanitize-ansi.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/sanitize-ansi.util.ts new file mode 100644 index 0000000000..e2f9d6ba30 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/sanitize-ansi.util.ts @@ -0,0 +1,2 @@ +export const sanitizeAnsi = (output: string): string => + output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); diff --git a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts index 23f182d998..c2273261f5 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@/cli/utilities/build/common/logger'; import { createFrontComponentsWatcher, createFunctionsWatcher, @@ -8,12 +7,11 @@ import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifes import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; import { DevModeOrchestrator } from '@/cli/utilities/dev/dev-mode-orchestrator'; -import { ApiService } from '@/cli/utilities/api/api-service'; import path from 'path'; import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants'; import * as fs from 'fs-extra'; - -const initLogger = createLogger('init'); +import { DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager'; +import { renderDevUI } from '@/cli/utilities/dev/dev-ui'; export type AppDevOptions = { appPath?: string; @@ -26,42 +24,33 @@ export class AppDevCommand { private functionsWatcher: EsbuildWatcher | null = null; private frontComponentsWatcher: EsbuildWatcher | null = null; private watchersStarted = false; - private apiService = new ApiService(); + private uiStateManager: DevUiStateManager | null = null; + private unmountUI: (() => void) | null = null; async execute(options: AppDevOptions): Promise { this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; - await this.checkServer(); - - initLogger.log('๐Ÿš€ Starting Twenty Application Development Mode'); - initLogger.log(`๐Ÿ“ App Path: ${this.appPath}`); - console.log(''); await this.cleanOutputDir(); + this.uiStateManager = new DevUiStateManager({ + appPath: this.appPath, + frontendUrl: process.env.FRONTEND_URL, + }); + + const { unmount } = await renderDevUI(this.uiStateManager); + + this.unmountUI = unmount; + this.orchestrator = new DevModeOrchestrator({ appPath: this.appPath, handleManifestBuilt: this.handleWatcherRestarts.bind(this), + uiStateManager: this.uiStateManager, }); await this.startManifestWatcher(); this.setupGracefulShutdown(); } - private async checkServer(): Promise { - if (process.env.TWENTY_SKIP_SERVER_CHECK === 'true') { - return; - } - - const isAuthenticated = await this.apiService.validateAuth(); - - if (!isAuthenticated) { - initLogger.error( - 'Please check your server is up and your credentials are correct.', - ); - process.exit(1); - } - } - private async cleanOutputDir() { const outputDir = path.join(this.appPath, OUTPUT_DIR); await fs.ensureDir(outputDir); @@ -141,8 +130,7 @@ export class AppDevCommand { private setupGracefulShutdown(): void { const shutdown = async () => { - console.log(''); - initLogger.warn('๐Ÿ›‘ Stopping...'); + this.unmountUI?.(); await Promise.all([ this.manifestWatcher?.close(), diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts index 63e47a36ae..217125e1a4 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts @@ -18,7 +18,8 @@ export class ApiService { private client: AxiosInstance; private configService: ConfigService; - constructor() { + constructor(options?: { disableInterceptors: boolean }) { + const { disableInterceptors = false } = options || {}; this.configService = new ConfigService(); this.client = axios.create(); @@ -34,6 +35,10 @@ export class ApiService { return config; }); + if (disableInterceptors) { + return; + } + this.client.interceptors.response.use( (response) => response, (error) => { diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts index 1f2dc5468c..5c6d8b2d22 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts @@ -137,7 +137,14 @@ export class EsbuildWatcher implements RestartableWatcher { build.onEnd(async (result) => { try { if (result.errors.length > 0) { - await this.onBuildError?.(result.errors.map((err) => err.text)); + if (!result.errors[0].text.includes('Could not resolve')) { + await this.onBuildError?.( + result.errors.map((err) => ({ + error: err.text, + location: err.location, + })), + ); + } return; } diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/logger.ts b/packages/twenty-sdk/src/cli/utilities/build/common/logger.ts deleted file mode 100644 index fd4e35f209..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/logger.ts +++ /dev/null @@ -1,45 +0,0 @@ -import chalk, { type ChalkInstance } from 'chalk'; - -export type LoggerContext = 'init' | 'manifest-builder' | 'dev-mode'; - -type LoggerConfig = { - prefix: string; - color: ChalkInstance; -}; - -const LOGGER_CONFIGS: Record = { - init: { - prefix: '[init]', - color: chalk.cyan, - }, - 'manifest-builder': { - prefix: '[manifest-builder]', - color: chalk.blue, - }, - 'dev-mode': { - prefix: '[dev-mode]', - color: chalk.blueBright, - }, -}; - -export type Logger = { - log: (message: string) => void; - success: (message: string) => void; - error: (message: string) => void; - warn: (message: string) => void; -}; - -export const createLogger = (context: LoggerContext): Logger => { - const config = LOGGER_CONFIGS[context]; - const prefix = config.color(config.prefix); - - return { - log: (message: string) => console.log(`${prefix} ${message}`), - success: (message: string) => - console.log(`${prefix} ${chalk.green(message)}`), - error: (message: string) => - console.error(`${prefix} ${chalk.red(message)}`), - warn: (message: string) => - console.log(`${prefix} ${chalk.yellow(message)}`), - }; -}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/restartable-watcher-interface.ts b/packages/twenty-sdk/src/cli/utilities/build/common/restartable-watcher-interface.ts index ae08c27732..dd74044d37 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/restartable-watcher-interface.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/restartable-watcher-interface.ts @@ -1,4 +1,5 @@ import { type FileFolder } from 'twenty-shared/types'; +import { type Location } from 'esbuild'; export interface RestartableWatcher { restart(sourcePaths: string[]): Promise; @@ -14,7 +15,9 @@ export type OnFileBuiltCallback = (options: { checksum: string; }) => void | Promise; -export type OnBuildErrorCallback = (errors: string[]) => void | Promise; +export type OnBuildErrorCallback = ( + errors: { error: string; location: Location | null }[], +) => void | Promise; export type RestartableWatcherOptions = { appPath: string; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts index 91cf07c3af..73dba997a7 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts @@ -4,7 +4,6 @@ import { type Application, type ApplicationVariables, } from 'twenty-shared/application'; -import { createLogger } from '../../common/logger'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type EntityBuildResult, @@ -14,8 +13,6 @@ import { } from '@/cli/utilities/build/manifest/entities/entity-interface'; import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types'; -const logger = createLogger('manifest-builder'); - const findApplicationConfigPath = async (appPath: string): Promise => { const files = await glob('**/application.config.ts', { cwd: appPath, @@ -68,12 +65,6 @@ export class ApplicationEntityBuilder } } - display(applications: Application[]): void { - const application = applications[0]; - const appName = application?.displayName ?? 'Application'; - logger.success(`โœ“ Loaded "${appName}"`); - } - findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] { const seen = new Map(); const application = manifest.application; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/entity-interface.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/entity-interface.ts index 1d6ff3dcc3..97747af789 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/entity-interface.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/entity-interface.ts @@ -19,6 +19,5 @@ export type EntityBuildResult = { export type ManifestEntityBuilder = { build(appPath: string): Promise>; validate(data: EntityManifest[], errors: ValidationError[]): void; - display(data: EntityManifest[]): void; findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[]; }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts index f46712fac5..f05208dee6 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts @@ -1,6 +1,5 @@ import { glob } from 'fast-glob'; import { type FrontComponentManifest } from 'twenty-shared/application'; -import { createLogger } from '@/cli/utilities/build/common/logger'; import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server'; import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types'; @@ -11,8 +10,6 @@ import { type ManifestWithoutSources, } from '@/cli/utilities/build/manifest/entities/entity-interface'; -const logger = createLogger('manifest-builder'); - type FrontComponentConfig = Omit< FrontComponentManifest, | 'sourceComponentPath' @@ -89,18 +86,6 @@ export class FrontComponentEntityBuilder } } - display(components: FrontComponentManifest[]): void { - logger.success(`โœ“ Found ${components.length} front component(s)`); - - if (components.length > 0) { - logger.log('๐Ÿ“ Entry points:'); - for (const component of components) { - const name = component.name || component.universalIdentifier; - logger.log(` - ${name} (${component.sourceComponentPath})`); - } - } - } - findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] { const seen = new Map(); const components = manifest.frontComponents ?? []; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts index 25abcaab17..b5a42cd27f 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts @@ -1,6 +1,5 @@ import { glob } from 'fast-glob'; import { type ServerlessFunctionManifest } from 'twenty-shared/application'; -import { createLogger } from '@/cli/utilities/build/common/logger'; import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server'; import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types'; @@ -11,8 +10,6 @@ import { type ManifestWithoutSources, } from '@/cli/utilities/build/manifest/entities/entity-interface'; -const logger = createLogger('manifest-builder'); - type ExtractedFunctionManifest = Omit< ServerlessFunctionManifest, 'sourceHandlerPath' | 'builtHandlerPath' | 'builtHandlerChecksum' @@ -148,18 +145,6 @@ export class FunctionEntityBuilder } } - display(functions: ServerlessFunctionManifest[]): void { - logger.success(`โœ“ Found ${functions.length} function(s)`); - - if (functions.length > 0) { - logger.log('๐Ÿ“ Entry points:'); - for (const fn of functions) { - const name = fn.name || fn.universalIdentifier; - logger.log(` - ${name} (${fn.sourceHandlerPath})`); - } - } - } - findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] { const seen = new Map(); const functions = manifest.functions ?? []; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts index 24c7cae2c7..5f8a4321ad 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts @@ -131,10 +131,6 @@ export class ObjectExtensionEntityBuilder } } - display(_extensions: ObjectExtensionManifest[]): void { - // Object extensions don't have a dedicated display - they're part of the manifest - } - findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] { const extensions = manifest.objectExtensions ?? []; const objects = manifest.objects ?? []; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts index 4949fdbef7..fb8e367579 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts @@ -2,7 +2,6 @@ import { glob } from 'fast-glob'; import { type ObjectManifest } from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; import { isNonEmptyArray } from 'twenty-shared/utils'; -import { createLogger } from '@/cli/utilities/build/common/logger'; import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server'; import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types'; import { @@ -12,8 +11,6 @@ import { type ManifestWithoutSources, } from '@/cli/utilities/build/manifest/entities/entity-interface'; -const logger = createLogger('manifest-builder'); - export class ObjectEntityBuilder implements ManifestEntityBuilder { @@ -113,10 +110,6 @@ export class ObjectEntityBuilder } } - display(objects: ObjectManifest[]): void { - logger.success(`โœ“ Found ${objects.length} object(s)`); - } - findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] { const seen = new Map(); const objects = manifest.objects ?? []; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts index ebdbaa26fb..8a22bd560b 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts @@ -8,9 +8,6 @@ import { type ManifestEntityBuilder, type ManifestWithoutSources, } from '@/cli/utilities/build/manifest/entities/entity-interface'; -import { createLogger } from '@/cli/utilities/build/common/logger'; - -const logger = createLogger('manifest-builder'); export class RoleEntityBuilder implements ManifestEntityBuilder { async build(appPath: string): Promise> { @@ -66,10 +63,6 @@ export class RoleEntityBuilder implements ManifestEntityBuilder { } } - display(roles: RoleManifest[]): void { - logger.success(`โœ“ Found ${roles?.length ?? 'no'} role(s)`); - } - findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] { const seen = new Map(); const roles = manifest.roles ?? []; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts index 7ff2b2a176..4abe659078 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts @@ -1,4 +1,5 @@ import chokidar, { type FSWatcher } from 'chokidar'; +import { type EventName } from 'chokidar/handler.js'; export type ManifestWatcherOptions = { appPath: string; @@ -7,7 +8,7 @@ export type ManifestWatcherOptions = { export class ManifestWatcher { private appPath: string; - private handleChangeDetected: (filePath: string) => void; + private handleChangeDetected: (filePath: string, event: EventName) => void; private watcher: FSWatcher | null = null; constructor(options: ManifestWatcherOptions) { @@ -29,7 +30,7 @@ export class ManifestWatcher { if (event === 'addDir') { return; } - this.handleChangeDetected(filePath); + this.handleChangeDetected(filePath, event); }); } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts b/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts index ce12c3ef3f..8dfe89ad3e 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@/cli/utilities/build/common/logger'; import { type ManifestBuildResult, runManifestBuild, @@ -9,13 +8,16 @@ import { ApiService } from '@/cli/utilities/api/api-service'; import { FileUploader } from '@/cli/utilities/file/file-uploader'; import { type FileFolder } from 'twenty-shared/types'; import { validateManifest } from '@/cli/utilities/build/manifest/manifest-validate'; - -const logger = createLogger('dev-mode'); +import type { Location } from 'esbuild'; +import { type DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager'; +import { relative } from 'path'; +import { type EventName } from 'chokidar/handler.js'; export type DevModeOrchestratorOptions = { appPath: string; debounceMs?: number; handleManifestBuilt: (result: ManifestBuildResult) => void | Promise; + uiStateManager: DevUiStateManager; }; export class DevModeOrchestrator { @@ -24,16 +26,24 @@ export class DevModeOrchestrator { private builtFileInfos = new Map< string, - { checksum: string; builtPath: string; fileFolder: FileFolder } + { + checksum: string; + builtPath: string; + sourcePath: string; + fileFolder: FileFolder; + } >(); private fileUploader: FileUploader | null = null; - private apiService = new ApiService(); + private apiService = new ApiService({ disableInterceptors: true }); private activeUploads = new Set>(); private syncTimer: NodeJS.Timeout | null = null; private isSyncing = false; + private uiStateManager: DevUiStateManager; + private serverChecked = false; + private serverCheckedLogged = false; private handleManifestBuilt: ( result: ManifestBuildResult, @@ -43,57 +53,132 @@ export class DevModeOrchestrator { this.appPath = options.appPath; this.debounceMs = options.debounceMs ?? 200; this.handleManifestBuilt = options.handleManifestBuilt; + this.uiStateManager = options.uiStateManager; } - async handleChangeDetected(filePath: string) { - logger.log(`File changed: ${filePath}`); + private async checkServer(): Promise { + this.serverChecked = await this.apiService.validateAuth(); + + if (!this.serverChecked && !this.serverCheckedLogged) { + this.uiStateManager.addEvent({ + message: + 'Please check your server is up and your credentials are correct: "yarn auth:login"', + status: 'error', + }); + this.uiStateManager.updateManifestState({ + manifestStatus: 'error', + }); + this.serverCheckedLogged = true; + } + } + + async handleChangeDetected(sourcePath: string, event: EventName) { + if (!this.serverChecked) { + await this.checkServer(); + } + + if (!this.serverChecked) { + return; + } + + const normalizedSourcePath = this.normalizeFilePath(sourcePath); + + this.uiStateManager.addEvent({ + message: `Change detected: ${normalizedSourcePath}`, + status: 'info', + }); + + if (event === 'unlink') { + this.uiStateManager.removeEntity(normalizedSourcePath); + } else { + this.uiStateManager.updateFileStatus(normalizedSourcePath, 'building'); + } + this.scheduleSync(); } - handleFileBuildError(errors: string[]): void { - logger.error(`Build failed:`); + handleFileBuildError( + errors: { error: string; location: Location | null }[], + ): void { + this.uiStateManager.addEvent({ + message: 'Build failed:', + status: 'error', + }); for (const error of errors) { - logger.error(` ${error}`); + this.uiStateManager.addEvent({ + message: error.error, + status: 'error', + }); } } handleFileBuilt({ fileFolder, builtPath, - filePath, + sourcePath, checksum, }: { fileFolder: FileFolder; builtPath: string; - filePath: string; + sourcePath: string; checksum: string; }): void { - logger.success(`โœ“ Successfully built ${filePath}`); + this.uiStateManager.addEvent({ + message: `Successfully built ${builtPath}`, + status: 'success', + }); - this.builtFileInfos.set(builtPath, { checksum, builtPath, fileFolder }); + this.builtFileInfos.set(builtPath, { + checksum, + builtPath, + sourcePath, + fileFolder, + }); if (this.fileUploader) { - this.uploadFile(builtPath, fileFolder); + this.uploadFile(builtPath, sourcePath, fileFolder); } this.scheduleSync(); } - private uploadFile(builtPath: string, fileFolder: FileFolder): void { - logger.log(`Uploading ${builtPath}...`); + private normalizeFilePath(filePath: string): string { + return relative(this.appPath, filePath); + } + + 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) { - logger.success(`Successfully uploaded ${builtPath}`); + this.uiStateManager.addEvent({ + message: `Successfully uploaded ${builtPath}`, + status: 'success', + }); + this.uiStateManager.updateFileStatus(sourcePath, 'success'); } else { - logger.error(`Failed to upload ${builtPath}: ${result.error}`); + this.uiStateManager.addEvent({ + message: `Failed to upload ${builtPath}: ${result.error}`, + status: 'error', + }); } }) .catch((error) => { - logger.error(`Upload failed for ${builtPath}: ${error}`); + this.uiStateManager.addEvent({ + message: `Upload failed for ${builtPath}: ${error}`, + status: 'error', + }); }) .finally(() => { this.activeUploads.delete(uploadPromise); @@ -126,24 +211,47 @@ export class DevModeOrchestrator { this.isSyncing = true; try { - logger.log(`Building manifest...`); + this.uiStateManager.addEvent({ + message: 'Building manifest', + status: 'info', + }); + this.uiStateManager.updateManifestState({ + manifestStatus: 'building', + }); const result = await runManifestBuild(this.appPath); if (result.error || !result.manifest) { - logger.error( - `Failed to build manifest: ${result.error ?? 'Unknown error'}`, - ); + this.uiStateManager.updateManifestState({ + manifestStatus: 'error', + }); + this.uiStateManager.addEvent({ + message: result.error ?? 'Unknown error', + status: 'error', + }); return; } const validation = validateManifest(result.manifest); + this.uiStateManager.updateManifestState({ + appName: result.manifest.application.displayName, + }); + + this.uiStateManager.updateAllFilesTypes({ + manifestFilePaths: result.filePaths, + }); + if (!validation.isValid) { - const messages = validation.errors - .map((e) => ` โ€ข ${e.path}: ${e.message}`) - .join('\n'); - logger.error(`Invalid manifest:\n${messages}`); + for (const e of validation.errors) { + this.uiStateManager.addEvent({ + message: `${e.path}: ${e.message}`, + status: 'error', + }); + this.uiStateManager.updateManifestState({ + manifestStatus: 'error', + }); + } return; } @@ -151,11 +259,17 @@ export class DevModeOrchestrator { if (validation.warnings.length > 0) { for (const warning of validation.warnings) { const path = warning.path ? `${warning.path}: ` : ''; - logger.warn(`โš  ${path}${warning.message}`); + this.uiStateManager.addEvent({ + message: `โš  ${path}${warning.message}`, + status: 'warning', + }); } } - logger.success(`Successfully built manifest`); + this.uiStateManager.addEvent({ + message: 'Successfully built manifest', + status: 'success', + }); await this.handleManifestBuilt(result); @@ -167,9 +281,9 @@ export class DevModeOrchestrator { }); for (const [ builtPath, - { fileFolder }, + { fileFolder, sourcePath }, ] of this.builtFileInfos.entries()) { - this.uploadFile(builtPath, fileFolder); + this.uploadFile(builtPath, sourcePath, fileFolder); } } @@ -181,21 +295,54 @@ export class DevModeOrchestrator { manifest: result.manifest, builtFileInfos: this.builtFileInfos, }); + this.uiStateManager.addEvent({ + message: 'Manifest checksums set', + status: 'info', + }); await writeManifestToOutput(this.appPath, manifest); - logger.log('Syncing...'); + 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) { - logger.success('โœ“ Synced'); + this.uiStateManager.addEvent({ + message: 'โœ“ Synced', + status: 'success', + }); + this.uiStateManager.updateManifestState({ + manifestStatus: 'synced', + }); } else { - logger.error( - `โœ— Sync failed: ${JSON.stringify(syncResult.error, null, 2)}`, - ); + this.uiStateManager.addEvent({ + message: `Sync failed: ${JSON.stringify(syncResult.error, null, 2)}`, + status: 'error', + }); + this.uiStateManager.updateManifestState({ + manifestStatus: 'error', + }); } } catch (error) { - logger.error(`โœ— Sync failed: ${JSON.stringify(error)}`); + this.uiStateManager.addEvent({ + message: `Sync failed: ${JSON.stringify(error, null, 2)}`, + status: 'error', + }); + this.uiStateManager.updateManifestState({ + manifestStatus: 'error', + }); } finally { this.isSyncing = false; } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state-manager.ts b/packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state-manager.ts new file mode 100644 index 0000000000..3afbc464ff --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state-manager.ts @@ -0,0 +1,177 @@ +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-build'; + +const MAX_EVENT_NUMBER = 200; + +export class DevUiStateManager { + private state: DevUiState; + private eventIdCounter = 0; + private listeners = new Set(); + + 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, + status, + }; + + this.state = { + ...this.state, + events: [...this.state.events.slice(-MAX_EVENT_NUMBER - 1), event], + }; + + this.notify(); + } + + updateManifestState({ + manifestStatus, + appName, + }: { + manifestStatus?: ManifestStatus; + appName?: string; + }): void { + this.state = { + ...this.state, + ...(manifestStatus ? { manifestStatus } : {}), + ...(appName ? { appName } : {}), + }; + + this.notify(); + } + + convertEntityTypeToSyncableEntity( + entityType: string, + ): SyncableEntity | undefined { + switch (entityType) { + case 'objects': + return SyncableEntity.Object; + case 'objectExtensions': + return SyncableEntity.ObjectExtension; + case 'functions': + return SyncableEntity.Function; + case 'frontComponents': + return SyncableEntity.FrontComponent; + case 'roles': + return SyncableEntity.Role; + default: + return; + } + } + + updateAllFilesTypes({ + manifestFilePaths, + }: { + manifestFilePaths: EntityFilePaths; + }): void { + const entityMaps = new Map(); + + (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); + + entities.set(filePath, { + name: filePath, + path: filePath, + status: status, + }); + + this.state = { ...this.state, entities }; + + this.notify(); + } +} diff --git a/packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state.ts new file mode 100644 index 0000000000..70111a1e0f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state.ts @@ -0,0 +1,37 @@ +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; + entities: Map; + events: UiEvent[]; +}; + +export type Listener = (state: DevUiState) => void; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/dev-ui.tsx b/packages/twenty-sdk/src/cli/utilities/dev/dev-ui.tsx new file mode 100644 index 0000000000..4ad77c64bc --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/dev-ui.tsx @@ -0,0 +1,293 @@ +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 = { + pending: 'โ—‹', + building: 'โ—', + uploading: 'โ†‘', + success: 'โœ“', +}; + +const STATUS_COLORS: Record = { + pending: 'gray', + building: 'yellow', + uploading: 'cyan', + success: 'green', +}; + +const ENTITY_LABELS: Record = { + [SyncableEntity.Object]: 'Objects', + [SyncableEntity.ObjectExtension]: 'Object Extensions', + [SyncableEntity.Function]: 'Functions', + [SyncableEntity.FrontComponent]: 'Front Components', + [SyncableEntity.Role]: 'Roles', +}; + +const ENTITY_ORDER = Object.keys(ENTITY_LABELS) as SyncableEntity[]; + +const EVENT_COLORS: Record = { + info: 'gray', + success: 'green', + error: 'red', + warning: 'yellow', +}; + +const groupEntitiesByType = ( + entities: Map, +): Map => { + const grouped = new Map(); + + 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 ( + + {time} + {event.message} + + ); + }; + + const StatusIcon = ({ + status, + }: { + status: FileStatus; + }): React.ReactElement => { + const buildingFrame = useSpinner(SPINNER_FRAMES, 200); + const uploadingFrame = useSpinner(UPLOAD_FRAMES, 200); + + const iconByStatus: Record = { + building: buildingFrame, + uploading: uploadingFrame, + pending: STATUS_ICONS.pending, + success: STATUS_ICONS.success, + }; + + return {iconByStatus[status]} ; + }; + + const EntityRow = ({ + entity, + }: { + entity: EntityInfo; + }): React.ReactElement => { + return ( + + + {entity.name} + {entity.path !== entity.name && ( + ({shortenPath(entity.path)}) + )} + + ); + }; + + const EntitySection = ({ + type, + entities, + }: { + type: SyncableEntity; + entities: EntityInfo[]; + }): React.ReactElement | null => { + if (entities.length === 0) return null; + + return ( + + + {ENTITY_LABELS[type]} + + {entities.map((entity) => ( + + ))} + + ); + }; + + const MANIFEST_STATUS_CONFIG = { + synced: { color: 'green', icon: 'โœ“', text: 'Synced' }, + building: { color: 'yellow', icon: null, text: 'Building...' }, + syncing: { color: 'yellow', icon: null, text: 'Syncing...' }, + error: { color: 'red', icon: 'x', text: 'Error' }, + idle: { color: 'gray', icon: 'o', text: 'Idle' }, + } 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 ?? spinnerFrame; + + return ( + + {icon} {config.text} + + ); + }; + + const ApplicationPanel = ({ + snapshot, + }: { + snapshot: DevUiState; + }): React.ReactElement => { + const groupedEntities = groupEntitiesByType(snapshot.entities); + const appUrl = getApplicationUrl(snapshot); + + return ( + + + Application + + + + Name: + {snapshot.appName ?? 'Loading...'} + + {snapshot.appDescription && ( + + Description: + {snapshot.appDescription} + + )} + + Status: + + + {appUrl && ( + + Open: + + {' '} + {appUrl} + + + )} + + + + {ENTITY_ORDER.map((type) => { + const entities = groupedEntities.get(type) ?? []; + return ; + })} + + + ); + }; + + const Legend = (): React.ReactElement => ( + + + {STATUS_ICONS.pending}{' '} + pending {SPINNER_FRAMES[0]}{' '} + building {UPLOAD_FRAMES[0]}{' '} + uploading{' '} + {STATUS_ICONS.success}{' '} + success + + + ); + + const DevUI = (): React.ReactElement => { + const [snapshot, setSnapshot] = useState( + uiStateManager.getSnapshot(), + ); + + useEffect(() => { + return uiStateManager.subscribe(setSnapshot); + }, []); + + return ( + <> + + {(event: UiEvent) => } + + + + + + + + ); + }; + + const { unmount } = render(); + return { unmount }; +}; diff --git a/packages/twenty-sdk/tsconfig.json b/packages/twenty-sdk/tsconfig.json index 6e61bc222d..6069a8c235 100644 --- a/packages/twenty-sdk/tsconfig.json +++ b/packages/twenty-sdk/tsconfig.json @@ -5,6 +5,7 @@ "esModuleInterop": false, "allowSyntheticDefaultImports": true, "jsx": "react-jsx", + "moduleResolution": "bundler", "strictNullChecks": true, "alwaysStrict": true, "noImplicitAny": true, diff --git a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts new file mode 100644 index 0000000000..fef525e7f7 --- /dev/null +++ b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts @@ -0,0 +1,7 @@ +export enum SyncableEntity { + Object = 'object', + ObjectExtension = 'objectExtension', + Function = 'function', + FrontComponent = 'frontComponent', + Role = 'role', +} diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 2f4fbeaae1..f30f675e62 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -12,6 +12,7 @@ export type { Application } from './applicationType'; export type { ApplicationVariables } from './applicationVariablesType'; export { DEFAULT_API_KEY_NAME } from './constants/DefaultApiKeyName'; export { DEFAULT_API_URL_NAME } from './constants/DefaultApiUrlName'; +export { SyncableEntity } from './enums/syncable-entities.enum'; export type { FieldManifest } from './fieldManifestType'; export type { FrontComponentManifest } from './frontComponentManifestType'; export type { ObjectExtensionManifest } from './objectExtensionManifestType'; diff --git a/yarn.lock b/yarn.lock index 69b96fcf1c..405b9698e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27504,6 +27504,15 @@ __metadata: languageName: node linkType: hard +"ansi-escapes@npm:^7.2.0": + version: 7.2.0 + resolution: "ansi-escapes@npm:7.2.0" + dependencies: + environment: "npm:^1.0.0" + checksum: 10c0/b562fd995761fa12f33be316950ee58fda489e125d331bcd9131434969a2eb55dc14e9405f214dcf4697c9d67c576ba0baf6e8f3d52058bf9222c97560b220cb + languageName: node + linkType: hard + "ansi-regex@npm:^2.0.0": version: 2.1.1 resolution: "ansi-regex@npm:2.1.1" @@ -30576,6 +30585,16 @@ __metadata: languageName: node linkType: hard +"cli-truncate@npm:^5.1.1": + version: 5.1.1 + resolution: "cli-truncate@npm:5.1.1" + dependencies: + slice-ansi: "npm:^7.1.0" + string-width: "npm:^8.0.0" + checksum: 10c0/3842920829a62f3e041ce39199050c42706c3c9c756a4efc8b86d464e102d1fa031d8b1b9b2e3bb36e1017c763558275472d031bdc884c1eff22a2f20e4f6b0a + languageName: node + linkType: hard + "cli-ux@npm:^4.9.0": version: 4.9.3 resolution: "cli-ux@npm:4.9.3" @@ -37233,7 +37252,7 @@ __metadata: languageName: node linkType: hard -"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1": +"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.0, get-east-asian-width@npm:^1.3.1": version: 1.4.0 resolution: "get-east-asian-width@npm:1.4.0" checksum: 10c0/4e481d418e5a32061c36fbb90d1b225a254cc5b2df5f0b25da215dcd335a3c111f0c2023ffda43140727a9cafb62dac41d022da82c08f31083ee89f714ee3b83 @@ -39597,6 +39616,46 @@ __metadata: languageName: node linkType: hard +"ink@npm:^6.6.0": + version: 6.6.0 + resolution: "ink@npm:6.6.0" + dependencies: + "@alcalzone/ansi-tokenize": "npm:^0.2.1" + ansi-escapes: "npm:^7.2.0" + ansi-styles: "npm:^6.2.1" + auto-bind: "npm:^5.0.1" + chalk: "npm:^5.6.0" + cli-boxes: "npm:^3.0.0" + cli-cursor: "npm:^4.0.0" + cli-truncate: "npm:^5.1.1" + code-excerpt: "npm:^4.0.0" + es-toolkit: "npm:^1.39.10" + indent-string: "npm:^5.0.0" + is-in-ci: "npm:^2.0.0" + patch-console: "npm:^2.0.0" + react-reconciler: "npm:^0.33.0" + signal-exit: "npm:^3.0.7" + slice-ansi: "npm:^7.1.0" + stack-utils: "npm:^2.0.6" + string-width: "npm:^8.1.0" + type-fest: "npm:^4.27.0" + widest-line: "npm:^5.0.0" + wrap-ansi: "npm:^9.0.0" + ws: "npm:^8.18.0" + yoga-layout: "npm:~3.2.1" + peerDependencies: + "@types/react": ">=19.0.0" + react: ">=19.0.0" + react-devtools-core: ^6.1.2 + peerDependenciesMeta: + "@types/react": + optional: true + react-devtools-core: + optional: true + checksum: 10c0/60fe53f122f025c2ee849fb79aba13923fc2ca0ff9ff9af05725742ba613ccad5f1b7e7aba36f6b4a64a9e1c2eb8caa1c678d47993f1281d71408df12b4b82b6 + languageName: node + linkType: hard + "inline-source-map@npm:~0.6.0": version: 0.6.3 resolution: "inline-source-map@npm:0.6.3" @@ -51664,6 +51723,17 @@ __metadata: languageName: node linkType: hard +"react-reconciler@npm:^0.33.0": + version: 0.33.0 + resolution: "react-reconciler@npm:0.33.0" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.0 + checksum: 10c0/3f7b27ea8d0ff4c8bf0e402a285e1af9b7d0e6f4c1a70a28f4384938bc1130bc82a90a31df0b79ef5e380e2e55e2598bd90b4dbf802b1203d735ba0355817d3a + languageName: node + linkType: hard + "react-redux@npm:^8.1.3": version: 8.1.3 resolution: "react-redux@npm:8.1.3" @@ -51869,6 +51939,13 @@ __metadata: languageName: node linkType: hard +"react@npm:^19.0.0": + version: 19.2.4 + resolution: "react@npm:19.2.4" + checksum: 10c0/cd2c9ff67a720799cc3b38a516009986f7fc4cb8d3e15716c6211cf098d1357ee3e348ab05ad0600042bbb0fd888530ba92e329198c92eafa0994f5213396596 + languageName: node + linkType: hard + "react@npm:^19.1.0": version: 19.2.0 resolution: "react@npm:19.2.0" @@ -55508,6 +55585,16 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^8.0.0, string-width@npm:^8.1.0": + version: 8.1.0 + resolution: "string-width@npm:8.1.0" + dependencies: + get-east-asian-width: "npm:^1.3.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/749b5d0dab2532b4b6b801064230f4da850f57b3891287023117ab63a464ad79dd208f42f793458f48f3ad121fe2e1f01dd525ff27ead957ed9f205e27406593 + languageName: node + linkType: hard + "string.prototype.includes@npm:^2.0.1": version: 2.0.1 resolution: "string.prototype.includes@npm:2.0.1" @@ -57496,7 +57583,6 @@ __metadata: "@types/fs-extra": "npm:^11.0.0" "@types/inquirer": "npm:^9.0.0" "@types/lodash.camelcase": "npm:^4.3.7" - "@types/lodash.kebabcase": "npm:^4.1.7" "@types/node": "npm:^24.0.0" "@types/react": "npm:^19.0.2" archiver: "npm:^7.0.1" @@ -57510,10 +57596,11 @@ __metadata: fs-extra: "npm:^11.2.0" graphql: "npm:^16.8.1" graphql-sse: "npm:^2.5.4" + ink: "npm:^6.6.0" inquirer: "npm:^10.0.0" jsonc-parser: "npm:^3.2.0" lodash.camelcase: "npm:^4.3.0" - lodash.kebabcase: "npm:^4.1.1" + react: "npm:^19.0.0" tsx: "npm:^4.7.0" typescript: "npm:^5.9.2" uuid: "npm:^13.0.0"