diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f58b135b..17f0ec01 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -50,6 +50,31 @@ $ tests/vendor/bin/phpunit -c phpunit.xml.dist The CLI test dependencies install into `tests/vendor/`, leaving the main application `vendor/` directory unchanged. +On Windows PowerShell, set the Composer config and run the same suite with: + +```powershell +Set-Location tests +$env:COMPOSER = 'composer.cli-tests.json' +composer install +Set-Location .. +php tests/vendor/bin/phpunit -c phpunit.xml.dist +``` + +CLI or Dashboard UI changes must remain compatible with macOS, Linux, and native Windows. Before submitting such changes, run: + +```bash +php bin/qi doctor +php bin/qi help +``` + +On Windows, also smoke-test the supplied launcher from PowerShell or Command Prompt: + +```powershell +.\bin\qi.cmd help +``` + +Windows-specific changes should cover drive-letter paths, paths containing spaces, Command Prompt and PowerShell execution, and the `ui:start`/`ui:status`/`ui:stop` lifecycle. GitHub Actions runs the PHPUnit suite and launcher smoke test on native Windows. + ## :computer: Make something great 1. Create a new branch in your repository before doing any work. It should be based off the `develop` branch: diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index b0df942e..5fa2778d 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -37,3 +37,38 @@ jobs: - name: Lint CLI entrypoint run: php -l bin/qi + + windows: + name: Windows PHP 8.3 + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Validate test Composer config + working-directory: tests + env: + COMPOSER: composer.cli-tests.json + run: composer validate --no-check-publish --strict + + - name: Install test dependencies + working-directory: tests + env: + COMPOSER: composer.cli-tests.json + run: composer install --no-interaction --prefer-dist + + - name: Run PHPUnit + timeout-minutes: 10 + shell: cmd + run: php tests/vendor/bin/phpunit -c phpunit.xml.dist + + - name: Smoke test CMD launcher + shell: cmd + run: bin\qi.cmd help diff --git a/README.md b/README.md index 35000cac..91da60b1 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ QuickInstall is a tool we built to support the community of phpBB extension deve ##### Browsers QuickInstall is designed to run on all modern browsers. Please don't use old stuff anymore...seriously. -| | | | | | | -|-|-|-|-|-|-| -| Desktop: | ![Chrome](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/chrome/chrome_32x32.png) 60+ | ![Firefox](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/firefox/firefox_32x32.png) 60+ | ![Safari](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/safari/safari_32x32.png) 12+ | ![Edge](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/edge/edge_32x32.png) 80+ | ![Opera](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/opera/opera_32x32.png) 40+ | -| Mobile: | ![iOS](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/safari-ios/safari-ios_32x32.png) 12+ | ![Android](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/android-webview/android-webview_32x32.png) 6+ | | | | +| | | | | | | +|----------|---------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------| +| Desktop: | ![Chrome](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/chrome/chrome_32x32.png) 60+ | ![Firefox](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/firefox/firefox_32x32.png) 60+ | ![Safari](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/safari/safari_32x32.png) 12+ | ![Edge](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/edge/edge_32x32.png) 80+ | ![Opera](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/opera/opera_32x32.png) 40+ | +| Mobile: | ![iOS](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/safari-ios/safari-ios_32x32.png) 12+ | ![Android](https://cdnjs.cloudflare.com/ajax/libs/browser-logos/69.0.4/android-webview/android-webview_32x32.png) 6+ | | | | ##### phpBB Requirements phpBB boards require a web server running PHP and one of the following database management systems. @@ -59,26 +59,29 @@ phpBB boards require a web server running PHP and one of the following database ## QuickInstall CLI -QuickInstall now includes a Docker-based CLI for creating local phpBB test boards. It writes generated state to `.qi/` and leaves the legacy web UI unchanged. The QuickInstall CLI requires PHP 8.0 or newer for the `php bin/qi` command. +QuickInstall now includes a cross-platform, Docker-based CLI and local Dashboard UI for creating phpBB test boards. They work on macOS, Linux, and native Windows and write generated state to `.qi/` without changing the legacy web application. + +Requirements: + +- Docker Desktop, installed and running +- PHP 8.0 or newer for the CLI command +- Git when using Git sources + +Create your first new board: ```bash -php bin/qi init php bin/qi board:create test --phpbb 3.3 --db mariadb --port 8081 --populate extension-dev ``` The QuickInstall CLI targets phpBB 3.2+ installer-based boards. phpBB 3.0/3.1 remain legacy-web-app territory and are not planned for the QuickInstall CLI. -Downloaded extensions can be unzipped into `customisations/` and mounted into boards: +If you prefer a browser workflow, start the QuickInstall Dashboard UI: ```bash -php bin/qi ext:mount test customisations/vendor/extname +php bin/qi ui:start ``` -Downloaded styles can be unzipped into `customisations/` and mounted into boards: - -```bash -php bin/qi style:mount test customisations/stylename -``` +The examples use `php bin/qi`, which works on every supported operating system when PHP is in `PATH`. Windows users can alternatively use `.\bin\qi.cmd` from either Command Prompt or PowerShell. See the complete [QuickInstall CLI docs](docs/sandbox-cli.md). diff --git a/bin/qi b/bin/qi index 34584b5a..1ce793a8 100755 --- a/bin/qi +++ b/bin/qi @@ -8,22 +8,8 @@ if (PHP_VERSION_ID < 80000) exit(1); } +require __DIR__ . '/../src/QuickInstall/Sandbox/bootstrap.php'; 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'; -require __DIR__ . '/../src/QuickInstall/Sandbox/SourceProvider.php'; -require __DIR__ . '/../src/QuickInstall/Sandbox/SourceService.php'; -require __DIR__ . '/../src/QuickInstall/Sandbox/BoardService.php'; -require __DIR__ . '/../src/QuickInstall/Sandbox/DockerComposeWriter.php'; -require __DIR__ . '/../src/QuickInstall/Sandbox/BoardRunner.php'; -require __DIR__ . '/../src/QuickInstall/Sandbox/SeederWriter.php'; -require __DIR__ . '/../src/QuickInstall/Sandbox/ExtensionManager.php'; -require __DIR__ . '/../src/QuickInstall/Sandbox/StyleManager.php'; use QuickInstall\Sandbox\Application; diff --git a/bin/qi.cmd b/bin/qi.cmd new file mode 100644 index 00000000..581e342b --- /dev/null +++ b/bin/qi.cmd @@ -0,0 +1,9 @@ +@echo off +where php >nul 2>&1 +if errorlevel 1 ( + echo QuickInstall requires PHP 8.0 or newer. 1>&2 + echo Install PHP, add php.exe to PATH, then reopen your terminal. 1>&2 + exit /b 1 +) +php "%~dp0qi" %* +exit /b %ERRORLEVEL% diff --git a/build.xml b/build.xml index 7d46b350..d8958f11 100644 --- a/build.xml +++ b/build.xml @@ -32,7 +32,6 @@ - diff --git a/docs/sandbox-cli.md b/docs/sandbox-cli.md index 5863bd2e..dc3dd904 100644 --- a/docs/sandbox-cli.md +++ b/docs/sandbox-cli.md @@ -48,6 +48,44 @@ If you ever need help with commands, run: php bin/qi help ``` +If you prefer a browser workflow, start the QuickInstall Dashboard UI: + +```bash +php bin/qi ui:start +``` + +Then open the URL printed by the command: + +```text +http://127.0.0.1:8079/ +``` + +## Commands by OS + +The examples in this guide use the portable command form: + +```bash +php bin/qi +``` + +It works on macOS, Linux, and Windows when PHP is available in `PATH`. + +Windows also includes a `qi.cmd` launcher. From PowerShell: + +```powershell +.\bin\qi.cmd +``` + +From Command Prompt: + +```batch +bin\qi.cmd +``` + +Arguments and options are identical with every launcher. Run commands from the QuickInstall project root. Windows drive-letter paths, UNC paths, backslashes, spaces, and case-insensitive filesystem paths are supported. + +Docker Desktop on Windows must use its WSL 2 or Hyper-V Linux-container backend rather than Windows containers. + ## Common Recipes Create a small empty board: @@ -207,6 +245,12 @@ By default, extension sources must live under `customisations/`. To mount a trus php bin/qi ext:mount demo /path/to/vendor/extname --allow-external ``` +On Windows, quote external paths containing spaces: + +```powershell +.\bin\qi.cmd ext:mount demo "C:\Path\To\My Extensions\vendor\extname" --allow-external +``` + ## Styles Put downloaded styles under `customisations/`: @@ -254,6 +298,12 @@ By default, style sources must live under `customisations/`. To mount a trusted php bin/qi style:mount demo /path/to/stylename --allow-external ``` +On Windows, quote external paths containing spaces: + +```powershell +.\bin\qi.cmd style:mount demo "C:\Path\To\My Styles\stylename" --allow-external +``` + ## Supported phpBB Versions Show supported selectors: @@ -331,16 +381,63 @@ Fetched sources live under: .qi/sources/phpbb- ``` +## Dashboard UI + +The QuickInstall Dashboard UI provides a local browser interface for the same workflows exposed by the CLI. It is served by PHP's built-in web server and backed by the same `.qi/` workspace. The Dashboard UI can be started, checked, restarted, and stopped on macOS, Linux, or native Windows. + +Start the UI: + +```bash +php bin/qi ui:start +``` + +The default URL is: + +```text +http://127.0.0.1:8079/ +``` + +Use a different local port: + +```bash +php bin/qi ui:start --port 8088 +``` + +Check or stop the tracked Dashboard UI server: + +```bash +php bin/qi ui:status +php bin/qi ui:stop +``` + +Restart it: + +```bash +php bin/qi ui:restart +``` + +Supported Dashboard UI server hosts are loopback-only: + +```bash +php bin/qi ui:start --host 127.0.0.1 +php bin/qi ui:start --host localhost +php bin/qi ui:start --host ::1 +``` + ## Where Files Go Generated state: -| Path | Contents | -|------------------------|----------------------------------------------| -| `.qi/boards/` | Installed phpBB board files | -| `.qi/runtime/` | Docker Compose, Dockerfile, installer config | -| `.qi/db/` | Database files | -| `.qi/sources/` | Downloaded phpBB source | +| Path | Contents | +|--------------------------|----------------------------------------------| +| `.qi/boards/` | Installed phpBB board files | +| `.qi/runtime/` | Docker Compose, Dockerfile, installer config | +| `.qi/db/` | Database files | +| `.qi/sources/` | Downloaded phpBB source | +| `.qi/runtime/ui.json` | Tracked Dashboard UI server state | +| `.qi/runtime/ui.log` | Dashboard UI server output log | +| `.qi/runtime/ui.log.err` | Windows Dashboard UI server error log | +| `.qi/cache/` | Cached update-check metadata | User-managed drop zone: @@ -355,13 +452,42 @@ customisations/ - `board:create` rejects ports already registered to another board or already in use on the host. - `ext:mount` and `style:mount` only use `customisations/` unless `--allow-external` is used. - Custom Git source URLs require `--allow-external`; only use trusted forks. +- The Dashboard UI server only accepts loopback hosts (`127.0.0.1`, `localhost`, or `::1`) and rejects non-local requests. +- `ui:start` refuses ports already in use on the selected loopback host. +- Dashboard UI form submissions use CSRF tokens and only accept local origins or referrers. ## Troubleshooting +If QuickInstall starts but a command is not working as expected, run the environment check from the project root: + +```bash +php bin/qi doctor +``` + +On Windows PowerShell, you can use the Windows launcher instead: + +```powershell +.\bin\qi.cmd doctor +``` + +Every check should report `OK`; failures identify the missing or unavailable requirement. + +#### QuickInstall does not start on Windows + +First, check whether PHP is available: + +```powershell +php --version +``` + +If Windows does not recognize `php`, install PHP 8 or newer and add the directory containing `php.exe` to the Windows `PATH`. Open a new terminal, confirm `php --version` works, then run `.\bin\qi.cmd doctor` again. + #### Docker command fails Check that Docker Desktop is running and that the docker command works in this terminal. +On Windows, also confirm Docker Desktop is using Linux containers. `php bin/qi doctor` reports `Linux containers: OK` when configured correctly. + #### Composer command fails QuickInstall uses composer from PATH first, then `composer.phar` from the project root. Restore `composer.phar` or install Composer if both are missing. @@ -383,3 +509,26 @@ Use this when a board's files, database, or generated Docker runtime are no long php bin/qi board:destroy demo php bin/qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate none ``` + +#### Dashboard UI will not start + +Check the tracked status: + +```bash +php bin/qi ui:status +``` + +If the status is stale, clear the old state and start again: + +```bash +php bin/qi ui:stop +php bin/qi ui:start +``` + +If the selected port is already in use, choose a different local port: + +```bash +php bin/qi ui:start --port 8088 +``` + +The Dashboard UI server output log is written to `.qi/runtime/ui.log`. On Windows, PHP server errors are written to `.qi/runtime/ui.log.err`. diff --git a/package.json b/package.json index 7c217378..70de9769 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "license": "GPL-2.0-only", + "private": true, "dependencies": { "autoprefixer": "^10.4.2", "bootstrap": "5.1.3", diff --git a/public/assets/phpbb-logo.svg b/public/assets/phpbb-logo.svg new file mode 100644 index 00000000..df6c12b3 --- /dev/null +++ b/public/assets/phpbb-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/sandbox-ui.css b/public/assets/sandbox-ui.css new file mode 100644 index 00000000..4393fc1e --- /dev/null +++ b/public/assets/sandbox-ui.css @@ -0,0 +1,668 @@ +:root { + color-scheme: light; + --canvas: #f6f8fa; + --panel: #fff; + --text: #24292f; + --muted: #57606a; + --subtle: #6e7781; + --border: #d0d7de; + --border-muted: #d8dee4; + --row: #f6f8fa; + --accent: #009bdf; + --accent-hover: #007caf; + --success: #1a7f37; + --success-bg: #dafbe1; + --success-border: #aceebb; + --success-text: #116329; + --warning: #9a6700; + --warning-bg: #fff8c5; + --danger: #cf222e; + --danger-bg: #ffebe9; + --danger-border: #ffcecb; + --danger-text: #82071e; + --terminal: #0d1117; + --button-bg: #f6f8fa; + --button-hover: #f3f4f6; + --button-border: rgba(27, 31, 36, .15); + --input-bg: #fff; + --toggle-bg: #eaeef2; + --chrome: #fff; + --brand-bg: #24292f; + --brand-text: #fff; + --sidebar-bg: #161b22; + --sidebar-border: #30363d; + --sidebar-text: #e6edf3; + --sidebar-muted: #8b949e; + --sidebar-hover: #21262d; + --sidebar-brand-bg: var(--accent); + --sidebar-brand-text: #e6edf3; + --table-hover: #fbfcfd; + --terminal-border: #30363d; + --terminal-header: #161b22; + --terminal-muted: #8b949e; + --terminal-progress-bg: #21262d; + --terminal-text: #e6edf3; + --busy-shadow: 0 8px 24px rgba(140, 149, 159, .25); + --radius: 6px; + --shadow: 0 1px 0 rgba(27, 31, 36, .04); + --focus: 0 0 0 3px rgba(11, 118, 183, .24); + --svg-filter-accent: brightness(0) saturate(100%) invert(46%) sepia(52%) saturate(3716%) hue-rotate(170deg) brightness(96%) contrast(101%); +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --canvas: #0d1117; + --panel: #161b22; + --text: #e6edf3; + --muted: #8b949e; + --subtle: #7d8590; + --border: #30363d; + --border-muted: #30363d; + --row: #161b22; + --accent: #009bdf; + --accent-hover: #26b4f2; + --success: #3fb950; + --success-bg: rgba(46, 160, 67, .18); + --success-border: rgba(63, 185, 80, .36); + --success-text: #7ee787; + --warning: #d29922; + --warning-bg: rgba(187, 128, 9, .18); + --danger: #ff7b72; + --danger-bg: rgba(248, 81, 73, .15); + --danger-border: rgba(248, 81, 73, .4); + --danger-text: #ffb4ab; + --button-bg: #21262d; + --button-hover: #30363d; + --button-border: #30363d; + --input-bg: #0d1117; + --toggle-bg: #30363d; + --chrome: #161b22; + --brand-bg: #0d1117; + --brand-text: #e6edf3; + --sidebar-bg: #161b22; + --sidebar-border: #30363d; + --sidebar-text: #e6edf3; + --sidebar-muted: #8b949e; + --sidebar-hover: #21262d; + --sidebar-brand-bg: var(--accent); + --sidebar-brand-text: #e6edf3; + --table-hover: #1c2128; + --terminal: #010409; + --terminal-border: #30363d; + --terminal-header: #0d1117; + --terminal-muted: #8b949e; + --terminal-progress-bg: #161b22; + --terminal-text: #e6edf3; + --busy-shadow: 0 8px 24px rgba(1, 4, 9, .45); + --shadow: 0 1px 0 rgba(1, 4, 9, .35); + --focus: 0 0 0 3px rgba(47, 157, 244, .32); + } +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + background: var(--canvas); + color: var(--text); +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +h1, h2, h3, h4, p { margin: 0; } +h1 { font-size: 26px; line-height: 1.25; font-weight: 650; } +h2 { font-size: 20px; line-height: 1.3; font-weight: 650; } +h3 { font-size: 16px; line-height: 1.4; font-weight: 650; } + +button, +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + padding: 5px 12px; + border: 1px solid var(--button-border); + border-radius: var(--radius); + background: var(--button-bg); + color: var(--text); + font-weight: 600; + font-size: 14px; + line-height: 20px; + font-family: inherit; + box-shadow: var(--shadow); + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} +button:hover, +.button:hover { background: var(--button-hover); text-decoration: none; } +button:focus-visible, +.button:focus-visible, +input:focus, +select:focus, +a:focus-visible { + outline: none; + box-shadow: var(--focus); + border-color: var(--accent); +} +button:disabled { opacity: .65; cursor: wait; } +.primary { background: var(--accent); border-color: var(--button-border); color: #fff; } +.primary:hover { background: var(--accent-hover); } +.secondary { background: var(--button-bg); color: var(--text); } +.danger { background: var(--panel); color: var(--danger); border-color: var(--danger-border); } +.danger:hover { background: var(--danger-bg); } + +.app { + display: grid; + grid-template-columns: 264px minmax(0, 1fr); + min-height: 100vh; +} +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 24px 16px; + background: var(--sidebar-bg); + border-right: 1px solid var(--sidebar-border); + color: var(--sidebar-text); +} +.brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px 20px; + border-bottom: 1px solid var(--sidebar-border); + margin-bottom: 16px; +} +.brand-mark { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: var(--radius); + background: var(--sidebar-brand-bg); + color: var(--sidebar-brand-text); + font-size: 16px; + font-weight: 700; + letter-spacing: .04em; +} +.brand-lockup { + display: grid; + gap: 2px; +} +.brand-logo { + display: block; + width: 98px; + height: 32px; + background: url("/assets/phpbb-logo.svg") left center / contain no-repeat; + filter: var(--svg-filter-accent); +} +.brand-lockup small { color: var(--sidebar-muted); font-size: 12px; } +.sidebar nav { display: grid; gap: 2px; } +.sidebar a { + display: block; + padding: 7px 10px; + border-radius: var(--radius); + color: var(--sidebar-text); + font-weight: 500; +} +.sidebar a:hover { background: var(--sidebar-hover); text-decoration: none; } +.main { min-width: 0; } +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + padding: 24px 32px; + background: var(--chrome); + border-bottom: 1px solid var(--border-muted); +} +.lede { margin-top: 4px; color: var(--muted); max-width: 680px; } +.content { max-width: 1220px; margin: 0 auto; padding-bottom: 40px; } +.eyebrow { + margin-bottom: 3px; + color: var(--subtle); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .05em; +} + +.status-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + padding: 24px 32px 0; +} +.metric, +.card { + background: var(--panel); + border: 1px solid var(--border-muted); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.metric { padding: 14px 16px; } +.metric span { display: block; color: var(--muted); font-size: 12px; font-weight: 600; } +.metric strong { display: block; margin-top: 4px; font-size: 24px; line-height: 1.2; } +.metric small { display: block; margin-top: 2px; color: var(--text); font-weight: 600; } +.metric em { display: block; margin-top: 8px; color: var(--subtle); font-size: 12px; font-style: normal; } + +.toast-stack { + display: grid; + gap: 8px; + padding: 16px 32px 0; +} +.notice, +.error, +.update-banner { + padding: 10px 12px; + border: 1px solid; + border-radius: var(--radius); + font-weight: 500; +} +.notice { background: var(--success-bg); border-color: var(--success-border); color: var(--success-text); } +.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-text); } +.update-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 16px 32px 0; + border-color: var(--warning); + background: var(--warning-bg); + color: var(--text); +} +.update-banner strong, +.update-banner span { display: block; } +.update-banner span { + margin-top: 2px; + color: var(--muted); + font-size: 13px; +} +.update-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.action-result { + margin: 0 0 16px; + transition: opacity .18s ease, transform .18s ease, margin .18s ease, padding .18s ease, border-width .18s ease; +} +.content > .action-result { margin: 16px 32px 0; } +.board .action-result { margin: 12px 16px 0; } +.action-result.is-dismissing { + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + border-width: 0; + opacity: 0; + transform: translateY(-4px); +} + +.section { padding: 24px 32px 0; } +.section-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-muted); + margin-bottom: 16px; +} +.section-head p:not(.eyebrow) { margin-top: 4px; color: var(--muted); max-width: 760px; } +.board-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 16px; } +.board { padding: 0; } +.card-head { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 16px; + border-bottom: 1px solid var(--border-muted); +} +.card-head .url { + margin-top: 3px; + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 260px; +} +.card-head .url a { color: var(--accent); font-weight: 600; } +.badge { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 999px; + background: var(--toggle-bg); + color: var(--muted); + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} +.status-running { background: var(--success-bg); color: var(--success); } +.status-error, +.status-missing { background: var(--danger-bg); color: var(--danger); } +.status-partial { background: var(--warning-bg); color: var(--warning); } +.facts { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; + padding: 12px 16px; + gap: 0; + border-bottom: 1px solid var(--border-muted); +} +.facts div { min-width: 0; padding-right: 12px; } +.facts dt { color: var(--subtle); font-size: 12px; } +.facts dd { + margin: 2px 0 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.compact { padding: 12px 16px; border-bottom: 1px solid var(--border-muted); } +.subsection-title { + padding: 12px 16px 0; + color: var(--muted); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; +} +.seed-form { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + align-items: end; + padding: 10px 16px 16px; + border-bottom: 1px solid var(--border-muted); +} +.mounted-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; } +.mounted { padding: 14px 16px; } +.mounted + .mounted { border-left: 1px solid var(--border-muted); } +.mounted h4 { + margin-bottom: 8px; + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: .04em; +} +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + border-top: 1px solid var(--border-muted); +} +.row:first-of-type { border-top: 0; } +.row span { min-width: 0; } +.row strong, +.row small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.row small { color: var(--subtle); font-size: 12px; } + +.settings-form { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + padding: 16px; +} +.settings-form h3 { grid-column: 1 / -1; } +.compact-form { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.stack-form { grid-template-columns: 1fr 1fr; } +.stack-form .source-field { grid-column: 1 / -1; } +.split { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.form-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; padding-top: 4px; } +.field { display: grid; gap: 6px; color: var(--text); font-weight: 600; } +.field span { font-size: 13px; } +.field small { + color: var(--muted); + font-size: 12px; + font-weight: 400; + line-height: 1.35; +} +.field code { + padding: 1px 4px; + border-radius: 4px; + background: var(--row); + color: var(--text); + font-family: ui-monospace, SFMono-Regular, SFMono, Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; +} +.field input, +.field select { + width: 100%; + height: 32px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--input-bg); + color: var(--text); + padding: 5px 8px;; + font-size: 14px; + line-height: 20px; + font-family: inherit; +} +.field input[name=source], +.path-cell { + font-family: ui-monospace, SFMono-Regular, SFMono, Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; +} +.toggle { + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + gap: 10px; + align-items: center; + align-self: end; + min-height: 32px; + color: var(--text); + font-weight: 600; +} +.toggle input { position: absolute; opacity: 0; pointer-events: none; } +.toggle span { + position: relative; + width: 36px; + height: 20px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--toggle-bg); + transition: background .12s ease; +} +.toggle span:before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--panel); + box-shadow: 0 1px 2px rgba(27, 31, 36, .25); + transition: transform .12s ease; +} +.toggle input:checked + span { background: var(--accent); border-color: var(--accent); } +.toggle input:checked + span:before { transform: translateX(16px); } +.toggle input:focus-visible + span { box-shadow: var(--focus); } + +.table-wrap { + border: 1px solid var(--border-muted); + border-radius: var(--radius); + background: var(--panel); +} +table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; +} +th, +td { + padding: 9px 12px; + border-bottom: 1px solid var(--border-muted); + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; +} +th { background: var(--row); color: var(--muted); font-size: 12px; font-weight: 600; } +tr:hover td { background: var(--table-hover); } +tr:last-child td { border-bottom: 0; } +.path-column { width: 36%; } +.actions-column { width: 96px; white-space: nowrap; } +.source-remove { display: flex; justify-content: flex-end; } +.empty { + display: grid; + gap: 3px; + padding: 18px; + border: 1px dashed var(--border); + border-radius: var(--radius); + background: var(--panel); + color: var(--muted); +} +.empty strong { color: var(--text); } +.muted { color: var(--subtle); } + +.terminal { + overflow: hidden; + border: 1px solid var(--terminal-border); + border-radius: var(--radius); + background: var(--terminal); + box-shadow: var(--shadow); +} +.terminal-bar { + display: flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 12px; + border-bottom: 1px solid var(--terminal-border); + background: var(--terminal-header); + color: var(--terminal-muted); + font-size: 12px; +} +.terminal-bar span { width: 10px; height: 10px; border-radius: 50%; background: #484f58; } +.terminal-bar span:first-child { background: #f85149; } +.terminal-bar span:nth-child(2) { background: #d29922; } +.terminal-bar span:nth-child(3) { background: #3fb950; } +.terminal-bar strong { margin-left: 6px; font-weight: 600; } +.progress { display: none; height: 2px; background: var(--terminal-progress-bg); overflow: hidden; } +.progress span { + display: block; + width: 38%; + height: 100%; + background: var(--accent); + animation: progress 1.1s ease-in-out infinite; +} +.is-processing .progress { display: block; } +.activity-log { + height: 300px; + overflow: auto; + margin: 0; + padding: 14px; + color: var(--terminal-text); + background: var(--terminal); + font: 12px/1.55 ui-monospace, SFMono-Regular, SFMono, Consolas, "Liberation Mono", Menlo, monospace; + white-space: pre-wrap; +} +.footer { + display: flex; + justify-content: center; + gap: 8px 12px; + flex-wrap: wrap; + max-width: 1220px; + margin: 0 auto; + padding: 8px 32px 32px; + color: var(--muted); + font-size: 12px; + text-align: center; +} +.footer > * + *:before { + content: "/"; + margin-right: 12px; + color: var(--subtle); + font-weight: 400; +} +.footer a { font-weight: 600; } +.busy { + position: fixed; + right: 20px; + bottom: 20px; + display: none; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + color: var(--text); + box-shadow: var(--busy-shadow); + z-index: 20; +} +.busy.is-active { display: flex; } +.spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .8s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes progress { + 0% { transform: translateX(-110%); } + 100% { transform: translateX(285%); } +} + +@media (max-width: 1080px) { + .app { grid-template-columns: 1fr; } + .sidebar { + position: relative; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--sidebar-border); + } + .sidebar nav { display: flex; overflow: auto; } + .sidebar a { white-space: nowrap; } + .status-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .split { grid-template-columns: 1fr; } +} + +@media (max-width: 760px) { + .topbar { flex-direction: column; padding: 20px; } + .content { max-width: none; } + .section, + .status-strip, + .toast-stack, + .footer { padding-left: 20px; padding-right: 20px; } + .content > .action-result { + margin-left: 20px; + margin-right: 20px; + } + .update-banner { + align-items: flex-start; + flex-direction: column; + margin-left: 20px; + margin-right: 20px; + } + .board-grid { grid-template-columns: 1fr; } + .settings-form, + .compact-form, + .stack-form, + .seed-form { grid-template-columns: 1fr; } + .facts { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 10px; } + .mounted-grid { grid-template-columns: 1fr; } + .mounted + .mounted { + border-left: 0; + border-top: 1px solid var(--border-muted); + } +} diff --git a/public/assets/sandbox-ui.js b/public/assets/sandbox-ui.js new file mode 100644 index 00000000..29516ba4 --- /dev/null +++ b/public/assets/sandbox-ui.js @@ -0,0 +1,188 @@ +const dashboard = document.getElementById('dashboard'); +const busy = document.getElementById('busy'); + +function setProcessing(active) { + busy.classList.toggle('is-active', active); + document.documentElement.classList.toggle('is-processing', active); +} + +function bindAjax() { + bindUpdateBanner(); + + document.querySelectorAll('form[data-ajax]').forEach((form) => { + if (form.dataset.bound) { + return; + } + + form.dataset.bound = '1'; + form.addEventListener('submit', async(event) => { + event.preventDefault(); + if (form.dataset.confirm && !window.confirm(form.dataset.confirm)) { + return; + } + + const context = actionContext(form); + const submitter = event.submitter; + const original = submitter ? submitter.textContent : ''; + if (submitter) { + submitter.disabled = true; + submitter.textContent = 'Working...'; + } + + setProcessing(true); + try { + const response = await fetch(location.href, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + body: new FormData(form), + }); + const responseText = await response.text(); + let data; + try { + data = JSON.parse(responseText); + } catch (error) { + const detail = responseText + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 500); + throw new Error(detail || 'QuickInstall returned an invalid response. Check the UI error log.'); + } + if (!data || typeof data !== 'object') { + throw new Error('QuickInstall returned an invalid response object.'); + } + if (typeof data.html === 'string' && data.html !== '') { + dashboard.innerHTML = data.html; + bindAjax(); + } + showActionResult(data, context); + scrollLog(); + } catch (error) { + alert('Request failed: ' + error.message); + } finally { + setProcessing(false); + if (submitter) { + submitter.disabled = false; + submitter.textContent = original; + } + } + }); + }); + + document.querySelectorAll('[data-clear-log]').forEach((button) => { + if (button.dataset.bound) { + return; + } + + button.dataset.bound = '1'; + button.addEventListener('click', () => { + const log = document.getElementById('activity-log'); + if (log) { + log.textContent = 'No command output yet.'; + } + }); + }); +} + +function bindUpdateBanner() { + const banner = dashboard.querySelector('[data-update-version]'); + if (!banner) { + return; + } + + const version = banner.dataset.updateVersion || ''; + const key = 'qi.dismissedUpdate.' + version; + if (version && localStorage.getItem(key) === '1') { + banner.remove(); + return; + } + + const dismiss = banner.querySelector('[data-dismiss-update]'); + if (!dismiss || dismiss.dataset.bound) { + return; + } + + dismiss.dataset.bound = '1'; + dismiss.addEventListener('click', () => { + if (version) { + localStorage.setItem(key, '1'); + } + banner.remove(); + }); +} + +function actionContext(form) { + const formData = new FormData(form); + const section = form.closest('section'); + const board = form.closest('[data-board]'); + + return { + action: formData.get('action') || '', + board: board ? board.dataset.board : '', + section: section ? section.id : '', + }; +} + +function showActionResult(data, context) { + const globalToast = dashboard.querySelector('.toast-stack'); + if (globalToast) { + globalToast.remove(); + } + + const message = data.error || data.notice || ''; + if (!message) { + return; + } + + const result = document.createElement('div'); + result.className = 'action-result ' + (data.error ? 'error' : 'notice'); + result.setAttribute('role', data.error ? 'alert' : 'status'); + result.textContent = message; + + let target = null; + if (context.board) { + target = boardHeader(context.board); + } + if (!target && context.section) { + const section = document.getElementById(context.section); + target = section ? section.querySelector('.section-head') : null; + } + if (!target) { + target = dashboard.querySelector('.status-strip'); + } + + if (target) { + if (target.classList.contains('status-strip')) { + target.insertAdjacentElement('beforebegin', result); + } else { + target.insertAdjacentElement('afterend', result); + } + window.setTimeout(() => { + result.classList.add('is-dismissing'); + window.setTimeout(() => result.remove(), 180); + }, data.error ? 9000 : 5000); + } +} + +function boardHeader(name) { + const boards = dashboard.querySelectorAll('[data-board]'); + for (const board of boards) { + if (board.dataset.board === name) { + return board.querySelector('.card-head'); + } + } + + return null; +} + +function scrollLog() { + const log = document.getElementById('activity-log'); + if (log) { + log.scrollTop = log.scrollHeight; + } +} + +bindAjax(); +scrollLog(); diff --git a/public/sandbox-ui.php b/public/sandbox-ui.php new file mode 100644 index 00000000..2a4afc72 --- /dev/null +++ b/public/sandbox-ui.php @@ -0,0 +1,34 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; +$file = realpath(__DIR__ . $path); +if (PHP_SAPI === 'cli-server' && $file !== false && str_starts_with($file, __DIR__ . DIRECTORY_SEPARATOR) && is_file($file)) +{ + $extension = pathinfo($file, PATHINFO_EXTENSION); + $contentTypes = [ + 'css' => 'text/css; charset=utf-8', + 'js' => 'application/javascript; charset=utf-8', + 'svg' => 'image/svg+xml; charset=utf-8', + ]; + if (isset($contentTypes[$extension])) + { + header('Content-Type: ' . $contentTypes[$extension]); + readfile($file); + return true; + } + + return false; +} + +require_once __DIR__ . '/../src/QuickInstall/Sandbox/bootstrap.php'; +require_once __DIR__ . '/../src/QuickInstall/Sandbox/Web/Application.php'; + +(new QuickInstall\Sandbox\Web\Application(dirname(__DIR__)))->run(); diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index 6929293f..c68cbaad 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -12,6 +12,7 @@ use InvalidArgumentException; use RuntimeException; +use Throwable; class Application { @@ -22,7 +23,7 @@ class Application public function __construct(string $root, $stderr = null, $stdin = null) { $this->project = new Project($root); - $this->stderr = $stderr ?: STDERR; + $this->stderr = $stderr ?: (defined('STDERR') ? STDERR : fopen('php://stderr', 'wb')); $this->stdin = $stdin ?: (defined('STDIN') ? STDIN : null); } @@ -36,8 +37,13 @@ public function run(array $argv): int return 0; } + $operationLock = null; try { + if ($this->mutatesWorkspace($command)) + { + $operationLock = $this->project->lockOperations(); + } switch ($command) { case 'help': @@ -49,6 +55,9 @@ public function run(array $argv): int case 'init': return $this->init(); + case 'doctor': + return $this->doctor(); + case 'source:list': return $this->sourceList(); @@ -100,6 +109,18 @@ public function run(array $argv): int case 'style:list': return $this->styleList($argv); + case 'ui:start': + return $this->uiStart($argv); + + case 'ui:stop': + return $this->uiStop(); + + case 'ui:restart': + return $this->uiRestart($argv); + + case 'ui:status': + return $this->uiStatus(); + default: $this->writeError("Unknown command: $command\n\n"); $this->help(); @@ -111,6 +132,28 @@ public function run(array $argv): int $this->writeError($e->getMessage() . "\n"); return 1; } + finally + { + $this->project->unlockOperations($operationLock); + try + { + $this->printUpdateNotice($command, $argv); + } + catch (Throwable $e) + { + // Update notifications must never change command success or failure. + } + } + } + + private function mutatesWorkspace(string $command): bool + { + return in_array($command, [ + 'init', 'source:fetch', 'source:remove', 'source:prune', + 'board:create', 'board:start', 'board:stop', 'board:destroy', 'board:seed', + 'ext:mount', 'ext:unmount', 'style:mount', 'style:unmount', + 'ui:start', 'ui:stop', 'ui:restart', + ], true); } private function init(): int @@ -374,12 +417,33 @@ private function shouldPrompt(): bool if (defined('STDIN') && $this->stdin === STDIN) { + if (function_exists('stream_isatty')) + { + return stream_isatty(STDIN); + } return !function_exists('posix_isatty') || posix_isatty(STDIN); } return true; } + private function doctor(): int + { + $checks = (new DoctorService($this->project))->checks(); + $failed = false; + echo $this->style('QuickInstall requirements', '1') . "\n"; + foreach ($checks as $check) + { + $status = $check['ok'] + ? $this->style("\u{2714}", '1;32') + : $this->style('!', '1;31'); + echo "$status {$check['name']}: {$check['detail']}\n"; + $failed = $failed || !$check['ok']; + } + + return $failed ? 1 : 0; + } + private function style(string $text, string $code): string { if (!$this->supportsAnsi()) @@ -401,6 +465,14 @@ private function supportsAnsi(): bool { return posix_isatty(STDOUT); } + if (function_exists('stream_isatty') && defined('STDOUT') && !stream_isatty(STDOUT)) + { + return false; + } + if (PHP_OS_FAMILY === 'Windows' && function_exists('sapi_windows_vt100_support') && defined('STDOUT')) + { + return sapi_windows_vt100_support(STDOUT, true); + } return PHP_SAPI === 'cli'; } @@ -538,33 +610,106 @@ private function extUnmount(array $args): int private function refreshBoardIfRunning(string $board): void { - $runner = new BoardRunner($this->project, $this->sandboxOutput()); - (new DockerComposeWriter($this->project))->write($board, $this->runtimeConfig($this->project->board($board))); - if ($runner->status($board) === 'running') + (new BoardRefreshService($this->project))->refreshIfRunning($board); + } + + private function uiStart(array $args): int + { + $cli = CommandLine::parse($args); + [$host, $port] = $this->uiOptions($cli); + $result = (new UiServerService($this->project))->start($host, $port); + $state = $result['state']; + if ($result['status'] === 'already_running') { - $runner->recreateWeb($board); - $runner->purgeCache($board); + echo "QuickInstall Dashboard UI is already running: {$state['url']}\n"; + 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"; + + return 0; } - private function runtimeConfig(array $board): array + private function uiStop(): int { - if (empty($board['port']) && !empty($board['url'])) + $result = (new UiServerService($this->project))->stop(); + $state = $result['state']; + if ($result['status'] === 'not_tracked') { - $port = parse_url($board['url'], PHP_URL_PORT); - $board['port'] = $port ?: 80; + echo "QuickInstall Dashboard UI is not tracked as running.\n"; + return 0; } - return $board + [ - 'admin_name' => 'admin', - 'admin_pass' => 'password', - 'admin_email' => 'admin@example.test', - 'board_email' => 'board@example.test', - 'populate' => 'none', - 'debug' => false, - 'extensions' => [], - 'styles' => [], - ]; + if ($result['status'] === 'stopped') + { + echo "Stopped QuickInstall Dashboard UI"; + if (!empty($state['url'])) + { + echo ": {$state['url']}"; + } + } + else + { + echo "QuickInstall Dashboard UI process was not running"; + $pid = (int) ($state['pid'] ?? 0); + if ($pid > 0) + { + echo " (PID $pid)"; + } + } + echo "\n"; + + return 0; + } + + private function uiRestart(array $args): int + { + $this->uiStop(); + return $this->uiStart($args); + } + + private function uiStatus(): int + { + $result = (new UiServerService($this->project))->status(); + $state = $result['state']; + if ($result['status'] === 'running') + { + echo "QuickInstall Dashboard UI is running\n"; + echo "URL: {$state['url']}\n"; + echo "PID: {$state['pid']}\n"; + echo "Log: {$state['log']}\n"; + return 0; + } + + if ($result['status'] === 'stale') + { + echo "QuickInstall Dashboard UI is not running, but stale state exists.\n"; + echo "Last URL: " . ($state['url'] ?? '(unknown)') . "\n"; + echo "Run: php bin/qi ui:stop\n"; + return 1; + } + + echo "QuickInstall Dashboard UI is not running.\n"; + return 0; + } + + private function uiOptions(CommandLine $cli): array + { + $host = $cli->option('host', '127.0.0.1'); + $port = (int) $cli->option('port', '8079'); + if (!in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) + { + throw new InvalidArgumentException('ui:start only supports local loopback hosts: 127.0.0.1, localhost, or ::1.'); + } + if ($port < 1 || $port > 65535) + { + throw new InvalidArgumentException('--port must be between 1 and 65535.'); + } + + return [$host, $port]; } private function extList(array $args): int @@ -677,41 +822,44 @@ private function writeError(string $message): void fwrite($this->stderr, $message); } + private function printUpdateNotice(string $command, array $args): void + { + if (in_array($command, ['help', '--help', '-h'], true) || in_array('--help', $args, true) || in_array('-h', $args, true)) + { + return; + } + + $update = (new UpdateService($this->project))->getUpdate(); + if ($update === null) + { + return; + } + + echo "\nQuickInstall {$update['current']} available"; + if (!empty($update['download'])) + { + echo ": {$update['download']}"; + } + echo "\n"; + } + private function sandboxOutput(): Output { - $stdout = defined('STDOUT') && defined('STDERR') && $this->stderr === STDERR ? STDOUT : fopen('php://output', 'w'); + $stdout = defined('STDOUT') && defined('STDERR') && $this->stderr === STDERR ? STDOUT : fopen('php://output', 'wb'); 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) - { - $this->project->board($board); - $mounted = []; - $errors = []; - foreach ($manager->discover($source, $allowExternal) as $path) - { - try - { - $mounted[] = $manager->mount($board, $path, false, $allowExternal); - } - catch (RuntimeException | InvalidArgumentException $e) - { - $errors[] = "$path: " . $e->getMessage(); - } - } + $result = (new CustomisationMountService($this->project))->mount($manager, $board, $source, $copy, $recursive, $allowExternal); - if ($mounted) - { - $this->refreshBoardIfRunning($board); - } - $this->printBulkMountResult($type, $board, $mounted, $errors); - return $errors ? 1 : 0; + if ($result['recursive']) + { + $this->printBulkMountResult($type, $board, $result['mounted'], $result['errors']); + return $result['errors'] ? 1 : 0; } - $mounted = $manager->mount($board, $source, $copy, $allowExternal); - $this->refreshBoardIfRunning($board); + $mounted = $result['mounted'][0]; echo "Mounted {$mounted['name']} on $board ({$mounted['mode']})\n"; echo "Source: {$mounted['source']}\n"; echo "Target: {$mounted['target']}\n"; @@ -728,22 +876,22 @@ private function help(array $args = []): void return; } - echo "QuickInstall CLI\n"; + echo $this->style('QuickInstall CLI', '1') . "\n"; echo "Create disposable local phpBB boards with Docker.\n\n"; - echo "Usage:\n"; - echo " qi [arguments] [options]\n"; - echo " qi help [command]\n\n"; - echo "Common workflow:\n"; - echo " qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate extension-dev\n"; - echo " qi board:start demo\n\n"; + echo $this->style('Usage:', '1;33') . "\n"; + echo ' ' . $this->style('qi [arguments] [options]', '1;36') . "\n"; + echo ' ' . $this->style('qi help [command]', '1;36') . "\n\n"; + echo $this->style('Common workflow:', '1;33') . "\n"; + echo ' ' . $this->style('qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate extension-dev', '1;36') . "\n"; + echo ' ' . $this->style('qi board:start demo', '1;36') . "\n\n"; foreach ($commands as $group => $items) { - echo "$group:\n"; + echo $this->style("$group:", '1;33') . "\n"; $width = max(array_map('strlen', array_keys($items))); foreach ($items as $name => $help) { - echo ' ' . str_pad($name, $width) . ' ' . $help['summary'] . "\n"; + echo ' ' . $this->style(str_pad($name, $width), '1;36') . ' ' . $help['summary'] . "\n"; } echo "\n"; } @@ -762,27 +910,27 @@ private function helpCommand(string $command, array $groups): void } $help = $items[$command]; - echo "{$help['title']}\n\n"; - echo "Usage:\n"; - echo " qi {$help['usage']}\n\n"; - echo "Description:\n"; + echo $this->style($help['title'], '1') . "\n\n"; + echo $this->style('Usage:', '1;33') . "\n"; + echo ' ' . $this->style("qi {$help['usage']}", '1;36') . "\n\n"; + echo $this->style('Description:', '1;33') . "\n"; echo " {$help['description']}\n"; if (!empty($help['arguments'])) { - echo "\nArguments:\n"; + echo "\n" . $this->style('Arguments:', '1;33') . "\n"; $this->printHelpRows($help['arguments']); } if (!empty($help['options'])) { - echo "\nOptions:\n"; + echo "\n" . $this->style('Options:', '1;33') . "\n"; $this->printHelpRows($help['options']); } if (!empty($help['examples'])) { - echo "\nExamples:\n"; + echo "\n" . $this->style('Examples:', '1;33') . "\n"; foreach ($help['examples'] as $example) { - echo " qi $example\n"; + echo ' ' . $this->style("qi $example", '1;36') . "\n"; } } echo "\n"; @@ -798,13 +946,24 @@ private function printHelpRows(array $rows): void $width = max(array_map('strlen', array_keys($rows))); foreach ($rows as $name => $description) { - echo ' ' . str_pad($name, $width) . ' ' . $description . "\n"; + echo ' ' . $this->style(str_pad($name, $width), '1;36') . ' ' . $description . "\n"; } } private function helpCommands(): array { return [ + 'Setup commands' => [ + 'doctor' => [ + 'title' => 'doctor', + 'usage' => 'doctor', + 'summary' => 'Check local PHP, Docker, Git, and Composer requirements.', + 'description' => 'Checks that required host tools are available, Docker Desktop is running, Docker Compose works, and Docker is using Linux containers.', + 'examples' => [ + 'doctor', + ], + ], + ], 'Board commands' => [ 'board:create' => [ 'title' => 'board:create', @@ -984,6 +1143,53 @@ private function helpCommands(): array ], ], ], + 'UI commands' => [ + 'ui:start' => [ + 'title' => 'ui:start', + 'usage' => 'ui:start [--host 127.0.0.1] [--port 8079]', + '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.', + ], + 'examples' => [ + 'ui:start', + 'ui:start --port 8088', + ], + ], + 'ui:stop' => [ + 'title' => 'ui:stop', + 'usage' => 'ui:stop', + 'summary' => 'Stop the tracked QuickInstall Dashboard UI.', + 'description' => 'Stops the background Dashboard UI server that was started with ui:start and removes its runtime state file.', + 'examples' => [ + 'ui:stop', + ], + ], + 'ui:restart' => [ + 'title' => 'ui:restart', + 'usage' => 'ui:restart [--host 127.0.0.1] [--port 8079]', + '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.', + ], + 'examples' => [ + 'ui:restart', + ], + ], + 'ui:status' => [ + 'title' => 'ui:status', + 'usage' => 'ui:status', + 'summary' => 'Show whether the QuickInstall Dashboard UI is running.', + 'description' => 'Reads the tracked UI runtime state and checks whether the configured local UI port responds.', + 'examples' => [ + 'ui:status', + ], + ], + ], 'Source commands' => [ 'source:fetch' => [ 'title' => 'source:fetch', diff --git a/src/QuickInstall/Sandbox/BoardRefreshService.php b/src/QuickInstall/Sandbox/BoardRefreshService.php new file mode 100644 index 00000000..81c79fcc --- /dev/null +++ b/src/QuickInstall/Sandbox/BoardRefreshService.php @@ -0,0 +1,53 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +class BoardRefreshService +{ + private Project $project; + private BoardRunner $runner; + + public function __construct(Project $project, ?Output $output = null, ?BoardRunner $runner = null) + { + $this->project = $project; + $this->runner = $runner ?: new BoardRunner($project, $output); + } + + public function refreshIfRunning(string $board): void + { + (new DockerComposeWriter($this->project))->write($board, $this->runtimeConfig($this->project->board($board))); + if ($this->runner->status($board) === 'running') + { + $this->runner->recreateWeb($board); + $this->runner->purgeCache($board); + } + } + + private function runtimeConfig(array $board): array + { + if (empty($board['port']) && !empty($board['url'])) + { + $port = parse_url($board['url'], PHP_URL_PORT); + $board['port'] = $port ?: 80; + } + + return $board + [ + 'admin_name' => 'admin', + 'admin_pass' => 'password', + 'admin_email' => 'admin@example.test', + 'board_email' => 'board@example.test', + 'populate' => 'none', + 'debug' => false, + 'extensions' => [], + 'styles' => [], + ]; + } +} diff --git a/src/QuickInstall/Sandbox/BoardRunner.php b/src/QuickInstall/Sandbox/BoardRunner.php index 3f344a7c..ee90c7ce 100644 --- a/src/QuickInstall/Sandbox/BoardRunner.php +++ b/src/QuickInstall/Sandbox/BoardRunner.php @@ -54,11 +54,7 @@ public function stop(string $name): void public function destroy(string $name): void { $this->project->board($name); - $compose = $this->project->composePath($name); - if (file_exists($compose)) - { - $this->run(['docker', 'compose', '-f', $compose, 'down', '--volumes', '--remove-orphans', '--rmi', 'local']); - } + $this->removeContainers($name, true); $this->project->deleteTree($this->project->boardPath($name)); $this->project->deleteTree($this->project->runtimePath($name)); @@ -66,6 +62,27 @@ public function destroy(string $name): void $this->project->removeBoard($name); } + public function prepareReplacement(string $name): void + { + $this->project->board($name); + $this->removeContainers($name, false); + } + + private function removeContainers(string $name, bool $removeImage): void + { + $compose = $this->project->composePath($name); + if (file_exists($compose)) + { + $command = ['docker', 'compose', '-f', $compose, 'down', '--volumes', '--remove-orphans']; + if ($removeImage) + { + $command[] = '--rmi'; + $command[] = 'local'; + } + $this->run($command); + } + } + public function status(string $name): string { $compose = $this->project->composePath($name); @@ -193,7 +210,7 @@ protected function enableConfigPhpDebug(string $boardPath, bool $displayLoadTime $contents = $this->setConfigDefine($contents, 'PHPBB_DISPLAY_LOAD_TIME', "@define('PHPBB_DISPLAY_LOAD_TIME', true);"); } - file_put_contents($configPath, $contents); + $this->writeFile($configPath, $contents, 'board config'); } protected function enableYamlDebug(string $boardPath): void @@ -241,7 +258,7 @@ protected function enableYamlDebug(string $boardPath): void $contents = rtrim($contents) . "\n\nparameters:\n" . implode("\n", $lines) . "\n"; } - file_put_contents($configPath, $contents); + $this->writeFile($configPath, $contents, 'board debug config'); } protected function setConfigDefine(string $contents, string $name, string $line): string @@ -331,7 +348,7 @@ protected function httpStatus(string $url): int protected function serviceState(string $name, string $service): string { - $result = $this->capture(['docker', 'compose', '-f', $this->project->composePath($name), 'ps', $service, '--format', 'json']); + $result = $this->capture(['docker', 'compose', '-f', $this->project->composePath($name), 'ps', '-a', $service, '--format', 'json']); if ($result['exit_code'] !== 0 || trim($result['output']) === '') { return ''; @@ -353,7 +370,7 @@ protected function runSeeder(string $name, string $preset, int $seed, string $ac $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]); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'exec', '-T', 'web', 'timeout', '300', 'php', '-d', 'memory_limit=512M', '/tmp/qi_seed.php', $preset, (string) $seed, $action]); } protected function seedMarker(string $name, string $preset): string @@ -364,15 +381,24 @@ protected function seedMarker(string $name, string $preset): string protected function deleteSeedMarker(string $name, string $preset): void { $marker = $this->seedMarker($name, $preset); - if (file_exists($marker)) + if (file_exists($marker) && !unlink($marker)) { - unlink($marker); + throw new RuntimeException("Unable to delete seed marker: $marker"); } } protected function writeSeedMarker(string $name, string $preset): void { - file_put_contents($this->seedMarker($name, $preset), gmdate('c') . "\n"); + $marker = $this->seedMarker($name, $preset); + $this->writeFile($marker, gmdate('c') . "\n", 'seed marker'); + } + + private function writeFile(string $path, string $contents, string $label): void + { + if (file_put_contents($path, $contents, LOCK_EX) !== strlen($contents)) + { + throw new RuntimeException("Unable to write $label: $path"); + } } protected function run(array $command): void diff --git a/src/QuickInstall/Sandbox/BoardService.php b/src/QuickInstall/Sandbox/BoardService.php index d43413d0..608f3b4c 100644 --- a/src/QuickInstall/Sandbox/BoardService.php +++ b/src/QuickInstall/Sandbox/BoardService.php @@ -12,6 +12,7 @@ use InvalidArgumentException; use RuntimeException; +use Throwable; class BoardService { @@ -27,43 +28,55 @@ public function __construct(Project $project, ?Output $output = null) public function create(string $name, string $version = 'latest', string $db = 'mariadb', int $port = 8080, string $populate = 'none', bool $debug = false, bool $replace = false): array { $this->project->init(); - $selection = (new VersionMatrix())->resolve($version); + $this->project->assertName($name, 'board'); + $selection = $this->resolveSourceSelection($version); $boards = $this->project->boards(); - if (isset($boards[$name])) + $existingName = null; + foreach (array_keys($boards) as $registeredName) { + if ($this->project->namesEqual((string) $registeredName, $name)) + { + $existingName = (string) $registeredName; + break; + } + } + if ($existingName !== null) + { + if ($existingName !== $name) + { + throw new InvalidArgumentException("Board already exists: $existingName. Board names are case-insensitive."); + } if (!$replace) { throw new InvalidArgumentException("Board already exists: $name. Use board:destroy first, or pass --replace to recreate it."); } - - $this->createBoardRunner()->destroy($name); } foreach ($this->project->boards() as $board) { - if (($board['name'] ?? '') !== $name && (int) ($board['port'] ?? 0) === $port) + if (!$this->project->namesEqual((string) ($board['name'] ?? ''), $name) && (int) ($board['port'] ?? 0) === $port) { throw new InvalidArgumentException("Port $port is already used by board: {$board['name']}"); } } - if ($this->isPortInUse($port)) + $reusingOwnPort = $existingName !== null && (int) ($boards[$existingName]['port'] ?? 0) === $port; + if (!$reusingOwnPort && $this->isPortInUse($port)) { throw new InvalidArgumentException("Port $port is already in use on this host."); } $source = $this->createSourceProvider()->ensure($version); - - $boardDir = $this->project->boardPath($name); - if (!is_dir($boardDir) && !mkdir($boardDir, 0775, true) && !is_dir($boardDir)) + $php = $source['php'] ?? $selection['php'] ?? null; + if ($php === null || $php === '') { - throw new RuntimeException("Unable to create board directory: $boardDir"); + throw new RuntimeException("Unable to determine PHP runtime for source: {$source['source_key']}"); } $config = [ 'phpbb' => $source['version'], 'phpbb_source' => $source['source_key'], - 'php' => $source['php'] ?? $selection['php'], + 'php' => $php, 'db' => $db, 'port' => $port, 'populate' => $populate, @@ -75,30 +88,131 @@ public function create(string $name, string $version = 'latest', string $db = 'm 'extensions' => [], 'styles' => [], ]; + $backups = []; + if ($existingName !== null) + { + $this->createBoardRunner()->prepareReplacement($name); + $backups = $this->backupBoardState($name); + } - $paths = $this->createDockerComposeWriter()->write($name, $config); - $board = [ - 'name' => $name, - 'phpbb' => $source['version'], - 'phpbb_source' => $source['source_key'], - 'phpbb_branch' => $source['phpbb_branch'], - 'php' => $source['php'] ?? $selection['php'], - 'db' => $db, - 'port' => $port, - 'url' => "http://localhost:$port/", - 'path' => $boardDir, - 'populate' => $populate, - 'debug' => $debug, - 'extensions' => [], - 'styles' => [], - 'created_at' => gmdate('c'), - ]; + $boardDir = $this->project->boardPath($name); + try + { + if (!is_dir($boardDir) && !mkdir($boardDir, 0775, true) && !is_dir($boardDir)) + { + throw new RuntimeException("Unable to create board directory: $boardDir"); + } - $this->project->appendBoard($board); + $paths = $this->createDockerComposeWriter()->write($name, $config); + $board = [ + 'name' => $name, + 'phpbb' => $source['version'], + 'phpbb_source' => $source['source_key'], + 'phpbb_branch' => $source['phpbb_branch'], + 'php' => $php, + 'db' => $db, + 'port' => $port, + 'url' => "http://localhost:$port/", + 'path' => $boardDir, + 'populate' => $populate, + 'debug' => $debug, + 'extensions' => [], + 'styles' => [], + 'created_at' => gmdate('c'), + ]; + + $this->project->appendBoard($board); + } + catch (Throwable $e) + { + $this->restoreBoardState($backups); + throw $e; + } + + $this->discardBoardBackups($backups); return ['board' => $board, 'paths' => $paths]; } + private function backupBoardState(string $name): array + { + $token = str_replace('.', '', uniqid('replace-', true)); + $backups = []; + foreach ([$this->project->boardPath($name), $this->project->runtimePath($name), $this->project->dbPath($name)] as $path) + { + $backups[$path] = null; + if (!file_exists($path) && !is_link($path)) + { + continue; + } + + $backup = $path . '.' . $token; + if (!@rename($path, $backup)) + { + $this->restoreBoardState(array_filter($backups, 'is_string')); + throw new RuntimeException("Unable to preserve existing board during replacement: $path"); + } + $backups[$path] = $backup; + } + + return $backups; + } + + private function restoreBoardState(array $backups): void + { + foreach ($backups as $path => $backup) + { + if (file_exists($path) || is_link($path)) + { + $this->project->deleteTree($path); + } + if (is_string($backup) && (file_exists($backup) || is_link($backup)) && !@rename($backup, $path)) + { + throw new RuntimeException("Unable to restore board after failed replacement: $path"); + } + } + } + + private function discardBoardBackups(array $backups): void + { + foreach ($backups as $backup) + { + if (!is_string($backup)) + { + continue; + } + try + { + $this->project->deleteTree($backup); + } + catch (RuntimeException $e) + { + if ($this->output !== null) + { + $this->output->error("Warning: unable to remove replacement backup $backup: {$e->getMessage()}\n"); + } + } + } + } + + private function resolveSourceSelection(string $version): array + { + try + { + return (new VersionMatrix())->resolve($version); + } + catch (InvalidArgumentException $e) + { + $sources = $this->project->readJson('sources.json', []); + if (!isset($sources[$version])) + { + throw $e; + } + + return $sources[$version]; + } + } + protected function isPortInUse(int $port): bool { $socket = @stream_socket_server("tcp://127.0.0.1:$port", $errno, $errstr); diff --git a/src/QuickInstall/Sandbox/CustomisationMountService.php b/src/QuickInstall/Sandbox/CustomisationMountService.php new file mode 100644 index 00000000..f757c064 --- /dev/null +++ b/src/QuickInstall/Sandbox/CustomisationMountService.php @@ -0,0 +1,69 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +use InvalidArgumentException; +use RuntimeException; + +class CustomisationMountService +{ + private Project $project; + private ?Output $output; + + public function __construct(Project $project, ?Output $output = null) + { + $this->project = $project; + $this->output = $output; + } + + public function mount(object $manager, string $board, string $source, bool $copy = false, bool $recursive = false, bool $allowExternal = false): array + { + if ($recursive) + { + return $this->mountRecursive($manager, $board, $source, $allowExternal); + } + + $mounted = [$manager->mount($board, $source, $copy, $allowExternal)]; + $this->refreshBoardIfRunning($board); + + return ['mounted' => $mounted, 'errors' => [], 'recursive' => false]; + } + + private function mountRecursive(object $manager, string $board, string $source, bool $allowExternal): array + { + $this->project->board($board); + $mounted = []; + $errors = []; + foreach ($manager->discover($source, $allowExternal) as $path) + { + try + { + $mounted[] = $manager->mount($board, $path, false, $allowExternal); + } + catch (RuntimeException | InvalidArgumentException $e) + { + $errors[] = "$path: " . $e->getMessage(); + } + } + + if ($mounted) + { + $this->refreshBoardIfRunning($board); + } + + return ['mounted' => $mounted, 'errors' => $errors, 'recursive' => true]; + } + + private function refreshBoardIfRunning(string $board): void + { + (new BoardRefreshService($this->project, $this->output))->refreshIfRunning($board); + } +} diff --git a/src/QuickInstall/Sandbox/DockerComposeWriter.php b/src/QuickInstall/Sandbox/DockerComposeWriter.php index c9ccc318..100c7ab6 100644 --- a/src/QuickInstall/Sandbox/DockerComposeWriter.php +++ b/src/QuickInstall/Sandbox/DockerComposeWriter.php @@ -34,11 +34,14 @@ public function write(string $name, array $config): array $dockerfile = $runtimeDir . '/Dockerfile'; $entrypoint = $runtimeDir . '/entrypoint.sh'; - file_put_contents($installConfig, $this->installConfig($name, $config)); - file_put_contents($compose, $this->compose($name, $config)); - file_put_contents($dockerfile, $this->dockerfile($config)); - file_put_contents($entrypoint, $this->entrypoint()); - chmod($entrypoint, 0755); + $this->writeFile($installConfig, $this->installConfig($name, $config)); + $this->writeFile($compose, $this->compose($name, $config)); + $this->writeFile($dockerfile, $this->dockerfile($config)); + $this->writeFile($entrypoint, $this->entrypoint()); + if ((PHP_OS_FAMILY !== 'Windows') && !chmod($entrypoint, 0755)) + { + throw new RuntimeException("Unable to make entrypoint executable: $entrypoint"); + } return [ 'compose' => $compose, @@ -48,6 +51,15 @@ public function write(string $name, array $config): array ]; } + private function writeFile(string $path, string $contents): void + { + $contents = str_replace(["\r\n", "\r"], "\n", $contents); + if (file_put_contents($path, $contents, LOCK_EX) !== strlen($contents)) + { + throw new RuntimeException("Unable to write runtime file: $path"); + } + } + private function installConfig(string $name, array $config): string { $dbms = $this->dbms($config['db']); @@ -67,7 +79,7 @@ private function installConfig(string $name, array $config): string board: lang: en name: $boardName - description: "QuickInstall sandbox" + description: "QuickInstall test board" database: dbms: $dbms dbhost: "$dbhost" @@ -119,10 +131,7 @@ private function compose(string $name, array $config): string ports: - "127.0.0.1:{$config['port']}:80" volumes: - - {$this->yamlString($sourcePath . ':/opt/phpbb-source:ro')} - - {$this->yamlString($boardPath . ':/var/www/html')} -{$extensionVolumes}{$styleVolumes} - ./install-config.yml:/opt/quickinstall/install-config.yml:ro - - ./entrypoint.sh:/opt/quickinstall/entrypoint.sh:ro +{$this->bindVolume($sourcePath, '/opt/phpbb-source', true)}{$this->bindVolume($boardPath, '/var/www/html')}{$extensionVolumes}{$styleVolumes}{$this->bindVolume('./install-config.yml', '/opt/quickinstall/install-config.yml', true)}{$this->bindVolume('./entrypoint.sh', '/opt/quickinstall/entrypoint.sh', true)} entrypoint: ["/bin/sh", "/opt/quickinstall/entrypoint.sh"] depends_on: db: @@ -153,7 +162,7 @@ private function extensionVolumes(array $extensions): string } $target = '/var/www/html/ext/' . $name; - $volumes .= ' - ' . $this->yamlString($source . ':' . $target) . "\n"; + $volumes .= $this->bindVolume($source, $target); } return $volumes; @@ -176,12 +185,25 @@ private function styleVolumes(array $styles): string } $target = '/var/www/html/styles/' . $name; - $volumes .= ' - ' . $this->yamlString($source . ':' . $target) . "\n"; + $volumes .= $this->bindVolume($source, $target); } return $volumes; } + private function bindVolume(string $source, string $target, bool $readOnly = false): string + { + $volume = " - type: bind\n"; + $volume .= " source: {$this->yamlString($source)}\n"; + $volume .= " target: {$this->yamlString($target)}\n"; + if ($readOnly) + { + $volume .= " read_only: true\n"; + } + + return $volume; + } + private function yamlString(string $value): string { return '"' . str_replace( @@ -273,7 +295,7 @@ private function databaseService(string $db, string $name, string $phpVersion): POSTGRES_USER: phpbb POSTGRES_PASSWORD: phpbb volumes: - - {$this->yamlString($dbPath . ':/var/lib/postgresql/data')} +{$this->bindVolume($dbPath, '/var/lib/postgresql/data')} healthcheck: test: ["CMD-SHELL", "pg_isready -U phpbb -d phpbb"] interval: 5s @@ -297,8 +319,6 @@ private function databaseService(string $db, string $name, string $phpVersion): $command = ''; break; } - $dbVolume = $this->yamlString($dbPath . ':/var/lib/mysql'); - return <<bindVolume($dbPath, '/var/lib/mysql')} healthcheck: test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -proot"] interval: 5s diff --git a/src/QuickInstall/Sandbox/DoctorService.php b/src/QuickInstall/Sandbox/DoctorService.php new file mode 100644 index 00000000..38aca709 --- /dev/null +++ b/src/QuickInstall/Sandbox/DoctorService.php @@ -0,0 +1,143 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +class DoctorService +{ + private Project $project; + private ProcessRunner $runner; + + public function __construct(Project $project, ?ProcessRunner $runner = null) + { + $this->project = $project; + $this->runner = $runner ?: new ProcessRunner(new BufferedOutput()); + } + + public function checks(): array + { + $iniPath = php_ini_loaded_file(); + $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 configuration', true, $iniPath !== false ? $iniPath : 'no php.ini loaded; using built-in defaults'), + $this->extensionCheck('JSON', 'json'), + $this->extensionCheck('OpenSSL', 'openssl', 'extension=openssl'), + $this->extensionCheck('Phar', 'phar'), + $this->extensionCheck('Filter', 'filter'), + $this->extensionCheck('Hash', 'hash'), + $this->extensionCheck('ZIP', 'zip', 'extension=zip'), + $this->check( + 'URL streams', + filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOLEAN), + filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOLEAN) ? 'allow_url_fopen enabled' : 'enable allow_url_fopen=On in php.ini' + ), + $this->check('Process execution', is_callable('proc_open'), is_callable('proc_open') ? 'available' : 'proc_open is disabled'), + $this->check('Network sockets', is_callable('fsockopen'), is_callable('fsockopen') ? 'available' : 'fsockopen is disabled'), + $this->check('Project writable', $projectWritable, $projectWritable ? $this->project->rootPath() : 'QuickInstall project directory is not writable'), + ]; + + $docker = $this->capture(['docker', 'version', '--format', '{{.Server.Version}}']); + $checks[] = $this->check('Docker daemon', $docker['exit_code'] === 0, $this->detail($docker, 'not reachable')); + + $compose = $this->capture(['docker', 'compose', 'version', '--short']); + $checks[] = $this->check('Docker Compose', $compose['exit_code'] === 0, $this->detail($compose, 'not available')); + + if ($docker['exit_code'] === 0) + { + $osType = $this->capture(['docker', 'info', '--format', '{{.OSType}}']); + $isLinux = $osType['exit_code'] === 0 && strtolower(trim($osType['output'])) === 'linux'; + $checks[] = $this->check('Linux containers', $isLinux, $this->detail($osType, 'Docker must use Linux containers')); + } + + $git = $this->capture(['git', '--version']); + $checks[] = $this->check('Git', $git['exit_code'] === 0, $this->detail($git, 'not available')); + $composerPhar = $this->project->rootPath('composer.phar'); + if (is_file($composerPhar)) + { + $composer = $this->capture([PHP_BINARY, $composerPhar, '--version', '--no-ansi']); + $checks[] = $this->check( + 'Composer', + $composer['exit_code'] === 0, + $composer['exit_code'] === 0 ? 'bundled ' . $this->firstDetail($composer, 'composer.phar') : $this->detail($composer, 'bundled composer.phar could not run') + ); + } + else + { + $composer = $this->capture(['composer', '--version']); + $checks[] = $this->check('Composer', $composer['exit_code'] === 0, $this->detail($composer, 'not available')); + } + + return $checks; + } + + private function extensionCheck(string $label, string $extension, ?string $setting = null): array + { + $loaded = extension_loaded($extension); + $detail = 'loaded'; + if (!$loaded) + { + $detail = "missing PHP $extension extension"; + if ($setting !== null) + { + $iniPath = php_ini_loaded_file(); + $detail .= '; enable ' . $setting . ' in ' . ($iniPath !== false ? $iniPath : 'php.ini'); + } + } + + return $this->check("$label extension", $loaded, $detail); + } + + private function projectWritable(): bool + { + $path = is_dir($this->project->workspacePath()) ? $this->project->workspacePath() : $this->project->rootPath(); + return is_dir($path) && is_writable($path); + } + + private function capture(array $command): array + { + try + { + return $this->runner->capture($command, $this->project->rootPath()); + } + catch (\Throwable $e) + { + return ['exit_code' => 1, 'output' => $e->getMessage()]; + } + } + + private function check(string $name, bool $ok, string $detail): array + { + return ['name' => $name, 'ok' => $ok, 'detail' => trim($detail)]; + } + + private function detail(array $result, string $fallback): string + { + $output = trim((string) ($result['output'] ?? '')); + if ($output === '') + { + return $fallback; + } + $lines = preg_split('/\R/', $output) ?: []; + return trim((string) end($lines)); + } + + private function firstDetail(array $result, string $fallback): string + { + $output = trim((string) ($result['output'] ?? '')); + if ($output === '') + { + return $fallback; + } + $lines = preg_split('/\R/', $output) ?: []; + return trim((string) reset($lines)); + } +} diff --git a/src/QuickInstall/Sandbox/ExtensionManager.php b/src/QuickInstall/Sandbox/ExtensionManager.php index 17758533..057d17b8 100644 --- a/src/QuickInstall/Sandbox/ExtensionManager.php +++ b/src/QuickInstall/Sandbox/ExtensionManager.php @@ -219,7 +219,11 @@ private function extensionName(string $sourcePath): string private function assertExtensionName(string $name): void { - if (!preg_match('/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/', $name)) + $parts = explode('/', $name, 2); + if (!preg_match('/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/', $name) + || count($parts) !== 2 + || in_array($parts[0], ['.', '..'], true) + || in_array($parts[1], ['.', '..'], true)) { throw new InvalidArgumentException("Invalid extension name: $name"); } @@ -229,7 +233,7 @@ private function discoverExtensions(string $path, array &$found): void { if (is_file($path . '/composer.json')) { - $found[] = $path; + $found[] = realpath($path) ?: $path; return; } diff --git a/src/QuickInstall/Sandbox/ProcessRunner.php b/src/QuickInstall/Sandbox/ProcessRunner.php index 06a36f10..82fa4caf 100644 --- a/src/QuickInstall/Sandbox/ProcessRunner.php +++ b/src/QuickInstall/Sandbox/ProcessRunner.php @@ -15,19 +15,25 @@ class ProcessRunner { private Output $output; + private string $osFamily; + private float $timeoutSeconds; - public function __construct(?Output $output = null) + public function __construct(?Output $output = null, ?string $osFamily = null, float $timeoutSeconds = 900.0) { $this->output = $output ?: new BufferedOutput(); + $this->osFamily = $osFamily ?: PHP_OS_FAMILY; + $this->timeoutSeconds = $timeoutSeconds; } 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); + $displayCommand = array_map([$this, 'redactArgument'], $command); + $this->output->write('$ ' . implode(' ', array_map('escapeshellarg', $displayCommand)) . "\n"); + $streamOutput = $this->output instanceof StreamOutput; + $result = $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'])); + throw new RuntimeException("Command failed with exit code {$result['exit_code']}: {$command[0]}" . $this->failureDetails($command, $result, !$streamOutput)); } } @@ -38,13 +44,19 @@ public function capture(array $command, ?string $cwd = null): array private function execute(array $command, bool $stream, ?string $cwd): array { + $command = $this->platformCommand($command); + if ($this->osFamily === 'Windows') + { + return $this->executeWithFiles($command, $stream, $cwd); + } + $descriptor = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; - $process = proc_open($command, $descriptor, $pipes, $cwd); + $process = @proc_open($command, $descriptor, $pipes, $cwd); if (!is_resource($process)) { return ['exit_code' => 1, 'output' => '']; @@ -58,8 +70,24 @@ private function execute(array $command, bool $stream, ?string $cwd): array $output = ''; $open = [1 => true, 2 => true]; + $processExitCode = null; + $timedOut = false; + $deadline = microtime(true) + $this->timeoutSeconds; while ($open[1] || $open[2]) { + $status = proc_get_status($process); + $processRunning = (bool) ($status['running'] ?? false); + if ($processRunning && microtime(true) >= $deadline) + { + $this->terminateProcess($process, $status); + $timedOut = true; + $processRunning = false; + } + if (!$processRunning && isset($status['exitcode']) && $status['exitcode'] >= 0) + { + $processExitCode = (int) $status['exitcode']; + } + foreach ([1, 2] as $index) { if (!$open[$index]) @@ -77,7 +105,10 @@ private function execute(array $command, bool $stream, ?string $cwd): array } } - if (feof($pipes[$index])) + // Windows anonymous pipes do not always report EOF promptly after the + // child exits. Once proc_get_status confirms exit, the final read above + // has drained the pipe and it is safe to close it. + if (feof($pipes[$index]) || !$processRunning) { fclose($pipes[$index]); $open[$index] = false; @@ -90,40 +121,235 @@ private function execute(array $command, bool $stream, ?string $cwd): array } } + $closeExitCode = proc_close($process); + if ($timedOut) + { + $output .= "\nCommand timed out after " . $this->timeoutLabel() . "."; + } return [ - 'exit_code' => proc_close($process), + 'exit_code' => $timedOut ? 124 : ($processExitCode ?? $closeExitCode), 'output' => $output, ]; } + private function executeWithFiles(array $command, bool $stream, ?string $cwd): array + { + $stdoutPath = tempnam(sys_get_temp_dir(), 'qi-process-out-'); + $stderrPath = tempnam(sys_get_temp_dir(), 'qi-process-err-'); + if ($stdoutPath === false || $stderrPath === false) + { + if ($stdoutPath !== false) + { + @unlink($stdoutPath); + } + if ($stderrPath !== false) + { + @unlink($stderrPath); + } + return ['exit_code' => 1, 'output' => 'Unable to create temporary process output files.']; + } + + $descriptor = [ + 0 => ['file', $this->nullDevice(), 'r'], + 1 => ['file', $stdoutPath, 'w'], + 2 => ['file', $stderrPath, 'w'], + ]; + $process = @proc_open($command, $descriptor, $pipes, $cwd); + if (!is_resource($process)) + { + @unlink($stdoutPath); + @unlink($stderrPath); + return ['exit_code' => 1, 'output' => '']; + } + + $timedOut = false; + $deadline = microtime(true) + $this->timeoutSeconds; + while (true) + { + $status = proc_get_status($process); + if (!($status['running'] ?? false)) + { + $processExitCode = isset($status['exitcode']) && $status['exitcode'] >= 0 ? (int) $status['exitcode'] : null; + break; + } + if (microtime(true) >= $deadline) + { + $this->terminateProcess($process, $status); + $timedOut = true; + $processExitCode = null; + break; + } + usleep(10000); + } + $closeExitCode = proc_close($process); + $exitCode = $timedOut ? 124 : ($processExitCode ?? $closeExitCode); + $stdout = (string) @file_get_contents($stdoutPath); + $stderr = (string) @file_get_contents($stderrPath); + @unlink($stdoutPath); + @unlink($stderrPath); + if ($stream) + { + $this->output->write($stdout); + $this->output->error($stderr); + } + if ($timedOut) + { + $timeout = "\nCommand timed out after " . $this->timeoutLabel() . "."; + $stderr .= $timeout; + if ($stream) + { + $this->output->error($timeout); + } + } + + return ['exit_code' => $exitCode, 'output' => $stdout . $stderr]; + } + + private function failureDetails(array $command, array $result, bool $includeOutputSummary = true): string + { + $details = $includeOutputSummary ? $this->outputSummary((string) ($result['output'] ?? '')) : ''; + $hint = $this->commandHint($command, (int) $result['exit_code'], (string) ($result['output'] ?? '')); + + return $details . $hint; + } + private function runWithStreamOutput(array $command, ?string $cwd): array { + $command = $this->platformCommand($command); $descriptor = [ - 0 => ['file', '/dev/null', 'r'], + 0 => ['file', $this->nullDevice(), 'r'], 1 => $this->output->stdout(), 2 => $this->output->stderr(), ]; - $process = proc_open($command, $descriptor, $pipes, $cwd); + $process = @proc_open($command, $descriptor, $pipes, $cwd); if (!is_resource($process)) { return ['exit_code' => 1, 'output' => '']; } + $timedOut = false; + $processExitCode = null; + $deadline = microtime(true) + $this->timeoutSeconds; + while (true) + { + $status = proc_get_status($process); + if (!($status['running'] ?? false)) + { + $processExitCode = isset($status['exitcode']) && $status['exitcode'] >= 0 ? (int) $status['exitcode'] : null; + break; + } + if (microtime(true) >= $deadline) + { + $this->terminateProcess($process, $status); + $this->output->error("\nCommand timed out after " . $this->timeoutLabel() . ".\n"); + $timedOut = true; + break; + } + usleep(10000); + } + $closeExitCode = proc_close($process); + return [ - 'exit_code' => proc_close($process), + 'exit_code' => $timedOut ? 124 : ($processExitCode ?? $closeExitCode), 'output' => '', ]; } - private function commandHint(array $command, int $status): string + private function terminateProcess($process, array $status): void + { + $pid = (int) ($status['pid'] ?? 0); + if ($this->osFamily === 'Windows' && PHP_OS_FAMILY === 'Windows' && $pid > 0) + { + $descriptor = [ + 0 => ['file', 'NUL', 'r'], + 1 => ['file', 'NUL', 'w'], + 2 => ['file', 'NUL', 'w'], + ]; + $killer = @proc_open(['taskkill', '/PID', (string) $pid, '/T', '/F'], $descriptor, $pipes); + if (is_resource($killer)) + { + proc_close($killer); + } + return; + } + + @proc_terminate($process, 15); + usleep(100000); + $status = proc_get_status($process); + if ($status['running'] ?? false) + { + @proc_terminate($process, 9); + } + } + + private function timeoutLabel(): string + { + return $this->timeoutSeconds >= 60 + ? rtrim(rtrim(number_format($this->timeoutSeconds / 60, 2, '.', ''), '0'), '.') . ' minutes' + : rtrim(rtrim(number_format($this->timeoutSeconds, 2, '.', ''), '0'), '.') . ' seconds'; + } + + private function platformCommand(array $command): array + { + if ($this->osFamily !== 'Windows' || !$command) + { + return $command; + } + + $executable = strtolower((string) $command[0]); + if (!str_ends_with($executable, '.bat') && !str_ends_with($executable, '.cmd')) + { + return $command; + } + + $commandLine = implode(' ', array_map([$this, 'escapeWindowsBatchArgument'], $command)); + return [getenv('COMSPEC') ?: 'cmd.exe', '/D', '/S', '/C', '"' . $commandLine . '"']; + } + + private function nullDevice(): string + { + return $this->osFamily === 'Windows' && PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'; + } + + private function escapeWindowsBatchArgument(string $argument): string + { + // cmd.exe expands percent variables even inside quotes. Doubling percent signs + // preserves URL escapes and other literal arguments passed to batch wrappers. + $argument = str_replace('%', '%%', $argument); + $argument = str_replace('"', '\\"', $argument); + return '"' . $argument . '"'; + } + + private function outputSummary(string $output): string + { + $output = trim($output); + if ($output === '') + { + return ''; + } + + $lines = preg_split('/\R/', $output) ?: []; + $lines = array_slice(array_values(array_filter($lines, static function ($line) { + return trim($line) !== ''; + })), -8); + + return "\nCommand output:\n" . implode("\n", $lines); + } + + private function redactArgument(string $argument): string + { + return preg_replace('#^([a-z][a-z0-9+.-]*://)([^/@\s]+)@#i', '$1***@', $argument) ?? $argument; + } + + private function commandHint(array $command, int $status, string $output): 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') + if (($command[0] ?? '') === 'docker' && ($output === '' || preg_match('/Cannot connect to the Docker daemon|docker daemon|Docker Desktop/i', $output))) { return "\nCheck that Docker Desktop is running and that the docker command works in this terminal."; } diff --git a/src/QuickInstall/Sandbox/Project.php b/src/QuickInstall/Sandbox/Project.php index 6882a9ee..99acc985 100644 --- a/src/QuickInstall/Sandbox/Project.php +++ b/src/QuickInstall/Sandbox/Project.php @@ -11,16 +11,19 @@ namespace QuickInstall\Sandbox; use InvalidArgumentException; +use JsonException; use RuntimeException; class Project { private string $root; private string $workspace; + private string $osFamily; - public function __construct(string $root) + public function __construct(string $root, ?string $osFamily = null) { - $this->root = rtrim($root, '/'); + $this->osFamily = $osFamily ?: PHP_OS_FAMILY; + $this->root = rtrim($this->normalizeAbsolutePath($root, false), '/'); $this->workspace = $this->root . '/.qi'; } @@ -28,7 +31,7 @@ public function init(): array { $created = []; $customisationsPath = $this->customisationsPath(); - $workspaceExists = is_dir($this->workspace); + $workspaceExists = is_file($this->workspace . '/sources.json') || is_file($this->workspace . '/boards.json'); if (!is_dir($customisationsPath)) { if (!mkdir($customisationsPath, 0775, true) && !is_dir($customisationsPath)) @@ -39,7 +42,7 @@ public function init(): array $created[] = 'customisations drop zone'; } - foreach (['', '/sources', '/boards', '/runtime', '/db'] as $dir) + foreach (['', '/sources', '/boards', '/runtime', '/db', '/cache'] as $dir) { $path = $this->workspace . $dir; if (!is_dir($path) && !mkdir($path, 0775, true) && !is_dir($path)) @@ -80,6 +83,36 @@ public function customisationsPath(): string return $this->rootPath('customisations'); } + public function lockOperations() + { + if (!is_dir($this->workspace) && !mkdir($this->workspace, 0775, true) && !is_dir($this->workspace)) + { + throw new RuntimeException("Unable to create QuickInstall workspace: {$this->workspace}"); + } + + $path = $this->workspacePath('operation.lock'); + $lock = fopen($path, 'c+b'); + if (!is_resource($lock) || !flock($lock, LOCK_EX)) + { + if (is_resource($lock)) + { + fclose($lock); + } + throw new RuntimeException("Unable to lock QuickInstall workspace: $path"); + } + + return $lock; + } + + public function unlockOperations($lock): void + { + if (is_resource($lock)) + { + flock($lock, LOCK_UN); + fclose($lock); + } + } + public function boardPath(string $name): string { $this->assertName($name, 'board'); @@ -100,24 +133,147 @@ public function readJson(string $file, array $default): array return $default; } - $data = json_decode((string) file_get_contents($path), true); - return is_array($data) ? $data : $default; + return $this->withJsonLock($path, LOCK_SH, static function () use ($path): array { + $contents = file_get_contents($path); + if ($contents === false) + { + throw new RuntimeException("Unable to read $path"); + } + + try + { + $data = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } + catch (JsonException $e) + { + throw new RuntimeException("Invalid JSON state file: $path", 0, $e); + } + + if (!is_array($data)) + { + throw new RuntimeException("JSON state file must contain an object or array: $path"); + } + + return $data; + }); } public function writeJson(string $file, array $data): void { $path = $this->workspacePath($file); - $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; - if (file_put_contents($path, $encoded) === false) + try { - throw new RuntimeException("Unable to write $path"); + $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; } + catch (JsonException $e) + { + throw new RuntimeException("Unable to encode JSON state for $path", 0, $e); + } + + $this->withJsonLock($path, LOCK_EX, function () use ($path, $encoded): void { + $directory = dirname($path); + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) + { + throw new RuntimeException("Unable to create JSON state directory: $directory"); + } + + $temp = tempnam($directory, '.' . basename($path) . '.tmp-'); + if ($temp === false) + { + throw new RuntimeException("Unable to create temporary JSON state file for $path"); + } + + try + { + if (file_put_contents($temp, $encoded, LOCK_EX) !== strlen($encoded)) + { + throw new RuntimeException("Unable to write temporary JSON state file for $path"); + } + $this->replaceFile($temp, $path); + } + finally + { + if (is_file($temp)) + { + @unlink($temp); + } + } + }); + } + + private function withJsonLock(string $path, int $operation, callable $callback) + { + $lockPath = $path . '.lock'; + $directory = dirname($lockPath); + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) + { + throw new RuntimeException("Unable to create JSON lock directory: $directory"); + } + + $lock = fopen($lockPath, 'c+b'); + if (!is_resource($lock)) + { + throw new RuntimeException("Unable to open JSON lock file: $lockPath"); + } + + try + { + if (!flock($lock, $operation)) + { + throw new RuntimeException("Unable to lock JSON state file: $path"); + } + + return $callback(); + } + finally + { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private function replaceFile(string $temp, string $path): void + { + if (@rename($temp, $path)) + { + return; + } + + if ($this->osFamily !== 'Windows' || !is_file($path)) + { + throw new RuntimeException("Unable to replace JSON state file: $path"); + } + + $backup = $path . '.replace-backup'; + @unlink($backup); + if (!@rename($path, $backup)) + { + throw new RuntimeException("Unable to prepare JSON state replacement: $path"); + } + + if (@rename($temp, $path)) + { + @unlink($backup); + return; + } + + @rename($backup, $path); + throw new RuntimeException("Unable to replace JSON state file: $path"); } public function appendBoard(array $board): void { + $name = (string) ($board['name'] ?? ''); + $this->assertName($name, 'board'); $boards = $this->readJson('boards.json', []); - $boards[$board['name']] = $board; + foreach (array_keys($boards) as $registeredName) + { + if ($registeredName !== $name && $this->namesEqual((string) $registeredName, $name)) + { + throw new InvalidArgumentException("Board already exists: $registeredName. Board names are case-insensitive."); + } + } + $boards[$name] = $board; $this->writeJson('boards.json', $boards); } @@ -264,30 +420,56 @@ public function resolveDropZonePath(string $path, string $basePath, bool $allowE public function isPathUnder(string $path, string $parent): bool { $parent = realpath($parent); - return $parent !== false && ($path === $parent || str_starts_with($path, $parent . '/')); + if ($parent === false) + { + return false; + } + + $path = $this->comparablePath($this->normalizeAbsolutePath($path)); + $parent = $this->comparablePath($this->normalizeAbsolutePath($parent)); + return $path === $parent || str_starts_with($path, rtrim($parent, '/') . '/'); } private function assertWorkspacePath(string $path): void { - $path = $this->normalizeAbsolutePath($path); - $workspace = $this->normalizeAbsolutePath($this->workspace); + $path = $this->comparablePath($this->normalizeAbsolutePath($path)); + $workspace = $this->comparablePath($this->normalizeAbsolutePath($this->workspace)); if ($path !== $workspace && !str_starts_with($path, $workspace . '/')) { throw new RuntimeException("Refusing to delete path outside QuickInstall workspace: $path"); } } - private function normalizeAbsolutePath(string $path): string + private function normalizeAbsolutePath(string $path, bool $resolveRelative = true): string { if ($path === '') { - return $this->root; + return $this->root ?? ''; } - if ($path[0] !== '/') + + $path = str_replace('\\', '/', $path); + if ($resolveRelative && !$this->isAbsolutePath($path)) { $path = $this->rootPath($path); } + $prefix = ''; + if (preg_match('/^[A-Za-z]:/', $path, $matches)) + { + $prefix = strtoupper($matches[0]); + $path = substr($path, 2); + } + else if (str_starts_with($path, '//')) + { + $prefix = '//'; + $path = substr($path, 2); + } + else if (str_starts_with($path, '/')) + { + $prefix = '/'; + $path = substr($path, 1); + } + $parts = []; foreach (explode('/', $path) as $part) { @@ -297,20 +479,57 @@ private function normalizeAbsolutePath(string $path): string } if ($part === '..') { - array_pop($parts); + if ($parts) + { + array_pop($parts); + } continue; } $parts[] = $part; } - return '/' . implode('/', $parts); + $normalized = implode('/', $parts); + if ($prefix === '/') + { + return '/' . $normalized; + } + if ($prefix === '//') + { + return '//' . $normalized; + } + if ($prefix !== '') + { + return $prefix . '/' . $normalized; + } + + return $normalized; + } + + private function isAbsolutePath(string $path): bool + { + return str_starts_with($path, '/') || (bool) preg_match('/^[A-Za-z]:\//', $path); + } + + private function comparablePath(string $path): string + { + $path = rtrim($path, '/'); + return $this->osFamily === 'Windows' ? strtolower($path) : $path; } public function assertName(string $name, string $label): void { - if (!preg_match('/^[A-Za-z0-9._-]+$/', $name)) + $reservedWindowsName = (bool) preg_match('/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i', $name); + if (!preg_match('/^[A-Za-z0-9._-]+$/', $name) + || $name === '.' + || $name === '..' + || ($this->osFamily === 'Windows' && ($reservedWindowsName || str_ends_with($name, '.')))) { throw new InvalidArgumentException("Invalid $label: $name"); } } + + public function namesEqual(string $left, string $right): bool + { + return strcasecmp($left, $right) === 0; + } } diff --git a/src/QuickInstall/Sandbox/SeederWriter.php b/src/QuickInstall/Sandbox/SeederWriter.php index 37218c1b..19873aac 100644 --- a/src/QuickInstall/Sandbox/SeederWriter.php +++ b/src/QuickInstall/Sandbox/SeederWriter.php @@ -37,6 +37,8 @@ private function script(): string return <<<'PHP' resolve($version, $type === 'git'); $url = $url ?: ($type === 'git' ? 'https://github.com/phpbb/phpbb.git' : null); + if ($type === 'git') + { + $this->validateGitUrl((string) $url); + } if ($type === 'git' && !$allowExternal && $url !== 'https://github.com/phpbb/phpbb.git') { throw new InvalidArgumentException('Custom Git source URLs can run Composer code on your host. Use --allow-external only for trusted phpBB forks.'); } $sources = $this->project->readJson('sources.json', []); + foreach (array_keys($sources) as $registeredKey) + { + if ($this->project->namesEqual((string) $registeredKey, (string) $selection['source_key']) && (string) $registeredKey !== (string) $selection['source_key']) + { + throw new InvalidArgumentException("Source already exists with different letter case: $registeredKey"); + } + } $record = [ 'version' => $selection['version'], 'source_key' => $selection['source_key'], @@ -66,6 +77,18 @@ public function add(string $version, string $type, ?string $url, bool $allowExte return $record; } + private function validateGitUrl(string $url): void + { + if (!str_ends_with($url, '.git')) + { + throw new InvalidArgumentException('Git URL must point to a repository clone URL ending in .git.'); + } + if (parse_url($url, PHP_URL_USER) !== null || parse_url($url, PHP_URL_PASS) !== null) + { + throw new InvalidArgumentException('Git URLs must not contain credentials. Use SSH or a credential manager so secrets are not stored or logged.'); + } + } + public function ensure(string $version): array { $sources = $this->project->readJson('sources.json', []); @@ -112,7 +135,6 @@ protected function ensureRegisteredSource(array $source): array { $source += [ 'path' => $this->project->sourcePath($source['source_key']), - 'php' => '8.1', ]; if (!file_exists($source['path'] . '/common.php')) { @@ -120,7 +142,7 @@ protected function ensureRegisteredSource(array $source): array $this->fetch($source); } - $source = $this->withInstalledSourceMetadata($source, $source['php']); + $source = $this->withInstalledSourceMetadata($source, $source['php'] ?? null); $sources = $this->project->readJson('sources.json', []); $sources[$source['source_key']] = $source; $this->project->writeJson('sources.json', $sources); @@ -202,9 +224,10 @@ protected function ensureFloating(string $version, array $selection): array { $this->project->deleteTree($actualPath); } - if (!rename($tempSource['path'], $actualPath)) + error_clear_last(); + if (!@rename($tempSource['path'], $actualPath)) { - throw new RuntimeException("Unable to move source into place: $actualPath"); + throw new RuntimeException("Unable to move source into place: $actualPath" . $this->lastFilesystemError()); } } @@ -315,7 +338,11 @@ public function fetch(array $source): void if (is_dir($path)) { - rmdir($path); + error_clear_last(); + if (!@rmdir($path) && is_dir($path)) + { + throw new RuntimeException("Unable to remove empty source directory: $path" . $this->lastFilesystemError()); + } } $command = $this->composerCommand([ @@ -352,9 +379,10 @@ protected function normalizeGitSourceRoot(string $path): void { $this->project->deleteTree($temporaryAppRoot); } - if (!rename($appRoot, $temporaryAppRoot)) + error_clear_last(); + if (!@rename($appRoot, $temporaryAppRoot)) { - throw new RuntimeException("Unable to prepare Git source root: $appRoot"); + throw new RuntimeException("Unable to prepare Git source root: $appRoot" . $this->lastFilesystemError()); } foreach (scandir($path) ?: [] as $item) @@ -380,9 +408,10 @@ protected function normalizeGitSourceRoot(string $path): void { throw new RuntimeException("Unable to normalize Git source. Target already exists: $target"); } - if (!rename($source, $target)) + error_clear_last(); + if (!@rename($source, $target)) { - throw new RuntimeException("Unable to move Git source file into place: $source"); + throw new RuntimeException("Unable to move Git source file into place: $source" . $this->lastFilesystemError()); } } @@ -395,19 +424,41 @@ protected function hasFiles(string $path): bool return $files !== false && count(array_diff($files, ['.', '..'])) > 0; } + private function lastFilesystemError(): string + { + $error = error_get_last(); + $message = is_array($error) ? trim((string) ($error['message'] ?? '')) : ''; + return $message !== '' ? ": $message" : ''; + } + protected function installedPhpbbVersion(string $path): string { - $phpbbCli = $path . '/install/phpbbcli.php'; - if (is_file($phpbbCli) && preg_match("/define\\('PHPBB_VERSION',\\s*'([^']+)'\\)/", (string) file_get_contents($phpbbCli), $matches)) + $version = $this->detectedPhpbbVersion($path); + if ($version !== null) { - return $matches[1]; + return $version; } throw new RuntimeException("Unable to determine phpBB version from source: $path"); } - protected function withInstalledSourceMetadata(array $source, string $defaultPhp): array + protected function withInstalledSourceMetadata(array $source, ?string $defaultPhp): array { + $detectedVersion = $this->detectedPhpbbVersion($source['path'] ?? ''); + if ($detectedVersion !== null) + { + $source = $this->withDetectedPhpbbMetadata($source, $detectedVersion); + $defaultPhp = $source['php'] ?? null; + if (($source['type'] ?? '') === 'git' && ($defaultPhp === null || $defaultPhp === '')) + { + throw new RuntimeException("Unable to determine PHP runtime from phpBB version for Git source: {$source['path']}"); + } + } + else if (($source['type'] ?? '') === 'git') + { + throw new RuntimeException("Unable to determine phpBB version from Git source: {$source['path']}"); + } + $requirement = $this->phpRequirement($source['path'] ?? ''); if ($requirement !== null) { @@ -418,6 +469,40 @@ protected function withInstalledSourceMetadata(array $source, string $defaultPhp return $source; } + protected function detectedPhpbbVersion(string $path): ?string + { + $phpbbCli = $path . '/install/phpbbcli.php'; + if (is_file($phpbbCli) && preg_match("/define\\('PHPBB_VERSION',\\s*'([^']+)'\\)/", (string) file_get_contents($phpbbCli), $matches)) + { + return $matches[1]; + } + + return null; + } + + protected function withDetectedPhpbbMetadata(array $source, string $detectedVersion): array + { + $source['detected_phpbb_version'] = $detectedVersion; + if (!preg_match('/^(\d+\.\d+\.\d+)/', $detectedVersion, $matches)) + { + return $source; + } + + try + { + $selection = (new VersionMatrix())->resolve($matches[1]); + } + catch (InvalidArgumentException $e) + { + return $source; + } + + $source['phpbb_branch'] = $selection['phpbb_branch']; + $source['php'] = $selection['php']; + + return $source; + } + protected function phpRequirement(string $path): ?string { $composer = $path . '/composer.json'; @@ -435,9 +520,13 @@ protected function phpRequirement(string $path): ?string return $data['require']['php']; } - protected function runtimeForRequirement(string $defaultPhp, string $requirement): string + protected function runtimeForRequirement(?string $defaultPhp, string $requirement): ?string { $minimum = $this->minimumPhpFromRequirement($requirement); + if ($defaultPhp === null || $defaultPhp === '') + { + return $minimum; + } if ($minimum === null || version_compare($defaultPhp, $minimum, '>=')) { return $defaultPhp; @@ -448,7 +537,7 @@ protected function runtimeForRequirement(string $defaultPhp, string $requirement protected function minimumPhpFromRequirement(string $requirement): ?string { - if (!preg_match_all('/(?=|>|<=|<|!=|=|==|\\^|~)\\s*)?([0-9]+\\.[0-9]+)(?:\\.[0-9]+)?(?![0-9])/', $requirement, $matches, PREG_SET_ORDER)) + if (!preg_match_all('/(?=|>|<=|<|!=|=|==|\\^|~)\\s*)?(\d+\\.\d+)(?:\\.\d+)?(?!\d)/', $requirement, $matches, PREG_SET_ORDER)) { return null; } @@ -533,9 +622,10 @@ protected function latestComposerVersion(string $constraint): ?string protected function composerCommand(array $arguments): array { - if ($this->isCommandAvailable('composer')) + $composer = $this->findCommand('composer'); + if ($composer !== null) { - return array_merge(['composer'], $arguments); + return array_merge([$composer], $arguments); } $phar = $this->project->rootPath('composer.phar'); @@ -549,16 +639,42 @@ protected function composerCommand(array $arguments): array protected function isCommandAvailable(string $command): bool { + return $this->findCommand($command) !== null; + } + + protected function findCommand(string $command): ?string + { + $extensions = ['']; + if (PHP_OS_FAMILY === 'Windows') + { + $extensions = array_merge($extensions, array_filter(array_map('strtolower', explode(';', (string) getenv('PATHEXT'))))); + if (!in_array('.bat', $extensions, true)) + { + $extensions[] = '.bat'; + } + if (!in_array('.cmd', $extensions, true)) + { + $extensions[] = '.cmd'; + } + if (!in_array('.exe', $extensions, true)) + { + $extensions[] = '.exe'; + } + } + foreach (explode(PATH_SEPARATOR, (string) getenv('PATH')) as $path) { - $candidate = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $command; - if (is_file($candidate) && is_executable($candidate)) + foreach ($extensions as $extension) { - return true; + $candidate = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $command . $extension; + if (is_file($candidate) && (PHP_OS_FAMILY === 'Windows' || is_executable($candidate))) + { + return $candidate; + } } } - return false; + return null; } protected function run(array $command, string $cwd): void diff --git a/src/QuickInstall/Sandbox/SourceService.php b/src/QuickInstall/Sandbox/SourceService.php index ad4220de..f68ca255 100644 --- a/src/QuickInstall/Sandbox/SourceService.php +++ b/src/QuickInstall/Sandbox/SourceService.php @@ -11,6 +11,7 @@ namespace QuickInstall\Sandbox; use InvalidArgumentException; +use RuntimeException; class SourceService { @@ -42,15 +43,57 @@ 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, $this->output); + $provider = $this->createSourceProvider(); if ($git) { - $provider->add($version, 'git', $url, $allowExternal); + $previousSources = $this->project->readJson('sources.json', []); + $source = $provider->add($version, 'git', $url, $allowExternal); + try + { + return $provider->ensure($source['source_key']); + } + catch (RuntimeException|InvalidArgumentException $e) + { + $this->rollbackFailedGitFetch($source, $previousSources); + throw $e; + } } return $provider->ensure($version); } + private function rollbackFailedGitFetch(array $source, array $previousSources): void + { + $sourceKey = (string) ($source['source_key'] ?? ''); + if ($sourceKey === '') + { + return; + } + + $sources = $this->project->readJson('sources.json', []); + if (isset($previousSources[$sourceKey])) + { + $sources[$sourceKey] = $previousSources[$sourceKey]; + } + else + { + unset($sources[$sourceKey]); + } + $this->project->writeJson('sources.json', $sources); + + $path = (string) ($source['path'] ?? ''); + $previousPath = (string) ($previousSources[$sourceKey]['path'] ?? ''); + if ($path !== '' && $path !== $previousPath && (file_exists($path) || is_link($path))) + { + $this->project->deleteTree($path); + } + } + + protected function createSourceProvider(): SourceProvider + { + return new SourceProvider($this->project, $this->output); + } + public function supportedVersions(): array { return (new VersionMatrix())->list(); diff --git a/src/QuickInstall/Sandbox/StreamOutput.php b/src/QuickInstall/Sandbox/StreamOutput.php index 3af7c7cb..b231ec89 100644 --- a/src/QuickInstall/Sandbox/StreamOutput.php +++ b/src/QuickInstall/Sandbox/StreamOutput.php @@ -17,8 +17,8 @@ class StreamOutput implements Output 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')); + $this->stdout = $stdout ?: (defined('STDOUT') ? STDOUT : fopen('php://output', 'wb')); + $this->stderr = $stderr ?: (defined('STDERR') ? STDERR : fopen('php://stderr', 'wb')); } public function write(string $message): void diff --git a/src/QuickInstall/Sandbox/StyleManager.php b/src/QuickInstall/Sandbox/StyleManager.php index f2e14ad3..41e7727e 100644 --- a/src/QuickInstall/Sandbox/StyleManager.php +++ b/src/QuickInstall/Sandbox/StyleManager.php @@ -34,6 +34,13 @@ public function mount(string $board, string $source, bool $copy = false, bool $a $name = $this->styleName($sourcePath); $target = $this->project->boardPath($board) . '/styles/' . $name; $styles = $boardConfig['styles'] ?? []; + foreach (array_keys($styles) as $mountedName) + { + if ($this->project->namesEqual((string) $mountedName, $name) && (string) $mountedName !== $name) + { + throw new InvalidArgumentException("Style is already mounted with different letter case: $mountedName"); + } + } if (file_exists($target) || is_link($target)) { @@ -180,7 +187,7 @@ private function discoverStyles(string $path, array &$found): void { if ($this->isStylePath($path)) { - $found[] = $path; + $found[] = realpath($path) ?: $path; return; } diff --git a/src/QuickInstall/Sandbox/UiServerService.php b/src/QuickInstall/Sandbox/UiServerService.php new file mode 100644 index 00000000..08e52838 --- /dev/null +++ b/src/QuickInstall/Sandbox/UiServerService.php @@ -0,0 +1,410 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +use RuntimeException; + +class UiServerService +{ + private Project $project; + private ProcessRunner $processRunner; + + public function __construct(Project $project, ?ProcessRunner $processRunner = null) + { + $this->project = $project; + $this->processRunner = $processRunner ?: new ProcessRunner(new BufferedOutput()); + } + + public function start(string $host, int $port): array + { + $state = $this->readState(); + if ($this->isStateRunning($state)) + { + return ['status' => 'already_running', 'state' => $state]; + } + if ($this->isPortOpen($host, $port)) + { + throw new RuntimeException("Port $port is already in use on $host. Run qi ui:status for tracked UI details, or choose another port with --port."); + } + + $this->project->init(); + $router = dirname(__DIR__, 3) . '/public/sandbox-ui.php'; + $url = 'http://' . $this->urlHost($host) . ':' . $port . '/'; + $command = [PHP_BINARY, '-S', $this->serverAddress($host, $port), $router]; + $logPath = $this->logPath(); + $this->resetLog($logPath); + $pid = $this->startDetachedProcess($command, dirname(__DIR__, 3), $logPath); + $state = [ + 'pid' => $pid, + 'host' => $host, + 'port' => $port, + 'url' => $url, + 'log' => $logPath, + 'error_log' => PHP_OS_FAMILY === 'Windows' ? $logPath . '.err' : $logPath, + 'router' => $router, + 'started_at' => gmdate('c'), + ]; + try + { + $this->waitForStart($state); + } + catch (RuntimeException $e) + { + $this->terminateProcess($pid); + throw $e; + } + $this->writeState($state); + + return ['status' => 'started', 'state' => $state]; + } + + public function stop(): array + { + $state = $this->readState(); + if (!$state) + { + return ['status' => 'not_tracked', 'state' => []]; + } + + $pid = (int) ($state['pid'] ?? 0); + if ($pid > 0 && $this->processMatchesState($pid, $state) && $this->terminateProcess($pid)) + { + $this->waitForStop($state); + $this->deleteState(); + return ['status' => 'stopped', 'state' => $state]; + } + + $this->deleteState(); + return ['status' => 'not_running', 'state' => $state]; + } + + public function restart(string $host, int $port): array + { + $stopped = $this->stop(); + $started = $this->start($host, $port); + + return ['stop' => $stopped, 'start' => $started]; + } + + public function status(): array + { + $state = $this->readState(); + if ($this->isStateRunning($state)) + { + return ['status' => 'running', 'state' => $state]; + } + if ($state) + { + return ['status' => 'stale', 'state' => $state]; + } + + return ['status' => 'not_running', 'state' => []]; + } + + private function startDetachedProcess(array $command, string $cwd, string $logPath): int + { + if (PHP_OS_FAMILY === 'Windows') + { + return $this->startWindowsDetachedProcess($command, $cwd, $logPath); + } + + return $this->startUnixDetachedProcess($command, $cwd, $logPath); + } + + private function startUnixDetachedProcess(array $command, string $cwd, string $logPath): int + { + $pidFile = $this->project->workspacePath('runtime/ui.pid'); + $shellCommand = 'cd ' . escapeshellarg($cwd) + . ' && (nohup ' . implode(' ', array_map('escapeshellarg', $command)) + . ' >> ' . escapeshellarg($logPath) . ' 2>&1 < /dev/null & echo $! > ' . escapeshellarg($pidFile) . ')'; + $result = $this->captureProcess(['/bin/sh', '-c', $shellCommand], $cwd); + $pid = is_file($pidFile) ? (int) trim((string) file_get_contents($pidFile)) : 0; + if (is_file($pidFile)) + { + unlink($pidFile); + } + if ($result['exit_code'] !== 0 || $pid <= 0) + { + throw new RuntimeException('Unable to start QuickInstall Dashboard UI server.'); + } + + return $pid; + } + + private function startWindowsDetachedProcess(array $command, string $cwd, string $logPath): int + { + $arguments = array_slice($command, 1); + $arguments = array_map([$this, 'quoteWindowsProcessArgument'], $arguments); + $argumentList = '@(' . implode(',', array_map([$this, 'powerShellString'], $arguments)) . ')'; + $errorLog = $logPath . '.err'; + $script = '$p = Start-Process -FilePath ' . $this->powerShellString($command[0]) + . ' -ArgumentList ' . $argumentList + . ' -WorkingDirectory ' . $this->powerShellString($cwd) + . ' -RedirectStandardOutput ' . $this->powerShellString($logPath) + . ' -RedirectStandardError ' . $this->powerShellString($errorLog) + . ' -WindowStyle Hidden -PassThru; ' + . 'Write-Output $p.Id'; + $result = $this->captureProcess(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $script], $cwd); + $pid = (int) trim($result['output']); + if ($result['exit_code'] !== 0 || $pid <= 0) + { + throw new RuntimeException('Unable to start QuickInstall Dashboard UI server. PowerShell is required on Windows.'); + } + + return $pid; + } + + private function quoteWindowsProcessArgument(string $value): string + { + // Start-Process flattens ArgumentList into one command line. Preserve each + // argument, including router paths containing spaces, with CRT-style quoting. + $value = preg_replace('/(\\\\*)"/', '$1$1\\"', $value) ?? $value; + $value = preg_replace('/(\\\\+)$/', '$1$1', $value) ?? $value; + return '"' . $value . '"'; + } + + private function captureProcess(array $command, string $cwd): array + { + $this->project->init(); + $runtimeDir = $this->project->workspacePath('runtime'); + $stdoutPath = tempnam($runtimeDir, 'process-out-'); + $stderrPath = tempnam($runtimeDir, 'process-err-'); + if ($stdoutPath === false || $stderrPath === false) + { + throw new RuntimeException('Unable to create temporary UI process output files.'); + } + + $descriptor = [ + 0 => ['file', PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'r'], + 1 => ['file', $stdoutPath, 'w'], + 2 => ['file', $stderrPath, 'w'], + ]; + $process = @proc_open($command, $descriptor, $pipes, $cwd); + if (!is_resource($process)) + { + @unlink($stdoutPath); + @unlink($stderrPath); + return ['exit_code' => 1, 'output' => '']; + } + + $exitCode = proc_close($process); + $output = (string) @file_get_contents($stdoutPath) . (string) @file_get_contents($stderrPath); + @unlink($stdoutPath); + @unlink($stderrPath); + + return ['exit_code' => $exitCode, 'output' => $output]; + } + + private function powerShellString(string $value): string + { + return "'" . str_replace("'", "''", $value) . "'"; + } + + private function readState(): array + { + return $this->project->readJson('runtime/ui.json', []); + } + + private function writeState(array $state): void + { + $this->project->writeJson('runtime/ui.json', $state); + } + + private function deleteState(): void + { + $path = $this->project->workspacePath('runtime/ui.json'); + if (is_file($path)) + { + unlink($path); + } + } + + private function logPath(): string + { + $this->project->init(); + return $this->project->workspacePath('runtime/ui.log'); + } + + private function resetLog(string $logPath): void + { + // Keep only current UI server session output. + if (file_put_contents($logPath, '') === false) + { + throw new RuntimeException("Unable to reset UI log: $logPath"); + } + $errorLog = $logPath . '.err'; + if (PHP_OS_FAMILY === 'Windows' && file_put_contents($errorLog, '') === false) + { + throw new RuntimeException("Unable to reset UI error log: $errorLog"); + } + } + + private function isStateRunning(array $state): bool + { + if (!$state) + { + return false; + } + + $pid = (int) ($state['pid'] ?? 0); + if ($pid <= 0 || !$this->processMatchesState($pid, $state)) + { + return false; + } + + return $this->isPortOpen((string) ($state['host'] ?? '127.0.0.1'), (int) ($state['port'] ?? 0)); + } + + private function isPortOpen(string $host, int $port): bool + { + if ($port < 1) + { + return false; + } + $socket = @fsockopen($host, $port, $errno, $errstr, 0.2); + if (!is_resource($socket)) + { + return false; + } + + fclose($socket); + return true; + } + + private function terminateProcess(int $pid): bool + { + if ($pid <= 0) + { + return false; + } + if (PHP_OS_FAMILY === 'Windows') + { + $result = $this->processRunner->capture(['taskkill', '/PID', (string) $pid, '/T', '/F']); + return $result['exit_code'] === 0; + } + if (function_exists('posix_kill') && @posix_kill($pid, 0)) + { + return @posix_kill($pid, 15); + } + + $result = $this->processRunner->capture(['kill', (string) $pid]); + return $result['exit_code'] === 0; + } + + private function isProcessRunning(int $pid): bool + { + if ($pid <= 0) + { + return false; + } + if (PHP_OS_FAMILY === 'Windows') + { + $result = $this->processRunner->capture(['tasklist', '/FI', 'PID eq ' . $pid, '/NH']); + return $result['exit_code'] === 0 && str_contains($result['output'], (string) $pid); + } + if (function_exists('posix_kill')) + { + return @posix_kill($pid, 0); + } + + $result = $this->processRunner->capture(['kill', '-0', (string) $pid]); + return $result['exit_code'] === 0; + } + + private function processMatchesState(int $pid, array $state): bool + { + if (!$this->isProcessRunning($pid)) + { + return false; + } + + $router = (string) ($state['router'] ?? dirname(__DIR__, 3) . '/public/sandbox-ui.php'); + if (PHP_OS_FAMILY === 'Windows') + { + $script = '(Get-CimInstance Win32_Process -Filter ' . $this->powerShellString('ProcessId = ' . $pid) . ').CommandLine'; + $result = $this->processRunner->capture(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $script]); + } + else + { + $result = $this->processRunner->capture(['ps', '-p', (string) $pid, '-o', 'command=']); + } + + if ($result['exit_code'] !== 0) + { + return false; + } + + return $this->commandMatchesRouter((string) $result['output'], $router); + } + + private function commandMatchesRouter(string $command, string $router): bool + { + $command = $this->comparableCommand($command); + $router = $this->comparableCommand($router); + return $command !== '' + && $router !== '' + && str_contains($command, $router) + && (bool) preg_match('/(?:^|\s)["\']?-S["\']?(?=\s|$)/i', $command); + } + + private function comparableCommand(string $value): string + { + $value = str_replace('\\', '/', trim($value)); + return PHP_OS_FAMILY === 'Windows' ? strtolower($value) : $value; + } + + private function serverAddress(string $host, int $port): string + { + return ($host === '::1' ? '[::1]' : $host) . ':' . $port; + } + + private function urlHost(string $host): string + { + return $host === '::1' ? '[::1]' : $host; + } + + private function waitForStart(array $state): void + { + $host = (string) ($state['host'] ?? '127.0.0.1'); + $port = (int) ($state['port'] ?? 0); + $deadline = microtime(true) + 3; + while (microtime(true) < $deadline) + { + if ($this->isPortOpen($host, $port)) + { + return; + } + usleep(100000); + } + + $logs = (string) ($state['log'] ?? '(unknown)'); + if (!empty($state['error_log']) && $state['error_log'] !== $state['log']) + { + $logs .= ' and ' . $state['error_log']; + } + throw new RuntimeException('QuickInstall Dashboard UI server did not become available. Check the Dashboard UI logs for details: ' . $logs); + } + + private function waitForStop(array $state): void + { + $host = (string) ($state['host'] ?? '127.0.0.1'); + $port = (int) ($state['port'] ?? 0); + $deadline = microtime(true) + 3; + while (microtime(true) < $deadline) + { + if (!$this->isPortOpen($host, $port)) + { + return; + } + usleep(100000); + } + } +} diff --git a/src/QuickInstall/Sandbox/UpdateService.php b/src/QuickInstall/Sandbox/UpdateService.php new file mode 100644 index 00000000..5ba18b9b --- /dev/null +++ b/src/QuickInstall/Sandbox/UpdateService.php @@ -0,0 +1,225 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +use RuntimeException; +use Throwable; + +class UpdateService +{ + private const ENDPOINT = 'https://www.phpbb.com/customise/db/official_tool/phpbb3_quickinstall/version_check'; + private const CACHE_FILE = 'cache/update-check.json'; + private const SUCCESS_TTL = 86400; + private const ERROR_TTL = 3600; + private const TIMEOUT = 3; + + private Project $project; + private ?string $endpoint; + private ?string $currentVersion = null; + + public function __construct(Project $project, ?string $endpoint = null) + { + $this->project = $project; + $this->endpoint = $endpoint ?: self::ENDPOINT; + } + + public function getUpdate(): ?array + { + if (!is_dir($this->project->workspacePath())) + { + return null; + } + + try + { + // Use fresh cache before touching network. + $cached = $this->cachedUpdate(); + if ($cached !== false) + { + return $cached; + } + + // Fetch legacy version metadata, then cache normalized result. + $data = $this->fetchVersionData(); + $update = $this->latestUpdate($data); + $this->writeCache([ + 'checked_at' => time(), + 'current_version' => $this->currentVersion(), + 'update' => $update, + 'error' => null, + ]); + + return $update; + } + catch (Throwable $e) + { + // Cache failures briefly to avoid repeated slow checks. + try + { + $this->writeCache([ + 'checked_at' => time(), + 'current_version' => $this->safeCurrentVersion(), + 'update' => null, + 'error' => $e->getMessage(), + ]); + } + catch (Throwable $cacheError) + { + // Update checks and their cache are always best-effort. + } + return null; + } + } + + public function currentVersion(): string + { + if ($this->currentVersion !== null) + { + return $this->currentVersion; + } + + $composerPath = $this->project->rootPath('composer.json'); + if (!is_file($composerPath)) + { + $composerPath = dirname(__DIR__, 3) . '/composer.json'; + } + + $data = json_decode((string) file_get_contents($composerPath), true); + if (!is_array($data) || empty($data['version'])) + { + throw new RuntimeException('Unable to read QuickInstall version.'); + } + + $this->currentVersion = (string) $data['version']; + + return $this->currentVersion; + } + + private function safeCurrentVersion(): string + { + try + { + return $this->currentVersion(); + } + catch (RuntimeException $e) + { + return ''; + } + } + + private function cachedUpdate(): array|bool|null + { + $cache = $this->readCache(); + if (!$cache) + { + return false; + } + if (($cache['current_version'] ?? '') !== $this->currentVersion()) + { + return false; + } + + // Expire failures sooner so transient network problems recover. + $checkedAt = (int) ($cache['checked_at'] ?? 0); + $ttl = empty($cache['error']) ? self::SUCCESS_TTL : self::ERROR_TTL; + if ($checkedAt <= 0 || $checkedAt + $ttl < time()) + { + return false; + } + + return is_array($cache['update'] ?? null) ? $this->sanitizeUpdate($cache['update']) : null; + } + + private function fetchVersionData(): array + { + // Keep UI and CLI responsive if phpBB.com is unavailable. + $context = stream_context_create([ + 'http' => [ + 'timeout' => self::TIMEOUT, + 'ignore_errors' => true, + ], + ]); + $json = @file_get_contents($this->endpoint, false, $context); + if ($json === false || $json === '') + { + throw new RuntimeException('VERSIONCHECK_FAIL'); + } + + $data = json_decode($json, true); + if (!is_array($data) || empty($data['stable']) || !is_array($data['stable'])) + { + throw new RuntimeException('VERSIONCHECK_FAIL'); + } + + return $data; + } + + private function latestUpdate(array $data): ?array + { + $currentVersion = $this->currentVersion(); + $updates = []; + + // Match legacy stable-only behavior. + foreach ($data['stable'] as $update) + { + if (!is_array($update) || empty($update['current'])) + { + continue; + } + if (version_compare((string) $update['current'], $currentVersion, '>')) + { + $updates[] = $this->sanitizeUpdate([ + 'current' => (string) $update['current'], + 'download' => (string) ($update['download'] ?? ''), + ]); + } + } + + return $updates ? array_pop($updates) : null; + } + + private function sanitizeUpdate(array $update): array + { + $download = (string) ($update['download'] ?? ''); + if ($download !== '' && (filter_var($download, FILTER_VALIDATE_URL) === false || strtolower((string) parse_url($download, PHP_URL_SCHEME)) !== 'https')) + { + $download = ''; + } + + return [ + 'current' => (string) ($update['current'] ?? ''), + 'download' => $download, + ]; + } + + private function readCache(): ?array + { + $data = $this->project->readJson(self::CACHE_FILE, []); + return $data ?: null; + } + + private function writeCache(array $data): void + { + if (!is_dir($this->project->workspacePath())) + { + return; + } + + // Existing workspaces may predate .qi/cache. + $cacheDir = dirname($this->project->workspacePath(self::CACHE_FILE)); + if (!is_dir($cacheDir) && !mkdir($cacheDir, 0775, true) && !is_dir($cacheDir)) + { + throw new RuntimeException("Unable to create update cache directory: $cacheDir"); + } + + $this->project->writeJson(self::CACHE_FILE, $data); + } +} diff --git a/src/QuickInstall/Sandbox/VersionMatrix.php b/src/QuickInstall/Sandbox/VersionMatrix.php index 32efffd2..db8260d5 100644 --- a/src/QuickInstall/Sandbox/VersionMatrix.php +++ b/src/QuickInstall/Sandbox/VersionMatrix.php @@ -18,7 +18,7 @@ public function list(): array { return [ ['selector' => 'latest', 'resolves_to' => '3.3.*', 'status' => 'supported', 'php' => '8.1', 'notes' => 'Default supported stable line'], - ['selector' => '3.3 / 3.3.x', 'resolves_to' => '3.3.* or exact tag', 'status' => 'supported', 'php' => '8.1', 'notes' => 'Recommended'], + ['selector' => '3.3 / 3.3.x', 'resolves_to' => '3.3.* or exact tag', 'status' => 'supported', 'php' => '8.1', 'notes' => 'Recommended; exact 3.3.0-3.3.4 use PHP 7.4'], ['selector' => '3.2 / 3.2.x', 'resolves_to' => '3.2.* or exact tag', 'status' => 'supported', 'php' => '7.1', 'notes' => 'Legacy-modern'], ['selector' => '4.0.x / master', 'resolves_to' => 'exact tag or dev-master', 'status' => 'experimental', 'php' => '8.2', 'notes' => 'Installer may change upstream'], ['selector' => '3.0.x / 3.1.x', 'resolves_to' => '-', 'status' => 'unsupported', 'php' => '-', 'notes' => 'Use legacy web app'], @@ -42,11 +42,11 @@ public function resolve(string $requested, bool $git = false): array { return [ 'version' => $requested, - 'source_key' => $this->sourceKey($requested), + 'source_key' => preg_replace('/[^A-Za-z0-9._-]/', '-', $requested), 'constraint' => null, 'branch' => $requested, 'phpbb_branch' => preg_match('/^4\.|^(master|main)$/', $requested) ? '4.0' : 'custom', - 'php' => preg_match('/^4\.|^(master|main)$/', $requested) ? '8.2' : '8.1', + 'php' => preg_match('/^4\.|^(master|main)$/', $requested) ? '8.2' : null, 'status' => 'experimental', ]; } @@ -111,7 +111,7 @@ public function resolve(string $requested, bool $git = false): array 'constraint' => $requested, 'branch' => '3.3', 'phpbb_branch' => '3.3', - 'php' => '8.1', + 'php' => version_compare($requested, '3.3.5', '<') ? '7.4' : '8.1', 'status' => 'supported', ]; } @@ -144,9 +144,4 @@ public function resolve(string $requested, bool $git = false): array throw new InvalidArgumentException("Unsupported phpBB selector: $requested. Use latest, 3.3, 3.3.x, 3.2, 3.2.x, 4.0.x, or master."); } - - private function sourceKey(string $value): string - { - return preg_replace('/[^A-Za-z0-9._-]/', '-', $value); - } } diff --git a/src/QuickInstall/Sandbox/Web/Application.php b/src/QuickInstall/Sandbox/Web/Application.php new file mode 100644 index 00000000..29f08a58 --- /dev/null +++ b/src/QuickInstall/Sandbox/Web/Application.php @@ -0,0 +1,596 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox\Web; + +use ErrorException; +use InvalidArgumentException; +use QuickInstall\Sandbox\BoardRefreshService; +use QuickInstall\Sandbox\BoardService; +use QuickInstall\Sandbox\BufferedOutput; +use QuickInstall\Sandbox\CustomisationMountService; +use QuickInstall\Sandbox\ExtensionManager; +use QuickInstall\Sandbox\Project; +use QuickInstall\Sandbox\SourceService; +use QuickInstall\Sandbox\StyleManager; +use QuickInstall\Sandbox\UpdateService; +use RuntimeException; +use Throwable; + +class Application +{ + private Project $project; + private BufferedOutput $output; + private string $notice = ''; + private string $error = ''; + private static string $cliCsrfToken = ''; + + public function __construct(string $root) + { + $this->project = new Project($root); + $this->output = new BufferedOutput(); + } + + public function run(): void + { + $this->assertLocalRequest(); + $csrfToken = $this->csrfToken(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') + { + $this->assertTrustedPost(); + $this->assertCsrfToken($csrfToken); + if ($this->isAjax()) + { + $this->handleAjaxPost(); + return; + } + $this->handlePost(); + } + + $this->notice = $this->notice ?: (string) ($_GET['notice'] ?? ''); + $this->error = $this->error ?: (string) ($_GET['error'] ?? ''); + $this->render(); + } + + private function handleAjaxPost(): void + { + $previousDisplayErrors = ini_set('display_errors', '0'); + ob_start(); + set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { + if (!(error_reporting() & $severity)) + { + return false; + } + + throw new ErrorException($message, 0, $severity, $file, $line); + }); + + try + { + $this->handlePost(); + } + catch (Throwable $e) + { + $this->error = $this->friendlyError($e->getMessage()); + } + finally + { + restore_error_handler(); + $unexpectedOutput = trim((string) ob_get_clean()); + if ($previousDisplayErrors !== false) + { + ini_set('display_errors', $previousDisplayErrors); + } + if ($unexpectedOutput !== '') + { + $this->output->error(trim(strip_tags($unexpectedOutput)) . "\n"); + } + } + + $this->renderJson(); + } + + private function assertLocalRequest(): void + { + $remote = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; + if (!in_array($remote, ['127.0.0.1', '::1'], true)) + { + http_response_code(403); + echo 'QuickInstall UI is local-only.'; + exit; + } + } + + private function assertTrustedPost(): void + { + foreach (['HTTP_ORIGIN', 'HTTP_REFERER'] as $header) + { + $value = (string) ($_SERVER[$header] ?? ''); + if ($value === '') + { + continue; + } + + $host = parse_url($value, PHP_URL_HOST); + if (!is_string($host) || !in_array(strtolower($host), ['127.0.0.1', 'localhost', '::1'], true)) + { + http_response_code(403); + echo 'QuickInstall UI only accepts local form submissions.'; + exit; + } + } + } + + private function assertCsrfToken(string $token): void + { + $provided = (string) ($_POST['qi_csrf_token'] ?? ''); + if ($provided === '' || !hash_equals($token, $provided)) + { + http_response_code(403); + echo 'QuickInstall UI form token is missing or invalid. Refresh the page and try again.'; + exit; + } + } + + private function csrfToken(): string + { + if (PHP_SAPI === 'cli' && session_status() === PHP_SESSION_NONE && headers_sent()) + { + if (self::$cliCsrfToken === '') + { + self::$cliCsrfToken = bin2hex(random_bytes(24)); + } + + return self::$cliCsrfToken; + } + + if (session_status() === PHP_SESSION_NONE) + { + session_start(); + } + + if (empty($_SESSION['qi_csrf_token']) || !is_string($_SESSION['qi_csrf_token'])) + { + $_SESSION['qi_csrf_token'] = bin2hex(random_bytes(24)); + } + + return $_SESSION['qi_csrf_token']; + } + + private function handlePost(): void + { + $operationLock = null; + try + { + $operationLock = $this->project->lockOperations(); + $this->disableExecutionTimeLimit(); + $action = (string) ($_POST['action'] ?? ''); + switch ($action) + { + case 'init': + $created = $this->project->init(); + $this->notice = $created ? 'Workspace initialized.' : 'Workspace already initialized.'; + 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')); + $this->notice = "Fetched source: $version"; + break; + + case 'source_remove': + $source = $this->required('source'); + $removed = (new SourceService($this->project))->remove($source, $this->checked('force')); + $this->notice = "Removed source: {$removed['source']['source_key']}"; + if (!empty($removed['used_by'])) + { + $this->notice .= ' (was referenced by ' . implode(', ', $removed['used_by']) . ')'; + } + break; + + case 'board_create': + $name = $this->required('name'); + $version = $this->optional('phpbb') ?: 'latest'; + $db = $this->optional('db') ?: 'mariadb'; + $db = $db === 'sqlite3' ? 'sqlite' : $db; + $port = (int) ($this->optional('port') ?: '8080'); + $populate = $this->optional('populate') ?: 'none'; + $this->validateBoardCreateOptions($db, $port, $populate); + (new BoardService($this->project, $this->output))->create($name, $version, $db, $port, $populate, $this->checked('debug'), $this->checked('replace')); + $this->notice = "Created board: $name"; + break; + + case 'board_start': + $name = $this->required('name'); + (new BoardService($this->project, $this->output))->start($name); + $this->notice = "Started board: $name"; + break; + + case 'board_stop': + $name = $this->required('name'); + (new BoardService($this->project, $this->output))->stop($name); + $this->notice = "Stopped board: $name"; + break; + + case 'board_destroy': + $name = $this->required('name'); + (new BoardService($this->project, $this->output))->destroy($name); + $this->notice = "Destroyed board: $name"; + break; + + case 'board_seed': + $name = $this->required('name'); + $preset = $this->optional('preset') ?: 'extension-dev'; + $seed = (int) ($this->optional('seed') ?: '1'); + $this->validatePreset($preset); + if ($seed < 1) + { + throw new InvalidArgumentException('Seed must be a positive integer.'); + } + (new BoardService($this->project, $this->output))->seed($name, $preset, $seed, $this->optional('seed_action') ?: 'seed'); + $this->notice = "Seed action completed for board: $name"; + break; + + case 'ext_mount': + $this->mountCustomisation('extension', new ExtensionManager($this->project)); + break; + + case 'ext_unmount': + $board = $this->required('board'); + $name = $this->required('name'); + $extensions = new ExtensionManager($this->project); + $extensions->unmount($board, $name); + (new BoardRefreshService($this->project, $this->output))->refreshIfRunning($board); + $extensions->cleanupStaleTarget($board, $name); + $this->notice = "Unmounted extension: $name"; + break; + + case 'style_mount': + $this->mountCustomisation('style', new StyleManager($this->project)); + break; + + case 'style_unmount': + $board = $this->required('board'); + $name = $this->required('name'); + $styles = new StyleManager($this->project); + $styles->unmount($board, $name); + (new BoardRefreshService($this->project, $this->output))->refreshIfRunning($board); + $styles->cleanupStaleTarget($board, $name); + $this->notice = "Unmounted style: $name"; + break; + + default: + throw new InvalidArgumentException('Unknown action.'); + } + } + catch (InvalidArgumentException | RuntimeException $e) + { + $this->error = $this->friendlyError($e->getMessage()); + } + finally + { + $this->project->unlockOperations($operationLock); + } + } + + private function disableExecutionTimeLimit(): void + { + if ((int) ini_get('max_execution_time') === 0) + { + return; + } + + if (function_exists('set_time_limit') && @set_time_limit(0)) + { + return; + } + @ini_set('max_execution_time', '0'); + if ((int) ini_get('max_execution_time') !== 0) + { + throw new RuntimeException('QuickInstall could not disable the PHP execution time limit for this action. Allow set_time_limit or set max_execution_time=0 for the Dashboard UI.'); + } + } + + private function friendlyError(string $message): string + { + if ($this->isDockerConnectivityError($message)) + { + return 'Check that Docker Desktop is running and that the docker command works in this terminal.'; + } + + return $message; + } + + private function isDockerConnectivityError(string $message): bool + { + return (bool) preg_match('/Cannot connect to the Docker daemon|failed to connect to the docker API|daemon is running|docker\.sock|Docker Desktop/i', $message); + } + + private function mountCustomisation(string $type, object $manager): void + { + $board = $this->required('board'); + $source = $this->required('source'); + $copy = $this->checked('copy'); + $recursive = $this->checked('recursive'); + $allowExternal = $this->checked('allow_external'); + if ($copy && $recursive) + { + throw new InvalidArgumentException('Recursive mount cannot be combined with copy mode.'); + } + + $result = (new CustomisationMountService($this->project, $this->output))->mount($manager, $board, $source, $copy, $recursive, $allowExternal); + if ($result['recursive']) + { + $count = count($result['mounted']); + if ($result['errors']) + { + $this->error = "Mounted {$count} {$type}(s) on $board. Skipped " . count($result['errors']) . " {$type}(s): " . implode('; ', $result['errors']); + return; + } + $this->notice = "Mounted {$count} {$type}(s) on $board"; + return; + } + + $mounted = $result['mounted'][0]; + $this->notice = "Mounted {$type}: {$mounted['name']}"; + } + + private function required(string $name): string + { + $value = trim((string) ($_POST[$name] ?? '')); + if ($value === '') + { + throw new InvalidArgumentException("$name is required."); + } + + return $value; + } + + private function optional(string $name): ?string + { + $value = trim((string) ($_POST[$name] ?? '')); + return $value === '' ? null : $value; + } + + private function checked(string $name): bool + { + return !empty($_POST[$name]); + } + + private function validateBoardCreateOptions(string $db, int $port, string $populate): void + { + if (!in_array($db, ['mariadb', 'mysql', 'postgres', 'sqlite'], true)) + { + throw new InvalidArgumentException('DB must be one of: mariadb, mysql, postgres, sqlite.'); + } + if ($port < 1 || $port > 65535) + { + throw new InvalidArgumentException('Port must be between 1 and 65535.'); + } + if ($populate !== 'none') + { + $this->validatePreset($populate); + } + if ($db === 'sqlite' && $populate !== 'none') + { + throw new InvalidArgumentException('SQLite boards support populate none only.'); + } + } + + private function validatePreset(string $preset): void + { + if (!in_array($preset, ['tiny', 'extension-dev', 'load-test', 'random'], true)) + { + throw new InvalidArgumentException('Preset must be one of: tiny, extension-dev, load-test, random.'); + } + } + + private function render(): void + { + if (!headers_sent()) + { + header('Content-Type: text/html; charset=utf-8'); + } + $updates = new UpdateService($this->project); + + echo $this->renderTemplate('layout.php', [ + 'dashboard' => $this->renderTemplate('dashboard.php', $this->viewData($updates)), + 'csrfToken' => $this->csrfToken(), + 'quickInstallVersion' => $updates->currentVersion(), + ]); + } + + private function renderJson(): void + { + $previousDisplayErrors = ini_set('display_errors', '0'); + ob_start(); + set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { + if (!(error_reporting() & $severity)) + { + return false; + } + throw new ErrorException($message, 0, $severity, $file, $line); + }); + + try + { + $html = $this->renderTemplate('dashboard.php', $this->viewData()); + $json = json_encode([ + 'ok' => $this->error === '', + 'notice' => $this->notice, + 'error' => $this->error, + 'output' => $this->output->all(), + 'html' => $html, + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); + } + catch (Throwable $e) + { + $json = json_encode([ + 'ok' => false, + 'notice' => '', + 'error' => $this->friendlyError($e->getMessage()), + 'output' => $this->output->all(), + 'html' => null, + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE) ?: '{"ok":false,"notice":"","error":"Unable to encode QuickInstall response.","output":"","html":null}'; + } + finally + { + restore_error_handler(); + ob_end_clean(); + if ($previousDisplayErrors !== false) + { + ini_set('display_errors', $previousDisplayErrors); + } + } + + if (!headers_sent()) + { + header('Content-Type: application/json; charset=utf-8'); + } + echo $json; + } + + private function viewData(?UpdateService $updates = null): array + { + $updates = $updates ?: new UpdateService($this->project); + $boards = (new BoardService($this->project, $this->output))->list(); + $sources = (new SourceService($this->project))->list(); + $versions = (new SourceService($this->project))->supportedVersions(); + $extensions = new ExtensionManager($this->project); + $styles = new StyleManager($this->project); + + $running = count(array_filter($boards, static function ($board) { + return ($board['status'] ?? '') === 'running'; + })); + $mountedExtensions = 0; + $mountedStyles = 0; + foreach ($boards as $board) + { + $mountedExtensions += count($board['extensions'] ?? []); + $mountedStyles += count($board['styles'] ?? []); + } + + $viewBoards = []; + foreach ($boards as $board) + { + $name = (string) $board['name']; + $board['mounted_extensions'] = $extensions->list($name); + $board['mounted_styles'] = $styles->list($name); + $viewBoards[] = $board; + } + + $viewSources = []; + foreach ($sources as $source) + { + $source['display_path'] = $this->displayPath((string) ($source['path'] ?? '')); + $viewSources[] = $source; + } + + return [ + 'notice' => $this->notice, + 'error' => $this->error, + 'output' => $this->output->all(), + 'csrfToken' => $this->csrfToken(), + 'update' => $updates->getUpdate(), + 'metrics' => [ + ['label' => 'Boards', 'value' => (string) count($boards), 'detail' => $running . ' running', 'description' => 'Runtime definitions'], + ['label' => 'Sources', 'value' => (string) count($sources), 'detail' => count(array_filter($sources, static function ($source) { + return !empty($source['downloaded']); + })) . ' downloaded', 'description' => 'phpBB cache'], + ['label' => 'Extensions', 'value' => (string) $mountedExtensions, 'detail' => 'mounted', 'description' => 'Board mounts'], + ['label' => 'Styles', 'value' => (string) $mountedStyles, 'detail' => 'mounted', 'description' => 'Board mounts'], + ], + 'boards' => $viewBoards, + 'sources' => $viewSources, + 'versionOptions' => $this->versionOptions($versions, $sources), + 'dbOptions' => ['mariadb', 'mysql', 'postgres', 'sqlite'], + 'populateOptions' => ['none', 'tiny', 'extension-dev', 'load-test', 'random'], + 'presetOptions' => ['tiny', 'extension-dev', 'load-test', 'random'], + 'seedActionOptions' => ['seed', 'replace', 'reset'], + ]; + } + + private function versionOptions(array $versions, array $sources = []): array + { + $options = []; + foreach ($versions as $version) + { + if (($version['status'] ?? '') === 'unsupported') + { + continue; + } + + foreach (preg_split('/\s*\/\s*/', (string) ($version['selector'] ?? '')) ?: [] as $selector) + { + $selector = trim($selector); + if ($selector !== '' && !str_contains($selector, ' ')) + { + $options[] = $selector; + } + } + } + + foreach ($sources as $source) + { + $sourceKey = trim((string) ($source['source_key'] ?? '')); + if ($sourceKey !== '') + { + $options[] = $sourceKey; + } + } + + return array_values(array_unique($options)); + } + + private function displayPath(string $path): string + { + if ($path === '') + { + return ''; + } + + $root = rtrim($this->project->rootPath(), '/'); + if ($path === $root) + { + return '/' . basename($root); + } + if (str_starts_with($path, $root . '/')) + { + return '/' . basename($root) . substr($path, strlen($root)); + } + + return $path; + } + + private function renderTemplate(string $template, array $data = []): string + { + $path = __DIR__ . '/templates/' . $template; + if (!is_file($path)) + { + throw new RuntimeException("Missing web template: $template"); + } + + extract($data, EXTR_SKIP); + ob_start(); + require $path; + return (string) ob_get_clean(); + } + + private function escape($value): string + { + return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private function isAjax(): bool + { + return strtolower((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest'; + } +} diff --git a/src/QuickInstall/Sandbox/Web/templates/csrf.php b/src/QuickInstall/Sandbox/Web/templates/csrf.php new file mode 100644 index 00000000..f5dd1eaf --- /dev/null +++ b/src/QuickInstall/Sandbox/Web/templates/csrf.php @@ -0,0 +1,9 @@ + + + + diff --git a/src/QuickInstall/Sandbox/Web/templates/dashboard.php b/src/QuickInstall/Sandbox/Web/templates/dashboard.php new file mode 100644 index 00000000..4be60f50 --- /dev/null +++ b/src/QuickInstall/Sandbox/Web/templates/dashboard.php @@ -0,0 +1,270 @@ + + +
+
+ QuickInstall escape($update['current']) ?> available + Download the latest release from phpBB. +
+
+ + Download + + +
+
+ + +
+ +
+ escape($metric['label']) ?> + escape($metric['value']) ?> + escape($metric['detail']) ?> + escape($metric['description']) ?> +
+ +
+ + +
+ +

