Implement dev mode nice UI (#17471)
Implement a nice terminal UI for dev mode using INK <img width="1512" height="721" alt="image" src="https://github.com/user-attachments/assets/79a71f37-1b31-4761-9e8d-718ef029ceb8" />
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
-1
@@ -13,7 +13,6 @@ describe('rich-app app:dev', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runAppDev({ appPath: APP_PATH });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
|
||||
+12
-13
@@ -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');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
-1
@@ -14,7 +14,6 @@ describe('root-app app:dev', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runAppDev({ appPath: APP_PATH });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
|
||||
+12
-13
@@ -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');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export const runAppDev = (
|
||||
return runCliCommand({
|
||||
command: 'app:dev',
|
||||
args: [appPath],
|
||||
waitForOutput: ['[dev-mode] ✓ Synced'],
|
||||
waitForOutput: ['✓ Synced'],
|
||||
timeout,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -36,7 +36,6 @@ export const runCliCommand = (
|
||||
env: {
|
||||
...process.env,
|
||||
FORCE_COLOR: '0',
|
||||
TWENTY_SKIP_SERVER_CHECK: 'true',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const sanitizeAnsi = (output: string): string =>
|
||||
output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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(),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LoggerContext, LoggerConfig> = {
|
||||
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)}`),
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FileFolder } from 'twenty-shared/types';
|
||||
import { type Location } from 'esbuild';
|
||||
|
||||
export interface RestartableWatcher {
|
||||
restart(sourcePaths: string[]): Promise<void>;
|
||||
@@ -14,7 +15,9 @@ export type OnFileBuiltCallback = (options: {
|
||||
checksum: string;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
export type OnBuildErrorCallback = (errors: string[]) => void | Promise<void>;
|
||||
export type OnBuildErrorCallback = (
|
||||
errors: { error: string; location: Location | null }[],
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type RestartableWatcherOptions = {
|
||||
appPath: string;
|
||||
|
||||
@@ -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<string> => {
|
||||
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<string, string[]>();
|
||||
const application = manifest.application;
|
||||
|
||||
@@ -19,6 +19,5 @@ export type EntityBuildResult<TManifest> = {
|
||||
export type ManifestEntityBuilder<EntityManifest> = {
|
||||
build(appPath: string): Promise<EntityBuildResult<EntityManifest>>;
|
||||
validate(data: EntityManifest[], errors: ValidationError[]): void;
|
||||
display(data: EntityManifest[]): void;
|
||||
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[];
|
||||
};
|
||||
|
||||
@@ -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<string, string[]>();
|
||||
const components = manifest.frontComponents ?? [];
|
||||
|
||||
@@ -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<string, string[]>();
|
||||
const functions = manifest.functions ?? [];
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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<ObjectManifest>
|
||||
{
|
||||
@@ -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<string, string[]>();
|
||||
const objects = manifest.objects ?? [];
|
||||
|
||||
@@ -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<RoleManifest> {
|
||||
async build(appPath: string): Promise<EntityBuildResult<RoleManifest>> {
|
||||
@@ -66,10 +63,6 @@ export class RoleEntityBuilder implements ManifestEntityBuilder<RoleManifest> {
|
||||
}
|
||||
}
|
||||
|
||||
display(roles: RoleManifest[]): void {
|
||||
logger.success(`✓ Found ${roles?.length ?? 'no'} role(s)`);
|
||||
}
|
||||
|
||||
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
|
||||
const seen = new Map<string, string[]>();
|
||||
const roles = manifest.roles ?? [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
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<Promise<void>>();
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<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,
|
||||
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<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);
|
||||
|
||||
entities.set(filePath, {
|
||||
name: filePath,
|
||||
path: filePath,
|
||||
status: status,
|
||||
});
|
||||
|
||||
this.state = { ...this.state, entities };
|
||||
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
@@ -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<string, EntityInfo>;
|
||||
events: UiEvent[];
|
||||
};
|
||||
|
||||
export type Listener = (state: DevUiState) => void;
|
||||
@@ -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<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.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<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: 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 (
|
||||
<Text color={config.color}>
|
||||
{icon} {config.text}
|
||||
</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 };
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"strictNullChecks": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum SyncableEntity {
|
||||
Object = 'object',
|
||||
ObjectExtension = 'objectExtension',
|
||||
Function = 'function',
|
||||
FrontComponent = 'frontComponent',
|
||||
Role = 'role',
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user