From 6a69c29755ca0d225c10376fada8796c0e6a096f Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:38:00 +0400 Subject: [PATCH 01/11] [#575] Add Environment::fileExists() to ask whether the env file exists Add a non-throwing fileExists() that tells whether the environment file the app is configured for exists. It follows the same resolution as load(): the app_env of shared/config/env.php picks .env or .env.. The resolution is shared with resolveEnvironmentFile() through two small private helpers, and it uses App::getBaseDir() instead of the base_dir() helper, so the method can be asked before the helpers are loaded. load() behaves as before. Co-Authored-By: Claude Sonnet 5.5 --- src/Environment/Environment.php | 36 ++++++++-- ...nvironmentFileExistsWithoutHelpersTest.php | 31 ++++++++ tests/Unit/Environment/EnvironmentTest.php | 71 +++++++++++++++++++ 3 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/Environment/EnvironmentFileExistsWithoutHelpersTest.php diff --git a/src/Environment/Environment.php b/src/Environment/Environment.php index 2f3d4d70..af75becf 100644 --- a/src/Environment/Environment.php +++ b/src/Environment/Environment.php @@ -62,6 +62,21 @@ public function load(): void $this->loaded = true; } + /** + * Checks whether the environment file the app is configured for exists. + * It does not throw and needs no helpers, so it can be asked before the boot. + */ + public function fileExists(): bool + { + $envConfigFile = $this->envConfigFile(); + + if (!file_exists($envConfigFile)) { + return false; + } + + return file_exists(App::getBaseDir() . DS . $this->getEnvFileName($this->readAppEnv($envConfigFile))); + } + /** * Gets the app current environment */ @@ -172,14 +187,13 @@ public function updateRow(string $key, ?string $value): void */ private function resolveEnvironmentFile(): array { - $filePath = base_dir() . DS . 'shared' . DS . 'config' . DS . 'env.php'; + $envConfigFile = $this->envConfigFile(); - if (!file_exists($filePath)) { - throw EnvException::fileNotFound($filePath); + if (!file_exists($envConfigFile)) { + throw EnvException::fileNotFound($envConfigFile); } - $envConfig = require $filePath; - $appEnv = $envConfig['app_env'] ?? Env::PRODUCTION; + $appEnv = $this->readAppEnv($envConfigFile); $envFile = $this->getEnvFileName($appEnv); if (!file_exists(App::getBaseDir() . DS . $envFile)) { @@ -189,6 +203,18 @@ private function resolveEnvironmentFile(): array return [$appEnv, $envFile]; } + private function envConfigFile(): string + { + return App::getBaseDir() . DS . 'shared' . DS . 'config' . DS . 'env.php'; + } + + private function readAppEnv(string $envConfigFile): string + { + $envConfig = require $envConfigFile; + + return $envConfig['app_env'] ?? Env::PRODUCTION; + } + /** * @return array */ diff --git a/tests/Unit/Environment/EnvironmentFileExistsWithoutHelpersTest.php b/tests/Unit/Environment/EnvironmentFileExistsWithoutHelpersTest.php new file mode 100644 index 00000000..b3f826e3 --- /dev/null +++ b/tests/Unit/Environment/EnvironmentFileExistsWithoutHelpersTest.php @@ -0,0 +1,31 @@ +context = $this->createContext(); + } + + public function tearDown(): void + { + $this->clearAppContext(); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testFileExistsWorksBeforeTheHelpersAreLoaded(): void + { + $this->assertFalse(function_exists('base_dir')); + + $this->assertTrue((new Environment())->fileExists()); + } +} diff --git a/tests/Unit/Environment/EnvironmentTest.php b/tests/Unit/Environment/EnvironmentTest.php index b9a13a8f..3982e09b 100644 --- a/tests/Unit/Environment/EnvironmentTest.php +++ b/tests/Unit/Environment/EnvironmentTest.php @@ -75,6 +75,77 @@ public function testLoadDoesNotReadBootstrapAgain(): void } } + public function testFileExistsIsTrueWhenTheEnvironmentFileExists(): void + { + $this->assertFileExists(PROJECT_ROOT . DS . '.env.testing'); + + $this->assertTrue((new Environment())->fileExists()); + } + + public function testFileExistsIsFalseWhenTheEnvironmentFileIsMissing(): void + { + $this->withoutEnvironmentFile(function (): void { + $this->assertFalse((new Environment())->fileExists()); + }); + } + + public function testFileExpectsTheEnvironmentSpecificFileName(): void + { + $this->withoutEnvironmentFile(function (): void { + $plainFile = PROJECT_ROOT . DS . '.env'; + + $this->assertFileDoesNotExist($plainFile); + + try { + $this->createFile($plainFile, "APP_KEY=plain\n"); + + $this->assertFalse((new Environment())->fileExists()); + } finally { + $this->removeFile($plainFile); + } + }); + } + + public function testFileExistsIsFalseWhenTheBootstrapConfigIsMissing(): void + { + $context = App::getContext(); + + try { + App::setContext(new AppContext(PROJECT_ROOT . DS . 'cron-command-tests-empty', $context->getContainer())); + + $this->assertFalse((new Environment())->fileExists()); + } finally { + App::setContext($context); + } + } + + public function testLoadReportsTheMissingEnvironmentFile(): void + { + $this->withoutEnvironmentFile(function (): void { + $this->expectException(EnvException::class); + $this->expectExceptionMessage('.env.testing'); + + (new Environment())->load(); + }); + } + + /** + * Hides the environment file of the test app for the duration of the callback + */ + private function withoutEnvironmentFile(callable $test): void + { + $file = PROJECT_ROOT . DS . '.env.testing'; + $hidden = $file . '.hidden'; + + $this->assertTrue(rename($file, $hidden)); + + try { + $test(); + } finally { + rename($hidden, $file); + } + } + public function testEnvironmentGetValue(): void { $this->assertNull($this->env->getValue('NON_EXISTING_KEY')); From b0e54a1e8eb2f22e330a9781f2f68141d9a76e93 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:43:02 +0400 Subject: [PATCH 02/11] [#575] Let a command declare that it does not need the environment Add a requiresEnvironment property to CliCommand, true by default, with a public requiresEnvironment() getter, and set it to false in EnvCommand, which creates the environment file and must run when there is none. Nothing uses the getter yet; the run stage will in a following commit. Co-Authored-By: Claude Sonnet 5.5 --- src/Console/CliCommand.php | 13 +++++++++++++ src/Console/Commands/EnvCommand.php | 5 +++++ tests/Unit/Console/CliCommandTest.php | 5 +++++ tests/Unit/Console/Commands/EnvCommandTest.php | 5 +++++ 4 files changed, 28 insertions(+) diff --git a/src/Console/CliCommand.php b/src/Console/CliCommand.php index 6a26cb5a..56af49f2 100644 --- a/src/Console/CliCommand.php +++ b/src/Console/CliCommand.php @@ -54,6 +54,11 @@ abstract class CliCommand extends Command implements CommandInterface */ protected array $options = []; + /** + * Whether the command needs the environment file to run + */ + protected bool $requiresEnvironment = true; + protected ?InputInterface $input = null; protected ?OutputInterface $output = null; @@ -74,6 +79,14 @@ public function __construct() } } + /** + * Tells whether the command needs the environment file to run + */ + public function requiresEnvironment(): bool + { + return $this->requiresEnvironment; + } + /** * Returns the argument value for a given argument name. * @return mixed|string diff --git a/src/Console/Commands/EnvCommand.php b/src/Console/Commands/EnvCommand.php index 4dfadf17..64c56cd8 100644 --- a/src/Console/Commands/EnvCommand.php +++ b/src/Console/Commands/EnvCommand.php @@ -46,6 +46,11 @@ class EnvCommand extends CliCommand ['yes', 'y', 'none', 'Acceptance of the confirmation'], ]; + /** + * The command creates the environment file, so it must run when there is none + */ + protected bool $requiresEnvironment = false; + /** * Executes the command and creates new .env file * @throws BaseException diff --git a/tests/Unit/Console/CliCommandTest.php b/tests/Unit/Console/CliCommandTest.php index ddfa9222..c280091e 100644 --- a/tests/Unit/Console/CliCommandTest.php +++ b/tests/Unit/Console/CliCommandTest.php @@ -36,6 +36,11 @@ public function testCliCommandMetadataIsConfigured(): void $this->assertSame('Used only for core command discovery tests', $this->command->getHelp()); } + public function testCliCommandRequiresTheEnvironmentByDefault(): void + { + $this->assertTrue($this->command->requiresEnvironment()); + } + public function testCliCommandArgumentsAndOptionsAreRegistered(): void { $definition = $this->command->getDefinition(); diff --git a/tests/Unit/Console/Commands/EnvCommandTest.php b/tests/Unit/Console/Commands/EnvCommandTest.php index 6fc9979b..be03b39d 100644 --- a/tests/Unit/Console/Commands/EnvCommandTest.php +++ b/tests/Unit/Console/Commands/EnvCommandTest.php @@ -27,6 +27,11 @@ public function testCommandMetadata(): void $this->assertSame('The command will generate new .env file from .env.example', $this->command->getHelp()); } + public function testCommandDoesNotRequireTheEnvironment(): void + { + $this->assertFalse($this->command->requiresEnvironment()); + } + public function testExecShowsErrorWhenEnvExampleMissing(): void { $envExample = base_dir() . DS . '.env.example'; From 5cb58c645aae4d57cef14d6169c8884646ad4ef7 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:10:29 +0400 Subject: [PATCH 03/11] [#575] Stop commands that need the environment when the env file is missing After the unknown command guard, RunCommandStage now asks the resolved command. A CliCommand that requires the environment is stopped with the same EnvException the boot raises when the environment file does not exist, before anything runs. Commands that declare they do not need it, such as core:env, and plain Symfony commands, such as list and help, still run. Co-Authored-By: Claude Sonnet 5.5 --- src/App/Stages/Console/RunCommandStage.php | 12 ++- .../Stages/Console/RunCommandStageTest.php | 96 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/App/Stages/Console/RunCommandStage.php b/src/App/Stages/Console/RunCommandStage.php index fb8cc28c..3ea0987c 100644 --- a/src/App/Stages/Console/RunCommandStage.php +++ b/src/App/Stages/Console/RunCommandStage.php @@ -12,12 +12,15 @@ use Quantum\App\Contracts\ConsoleStageInterface; use Quantum\App\Adapters\Console\ConsoleContext; +use Quantum\Environment\Environment; +use Quantum\Console\CliCommand; use Exception; /** * Class RunCommandStage * - * Runs the requested command; a missing or unknown command is rejected before anything runs. + * Runs the requested command; a missing or unknown command is rejected before anything runs, + * and so is a command that needs the environment file when there is none. * * @package Quantum\App */ @@ -34,6 +37,13 @@ public function process(ConsoleContext $context): void throw new Exception("Command `$commandName` is not defined"); } + $command = $context->getApplication()->get($commandName); + $environment = new Environment(); + + if ($command instanceof CliCommand && $command->requiresEnvironment() && !$environment->fileExists()) { + $environment->load(); + } + $context->setExitCode( $context->getApplication()->run($context->getInput(), $context->getOutput()) ); diff --git a/tests/Unit/App/Stages/Console/RunCommandStageTest.php b/tests/Unit/App/Stages/Console/RunCommandStageTest.php index c823b510..5b04c934 100644 --- a/tests/Unit/App/Stages/Console/RunCommandStageTest.php +++ b/tests/Unit/App/Stages/Console/RunCommandStageTest.php @@ -3,11 +3,13 @@ namespace Quantum\Tests\Unit\App\Stages\Console; use Symfony\Component\Console\Output\OutputInterface; +use Quantum\Environment\Exceptions\EnvException; use Symfony\Component\Console\Input\InputInterface; use Quantum\App\Stages\Console\RunCommandStage; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Application; use Quantum\App\Enums\ExitCode; +use Quantum\Console\CliCommand; use Exception; class RunCommandStageTest extends ConsoleStageTestCase @@ -83,6 +85,100 @@ public function testRunCommandStageRunsNothingWhenTheCommandNameIsMissing(): voi $this->assertSame('', $context->getOutput()->fetch()); } + public function testRunCommandStageRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMissing(): void + { + $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true)); + + $this->withoutEnvironmentFile(function () use ($context): void { + try { + (new RunCommandStage())->process($context); + + $this->fail('The command must not run without the environment file.'); + } catch (EnvException $e) { + $this->assertStringContainsString('.env.testing', $e->getMessage()); + } + }); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + $this->assertSame('', $context->getOutput()->fetch()); + } + + public function testRunCommandStageRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMissing(): void + { + $context = $this->createConsoleContext(['command' => 'opt:out'], $this->createCliApplication('opt:out', false)); + + $this->withoutEnvironmentFile(function () use ($context): void { + (new RunCommandStage())->process($context); + }); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + $this->assertStringContainsString('ran opt:out', $context->getOutput()->fetch()); + } + + public function testRunCommandStageRunsAPlainSymfonyCommandWhenTheEnvFileIsMissing(): void + { + $context = $this->createConsoleContext(['command' => 'run:ok'], $this->createApplication('run:ok', 0)); + + $this->withoutEnvironmentFile(function () use ($context): void { + (new RunCommandStage())->process($context); + }); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + $this->assertStringContainsString('hello from run:ok', $context->getOutput()->fetch()); + } + + public function testRunCommandStageRunsACommandThatRequiresTheEnvironmentWhenTheEnvFileExists(): void + { + $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true)); + + (new RunCommandStage())->process($context); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + $this->assertStringContainsString('ran needs:env', $context->getOutput()->fetch()); + } + + /** + * An application with one CliCommand that does or does not require the environment + */ + private function createCliApplication(string $commandName, bool $requiresEnvironment): Application + { + $application = new Application(); + + $application->add(new class ($commandName, $requiresEnvironment) extends CliCommand { + public function __construct(string $commandName, bool $requiresEnvironment) + { + $this->name = $commandName; + $this->requiresEnvironment = $requiresEnvironment; + + parent::__construct(); + } + + public function exec(): void + { + $this->output('ran ' . $this->name); + } + }); + + return $application; + } + + /** + * Hides the environment file of the test app for the duration of the callback + */ + private function withoutEnvironmentFile(callable $test): void + { + $file = PROJECT_ROOT . DS . '.env.testing'; + $hidden = $file . '.hidden'; + + $this->assertTrue(rename($file, $hidden)); + + try { + $test(); + } finally { + rename($hidden, $file); + } + } + private function createApplication(string $commandName, int $exitCode): Application { $application = new Application(); From e0dec6ff8755bee04617cd655bbab0439de8bd5d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:42:58 +0400 Subject: [PATCH 04/11] [#575] Pin the console boot behavior before changing how it is decided Add characterization tests for the boot done by the console adapter: an unknown command gets the full boot, core:env with an option still gets the reduced boot, the environment is mutable after a full boot, and the Symfony application is named from the app config after a full boot and UNKNOWN after the reduced boot. They pass on the current code and guard the following change that decides the boot by the environment file instead of the command name. The boot for a missing command name is deliberately not pinned, because that behavior is meant to change later. Co-Authored-By: Claude Sonnet 5.5 --- .../Console/ConsoleAppAdapterTest.php | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 0be1b04a..c6a5ffbe 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -8,6 +8,7 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\Tracer\ErrorHandler; use Quantum\Di\Di; +use ReflectionProperty; use Exception; use Mockery; @@ -212,6 +213,72 @@ public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommand(): void $this->assertFalse(config()->has('app')); } + public function testConsoleAppAdapterBootsAllStagesForUnknownCommand(): void + { + $_SERVER['argv'] = ['qt', 'unknown:command', '--quiet']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(Di::isRegistered(ErrorHandler::class)); + $this->assertTrue(config()->has('app')); + } + + public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommandWithOptions(): void + { + $_SERVER['argv'] = ['qt', 'core:env', '--yes']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertFalse(Di::has(Environment::class)); + $this->assertFalse(Di::isRegistered(ErrorHandler::class)); + $this->assertFalse(config()->has('app')); + } + + public function testConsoleAppAdapterMakesEnvironmentMutableForRegularCommands(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $property = new ReflectionProperty(Environment::class, 'isMutable'); + $property->setAccessible(true); + + $this->assertTrue($property->getValue(environment())); + } + + public function testConsoleAppAdapterNamesTheApplicationFromTheAppConfigAfterTheFullBoot(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $adapter = new ConsoleAppAdapter($this->createContext()); + + $this->assertSame(config()->get('app.name'), $this->applicationOf($adapter)->getName()); + $this->assertSame(config()->get('app.version'), $this->applicationOf($adapter)->getVersion()); + $this->assertNotSame('UNKNOWN', $this->applicationOf($adapter)->getName()); + } + + public function testConsoleAppAdapterNamesTheApplicationUnknownAfterTheReducedBoot(): void + { + $_SERVER['argv'] = ['qt', 'core:env']; + + $adapter = new ConsoleAppAdapter($this->createContext()); + + $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getName()); + $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getVersion()); + } + + /** + * The Symfony Application the adapter built while booting + */ + private function applicationOf(ConsoleAppAdapter $adapter): Application + { + $property = new ReflectionProperty(ConsoleAppAdapter::class, 'application'); + $property->setAccessible(true); + + return $property->getValue($adapter); + } + /** * Starts the adapter with a real Symfony Application (no auto exit, no exception catching), * so the registered command is really executed and its exit code comes from Symfony. From 9e0e5470f84e1dd38dc5ccc8a9b73f7013f59bf6 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:53:51 +0400 Subject: [PATCH 05/11] [#575] Decide the console boot by the environment file, not by the command name The console adapter no longer compares the first argument with core:env. It asks Environment::fileExists() once and uses the answer for both the boot stages and the mutable environment step: with the environment file the app boots fully, whatever the command; without it only the helpers are loaded, so core:env can create the file. Commands that need the environment are stopped by RunCommandStage with the usual EnvException, and list and help now work without the file. Behavior change: core:env now gets the full boot when the environment file exists. Three tests used core:env with the file present as the trigger for the reduced boot (BootsReducedStagesForCoreEnvCommand, ...WithOptions and NamesTheApplicationUnknownAfterTheReducedBoot); they keep their assertions and now hide the environment file instead. New tests cover the full boot of core:env with the file, the reduced boot of any command without it, and start() with and without the file. Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/Console/ConsoleAppAdapter.php | 7 +- .../Console/ConsoleAppAdapterTest.php | 116 +++++++++++++++--- 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index 7d511613..e6cac908 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -21,6 +21,7 @@ use Quantum\App\Stages\Boot\LoadAppConfigStage; use Quantum\App\Stages\Boot\LoadHelpersStage; use Quantum\App\Contracts\AppInterface; +use Quantum\Environment\Environment; use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; @@ -51,13 +52,13 @@ public function __construct(AppContext $context) $this->input = new ArgvInput(); $this->output = new ConsoleOutput(); - $commandName = $this->input->getFirstArgument(); + $environmentFileExists = (new Environment())->fileExists(); $stages = [ new LoadHelpersStage(), ]; - if ($commandName !== 'core:env') { + if ($environmentFileExists) { $stages[] = new LoadEnvironmentStage(); $stages[] = new LoadAppConfigStage(); $stages[] = new SetupErrorHandlerStage(); @@ -66,7 +67,7 @@ public function __construct(AppContext $context) $pipeline = new BootPipeline($stages); $pipeline->run($this->context); - if ($commandName !== 'core:env') { + if ($environmentFileExists) { environment()->setMutable(true); } diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index c6a5ffbe..b2444e86 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -2,6 +2,7 @@ namespace Quantum\Tests\Unit\App\Adapters\Console; +use Quantum\Environment\Exceptions\EnvException; use Quantum\App\Adapters\Console\ConsoleAppAdapter; use Symfony\Component\Console\Application; use Quantum\Environment\Environment; @@ -202,15 +203,41 @@ public function testConsoleAppAdapterBootsAllStagesForRegularCommands(): void $this->assertTrue(config()->has('app')); } - public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommand(): void + public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommandWhenTheEnvFileIsMissing(): void + { + $_SERVER['argv'] = ['qt', 'core:env']; + + $this->withoutEnvironmentFile(function (): void { + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertFalse(Di::has(Environment::class)); + $this->assertFalse(Di::isRegistered(ErrorHandler::class)); + $this->assertFalse(config()->has('app')); + }); + } + + public function testConsoleAppAdapterBootsAllStagesForCoreEnvCommandWhenTheEnvFileExists(): void { $_SERVER['argv'] = ['qt', 'core:env']; $this->consoleAppAdapter->__construct($this->createContext()); - $this->assertFalse(Di::has(Environment::class)); - $this->assertFalse(Di::isRegistered(ErrorHandler::class)); - $this->assertFalse(config()->has('app')); + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(Di::isRegistered(ErrorHandler::class)); + $this->assertTrue(config()->has('app')); + } + + public function testConsoleAppAdapterBootsReducedStagesForAnyCommandWhenTheEnvFileIsMissing(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->withoutEnvironmentFile(function (): void { + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertFalse(Di::has(Environment::class)); + $this->assertFalse(Di::isRegistered(ErrorHandler::class)); + $this->assertFalse(config()->has('app')); + }); } public function testConsoleAppAdapterBootsAllStagesForUnknownCommand(): void @@ -224,15 +251,17 @@ public function testConsoleAppAdapterBootsAllStagesForUnknownCommand(): void $this->assertTrue(config()->has('app')); } - public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommandWithOptions(): void + public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommandWithOptionsWhenTheEnvFileIsMissing(): void { $_SERVER['argv'] = ['qt', 'core:env', '--yes']; - $this->consoleAppAdapter->__construct($this->createContext()); + $this->withoutEnvironmentFile(function (): void { + $this->consoleAppAdapter->__construct($this->createContext()); - $this->assertFalse(Di::has(Environment::class)); - $this->assertFalse(Di::isRegistered(ErrorHandler::class)); - $this->assertFalse(config()->has('app')); + $this->assertFalse(Di::has(Environment::class)); + $this->assertFalse(Di::isRegistered(ErrorHandler::class)); + $this->assertFalse(config()->has('app')); + }); } public function testConsoleAppAdapterMakesEnvironmentMutableForRegularCommands(): void @@ -262,10 +291,65 @@ public function testConsoleAppAdapterNamesTheApplicationUnknownAfterTheReducedBo { $_SERVER['argv'] = ['qt', 'core:env']; - $adapter = new ConsoleAppAdapter($this->createContext()); + $this->withoutEnvironmentFile(function (): void { + $adapter = new ConsoleAppAdapter($this->createContext()); - $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getName()); - $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getVersion()); + $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getName()); + $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getVersion()); + }); + } + + public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMissing(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + $this->withoutEnvironmentFile(function (): void { + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->expectException(EnvException::class); + + $this->consoleAppAdapter->start(); + }); + }); + } + + public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMissing(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withoutEnvironmentFile(function () use ($commandName): void { + $this->assertSame(0, $this->startWithRealApplication($commandName)); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + }, null, false); + } + + public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMissing(): void + { + $this->withoutEnvironmentFile(function (): void { + $this->assertSame(0, $this->startWithRealApplication('list')); + }); + } + + /** + * Hides the environment file of the test app for the duration of the callback + */ + private function withoutEnvironmentFile(callable $test): void + { + $file = PROJECT_ROOT . DS . '.env.testing'; + $hidden = $file . '.hidden'; + + $this->assertTrue(rename($file, $hidden)); + + try { + $test(); + } finally { + rename($hidden, $file); + } } /** @@ -330,13 +414,15 @@ private function registeredCommandNames(): array * Creates a temporary command class in the given directory, loads it, runs the test * with the command name, and removes the file and any directories it had to create. * The command records that it ran, and from which namespace, in $GLOBALS['tmpCommandRan'] and - * $GLOBALS['tmpCommandSource']. A fixed command name creates two commands with the same name. + * $GLOBALS['tmpCommandSource']. A fixed command name creates two commands with the same name, + * and the command can declare that it does not require the environment. */ - private function withTemporaryCommand(string $directory, string $namespace, callable $test, ?string $name = null): void + private function withTemporaryCommand(string $directory, string $namespace, callable $test, ?string $name = null, bool $requiresEnvironment = true): void { $suffix = substr(md5(uniqid('', true)), 0, 8); $className = 'TmpCommand' . $suffix; $commandName = $name ?? 'tmp:command' . $suffix; + $requires = $requiresEnvironment ? 'true' : 'false'; $command = << Date: Wed, 14 Oct 2026 15:25:46 +0400 Subject: [PATCH 06/11] [#575] Make core:env look for the existing .env in the base dir The overwrite check of core:env used the relative path .env, which PHP resolves against the current working directory, while the copy always went to the base dir. Running the command from another directory therefore skipped the confirmation and silently overwrote the project's existing .env. The check now uses the same base dir path as the copy. Co-Authored-By: Claude Sonnet 5.5 --- src/Console/Commands/EnvCommand.php | 2 +- .../Unit/Console/Commands/EnvCommandTest.php | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/Console/Commands/EnvCommand.php b/src/Console/Commands/EnvCommand.php index 64c56cd8..171bf7ea 100644 --- a/src/Console/Commands/EnvCommand.php +++ b/src/Console/Commands/EnvCommand.php @@ -66,7 +66,7 @@ public function exec(): void $this->error('.env.example file not found'); } - if ($fs->exists('.env') && !$this->getOption('yes') && !$this->confirm('The operation will overwrite values of the existing .env and will create new one from .env.example. Continue?')) { + if ($fs->exists(App::getBaseDir() . DS . '.env') && !$this->getOption('yes') && !$this->confirm('The operation will overwrite values of the existing .env and will create new one from .env.example. Continue?')) { $this->info('Operation was canceled!'); return; } diff --git a/tests/Unit/Console/Commands/EnvCommandTest.php b/tests/Unit/Console/Commands/EnvCommandTest.php index be03b39d..717b7b00 100644 --- a/tests/Unit/Console/Commands/EnvCommandTest.php +++ b/tests/Unit/Console/Commands/EnvCommandTest.php @@ -2,7 +2,9 @@ namespace Quantum\Tests\Unit\Console\Commands; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Console\Helper\HelperSet; use Quantum\Console\Commands\EnvCommand; use Quantum\Tests\Unit\AppTestCase; @@ -32,6 +34,66 @@ public function testCommandDoesNotRequireTheEnvironment(): void $this->assertFalse($this->command->requiresEnvironment()); } + public function testExecAsksForConfirmationWhenTheBaseDirEnvFileExists(): void + { + $envFile = base_dir() . DS . '.env'; + $workingDirectory = getcwd(); + + $this->assertFileDoesNotExist($envFile); + + try { + $this->createFile($envFile, "ORIGINAL=1\n"); + + // A different working directory shows that the command looks at the base dir and not at the current dir + chdir(sys_get_temp_dir()); + + $this->command->setHelperSet(new HelperSet(['question' => new QuestionHelper()])); + $this->tester->setInputs(['no']); + $this->tester->execute([]); + + $this->assertStringContainsString('Operation was canceled!', $this->tester->getDisplay()); + $this->assertSame("ORIGINAL=1\n", file_get_contents($envFile)); + } finally { + chdir($workingDirectory); + $this->removeFile($envFile); + } + } + + public function testExecOverwritesTheBaseDirEnvFileWithoutAskingWhenYesIsGiven(): void + { + $envFile = base_dir() . DS . '.env'; + + $this->assertFileDoesNotExist($envFile); + + try { + $this->createFile($envFile, "ORIGINAL=1\n"); + + $this->tester->execute(['--yes' => true]); + + $this->assertStringContainsString('.env is successfully generated', $this->tester->getDisplay()); + $this->assertSame(file_get_contents(base_dir() . DS . '.env.example'), file_get_contents($envFile)); + } finally { + $this->removeFile($envFile); + } + } + + public function testExecCreatesTheEnvFileWithoutAskingWhenThereIsNone(): void + { + $envFile = base_dir() . DS . '.env'; + + $this->assertFileDoesNotExist($envFile); + + try { + $this->tester->execute([]); + + $this->assertStringContainsString('.env is successfully generated', $this->tester->getDisplay()); + $this->assertFileExists($envFile); + $this->assertSame(file_get_contents(base_dir() . DS . '.env.example'), file_get_contents($envFile)); + } finally { + $this->removeFile($envFile); + } + } + public function testExecShowsErrorWhenEnvExampleMissing(): void { $envExample = base_dir() . DS . '.env.example'; From 3650e729fe8a0c89b39b6fb137ad783bf7023dcb Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:27:30 +0400 Subject: [PATCH 07/11] [#575] Let the console context carry a boot failure The console run will continue after a failed boot and decide per command whether the failure matters, so the context needs to hold it. The new constructor argument is optional, so existing callers are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/App/Adapters/Console/ConsoleContext.php | 15 ++++++++++++++- .../App/Adapters/Console/ConsoleContextTest.php | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/App/Adapters/Console/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php index c97cbe68..469624bb 100644 --- a/src/App/Adapters/Console/ConsoleContext.php +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Application; use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; +use Throwable; /** * Class ConsoleContext @@ -34,18 +35,22 @@ class ConsoleContext private OutputInterface $output; + private ?Throwable $bootFailure; + private int $exitCode = ExitCode::SUCCESS; public function __construct( AppContext $appContext, Application $application, InputInterface $input, - OutputInterface $output + OutputInterface $output, + ?Throwable $bootFailure = null ) { $this->appContext = $appContext; $this->application = $application; $this->input = $input; $this->output = $output; + $this->bootFailure = $bootFailure; } public function getAppContext(): AppContext @@ -68,6 +73,14 @@ public function getOutput(): OutputInterface return $this->output; } + /** + * The failure of the boot that the console run continued after, if there was one + */ + public function getBootFailure(): ?Throwable + { + return $this->bootFailure; + } + public function getExitCode(): int { return $this->exitCode; diff --git a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php index ece86dae..e756aeb0 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php @@ -10,6 +10,7 @@ use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; use Quantum\App\App; +use RuntimeException; class ConsoleContextTest extends AppTestCase { @@ -47,6 +48,20 @@ public function testConsoleContextExposesApplicationInputAndOutput(): void $this->assertSame($this->output, $this->consoleContext->getOutput()); } + public function testConsoleContextHasNoBootFailureByDefault(): void + { + $this->assertNull($this->consoleContext->getBootFailure()); + } + + public function testConsoleContextExposesTheBootFailure(): void + { + $failure = new RuntimeException('The boot failed'); + + $consoleContext = new ConsoleContext($this->appContext, $this->application, $this->input, $this->output, $failure); + + $this->assertSame($failure, $consoleContext->getBootFailure()); + } + public function testConsoleContextDefaultsExitCodeToSuccess(): void { $this->assertSame(ExitCode::SUCCESS, $this->consoleContext->getExitCode()); From 16c9f23488d09cf703c3308ea0d8a8d36613d838 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:31:27 +0400 Subject: [PATCH 08/11] [#575] Boot the console optimistically and enforce a boot failure per command Deciding the boot by the env file could not repair a broken .env: an empty or malformed file got the full boot, which failed, so `core:env --yes` was locked out where the old name check could still repair it. The adapter now runs the whole boot in a try, keeps the failure and continues. RunCommandStage re-throws that failure only for a command that requires the environment, so `core:env`, `list` and `help` run in a failed boot and every other command reports the original error. start() throws the recorded failure when anything else fails first (for example a command constructor). The application starts as UNKNOWN and is named from the app config after a successful boot, and the environment becomes mutable only then. Replaced on purpose: the three tests that pinned the reduced boot for a missing env file, and the RunCommandStage tests that pinned the file-missing guard; both now describe the recorded boot failure. Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/Console/ConsoleAppAdapter.php | 54 ++-- src/App/Stages/Console/RunCommandStage.php | 11 +- .../Console/ConsoleAppAdapterTest.php | 258 ++++++++++++++++-- .../Stages/Console/ConsoleStageTestCase.php | 5 +- .../Stages/Console/RunCommandStageTest.php | 66 ++--- 5 files changed, 292 insertions(+), 102 deletions(-) diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index e6cac908..b220de65 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -13,18 +13,18 @@ use Quantum\App\Stages\Console\RegisterCoreCommandsStage; use Quantum\App\Stages\Console\RegisterAppCommandsStage; use Symfony\Component\Console\Output\ConsoleOutput; -use Quantum\App\Stages\Console\RunCommandStage; -use Symfony\Component\Console\Input\ArgvInput; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; use Quantum\App\Stages\Boot\LoadEnvironmentStage; -use Symfony\Component\Console\Application; +use Quantum\App\Stages\Console\RunCommandStage; +use Symfony\Component\Console\Input\ArgvInput; use Quantum\App\Stages\Boot\LoadAppConfigStage; use Quantum\App\Stages\Boot\LoadHelpersStage; +use Symfony\Component\Console\Application; use Quantum\App\Contracts\AppInterface; -use Quantum\Environment\Environment; use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; +use Throwable; use Exception; if (!defined('DS')) { @@ -37,6 +37,8 @@ */ class ConsoleAppAdapter implements AppInterface { + private const UNKNOWN = 'UNKNOWN'; + protected AppContext $context; protected ArgvInput $input; @@ -45,6 +47,8 @@ class ConsoleAppAdapter implements AppInterface protected Application $application; + protected ?Throwable $bootFailure = null; + public function __construct(AppContext $context) { $this->context = $context; @@ -52,29 +56,25 @@ public function __construct(AppContext $context) $this->input = new ArgvInput(); $this->output = new ConsoleOutput(); - $environmentFileExists = (new Environment())->fileExists(); - - $stages = [ - new LoadHelpersStage(), - ]; + $this->application = $this->createApplication(self::UNKNOWN, self::UNKNOWN); - if ($environmentFileExists) { - $stages[] = new LoadEnvironmentStage(); - $stages[] = new LoadAppConfigStage(); - $stages[] = new SetupErrorHandlerStage(); - } + try { + $pipeline = new BootPipeline([ + new LoadHelpersStage(), + new LoadEnvironmentStage(), + new LoadAppConfigStage(), + new SetupErrorHandlerStage(), + ]); - $pipeline = new BootPipeline($stages); - $pipeline->run($this->context); + $pipeline->run($this->context); - if ($environmentFileExists) { environment()->setMutable(true); - } - $this->application = $this->createApplication( - config()->get('app.name', 'UNKNOWN'), - config()->get('app.version', 'UNKNOWN') - ); + $this->application->setName(config()->get('app.name') ?: self::UNKNOWN); + $this->application->setVersion(config()->get('app.version') ?: self::UNKNOWN); + } catch (Throwable $exception) { + $this->bootFailure = $exception; + } } public function createApplication(string $name, string $version): Application @@ -83,11 +83,11 @@ public function createApplication(string $name, string $version): Application } /** - * @throws Exception + * @throws Exception|Throwable */ public function start(): ?int { - $context = new ConsoleContext($this->context, $this->application, $this->input, $this->output); + $context = new ConsoleContext($this->context, $this->application, $this->input, $this->output, $this->bootFailure); $pipeline = new ConsolePipeline([ new RegisterCoreCommandsStage(), @@ -95,7 +95,11 @@ public function start(): ?int new RunCommandStage(), ]); - $pipeline->run($context); + try { + $pipeline->run($context); + } catch (Throwable $exception) { + throw $this->bootFailure ?? $exception; + } return $context->getExitCode() ?: ExitCode::SUCCESS; } diff --git a/src/App/Stages/Console/RunCommandStage.php b/src/App/Stages/Console/RunCommandStage.php index 3ea0987c..facb4d52 100644 --- a/src/App/Stages/Console/RunCommandStage.php +++ b/src/App/Stages/Console/RunCommandStage.php @@ -12,22 +12,22 @@ use Quantum\App\Contracts\ConsoleStageInterface; use Quantum\App\Adapters\Console\ConsoleContext; -use Quantum\Environment\Environment; use Quantum\Console\CliCommand; use Exception; +use Throwable; /** * Class RunCommandStage * * Runs the requested command; a missing or unknown command is rejected before anything runs, - * and so is a command that needs the environment file when there is none. + * and so is a command that needs the environment after the boot failed. * * @package Quantum\App */ class RunCommandStage implements ConsoleStageInterface { /** - * @throws Exception + * @throws Exception|Throwable */ public function process(ConsoleContext $context): void { @@ -38,10 +38,9 @@ public function process(ConsoleContext $context): void } $command = $context->getApplication()->get($commandName); - $environment = new Environment(); - if ($command instanceof CliCommand && $command->requiresEnvironment() && !$environment->fileExists()) { - $environment->load(); + if ($context->getBootFailure() !== null && $command instanceof CliCommand && $command->requiresEnvironment()) { + throw $context->getBootFailure(); } $context->setExitCode( diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index b2444e86..149c3dd7 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -2,14 +2,17 @@ namespace Quantum\Tests\Unit\App\Adapters\Console; -use Quantum\Environment\Exceptions\EnvException; use Quantum\App\Adapters\Console\ConsoleAppAdapter; +use Quantum\Environment\Exceptions\EnvException; use Symfony\Component\Console\Application; +use Dotenv\Exception\InvalidFileException; use Quantum\Environment\Environment; use Quantum\Tests\Unit\AppTestCase; use Quantum\Tracer\ErrorHandler; use Quantum\Di\Di; use ReflectionProperty; +use RuntimeException; +use Throwable; use Exception; use Mockery; @@ -203,31 +206,38 @@ public function testConsoleAppAdapterBootsAllStagesForRegularCommands(): void $this->assertTrue(config()->has('app')); } - public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommandWhenTheEnvFileIsMissing(): void + public function testConsoleAppAdapterBootsAllStagesForCoreEnvCommandWhenTheEnvFileExists(): void { $_SERVER['argv'] = ['qt', 'core:env']; - $this->withoutEnvironmentFile(function (): void { - $this->consoleAppAdapter->__construct($this->createContext()); + $this->consoleAppAdapter->__construct($this->createContext()); - $this->assertFalse(Di::has(Environment::class)); - $this->assertFalse(Di::isRegistered(ErrorHandler::class)); - $this->assertFalse(config()->has('app')); - }); + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(Di::isRegistered(ErrorHandler::class)); + $this->assertTrue(config()->has('app')); } - public function testConsoleAppAdapterBootsAllStagesForCoreEnvCommandWhenTheEnvFileExists(): void + public function testConsoleAppAdapterRecordsNoBootFailureAfterASuccessfulBoot(): void { - $_SERVER['argv'] = ['qt', 'core:env']; + $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->consoleAppAdapter->__construct($this->createContext()); - $this->assertTrue(Di::has(Environment::class)); - $this->assertTrue(Di::isRegistered(ErrorHandler::class)); - $this->assertTrue(config()->has('app')); + $this->assertNull($this->bootFailureOf($this->consoleAppAdapter)); } - public function testConsoleAppAdapterBootsReducedStagesForAnyCommandWhenTheEnvFileIsMissing(): void + public function testConsoleAppAdapterRecordsTheBootFailureInsteadOfThrowingWhenTheEnvFileIsMissing(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->withoutEnvironmentFile(function (): void { + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertInstanceOf(EnvException::class, $this->bootFailureOf($this->consoleAppAdapter)); + }); + } + + public function testConsoleAppAdapterDoesNotMakeTheEnvironmentMutableAfterAFailedBoot(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; @@ -235,11 +245,24 @@ public function testConsoleAppAdapterBootsReducedStagesForAnyCommandWhenTheEnvFi $this->consoleAppAdapter->__construct($this->createContext()); $this->assertFalse(Di::has(Environment::class)); - $this->assertFalse(Di::isRegistered(ErrorHandler::class)); - $this->assertFalse(config()->has('app')); }); } + public function testConsoleAppAdapterNamesTheApplicationUnknownWhenTheAppNameIsEmpty(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $context = $this->createContext(); + + config()->set('app', ['name' => null, 'version' => null]); + + $adapter = new ConsoleAppAdapter($context); + + $this->assertNull($this->bootFailureOf($adapter)); + $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getName()); + $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getVersion()); + } + public function testConsoleAppAdapterBootsAllStagesForUnknownCommand(): void { $_SERVER['argv'] = ['qt', 'unknown:command', '--quiet']; @@ -251,19 +274,6 @@ public function testConsoleAppAdapterBootsAllStagesForUnknownCommand(): void $this->assertTrue(config()->has('app')); } - public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommandWithOptionsWhenTheEnvFileIsMissing(): void - { - $_SERVER['argv'] = ['qt', 'core:env', '--yes']; - - $this->withoutEnvironmentFile(function (): void { - $this->consoleAppAdapter->__construct($this->createContext()); - - $this->assertFalse(Di::has(Environment::class)); - $this->assertFalse(Di::isRegistered(ErrorHandler::class)); - $this->assertFalse(config()->has('app')); - }); - } - public function testConsoleAppAdapterMakesEnvironmentMutableForRegularCommands(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; @@ -287,7 +297,7 @@ public function testConsoleAppAdapterNamesTheApplicationFromTheAppConfigAfterThe $this->assertNotSame('UNKNOWN', $this->applicationOf($adapter)->getName()); } - public function testConsoleAppAdapterNamesTheApplicationUnknownAfterTheReducedBoot(): void + public function testConsoleAppAdapterNamesTheApplicationUnknownAfterAFailedBoot(): void { $_SERVER['argv'] = ['qt', 'core:env']; @@ -335,6 +345,184 @@ public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMissing(): voi }); } + public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMalformed(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($commandName): void { + $adapter = $this->bootRealAdapter($commandName); + + $this->assertSame(0, $adapter->start()); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + }, null, false); + } + + public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsEmpty(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withEnvironmentFileContent('', function () use ($commandName): void { + $adapter = $this->bootRealAdapter($commandName); + + $this->assertSame(0, $adapter->start()); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + }, null, false); + } + + public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMalformed(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($commandName): void { + $adapter = $this->bootRealAdapter($commandName); + + try { + $adapter->start(); + $this->fail('The command must not run when the environment file is malformed.'); + } catch (InvalidFileException $exception) { + $this->assertArrayNotHasKey('tmpCommandRan', $GLOBALS); + } + }); + }); + } + + public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMalformed(): void + { + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function (): void { + $this->assertSame(0, $this->bootRealAdapter('list')->start()); + }); + } + + public function testConsoleAppAdapterStartReportsTheBootFailureForAnUnknownCommandWhenTheEnvFileIsMalformed(): void + { + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function (): void { + $adapter = $this->bootRealAdapter('unknown:command'); + + $this->expectException(InvalidFileException::class); + + $adapter->start(); + }); + } + + public function testConsoleAppAdapterStartReportsTheBootFailureForAnUnknownCommandWhenTheEnvFileIsMissing(): void + { + $this->withoutEnvironmentFile(function (): void { + $adapter = $this->bootRealAdapter('unknown:command'); + + $this->expectException(EnvException::class); + + $adapter->start(); + }); + } + + public function testConsoleAppAdapterStartReportsTheBootFailureWhenACommandCannotBeCreatedAfterAFailedBoot(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withBrokenCommand($directory, function (): void { + $this->withoutEnvironmentFile(function (): void { + $adapter = $this->bootRealAdapter('list'); + + $this->expectException(EnvException::class); + + $adapter->start(); + }); + }); + } + + public function testConsoleAppAdapterStartReportsTheCommandFailureWhenACommandCannotBeCreatedAfterASuccessfulBoot(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withBrokenCommand($directory, function (): void { + $adapter = $this->bootRealAdapter('list'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The command cannot be created'); + + $adapter->start(); + }); + } + + /** + * Replaces the content of the environment file of the test app for the duration of the callback + */ + private function withEnvironmentFileContent(string $content, callable $test): void + { + $file = PROJECT_ROOT . DS . '.env.testing'; + $backup = $file . '.backup'; + + $this->assertTrue(rename($file, $backup)); + + try { + $this->assertNotFalse(file_put_contents($file, $content)); + + $test(); + } finally { + if (is_file($file)) { + unlink($file); + } + + rename($backup, $file); + } + } + + /** + * Boots the real adapter, with its own createApplication(), for the command and keeps + * the Symfony Application from exiting and from catching exceptions, so start() can be asserted on + */ + private function bootRealAdapter(string $commandName): ConsoleAppAdapter + { + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + $adapter = new ConsoleAppAdapter($this->createContext()); + + $application = $this->applicationOf($adapter); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + return $adapter; + } + + /** + * Puts an app command whose constructor throws into the directory for the duration of the test + */ + private function withBrokenCommand(string $directory, callable $test): void + { + $className = 'TmpBrokenCommand' . substr(md5(uniqid('', true)), 0, 8); + + $command = <<withTemporaryFile($directory, $className, $command, $test); + } + /** * Hides the environment file of the test app for the duration of the callback */ @@ -352,6 +540,16 @@ private function withoutEnvironmentFile(callable $test): void } } + /** + * The failure the adapter recorded while booting, if there was one + */ + private function bootFailureOf(ConsoleAppAdapter $adapter): ?Throwable + { + $property = new ReflectionProperty(ConsoleAppAdapter::class, 'bootFailure'); + $property->setAccessible(true); + + return $property->getValue($adapter); + } /** * The Symfony Application the adapter built while booting */ diff --git a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php index 37ac4232..410c6563 100644 --- a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php +++ b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php @@ -8,6 +8,7 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\App; +use Throwable; abstract class ConsoleStageTestCase extends AppTestCase { @@ -15,13 +16,13 @@ abstract class ConsoleStageTestCase extends AppTestCase * Creates a console context around a real Symfony application that neither exits nor catches exceptions. * @param array $input */ - protected function createConsoleContext(array $input = [], ?Application $application = null): ConsoleContext + protected function createConsoleContext(array $input = [], ?Application $application = null, ?Throwable $bootFailure = null): ConsoleContext { $application ??= new Application(); $application->setAutoExit(false); $application->setCatchExceptions(false); - return new ConsoleContext(App::getContext(), $application, new ArrayInput($input), new BufferedOutput()); + return new ConsoleContext(App::getContext(), $application, new ArrayInput($input), new BufferedOutput(), $bootFailure); } /** diff --git a/tests/Unit/App/Stages/Console/RunCommandStageTest.php b/tests/Unit/App/Stages/Console/RunCommandStageTest.php index 5b04c934..9daf8a8b 100644 --- a/tests/Unit/App/Stages/Console/RunCommandStageTest.php +++ b/tests/Unit/App/Stages/Console/RunCommandStageTest.php @@ -3,13 +3,13 @@ namespace Quantum\Tests\Unit\App\Stages\Console; use Symfony\Component\Console\Output\OutputInterface; -use Quantum\Environment\Exceptions\EnvException; use Symfony\Component\Console\Input\InputInterface; use Quantum\App\Stages\Console\RunCommandStage; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Application; use Quantum\App\Enums\ExitCode; use Quantum\Console\CliCommand; +use RuntimeException; use Exception; class RunCommandStageTest extends ConsoleStageTestCase @@ -85,49 +85,54 @@ public function testRunCommandStageRunsNothingWhenTheCommandNameIsMissing(): voi $this->assertSame('', $context->getOutput()->fetch()); } - public function testRunCommandStageRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMissing(): void + public function testRunCommandStageRejectsUnknownCommandWhenTheBootFailed(): void { - $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true)); + $context = $this->createConsoleContext(['command' => 'unknown:command'], $this->createApplication('run:ok', 0), new RuntimeException('The boot failed')); - $this->withoutEnvironmentFile(function () use ($context): void { - try { - (new RunCommandStage())->process($context); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Command `unknown:command` is not defined'); - $this->fail('The command must not run without the environment file.'); - } catch (EnvException $e) { - $this->assertStringContainsString('.env.testing', $e->getMessage()); - } - }); + (new RunCommandStage())->process($context); + } + + public function testRunCommandStageRejectsACommandThatRequiresTheEnvironmentWhenTheBootFailed(): void + { + $failure = new RuntimeException('The boot failed'); + $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true), $failure); + + try { + (new RunCommandStage())->process($context); + + $this->fail('The command must not run after a failed boot.'); + } catch (RuntimeException $e) { + $this->assertSame($failure, $e); + } $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); $this->assertSame('', $context->getOutput()->fetch()); } - public function testRunCommandStageRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMissing(): void + public function testRunCommandStageRunsACommandThatDoesNotRequireTheEnvironmentWhenTheBootFailed(): void { - $context = $this->createConsoleContext(['command' => 'opt:out'], $this->createCliApplication('opt:out', false)); + $context = $this->createConsoleContext(['command' => 'opt:out'], $this->createCliApplication('opt:out', false), new RuntimeException('The boot failed')); - $this->withoutEnvironmentFile(function () use ($context): void { - (new RunCommandStage())->process($context); - }); + (new RunCommandStage())->process($context); $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); $this->assertStringContainsString('ran opt:out', $context->getOutput()->fetch()); } - public function testRunCommandStageRunsAPlainSymfonyCommandWhenTheEnvFileIsMissing(): void + public function testRunCommandStageRunsAPlainSymfonyCommandWhenTheBootFailed(): void { - $context = $this->createConsoleContext(['command' => 'run:ok'], $this->createApplication('run:ok', 0)); + $context = $this->createConsoleContext(['command' => 'run:ok'], $this->createApplication('run:ok', 0), new RuntimeException('The boot failed')); - $this->withoutEnvironmentFile(function () use ($context): void { - (new RunCommandStage())->process($context); - }); + (new RunCommandStage())->process($context); $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); $this->assertStringContainsString('hello from run:ok', $context->getOutput()->fetch()); } - public function testRunCommandStageRunsACommandThatRequiresTheEnvironmentWhenTheEnvFileExists(): void + public function testRunCommandStageRunsACommandThatRequiresTheEnvironmentWhenTheBootSucceeded(): void { $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true)); @@ -162,23 +167,6 @@ public function exec(): void return $application; } - /** - * Hides the environment file of the test app for the duration of the callback - */ - private function withoutEnvironmentFile(callable $test): void - { - $file = PROJECT_ROOT . DS . '.env.testing'; - $hidden = $file . '.hidden'; - - $this->assertTrue(rename($file, $hidden)); - - try { - $test(); - } finally { - rename($hidden, $file); - } - } - private function createApplication(string $commandName, int $exitCode): Application { $application = new Application(); From b4535a84acac85e639c4a76e7fe7eaab3c872b52 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:35:27 +0400 Subject: [PATCH 09/11] [#575] Remove Environment::fileExists(), nothing decides by it any more The console no longer asks whether the env file exists: it boots, keeps the failure and decides per command. fileExists(), its two private helpers, the getBaseDir() switch in resolveEnvironmentFile() and the tests that came with them are removed, so the Environment package is back to what master has. Co-Authored-By: Claude Sonnet 5.5 --- src/Environment/Environment.php | 36 ++-------- ...nvironmentFileExistsWithoutHelpersTest.php | 31 -------- tests/Unit/Environment/EnvironmentTest.php | 71 ------------------- 3 files changed, 5 insertions(+), 133 deletions(-) delete mode 100644 tests/Unit/Environment/EnvironmentFileExistsWithoutHelpersTest.php diff --git a/src/Environment/Environment.php b/src/Environment/Environment.php index af75becf..2f3d4d70 100644 --- a/src/Environment/Environment.php +++ b/src/Environment/Environment.php @@ -62,21 +62,6 @@ public function load(): void $this->loaded = true; } - /** - * Checks whether the environment file the app is configured for exists. - * It does not throw and needs no helpers, so it can be asked before the boot. - */ - public function fileExists(): bool - { - $envConfigFile = $this->envConfigFile(); - - if (!file_exists($envConfigFile)) { - return false; - } - - return file_exists(App::getBaseDir() . DS . $this->getEnvFileName($this->readAppEnv($envConfigFile))); - } - /** * Gets the app current environment */ @@ -187,13 +172,14 @@ public function updateRow(string $key, ?string $value): void */ private function resolveEnvironmentFile(): array { - $envConfigFile = $this->envConfigFile(); + $filePath = base_dir() . DS . 'shared' . DS . 'config' . DS . 'env.php'; - if (!file_exists($envConfigFile)) { - throw EnvException::fileNotFound($envConfigFile); + if (!file_exists($filePath)) { + throw EnvException::fileNotFound($filePath); } - $appEnv = $this->readAppEnv($envConfigFile); + $envConfig = require $filePath; + $appEnv = $envConfig['app_env'] ?? Env::PRODUCTION; $envFile = $this->getEnvFileName($appEnv); if (!file_exists(App::getBaseDir() . DS . $envFile)) { @@ -203,18 +189,6 @@ private function resolveEnvironmentFile(): array return [$appEnv, $envFile]; } - private function envConfigFile(): string - { - return App::getBaseDir() . DS . 'shared' . DS . 'config' . DS . 'env.php'; - } - - private function readAppEnv(string $envConfigFile): string - { - $envConfig = require $envConfigFile; - - return $envConfig['app_env'] ?? Env::PRODUCTION; - } - /** * @return array */ diff --git a/tests/Unit/Environment/EnvironmentFileExistsWithoutHelpersTest.php b/tests/Unit/Environment/EnvironmentFileExistsWithoutHelpersTest.php deleted file mode 100644 index b3f826e3..00000000 --- a/tests/Unit/Environment/EnvironmentFileExistsWithoutHelpersTest.php +++ /dev/null @@ -1,31 +0,0 @@ -context = $this->createContext(); - } - - public function tearDown(): void - { - $this->clearAppContext(); - } - - /** - * @runInSeparateProcess - * @preserveGlobalState disabled - */ - public function testFileExistsWorksBeforeTheHelpersAreLoaded(): void - { - $this->assertFalse(function_exists('base_dir')); - - $this->assertTrue((new Environment())->fileExists()); - } -} diff --git a/tests/Unit/Environment/EnvironmentTest.php b/tests/Unit/Environment/EnvironmentTest.php index 3982e09b..b9a13a8f 100644 --- a/tests/Unit/Environment/EnvironmentTest.php +++ b/tests/Unit/Environment/EnvironmentTest.php @@ -75,77 +75,6 @@ public function testLoadDoesNotReadBootstrapAgain(): void } } - public function testFileExistsIsTrueWhenTheEnvironmentFileExists(): void - { - $this->assertFileExists(PROJECT_ROOT . DS . '.env.testing'); - - $this->assertTrue((new Environment())->fileExists()); - } - - public function testFileExistsIsFalseWhenTheEnvironmentFileIsMissing(): void - { - $this->withoutEnvironmentFile(function (): void { - $this->assertFalse((new Environment())->fileExists()); - }); - } - - public function testFileExpectsTheEnvironmentSpecificFileName(): void - { - $this->withoutEnvironmentFile(function (): void { - $plainFile = PROJECT_ROOT . DS . '.env'; - - $this->assertFileDoesNotExist($plainFile); - - try { - $this->createFile($plainFile, "APP_KEY=plain\n"); - - $this->assertFalse((new Environment())->fileExists()); - } finally { - $this->removeFile($plainFile); - } - }); - } - - public function testFileExistsIsFalseWhenTheBootstrapConfigIsMissing(): void - { - $context = App::getContext(); - - try { - App::setContext(new AppContext(PROJECT_ROOT . DS . 'cron-command-tests-empty', $context->getContainer())); - - $this->assertFalse((new Environment())->fileExists()); - } finally { - App::setContext($context); - } - } - - public function testLoadReportsTheMissingEnvironmentFile(): void - { - $this->withoutEnvironmentFile(function (): void { - $this->expectException(EnvException::class); - $this->expectExceptionMessage('.env.testing'); - - (new Environment())->load(); - }); - } - - /** - * Hides the environment file of the test app for the duration of the callback - */ - private function withoutEnvironmentFile(callable $test): void - { - $file = PROJECT_ROOT . DS . '.env.testing'; - $hidden = $file . '.hidden'; - - $this->assertTrue(rename($file, $hidden)); - - try { - $test(); - } finally { - rename($hidden, $file); - } - } - public function testEnvironmentGetValue(): void { $this->assertNull($this->env->getValue('NON_EXISTING_KEY')); From 58840f435771913dbe2e75d630f7af6d35974cd5 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:44:07 +0400 Subject: [PATCH 10/11] [#575] Add the changelog entries for the console boot change Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e2b781..1a41c472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ The format is based on Keep a Changelog. - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) ### Changed +- The console boot no longer decides by the `core:env` command name (#575): + - `ConsoleAppAdapter` always runs the full boot and, when it fails, keeps the failure instead of stopping. `RunCommandStage` re-throws that original failure only for a command that requires the environment, so `core:env`, `list`, and `help` still run and `core:env --yes` can repair a missing, empty, or malformed `.env`. The reduced boot for `core:env` is gone, and the Symfony `Application` is named `UNKNOWN` until a successful boot names it from the app config. + - Added `CliCommand::$requiresEnvironment` (default `true`) with `CliCommand::requiresEnvironment()`; a command that works without the environment, like `EnvCommand`, sets it to `false`. `ConsoleContext` takes an optional boot failure and exposes it with `getBootFailure()`. - **BREAKING:** Refactored the web and console runtime into staged pipelines (#537): - `WebAppAdapter::start()` now runs a `RequestPipeline` of the preflight, route resolution, not found, prepare, dispatch, and send stages with a `RequestContext`, and `ConsoleAppAdapter::start()` runs a `ConsolePipeline` of the register core commands, register app commands, and run stages with a `ConsoleContext`; the run stage rejects a missing or unknown command before running anything, and registration goes through the new `Quantum\Console\CommandRegistrar`. Behavior is unchanged, and runtime stage events are not dispatched yet. - Moved the web adapter, context, and pipeline to `Quantum\App\Adapters\Web`, the console ones to `Quantum\App\Adapters\Console`, and the boot stages to `Quantum\App\Stages\Boot`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports. @@ -34,6 +37,9 @@ The format is based on Keep a Changelog. - **BREAKING:** Reshaped Lang configuration so `lang.default` now selects the adapter, locale fallback moved to `lang.default_locale`, and the unused `lang.enabled` toggle was removed (#533) - **BREAKING:** Removed `Lang::isEnabled()` from the public Lang API because it no longer affected runtime behavior (#533) +### Fixed +- `core:env` now looks for the existing `.env` in the application base directory, so running it from another working directory asks for the confirmation instead of silently overwriting the file (#575) + ## [3.0.3] - 2026-07-10 ### Changed From dc8a0dbf78aa101edad0c270cf9b3a007043e5a7 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:16:16 +0400 Subject: [PATCH 11/11] [#575] Keep an empty app name and version instead of replacing them The application is named from the app config after a successful boot, and `?:` also replaced an empty string or '0' with UNKNOWN. `??` still covers the null name that used to crash the console and keeps every other value. The two tests that set the app config before building the adapter now load the helpers themselves, so they also pass when run alone. Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/Console/ConsoleAppAdapter.php | 4 ++-- .../Console/ConsoleAppAdapterTest.php | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index b220de65..ed9153ae 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -70,8 +70,8 @@ public function __construct(AppContext $context) environment()->setMutable(true); - $this->application->setName(config()->get('app.name') ?: self::UNKNOWN); - $this->application->setVersion(config()->get('app.version') ?: self::UNKNOWN); + $this->application->setName(config()->get('app.name') ?? self::UNKNOWN); + $this->application->setVersion(config()->get('app.version') ?? self::UNKNOWN); } catch (Throwable $exception) { $this->bootFailure = $exception; } diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 149c3dd7..8c568de5 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -4,11 +4,13 @@ use Quantum\App\Adapters\Console\ConsoleAppAdapter; use Quantum\Environment\Exceptions\EnvException; +use Quantum\App\Stages\Boot\LoadHelpersStage; use Symfony\Component\Console\Application; use Dotenv\Exception\InvalidFileException; use Quantum\Environment\Environment; use Quantum\Tests\Unit\AppTestCase; use Quantum\Tracer\ErrorHandler; +use Quantum\App\BootPipeline; use Quantum\Di\Di; use ReflectionProperty; use RuntimeException; @@ -254,6 +256,8 @@ public function testConsoleAppAdapterNamesTheApplicationUnknownWhenTheAppNameIsE $context = $this->createContext(); + (new BootPipeline([new LoadHelpersStage()]))->run($context); + config()->set('app', ['name' => null, 'version' => null]); $adapter = new ConsoleAppAdapter($context); @@ -263,6 +267,23 @@ public function testConsoleAppAdapterNamesTheApplicationUnknownWhenTheAppNameIsE $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getVersion()); } + public function testConsoleAppAdapterKeepsAnEmptyAppNameAndVersionFromTheConfig(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $context = $this->createContext(); + + (new BootPipeline([new LoadHelpersStage()]))->run($context); + + config()->set('app', ['name' => '', 'version' => '0']); + + $adapter = new ConsoleAppAdapter($context); + + $this->assertNull($this->bootFailureOf($adapter)); + $this->assertSame('', $this->applicationOf($adapter)->getName()); + $this->assertSame('0', $this->applicationOf($adapter)->getVersion()); + } + public function testConsoleAppAdapterBootsAllStagesForUnknownCommand(): void { $_SERVER['argv'] = ['qt', 'unknown:command', '--quiet'];