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
6 changes: 3 additions & 3 deletions .github/workflows/vortex-test-common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ jobs:
runs-on: ubuntu-latest

strategy:
fail-fast: false
fail-fast: true
matrix:
batch: [0, 1, 2, 3]
batch: [0, 1, 2, 3, 4]

container:
image: drevops/ci-runner:25.8.0@sha256:d6ba40fc4248ce291302a2cf212de9b80da5433f2fdf29be09ed59659df9e18d
Expand Down Expand Up @@ -183,7 +183,7 @@ jobs:
runs-on: ubuntu-latest

strategy:
fail-fast: false
fail-fast: true
matrix:
batch: [0, 1]

Comment on lines +186 to 189

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 (assertive)

Fail-fast for deployment tests — tradeoff check.

Ok if speed > parallel failure visibility. If you still want all batches’ logs even after one fails, keep fail-fast false here.

🤖 Prompt for AI Agents
.github/workflows/vortex-test-common.yml lines 186-189: the matrix step
currently sets fail-fast: true which aborts remaining batch jobs on the first
failure and prevents collecting logs from other batches; if you need logs and
results from all batches change fail-fast to false, commit that change, and
re-run the workflow to verify all batch jobs execute to completion so you can
inspect their logs even when one fails.

Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ web/sites/default/*
# Preserve default settings files to use them in Functional test.
!web/sites/default/default.settings.php
!web/sites/default/default.services.yml
# Whitelist Drupal Scaffold files. @see https://github.com/drupal-composer/drupal-scaffold#limitation
# Allow Drupal Scaffold files. @see https://github.com/drupal-composer/drupal-scaffold#limitation
Comment thread
coderabbitai[bot] marked this conversation as resolved.
!web/.editorconfig
!web/.eslintignore
!web/.gitattributes
Expand All @@ -41,6 +41,9 @@ web/sites/default/*
!web/robots.txt
!web/update.php

# Ignore all recipes by default. Custom recipes should be added explicitly.
recipes/*

Comment on lines +44 to +46

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 (assertive)

Ignoring recipes by default is sensible; consider an opt-in keep file.

If you want the directory present but empty in new repos, add an exception and commit a placeholder.

 # Ignore all recipes by default. Custom recipes should be added explicitly.
 recipes/*
+!recipes/.gitkeep
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Ignore all recipes by default. Custom recipes should be added explicitly.
recipes/*
# Ignore all recipes by default. Custom recipes should be added explicitly.
recipes/*
!recipes/.gitkeep
🤖 Prompt for AI Agents
In .gitignore around lines 44-46, the current rule ignores the entire recipes
directory which prevents committing an empty directory; update .gitignore to
keep the directory by adding an exception for the directory (e.g., allow the
directory itself or a specific keep file) and then add and commit a placeholder
file inside recipes (commonly named .gitkeep or README) so the empty directory
is preserved in the repo.

#;< VORTEX_DEV
#; Ignore these files in Vortex itself, but do not ignore in consumer site.
/composer.lock
Expand Down
2 changes: 1 addition & 1 deletion .vortex/installer/src/Prompts/Handlers/AbstractHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ public function resolvedValue(array $responses): null|string|bool|array {
/**
* {@inheritdoc}
*/
public function resolvedMessage(array $responses): ?string {
public function resolvedMessage(array $responses, mixed $resolved): ?string {
return NULL;
}
Comment on lines +137 to 139

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

❓ Verification inconclusive

Public API change to resolvedMessage — ensure all overrides and call sites updated

Signature expansion is fine; confirm no handlers still implement the old single-arg method and no call sites pass one argument.

Add/upd phpdoc here and in overrides to document $resolved purpose and types for IDEs/static analysis.


🏁 Script executed:

#!/bin/bash
# Find handlers overriding old signature (no second param).
rg -nP --type=php 'function\s+resolvedMessage\s*\(\s*array\s+\$responses\s*(?!,)\s*\)' .vortex/installer/src/Prompts/Handlers

# Find call sites still passing a single argument.
rg -nP --type=php '->resolvedMessage\s*\(\s*[^,)]*\)' .vortex/installer/src

Length of output: 610


🏁 Script executed:

#!/bin/bash
# re-run override search
rg -nP --type=php -n 'function\s+resolvedMessage\s*\(\s*array\s+\$responses\s*(?!,)' .vortex/installer/src/Prompts/Handlers

# find call sites still passing a single argument
rg -nP --type=php -n '\b->resolvedMessage\s*\(\s*[^,)]*\)' .vortex/installer/src

Length of output: 208


Legacy single-arg resolvedMessage usage removed—all overrides and call sites now include both parameters. Optional: add or update phpdoc on resolvedMessage (and its overrides) to document the $resolved parameter for IDEs/static analysis.

🤖 Prompt for AI Agents
.vortex/installer/src/Prompts/Handlers/AbstractHandler.php around lines 137 to
139: the method signature was updated to accept the new $resolved parameter but
the phpdoc wasn't updated; add or update the docblock immediately above
resolvedMessage to document both parameters ($responses array and mixed
$resolved) and the ?string return type (including description of what $resolved
represents), and propagate the same phpdoc update to any overriding
implementations so IDEs and static analyzers recognize the new parameter.


Expand Down
4 changes: 3 additions & 1 deletion .vortex/installer/src/Prompts/Handlers/HandlerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,13 @@ public function resolvedValue(array $responses): null|string|bool|array;
*
* @param array $responses
* Current form responses for context-aware message generation.
* @param mixed $resolved
* The resolved value from resolvedValue().
*
* @return string|null
* The message to display, or null if no message needed.
*/
public function resolvedMessage(array $responses): ?string;
public function resolvedMessage(array $responses, mixed $resolved): ?string;

Comment on lines +149 to 156

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Interface signature change is a BC break; ensure all implementers and call sites are updated; fix doc typos.

  • Verify every HandlerInterface implementation now accepts the new $resolved param.
  • Update any calls to resolvedMessage(...) to pass the resolved value.
  • Minor doc fixes: “handlerManager” → “PromptManager”, “handlering” → “prompting”.

Search for outdated signatures and call sites:

Docblock tweak (outside the changed lines):

/**
 * Get a message to display when showing the resolved value.
 *
 * This is used by PromptManager to show an appropriate message (via
 * info(), ok(), etc.) when using a resolved value instead of prompting
 * for input.
 */

🏁 Script executed:

#!/bin/bash
# Implementations missing $resolved in signature
rg -nP --type=php '(?s)class\s+\w+\s+implements\s+HandlerInterface.*?\bfunction\s+resolvedMessage\s*\(\s*array\s+\$responses\s*\)' -C2

# Calls passing only $responses
rg -nP --type=php '\bresolvedMessage\s*\(\s*\$[a-zA-Z_][\w]*\s*\)'

# Interface reference to help fix doc wording
rg -nP --type=php 'PromptManager|handlerManager|handlering' .vortex/installer/src/Prompts

Length of output: 797


Fix doc typos in HandlerInterface
Replace “handlerManager” with “PromptManager” and “handlering” with “prompting” in the docblock above resolvedMessage (HandlerInterface.php lines 143–144).

🤖 Prompt for AI Agents
.vortex/installer/src/Prompts/Handlers/HandlerInterface.php lines 143–156: the
docblock above resolvedMessage contains typos — change “handlerManager” to
“PromptManager” and “handlering” to “prompting” in the comment text so the
parameter/class names and verbs read correctly; keep the rest of the docblock
intact and ensure spelling/casing matches existing project conventions.

/**
* Set the responses.
Expand Down
2 changes: 1 addition & 1 deletion .vortex/installer/src/Prompts/Handlers/Internal.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ protected function processDemoMode(array $responses, string $dir): void {
$is_demo = $this->config->get(Config::IS_DEMO);

if (is_null($is_demo)) {
if ($responses[Starter::id()] !== Starter::DRUPAL_LOAD_DATABASE_DEMO) {
if ($responses[Starter::id()] !== Starter::LOAD_DATABASE_DEMO) {
$is_demo = FALSE;
}
Comment on lines +127 to 129

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Harden access to Starter response to avoid undefined index.

In some non-interactive or partial-resolution paths, Starter::id() may be absent; use null coalescing.

Apply:

-      if ($responses[Starter::id()] !== Starter::LOAD_DATABASE_DEMO) {
+      if (($responses[Starter::id()] ?? null) !== Starter::LOAD_DATABASE_DEMO) {
         $is_demo = FALSE;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($responses[Starter::id()] !== Starter::LOAD_DATABASE_DEMO) {
$is_demo = FALSE;
}
if (($responses[Starter::id()] ?? null) !== Starter::LOAD_DATABASE_DEMO) {
$is_demo = FALSE;
}
🤖 Prompt for AI Agents
.vortex/installer/src/Prompts/Handlers/Internal.php around lines 127 to 129: the
code directly indexes $responses[Starter::id()] which can be undefined; change
the check to use a null-coalescing lookup (e.g. $responses[Starter::id()] ??
null) or assign a local variable with ($responses[Starter::id()] ?? null) and
compare that to Starter::LOAD_DATABASE_DEMO so the undefined index is avoided
and $is_demo is set correctly.

// Check if it should be enabled based on the provision type and database
Expand Down
71 changes: 51 additions & 20 deletions .vortex/installer/src/Prompts/Handlers/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,32 +73,29 @@ public function discover(): null|string|bool|array {
/**
* {@inheritdoc}
*/
public function process(): void {
$v = $this->getResponseAsString();
public function resolvedValue(array $responses): null|string|bool|array {
$discovered = $this->discover();

// If user selected 'custom', use the ProfileCustom response instead.
if ($v === self::CUSTOM && isset($this->responses['profile_custom'])) {
$v = $this->responses['profile_custom'];
if (!is_null($discovered)) {
return $discovered;
}

$t = $this->tmpDir;
$w = $this->webroot;

File::replaceContentInFile($t . '/.env', '/DRUPAL_PROFILE=.*/', 'DRUPAL_PROFILE=' . $v);
if (($responses[Starter::id()] ?? '') === Starter::INSTALL_PROFILE_DRUPALCMS) {
return Starter::INSTALL_PROFILE_DRUPALCMS_PATH;
}

if (in_array($v, [self::STANDARD, self::MINIMAL, self::DEMO_UMAMI])) {
File::rmdir(sprintf('%s/%s/profiles/your_site_profile', $t, $w));
File::rmdir(sprintf('%s/%s/profiles/custom/your_site_profile', $t, $w));
return NULL;
}

File::replaceContentAsync([
'/profiles/your_site_profile,' => '',
'/profiles/custom/your_site_profile,' => '',
]);
}
else {
File::replaceContentAsync('your_site_profile', $v);
File::renameInDir($t, 'your_site_profile', $v);
/**
* {@inheritdoc}
*/
public function resolvedMessage(array $responses, mixed $resolved): ?string {
if (is_string($resolved)) {
return sprintf('Profile will be set to "%s".', $resolved);
}

return NULL;
}
Comment on lines +93 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 (assertive)

Avoid logging “custom” as a final profile value.
Skip the message when the resolved value is the sentinel “custom” to avoid misleading output.

   public function resolvedMessage(array $responses, mixed $resolved): ?string {
-    if (is_string($resolved)) {
+    if (is_string($resolved)) {
+      if ($resolved === self::CUSTOM) {
+        return NULL;
+      }
       return sprintf('Profile will be set to "%s".', $resolved);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function resolvedMessage(array $responses, mixed $resolved): ?string {
if (is_string($resolved)) {
return sprintf('Profile will be set to "%s".', $resolved);
}
return NULL;
}
public function resolvedMessage(array $responses, mixed $resolved): ?string {
if (is_string($resolved)) {
if ($resolved === self::CUSTOM) {
return NULL;
}
return sprintf('Profile will be set to "%s".', $resolved);
}
return NULL;
}
🤖 Prompt for AI Agents
.vortex/installer/src/Prompts/Handlers/Profile.php around lines 93 to 99: the
resolvedMessage currently returns a message for any string value, which can
incorrectly log the sentinel "custom"; update the logic to return NULL when the
resolved value is the string "custom" and only return the sprintf message for
other string values — i.e., check is_string($resolved) and if $resolved ===
'custom' return NULL, otherwise return the existing formatted message.


/**
Expand Down Expand Up @@ -131,4 +128,38 @@ public function discoverName(): null|string|bool|array {
return str_replace(['.info.yml', '.info'], '', basename($path));
}

/**
* {@inheritdoc}
*/
public function process(): void {
$v = $this->getResponseAsString();

// If user selected 'custom', use the ProfileCustom response instead.
if ($v === self::CUSTOM && isset($this->responses[ProfileCustom::id()])) {
$v = $this->responses[ProfileCustom::id()];
}

$t = $this->tmpDir;
$w = $this->webroot;

File::replaceContentInFile($t . '/.env', '/DRUPAL_PROFILE=.*/', 'DRUPAL_PROFILE=' . $v);

Comment on lines +145 to +146

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 (assertive)

Anchor the .env replacement to the line for DRUPAL_PROFILE (defensive).

Reduces accidental matches if the token appears elsewhere.

Apply this diff (if replaceContentInFile supports PCRE flags):

-    File::replaceContentInFile($t . '/.env', '/DRUPAL_PROFILE=.*/', 'DRUPAL_PROFILE=' . $v);
+    File::replaceContentInFile($t . '/.env', '/^DRUPAL_PROFILE=.*/m', 'DRUPAL_PROFILE=' . $v);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
File::replaceContentInFile($t . '/.env', '/DRUPAL_PROFILE=.*/', 'DRUPAL_PROFILE=' . $v);
File::replaceContentInFile(
$t . '/.env',
'/^DRUPAL_PROFILE=.*/m',
'DRUPAL_PROFILE=' . $v
);
🤖 Prompt for AI Agents
.vortex/installer/src/Prompts/Handlers/Profile.php around lines 145-146: the
current replacement uses an unanchored pattern that can match DRUPAL_PROFILE
anywhere in the file; change it to use a line-anchored PCRE (start-of-line)
pattern with multiline mode so only the DRUPAL_PROFILE line is replaced, and
pass the multiline flag if replaceContentInFile supports PCRE flags; replace the
entire line starting with DRUPAL_PROFILE= with the new value.

// Assume that profiles provided as a path are contrib profiles.
$is_contrib_profile = str_contains($v, DIRECTORY_SEPARATOR);

if (in_array($v, [self::STANDARD, self::MINIMAL, self::DEMO_UMAMI]) || $is_contrib_profile) {
File::rmdir(sprintf('%s/%s/profiles/your_site_profile', $t, $w));
File::rmdir(sprintf('%s/%s/profiles/custom/your_site_profile', $t, $w));

File::replaceContentAsync([
'/profiles/your_site_profile,' => '',
'/profiles/custom/your_site_profile,' => '',
]);
}
else {
File::replaceContentAsync('your_site_profile', $v);
File::renameInDir($t, 'your_site_profile', $v);
Comment on lines +147 to +161

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 (assertive)

⚠️ Potential issue

Fix OS-specific path detection for contrib/profile paths.

str_contains($v, DIRECTORY_SEPARATOR) fails on Windows when $v uses '/' (e.g., '../recipes/...'), leading to incorrect branch and potential bad renames. Detect both separators.

-    $is_contrib_profile = str_contains($v, DIRECTORY_SEPARATOR);
+    // Detect both Unix and Windows separators in provided path.
+    $is_contrib_profile = (strpbrk($v, '/\\') !== FALSE);

Consider adding a small unit test covering Unix-style and Windows-style paths.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Assume that profiles provided as a path are contrib profiles.
$is_contrib_profile = str_contains($v, DIRECTORY_SEPARATOR);
if (in_array($v, [self::STANDARD, self::MINIMAL, self::DEMO_UMAMI]) || $is_contrib_profile) {
File::rmdir(sprintf('%s/%s/profiles/your_site_profile', $t, $w));
File::rmdir(sprintf('%s/%s/profiles/custom/your_site_profile', $t, $w));
File::replaceContentAsync([
'/profiles/your_site_profile,' => '',
'/profiles/custom/your_site_profile,' => '',
]);
}
else {
File::replaceContentAsync('your_site_profile', $v);
File::renameInDir($t, 'your_site_profile', $v);
// Assume that profiles provided as a path are contrib profiles.
// Detect both Unix and Windows separators in provided path.
$is_contrib_profile = (strpbrk($v, '/\\') !== FALSE);
if (in_array($v, [self::STANDARD, self::MINIMAL, self::DEMO_UMAMI]) || $is_contrib_profile) {
File::rmdir(sprintf('%s/%s/profiles/your_site_profile', $t, $w));
File::rmdir(sprintf('%s/%s/profiles/custom/your_site_profile', $t, $w));
File::replaceContentAsync([
'/profiles/your_site_profile,' => '',
'/profiles/custom/your_site_profile,' => '',
]);
}
else {
File::replaceContentAsync('your_site_profile', $v);
File::renameInDir($t, 'your_site_profile', $v);
🤖 Prompt for AI Agents
.vortex/installer/src/Prompts/Handlers/Profile.php around lines 147 to 161: the
contrib-profile detection uses str_contains($v, DIRECTORY_SEPARATOR) which fails
when paths use the other OS separator (e.g. '/' on Windows or '\' on Unix
inputs); change the check to detect either separator (e.g. test for '/' OR '\')
so both Unix-style and Windows-style paths are treated as contrib profiles, then
keep the existing branch logic; add a unit test that passes examples with
'../recipes/...' and '..\\recipes\\...' to cover both separators.

}
}

}
58 changes: 47 additions & 11 deletions .vortex/installer/src/Prompts/Handlers/Starter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@

namespace DrevOps\VortexInstaller\Prompts\Handlers;

use DrevOps\VortexInstaller\Utils\JsonManipulator;
use DrevOps\VortexInstaller\Utils\Tui;

class Starter extends AbstractHandler {

const DRUPAL_LOAD_DATABASE_DEMO = 'demodb';
const INSTALL_PROFILE_CORE = 'install_profile_core';

const DRUPAL_INSTALL_PROFILE = 'drupal_profile';
const INSTALL_PROFILE_DRUPALCMS = 'install_profile_drupalcms';

const LOAD_DATABASE_DEMO = 'load_demodb';

const INSTALL_PROFILE_DRUPALCMS_PATH = '../recipes/drupal_cms_starter';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

❓ Verification inconclusive

Verify this constant is actually used; otherwise remove or use it in postInstall.
If unused, delete; if used, prefer referencing it when constructing DRUPAL_PROFILE.

Run to confirm:


🏁 Script executed:

#!/bin/bash
rg -nC2 '\bINSTALL_PROFILE_DRUPALCMS_PATH\b'

Length of output: 46


Remove unused INSTALL_PROFILE_DRUPALCMS_PATH constant
This constant is defined in .vortex/installer/src/Prompts/Handlers/Starter.php but never referenced—delete it.

🤖 Prompt for AI Agents
In .vortex/installer/src/Prompts/Handlers/Starter.php around line 18, the
constant INSTALL_PROFILE_DRUPALCMS_PATH is declared but never used; remove the
unused constant declaration from the file to eliminate dead code and unused
symbol warnings, and run tests/lint to confirm no references remain.


Comment on lines +12 to 19

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

❓ Verification inconclusive

Confirm usage of INSTALL_PROFILE_DRUPALCMS_PATH or remove it.
The constant isn’t referenced in this file. Verify it’s used elsewhere to avoid dead code.


🏁 Script executed:

#!/bin/bash
# Find references to the constant across the repo.
rg -nC2 '\bINSTALL_PROFILE_DRUPALCMS_PATH\b'

Length of output: 46


Remove the unused constant INSTALL_PROFILE_DRUPALCMS_PATH. It isn’t referenced anywhere in the repo.

🤖 Prompt for AI Agents
In .vortex/installer/src/Prompts/Handlers/Starter.php around lines 12 to 19, the
constant INSTALL_PROFILE_DRUPALCMS_PATH is declared but unused; remove the
INSTALL_PROFILE_DRUPALCMS_PATH constant declaration from this file (delete the
line defining it) and run a quick repo-wide search to confirm it has no
references; adjust surrounding spacing/commas if needed to keep file formatting
consistent.

/**
* {@inheritdoc}
Expand All @@ -25,8 +30,12 @@ public function label(): string {
public static function description(array $responses): ?string {
$label1 = Tui::bold('Drupal, installed from profile');
$label11 = Tui::underscore('populating a fresh database');
$label2 = Tui::bold('Drupal, loaded from the demo database');
$label21 = Tui::underscore('loading an existing demo database');

$label2 = Tui::bold('Drupal CMS, installed from profile');
$label21 = Tui::underscore('populating a fresh database');

$label3 = Tui::bold('Drupal, loaded from the demo database');
$label31 = Tui::underscore('loading an existing demo database');

return <<<DOC
Choose how your site will be created the first time after this installer finishes:
Expand All @@ -36,7 +45,11 @@ public static function description(array $responses): ?string {
from one of the standard Drupal installation profiles.

○ {$label2}
Creates a site by {$label21}
Creates a new site by {$label21}
from the Drupal CMS recipe.

○ {$label3}
Creates a site by {$label31}
provided with the installer.
DOC;
}
Expand All @@ -53,16 +66,17 @@ public function hint(array $responses): ?string {
*/
public function options(array $responses): ?array {
return [
self::DRUPAL_INSTALL_PROFILE => 'Drupal, installed from profile',
self::DRUPAL_LOAD_DATABASE_DEMO => 'Drupal, loaded from the demo database',
self::INSTALL_PROFILE_CORE => 'Drupal, installed from profile',
self::INSTALL_PROFILE_DRUPALCMS => 'Drupal CMS, installed from profile',
self::LOAD_DATABASE_DEMO => 'Drupal, loaded from the demo database',
];
}

/**
* {@inheritdoc}
*/
public function default(array $responses): null|string|bool|array {
return self::DRUPAL_LOAD_DATABASE_DEMO;
return self::LOAD_DATABASE_DEMO;
}

/**
Expand All @@ -76,7 +90,29 @@ public function discover(): null|string|bool|array {
* {@inheritdoc}
*/
public function process(): void {
// @todo Implement.
if ($this->response == self::INSTALL_PROFILE_DRUPALCMS) {
$cj = JsonManipulator::fromFile($this->tmpDir . '/composer.json');

$cj->addLink('require', 'drupal/cms', '^1.0', TRUE);
$cj->addLink('require', 'wikimedia/composer-merge-plugin', '^2.1', TRUE);
$cj->addLink('require', 'symfony/http-client', '^6.4 || ^7.0', TRUE);

$cj->addConfigSetting('allow-plugins.drupal/core-project-message', TRUE);
$cj->addConfigSetting('allow-plugins.wikimedia/composer-merge-plugin', TRUE);

$cj->addProperty('extra.merge-plugin.ignore-duplicates', FALSE);
$cj->addProperty('extra.merge-plugin.merge-false', TRUE);
$cj->addProperty('extra.merge-plugin.merge-extra', FALSE);
$cj->addProperty('extra.merge-plugin.merge-extra-deep', FALSE);
$cj->addProperty('extra.merge-plugin.merge-replace', TRUE);
$cj->addProperty('extra.merge-plugin.merge-scripts', FALSE);
$cj->addProperty('extra.merge-plugin.recurse', TRUE);
$cj->addProperty('extra.merge-plugin.replace', TRUE);
$cj->addProperty('extra.merge-plugin.require', ['vendor/drupal/cms/composer.json']);

Comment on lines +96 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Ensure recipes install under web/recipes and allow composer/installers.

Meets PR objective (type:drupal-recipe → web/recipes/{$name}) and avoids Composer 2 plugin blocks.

Apply:

       $cj->addLink('require', 'drupal/cms', '^1.0', TRUE);
       $cj->addLink('require', 'wikimedia/composer-merge-plugin', '^2.1', TRUE);
       $cj->addLink('require', 'symfony/http-client', '^6.4 || ^7.0', TRUE);
+      $cj->addConfigSetting('allow-plugins.composer/installers', TRUE);
@@
-      $cj->addProperty('extra.merge-plugin.require', ['vendor/drupal/cms/composer.json']);
+      $cj->addProperty('extra.merge-plugin.require', ['vendor/drupal/cms/composer.json']);
+      $cj->addProperty('extra.installer-paths.web/recipes/{$name}', ['type:drupal-recipe']);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$cj->addLink('require', 'drupal/cms', '^1.0', TRUE);
$cj->addLink('require', 'wikimedia/composer-merge-plugin', '^2.1', TRUE);
$cj->addLink('require', 'symfony/http-client', '^6.4 || ^7.0', TRUE);
$cj->addConfigSetting('allow-plugins.drupal/core-project-message', TRUE);
$cj->addConfigSetting('allow-plugins.wikimedia/composer-merge-plugin', TRUE);
$cj->addProperty('extra.merge-plugin.ignore-duplicates', FALSE);
$cj->addProperty('extra.merge-plugin.merge-false', TRUE);
$cj->addProperty('extra.merge-plugin.merge-extra', FALSE);
$cj->addProperty('extra.merge-plugin.merge-extra-deep', FALSE);
$cj->addProperty('extra.merge-plugin.merge-replace', TRUE);
$cj->addProperty('extra.merge-plugin.merge-scripts', FALSE);
$cj->addProperty('extra.merge-plugin.recurse', TRUE);
$cj->addProperty('extra.merge-plugin.replace', TRUE);
$cj->addProperty('extra.merge-plugin.require', ['vendor/drupal/cms/composer.json']);
$cj->addLink('require', 'drupal/cms', '^1.0', TRUE);
$cj->addLink('require', 'wikimedia/composer-merge-plugin', '^2.1', TRUE);
$cj->addLink('require', 'symfony/http-client', '^6.4 || ^7.0', TRUE);
$cj->addConfigSetting('allow-plugins.composer/installers', TRUE);
$cj->addConfigSetting('allow-plugins.drupal/core-project-message', TRUE);
$cj->addConfigSetting('allow-plugins.wikimedia/composer-merge-plugin', TRUE);
$cj->addProperty('extra.merge-plugin.ignore-duplicates', FALSE);
$cj->addProperty('extra.merge-plugin.merge-false', TRUE);
$cj->addProperty('extra.merge-plugin.merge-extra', FALSE);
$cj->addProperty('extra.merge-plugin.merge-extra-deep', FALSE);
$cj->addProperty('extra.merge-plugin.merge-replace', TRUE);
$cj->addProperty('extra.merge-plugin.merge-scripts', FALSE);
$cj->addProperty('extra.merge-plugin.recurse', TRUE);
$cj->addProperty('extra.merge-plugin.replace', TRUE);
$cj->addProperty('extra.merge-plugin.require', ['vendor/drupal/cms/composer.json']);
$cj->addProperty('extra.installer-paths.web/recipes/{$name}', ['type:drupal-recipe']);
🤖 Prompt for AI Agents
.vortex/installer/src/Prompts/Handlers/Starter.php around lines 94-110: ensure
project composer config will install recipe packages under web/recipes and won't
be blocked by Composer 2 plugin policies; add an allow-plugins entry for
composer/installers (set to TRUE) alongside the existing allow-plugins entries,
and add an extra.installer-paths entry that maps the drupal-recipe package type
to "web/recipes/{$name}" (so recipes go into web/recipes), keeping the current
merge-plugin properties intact and avoiding any Composer 2 plugin API blocks.

$c = $cj->getContents();
file_put_contents($this->tmpDir . '/composer.json', $c);
}
Comment on lines +93 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Fix loose comparison, null deref, missing installer-path, and Composer plugin allowances.

  • Use strict comparison.
  • Guard JsonManipulator::fromFile() returning NULL and check write result.
  • Add allow-plugins for composer/installers to avoid Composer 2 blocks.
  • Ensure recipes of type drupal-recipe install under web/recipes/{$name}.
  • Replace invalid merge-false with merge-dev.
-    if ($this->response == self::INSTALL_PROFILE_DRUPALCMS) {
-      $cj = JsonManipulator::fromFile($this->tmpDir . '/composer.json');
+    if ($this->response === self::INSTALL_PROFILE_DRUPALCMS) {
+      $composerPath = $this->tmpDir . '/composer.json';
+      $cj = JsonManipulator::fromFile($composerPath);
+      if ($cj === NULL) {
+        throw new \RuntimeException(sprintf('composer.json not found or unreadable at %s', $composerPath));
+      }
@@
       $cj->addLink('require', 'drupal/cms', '^1.0', TRUE);
       $cj->addLink('require', 'wikimedia/composer-merge-plugin', '^2.1', TRUE);
       $cj->addLink('require', 'symfony/http-client', '^6.4 || ^7.0', TRUE);
@@
       $cj->addConfigSetting('allow-plugins.drupal/core-project-message', TRUE);
       $cj->addConfigSetting('allow-plugins.wikimedia/composer-merge-plugin', TRUE);
+      $cj->addConfigSetting('allow-plugins.composer/installers', TRUE);
@@
       $cj->addProperty('extra.merge-plugin.ignore-duplicates', FALSE);
-      $cj->addProperty('extra.merge-plugin.merge-false', TRUE);
+      $cj->addProperty('extra.merge-plugin.merge-dev', TRUE);
       $cj->addProperty('extra.merge-plugin.merge-extra', FALSE);
       $cj->addProperty('extra.merge-plugin.merge-extra-deep', FALSE);
       $cj->addProperty('extra.merge-plugin.merge-replace', TRUE);
       $cj->addProperty('extra.merge-plugin.merge-scripts', FALSE);
       $cj->addProperty('extra.merge-plugin.recurse', TRUE);
       $cj->addProperty('extra.merge-plugin.replace', TRUE);
       $cj->addProperty('extra.merge-plugin.require', ['vendor/drupal/cms/composer.json']);
+      $cj->addProperty('extra.installer-paths.web/recipes/{$name}', ['type:drupal-recipe']);
@@
-      $c = $cj->getContents();
-      file_put_contents($this->tmpDir . '/composer.json', $c);
+      $c = $cj->getContents();
+      if (file_put_contents($composerPath, $c) === FALSE) {
+        throw new \RuntimeException(sprintf('Failed to write composer.json to %s', $composerPath));
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($this->response == self::INSTALL_PROFILE_DRUPALCMS) {
$cj = JsonManipulator::fromFile($this->tmpDir . '/composer.json');
$cj->addLink('require', 'drupal/cms', '^1.0', TRUE);
$cj->addLink('require', 'wikimedia/composer-merge-plugin', '^2.1', TRUE);
$cj->addLink('require', 'symfony/http-client', '^6.4 || ^7.0', TRUE);
$cj->addConfigSetting('allow-plugins.drupal/core-project-message', TRUE);
$cj->addConfigSetting('allow-plugins.wikimedia/composer-merge-plugin', TRUE);
$cj->addProperty('extra.merge-plugin.ignore-duplicates', FALSE);
$cj->addProperty('extra.merge-plugin.merge-false', TRUE);
$cj->addProperty('extra.merge-plugin.merge-extra', FALSE);
$cj->addProperty('extra.merge-plugin.merge-extra-deep', FALSE);
$cj->addProperty('extra.merge-plugin.merge-replace', TRUE);
$cj->addProperty('extra.merge-plugin.merge-scripts', FALSE);
$cj->addProperty('extra.merge-plugin.recurse', TRUE);
$cj->addProperty('extra.merge-plugin.replace', TRUE);
$cj->addProperty('extra.merge-plugin.require', ['vendor/drupal/cms/composer.json']);
$c = $cj->getContents();
file_put_contents($this->tmpDir . '/composer.json', $c);
}
if ($this->response === self::INSTALL_PROFILE_DRUPALCMS) {
$composerPath = $this->tmpDir . '/composer.json';
$cj = JsonManipulator::fromFile($composerPath);
if ($cj === NULL) {
throw new \RuntimeException(sprintf('composer.json not found or unreadable at %s', $composerPath));
}
$cj->addLink('require', 'drupal/cms', '^1.0', TRUE);
$cj->addLink('require', 'wikimedia/composer-merge-plugin', '^2.1', TRUE);
$cj->addLink('require', 'symfony/http-client', '^6.4 || ^7.0', TRUE);
$cj->addConfigSetting('allow-plugins.drupal/core-project-message', TRUE);
$cj->addConfigSetting('allow-plugins.wikimedia/composer-merge-plugin', TRUE);
$cj->addConfigSetting('allow-plugins.composer/installers', TRUE);
$cj->addProperty('extra.merge-plugin.ignore-duplicates', FALSE);
$cj->addProperty('extra.merge-plugin.merge-dev', TRUE);
$cj->addProperty('extra.merge-plugin.merge-extra', FALSE);
$cj->addProperty('extra.merge-plugin.merge-extra-deep', FALSE);
$cj->addProperty('extra.merge-plugin.merge-replace', TRUE);
$cj->addProperty('extra.merge-plugin.merge-scripts', FALSE);
$cj->addProperty('extra.merge-plugin.recurse', TRUE);
$cj->addProperty('extra.merge-plugin.replace', TRUE);
$cj->addProperty('extra.merge-plugin.require', ['vendor/drupal/cms/composer.json']);
$cj->addProperty('extra.installer-paths.web/recipes/{$name}', ['type:drupal-recipe']);
$c = $cj->getContents();
if (file_put_contents($composerPath, $c) === FALSE) {
throw new \RuntimeException(sprintf('Failed to write composer.json to %s', $composerPath));
}
}

}

/**
Expand All @@ -89,12 +125,12 @@ public function postInstall(): ?string {

$output = '';

if ($this->response == self::DRUPAL_LOAD_DATABASE_DEMO) {
if ($this->response == self::LOAD_DATABASE_DEMO) {

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 (assertive)

Use strict comparison.

-    if ($this->response == self::LOAD_DATABASE_DEMO) {
+    if ($this->response === self::LOAD_DATABASE_DEMO) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($this->response == self::LOAD_DATABASE_DEMO) {
if ($this->response === self::LOAD_DATABASE_DEMO) {
🤖 Prompt for AI Agents
.vortex/installer/src/Prompts/Handlers/Starter.php around line 128: the
condition uses loose equality (==) when comparing $this->response to the
constant self::LOAD_DATABASE_DEMO; change it to a strict comparison (===) so the
types and values must match exactly (or cast $this->response to the expected
type before comparing) to prevent unintended truthy/loose-equality matches.

$output .= 'Build project locally:' . PHP_EOL;
$output .= ' ahoy build' . PHP_EOL;
$output .= PHP_EOL;
}
elseif ($this->response == self::DRUPAL_INSTALL_PROFILE) {
elseif ($this->response == self::INSTALL_PROFILE_CORE || $this->response == self::INSTALL_PROFILE_DRUPALCMS) {
$output .= 'Build project locally:' . PHP_EOL;
$output .= ' VORTEX_PROVISION_TYPE=profile ahoy build' . PHP_EOL;
$output .= PHP_EOL;
Expand Down
13 changes: 3 additions & 10 deletions .vortex/installer/src/Prompts/Handlers/Webroot.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,9 @@ public function resolvedValue(array $responses): null|string|bool|array {
/**
* {@inheritdoc}
*/
public function resolvedMessage(array $responses): ?string {
if (
isset($responses[HostingProvider::id()]) &&
$responses[HostingProvider::id()] !== HostingProvider::OTHER
) {
$webroot = $this->default($responses);
if (is_array($webroot)) {
throw new \InvalidArgumentException('Web root must be a string, got: ' . gettype($webroot));
}
return sprintf('Web root will be set to "%s".', (string) $webroot);
public function resolvedMessage(array $responses, mixed $resolved): ?string {
if (is_string($resolved)) {
return sprintf('Web root will be set to "%s".', $resolved);
}

return NULL;
Expand Down
49 changes: 38 additions & 11 deletions .vortex/installer/src/Prompts/PromptManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ public function runPrompts(): void {
->intro('Drupal')
->add(fn($r, $pr, $n): int|string => select(...$this->args(Starter::class, NULL, $r)), Starter::id())
->add(
fn($r, $pr, $n): int|string => select(...$this->args(Profile::class)),
function (array $r, $pr, $n): string {
return $this->resolveOrPrompt(Profile::id(), $r, fn(): int|string => select(...$this->args(Profile::class)));
},
Profile::id()
)
->addIf(
Expand All @@ -147,17 +149,8 @@ public function runPrompts(): void {
->intro('Hosting')
->add(fn($r, $pr, $n): int|string => select(...$this->args(HostingProvider::class)), HostingProvider::id())
->add(
// @todo Move into centralised resolver within this class.
function (array $r, $pr, $n): string {
$handler = $this->handlers[Webroot::id()];
$resolved = $handler->resolvedValue($r);
if (is_string($resolved)) {
info($handler->resolvedMessage($r));
return $resolved;
}
else {
return text(...$this->args(Webroot::class, NULL, $r));
}
return $this->resolveOrPrompt(Webroot::id(), $r, fn(): string => text(...$this->args(Webroot::class, NULL, $r)));
},
Webroot::id()
)
Expand Down Expand Up @@ -567,4 +560,38 @@ private function args(string $handler_class, mixed $default_override = NULL, arr
return array_filter($args, fn($value): bool => $value !== NULL);
}

/**
* Resolve a value via handler or prompt the user.
*
* This method is used to resolve a value via a handler's resolvedValue()
* method. If the value is not resolved, it will prompt the user using the
* provided prompt callable.
*
* @param string $handler_id
* The handler ID.
* @param array $r
* Current form responses for context-aware methods.
* @param callable $prompt
* The prompt callable to use if the value is not resolved.
*
* @return string
* The resolved value.
*/
protected function resolveOrPrompt(string $handler_id, array $r, callable $prompt): string {
$handler = $this->handlers[$handler_id];
$resolved = $handler->resolvedValue($r);

if (is_string($resolved)) {
$message = $handler->resolvedMessage($r, $resolved);

if ($message) {
info($message);
}

return $resolved;
}

return (string) $prompt();
}

Comment on lines +563 to +596

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 (assertive)

Optional: guard handler existence in resolveOrPrompt.

Defensive check mirrors other parts of the class.

   protected function resolveOrPrompt(string $handler_id, array $r, callable $prompt): string {
-    $handler = $this->handlers[$handler_id];
+    if (!array_key_exists($handler_id, $this->handlers)) {
+      throw new \RuntimeException(sprintf('Handler for "%s" not found.', $handler_id));
+    }
+    $handler = $this->handlers[$handler_id];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Resolve a value via handler or prompt the user.
*
* This method is used to resolve a value via a handler's resolvedValue()
* method. If the value is not resolved, it will prompt the user using the
* provided prompt callable.
*
* @param string $handler_id
* The handler ID.
* @param array $r
* Current form responses for context-aware methods.
* @param callable $prompt
* The prompt callable to use if the value is not resolved.
*
* @return string
* The resolved value.
*/
protected function resolveOrPrompt(string $handler_id, array $r, callable $prompt): string {
$handler = $this->handlers[$handler_id];
$resolved = $handler->resolvedValue($r);
if (is_string($resolved)) {
$message = $handler->resolvedMessage($r, $resolved);
if ($message) {
info($message);
}
return $resolved;
}
return (string) $prompt();
}
/**
* Resolve a value via handler or prompt the user.
*
* This method is used to resolve a value via a handler's resolvedValue()
* method. If the value is not resolved, it will prompt the user using the
* provided prompt callable.
*
* @param string $handler_id
* The handler ID.
* @param array $r
* Current form responses for context-aware methods.
* @param callable $prompt
* The prompt callable to use if the value is not resolved.
*
* @return string
* The resolved value.
*/
protected function resolveOrPrompt(string $handler_id, array $r, callable $prompt): string {
if (!array_key_exists($handler_id, $this->handlers)) {
throw new \RuntimeException(sprintf('Handler for "%s" not found.', $handler_id));
}
$handler = $this->handlers[$handler_id];
$resolved = $handler->resolvedValue($r);
if (is_string($resolved)) {
$message = $handler->resolvedMessage($r, $resolved);
if ($message) {
info($message);
}
return $resolved;
}
return (string) $prompt();
}
🤖 Prompt for AI Agents
.vortex/installer/src/Prompts/PromptManager.php around lines 563 to 596:
resolveOrPrompt assumes $this->handlers[$handler_id] exists; add a defensive
guard like if (!isset($this->handlers[$handler_id])) { throw new
\InvalidArgumentException("Unknown handler: {$handler_id}"); } (or return a
sensible default) before using it, so the method mirrors other parts of the
class that validate handler existence and avoids undefined index errors.

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ web/sites/default/*
# Preserve default settings files to use them in Functional test.
!web/sites/default/default.settings.php
!web/sites/default/default.services.yml
# Whitelist Drupal Scaffold files. @see https://github.com/drupal-composer/drupal-scaffold#limitation
# Allow Drupal Scaffold files. @see https://github.com/drupal-composer/drupal-scaffold#limitation
!web/.editorconfig
!web/.eslintignore
!web/.gitattributes
Expand All @@ -39,6 +39,9 @@ web/sites/default/*
!web/robots.txt
!web/update.php

# Ignore all recipes by default. Custom recipes should be added explicitly.
recipes/*

# Ignore dependencies cache files.
/vendor
/node_modules
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@
"web/profiles/contrib/{$name}": [
"type:drupal-profile"
],
"recipes/{$name}": [
"type:drupal-recipe"
],
"web/themes/contrib/{$name}": [
"type:drupal-theme"
],
Expand Down
Loading