From 8828393620b35e6aa0204816401ad42d3afbe6d5 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 18:53:50 -0500 Subject: [PATCH 1/4] Adding serve commands Assisted-by: Claude Code --- src/Command/NewCommand.php | 5 +- src/Command/ServeCommand.php | 231 +++++++++++++++++++++++++++++++++++ src/Commands.php | 2 + 3 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 src/Command/ServeCommand.php diff --git a/src/Command/NewCommand.php b/src/Command/NewCommand.php index 7aa6d46..5b3dd24 100644 --- a/src/Command/NewCommand.php +++ b/src/Command/NewCommand.php @@ -73,7 +73,7 @@ final class NewCommand extends Command Stub::PROJECT_PREFIX . 'config' => 'crest.php', Stub::PROJECT_PREFIX . 'env' => '.env', Stub::PROJECT_PREFIX . 'gitignore' => '.gitignore', - Stub::PROJECT_PREFIX . 'htrouter' => '.htrouter.php', + Stub::PROJECT_PREFIX . 'htrouter' => ServeCommand::ROUTER, Stub::PROJECT_PREFIX . 'readme' => 'README.md', Stub::PROJECT_PREFIX . 'compose' => 'docker-compose.yml', Stub::PROJECT_PREFIX . 'dockerfile' => 'resources/docker/Dockerfile', @@ -320,8 +320,7 @@ private function report(Output $output, string $shown): void $output->line(); $output->line($cd); $output->line(' composer install'); - // Until `crest serve` exists, the host way names the server directly. - $output->line(' php -S localhost:8080 -t public .htrouter.php'); + $output->line(' crest serve'); $output->line(); $output->line(sprintf('Then GET / answers from %s/%s.php', self::ACTION_PATH, self::SEED)); } diff --git a/src/Command/ServeCommand.php b/src/Command/ServeCommand.php new file mode 100644 index 0000000..e2244c9 --- /dev/null +++ b/src/Command/ServeCommand.php @@ -0,0 +1,231 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Command\Command; +use Crest\Console\Exceptions\Exception; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; +use Crest\Process\Runner; +use Crest\Process\ShellRunner; +use Crest\Project\Locator; + +use function count; +use function dirname; +use function file_get_contents; +use function getcwd; +use function getenv; +use function is_file; +use function preg_match; +use function preg_match_all; +use function rtrim; +use function sprintf; + +use const PHP_BINARY; + +/** + * Starts PHP's built-in web server for the project. It uses the router + * script that `new` writes. The router and the document root are the same + * as in the CMD of the generated Dockerfile. Thus `serve` and `up` run the + * same application. + * + * It extends the console Command, not ProjectCommand. It does not read + * crest.php, and it does not load the project's vendor/: the server runs + * the project in a child process, where public/index.php loads the project + * autoloader. Thus a global crest can run it. + */ +final class ServeCommand extends Command +{ + /** + * The router script, relative to the project root. NewCommand writes the + * file with this name. + */ + public const ROUTER = '.htrouter.php'; + + /** + * The composer autoloader, relative to the project root. The + * public/index.php that `new` writes loads it. + */ + private const AUTOLOADER = 'vendor/autoload.php'; + + /** + * The document root, relative to the project root. + */ + private const DOCUMENT_ROOT = 'public'; + + /** + * The variables file of docker compose, relative to the project root. + */ + private const ENV_FILE = '.env'; + + /** + * Only this machine can connect. The container listens on 0.0.0.0. Not + * localhost: on some hosts localhost gives ::1 first, and then PHP + * listens on IPv6 only. + */ + private const HOST = '127.0.0.1'; + + private const PORT_DEFAULT = 8080; + + /** + * An APP_PORT line of .env, as docker compose reads it: an optional + * `export`, then '=' or ':'. Spaces around the name, the separator and + * the value are ignored. A quoted value ends at its quote. An unquoted + * value ends before ' #'. A ' #' comment after the value is ignored. + */ + private const PORT_LINE = '/^\h*(?:export\h+)?APP_PORT\h*[=:]\h*(?|"([^"]*)"|\'([^\']*)\'|(.*?))\h*(?: #.*)?\r?$/m'; + + private const PORT_MAX = 65535; + + /** + * The variable that docker-compose.yml reads for the published port. + */ + private const PORT_VARIABLE = 'APP_PORT'; + + private readonly Runner $runner; + + /** + * The default lets the kernel's `new $class()` work. A test gives a fake + * runner, and checks the exact argv without a server. + */ + public function __construct(?Runner $runner = null) + { + $this->runner = $runner ?? new ShellRunner(); + } + + public function define(): Definition + { + return Definition::for('serve', "Start PHP's built-in web server for the project") + ->option('port=s', 'Port, 1 to 65535. Default: APP_PORT, then 8080'); + } + + /** + * Checks the router, the autoloader and the port first. An error then + * stops the command before PHP starts. PHP itself reports a port that is + * in use and a missing public/ directory. + */ + public function handle(Input $input, Output $output): int + { + $root = $this->root($input); + $router = $root . '/' . self::ROUTER; + + if (false === is_file($router)) { + throw new Exception( + sprintf('%s was not found; serve uses the router script that crest new writes', $router) + ); + } + + $autoloader = $root . '/' . self::AUTOLOADER; + + if (false === is_file($autoloader)) { + throw new Exception(sprintf('%s was not found; run composer install first', $autoloader)); + } + + $port = $this->port($input, $root); + + // The PHP that runs crest, not the first php on the PATH. + return $this->runner->run( + [PHP_BINARY, '-S', self::HOST . ':' . $port, '-t', self::DOCUMENT_ROOT, self::ROUTER], + $root + ); + } + + /** + * The APP_PORT value in the .env file. Empty if there is no file or no + * APP_PORT line. The last line wins, as in docker compose. + */ + private function envValue(string $file): string + { + if ( + true === is_file($file) + && 0 < preg_match_all(self::PORT_LINE, (string) file_get_contents($file), $matches) + ) { + $values = $matches[1]; + + return $values[count($values) - 1]; + } + + return ''; + } + + /** + * --port, if the user gave it. If not, APP_PORT from the environment, + * then APP_PORT from .env, then PORT_DEFAULT. docker compose uses the + * same order, so that serve and up use the same port. An empty APP_PORT + * reads as absent, as ${APP_PORT:-8080} in docker-compose.yml reads it. + */ + private function port(Input $input, string $root): int + { + $option = $input->optionStringOrNull('port'); + + if (null !== $option) { + return $this->portNumber($option, ''); + } + + $variable = (string) getenv(self::PORT_VARIABLE); + + if ('' !== $variable) { + return $this->portNumber($variable, sprintf(' (%s in the environment)', self::PORT_VARIABLE)); + } + + $file = $root . '/' . self::ENV_FILE; + $value = $this->envValue($file); + + if ('' !== $value) { + return $this->portNumber($value, sprintf(' (%s in %s)', self::PORT_VARIABLE, $file)); + } + + return self::PORT_DEFAULT; + } + + /** + * Digits only, from 1 to PORT_MAX. The pattern ends with \z, not $, + * because $ also accepts a trailing newline. The int goes into the argv, + * so 0080 gives 80. The source tells the user where the value came from. + * It is empty for --port. + */ + private function portNumber(string $value, string $source): int + { + $port = (int) $value; + + if (0 === preg_match('/^\d+\z/', $value) || $port < 1 || $port > self::PORT_MAX) { + throw new Exception( + sprintf("'%s' is not a port%s; expected an integer from 1 to %d", $value, $source, self::PORT_MAX) + ); + } + + return $port; + } + + /** + * --directory, if the user gave one. An empty value reads as absent, as + * it does for `new` and `up`. If not, the directory of the nearest + * crest.php above the working directory, so that serve works from a + * subdirectory. If there is no crest.php, the working directory. + */ + private function root(Input $input): string + { + $directory = $input->optionString('directory'); + + if ('' !== $directory) { + return rtrim($directory, '/'); + } + + $cwd = (string) getcwd(); + $file = Locator::locate($cwd); + + return null === $file ? $cwd : dirname($file); + } +} diff --git a/src/Commands.php b/src/Commands.php index d801364..82bea15 100644 --- a/src/Commands.php +++ b/src/Commands.php @@ -27,6 +27,7 @@ use Crest\Command\Make\ResponderCommand; use Crest\Command\NewCommand; use Crest\Command\Route\ListCommand as RouteListCommand; +use Crest\Command\ServeCommand; use Crest\Command\Stub\PublishCommand as StubPublishCommand; use Crest\Command\UpCommand; use Crest\Console\Registry; @@ -73,6 +74,7 @@ public static function registry(): Registry ->add('make:responder', ResponderCommand::class) ->add('new', NewCommand::class) ->add('route:list', RouteListCommand::class) + ->add('serve', ServeCommand::class, 'server') ->add('stub:publish', StubPublishCommand::class) ->add('up', UpCommand::class) ->withDiscovery(self::KEY); From 49f93f4729a92735e2de35ed48bc5313fd160e01 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 18:54:01 -0500 Subject: [PATCH 2/4] tests and infection Assisted-by: Claude Code --- tests/Unit/Command/NewCommandTest.php | 5 +- tests/Unit/Command/ServeCommandTest.php | 429 ++++++++++++++++++++++++ tests/Unit/CommandsTest.php | 8 + 3 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Command/ServeCommandTest.php diff --git a/tests/Unit/Command/NewCommandTest.php b/tests/Unit/Command/NewCommandTest.php index 7aea690..50238ac 100644 --- a/tests/Unit/Command/NewCommandTest.php +++ b/tests/Unit/Command/NewCommandTest.php @@ -399,7 +399,7 @@ public function testTheClosingOutputShowsBothWaysToRunIt(): void . PHP_EOL . ' cd ' . $target . PHP_EOL . ' composer install' . PHP_EOL - . ' php -S localhost:8080 -t public .htrouter.php' . PHP_EOL + . ' crest serve' . PHP_EOL . PHP_EOL . 'Then GET / answers from src/Action/Get.php' . PHP_EOL, $this->readStdout() @@ -597,7 +597,8 @@ public function testTheRouterScriptIsRendered(): void " + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command; + +use Crest\Command\NewCommand; +use Crest\Command\ServeCommand; +use Crest\Console\Exceptions\Exception; +use Crest\Project\Locator; +use Crest\Tests\Support\Process\FakeRunner; +use Crest\Tests\Support\RunsACommandDirectly; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function array_slice; +use function chdir; +use function file_get_contents; +use function file_put_contents; +use function getcwd; +use function json_decode; +use function mkdir; +use function preg_match; +use function putenv; +use function unlink; + +use const JSON_THROW_ON_ERROR; +use const PHP_BINARY; + +final class ServeCommandTest extends TestCase +{ + use RunsACommandDirectly; + use ScratchDirectory; + + private string $previousCwd = ''; + + protected function setUp(): void + { + $this->captureStreams(); + + // The scratch project has the router, vendor/autoload.php and src/, + // but no public/. If a real server starts by mistake, PHP stops at + // once with "Directory public does not exist". Thus no test can hang. + $this->makeScratchDirectory('serve', 'src'); + $this->project($this->root); + + // An APP_PORT from outside the suite must not change the port. + putenv('APP_PORT'); + + $this->previousCwd = (string) getcwd(); + } + + protected function tearDown(): void + { + putenv('APP_PORT'); + chdir($this->previousCwd); + + $this->closeStreams(); + $this->removeScratchDirectory(); + } + + /** + * @return iterable + */ + public static function envFiles(): iterable + { + yield 'the generated file' => ["PROJECT_PREFIX=my-app\nAPP_PORT=9000\nUID=1000\n", '127.0.0.1:9000']; + yield 'double quotes' => ["APP_PORT=\"9000\"\n", '127.0.0.1:9000']; + yield 'single quotes' => ["APP_PORT='9000'\n", '127.0.0.1:9000']; + yield 'spaces' => [" APP_PORT = 9000 \n", '127.0.0.1:9000']; + yield 'Windows line ends' => ["APP_PORT=9000\r\nUID=1000\r\n", '127.0.0.1:9000']; + // docker compose uses the last line. + yield 'two lines' => ["APP_PORT=9000\nAPP_PORT=9001\n", '127.0.0.1:9001']; + yield 'a comment first' => ["# APP_PORT=9001\nAPP_PORT=9000\n", '127.0.0.1:9000']; + yield 'a longer name first' => ["MY_APP_PORT=9001\nAPP_PORT=9000\n", '127.0.0.1:9000']; + yield 'export' => ["export APP_PORT=9000\n", '127.0.0.1:9000']; + yield 'a colon' => ["APP_PORT: 9000\n", '127.0.0.1:9000']; + yield 'a comment after the value' => ["APP_PORT=9000 # web\n", '127.0.0.1:9000']; + yield 'a comment after quotes' => ["APP_PORT=\"9000\" # web\n", '127.0.0.1:9000']; + // Empty reads as absent, as ${APP_PORT:-8080} in docker-compose.yml. + yield 'empty' => ["APP_PORT=\nUID=1000\n", '127.0.0.1:8080']; + yield 'empty quotes' => ["APP_PORT=\"\"\n", '127.0.0.1:8080']; + yield 'empty on the last line' => ["APP_PORT=9000\nAPP_PORT=\n", '127.0.0.1:8080']; + yield 'no APP_PORT' => ["UID=1000\n", '127.0.0.1:8080']; + } + + /** + * @return iterable + */ + public static function invalidPorts(): iterable + { + yield 'empty' => ['']; + yield 'letters' => ['abc']; + yield 'zero' => ['0']; + yield 'above the range' => ['65536']; + yield 'negative' => ['-1']; + yield 'text after the digits' => ['8080x']; + yield 'text before the digits' => ['x8080']; + // PHP casts '+8080' to 8080. Only the pattern stops it. + yield 'a sign before the digits' => ['+8080']; + yield 'a trailing newline' => ["8080\n"]; + } + + /** + * @return iterable + */ + public static function validPorts(): iterable + { + yield 'the lowest' => ['1', '127.0.0.1:1']; + yield 'the highest' => ['65535', '127.0.0.1:65535']; + yield 'another' => ['9000', '127.0.0.1:9000']; + yield 'leading zeros' => ['0080', '127.0.0.1:80']; + } + + public function testAMissingAutoloaderStopsBeforePhpStarts(): void + { + // composer install has not run. + file_put_contents($this->root . '/src/' . ServeCommand::ROUTER, "assertSame( + $this->root . '/src/vendor/autoload.php was not found; run composer install first', + $this->refusal(['--directory', $this->root . '/src']) + ); + } + + public function testAMissingRouterStopsBeforePhpStarts(): void + { + // A project that `new` did not make, or a wrong --directory. + $this->assertSame( + $this->root . '/src/.htrouter.php was not found; serve uses the router script that crest new writes', + $this->refusal(['--directory', $this->root . '/src']) + ); + } + + public function testAnEmptyAppPortInTheEnvironmentReadsAsAbsent(): void + { + // As ${APP_PORT:-8080} in docker-compose.yml. + putenv('APP_PORT='); + file_put_contents($this->root . '/.env', "APP_PORT=9000\n"); + + $runner = new FakeRunner(); + + $this->handleDirectly(new ServeCommand($runner), ['--directory', $this->root]); + + $this->assertSame([[$this->argv('127.0.0.1:9000'), $this->root]], $runner->calls); + } + + public function testAnEmptyDirectoryOptionMeansTheWorkingDirectory(): void + { + // `--directory="$DIR"` with an unset variable. As for `new` and `up`, + // empty reads as absent. + chdir($this->root); + + // The test needs no crest.php here or above. + $this->assertNull(Locator::locate($this->root)); + + $runner = new FakeRunner(); + + $this->handleDirectly(new ServeCommand($runner), ['--directory=']); + + $this->assertSame([[$this->argv('127.0.0.1:8080'), $this->root]], $runner->calls); + } + + public function testAnInvalidAppPortInTheEnvFileStopsBeforePhpStarts(): void + { + file_put_contents($this->root . '/.env', "APP_PORT=abc\n"); + + $this->assertSame( + "'abc' is not a port (APP_PORT in " . $this->root . "/.env); expected an integer from 1 to 65535", + $this->refusal(['--directory', $this->root]) + ); + } + + public function testAnInvalidAppPortInTheEnvironmentStopsBeforePhpStarts(): void + { + putenv('APP_PORT=abc'); + + $this->assertSame( + "'abc' is not a port (APP_PORT in the environment); expected an integer from 1 to 65535", + $this->refusal(['--directory', $this->root]) + ); + } + + /** + * @dataProvider invalidPorts + */ + public function testAnInvalidPortStopsBeforePhpStarts(string $port): void + { + $this->assertSame( + "'" . $port . "' is not a port; expected an integer from 1 to 65535", + $this->refusal(['--port=' . $port, '--directory', $this->root]) + ); + } + + public function testATrailingSlashOnTheDirectoryIsRemoved(): void + { + // Shell completion adds the slash. The root is the same directory. + $runner = new FakeRunner(); + + $this->handleDirectly(new ServeCommand($runner), ['--directory', $this->root . '/']); + + $this->assertSame([[$this->argv('127.0.0.1:8080'), $this->root]], $runner->calls); + } + + /** + * @dataProvider validPorts + */ + public function testAValidPortReachesTheServer(string $port, string $address): void + { + $runner = new FakeRunner(); + + $this->handleDirectly(new ServeCommand($runner), ['--port=' . $port, '--directory', $this->root]); + + $this->assertSame([[$this->argv($address), $this->root]], $runner->calls); + } + + public function testAVariableInTheEnvFileStopsBeforePhpStarts(): void + { + // docker compose expands ${WEB_PORT}. serve does not. It stops, and it + // does not use a different port. + file_put_contents($this->root . '/.env', "APP_PORT=\${WEB_PORT}\n"); + + $this->assertSame( + "'\${WEB_PORT}' is not a port (APP_PORT in " . $this->root . "/.env); expected an integer from 1 to 65535", + $this->refusal(['--directory', $this->root]) + ); + } + + public function testServeAgreesWithTheGeneratedContainer(): void + { + // serve and `up` must run the same application on the same port. new + // writes the Dockerfile and docker-compose.yml. public/ is removed, for + // the reason in setUp(). + $this->handleDirectly(new NewCommand(), ['app', '--directory', $this->root]); + + $project = $this->root . '/app'; + + mkdir($project . '/vendor'); + file_put_contents($project . '/vendor/autoload.php', "safeDeleteDirectory($project . '/public'); + + // CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", ".htrouter.php"] + preg_match('/^CMD (\[.+\])$/m', (string) file_get_contents($project . '/resources/docker/Dockerfile'), $cmd); + + // "${APP_PORT:-8080}:8080": the variable and its default. + preg_match('/"\$\{(\w+):-(\d+)\}:\d+"/', (string) file_get_contents($project . '/docker-compose.yml'), $port); + + /** @var list $arguments */ + $arguments = json_decode($cmd[1] ?? '', true, 512, JSON_THROW_ON_ERROR); + + // The arguments after the address: the document root and the router. + $tail = array_slice($arguments, 3); + $variable = $port[1] ?? ''; + $runner = new FakeRunner(); + + file_put_contents($project . '/.env', $variable . "=9124\n"); + $this->handleDirectly(new ServeCommand($runner), ['--directory', $project]); + + unlink($project . '/.env'); + $this->handleDirectly(new ServeCommand($runner), ['--directory', $project]); + + putenv($variable . '=9123'); + $this->handleDirectly(new ServeCommand($runner), ['--directory', $project]); + + $this->assertSame( + [ + [[PHP_BINARY, '-S', '127.0.0.1:9124', ...$tail], $project], + [[PHP_BINARY, '-S', '127.0.0.1:' . ($port[2] ?? ''), ...$tail], $project], + [[PHP_BINARY, '-S', '127.0.0.1:9123', ...$tail], $project], + ], + $runner->calls + ); + } + + public function testTheDefaultPortIs8080(): void + { + $runner = new FakeRunner(); + + $status = $this->handleDirectly(new ServeCommand($runner), ['--directory', $this->root]); + + $this->assertSame(0, $status); + $this->assertSame([[$this->argv('127.0.0.1:8080'), $this->root]], $runner->calls); + } + + public function testTheDirectoryOptionWinsOverCrestPhp(): void + { + // Run from a project root that has crest.php. --directory names + // another project, and serve runs there. + file_put_contents($this->root . '/crest.php', "project($this->root . '/src'); + chdir($this->root); + + $runner = new FakeRunner(); + + $this->handleDirectly(new ServeCommand($runner), ['--directory', $this->root . '/src']); + + $this->assertSame([[$this->argv('127.0.0.1:8080'), $this->root . '/src']], $runner->calls); + } + + /** + * @dataProvider envFiles + */ + public function testTheEnvFileCanSetThePort(string $contents, string $address): void + { + // docker-compose.yml publishes APP_PORT from .env. serve uses the + // same port. + file_put_contents($this->root . '/.env', $contents); + + $runner = new FakeRunner(); + + $this->handleDirectly(new ServeCommand($runner), ['--directory', $this->root]); + + $this->assertSame([[$this->argv($address), $this->root]], $runner->calls); + } + + public function testTheEnvironmentComesBeforeTheEnvFile(): void + { + // The same order as docker compose. + putenv('APP_PORT=9001'); + file_put_contents($this->root . '/.env', "APP_PORT=9002\n"); + + $runner = new FakeRunner(); + + $this->handleDirectly(new ServeCommand($runner), ['--directory', $this->root]); + + $this->assertSame([[$this->argv('127.0.0.1:9001'), $this->root]], $runner->calls); + } + + public function testTheExitStatusOfTheServerIsReturned(): void + { + // For example, PHP exits with 1 when the port is in use. + $this->assertSame( + 3, + $this->handleDirectly(new ServeCommand(new FakeRunner(3)), ['--directory', $this->root]) + ); + } + + public function testThePortOptionComesBeforeAppPort(): void + { + putenv('APP_PORT=9001'); + file_put_contents($this->root . '/.env', "APP_PORT=9002\n"); + + $runner = new FakeRunner(); + + $this->handleDirectly(new ServeCommand($runner), ['--port=9003', '--directory', $this->root]); + + $this->assertSame([[$this->argv('127.0.0.1:9003'), $this->root]], $runner->calls); + } + + public function testTheRootIsTheNearestCrestPhpAbove(): void + { + // Run from src/. `up` also works from a subdirectory. + file_put_contents($this->root . '/crest.php', "root . '/src'); + + $runner = new FakeRunner(); + + $this->handleDirectly(new ServeCommand($runner), []); + + $this->assertSame([[$this->argv('127.0.0.1:8080'), $this->root]], $runner->calls); + } + + public function testWithoutCrestPhpTheWorkingDirectoryIsTheRoot(): void + { + // No crest.php here or above: this repository has no crest.php above + // tests/_output. + chdir($this->root); + + $this->assertNull(Locator::locate($this->root)); + + $runner = new FakeRunner(); + + $this->handleDirectly(new ServeCommand($runner), []); + + $this->assertSame([[$this->argv('127.0.0.1:8080'), $this->root]], $runner->calls); + } + + /** + * The argv that serve gives to the runner. + * + * @return list + */ + private function argv(string $address): array + { + return [PHP_BINARY, '-S', $address, '-t', 'public', '.htrouter.php']; + } + + /** + * Writes the two files that serve checks in a project: the router and + * the composer autoloader. + */ + private function project(string $directory): void + { + mkdir($directory . '/vendor'); + file_put_contents($directory . '/' . ServeCommand::ROUTER, " $tokens + */ + private function refusal(array $tokens): string + { + $runner = new FakeRunner(); + + try { + $this->handleDirectly(new ServeCommand($runner), $tokens); + } catch (Exception $exception) { + $this->assertSame([], $runner->calls); + + return $exception->getMessage(); + } + + $this->fail('serve did not stop'); + } +} diff --git a/tests/Unit/CommandsTest.php b/tests/Unit/CommandsTest.php index 66ce51e..e0e298a 100644 --- a/tests/Unit/CommandsTest.php +++ b/tests/Unit/CommandsTest.php @@ -13,6 +13,7 @@ namespace Crest\Tests\Unit; +use Crest\Command\ServeCommand; use Crest\Commands; use PHPUnit\Framework\TestCase; @@ -39,6 +40,7 @@ public function testAliasesAreNotListedAsCommands(): void 'make:responder', 'new', 'route:list', + 'serve', 'stub:publish', 'up', ], @@ -88,4 +90,10 @@ public function testRegistryResolvesTheListAliases(): void $this->assertTrue($registry->has('commands')); $this->assertTrue($registry->has('enumerate')); } + + public function testRegistryResolvesTheServeAlias(): void + { + // devtools answered to `server` too. + $this->assertSame(ServeCommand::class, Commands::registry()->get('server')); + } } From 40bfc1f3ac7fcb4d5ee6592bad849e33c896a76a Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 18:54:16 -0500 Subject: [PATCH 3/4] new stubs Assisted-by: Claude Code --- resources/infection.json5 | 3 +++ resources/stubs/adr/project-htrouter.stub | 3 ++- resources/stubs/adr/project-readme.stub | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/resources/infection.json5 b/resources/infection.json5 index e5be8d9..24fe7a2 100644 --- a/resources/infection.json5 +++ b/resources/infection.json5 @@ -31,6 +31,7 @@ // cannot return false on any path reaching the cast. "Crest\\Generator\\Stub::render", "Crest\\Project\\Config::psr4Map", + "Crest\\Command\\ServeCommand::envValue", // Same guarantee from further off: sources() only ever yields // paths it has confirmed with is_file(), or glob() results. "Crest\\Command\\Stub\\PublishCommand::handle", @@ -41,6 +42,8 @@ // the working directory cannot be read, which the suite // cannot cause. "Crest\\Command\\NewCommand::parent", + // The same reason as NewCommand::parent. + "Crest\\Command\\ServeCommand::root", // Environment branch. See the note on Identical below. "Crest\\Command\\AboutCommand::phalcon" ] diff --git a/resources/stubs/adr/project-htrouter.stub b/resources/stubs/adr/project-htrouter.stub index c98bbb5..f3d4668 100644 --- a/resources/stubs/adr/project-htrouter.stub +++ b/resources/stubs/adr/project-htrouter.stub @@ -1,7 +1,8 @@ Date: Fri, 25 Sep 2026 18:54:29 -0500 Subject: [PATCH 4/4] updating docs Assisted-by: Claude Code --- CHANGELOG.md | 1 + docs/index.md | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4d50a..188aefc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Added `Crest\Process\Runner`, the seam through which commands run external programs, with `ShellRunner` as the default. A missing program or working directory is reported as a crest error. [#8](https://github.com/phalcon/crest/issues/8) - Added `Crest\Generator\ClassName::namespace()`, validating a namespace with the same identifier rule as a class name. [#8](https://github.com/phalcon/crest/issues/8) - Added `Crest\Command\Make\NamedArtifactCommand`, the base of `make:command`, `make:middleware`, `make:provider` and `make:responder`. The four commands repeated the same `handle()` and `define()`; each now gives only its key, its suffix, its description, an example name and the instructions it prints after the file is written. The base declares the `name` argument and the `--force` option, because `handle()` reads both, so a new generator cannot leave them out. +- Added `serve` (alias `server`), running PHP's built-in web server in the project root with the router script that `new` writes: `php -S 127.0.0.1:8080 -t public .htrouter.php`, with the same router and document root as the generated container. The port is `--port`, else `APP_PORT` from the environment or the project `.env`, else 8080, the order that docker compose uses. It finds the root from a subdirectory through the nearest `crest.php`, and it does not need the crest in `vendor/`. It stops before PHP starts when `.htrouter.php` or `vendor/autoload.php` is missing. `new` and the generated README now print `crest serve` for the host way. [#10](https://github.com/phalcon/crest/issues/10) ### Changed diff --git a/docs/index.md b/docs/index.md index 0e70eb1..4c258fd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,7 @@ for the same listing from the tool itself. | `make:responder` | create an ADR responder | | `new` | create an ADR project | | `route:list` | every route the application answers | +| `serve` (`server`) | start PHP's built-in web server for the project | | `stub:publish` | copy packaged stubs into the project for editing | | `up` | start the project containers | @@ -55,6 +56,29 @@ next steps: `crest down` stops and removes the containers. `up --build` rebuilds the image first, and `down --volumes` also removes the named volumes. +Without docker, run `composer install`, then `crest serve`. It runs +`php -S 127.0.0.1:8080 -t public .htrouter.php` in the project root, with the +same router and document root as the container. The port is `--port`, else +`APP_PORT` from the environment, else `APP_PORT` in the project `.env`, else +8080. docker compose reads `APP_PORT` in the same order. `serve` reads `.env` +as docker compose does: the last `APP_PORT` line wins, and `export`, +`APP_PORT: `, quotes and `#` comments are allowed. It does not expand +`${...}`. Such a value stops `serve` with an error. `serve` finds the root +from a subdirectory, and it does not need the crest in `vendor/`, so the +global crest runs it. It stops before PHP starts if `.htrouter.php` or +`vendor/autoload.php` is missing. + +The server uses the PHP that runs crest. PHP options on the command line, for +example `-d extension=phalcon.so`, do not reach it. Put such settings in +`php.ini`. + +Press Ctrl+C to stop the server. If the server continues to run, for example +after crest was stopped in a different way, stop its PHP process: + + pkill -f -- '-S 127.0.0.1:8080' + +Use your port if it is not 8080. + `new`, `up`, `down` and `install` run before the project has a `vendor/`. Run them with a crest outside the project, for example one that you install with `composer global require phalcon/crest`. @@ -78,7 +102,10 @@ publish with no name leaves the project stubs out, because they do nothing inside a project. You can change each project stub on its own, but `project-front`, -`project-config` and `project-index` must agree with each other. Do not change +`project-config` and `project-index` must agree with each other. Also, +`project-dockerfile`, `project-compose` and `project-env` must agree with +`serve`: keep `-t public .htrouter.php` in the `CMD`, and keep `APP_PORT`, +with the default 8080, as the port variable. Do not change the name of the front controller class `AppFront` or the paths of the generated files, because `new` does not read them from the stubs. A published copy must use only the placeholders of the packaged copy. If a placeholder has