From 0750ff4328340bf37dee496deaa3116ec59299c8 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Wed, 15 Jul 2026 08:20:38 -0700 Subject: [PATCH 01/12] Auto launch browser when localhost url is ready --- src/QuickInstall/Sandbox/Application.php | 41 ++++++++++-- src/QuickInstall/Sandbox/BrowserLauncher.php | 70 ++++++++++++++++++++ src/QuickInstall/Sandbox/bootstrap.php | 1 + tests/Integration/ApplicationTest.php | 10 +++ tests/Unit/BrowserLauncherTest.php | 41 ++++++++++++ 5 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/QuickInstall/Sandbox/BrowserLauncher.php create mode 100644 tests/Unit/BrowserLauncherTest.php diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index 2842442c..1ed69cfd 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -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); } @@ -482,10 +484,16 @@ private function supportsAnsi(): bool private function boardStart(array $args): int { - $name = $this->boardName($args, 'Usage: qi board:start '); + $cli = CommandLine::parse($args); + $name = $cli->argument(0); + if ($name === null) + { + throw new InvalidArgumentException('Usage: qi board:start [--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; } @@ -625,6 +633,7 @@ 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; } @@ -632,10 +641,27 @@ private function uiStart(array $args): int 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(); @@ -991,12 +1017,15 @@ private function helpCommands(): array ], 'board:start' => [ 'title' => 'board:start', - 'usage' => 'board:start ', + 'usage' => 'board:start [--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' => [ '' => 'Required board name.', ], + 'options' => [ + '--no-open' => 'Do not open the board URL in the default browser.', + ], 'examples' => [ 'board:start demo', ], @@ -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', @@ -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', diff --git a/src/QuickInstall/Sandbox/BrowserLauncher.php b/src/QuickInstall/Sandbox/BrowserLauncher.php new file mode 100644 index 00000000..313644d7 --- /dev/null +++ b/src/QuickInstall/Sandbox/BrowserLauncher.php @@ -0,0 +1,70 @@ + + * @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; + } +} diff --git a/src/QuickInstall/Sandbox/bootstrap.php b/src/QuickInstall/Sandbox/bootstrap.php index 70de116c..eb3716a9 100644 --- a/src/QuickInstall/Sandbox/bootstrap.php +++ b/src/QuickInstall/Sandbox/bootstrap.php @@ -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'; diff --git a/tests/Integration/ApplicationTest.php b/tests/Integration/ApplicationTest.php index 6b2bc97a..edddc201 100644 --- a/tests/Integration/ApplicationTest.php +++ b/tests/Integration/ApplicationTest.php @@ -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 [--no-open]', $result['output']); + self::assertStringContainsString('default browser', $result['output']); } public function testUiLifecycleCommandsAreExposed(): void diff --git a/tests/Unit/BrowserLauncherTest.php b/tests/Unit/BrowserLauncherTest.php new file mode 100644 index 00000000..e8857df3 --- /dev/null +++ b/tests/Unit/BrowserLauncherTest.php @@ -0,0 +1,41 @@ +open('http://127.0.0.1:8079/')); + self::assertSame($expected, $captured); + } + + public static function commandProvider(): array + { + $url = 'http://127.0.0.1:8079/'; + return [ + 'macOS' => ['Darwin', ['open', $url]], + 'Windows' => ['Windows', ['cmd.exe', '/d', '/s', '/c', 'start', '', $url]], + 'Linux' => ['Linux', ['xdg-open', $url]], + ]; + } + + public function testReturnsFalseForUnsupportedPlatform(): void + { + $launcher = new BrowserLauncher('Unknown', static function (): bool { + self::fail('Unsupported platforms must not execute a command.'); + }); + + self::assertFalse($launcher->open('http://127.0.0.1:8079/')); + } +} From 928b390c80b639a439780ebfadceb978e54537eb Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Wed, 15 Jul 2026 08:39:58 -0700 Subject: [PATCH 02/12] Update gitignore --- .gitignore | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index ffb13102..4d296a28 100644 --- a/.gitignore +++ b/.gitignore @@ -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 +*~ From 675208bf9e4c28b85f69451d391a62ebaadb5d1c Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Wed, 15 Jul 2026 10:02:29 -0700 Subject: [PATCH 03/12] Revert awesome utf icons in doctor back to boring ascii --- src/QuickInstall/Sandbox/Application.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index 1ed69cfd..b1c7f7c1 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -440,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']; } From 955119ec7e4b30820135713d8b520080c44895e1 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Wed, 15 Jul 2026 10:25:12 -0700 Subject: [PATCH 04/12] Run the Doctor from within the web-ui --- src/QuickInstall/Sandbox/DoctorService.php | 2 +- src/QuickInstall/Sandbox/Web/Application.php | 26 ++++++++++++ .../Sandbox/Web/templates/layout.php | 17 +++++--- tests/Integration/WebApplicationTest.php | 40 +++++++++++++++++++ 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/QuickInstall/Sandbox/DoctorService.php b/src/QuickInstall/Sandbox/DoctorService.php index 29d8e4de..fee85dc4 100644 --- a/src/QuickInstall/Sandbox/DoctorService.php +++ b/src/QuickInstall/Sandbox/DoctorService.php @@ -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'), diff --git a/src/QuickInstall/Sandbox/Web/Application.php b/src/QuickInstall/Sandbox/Web/Application.php index ad7d5a73..7c4356c2 100644 --- a/src/QuickInstall/Sandbox/Web/Application.php +++ b/src/QuickInstall/Sandbox/Web/Application.php @@ -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; @@ -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')); @@ -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) diff --git a/src/QuickInstall/Sandbox/Web/templates/layout.php b/src/QuickInstall/Sandbox/Web/templates/layout.php index d47a24a3..dfcb3141 100644 --- a/src/QuickInstall/Sandbox/Web/templates/layout.php +++ b/src/QuickInstall/Sandbox/Web/templates/layout.php @@ -39,11 +39,18 @@

QuickInstall Dashboard

Manage disposable phpBB boards backed by the same Docker services as the CLI.

-
- - - -
+
+
+ + + +
+
+ + + +
+
diff --git a/tests/Integration/WebApplicationTest.php b/tests/Integration/WebApplicationTest.php index 1b3d5726..017eb737 100644 --- a/tests/Integration/WebApplicationTest.php +++ b/tests/Integration/WebApplicationTest.php @@ -60,6 +60,7 @@ public function testRenderShowsCoreSandboxWorkflows(): void self::assertStringContainsString('QuickInstall Dashboard', $html); self::assertStringContainsString('QuickInstall + Docker', $html); self::assertStringContainsString('Create board', $html); + self::assertStringContainsString('Run Doctor', $html); self::assertStringContainsString('Sources', $html); self::assertStringContainsString('Mount extension', $html); self::assertStringContainsString('Mount style', $html); @@ -78,6 +79,45 @@ public function testRenderShowsCoreSandboxWorkflows(): void self::assertStringNotContainsString('