diff --git a/src/Console/Commands/ServeCommand.php b/src/Console/Commands/ServeCommand.php index bc738dac..804151fd 100644 --- a/src/Console/Commands/ServeCommand.php +++ b/src/Console/Commands/ServeCommand.php @@ -7,9 +7,12 @@ use Closure; use Hyde\Hyde; use Hyde\Facades\Config; +use Illuminate\Support\Arr; +use InvalidArgumentException; use Hyde\RealtimeCompiler\ConsoleOutput; use Illuminate\Support\Facades\Process; use LaravelZero\Framework\Commands\Command; +use Hyde\Publications\Commands\ValidatingCommand; use function sprintf; use function class_exists; @@ -19,12 +22,16 @@ * * @see https://github.com/hydephp/realtime-compiler */ -class ServeCommand extends Command +class ServeCommand extends ValidatingCommand { /** @var string */ protected $signature = 'serve {--host= : [default: "localhost"]}} {--port= : [default: 8080]} + {--save-preview= : Should the served page be saved to disk? (Overrides config setting)} + {--dashboard= : Enable the realtime compiler dashboard. (Overrides config setting)} + {--pretty-urls= : Enable pretty URLs. (Overrides config setting)} + {--play-cdn= : Enable the Tailwind Play CDN. (Overrides config setting)} '; /** @var string */ @@ -32,7 +39,7 @@ class ServeCommand extends Command protected ConsoleOutput $console; - public function handle(): int + public function safeHandle(): int { $this->configureOutput(); $this->printStartMessage(); @@ -46,14 +53,14 @@ public function handle(): int return Command::SUCCESS; } - protected function getPortSelection(): int + protected function getHostSelection(): string { - return (int) ($this->option('port') ?: Config::getInt('hyde.server.port', 8080)); + return (string) $this->option('host') ?: Config::getString('hyde.server.host', 'localhost'); } - protected function getHostSelection(): string + protected function getPortSelection(): int { - return (string) $this->option('host') ?: Config::getString('hyde.server.host', 'localhost'); + return (int) ($this->option('port') ?: Config::getInt('hyde.server.port', 8080)); } protected function getExecutablePath(): string @@ -68,9 +75,13 @@ protected function runServerProcess(string $command): void protected function getEnvironmentVariables(): array { - return [ - 'HYDE_RC_REQUEST_OUTPUT' => ! $this->option('no-ansi'), - ]; + return Arr::whereNotNull([ + 'HYDE_SERVER_REQUEST_OUTPUT' => ! $this->option('no-ansi'), + 'HYDE_SERVER_SAVE_PREVIEW' => $this->parseEnvironmentOption('save-preview'), + 'HYDE_SERVER_DASHBOARD' => $this->parseEnvironmentOption('dashboard'), + 'HYDE_PRETTY_URLS' => $this->parseEnvironmentOption('pretty-urls'), + 'HYDE_PLAY_CDN' => $this->parseEnvironmentOption('play-cdn'), + ]); } protected function configureOutput(): void @@ -84,7 +95,7 @@ protected function printStartMessage(): void { $this->useBasicOutput() ? $this->output->writeln('Starting the HydeRC server... Press Ctrl+C to stop') - : $this->console->printStartMessage($this->getHostSelection(), $this->getPortSelection()); + : $this->console->printStartMessage($this->getHostSelection(), $this->getPortSelection(), $this->getEnvironmentVariables()); } protected function getOutputHandler(): Closure @@ -98,4 +109,31 @@ protected function useBasicOutput(): bool { return $this->option('no-ansi') || ! class_exists(ConsoleOutput::class); } + + protected function parseEnvironmentOption(string $name): ?string + { + $value = $this->option($name) ?? $this->checkArgvForOption($name); + + if ($value !== null) { + return match ($value) { + 'true', '' => 'enabled', + 'false' => 'disabled', + default => throw new InvalidArgumentException(sprintf('Invalid boolean value for --%s option.', $name)) + }; + } + + return null; + } + + /** Fallback check so that an environment option without a value is acknowledged as true. */ + protected function checkArgvForOption(string $name): ?string + { + if (isset($_SERVER['argv'])) { + if (in_array("--$name", $_SERVER['argv'], true)) { + return 'true'; + } + } + + return null; + } } diff --git a/src/Foundation/Internal/LoadConfiguration.php b/src/Foundation/Internal/LoadConfiguration.php index b4b51bf4..10bf1418 100644 --- a/src/Foundation/Internal/LoadConfiguration.php +++ b/src/Foundation/Internal/LoadConfiguration.php @@ -6,9 +6,10 @@ use Phar; use Illuminate\Contracts\Foundation\Application; -use Illuminate\Contracts\Config\Repository as RepositoryContract; +use Illuminate\Contracts\Config\Repository; use Illuminate\Foundation\Bootstrap\LoadConfiguration as BaseLoadConfiguration; +use function getenv; use function array_merge; use function dirname; use function in_array; @@ -30,7 +31,7 @@ protected function getConfigurationFiles(Application $app): array } /** Load the configuration items from all the files. */ - protected function loadConfigurationFiles(Application $app, RepositoryContract $repository): void + protected function loadConfigurationFiles(Application $app, Repository $repository): void { parent::loadConfigurationFiles($app, $repository); @@ -39,7 +40,7 @@ protected function loadConfigurationFiles(Application $app, RepositoryContract $ $this->loadRuntimeConfiguration($app, $repository); } - private function mergeConfigurationFiles(RepositoryContract $repository): void + private function mergeConfigurationFiles(Repository $repository): void { // These files do commonly not need to be customized by the user, so to get them out of the way, // we don't include them in the default project install. @@ -49,7 +50,7 @@ private function mergeConfigurationFiles(RepositoryContract $repository): void } } - private function mergeConfigurationFile(RepositoryContract $repository, string $file): void + private function mergeConfigurationFile(Repository $repository, string $file): void { // We of course want the user to be able to customize the config files, // if they're present, so we'll merge their changes here. @@ -78,18 +79,42 @@ private static function providePharSupportIfNeeded(array &$files): void } } - private function loadRuntimeConfiguration(Application $app, RepositoryContract $repository): void + private function loadRuntimeConfiguration(Application $app, Repository $repository): void { - if ($app->runningInConsole() && isset($_SERVER['argv'])) { - // Check if the `--pretty-urls` CLI argument is set, and if so, set the config value accordingly. - if (in_array('--pretty-urls', $_SERVER['argv'], true)) { - $repository->set('hyde.pretty_urls', true); + if ($app->runningInConsole()) { + if ($this->getArgv() !== null) { + $this->mergeCommandLineArguments($repository, '--pretty-urls', 'hyde.pretty_urls', true); + $this->mergeCommandLineArguments($repository, '--no-api', 'hyde.api_calls', false); } - // Check if the `--no-api` CLI argument is set, and if so, set the config value accordingly. - if (in_array('--no-api', $_SERVER['argv'], true)) { - $repository->set('hyde.api_calls', false); - } + $this->mergeRealtimeCompilerEnvironment($repository, 'HYDE_SERVER_SAVE_PREVIEW', 'hyde.server.save_preview'); + $this->mergeRealtimeCompilerEnvironment($repository, 'HYDE_SERVER_DASHBOARD', 'hyde.server.dashboard.enabled'); + $this->mergeRealtimeCompilerEnvironment($repository, 'HYDE_PRETTY_URLS', 'hyde.pretty_urls'); + $this->mergeRealtimeCompilerEnvironment($repository, 'HYDE_PLAY_CDN', 'hyde.use_play_cdn'); + } + } + + private function mergeCommandLineArguments(Repository $repository, string $argumentName, string $configKey, bool $value): void + { + if (in_array($argumentName, $this->getArgv(), true)) { + $repository->set($configKey, $value); } } + + private function mergeRealtimeCompilerEnvironment(Repository $repository, string $environmentKey, string $configKey): void + { + if ($this->getEnv($environmentKey) !== false) { + $repository->set($configKey, $this->getEnv($environmentKey) === 'enabled'); + } + } + + protected function getArgv(): ?array + { + return $_SERVER['argv'] ?? null; + } + + protected function getEnv(string $name): string|false|null + { + return getenv($name); + } } diff --git a/tests/Feature/Commands/ServeCommandTest.php b/tests/Feature/Commands/ServeCommandTest.php index 7112b461..7aa1cd8d 100644 --- a/tests/Feature/Commands/ServeCommandTest.php +++ b/tests/Feature/Commands/ServeCommandTest.php @@ -13,6 +13,8 @@ /** * @covers \Hyde\Console\Commands\ServeCommand + * + * @see \Hyde\Framework\Testing\Unit\ServeCommandOptionsUnitTest */ class ServeCommandTest extends TestCase { @@ -144,7 +146,7 @@ public function test_hyde_serve_command_passes_through_process_output() Process::shouldReceive('env') ->once() - ->with(['HYDE_RC_REQUEST_OUTPUT' => false]) + ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false]) ->andReturnSelf(); Process::shouldReceive('run') diff --git a/tests/Unit/LoadConfigurationTest.php b/tests/Unit/LoadConfigurationTest.php index d1125608..4358e8bf 100644 --- a/tests/Unit/LoadConfigurationTest.php +++ b/tests/Unit/LoadConfigurationTest.php @@ -15,22 +15,57 @@ class LoadConfigurationTest extends UnitTestCase { public function testItLoadsRuntimeConfiguration() { - $serverBackup = $_SERVER; + $app = new Application(getcwd()); - $_SERVER['argv'] = ['--pretty-urls', '--no-api']; + $loader = new LoadConfigurationTestClass([]); + $loader->bootstrap($app); - $app = new Application(getcwd()); + $this->assertFalse(config('hyde.pretty_urls')); + $this->assertNull(config('hyde.api_calls')); - $loader = new LoadConfiguration(); + $loader = new LoadConfigurationTestClass(['--pretty-urls', '--no-api']); $loader->bootstrap($app); $this->assertTrue(config('hyde.pretty_urls')); $this->assertFalse(config('hyde.api_calls')); + } + + public function testItLoadsRealtimeCompilerEnvironmentConfiguration() + { + (new LoadConfigurationEnvironmentTestClass(['HYDE_SERVER_DASHBOARD' => 'enabled']))->bootstrap(new Application(getcwd())); + $this->assertTrue(config('hyde.server.dashboard.enabled')); - $_SERVER = $serverBackup; + (new LoadConfigurationEnvironmentTestClass(['HYDE_SERVER_DASHBOARD' => 'disabled']))->bootstrap(new Application(getcwd())); + $this->assertFalse(config('hyde.server.dashboard.enabled')); + } +} - $loader->bootstrap($app); - $this->assertFalse(config('hyde.pretty_urls')); - $this->assertNull(config('hyde.api_calls')); +class LoadConfigurationTestClass extends LoadConfiguration +{ + protected array $argv; + + public function __construct(array $argv) + { + $this->argv = $argv; + } + + protected function getArgv(): ?array + { + return $this->argv; + } +} + +class LoadConfigurationEnvironmentTestClass extends LoadConfiguration +{ + protected array $env; + + public function __construct(array $env) + { + $this->env = $env; + } + + protected function getEnv(string $name): string|false|null + { + return $this->env[$name]; } } diff --git a/tests/Unit/ServeCommandOptionsUnitTest.php b/tests/Unit/ServeCommandOptionsUnitTest.php new file mode 100644 index 00000000..935d9793 --- /dev/null +++ b/tests/Unit/ServeCommandOptionsUnitTest.php @@ -0,0 +1,245 @@ + 'localhost', + 'hyde.server.port' => 8080, + ]); + } + + public function test_getHostSelection() + { + $this->assertSame('localhost', $this->getMock()->getHostSelection()); + } + + public function test_getHostSelection_withHostOption() + { + $this->assertSame('foo', $this->getMock(['host' => 'foo'])->getHostSelection()); + } + + public function test_getHostSelection_withConfigOption() + { + self::mockConfig(['hyde.server.host' => 'foo']); + $this->assertSame('foo', $this->getMock()->getHostSelection()); + } + + public function test_getHostSelection_withHostOptionAndConfigOption() + { + self::mockConfig(['hyde.server.host' => 'foo']); + $this->assertSame('bar', $this->getMock(['host' => 'bar'])->getHostSelection()); + } + + public function test_getPortSelection() + { + $this->assertSame(8080, $this->getMock()->getPortSelection()); + } + + public function test_getPortSelection_withPortOption() + { + $this->assertSame(8081, $this->getMock(['port' => 8081])->getPortSelection()); + } + + public function test_getPortSelection_withConfigOption() + { + self::mockConfig(['hyde.server.port' => 8082]); + $this->assertSame(8082, $this->getMock()->getPortSelection()); + } + + public function test_getPortSelection_withPortOptionAndConfigOption() + { + self::mockConfig(['hyde.server.port' => 8082]); + $this->assertSame(8081, $this->getMock(['port' => 8081])->getPortSelection()); + } + + public function test_getEnvironmentVariables() + { + $this->assertSame([ + 'HYDE_SERVER_REQUEST_OUTPUT' => true, + ], $this->getMock()->getEnvironmentVariables()); + } + + public function test_getEnvironmentVariables_withNoAnsiOption() + { + $this->assertSame([ + 'HYDE_SERVER_REQUEST_OUTPUT' => false, + ], $this->getMock(['no-ansi' => true])->getEnvironmentVariables()); + } + + public function testSavePreviewOptionPropagatesToEnvironmentVariables() + { + $command = $this->getMock(['save-preview' => 'false']); + $this->assertSame('disabled', $command->getEnvironmentVariables()['HYDE_SERVER_SAVE_PREVIEW']); + + $command = $this->getMock(['save-preview' => 'true']); + $this->assertSame('enabled', $command->getEnvironmentVariables()['HYDE_SERVER_SAVE_PREVIEW']); + + $command = $this->getMock(['save-preview' => '']); + $this->assertSame('enabled', $command->getEnvironmentVariables()['HYDE_SERVER_SAVE_PREVIEW']); + + $command = $this->getMock(['save-preview' => null]); + $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_SERVER_SAVE_PREVIEW'])); + + $command = $this->getMock(); + $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_SERVER_SAVE_PREVIEW'])); + } + + public function testDashboardOptionPropagatesToEnvironmentVariables() + { + $command = $this->getMock(['dashboard' => 'false']); + $this->assertSame('disabled', $command->getEnvironmentVariables()['HYDE_SERVER_DASHBOARD']); + + $command = $this->getMock(['dashboard' => 'true']); + $this->assertSame('enabled', $command->getEnvironmentVariables()['HYDE_SERVER_DASHBOARD']); + + $command = $this->getMock(['dashboard' => '']); + $this->assertSame('enabled', $command->getEnvironmentVariables()['HYDE_SERVER_DASHBOARD']); + + $command = $this->getMock(['dashboard' => null]); + $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_SERVER_DASHBOARD'])); + + $command = $this->getMock(); + $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_SERVER_DASHBOARD'])); + } + + public function testPrettyUrlsOptionPropagatesToEnvironmentVariables() + { + $command = $this->getMock(['pretty-urls' => 'false']); + $this->assertSame('disabled', $command->getEnvironmentVariables()['HYDE_PRETTY_URLS']); + + $command = $this->getMock(['pretty-urls' => 'true']); + $this->assertSame('enabled', $command->getEnvironmentVariables()['HYDE_PRETTY_URLS']); + + $command = $this->getMock(['pretty-urls' => '']); + $this->assertSame('enabled', $command->getEnvironmentVariables()['HYDE_PRETTY_URLS']); + + $command = $this->getMock(['pretty-urls' => null]); + $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_PRETTY_URLS'])); + + $command = $this->getMock(); + $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_PRETTY_URLS'])); + } + + public function testPlayCdnOptionPropagatesToEnvironmentVariables() + { + $command = $this->getMock(['play-cdn' => 'false']); + $this->assertSame('disabled', $command->getEnvironmentVariables()['HYDE_PLAY_CDN']); + + $command = $this->getMock(['play-cdn' => 'true']); + $this->assertSame('enabled', $command->getEnvironmentVariables()['HYDE_PLAY_CDN']); + + $command = $this->getMock(['play-cdn' => '']); + $this->assertSame('enabled', $command->getEnvironmentVariables()['HYDE_PLAY_CDN']); + + $command = $this->getMock(['play-cdn' => null]); + $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_PLAY_CDN'])); + + $command = $this->getMock(); + $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_PLAY_CDN'])); + } + + public function test_parseEnvironmentOption() + { + $command = $this->getMock(['foo' => 'true']); + $this->assertSame('enabled', $command->parseEnvironmentOption('foo')); + + $command = $this->getMock(['foo' => 'false']); + $this->assertSame('disabled', $command->parseEnvironmentOption('foo')); + } + + public function test_parseEnvironmentOption_withEmptyString() + { + $command = $this->getMock(['foo' => '']); + $this->assertSame('enabled', $command->parseEnvironmentOption('foo')); + } + + public function test_parseEnvironmentOption_withNull() + { + $command = $this->getMock(['foo' => null]); + $this->assertNull($command->parseEnvironmentOption('foo')); + } + + public function test_parseEnvironmentOption_withInvalidValue() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid boolean value for --foo option.'); + + $command = $this->getMock(['foo' => 'bar']); + $command->parseEnvironmentOption('foo'); + } + + public function test_checkArgvForOption() + { + $serverBackup = $_SERVER; + + $_SERVER['argv'] = ['--pretty-urls']; + + $command = $this->getMock(); + + $this->assertSame('true', $command->checkArgvForOption('pretty-urls')); + $this->assertSame(null, $command->checkArgvForOption('dashboard')); + + $_SERVER = $serverBackup; + } + + protected function getMock(array $options = []): ServeCommandMock + { + return new ServeCommandMock($options); + } +} + +/** + * @method getHostSelection + * @method getPortSelection + * @method getEnvironmentVariables + * @method parseEnvironmentOption(string $name) + * @method checkArgvForOption(string $name) + */ +class ServeCommandMock extends ServeCommand +{ + public function __construct(array $options = []) + { + parent::__construct(); + + $this->input = new InputMock($options); + } + + public function __call($method, $parameters) + { + return call_user_func_array([$this, $method], $parameters); + } + + public function option($key = null) + { + return $this->input->getOption($key); + } +} + +class InputMock +{ + protected array $options; + + public function __construct(array $options = []) + { + $this->options = $options; + } + + public function getOption(string $key) + { + return $this->options[$key] ?? null; + } +}