diff --git a/.vortex/cli/src/Command/AbstractInstallCommand.php b/.vortex/cli/src/Command/AbstractInstallCommand.php new file mode 100644 index 000000000..38c2822a3 --- /dev/null +++ b/.vortex/cli/src/Command/AbstractInstallCommand.php @@ -0,0 +1,434 @@ +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_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(sprintf('%s failed with an error: %s', $this->operationName(), $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; + } + + /** + * 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. + * + * 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/AgentSurfaceTrait.php b/.vortex/cli/src/Command/AgentSurfaceTrait.php new file mode 100644 index 000000000..f4372863f --- /dev/null +++ b/.vortex/cli/src/Command/AgentSurfaceTrait.php @@ -0,0 +1,140 @@ +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; + } + + 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) { + $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/BuildCommand.php b/.vortex/cli/src/Command/BuildCommand.php index ae3deda4f..95611a65a 100644 --- a/.vortex/cli/src/Command/BuildCommand.php +++ b/.vortex/cli/src/Command/BuildCommand.php @@ -75,11 +75,13 @@ 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']); + // 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; }, - failure: 'Missing requirements. Run: ./vortex.phar check-requirements', + failure: 'Missing requirements. Run: ./vortex.phar doctor', streaming: TRUE, ); diff --git a/.vortex/cli/src/Command/ConfigureCommand.php b/.vortex/cli/src/Command/ConfigureCommand.php new file mode 100644 index 000000000..70961c47d --- /dev/null +++ b/.vortex/cli/src/Command/ConfigureCommand.php @@ -0,0 +1,238 @@ +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); + + // 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(); + + 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/Command/CheckRequirementsCommand.php b/.vortex/cli/src/Command/DoctorCommand.php similarity index 75% rename from .vortex/cli/src/Command/CheckRequirementsCommand.php rename to .vortex/cli/src/Command/DoctorCommand.php index 135141214..3612522ab 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.'); @@ -94,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 = []; @@ -250,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; } /** @@ -291,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; } @@ -303,6 +315,25 @@ protected function checkPygmy(): bool { return FALSE; } + /** + * 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) { + return FALSE; + } + + $output = $this->processRunner->getOutput(); + + return str_contains(is_string($output) ? $output : implode(PHP_EOL, $output), 'amazeeio'); + } + /** * Check if a command exists. */ @@ -311,15 +342,26 @@ 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 { - $this->processRunner->run('docker compose version'); - if ($this->processRunner->getExitCode() === RunnerInterface::EXIT_SUCCESS) { - return TRUE; + protected function dockerComposeVersionCommand(): ?string { + // 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'); + return $this->commandExists('docker-compose') ? 'docker-compose --version' : NULL; } /** diff --git a/.vortex/cli/src/Command/InstallCommand.php b/.vortex/cli/src/Command/InstallCommand.php index bdd3e792c..91eb9d43d 100644 --- a/.vortex/cli/src/Command/InstallCommand.php +++ b/.vortex/cli/src/Command/InstallCommand.php @@ -4,68 +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\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; -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; - - 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'; - - const OPTION_SCHEMA = 'schema'; - - const OPTION_VALIDATE = 'validate'; - - const OPTION_PROMPTS = 'prompts'; - - const OPTION_AGENT_HELP = 'agent-help'; +class InstallCommand extends AbstractInstallCommand { /** * Defines default command name. @@ -74,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} */ @@ -117,387 +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->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->addCommonOptions(); } /** * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output): int { - if ($input->getOption('help')) { - $output->write($this->getHelp()); - - 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); - } - - 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; - } - - /** - * 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. - * - * @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/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/src/Command/UpdateCommand.php b/.vortex/cli/src/Command/UpdateCommand.php new file mode 100644 index 000000000..ad88ea0bb --- /dev/null +++ b/.vortex/cli/src/Command/UpdateCommand.php @@ -0,0 +1,151 @@ +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, resolved against the official repository. Defaults to the latest release of this major. Use --uri to update from a fork.'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $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); + } + + 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. + * + * @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/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/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/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/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 @@ +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/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/tests/Functional/Command/CheckRequirementsCommandTest.php b/.vortex/cli/tests/Functional/Command/DoctorCommandTest.php similarity index 60% rename from .vortex/cli/tests/Functional/Command/CheckRequirementsCommandTest.php rename to .vortex/cli/tests/Functional/Command/DoctorCommandTest.php index 666fd6838..042fa8f6e 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,54 +16,29 @@ 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, 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 CheckRequirementsCommand(); - $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); @@ -77,11 +52,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 +64,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 +82,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 +92,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 +104,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 +118,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 +135,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 +150,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 +167,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 +184,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 +201,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,14 +223,18 @@ 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, ]), ), + '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 dataProviderCheckRequirementsCommand(): \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; }, @@ -274,13 +249,47 @@ 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::DOCTOR_PYGMY_RUNNING, + 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', + ]; + // 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::CHECK_REQUIREMENTS_PYGMY_RUNNING, - TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL, + TuiOutput::DOCTOR_PYGMY_RUNNING, ]), ), ]; @@ -291,12 +300,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 +322,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 +344,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,8 +360,8 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'command_inputs' => ['--only' => 'invalid'], 'expect_failure' => TRUE, 'output_assertions' => [ - '* ' . TuiOutput::CHECK_REQUIREMENTS_UNKNOWN . ' invalid', - '* Available: docker, docker-compose, ahoy', + '* ' . TuiOutput::DOCTOR_UNKNOWN . ' invalid', + '* ' . TuiOutput::DOCTOR_AVAILABLE, ], ]; yield 'Mixed valid and invalid requirements' => [ @@ -361,8 +370,8 @@ public static function dataProviderCheckRequirementsCommand(): \Iterator { 'command_inputs' => ['--only' => 'docker,invalid'], 'expect_failure' => TRUE, 'output_assertions' => [ - '* ' . TuiOutput::CHECK_REQUIREMENTS_UNKNOWN . ' invalid', - '* Available: docker, docker-compose, ahoy', + '* ' . TuiOutput::DOCTOR_UNKNOWN . ' invalid', + '* ' . TuiOutput::DOCTOR_AVAILABLE, ], ]; yield 'Valid destination directory' => [ @@ -370,7 +379,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 +413,74 @@ 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, ?\Closure $output_callback = NULL): 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; + }); + // 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); + }); + + $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..077bcb923 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); @@ -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' => [ @@ -371,7 +373,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 +382,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([ @@ -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' => [ @@ -416,7 +419,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 +434,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([ @@ -454,28 +457,29 @@ 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([ 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, + 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, ]), ], ]; @@ -486,7 +490,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([ @@ -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,17 +515,20 @@ 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, ]), ], ]; - 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 +540,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/Functional/Command/RouteCommandTest.php b/.vortex/cli/tests/Functional/Command/RouteCommandTest.php new file mode 100644 index 000000000..b946bf38b --- /dev/null +++ b/.vortex/cli/tests/Functional/Command/RouteCommandTest.php @@ -0,0 +1,156 @@ +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)); + } + + /** + * 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, ?InstallCommand $install = NULL): string { + static::applicationInitFromCommand(new RouteCommand(), FALSE); + + $application = $this->applicationGet(); + $application->add($install ?? 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/Command/UpdateCommandTest.php b/.vortex/cli/tests/Functional/Command/UpdateCommandTest.php new file mode 100644 index 000000000..74688aaca --- /dev/null +++ b/.vortex/cli/tests/Functional/Command/UpdateCommandTest.php @@ -0,0 +1,234 @@ +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', + ]); + } + + /** + * 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. + */ + public function testUpdateExposesAgentSurface(): void { + $command = $this->updateCommand(); + + static::applicationInitFromCommand($command); + + $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. + */ + 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/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/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); 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/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/tests/Unit/ProjectTest.php b/.vortex/cli/tests/Unit/ProjectTest.php new file mode 100644 index 000000000..cea3fb5ac --- /dev/null +++ b/.vortex/cli/tests/Unit/ProjectTest.php @@ -0,0 +1,47 @@ +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())); + } + +} 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(); } diff --git a/.vortex/cli/vortex b/.vortex/cli/vortex index 4e16b913d..8e39849ff 100755 --- a/.vortex/cli/vortex +++ b/.vortex/cli/vortex @@ -9,8 +9,11 @@ declare(strict_types=1); use DrevOps\VortexCli\Command\BuildCommand; -use DrevOps\VortexCli\Command\CheckRequirementsCommand; +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; use Symfony\Component\Console\Application; @@ -28,9 +31,12 @@ $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 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(); diff --git a/.vortex/docs/content/cli.mdx b/.vortex/docs/content/cli.mdx index bbcf546a0..5819bd353 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,14 +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 | -|----------------|-------|--------------------------------------------------------| -| `--only` | `-o` | Comma-separated subset of requirements to check | -| `--no-summary` | | Hide the summary listing tool versions | +| 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 | +|-----------------|-------|--------------------------------------------------------| +| `--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. ### build @@ -89,12 +138,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 +``` + +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 `--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: 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..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()); @@ -93,10 +98,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,