@gati-framework/cli
Version:
CLI tool for Gati framework - create, develop, build and deploy cloud-native applications
333 lines ⢠16.9 kB
JavaScript
/**
* @module cli/commands/dev
* @description Dev command for local development server (Issue #10)
*/
import { Command } from 'commander';
import { resolve } from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import chalk from 'chalk';
import ora from 'ora';
import { loadDevEnv } from '../utils/env-loader.js';
import { createWatcher } from '../utils/watcher.js';
/**
* Start development server
*/
async function startDevServer(cwd, options) {
const spinner = ora('Starting development server...').start();
let watcher = null;
let restartTimer = null;
try {
// Load environment variables
loadDevEnv(cwd, true);
// Auto-generate types if schema exists
const { generateTypes } = await import('@gati-framework/runtime');
const schemaPath = resolve(cwd, 'gati.types.json');
if (existsSync(schemaPath)) {
try {
const schema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
const declarations = generateTypes(schema);
const typesDir = resolve(cwd, '.gati');
const typesPath = resolve(typesDir, 'types.d.ts');
if (!existsSync(typesDir)) {
mkdirSync(typesDir, { recursive: true });
}
writeFileSync(typesPath, declarations);
console.log(chalk.gray('š§ Auto-generated types from gati.types.json'));
}
catch (error) {
console.log(chalk.yellow('ā Failed to auto-generate types'));
}
}
// Auto-generate manifests from handlers
const srcDir = resolve(cwd, 'src');
const manifestsDir = resolve(cwd, '.gati', 'manifests');
if (existsSync(srcDir)) {
try {
const { analyzeFile } = await import('../analyzer/simple-analyzer.js');
const { glob } = await import('glob');
if (!existsSync(manifestsDir)) {
mkdirSync(manifestsDir, { recursive: true });
}
const handlers = [];
const modules = [];
// Find all TypeScript/JavaScript files
const files = await glob('src/**/*.{ts,js}', { cwd, absolute: true });
for (const filePath of files) {
const result = analyzeFile(filePath, srcDir);
if (result) {
// Add to collections
if (result.route) {
handlers.push(result);
}
else {
modules.push(result);
}
}
}
// Write app manifest
const appManifest = { handlers, modules, timestamp: Date.now() };
const appManifestPath = resolve(manifestsDir, '_app.json');
writeFileSync(appManifestPath, JSON.stringify(appManifest, null, 2));
console.log(chalk.gray(`š§ Auto-generated manifests (${handlers.length} handlers, ${modules.length} modules)`));
}
catch (error) {
console.log(chalk.yellow('ā Failed to auto-generate manifests'));
}
}
// Check if gati.config exists (.js or .ts in root)
const configJsPath = resolve(cwd, 'gati.config.js');
const configTsPath = resolve(cwd, 'gati.config.ts');
if (!existsSync(configJsPath) && !existsSync(configTsPath)) {
spinner.fail(chalk.red('ā No gati.config.js or gati.config.ts found in current directory'));
// eslint-disable-next-line no-console
console.log(chalk.yellow('\nš” Run `gati create` to create a new project'));
process.exit(1);
}
// Parse port option
const port = options.port ? parseInt(options.port, 10) : undefined;
if (options.port && isNaN(port)) {
spinner.fail(chalk.red(`ā Invalid port: ${options.port}`));
process.exit(1);
}
// Dynamic import to allow reloading
let app = null;
const loadApp = async () => {
try {
// Import the config from root directory
const configJsPath = resolve(cwd, 'gati.config.js');
const configTsPath = resolve(cwd, 'gati.config.ts');
const configPath = existsSync(configJsPath) ? configJsPath : configTsPath;
const isTypeScript = configPath.endsWith('.ts');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
let configModule;
if (isTypeScript) {
// Use tsx to load TypeScript files
const { tsImport } = await import('tsx/esm/api');
const fileUrl = `file:///${configPath.replace(/\\/g, '/')}`;
configModule = await tsImport(fileUrl, import.meta.url);
}
else {
// Use regular import for JavaScript files
const configUrl = new URL(`file://${configPath.replace(/\\/g, '/')}`).href;
const cacheBuster = `?t=${Date.now()}`;
configModule = await import(configUrl + cacheBuster);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const config = configModule.default || configModule;
// Find available port
const { findAvailablePort } = await import('../utils/port-finder.js');
const availablePort = await findAvailablePort(config.server?.port || 3000);
if (availablePort !== (config.server?.port || 3000)) {
console.log(chalk.yellow(`Port ${config.server?.port || 3000} in use, using port ${availablePort}`));
}
// Create app from config
const { createApp } = await import('@gati-framework/runtime');
const gatiApp = createApp({
port: availablePort,
host: config.server?.host || 'localhost',
});
// Load handlers from manifests
const manifestsDir = resolve(cwd, '.gati', 'manifests');
if (existsSync(manifestsDir)) {
const appManifestPath = resolve(manifestsDir, '_app.json');
if (existsSync(appManifestPath)) {
try {
const appManifest = JSON.parse(readFileSync(appManifestPath, 'utf-8'));
for (const handler of appManifest.handlers || []) {
// In dev mode, load TypeScript files directly using tsx
const handlerFilePath = handler.filePath;
try {
// Use tsx's tsImport to load TypeScript files
// Convert Windows paths to file:// URLs properly
const { tsImport } = await import('tsx/esm/api');
const fileUrl = `file:///${handlerFilePath.replace(/\\/g, '/')}`;
const handlerModule = await tsImport(fileUrl, import.meta.url);
const handlerFn = handlerModule[handler.exportName];
if (handlerFn) {
gatiApp.registerRoute(handler.method || 'GET', handler.route, handlerFn);
console.log(`ā
Loaded ${handler.method} ${handler.route}`);
}
}
catch (error) {
console.warn(`Failed to load handler ${handlerFilePath}:`, error);
}
}
}
catch (error) {
console.warn('Failed to load app manifest:', error);
}
}
}
// Initialize modules if provided
if (config.modules && typeof config.modules === 'function') {
// Get global context and initialize modules
const gctx = gatiApp.getGlobalContext();
config.modules(gctx);
}
// Register custom routes from config (overrides auto-discovered)
if (config.routes && Array.isArray(config.routes)) {
for (const route of config.routes) {
const method = route.method?.toLowerCase();
if (method && route.path && route.handler) {
gatiApp.registerRoute(method.toUpperCase(), route.path, route.handler);
}
}
}
// Initialize file watcher for hot reloading (only if watch enabled)
if (options.watch) {
const { FileWatcher } = await import('../analyzer/file-watcher.js');
// Check if Timescape is enabled in config
const timescapeEnabled = config.timescape?.enabled !== false;
new FileWatcher(cwd, async (manifest) => {
// Clear existing routes and reload all
gatiApp.getRouteManager().clear();
// Update routes when manifest changes
for (const handler of manifest.handlers) {
try {
const jsPath = handler.filePath
.replace(/\.ts$/, '.js')
.replace(/[\\/]src[\\/]/, '/dist/src/')
.replace(/\\/g, '/');
const handlerModule = await import(`file://${jsPath}?t=${Date.now()}`);
const handlerFn = handlerModule[handler.exportName];
if (handlerFn) {
gatiApp.registerRoute(handler.method || 'GET', handler.route, handlerFn);
console.log(`š Reloaded ${handler.method} ${handler.route}`);
}
}
catch (error) {
console.warn(`Failed to reload handler ${handler.route}:`, error);
}
}
}, {
enableVersioning: timescapeEnabled,
onVersionChange: (change) => {
// Version change notification is already handled in FileWatcher
// Here we could trigger additional actions like:
// - Generate transformer stubs
// - Send notifications
// - Update documentation
if (change.breaking && timescapeEnabled) {
console.log(chalk.yellow('\nš” Tip: Implement the transformer to maintain backward compatibility'));
console.log(chalk.gray(' Run: gati timescape generate-transformer ' + change.newVersion));
}
}
}).start();
}
return gatiApp;
}
catch (error) {
throw new Error(`Failed to load app: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
// Function to start the server
const startServer = async () => {
try {
spinner.text = 'Loading application...';
app = await loadApp();
if (!app || typeof app.listen !== 'function') {
throw new Error('Exported app does not have a listen() method');
}
// Override port if specified
// TODO: Port override not yet supported
// Users should configure port in their project's main file
if (port) {
spinner.warn(chalk.yellow(`ā Port override not yet supported. Configure port in your project file.`));
}
spinner.text = 'Starting HTTP server...';
if (app) {
// Execute startup hooks before listening
const gctx = app.getGlobalContext();
console.log(chalk.blue('\nš Executing startup hooks...'));
await gctx.lifecycle.executeStartup();
await app.listen();
// Show success message
spinner.succeed(chalk.green('ā Development server started'));
console.log(chalk.cyan(`\nš Server running on http://localhost:${app.getConfig().port}`));
console.log(chalk.gray('\nš Press Ctrl+C to stop\n'));
return; // Exit function to prevent duplicate messages
}
// Success message moved above
}
catch (error) {
spinner.fail(chalk.red('ā Failed to start server'));
// eslint-disable-next-line no-console
console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
throw error;
}
};
// Function to restart the server
const restartServer = () => {
if (restartTimer) {
clearTimeout(restartTimer);
}
restartTimer = setTimeout(() => {
void (async () => {
try {
// eslint-disable-next-line no-console
console.log(chalk.yellow('\nš Restarting server...'));
// Stop existing server
if (app && typeof app.close === 'function') {
await app.close();
}
// Start new server
await startServer();
}
catch (error) {
// eslint-disable-next-line no-console
console.error(chalk.red('ā Restart failed:'), error instanceof Error ? error.message : 'Unknown error');
}
})();
}, 500);
};
// Start initial server
await startServer();
// Set up file watching if enabled
if (options.watch) {
// eslint-disable-next-line no-console
console.log(chalk.blue('š Watching for file changes...\n'));
watcher = createWatcher(cwd, {
paths: ['src/**/*.ts', 'src/**/*.js', 'gati.config.ts'],
verbose: true,
}, (_path, event) => {
if (event === 'change' || event === 'add' || event === 'unlink') {
restartServer();
}
});
}
// Handle graceful shutdown
const shutdown = async () => {
// eslint-disable-next-line no-console
console.log(chalk.yellow('\n\nš Shutting down...'));
// Stop watcher
if (watcher) {
await watcher.close();
}
// Stop server with lifecycle hooks
if (app && typeof app.close === 'function') {
const gctx = app.getGlobalContext();
console.log(chalk.blue('\nš Executing shutdown hooks...'));
await gctx.lifecycle.executeShutdown();
await app.close();
}
process.exit(0);
};
process.on('SIGINT', () => void shutdown());
process.on('SIGTERM', () => void shutdown());
}
catch (error) {
spinner.fail(chalk.red('ā Failed to start development server'));
// eslint-disable-next-line no-console
console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
process.exit(1);
}
}
export const devCommand = new Command('dev')
.description('Start development server with hot reload')
.option('-p, --port <port>', 'Port to run the server on')
.option('--no-watch', 'Disable file watching')
.action(async (options) => {
const cwd = process.cwd();
await startDevServer(cwd, options);
});
//# sourceMappingURL=dev.js.map