From d7e44d66e55c15197f85b25eb51d8b44fc78f9f0 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 06:43:06 +1000 Subject: [PATCH 01/12] [#2846] Extracted Vortex project detection into 'Project::isVortex()'. Option lookups in 'OptionsResolver::resolve()' now tolerate an absent key so verbs can define only the options they offer. --- .vortex/cli/src/Utils/OptionsResolver.php | 12 +++--- .vortex/cli/src/Utils/Project.php | 36 +++++++++++++++++ .vortex/cli/tests/Unit/ProjectTest.php | 47 +++++++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 .vortex/cli/src/Utils/Project.php create mode 100644 .vortex/cli/tests/Unit/ProjectTest.php diff --git a/.vortex/cli/src/Utils/OptionsResolver.php b/.vortex/cli/src/Utils/OptionsResolver.php index 0d6c2f94c..d88edcac8 100644 --- a/.vortex/cli/src/Utils/OptionsResolver.php +++ b/.vortex/cli/src/Utils/OptionsResolver.php @@ -62,8 +62,10 @@ public static function resolve(array $options): array { $config = Config::fromString($config_json); - $config->setQuiet($options['quiet']); - $config->setNoInteraction($options['no-interaction']); + // Verbs define only the options they offer, so every lookup below tolerates + // an absent key rather than assuming the install command's full set. + $config->setQuiet((bool) ($options['quiet'] ?? FALSE)); + $config->setNoInteraction((bool) ($options['no-interaction'] ?? FALSE)); // Set root directory to resolve relative paths. $root = !empty($options['root']) && is_scalar($options['root']) ? strval($options['root']) : NULL; @@ -109,7 +111,7 @@ public static function resolve(array $options): array { } // Check if the project is a Vortex project. - $config->set(Config::IS_VORTEX_PROJECT, File::contains($config->getDst() . DIRECTORY_SEPARATOR . 'README.md', '/badge\/Vortex-/')); + $config->set(Config::IS_VORTEX_PROJECT, Project::isVortex((string) $config->getDst())); // Flag to proceed with installation. If FALSE - the installation will only // print resolved values and will not proceed. @@ -148,10 +150,10 @@ public static function resolve(array $options): array { } // Set no-cleanup flag. - $config->set(Config::NO_CLEANUP, (bool) $options['no-cleanup']); + $config->set(Config::NO_CLEANUP, (bool) ($options['no-cleanup'] ?? FALSE)); // Set build-now flag. - $config->set(Config::BUILD_NOW, (bool) $options['build']); + $config->set(Config::BUILD_NOW, (bool) ($options['build'] ?? FALSE)); return [$config, $artifact]; } diff --git a/.vortex/cli/src/Utils/Project.php b/.vortex/cli/src/Utils/Project.php new file mode 100644 index 000000000..36391dc6e --- /dev/null +++ b/.vortex/cli/src/Utils/Project.php @@ -0,0 +1,36 @@ +assertSame($expected, Project::isVortex($trailing_separator ? $dir . DIRECTORY_SEPARATOR : $dir)); + } + + /** + * Data provider for testIsVortex(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderIsVortex(): \Iterator { + yield 'no README' => [NULL, FALSE]; + yield 'empty README' => ['', FALSE]; + yield 'unrelated README' => ['# My project', FALSE]; + yield 'badge present' => ['[![Vortex](https://img.shields.io/badge/Vortex-1.40.0-blue.svg)](https://www.vortextemplate.com/)', TRUE]; + yield 'badge with development version' => ['![badge/Vortex-develop-blue]', TRUE]; + yield 'similarly named badge' => ['[![Coverage](https://img.shields.io/badge/Coverage-100%25-green.svg)]', FALSE]; + yield 'badge present with trailing separator' => ['![badge/Vortex-1.0.0-blue]', TRUE, TRUE]; + } + + public function testIsVortexOnMissingDirectory(): void { + $this->assertFalse(Project::isVortex(self::$tmp . '/does_not_exist_' . uniqid())); + } + +} From b7b5392b624bf00e471909d5f8021a22e3a53c7a Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 06:46:36 +1000 Subject: [PATCH 02/12] [#2846] Renamed the 'check-requirements' command to 'doctor'. The class moves across rather than being rewritten, so every reported behaviour survives: the four checked tools, the installed-and-running distinction, per-tool install instructions, the version summary and '--only'. Two tests pin that surface so it cannot quietly shrink. --- .vortex/cli/src/Command/BuildCommand.php | 4 +- ...uirementsCommand.php => DoctorCommand.php} | 16 +- .../Functional/Command/BuildCommandTest.php | 18 +- ...sCommandTest.php => DoctorCommandTest.php} | 184 ++++++++++++------ .../Functional/Command/InstallCommandTest.php | 58 +++--- .vortex/cli/tests/Helpers/TuiOutput.php | 68 +++---- .vortex/cli/vortex | 4 +- 7 files changed, 212 insertions(+), 140 deletions(-) rename .vortex/cli/src/Command/{CheckRequirementsCommand.php => DoctorCommand.php} (93%) rename .vortex/cli/tests/Functional/Command/{CheckRequirementsCommandTest.php => DoctorCommandTest.php} (69%) diff --git a/.vortex/cli/src/Command/BuildCommand.php b/.vortex/cli/src/Command/BuildCommand.php index ae3deda4f..112b1f603 100644 --- a/.vortex/cli/src/Command/BuildCommand.php +++ b/.vortex/cli/src/Command/BuildCommand.php @@ -75,11 +75,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int label: 'Checking requirements', action: function (): bool { $command_runner = $this->getCommandRunner()->disableLog(); - $command_runner->run('check-requirements', [], ['--no-summary' => '1']); + $command_runner->run('doctor', [], ['--no-summary' => '1']); return $command_runner->getExitCode() === RunnerInterface::EXIT_SUCCESS; }, - failure: 'Missing requirements. Run: ./vortex.phar check-requirements', + failure: 'Missing requirements. Run: ./vortex.phar doctor', streaming: TRUE, ); diff --git a/.vortex/cli/src/Command/CheckRequirementsCommand.php b/.vortex/cli/src/Command/DoctorCommand.php similarity index 93% rename from .vortex/cli/src/Command/CheckRequirementsCommand.php rename to .vortex/cli/src/Command/DoctorCommand.php index 135141214..5c30a0a5a 100644 --- a/.vortex/cli/src/Command/CheckRequirementsCommand.php +++ b/.vortex/cli/src/Command/DoctorCommand.php @@ -18,9 +18,13 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Check requirements command. + * Doctor command. + * + * Diagnoses the local environment: reports which tools a Vortex project needs, + * whether each is installed and running, and how to install the ones that are + * not. Read-only - it never changes the project. */ -class CheckRequirementsCommand extends Command implements ProcessRunnerAwareInterface, ExecutableFinderAwareInterface { +class DoctorCommand extends Command implements ProcessRunnerAwareInterface, ExecutableFinderAwareInterface { use ProcessRunnerAwareTrait; use ExecutableFinderAwareTrait; @@ -50,7 +54,7 @@ class CheckRequirementsCommand extends Command implements ProcessRunnerAwareInte * * @var string */ - public static $defaultName = 'check-requirements'; + public static $defaultName = 'doctor'; /** * Present tools. @@ -75,9 +79,9 @@ class CheckRequirementsCommand extends Command implements ProcessRunnerAwareInte * {@inheritdoc} */ protected function configure(): void { - $this->setName('check-requirements'); - $this->setDescription('Check if required tools are installed and running.'); - $this->setHelp('Checks for Docker, Docker Compose, Ahoy, and Pygmy.'); + $this->setName('doctor'); + $this->setDescription('Diagnose the local environment for common problems.'); + $this->setHelp('Checks that Docker, Docker Compose, Ahoy and Pygmy are installed and running, and reports how to install the ones that are missing.'); $this->addDestinationOption(); $this->addOption(static::OPTION_ONLY, 'o', InputOption::VALUE_REQUIRED, sprintf('Comma-separated list of requirements to check. Available: %s.', implode(', ', static::REQUIREMENTS))); $this->addOption(static::OPTION_NO_SUMMARY, NULL, InputOption::VALUE_NONE, 'Hide summary with tool versions.'); diff --git a/.vortex/cli/tests/Functional/Command/BuildCommandTest.php b/.vortex/cli/tests/Functional/Command/BuildCommandTest.php index 711a30890..159ff2345 100644 --- a/.vortex/cli/tests/Functional/Command/BuildCommandTest.php +++ b/.vortex/cli/tests/Functional/Command/BuildCommandTest.php @@ -6,7 +6,7 @@ use AlexSkrypnyk\File\File; use DrevOps\VortexCli\Command\BuildCommand; -use DrevOps\VortexCli\Command\CheckRequirementsCommand; +use DrevOps\VortexCli\Command\DoctorCommand; use DrevOps\VortexCli\Logger\FileLoggerInterface; use DrevOps\VortexCli\Runner\ProcessRunner; use DrevOps\VortexCli\Runner\RunnerInterface; @@ -91,7 +91,7 @@ public function testBuildCommand( // Mock setCwd to return runner for method chaining. $build_process_runner->method('setCwd')->willReturn($build_process_runner); - // Always register CheckRequirementsCommand with mocked runner and finder. + // Always register DoctorCommand with mocked runner and finder. // Mock ExecutableFinder. $requirements_finder = $this->createMock(ExecutableFinder::class); $final_finder_callback = $requirements_finder_callback ?? fn(string $name): string => '/usr/bin/' . $name; @@ -128,13 +128,13 @@ public function testBuildCommand( return $final_requirements_callback($current_requirements_command); }); - // Mock ExecutableFinder for CheckRequirementsCommand's ProcessRunner. + // Mock ExecutableFinder for DoctorCommand's ProcessRunner. $requirements_runner->method('getExecutableFinder')->willReturn($requirements_finder); - $check_command = new CheckRequirementsCommand(); - $check_command->setExecutableFinder($requirements_finder); - $check_command->setProcessRunner($requirements_runner); - $this->applicationGet()->add($check_command); + $doctor_command = new DoctorCommand(); + $doctor_command->setExecutableFinder($requirements_finder); + $doctor_command->setProcessRunner($requirements_runner); + $this->applicationGet()->add($doctor_command); // Run build with provided inputs. $this->applicationRun($command_inputs, [], $expect_failure); @@ -177,7 +177,7 @@ public static function dataProviderBuildCommand(): \Iterator { 'output_assertions' => array_merge( TuiOutput::present([ TuiOutput::BUILD_CHECKING_REQUIREMENTS, - TuiOutput::CHECK_REQUIREMENTS_MISSING, + TuiOutput::DOCTOR_MISSING, ]), TuiOutput::absent([ TuiOutput::BUILD_BUILDING_SITE, @@ -194,7 +194,7 @@ public static function dataProviderBuildCommand(): \Iterator { 'output_assertions' => array_merge( TuiOutput::present([ TuiOutput::BUILD_CHECKING_REQUIREMENTS, - TuiOutput::CHECK_REQUIREMENTS_MISSING, + TuiOutput::DOCTOR_MISSING, ]), TuiOutput::absent([ TuiOutput::BUILD_BUILDING_SITE, diff --git a/.vortex/cli/tests/Functional/Command/CheckRequirementsCommandTest.php b/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php similarity index 69% rename from .vortex/cli/tests/Functional/Command/CheckRequirementsCommandTest.php rename to .vortex/cli/tests/Functional/Command/DoctorCommandTest.php index 666fd6838..41d7160fc 100644 --- a/.vortex/cli/tests/Functional/Command/CheckRequirementsCommandTest.php +++ b/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php @@ -5,7 +5,7 @@ namespace DrevOps\VortexCli\Tests\Functional\Command; use AlexSkrypnyk\File\File; -use DrevOps\VortexCli\Command\CheckRequirementsCommand; +use DrevOps\VortexCli\Command\DoctorCommand; use DrevOps\VortexCli\Runner\ProcessRunner; use DrevOps\VortexCli\Runner\RunnerInterface; use DrevOps\VortexCli\Tests\Functional\FunctionalTestCase; @@ -16,16 +16,16 @@ use Symfony\Component\Process\ExecutableFinder; /** - * Functional tests for CheckRequirementsCommand. + * Functional tests for DoctorCommand. */ -#[CoversClass(CheckRequirementsCommand::class)] -class CheckRequirementsCommandTest extends FunctionalTestCase { +#[CoversClass(DoctorCommand::class)] +class DoctorCommandTest extends FunctionalTestCase { /** - * Test check requirements with mocked runner. + * Test diagnostics with mocked runner. */ - #[DataProvider('dataProviderCheckRequirementsCommand')] - public function testCheckRequirementsCommand( + #[DataProvider('dataProviderDoctorCommand')] + public function testDoctorCommand( \Closure $executable_finder_callback, \Closure $exit_code_callback, array $command_inputs, @@ -61,7 +61,7 @@ public function testCheckRequirementsCommand( }); // Create command and inject mocks using setters. - $command = new CheckRequirementsCommand(); + $command = new DoctorCommand(); $command->setExecutableFinder($mock_finder); $command->setProcessRunner($mock_runner); @@ -77,11 +77,11 @@ public function testCheckRequirementsCommand( } /** - * Data provider for testCheckRequirementsCommand. + * Data provider for testDoctorCommand. * * @return \Iterator, expect_failure: bool, output_assertions: array, before?: (\Closure | null)}> */ - public static function dataProviderCheckRequirementsCommand(): \Iterator { + public static function dataProviderDoctorCommand(): \Iterator { yield 'Check all requirements' => [ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name, 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS, @@ -89,8 +89,8 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => FALSE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_ALL_MET, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), [ '* Docker: version 1.0.0', @@ -107,8 +107,8 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => TRUE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_MISSING, - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_MISSING, + TuiOutput::DOCTOR_MISSING_LABEL, ]), [ '* Docker:', @@ -117,8 +117,8 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { '* Pygmy:', ], TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, + TuiOutput::DOCTOR_PRESENT_LABEL, + TuiOutput::DOCTOR_ALL_MET, ]), ), ]; @@ -129,8 +129,8 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => FALSE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_ALL_MET, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), ['* Docker: version 1.0.0'], ['! Ahoy:', '! Pygmy:'], @@ -143,8 +143,8 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => FALSE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_ALL_MET, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), [ '* Docker: version 1.0.0', @@ -160,11 +160,11 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => FALSE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, + TuiOutput::DOCTOR_ALL_MET, ]), TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_PRESENT_LABEL, + TuiOutput::DOCTOR_MISSING_LABEL, ]), ), ]; @@ -175,13 +175,13 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => TRUE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_MISSING, - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_MISSING, + TuiOutput::DOCTOR_MISSING_LABEL, ]), ['* Docker:'], TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_DOCKER_AVAILABLE, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_DOCKER_AVAILABLE, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), ), ]; @@ -192,13 +192,13 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => TRUE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_MISSING, - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_MISSING, + TuiOutput::DOCTOR_MISSING_LABEL, ]), ['* Ahoy:'], TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_AHOY_AVAILABLE, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_AHOY_AVAILABLE, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), ), ]; @@ -209,13 +209,13 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => TRUE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_MISSING, - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_MISSING, + TuiOutput::DOCTOR_MISSING_LABEL, ]), ['* Pygmy:'], TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_PYGMY_RUNNING, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_PYGMY_RUNNING, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), ), ]; @@ -226,12 +226,12 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => FALSE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_ALL_MET, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), ['* Pygmy: version 1.0.0'], TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_MISSING_LABEL, ]), ), ]; @@ -248,12 +248,12 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => FALSE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_ALL_MET, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), ['* Pygmy: version 1.0.0'], TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_MISSING_LABEL, ]), ), ]; @@ -274,13 +274,13 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => TRUE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_MISSING, - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_MISSING, + TuiOutput::DOCTOR_MISSING_LABEL, ]), ['* Pygmy:'], TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_PYGMY_RUNNING, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_PYGMY_RUNNING, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), ), ]; @@ -291,12 +291,12 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => FALSE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_ALL_MET, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), ['* Docker Compose: version 1.0.0'], TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_MISSING_LABEL, ]), ), ]; @@ -313,12 +313,12 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => FALSE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_ALL_MET, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), ['* Docker Compose: version 1.0.0'], TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_MISSING_LABEL, ]), ), ]; @@ -335,13 +335,13 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'expect_failure' => TRUE, 'output_assertions' => array_merge( TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_MISSING, - TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL, + TuiOutput::DOCTOR_MISSING, + TuiOutput::DOCTOR_MISSING_LABEL, ]), ['* Docker Compose:'], TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_DOCKER_COMPOSE_AVAILABLE, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_DOCKER_COMPOSE_AVAILABLE, + TuiOutput::DOCTOR_PRESENT_LABEL, ]), ), ]; @@ -351,7 +351,7 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'command_inputs' => ['--only' => 'invalid'], 'expect_failure' => TRUE, 'output_assertions' => [ - '* ' . TuiOutput::CHECK_REQUIREMENTS_UNKNOWN . ' invalid', + '* ' . TuiOutput::DOCTOR_UNKNOWN . ' invalid', '* Available: docker, docker-compose, ahoy', ], ]; @@ -361,7 +361,7 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'command_inputs' => ['--only' => 'docker,invalid'], 'expect_failure' => TRUE, 'output_assertions' => [ - '* ' . TuiOutput::CHECK_REQUIREMENTS_UNKNOWN . ' invalid', + '* ' . TuiOutput::DOCTOR_UNKNOWN . ' invalid', '* Available: docker, docker-compose, ahoy', ], ]; @@ -370,7 +370,7 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS, 'command_inputs' => [], 'expect_failure' => FALSE, - 'output_assertions' => TuiOutput::present([TuiOutput::CHECK_REQUIREMENTS_ALL_MET]), + 'output_assertions' => TuiOutput::present([TuiOutput::DOCTOR_ALL_MET]), 'before' => function (array $inputs, string $tmp): array { $dir = $tmp . '/valid_dest_' . uniqid(); File::mkdir($dir); @@ -404,4 +404,72 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { ]; } + /** + * Every checked tool reports an install instruction when it is absent. + * + * Pins the reported surface so the diagnostics cannot quietly shrink to a + * bare present/absent list. + */ + public function testReportsInstallInstructionsForEveryMissingTool(): void { + $command = $this->doctorCommand(fn(string $name): ?string => NULL, fn(string $command): int => RunnerInterface::EXIT_COMMAND_NOT_FOUND); + + static::applicationInitFromCommand($command); + $this->applicationRun([], [], TRUE); + + $this->assertSame([], $command->getPresent(), 'No tool should be reported as present when none are installed'); + + $missing = $command->getMissing(); + $this->assertSame(['Docker', 'Docker Compose', 'Ahoy', 'Pygmy'], array_keys($missing), 'Every checked tool should be reported as missing'); + + foreach ($missing as $tool => $instruction) { + $this->assertNotEmpty($instruction, sprintf('Tool "%s" should report an install instruction', $tool)); + } + } + + /** + * A tool that is installed but not running is reported as missing. + * + * Being on PATH is not the same as being usable, and conflating the two + * would hide the most common local failure. + */ + public function testDistinguishesInstalledFromRunning(): void { + $command = $this->doctorCommand( + fn(string $name): string => '/usr/bin/' . $name, + fn(string $command): int => str_contains($command, 'pygmy status') || str_contains($command, 'amazeeio') ? RunnerInterface::EXIT_FAILURE : RunnerInterface::EXIT_SUCCESS, + ); + + static::applicationInitFromCommand($command); + $this->applicationRun(['--only' => 'pygmy'], [], TRUE); + + $this->assertArrayNotHasKey('Pygmy', $command->getPresent(), 'Pygmy on PATH but not running should not be reported as present'); + $this->assertSame(['Pygmy' => 'Run: pygmy up'], $command->getMissing(), 'Pygmy on PATH but not running should be reported as missing'); + } + + /** + * Build a command with the executable finder and process runner mocked. + */ + protected function doctorCommand(\Closure $executable_finder_callback, \Closure $exit_code_callback): DoctorCommand { + $mock_finder = $this->createMock(ExecutableFinder::class); + $mock_finder->method('find')->willReturnCallback(fn(string $name) => $executable_finder_callback($name)); + + $mock_runner = $this->createMock(ProcessRunner::class); + $current_command = ''; + $mock_runner->method('run')->willReturnCallback(function (string $command) use ($mock_runner, &$current_command): MockObject { + $current_command = $command; + return $mock_runner; + }); + $mock_runner->method('getOutput')->willReturn('version 1.0.0'); + // Bound by reference so each assertion sees the command being run, not the + // empty string the runner started with. + $mock_runner->method('getExitCode')->willReturnCallback(function () use ($exit_code_callback, &$current_command) { + return $exit_code_callback($current_command); + }); + + $command = new DoctorCommand(); + $command->setExecutableFinder($mock_finder); + $command->setProcessRunner($mock_runner); + + return $command; + } + } diff --git a/.vortex/cli/tests/Functional/Command/InstallCommandTest.php b/.vortex/cli/tests/Functional/Command/InstallCommandTest.php index 06027af69..224a5b667 100644 --- a/.vortex/cli/tests/Functional/Command/InstallCommandTest.php +++ b/.vortex/cli/tests/Functional/Command/InstallCommandTest.php @@ -6,7 +6,7 @@ use DrevOps\VortexCli\Logger\FileLoggerInterface; use DrevOps\VortexCli\Command\BuildCommand; -use DrevOps\VortexCli\Command\CheckRequirementsCommand; +use DrevOps\VortexCli\Command\DoctorCommand; use DrevOps\VortexCli\Command\InstallCommand; use DrevOps\VortexCli\Downloader\RepositoryDownloader; use DrevOps\VortexCli\Prompts\InstallPresenter; @@ -86,7 +86,7 @@ public function testInstallCommand( // Mock ExecutableFinder for BuildCommand's ProcessRunner. $build_runner->method('getExecutableFinder')->willReturn($executable_finder); - // 3. Mock ProcessRunner for CheckRequirementsCommand. + // 3. Mock ProcessRunner for DoctorCommand. $check_requirements_runner = $this->createMock(ProcessRunner::class); $check_requirements_runner_command = ''; $check_requirements_runner->method('run') @@ -99,7 +99,7 @@ public function testInstallCommand( ->willReturnCallback(function () use ($check_requirements_runner_exit_callback, &$check_requirements_runner_command) { return $check_requirements_runner_exit_callback($check_requirements_runner_command); }); - // Mock ExecutableFinder for CheckRequirementsCommand's ProcessRunner. + // Mock ExecutableFinder for DoctorCommand's ProcessRunner. $check_requirements_runner->method('getExecutableFinder')->willReturn($executable_finder); // Create and configure InstallCommand. @@ -121,10 +121,10 @@ public function testInstallCommand( // Initialize application and register mocked commands. static::applicationInitFromCommand($install_command); - $check_command = new CheckRequirementsCommand(); - $check_command->setExecutableFinder($executable_finder); - $check_command->setProcessRunner($check_requirements_runner); - $this->applicationGet()->add($check_command); + $doctor_command = new DoctorCommand(); + $doctor_command->setExecutableFinder($executable_finder); + $doctor_command->setProcessRunner($check_requirements_runner); + $this->applicationGet()->add($doctor_command); $build_command = new BuildCommand(); $build_command->setProcessRunner($build_runner); @@ -371,7 +371,7 @@ public static function dataProviderInstallCommand(): \Iterator { 'download_should_fail' => TRUE, ]; // ----------------------------------------------------------------------- - // Sub-commands: build with check-requirements. + // Sub-commands: build with doctor. // ----------------------------------------------------------------------- yield 'Install with build flag succeeds' => [ 'command_inputs' => self::tuiOptions([ @@ -380,7 +380,7 @@ public static function dataProviderInstallCommand(): \Iterator { ]), 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command, 'build_runner_exit_callback' => TuiOutput::buildRunnerSuccess(), - 'check_requirements_runner_exit_callback' => TuiOutput::checkRequirementsSuccess(), + 'check_requirements_runner_exit_callback' => TuiOutput::doctorSuccess(), 'expect_failure' => FALSE, 'output_assertions' => [ ...TuiOutput::present([ @@ -416,7 +416,7 @@ public static function dataProviderInstallCommand(): \Iterator { ]), 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command, 'build_runner_exit_callback' => TuiOutput::buildRunnerSuccessProfile(), - 'check_requirements_runner_exit_callback' => TuiOutput::checkRequirementsSuccess(), + 'check_requirements_runner_exit_callback' => TuiOutput::doctorSuccess(), 'expect_failure' => FALSE, 'output_assertions' => [ // Install command output - should be present. @@ -431,15 +431,15 @@ public static function dataProviderInstallCommand(): \Iterator { ]), // Check requirements output - should be present. ...TuiOutput::present([ - TuiOutput::CHECK_REQUIREMENTS_CHECKING_DOCKER, - TuiOutput::CHECK_REQUIREMENTS_CHECKING_DOCKER_COMPOSE, - TuiOutput::CHECK_REQUIREMENTS_CHECKING_AHOY, - TuiOutput::CHECK_REQUIREMENTS_CHECKING_PYGMY, - TuiOutput::CHECK_REQUIREMENTS_DOCKER_AVAILABLE, - TuiOutput::CHECK_REQUIREMENTS_DOCKER_COMPOSE_AVAILABLE, - TuiOutput::CHECK_REQUIREMENTS_AHOY_AVAILABLE, - TuiOutput::CHECK_REQUIREMENTS_PYGMY_RUNNING, - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, + TuiOutput::DOCTOR_CHECKING_DOCKER, + TuiOutput::DOCTOR_CHECKING_DOCKER_COMPOSE, + TuiOutput::DOCTOR_CHECKING_AHOY, + TuiOutput::DOCTOR_CHECKING_PYGMY, + TuiOutput::DOCTOR_DOCKER_AVAILABLE, + TuiOutput::DOCTOR_DOCKER_COMPOSE_AVAILABLE, + TuiOutput::DOCTOR_AHOY_AVAILABLE, + TuiOutput::DOCTOR_PYGMY_RUNNING, + TuiOutput::DOCTOR_ALL_MET, ]), // Build output (profile) - should be present. ...TuiOutput::present([ @@ -469,11 +469,11 @@ public static function dataProviderInstallCommand(): \Iterator { TuiOutput::BUILD_PROVISION_TYPE_DB, TuiOutput::INSTALL_BUILD_FAILED, TuiOutput::INSTALL_EXIT_CODE, - TuiOutput::CHECK_REQUIREMENTS_MISSING, - TuiOutput::CHECK_REQUIREMENTS_DOCKER_MISSING, - TuiOutput::CHECK_REQUIREMENTS_DOCKER_COMPOSE_MISSING, - TuiOutput::CHECK_REQUIREMENTS_AHOY_MISSING, - TuiOutput::CHECK_REQUIREMENTS_PYGMY_NOT_RUNNING, + TuiOutput::DOCTOR_MISSING, + TuiOutput::DOCTOR_DOCKER_MISSING, + TuiOutput::DOCTOR_DOCKER_COMPOSE_MISSING, + TuiOutput::DOCTOR_AHOY_MISSING, + TuiOutput::DOCTOR_PYGMY_NOT_RUNNING, TuiOutput::FOOTER_READY_TO_BUILD, TuiOutput::FOOTER_BUILD_ERRORS, ]), @@ -486,7 +486,7 @@ public static function dataProviderInstallCommand(): \Iterator { ]), 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command, 'build_runner_exit_callback' => TuiOutput::buildRunnerFailure(), - 'check_requirements_runner_exit_callback' => TuiOutput::checkRequirementsSuccess(), + 'check_requirements_runner_exit_callback' => TuiOutput::doctorSuccess(), 'expect_failure' => TRUE, 'output_assertions' => [ ...TuiOutput::present([ @@ -515,14 +515,14 @@ public static function dataProviderInstallCommand(): \Iterator { ]), ], ]; - yield 'Install with build flag and requirements of check-requirements command check fails' => [ + yield 'Install with build flag and requirements of doctor command check fails' => [ 'command_inputs' => self::tuiOptions([ InstallCommand::OPTION_NO_INTERACTION => TRUE, InstallCommand::OPTION_BUILD => TRUE, ]), 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command, 'build_runner_exit_callback' => TuiOutput::buildRunnerSuccess(), - 'check_requirements_runner_exit_callback' => TuiOutput::checkRequirementsFailure(), + 'check_requirements_runner_exit_callback' => TuiOutput::doctorFailure(), 'expect_failure' => TRUE, 'output_assertions' => [ ...TuiOutput::present([ @@ -534,10 +534,10 @@ public static function dataProviderInstallCommand(): \Iterator { TuiOutput::INSTALL_PREPARING_DEMO, TuiOutput::INSTALL_BUILDING, TuiOutput::BUILD_CHECKING_REQUIREMENTS, - TuiOutput::CHECK_REQUIREMENTS_MISSING, + TuiOutput::DOCTOR_MISSING, ]), ...TuiOutput::absent([ - TuiOutput::CHECK_REQUIREMENTS_ALL_MET, + TuiOutput::DOCTOR_ALL_MET, TuiOutput::INSTALL_BUILD_SUCCESS, ]), ], diff --git a/.vortex/cli/tests/Helpers/TuiOutput.php b/.vortex/cli/tests/Helpers/TuiOutput.php index 0434a5a7c..87cfa4ff4 100644 --- a/.vortex/cli/tests/Helpers/TuiOutput.php +++ b/.vortex/cli/tests/Helpers/TuiOutput.php @@ -23,33 +23,33 @@ class TuiOutput { const BUILD_PROVISION_TYPE_PROFILE = '[INFO] Provisioning site from the profile.'; - const CHECK_REQUIREMENTS_CHECKING_DOCKER = 'Checking Docker'; + const DOCTOR_CHECKING_DOCKER = 'Checking Docker'; - const CHECK_REQUIREMENTS_CHECKING_DOCKER_COMPOSE = 'Checking Docker Compose'; + const DOCTOR_CHECKING_DOCKER_COMPOSE = 'Checking Docker Compose'; - const CHECK_REQUIREMENTS_CHECKING_AHOY = 'Checking Ahoy'; + const DOCTOR_CHECKING_AHOY = 'Checking Ahoy'; - const CHECK_REQUIREMENTS_CHECKING_PYGMY = 'Checking Pygmy'; + const DOCTOR_CHECKING_PYGMY = 'Checking Pygmy'; - const CHECK_REQUIREMENTS_DOCKER_AVAILABLE = 'Docker is available'; + const DOCTOR_DOCKER_AVAILABLE = 'Docker is available'; - const CHECK_REQUIREMENTS_DOCKER_MISSING = 'Docker is missing'; + const DOCTOR_DOCKER_MISSING = 'Docker is missing'; - const CHECK_REQUIREMENTS_DOCKER_COMPOSE_AVAILABLE = 'Docker Compose is available'; + const DOCTOR_DOCKER_COMPOSE_AVAILABLE = 'Docker Compose is available'; - const CHECK_REQUIREMENTS_DOCKER_COMPOSE_MISSING = 'Docker Compose is missing'; + const DOCTOR_DOCKER_COMPOSE_MISSING = 'Docker Compose is missing'; - const CHECK_REQUIREMENTS_AHOY_AVAILABLE = 'Ahoy is available'; + const DOCTOR_AHOY_AVAILABLE = 'Ahoy is available'; - const CHECK_REQUIREMENTS_AHOY_MISSING = 'Ahoy is missing'; + const DOCTOR_AHOY_MISSING = 'Ahoy is missing'; - const CHECK_REQUIREMENTS_PYGMY_RUNNING = 'Pygmy is running'; + const DOCTOR_PYGMY_RUNNING = 'Pygmy is running'; - const CHECK_REQUIREMENTS_PYGMY_NOT_RUNNING = 'Pygmy is not running'; + const DOCTOR_PYGMY_NOT_RUNNING = 'Pygmy is not running'; - const CHECK_REQUIREMENTS_ALL_MET = 'All requirements met'; + const DOCTOR_ALL_MET = 'All requirements met'; - const CHECK_REQUIREMENTS_MISSING = 'Missing requirements'; + const DOCTOR_MISSING = 'Missing requirements'; const INSTALL_STARTING = 'Starting project installation'; @@ -149,13 +149,13 @@ class TuiOutput { const BUILD_REVIEW_DOCS = 'Review hosting/provisioning docs'; // Check requirements labels. - const CHECK_REQUIREMENTS_PRESENT_LABEL = 'Present:'; + const DOCTOR_PRESENT_LABEL = 'Present:'; - const CHECK_REQUIREMENTS_MISSING_LABEL = 'Missing:'; + const DOCTOR_MISSING_LABEL = 'Missing:'; - const CHECK_REQUIREMENTS_UNKNOWN = 'Unknown requirements:'; + const DOCTOR_UNKNOWN = 'Unknown requirements:'; - const CHECK_REQUIREMENTS_AVAILABLE = 'Available: docker, docker-compose, ahoy, pygmy'; + const DOCTOR_AVAILABLE = 'Available: docker, docker-compose, ahoy, pygmy'; const DESTINATION_NOT_EXIST = 'Destination directory does not exist:'; @@ -273,18 +273,18 @@ public static function buildRunnerFailure(): \Closure { * @return \Closure * Closure that echoes requirements check output and returns success. */ - public static function checkRequirementsSuccess(): \Closure { + public static function doctorSuccess(): \Closure { return function (string $command): int { self::echo([ - self::CHECK_REQUIREMENTS_CHECKING_DOCKER, - self::CHECK_REQUIREMENTS_DOCKER_AVAILABLE, - self::CHECK_REQUIREMENTS_CHECKING_DOCKER_COMPOSE, - self::CHECK_REQUIREMENTS_DOCKER_COMPOSE_AVAILABLE, - self::CHECK_REQUIREMENTS_CHECKING_AHOY, - self::CHECK_REQUIREMENTS_AHOY_AVAILABLE, - self::CHECK_REQUIREMENTS_CHECKING_PYGMY, - self::CHECK_REQUIREMENTS_PYGMY_RUNNING, - self::CHECK_REQUIREMENTS_ALL_MET, + self::DOCTOR_CHECKING_DOCKER, + self::DOCTOR_DOCKER_AVAILABLE, + self::DOCTOR_CHECKING_DOCKER_COMPOSE, + self::DOCTOR_DOCKER_COMPOSE_AVAILABLE, + self::DOCTOR_CHECKING_AHOY, + self::DOCTOR_AHOY_AVAILABLE, + self::DOCTOR_CHECKING_PYGMY, + self::DOCTOR_PYGMY_RUNNING, + self::DOCTOR_ALL_MET, ]); return RunnerInterface::EXIT_SUCCESS; }; @@ -298,14 +298,14 @@ public static function checkRequirementsSuccess(): \Closure { * @return \Closure * Closure that echoes requirements check output and returns failure. */ - public static function checkRequirementsFailure(): \Closure { + public static function doctorFailure(): \Closure { return function (string $command): int { self::echo([ - self::CHECK_REQUIREMENTS_CHECKING_DOCKER, - self::CHECK_REQUIREMENTS_DOCKER_AVAILABLE, - self::CHECK_REQUIREMENTS_CHECKING_DOCKER_COMPOSE, - self::CHECK_REQUIREMENTS_DOCKER_COMPOSE_MISSING, - self::CHECK_REQUIREMENTS_MISSING, + self::DOCTOR_CHECKING_DOCKER, + self::DOCTOR_DOCKER_AVAILABLE, + self::DOCTOR_CHECKING_DOCKER_COMPOSE, + self::DOCTOR_DOCKER_COMPOSE_MISSING, + self::DOCTOR_MISSING, ]); return RunnerInterface::EXIT_FAILURE; }; diff --git a/.vortex/cli/vortex b/.vortex/cli/vortex index 4e16b913d..f689bf490 100755 --- a/.vortex/cli/vortex +++ b/.vortex/cli/vortex @@ -9,7 +9,7 @@ declare(strict_types=1); use DrevOps\VortexCli\Command\BuildCommand; -use DrevOps\VortexCli\Command\CheckRequirementsCommand; +use DrevOps\VortexCli\Command\DoctorCommand; use DrevOps\VortexCli\Command\InstallCommand; use DrevOps\VortexCli\Utils\Config; use DrevOps\VortexCli\Utils\Env; @@ -28,7 +28,7 @@ $version = str_contains($version, 'vortex-cli-version') ? 'development' : $versi $application = new Application('Vortex CLI', $version); $application->add(new InstallCommand()); -$application->add(new CheckRequirementsCommand()); +$application->add(new DoctorCommand()); $application->add(new BuildCommand()); $application->setDefaultCommand('install'); From 7854c0e840a3e10ffdb0db8a82be2424b5ed10b3 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 06:49:23 +1000 Subject: [PATCH 03/12] [#2846] Extracted the agent surface into 'AgentSurfaceTrait'. The questions are declared by the build rather than by a verb, so '--schema', '--validate' and '--agent-help' answer identically wherever the trait is mounted. A bare invocation resolves to a different verb depending on the target directory, and an agent must be able to describe the questions either way. --- .vortex/cli/src/Command/AgentSurfaceTrait.php | 130 ++++++++++++++++++ .vortex/cli/src/Command/InstallCommand.php | 91 +----------- 2 files changed, 135 insertions(+), 86 deletions(-) create mode 100644 .vortex/cli/src/Command/AgentSurfaceTrait.php diff --git a/.vortex/cli/src/Command/AgentSurfaceTrait.php b/.vortex/cli/src/Command/AgentSurfaceTrait.php new file mode 100644 index 000000000..1cb6d1713 --- /dev/null +++ b/.vortex/cli/src/Command/AgentSurfaceTrait.php @@ -0,0 +1,130 @@ +addOption(static::OPTION_PROMPTS, 'p', InputOption::VALUE_REQUIRED, 'A JSON string with prompt answers or a path to a JSON file. Keys are prompt IDs from --schema.'); + $this->addOption(static::OPTION_SCHEMA, NULL, InputOption::VALUE_NONE, 'Output prompt schema as JSON.'); + $this->addOption(static::OPTION_VALIDATE, NULL, InputOption::VALUE_NONE, 'Validate answers without making any changes.'); + $this->addOption(static::OPTION_AGENT_HELP, NULL, InputOption::VALUE_NONE, 'Output instructions for AI agents on how to use the CLI.'); + } + + /** + * Answer an agent surface option, if one was requested. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * The input. + * @param \Symfony\Component\Console\Output\OutputInterface $output + * The output. + * + * @return int|null + * The exit code when the surface answered, or NULL to carry on. + */ + protected function handleAgentSurface(InputInterface $input, OutputInterface $output): ?int { + if ($input->getOption(static::OPTION_AGENT_HELP)) { + return $this->handleAgentHelp($output); + } + + if ($input->getOption(static::OPTION_SCHEMA)) { + return $this->handleSchema($output); + } + + if ($input->getOption(static::OPTION_VALIDATE)) { + return $this->handleValidate($input, $output); + } + + return NULL; + } + + /** + * Handle --schema option. + */ + protected function handleSchema(OutputInterface $output): int { + $config = Config::fromString('{}'); + $prompt_manager = new PromptManager($config); + + $generator = new SchemaGenerator($prompt_manager->getHandlers()); + $schema = $generator->generate(); + + $output->write((string) json_encode($schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return Command::SUCCESS; + } + + /** + * Handle --validate option. + */ + protected function handleValidate(InputInterface $input, OutputInterface $output): int { + $prompts_option = $input->getOption(static::OPTION_PROMPTS); + + if (empty($prompts_option) || !is_string($prompts_option)) { + $output->writeln('The --validate option requires --prompts.'); + + return Command::FAILURE; + } + + $prompts_json = is_file($prompts_option) ? (string) file_get_contents($prompts_option) : $prompts_option; + $decoded = json_decode($prompts_json); + + if (!$decoded instanceof \stdClass) { + $output->writeln('Invalid JSON in --prompts. Expected a JSON object.'); + + return Command::FAILURE; + } + + $user_config = json_decode($prompts_json, TRUE); + + $config = Config::fromString('{}'); + $prompt_manager = new PromptManager($config); + + $validator = new SchemaValidator($prompt_manager->getHandlers()); + $result = $validator->validate($user_config); + + $output->write((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $result['valid'] ? Command::SUCCESS : Command::FAILURE; + } + + /** + * Handle --agent-help option. + */ + protected function handleAgentHelp(OutputInterface $output): int { + $output->write(AgentHelp::render()); + + return Command::SUCCESS; + } + +} diff --git a/.vortex/cli/src/Command/InstallCommand.php b/.vortex/cli/src/Command/InstallCommand.php index bdd3e792c..6e6a52b37 100644 --- a/.vortex/cli/src/Command/InstallCommand.php +++ b/.vortex/cli/src/Command/InstallCommand.php @@ -15,9 +15,6 @@ use DrevOps\VortexCli\Runner\ExecutableFinderAwareInterface; use DrevOps\VortexCli\Runner\ExecutableFinderAwareTrait; use DrevOps\VortexCli\Runner\RunnerInterface; -use DrevOps\VortexCli\Schema\AgentHelp; -use DrevOps\VortexCli\Schema\SchemaGenerator; -use DrevOps\VortexCli\Schema\SchemaValidator; use DrevOps\VortexCli\Task\Task; use DrevOps\VortexCli\Utils\Config; use DrevOps\VortexCli\Utils\Env; @@ -42,6 +39,7 @@ class InstallCommand extends Command implements CommandRunnerAwareInterface, Exe use CommandRunnerAwareTrait; use ExecutableFinderAwareTrait; + use AgentSurfaceTrait; const OPTION_DESTINATION = 'destination'; @@ -59,14 +57,6 @@ class InstallCommand extends Command implements CommandRunnerAwareInterface, Exe const OPTION_BUILD = 'build'; - const OPTION_SCHEMA = 'schema'; - - const OPTION_VALIDATE = 'validate'; - - const OPTION_PROMPTS = 'prompts'; - - const OPTION_AGENT_HELP = 'agent-help'; - /** * Defines default command name. * @@ -145,10 +135,7 @@ protected function configure(): void { $this->addOption(static::OPTION_URI, 'l', InputOption::VALUE_REQUIRED, 'Remote or local repository URI with an optional git ref set after @.'); $this->addOption(static::OPTION_NO_CLEANUP, NULL, InputOption::VALUE_NONE, 'Do not remove the CLI after successful installation.'); $this->addOption(static::OPTION_BUILD, 'b', InputOption::VALUE_NONE, 'Run auto-build after installation without prompting.'); - $this->addOption(static::OPTION_PROMPTS, 'p', InputOption::VALUE_REQUIRED, 'A JSON string with prompt answers or a path to a JSON file. Keys are prompt IDs from --schema.'); - $this->addOption(static::OPTION_SCHEMA, NULL, InputOption::VALUE_NONE, 'Output prompt schema as JSON.'); - $this->addOption(static::OPTION_VALIDATE, NULL, InputOption::VALUE_NONE, 'Validate config without installing.'); - $this->addOption(static::OPTION_AGENT_HELP, NULL, InputOption::VALUE_NONE, 'Output instructions for AI agents on how to use the CLI.'); + $this->addAgentSurfaceOptions(); } /** @@ -161,16 +148,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - if ($input->getOption(static::OPTION_AGENT_HELP)) { - return $this->handleAgentHelp($output); - } - - if ($input->getOption(static::OPTION_SCHEMA)) { - return $this->handleSchema($input, $output); - } - - if ($input->getOption(static::OPTION_VALIDATE)) { - return $this->handleValidate($input, $output); + $agent_surface = $this->handleAgentSurface($input, $output); + if ($agent_surface !== NULL) { + return $agent_surface; } Tui::init($output); @@ -311,67 +291,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - /** - * Handle --schema option. - */ - protected function handleSchema(InputInterface $input, OutputInterface $output): int { - $config = Config::fromString('{}'); - $prompt_manager = new PromptManager($config); - - $generator = new SchemaGenerator($prompt_manager->getHandlers()); - $schema = $generator->generate(); - - $output->write((string) json_encode($schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - - return Command::SUCCESS; - } - - /** - * Handle --validate option. - */ - protected function handleValidate(InputInterface $input, OutputInterface $output): int { - $prompts_option = $input->getOption(static::OPTION_PROMPTS); - - if (empty($prompts_option) || !is_string($prompts_option)) { - $output->writeln('The --validate option requires --prompts.'); - - return Command::FAILURE; - } - - $prompts_json = is_file($prompts_option) ? (string) file_get_contents($prompts_option) : $prompts_option; - $decoded = json_decode($prompts_json); - - if (!$decoded instanceof \stdClass) { - $output->writeln('Invalid JSON in --prompts. Expected a JSON object.'); - - return Command::FAILURE; - } - - $user_config = json_decode($prompts_json, TRUE); - - $config = Config::fromString('{}'); - $prompt_manager = new PromptManager($config); - - $validator = new SchemaValidator($prompt_manager->getHandlers()); - $result = $validator->validate($user_config); - - $output->write((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - - return $result['valid'] ? Command::SUCCESS : Command::FAILURE; - } - - /** - * Handle --agent-help option. - * - * Outputs instructions for AI agents on how to use the CLI - * programmatically via --schema and --validate. - */ - protected function handleAgentHelp(OutputInterface $output): int { - $output->write(AgentHelp::render()); - - return Command::SUCCESS; - } - /** * Run the 'build' command. * From 4deb7360f5acca3151ea750424fbea1064834476 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:00:53 +1000 Subject: [PATCH 04/12] [#2846] Extracted the template-applying flow into 'AbstractInstallCommand'. Downloading, collecting answers, processing and copying is one flow shared by every verb that applies a template, so it moves to a base class and 'install' becomes a facade over it. Closing guidance is now suppressed on non-interactive runs so a caller's stdout stays clean; build failure output still prints on every path because it explains a non-zero exit code. The header names the operation, which follows the destination state rather than the command name. --- .../src/Command/AbstractInstallCommand.php | 420 ++++++++++++++++++ .vortex/cli/src/Command/InstallCommand.php | 389 +--------------- .vortex/cli/src/Prompts/InstallPresenter.php | 9 +- .../Functional/Command/InstallCommandTest.php | 46 +- .../AbstractHandlerProcessTestCase.php | 4 +- 5 files changed, 472 insertions(+), 396 deletions(-) create mode 100644 .vortex/cli/src/Command/AbstractInstallCommand.php diff --git a/.vortex/cli/src/Command/AbstractInstallCommand.php b/.vortex/cli/src/Command/AbstractInstallCommand.php new file mode 100644 index 000000000..a7b0b6a67 --- /dev/null +++ b/.vortex/cli/src/Command/AbstractInstallCommand.php @@ -0,0 +1,420 @@ +addOption(static::OPTION_DESTINATION, NULL, InputOption::VALUE_REQUIRED, 'Destination directory. Defaults to the current directory.'); + $this->addOption(static::OPTION_ROOT, NULL, InputOption::VALUE_REQUIRED, 'Path to the root for file path resolution. If not specified, current directory is used.'); + $this->addOption(static::OPTION_NO_INTERACTION, 'n', InputOption::VALUE_NONE, 'Do not ask any interactive question.'); + $this->addOption(static::OPTION_CONFIG, 'c', InputOption::VALUE_REQUIRED, 'A JSON string with options or a path to a JSON file.'); + $this->addOption(static::OPTION_URI, 'l', InputOption::VALUE_REQUIRED, 'Remote or local repository URI with an optional git ref set after @.'); + $this->addOption(static::OPTION_NO_CLEANUP, NULL, InputOption::VALUE_NONE, 'Do not remove the CLI after successful installation.'); + $this->addOption(static::OPTION_BUILD, 'b', InputOption::VALUE_NONE, 'Run auto-build after installation without prompting.'); + $this->addAgentSurfaceOptions(); + } + + /** + * Download the template, collect answers, apply them and copy the result. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * The input. + * @param \Symfony\Component\Console\Output\OutputInterface $output + * The output. + * + * @return int + * The command exit code. + */ + protected function doInstall(InputInterface $input, OutputInterface $output): int { + if ($input->getOption('help')) { + $output->write($this->getHelp()); + + return Command::SUCCESS; + } + + $agent_surface = $this->handleAgentSurface($input, $output); + if ($agent_surface !== NULL) { + return $agent_surface; + } + + Tui::init($output); + + try { + OptionsResolver::checkRequirements($this->getExecutableFinder()); + [$this->config, $this->artifact] = OptionsResolver::resolve($input->getOptions()); + + Tui::init($output, !$this->config->getNoInteraction()); + $this->promptManager = new PromptManager($this->config); + $this->presenter = new InstallPresenter($this->config); + $this->presenter->setPromptManager($this->promptManager); + $this->fileManager = new FileManager($this->config); + + $this->presenter->header($this->artifact, $this->getApplication()->getVersion()); + + $this->assertMajorCompatibility(); + + // Only validate if using custom repository or custom reference. + if (!$this->artifact->isDefault()) { + Task::action( + label: 'Validating repository and reference', + action: function (): string { + $this->getRepositoryDownloader()->validate($this->artifact); + return 'Repository and reference validated successfully'; + }, + hint: fn(): string => sprintf('Checking repository "%s" and reference "%s"', $this->artifact->getRepo(), $this->artifact->getRef()), + success: fn(string $return): string => $return + ); + Tui::line(''); + } + + Tui::line(Tui::dim('Press any key to continue...')); + Tui::getChar(); + + $this->promptManager->runPrompts(); + + // Flushed here rather than at resolve time because prompt answers are + // read from the environment during the run above, so earlier reporting + // would miss every deprecated prompt variable. + $this->noticeDeprecatedEnvVars(); + + Tui::list($this->promptManager->getResponsesSummary(), 'Installation summary'); + + if (!$this->promptManager->shouldProceed()) { + Tui::info('Aborting project installation. No files were changed.'); + + return Command::SUCCESS; + } + + Tui::info('Starting project installation'); + + Task::action( + label: 'Downloading Vortex', + action: function (): string { + $release_prefix = Version::releasePrefix($this->getApplication()->getVersion()); + $version = $this->getRepositoryDownloader()->download($this->artifact, $this->config->get(Config::TMP), $release_prefix); + $this->config->set(Config::VERSION, $version); + return $version; + }, + hint: fn(): string => sprintf('Downloading from "%s" repository at ref "%s"', $this->artifact->getRepo(), $this->artifact->getRef()), + success: fn(string $return): string => sprintf('Vortex downloaded (%s)', $return) + ); + + Task::action( + label: 'Customizing Vortex for your project', + action: fn() => $this->promptManager->runProcessors(), + success: 'Vortex was customized for your project', + ); + + Task::action( + label: 'Preparing destination directory', + action: fn(): array => $this->fileManager->prepareDestination(), + success: 'Destination directory is ready', + ); + + Task::action( + label: 'Copying files to the destination directory', + action: fn() => $this->fileManager->copyFiles(), + success: 'Files copied to destination directory', + ); + + Task::action( + label: 'Preparing demo content', + action: fn(): string|array => $this->fileManager->prepareDemo($this->getFileDownloader()), + success: 'Demo content prepared', + ); + } + catch (\Exception $exception) { + Tui::output()->setVerbosity(OutputInterface::VERBOSITY_NORMAL); + Tui::error('Installation failed with an error: ' . $exception->getMessage()); + + return Command::FAILURE; + } + + if ($this->shouldGuide()) { + $this->presenter->footer(); + } + + // Should build by default. + $should_build = TRUE; + // Requested build via `--build` option. Defaults to FALSE. + $requested_build = (bool) $this->config->get(Config::BUILD_NOW); + // Non-interactive: respect the `--build` option. + if ($this->config->getNoInteraction()) { + $should_build = $requested_build; + } + // Interactive: ask only if `--build` option was not provided. + elseif (!$requested_build) { + $should_build = Tui::confirm( + label: 'Run the site build now?', + default: (bool) Env::get('VORTEX_CLI_INSTALL_PROMPT_BUILD_NOW', TRUE), + hint: 'Takes ~5-10 min; output will be streamed. You can skip and run later with: ahoy build', + ); + } + + if ($should_build) { + $build_ok = Task::action( + label: 'Building site', + action: fn(): bool => $this->runBuildCommand($output), + streaming: TRUE, + ); + + if (!$build_ok) { + // Printed on every path: it explains a non-zero exit code, so a + // scripted caller needs it as much as a person does. + $this->presenter->footerBuildFailed(); + + return Command::FAILURE; + } + + if ($this->shouldGuide()) { + $this->presenter->footerBuildSucceeded(); + } + } + elseif ($this->shouldGuide()) { + $this->presenter->footerBuildSkipped(); + } + + // Cleanup should take place only in case of the successful installation. + // Otherwise, the user should be able to re-run the install. + register_shutdown_function([$this, 'cleanup']); + + return Command::SUCCESS; + } + + /** + * Whether closing guidance should be printed. + * + * Guidance is written for the person who just answered the questions. A + * scripted run has no one to read it and its stdout belongs to the caller. + * + * @return bool + * TRUE when a person is watching. + */ + protected function shouldGuide(): bool { + return !$this->config->getNoInteraction(); + } + + /** + * Run the 'build' command. + * + * @param \Symfony\Component\Console\Output\OutputInterface $output + * The output interface. + * + * @return bool + * TRUE if the build command succeeded, FALSE otherwise. + */ + protected function runBuildCommand(OutputInterface $output): bool { + $responses = $this->promptManager->getResponses(); + $starter = $responses[Starter::id()] ?? Starter::LOAD_DATABASE_DEMO; + $is_profile = in_array($starter, [Starter::INSTALL_PROFILE_CORE, Starter::INSTALL_PROFILE_DRUPALCMS], TRUE); + + $args = ['--destination' => $this->config->getDst()]; + if ($is_profile) { + $args['--profile'] = '1'; + } + + $runner = $this->getCommandRunner(); + $runner->run('build', args: $args, output: $output); + + return $runner->getExitCode() === RunnerInterface::EXIT_SUCCESS; + } + + /** + * Refuse to operate across major versions. + * + * Each CLI build serves a single major line. Updating an existing + * project of a different major would cross a breaking boundary, so stop and + * point the user at the matching CLI instead. Fresh installs and + * projects whose major cannot be determined are treated as compatible. + * + * @throws \RuntimeException + * When the destination project's major differs from this CLI's major. + */ + protected function assertMajorCompatibility(): void { + if (!$this->config->isVortexProject()) { + return; + } + + $cli_major = Version::major($this->getApplication()->getVersion()); + if ($cli_major === NULL) { + return; + } + + $project_major = Version::detectProjectMajor((string) $this->config->getDst()); + if ($project_major === NULL || $project_major === $cli_major) { + return; + } + + throw new \RuntimeException(sprintf( + 'This Vortex CLI targets Vortex %1$d.x, but the destination is a Vortex %2$d.x project. Update it with the %2$d.x CLI instead: https://www.vortextemplate.com/v%2$d/install', + $cli_major, + $project_major, + )); + } + + /** + * Report every superseded environment variable that supplied a value. + */ + protected function noticeDeprecatedEnvVars(): void { + foreach (Env::legacyUsed() as $current => $legacy) { + Tui::note(sprintf('%s is deprecated and will be removed in a future release. Use %s instead.', $legacy, $current)); + } + } + + /** + * Clean up CLI artifacts. + */ + public function cleanup(): void { + // Skip cleanup if the no-cleanup flag is set. + if ($this->config->get(Config::NO_CLEANUP, FALSE)) { + return; + } + + $phar_path = \Phar::running(FALSE); + if (!empty($phar_path) && file_exists($phar_path)) { + File::remove($phar_path); + } + } + + /** + * Get the repository downloader. + * + * Provides a default RepositoryDownloader instance or returns the injected + * one. This allows tests to inject mocks via setRepositoryDownloader(). + * + * @return \DrevOps\VortexCli\Downloader\RepositoryDownloader + * The repository downloader. + */ + protected function getRepositoryDownloader(): RepositoryDownloader { + return $this->repositoryDownloader ??= new RepositoryDownloader(); + } + + /** + * Set the repository downloader. + * + * @param \DrevOps\VortexCli\Downloader\RepositoryDownloader $repositoryDownloader + * The repository downloader. + */ + public function setRepositoryDownloader(RepositoryDownloader $repositoryDownloader): void { + $this->repositoryDownloader = $repositoryDownloader; + } + + /** + * Get the file downloader. + * + * Provides a default Downloader instance or returns the injected one. + * This allows tests to inject mocks via setFileDownloader(). + * + * @return \DrevOps\VortexCli\Downloader\Downloader + * The file downloader. + */ + protected function getFileDownloader(): Downloader { + return $this->fileDownloader ??= new Downloader(); + } + + /** + * Set the file downloader. + * + * @param \DrevOps\VortexCli\Downloader\Downloader $fileDownloader + * The file downloader. + */ + public function setFileDownloader(Downloader $fileDownloader): void { + $this->fileDownloader = $fileDownloader; + } + +} diff --git a/.vortex/cli/src/Command/InstallCommand.php b/.vortex/cli/src/Command/InstallCommand.php index 6e6a52b37..91eb9d43d 100644 --- a/.vortex/cli/src/Command/InstallCommand.php +++ b/.vortex/cli/src/Command/InstallCommand.php @@ -4,58 +4,17 @@ namespace DrevOps\VortexCli\Command; -use DrevOps\VortexCli\Downloader\Artifact; -use DrevOps\VortexCli\Downloader\Downloader; -use DrevOps\VortexCli\Downloader\RepositoryDownloader; -use DrevOps\VortexCli\Prompts\Handlers\Starter; -use DrevOps\VortexCli\Prompts\InstallPresenter; -use DrevOps\VortexCli\Prompts\PromptManager; -use DrevOps\VortexCli\Runner\CommandRunnerAwareInterface; -use DrevOps\VortexCli\Runner\CommandRunnerAwareTrait; -use DrevOps\VortexCli\Runner\ExecutableFinderAwareInterface; -use DrevOps\VortexCli\Runner\ExecutableFinderAwareTrait; -use DrevOps\VortexCli\Runner\RunnerInterface; -use DrevOps\VortexCli\Task\Task; -use DrevOps\VortexCli\Utils\Config; -use DrevOps\VortexCli\Utils\Env; -use DrevOps\VortexCli\Utils\File; -use DrevOps\VortexCli\Utils\FileManager; -use DrevOps\VortexCli\Utils\OptionsResolver; -use DrevOps\VortexCli\Utils\Tui; -use DrevOps\VortexCli\Utils\Version; -use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** - * Run command. - * * Install command. * + * Installs Vortex from a remote or local repository into the destination. + * * @package DrevOps\VortexCli\Command */ -class InstallCommand extends Command implements CommandRunnerAwareInterface, ExecutableFinderAwareInterface { - - use CommandRunnerAwareTrait; - use ExecutableFinderAwareTrait; - use AgentSurfaceTrait; - - const OPTION_DESTINATION = 'destination'; - - const OPTION_ROOT = 'root'; - - const OPTION_NO_INTERACTION = 'no-interaction'; - - const OPTION_CONFIG = 'config'; - - const OPTION_QUIET = 'quiet'; - - const OPTION_URI = 'uri'; - - const OPTION_NO_CLEANUP = 'no-cleanup'; - - const OPTION_BUILD = 'build'; +class InstallCommand extends AbstractInstallCommand { /** * Defines default command name. @@ -64,41 +23,6 @@ class InstallCommand extends Command implements CommandRunnerAwareInterface, Exe */ public static $defaultName = 'install'; - /** - * Defines the configuration object. - */ - protected Config $config; - - /** - * The prompt manager. - */ - protected PromptManager $promptManager; - - /** - * The install presenter. - */ - protected InstallPresenter $presenter; - - /** - * The file manager. - */ - protected FileManager $fileManager; - - /** - * The repository downloader. - */ - protected ?RepositoryDownloader $repositoryDownloader = NULL; - - /** - * The file downloader. - */ - protected ?Downloader $fileDownloader = NULL; - - /** - * The artifact representing the repository and reference to install. - */ - protected Artifact $artifact; - /** * {@inheritdoc} */ @@ -107,316 +31,35 @@ protected function configure(): void { $this->setDescription('Install Vortex from remote or local repository.'); $this->setHelp(<<Interactively install Vortex from the latest stable release into the current directory: - php vortex.phar --destination=. + php vortex.phar install --destination=. Non-interactively install Vortex from the latest stable release into the specified directory: - php vortex.phar --no-interaction --destination=path/to/destination + php vortex.phar install --no-interaction --destination=path/to/destination Install from the latest auto-discovered stable release (default behavior if --uri is specified): - php vortex.phar --uri=https://github.com/drevops/vortex.git - php vortex.phar --uri=https://github.com/drevops/vortex.git#stable + php vortex.phar install --uri=https://github.com/drevops/vortex.git + php vortex.phar install --uri=https://github.com/drevops/vortex.git#stable Install using repository URL with specific git ref after #: - php vortex.phar --uri=https://github.com/drevops/vortex.git#25.11.0 - php vortex.phar --uri=https://github.com/drevops/vortex.git#v1.2.3 - php vortex.phar --uri=https://github.com/drevops/vortex.git#main + php vortex.phar install --uri=https://github.com/drevops/vortex.git#25.11.0 + php vortex.phar install --uri=https://github.com/drevops/vortex.git#v1.2.3 + php vortex.phar install --uri=https://github.com/drevops/vortex.git#main Copy GitHub URL directly from your browser: - php vortex.phar --uri=https://github.com/drevops/vortex/releases/tag/25.11.0 - php vortex.phar --uri=https://github.com/drevops/vortex/tree/1.2.3 - php vortex.phar --uri=https://github.com/drevops/vortex/tree/main - php vortex.phar --uri=https://github.com/drevops/vortex/commit/abcd123 + php vortex.phar install --uri=https://github.com/drevops/vortex/releases/tag/25.11.0 + php vortex.phar install --uri=https://github.com/drevops/vortex/tree/1.2.3 + php vortex.phar install --uri=https://github.com/drevops/vortex/tree/main + php vortex.phar install --uri=https://github.com/drevops/vortex/commit/abcd123 EOF ); - $this->addOption(static::OPTION_DESTINATION, NULL, InputOption::VALUE_REQUIRED, 'Destination directory. Defaults to the current directory.'); - $this->addOption(static::OPTION_ROOT, NULL, InputOption::VALUE_REQUIRED, 'Path to the root for file path resolution. If not specified, current directory is used.'); - $this->addOption(static::OPTION_NO_INTERACTION, 'n', InputOption::VALUE_NONE, 'Do not ask any interactive question.'); - $this->addOption(static::OPTION_CONFIG, 'c', InputOption::VALUE_REQUIRED, 'A JSON string with options or a path to a JSON file.'); - $this->addOption(static::OPTION_URI, 'l', InputOption::VALUE_REQUIRED, 'Remote or local repository URI with an optional git ref set after @.'); - $this->addOption(static::OPTION_NO_CLEANUP, NULL, InputOption::VALUE_NONE, 'Do not remove the CLI after successful installation.'); - $this->addOption(static::OPTION_BUILD, 'b', InputOption::VALUE_NONE, 'Run auto-build after installation without prompting.'); - $this->addAgentSurfaceOptions(); + $this->addCommonOptions(); } /** * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output): int { - if ($input->getOption('help')) { - $output->write($this->getHelp()); - - return Command::SUCCESS; - } - - $agent_surface = $this->handleAgentSurface($input, $output); - if ($agent_surface !== NULL) { - return $agent_surface; - } - - Tui::init($output); - - try { - OptionsResolver::checkRequirements($this->getExecutableFinder()); - [$this->config, $this->artifact] = OptionsResolver::resolve($input->getOptions()); - - Tui::init($output, !$this->config->getNoInteraction()); - $this->promptManager = new PromptManager($this->config); - $this->presenter = new InstallPresenter($this->config); - $this->presenter->setPromptManager($this->promptManager); - $this->fileManager = new FileManager($this->config); - - $this->presenter->header($this->artifact, $this->getApplication()->getVersion()); - - $this->assertMajorCompatibility(); - - // Only validate if using custom repository or custom reference. - if (!$this->artifact->isDefault()) { - Task::action( - label: 'Validating repository and reference', - action: function (): string { - $this->getRepositoryDownloader()->validate($this->artifact); - return 'Repository and reference validated successfully'; - }, - hint: fn(): string => sprintf('Checking repository "%s" and reference "%s"', $this->artifact->getRepo(), $this->artifact->getRef()), - success: fn(string $return): string => $return - ); - Tui::line(''); - } - - Tui::line(Tui::dim('Press any key to continue...')); - Tui::getChar(); - - $this->promptManager->runPrompts(); - - // Flushed here rather than at resolve time because prompt answers are - // read from the environment during the run above, so earlier reporting - // would miss every deprecated prompt variable. - $this->noticeDeprecatedEnvVars(); - - Tui::list($this->promptManager->getResponsesSummary(), 'Installation summary'); - - if (!$this->promptManager->shouldProceed()) { - Tui::info('Aborting project installation. No files were changed.'); - - return Command::SUCCESS; - } - - Tui::info('Starting project installation'); - - Task::action( - label: 'Downloading Vortex', - action: function (): string { - $release_prefix = Version::releasePrefix($this->getApplication()->getVersion()); - $version = $this->getRepositoryDownloader()->download($this->artifact, $this->config->get(Config::TMP), $release_prefix); - $this->config->set(Config::VERSION, $version); - return $version; - }, - hint: fn(): string => sprintf('Downloading from "%s" repository at ref "%s"', $this->artifact->getRepo(), $this->artifact->getRef()), - success: fn(string $return): string => sprintf('Vortex downloaded (%s)', $return) - ); - - Task::action( - label: 'Customizing Vortex for your project', - action: fn() => $this->promptManager->runProcessors(), - success: 'Vortex was customized for your project', - ); - - Task::action( - label: 'Preparing destination directory', - action: fn(): array => $this->fileManager->prepareDestination(), - success: 'Destination directory is ready', - ); - - Task::action( - label: 'Copying files to the destination directory', - action: fn() => $this->fileManager->copyFiles(), - success: 'Files copied to destination directory', - ); - - Task::action( - label: 'Preparing demo content', - action: fn(): string|array => $this->fileManager->prepareDemo($this->getFileDownloader()), - success: 'Demo content prepared', - ); - } - catch (\Exception $exception) { - Tui::output()->setVerbosity(OutputInterface::VERBOSITY_NORMAL); - Tui::error('Installation failed with an error: ' . $exception->getMessage()); - - return Command::FAILURE; - } - - $this->presenter->footer(); - - // Should build by default. - $should_build = TRUE; - // Requested build via `--build` option. Defaults to FALSE. - $requested_build = (bool) $this->config->get(Config::BUILD_NOW); - // Non-interactive: respect the `--build` option. - if ($this->config->getNoInteraction()) { - $should_build = $requested_build; - } - // Interactive: ask only if `--build` option was not provided. - elseif (!$requested_build) { - $should_build = Tui::confirm( - label: 'Run the site build now?', - default: (bool) Env::get('VORTEX_CLI_INSTALL_PROMPT_BUILD_NOW', TRUE), - hint: 'Takes ~5-10 min; output will be streamed. You can skip and run later with: ahoy build', - ); - } - - if ($should_build) { - $build_ok = Task::action( - label: 'Building site', - action: fn(): bool => $this->runBuildCommand($output), - streaming: TRUE, - ); - - if (!$build_ok) { - $this->presenter->footerBuildFailed(); - - return Command::FAILURE; - } - - $this->presenter->footerBuildSucceeded(); - } - else { - $this->presenter->footerBuildSkipped(); - } - - // Cleanup should take place only in case of the successful installation. - // Otherwise, the user should be able to re-run the install. - register_shutdown_function([$this, 'cleanup']); - - return Command::SUCCESS; - } - - /** - * Run the 'build' command. - * - * @param \Symfony\Component\Console\Output\OutputInterface $output - * The output interface. - * - * @return bool - * TRUE if the build command succeeded, FALSE otherwise. - */ - protected function runBuildCommand(OutputInterface $output): bool { - $responses = $this->promptManager->getResponses(); - $starter = $responses[Starter::id()] ?? Starter::LOAD_DATABASE_DEMO; - $is_profile = in_array($starter, [Starter::INSTALL_PROFILE_CORE, Starter::INSTALL_PROFILE_DRUPALCMS], TRUE); - - $args = ['--destination' => $this->config->getDst()]; - if ($is_profile) { - $args['--profile'] = '1'; - } - - $runner = $this->getCommandRunner(); - $runner->run('build', args: $args, output: $output); - - return $runner->getExitCode() === RunnerInterface::EXIT_SUCCESS; - } - - /** - * Refuse to operate across major versions. - * - * Each CLI build serves a single major line. Updating an existing - * project of a different major would cross a breaking boundary, so stop and - * point the user at the matching CLI instead. Fresh installs and - * projects whose major cannot be determined are treated as compatible. - * - * @throws \RuntimeException - * When the destination project's major differs from this CLI's major. - */ - protected function assertMajorCompatibility(): void { - if (!$this->config->isVortexProject()) { - return; - } - - $cli_major = Version::major($this->getApplication()->getVersion()); - if ($cli_major === NULL) { - return; - } - - $project_major = Version::detectProjectMajor((string) $this->config->getDst()); - if ($project_major === NULL || $project_major === $cli_major) { - return; - } - - throw new \RuntimeException(sprintf( - 'This Vortex CLI targets Vortex %1$d.x, but the destination is a Vortex %2$d.x project. Update it with the %2$d.x CLI instead: https://www.vortextemplate.com/v%2$d/install', - $cli_major, - $project_major, - )); - } - - /** - * Report every superseded environment variable that supplied a value. - */ - protected function noticeDeprecatedEnvVars(): void { - foreach (Env::legacyUsed() as $current => $legacy) { - Tui::note(sprintf('%s is deprecated and will be removed in a future release. Use %s instead.', $legacy, $current)); - } - } - - /** - * Clean up CLI artifacts. - */ - public function cleanup(): void { - // Skip cleanup if the no-cleanup flag is set. - if ($this->config->get(Config::NO_CLEANUP, FALSE)) { - return; - } - - $phar_path = \Phar::running(FALSE); - if (!empty($phar_path) && file_exists($phar_path)) { - File::remove($phar_path); - } - } - - /** - * Get the repository downloader. - * - * Provides a default RepositoryDownloader instance or returns the injected - * one. This allows tests to inject mocks via setRepositoryDownloader(). - * - * @return \DrevOps\VortexCli\Downloader\RepositoryDownloader - * The repository downloader. - */ - protected function getRepositoryDownloader(): RepositoryDownloader { - return $this->repositoryDownloader ??= new RepositoryDownloader(); - } - - /** - * Set the repository downloader. - * - * @param \DrevOps\VortexCli\Downloader\RepositoryDownloader $repositoryDownloader - * The repository downloader. - */ - public function setRepositoryDownloader(RepositoryDownloader $repositoryDownloader): void { - $this->repositoryDownloader = $repositoryDownloader; - } - - /** - * Get the file downloader. - * - * Provides a default Downloader instance or returns the injected one. - * This allows tests to inject mocks via setFileDownloader(). - * - * @return \DrevOps\VortexCli\Downloader\Downloader - * The file downloader. - */ - protected function getFileDownloader(): Downloader { - return $this->fileDownloader ??= new Downloader(); - } - - /** - * Set the file downloader. - * - * @param \DrevOps\VortexCli\Downloader\Downloader $fileDownloader - * The file downloader. - */ - public function setFileDownloader(Downloader $fileDownloader): void { - $this->fileDownloader = $fileDownloader; + return $this->doInstall($input, $output); } } diff --git a/.vortex/cli/src/Prompts/InstallPresenter.php b/.vortex/cli/src/Prompts/InstallPresenter.php index f0427c5cb..c82c1b869 100644 --- a/.vortex/cli/src/Prompts/InstallPresenter.php +++ b/.vortex/cli/src/Prompts/InstallPresenter.php @@ -72,7 +72,12 @@ public function header(Artifact $artifact, string $version): void { Tui::note($logo); - $title = 'Welcome to the Vortex CLI interactive install'; + // A destination that already holds Vortex is being updated, whichever verb + // was used to get here, so the framing follows the destination rather than + // the command name. + $operation = $this->config->isVortexProject() ? 'update' : 'install'; + + $title = sprintf('Welcome to the Vortex CLI interactive %s', $operation); $content = ''; if ($artifact->isStable()) { @@ -97,7 +102,7 @@ public function header(Artifact $artifact, string $version): void { $content .= PHP_EOL; $content .= 'Existing committed files may be modified. You may need to resolve some of the changes manually.' . PHP_EOL; - $title = 'Welcome to the Vortex CLI non-interactive install'; + $title = sprintf('Welcome to the Vortex CLI non-interactive %s', $operation); } else { $content .= 'You will be asked a few questions to tailor the configuration to your site.' . PHP_EOL; diff --git a/.vortex/cli/tests/Functional/Command/InstallCommandTest.php b/.vortex/cli/tests/Functional/Command/InstallCommandTest.php index 224a5b667..077bcb923 100644 --- a/.vortex/cli/tests/Functional/Command/InstallCommandTest.php +++ b/.vortex/cli/tests/Functional/Command/InstallCommandTest.php @@ -165,6 +165,12 @@ public static function dataProviderInstallCommand(): \Iterator { TuiOutput::INSTALL_PREPARING_DESTINATION, TuiOutput::INSTALL_COPYING_FILES, TuiOutput::INSTALL_PREPARING_DEMO, + ]), + // Guidance belongs to the person who answered the questions, so a + // non-interactive run leaves the caller's stdout alone. + ...TuiOutput::absent([ + TuiOutput::INSTALL_BUILDING, + TuiOutput::FOOTER_SITE_READY, TuiOutput::FOOTER_FINISHED_INSTALLING, TuiOutput::FOOTER_GIT_ADD, TuiOutput::FOOTER_GIT_COMMIT, @@ -173,10 +179,6 @@ public static function dataProviderInstallCommand(): \Iterator { TuiOutput::FOOTER_AHOY_BUILD, TuiOutput::POSTBUILD_SETUP_GHA, ]), - ...TuiOutput::absent([ - TuiOutput::INSTALL_BUILDING, - TuiOutput::FOOTER_SITE_READY, - ]), ], ]; yield 'Install reading a superseded variable warns about it' => [ @@ -392,20 +394,21 @@ public static function dataProviderInstallCommand(): \Iterator { TuiOutput::INSTALL_PREPARING_DEMO, TuiOutput::INSTALL_BUILDING, TuiOutput::INSTALL_BUILD_SUCCESS, + // Reported by the build command's own summary, not by the guidance. + TuiOutput::INSTALL_LOGIN, + TuiOutput::FOOTER_AHOY_LOGIN, + ]), + ...TuiOutput::absent([ + TuiOutput::FOOTER_READY_TO_BUILD, + TuiOutput::FOOTER_BUILD_ERRORS, TuiOutput::FOOTER_FINISHED_INSTALLING, TuiOutput::FOOTER_GIT_ADD, TuiOutput::FOOTER_GIT_COMMIT, TuiOutput::FOOTER_SITE_READY, TuiOutput::FOOTER_GET_SITE_INFO, TuiOutput::FOOTER_AHOY_INFO, - TuiOutput::INSTALL_LOGIN, - TuiOutput::FOOTER_AHOY_LOGIN, TuiOutput::POSTBUILD_SETUP_GHA, ]), - ...TuiOutput::absent([ - TuiOutput::FOOTER_READY_TO_BUILD, - TuiOutput::FOOTER_BUILD_ERRORS, - ]), ], ]; yield 'Install with build flag and profile starter succeeds' => [ @@ -454,15 +457,9 @@ public static function dataProviderInstallCommand(): \Iterator { // Final install output - should be present. ...TuiOutput::present([ TuiOutput::INSTALL_BUILD_SUCCESS, - TuiOutput::FOOTER_FINISHED_INSTALLING, - TuiOutput::FOOTER_GIT_ADD, - TuiOutput::FOOTER_GIT_COMMIT, - TuiOutput::FOOTER_SITE_READY, - TuiOutput::FOOTER_GET_SITE_INFO, - TuiOutput::FOOTER_AHOY_INFO, + // Reported by the build command's own summary, not by the guidance. TuiOutput::INSTALL_LOGIN, TuiOutput::FOOTER_AHOY_LOGIN, - TuiOutput::POSTBUILD_SETUP_GHA, ]), // Negative assertions - should be absent. ...TuiOutput::absent([ @@ -476,6 +473,13 @@ public static function dataProviderInstallCommand(): \Iterator { TuiOutput::DOCTOR_PYGMY_NOT_RUNNING, TuiOutput::FOOTER_READY_TO_BUILD, TuiOutput::FOOTER_BUILD_ERRORS, + TuiOutput::FOOTER_FINISHED_INSTALLING, + TuiOutput::FOOTER_GIT_ADD, + TuiOutput::FOOTER_GIT_COMMIT, + TuiOutput::FOOTER_SITE_READY, + TuiOutput::FOOTER_GET_SITE_INFO, + TuiOutput::FOOTER_AHOY_INFO, + TuiOutput::POSTBUILD_SETUP_GHA, ]), ], ]; @@ -498,9 +502,8 @@ public static function dataProviderInstallCommand(): \Iterator { TuiOutput::INSTALL_PREPARING_DEMO, TuiOutput::INSTALL_BUILDING, TuiOutput::INSTALL_BUILD_FAILED, - TuiOutput::FOOTER_FINISHED_INSTALLING, - TuiOutput::FOOTER_GIT_ADD, - TuiOutput::FOOTER_GIT_COMMIT, + // Failure output explains a non-zero exit code, so it is printed on + // every path - a scripted caller needs it as much as a person does. TuiOutput::FOOTER_BUILD_ERRORS, TuiOutput::FOOTER_BUILD_FAILED_MESSAGE, TuiOutput::FOOTER_TROUBLESHOOTING, @@ -512,6 +515,9 @@ public static function dataProviderInstallCommand(): \Iterator { TuiOutput::INSTALL_BUILD_SUCCESS, TuiOutput::FOOTER_SITE_READY, TuiOutput::FOOTER_READY_TO_BUILD, + TuiOutput::FOOTER_FINISHED_INSTALLING, + TuiOutput::FOOTER_GIT_ADD, + TuiOutput::FOOTER_GIT_COMMIT, ]), ], ]; diff --git a/.vortex/cli/tests/Functional/Handlers/AbstractHandlerProcessTestCase.php b/.vortex/cli/tests/Functional/Handlers/AbstractHandlerProcessTestCase.php index 66d9c5000..e1614fa89 100644 --- a/.vortex/cli/tests/Functional/Handlers/AbstractHandlerProcessTestCase.php +++ b/.vortex/cli/tests/Functional/Handlers/AbstractHandlerProcessTestCase.php @@ -71,7 +71,9 @@ public function testHandlerProcess( $this->runNonInteractiveInstall(options: $this->installOptions); - $expected = empty($expected) ? ['Welcome to the Vortex CLI non-interactive install'] : $expected; + // The header names the operation, which follows the destination state, so + // the smoke check stops at the part every scenario shares. + $expected = empty($expected) ? ['Welcome to the Vortex CLI non-interactive'] : $expected; $this->assertApplicationOutputContains($expected); $baseline = File::dir(static::$fixtures . '/../' . self::BASELINE_DIR); From 2111dd33b60d0aedae0349f372dccc5ecf9fe4f1 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:02:49 +1000 Subject: [PATCH 05/12] [#2846] Added the 'update' command. Updating is now a verb of its own rather than re-running 'install' over an existing project. '--to' names the target template version directly; an explicit '--uri' names both repository and ref, so it stays the more specific input and wins. The cross-major refusal and its pointer to the matching release carry over unchanged. --- .vortex/cli/src/Command/UpdateCommand.php | 98 +++++++++++ .../Functional/Command/UpdateCommandTest.php | 152 ++++++++++++++++++ .vortex/cli/vortex | 2 + 3 files changed, 252 insertions(+) create mode 100644 .vortex/cli/src/Command/UpdateCommand.php create mode 100644 .vortex/cli/tests/Functional/Command/UpdateCommandTest.php diff --git a/.vortex/cli/src/Command/UpdateCommand.php b/.vortex/cli/src/Command/UpdateCommand.php new file mode 100644 index 000000000..b99a5daeb --- /dev/null +++ b/.vortex/cli/src/Command/UpdateCommand.php @@ -0,0 +1,98 @@ +setName('update'); + $this->setDescription('Update the project to a template version, re-applying your answers.'); + $this->setHelp(<<Update the current directory to the latest release of this major: + php vortex.phar update + + Update to a named template version: + php vortex.phar update --to=1.2.3 + + Update a project in another directory, without asking any question: + php vortex.phar update --no-interaction --destination=path/to/project + + Update from a specific repository, with an optional git ref after #: + php vortex.phar update --uri=https://github.com/drevops/vortex.git#main + + Answers are pre-filled from the existing project, so unchanged settings can be + accepted as they are. A project of a different major version is refused: run + that major's release against it instead. +EOF + ); + $this->addCommonOptions(); + $this->addOption(static::OPTION_TO, NULL, InputOption::VALUE_REQUIRED, 'The template version to update to. Defaults to the latest release of this major.'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $uri = $this->targetUri($input->getOption(static::OPTION_TO), $input->getOption(static::OPTION_URI)); + + if ($uri !== NULL) { + $input->setOption(static::OPTION_URI, $uri); + } + + return $this->doInstall($input, $output); + } + + /** + * Resolve the repository URI to download. + * + * @param mixed $to + * The target version, if any. + * @param mixed $uri + * The explicit repository URI, if any. + * + * @return string|null + * The URI to download, or NULL to leave resolution at its default. + */ + public function targetUri(mixed $to, mixed $uri): ?string { + // An explicit URI names both the repository and the ref, so it is the more + // specific input and wins over a bare version. + if (is_string($uri) && $uri !== '') { + return $uri; + } + + if (is_string($to) && $to !== '') { + return RepositoryDownloader::DEFAULT_REPO . '#' . $to; + } + + return NULL; + } + +} diff --git a/.vortex/cli/tests/Functional/Command/UpdateCommandTest.php b/.vortex/cli/tests/Functional/Command/UpdateCommandTest.php new file mode 100644 index 000000000..b6be38cca --- /dev/null +++ b/.vortex/cli/tests/Functional/Command/UpdateCommandTest.php @@ -0,0 +1,152 @@ +assertSame($expected, (new UpdateCommand())->targetUri($to, $uri)); + } + + /** + * Data provider for testTargetUri(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderTargetUri(): \Iterator { + $repo = RepositoryDownloader::DEFAULT_REPO; + + yield 'neither given' => [NULL, NULL, NULL]; + yield 'both empty' => ['', '', NULL]; + yield 'version only' => ['1.2.3', NULL, $repo . '#1.2.3']; + yield 'version only, empty uri' => ['1.2.3', '', $repo . '#1.2.3']; + yield 'branch as version' => ['main', NULL, $repo . '#main']; + yield 'uri only' => [NULL, 'https://example.com/fork.git#main', 'https://example.com/fork.git#main']; + yield 'explicit uri wins over version' => ['1.2.3', 'https://example.com/fork.git#main', 'https://example.com/fork.git#main']; + yield 'non-string version ignored' => [123, NULL, NULL]; + yield 'non-string uri falls through to version' => ['1.2.3', TRUE, $repo . '#1.2.3']; + } + + /** + * The named version reaches the downloader as the reference to fetch. + */ + public function testUpdateDownloadsTargetVersion(): void { + $command = $this->updateCommand(); + + $artifact = NULL; + $downloader = $this->createMock(RepositoryDownloader::class); + $downloader->method('download')->willReturnCallback(function (Artifact $downloaded) use (&$artifact): string { + $artifact = $downloaded; + throw new \RuntimeException('Failed to download Vortex.'); + }); + $command->setRepositoryDownloader($downloader); + + static::applicationInitFromCommand($command); + $this->applicationRun([ + '--' . UpdateCommand::OPTION_NO_INTERACTION => TRUE, + '--' . UpdateCommand::OPTION_TO => '1.2.3', + '--' . UpdateCommand::OPTION_DESTINATION => self::$sut, + ], [], TRUE); + + $this->assertInstanceOf(Artifact::class, $artifact); + $this->assertSame(RepositoryDownloader::DEFAULT_REPO, $artifact->getRepo()); + $this->assertSame('1.2.3', $artifact->getRef()); + } + + /** + * Updating a project of another major is refused, naming the right release. + */ + public function testUpdateRefusesForeignMajor(): void { + $command = $this->updateCommand(); + $this->markSutAsVortexProject('{"require": {"drevops/vortex-tooling": "^2.0.0"}}'); + + static::applicationInitFromCommand($command); + $this->applicationGet()->setVersion('1.40.0'); + + $this->applicationRun([ + '--' . UpdateCommand::OPTION_NO_INTERACTION => TRUE, + '--' . UpdateCommand::OPTION_URI => File::dir(static::$root), + '--' . UpdateCommand::OPTION_DESTINATION => self::$sut, + ], [], TRUE); + + $this->assertApplicationAnyOutputContainsOrNot(['* https://www.vortextemplate.com/v2/install']); + } + + /** + * An existing project is framed as an update rather than an install. + */ + public function testUpdateFramesTheRunAsAnUpdate(): void { + $command = $this->updateCommand(); + $this->markSutAsVortexProject('{"require": {"drevops/vortex-tooling": "^1.0.0"}}'); + + $downloader = $this->createMock(RepositoryDownloader::class); + $downloader->method('download')->willThrowException(new \RuntimeException('Failed to download Vortex.')); + $command->setRepositoryDownloader($downloader); + + static::applicationInitFromCommand($command); + $this->applicationRun([ + '--' . UpdateCommand::OPTION_NO_INTERACTION => TRUE, + '--' . UpdateCommand::OPTION_DESTINATION => self::$sut, + ], [], TRUE); + + $this->assertApplicationAnyOutputContainsOrNot([ + '* Welcome to the Vortex CLI non-interactive update', + '* It looks like Vortex is already installed into this project.', + '! Welcome to the Vortex CLI non-interactive install', + ]); + } + + /** + * The agent surface answers on the update verb as well. + */ + public function testUpdateExposesAgentSurface(): void { + $command = $this->updateCommand(); + + static::applicationInitFromCommand($command); + + $this->assertJson($this->applicationRun(['--' . UpdateCommand::OPTION_SCHEMA => TRUE])); + } + + /** + * Build an update command with the executable finder mocked. + */ + protected function updateCommand(): UpdateCommand { + $executable_finder = $this->createMock(ExecutableFinder::class); + $executable_finder->method('find')->willReturnCallback(fn(string $command): string => '/usr/bin/' . $command); + + $command = new UpdateCommand(); + $command->setExecutableFinder($executable_finder); + + Env::put(Config::IS_DEMO_DB_FETCH_SKIP, '1'); + + return $command; + } + + /** + * Make the destination look like an installed project of a given major. + */ + protected function markSutAsVortexProject(string $composer_json): void { + $this->assertNotFalse(file_put_contents(self::$sut . '/README.md', '[![Vortex](https://img.shields.io/badge/Vortex-1.40.0-65ACBC.svg)](https://github.com/drevops/vortex)')); + $this->assertNotFalse(file_put_contents(self::$sut . '/composer.json', $composer_json)); + } + +} diff --git a/.vortex/cli/vortex b/.vortex/cli/vortex index f689bf490..0cb498d03 100755 --- a/.vortex/cli/vortex +++ b/.vortex/cli/vortex @@ -11,6 +11,7 @@ declare(strict_types=1); use DrevOps\VortexCli\Command\BuildCommand; use DrevOps\VortexCli\Command\DoctorCommand; use DrevOps\VortexCli\Command\InstallCommand; +use DrevOps\VortexCli\Command\UpdateCommand; use DrevOps\VortexCli\Utils\Config; use DrevOps\VortexCli\Utils\Env; use Symfony\Component\Console\Application; @@ -28,6 +29,7 @@ $version = str_contains($version, 'vortex-cli-version') ? 'development' : $versi $application = new Application('Vortex CLI', $version); $application->add(new InstallCommand()); +$application->add(new UpdateCommand()); $application->add(new DoctorCommand()); $application->add(new BuildCommand()); From 3a1cf88ed3a691285a6f4eac9709cbfb2de8832e Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:10:19 +1000 Subject: [PATCH 06/12] [#2846] Added the 'configure' command. Reconfigures a project in place, with no template download: the project is both the tree answers are read from and the tree they are written to. The working directory is set past the environment so an ambient variable cannot redirect the write target, and a relative destination is resolved before any value is derived from it. '--apply' is honoured on both the interactive and the scripted path - the two differ only in how answers are reported, so there is no second branch to forget and nothing excluded from coverage. Writing is refused outside a Vortex project, and confirmed first when a person is watching. --- .vortex/cli/src/Command/ConfigureCommand.php | 232 ++++++++++++++++ .../cli/src/Command/DestinationAwareTrait.php | 6 +- .vortex/cli/src/Prompts/PromptManager.php | 15 +- .../Command/ConfigureCommandTest.php | 262 ++++++++++++++++++ .vortex/cli/vortex | 2 + 5 files changed, 510 insertions(+), 7 deletions(-) create mode 100644 .vortex/cli/src/Command/ConfigureCommand.php create mode 100644 .vortex/cli/tests/Functional/Command/ConfigureCommandTest.php diff --git a/.vortex/cli/src/Command/ConfigureCommand.php b/.vortex/cli/src/Command/ConfigureCommand.php new file mode 100644 index 000000000..24ce02f55 --- /dev/null +++ b/.vortex/cli/src/Command/ConfigureCommand.php @@ -0,0 +1,232 @@ +setName('configure'); + $this->setDescription('Reconfigure an existing project in place.'); + $this->setHelp(<<Collect answers for the current directory and print them without changing anything: + php vortex.phar configure + + Collect answers and write them to the project: + php vortex.phar configure --apply + + Reconfigure another directory without asking any question: + php vortex.phar configure --no-interaction --apply --destination=path/to/project + + Answer up front and write the result: + php vortex.phar configure --apply --prompts='{"name":"My Project"}' + + Answers are pre-filled from the existing project. No template is downloaded, + so the project is both the source the answers are read from and the tree they + are written to. +EOF + ); + $this->addDestinationOption(); + $this->addOption(static::OPTION_NO_INTERACTION, 'n', InputOption::VALUE_NONE, 'Do not ask any interactive question.'); + $this->addOption(static::OPTION_CONFIG, 'c', InputOption::VALUE_REQUIRED, 'A JSON string with options or a path to a JSON file.'); + $this->addOption(static::OPTION_APPLY, 'a', InputOption::VALUE_NONE, 'Write the collected answers to the project.'); + $this->addAgentSurfaceOptions(); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($input->getOption('help')) { + $output->write($this->getHelp()); + + return Command::SUCCESS; + } + + $agent_surface = $this->handleAgentSurface($input, $output); + if ($agent_surface !== NULL) { + return $agent_surface; + } + + Tui::init($output); + + try { + $config = $this->resolveConfig($input); + $interactive = !$config->getNoInteraction(); + + Tui::init($output, $interactive); + + $apply = (bool) $input->getOption(static::OPTION_APPLY); + if ($apply) { + $this->assertVortexProject($config); + } + + $prompt_manager = new PromptManager($config); + $prompt_manager->runPrompts(); + + if ($apply) { + if ($interactive) { + Tui::list($prompt_manager->getResponsesSummary(), 'Configuration summary'); + + if (!$prompt_manager->shouldProceed(sprintf('These answers will be written to the project directory "%s"', $config->getDst()), 'Apply the answers to the project?')) { + Tui::info('Aborting. No files were changed.'); + + return Command::SUCCESS; + } + } + + $this->apply($prompt_manager, $interactive); + } + elseif ($interactive) { + Tui::list($prompt_manager->getResponsesSummary(), 'Configuration summary'); + } + } + catch (\Exception $exception) { + Tui::output()->setVerbosity(OutputInterface::VERBOSITY_NORMAL); + Tui::error('Configuration failed with an error: ' . $exception->getMessage()); + + return Command::FAILURE; + } + + if ($interactive) { + $this->footer($apply); + } + else { + // The answers are this command's data output: a scripted caller reads + // them from stdout, so nothing else is written there. + $output->writeln((string) json_encode($prompt_manager->getResponses(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + + return Command::SUCCESS; + } + + /** + * Resolve the configuration for an in-place run. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * The input. + * + * @return \DrevOps\VortexCli\Utils\Config + * The resolved configuration. + */ + protected function resolveConfig(InputInterface $input): Config { + // Resolved to an absolute path first: a relative "." would otherwise reach + // basename() literally and derive a bogus default site name. + $destination = $this->getDestination($input); + + $options = $input->getOptions(); + $options['destination'] = $destination; + + [$config] = OptionsResolver::resolve($options); + + // Nothing is downloaded, so the project is both the tree the handlers read + // and the tree they write to. Set past the environment: an ambient working + // directory variable must not redirect the write target. + $config->set(Config::TMP, $destination, TRUE); + + // Version placeholders are stamped from the build rather than from a + // downloaded release, since there is no release to take it from. + $config->set(Config::VERSION, (string) $this->getApplication()?->getVersion(), TRUE); + + return $config; + } + + /** + * Refuse to write to a directory that does not hold a Vortex project. + * + * Applying answers rewrites files in place, so the target is confirmed as a + * Vortex project before anything is written. + * + * @param \DrevOps\VortexCli\Utils\Config $config + * The resolved configuration. + * + * @throws \RuntimeException + * When the destination is not a Vortex project. + */ + protected function assertVortexProject(Config $config): void { + if ($config->isVortexProject()) { + return; + } + + throw new \RuntimeException(sprintf('"%s" is not a Vortex project, so there is nothing to reconfigure. Install Vortex into it first.', $config->getDst())); + } + + /** + * Write the collected answers to the project. + * + * @param \DrevOps\VortexCli\Prompts\PromptManager $prompt_manager + * The prompt manager holding the collected answers. + * @param bool $interactive + * Whether a person is watching. + */ + protected function apply(PromptManager $prompt_manager, bool $interactive): void { + $action = fn() => $prompt_manager->runProcessors(); + + if (!$interactive) { + $action(); + + return; + } + + Task::action( + label: 'Applying answers to the project', + action: $action, + success: 'Answers applied to the project', + ); + } + + /** + * Show what happened and what to do next. + * + * @param bool $applied + * Whether the answers were written to the project. + */ + protected function footer(bool $applied): void { + if (!$applied) { + Tui::box('No files were changed. Re-run with --apply to write these answers to the project.', 'Finished collecting answers'); + + return; + } + + Tui::box('Please review the changes and commit the required files.', 'Finished configuring Vortex'); + } + +} diff --git a/.vortex/cli/src/Command/DestinationAwareTrait.php b/.vortex/cli/src/Command/DestinationAwareTrait.php index 06c306fdb..b7abb45fd 100644 --- a/.vortex/cli/src/Command/DestinationAwareTrait.php +++ b/.vortex/cli/src/Command/DestinationAwareTrait.php @@ -12,12 +12,14 @@ */ trait DestinationAwareTrait { + const OPTION_DESTINATION = 'destination'; + /** * Add the destination option to the command. */ protected function addDestinationOption(): void { $this->addOption( - 'destination', + static::OPTION_DESTINATION, 'd', InputOption::VALUE_REQUIRED, 'Target directory for the operation. Defaults to current directory.' @@ -37,7 +39,7 @@ protected function addDestinationOption(): void { * If the destination directory does not exist. */ protected function getDestination(InputInterface $input): string { - $destination = $input->getOption('destination'); + $destination = $input->getOption(static::OPTION_DESTINATION); if ($destination === NULL || $destination === '') { return getcwd() ?: '.'; diff --git a/.vortex/cli/src/Prompts/PromptManager.php b/.vortex/cli/src/Prompts/PromptManager.php index d95bac8f6..12ab9981d 100644 --- a/.vortex/cli/src/Prompts/PromptManager.php +++ b/.vortex/cli/src/Prompts/PromptManager.php @@ -421,21 +421,26 @@ public function runPostBuild(string $result): string { } /** - * Check if the installation should proceed. + * Check if the operation should proceed. * * This method checks the configuration for the no-interaction mode and * prompts the user for confirmation if not in no-interaction mode. * + * @param string|null $message + * The line shown above the confirmation. Defaults to install wording. + * @param string|null $label + * The confirmation label. Defaults to install wording. + * * @return bool - * TRUE if the installation should proceed, FALSE otherwise. + * TRUE if the operation should proceed, FALSE otherwise. */ - public function shouldProceed(): bool { + public function shouldProceed(?string $message = NULL, ?string $label = NULL): bool { $proceed = TRUE; if (!$this->config->getNoInteraction()) { - Tui::line(sprintf('Vortex will be installed into your project\'s directory "%s"', $this->config->getDst())); + Tui::line($message ?? sprintf('Vortex will be installed into your project\'s directory "%s"', $this->config->getDst())); $proceed = confirm( - label: 'Proceed with installing Vortex?', + label: $label ?? 'Proceed with installing Vortex?', ); } diff --git a/.vortex/cli/tests/Functional/Command/ConfigureCommandTest.php b/.vortex/cli/tests/Functional/Command/ConfigureCommandTest.php new file mode 100644 index 000000000..11b4bd3a8 --- /dev/null +++ b/.vortex/cli/tests/Functional/Command/ConfigureCommandTest.php @@ -0,0 +1,262 @@ +installProject(); + $this->assertFileExists(self::$sut . '/AGENTS.md'); + + $this->runConfigure($this->configureOptions($no_interaction, [ + '--' . ConfigureCommand::OPTION_APPLY => TRUE, + ])); + + $this->assertFileDoesNotExist(self::$sut . '/AGENTS.md', 'Turning the answer off should remove the file it controls'); + $this->assertFileDoesNotExist(self::$sut . '/CLAUDE.md'); + } + + /** + * Data provider for testApplyWritesToTheProject(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderApplyWritesToTheProject(): \Iterator { + yield 'scripted' => [TRUE]; + yield 'interactive' => [FALSE]; + } + + /** + * Without --apply nothing is written on either path. + */ + #[DataProvider('dataProviderWithoutApplyNothingChanges')] + public function testWithoutApplyNothingChanges(bool $no_interaction): void { + $this->installProject(); + + $before = $this->snapshotOf(self::$sut); + + $this->runConfigure($this->configureOptions($no_interaction)); + + $this->assertSame($before, $this->snapshotOf(self::$sut), 'No file should change without --apply'); + $this->assertFileExists(self::$sut . '/AGENTS.md'); + } + + /** + * Data provider for testWithoutApplyNothingChanges(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderWithoutApplyNothingChanges(): \Iterator { + yield 'scripted' => [TRUE]; + yield 'interactive' => [FALSE]; + } + + /** + * A scripted run emits the answers as JSON and nothing else. + */ + public function testScriptedRunEmitsAnswersAsJson(): void { + $this->installProject(); + + $output = $this->runConfigure([ + '--' . ConfigureCommand::OPTION_DESTINATION => self::$sut, + '--' . ConfigureCommand::OPTION_NO_INTERACTION => TRUE, + ]); + + $this->assertJson(trim($output), 'A scripted run should emit only the answers as JSON'); + + $answers = json_decode(trim($output), TRUE); + $this->assertIsArray($answers); + $this->assertArrayHasKey(Name::id(), $answers); + } + + /** + * An interactive run reports a summary instead of raw JSON. + */ + public function testInteractiveRunReportsSummary(): void { + $this->installProject(); + + $this->runConfigure(['--' . ConfigureCommand::OPTION_DESTINATION => self::$sut]); + + $this->assertApplicationAnyOutputContainsOrNot([ + '* Configuration summary', + '* Finished collecting answers', + '* Re-run with --apply', + ]); + } + + /** + * Answers are pre-filled from the project being reconfigured. + */ + public function testDiscoveryPreFillsFromTheProject(): void { + $this->installProject([InstallCommand::OPTION_PROMPTS => (string) json_encode([Name::id() => 'Star Wars'])]); + + $output = $this->runConfigure([ + '--' . ConfigureCommand::OPTION_DESTINATION => self::$sut, + '--' . ConfigureCommand::OPTION_NO_INTERACTION => TRUE, + ]); + + $answers = json_decode(trim($output), TRUE); + $this->assertIsArray($answers); + $this->assertSame('Star Wars', $answers[Name::id()], 'The site name should be discovered from the project rather than defaulted'); + } + + /** + * A relative destination is resolved before any value is derived from it. + */ + public function testRelativeDestinationIsResolved(): void { + $this->installProject(); + + $cwd = getcwd(); + $this->assertNotFalse($cwd); + chdir(self::$sut); + + try { + $output = $this->runConfigure([ + '--' . ConfigureCommand::OPTION_DESTINATION => '.', + '--' . ConfigureCommand::OPTION_NO_INTERACTION => TRUE, + ]); + } + finally { + chdir($cwd); + } + + $answers = json_decode(trim($output), TRUE); + $this->assertIsArray($answers); + $this->assertNotSame('.', $answers[Name::id()], 'A relative destination should not reach the derived values literally'); + } + + /** + * Applying to a directory that is not a Vortex project is refused. + */ + public function testApplyRefusesNonVortexProject(): void { + $this->runConfigure([ + '--' . ConfigureCommand::OPTION_DESTINATION => self::$sut, + '--' . ConfigureCommand::OPTION_NO_INTERACTION => TRUE, + '--' . ConfigureCommand::OPTION_APPLY => TRUE, + ], TRUE); + + $this->assertApplicationAnyOutputContainsOrNot([ + '* is not a Vortex project, so there is nothing to reconfigure', + ]); + } + + /** + * The agent surface answers on the configure verb as well. + */ + #[DataProvider('dataProviderConfigureExposesAgentSurface')] + public function testConfigureExposesAgentSurface(string $option, string $expected): void { + $output = $this->runConfigure(['--' . $option => TRUE]); + + $this->assertStringContainsString($expected, $output); + } + + /** + * Data provider for testConfigureExposesAgentSurface(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderConfigureExposesAgentSurface(): \Iterator { + yield 'schema' => [ConfigureCommand::OPTION_SCHEMA, '"prompts"']; + yield 'agent help' => [ConfigureCommand::OPTION_AGENT_HELP, 'AI Agent Instructions']; + } + + /** + * Options that answer one file-controlling question with "no". + * + * The answer is one the handlers can act on in an already-installed project, + * so a run that honours it leaves a visible trace on disk. + * + * @return array + * The command options. + */ + protected function configureOptions(bool $no_interaction, array $extra = []): array { + $options = [ + '--' . ConfigureCommand::OPTION_DESTINATION => self::$sut, + '--' . ConfigureCommand::OPTION_PROMPTS => (string) json_encode([AiCodeInstructions::id() => FALSE]), + ] + $extra; + + if ($no_interaction) { + $options['--' . ConfigureCommand::OPTION_NO_INTERACTION] = TRUE; + } + + return $options; + } + + /** + * Run the configure command against a fresh application. + */ + protected function runConfigure(array $options, bool $expect_failure = FALSE): string { + $command = new ConfigureCommand(); + static::applicationInitFromCommand($command); + + return $this->applicationRun($options, [], $expect_failure); + } + + /** + * Install Vortex into the destination so there is a project to reconfigure. + */ + protected function installProject(array $options = []): void { + Env::put(Config::IS_DEMO_DB_FETCH_SKIP, '1'); + + static::applicationInitFromCommand(InstallCommand::class); + + $this->runNonInteractiveInstall(options: $options); + } + + /** + * Capture the content of every file in a directory, keyed by relative path. + * + * @return array + * File contents keyed by path. + */ + protected function snapshotOf(string $dir): array { + $snapshot = []; + + foreach (File::scandir($dir, File::ignoredPaths()) as $path) { + $snapshot[str_replace($dir, '', (string) $path)] = (string) file_get_contents((string) $path); + } + + return $snapshot; + } + +} diff --git a/.vortex/cli/vortex b/.vortex/cli/vortex index 0cb498d03..c623c3f5d 100755 --- a/.vortex/cli/vortex +++ b/.vortex/cli/vortex @@ -9,6 +9,7 @@ declare(strict_types=1); use DrevOps\VortexCli\Command\BuildCommand; +use DrevOps\VortexCli\Command\ConfigureCommand; use DrevOps\VortexCli\Command\DoctorCommand; use DrevOps\VortexCli\Command\InstallCommand; use DrevOps\VortexCli\Command\UpdateCommand; @@ -30,6 +31,7 @@ $application = new Application('Vortex CLI', $version); $application->add(new InstallCommand()); $application->add(new UpdateCommand()); +$application->add(new ConfigureCommand()); $application->add(new DoctorCommand()); $application->add(new BuildCommand()); From b88556954e9fa5d22f8935ac6550ce51f791f7cb Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:13:58 +1000 Subject: [PATCH 07/12] [#2846] Routed a bare invocation by the state of the target directory. An existing Vortex project is reconfigured, anything else gets a fresh install. The router declares no options of its own and passes the input through, so the selected verb sees exactly what was typed and there is no second copy of the option set to drift. It reads the destination from the raw input rather than a bound option, because a destination that does not exist yet is the ordinary fresh-install case. The agent surface is reachable through both routes, so a downloaded binary run with no arguments can still describe its own questions. Each verb is also exercised from the built PHAR. --- .vortex/cli/src/Command/RouteCommand.php | 113 ++++++++++++++++ .../Functional/Command/RouteCommandTest.php | 127 ++++++++++++++++++ .vortex/cli/tests/Functional/PharTest.php | 47 ++++++- .vortex/cli/vortex | 4 +- 4 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 .vortex/cli/src/Command/RouteCommand.php create mode 100644 .vortex/cli/tests/Functional/Command/RouteCommandTest.php diff --git a/.vortex/cli/src/Command/RouteCommand.php b/.vortex/cli/src/Command/RouteCommand.php new file mode 100644 index 000000000..ab3d8801c --- /dev/null +++ b/.vortex/cli/src/Command/RouteCommand.php @@ -0,0 +1,113 @@ +setName('route'); + $this->setDescription('Install into a new directory, or reconfigure an existing Vortex project.'); + $this->setHelp(<<Install into the current directory: + php vortex.phar + + Reconfigure the Vortex project in the current directory: + php vortex.phar + + Describe the available questions, whichever verb applies: + php vortex.phar --schema + php vortex.phar --agent-help + + List every command and its options: + php vortex.phar list +EOF + ); + + // The selected verb defines the options, so this one accepts them all and + // passes them through rather than declaring a second copy that could drift. + $this->ignoreValidationErrors(); + + // Hidden from the command list: it is reached by typing nothing, never by + // name. + $this->setHidden(TRUE); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $application = $this->getApplication(); + + if (!$application instanceof Application) { + // @codeCoverageIgnoreStart + return Command::FAILURE; + // @codeCoverageIgnoreEnd + } + + return $application->find($this->target($this->directory($input)))->run($input, $output); + } + + /** + * The command name a directory resolves to. + * + * @param string $directory + * The target directory. + * + * @return string + * The command name to run. + */ + public function target(string $directory): string { + return Project::isVortex($directory) ? 'configure' : 'install'; + } + + /** + * The directory to resolve on. + * + * Read from the raw input rather than a bound option: this command declares + * no options of its own, and a destination that does not exist yet is the + * most ordinary fresh-install case there is. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * The input. + * + * @return string + * The target directory. + */ + protected function directory(InputInterface $input): string { + $destination = $input->getParameterOption(['--destination', '-d'], NULL, TRUE); + + return is_string($destination) && $destination !== '' ? $destination : (string) getcwd(); + } + +} diff --git a/.vortex/cli/tests/Functional/Command/RouteCommandTest.php b/.vortex/cli/tests/Functional/Command/RouteCommandTest.php new file mode 100644 index 000000000..20fbcabb7 --- /dev/null +++ b/.vortex/cli/tests/Functional/Command/RouteCommandTest.php @@ -0,0 +1,127 @@ +assertSame($expected, (new RouteCommand())->target($dir)); + } + + /** + * Data provider for testTarget(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderTarget(): \Iterator { + yield 'empty directory installs' => [NULL, 'install']; + yield 'unrelated project installs' => ['# Some other project', 'install']; + yield 'vortex project reconfigures' => ['[![Vortex](https://img.shields.io/badge/Vortex-1.40.0-blue.svg)]', 'configure']; + } + + public function testTargetForMissingDirectory(): void { + $this->assertSame('install', (new RouteCommand())->target(self::$tmp . '/never_created_' . uniqid())); + } + + /** + * A bare invocation in an empty directory can describe its own questions. + * + * This is the entry point a downloaded binary is run from, so the agent + * surface has to be reachable through it. + */ + public function testBareInvocationDescribesQuestionsWhenInstalling(): void { + $output = $this->runRouted(['--schema' => TRUE]); + + $this->assertJson(trim($output)); + $this->assertStringContainsString('"prompts"', $output); + } + + /** + * The same holds once the directory holds a project and routes elsewhere. + */ + public function testBareInvocationDescribesQuestionsWhenConfiguring(): void { + $this->markSutAsVortexProject(); + + $output = $this->runRouted(['--schema' => TRUE]); + + $this->assertJson(trim($output)); + $this->assertStringContainsString('"prompts"', $output); + } + + /** + * Agent instructions are reachable from a bare invocation too. + */ + public function testBareInvocationPrintsAgentHelp(): void { + $this->assertStringContainsString('AI Agent Instructions', $this->runRouted(['--agent-help' => TRUE])); + } + + /** + * A destination holding a project is routed to the configure verb. + */ + public function testRoutesToConfigureForExistingProject(): void { + $this->markSutAsVortexProject(); + + $output = $this->runRouted([ + '--destination' => self::$sut, + '--no-interaction' => TRUE, + ]); + + // The configure verb reports the collected answers as JSON; the install + // verb never would. + $this->assertJson(trim($output)); + } + + /** + * Run the application with the route command as the default. + */ + protected function runRouted(array $options, bool $expect_failure = FALSE): string { + static::applicationInitFromCommand(new RouteCommand(), FALSE); + + $application = $this->applicationGet(); + $application->add(new InstallCommand()); + $application->add(new ConfigureCommand()); + $application->setDefaultCommand('route'); + + return $this->applicationRun($options, [], $expect_failure); + } + + /** + * Make the destination look like an installed project. + */ + protected function markSutAsVortexProject(): void { + UpstreamFile::dump(self::$sut . '/README.md', '[![Vortex](https://img.shields.io/badge/Vortex-1.40.0-65ACBC.svg)](https://github.com/drevops/vortex)'); + } + +} diff --git a/.vortex/cli/tests/Functional/PharTest.php b/.vortex/cli/tests/Functional/PharTest.php index 241c1b78a..06f1fdf6c 100644 --- a/.vortex/cli/tests/Functional/PharTest.php +++ b/.vortex/cli/tests/Functional/PharTest.php @@ -9,6 +9,7 @@ use DrevOps\VortexCli\Command\InstallCommand; use DrevOps\VortexCli\Utils\File; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; /** * Test PHAR cleanup functionality. @@ -77,12 +78,56 @@ public function testPharOptionHelp(): void { $this->runInstallationWithPhar($this->pharFile, ['help' => TRUE]); $this->assertProcessSuccessful(); - $this->assertProcessOutputContains('Install Vortex from remote or local repository'); + // A bare invocation is resolved by the target directory, so its help + // describes that rather than any one verb. + $this->assertProcessOutputContains('Install into a new directory, or reconfigure an existing Vortex project.'); $this->assertProcessOutputNotContains('Welcome to the Vortex CLI non-interactive install'); $this->assertFileDoesNotExist(static::$sut . DIRECTORY_SEPARATOR . 'composer.json', 'Composer file should NOT be created when --help flag is used'); $this->assertFileExists($this->pharFile, 'PHAR file should NOT be removed when --help option is used'); } + /** + * Every verb runs from the built PHAR. + */ + #[DataProvider('dataProviderPharRunsVerb')] + public function testPharRunsVerb(string $verb, string $expected): void { + $this->processRun('php', [$this->pharFile, $verb, '--help']); + + $this->assertProcessSuccessful(); + $this->assertProcessOutputContains($expected); + } + + /** + * Data provider for testPharRunsVerb(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderPharRunsVerb(): \Iterator { + yield 'install' => ['install', 'Install Vortex from remote or local repository.']; + yield 'update' => ['update', 'Update the project to a template version, re-applying your answers.']; + yield 'configure' => ['configure', 'Reconfigure an existing project in place.']; + yield 'doctor' => ['doctor', 'Diagnose the local environment for common problems.']; + yield 'build' => ['build', 'Build the site using ahoy build.']; + } + + /** + * The command list advertises every verb and hides the router. + */ + public function testPharListsVerbs(): void { + $this->processRun('php', [$this->pharFile, 'list']); + + $this->assertProcessSuccessful(); + + foreach (['install', 'update', 'configure', 'doctor', 'build'] as $verb) { + $this->assertProcessOutputContains($verb); + } + + // The router is reached by typing nothing, never by name, so its own + // description should not appear among the listed commands. + $this->assertProcessOutputNotContains('Install into a new directory, or reconfigure an existing Vortex project.'); + } + protected static function buildPhar(string $dst): void { fwrite(STDERR, 'Building CLI PHAR file...'); if (!file_exists('vendor')) { diff --git a/.vortex/cli/vortex b/.vortex/cli/vortex index c623c3f5d..8e39849ff 100755 --- a/.vortex/cli/vortex +++ b/.vortex/cli/vortex @@ -12,6 +12,7 @@ use DrevOps\VortexCli\Command\BuildCommand; use DrevOps\VortexCli\Command\ConfigureCommand; use DrevOps\VortexCli\Command\DoctorCommand; use DrevOps\VortexCli\Command\InstallCommand; +use DrevOps\VortexCli\Command\RouteCommand; use DrevOps\VortexCli\Command\UpdateCommand; use DrevOps\VortexCli\Utils\Config; use DrevOps\VortexCli\Utils\Env; @@ -34,7 +35,8 @@ $application->add(new UpdateCommand()); $application->add(new ConfigureCommand()); $application->add(new DoctorCommand()); $application->add(new BuildCommand()); +$application->add(new RouteCommand()); -$application->setDefaultCommand('install'); +$application->setDefaultCommand('route'); $application->run(); From 58a9fe56f9066aac67f046318ac6449fe8183be8 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:16:55 +1000 Subject: [PATCH 08/12] [#2846] Pointed the update consumers at the 'update' verb. 'vortex-update' calls the verb rather than re-running an install through the default command. The template harness gains a 'runUpdate()' alongside 'runInstall()', and the two CLI scenarios that install and then re-run against a newer template now exercise the update verb, which is what they were always describing. --- .vortex/tests/phpunit/Functional/CliTest.php | 12 +++++------ .vortex/tests/phpunit/Traits/SutTrait.php | 14 ++++++++++--- .vortex/tooling/src/vortex-update | 4 ++-- .../tooling/tests/Unit/UpdateVortexTest.php | 20 +++++++++---------- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/.vortex/tests/phpunit/Functional/CliTest.php b/.vortex/tests/phpunit/Functional/CliTest.php index 5a9b63f40..8d2735488 100644 --- a/.vortex/tests/phpunit/Functional/CliTest.php +++ b/.vortex/tests/phpunit/Functional/CliTest.php @@ -31,7 +31,7 @@ protected function setUp(): void { } #[Group('p4')] - public function testInstallFromLatest(): void { + public function testUpdateFromLatest(): void { $this->logSubstep('Add custom files to SUT'); File::dump('test1.txt', 'test content'); // File resides in directory that is included in Vortex when initialised. @@ -67,12 +67,12 @@ public function testInstallFromLatest(): void { static::$sutEnv = [ // Unset the environment variable that forces using the remote repository - // in runInstall(). + // in runCli(). 'VORTEX_CLI_INSTALL_TEMPLATE_REPO' => FALSE, // Do not suppress the CLI output so it could be used in assertions. 'SHELL_VERBOSITY' => FALSE, ]; - $this->runInstall([sprintf('--uri=%s#%s', static::$repo, 'stable')]); + $this->runUpdate([sprintf('--uri=%s#%s', static::$repo, 'stable')]); $this->assertProcessOutputContains(static::$repo); $this->assertProcessOutputNotContains($latest_commit1); $this->assertProcessOutputNotContains($latest_commit2); @@ -91,7 +91,7 @@ public function testInstallFromLatest(): void { } #[Group('p3')] - public function testInstallFromRef(): void { + public function testUpdateFromRef(): void { $this->logSubstep('Add custom files to SUT'); File::dump('test1.txt', 'test content'); // File resides in directory that is included in Vortex when initialised. @@ -127,12 +127,12 @@ public function testInstallFromRef(): void { static::$sutEnv = [ // Unset the environment variable that forces using the remote repository - // in runInstall(). + // in runCli(). 'VORTEX_CLI_INSTALL_TEMPLATE_REPO' => FALSE, // Do not suppress the CLI output so it could be used in assertions. 'SHELL_VERBOSITY' => FALSE, ]; - $this->runInstall([sprintf('--uri=%s#%s', static::$repo, $latest_commit1)]); + $this->runUpdate([sprintf('--uri=%s#%s', static::$repo, $latest_commit1)]); $this->assertProcessOutputContains(static::$repo); $this->assertProcessOutputContains($latest_commit1); $this->gitAssertIsRepository(static::$sut); diff --git a/.vortex/tests/phpunit/Traits/SutTrait.php b/.vortex/tests/phpunit/Traits/SutTrait.php index e7d3ae211..e4c420c08 100644 --- a/.vortex/tests/phpunit/Traits/SutTrait.php +++ b/.vortex/tests/phpunit/Traits/SutTrait.php @@ -221,6 +221,14 @@ protected function linkToolingBinaries(string $sut_root, string $target): void { } protected function runInstall(array $arguments = []): void { + $this->runCli('install', $arguments); + } + + protected function runUpdate(array $arguments = []): void { + $this->runCli('update', $arguments); + } + + protected function runCli(string $verb, array $arguments = []): void { $this->logNote('Switch to the project root directory'); chdir(static::locationsRoot()); @@ -228,13 +236,13 @@ protected function runInstall(array $arguments = []): void { // @todo Convert options to $arguments once // ProcessTrait::processParseCommand() is fixed. - $cmd = sprintf('php %s install --no-interaction --destination=%s', static::CLI_BIN, escapeshellarg(static::locationsSut())); + $cmd = sprintf('php %s %s --no-interaction --destination=%s', static::CLI_BIN, $verb, escapeshellarg(static::locationsSut())); if (!empty(static::$sutPrompts)) { $cmd .= ' --prompts=' . escapeshellarg((string) json_encode(static::$sutPrompts)); } - $this->logNote('Run the Vortex CLI to install the project'); + $this->logNote(sprintf('Run the Vortex CLI to %s the project', $verb)); $this->cmd( $cmd, arg: $arguments, @@ -260,7 +268,7 @@ protected function runInstall(array $arguments = []): void { txt: 'Run the Vortex CLI' ); - $this->logNote('Switch back to the SUT directory after the installation has run'); + $this->logNote('Switch back to the SUT directory after the CLI has run'); chdir(static::locationsSut()); $this->adjustCodebaseForUnmountedVolumes(); diff --git a/.vortex/tooling/src/vortex-update b/.vortex/tooling/src/vortex-update index 82e460fbc..2241ba01e 100755 --- a/.vortex/tooling/src/vortex-update +++ b/.vortex/tooling/src/vortex-update @@ -93,10 +93,10 @@ else { $exit_code = 0; TASK('Running the Vortex CLI.', 'Finished running the Vortex CLI.', function () use ($interactive, $cli_path, $template_repo, &$exit_code): void { if ($interactive === '0') { - passthru(sprintf('php %s --no-interaction --uri=%s', escapeshellarg($cli_path), escapeshellarg($template_repo)), $exit_code); + passthru(sprintf('php %s update --no-interaction --uri=%s', escapeshellarg($cli_path), escapeshellarg($template_repo)), $exit_code); } else { - passthru(sprintf('php %s --uri=%s', escapeshellarg($cli_path), escapeshellarg($template_repo)), $exit_code); + passthru(sprintf('php %s update --uri=%s', escapeshellarg($cli_path), escapeshellarg($template_repo)), $exit_code); } if ($exit_code !== 0) { diff --git a/.vortex/tooling/tests/Unit/UpdateVortexTest.php b/.vortex/tooling/tests/Unit/UpdateVortexTest.php index 4a820a4e5..8312cdfb1 100644 --- a/.vortex/tooling/tests/Unit/UpdateVortexTest.php +++ b/.vortex/tooling/tests/Unit/UpdateVortexTest.php @@ -101,7 +101,7 @@ public static function dataProviderUpdateVortex(): array { [], [ $download_request(), - ['cmd' => "php 'vortex.phar' --no-interaction --uri='" . $default_repo . "'", 'result_code' => 0], + ['cmd' => "php 'vortex.phar' update --no-interaction --uri='" . $default_repo . "'", 'result_code' => 0], ], [ '* Using Vortex CLI from URL: https://www.vortextemplate.com/install', @@ -113,7 +113,7 @@ public static function dataProviderUpdateVortex(): array { 'local CLI path' => [ ['VORTEX_CLI_PATH' => '__TMP__/my-vortex.phar'], [ - ['cmd' => "php '__TMP__/my-vortex.phar' --no-interaction --uri='" . $default_repo . "'", 'result_code' => 0], + ['cmd' => "php '__TMP__/my-vortex.phar' update --no-interaction --uri='" . $default_repo . "'", 'result_code' => 0], ], [ '* Using Vortex CLI from local path: __TMP__/my-vortex.phar', @@ -127,7 +127,7 @@ public static function dataProviderUpdateVortex(): array { 'superseded local CLI path still resolves and warns' => [ ['VORTEX_CLI_PATH' => '', 'VORTEX_INSTALLER_PATH' => '__TMP__/my-vortex.phar'], [ - ['cmd' => "php '__TMP__/my-vortex.phar' --no-interaction --uri='" . $default_repo . "'", 'result_code' => 0], + ['cmd' => "php '__TMP__/my-vortex.phar' update --no-interaction --uri='" . $default_repo . "'", 'result_code' => 0], ], [ '* VORTEX_INSTALLER_PATH is deprecated and will be removed in a future release. Use VORTEX_CLI_PATH instead.', @@ -161,7 +161,7 @@ public static function dataProviderUpdateVortex(): array { ['VORTEX_CLI_INSTALL_INTERACTIVE' => '1'], [ $download_request(), - ['cmd' => "php 'vortex.phar' --uri='" . $default_repo . "'", 'result_code' => 0], + ['cmd' => "php 'vortex.phar' update --uri='" . $default_repo . "'", 'result_code' => 0], ], [ '* Using Vortex CLI from URL:', @@ -173,7 +173,7 @@ public static function dataProviderUpdateVortex(): array { [], [ $download_request(), - ['cmd' => "php 'vortex.phar' --uri='" . $default_repo . "'", 'result_code' => 0], + ['cmd' => "php 'vortex.phar' update --uri='" . $default_repo . "'", 'result_code' => 0], ], [ '* Using Vortex CLI from URL:', @@ -186,7 +186,7 @@ public static function dataProviderUpdateVortex(): array { [], [ $download_request(), - ['cmd' => "php 'vortex.phar' --no-interaction --uri='file:///local/path/to/vortex.git#1.2.3'", 'result_code' => 0], + ['cmd' => "php 'vortex.phar' update --no-interaction --uri='file:///local/path/to/vortex.git#1.2.3'", 'result_code' => 0], ], [ '* Using Vortex CLI from URL:', @@ -199,7 +199,7 @@ public static function dataProviderUpdateVortex(): array { [], [ $download_request(), - ['cmd' => "php 'vortex.phar' --no-interaction --uri='/local/path/to/vortex#stable'", 'result_code' => 0], + ['cmd' => "php 'vortex.phar' update --no-interaction --uri='/local/path/to/vortex#stable'", 'result_code' => 0], ], [ '* Using Vortex CLI from URL:', @@ -212,7 +212,7 @@ public static function dataProviderUpdateVortex(): array { [], [ $download_request(), - ['cmd' => "php 'vortex.phar' --no-interaction --uri='git@github.com:drevops/vortex.git#v1.2.3'", 'result_code' => 0], + ['cmd' => "php 'vortex.phar' update --no-interaction --uri='git@github.com:drevops/vortex.git#v1.2.3'", 'result_code' => 0], ], [ '* Using Vortex CLI from URL:', @@ -225,7 +225,7 @@ public static function dataProviderUpdateVortex(): array { [], [ $download_request(), - ['cmd' => "php 'vortex.phar' --uri='https://github.com/custom/repo.git#main'", 'result_code' => 0], + ['cmd' => "php 'vortex.phar' update --uri='https://github.com/custom/repo.git#main'", 'result_code' => 0], ], [ '* Using Vortex CLI from URL:', @@ -238,7 +238,7 @@ public static function dataProviderUpdateVortex(): array { [], [ $download_request(), - ['cmd' => "php 'vortex.phar' --no-interaction --uri='" . $default_repo . "'", 'result_code' => 1], + ['cmd' => "php 'vortex.phar' update --no-interaction --uri='" . $default_repo . "'", 'result_code' => 1], ], [], NULL, From 9d48d90bb9a0702ebe17047732252dffcaf89989 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:19:47 +1000 Subject: [PATCH 09/12] [#2846] Documented the full command surface. The CLI reference now covers every verb rather than installation alone, including what 'configure' can and cannot change in an already-installed project. The agent instructions describe the verbs, the bare invocation entry point and 'configure --apply', so an agent can drive the whole surface from '--agent-help'. --- .vortex/cli/src/Schema/AgentHelp.php | 48 ++++++++++-- .vortex/docs/content/cli.mdx | 78 ++++++++++++++++--- .../content/contributing/maintenance/cli.mdx | 6 +- 3 files changed, 113 insertions(+), 19 deletions(-) diff --git a/.vortex/cli/src/Schema/AgentHelp.php b/.vortex/cli/src/Schema/AgentHelp.php index 157efd521..e770335cf 100644 --- a/.vortex/cli/src/Schema/AgentHelp.php +++ b/.vortex/cli/src/Schema/AgentHelp.php @@ -22,8 +22,23 @@ public static function render(): string { # Vortex CLI - AI Agent Instructions You are interacting with the Vortex CLI, a tool that sets up Drupal projects -from the Vortex template. This guide explains how to use its install command -programmatically. +from the Vortex template. This guide explains how to use it programmatically. + +## Verbs + +- `install`: download the template and write a new project. +- `update`: update an existing project to a template version, re-applying the + answers discovered from it. `--to` names the target version. +- `configure`: reconfigure an existing project in place, without downloading a + template. Collects answers; writes them only with `--apply`. +- `doctor`: report which local tools are installed and running. +- `build`: build the site with `ahoy build`. + +Running the CLI with no verb resolves by the state of the target directory: an +existing Vortex project is reconfigured, anything else gets a fresh install. +`--schema`, `--validate` and `--agent-help` answer the same way whichever verb +applies, so a downloaded binary run with no arguments can always describe its +own questions. ## Workflow @@ -55,18 +70,37 @@ public static function render(): string { php vortex.phar --validate --prompts=prompts.json # Install non-interactively -php vortex.phar --no-interaction --prompts='' --destination=./my-project +php vortex.phar install --no-interaction --prompts='' --destination=./my-project + +# Update an existing project to a named template version +php vortex.phar update --no-interaction --to=1.2.3 --destination=./my-project + +# Read the answers of an existing project without changing it +php vortex.phar configure --no-interaction --destination=./my-project + +# Reconfigure an existing project in place +php vortex.phar configure --no-interaction --apply --prompts='' --destination=./my-project + +# Check the local tooling +php vortex.phar doctor ``` ## Options - `--prompts` (`-p`): JSON object of prompt answers, keyed by prompt ID. Accepts a JSON string or a path to a JSON file. -- `--config` (`-c`): JSON object of install configuration (repository, ref, +- `--config` (`-c`): JSON object of CLI configuration (repository, ref, and other internal settings). Not for prompt answers. -- `--destination`: Target directory for installation. +- `--destination`: Target directory for the operation. - `--no-interaction` (`-n`): Non-interactive mode. Prompts without answers in - `--prompts` use discovered or default values. + `--prompts` use discovered or default values. Closing guidance is suppressed, + so stdout carries only the command's own output. +- `--to`: `update` only. The template version to update to. +- `--apply` (`-a`): `configure` only. Write the collected answers to the + project. Without it nothing on disk changes. + +A non-interactive `configure` writes the collected answers to stdout as JSON, +which is the machine-readable form of the project's current configuration. ## Schema Format @@ -121,6 +155,8 @@ public static function render(): string { - Use `--validate` to check your prompt answers before installing. - The `resolved` field in validation output shows the complete config that would be used, including defaults. +- Run `configure` without `--apply` first to read a project's current answers, + then re-run with `--apply` once the answer set is right. AGENT_HELP; } diff --git a/.vortex/docs/content/cli.mdx b/.vortex/docs/content/cli.mdx index bbcf546a0..f1bbc8526 100644 --- a/.vortex/docs/content/cli.mdx +++ b/.vortex/docs/content/cli.mdx @@ -4,7 +4,7 @@ sidebar_position: 4 # CLI -The **Vortex** CLI is a single self-contained binary that scaffolds a new project from the template and updates an existing one. It ships as a PHAR, so it needs nothing installed beyond PHP. +The **Vortex** CLI is a single self-contained binary that scaffolds a new project from the template, updates an existing one, reconfigures it, and reports on the local tooling. It ships as a PHAR, so it needs nothing installed beyond PHP. ```shell title="Download and run" curl -SsL https://www.vortextemplate.com/install > vortex.phar && php vortex.phar @@ -14,13 +14,15 @@ For the step-by-step walkthrough of setting up a new project, see [Installation] ## Commands -| Command | Purpose | -|----------------------|------------------------------------------------------| -| `install` | Install **Vortex** from a remote or local repository | -| `check-requirements` | Check that the required tools are installed | -| `build` | Build the site using `ahoy build` | +| Command | Purpose | +|-------------|-----------------------------------------------------------------| +| `install` | Install **Vortex** from a remote or local repository | +| `update` | Update an existing project to a template version | +| `configure` | Reconfigure an existing project in place, without downloading | +| `doctor` | Report which required tools are installed and running | +| `build` | Build the site using `ahoy build` | -`install` is the default command, so `php vortex.phar` and `php vortex.phar install` do the same thing. +Run without a command and the CLI resolves by the state of the target directory: an existing **Vortex** project is reconfigured, anything else gets a fresh install. So `php vortex.phar` in an empty directory installs, and the same command inside a project reconfigures it. ```shell title="List every command and option" php vortex.phar list @@ -29,7 +31,7 @@ php vortex.phar install --help ### install -Downloads the template, asks the configuration questions, and writes the result into the destination directory. Run it in an existing **Vortex** project to update that project instead. +Downloads the template, asks the configuration questions, and writes the result into the destination directory. | Option | Short | Description | |-----------------------|-------|---------------------------------------------------------------------------------| @@ -53,15 +55,61 @@ php vortex.phar install --uri=https://github.com/drevops/vortex.git#stable php vortex.phar install --uri=https://github.com/drevops/vortex.git#1.2.3 ``` -### check-requirements +### update -Checks for the tools a **Vortex** project needs: Docker, Docker Compose, Ahoy and Pygmy. +Updates an existing project to a template version, re-applying the answers discovered from the project. It takes every `install` option, plus: + +| Option | Short | Description | +|----------|-------|--------------------------------------------------------------------------| +| `--to` | | The template version to update to. Defaults to the latest of this major | + +```shell title="Updating a project" +php vortex.phar update +php vortex.phar update --to=1.2.3 +php vortex.phar update --no-interaction --destination=./my-project +``` + +An explicit `--uri` names both the repository and the ref, so it takes precedence over `--to`. A project of a different major version is refused, with a pointer to the release that can update it. + +### configure + +Reconfigures an existing project in place. Nothing is downloaded: the project is both the source the answers are read from and the tree they are written to. + +| Option | Short | Description | +|--------------------|-------|---------------------------------------------------------------------------| +| `--destination` | `-d` | Project directory. Defaults to the current directory | +| `--apply` | `-a` | Write the collected answers to the project | +| `--no-interaction` | `-n` | Do not ask any interactive question | +| `--config` | `-c` | JSON string, or path to a JSON file, of CLI configuration | +| `--prompts` | `-p` | JSON string, or path to a JSON file, of prompt answers keyed by prompt ID | +| `--schema` | | Output the prompt schema as JSON and exit | +| `--validate` | | Validate the supplied answers without changing anything | +| `--agent-help` | | Output instructions for AI agents and exit | + +Without `--apply` nothing on disk changes: the run collects answers and reports them, which is how you read a project's current configuration. A non-interactive run writes them to stdout as JSON. + +```shell title="Reading and then writing a configuration" +php vortex.phar configure --no-interaction > current.json +php vortex.phar configure --apply --prompts=current.json +``` + +:::note What `configure` can change + +`configure` re-runs the same processing an install performs, against the project instead of a freshly downloaded template. Answers whose effect is to remove something - a service, a CI provider's files, the AI agent instructions - take effect. Answers that fill in template placeholders, such as the site name, do not: the placeholders were consumed at install time and are no longer in the project. Use `update` to bring template changes back in. + +::: + +### doctor + +Checks for the tools a **Vortex** project needs: Docker, Docker Compose, Ahoy and Pygmy. It reports whether each is installed *and* running, the version of each one present, and how to install the ones that are missing. It only reports - it never changes the project. | Option | Short | Description | |----------------|-------|--------------------------------------------------------| | `--only` | `-o` | Comma-separated subset of requirements to check | | `--no-summary` | | Hide the summary listing tool versions | +This is the host-level check that runs before a project exists. Once a project is built, [`ahoy doctor`](./tools/doctor) goes further and inspects the running stack itself - ports, containers, the web server and the site bootstrap. + ### build Builds the site by running `ahoy build` in the project directory. @@ -89,12 +137,20 @@ php vortex.phar install --no-interaction --prompts=prompts.json --destination=./ `--prompts` keys are the prompt IDs from `--schema`. `--config` is separate: it carries CLI configuration such as the repository and ref, not prompt answers. +`--schema`, `--validate` and `--agent-help` are available on `install`, `update` and `configure`, and answer identically on each: the questions belong to the build, not to a verb. They also answer without a command name, so a freshly downloaded binary can describe its own questions before anything exists on disk: + +```shell title="Describe the questions with nothing installed yet" +php vortex.phar --schema +``` + +Closing guidance is suppressed on any non-interactive run, so a script's stdout carries only the output it asked for. + :::tip Using an AI agent `--agent-help` prints the whole workflow above as instructions written for an AI coding agent: ```shell -php vortex.phar install --agent-help +php vortex.phar --agent-help ``` ::: diff --git a/.vortex/docs/content/contributing/maintenance/cli.mdx b/.vortex/docs/content/contributing/maintenance/cli.mdx index 4b9a5c75e..789ff2577 100644 --- a/.vortex/docs/content/contributing/maintenance/cli.mdx +++ b/.vortex/docs/content/contributing/maintenance/cli.mdx @@ -35,8 +35,10 @@ the CLI supports **non-interactive installs** (via the `--no-interaction` flag) for both new and existing projects, as well as updates — making it suitable for automated pipelines and CI environments. -The binary is `vortex` and it exposes three commands: `install` (the default), -`check-requirements` and `build`. +The binary is `vortex` and it exposes five commands: `install`, `update`, +`configure`, `doctor` and `build`. Running it without a command resolves by the +state of the target directory — an existing **Vortex** project is reconfigured, +anything else gets a fresh install. Prompt answers can be provided from multiple sources, resolved in the following priority order: From b041670048127e8de8e3f727d4159d09ee774a80 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:39:30 +1000 Subject: [PATCH 10/12] [#2846] Isolated the environment and working directory between tests. Running the CLI loads the destination's '.env' into the process, so a functional test left variables such as 'VORTEX_PROJECT' resolving in whichever test ran next - and the discovery tests read exactly those. Locations are derived from the working directory, so a test that chdir'd elsewhere resolved later paths against the wrong root. Both are now cleared on teardown, which makes the suite independent of execution order. --- .../cli/tests/Functional/FunctionalTestCase.php | 9 +++++++++ .vortex/cli/tests/Unit/UnitTestCase.php | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.vortex/cli/tests/Functional/FunctionalTestCase.php b/.vortex/cli/tests/Functional/FunctionalTestCase.php index 233813cb2..de76263f2 100644 --- a/.vortex/cli/tests/Functional/FunctionalTestCase.php +++ b/.vortex/cli/tests/Functional/FunctionalTestCase.php @@ -39,6 +39,15 @@ public static function setUpBeforeClass(): void { protected function tearDown(): void { static::tuiTearDown(); + // Running the CLI loads the destination's .env into the process, so the + // variables it defines are cleared here rather than being left to resolve + // in whichever test runs next. + static::envUnsetPrefix('VORTEX_'); + static::envUnsetPrefix('DRUPAL_'); + static::envUnsetPrefix('LAGOON_'); + static::envUnset('WEBROOT'); + static::envUnset('TZ'); + if (empty(static::$fixtures)) { throw new \RuntimeException('Fixtures directory is not set.'); } diff --git a/.vortex/cli/tests/Unit/UnitTestCase.php b/.vortex/cli/tests/Unit/UnitTestCase.php index ac6d5ef7a..4bff0bceb 100644 --- a/.vortex/cli/tests/Unit/UnitTestCase.php +++ b/.vortex/cli/tests/Unit/UnitTestCase.php @@ -28,6 +28,15 @@ abstract class UnitTestCase extends UpstreamUnitTestCase { use SnapshotTrait; use EnvTrait; + /** + * The working directory the suite was started from. + * + * Locations are derived from the working directory, so a test that leaves it + * somewhere else would resolve every later test's paths against the wrong + * root. + */ + protected static ?string $originalCwd = NULL; + /** * {@inheritdoc} */ @@ -37,6 +46,8 @@ protected function setUp(): void { throw new \RuntimeException('Failed to determine current working directory.'); } + static::$originalCwd ??= $cwd; + // Run tests from the root of the repo. self::locationsInit($cwd . '/../../'); } @@ -45,6 +56,10 @@ protected function setUp(): void { * {@inheritdoc} */ protected function tearDown(): void { + if (static::$originalCwd !== NULL) { + chdir(static::$originalCwd); + } + static::envReset(); parent::tearDown(); } From c2d668bc7707fbe80330903a088e56e15b79cd7b Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:56:46 +1000 Subject: [PATCH 11/12] [#2846] Corrected the destination and container checks in 'doctor'. The Pygmy fallback piped 'docker ps' into 'grep', but commands run without a shell, so the pipe was never interpreted and the branch could not succeed - the container list is now read and matched in PHP. Docker Compose reports the version of whichever form is present rather than assuming the modern subcommand, so a legacy-only host no longer reports a bare 'Available'. An injected process runner is given the working directory like any other, and 'build' now runs the checks against the directory being built. '--destination' gains the '-d' shortcut on every verb, matching what the router already accepts, and the '--uri' description names '#' as the ref separator. --- .../src/Command/AbstractInstallCommand.php | 4 +- .vortex/cli/src/Command/BuildCommand.php | 4 +- .vortex/cli/src/Command/ConfigureCommand.php | 6 ++ .vortex/cli/src/Command/DoctorCommand.php | 57 ++++++++++++++----- .vortex/cli/src/Command/UpdateCommand.php | 2 +- .../Functional/Command/DoctorCommandTest.php | 54 ++++++------------ .../Functional/Command/RouteCommandTest.php | 33 ++++++++++- .vortex/docs/content/cli.mdx | 9 +-- 8 files changed, 108 insertions(+), 61 deletions(-) diff --git a/.vortex/cli/src/Command/AbstractInstallCommand.php b/.vortex/cli/src/Command/AbstractInstallCommand.php index a7b0b6a67..05143f568 100644 --- a/.vortex/cli/src/Command/AbstractInstallCommand.php +++ b/.vortex/cli/src/Command/AbstractInstallCommand.php @@ -99,11 +99,11 @@ abstract class AbstractInstallCommand extends Command implements CommandRunnerAw * Add the options shared by every template-applying verb. */ protected function addCommonOptions(): void { - $this->addOption(static::OPTION_DESTINATION, NULL, InputOption::VALUE_REQUIRED, 'Destination directory. Defaults to the current directory.'); + $this->addOption(static::OPTION_DESTINATION, 'd', InputOption::VALUE_REQUIRED, 'Destination directory. Defaults to the current directory.'); $this->addOption(static::OPTION_ROOT, NULL, InputOption::VALUE_REQUIRED, 'Path to the root for file path resolution. If not specified, current directory is used.'); $this->addOption(static::OPTION_NO_INTERACTION, 'n', InputOption::VALUE_NONE, 'Do not ask any interactive question.'); $this->addOption(static::OPTION_CONFIG, 'c', InputOption::VALUE_REQUIRED, 'A JSON string with options or a path to a JSON file.'); - $this->addOption(static::OPTION_URI, 'l', InputOption::VALUE_REQUIRED, 'Remote or local repository URI with an optional git ref set after @.'); + $this->addOption(static::OPTION_URI, 'l', InputOption::VALUE_REQUIRED, 'Remote or local repository URI with an optional git ref set after #.'); $this->addOption(static::OPTION_NO_CLEANUP, NULL, InputOption::VALUE_NONE, 'Do not remove the CLI after successful installation.'); $this->addOption(static::OPTION_BUILD, 'b', InputOption::VALUE_NONE, 'Run auto-build after installation without prompting.'); $this->addAgentSurfaceOptions(); diff --git a/.vortex/cli/src/Command/BuildCommand.php b/.vortex/cli/src/Command/BuildCommand.php index 112b1f603..95611a65a 100644 --- a/.vortex/cli/src/Command/BuildCommand.php +++ b/.vortex/cli/src/Command/BuildCommand.php @@ -75,7 +75,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int label: 'Checking requirements', action: function (): bool { $command_runner = $this->getCommandRunner()->disableLog(); - $command_runner->run('doctor', [], ['--no-summary' => '1']); + // Checks run against the directory being built, not whichever one + // the CLI happens to have been started from. + $command_runner->run('doctor', [], ['--no-summary' => '1', '--destination' => $this->cwd]); return $command_runner->getExitCode() === RunnerInterface::EXIT_SUCCESS; }, diff --git a/.vortex/cli/src/Command/ConfigureCommand.php b/.vortex/cli/src/Command/ConfigureCommand.php index 24ce02f55..70961c47d 100644 --- a/.vortex/cli/src/Command/ConfigureCommand.php +++ b/.vortex/cli/src/Command/ConfigureCommand.php @@ -88,6 +88,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int Tui::init($output); + // Declared up front so the reporting below cannot depend on how far the + // block underneath got before an exception. + $apply = FALSE; + $interactive = FALSE; + try { $config = $this->resolveConfig($input); $interactive = !$config->getNoInteraction(); @@ -95,6 +100,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int Tui::init($output, $interactive); $apply = (bool) $input->getOption(static::OPTION_APPLY); + if ($apply) { $this->assertVortexProject($config); } diff --git a/.vortex/cli/src/Command/DoctorCommand.php b/.vortex/cli/src/Command/DoctorCommand.php index 5c30a0a5a..cb8a64fc2 100644 --- a/.vortex/cli/src/Command/DoctorCommand.php +++ b/.vortex/cli/src/Command/DoctorCommand.php @@ -98,7 +98,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $only = $input->getOption(static::OPTION_ONLY); $requirements = $this->validateRequirements($only ? array_map(trim(...), explode(',', (string) $only)) : NULL); - $this->processRunner ??= $this->getProcessRunner()->setCwd($this->cwd); + // Assigned before the working directory is applied so an injected runner + // is configured too, rather than being left pointing elsewhere. + $this->processRunner = $this->getProcessRunner(); + $this->processRunner->setCwd($this->cwd); + $this->present = []; $this->missing = []; @@ -254,14 +258,17 @@ protected function checkDocker(): bool { * Check if Docker Compose is available. */ protected function checkDockerCompose(): bool { - $result = $this->dockerComposeExists(); - if ($result) { - $this->present['Docker Compose'] = $this->getCommandVersion('docker compose version'); - } - else { + $command = $this->dockerComposeVersionCommand(); + + if ($command === NULL) { $this->missing['Docker Compose'] = 'https://docs.docker.com/compose/install/'; + + return FALSE; } - return $result; + + $this->present['Docker Compose'] = $this->getCommandVersion($command); + + return TRUE; } /** @@ -295,9 +302,10 @@ protected function checkPygmy(): bool { return TRUE; } - $this->processRunner->run('docker ps --format "{{.Names}}" | grep -q amazeeio'); - // @phpstan-ignore-next-line notIdentical.alwaysFalse - if ($this->processRunner->getExitCode() === RunnerInterface::EXIT_SUCCESS) { + // Pygmy's own containers can be running while its status command is not + // usable, so the container list is the second opinion. Commands run without + // a shell, so the match is made here rather than piped into grep. + if ($this->hasAmazeeioContainers()) { $this->present['Pygmy'] = $version; return TRUE; } @@ -307,6 +315,21 @@ protected function checkPygmy(): bool { return FALSE; } + /** + * Whether any running container belongs to Pygmy. + */ + protected function hasAmazeeioContainers(): bool { + $this->processRunner->run('docker', ['ps', '--format', '{{.Names}}']); + + if ($this->processRunner->getExitCode() !== RunnerInterface::EXIT_SUCCESS) { + return FALSE; + } + + $output = $this->processRunner->getOutput(); + + return str_contains(is_string($output) ? $output : implode(PHP_EOL, $output), 'amazeeio'); + } + /** * Check if a command exists. */ @@ -315,15 +338,21 @@ protected function commandExists(string $command): bool { } /** - * Check if Docker Compose exists. + * The command reporting the available Docker Compose version. + * + * Which form is present decides which command can report a version, so the + * two are resolved together rather than assuming the modern subcommand. + * + * @return string|null + * The version command, or NULL when neither form is available. */ - protected function dockerComposeExists(): bool { + protected function dockerComposeVersionCommand(): ?string { $this->processRunner->run('docker compose version'); if ($this->processRunner->getExitCode() === RunnerInterface::EXIT_SUCCESS) { - return TRUE; + return 'docker compose version'; } - return $this->commandExists('docker-compose'); + return $this->commandExists('docker-compose') ? 'docker-compose --version' : NULL; } /** diff --git a/.vortex/cli/src/Command/UpdateCommand.php b/.vortex/cli/src/Command/UpdateCommand.php index b99a5daeb..cce432235 100644 --- a/.vortex/cli/src/Command/UpdateCommand.php +++ b/.vortex/cli/src/Command/UpdateCommand.php @@ -54,7 +54,7 @@ protected function configure(): void { EOF ); $this->addCommonOptions(); - $this->addOption(static::OPTION_TO, NULL, InputOption::VALUE_REQUIRED, 'The template version to update to. Defaults to the latest release of this major.'); + $this->addOption(static::OPTION_TO, NULL, InputOption::VALUE_REQUIRED, 'The template version to update to, resolved against the official repository. Defaults to the latest release of this major. Use --uri to update from a fork.'); } /** diff --git a/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php b/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php index 41d7160fc..ee45ca263 100644 --- a/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php +++ b/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php @@ -32,38 +32,13 @@ public function testDoctorCommand( bool $expect_failure, array $output_assertions, ?\Closure $before = NULL, + ?\Closure $output_callback = NULL, ): void { if ($before instanceof \Closure) { $command_inputs = $before($command_inputs, self::$tmp); } - // Create a mock ExecutableFinder. - $mock_finder = $this->createMock(ExecutableFinder::class); - $mock_finder->method('find') - ->willReturnCallback(fn(string $name) => $executable_finder_callback($name)); - - // Create a mock ProcessRunner. - $mock_runner = $this->createMock(ProcessRunner::class); - // Set up common default behaviors. - $current_command = ''; - $mock_runner->method('run') - ->willReturnCallback(function (string $command) use ($mock_runner, &$current_command): MockObject { - $current_command = $command; - return $mock_runner; - }); - - $mock_runner->method('getOutput')->willReturn('version 1.0.0'); - - // Set up getExitCode using the provided callback. - $mock_runner->method('getExitCode') - ->willReturnCallback(function () use ($exit_code_callback, &$current_command) { - return $exit_code_callback($current_command); - }); - - // Create command and inject mocks using setters. - $command = new DoctorCommand(); - $command->setExecutableFinder($mock_finder); - $command->setProcessRunner($mock_runner); + $command = $this->doctorCommand($executable_finder_callback, $exit_code_callback, $output_callback); // Initialize application with our command. static::applicationInitFromCommand($command); @@ -256,6 +231,10 @@ public static function dataProviderDoctorCommand(): \Iterator { TuiOutput::DOCTOR_MISSING_LABEL, ]), ), + 'before' => NULL, + // The container list is read, so a running Pygmy shows up in its output + // rather than in an exit code. + 'output_callback' => fn(string $current_command): string => str_contains($current_command, 'docker') ? 'amazeeio-haproxy' : 'version 1.0.0', ]; yield 'Pygmy status fails and no amazeeio containers' => [ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name, @@ -263,10 +242,6 @@ public static function dataProviderDoctorCommand(): \Iterator { // Pygmy status fails. if (str_contains($current_command, 'pygmy status')) { return RunnerInterface::EXIT_FAILURE; - } - // No amazeeio containers. - if (str_contains($current_command, 'docker ps') && str_contains($current_command, 'amazeeio')) { - return RunnerInterface::EXIT_FAILURE; } return RunnerInterface::EXIT_SUCCESS; }, @@ -283,6 +258,9 @@ public static function dataProviderDoctorCommand(): \Iterator { TuiOutput::DOCTOR_PRESENT_LABEL, ]), ), + 'before' => NULL, + // Containers are running, but none of them belong to Pygmy. + 'output_callback' => fn(string $current_command): string => str_contains($current_command, 'docker') ? 'some-other-container' : 'version 1.0.0', ]; yield 'Docker Compose via modern syntax' => [ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name, @@ -352,7 +330,7 @@ public static function dataProviderDoctorCommand(): \Iterator { 'expect_failure' => TRUE, 'output_assertions' => [ '* ' . TuiOutput::DOCTOR_UNKNOWN . ' invalid', - '* Available: docker, docker-compose, ahoy', + '* ' . TuiOutput::DOCTOR_AVAILABLE, ], ]; yield 'Mixed valid and invalid requirements' => [ @@ -362,7 +340,7 @@ public static function dataProviderDoctorCommand(): \Iterator { 'expect_failure' => TRUE, 'output_assertions' => [ '* ' . TuiOutput::DOCTOR_UNKNOWN . ' invalid', - '* Available: docker, docker-compose, ahoy', + '* ' . TuiOutput::DOCTOR_AVAILABLE, ], ]; yield 'Valid destination directory' => [ @@ -448,7 +426,7 @@ public function testDistinguishesInstalledFromRunning(): void { /** * Build a command with the executable finder and process runner mocked. */ - protected function doctorCommand(\Closure $executable_finder_callback, \Closure $exit_code_callback): DoctorCommand { + protected function doctorCommand(\Closure $executable_finder_callback, \Closure $exit_code_callback, ?\Closure $output_callback = NULL): DoctorCommand { $mock_finder = $this->createMock(ExecutableFinder::class); $mock_finder->method('find')->willReturnCallback(fn(string $name) => $executable_finder_callback($name)); @@ -458,9 +436,11 @@ protected function doctorCommand(\Closure $executable_finder_callback, \Closure $current_command = $command; return $mock_runner; }); - $mock_runner->method('getOutput')->willReturn('version 1.0.0'); - // Bound by reference so each assertion sees the command being run, not the - // empty string the runner started with. + // Bound by reference so each stub sees the command being run, not the empty + // string the runner started with. + $mock_runner->method('getOutput')->willReturnCallback(function () use ($output_callback, &$current_command): string { + return $output_callback instanceof \Closure ? $output_callback($current_command) : 'version 1.0.0'; + }); $mock_runner->method('getExitCode')->willReturnCallback(function () use ($exit_code_callback, &$current_command) { return $exit_code_callback($current_command); }); diff --git a/.vortex/cli/tests/Functional/Command/RouteCommandTest.php b/.vortex/cli/tests/Functional/Command/RouteCommandTest.php index 20fbcabb7..b946bf38b 100644 --- a/.vortex/cli/tests/Functional/Command/RouteCommandTest.php +++ b/.vortex/cli/tests/Functional/Command/RouteCommandTest.php @@ -8,10 +8,12 @@ use DrevOps\VortexCli\Command\ConfigureCommand; use DrevOps\VortexCli\Command\InstallCommand; use DrevOps\VortexCli\Command\RouteCommand; +use DrevOps\VortexCli\Downloader\RepositoryDownloader; use DrevOps\VortexCli\Tests\Functional\FunctionalTestCase; use DrevOps\VortexCli\Utils\File; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Process\ExecutableFinder; /** * Functional tests for RouteCommand. @@ -103,14 +105,41 @@ public function testRoutesToConfigureForExistingProject(): void { $this->assertJson(trim($output)); } + /** + * A destination without a project is routed to the install verb. + */ + public function testRoutesToInstallForEmptyDirectory(): void { + // The download is stubbed to fail: reaching it at all is what proves the + // install verb was selected, without paying for a real download. + $downloader = $this->createMock(RepositoryDownloader::class); + $downloader->method('download')->willThrowException(new \RuntimeException('Failed to download Vortex.')); + + $executable_finder = $this->createMock(ExecutableFinder::class); + $executable_finder->method('find')->willReturnCallback(fn(string $command): string => '/usr/bin/' . $command); + + $install = new InstallCommand(); + $install->setRepositoryDownloader($downloader); + $install->setExecutableFinder($executable_finder); + + $this->runRouted([ + '--destination' => self::$sut, + '--no-interaction' => TRUE, + ], TRUE, $install); + + $this->assertApplicationAnyOutputContainsOrNot([ + '* Welcome to the Vortex CLI non-interactive install', + '* Failed to download Vortex.', + ]); + } + /** * Run the application with the route command as the default. */ - protected function runRouted(array $options, bool $expect_failure = FALSE): string { + protected function runRouted(array $options, bool $expect_failure = FALSE, ?InstallCommand $install = NULL): string { static::applicationInitFromCommand(new RouteCommand(), FALSE); $application = $this->applicationGet(); - $application->add(new InstallCommand()); + $application->add($install ?? new InstallCommand()); $application->add(new ConfigureCommand()); $application->setDefaultCommand('route'); diff --git a/.vortex/docs/content/cli.mdx b/.vortex/docs/content/cli.mdx index f1bbc8526..2aa2a4e7b 100644 --- a/.vortex/docs/content/cli.mdx +++ b/.vortex/docs/content/cli.mdx @@ -103,10 +103,11 @@ php vortex.phar configure --apply --prompts=current.json Checks for the tools a **Vortex** project needs: Docker, Docker Compose, Ahoy and Pygmy. It reports whether each is installed *and* running, the version of each one present, and how to install the ones that are missing. It only reports - it never changes the project. -| Option | Short | Description | -|----------------|-------|--------------------------------------------------------| -| `--only` | `-o` | Comma-separated subset of requirements to check | -| `--no-summary` | | Hide the summary listing tool versions | +| Option | Short | Description | +|-----------------|-------|--------------------------------------------------------| +| `--destination` | `-d` | Directory to run the checks in. Defaults to the current directory | +| `--only` | `-o` | Comma-separated subset of requirements to check | +| `--no-summary` | | Hide the summary listing tool versions | This is the host-level check that runs before a project exists. Once a project is built, [`ahoy doctor`](./tools/doctor) goes further and inspects the running stack itself - ports, containers, the web server and the site bootstrap. From 2006d3455a92e4d83848fa2969a49b883a8fe5d0 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 08:37:14 +1000 Subject: [PATCH 12/12] Addressed code review: guarded Docker probes, gated foreign-major targets, and pinned the update endpoint. The Docker probes now check the binary is resolvable first: the runner refuses to execute a command it cannot find, so 'doctor' aborted on a host without Docker instead of reporting it as missing - the one thing the command exists to do. A named '--to' version from another major is refused, since the destination gate compares the project against the build and says nothing about the version being requested, leaving a route to pull a template across a breaking boundary. The tooling downloads the CLI from the major-specific path matching its own line, so an update fetches a build that carries the commands it calls. A failed run is now framed by the destination like the header already was, an unreadable answers file is reported as unreadable rather than as broken JSON, and the docs no longer promise clean stdout for every non-interactive command. --- .../src/Command/AbstractInstallCommand.php | 16 +++- .vortex/cli/src/Command/AgentSurfaceTrait.php | 10 +++ .vortex/cli/src/Command/DoctorCommand.php | 15 +++- .vortex/cli/src/Command/UpdateCommand.php | 55 ++++++++++++- .../Functional/Command/DoctorCommandTest.php | 31 +++++++ .../Functional/Command/UpdateCommandTest.php | 82 +++++++++++++++++++ .vortex/docs/content/cli.mdx | 2 +- .vortex/tooling/src/vortex-update | 7 +- 8 files changed, 211 insertions(+), 7 deletions(-) diff --git a/.vortex/cli/src/Command/AbstractInstallCommand.php b/.vortex/cli/src/Command/AbstractInstallCommand.php index 05143f568..38c2822a3 100644 --- a/.vortex/cli/src/Command/AbstractInstallCommand.php +++ b/.vortex/cli/src/Command/AbstractInstallCommand.php @@ -220,7 +220,7 @@ protected function doInstall(InputInterface $input, OutputInterface $output): in } catch (\Exception $exception) { Tui::output()->setVerbosity(OutputInterface::VERBOSITY_NORMAL); - Tui::error('Installation failed with an error: ' . $exception->getMessage()); + Tui::error(sprintf('%s failed with an error: %s', $this->operationName(), $exception->getMessage())); return Command::FAILURE; } @@ -276,6 +276,20 @@ protected function doInstall(InputInterface $input, OutputInterface $output): in return Command::SUCCESS; } + /** + * The operation being performed, for reporting. + * + * Follows the destination the same way the header does, so a run cannot + * announce itself as one operation and fail as another. A destination that + * was never resolved has not been inspected yet, so it reads as an install. + * + * @return string + * The capitalised operation name. + */ + protected function operationName(): string { + return isset($this->config) && $this->config->isVortexProject() ? 'Update' : 'Installation'; + } + /** * Whether closing guidance should be printed. * diff --git a/.vortex/cli/src/Command/AgentSurfaceTrait.php b/.vortex/cli/src/Command/AgentSurfaceTrait.php index 1cb6d1713..f4372863f 100644 --- a/.vortex/cli/src/Command/AgentSurfaceTrait.php +++ b/.vortex/cli/src/Command/AgentSurfaceTrait.php @@ -96,7 +96,17 @@ protected function handleValidate(InputInterface $input, OutputInterface $output return Command::FAILURE; } + if (is_file($prompts_option) && !is_readable($prompts_option)) { + $output->writeln(sprintf('Cannot read --prompts file: %s.', $prompts_option)); + + return Command::FAILURE; + } + $prompts_json = is_file($prompts_option) ? (string) file_get_contents($prompts_option) : $prompts_option; + + // Decoded twice on purpose: an associative decode renders both '{}' and + // '[]' as an empty array, so the object shape can only be established from + // the untyped decode. $decoded = json_decode($prompts_json); if (!$decoded instanceof \stdClass) { diff --git a/.vortex/cli/src/Command/DoctorCommand.php b/.vortex/cli/src/Command/DoctorCommand.php index cb8a64fc2..3612522ab 100644 --- a/.vortex/cli/src/Command/DoctorCommand.php +++ b/.vortex/cli/src/Command/DoctorCommand.php @@ -319,6 +319,10 @@ protected function checkPygmy(): bool { * Whether any running container belongs to Pygmy. */ protected function hasAmazeeioContainers(): bool { + if (!$this->commandExists('docker')) { + return FALSE; + } + $this->processRunner->run('docker', ['ps', '--format', '{{.Names}}']); if ($this->processRunner->getExitCode() !== RunnerInterface::EXIT_SUCCESS) { @@ -347,9 +351,14 @@ protected function commandExists(string $command): bool { * The version command, or NULL when neither form is available. */ protected function dockerComposeVersionCommand(): ?string { - $this->processRunner->run('docker compose version'); - if ($this->processRunner->getExitCode() === RunnerInterface::EXIT_SUCCESS) { - return 'docker compose version'; + // Probed only when Docker is on PATH: the runner refuses to execute a + // command it cannot resolve, and a missing tool is what this reports on. + if ($this->commandExists('docker')) { + $this->processRunner->run('docker compose version'); + + if ($this->processRunner->getExitCode() === RunnerInterface::EXIT_SUCCESS) { + return 'docker compose version'; + } } return $this->commandExists('docker-compose') ? 'docker-compose --version' : NULL; diff --git a/.vortex/cli/src/Command/UpdateCommand.php b/.vortex/cli/src/Command/UpdateCommand.php index cce432235..ad88ea0bb 100644 --- a/.vortex/cli/src/Command/UpdateCommand.php +++ b/.vortex/cli/src/Command/UpdateCommand.php @@ -5,6 +5,9 @@ namespace DrevOps\VortexCli\Command; use DrevOps\VortexCli\Downloader\RepositoryDownloader; +use DrevOps\VortexCli\Utils\Tui; +use DrevOps\VortexCli\Utils\Version; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -61,7 +64,19 @@ protected function configure(): void { * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output): int { - $uri = $this->targetUri($input->getOption(static::OPTION_TO), $input->getOption(static::OPTION_URI)); + $to = $input->getOption(static::OPTION_TO); + + try { + $this->assertTargetMajor($to); + } + catch (\RuntimeException $runtime_exception) { + Tui::init($output); + Tui::error('Update failed with an error: ' . $runtime_exception->getMessage()); + + return Command::FAILURE; + } + + $uri = $this->targetUri($to, $input->getOption(static::OPTION_URI)); if ($uri !== NULL) { $input->setOption(static::OPTION_URI, $uri); @@ -70,6 +85,44 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $this->doInstall($input, $output); } + /** + * Refuse a target version from another major line. + * + * The destination gate compares the project against this build, which says + * nothing about the version being asked for. A named version is resolved + * straight to an archive, so without this a build could pull a template from + * across a breaking boundary into a project it considers compatible. + * + * @param mixed $to + * The target version, if any. + * + * @throws \RuntimeException + * When the target version's major differs from this build's major. + */ + protected function assertTargetMajor(mixed $to): void { + if (!is_string($to) || $to === '') { + return; + } + + // A branch, tag alias or commit carries no major to compare. + $target_major = Version::major($to); + if ($target_major === NULL) { + return; + } + + $cli_major = Version::major((string) $this->getApplication()?->getVersion()); + if ($cli_major === NULL || $cli_major === $target_major) { + return; + } + + throw new \RuntimeException(sprintf( + 'This Vortex CLI targets Vortex %1$d.x, but "%2$s" is a Vortex %3$d.x version. Update to it with the %3$d.x CLI instead: https://www.vortextemplate.com/v%3$d/install', + $cli_major, + $to, + $target_major, + )); + } + /** * Resolve the repository URI to download. * diff --git a/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php b/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php index ee45ca263..042fa8f6e 100644 --- a/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php +++ b/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php @@ -262,6 +262,37 @@ public static function dataProviderDoctorCommand(): \Iterator { // Containers are running, but none of them belong to Pygmy. 'output_callback' => fn(string $current_command): string => str_contains($current_command, 'docker') ? 'some-other-container' : 'version 1.0.0', ]; + // The runner refuses to execute a command it cannot resolve, so a probe + // that assumed Docker was present would abort the whole report. + yield 'Docker Compose legacy form with Docker absent' => [ + 'executable_finder_callback' => fn(string $name): ?string => $name === 'docker' ? NULL : '/usr/bin/' . $name, + 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS, + 'command_inputs' => ['--only' => 'docker-compose'], + 'expect_failure' => FALSE, + 'output_assertions' => array_merge( + TuiOutput::present([ + TuiOutput::DOCTOR_ALL_MET, + TuiOutput::DOCTOR_PRESENT_LABEL, + ]), + ['* Docker Compose: version 1.0.0'], + ), + ]; + yield 'Pygmy container fallback with Docker absent' => [ + 'executable_finder_callback' => fn(string $name): ?string => $name === 'docker' ? NULL : '/usr/bin/' . $name, + 'exit_code_callback' => fn(string $current_command): int => str_contains($current_command, 'pygmy status') ? RunnerInterface::EXIT_FAILURE : RunnerInterface::EXIT_SUCCESS, + 'command_inputs' => ['--only' => 'pygmy'], + 'expect_failure' => TRUE, + 'output_assertions' => array_merge( + TuiOutput::present([ + TuiOutput::DOCTOR_MISSING, + TuiOutput::DOCTOR_MISSING_LABEL, + ]), + ['* Pygmy:'], + TuiOutput::absent([ + TuiOutput::DOCTOR_PYGMY_RUNNING, + ]), + ), + ]; yield 'Docker Compose via modern syntax' => [ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name, 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS, diff --git a/.vortex/cli/tests/Functional/Command/UpdateCommandTest.php b/.vortex/cli/tests/Functional/Command/UpdateCommandTest.php index b6be38cca..74688aaca 100644 --- a/.vortex/cli/tests/Functional/Command/UpdateCommandTest.php +++ b/.vortex/cli/tests/Functional/Command/UpdateCommandTest.php @@ -115,6 +115,65 @@ public function testUpdateFramesTheRunAsAnUpdate(): void { ]); } + /** + * A target version from another major line is refused. + * + * The destination gate compares the project against this build and says + * nothing about the version being asked for, so the request is checked too. + */ + #[DataProvider('dataProviderTargetMajorGate')] + public function testTargetMajorGate(string $version, string $to, bool $expect_failure, array $assertions): void { + $command = $this->updateCommand(); + + $downloader = $this->createMock(RepositoryDownloader::class); + $downloader->method('download')->willThrowException(new \RuntimeException('Failed to download Vortex.')); + $command->setRepositoryDownloader($downloader); + + static::applicationInitFromCommand($command); + $this->applicationGet()->setVersion($version); + + $this->applicationRun([ + '--' . UpdateCommand::OPTION_NO_INTERACTION => TRUE, + '--' . UpdateCommand::OPTION_TO => $to, + '--' . UpdateCommand::OPTION_DESTINATION => self::$sut, + ], [], $expect_failure); + + $this->assertApplicationAnyOutputContainsOrNot($assertions); + } + + /** + * Data provider for testTargetMajorGate(). + * + * @return \Iterator}> + * Test data. + */ + public static function dataProviderTargetMajorGate(): \Iterator { + yield 'foreign major refused' => [ + '1.40.0', + '2.0.0', + TRUE, + ['* https://www.vortextemplate.com/v2/install', '! Failed to download Vortex.'], + ]; + yield 'same major allowed' => [ + '1.40.0', + '1.2.3', + TRUE, + ['* Failed to download Vortex.'], + ]; + yield 'branch carries no major' => [ + '1.40.0', + 'main', + TRUE, + ['* Failed to download Vortex.'], + ]; + yield 'unstamped build skips the gate' => [ + 'UNKNOWN', + '2.0.0', + TRUE, + ['* Failed to download Vortex.'], + ]; + } + /** * The agent surface answers on the update verb as well. */ @@ -126,6 +185,29 @@ public function testUpdateExposesAgentSurface(): void { $this->assertJson($this->applicationRun(['--' . UpdateCommand::OPTION_SCHEMA => TRUE])); } + /** + * An unreadable answers file is reported as such, not as broken JSON. + */ + public function testUnreadablePromptsFileIsReported(): void { + $path = self::$tmp . '/unreadable_' . uniqid() . '.json'; + $this->assertNotFalse(file_put_contents($path, '{"name":"Star Wars"}')); + chmod($path, 0000); + + static::applicationInitFromCommand($this->updateCommand()); + + $this->applicationRun([ + '--' . UpdateCommand::OPTION_VALIDATE => TRUE, + '--' . UpdateCommand::OPTION_PROMPTS => $path, + ], [], TRUE); + + chmod($path, 0644); + + $this->assertApplicationAnyOutputContainsOrNot([ + '* Cannot read --prompts file:', + '! Invalid JSON in --prompts.', + ]); + } + /** * Build an update command with the executable finder mocked. */ diff --git a/.vortex/docs/content/cli.mdx b/.vortex/docs/content/cli.mdx index 2aa2a4e7b..5819bd353 100644 --- a/.vortex/docs/content/cli.mdx +++ b/.vortex/docs/content/cli.mdx @@ -144,7 +144,7 @@ php vortex.phar install --no-interaction --prompts=prompts.json --destination=./ php vortex.phar --schema ``` -Closing guidance is suppressed on any non-interactive run, so a script's stdout carries only the output it asked for. +A non-interactive run suppresses the closing guidance - the boxes telling you what to do next. Progress and status output is still reported, so a scripted `install` remains readable in a log. Only `configure` reduces its stdout to a single JSON document. :::tip Using an AI agent diff --git a/.vortex/tooling/src/vortex-update b/.vortex/tooling/src/vortex-update index 2241ba01e..3046d306a 100755 --- a/.vortex/tooling/src/vortex-update +++ b/.vortex/tooling/src/vortex-update @@ -27,7 +27,12 @@ require_once __DIR__ . '/helpers.php'; $template_repo = getenv_default('VORTEX_CLI_INSTALL_TEMPLATE_REPO', 'VORTEX_INSTALLER_TEMPLATE_REPO', 'https://github.com/drevops/vortex.git#stable'); // The URL of the Vortex CLI. -$cli_url = getenv_default('VORTEX_CLI_URL', 'VORTEX_INSTALLER_URL', 'https://www.vortextemplate.com/v1/install'); +// +// Pinned to the major-specific path so an existing project always updates +// within its own major line, and so the downloaded build is one that carries +// the commands this script calls. The bare '/install' tracks the active major +// and would jump a project across a major boundary. +$cli_url = getenv_default('VORTEX_CLI_URL', 'VORTEX_INSTALLER_URL', 'https://www.vortextemplate.com/v2/install'); // Cache busting parameter for the CLI URL. $cli_url_cache_bust = getenv_default('VORTEX_CLI_URL_CACHE_BUST', 'VORTEX_INSTALLER_URL_CACHE_BUST', (string) time());