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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
-6
@@ -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'");
|
||||
-4
@@ -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];
|
||||
-5
@@ -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'");
|
||||
});
|
||||
+11
-23
@@ -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);
|
||||
|
||||
+11
-11
@@ -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 [];
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user