From f3b60adb21fa179e6fed8a0d6a55e0f804aefc4d Mon Sep 17 00:00:00 2001 From: Stephan Ploehn Date: Tue, 26 Aug 2025 10:20:24 +0200 Subject: [PATCH] feat(kirby): added serve command for kirby cli --- composer.json | 5 +- site/commands/serve.php | 208 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 site/commands/serve.php diff --git a/composer.json b/composer.json index 92f95bb..8602a6e 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "kirby", "cms", "starterkit", - "tailwindcss + "tailwindcss" ], "authors": [ { @@ -23,7 +23,8 @@ }, "require": { "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", - "getkirby/cms": "^5.0" + "getkirby/cms": "^5.0", + "ext-pcntl": "*" }, "config": { "allow-plugins": { diff --git a/site/commands/serve.php b/site/commands/serve.php new file mode 100644 index 0000000..085dee1 --- /dev/null +++ b/site/commands/serve.php @@ -0,0 +1,208 @@ + 'Run Kirby dev server and Tailwind watcher', + 'command' => static function ($cli): void { + $cwd = getcwd(); + + // Resolve paths + $router = $cwd . '/vendor/getkirby/cms/router.php'; + $docroot = $cli->arg('docroot') ?? 'public'; + $docrootPath = $cwd . '/' . $docroot; + + // Basic checks + if (!is_file($router)) { + $cli->error('Kirby router not found. Make sure dependencies are installed: vendor/getkirby/cms/router.php'); + return; + } + if (!is_dir($docrootPath)) { + $cli->error("Document root not found: {$docrootPath}"); + return; + } + + // Helper function to check if a port is available + $isPortAvailable = function (string $host, int $port): bool { + $socket = @fsockopen($host, $port, $errno, $errstr, 1); + if ($socket) { + fclose($socket); + return false; // Port is occupied + } + return true; // Port is available + }; + + $host = 'localhost'; + $startPort = 8000; + $maxPort = 8100; + $port = $startPort; + + while ($port <= $maxPort) { + if ($isPortAvailable($host, $port)) { + break; + } + $port++; + } + + if ($port > $maxPort) { + $cli->error("No available ports found between {$startPort} and {$maxPort}"); + return; + } + + $cli->info("Using port {$port} for PHP dev server"); + + $phpCmd = sprintf( + 'php -S %s:%s -t %s %s', + escapeshellarg($host), + escapeshellarg($port), + escapeshellarg($docrootPath), + escapeshellarg($router) + ); + + $npmCmd = 'npm run watch'; + + $cli->info("Starting PHP dev server on http://{$host}:{$port} (docroot: {$docroot})"); + + // Helper to start a process with pipes + $start = static function (string $cmd) use ($cwd) { + $descriptorSpec = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $proc = proc_open($cmd, $descriptorSpec, $pipes, $cwd); + + if (!is_resource($proc)) { + return null; + } + + // Non-blocking I/O + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + return [ + 'proc' => $proc, + 'pipes' => $pipes, + 'cmd' => $cmd, + ]; + }; + + $processes = []; + + $phpProc = $start($phpCmd); + if ($phpProc === null) { + $cli->error('Failed to start PHP dev server.'); + return; + } + $processes['php'] = $phpProc; + + $npmProc = $start($npmCmd); + if ($npmProc === null) { + $cli->warning('Could not start npm watcher. Is Node/npm installed and in your PATH?'); + } else { + $processes['npm'] = $npmProc; + } + + // Graceful termination + $terminateAll = static function () use (&$processes, $cli): void { + $cli->line(""); + foreach ($processes as $name => $p) { + // Check if the resource is still valid before calling proc_get_status + if (is_resource($p['proc'])) { + $status = proc_get_status($p['proc']); + if ($status && $status['running']) { + $cli->line("Stopping {$name}..."); + // Try graceful terminate + @proc_terminate($p['proc']); + } + } + + // Close pipes regardless of process status + foreach ($p['pipes'] as $pipe) { + if (is_resource($pipe)) { + @fclose($pipe); + } + } + + // Final close if the resource is still valid + if (is_resource($p['proc'])) { + @proc_close($p['proc']); + } + } + }; + + // Handle Ctrl+C if pcntl is available + if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { + pcntl_async_signals(true); + pcntl_signal(SIGINT, static function () use ($terminateAll) { + $terminateAll(); + // Exit cleanly after terminating children + exit(130); + }); + // Optional: also handle SIGTERM + pcntl_signal(SIGTERM, static function () use ($terminateAll) { + $terminateAll(); + exit(143); + }); + } + + // Ensure cleanup on normal shutdown + register_shutdown_function($terminateAll); + + // Stream outputs until all children exit + $prefixColor = [ + 'php' => fn($s) => "\033[1;34m🐘 [php]\033[0m $s", + 'npm' => fn($s) => "\033[1;32m [npm]\033[0m $s", + ]; + + while (true) { + $running = false; + + foreach ($processes as $name => $p) { + // Check if the resource is still valid + if (!is_resource($p['proc'])) { + continue; + } + + $status = proc_get_status($p['proc']); + if (!$status) { + continue; + } + + $running = $running || $status['running']; + + // Read stdout + if (is_resource($p['pipes'][1])) { + $out = @stream_get_contents($p['pipes'][1]); + if ($out !== false && $out !== '') { + foreach (preg_split('/\R/', rtrim($out)) as $line) { + if ($line !== '') { + $cli->line(($prefixColor[$name])($line)); + } + } + } + } + + // Read stderr + if (is_resource($p['pipes'][2])) { + $err = @stream_get_contents($p['pipes'][2]); + if ($err !== false && $err !== '') { + foreach (preg_split('/\R/', rtrim($err)) as $line) { + if ($line !== '') { + $cli->error(($prefixColor[$name])($line)); + } + } + } + } + } + + if (!$running) { + break; + } + + // Small sleep to avoid busy-waiting + usleep(50_000); + } + + $cli->success('All processes exited.'); + } +]; \ No newline at end of file