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
4 changes: 4 additions & 0 deletions bin/qi
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
30 changes: 18 additions & 12 deletions src/QuickInstall/Sandbox/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -163,7 +163,7 @@ private function sourceRemove(array $args): int
throw new InvalidArgumentException('Usage: qi source:remove <version|source> [--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']))
{
Expand All @@ -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";
Expand All @@ -199,7 +199,7 @@ private function sourceFetch(array $args): int
throw new InvalidArgumentException('Usage: qi source:fetch <version|branch> [--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;
Expand All @@ -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;
Expand All @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -408,7 +408,7 @@ private function supportsAnsi(): bool
private function boardStart(array $args): int
{
$name = $this->boardName($args, 'Usage: qi board:start <name>');
$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;
Expand All @@ -417,15 +417,15 @@ private function boardStart(array $args): int
private function boardStop(array $args): int
{
$name = $this->boardName($args, 'Usage: qi board:stop <name>');
(new BoardService($this->project))->stop($name);
(new BoardService($this->project, $this->sandboxOutput()))->stop($name);
echo "Stopped board: $name\n";
return 0;
}

private function boardDestroy(array $args): int
{
$name = $this->boardName($args, 'Usage: qi board:destroy <name>');
(new BoardService($this->project))->destroy($name);
(new BoardService($this->project, $this->sandboxOutput()))->destroy($name);
echo "Destroyed board: $name\n";
return 0;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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')
{
Expand Down Expand Up @@ -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)
Expand Down
76 changes: 14 additions & 62 deletions src/QuickInstall/Sandbox/BoardRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
{
Expand All @@ -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
Expand Down Expand Up @@ -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]);
}
Expand All @@ -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
Expand All @@ -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 '';
}
}
8 changes: 5 additions & 3 deletions src/QuickInstall/Sandbox/BoardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions src/QuickInstall/Sandbox/BufferedOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php
/**
*
* QuickInstall sandbox buffered output
*
* @copyright (c) 2026 phpBB Limited <https://www.phpbb.com>
* @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;
}
}
18 changes: 18 additions & 0 deletions src/QuickInstall/Sandbox/Output.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
/**
*
* QuickInstall sandbox output
*
* @copyright (c) 2026 phpBB Limited <https://www.phpbb.com>
* @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;
}
Loading