escape($notice) ?>

+ + +

escape($error) ?>

+ +
+ + +
+
+
+

Boards

+

Start, stop, seed, and inspect local Docker-backed phpBB installs.

+
+
+ +
+ No boards created + Create a board below to generate its compose runtime and workspace files. +
+ +
+ + +
+
+
+

escape($name) ?>

+

+ + escape($url) ?> + + escape($url) ?> + +

+
+ escape($status) ?> +
+
+
phpBB
escape($board['phpbb'] ?? '') ?>
+
PHP
escape($board['php'] ?? '') ?>
+
Database
escape($board['db'] ?? '') ?>
+
Populate
escape($board['populate'] ?? 'none') ?>
+
+
+ Open +
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
Seed content
+
+ + + + + + + +
+
+ +
+

escape($mountGroup[0]) ?>

+ +

None mounted

+ + +
+ + escape($item['name']) ?>escape($item['mode']) ?> + + + + +
+ +
+ +
+
+ +
+
+ +
+
+
+

Create board

+

Choose the phpBB source, runtime, port, and optional seed preset.

+
+
+
+ + + + + + + + + + +
+
+
+ +
+
+
+

Customisations

+

Bind or copy local extension and style work into a board.

+
+
+
+ +
+ +

escape($mountForm[1]) ?>

