diff --git a/.gitignore b/.gitignore index 1cb62c5..58c1911 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ cache/* sources/* vendor/* build/* +.qi/* +customisations/* *~ .idea node_modules +/.agents +/agent +/skills-lock.json diff --git a/README.md b/README.md index fe6d83e..76c249e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,12 @@ QuickInstall is a tool we built to support the community of phpBB extension deve ## 🛠 Upgrading 1. Get the latest version of [QuickInstall](https://www.phpbb.com/customise/db/official_tool/phpbb3_quickinstall/) and extract it. -2. Copy everything into your existing QuickInstall directory **except for the 📁`boards/`, 📁`sources/` and 📁`settings/` directories**. +2. Copy everything into your existing QuickInstall directory **except for:** + - 📁`.qi/` (may be hidden by your operating system) + - 📁`boards/` + - 📁`customisations/` + - 📁`sources/` + - 📁`settings/` > If you are upgrading from QuickInstall 1.1.8 (or older) you MUST review and re-save your old Profile settings. @@ -50,6 +55,32 @@ phpBB boards require a web server running PHP and one of the following database | 3.1.x | 5.4.7 - 5.6.x | 3.23+ | 5.1+ | 8.3+ | SQLite 2 or 3 | Server 2000+ | | 3.0.x | 5.4.7 - 5.6.x | 3.23+ | - | 7.x | SQLite 2 | Server 2000 | +## 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. + +```bash +php bin/qi init +php bin/qi board:create test --phpbb 3.3 --db mariadb --port 8081 --populate extension-dev +php bin/qi board:start test +``` + +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: + +```bash +php bin/qi ext:mount test customisations/vendor/extname +``` + +Downloaded styles can be unzipped into `customisations/` and mounted into boards: + +```bash +php bin/qi style:mount test customisations/stylename +``` + +See the complete [QuickInstall CLI docs](index.php?page=cli). + ## 🐞 Support You can receive support at the [phpBB3 QuickInstall Discussion/Support](https://www.phpbb.com/customise/db/official_tool/phpbb3_quickinstall/support) forum. diff --git a/bin/qi b/bin/qi new file mode 100755 index 0000000..dd6c3b1 --- /dev/null +++ b/bin/qi @@ -0,0 +1,26 @@ +#!/usr/bin/env php +run($argv)); diff --git a/composer.phar b/composer.phar index bb6ba64..43f4b99 100644 Binary files a/composer.phar and b/composer.phar differ diff --git a/docs/sandbox-cli.md b/docs/sandbox-cli.md new file mode 100644 index 0000000..c0222b7 --- /dev/null +++ b/docs/sandbox-cli.md @@ -0,0 +1,357 @@ +# QuickInstall CLI + +QuickInstall CLI creates disposable local phpBB boards for extension, style, and development testing. + +You do not need MAMP, WAMP, XAMPP, or any local Apache/MySQL setup. QuickInstall uses Docker for the board runtime and stores generated boards under `.qi/`. + +## Quick Start + +Install requirements: + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (must be installed and running) +- PHP 8.0 or newer for the CLI command +- Git, only required for Git sources + +From the QuickInstall project root: + +Initialize if this is your first time: + +```bash +php bin/qi init +``` + +Create your first board: + +```bash +php bin/qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate none +php bin/qi board:start demo +``` + +Open: + +```text +http://localhost:8081/ +``` + +Admin login: + +```text +admin / password +``` + +That is the normal workflow. `board:create` downloads the requested phpBB source if needed, writes Docker config, and prepares the board. `board:start` starts Docker, installs phpBB, applies the selected seed preset once, and waits until the board URL responds before printing the final URL. + +If you ever need help with commands, run: + +```bash +php bin/qi help +``` + +## Common Recipes + +Create a small empty board: + +```bash +php bin/qi board:create clean --phpbb 3.3 --db mariadb --port 8081 --populate none +php bin/qi board:start clean +``` + +Create a board with extension-development fixtures: + +```bash +php bin/qi board:create extdev --phpbb 3.3.17 --db mariadb --port 8082 --populate extension-dev +php bin/qi board:start extdev +``` + +Create an older supported phpBB 3.2 board: + +```bash +php bin/qi board:create old --phpbb 3.2 --db mariadb --port 8083 --populate tiny +php bin/qi board:start old +``` + +Create an experimental master branch board: + +```bash +php bin/qi board:create alpha --phpbb master --db mariadb --port 8084 --populate tiny +php bin/qi board:start alpha +``` + +List boards (shows all created boards and their statuses): + +```bash +php bin/qi board:list +``` + +Stop or remove a board: + +```bash +php bin/qi board:stop demo +php bin/qi board:destroy demo +``` + +`board:destroy` removes the board files, Docker runtime files, database files, local Docker containers, local Docker image, and board registry entry. + +Board names are unique. To reuse a name with a different setup, destroy it first: + +```bash +php bin/qi board:destroy demo +php bin/qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate tiny +``` + +Or recreate it in one command: + +```bash +php bin/qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate tiny --replace +``` + +## Fixture Presets + +Fixture seeding populates a board with categories, forums, users, topics, and replies. For non-tiny presets, it also adds a few seeded users to Global Moderators and Newly Registered Users. Newly registered users are kept at zero posts. It does not create custom groups, permission matrices, or attachments. + +Use `--populate ` during `board:create`: + +```bash +php bin/qi board:create demo --populate extension-dev +``` + +Available presets: + +| Preset | Description | +|-----------------|----------------------------------------------------------------------| +| `none` | No seed data | +| `tiny` | 3 users, 1 category, 2 forums, 2 topics, 2 replies per topic | +| `extension-dev` | 10 users, 2 categories, 6 forums, 25 topics, 10 replies per topic | +| `load-test` | 100 users, 4 categories, 20 forums, 100 topics, 20 replies per topic | +| `random` | Random counts up to load-test size | + +Fixture seeding is supported for MariaDB, MySQL, and PostgreSQL boards. SQLite boards currently support `--populate none` only; phpBB's posting and permission APIs can hold SQLite write locks too long for reliable fixture generation. + +You can seed again manually: + +```bash +php bin/qi board:seed demo --preset extension-dev --seed 1 +``` + +Replace seed data: + +```bash +php bin/qi board:seed demo --preset extension-dev --seed 1 --replace +``` + +Remove seed data: + +```bash +php bin/qi board:seed demo --preset extension-dev --seed 1 --reset +``` + +`--seed` is a repeatable random seed number. Use the same seed to get the same fixture shape. + +## Extensions + +Put downloaded extensions under `customisations/`: + +```text +customisations/vendor/extname/composer.json +``` + +Mount into a board: + +```bash +php bin/qi ext:mount demo customisations/vendor/extname +``` + +Mount every extension found under a directory: + +```bash +php bin/qi ext:mount demo customisations --recursive +``` + +QuickInstall reads the extension `composer.json` name, such as `vendor/extname`, and bind-mounts it to: + +```text +/var/www/html/ext/vendor/extname +``` + +Edits in `customisations/vendor/extname` are reflected in the board immediately. If the board is running, QuickInstall recreates the web container and purges phpBB cache. + +List and unmount extensions: + +```bash +php bin/qi ext:list demo +php bin/qi ext:unmount demo vendor/extname +``` + +Copy instead of bind-mount: + +```bash +php bin/qi ext:mount demo customisations/vendor/extname --copy +``` + +`--copy` is only supported for one extension at a time. Recursive mounting always uses bind mounts. + +By default, extension sources must live under `customisations/`. To mount a trusted extension from somewhere else on your machine: + +```bash +php bin/qi ext:mount demo /path/to/vendor/extname --allow-external +``` + +## Styles + +Put downloaded styles under `customisations/`: + +```text +customisations/stylename/style.cfg +``` + +Mount into a board: + +```bash +php bin/qi style:mount demo customisations/stylename +``` + +Mount every style found under a directory: + +```bash +php bin/qi style:mount demo customisations --recursive +``` + +QuickInstall uses the style folder name and bind-mounts it to: + +```text +/var/www/html/styles/stylename +``` + +List and unmount styles: + +```bash +php bin/qi style:list demo +php bin/qi style:unmount demo stylename +``` + +Copy instead of bind-mount: + +```bash +php bin/qi style:mount demo customisations/stylename --copy +``` + +`--copy` is only supported for one style at a time. Recursive mounting always uses bind mounts. + +By default, style sources must live under `customisations/`. To mount a trusted style from somewhere else on your machine: + +```bash +php bin/qi style:mount demo /path/to/stylename --allow-external +``` + +## Supported phpBB Versions + +Show supported selectors: + +```bash +php bin/qi phpbb:list +``` + +Supported selectors: + +| Selector | Resolves to | +|--------------------|--------------------------------------| +| `latest` | Defaults to the supported 3.3 line | +| `3.3` | Latest 3.3.x Composer release | +| `3.3.x` | Exact 3.3 tag, such as 3.3.17 | +| `3.2` | Latest 3.2.x Composer release | +| `3.2.x` | Exact 3.2 tag, such as 3.2.11 | +| `4.0.x` / `master` | Experimental | +| `3.0` / `3.1` | Unsupported by QuickInstall CLI | + +phpBB 3.0 and 3.1 are intentionally not supported by QuickInstall CLI. They are too old for this modern installer-based flow. + +## Sources + +Most users do not need source commands. `board:create --phpbb ` automatically registers and downloads normal Composer release sources. + +Useful source commands: + +```bash +php bin/qi source:list +php bin/qi source:fetch 3.3.17 +php bin/qi source:remove 3.3.17 +php bin/qi source:prune +``` + +`source:list` shows whether each source has been downloaded and which boards use it. `source:remove` deletes one source from `.qi/sources/` and removes it from the source registry. It refuses to remove a source still used by a board unless `--force` is passed. + +`source:prune` removes all unused sources. It never removes sources referenced by existing boards. + +Use explicit Git sources for custom branches or forks: + +```bash +php bin/qi source:add master --git --url https://github.com/phpbb/phpbb.git +php bin/qi source:fetch master +``` + +Custom Git URLs can run Composer code on your host during fetch. QuickInstall only accepts the official phpBB Git URL by default. For a trusted fork, opt in explicitly: + +```bash +php bin/qi source:add my-branch --git --url https://github.com/example/phpbb.git --allow-external +php bin/qi source:fetch my-branch +``` + +Fetched sources live under: + +```text +.qi/sources/phpbb- +``` + +## 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 | + +User-managed drop zone: + +```text +customisations/ +``` + +## Safety Defaults + +- Board web ports bind to `127.0.0.1`, not every network interface. +- `board:create` refuses to overwrite an existing board unless `--replace` is used. +- `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. + +## Troubleshooting + +#### Docker command fails + +Check that Docker Desktop is running and that the docker command works in this terminal. + +#### 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. + +#### View container logs + +If a board starts but the browser shows an error, or `board:start` waits longer than expected, inspect the Docker logs. The `web` logs usually show phpBB, PHP, or web server failures. The `db` logs show database startup and connection problems. + +```bash +docker compose -f .qi/runtime/demo/compose.yml logs web +docker compose -f .qi/runtime/demo/compose.yml logs db +``` + +#### Reset a board completely + +Use this when a board's files, database, or generated Docker runtime are no longer worth repairing. Destroying a board removes its generated state, so create and start it again afterward. + +```bash +php bin/qi board:destroy demo +php bin/qi board:create demo --phpbb 3.3 --db mariadb --port 8081 --populate none +php bin/qi board:start demo +``` diff --git a/includes/qi.php b/includes/qi.php index 6cf05c4..7080b8d 100644 --- a/includes/qi.php +++ b/includes/qi.php @@ -37,6 +37,7 @@ public static function page_header($page_title = '') 'PAGE_TITLE' => self::lang($page_title), 'QI_ROOT_PATH' => $quickinstall_path, + 'U_CLI' => self::url('cli'), 'U_DOCS' => self::url('docs'), 'U_MANAGE' => self::url('manage'), 'U_MAIN' => self::url('main'), @@ -161,6 +162,110 @@ public static function lang_key_exists($key) return isset($user->lang[$key]); } + public static function render_markdown($doc_body, $anchor_prefix = '') + { + if ($anchor_prefix !== '') + { + $doc_body = self::add_markdown_anchors($doc_body, $anchor_prefix); + } + + $doc_body = Parsedown::instance()->text($doc_body); + $doc_body = preg_replace_callback('#
#', function ($matches) {
+			$language = !empty($matches[1]) ? $matches[1] : 'plain';
+			$language = preg_replace('/[^a-z0-9_-]/i', '', $language);
+
+			return '
';
+		}, $doc_body);
+
+		return str_replace(
+			['', '
', '

'], + ['

', '
', '

'], + $doc_body + ); + } + + public static function get_markdown_anchors($doc_body, $prefix) + { + $links = array(); + $anchors = self::collect_markdown_anchors($doc_body, $prefix); + + foreach ($anchors as $anchor) + { + $links[] = array( + 'TITLE' => $anchor['title'], + 'U_ANCHOR' => $anchor['anchor'], + ); + } + + return $links; + } + + private static function add_markdown_anchors($doc_body, $prefix) + { + $lines = preg_split('/\R/', $doc_body); + $anchors = self::collect_markdown_anchors($doc_body, $prefix); + + foreach ($anchors as $anchor) + { + $index = $anchor['line']; + $lines[$index] = '
' . "\n\n" . $lines[$index]; + } + + return implode("\n", $lines); + } + + private static function collect_markdown_anchors($doc_body, $prefix) + { + $seen = array(); + $anchors = array(); + $lines = preg_split('/\R/', $doc_body); + $in_code_block = false; + + foreach ($lines as $index => $line) + { + if (preg_match('/^\s*(```|~~~)/', $line)) + { + $in_code_block = !$in_code_block; + } + + if ($in_code_block || !preg_match('/^ {0,3}##(?!#)[ \t]+(.+)$/', $line, $matches)) + { + continue; + } + + $title = preg_replace('/\s+#+\s*$/', '', trim($matches[1])); + $anchor = self::generate_markdown_anchor($title, $prefix); + + if (isset($seen[$anchor])) + { + $seen[$anchor]++; + $anchor .= '-' . $seen[$anchor]; + } + else + { + $seen[$anchor] = 1; + } + + $anchors[] = array( + 'line' => $index, + 'title' => $title, + 'anchor' => $anchor, + ); + } + + return $anchors; + } + + private static function generate_markdown_anchor($title, $prefix) + { + $anchor = html_entity_decode(strip_tags($title), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $anchor = strtolower($anchor); + $anchor = preg_replace('/[^\pL\pN]+/u', '-', $anchor); + $anchor = trim($anchor, '-'); + + return $prefix . '-' . ($anchor ?: 'section'); + } + /** * Applies language selected by user to QI. * diff --git a/language/en/qi.php b/language/en/qi.php index da258b0..1b16e8a 100644 --- a/language/en/qi.php +++ b/language/en/qi.php @@ -137,6 +137,8 @@ 'DELETE_SELECTED' => 'Delete selected', 'DIR_EXISTS' => 'The directory "%s" already exists.', 'DIR_FILE_SETTINGS' => 'Directories and Files', + 'CLI_DOCS' => 'QuickInstall CLI', + 'CLI_DOCS_SHORT' => 'CLI', 'DOCS_LONG' => 'Documentation', 'DOCS_SHORT' => 'Docs', 'DOWNLOAD' => 'Download', @@ -176,6 +178,7 @@ 'JAVASCRIPT_DISABLED_ALERT' => 'Javascript is disabled! Please enable Javascript for full functionality.', 'LANGUAGE_PACK_MISSING' => 'The source phpBB board does not have a valid language pack. Please download a fresh copy of phpBB and try again.', + 'LEARN_MORE' => 'Learn more.', 'LOAD' => 'Load', 'LOG_INSTALL_INSTALLED_QI' => 'Installed by phpBB QuickInstall version %s', 'LOREM_IPSUM' => 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', @@ -254,6 +257,7 @@ 'REDIRECT_BOARD' => 'Redirect to new board', 'RESET' => 'Reset', + 'QUICKINSTALL_CLI_NOTICE' => 'QuickInstall now includes a Docker-based CLI for creating local phpBB test boards without the need for a local web server or manual phpBB downloads.', 'SAVE' => 'Save', 'SAVE_PROFILE' => 'Save as new profile', 'SAVE_PROFILE_EXPLAIN' => 'Enter a name to create a new profile with these settings, or leave this field blank to update the current profile. If a profile of the same name already exists, it will be overwritten.

Allowed characters: A-Z a-z 0-9 - _ .', diff --git a/modules/qi_cli.php b/modules/qi_cli.php new file mode 100644 index 0000000..f654758 --- /dev/null +++ b/modules/qi_cli.php @@ -0,0 +1,37 @@ +assign_block_vars('cli_nav', $anchor); + } + + $template->assign_var('CLI_BODY', qi::render_markdown($doc_body, 'cli')); + } + + $template->assign_var('S_CLI', true); + + qi::page_header('CLI_DOCS'); + + qi::page_display('cli_body'); + } +} diff --git a/modules/qi_docs.php b/modules/qi_docs.php index e777ef9..7e72927 100644 --- a/modules/qi_docs.php +++ b/modules/qi_docs.php @@ -20,14 +20,7 @@ public function run() $doc_file = $quickinstall_path . 'README.md'; if (file_exists($doc_file)) { - $doc_body = file_get_contents($doc_file); - $doc_body = $this->add_readme_anchors($doc_body); - $doc_body = Parsedown::instance()->text($doc_body); - $doc_body = str_replace( - ['

', '
', '

'], - ['

', '
', '

'], - $doc_body - ); + $doc_body = qi::render_markdown(file_get_contents($doc_file), 'readme'); $template->assign_var('DOC_BODY', $doc_body); } @@ -77,51 +70,4 @@ public function run() qi::page_display('docs_body'); } - - private function add_readme_anchors($doc_body) - { - $anchors = array(); - $lines = preg_split('/\R/', $doc_body); - $in_code_block = false; - - foreach ($lines as $index => $line) - { - if (preg_match('/^\s*(```|~~~)/', $line)) - { - $in_code_block = !$in_code_block; - } - - if ($in_code_block || !preg_match('/^ {0,3}##(?!#)[ \t]+(.+)$/', $line, $matches)) - { - continue; - } - - $title = preg_replace('/\s+#+\s*$/', '', trim($matches[1])); - $anchor = $this->generate_anchor($title, 'readme'); - - if (isset($anchors[$anchor])) - { - $anchors[$anchor]++; - $anchor .= '-' . $anchors[$anchor]; - } - else - { - $anchors[$anchor] = 1; - } - - $lines[$index] = '
' . "\n\n" . $line; - } - - return implode("\n", $lines); - } - - private function generate_anchor($title, $prefix) - { - $anchor = html_entity_decode(strip_tags($title), ENT_QUOTES | ENT_HTML5, 'UTF-8'); - $anchor = strtolower($anchor); - $anchor = preg_replace('/[^\pL\pN]+/u', '-', $anchor); - $anchor = trim($anchor, '-'); - - return $prefix . '-' . ($anchor ?: 'section'); - } } diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php new file mode 100644 index 0000000..be16c56 --- /dev/null +++ b/src/QuickInstall/Sandbox/Application.php @@ -0,0 +1,995 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +use InvalidArgumentException; +use RuntimeException; + +class Application +{ + private Project $project; + + public function __construct(string $root) + { + $this->project = new Project($root); + } + + public function run(array $argv): int + { + array_shift($argv); + $command = array_shift($argv) ?: 'help'; + if (!in_array($command, ['help', '--help', '-h'], true) && (in_array('--help', $argv, true) || in_array('-h', $argv, true))) + { + $this->help([$command]); + return 0; + } + + try + { + switch ($command) + { + case 'help': + case '--help': + case '-h': + $this->help($argv); + return 0; + + case 'init': + return $this->init(); + + case 'source:add': + return $this->sourceAdd($argv); + + case 'source:list': + return $this->sourceList(); + + case 'source:fetch': + return $this->sourceFetch($argv); + + case 'source:remove': + return $this->sourceRemove($argv); + + case 'source:prune': + return $this->sourcePrune(); + + case 'phpbb:list': + return $this->phpbbList(); + + case 'board:create': + return $this->boardCreate($argv); + + case 'board:list': + return $this->boardList(); + + case 'board:start': + return $this->boardStart($argv); + + case 'board:stop': + return $this->boardStop($argv); + + case 'board:destroy': + return $this->boardDestroy($argv); + + case 'board:seed': + return $this->boardSeed($argv); + + case 'ext:mount': + return $this->extMount($argv); + + case 'ext:unmount': + return $this->extUnmount($argv); + + case 'ext:list': + return $this->extList($argv); + + case 'style:mount': + return $this->styleMount($argv); + + case 'style:unmount': + return $this->styleUnmount($argv); + + case 'style:list': + return $this->styleList($argv); + + default: + fwrite(STDERR, "Unknown command: $command\n\n"); + $this->help(); + return 1; + } + } + catch (InvalidArgumentException|RuntimeException $e) + { + fwrite(STDERR, $e->getMessage() . "\n"); + return 1; + } + } + + private function init(): int + { + $created = $this->project->init(); + if (!$created) + { + echo "QuickInstall workspace already initialized\n"; + return 0; + } + + echo "Created " . implode(' and ', $created) . "\n"; + return 0; + } + + private function sourceAdd(array $args): int + { + $cli = CommandLine::parse($args); + $version = $cli->argument(0); + if ($version === null) + { + throw new InvalidArgumentException('Usage: qi source:add [--git] [--url URL] [--allow-external]'); + } + + $record = (new SourceService($this->project))->add($version, $cli->has('git'), $cli->option('url'), $cli->has('allow-external')); + + echo "Registered phpBB source {$record['version']} ({$record['type']})\n"; + $this->nextStep("fetch it with Composer/Git into {$record['path']}"); + return 0; + } + + private function sourceList(): int + { + $sources = (new SourceService($this->project))->list(); + + if (!$sources) + { + echo "No sources registered\n"; + return 0; + } + + $this->printTable( + ['Source', 'Version', 'Type', 'Status', 'Downloaded', 'Used By', 'Path'], + array_map(static function ($source) { + return [ + $source['source_key'] ?? $source['version'], + $source['version'], + $source['type'], + $source['status'] ?? '-', + !empty($source['downloaded']) ? 'yes' : 'no', + !empty($source['used_by']) ? implode(', ', $source['used_by']) : '-', + $source['path'], + ]; + }, $sources) + ); + + return 0; + } + + private function sourceRemove(array $args): int + { + $cli = CommandLine::parse($args); + $version = $cli->argument(0); + if ($version === null) + { + throw new InvalidArgumentException('Usage: qi source:remove [--force]'); + } + + $removed = (new SourceService($this->project))->remove($version, $cli->has('force')); + echo "Removed source: {$removed['source']['source_key']}\n"; + if (!empty($removed['used_by'])) + { + echo "Warning: source was referenced by board(s): " . implode(', ', $removed['used_by']) . "\n"; + } + + return 0; + } + + private function sourcePrune(): int + { + $removed = (new SourceService($this->project))->prune(); + if (!$removed) + { + echo "No unused sources to prune\n"; + return 0; + } + + foreach ($removed as $source) + { + echo "Removed source: {$source['source_key']}\n"; + } + + return 0; + } + + private function sourceFetch(array $args): int + { + $cli = CommandLine::parse($args); + $version = $cli->argument(0); + if ($version === null) + { + throw new InvalidArgumentException('Usage: qi source:fetch '); + } + + $record = (new SourceService($this->project))->fetch($version); + + echo "Fetched phpBB source: {$record['path']}\n"; + return 0; + } + + private function phpbbList(): int + { + foreach ((new SourceService($this->project))->supportedVersions() as $row) + { + echo "{$row['selector']}\t{$row['status']}\tPHP {$row['php']}\t{$row['resolves_to']}\t{$row['notes']}\n"; + } + + return 0; + } + + private function boardCreate(array $args): int + { + $cli = CommandLine::parse($args); + $name = $cli->argument(0); + if ($name === null) + { + throw new InvalidArgumentException('Usage: qi board:create [--phpbb VERSION] [--db mariadb|mysql|postgres|sqlite] [--port PORT] [--populate PRESET] [--replace]'); + } + + $version = $cli->option('phpbb', 'latest'); + $db = $cli->option('db', 'mariadb'); + $db = $db === 'sqlite3' ? 'sqlite' : $db; + $port = (int) $cli->option('port', '8080'); + $populate = $cli->option('populate', 'none'); + $this->validateBoardCreateOptions($db, $port, $populate); + + $created = (new BoardService($this->project))->create($name, $version, $db, $port, $populate, $cli->has('replace')); + $paths = $created['paths']; + + echo "Created board scaffold: $name\n"; + echo "Compose: {$paths['compose']}\n"; + echo "Install config: {$paths['install_config']}\n"; + echo "URL after start: http://localhost:$port/\n"; + if ($populate !== 'none') + { + echo "Populate preset: $populate (runs on board:start)\n"; + } + $this->nextStep("php bin/qi board:start $name"); + return 0; + } + + private function boardList(): int + { + $boards = (new BoardService($this->project))->list(); + if (!$boards) + { + echo "No boards created\n"; + return 0; + } + + $this->printTable( + ['Name', 'Status', 'phpBB', 'PHP', 'DB', 'Populate', 'URL'], + array_map(static function ($board) { + return [ + $board['name'], + $board['status'], + $board['phpbb'], + $board['php'], + $board['db'], + $board['populate'] ?? 'none', + $board['url'], + ]; + }, $boards) + ); + + return 0; + } + + private function printTable(array $headers, array $rows): void + { + $widths = array_map('strlen', $headers); + foreach ($rows as $row) + { + foreach ($row as $index => $value) + { + $widths[$index] = max($widths[$index], strlen((string) $value)); + } + } + + $this->printTableRow($headers, $widths); + $this->printTableRow(array_map(static function ($width) { + return str_repeat('-', $width); + }, $widths), $widths); + + foreach ($rows as $row) + { + $this->printTableRow($row, $widths); + } + } + + private function printTableRow(array $row, array $widths): void + { + $cells = []; + foreach ($row as $index => $value) + { + $cells[] = str_pad((string) $value, $widths[$index]); + } + + echo implode(' ', $cells) . "\n"; + } + + private function nextStep(string $text): void + { + echo "\n" . $this->style('NEXT:', '1;33') . " " . $this->style($text, '1') . "\n"; + } + + private function style(string $text, string $code): string + { + if (!$this->supportsAnsi()) + { + return $text; + } + + return "\033[" . $code . "m" . $text . "\033[0m"; + } + + private function supportsAnsi(): bool + { + if (getenv('NO_COLOR') !== false) + { + return false; + } + + if (function_exists('posix_isatty') && defined('STDOUT')) + { + return posix_isatty(STDOUT); + } + + return PHP_SAPI === 'cli'; + } + + private function boardStart(array $args): int + { + $name = $this->boardName($args, 'Usage: qi board:start '); + $board = (new BoardService($this->project))->start($name); + echo "Started board: $name\n"; + echo "URL: {$board['url']}\n"; + return 0; + } + + private function boardStop(array $args): int + { + $name = $this->boardName($args, 'Usage: qi board:stop '); + (new BoardService($this->project))->stop($name); + echo "Stopped board: $name\n"; + return 0; + } + + private function boardDestroy(array $args): int + { + $name = $this->boardName($args, 'Usage: qi board:destroy '); + (new BoardService($this->project))->destroy($name); + echo "Destroyed board: $name\n"; + return 0; + } + + private function boardSeed(array $args): int + { + $cli = CommandLine::parse($args); + $name = $cli->argument(0); + if ($name === null) + { + throw new InvalidArgumentException('Usage: qi board:seed [--preset tiny|extension-dev|load-test|random] [--seed N] [--reset|--replace]'); + } + + $preset = $cli->option('preset', 'extension-dev'); + $seed = (int) $cli->option('seed', '1'); + $this->validatePreset($preset); + if ($seed < 1) + { + throw new InvalidArgumentException('--seed must be a positive integer.'); + } + if ($cli->has('reset') && $cli->has('replace')) + { + throw new InvalidArgumentException('Use --reset or --replace, not both.'); + } + $action = $cli->has('reset') ? 'reset' : ($cli->has('replace') ? 'replace' : 'seed'); + + (new BoardService($this->project))->seed($name, $preset, $seed, $action); + echo ucfirst($action) . " completed for board: $name\n"; + return 0; + } + + private function boardName(array $args, string $usage): string + { + $cli = CommandLine::parse($args); + $name = $cli->argument(0); + if ($name === null) + { + throw new InvalidArgumentException($usage); + } + + return $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 currently support --populate none only. Use mariadb, mysql, or postgres for fixture seeding.'); + } + } + + 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 extMount(array $args): int + { + $cli = CommandLine::parse($args); + $board = $cli->argument(0); + $source = $cli->argument(1); + if ($board === null || $source === null) + { + throw new InvalidArgumentException('Usage: qi ext:mount [--copy] [--recursive] [--allow-external]'); + } + if ($cli->has('recursive') && $cli->has('copy')) + { + throw new InvalidArgumentException('--recursive cannot be combined with --copy. Mount recursively with bind mode, or copy individual extensions.'); + } + + return $this->mountResources('extension', new ExtensionManager($this->project), $board, $source, $cli->has('copy'), $cli->has('recursive'), $cli->has('allow-external')); + } + + private function extUnmount(array $args): int + { + $cli = CommandLine::parse($args); + $board = $cli->argument(0); + $name = $cli->argument(1); + if ($board === null || $name === null) + { + throw new InvalidArgumentException('Usage: qi ext:unmount '); + } + + $extensions = new ExtensionManager($this->project); + $target = $extensions->unmount($board, $name); + $this->refreshBoardIfRunning($board); + $extensions->cleanupStaleTarget($board, $name); + echo "Unmounted $name from $board\n"; + echo "Removed: $target\n"; + return 0; + } + + private function refreshBoardIfRunning(string $board): void + { + $runner = new BoardRunner($this->project); + (new DockerComposeWriter($this->project))->write($board, $this->runtimeConfig($this->project->board($board))); + if ($runner->status($board) === 'running') + { + $runner->recreateWeb($board); + $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', + 'extensions' => [], + 'styles' => [], + ]; + } + + private function extList(array $args): int + { + $board = $this->boardName($args, 'Usage: qi ext:list '); + $mounted = (new ExtensionManager($this->project))->list($board); + + if (!$mounted) + { + echo "No extensions mounted for board: $board\n"; + return 0; + } + + foreach ($mounted as $extension) + { + echo "{$extension['name']}\t{$extension['mode']}\t{$extension['source']}\n"; + } + + return 0; + } + + private function styleMount(array $args): int + { + $cli = CommandLine::parse($args); + $board = $cli->argument(0); + $source = $cli->argument(1); + if ($board === null || $source === null) + { + throw new InvalidArgumentException('Usage: qi style:mount [--copy] [--recursive] [--allow-external]'); + } + if ($cli->has('recursive') && $cli->has('copy')) + { + throw new InvalidArgumentException('--recursive cannot be combined with --copy. Mount recursively with bind mode, or copy individual styles.'); + } + + return $this->mountResources('style', new StyleManager($this->project), $board, $source, $cli->has('copy'), $cli->has('recursive'), $cli->has('allow-external')); + } + + private function styleUnmount(array $args): int + { + $cli = CommandLine::parse($args); + $board = $cli->argument(0); + $name = $cli->argument(1); + if ($board === null || $name === null) + { + throw new InvalidArgumentException('Usage: qi style:unmount