-
-
Notifications
You must be signed in to change notification settings - Fork 29
[#2846] Grew the Vortex CLI into a multi-verb tool with 'update', 'configure' and 'doctor'. #2887
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
d7e44d6
[#2846] Extracted Vortex project detection into 'Project::isVortex()'.
AlexSkrypnyk b7b5392
[#2846] Renamed the 'check-requirements' command to 'doctor'.
AlexSkrypnyk 7854c0e
[#2846] Extracted the agent surface into 'AgentSurfaceTrait'.
AlexSkrypnyk 4deb736
[#2846] Extracted the template-applying flow into 'AbstractInstallCom…
AlexSkrypnyk 2111dd3
[#2846] Added the 'update' command.
AlexSkrypnyk 3a1cf88
[#2846] Added the 'configure' command.
AlexSkrypnyk b885569
[#2846] Routed a bare invocation by the state of the target directory.
AlexSkrypnyk 58a9fe5
[#2846] Pointed the update consumers at the 'update' verb.
AlexSkrypnyk 9d48d90
[#2846] Documented the full command surface.
AlexSkrypnyk b041670
[#2846] Isolated the environment and working directory between tests.
AlexSkrypnyk c2d668b
[#2846] Corrected the destination and container checks in 'doctor'.
AlexSkrypnyk 2006d34
Addressed code review: guarded Docker probes, gated foreign-major tar…
AlexSkrypnyk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace DrevOps\VortexCli\Command; | ||
|
|
||
| use DrevOps\VortexCli\Prompts\PromptManager; | ||
| use DrevOps\VortexCli\Schema\AgentHelp; | ||
| use DrevOps\VortexCli\Schema\SchemaGenerator; | ||
| use DrevOps\VortexCli\Schema\SchemaValidator; | ||
| use DrevOps\VortexCli\Utils\Config; | ||
| use Symfony\Component\Console\Command\Command; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
| use Symfony\Component\Console\Input\InputOption; | ||
| use Symfony\Component\Console\Output\OutputInterface; | ||
|
|
||
| /** | ||
| * Lets a command describe and validate its questions without running them. | ||
| * | ||
| * The questions are declared by the build rather than by any one verb, so this | ||
| * surface answers identically wherever it is mounted. That matters because a | ||
| * bare invocation routes to a different verb depending on the target directory, | ||
| * and an agent must be able to describe the questions either way. | ||
| */ | ||
| trait AgentSurfaceTrait { | ||
|
|
||
| const OPTION_PROMPTS = 'prompts'; | ||
|
|
||
| const OPTION_SCHEMA = 'schema'; | ||
|
|
||
| const OPTION_VALIDATE = 'validate'; | ||
|
|
||
| const OPTION_AGENT_HELP = 'agent-help'; | ||
|
|
||
| /** | ||
| * Add the options that make up the agent surface. | ||
| */ | ||
| protected function addAgentSurfaceOptions(): void { | ||
| $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 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; | ||
| } | ||
|
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,238 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace DrevOps\VortexCli\Command; | ||
|
|
||
| use DrevOps\VortexCli\Prompts\PromptManager; | ||
| use DrevOps\VortexCli\Task\Task; | ||
| use DrevOps\VortexCli\Utils\Config; | ||
| use DrevOps\VortexCli\Utils\OptionsResolver; | ||
| use DrevOps\VortexCli\Utils\Tui; | ||
| use Symfony\Component\Console\Command\Command; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
| use Symfony\Component\Console\Input\InputOption; | ||
| use Symfony\Component\Console\Output\OutputInterface; | ||
|
|
||
| /** | ||
| * Configure command. | ||
| * | ||
| * Reconfigures an existing project in place, without downloading a template. | ||
| * Answers are pre-filled from the project, and written back to it on --apply. | ||
| * | ||
| * @package DrevOps\VortexCli\Command | ||
| */ | ||
| class ConfigureCommand extends Command { | ||
|
|
||
| use AgentSurfaceTrait; | ||
| use DestinationAwareTrait; | ||
|
|
||
| const OPTION_NO_INTERACTION = 'no-interaction'; | ||
|
|
||
| const OPTION_CONFIG = 'config'; | ||
|
|
||
| const OPTION_APPLY = 'apply'; | ||
|
|
||
| /** | ||
| * Defines default command name. | ||
| * | ||
| * @var string | ||
| */ | ||
| public static $defaultName = 'configure'; | ||
|
|
||
| /** | ||
| * {@inheritdoc} | ||
| */ | ||
| protected function configure(): void { | ||
| $this->setName('configure'); | ||
| $this->setDescription('Reconfigure an existing project in place.'); | ||
| $this->setHelp(<<<EOF | ||
| <info>Collect answers for the current directory and print them without changing anything:</info> | ||
| php vortex.phar configure | ||
|
|
||
| <info>Collect answers and write them to the project:</info> | ||
| php vortex.phar configure --apply | ||
|
|
||
| <info>Reconfigure another directory without asking any question:</info> | ||
| php vortex.phar configure --no-interaction --apply --destination=path/to/project | ||
|
|
||
| <info>Answer up front and write the result:</info> | ||
| 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'); | ||
| } | ||
|
|
||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.