+ + + + + + +
+
+ +
+
+ +
+
+
+

Sources

+

Fetch and inspect phpBB sources used by boards.

+
+
+
+ + + + + + +
+
+ +
No sources registeredFetching a source will populate the local .qi source cache.
+ +
+ + + + + + + + + + + + + + + + +
SourceVersionStatusDownloadedUsed byPathActions
escape($sourceKey) ?>escape($source['version'] ?? '') ?>escape($source['status'] ?? '-') ?>escape($usedBy ? implode(', ', $usedBy) : '-') ?>escape($source['display_path'] ?? $source['path'] ?? '') ?> +
+ + + + + +
+
+
+ +
+ +
+
+
+

Activity log

+

Docker, Composer, and seed output from the latest action.

+
+ +
+
+
quickinstall
+ +
escape($output) ?>
+
+
diff --git a/src/QuickInstall/Sandbox/Web/templates/layout.php b/src/QuickInstall/Sandbox/Web/templates/layout.php new file mode 100644 index 00000000..d47a24a3 --- /dev/null +++ b/src/QuickInstall/Sandbox/Web/templates/layout.php @@ -0,0 +1,65 @@ + + + + + + + QuickInstall Dashboard + + + + +
+ +
+
+
+

QuickInstall Dashboard

+

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

