Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions .vortex/installer/src/Command/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use DrevOps\VortexInstaller\Runner\ExecutableFinderAwareInterface;
use DrevOps\VortexInstaller\Runner\ExecutableFinderAwareTrait;
use DrevOps\VortexInstaller\Runner\RunnerInterface;
use DrevOps\VortexInstaller\Schema\ConfigValidator;
use DrevOps\VortexInstaller\Schema\SchemaGenerator;
use DrevOps\VortexInstaller\Task\Task;
use DrevOps\VortexInstaller\Utils\Config;
use DrevOps\VortexInstaller\Utils\Env;
Expand Down Expand Up @@ -53,6 +55,12 @@ class InstallCommand extends Command implements CommandRunnerAwareInterface, Exe

const OPTION_BUILD = 'build';

const OPTION_SCHEMA = 'schema';

const OPTION_VALIDATE = 'validate';

const OPTION_AGENT_HELP = 'agent-help';

const BUILD_RESULT_SUCCESS = 'success';

const BUILD_RESULT_SKIPPED = 'skipped';
Expand Down Expand Up @@ -127,6 +135,9 @@ protected function configure(): void {
$this->addOption(static::OPTION_URI, 'l', InputOption::VALUE_REQUIRED, 'Remote or local repository URI with an optional git ref set after @.');
$this->addOption(static::OPTION_NO_CLEANUP, NULL, InputOption::VALUE_NONE, 'Do not remove installer after successful installation.');
$this->addOption(static::OPTION_BUILD, 'b', InputOption::VALUE_NONE, 'Run auto-build after installation without prompting.');
$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 installer.');
}

