diff --git a/bin/qi b/bin/qi index dd6c3b12..34584b5a 100755 --- a/bin/qi +++ b/bin/qi @@ -9,6 +9,10 @@ if (PHP_VERSION_ID < 80000) } require __DIR__ . '/../src/QuickInstall/Sandbox/Application.php'; +require __DIR__ . '/../src/QuickInstall/Sandbox/Output.php'; +require __DIR__ . '/../src/QuickInstall/Sandbox/StreamOutput.php'; +require __DIR__ . '/../src/QuickInstall/Sandbox/BufferedOutput.php'; +require __DIR__ . '/../src/QuickInstall/Sandbox/ProcessRunner.php'; require __DIR__ . '/../src/QuickInstall/Sandbox/CommandLine.php'; require __DIR__ . '/../src/QuickInstall/Sandbox/Project.php'; require __DIR__ . '/../src/QuickInstall/Sandbox/VersionMatrix.php'; diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index cacee5ce..6929293f 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -128,7 +128,7 @@ private function init(): int private function sourceList(): int { - $sources = (new SourceService($this->project))->list(); + $sources = (new SourceService($this->project, $this->sandboxOutput()))->list(); if (!$sources) { @@ -163,7 +163,7 @@ private function sourceRemove(array $args): int throw new InvalidArgumentException('Usage: qi source:remove [--force]'); } - $removed = (new SourceService($this->project))->remove($version, $cli->has('force')); + $removed = (new SourceService($this->project, $this->sandboxOutput()))->remove($version, $cli->has('force')); echo "Removed source: {$removed['source']['source_key']}\n"; if (!empty($removed['used_by'])) { @@ -175,7 +175,7 @@ private function sourceRemove(array $args): int private function sourcePrune(): int { - $removed = (new SourceService($this->project))->prune(); + $removed = (new SourceService($this->project, $this->sandboxOutput()))->prune(); if (!$removed) { echo "No unused sources to prune\n"; @@ -199,7 +199,7 @@ private function sourceFetch(array $args): int throw new InvalidArgumentException('Usage: qi source:fetch [--git] [--url URL] [--allow-external]'); } - $record = (new SourceService($this->project))->fetch($version, $cli->has('git'), $cli->option('url'), $cli->has('allow-external')); + $record = (new SourceService($this->project, $this->sandboxOutput()))->fetch($version, $cli->has('git'), $cli->option('url'), $cli->has('allow-external')); echo "Fetched phpBB source: {$record['path']}\n"; return 0; @@ -217,7 +217,7 @@ private function phpbbList(): int $row['resolves_to'], $row['notes'], ]; - }, (new SourceService($this->project))->supportedVersions()) + }, (new SourceService($this->project, $this->sandboxOutput()))->supportedVersions()) ); return 0; @@ -240,7 +240,7 @@ private function boardCreate(array $args): int $debug = $cli->has('debug'); $this->validateBoardCreateOptions($db, $port, $populate); - $created = (new BoardService($this->project))->create($name, $version, $db, $port, $populate, $debug, $cli->has('replace')); + $created = (new BoardService($this->project, $this->sandboxOutput()))->create($name, $version, $db, $port, $populate, $debug, $cli->has('replace')); $paths = $created['paths']; echo "Created board scaffold: $name\n"; @@ -266,7 +266,7 @@ private function boardCreate(array $args): int private function boardList(): int { - $boards = (new BoardService($this->project))->list(); + $boards = (new BoardService($this->project, $this->sandboxOutput()))->list(); if (!$boards) { echo "No boards created\n"; @@ -408,7 +408,7 @@ private function supportsAnsi(): bool private function boardStart(array $args): int { $name = $this->boardName($args, 'Usage: qi board:start '); - $board = (new BoardService($this->project))->start($name); + $board = (new BoardService($this->project, $this->sandboxOutput()))->start($name); echo "Started board: $name\n"; echo "URL: {$board['url']}\n"; return 0; @@ -417,7 +417,7 @@ private function boardStart(array $args): int private function boardStop(array $args): int { $name = $this->boardName($args, 'Usage: qi board:stop '); - (new BoardService($this->project))->stop($name); + (new BoardService($this->project, $this->sandboxOutput()))->stop($name); echo "Stopped board: $name\n"; return 0; } @@ -425,7 +425,7 @@ private function boardStop(array $args): int private function boardDestroy(array $args): int { $name = $this->boardName($args, 'Usage: qi board:destroy '); - (new BoardService($this->project))->destroy($name); + (new BoardService($this->project, $this->sandboxOutput()))->destroy($name); echo "Destroyed board: $name\n"; return 0; } @@ -452,7 +452,7 @@ private function boardSeed(array $args): int } $action = $cli->has('reset') ? 'reset' : ($cli->has('replace') ? 'replace' : 'seed'); - (new BoardService($this->project))->seed($name, $preset, $seed, $action); + (new BoardService($this->project, $this->sandboxOutput()))->seed($name, $preset, $seed, $action); echo ucfirst($action) . " completed for board: $name\n"; return 0; } @@ -538,7 +538,7 @@ private function extUnmount(array $args): int private function refreshBoardIfRunning(string $board): void { - $runner = new BoardRunner($this->project); + $runner = new BoardRunner($this->project, $this->sandboxOutput()); (new DockerComposeWriter($this->project))->write($board, $this->runtimeConfig($this->project->board($board))); if ($runner->status($board) === 'running') { @@ -677,6 +677,12 @@ private function writeError(string $message): void fwrite($this->stderr, $message); } + private function sandboxOutput(): Output + { + $stdout = defined('STDOUT') && defined('STDERR') && $this->stderr === STDERR ? STDOUT : fopen('php://output', 'w'); + return new StreamOutput($stdout, $this->stderr); + } + private function mountResources(string $type, object $manager, string $board, string $source, bool $copy, bool $recursive, bool $allowExternal): int { if ($recursive) diff --git a/src/QuickInstall/Sandbox/BoardRunner.php b/src/QuickInstall/Sandbox/BoardRunner.php index 79b45a51..3f344a7c 100644 --- a/src/QuickInstall/Sandbox/BoardRunner.php +++ b/src/QuickInstall/Sandbox/BoardRunner.php @@ -16,21 +16,25 @@ class BoardRunner { private Project $project; + private Output $output; + private ProcessRunner $processRunner; - public function __construct(Project $project) + public function __construct(Project $project, ?Output $output = null, ?ProcessRunner $processRunner = null) { $this->project = $project; + $this->output = $output ?: new BufferedOutput(); + $this->processRunner = $processRunner ?: new ProcessRunner($this->output); } public function start(string $name): void { $board = $this->project->board($name); $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'up', '--build', '-d', '--force-recreate', '--remove-orphans', 'web']); - echo "Waiting for phpBB install to finish...\n"; + $this->output->write("Waiting for phpBB install to finish...\n"); $this->waitUntilInstalled($name); if (!empty($board['debug'])) { - echo "Enabling phpBB debug config...\n"; + $this->output->write("Enabling phpBB debug config...\n"); $this->enableDebug($name); } if (($board['db'] ?? '') === 'sqlite' && ($board['populate'] ?? 'none') !== 'none') @@ -141,7 +145,7 @@ protected function seedIfNeeded(string $name, string $preset): void $marker = $this->seedMarker($name, $preset); if (file_exists($marker)) { - echo "Populate preset already applied: $preset\n"; + $this->output->write("Populate preset already applied: $preset\n"); return; } @@ -289,7 +293,7 @@ protected function waitUntilHttpReady(string $name, string $url): void return; } - echo "Waiting for board URL...\n"; + $this->output->write("Waiting for board URL...\n"); $deadline = time() + 30; while (time() <= $deadline) { @@ -302,8 +306,8 @@ protected function waitUntilHttpReady(string $name, string $url): void usleep(500000); } - echo "Warning: board container started, but $url was not reachable from the host after 30 seconds.\n"; - echo "Try opening it again in a few seconds, or run: docker compose -f " . $this->project->composePath($name) . " logs web\n"; + $this->output->write("Warning: board container started, but $url was not reachable from the host after 30 seconds.\n"); + $this->output->write("Try opening it again in a few seconds, or run: docker compose -f " . $this->project->composePath($name) . " logs web\n"); } protected function httpStatus(string $url): int @@ -347,7 +351,7 @@ protected function runSeeder(string $name, string $preset, int $seed, string $ac $writer = new SeederWriter($this->project); $script = $writer->write($name); - echo "Running seed preset: $preset\n"; + $this->output->write("Running seed preset: $preset\n"); $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'cp', $script, 'web:/tmp/qi_seed.php']); $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'exec', '-T', 'web', 'timeout', '300', 'php', '/tmp/qi_seed.php', $preset, (string) $seed, $action]); } @@ -373,50 +377,12 @@ protected function writeSeedMarker(string $name, string $preset): void protected function run(array $command): void { - echo '$ ' . implode(' ', array_map('escapeshellarg', $command)) . "\n"; - - $descriptor = [ - 0 => ['file', '/dev/null', 'r'], - 1 => defined('STDOUT') ? constant('STDOUT') : ['file', 'php://output', 'w'], - 2 => defined('STDERR') ? constant('STDERR') : ['file', 'php://stderr', 'w'], - ]; - - $process = proc_open($command, $descriptor, $pipes); - if (!is_resource($process)) - { - throw new RuntimeException('Unable to start command: ' . $command[0]); - } - - $status = proc_close($process); - if ($status !== 0) - { - throw new RuntimeException("Command failed with exit code $status: {$command[0]}" . $this->commandHint($command, $status)); - } + $this->processRunner->run($command); } protected function capture(array $command): array { - $descriptor = [ - 0 => ['file', '/dev/null', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - - $process = proc_open($command, $descriptor, $pipes); - if (!is_resource($process)) - { - return ['exit_code' => 1, 'output' => '']; - } - - $output = stream_get_contents($pipes[1]) ?: ''; - $error = stream_get_contents($pipes[2]) ?: ''; - fclose($pipes[1]); - fclose($pipes[2]); - - return [ - 'exit_code' => proc_close($process), - 'output' => $output . $error, - ]; + return $this->processRunner->capture($command); } protected function lines(string $output): array @@ -426,18 +392,4 @@ protected function lines(string $output): array })); } - protected function commandHint(array $command, int $status): string - { - if ($status === 124 && in_array('timeout', $command, true)) - { - return "\nThe operation timed out. For seeding, try a smaller preset or use mariadb/mysql/postgres instead of sqlite."; - } - - if (($command[0] ?? '') === 'docker') - { - return "\nCheck that Docker Desktop is running and that the docker command works in this terminal."; - } - - return ''; - } } diff --git a/src/QuickInstall/Sandbox/BoardService.php b/src/QuickInstall/Sandbox/BoardService.php index 69f4e23b..1577db96 100644 --- a/src/QuickInstall/Sandbox/BoardService.php +++ b/src/QuickInstall/Sandbox/BoardService.php @@ -16,10 +16,12 @@ class BoardService { private Project $project; + private ?Output $output; - public function __construct(Project $project) + public function __construct(Project $project, ?Output $output = null) { $this->project = $project; + $this->output = $output; } public function create(string $name, string $version = 'latest', string $db = 'mariadb', int $port = 8080, string $populate = 'none', bool $debug = false, bool $replace = false): array @@ -153,12 +155,12 @@ public function seed(string $name, string $preset, int $seed, string $action): v protected function createBoardRunner(): BoardRunner { - return new BoardRunner($this->project); + return new BoardRunner($this->project, $this->output); } protected function createSourceProvider(): SourceProvider { - return new SourceProvider($this->project); + return new SourceProvider($this->project, $this->output); } protected function createDockerComposeWriter(): DockerComposeWriter diff --git a/src/QuickInstall/Sandbox/BufferedOutput.php b/src/QuickInstall/Sandbox/BufferedOutput.php new file mode 100644 index 00000000..c71b4cd3 --- /dev/null +++ b/src/QuickInstall/Sandbox/BufferedOutput.php @@ -0,0 +1,42 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +class BufferedOutput implements Output +{ + private string $output = ''; + private string $errorOutput = ''; + + public function write(string $message): void + { + $this->output .= $message; + } + + public function error(string $message): void + { + $this->errorOutput .= $message; + } + + public function output(): string + { + return $this->output; + } + + public function errorOutput(): string + { + return $this->errorOutput; + } + + public function all(): string + { + return $this->output . $this->errorOutput; + } +} diff --git a/src/QuickInstall/Sandbox/Output.php b/src/QuickInstall/Sandbox/Output.php new file mode 100644 index 00000000..330137d2 --- /dev/null +++ b/src/QuickInstall/Sandbox/Output.php @@ -0,0 +1,18 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +interface Output +{ + public function write(string $message): void; + + public function error(string $message): void; +} diff --git a/src/QuickInstall/Sandbox/ProcessRunner.php b/src/QuickInstall/Sandbox/ProcessRunner.php new file mode 100644 index 00000000..06a36f10 --- /dev/null +++ b/src/QuickInstall/Sandbox/ProcessRunner.php @@ -0,0 +1,133 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +use RuntimeException; + +class ProcessRunner +{ + private Output $output; + + public function __construct(?Output $output = null) + { + $this->output = $output ?: new BufferedOutput(); + } + + public function run(array $command, ?string $cwd = null): void + { + $this->output->write('$ ' . implode(' ', array_map('escapeshellarg', $command)) . "\n"); + $result = $this->output instanceof StreamOutput ? $this->runWithStreamOutput($command, $cwd) : $this->execute($command, true, $cwd); + if ($result['exit_code'] !== 0) + { + throw new RuntimeException("Command failed with exit code {$result['exit_code']}: {$command[0]}" . $this->commandHint($command, $result['exit_code'])); + } + } + + public function capture(array $command, ?string $cwd = null): array + { + return $this->execute($command, false, $cwd); + } + + private function execute(array $command, bool $stream, ?string $cwd): array + { + $descriptor = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptor, $pipes, $cwd); + if (!is_resource($process)) + { + return ['exit_code' => 1, 'output' => '']; + } + + fclose($pipes[0]); + foreach ([1, 2] as $index) + { + stream_set_blocking($pipes[$index], false); + } + + $output = ''; + $open = [1 => true, 2 => true]; + while ($open[1] || $open[2]) + { + foreach ([1, 2] as $index) + { + if (!$open[$index]) + { + continue; + } + + $chunk = stream_get_contents($pipes[$index]); + if ($chunk !== false && $chunk !== '') + { + $output .= $chunk; + if ($stream) + { + $index === 1 ? $this->output->write($chunk) : $this->output->error($chunk); + } + } + + if (feof($pipes[$index])) + { + fclose($pipes[$index]); + $open[$index] = false; + } + } + + if ($open[1] || $open[2]) + { + usleep(10000); + } + } + + return [ + 'exit_code' => proc_close($process), + 'output' => $output, + ]; + } + + private function runWithStreamOutput(array $command, ?string $cwd): array + { + $descriptor = [ + 0 => ['file', '/dev/null', 'r'], + 1 => $this->output->stdout(), + 2 => $this->output->stderr(), + ]; + + $process = proc_open($command, $descriptor, $pipes, $cwd); + if (!is_resource($process)) + { + return ['exit_code' => 1, 'output' => '']; + } + + return [ + 'exit_code' => proc_close($process), + 'output' => '', + ]; + } + + private function commandHint(array $command, int $status): string + { + if ($status === 124 && in_array('timeout', $command, true)) + { + return "\nThe operation timed out. For seeding, try a smaller preset or use mariadb/mysql/postgres instead of sqlite."; + } + + if (($command[0] ?? '') === 'docker') + { + return "\nCheck that Docker Desktop is running and that the docker command works in this terminal."; + } + + return ''; + } +} diff --git a/src/QuickInstall/Sandbox/SourceProvider.php b/src/QuickInstall/Sandbox/SourceProvider.php index 32f13578..b61d9f04 100644 --- a/src/QuickInstall/Sandbox/SourceProvider.php +++ b/src/QuickInstall/Sandbox/SourceProvider.php @@ -16,10 +16,14 @@ class SourceProvider { private Project $project; + private Output $output; + private ProcessRunner $processRunner; - public function __construct(Project $project) + public function __construct(Project $project, ?Output $output = null, ?ProcessRunner $processRunner = null) { $this->project = $project; + $this->output = $output ?: new BufferedOutput(); + $this->processRunner = $processRunner ?: new ProcessRunner($this->output); } public function add(string $version, string $type, ?string $url, bool $allowExternal = false): array @@ -86,7 +90,7 @@ public function ensure(string $version): array if (!isset($sources[$selection['source_key']])) { - echo "Registering phpBB source: $version\n"; + $this->output->write("Registering phpBB source: $version\n"); $sources[$selection['source_key']] = $this->add($version, 'composer', null); } @@ -94,7 +98,7 @@ public function ensure(string $version): array $source = $this->withSelectionDefaults($source, $selection); if (!file_exists($source['path'] . '/common.php')) { - echo "Fetching phpBB source: $version\n"; + $this->output->write("Fetching phpBB source: $version\n"); $this->fetch($source); } $source = $this->withInstalledSourceMetadata($source, $selection['php']); @@ -112,7 +116,7 @@ protected function ensureRegisteredSource(array $source): array ]; if (!file_exists($source['path'] . '/common.php')) { - echo "Fetching phpBB source: {$source['source_key']}\n"; + $this->output->write("Fetching phpBB source: {$source['source_key']}\n"); $this->fetch($source); } @@ -134,7 +138,7 @@ protected function ensureFloating(string $version, array $selection): array if (!isset($sources[$selection['source_key']])) { - echo "Resolving phpBB source: $version\n"; + $this->output->write("Resolving phpBB source: $version\n"); } $resolvedVersion = null; @@ -169,7 +173,7 @@ protected function ensureFloating(string $version, array $selection): array $tempSource['constraint'] = $resolvedVersion; } - echo "Fetching phpBB source: $version\n"; + $this->output->write("Fetching phpBB source: $version\n"); try { $this->fetch($tempSource); @@ -286,7 +290,7 @@ public function fetch(array $source): void return; } - echo "Removing incomplete source path: $path\n"; + $this->output->write("Removing incomplete source path: $path\n"); $this->project->deleteTree($path); } @@ -559,49 +563,23 @@ protected function isCommandAvailable(string $command): bool protected function run(array $command, string $cwd): void { - echo '$ ' . implode(' ', array_map('escapeshellarg', $command)) . "\n"; - - $descriptor = [ - 0 => ['file', '/dev/null', 'r'], - 1 => defined('STDOUT') ? constant('STDOUT') : ['file', 'php://output', 'w'], - 2 => defined('STDERR') ? constant('STDERR') : ['file', 'php://stderr', 'w'], - ]; - - $process = proc_open($command, $descriptor, $pipes, $cwd); - if (!is_resource($process)) + try { - throw new RuntimeException('Unable to start command: ' . $command[0]); + $this->processRunner->run($command, $cwd); } - - $status = proc_close($process); - if ($status !== 0) + catch (RuntimeException $e) { - throw new RuntimeException("Command failed with exit code $status: {$command[0]}" . $this->commandHint($command)); + throw new RuntimeException($e->getMessage() . $this->commandHint($command), 0, $e); } } protected function capture(array $command, string $cwd): array { - $descriptor = [ - 0 => ['file', '/dev/null', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - - $process = proc_open($command, $descriptor, $pipes, $cwd); - if (!is_resource($process)) - { - return ['status' => 1, 'output' => '']; - } - - $output = stream_get_contents($pipes[1]); - stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); + $result = $this->processRunner->capture($command, $cwd); return [ - 'status' => proc_close($process), - 'output' => (string) $output, + 'status' => $result['exit_code'], + 'output' => $result['output'], ]; } diff --git a/src/QuickInstall/Sandbox/SourceService.php b/src/QuickInstall/Sandbox/SourceService.php index 783643d4..ad4220de 100644 --- a/src/QuickInstall/Sandbox/SourceService.php +++ b/src/QuickInstall/Sandbox/SourceService.php @@ -15,10 +15,12 @@ class SourceService { private Project $project; + private ?Output $output; - public function __construct(Project $project) + public function __construct(Project $project, ?Output $output = null) { $this->project = $project; + $this->output = $output; } public function list(): array @@ -40,7 +42,7 @@ public function list(): array public function fetch(string $version, bool $git = false, ?string $url = null, bool $allowExternal = false): array { $this->project->init(); - $provider = new SourceProvider($this->project); + $provider = new SourceProvider($this->project, $this->output); if ($git) { $provider->add($version, 'git', $url, $allowExternal); diff --git a/src/QuickInstall/Sandbox/StreamOutput.php b/src/QuickInstall/Sandbox/StreamOutput.php new file mode 100644 index 00000000..3af7c7cb --- /dev/null +++ b/src/QuickInstall/Sandbox/StreamOutput.php @@ -0,0 +1,43 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +class StreamOutput implements Output +{ + private $stdout; + private $stderr; + + public function __construct($stdout = null, $stderr = null) + { + $this->stdout = $stdout ?: (defined('STDOUT') ? STDOUT : fopen('php://output', 'w')); + $this->stderr = $stderr ?: (defined('STDERR') ? STDERR : fopen('php://stderr', 'w')); + } + + public function write(string $message): void + { + fwrite($this->stdout, $message); + } + + public function error(string $message): void + { + fwrite($this->stderr, $message); + } + + public function stdout() + { + return $this->stdout; + } + + public function stderr() + { + return $this->stderr; + } +} diff --git a/tests/Unit/BoardRunnerTest.php b/tests/Unit/BoardRunnerTest.php index 4f99a55c..2b9fb6c2 100644 --- a/tests/Unit/BoardRunnerTest.php +++ b/tests/Unit/BoardRunnerTest.php @@ -142,8 +142,11 @@ public function testStartRunsDockerWaitsEnablesDebugSeedsAndChecksHttp(): void file_put_contents($boardPath . '/config.php', "\n"); $runner = new TestBoardRunner($project); + ob_start(); $runner->start('demo'); + $output = ob_get_clean(); + self::assertSame('', $output); self::assertSame(['up', '--build', '-d', '--force-recreate', '--remove-orphans', 'web'], array_slice($runner->runs[0], -6)); self::assertSame(['demo'], $runner->installedWaits); self::assertSame([['demo', 'tiny']], $runner->seedIfNeededRuns); diff --git a/tests/Unit/SourceProviderTest.php b/tests/Unit/SourceProviderTest.php index a4f7897d..18872931 100644 --- a/tests/Unit/SourceProviderTest.php +++ b/tests/Unit/SourceProviderTest.php @@ -103,8 +103,11 @@ public function testEnsureFloatingComposerReusesExistingResolvedSource(): void ], ]; + ob_start(); $source = $provider->ensure('3.3'); + $output = ob_get_clean(); + self::assertSame('', $output); self::assertSame('3.3.15', $source['source_key']); self::assertSame('3.3.15', $source['constraint']); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 000c10cc..c4f58ad8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,6 +1,10 @@