+
+
+ + + +
+
+
+ +
+ +
+
+
+
+ Processing QuickInstall action... +
+ + diff --git a/src/QuickInstall/Sandbox/bootstrap.php b/src/QuickInstall/Sandbox/bootstrap.php new file mode 100644 index 00000000..08378ff5 --- /dev/null +++ b/src/QuickInstall/Sandbox/bootstrap.php @@ -0,0 +1,30 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +require_once __DIR__ . '/Output.php'; +require_once __DIR__ . '/StreamOutput.php'; +require_once __DIR__ . '/BufferedOutput.php'; +require_once __DIR__ . '/ProcessRunner.php'; +require_once __DIR__ . '/CommandLine.php'; +require_once __DIR__ . '/Project.php'; +require_once __DIR__ . '/DoctorService.php'; +require_once __DIR__ . '/VersionMatrix.php'; +require_once __DIR__ . '/UpdateService.php'; +require_once __DIR__ . '/SourceProvider.php'; +require_once __DIR__ . '/SourceService.php'; +require_once __DIR__ . '/BoardService.php'; +require_once __DIR__ . '/DockerComposeWriter.php'; +require_once __DIR__ . '/BoardRunner.php'; +require_once __DIR__ . '/SeederWriter.php'; +require_once __DIR__ . '/ExtensionManager.php'; +require_once __DIR__ . '/StyleManager.php'; +require_once __DIR__ . '/BoardRefreshService.php'; +require_once __DIR__ . '/CustomisationMountService.php'; +require_once __DIR__ . '/UiServerService.php'; diff --git a/style/assets/css/qi_style.css b/style/assets/css/qi_style.css index 28318dfe..9b0a713a 100644 --- a/style/assets/css/qi_style.css +++ b/style/assets/css/qi_style.css @@ -99,7 +99,11 @@ svg.bi { } #about-readme pre.codeblock-bash, -#about-cli pre.codeblock-bash { +#about-readme pre.codeblock-powershell, +#about-readme pre.codeblock-batch, +#about-cli pre.codeblock-bash, +#about-cli pre.codeblock-powershell, +#about-cli pre.codeblock-batch { background-color: #1f2933; border: 1px solid rgba(255, 255, 255, 0.12); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); @@ -180,7 +184,11 @@ svg.bi { } #about-readme pre.codeblock-bash, - #about-cli pre.codeblock-bash { + #about-readme pre.codeblock-powershell, + #about-readme pre.codeblock-batch, + #about-cli pre.codeblock-bash, + #about-cli pre.codeblock-powershell, + #about-cli pre.codeblock-batch { background-color: #0c1117; color: #d7e1ea; } diff --git a/style/assets/js/scripts.js b/style/assets/js/scripts.js index 44999462..1cbaa77e 100644 --- a/style/assets/js/scripts.js +++ b/style/assets/js/scripts.js @@ -144,7 +144,15 @@ } // Copy rendered documentation code blocks - for (const $block of $$('#about-readme pre.codeblock-bash, #about-cli pre.codeblock-bash')) { + const commandBlocks = [ + '#about-readme pre.codeblock-bash', + '#about-readme pre.codeblock-powershell', + '#about-readme pre.codeblock-batch', + '#about-cli pre.codeblock-bash', + '#about-cli pre.codeblock-powershell', + '#about-cli pre.codeblock-batch', + ].join(', '); + for (const $block of $$(commandBlocks)) { const $code = $('code', $block); if (!$code || $('[data-qi-copy-code]', $block)) { continue; diff --git a/tests/Integration/ApplicationTest.php b/tests/Integration/ApplicationTest.php index 6fd94b15..36adc794 100644 --- a/tests/Integration/ApplicationTest.php +++ b/tests/Integration/ApplicationTest.php @@ -55,6 +55,24 @@ public function testSourceListHandlesEmptyWorkspace(): void self::assertStringContainsString('No sources registered', $result['output']); } + public function testCachedUpdatePrintsPassiveCliNotice(): void + { + $root = $this->createTempProjectRoot(); + $project = new Project($root); + $project->init(); + $project->writeJson('cache/update-check.json', [ + 'checked_at' => time(), + 'current_version' => '1.7.0', + 'update' => ['current' => '1.8.0', 'download' => 'https://example.com/download'], + 'error' => null, + ]); + + $result = $this->runApplication($root, ['qi', 'source:list']); + + self::assertSame(0, $result['exit_code']); + self::assertStringContainsString('QuickInstall 1.8.0 available: https://example.com/download', $result['output']); + } + public function testPhpbbListPrintsSupportedSelectors(): void { $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'phpbb:list']); @@ -123,6 +141,61 @@ public function testBoardSeedRejectsConflictingResetAndReplace(): void self::assertStringContainsString('Use --reset or --replace, not both.', $result['stderr']); } + public function testUiStartHelpIsExposed(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'ui:start', '--help']); + + self::assertSame(0, $result['exit_code']); + self::assertStringContainsString('Usage:', $result['output']); + self::assertStringContainsString('qi ui:start', $result['output']); + self::assertStringContainsString('built-in server', $result['output']); + } + + public function testUiLifecycleCommandsAreExposed(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'help']); + + self::assertSame(0, $result['exit_code']); + self::assertStringContainsString('ui:start', $result['output']); + self::assertStringContainsString('ui:stop', $result['output']); + self::assertStringContainsString('ui:restart', $result['output']); + self::assertStringContainsString('ui:status', $result['output']); + } + + public function testUiLifecycleMutationsUseWorkspaceLock(): void + { + $application = new Application($this->createTempProjectRoot()); + $method = new \ReflectionMethod(Application::class, 'mutatesWorkspace'); + $method->setAccessible(true); + + self::assertTrue($method->invoke($application, 'ui:start')); + self::assertTrue($method->invoke($application, 'ui:stop')); + self::assertTrue($method->invoke($application, 'ui:restart')); + self::assertFalse($method->invoke($application, 'ui:status')); + } + + public function testUiStatusAndStopHandleNoTrackedServer(): void + { + $root = $this->createTempProjectRoot(); + + $status = $this->runApplication($root, ['qi', 'ui:status']); + $stop = $this->runApplication($root, ['qi', 'ui:stop']); + + self::assertSame(0, $status['exit_code']); + self::assertStringContainsString('not running', $status['output']); + self::assertSame(0, $stop['exit_code']); + self::assertStringContainsString('not tracked as running', $stop['output']); + } + + public function testUiStartRejectsNonLocalHostBeforeStartingServer(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'ui:start', '--host', '0.0.0.0']); + + self::assertSame(1, $result['exit_code']); + self::assertSame('', $result['output']); + self::assertStringContainsString('local loopback hosts', $result['stderr']); + } + public function testUnknownCommandReturnsFailureAndHelp(): void { $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'nope']); diff --git a/tests/Integration/BinQiTest.php b/tests/Integration/BinQiTest.php index 2949bf6e..47d6529f 100644 --- a/tests/Integration/BinQiTest.php +++ b/tests/Integration/BinQiTest.php @@ -18,6 +18,14 @@ public function testHelpSmokeTest(): void self::assertSame('', $result['stderr']); } + public function testHelpIncludesDoctorCommand(): void + { + $result = $this->runCli(['help', 'doctor']); + + self::assertSame(0, $result['exit_code']); + self::assertStringContainsString('Checks that required host tools are available', $result['stdout']); + } + public function testInvalidBoardCreateSmokeTestDoesNotRequireWorkspaceMutation(): void { $result = $this->runCli(['board:create', 'demo', '--port', '70000']); @@ -26,4 +34,22 @@ public function testInvalidBoardCreateSmokeTestDoesNotRequireWorkspaceMutation() self::assertSame('', $result['stdout']); self::assertStringContainsString('--port must be between 1 and 65535.', $result['stderr']); } + + public function testUiStatusSmokeTestLoadsUiServerService(): void + { + $result = $this->runCli(['ui:status']); + + self::assertContains($result['exit_code'], [0, 1]); + self::assertStringContainsString('QuickInstall Dashboard UI', $result['stdout']); + self::assertSame('', $result['stderr']); + } + + public function testExtMountSmokeTestLoadsCustomisationMountService(): void + { + $result = $this->runCli(['ext:mount', 'missing-board', 'customisations/missing-extension']); + + self::assertSame(1, $result['exit_code']); + self::assertSame('', $result['stdout']); + self::assertStringContainsString('Unknown board: missing-board', $result['stderr']); + } } diff --git a/tests/Integration/WebApplicationTest.php b/tests/Integration/WebApplicationTest.php new file mode 100644 index 00000000..9f9abd2d --- /dev/null +++ b/tests/Integration/WebApplicationTest.php @@ -0,0 +1,402 @@ +serverBackup = $_SERVER; + $this->postBackup = $_POST; + $this->getBackup = $_GET; + } + + protected function tearDown(): void + { + $_SERVER = $this->serverBackup; + $_POST = $this->postBackup; + $_GET = $this->getBackup; + $this->cleanupTempPaths(); + } + + public function testRenderShowsCoreSandboxWorkflows(): void + { + $root = $this->createTempProjectRoot(); + $project = new Project($root); + $project->init(); + $this->addDownloadedSource($project, '3.3.14'); + $project->appendBoard([ + 'name' => 'demo', + 'phpbb' => '3.3.14', + 'phpbb_source' => '3.3.14', + 'phpbb_branch' => '3.3', + 'php' => '8.1', + 'db' => 'mariadb', + 'port' => 8081, + 'url' => 'http://localhost:8081/', + 'path' => $project->boardPath('demo'), + 'populate' => 'none', + 'debug' => false, + 'extensions' => [], + 'styles' => [], + ]); + + $html = $this->runWebApplication($root); + + self::assertStringContainsString('QuickInstall Dashboard', $html); + self::assertStringContainsString('QuickInstall + Docker', $html); + self::assertStringContainsString('Create board', $html); + self::assertStringContainsString('Sources', $html); + self::assertStringContainsString('Mount extension', $html); + self::assertStringContainsString('Mount style', $html); + self::assertStringContainsString('board_start', $html); + self::assertStringContainsString('board_seed', $html); + self::assertStringContainsString('source_remove', $html); + self::assertStringContainsString('data-ajax', $html); + self::assertStringContainsString('activity-log', $html); + self::assertStringContainsString('/assets/sandbox-ui.css', $html); + self::assertStringContainsString('/assets/sandbox-ui.js', $html); + self::assertStringContainsString('