/**
Expand All @@ -139,6 +150,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return Command::SUCCESS;
}

if ($input->getOption(static::OPTION_AGENT_HELP)) {
return $this->handleAgentHelp($output);
}

if ($input->getOption(static::OPTION_SCHEMA)) {
return $this->handleSchema($input, $output);
}

if ($input->getOption(static::OPTION_VALIDATE)) {
return $this->handleValidate($input, $output);
}

Tui::init($output);

try {
Expand Down Expand Up @@ -266,6 +289,162 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return Command::SUCCESS;
}

/**
* Handle --schema option.
*/
protected function handleSchema(InputInterface $input, OutputInterface $output): int {
$config = Config::fromString('{}');
$prompt_manager = new PromptManager($config);

$generator = new SchemaGenerator();
$schema = $generator->generate($prompt_manager->getHandlers());

$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 {
$config_option = $input->getOption(static::OPTION_CONFIG);

if (empty($config_option) || !is_string($config_option)) {
$output->writeln('The --validate option requires --config.');

return Command::FAILURE;
}

$config_json = is_file($config_option) ? (string) file_get_contents($config_option) : $config_option;
$user_config = json_decode($config_json, TRUE);

if (!is_array($user_config)) {
$output->writeln('Invalid JSON in --config.');

return Command::FAILURE;
}
Comment on lines +319 to +326
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Handle file_get_contents failure explicitly.

Line 319 casts file_get_contents() result to string, which converts FALSE (on failure) to an empty string. This empty string then fails json_decode() silently. Consider checking for file read failure explicitly to provide a more helpful error message.

♻️ Proposed improvement
-    $config_json = is_file($config_option) ? (string) file_get_contents($config_option) : $config_option;
+    if (is_file($config_option)) {
+      $config_json = file_get_contents($config_option);
+      if ($config_json === FALSE) {
+        $output->writeln(sprintf('Unable to read config file: %s', $config_option));
+        return Command::FAILURE;
+      }
+    }
+    else {
+      $config_json = $config_option;
+    }
     $user_config = json_decode($config_json, TRUE);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.vortex/installer/src/Command/InstallCommand.php around lines 319 - 326, The
current code casts file_get_contents($config_option) to string which hides read
failures; update the logic around $config_json/$user_config so that when
is_file($config_option) is true you call file_get_contents($config_option) and
explicitly check its return value for === FALSE, write a clear error via
$output->writeln (e.g. "Failed to read config file: $config_option") and return
Command::FAILURE, otherwise proceed to json_decode($config_json, true) and keep
the existing is_array($user_config) check; reference variables/functions:
$config_option, $config_json, file_get_contents, $user_config, json_decode.


$config = Config::fromString('{}');
$prompt_manager = new PromptManager($config);

$validator = new ConfigValidator();
$result = $validator->validate($user_config, $prompt_manager->getHandlers());

$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 installer
* programmatically via --schema and --validate.
*/
protected function handleAgentHelp(OutputInterface $output): int {
$text = <<<'AGENT_HELP'
# Vortex Installer - AI Agent Instructions

You are interacting with the Vortex installer, a CLI tool that sets up Drupal
projects from the Vortex template. This guide explains how to use the installer
programmatically.

## Workflow

1. **Discover prompts**: Run with `--schema` to get a JSON manifest of all
available configuration prompts, their types, valid values, defaults, and
dependencies.

2. **Build a config**: Using the schema, construct a JSON object where keys are
either prompt IDs (e.g., `hosting_provider`) or environment variable names
(e.g., `VORTEX_INSTALLER_PROMPT_HOSTING_PROVIDER`). Set values according to
the prompt types and allowed options from the schema.

3. **Validate the config**: Run with `--validate --config='<json>'` to check
your config without performing an installation. The output is a JSON object
with `valid`, `errors`, `warnings`, and `resolved` fields.

4. **Install**: Run with `--no-interaction --config='<json>' --destination=<dir>`
to perform the actual installation using your validated config.

## Commands

```bash
# Get the prompt schema
php installer.php --schema

# Validate a config (JSON string)
php installer.php --validate --config='{"name":"My Project","hosting_provider":"lagoon"}'

# Validate a config (JSON file)
php installer.php --validate --config=config.json

# Install non-interactively
php installer.php --no-interaction --config='<json>' --destination=./my-project
```

## Schema Format

The `--schema` output contains a `prompts` array. Each prompt has:

- `id`: The prompt identifier (use as config key).
- `env`: The environment variable name (alternative config key).
- `type`: One of `text`, `select`, `multiselect`, `confirm`, `suggest`.
- `label`: Human-readable label.
- `description`: Optional description text.
- `options`: For `select`/`multiselect`, an array of `{value, label}` objects
representing the allowed values.
- `default`: The default value if not provided.
- `required`: Whether the prompt requires a value.
- `depends_on`: Dependency conditions. If set, this prompt only applies when
the referenced prompt has one of the specified values. A `_system` key
indicates a system-state dependency (not config-based).

## Value Types by Prompt Type

- `text` / `suggest`: string value.
- `select`: string value matching one of the option values.
- `multiselect`: array of strings, each matching an option value.
- `confirm`: boolean (`true` or `false`).

## Dependencies

Some prompts depend on other prompts. For example, `hosting_project_name`
depends on `hosting_provider` being `lagoon` or `acquia`. If you set
`hosting_provider` to `none`, you do not need to provide `hosting_project_name`.

When a dependency is not met:
- Omitting the dependent value is OK (it will be skipped).
- Providing a value triggers a warning (it will be ignored).

When a dependency is met:
- Required prompts must have a value or they produce an error.

## Validation Output

The `--validate` output contains:

- `valid`: boolean - whether the config is valid.
- `errors`: array of `{prompt, message}` objects for invalid values.
- `warnings`: array of `{prompt, message}` objects for ignored values.
- `resolved`: object with the final merged config (your values + defaults).

## Tips

- Start with `--schema` to understand what prompts exist.
- Provide values only for prompts you want to customize; defaults will be
used for the rest.
- Use `--validate` to check your config before installing.
- The `resolved` field in validation output shows the complete config that
would be used, including defaults.
AGENT_HELP;

$output->write($text);

return Command::SUCCESS;
}

protected function checkRequirements(): void {
$required_commands = [
'git',
Expand Down
44 changes: 44 additions & 0 deletions .vortex/installer/src/Prompts/Handlers/AbstractHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace DrevOps\VortexInstaller\Prompts\Handlers;

use DrevOps\VortexInstaller\Prompts\PromptType;
use DrevOps\VortexInstaller\Utils\Config;
use DrevOps\VortexInstaller\Utils\Converter;

Expand Down Expand Up @@ -61,6 +62,49 @@ public static function id(): string {
return Converter::machine(Converter::pascal2snake(str_replace('Handler', '', basename($filename, '.php'))));
}

/**
* {@inheritdoc}
*/
public static function envName(): string {
return Converter::constant('VORTEX_INSTALLER_PROMPT_' . static::id());
}

/**
* {@inheritdoc}
*/
public function type(): PromptType {
$options = $this->options([]);

if (is_array($options)) {
if (array_is_list($options)) {
return PromptType::Suggest;
}

$default = $this->default([]);

if (is_array($default)) {
return PromptType::MultiSelect;
}

return PromptType::Select;
}

$default = $this->default([]);

if (is_bool($default)) {
return PromptType::Confirm;
}

return PromptType::Text;
}
Comment on lines +75 to +99
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Type inference may produce inconsistent results when responses affect options/defaults.

The type() method calls $this->options([]) and $this->default([]) with empty arrays. However, some handlers may return different options or defaults depending on responses (e.g., DatabaseDownloadSource::options() filters options based on HostingProvider). This means the inferred type at schema generation time may differ from runtime behavior.

Consider documenting this limitation or ensuring the type inference is only used for schema generation where an empty responses context is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.vortex/installer/src/Prompts/Handlers/AbstractHandler.php around lines 75 -
99, The type() method currently calls $this->options([]) and $this->default([])
which can produce incorrect inference when those methods depend on runtime
responses (e.g., DatabaseDownloadSource::options() filtered by HostingProvider);
change type() to accept an optional $responses parameter (e.g., type(array
$responses = []): PromptType) and pass $responses through to
$this->options($responses) and $this->default($responses), then update all
callers to provide the real responses when available; if callers cannot provide
responses, add a docblock on type() noting that calling it with an empty array
yields only a best-effort schema inference for generation time.


/**
* {@inheritdoc}
*/
public function dependsOn(): ?array {
return NULL;
}

/**
* {@inheritdoc}
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ public function options(array $responses): ?array {
return $options;
}

/**
* {@inheritdoc}
*/
public function dependsOn(): ?array {
return [ProvisionType::id() => [ProvisionType::DATABASE]];
}

/**
* {@inheritdoc}
*/
Expand Down
7 changes: 7 additions & 0 deletions .vortex/installer/src/Prompts/Handlers/DatabaseImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ public function placeholder(array $responses): ?string {
return parent::placeholder($responses);
}

/**
* {@inheritdoc}
*/
public function dependsOn(): ?array {
return [DatabaseDownloadSource::id() => [DatabaseDownloadSource::CONTAINER_REGISTRY]];
}

/**
* {@inheritdoc}
*/
Expand Down
40 changes: 40 additions & 0 deletions .vortex/installer/src/Prompts/Handlers/HandlerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace DrevOps\VortexInstaller\Prompts\Handlers;

use DrevOps\VortexInstaller\Prompts\PromptType;

/**
* Interface HandlerInterface.
*
Expand All @@ -13,11 +15,49 @@
*/
interface HandlerInterface {

/**
* Reserved dependency key for system-state conditions.
*/
const DEPENDS_ON_SYSTEM = '_system';

/**
* Reserved dependency value for fresh install condition.
*/
const DEPENDS_ON_FRESH_INSTALL = '_fresh_install';

/**
* The unique identifier of the handler.
*/
public static function id(): string;

/**
* Get the environment variable name for this handler.
*
* @return string
* The environment variable name in the format VORTEX_INSTALLER_PROMPT_*.
*/
public static function envName(): string;

/**
* Get the prompt type for this handler.
*
* @return \DrevOps\VortexInstaller\Prompts\PromptType
* The prompt type enum case.
*/
public function type(): PromptType;

/**
* Get dependency conditions for this handler.
*
* Returns an associative array where keys are handler IDs and values are
* arrays of acceptable values. The handler should only run when the
* dependency handler's response matches one of the acceptable values.
*
* @return array<string, array<mixed>>|null
* The dependency conditions, or NULL if no dependencies.
*/
public function dependsOn(): ?array;

/**
* Label for of the handler.
*
Expand Down
7 changes: 7 additions & 0 deletions .vortex/installer/src/Prompts/Handlers/HostingProjectName.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ public function isRequired(): bool {
return TRUE;
}

/**
* {@inheritdoc}
*/
public function dependsOn(): ?array {
return [HostingProvider::id() => [HostingProvider::LAGOON, HostingProvider::ACQUIA]];
}

/**
* {@inheritdoc}
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ public function options(array $responses): ?array {
return $options;
}

/**
* {@inheritdoc}
*/
public function dependsOn(): ?array {
return [Migration::id() => [TRUE]];
}

/**
* {@inheritdoc}
*/
Expand Down
Loading
Loading