Files
php-dev-router/bin/php-dev-router
2026-06-16 18:55:05 +03:30

538 lines
17 KiB
PHP
Executable File

#!/usr/bin/env php
<?php
declare(strict_types=1);
const VERSION = '1.0.0';
const HOSTS_BEGIN = '# BEGIN php-dev-router';
const HOSTS_END = '# END php-dev-router';
main($argv);
function main(array $argv): void
{
$command = $argv[1] ?? 'help';
$options = parseOptions(array_slice($argv, 2));
$config = loadConfig($options['config'] ?? null);
try {
match ($command) {
'scan' => commandScan($config, $options),
'apply' => commandApply($config, $options),
'watch' => commandWatch($config, $options),
'doctor', 'status' => commandDoctor($config),
'install' => commandInstall($config, $options),
'help', '-h', '--help' => commandHelp(),
default => fail("Unknown command: {$command}\nRun: php-dev-router help"),
};
} catch (RuntimeException $exception) {
fwrite(STDERR, "php-dev-router: {$exception->getMessage()}\n");
exit(1);
}
}
function parseOptions(array $args): array
{
$options = [];
foreach ($args as $arg) {
if (!str_starts_with($arg, '--')) {
$options['_'][] = $arg;
continue;
}
$arg = substr($arg, 2);
if (str_contains($arg, '=')) {
[$key, $value] = explode('=', $arg, 2);
$options[$key] = $value;
} else {
$options[$arg] = true;
}
}
return $options;
}
function loadConfig(?string $path): array
{
$projectRoot = dirname(__DIR__);
$configPath = $path ? expandPath($path) : defaultConfigPath($projectRoot);
if (!is_file($configPath)) {
fail("Config file not found: {$configPath}");
}
$config = json_decode((string) file_get_contents($configPath), true);
if (!is_array($config)) {
fail("Config file is not valid JSON: {$configPath}");
}
$defaults = [
'projects_root' => getenv('HOME') . '/PhpstormProjects',
'domain' => 'php.test',
'ssl_certificate' => getenv('HOME') . '/.ssl/php-dev-router/fullchain.pem',
'ssl_certificate_key' => getenv('HOME') . '/.ssl/php-dev-router/privkey.pem',
'php_fpm_socket' => '/run/php-fpm/php-fpm.sock',
'nginx_conf' => '/etc/nginx/conf.d/php-dev-router.conf',
'hosts_file' => '/etc/hosts',
'systemd_service' => '/etc/systemd/system/php-dev-router.service',
'skip_dirs' => ['.git', '.idea', '.vscode', 'bootstrap/cache', 'node_modules', 'storage', 'vendor'],
'scan_max_depth' => 5,
'watch_interval_seconds' => 3,
'config_path' => $configPath,
'project_root' => $projectRoot,
];
$config = array_replace($defaults, $config);
foreach (['projects_root', 'ssl_certificate', 'ssl_certificate_key', 'nginx_conf', 'hosts_file', 'systemd_service'] as $key) {
$config[$key] = expandPath((string) $config[$key]);
}
$config['domain'] = trim((string) $config['domain'], ". \t\n\r\0\x0B");
$config['scan_max_depth'] = max(1, (int) $config['scan_max_depth']);
$config['watch_interval_seconds'] = max(1, (int) $config['watch_interval_seconds']);
return $config;
}
function commandHelp(): void
{
$version = VERSION;
echo <<<TEXT
php-dev-router v{$version}
Usage:
php-dev-router scan [--json]
php-dev-router apply [--dry-run] [--no-reload] [--no-nginx-test]
php-dev-router watch [--interval=3]
php-dev-router doctor
php-dev-router install [--dry-run]
Commands:
scan Discover PHP/Laravel apps and show hostnames.
apply Generate nginx config and exact local hosts entries.
watch Re-apply when the project inventory changes.
doctor Check config, certs, services, generated files, and DNS.
install Create the systemd service and run apply.
TEXT;
}
function commandScan(array $config, array $options): void
{
$apps = discoverApps($config);
if (isset($options['json'])) {
echo json_encode($apps, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return;
}
if ($apps === []) {
echo "No serveable PHP apps found in {$config['projects_root']}\n";
return;
}
foreach ($apps as $app) {
echo "{$app['host']} -> {$app['root']} ({$app['type']})\n";
}
}
function commandApply(array $config, array $options): void
{
$apps = discoverApps($config);
$nginx = renderNginx($config, $apps);
$hosts = renderHostsBlock($apps);
if (isset($options['dry-run'])) {
echo "Would write nginx config: {$config['nginx_conf']}\n";
echo "Would update hosts file: {$config['hosts_file']}\n\n";
echo $nginx;
echo "\n";
echo $hosts;
return;
}
ensureWritableTargets([$config['nginx_conf'], $config['hosts_file']]);
atomicWrite($config['nginx_conf'], $nginx);
updateHostsFile($config['hosts_file'], $hosts);
if (empty($options['no-nginx-test'])) {
runOrFail(['nginx', '-t']);
}
if (empty($options['no-reload'])) {
reloadService('nginx');
}
echo "Applied " . count($apps) . " app route(s).\n";
}
function commandWatch(array $config, array $options): void
{
$interval = isset($options['interval']) ? max(1, (int) $options['interval']) : $config['watch_interval_seconds'];
echo "Watching {$config['projects_root']} every {$interval}s\n";
$lastHash = '';
while (true) {
$apps = discoverApps($config);
$hash = hash('sha256', json_encode($apps, JSON_UNESCAPED_SLASHES));
if ($hash !== $lastHash) {
$lastHash = $hash;
try {
commandApply($config, ['no-reload' => false]);
} catch (RuntimeException $exception) {
fwrite(STDERR, date('c') . " apply failed: {$exception->getMessage()}\n");
}
}
sleep($interval);
}
}
function commandDoctor(array $config): void
{
$apps = discoverApps($config);
$checks = [
['Projects root', is_dir($config['projects_root']), $config['projects_root']],
['SSL cert', is_file($config['ssl_certificate']), $config['ssl_certificate']],
['SSL key', is_file($config['ssl_certificate_key']), $config['ssl_certificate_key']],
['PHP-FPM socket', file_exists($config['php_fpm_socket']), $config['php_fpm_socket']],
['nginx config dir', is_dir(dirname($config['nginx_conf'])), dirname($config['nginx_conf'])],
['hosts file', is_file($config['hosts_file']), $config['hosts_file']],
];
foreach ($checks as [$name, $ok, $detail]) {
echo sprintf("%-18s %s %s\n", $name, $ok ? 'OK ' : 'MISS', $detail);
}
echo "\nDetected apps: " . count($apps) . "\n";
foreach ($apps as $app) {
$resolved = trim((string) shell_exec('getent hosts ' . escapeshellarg($app['host']) . ' 2>/dev/null'));
$dnsStatus = str_contains($resolved, '127.0.0.1') ? 'resolves' : 'not-local';
echo " {$app['host']} -> {$app['root']} ({$dnsStatus})\n";
}
}
function commandInstall(array $config, array $options): void
{
$service = renderSystemdService($config);
if (isset($options['dry-run'])) {
echo "Would write systemd service: {$config['systemd_service']}\n\n";
echo $service;
echo "\n";
commandApply($config, ['dry-run' => true]);
return;
}
ensureWritableTargets([$config['systemd_service'], $config['nginx_conf'], $config['hosts_file']]);
atomicWrite($config['systemd_service'], $service);
commandApply($config, ['no-reload' => false]);
runOrFail(['systemctl', 'daemon-reload']);
enableServiceIfAvailable('php-fpm');
enableServiceIfAvailable('nginx');
runOrFail(['systemctl', 'enable', '--now', 'php-dev-router.service']);
echo "Installed php-dev-router watcher service.\n";
}
function discoverApps(array $config): array
{
$root = rtrim($config['projects_root'], '/');
if (!is_dir($root)) {
return [];
}
$candidates = [];
scanDirectory($root, $root, 0, $config, $candidates);
$usedHosts = [];
$apps = [];
foreach ($candidates as $candidate) {
$relative = trim(substr($candidate['app_dir'], strlen($root)), '/');
$slug = slugify($relative === '' ? basename($candidate['app_dir']) : $relative);
$host = "{$slug}.{$config['domain']}";
if (isset($usedHosts[$host])) {
$hash = substr(hash('sha1', $candidate['app_dir']), 0, 7);
$host = "{$slug}-{$hash}.{$config['domain']}";
}
$usedHosts[$host] = true;
$apps[] = [
'host' => $host,
'name' => $slug,
'type' => $candidate['type'],
'app_dir' => $candidate['app_dir'],
'root' => $candidate['root'],
'index' => $candidate['root'] . '/index.php',
];
}
usort($apps, fn (array $a, array $b): int => strcmp($a['host'], $b['host']));
return $apps;
}
function scanDirectory(string $dir, string $base, int $depth, array $config, array &$candidates): void
{
if ($depth > $config['scan_max_depth']) {
return;
}
$dir = rtrim($dir, '/');
if (is_file("{$dir}/public/index.php")) {
$type = is_file("{$dir}/artisan") ? 'laravel' : 'public-front-controller';
$candidates[] = ['app_dir' => $dir, 'root' => "{$dir}/public", 'type' => $type];
} elseif (is_file("{$dir}/index.php")) {
$candidates[] = ['app_dir' => $dir, 'root' => $dir, 'type' => 'generic-php'];
}
$children = @scandir($dir);
if ($children === false) {
return;
}
foreach ($children as $child) {
if ($child === '.' || $child === '..') {
continue;
}
$path = "{$dir}/{$child}";
if ($child === 'public' || str_starts_with($child, '.')) {
continue;
}
if (!is_dir($path) || shouldSkip($path, $base, $config['skip_dirs'])) {
continue;
}
scanDirectory($path, $base, $depth + 1, $config, $candidates);
}
}
function shouldSkip(string $path, string $base, array $skipDirs): bool
{
$relative = trim(substr($path, strlen(rtrim($base, '/'))), '/');
$name = basename($path);
foreach ($skipDirs as $skip) {
$skip = trim((string) $skip, '/');
if ($skip === $name || $skip === $relative || str_ends_with($relative, "/{$skip}")) {
return true;
}
}
return false;
}
function renderNginx(array $config, array $apps): string
{
$lines = [
'# Managed by php-dev-router. Re-running apply replaces this file.',
'# Source: ' . $config['config_path'],
'',
];
foreach ($apps as $app) {
$host = nginxEscape($app['host']);
$root = nginxEscape($app['root']);
$cert = nginxEscape($config['ssl_certificate']);
$key = nginxEscape($config['ssl_certificate_key']);
$socket = nginxEscape($config['php_fpm_socket']);
$lines[] = "server {";
$lines[] = " listen 80;";
$lines[] = " server_name {$host};";
$lines[] = " return 301 https://{$host}\$request_uri;";
$lines[] = "}";
$lines[] = "";
$lines[] = "server {";
$lines[] = " listen 443 ssl;";
$lines[] = " server_name {$host};";
$lines[] = "";
$lines[] = " ssl_certificate \"{$cert}\";";
$lines[] = " ssl_certificate_key \"{$key}\";";
$lines[] = "";
$lines[] = " root \"{$root}\";";
$lines[] = " index index.php index.html;";
$lines[] = "";
$lines[] = " client_max_body_size 128m;";
$lines[] = "";
$lines[] = " location / {";
$lines[] = " try_files \$uri \$uri/ /index.php?\$query_string;";
$lines[] = " }";
$lines[] = "";
$lines[] = " location ~ \\.php$ {";
$lines[] = " try_files \$uri =404;";
$lines[] = " fastcgi_pass unix:{$socket};";
$lines[] = " fastcgi_index index.php;";
$lines[] = " include fastcgi.conf;";
$lines[] = " }";
$lines[] = "";
$lines[] = " location ~ /\\.(?!well-known).* {";
$lines[] = " deny all;";
$lines[] = " }";
$lines[] = "}";
$lines[] = "";
}
return implode("\n", $lines);
}
function renderHostsBlock(array $apps): string
{
$lines = [HOSTS_BEGIN];
foreach ($apps as $app) {
$lines[] = "127.0.0.1 {$app['host']}";
}
$lines[] = HOSTS_END;
$lines[] = '';
return implode("\n", $lines);
}
function updateHostsFile(string $path, string $block): void
{
$current = is_file($path) ? (string) file_get_contents($path) : '';
$pattern = '/' . preg_quote(HOSTS_BEGIN, '/') . '.*?' . preg_quote(HOSTS_END, '/') . "\\n?/s";
if (preg_match($pattern, $current)) {
$new = preg_replace($pattern, $block, $current);
} else {
$new = rtrim($current) . "\n\n" . $block;
}
atomicWrite($path, (string) $new);
}
function renderSystemdService(array $config): string
{
$php = trim((string) shell_exec('command -v php 2>/dev/null')) ?: '/usr/bin/php';
$script = __FILE__;
$configPath = $config['config_path'];
$interval = $config['watch_interval_seconds'];
return <<<UNIT
[Unit]
Description=PHP development router
After=network.target nginx.service php-fpm.service
Wants=nginx.service php-fpm.service
[Service]
Type=simple
ExecStart={$php} {$script} watch --config={$configPath} --interval={$interval}
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
UNIT;
}
function defaultConfigPath(string $projectRoot): string
{
$localConfig = "{$projectRoot}/config.json";
if (is_file($localConfig)) {
return $localConfig;
}
return "{$projectRoot}/config.example.json";
}
function atomicWrite(string $path, string $contents): void
{
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
fail("Could not create directory: {$dir}");
}
$mode = file_exists($path) ? (fileperms($path) & 0777) : 0644;
$owner = file_exists($path) ? fileowner($path) : false;
$group = file_exists($path) ? filegroup($path) : false;
$tmp = tempnam($dir, '.php-dev-router.');
if ($tmp === false) {
fail("Could not create temp file in {$dir}");
}
if (file_put_contents($tmp, $contents) === false) {
@unlink($tmp);
fail("Could not write temp file: {$tmp}");
}
@chmod($tmp, $mode);
if ($owner !== false) {
@chown($tmp, $owner);
}
if ($group !== false) {
@chgrp($tmp, $group);
}
if (!rename($tmp, $path)) {
@unlink($tmp);
fail("Could not replace file: {$path}");
}
@chmod($path, $mode);
}
function runOrFail(array $command): void
{
$escaped = array_map('escapeshellarg', $command);
$cmd = implode(' ', $escaped) . ' 2>&1';
exec($cmd, $output, $code);
if ($code !== 0) {
fail("Command failed ({$code}): " . implode(' ', $command) . "\n" . implode("\n", $output));
}
}
function reloadService(string $service): void
{
exec('systemctl reload ' . escapeshellarg($service) . ' 2>&1', $output, $code);
if ($code === 0) {
return;
}
exec('systemctl restart ' . escapeshellarg($service) . ' 2>&1', $restartOutput, $restartCode);
if ($restartCode !== 0) {
fail("Could not reload or restart {$service}\n" . implode("\n", array_merge($output, $restartOutput)));
}
}
function enableServiceIfAvailable(string $service): void
{
exec('systemctl list-unit-files ' . escapeshellarg($service . '.service') . ' --no-legend 2>/dev/null', $output, $code);
if ($code === 0 && trim(implode("\n", $output)) !== '') {
runOrFail(['systemctl', 'enable', '--now', $service . '.service']);
}
}
function ensureWritableTargets(array $paths): void
{
$blocked = [];
foreach ($paths as $path) {
$parent = dirname($path);
if ((is_file($path) && is_writable($path)) || (!file_exists($path) && is_dir($parent) && is_writable($parent))) {
continue;
}
$blocked[] = $path;
}
if ($blocked !== []) {
fail("This command cannot write required target(s). Re-run with sudo.\nTargets: " . implode(', ', $blocked));
}
}
function expandPath(string $path): string
{
if ($path === '~') {
return (string) getenv('HOME');
}
if (str_starts_with($path, '~/')) {
return getenv('HOME') . substr($path, 1);
}
return $path;
}
function slugify(string $value): string
{
$value = strtolower($value);
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value;
$value = trim($value, '-');
return $value !== '' ? $value : 'app';
}
function nginxEscape(string $value): string
{
return str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
}
function fail(string $message): never
{
throw new RuntimeException($message);
}