@avleon/cli
Version:
CLI for scaffolding and running Avleon applications
312 lines (311 loc) • 10.6 kB
JavaScript
const chokidar = require("chokidar");
const { spawn } = require("child_process");
const path = require("path");
const fs = require("fs");
const { transformFileSync } = require("@swc/core");
const SRC_DIR = path.resolve(process.cwd(), "./src");
const DIST_DIR = path.resolve(process.cwd(), "./dist");
const pkg = fs.existsSync(path.join(process.cwd(), "package.json"))
? JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"))
: {};
const ENTRY_RELATIVE = pkg?.avleon?.entry || pkg?.main?.replace("src/", "dist/").replace(".ts", ".js") || "dist/server.js";
const ENTRY_FILE = path.resolve(process.cwd(), ENTRY_RELATIVE);
let nodeProcess = null;
let isRestarting = false;
// Color constants for better type safety
const Colors = {
RESET: '\x1b[0m',
DIM: '\x1b[90m',
WHITE: '\x1b[37m',
CYAN: '\x1b[36m',
GREEN: '\x1b[32m',
YELLOW: '\x1b[33m',
RED: '\x1b[31m',
BOLD: '\x1b[1m'
};
// Unified logging with colors and timestamps
function log(category, message, color = Colors.WHITE) {
const timestamp = new Date().toTimeString().split(' ')[0];
console.log(`${Colors.DIM}[${timestamp}]${Colors.RESET} ${color}[${category}]${Colors.RESET} ${message}`);
}
function logCompile(message) {
log('COMPILE', message, Colors.CYAN);
}
function logServer(message) {
log('AVLEON', message, Colors.GREEN);
}
function logWatch(message) {
log('WATCH', message, Colors.YELLOW);
}
function logError(category, message) {
log(category, message, Colors.RED);
}
// Helper function to check if file is a test file
function isTestFile(filePath) {
const filename = path.basename(filePath);
return filename.includes('.test.') || filename.includes('.spec.');
}
// compile single TS file
function compileFile(filePath) {
try {
const rel = path.relative(SRC_DIR, filePath);
const outFile = path.join(DIST_DIR, rel).replace(/\.ts$/, ".js");
const result = transformFileSync(filePath, {
jsc: {
parser: { syntax: "typescript", decorators: true },
target: "es2022",
transform: { decoratorMetadata: true },
},
module: { type: "commonjs" },
sourceMaps: false,
});
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, result.code, "utf8");
//logCompile(`✓ ${rel}`);
return true;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logError('COMPILE', `✗ ${path.relative(SRC_DIR, filePath)} - ${errorMessage}`);
return false;
}
}
// compile all TS (excluding test files)
function compileAll(dir = SRC_DIR) {
if (!fs.existsSync(dir)) {
logError('COMPILE', `Source directory ${dir} does not exist`);
return 0;
}
let compiled = 0;
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const f of files) {
const full = path.join(dir, f.name);
if (f.isDirectory()) {
compiled += compileAll(full);
}
else if (f.name.endsWith(".ts")) {
// Skip test files
if (isTestFile(full)) {
// logWatch(`⏭️ Skipping test file: ${path.relative(SRC_DIR, full)}`);
continue;
}
if (compileFile(full)) {
compiled++;
}
}
}
return compiled;
}
// start Node with integrated output
function startNode() {
if (nodeProcess) {
logServer("⚠️ Process already running, skipping start");
return;
}
if (isRestarting) {
// Reset the flag when actually starting
isRestarting = false;
}
if (!fs.existsSync(ENTRY_FILE)) {
logError('AVLEON', `Entry file ${ENTRY_FILE} does not exist`);
return;
}
logServer("🚀 Starting server...");
try {
nodeProcess = spawn("node", [ENTRY_FILE], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, FORCE_COLOR: '1' },
detached: false // Ensure process stays attached
});
// Forward stdout with server prefix
nodeProcess.stdout?.on('data', (data) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
lines.forEach((line) => logServer(line));
});
// Forward stderr with server prefix
nodeProcess.stderr?.on('data', (data) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
lines.forEach((line) => logError('AVLEON', line));
});
nodeProcess.on("exit", (code, signal) => {
const wasRestarting = isRestarting;
nodeProcess = null;
if (wasRestarting) {
// This is expected during restart
return;
}
if (code && code !== 0) {
logError('AVLEON', `Process exited with code ${code}`);
}
else if (signal) {
logServer(`Process terminated by signal ${signal}`);
}
isRestarting = false;
});
nodeProcess.on("error", (error) => {
logError('AVLEON', `Process error: ${error.message}`);
nodeProcess = null;
isRestarting = false;
});
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logError('AVLEON', `Failed to start server: ${errorMessage}`);
nodeProcess = null;
isRestarting = false;
}
}
// kill Node
function killNode() {
return new Promise((resolve) => {
if (!nodeProcess)
return resolve();
logServer("🛑 Stopping server...");
let resolved = false;
const cleanup = () => {
if (!resolved) {
resolved = true;
nodeProcess = null;
logServer("✓ Server stopped");
resolve();
}
};
nodeProcess.once("exit", cleanup);
nodeProcess.once("close", cleanup);
// Try graceful shutdown first
try {
nodeProcess.kill("SIGTERM");
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logError('AVLEON', `Error sending SIGTERM: ${errorMessage}`);
}
// Force kill after 1 second if still running
const forceKillTimeout = setTimeout(() => {
if (nodeProcess && !resolved) {
logServer("⚠️ Force killing server...");
try {
nodeProcess.kill("SIGKILL");
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logError('AVLEON', `Error sending SIGKILL: ${errorMessage}`);
}
setTimeout(cleanup, 100);
}
}, 1000);
// Fallback timeout - always resolve within 3 seconds
setTimeout(() => {
clearTimeout(forceKillTimeout);
if (!resolved) {
logError('AVLEON', 'Force cleanup - process may still be running');
nodeProcess = null;
resolved = true;
resolve();
}
}, 3000);
});
}
// restart Node
async function restartNode() {
if (isRestarting) {
logServer("⏳ Already restarting, skipping...");
return;
}
isRestarting = true;
logServer("🔄 Restarting server...");
try {
await killNode();
// Add delay to ensure process is fully cleaned up
await new Promise(resolve => setTimeout(resolve, 200));
startNode();
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logError('AVLEON', `Restart error: ${errorMessage}`);
isRestarting = false;
}
}
let restartTimer = null;
async function debouncedRestart() {
if (restartTimer)
clearTimeout(restartTimer);
restartTimer = setTimeout(async () => {
restartTimer = null;
await restartNode();
}, 300); // wait 300ms after last change
}
// watch src
function watch() {
const watcher = chokidar.watch(SRC_DIR, {
ignoreInitial: true,
ignored: [
/(^|[\/\\])\../, // ignore dotfiles
/\.(test|spec)\.(ts|js)$/ // ignore test files
],
persistent: true,
followSymlinks: false,
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 50
}
});
watcher.on("add", (file) => {
if (file.endsWith('.ts') && !isTestFile(file)) {
if (compileFile(file)) {
setTimeout(() => restartNode(), 100);
}
}
});
watcher.on("change", async (file) => {
if (file.endsWith('.ts') && !isTestFile(file)) {
if (compileFile(file)) {
await debouncedRestart();
}
}
});
watcher.on("unlink", async (file) => {
if (!isTestFile(file)) {
const outFile = path
.join(DIST_DIR, path.relative(SRC_DIR, file))
.replace(/\.ts$/, ".js");
if (fs.existsSync(outFile)) {
fs.unlinkSync(outFile);
}
await restartNode();
}
});
// Debug: log all watcher events
watcher.on('all', (eventName, filePath) => {
if (!['add', 'change', 'unlink'].includes(eventName)) {
//logWatch(`🔍 Debug: ${eventName} -> ${filePath}`);
}
});
watcher.on("error", (error) => {
logError('WATCH', `Watcher error: ${error.message}`);
});
}
process.on("uncaughtException", (error) => {
logError('CLI', `Uncaught exception: ${error.message}`);
logError('CLI', error.stack ?? "");
});
process.on("unhandledRejection", (reason) => {
logError('CLI', `Unhandled rejection: ${reason}`);
});
// Graceful shutdown
process.on("SIGINT", async () => {
await killNode();
process.exit(0);
});
// Main execution
(async () => {
console.log(`${Colors.BOLD}${Colors.CYAN}🔧 Development Server${Colors.RESET}`);
console.log('━'.repeat(50));
logWatch("🚀 Starting development server...");
const compiled = compileAll();
startNode();
watch();
console.log('━'.repeat(50));
logWatch("✨ Ready! Press Ctrl+C to stop");
})();