Skip to content
Open
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
39 changes: 23 additions & 16 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
purge_cache
qi_config.cfg
boards/*
settings/*
cache/*
sources/*
vendor/*
tests/vendor/*
build/*
.qi/*
customisations/*
*~
.idea
node_modules
/.agents
/agent
# Runtime data
/boards/
/cache/
/customisations/
/settings/
/sources/
/.qi/

# Dependencies and build output
/vendor/
/tests/vendor/
/node_modules/
/build/

# Local development tooling
/.idea/
/.agents/
/agent/
/AGENTS.md
/skills-lock.json

# OS and editor files
.DS_Store
*~
45 changes: 38 additions & 7 deletions src/QuickInstall/Sandbox/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
class Application
{
private Project $project;
private BrowserLauncher $browserLauncher;
private $stderr;
private $stdin;

public function __construct(string $root, $stderr = null, $stdin = null)
public function __construct(string $root, $stderr = null, $stdin = null, ?BrowserLauncher $browserLauncher = null)
{
$this->project = new Project($root);
$this->browserLauncher = $browserLauncher ?: new BrowserLauncher();
$this->stderr = $stderr ?: (defined('STDERR') ? STDERR : fopen('php://stderr', 'wb'));
$this->stdin = $stdin ?: (defined('STDIN') ? STDIN : null);
}
Expand Down Expand Up @@ -438,8 +440,8 @@ private function doctor(): int
foreach ($checks as $check)
{
$status = $check['ok']
? $this->style("\u{2714}", '1;32')
: $this->style('!', '1;31');
? $this->style('[OK]', '1;32')
: $this->style('[FAIL]', '1;31');
echo "$status {$check['name']}: {$check['detail']}\n";
$failed = $failed || !$check['ok'];
}
Expand Down Expand Up @@ -482,10 +484,16 @@ private function supportsAnsi(): bool

private function boardStart(array $args): int
{
$name = $this->boardName($args, 'Usage: qi board:start <name>');
$cli = CommandLine::parse($args);
$name = $cli->argument(0);
if ($name === null)
{
throw new InvalidArgumentException('Usage: qi board:start <name> [--no-open]');
}
$board = (new BoardService($this->project, $this->sandboxOutput()))->start($name);
echo "Started board: $name\n";
echo "URL: {$board['url']}\n";
$this->openBrowser($board['url'], $cli);
return 0;
}

Expand Down Expand Up @@ -625,17 +633,35 @@ private function uiStart(array $args): int
if ($result['status'] === 'already_running')
{
echo "QuickInstall Dashboard UI is already running: {$state['url']}\n";
$this->openBrowser($state['url'], $cli);
return 0;
}

echo "QuickInstall Dashboard UI started: {$state['url']}\n";
echo "PID: {$state['pid']}\n";
echo "Log: {$state['log']}\n";
echo "Stop it with: php bin/qi ui:stop\n";
$this->openBrowser($state['url'], $cli);

return 0;
}

private function openBrowser(string $url, CommandLine $cli): void
{
if ($cli->has('no-open'))
{
return;
}

if ($this->browserLauncher->open($url))
{
echo "Opened in your default browser.\n";
return;
}

$this->writeError("Unable to open the default browser. Open this URL manually: $url\n");
}

private function uiStop(): int
{
$result = (new UiServerService($this->project))->stop();
Expand Down Expand Up @@ -991,12 +1017,15 @@ private function helpCommands(): array
],
'board:start' => [
'title' => 'board:start',
'usage' => 'board:start <name>',
'usage' => 'board:start <name> [--no-open]',
'summary' => 'Start the board containers and install the board if needed.',
'description' => 'Starts the board containers with Docker Compose, runs phpBB install on first start, applies the configured populate preset once, and waits for the board URL to respond. Docker must already be running.',
'arguments' => [
'<name>' => 'Required board name.',
],
'options' => [
'--no-open' => 'Do not open the board URL in the default browser.',
],
'examples' => [
'board:start demo',
],
Expand Down Expand Up @@ -1149,12 +1178,13 @@ private function helpCommands(): array
'UI commands' => [
'ui:start' => [
'title' => 'ui:start',
'usage' => 'ui:start [--host 127.0.0.1] [--port 8079]',
'usage' => 'ui:start [--host 127.0.0.1] [--port 8079] [--no-open]',
'summary' => 'Start the local QuickInstall Dashboard UI.',
'description' => 'Starts PHP built-in server in the background on a loopback address and serves the QuickInstall Dashboard UI.',
'options' => [
'--host HOST' => 'Loopback host. One of: 127.0.0.1, localhost, ::1. Default: 127.0.0.1.',
'--port PORT' => 'Local UI port. Default: 8079.',
'--no-open' => 'Do not open the Dashboard URL in the default browser.',
],
'examples' => [
'ui:start',
Expand All @@ -1172,12 +1202,13 @@ private function helpCommands(): array
],
'ui:restart' => [
'title' => 'ui:restart',
'usage' => 'ui:restart [--host 127.0.0.1] [--port 8079]',
'usage' => 'ui:restart [--host 127.0.0.1] [--port 8079] [--no-open]',
'summary' => 'Restart the local QuickInstall Dashboard UI.',
'description' => 'Stops the tracked Dashboard UI server if present, then starts a fresh local Dashboard UI server.',
'options' => [
'--host HOST' => 'Loopback host. One of: 127.0.0.1, localhost, ::1. Default: 127.0.0.1.',
'--port PORT' => 'Local UI port. Default: 8079.',
'--no-open' => 'Do not open the Dashboard URL in the default browser.',
],
'examples' => [
'ui:restart',
Expand Down
70 changes: 70 additions & 0 deletions src/QuickInstall/Sandbox/BrowserLauncher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
/**
*
* QuickInstall browser launcher
*
* @copyright (c) 2026 phpBB Limited <https://www.phpbb.com>
* @license GNU General Public License, version 2 (GPL-2.0)
*
*/

