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
42 changes: 42 additions & 0 deletions core/tests/Fixtures/cli-install-input.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

// Run in a child process: readline must see the real stdin, not a mocked answer.
$root = dirname(__DIR__, 3);
$mode = $argv[1];
if ($mode === 'prefix-cli') {
define('EVO_CORE_PATH', $argv[2] . '/');
}
require $root . '/install/cli-install.php';

if ($mode === 'prefix-cli') {
exit(runCliInstall([
'cli-install.php', '--typeInstall=1', '--databaseType=sqlite', '--database=existing',
'--tablePrefix=evo_', '--cmsAdmin=admin', '--cmsAdminEmail=admin@example.local',
'--cmsPassword=example-password', '--language=uk', '--removeInstall=y', '--skipComposer=y',
]));
}

$installer = new InstallEvo([]);
try {
switch ($mode) {
case 'ask':
echo 'ANSWER:' . $installer->ask('Database name?', 'fallback');
break;
case 'choice':
echo 'ANSWER:' . $installer->choice('Install or update?', ['Install', 'Update']);
break;
case 'prefix-recovery':
$installer->dbh = new PDO('sqlite::memory:');
$installer->dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$installer->dbh->exec('CREATE TABLE evo_site_content (id INTEGER)');
$installer->tablePrefix = 'evo_';
$installer->checkIssetTablePrefix();
echo 'ANSWER:' . $installer->tablePrefix;
break;
default:
throw new RuntimeException('Unknown test mode');
}
} catch (RuntimeException $exception) {
error($exception->getMessage());
exit(1);
}
105 changes: 105 additions & 0 deletions core/tests/standalone/CliInstallInputTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

/**
* Exercise actual readline input in bounded child processes, without booting a site.
* Output goes to files so a regressed busy loop cannot block on a full output pipe.
*/
$root = dirname(__DIR__, 3);
$fixture = $root . '/core/tests/Fixtures/cli-install-input.php';
$temp = sys_get_temp_dir() . '/evo-cli-input-' . bin2hex(random_bytes(6));
mkdir($temp . '/database', 0700, true);

/**
* Run a PHP child with an immediately closed stdin pipe or the platform null device.
* A deadline turns the original endless retry into a failing regression test.
*
* @param list<string> $arguments PHP script path followed by its arguments.
* @return array{int, string} Exit status and combined output.
*/
$run = static function (array $arguments, string $input = '', bool $nullInput = false) use ($temp, $root): array {
$outputFile = $temp . '/output';
$descriptors = [
0 => $nullInput ? ['file', PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'r'] : ['pipe', 'r'],
1 => ['file', $outputFile, 'w'],
2 => ['file', $outputFile, 'a'],
];
$process = proc_open([PHP_BINARY, ...$arguments], $descriptors, $pipes, $root . '/install');
if (!is_resource($process)) {
throw new RuntimeException('Could not start PHP input probe');
}
if (!$nullInput) {
if ($input !== '') {
fwrite($pipes[0], $input);
}
fclose($pipes[0]);
}
$deadline = microtime(true) + 5;
do {
$status = proc_get_status($process);
if (!$status['running']) {
$exitCode = $status['exitcode'];
proc_close($process);
return [$exitCode, (string) file_get_contents($outputFile)];
}
usleep(10000);
} while (microtime(true) < $deadline);

proc_terminate($process, 9);
proc_close($process);
throw new RuntimeException('CLI input probe exceeded five seconds: ' . implode(' ', $arguments));
};

$check = static function (array $result, int $exitCode, string $message): void {
if ($result[0] !== $exitCode || !str_contains($result[1], $message)) {
throw new RuntimeException('Unexpected CLI result: ' . var_export($result, true));
}
if (preg_match('/\b(Fatal error|Warning|Notice|Deprecated):/', $result[1])) {
throw new RuntimeException('PHP diagnostic in CLI output: ' . $result[1]);
}
};

