Watch command skeleton (#17246)

Summary

Rewrites the app:dev command to use Vite's dev server instead of manual
file watching with chokidar. This provides better file watching
capabilities and aligns with the existing build tooling.

<img width="1588" height="822" alt="image"
src="https://github.com/user-attachments/assets/7c54bd39-4905-456f-8e3d-64e97b031de0"
/>
This commit is contained in:
Charles Bochet
2026-01-19 17:05:49 +01:00
committed by GitHub
parent 374303455a
commit 5a5e9eeb9e
17 changed files with 240 additions and 485 deletions
@@ -1,245 +0,0 @@
import * as chokidar from 'chokidar';
import path from 'path';
import { type BuildWatcherState, type RebuildDecision } from './types';
import { ASSETS_DIR } from '@/cli/constants/assets-dir';
/**
* BuildWatcher monitors file changes and triggers rebuilds.
*
* State machine:
* - IDLE: Waiting for changes
* - ANALYZING: Determining which files changed and what to rebuild
* - BUILDING: Rebuild in progress
* - ERROR: Build failed (can recover)
* - SUCCESS: Build succeeded, returning to IDLE
*/
export class BuildWatcher {
private state: BuildWatcherState = 'IDLE';
private watcher: chokidar.FSWatcher | null = null;
private pendingChanges: Set<string> = new Set();
private debounceTimer: NodeJS.Timeout | null = null;
private readonly debounceMs: number;
constructor(
private readonly appPath: string,
debounceMs = 500,
) {
this.debounceMs = debounceMs;
}
/**
* Start watching for file changes.
*/
async start(
onRebuild: (decision: RebuildDecision) => Promise<void>,
): Promise<void> {
this.watcher = chokidar.watch(this.appPath, {
ignored: [
/node_modules/,
/\.git/,
/\.twenty\/output/,
/\.twenty\/.*\.tar\.gz$/,
/dist/,
],
persistent: true,
ignoreInitial: true,
});
const handleChange = (filepath: string) => {
// Only watch TypeScript files and relevant config files
if (!this.isWatchedFile(filepath)) {
return;
}
this.pendingChanges.add(filepath);
// Debounce rapid changes
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(async () => {
const changedFiles = Array.from(this.pendingChanges);
this.pendingChanges.clear();
if (changedFiles.length === 0) {
return;
}
this.state = 'ANALYZING';
const decision = this.analyzeChanges(changedFiles);
this.state = 'BUILDING';
try {
await onRebuild(decision);
this.state = 'SUCCESS';
} catch {
this.state = 'ERROR';
} finally {
this.state = 'IDLE';
}
}, this.debounceMs);
};
this.watcher.on('change', handleChange);
this.watcher.on('add', handleChange);
this.watcher.on('unlink', handleChange);
}
/**
* Stop watching for file changes.
*/
async stop(): Promise<void> {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.watcher) {
await this.watcher.close();
this.watcher = null;
}
this.state = 'IDLE';
}
/**
* Check if a file should trigger a rebuild.
*/
private isWatchedFile(filepath: string): boolean {
const ext = path.extname(filepath);
const basename = path.basename(filepath);
const relativePath = path.relative(this.appPath, filepath);
// Watch TypeScript files
if (ext === '.ts' || ext === '.tsx') {
return true;
}
// Watch relevant config files
if (
basename === 'package.json' ||
basename === 'tsconfig.json' ||
basename === '.env'
) {
return true;
}
// Watch asset files in assets/ (at the root of the application)
const assetsDirPrefix = `${ASSETS_DIR}/`;
const assetsDirPrefixWin = `${ASSETS_DIR}\\`;
if (
relativePath.startsWith(assetsDirPrefix) ||
relativePath.startsWith(assetsDirPrefixWin)
) {
return true;
}
return false;
}
/**
* Analyze changed files to determine what needs to be rebuilt.
*
* Manifest is composed from:
* - src/app/application.config.ts
* - src/app/**\/*.object.ts
* - src/app/**\/*.object-extension.ts
* - src/app/**\/*.role.ts
* - src/app/**\/*.function.ts (also requires function rebuild)
*/
private analyzeChanges(changedFiles: string[]): RebuildDecision {
const affectedFunctions: string[] = [];
let rebuildGenerated = false;
let assetsChanged = false;
let sharedFilesChanged = false;
let configChanged = false;
let manifestChanged = false;
const assetsDirPrefix = `${ASSETS_DIR}/`;
for (const filepath of changedFiles) {
const relativePath = path.relative(this.appPath, filepath);
// Normalize path separators for cross-platform compatibility
const normalizedPath = relativePath.replace(/\\/g, '/');
const basename = path.basename(normalizedPath);
// Check if it's a build config file (requires full rebuild)
if (
basename === 'package.json' ||
basename === 'tsconfig.json' ||
basename === '.env'
) {
configChanged = true;
continue;
}
// Check if it's an asset file (in root assets/ folder)
if (normalizedPath.startsWith(assetsDirPrefix)) {
assetsChanged = true;
continue;
}
// Check if it's in the generated folder
if (normalizedPath.startsWith('generated/')) {
rebuildGenerated = true;
continue;
}
// Check if it's a manifest-related file in src/app/
if (normalizedPath.startsWith('src/app/')) {
// Function files: rebuild function AND regenerate manifest
if (normalizedPath.endsWith('.function.ts')) {
affectedFunctions.push(normalizedPath);
manifestChanged = true;
continue;
}
// Other manifest files: only regenerate manifest (no function rebuild)
// - application.config.ts
// - *.object.ts
// - *.object-extension.ts
// - *.role.ts
if (
basename === 'application.config.ts' ||
normalizedPath.endsWith('.object.ts') ||
normalizedPath.endsWith('.object-extension.ts') ||
normalizedPath.endsWith('.role.ts')
) {
manifestChanged = true;
continue;
}
}
// Check if it's a shared file that affects all functions
if (
normalizedPath.startsWith('src/') &&
!normalizedPath.startsWith('src/app/') &&
(normalizedPath.endsWith('.ts') || normalizedPath.endsWith('.tsx'))
) {
// Shared utility file outside src/app/ - rebuild all functions
sharedFilesChanged = true;
}
}
return {
shouldRebuild: true,
affectedFunctions,
sharedFilesChanged,
configChanged,
manifestChanged,
rebuildGenerated,
assetsChanged,
changedFiles,
};
}
/**
* Get the current watcher state.
*/
getState(): BuildWatcherState {
return this.state;
}
}
@@ -1,24 +1,22 @@
import path from 'path';
import * as fs from 'fs-extra';
import chalk from 'chalk';
import { glob } from 'fast-glob';
import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types';
import { TarballService } from '@/cli/utilities/file/utils/file-tarball';
import { loadManifest, type LoadManifestResult } from '@/cli/utilities/manifest/utils/manifest-load';
import { BuildManifestWriter, type BuiltFunctionInfo } from '@/cli/utilities/manifest/utils/manifest-writer';
import { ViteBuildRunner } from './vite-build-runner';
import { BuildWatcher } from './build-watcher';
import {
type BuildOptions,
type BuildResult,
type ViteBuildConfig,
type BuildWatchHandle,
type RebuildDecision,
} from './types';
import { ASSETS_DIR } from '@/cli/constants/assets-dir';
import { FUNCTIONS_DIR } from '@/cli/constants/functions-dir';
import { GENERATED_DIR } from '@/cli/constants/generated-dir';
import { OUTPUT_DIR } from '@/cli/constants/output-dir';
import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types';
import { TarballService } from '@/cli/utilities/file/utils/file-tarball';
import { buildManifest, type BuildManifestResult } from '@/cli/utilities/manifest/utils/manifest-build';
import { BuildManifestWriter, type BuiltFunctionInfo } from '@/cli/utilities/manifest/utils/manifest-writer';
import chalk from 'chalk';
import { glob } from 'fast-glob';
import * as fs from 'fs-extra';
import path from 'path';
import {
type BuildOptions,
type BuildResult,
type RebuildDecision,
type ViteBuildConfig
} from './types';
import { ViteBuildRunner } from './vite-build-runner';
/**
* BuildService orchestrates the build process for Twenty applications.
@@ -38,7 +36,7 @@ export class BuildService {
/** Cached state from the last successful build (used for incremental rebuilds) */
private lastBuildState: {
manifestResult: LoadManifestResult;
manifestResult: BuildManifestResult;
builtFunctions: BuiltFunctionInfo[];
outputDir: string;
} | null = null;
@@ -78,7 +76,7 @@ export class BuildService {
// Step 1: Load manifest
console.log(chalk.gray(' Loading manifest...'));
const manifestResult = await loadManifest(appPath);
const manifestResult = await buildManifest(appPath);
// Step 2: Prepare output directory
const outputDir = path.join(appPath, OUTPUT_DIR);
@@ -176,50 +174,7 @@ export class BuildService {
}
}
/**
* Start watch mode for incremental rebuilds.
*/
async watch(options: BuildOptions): Promise<BuildWatchHandle> {
const { appPath } = options;
console.log(chalk.blue('📦 Starting build watch mode'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
console.log('');
// Perform initial build
const initialResult = await this.build({ ...options, tarball: false });
if (!initialResult.success) {
console.error(
chalk.red('Initial build failed, starting watcher anyway...'),
);
}
// Start the watcher
const watcher = new BuildWatcher(appPath);
await watcher.start(async (decision) => {
if (!decision.shouldRebuild) {
return;
}
// Use incremental rebuild based on what changed
const result = await this.incrementalRebuild(appPath, decision);
if (result.success) {
console.log(
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
);
}
});
console.log(
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
);
return {
stop: () => watcher.stop(),
};
}
/**
* Perform an incremental rebuild based on what files changed.
@@ -251,7 +206,7 @@ export class BuildService {
// If manifest config changed, reload it
if (decision.manifestChanged) {
console.log(chalk.blue('🔄 Manifest changed, regenerating...'));
manifestResult = await loadManifest(appPath);
manifestResult = await buildManifest(appPath);
rebuildCount++;
}
@@ -348,7 +303,7 @@ export class BuildService {
private async rebuildSpecificFunctions(
appPath: string,
outputDir: string,
manifestResult: LoadManifestResult,
manifestResult: BuildManifestResult,
handlerPaths: string[],
): Promise<BuiltFunctionInfo[]> {
const { manifest } = manifestResult;
@@ -454,7 +409,7 @@ export class BuildService {
private async buildFunctions(
appPath: string,
outputDir: string,
manifestResult: LoadManifestResult,
manifestResult: BuildManifestResult,
): Promise<BuiltFunctionInfo[]> {
const { manifest } = manifestResult;
const functionsOutputDir = path.join(outputDir, FUNCTIONS_DIR);
+3 -4
View File
@@ -1,8 +1,7 @@
export * from './types';
export { BuildService } from './build.service';
export * from './types';
export { ViteBuildRunner } from './vite-build-runner';
export { BuildWatcher } from './build-watcher';
// Re-export from utilities for backward compatibility
export { BuildManifestWriter, type BuiltFunctionInfo } from '@/cli/utilities/manifest/utils/manifest-writer';
export { TarballService } from '@/cli/utilities/file/utils/file-tarball';
export { BuildManifestWriter, type BuiltFunctionInfo } from '@/cli/utilities/manifest/utils/manifest-writer';
@@ -56,11 +56,9 @@ export const registerCommands = (program: Command): void => {
program
.command('app:dev [appPath]')
.description('Start development mode: sync local application changes')
.option('-d, --debounce <ms>', 'Debounce delay in milliseconds', '1000')
.action(async (appPath, options) => {
.description('Watch and sync local application changes')
.action(async (appPath) => {
await devCommand.execute({
...options,
appPath: formatPath(appPath),
});
});
@@ -1,7 +1,7 @@
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types';
import { BuildService } from '@/cli/build/build.service';
import { type BuildResult } from '@/cli/build/types';
import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
export type BuildCommandOptions = {
appPath?: string;
@@ -15,28 +15,6 @@ export class AppBuildCommand {
async execute(options: BuildCommandOptions): Promise<ApiResponse<BuildResult>> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
if (options.watch) {
// Watch mode - this runs indefinitely
const watchHandle = await this.buildService.watch({
appPath,
watch: true,
tarball: options.tarball,
});
// Setup graceful shutdown
this.setupGracefulShutdown(watchHandle.stop);
// Return success immediately - the watch loop is running
return {
success: true,
data: {
outputDir: `${appPath}/.twenty/output`,
manifest: {} as any, // Will be populated during build
builtFunctions: [],
},
};
}
// One-time build
return this.buildService.build({
appPath,
@@ -1,112 +1,138 @@
import { ApiService } from '@/cli/utilities/api/services/api.service';
import { OUTPUT_DIR } from '@/cli/constants/output-dir';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
import { ManifestValidationError } from '@/cli/utilities/manifest/types/manifest.types';
import { type BuildManifestResult } from '@/cli/utilities/manifest/utils/manifest-build';
import {
displayEntitySummary,
displayErrors,
displayWarnings,
} from '@/cli/utilities/manifest/utils/manifest-display';
import { loadManifest } from '@/cli/utilities/manifest/utils/manifest-load';
import {
createManifestPlugin,
type ManifestBuildError,
} from '@/cli/utilities/vite-plugin/vite-manifest-plugin';
import chalk from 'chalk';
import * as chokidar from 'chokidar';
import * as fs from 'fs-extra';
import path from 'path';
import { createServer, type ViteDevServer } from 'vite';
export type AppDevOptions = {
appPath?: string;
};
export class AppDevCommand {
private apiService = new ApiService();
private server: ViteDevServer | null = null;
private appPath: string = '';
async execute(options: {
appPath?: string;
debounce: string;
}): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
async execute(options: AppDevOptions): Promise<void> {
this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
const debounceMs = parseInt(options.debounce, 10);
this.logStartupInfo(this.appPath);
this.logStartupInfo(appPath, debounceMs);
this.server = await this.createViteDevServer(this.appPath);
await this.synchronize(appPath);
await this.server.listen();
const watcher = this.setupFileWatcher(appPath, debounceMs);
this.setupGracefulShutdown(watcher);
}
private async synchronize(appPath: string) {
try {
const { manifest, packageJson, yarnLock, warnings } =
await loadManifest(appPath);
displayEntitySummary(manifest);
displayWarnings(warnings);
await this.apiService.syncApplication({
manifest,
packageJson,
yarnLock,
});
console.log(chalk.green(' ✓ Synced with server'));
} catch (error) {
if (error instanceof ManifestValidationError) {
displayErrors(error);
} else {
console.error(
chalk.red(' ✗ Sync failed:'),
error instanceof Error ? error.message : error,
);
}
}
}
private logStartupInfo(appPath: string, debounceMs: number): void {
console.log(chalk.blue('🚀 Starting Twenty Application Development Mode'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
console.log(chalk.gray(`⏱️ Debounce: ${debounceMs}ms`));
console.log('');
}
private setupFileWatcher(
appPath: string,
debounceMs: number,
): chokidar.FSWatcher {
const watcher = chokidar.watch(appPath, {
ignored: /node_modules|\.git/,
persistent: true,
});
let timeout: NodeJS.Timeout | null = null;
const debouncedSync = () => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(async () => {
console.log(chalk.blue('🔄 Changes detected, syncing...'));
await this.synchronize(appPath);
console.log(
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
);
}, debounceMs);
};
watcher.on('change', () => {
debouncedSync();
});
this.setupGracefulShutdown();
console.log(
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
);
return watcher;
}
private setupGracefulShutdown(watcher: chokidar.FSWatcher): void {
process.on('SIGINT', () => {
private logStartupInfo(appPath: string): void {
console.log(chalk.blue('🚀 Starting Twenty Application Development Mode'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
console.log('');
}
private async createViteDevServer(appPath: string): Promise<ViteDevServer> {
const manifestPlugin = createManifestPlugin({
appPath,
onBuildStart: () => {
console.log(chalk.blue('🔄 Building manifest...'));
},
onBuildSuccess: (result: BuildManifestResult) => {
this.handleBuildSuccess(result);
},
onBuildError: (error: ManifestBuildError) => {
this.handleBuildError(error);
},
});
return createServer({
root: appPath,
plugins: [manifestPlugin],
server: {
watch: {
ignored: ['**/node_modules/**', '**/.twenty/**', '**/dist/**'],
},
port: 0,
open: false,
hmr: false,
},
optimizeDeps: {
noDiscovery: true,
},
logLevel: 'silent',
publicDir: false,
build: {
watch: {
include: [path.join(appPath, 'src/**')],
},
},
});
}
private handleBuildSuccess(result: BuildManifestResult): void {
displayEntitySummary(result.manifest);
displayWarnings(result.warnings);
this.writeManifestToOutput(result);
}
private handleBuildError(error: ManifestBuildError): void {
if (error.errors) {
displayErrors(new ManifestValidationError(error.errors));
} else {
console.error(chalk.red(' ✗ Build failed:'), error.message);
}
}
private async writeManifestToOutput(
result: BuildManifestResult,
): Promise<void> {
try {
const outputDir = path.join(this.appPath, OUTPUT_DIR);
await fs.ensureDir(outputDir);
const manifestPath = path.join(outputDir, 'manifest.json');
await fs.writeJSON(manifestPath, result.manifest, { spaces: 2 });
console.log(chalk.green(` ✓ Manifest written to ${manifestPath}`));
console.log('');
console.log(
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
);
} catch (error) {
console.error(
chalk.red(' ✗ Failed to write manifest:'),
error instanceof Error ? error.message : error,
);
}
}
private setupGracefulShutdown(): void {
process.on('SIGINT', async () => {
console.log(chalk.yellow('\n🛑 Stopping development mode...'));
watcher.close();
if (this.server) {
await this.server.close();
}
process.exit(0);
});
}
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
import { ApiService } from '@/cli/utilities/api/services/api.service';
import { loadManifest } from '@/cli/utilities/manifest/utils/manifest-load';
import { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build';
export class AppLogsCommand {
private apiService = new ApiService();
@@ -16,7 +16,7 @@ export class AppLogsCommand {
functionName?: string;
}): Promise<void> {
try {
const { manifest } = await loadManifest(appPath);
const { manifest } = await buildManifest(appPath);
this.logWatchInfo({
appName: manifest.application.displayName,
functionUniversalIdentifier,
@@ -3,12 +3,12 @@ import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
import { GenerateService } from '@/cli/utilities/generate/services/generate.service';
import { ManifestValidationError } from '@/cli/utilities/manifest/types/manifest.types';
import { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build';
import {
displayEntitySummary,
displayErrors,
displayWarnings,
} from '@/cli/utilities/manifest/utils/manifest-display';
import { loadManifest } from '@/cli/utilities/manifest/utils/manifest-load';
import chalk from 'chalk';
export class AppSyncCommand {
@@ -36,7 +36,7 @@ export class AppSyncCommand {
private async synchronize({ appPath }: { appPath: string }) {
try {
const { manifest, packageJson, yarnLock, shouldGenerate, warnings } =
await loadManifest(appPath);
await buildManifest(appPath);
displayEntitySummary(manifest);
@@ -51,7 +51,7 @@ export class AppSyncCommand {
if (shouldGenerate) {
await this.generateService.generateClient(appPath);
const { manifest: manifestWithClient } = await loadManifest(appPath);
const { manifest: manifestWithClient } = await buildManifest(appPath);
serverlessSyncResult = await this.apiService.syncApplication({
manifest: manifestWithClient,
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
import { ApiService } from '@/cli/utilities/api/services/api.service';
import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types';
import { loadManifest } from '@/cli/utilities/manifest/utils/manifest-load';
import { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build';
export class AppUninstallCommand {
private apiService = new ApiService();
@@ -25,7 +25,7 @@ export class AppUninstallCommand {
process.exit(1);
}
const { manifest } = await loadManifest(appPath);
const { manifest } = await buildManifest(appPath);
const result = await this.apiService.uninstallApplication(
manifest.application.universalIdentifier,
@@ -7,11 +7,9 @@ describe('getFunctionBaseFile', () => {
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
});
// Verify it uses defineFunction
expect(result).toContain("import { defineFunction } from 'twenty-sdk'");
expect(result).toContain('export default defineFunction({');
// Verify function properties
expect(result).toContain(
"universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4'",
);
@@ -20,10 +18,8 @@ describe('getFunctionBaseFile', () => {
expect(result).toContain('handler,');
expect(result).toContain('triggers: [');
// Verify handler is exported
expect(result).toContain('export const handler = async');
// Verify description is included
expect(result).toContain(
"description: 'Add a description for your function'",
);
@@ -34,7 +30,6 @@ describe('getFunctionBaseFile', () => {
name: 'auto-uuid-function',
});
// Verify it has a universalIdentifier (UUID format)
expect(result).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
@@ -54,7 +49,6 @@ describe('getFunctionBaseFile', () => {
name: 'example-function',
});
// Verify trigger examples are included as comments
expect(result).toContain("type: 'route'");
expect(result).toContain("type: 'cron'");
expect(result).toContain("type: 'databaseEvent'");
@@ -12,11 +12,9 @@ describe('getNewObjectFileContent', () => {
name: 'company',
});
// Verify it uses defineObject
expect(result).toContain("import { defineObject } from 'twenty-sdk'");
expect(result).toContain('export default defineObject({');
// Verify object properties
expect(result).toContain("nameSingular: 'company'");
expect(result).toContain("namePlural: 'companies'");
expect(result).toContain("labelSingular: 'Company'");
@@ -24,7 +22,6 @@ describe('getNewObjectFileContent', () => {
expect(result).toContain("icon: 'IconBox'");
expect(result).toContain('fields: [');
// Verify it has a universalIdentifier (UUID format)
expect(result).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
@@ -51,7 +48,6 @@ describe('getNewObjectFileContent', () => {
name: 'person',
});
// Extract UUIDs
const uuidRegex =
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const uuid1 = result1.match(uuidRegex)?.[1];
@@ -7,11 +7,9 @@ describe('getRoleBaseFile', () => {
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
});
// Verify it uses defineRole
expect(result).toContain("import { defineRole } from 'twenty-sdk'");
expect(result).toContain('export default defineRole({');
// Verify role properties
expect(result).toContain(
"universalIdentifier: MY_ROLE_ROLE_UNIVERSAL_IDENTIFIER",
);
@@ -21,7 +19,6 @@ describe('getRoleBaseFile', () => {
expect(result).toContain("label: 'my-role'");
expect(result).toContain("description: 'Add a description for your role'");
// Verify permission defaults
expect(result).toContain('canReadAllObjectRecords: true');
expect(result).toContain('canUpdateAllObjectRecords: true');
expect(result).toContain('canSoftDeleteAllObjectRecords: true');
@@ -33,7 +30,6 @@ describe('getRoleBaseFile', () => {
name: 'auto-uuid-role',
});
// Verify it has a universalIdentifier (UUID format)
expect(result).toMatch(
/'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
@@ -63,7 +59,6 @@ describe('getRoleBaseFile', () => {
name: 'role-v2',
});
// kebab-case separates numbers with underscore when converted to constant
expect(result).toContain('export const ROLE_V_2_ROLE_UNIVERSAL_IDENTIFIER');
expect(result).toContain("label: 'role-v2'");
});
@@ -1,22 +1,22 @@
import { join } from 'path';
import { loadManifest, type LoadManifestResult } from '@/cli/utilities/manifest/utils/manifest-load';
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/test-app/src/app/default-function.role';
import {
POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
POST_CARD_EXTENSION_CATEGORY_FIELD_ID,
POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
} from '@/cli/__tests__/test-app/src/app/postCard.object-extension';
import { buildManifest, type BuildManifestResult } from '@/cli/utilities/manifest/utils/manifest-build';
import { join } from 'path';
const TEST_APP_PATH = join(__dirname, '../../__tests__/test-app');
const TEST_APP_PATH = join(__dirname, '../../../../__tests__/test-app');
describe('loadManifest with test-app', () => {
let manifest: LoadManifestResult['manifest'];
let packageJson: LoadManifestResult['packageJson'];
let yarnLock: LoadManifestResult['yarnLock'];
let warnings: LoadManifestResult['warnings'];
let shouldGenerate: LoadManifestResult['shouldGenerate'];
describe('buildManifest with test-app', () => {
let manifest: BuildManifestResult['manifest'];
let packageJson: BuildManifestResult['packageJson'];
let yarnLock: BuildManifestResult['yarnLock'];
let warnings: BuildManifestResult['warnings'];
let shouldGenerate: BuildManifestResult['shouldGenerate'];
beforeAll(async () => {
const result = await loadManifest(TEST_APP_PATH);
const result = await buildManifest(TEST_APP_PATH);
manifest = result.manifest;
packageJson = result.packageJson;
@@ -26,17 +26,13 @@ describe('loadManifest with test-app', () => {
}, 15_000);
it('should load manifest from test-app directory', async () => {
// Check package.json loaded correctly
expect(packageJson.name).toBe('test-app');
expect(packageJson.version).toBe('0.0.1');
// Check yarn.lock exists (can be empty)
expect(yarnLock).toBeDefined();
// Check no warnings
expect(warnings).toEqual([]);
// Check application config
expect(manifest.application).toBeDefined();
expect(manifest.application.universalIdentifier).toBe(
'4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -57,7 +53,6 @@ describe('loadManifest with test-app', () => {
expect(postCard.labelPlural).toBe('Post cards');
expect(postCard.icon).toBe('IconMail');
// Check fields
expect(postCard.fields).toHaveLength(5);
const contentField = postCard.fields?.find(
@@ -87,7 +82,6 @@ describe('loadManifest with test-app', () => {
expect(testFunction.handlerName).toBe('handler');
expect(testFunction.handlerPath).toBe('src/app/test-function.function.ts');
// Check triggers
expect(testFunction.triggers).toHaveLength(3);
const routeTrigger = testFunction.triggers.find(
@@ -113,7 +107,6 @@ describe('loadManifest with test-app', () => {
expect(dbEventTrigger).toBeDefined();
expect(dbEventTrigger?.eventName).toBe('person.created');
// Second function
const testFunction2 = manifest.serverlessFunctions[0];
expect(testFunction2.universalIdentifier).toBe(
'eb3ffc98-88ec-45d4-9b4a-56833b219ccb',
@@ -134,13 +127,11 @@ describe('loadManifest with test-app', () => {
expect(role.canReadAllObjectRecords).toBe(false);
expect(role.canUpdateAllObjectRecords).toBe(false);
// Check object permissions
expect(role.objectPermissions).toHaveLength(1);
expect(role.objectPermissions![0].objectNameSingular).toBe('postCard');
expect(role.objectPermissions![0].canReadObjectRecords).toBe(true);
expect(role.objectPermissions![0].canUpdateObjectRecords).toBe(true);
// Check field permissions
expect(role.fieldPermissions).toHaveLength(1);
expect(role.fieldPermissions![0].objectNameSingular).toBe('postCard');
expect(role.fieldPermissions![0].fieldName).toBe('content');
@@ -149,7 +140,6 @@ describe('loadManifest with test-app', () => {
expect(manifest.sources).toBeDefined();
expect(manifest.sources['src']).toBeDefined();
// Check that the source files are loaded
const srcSources = manifest.sources['src'] as Record<string, unknown>;
const appSources = srcSources['app'] as Record<string, string>;
expect(appSources['application.config.ts']).toBeDefined();
@@ -157,7 +147,6 @@ describe('loadManifest with test-app', () => {
expect(appSources['test-function.function.ts']).toBeDefined();
expect(appSources['default-function.role.ts']).toBeDefined();
// Verify source content contains expected code
expect(appSources['application.config.ts']).toContain('defineApp');
expect(appSources['postCard.object.ts']).toContain('defineObject');
expect(appSources['test-function.function.ts']).toContain('defineFunction');
@@ -166,7 +155,6 @@ describe('loadManifest with test-app', () => {
'extendObject',
);
// Check object extensions
expect(manifest.objectExtensions).toBeDefined();
expect(manifest.objectExtensions).toHaveLength(1);
@@ -1,5 +1,10 @@
import * as fs from 'fs-extra';
import { type FunctionConfig } from '@/application/functions/function-config';
import { type RoleConfig } from '@/application/role-config';
import { loadConfig, loadFunctionModule } from '@/cli/utilities/file/utils/file-config-loader';
import { findPathFile } from '@/cli/utilities/file/utils/file-find';
import { parseJsoncFile, parseTextFile } from '@/cli/utilities/file/utils/file-jsonc';
import { glob } from 'fast-glob';
import * as fs from 'fs-extra';
import path, { posix, relative, sep } from 'path';
import {
type Application,
@@ -11,16 +16,11 @@ import {
type ServerlessFunctionManifest,
} from 'twenty-shared/application';
import { type Sources } from 'twenty-shared/types';
import { type FunctionConfig } from '@/application/functions/function-config';
import { type RoleConfig } from '@/application/role-config';
import { loadConfig, loadFunctionModule } from '@/cli/utilities/file/utils/file-config-loader';
import { findPathFile } from '@/cli/utilities/file/utils/file-find';
import { parseJsoncFile, parseTextFile } from '@/cli/utilities/file/utils/file-jsonc';
import { validateManifest } from './manifest-validate';
import {
ManifestValidationError,
type ValidationWarning,
} from '../types/manifest.types';
import { validateManifest } from './manifest-validate';
/**
* Validate that the required folder structure exists.
@@ -236,7 +236,7 @@ const checkShouldGenerate = async (appPath: string): Promise<boolean> => {
return false;
};
export type LoadManifestResult = {
export type BuildManifestResult = {
packageJson: PackageJson;
yarnLock: string;
manifest: ApplicationManifest;
@@ -245,11 +245,11 @@ export type LoadManifestResult = {
};
/**
* Load an application manifest using the folder structure with jiti runtime evaluation.
* Build an application manifest using the folder structure with jiti runtime evaluation.
*/
export const loadManifest = async (
export const buildManifest = async (
appPath: string,
): Promise<LoadManifestResult> => {
): Promise<BuildManifestResult> => {
// Validate folder structure
await validateFolderStructure(appPath);
@@ -0,0 +1,71 @@
import { ManifestValidationError } from '@/cli/utilities/manifest/types/manifest.types';
import {
buildManifest,
type BuildManifestResult,
} from '@/cli/utilities/manifest/utils/manifest-build';
import { type Plugin } from 'vite';
const PLUGIN_NAME = 'twenty-manifest';
export type ManifestBuildError = {
message: string;
errors?: Array<{ path: string; message: string }>;
};
export type ManifestPluginOptions = {
appPath: string;
onBuildStart?: () => void;
onBuildSuccess?: (result: BuildManifestResult) => void;
onBuildError?: (error: ManifestBuildError) => void;
};
/**
* Creates a Vite plugin that builds the application manifest on startup
* and rebuilds it when source files change.
*/
export const createManifestPlugin = (
options: ManifestPluginOptions,
): Plugin => {
const { appPath, onBuildStart, onBuildSuccess, onBuildError } = options;
const runBuild = async (): Promise<void> => {
onBuildStart?.();
try {
const result = await buildManifest(appPath);
onBuildSuccess?.(result);
} catch (error) {
const buildError: ManifestBuildError = {
message: error instanceof Error ? error.message : String(error),
};
if (error instanceof ManifestValidationError) {
buildError.errors = error.errors;
}
onBuildError?.(buildError);
}
};
return {
name: PLUGIN_NAME,
buildStart: async () => {
await runBuild();
},
handleHotUpdate: async ({ file }) => {
const relevantExtensions = ['.ts', '.json'];
const isRelevantFile = relevantExtensions.some((ext) =>
file.endsWith(ext),
);
if (isRelevantFile) {
await runBuild();
}
return [];
},
};
};