namespace QuickInstall\Sandbox;

/** Opens a URL in the operating system's default browser. */
class BrowserLauncher
{
private string $osFamily;
private $executor;

public function __construct(?string $osFamily = null, ?callable $executor = null)
{
$this->osFamily = $osFamily ?: PHP_OS_FAMILY;
$this->executor = $executor;
}

public function open(string $url): bool
{
$command = $this->command($url);
if ($command === null)
{
return false;
}

if ($this->executor !== null)
{
return (bool) call_user_func($this->executor, $command);
}

$nullDevice = $this->osFamily === 'Windows' ? 'NUL' : '/dev/null';
$descriptor = [
0 => ['file', $nullDevice, 'r'],
1 => ['file', $nullDevice, 'w'],
2 => ['file', $nullDevice, 'w'],
];
$process = @proc_open($command, $descriptor, $pipes);
if (!is_resource($process))
{
return false;
}

return proc_close($process) === 0;
}

private function command(string $url): ?array
{
switch ($this->osFamily)
{
case 'Darwin':
return ['open', $url];

case 'Windows':
return ['cmd.exe', '/d', '/s', '/c', 'start', '', $url];

case 'Linux':
case 'BSD':
return ['xdg-open', $url];
}

return null;
}
}
2 changes: 1 addition & 1 deletion src/QuickInstall/Sandbox/DoctorService.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public function checks(): array
$projectWritable = $this->projectWritable();
$checks = [
$this->check('PHP 8+', PHP_VERSION_ID >= 80000, PHP_VERSION),
$this->check('PHP CLI', PHP_SAPI === 'cli', PHP_SAPI),
$this->check('PHP CLI', in_array(PHP_SAPI, ['cli', 'cli-server'], true), PHP_SAPI),
$this->check('PHP configuration', true, $iniPath !== false ? $iniPath : 'no php.ini loaded; using built-in defaults'),
$this->extensionCheck('JSON', 'json'),
$this->extensionCheck('OpenSSL', 'openssl', 'extension=openssl'),
Expand Down
26 changes: 26 additions & 0 deletions src/QuickInstall/Sandbox/Web/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use QuickInstall\Sandbox\BoardService;
use QuickInstall\Sandbox\BufferedOutput;
use QuickInstall\Sandbox\CustomisationMountService;
use QuickInstall\Sandbox\DoctorService;
use QuickInstall\Sandbox\ExtensionManager;
use QuickInstall\Sandbox\Project;
use QuickInstall\Sandbox\SourceService;
Expand Down Expand Up @@ -185,6 +186,10 @@ private function handlePost(): void
$this->notice = $created ? 'Workspace initialized.' : 'Workspace already initialized.';
break;

case 'doctor':
$this->runDoctor();
break;

case 'source_fetch':
$version = $this->required('version');
(new SourceService($this->project, $this->output))->fetch($version, $this->checked('git'), $this->optional('url'), $this->checked('allow_external'));
Expand Down Expand Up @@ -286,6 +291,27 @@ private function handlePost(): void
}
}