try {
foreach ([false, true] as $nullInput) {
// Exercise the real entrypoint and its RuntimeException -> exit 1 handling.
$check($run([$root . '/install/cli-install.php'], '', $nullInput), 1,
'End of input while waiting for: Please choose your variant of install:');
$check($run([$root . '/install/cli-install.php', '--typeInstall=1', '--databaseType=sqlite'], '', $nullInput), 1,
'End of input while waiting for: Please enter database:');
// EOF must not silently accept either kind of default.
$check($run([$fixture, 'ask'], '', $nullInput), 1, 'End of input while waiting for: Database name?');
$check($run([$fixture, 'choice'], '', $nullInput), 1, 'End of input while waiting for: Install or update?');
}

$check($run([$fixture, 'ask'], "\n"), 0, 'ANSWER:fallback');
$check($run([$fixture, 'ask'], " custom \n"), 0, 'ANSWER:custom');
$check($run([$fixture, 'choice'], "\n"), 0, 'ANSWER:Install');
$check($run([$fixture, 'choice'], "invalid\n2\n"), 0, 'ANSWER:Update');
$check($run([$fixture, 'choice'], "invalid\n"), 1, 'End of input while waiting for: Install or update?');

// Real SQLite prefix collision with all CLI arguments supplied. The failure
// must leave the existing data and installer intact, even with removeInstall=y.
$database = new PDO('sqlite:' . $temp . '/database/existing.sqlite');
$database->exec('CREATE TABLE evo_site_content (id INTEGER)');
$database->exec('INSERT INTO evo_site_content VALUES (42)');
foreach ([false, true] as $nullInput) {
$result = $run([$fixture, 'prefix-cli', $temp], '', $nullInput);
$check($result, 1, 'Table prefix already exists');
$check($result, 1, 'End of input while waiting for: Please enter table_prefix:');
if ((int) $database->query('SELECT id FROM evo_site_content')->fetchColumn() !== 42
|| !is_file($root . '/install/cli-install.php')
|| is_file($temp . '/config/database/connections/default.php')) {
throw new RuntimeException('Prefix conflict modified data, wrote configuration or removed the installer');
}
}
$check($run([$fixture, 'prefix-recovery'], "other_\n"), 0, 'ANSWER:other_');
$check($run([$fixture, 'prefix-recovery'], "\n"), 1, 'End of input while waiting for: Please enter table_prefix:');
echo "PASS: CLI EOF exits 1, defaults require a line, piped answers work, prefix conflict preserves data.\n";
} finally {
$database = null;
foreach (glob($temp . '/database/*') ?: [] as $file) {
unlink($file);
}
@unlink($temp . '/output');
rmdir($temp . '/database');
rmdir($temp);
}
14 changes: 14 additions & 0 deletions install/cli-install.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
*
* The sqlite database is created as core/database/<--database>.sqlite
* Use --skipComposer=y to keep the already installed dependencies untouched.
* Missing or invalid values can prompt for input. EOF at a prompt exits with code 1;
* an empty input line, rather than EOF, is required to accept a prompt's default.
* An update restores the packages of core/custom/composer.json unless --skipComposer=y is given,
* at the versions the last restore noted; --updateCustomPackages=y lets Composer choose others.
* When they cannot be restored, the update ends incomplete: exit code 1, install/ kept.
Expand Down Expand Up @@ -49,6 +51,10 @@ function runCliInstall(array $argv): int
exit(runCliInstall($argv));
}

/**
* Run CLI installation or update steps, prompting only for missing or invalid input.
* Input prompts throw on EOF so unattended runs terminate instead of retrying forever.
*/
class InstallEvo
{
/**
Expand Down Expand Up @@ -159,6 +165,7 @@ public function read_line($message, $message2 = "")
* @param array $options ['Install', 'Update', …]
* @param int $default Default option index
* @return string Selected element of the $options array
* @throws RuntimeException When stdin reaches EOF before a selection is supplied.
*/
public function choice(string $question, array $options, int $default = 0): string
{
Expand Down Expand Up @@ -191,6 +198,9 @@ public function choice(string $question, array $options, int $default = 0): stri
$max = count($options);
do {
$answer = readline("Choose [1-{$max}]: ");
if ($answer === false) {
throw new RuntimeException('End of input while waiting for: ' . $question . ' Supply the required CLI arguments or provide stdin.');
}
if ($answer === '' && isset($options[$default])) {
return $options[$default];
}
Expand All @@ -206,6 +216,7 @@ public function choice(string $question, array $options, int $default = 0): stri
* @param string $question Question text («Please enter database server»)
* @param string|null $default Default value if Enter is pressed
* @return string
* @throws RuntimeException When stdin reaches EOF; defaults apply only to an empty line.
*/
public function ask(string $question, ?string $default = null): string
{
Expand All @@ -217,6 +228,9 @@ public function ask(string $question, ?string $default = null): string
echo PHP_EOL . $yellow . $question . $suffix . $reset . PHP_EOL;

$answer = readline('➤ ');
if ($answer === false) {
throw new RuntimeException('End of input while waiting for: ' . $question . ' Supply the required CLI arguments or provide stdin.');
}
return $answer !== '' ? trim($answer) : (string) $default;
}

Expand Down
Loading