private function runDoctor(): void
{
$checks = (new DoctorService($this->project))->checks();
$failed = 0;
$this->output->write("QuickInstall requirements\n");
foreach ($checks as $check)
{
$status = $check['ok'] ? '[OK]' : '[FAIL]';
$this->output->write("$status {$check['name']}: {$check['detail']}\n");
$failed += $check['ok'] ? 0 : 1;
}

if ($failed === 0)
{
$this->notice = 'Doctor found no requirement problems.';
return;
}

$this->error = "Doctor found $failed requirement " . ($failed === 1 ? 'problem.' : 'problems.') . ' View the Activity Log below for details.';
}

private function disableExecutionTimeLimit(): void
{
if ((int) ini_get('max_execution_time') === 0)
Expand Down
2 changes: 1 addition & 1 deletion src/QuickInstall/Sandbox/Web/templates/dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
<label class="field" title="phpBB selector to fetch or reuse. Supported examples include latest, 3.3, 3.2, 4.0.x, and master."><span>phpBB</span><input name="phpbb" value="latest" list="phpbb-list"></label>
<datalist id="phpbb-list"><?php foreach ($versionOptions as $option): ?><option value="<?= $this->escape($option) ?>"><?php endforeach; ?></datalist>
<label class="field" title="Database engine for the board containers. SQLite supports unseeded boards only."><span>DB</span><select name="db"><?php foreach ($dbOptions as $option): ?><option value="<?= $this->escape($option) ?>"><?= $this->escape($option) ?></option><?php endforeach; ?></select></label>
<label class="field" title="Local host port for opening the board in your browser. Pick an unused port."><span>Port</span><input name="port" value="8080"></label>
<label class="field" title="Local host port for opening the board in your browser. Pick an unused port."><span>Web port</span><input name="port" value="8080"></label>
<label class="field" title="Optional content preset applied when the board is first installed."><span>Populate</span><select name="populate"><?php foreach ($populateOptions as $option): ?><option value="<?= $this->escape($option) ?>"><?= $this->escape($option) ?></option><?php endforeach; ?></select></label>
<label class="toggle" title="Enable phpBB debug settings after installation."><input type="checkbox" name="debug" value="1"><span></span>Debug</label>
<label class="toggle" title="Destroy and recreate an existing board with the same name."><input type="checkbox" name="replace" value="1"><span></span>Replace existing</label>
Expand Down
17 changes: 12 additions & 5 deletions src/QuickInstall/Sandbox/Web/templates/layout.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,18 @@
<h1>QuickInstall Dashboard</h1>
<p class="lede">Manage disposable phpBB boards backed by the same Docker services as the CLI.</p>
</div>
<form method="post" data-ajax>
<?php require __DIR__ . '/csrf.php'; ?>
<input type="hidden" name="action" value="init">
<button class="primary">Init workspace</button>
</form>
<div class="actions">
<form method="post" data-ajax>
<?php require __DIR__ . '/csrf.php'; ?>
<input type="hidden" name="action" value="doctor">
<button class="secondary">Run Doctor</button>
</form>
<form method="post" data-ajax>
<?php require __DIR__ . '/csrf.php'; ?>
<input type="hidden" name="action" value="init">
<button class="primary">Init workspace</button>
</form>
</div>
</header>
<div id="dashboard" class="content">
<?= $dashboard ?>
Expand Down
1 change: 1 addition & 0 deletions src/QuickInstall/Sandbox/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
require_once __DIR__ . '/BufferedOutput.php';
require_once __DIR__ . '/ProcessRunner.php';
require_once __DIR__ . '/CommandLine.php';
require_once __DIR__ . '/BrowserLauncher.php';
require_once __DIR__ . '/Project.php';
require_once __DIR__ . '/DoctorService.php';
require_once __DIR__ . '/VersionMatrix.php';
Expand Down
10 changes: 10 additions & 0 deletions tests/Integration/ApplicationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ public function testUiStartHelpIsExposed(): void
self::assertStringContainsString('Usage:', $result['output']);
self::assertStringContainsString('qi ui:start', $result['output']);
self::assertStringContainsString('built-in server', $result['output']);
self::assertStringContainsString('--no-open', $result['output']);
}

public function testBoardStartHelpDocumentsBrowserOptOut(): void
{
$result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'board:start', '--help']);

self::assertSame(0, $result['exit_code']);
self::assertStringContainsString('qi board:start <name> [--no-open]', $result['output']);
self::assertStringContainsString('default browser', $result['output']);
}

public function testUiLifecycleCommandsAreExposed(): void
Expand Down
Loading