From bcd61943c6c0a6fa586cdaec201f91c1c6549184 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 29 Jun 2026 11:41:40 -0700 Subject: [PATCH 01/43] First implement standalone CLI prototype --- .gitignore | 1 + README.md | 14 + bin/qi | 13 + docs/modernization-cli.md | 81 ++++++ src/QuickInstall/Modern/Application.php | 208 +++++++++++++++ src/QuickInstall/Modern/CommandLine.php | 56 ++++ .../Modern/DockerComposeWriter.php | 242 ++++++++++++++++++ src/QuickInstall/Modern/Project.php | 90 +++++++ src/QuickInstall/Modern/SourceProvider.php | 113 ++++++++ src/QuickInstall/Modern/VersionMatrix.php | 31 +++ 10 files changed, 849 insertions(+) create mode 100755 bin/qi create mode 100644 docs/modernization-cli.md create mode 100644 src/QuickInstall/Modern/Application.php create mode 100644 src/QuickInstall/Modern/CommandLine.php create mode 100644 src/QuickInstall/Modern/DockerComposeWriter.php create mode 100644 src/QuickInstall/Modern/Project.php create mode 100644 src/QuickInstall/Modern/SourceProvider.php create mode 100644 src/QuickInstall/Modern/VersionMatrix.php diff --git a/.gitignore b/.gitignore index 1cb62c54..4ecf6f75 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ cache/* sources/* vendor/* build/* +.qi/* *~ .idea node_modules diff --git a/README.md b/README.md index fe6d83e7..73c16e8d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,20 @@ QuickInstall is designed to run on all modern browsers. Please don't use old stu ##### phpBB Requirements phpBB boards require a web server running PHP and one of the following database management systems. +## Modern CLI Prototype + +QuickInstall now includes an experimental CLI scaffold for a Docker-based board factory. It writes generated state to `.qi/` and leaves the legacy web UI unchanged. + +```bash +php bin/qi init +php bin/qi source:add 3.3.17 +php bin/qi source:fetch 3.3.17 +php bin/qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 +docker compose -f .qi/runtime/test/compose.yml up -d +``` + +See [docs/modernization-cli.md](docs/modernization-cli.md). + | phpBB | PHP | MySQL | MariaDB | PostgreSQL | SQLite | MS SQL | |----------------|---------------|--------|---------|------------|----------------|--------------| | 4.0.0 (alpha) | 8.2+ | 8.0+ | 10.2.7+ | 9.5+ | SQLite 3.8.3+ | Server 2012+ | diff --git a/bin/qi b/bin/qi new file mode 100755 index 00000000..7ab6a0d0 --- /dev/null +++ b/bin/qi @@ -0,0 +1,13 @@ +#!/usr/bin/env php +run($argv)); diff --git a/docs/modernization-cli.md b/docs/modernization-cli.md new file mode 100644 index 00000000..699a5347 --- /dev/null +++ b/docs/modernization-cli.md @@ -0,0 +1,81 @@ +# QuickInstall Modern CLI Prototype + +This is the first step toward making QuickInstall a board factory instead of a web request that directly drives phpBB internals. + +The legacy web app remains unchanged. The new CLI writes all generated state to `.qi/`. + +## Commands + +```bash +php bin/qi init +php bin/qi source:add 3.3.17 +php bin/qi source:fetch 3.3.17 +php bin/qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 --populate extension-dev +docker compose -f .qi/runtime/test/compose.yml up -d +``` + +`source:fetch` runs Composer or Git and downloads phpBB into `.qi/sources/phpbb-`. + +After Docker starts, open: + +```text +http://localhost:8081/ +``` + +Admin login defaults: + +```text +admin / password +``` + +## Source Model + +Registered sources are stored in `.qi/sources.json`. + +Composer sources use `phpbb/phpbb`: + +```bash +php bin/qi source:add 3.3.17 +``` + +Git sources use the phpBB repository: + +```bash +php bin/qi source:add master --git --url https://github.com/phpbb/phpbb.git +``` + +Fetched source code is expected at: + +```text +.qi/sources/phpbb- +``` + +## Board Model + +`board:create` creates: + +```text +.qi/boards/ +.qi/runtime//compose.yml +.qi/runtime//Dockerfile +.qi/runtime//entrypoint.sh +.qi/runtime//install-config.yml +.qi/db/ +``` + +The container copies the selected phpBB source into the board directory on first boot. If `install/phpbbcli.php` exists, it runs phpBB's installer CLI with the generated YAML config. + +## Current Limits + +- phpBB source fetch requires `composer` for release sources and `git` plus `composer` for Git sources. +- Docker images are generic `php:-apache` builds with DB extensions installed at build time. +- phpBB 3.2+ installer CLI is the intended path. Older branches still need a legacy installer adapter. +- Fixture population is represented as metadata only. Seeder extraction from `includes/qi_populate.php` is next. +- The web UI does not call the CLI yet. + +## Next Implementation Steps + +1. Add `board:start`, `board:stop`, `board:destroy`, and `board:list`. +2. Add branch-specific installer adapters for phpBB 3.0/3.1. +3. Extract fixture seeding behind `qi board:seed`. +4. Put web UI behind the same board/source services. diff --git a/src/QuickInstall/Modern/Application.php b/src/QuickInstall/Modern/Application.php new file mode 100644 index 00000000..c09ad137 --- /dev/null +++ b/src/QuickInstall/Modern/Application.php @@ -0,0 +1,208 @@ +root = $root; + $this->project = new Project($root); + } + + public function run(array $argv): int + { + array_shift($argv); + $command = array_shift($argv) ?: 'help'; + + try + { + switch ($command) + { + case 'help': + case '--help': + case '-h': + $this->help(); + 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 'board:create': + return $this->boardCreate($argv); + + default: + fwrite(STDERR, "Unknown command: $command\n\n"); + $this->help(); + return 1; + } + } + catch (\InvalidArgumentException $e) + { + fwrite(STDERR, $e->getMessage() . "\n"); + return 1; + } + catch (\RuntimeException $e) + { + fwrite(STDERR, $e->getMessage() . "\n"); + return 1; + } + } + + private function init(): int + { + $this->project->init(); + echo "Created .qi workspace\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]'); + } + + $this->project->init(); + $source = new SourceProvider($this->project); + $record = $source->add($version, $cli->has('git') ? 'git' : 'composer', $cli->option('url')); + + echo "Registered phpBB source {$record['version']} ({$record['type']})\n"; + echo "Next: fetch it with Composer/Git into {$record['path']}\n"; + return 0; + } + + private function sourceList(): int + { + $sources = $this->project->readJson('sources.json', []); + + if (!$sources) + { + echo "No sources registered\n"; + return 0; + } + + foreach ($sources as $source) + { + echo "{$source['version']}\t{$source['type']}\t{$source['path']}\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 '); + } + + $sources = $this->project->readJson('sources.json', []); + if (!isset($sources[$version])) + { + throw new \InvalidArgumentException("Source is not registered: $version"); + } + + $source = new SourceProvider($this->project); + $source->fetch($sources[$version]); + + echo "Fetched phpBB source: {$sources[$version]['path']}\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]'); + } + + $version = $cli->option('phpbb', 'latest'); + $db = $cli->option('db', 'mariadb'); + $port = (int) $cli->option('port', '8080'); + $populate = $cli->option('populate', 'none'); + + $this->project->init(); + $matrix = new VersionMatrix(); + $runtime = $matrix->runtimeFor($version); + $sourcePath = $this->project->sourcePath($version); + if (!file_exists($sourcePath . '/common.php')) + { + throw new \RuntimeException("phpBB source is missing for $version. Run: php bin/qi source:fetch $version"); + } + + $boardDir = $this->project->boardPath($name); + if (!is_dir($boardDir) && !mkdir($boardDir, 0775, true)) + { + throw new \RuntimeException("Unable to create board directory: $boardDir"); + } + + $writer = new DockerComposeWriter($this->project); + $paths = $writer->write($name, [ + 'phpbb' => $version, + 'php' => $runtime['php'], + 'db' => $db, + 'port' => $port, + 'populate' => $populate, + 'admin_name' => 'admin', + 'admin_pass' => 'password', + 'admin_email' => 'admin@example.test', + 'board_email' => 'board@example.test', + ]); + + $this->project->appendBoard([ + 'name' => $name, + 'phpbb' => $version, + 'php' => $runtime['php'], + 'db' => $db, + 'url' => "http://localhost:$port/", + 'path' => $boardDir, + 'created_at' => gmdate('c'), + ]); + + 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"; + echo "Next: docker compose -f {$paths['compose']} up -d\n"; + return 0; + } + + private function help(): void + { + echo << [--git] [--url URL] + qi source:fetch + qi source:list + qi board:create [--phpbb VERSION] [--db mariadb|mysql|postgres|sqlite] [--port PORT] [--populate PRESET] + +Examples: + qi source:add 3.3.17 + qi source:add master --git --url https://github.com/phpbb/phpbb.git + qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 --populate extension-dev + +TXT; + } +} diff --git a/src/QuickInstall/Modern/CommandLine.php b/src/QuickInstall/Modern/CommandLine.php new file mode 100644 index 00000000..a1aca4b5 --- /dev/null +++ b/src/QuickInstall/Modern/CommandLine.php @@ -0,0 +1,56 @@ +args[] = $token; + continue; + } + + $option = substr($token, 2); + $value = true; + + if (strpos($option, '=') !== false) + { + [$option, $value] = explode('=', $option, 2); + } + else if (isset($tokens[$i + 1]) && strpos($tokens[$i + 1], '--') !== 0) + { + $value = $tokens[++$i]; + } + + $cli->options[$option] = $value; + } + + return $cli; + } + + public function argument(int $index): ?string + { + return $this->args[$index] ?? null; + } + + public function option(string $name, ?string $default = null): ?string + { + return isset($this->options[$name]) && $this->options[$name] !== true ? (string) $this->options[$name] : $default; + } + + public function has(string $name): bool + { + return isset($this->options[$name]); + } +} diff --git a/src/QuickInstall/Modern/DockerComposeWriter.php b/src/QuickInstall/Modern/DockerComposeWriter.php new file mode 100644 index 00000000..c15efe8a --- /dev/null +++ b/src/QuickInstall/Modern/DockerComposeWriter.php @@ -0,0 +1,242 @@ +project = $project; + } + + public function write(string $name, array $config): array + { + $runtimeDir = $this->project->workspacePath('runtime/' . $name); + if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0775, true)) + { + throw new \RuntimeException("Unable to create runtime directory: $runtimeDir"); + } + + $installConfig = $runtimeDir . '/install-config.yml'; + $compose = $runtimeDir . '/compose.yml'; + $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($config)); + chmod($entrypoint, 0755); + + return [ + 'compose' => $compose, + 'install_config' => $installConfig, + 'dockerfile' => $dockerfile, + 'entrypoint' => $entrypoint, + ]; + } + + private function installConfig(string $name, array $config): string + { + $dbms = $this->dbms($config['db']); + $dbhost = $config['db'] === 'sqlite' ? '/var/www/html/store/phpbb.sqlite' : 'db'; + $dbport = $config['db'] === 'postgres' ? '5432' : ($config['db'] === 'sqlite' ? '' : '3306'); + + return <<databaseService($config['db'], $name); + $sourcePath = $this->project->sourcePath($config['phpbb']); + $boardPath = $this->project->boardPath($name); + $dbPath = $this->project->workspacePath('db/' . $name); + if (!is_dir($dbPath) && !mkdir($dbPath, 0775, true)) + { + throw new \RuntimeException("Unable to create database directory: $dbPath"); + } + + return <<root = rtrim($root, '/'); + $this->workspace = $this->root . '/.qi'; + } + + public function init(): void + { + foreach (['', '/sources', '/boards', '/runtime', '/db', '/cache'] as $dir) + { + $path = $this->workspace . $dir; + if (!is_dir($path) && !mkdir($path, 0775, true)) + { + throw new \RuntimeException("Unable to create $path"); + } + } + + foreach (['sources.json' => [], 'boards.json' => []] as $file => $default) + { + $path = $this->workspace . '/' . $file; + if (!file_exists($path)) + { + $this->writeJson($file, $default); + } + } + } + + public function workspacePath(string $path = ''): string + { + return $this->workspace . ($path === '' ? '' : '/' . ltrim($path, '/')); + } + + public function boardPath(string $name): string + { + $this->assertName($name, 'board'); + return $this->workspacePath('boards/' . $name); + } + + public function sourcePath(string $version): string + { + $this->assertName($version, 'version'); + return $this->workspacePath('sources/phpbb-' . $version); + } + + public function readJson(string $file, array $default): array + { + $path = $this->workspacePath($file); + if (!file_exists($path)) + { + return $default; + } + + $data = json_decode((string) file_get_contents($path), true); + return is_array($data) ? $data : $default; + } + + 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) + { + throw new \RuntimeException("Unable to write $path"); + } + } + + public function appendBoard(array $board): void + { + $boards = $this->readJson('boards.json', []); + $boards[$board['name']] = $board; + $this->writeJson('boards.json', $boards); + } + + public function assertName(string $name, string $label): void + { + if (!preg_match('/^[A-Za-z0-9._-]+$/', $name)) + { + throw new \InvalidArgumentException("Invalid $label: $name"); + } + } +} diff --git a/src/QuickInstall/Modern/SourceProvider.php b/src/QuickInstall/Modern/SourceProvider.php new file mode 100644 index 00000000..733f7d75 --- /dev/null +++ b/src/QuickInstall/Modern/SourceProvider.php @@ -0,0 +1,113 @@ +project = $project; + } + + public function add(string $version, string $type, ?string $url): array + { + $this->project->assertName($version, 'version'); + if (!in_array($type, ['composer', 'git'], true)) + { + throw new \InvalidArgumentException("Unsupported source type: $type"); + } + + $sources = $this->project->readJson('sources.json', []); + $record = [ + 'version' => $version, + 'type' => $type, + 'package' => $type === 'composer' ? 'phpbb/phpbb' : null, + 'url' => $url ?: ($type === 'git' ? 'https://github.com/phpbb/phpbb.git' : null), + 'path' => $this->project->sourcePath($version), + 'registered_at' => gmdate('c'), + ]; + + $sources[$version] = $record; + $this->project->writeJson('sources.json', $sources); + + return $record; + } + + public function fetch(array $source): void + { + $path = $source['path']; + $parent = dirname($path); + if (!is_dir($parent) && !mkdir($parent, 0775, true)) + { + throw new \RuntimeException("Unable to create source directory: $parent"); + } + + if (is_dir($path) && $this->hasFiles($path)) + { + throw new \RuntimeException("Source path already exists and is not empty: $path"); + } + + if ($source['type'] === 'git') + { + $url = $source['url'] ?: 'https://github.com/phpbb/phpbb.git'; + $this->run([ + 'git', + 'clone', + '--branch', + $source['version'], + '--depth', + '1', + $url, + $path, + ], dirname($path)); + + $this->run(['composer', 'install', '--no-interaction'], $path); + return; + } + + if (is_dir($path)) + { + rmdir($path); + } + + $this->run([ + 'composer', + 'create-project', + 'phpbb/phpbb', + $path, + $source['version'], + '--no-interaction', + ], dirname($path)); + } + + private function hasFiles(string $path): bool + { + $files = scandir($path); + return $files !== false && count(array_diff($files, ['.', '..'])) > 0; + } + + private function run(array $command, string $cwd): void + { + echo '$ ' . implode(' ', array_map('escapeshellarg', $command)) . "\n"; + + $descriptor = [ + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, + ]; + + $process = proc_open($command, $descriptor, $pipes, $cwd); + if (!is_resource($process)) + { + throw new \RuntimeException('Unable to start command: ' . $command[0]); + } + + $status = proc_close($process); + if ($status !== 0) + { + throw new \RuntimeException("Command failed with exit code $status: {$command[0]}"); + } + } +} diff --git a/src/QuickInstall/Modern/VersionMatrix.php b/src/QuickInstall/Modern/VersionMatrix.php new file mode 100644 index 00000000..f2791ed2 --- /dev/null +++ b/src/QuickInstall/Modern/VersionMatrix.php @@ -0,0 +1,31 @@ + '8.2']; + } + + if (version_compare($version, '3.3.0', '>=')) + { + return ['php' => '8.1']; + } + + if (version_compare($version, '3.2.2', '>=')) + { + return ['php' => '7.2']; + } + + if (version_compare($version, '3.2.0', '>=')) + { + return ['php' => '7.1']; + } + + return ['php' => '5.6']; + } +} From 69c94933d88c85ad7de4666c569d8917fd5d0d03 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 29 Jun 2026 11:42:11 -0700 Subject: [PATCH 02/43] Ignore agent skills --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4ecf6f75..b7aa6aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ build/* *~ .idea node_modules +/.agents +/agent +/skills-lock.json From 924783c329a4c60847dd641f647c06d28b4a0bf8 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 29 Jun 2026 11:57:38 -0700 Subject: [PATCH 03/43] Add board seeding / populating --- README.md | 3 +- bin/qi | 2 + docs/modernization-cli.md | 49 +++++- src/QuickInstall/Modern/Application.php | 96 ++++++++++++ src/QuickInstall/Modern/BoardRunner.php | 157 +++++++++++++++++++ src/QuickInstall/Modern/Project.php | 78 ++++++++++ src/QuickInstall/Modern/SeederWriter.php | 187 +++++++++++++++++++++++ 7 files changed, 565 insertions(+), 7 deletions(-) create mode 100644 src/QuickInstall/Modern/BoardRunner.php create mode 100644 src/QuickInstall/Modern/SeederWriter.php diff --git a/README.md b/README.md index 73c16e8d..9116319e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ php bin/qi init php bin/qi source:add 3.3.17 php bin/qi source:fetch 3.3.17 php bin/qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 -docker compose -f .qi/runtime/test/compose.yml up -d +php bin/qi board:start test +php bin/qi board:seed test --preset extension-dev --seed 1 ``` See [docs/modernization-cli.md](docs/modernization-cli.md). diff --git a/bin/qi b/bin/qi index 7ab6a0d0..5afa1372 100755 --- a/bin/qi +++ b/bin/qi @@ -7,6 +7,8 @@ require __DIR__ . '/../src/QuickInstall/Modern/Project.php'; require __DIR__ . '/../src/QuickInstall/Modern/VersionMatrix.php'; require __DIR__ . '/../src/QuickInstall/Modern/SourceProvider.php'; require __DIR__ . '/../src/QuickInstall/Modern/DockerComposeWriter.php'; +require __DIR__ . '/../src/QuickInstall/Modern/BoardRunner.php'; +require __DIR__ . '/../src/QuickInstall/Modern/SeederWriter.php'; use QuickInstall\Modern\Application; diff --git a/docs/modernization-cli.md b/docs/modernization-cli.md index 699a5347..ea7396fa 100644 --- a/docs/modernization-cli.md +++ b/docs/modernization-cli.md @@ -11,7 +11,8 @@ php bin/qi init php bin/qi source:add 3.3.17 php bin/qi source:fetch 3.3.17 php bin/qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 --populate extension-dev -docker compose -f .qi/runtime/test/compose.yml up -d +php bin/qi board:start test +php bin/qi board:seed test --preset extension-dev --seed 1 ``` `source:fetch` runs Composer or Git and downloads phpBB into `.qi/sources/phpbb-`. @@ -65,17 +66,53 @@ Fetched source code is expected at: The container copies the selected phpBB source into the board directory on first boot. If `install/phpbbcli.php` exists, it runs phpBB's installer CLI with the generated YAML config. +Useful board commands: + +```bash +php bin/qi board:list +php bin/qi board:start test +php bin/qi board:stop test +php bin/qi board:destroy test +``` + +`board:destroy` stops containers and removes generated board files, runtime files, database files, and registry metadata for that board. + +`board:list` shows each registered board plus Docker status: + +```text +test running 3.3.17 PHP 8.1 mariadb http://localhost:8081/ +``` + +Statuses are `running`, `stopped`, `partial`, `missing`, or `error`. + +## Fixture Seeding + +Seed an installed, running board: + +```bash +php bin/qi board:seed test --preset extension-dev --seed 1 +``` + +Available presets: + +```text +tiny 3 users, 2 topics, 2 replies per topic +extension-dev 10 users, 5 topics, 4 replies per topic +load-test 50 users, 25 topics, 10 replies per topic +``` + +The first seeder targets phpBB 3.2+ style boards and uses phpBB APIs inside the `web` container: `user_add()` for users and `submit_post()` for topics/replies. + ## Current Limits - phpBB source fetch requires `composer` for release sources and `git` plus `composer` for Git sources. - Docker images are generic `php:-apache` builds with DB extensions installed at build time. - phpBB 3.2+ installer CLI is the intended path. Older branches still need a legacy installer adapter. -- Fixture population is represented as metadata only. Seeder extraction from `includes/qi_populate.php` is next. +- Fixture population currently supports users, topics, and replies. It does not yet cover categories, permissions matrices, custom groups, styles, or attachments. - The web UI does not call the CLI yet. ## Next Implementation Steps -1. Add `board:start`, `board:stop`, `board:destroy`, and `board:list`. -2. Add branch-specific installer adapters for phpBB 3.0/3.1. -3. Extract fixture seeding behind `qi board:seed`. -4. Put web UI behind the same board/source services. +1. Add branch-specific installer adapters for phpBB 3.0/3.1. +2. Expand `board:seed` for categories, forums, groups, permissions, styles, and attachments. +3. Put web UI behind the same board/source services. diff --git a/src/QuickInstall/Modern/Application.php b/src/QuickInstall/Modern/Application.php index c09ad137..c66356e9 100644 --- a/src/QuickInstall/Modern/Application.php +++ b/src/QuickInstall/Modern/Application.php @@ -43,6 +43,21 @@ public function run(array $argv): int 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); + default: fwrite(STDERR, "Unknown command: $command\n\n"); $this->help(); @@ -186,6 +201,80 @@ private function boardCreate(array $args): int return 0; } + private function boardList(): int + { + $boards = $this->project->boards(); + if (!$boards) + { + echo "No boards created\n"; + return 0; + } + + $runner = new BoardRunner($this->project); + foreach ($boards as $board) + { + $status = $runner->status($board['name']); + echo "{$board['name']}\t$status\t{$board['phpbb']}\tPHP {$board['php']}\t{$board['db']}\t{$board['url']}\n"; + } + + return 0; + } + + private function boardStart(array $args): int + { + $name = $this->boardName($args, 'Usage: qi board:start '); + (new BoardRunner($this->project))->start($name); + $board = $this->project->board($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 BoardRunner($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 BoardRunner($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] [--seed N]'); + } + + $preset = $cli->option('preset', 'extension-dev'); + $seed = (int) $cli->option('seed', '1'); + + (new BoardRunner($this->project))->seed($name, $preset, $seed); + echo "Seeded 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 help(): void { echo << qi source:list qi board:create [--phpbb VERSION] [--db mariadb|mysql|postgres|sqlite] [--port PORT] [--populate PRESET] + qi board:list + qi board:start + qi board:stop + qi board:destroy + qi board:seed [--preset tiny|extension-dev|load-test] [--seed N] Examples: qi source:add 3.3.17 qi source:add master --git --url https://github.com/phpbb/phpbb.git qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 --populate extension-dev + qi board:start test + qi board:seed test --preset extension-dev --seed 1 TXT; } diff --git a/src/QuickInstall/Modern/BoardRunner.php b/src/QuickInstall/Modern/BoardRunner.php new file mode 100644 index 00000000..2deac6a7 --- /dev/null +++ b/src/QuickInstall/Modern/BoardRunner.php @@ -0,0 +1,157 @@ +project = $project; + } + + public function start(string $name): void + { + $this->project->board($name); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'up', '--build', '-d']); + } + + public function stop(string $name): void + { + $this->project->board($name); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'stop']); + } + + 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']); + } + + $this->project->deleteTree($this->project->boardPath($name)); + $this->project->deleteTree($this->project->runtimePath($name)); + $this->project->deleteTree($this->project->dbPath($name)); + $this->project->removeBoard($name); + } + + public function status(string $name): string + { + $compose = $this->project->composePath($name); + if (!file_exists($compose)) + { + return 'missing'; + } + + $result = $this->capture(['docker', 'compose', '-f', $compose, 'ps', '--format', 'json']); + if ($result['exit_code'] !== 0) + { + return 'error'; + } + + $output = trim($result['output']); + if ($output === '') + { + return 'stopped'; + } + + $containers = []; + foreach (explode("\n", $output) as $line) + { + $data = json_decode($line, true); + if (is_array($data)) + { + $containers[] = $data; + } + } + + if (!$containers) + { + $data = json_decode($output, true); + $containers = is_array($data) && isset($data[0]) ? $data : []; + } + + if (!$containers) + { + return 'stopped'; + } + + $running = 0; + foreach ($containers as $container) + { + $state = strtolower((string) ($container['State'] ?? '')); + if ($state === 'running') + { + $running++; + } + } + + if ($running === count($containers)) + { + return 'running'; + } + + return $running > 0 ? 'partial' : 'stopped'; + } + + public function seed(string $name, string $preset, int $seed): void + { + $this->project->board($name); + $writer = new SeederWriter($this->project); + $script = $writer->write($name); + + $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', 'php', '/tmp/qi_seed.php', $preset, (string) $seed]); + } + + private function run(array $command): void + { + echo '$ ' . implode(' ', array_map('escapeshellarg', $command)) . "\n"; + + $descriptor = [ + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, + ]; + + $process = proc_open($command, $descriptor, $pipes); + if (!is_resource($process)) + { + throw new \RuntimeException('Unable to start command: ' . $command[0]); + } + + $status = proc_close($process); + if ($status !== 0) + { + throw new \RuntimeException("Command failed with exit code $status: {$command[0]}"); + } + } + + private function capture(array $command): array + { + $descriptor = [ + 0 => STDIN, + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptor, $pipes); + if (!is_resource($process)) + { + return ['exit_code' => 1, 'output' => '']; + } + + $output = stream_get_contents($pipes[1]) ?: ''; + $error = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + + return [ + 'exit_code' => proc_close($process), + 'output' => $output . $error, + ]; + } +} diff --git a/src/QuickInstall/Modern/Project.php b/src/QuickInstall/Modern/Project.php index 35542bf2..48425183 100644 --- a/src/QuickInstall/Modern/Project.php +++ b/src/QuickInstall/Modern/Project.php @@ -80,6 +80,84 @@ public function appendBoard(array $board): void $this->writeJson('boards.json', $boards); } + public function boards(): array + { + return $this->readJson('boards.json', []); + } + + public function board(string $name): array + { + $boards = $this->boards(); + if (!isset($boards[$name])) + { + throw new \InvalidArgumentException("Unknown board: $name"); + } + + return $boards[$name]; + } + + public function removeBoard(string $name): void + { + $boards = $this->boards(); + unset($boards[$name]); + $this->writeJson('boards.json', $boards); + } + + public function runtimePath(string $name): string + { + $this->assertName($name, 'board'); + return $this->workspacePath('runtime/' . $name); + } + + public function composePath(string $name): string + { + return $this->runtimePath($name) . '/compose.yml'; + } + + public function dbPath(string $name): string + { + $this->assertName($name, 'board'); + return $this->workspacePath('db/' . $name); + } + + public function deleteTree(string $path): void + { + if (!file_exists($path)) + { + return; + } + + if (is_file($path) || is_link($path)) + { + if (!unlink($path)) + { + throw new \RuntimeException("Unable to delete $path"); + } + return; + } + + $items = scandir($path); + if ($items === false) + { + throw new \RuntimeException("Unable to scan $path"); + } + + foreach ($items as $item) + { + if ($item === '.' || $item === '..') + { + continue; + } + + $this->deleteTree($path . '/' . $item); + } + + if (!rmdir($path)) + { + throw new \RuntimeException("Unable to delete $path"); + } + } + public function assertName(string $name, string $label): void { if (!preg_match('/^[A-Za-z0-9._-]+$/', $name)) diff --git a/src/QuickInstall/Modern/SeederWriter.php b/src/QuickInstall/Modern/SeederWriter.php new file mode 100644 index 00000000..f3a60955 --- /dev/null +++ b/src/QuickInstall/Modern/SeederWriter.php @@ -0,0 +1,187 @@ +project = $project; + } + + public function write(string $name): string + { + $path = $this->project->runtimePath($name) . '/seed.php'; + if (file_put_contents($path, $this->script()) === false) + { + throw new \RuntimeException("Unable to write $path"); + } + + return $path; + } + + private function script(): string + { + return <<<'PHP' + ['users' => 3, 'topics' => 2, 'replies' => 2], + 'extension-dev' => ['users' => 10, 'topics' => 5, 'replies' => 4], + 'load-test' => ['users' => 50, 'topics' => 25, 'replies' => 10], +]; + +if (!isset($presets[$preset])) +{ + fwrite(STDERR, "Unknown seed preset: $preset\n"); + exit(1); +} + +$phpbb_root_path = '/var/www/html/'; +$phpEx = 'php'; +define('IN_PHPBB', true); + +require_once $phpbb_root_path . 'common.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_user.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_content.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_posting.' . $phpEx; + +$user->session_begin(); +$auth->acl($user->data); +$user->setup(); + +$admin_id = 2; +$user->data['user_id'] = $admin_id; +$user->data['username'] = 'admin'; +$user->data['is_registered'] = true; + +$forum_id = qi_seed_first_postable_forum($db); +if (!$forum_id) +{ + fwrite(STDERR, "No postable forum found\n"); + exit(1); +} + +$counts = $presets[$preset]; +mt_srand($seed); + +$created_users = qi_seed_users($db, $phpbb_container, $counts['users'], $seed); +$created_topics = qi_seed_posts($forum_id, $counts['topics'], $counts['replies'], $seed); + +echo "Seeded preset $preset: $created_users users, $created_topics topics\n"; + +function qi_seed_first_postable_forum($db): int +{ + $sql = 'SELECT forum_id FROM ' . FORUMS_TABLE . ' WHERE forum_type = ' . FORUM_POST . ' ORDER BY forum_id ASC'; + $result = $db->sql_query_limit($sql, 1); + $row = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + + return $row ? (int) $row['forum_id'] : 0; +} + +function qi_seed_users($db, $phpbb_container, int $count, int $seed): int +{ + $created = 0; + $passwords = $phpbb_container->get('passwords.manager'); + + for ($i = 1; $i <= $count; $i++) + { + $username = sprintf('qi_user_%d_%02d', $seed, $i); + $sql = 'SELECT user_id FROM ' . USERS_TABLE . " WHERE username_clean = '" . $db->sql_escape(utf8_clean_string($username)) . "'"; + $result = $db->sql_query_limit($sql, 1); + $exists = (bool) $db->sql_fetchrow($result); + $db->sql_freeresult($result); + + if ($exists) + { + continue; + } + + $user_id = user_add([ + 'username' => $username, + 'user_password' => $passwords->hash('password'), + 'user_email' => sprintf('%s@example.test', $username), + 'group_id' => 2, + 'user_type' => USER_NORMAL, + 'user_ip' => '127.0.0.1', + 'user_lang' => 'en', + ]); + + if ($user_id !== false) + { + $created++; + } + } + + return $created; +} + +function qi_seed_posts(int $forum_id, int $topics, int $replies, int $seed): int +{ + $created = 0; + + for ($topic = 1; $topic <= $topics; $topic++) + { + $subject = sprintf('QI seeded topic %d-%02d', $seed, $topic); + $data = qi_seed_post_data($forum_id, 0, $subject, sprintf("Seeded topic body %d.%02d\n\nUseful test content for extension development.", $seed, $topic)); + $poll = []; + submit_post('post', $subject, '', POST_NORMAL, $poll, $data, true, true); + $created++; + + $topic_id = (int) ($data['topic_id'] ?? 0); + if (!$topic_id) + { + continue; + } + + for ($reply = 1; $reply <= $replies; $reply++) + { + $reply_subject = 'Re: ' . $subject; + $reply_data = qi_seed_post_data($forum_id, $topic_id, $subject, sprintf('Seeded reply %d for topic %d.', $reply, $topic)); + $reply_poll = []; + submit_post('reply', $reply_subject, '', POST_NORMAL, $reply_poll, $reply_data, true, true); + } + } + + return $created; +} + +function qi_seed_post_data(int $forum_id, int $topic_id, string $topic_title, string $message): array +{ + $uid = $bitfield = ''; + $options = 0; + generate_text_for_storage($message, $uid, $bitfield, $options, true, true, true); + + return [ + 'forum_id' => $forum_id, + 'topic_id' => $topic_id, + 'icon_id' => 0, + 'topic_title' => $topic_title, + 'topic_time_limit' => 0, + 'poster_id' => 2, + 'enable_bbcode' => true, + 'enable_smilies' => true, + 'enable_urls' => true, + 'enable_sig' => true, + 'message' => $message, + 'message_md5' => md5($message), + 'bbcode_bitfield' => $bitfield, + 'bbcode_uid' => $uid, + 'post_edit_locked' => 0, + 'notify_set' => false, + 'notify' => false, + 'post_time' => time(), + 'forum_name' => '', + 'enable_indexing' => true, + 'force_approved_state' => true, + ]; +} +PHP; + } +} From 11e387f15cfbc0538af1ef271ff3a54ab26854e0 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 29 Jun 2026 14:57:56 -0700 Subject: [PATCH 04/43] Improve seeding and fix bugs --- README.md | 7 +- docs/modernization-cli.md | 35 +-- src/QuickInstall/Modern/Application.php | 20 +- src/QuickInstall/Modern/BoardRunner.php | 46 +++- src/QuickInstall/Modern/SeederWriter.php | 246 +++++++++++++++++++-- src/QuickInstall/Modern/SourceProvider.php | 30 ++- 6 files changed, 330 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 9116319e..78213d39 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,12 @@ QuickInstall now includes an experimental CLI scaffold for a Docker-based board ```bash php bin/qi init -php bin/qi source:add 3.3.17 -php bin/qi source:fetch 3.3.17 -php bin/qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 +php bin/qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 --populate extension-dev php bin/qi board:start test -php bin/qi board:seed test --preset extension-dev --seed 1 ``` +The modern CLI targets phpBB 3.2+ installer-based boards. phpBB 3.0/3.1 remain legacy-web-app territory and are not planned for this Docker CLI. + See [docs/modernization-cli.md](docs/modernization-cli.md). | phpBB | PHP | MySQL | MariaDB | PostgreSQL | SQLite | MS SQL | diff --git a/docs/modernization-cli.md b/docs/modernization-cli.md index ea7396fa..5664d848 100644 --- a/docs/modernization-cli.md +++ b/docs/modernization-cli.md @@ -8,14 +8,13 @@ The legacy web app remains unchanged. The new CLI writes all generated state to ```bash php bin/qi init -php bin/qi source:add 3.3.17 -php bin/qi source:fetch 3.3.17 php bin/qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 --populate extension-dev php bin/qi board:start test -php bin/qi board:seed test --preset extension-dev --seed 1 ``` -`source:fetch` runs Composer or Git and downloads phpBB into `.qi/sources/phpbb-`. +`board:create --phpbb ` automatically registers and fetches missing Composer-based phpBB sources into `.qi/sources/phpbb-`. + +`--populate extension-dev` seeds the board once during `board:start`, after phpBB has installed successfully. Use `--populate none` to skip automatic seeding. After Docker starts, open: @@ -31,9 +30,9 @@ admin / password ## Source Model -Registered sources are stored in `.qi/sources.json`. +Registered sources are stored in `.qi/sources.json`. For normal released versions, you can skip source commands and let `board:create --phpbb ` register/fetch automatically. -Composer sources use `phpbb/phpbb`: +Composer sources use `phpbb/phpbb`. Explicit source commands are still useful when you want to fetch source ahead of time: ```bash php bin/qi source:add 3.3.17 @@ -43,8 +42,11 @@ Git sources use the phpBB repository: ```bash php bin/qi source:add master --git --url https://github.com/phpbb/phpbb.git +php bin/qi source:fetch master ``` +Use explicit `source:add --git` for branches, forks, and custom URLs. Automatic `board:create` fetching assumes Composer release sources. + Fetched source code is expected at: ```text @@ -80,7 +82,7 @@ php bin/qi board:destroy test `board:list` shows each registered board plus Docker status: ```text -test running 3.3.17 PHP 8.1 mariadb http://localhost:8081/ +test running 3.3.17 PHP 8.1 mariadb populate:extension-dev http://localhost:8081/ ``` Statuses are `running`, `stopped`, `partial`, `missing`, or `error`. @@ -93,26 +95,29 @@ Seed an installed, running board: php bin/qi board:seed test --preset extension-dev --seed 1 ``` +This is separate from `board:create --populate`. Manual `board:seed` always runs when called. Automatic `--populate` runs once on `board:start` and writes a marker under `.qi/runtime//`. + Available presets: ```text -tiny 3 users, 2 topics, 2 replies per topic -extension-dev 10 users, 5 topics, 4 replies per topic -load-test 50 users, 25 topics, 10 replies per topic +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 up to 100 users, up to 4 categories, up to 20 forums, up to 100 topics, up to 20 replies per topic ``` -The first seeder targets phpBB 3.2+ style boards and uses phpBB APIs inside the `web` container: `user_add()` for users and `submit_post()` for topics/replies. +The seeder targets phpBB 3.2+ style boards and uses phpBB APIs inside the `web` container. It creates categories/forums directly, creates users through `user_add()`, and creates topics/replies through `submit_post()`. Topic and reply authors are chosen randomly from the seeded users for the selected seed. Seeded topic titles use the DB topic ID suffix, so phpBB's default demo topic is reflected in numbering. The `random` preset uses `load-test` as caps and chooses counts from `1..cap` for users/categories/forums/topics and `0..cap` for replies. ## Current Limits - phpBB source fetch requires `composer` for release sources and `git` plus `composer` for Git sources. - Docker images are generic `php:-apache` builds with DB extensions installed at build time. -- phpBB 3.2+ installer CLI is the intended path. Older branches still need a legacy installer adapter. -- Fixture population currently supports users, topics, and replies. It does not yet cover categories, permissions matrices, custom groups, styles, or attachments. +- phpBB 3.2+ installer CLI is the supported path. phpBB 3.0/3.1 are intentionally out of scope for this modern CLI. +- Fixture population currently supports categories, forums, users, topics, and replies. It does not cover groups, permissions matrices, styles, or attachments. - The web UI does not call the CLI yet. ## Next Implementation Steps -1. Add branch-specific installer adapters for phpBB 3.0/3.1. -2. Expand `board:seed` for categories, forums, groups, permissions, styles, and attachments. +1. Expand source/version selection around supported phpBB branches only. +2. Improve `board:seed` reset/idempotency controls. 3. Put web UI behind the same board/source services. diff --git a/src/QuickInstall/Modern/Application.php b/src/QuickInstall/Modern/Application.php index c66356e9..f5d5f74a 100644 --- a/src/QuickInstall/Modern/Application.php +++ b/src/QuickInstall/Modern/Application.php @@ -158,11 +158,7 @@ private function boardCreate(array $args): int $this->project->init(); $matrix = new VersionMatrix(); $runtime = $matrix->runtimeFor($version); - $sourcePath = $this->project->sourcePath($version); - if (!file_exists($sourcePath . '/common.php')) - { - throw new \RuntimeException("phpBB source is missing for $version. Run: php bin/qi source:fetch $version"); - } + (new SourceProvider($this->project))->ensure($version); $boardDir = $this->project->boardPath($name); if (!is_dir($boardDir) && !mkdir($boardDir, 0775, true)) @@ -190,6 +186,7 @@ private function boardCreate(array $args): int 'db' => $db, 'url' => "http://localhost:$port/", 'path' => $boardDir, + 'populate' => $populate, 'created_at' => gmdate('c'), ]); @@ -197,7 +194,11 @@ private function boardCreate(array $args): int echo "Compose: {$paths['compose']}\n"; echo "Install config: {$paths['install_config']}\n"; echo "URL after start: http://localhost:$port/\n"; - echo "Next: docker compose -f {$paths['compose']} up -d\n"; + if ($populate !== 'none') + { + echo "Populate preset: $populate (runs on board:start)\n"; + } + echo "Next: php bin/qi board:start $name\n"; return 0; } @@ -214,7 +215,8 @@ private function boardList(): int foreach ($boards as $board) { $status = $runner->status($board['name']); - echo "{$board['name']}\t$status\t{$board['phpbb']}\tPHP {$board['php']}\t{$board['db']}\t{$board['url']}\n"; + $populate = $board['populate'] ?? 'none'; + echo "{$board['name']}\t$status\t{$board['phpbb']}\tPHP {$board['php']}\t{$board['db']}\tpopulate:$populate\t{$board['url']}\n"; } return 0; @@ -252,7 +254,7 @@ private function boardSeed(array $args): int $name = $cli->argument(0); if ($name === null) { - throw new \InvalidArgumentException('Usage: qi board:seed [--preset tiny|extension-dev|load-test] [--seed N]'); + throw new \InvalidArgumentException('Usage: qi board:seed [--preset tiny|extension-dev|load-test|random] [--seed N]'); } $preset = $cli->option('preset', 'extension-dev'); @@ -290,7 +292,7 @@ private function help(): void qi board:start qi board:stop qi board:destroy - qi board:seed [--preset tiny|extension-dev|load-test] [--seed N] + qi board:seed [--preset tiny|extension-dev|load-test|random] [--seed N] Examples: qi source:add 3.3.17 diff --git a/src/QuickInstall/Modern/BoardRunner.php b/src/QuickInstall/Modern/BoardRunner.php index 2deac6a7..a6f4a7b3 100644 --- a/src/QuickInstall/Modern/BoardRunner.php +++ b/src/QuickInstall/Modern/BoardRunner.php @@ -13,8 +13,10 @@ public function __construct(Project $project) public function start(string $name): void { - $this->project->board($name); + $board = $this->project->board($name); $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'up', '--build', '-d']); + $this->waitUntilInstalled($name); + $this->seedIfNeeded($name, $board['populate'] ?? 'none'); } public function stop(string $name): void @@ -100,6 +102,48 @@ public function status(string $name): string public function seed(string $name, string $preset, int $seed): void { $this->project->board($name); + $this->runSeeder($name, $preset, $seed); + } + + private function seedIfNeeded(string $name, string $preset): void + { + if ($preset === 'none' || $preset === '') + { + return; + } + + $marker = $this->project->runtimePath($name) . '/seeded-' . preg_replace('/[^A-Za-z0-9._-]/', '_', $preset); + if (file_exists($marker)) + { + echo "Populate preset already applied: $preset\n"; + return; + } + + $this->runSeeder($name, $preset, 1); + file_put_contents($marker, gmdate('c') . "\n"); + } + + private function waitUntilInstalled(string $name): void + { + $boardPath = $this->project->boardPath($name); + $deadline = time() + 120; + + while (time() <= $deadline) + { + $config = $boardPath . '/config.php'; + if (file_exists($boardPath . '/includes/startup.php') && file_exists($config) && filesize($config) > 0 && !is_dir($boardPath . '/install')) + { + return; + } + + usleep(500000); + } + + throw new \RuntimeException("Timed out waiting for phpBB install to complete for board: $name"); + } + + private function runSeeder(string $name, string $preset, int $seed): void + { $writer = new SeederWriter($this->project); $script = $writer->write($name); diff --git a/src/QuickInstall/Modern/SeederWriter.php b/src/QuickInstall/Modern/SeederWriter.php index f3a60955..b6f74a1c 100644 --- a/src/QuickInstall/Modern/SeederWriter.php +++ b/src/QuickInstall/Modern/SeederWriter.php @@ -31,9 +31,10 @@ private function script(): string $seed = (int) ($argv[2] ?? 1); $presets = [ - 'tiny' => ['users' => 3, 'topics' => 2, 'replies' => 2], - 'extension-dev' => ['users' => 10, 'topics' => 5, 'replies' => 4], - 'load-test' => ['users' => 50, 'topics' => 25, 'replies' => 10], + 'tiny' => ['users' => 3, 'categories' => 1, 'forums_per_category' => 2, 'topics' => 2, 'replies' => 2], + 'extension-dev' => ['users' => 10, 'categories' => 2, 'forums_per_category' => 3, 'topics' => 25, 'replies' => 10], + 'load-test' => ['users' => 100, 'categories' => 4, 'forums_per_category' => 5, 'topics' => 100, 'replies' => 20], + 'random' => ['users' => 100, 'categories' => 4, 'forums_per_category' => 5, 'topics' => 100, 'replies' => 20, 'randomize' => true], ]; if (!isset($presets[$preset])) @@ -50,6 +51,7 @@ private function script(): string require_once $phpbb_root_path . 'includes/functions_user.' . $phpEx; require_once $phpbb_root_path . 'includes/functions_content.' . $phpEx; require_once $phpbb_root_path . 'includes/functions_posting.' . $phpEx; +require_once $phpbb_root_path . 'includes/functions_admin.' . $phpEx; $user->session_begin(); $auth->acl($user->data); @@ -60,20 +62,42 @@ private function script(): string $user->data['username'] = 'admin'; $user->data['is_registered'] = true; -$forum_id = qi_seed_first_postable_forum($db); -if (!$forum_id) +mt_srand($seed); +$counts = qi_seed_resolve_counts($presets[$preset]); + +$users = qi_seed_users($db, $phpbb_container, $counts['users'], $seed); +$forums = qi_seed_forums($db, $counts['categories'], $counts['forums_per_category'], $seed); +if (!$forums) +{ + $forum_id = qi_seed_first_postable_forum($db); + $forums = $forum_id ? [$forum_id] : []; +} + +if (!$forums) { fwrite(STDERR, "No postable forum found\n"); exit(1); } -$counts = $presets[$preset]; -mt_srand($seed); +$created_topics = qi_seed_posts($forums, $users, $counts['topics'], $counts['replies'], $seed); + +echo "Seeded preset $preset: " . count($users) . " users available, " . count($forums) . " forums available, $created_topics topics\n"; -$created_users = qi_seed_users($db, $phpbb_container, $counts['users'], $seed); -$created_topics = qi_seed_posts($forum_id, $counts['topics'], $counts['replies'], $seed); +function qi_seed_resolve_counts(array $preset): array +{ + if (empty($preset['randomize'])) + { + return $preset; + } -echo "Seeded preset $preset: $created_users users, $created_topics topics\n"; + return [ + 'users' => mt_rand(1, $preset['users']), + 'categories' => mt_rand(1, $preset['categories']), + 'forums_per_category' => mt_rand(1, $preset['forums_per_category']), + 'topics' => mt_rand(1, $preset['topics']), + 'replies' => mt_rand(0, $preset['replies']), + ]; +} function qi_seed_first_postable_forum($db): int { @@ -85,9 +109,9 @@ function qi_seed_first_postable_forum($db): int return $row ? (int) $row['forum_id'] : 0; } -function qi_seed_users($db, $phpbb_container, int $count, int $seed): int +function qi_seed_users($db, $phpbb_container, int $count, int $seed): array { - $created = 0; + $users = []; $passwords = $phpbb_container->get('passwords.manager'); for ($i = 1; $i <= $count; $i++) @@ -95,11 +119,16 @@ function qi_seed_users($db, $phpbb_container, int $count, int $seed): int $username = sprintf('qi_user_%d_%02d', $seed, $i); $sql = 'SELECT user_id FROM ' . USERS_TABLE . " WHERE username_clean = '" . $db->sql_escape(utf8_clean_string($username)) . "'"; $result = $db->sql_query_limit($sql, 1); - $exists = (bool) $db->sql_fetchrow($result); + $row = $db->sql_fetchrow($result); $db->sql_freeresult($result); - if ($exists) + if ($row) { + $users[] = [ + 'user_id' => (int) $row['user_id'], + 'username' => $username, + 'user_colour' => '', + ]; continue; } @@ -115,21 +144,152 @@ function qi_seed_users($db, $phpbb_container, int $count, int $seed): int if ($user_id !== false) { - $created++; + $users[] = [ + 'user_id' => (int) $user_id, + 'username' => $username, + 'user_colour' => '', + ]; } } - return $created; + return $users ?: [[ + 'user_id' => 2, + 'username' => 'admin', + 'user_colour' => 'AA0000', + ]]; +} + +function qi_seed_forums($db, int $categories, int $forums_per_category, int $seed): array +{ + $forum_ids = []; + $category_ids = []; + $permission_source = qi_seed_first_postable_forum($db); + + for ($category = 1; $category <= $categories; $category++) + { + $category_name = sprintf('QI seed %d category %02d', $seed, $category); + $category_id = qi_seed_forum_id_by_name($db, $category_name); + if (!$category_id) + { + $category_id = qi_seed_insert_forum($db, [ + 'parent_id' => 0, + 'forum_type' => FORUM_CAT, + 'forum_name' => $category_name, + 'forum_desc' => sprintf('Generated category for seed %d.', $seed), + ]); + } + $category_ids[] = $category_id; + + for ($forum = 1; $forum <= $forums_per_category; $forum++) + { + $forum_name = sprintf('QI seed %d forum %02d-%02d', $seed, $category, $forum); + $forum_id = qi_seed_forum_id_by_name($db, $forum_name); + if (!$forum_id) + { + $forum_id = qi_seed_insert_forum($db, [ + 'parent_id' => $category_id, + 'forum_type' => FORUM_POST, + 'forum_name' => $forum_name, + 'forum_desc' => sprintf('Generated forum %02d in category %02d.', $forum, $category), + ]); + } + + $forum_ids[] = $forum_id; + } + } + + $new_id = 1; + recalc_nested_sets($new_id, 'forum_id', FORUMS_TABLE); + + $forum_ids = array_values(array_unique(array_map('intval', $forum_ids))); + $permission_targets = array_values(array_unique(array_merge(array_map('intval', $category_ids), $forum_ids))); + if ($permission_source && $permission_targets) + { + copy_forum_permissions($permission_source, $permission_targets, true, false); + } + + qi_seed_clear_caches(); + + return $forum_ids; +} + +function qi_seed_clear_caches(): void +{ + global $auth, $cache; + + if (is_object($auth) && method_exists($auth, 'acl_clear_prefetch')) + { + $auth->acl_clear_prefetch(); + } + + if (is_object($cache) && method_exists($cache, 'purge')) + { + $cache->purge(); + } +} + +function qi_seed_forum_id_by_name($db, string $name): int +{ + $sql = 'SELECT forum_id FROM ' . FORUMS_TABLE . " WHERE forum_name = '" . $db->sql_escape($name) . "'"; + $result = $db->sql_query_limit($sql, 1); + $row = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + + return $row ? (int) $row['forum_id'] : 0; +} + +function qi_seed_insert_forum($db, array $data): int +{ + $desc = $data['forum_desc']; + $desc_uid = $desc_bitfield = ''; + $desc_options = 7; + generate_text_for_storage($desc, $desc_uid, $desc_bitfield, $desc_options, true, true, true); + + $sql_ary = [ + 'parent_id' => (int) $data['parent_id'], + 'left_id' => 0, + 'right_id' => 0, + 'forum_parents' => '', + 'forum_name' => $data['forum_name'], + 'forum_desc' => $desc, + 'forum_desc_bitfield' => $desc_bitfield, + 'forum_desc_options' => $desc_options, + 'forum_desc_uid' => $desc_uid, + 'forum_link' => '', + 'forum_password' => '', + 'forum_image' => '', + 'forum_rules' => '', + 'forum_rules_link' => '', + 'forum_rules_bitfield' => '', + 'forum_rules_options' => 7, + 'forum_rules_uid' => '', + 'forum_type' => (int) $data['forum_type'], + 'forum_status' => ITEM_UNLOCKED, + 'forum_flags' => 48, + 'display_on_index' => 1, + 'enable_indexing' => 1, + 'enable_icons' => 1, + 'display_subforum_list' => 1, + ]; + + $db->sql_query('INSERT INTO ' . FORUMS_TABLE . ' ' . $db->sql_build_array('INSERT', $sql_ary)); + return (int) $db->sql_nextid(); } -function qi_seed_posts(int $forum_id, int $topics, int $replies, int $seed): int +function qi_seed_posts(array $forum_ids, array $authors, int $topics, int $replies, int $seed): int { $created = 0; + $existing = qi_seed_existing_topic_count($seed); - for ($topic = 1; $topic <= $topics; $topic++) + for ($topic = $existing + 1; $topic <= $topics; $topic++) { - $subject = sprintf('QI seeded topic %d-%02d', $seed, $topic); - $data = qi_seed_post_data($forum_id, 0, $subject, sprintf("Seeded topic body %d.%02d\n\nUseful test content for extension development.", $seed, $topic)); + $forum_id = $forum_ids[array_rand($forum_ids)]; + $author = $authors[array_rand($authors)]; + $topic_id_hint = qi_seed_next_topic_id(); + $subject = sprintf('QI seeded topic %d-%02d', $seed, $topic_id_hint); + + qi_seed_set_author($author); + $data = qi_seed_post_data($forum_id, 0, $subject, sprintf("Seeded topic body %d.%02d\n\nUseful test content for extension development.", $seed, $topic_id_hint)); $poll = []; submit_post('post', $subject, '', POST_NORMAL, $poll, $data, true, true); $created++; @@ -142,8 +302,10 @@ function qi_seed_posts(int $forum_id, int $topics, int $replies, int $seed): int for ($reply = 1; $reply <= $replies; $reply++) { + $reply_author = $authors[array_rand($authors)]; $reply_subject = 'Re: ' . $subject; - $reply_data = qi_seed_post_data($forum_id, $topic_id, $subject, sprintf('Seeded reply %d for topic %d.', $reply, $topic)); + qi_seed_set_author($reply_author); + $reply_data = qi_seed_post_data($forum_id, $topic_id, $subject, sprintf('Seeded reply %d for topic %d.', $reply, $topic_id_hint)); $reply_poll = []; submit_post('reply', $reply_subject, '', POST_NORMAL, $reply_poll, $reply_data, true, true); } @@ -152,8 +314,48 @@ function qi_seed_posts(int $forum_id, int $topics, int $replies, int $seed): int return $created; } +function qi_seed_existing_topic_count(int $seed): int +{ + global $db; + + $sql = 'SELECT COUNT(topic_id) AS topic_count + FROM ' . TOPICS_TABLE . " + WHERE topic_title LIKE '" . $db->sql_escape(sprintf('QI seeded topic %d-', $seed)) . "%'"; + $result = $db->sql_query($sql); + $count = (int) $db->sql_fetchfield('topic_count'); + $db->sql_freeresult($result); + + return $count; +} + +function qi_seed_next_topic_id(): int +{ + global $db; + + $sql = 'SELECT MAX(topic_id) AS max_topic_id + FROM ' . TOPICS_TABLE; + $result = $db->sql_query($sql); + $next_id = (int) $db->sql_fetchfield('max_topic_id') + 1; + $db->sql_freeresult($result); + + return max(1, $next_id); +} + +function qi_seed_set_author(array $author): void +{ + global $user; + + $user->data['user_id'] = (int) $author['user_id']; + $user->data['username'] = $author['username']; + $user->data['username_clean'] = utf8_clean_string($author['username']); + $user->data['user_colour'] = $author['user_colour'] ?? ''; + $user->data['is_registered'] = true; +} + function qi_seed_post_data(int $forum_id, int $topic_id, string $topic_title, string $message): array { + global $user; + $uid = $bitfield = ''; $options = 0; generate_text_for_storage($message, $uid, $bitfield, $options, true, true, true); @@ -164,7 +366,7 @@ function qi_seed_post_data(int $forum_id, int $topic_id, string $topic_title, st 'icon_id' => 0, 'topic_title' => $topic_title, 'topic_time_limit' => 0, - 'poster_id' => 2, + 'poster_id' => (int) $user->data['user_id'], 'enable_bbcode' => true, 'enable_smilies' => true, 'enable_urls' => true, diff --git a/src/QuickInstall/Modern/SourceProvider.php b/src/QuickInstall/Modern/SourceProvider.php index 733f7d75..fca3270a 100644 --- a/src/QuickInstall/Modern/SourceProvider.php +++ b/src/QuickInstall/Modern/SourceProvider.php @@ -35,6 +35,25 @@ public function add(string $version, string $type, ?string $url): array return $record; } + public function ensure(string $version): array + { + $sources = $this->project->readJson('sources.json', []); + if (!isset($sources[$version])) + { + echo "Registering phpBB source: $version\n"; + $sources[$version] = $this->add($version, 'composer', null); + } + + $source = $sources[$version]; + if (!file_exists($source['path'] . '/common.php')) + { + echo "Fetching phpBB source: $version\n"; + $this->fetch($source); + } + + return $source; + } + public function fetch(array $source): void { $path = $source['path']; @@ -72,14 +91,19 @@ public function fetch(array $source): void rmdir($path); } - $this->run([ + $command = [ 'composer', 'create-project', 'phpbb/phpbb', $path, - $source['version'], '--no-interaction', - ], dirname($path)); + ]; + if ($source['version'] !== 'latest') + { + array_splice($command, 4, 0, [$source['version']]); + } + + $this->run($command, dirname($path)); } private function hasFiles(string $path): bool From c6892672a24b049a6bc9c7056d48e5d89e0dcf78 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 29 Jun 2026 15:08:40 -0700 Subject: [PATCH 05/43] Expand source/version selection around supported phpBB branches only --- README.md | 2 +- docs/modernization-cli.md | 35 ++++- src/QuickInstall/Modern/Application.php | 37 +++-- .../Modern/DockerComposeWriter.php | 2 +- src/QuickInstall/Modern/SourceProvider.php | 42 ++++-- src/QuickInstall/Modern/VersionMatrix.php | 136 ++++++++++++++++-- 6 files changed, 216 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 78213d39..0668e089 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ QuickInstall now includes an experimental CLI scaffold for a Docker-based board ```bash php bin/qi init -php bin/qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 --populate extension-dev +php bin/qi board:create test --phpbb 3.3 --db mariadb --port 8081 --populate extension-dev php bin/qi board:start test ``` diff --git a/docs/modernization-cli.md b/docs/modernization-cli.md index 5664d848..6609c3fd 100644 --- a/docs/modernization-cli.md +++ b/docs/modernization-cli.md @@ -8,11 +8,11 @@ The legacy web app remains unchanged. The new CLI writes all generated state to ```bash php bin/qi init -php bin/qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 --populate extension-dev +php bin/qi board:create test --phpbb 3.3 --db mariadb --port 8081 --populate extension-dev php bin/qi board:start test ``` -`board:create --phpbb ` automatically registers and fetches missing Composer-based phpBB sources into `.qi/sources/phpbb-`. +`board:create --phpbb ` validates supported phpBB selectors, then automatically registers and fetches missing Composer-based phpBB sources into `.qi/sources/phpbb-`. `--populate extension-dev` seeds the board once during `board:start`, after phpBB has installed successfully. Use `--populate none` to skip automatic seeding. @@ -53,6 +53,32 @@ Fetched source code is expected at: .qi/sources/phpbb- ``` +## Version Selection + +Show supported selectors: + +```bash +php bin/qi phpbb:list +``` + +Supported selectors: + +```text +latest Supported stable line, currently constrained to 3.3.* +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 modern Docker CLI +``` + +Unsupported versions fail before source download: + +```text +phpBB 3.1.12 is not supported by the modern Docker CLI. Use phpBB 3.2+ or the legacy web app for phpBB 3.0/3.1. +``` + ## Board Model `board:create` creates: @@ -118,6 +144,5 @@ The seeder targets phpBB 3.2+ style boards and uses phpBB APIs inside the `web` ## Next Implementation Steps -1. Expand source/version selection around supported phpBB branches only. -2. Improve `board:seed` reset/idempotency controls. -3. Put web UI behind the same board/source services. +1. Add `board:seed --reset` / `--replace` controls for removing or replacing seed-generated data. +2. Put web UI behind the same board/source services. diff --git a/src/QuickInstall/Modern/Application.php b/src/QuickInstall/Modern/Application.php index f5d5f74a..f2a29724 100644 --- a/src/QuickInstall/Modern/Application.php +++ b/src/QuickInstall/Modern/Application.php @@ -40,6 +40,9 @@ public function run(array $argv): int case 'source:fetch': return $this->sourceFetch($argv); + case 'phpbb:list': + return $this->phpbbList(); + case 'board:create': return $this->boardCreate($argv); @@ -113,7 +116,10 @@ private function sourceList(): int foreach ($sources as $source) { - echo "{$source['version']}\t{$source['type']}\t{$source['path']}\n"; + $sourceKey = $source['source_key'] ?? $source['version']; + $constraint = $source['constraint'] ?? '-'; + $status = $source['status'] ?? '-'; + echo "$sourceKey\t{$source['version']}\t$constraint\t{$source['type']}\t$status\t{$source['path']}\n"; } return 0; @@ -128,16 +134,20 @@ private function sourceFetch(array $args): int throw new \InvalidArgumentException('Usage: qi source:fetch '); } - $sources = $this->project->readJson('sources.json', []); - if (!isset($sources[$version])) + $source = new SourceProvider($this->project); + $record = $source->ensure($version); + + echo "Fetched phpBB source: {$record['path']}\n"; + return 0; + } + + private function phpbbList(): int + { + foreach ((new VersionMatrix())->list() as $row) { - throw new \InvalidArgumentException("Source is not registered: $version"); + echo "{$row['selector']}\t{$row['status']}\tPHP {$row['php']}\t{$row['resolves_to']}\t{$row['notes']}\n"; } - $source = new SourceProvider($this->project); - $source->fetch($sources[$version]); - - echo "Fetched phpBB source: {$sources[$version]['path']}\n"; return 0; } @@ -157,8 +167,9 @@ private function boardCreate(array $args): int $this->project->init(); $matrix = new VersionMatrix(); - $runtime = $matrix->runtimeFor($version); - (new SourceProvider($this->project))->ensure($version); + $selection = $matrix->resolve($version); + $runtime = ['php' => $selection['php']]; + $source = (new SourceProvider($this->project))->ensure($version); $boardDir = $this->project->boardPath($name); if (!is_dir($boardDir) && !mkdir($boardDir, 0775, true)) @@ -169,6 +180,7 @@ private function boardCreate(array $args): int $writer = new DockerComposeWriter($this->project); $paths = $writer->write($name, [ 'phpbb' => $version, + 'phpbb_source' => $source['source_key'], 'php' => $runtime['php'], 'db' => $db, 'port' => $port, @@ -181,7 +193,9 @@ private function boardCreate(array $args): int $this->project->appendBoard([ 'name' => $name, - 'phpbb' => $version, + 'phpbb' => $source['version'], + 'phpbb_source' => $source['source_key'], + 'phpbb_branch' => $source['phpbb_branch'], 'php' => $runtime['php'], 'db' => $db, 'url' => "http://localhost:$port/", @@ -287,6 +301,7 @@ private function help(): void qi source:add [--git] [--url URL] qi source:fetch qi source:list + qi phpbb:list qi board:create [--phpbb VERSION] [--db mariadb|mysql|postgres|sqlite] [--port PORT] [--populate PRESET] qi board:list qi board:start diff --git a/src/QuickInstall/Modern/DockerComposeWriter.php b/src/QuickInstall/Modern/DockerComposeWriter.php index c15efe8a..9ccbfcb3 100644 --- a/src/QuickInstall/Modern/DockerComposeWriter.php +++ b/src/QuickInstall/Modern/DockerComposeWriter.php @@ -85,7 +85,7 @@ private function installConfig(string $name, array $config): string private function compose(string $name, array $config): string { $dbService = $this->databaseService($config['db'], $name); - $sourcePath = $this->project->sourcePath($config['phpbb']); + $sourcePath = $this->project->sourcePath($config['phpbb_source'] ?? $config['phpbb']); $boardPath = $this->project->boardPath($name); $dbPath = $this->project->workspacePath('db/' . $name); if (!is_dir($dbPath) && !mkdir($dbPath, 0775, true)) diff --git a/src/QuickInstall/Modern/SourceProvider.php b/src/QuickInstall/Modern/SourceProvider.php index fca3270a..24c7a4e7 100644 --- a/src/QuickInstall/Modern/SourceProvider.php +++ b/src/QuickInstall/Modern/SourceProvider.php @@ -13,23 +13,29 @@ public function __construct(Project $project) public function add(string $version, string $type, ?string $url): array { - $this->project->assertName($version, 'version'); if (!in_array($type, ['composer', 'git'], true)) { throw new \InvalidArgumentException("Unsupported source type: $type"); } + $selection = (new VersionMatrix())->resolve($version, $type === 'git'); $sources = $this->project->readJson('sources.json', []); $record = [ - 'version' => $version, + 'version' => $selection['version'], + 'source_key' => $selection['source_key'], + 'constraint' => $selection['constraint'], + 'branch' => $selection['branch'], + 'phpbb_branch' => $selection['phpbb_branch'], + 'php' => $selection['php'], + 'status' => $selection['status'], 'type' => $type, 'package' => $type === 'composer' ? 'phpbb/phpbb' : null, 'url' => $url ?: ($type === 'git' ? 'https://github.com/phpbb/phpbb.git' : null), - 'path' => $this->project->sourcePath($version), + 'path' => $this->project->sourcePath($selection['source_key']), 'registered_at' => gmdate('c'), ]; - $sources[$version] = $record; + $sources[$selection['source_key']] = $record; $this->project->writeJson('sources.json', $sources); return $record; @@ -37,14 +43,16 @@ public function add(string $version, string $type, ?string $url): array public function ensure(string $version): array { + $selection = (new VersionMatrix())->resolve($version); $sources = $this->project->readJson('sources.json', []); - if (!isset($sources[$version])) + if (!isset($sources[$selection['source_key']])) { echo "Registering phpBB source: $version\n"; - $sources[$version] = $this->add($version, 'composer', null); + $sources[$selection['source_key']] = $this->add($version, 'composer', null); } - $source = $sources[$version]; + $source = $sources[$selection['source_key']]; + $source = $this->withSelectionDefaults($source, $selection); if (!file_exists($source['path'] . '/common.php')) { echo "Fetching phpBB source: $version\n"; @@ -54,6 +62,20 @@ public function ensure(string $version): array return $source; } + private function withSelectionDefaults(array $source, array $selection): array + { + return $source + [ + 'version' => $selection['version'], + 'source_key' => $selection['source_key'], + 'constraint' => $selection['constraint'], + 'branch' => $selection['branch'], + 'phpbb_branch' => $selection['phpbb_branch'], + 'php' => $selection['php'], + 'status' => $selection['status'], + 'path' => $this->project->sourcePath($selection['source_key']), + ]; + } + public function fetch(array $source): void { $path = $source['path']; @@ -75,7 +97,7 @@ public function fetch(array $source): void 'git', 'clone', '--branch', - $source['version'], + $source['branch'] ?: $source['version'], '--depth', '1', $url, @@ -98,9 +120,9 @@ public function fetch(array $source): void $path, '--no-interaction', ]; - if ($source['version'] !== 'latest') + if (!empty($source['constraint'])) { - array_splice($command, 4, 0, [$source['version']]); + array_splice($command, 4, 0, [$source['constraint']]); } $this->run($command, dirname($path)); diff --git a/src/QuickInstall/Modern/VersionMatrix.php b/src/QuickInstall/Modern/VersionMatrix.php index f2791ed2..625cc8b7 100644 --- a/src/QuickInstall/Modern/VersionMatrix.php +++ b/src/QuickInstall/Modern/VersionMatrix.php @@ -4,28 +4,144 @@ class VersionMatrix { - public function runtimeFor(string $version): array + 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.2 / 3.2.x', 'resolves_to' => '3.2.* or exact tag', 'status' => 'supported', 'php' => '7.2', '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'], + ]; + } + + public function resolve(string $requested, bool $git = false): array { - if ($version === 'latest' || preg_match('/^(master|main|4\.|dev-)/', $version)) + $requested = trim($requested); + if ($requested === '') { - return ['php' => '8.2']; + throw new \InvalidArgumentException('Missing phpBB version'); } - if (version_compare($version, '3.3.0', '>=')) + if (preg_match('/^3\.[01](\.|$)/', $requested)) { - return ['php' => '8.1']; + throw new \InvalidArgumentException("phpBB $requested is not supported by the modern Docker CLI. Use phpBB 3.2+ or the legacy web app for phpBB 3.0/3.1."); } - if (version_compare($version, '3.2.2', '>=')) + if ($git) { - return ['php' => '7.2']; + return [ + 'version' => $requested, + 'source_key' => $this->sourceKey($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', + 'status' => 'experimental', + ]; } - if (version_compare($version, '3.2.0', '>=')) + if ($requested === 'latest') { - return ['php' => '7.1']; + return [ + 'version' => 'latest', + 'source_key' => '3.3', + 'constraint' => '3.3.*', + 'branch' => '3.3', + 'phpbb_branch' => '3.3', + 'php' => '8.1', + 'status' => 'supported', + ]; } - return ['php' => '5.6']; + if ($requested === '3.3') + { + return [ + 'version' => '3.3', + 'source_key' => '3.3', + 'constraint' => '3.3.*', + 'branch' => '3.3', + 'phpbb_branch' => '3.3', + 'php' => '8.1', + 'status' => 'supported', + ]; + } + + if ($requested === '3.2') + { + return [ + 'version' => '3.2', + 'source_key' => '3.2', + 'constraint' => '3.2.*', + 'branch' => '3.2', + 'phpbb_branch' => '3.2', + 'php' => '7.2', + 'status' => 'supported', + ]; + } + + if ($requested === 'master' || $requested === 'main' || $requested === 'dev-master') + { + return [ + 'version' => $requested, + 'source_key' => 'master', + 'constraint' => 'dev-master', + 'branch' => 'master', + 'phpbb_branch' => '4.0', + 'php' => '8.2', + 'status' => 'experimental', + ]; + } + + if (preg_match('/^3\.3\.\d+/', $requested)) + { + return [ + 'version' => $requested, + 'source_key' => $requested, + 'constraint' => $requested, + 'branch' => '3.3', + 'phpbb_branch' => '3.3', + 'php' => '8.1', + 'status' => 'supported', + ]; + } + + if (preg_match('/^3\.2\.\d+/', $requested)) + { + return [ + 'version' => $requested, + 'source_key' => $requested, + 'constraint' => $requested, + 'branch' => '3.2', + 'phpbb_branch' => '3.2', + 'php' => '7.2', + 'status' => 'supported', + ]; + } + + if (preg_match('/^4\.0\./', $requested)) + { + return [ + 'version' => $requested, + 'source_key' => $requested, + 'constraint' => $requested, + 'branch' => '4.0', + 'phpbb_branch' => '4.0', + 'php' => '8.2', + 'status' => 'experimental', + ]; + } + + throw new \InvalidArgumentException("Unsupported phpBB selector: $requested. Use latest, 3.3, 3.3.x, 3.2, 3.2.x, 4.0.x, or master."); + } + + public function runtimeFor(string $version): array + { + return ['php' => $this->resolve($version)['php']]; + } + + private function sourceKey(string $value): string + { + return preg_replace('/[^A-Za-z0-9._-]/', '-', $value); } } From 0c6f2f7ecb0c458cb00bc5a0ad00c18d1373a630 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 29 Jun 2026 15:15:51 -0700 Subject: [PATCH 06/43] Implemented board:seed --reset and --replace --- docs/modernization-cli.md | 9 ++- src/QuickInstall/Modern/Application.php | 13 +++-- src/QuickInstall/Modern/BoardRunner.php | 46 +++++++++++++-- src/QuickInstall/Modern/SeederWriter.php | 71 ++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/docs/modernization-cli.md b/docs/modernization-cli.md index 6609c3fd..9b4fec80 100644 --- a/docs/modernization-cli.md +++ b/docs/modernization-cli.md @@ -119,10 +119,16 @@ Seed an installed, running board: ```bash php bin/qi board:seed test --preset extension-dev --seed 1 +php bin/qi board:seed test --seed 1 --reset +php bin/qi board:seed test --preset extension-dev --seed 1 --replace ``` This is separate from `board:create --populate`. Manual `board:seed` always runs when called. Automatic `--populate` runs once on `board:start` and writes a marker under `.qi/runtime//`. +`--reset` removes seed-generated users, forums/categories, topics, and replies for the selected `--seed`. + +`--replace` runs `--reset` first, then creates fresh data with the selected preset and seed. + Available presets: ```text @@ -144,5 +150,4 @@ The seeder targets phpBB 3.2+ style boards and uses phpBB APIs inside the `web` ## Next Implementation Steps -1. Add `board:seed --reset` / `--replace` controls for removing or replacing seed-generated data. -2. Put web UI behind the same board/source services. +1. Put web UI behind the same board/source services. diff --git a/src/QuickInstall/Modern/Application.php b/src/QuickInstall/Modern/Application.php index f2a29724..be405d91 100644 --- a/src/QuickInstall/Modern/Application.php +++ b/src/QuickInstall/Modern/Application.php @@ -268,14 +268,19 @@ private function boardSeed(array $args): int $name = $cli->argument(0); if ($name === null) { - throw new \InvalidArgumentException('Usage: qi board:seed [--preset tiny|extension-dev|load-test|random] [--seed N]'); + 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'); + 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 BoardRunner($this->project))->seed($name, $preset, $seed); - echo "Seeded board: $name\n"; + (new BoardRunner($this->project))->seed($name, $preset, $seed, $action); + echo ucfirst($action) . " completed for board: $name\n"; return 0; } @@ -307,7 +312,7 @@ private function help(): void qi board:start qi board:stop qi board:destroy - qi board:seed [--preset tiny|extension-dev|load-test|random] [--seed N] + qi board:seed [--preset tiny|extension-dev|load-test|random] [--seed N] [--reset|--replace] Examples: qi source:add 3.3.17 diff --git a/src/QuickInstall/Modern/BoardRunner.php b/src/QuickInstall/Modern/BoardRunner.php index a6f4a7b3..45e66efe 100644 --- a/src/QuickInstall/Modern/BoardRunner.php +++ b/src/QuickInstall/Modern/BoardRunner.php @@ -99,10 +99,25 @@ public function status(string $name): string return $running > 0 ? 'partial' : 'stopped'; } - public function seed(string $name, string $preset, int $seed): void + public function seed(string $name, string $preset, int $seed, string $action = 'seed'): void { $this->project->board($name); - $this->runSeeder($name, $preset, $seed); + if (!in_array($action, ['seed', 'reset', 'replace'], true)) + { + throw new \InvalidArgumentException("Unknown seed action: $action"); + } + + if ($action === 'reset' || $action === 'replace') + { + $this->deleteSeedMarker($name, $preset); + } + + $this->runSeeder($name, $preset, $seed, $action); + + if ($action === 'replace') + { + $this->writeSeedMarker($name, $preset); + } } private function seedIfNeeded(string $name, string $preset): void @@ -112,7 +127,7 @@ private function seedIfNeeded(string $name, string $preset): void return; } - $marker = $this->project->runtimePath($name) . '/seeded-' . preg_replace('/[^A-Za-z0-9._-]/', '_', $preset); + $marker = $this->seedMarker($name, $preset); if (file_exists($marker)) { echo "Populate preset already applied: $preset\n"; @@ -120,7 +135,7 @@ private function seedIfNeeded(string $name, string $preset): void } $this->runSeeder($name, $preset, 1); - file_put_contents($marker, gmdate('c') . "\n"); + $this->writeSeedMarker($name, $preset); } private function waitUntilInstalled(string $name): void @@ -142,13 +157,32 @@ private function waitUntilInstalled(string $name): void throw new \RuntimeException("Timed out waiting for phpBB install to complete for board: $name"); } - private function runSeeder(string $name, string $preset, int $seed): void + private function runSeeder(string $name, string $preset, int $seed, string $action = 'seed'): void { $writer = new SeederWriter($this->project); $script = $writer->write($name); $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', 'php', '/tmp/qi_seed.php', $preset, (string) $seed]); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'exec', '-T', 'web', 'php', '/tmp/qi_seed.php', $preset, (string) $seed, $action]); + } + + private function seedMarker(string $name, string $preset): string + { + return $this->project->runtimePath($name) . '/seeded-' . preg_replace('/[^A-Za-z0-9._-]/', '_', $preset); + } + + private function deleteSeedMarker(string $name, string $preset): void + { + $marker = $this->seedMarker($name, $preset); + if (file_exists($marker)) + { + unlink($marker); + } + } + + private function writeSeedMarker(string $name, string $preset): void + { + file_put_contents($this->seedMarker($name, $preset), gmdate('c') . "\n"); } private function run(array $command): void diff --git a/src/QuickInstall/Modern/SeederWriter.php b/src/QuickInstall/Modern/SeederWriter.php index b6f74a1c..67a404b2 100644 --- a/src/QuickInstall/Modern/SeederWriter.php +++ b/src/QuickInstall/Modern/SeederWriter.php @@ -29,6 +29,7 @@ private function script(): string $preset = $argv[1] ?? 'extension-dev'; $seed = (int) ($argv[2] ?? 1); +$action = $argv[3] ?? 'seed'; $presets = [ 'tiny' => ['users' => 3, 'categories' => 1, 'forums_per_category' => 2, 'topics' => 2, 'replies' => 2], @@ -43,6 +44,12 @@ private function script(): string exit(1); } +if (!in_array($action, ['seed', 'reset', 'replace'], true)) +{ + fwrite(STDERR, "Unknown seed action: $action\n"); + exit(1); +} + $phpbb_root_path = '/var/www/html/'; $phpEx = 'php'; define('IN_PHPBB', true); @@ -65,6 +72,16 @@ private function script(): string mt_srand($seed); $counts = qi_seed_resolve_counts($presets[$preset]); +if ($action === 'reset' || $action === 'replace') +{ + $reset = qi_seed_reset($db, $seed); + echo "Reset seed $seed: {$reset['topics']} topics, {$reset['forums']} forums, {$reset['users']} users\n"; + if ($action === 'reset') + { + exit(0); + } +} + $users = qi_seed_users($db, $phpbb_container, $counts['users'], $seed); $forums = qi_seed_forums($db, $counts['categories'], $counts['forums_per_category'], $seed); if (!$forums) @@ -83,6 +100,60 @@ private function script(): string echo "Seeded preset $preset: " . count($users) . " users available, " . count($forums) . " forums available, $created_topics topics\n"; +function qi_seed_reset($db, int $seed): array +{ + $topic_ids = qi_seed_ids_by_like($db, TOPICS_TABLE, 'topic_id', 'topic_title', sprintf('QI seeded topic %d-', $seed) . '%'); + $forum_ids = qi_seed_ids_by_like($db, FORUMS_TABLE, 'forum_id', 'forum_name', sprintf('QI seed %d ', $seed) . '%'); + $user_ids = qi_seed_ids_by_like($db, USERS_TABLE, 'user_id', 'username', sprintf('qi_user_%d_', $seed) . '%'); + + if ($topic_ids) + { + delete_topics('topic_id', $topic_ids, true, true, true); + } + + if ($forum_ids) + { + $db->sql_query('DELETE FROM ' . ACL_GROUPS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); + $db->sql_query('DELETE FROM ' . ACL_USERS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); + $db->sql_query('DELETE FROM ' . FORUMS_TRACK_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); + $db->sql_query('DELETE FROM ' . FORUMS_WATCH_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); + $db->sql_query('DELETE FROM ' . FORUMS_ACCESS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); + $db->sql_query('DELETE FROM ' . FORUMS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids)); + + $new_id = 1; + recalc_nested_sets($new_id, 'forum_id', FORUMS_TABLE); + } + + if ($user_ids) + { + user_delete('remove', $user_ids); + } + + qi_seed_clear_caches(); + + return [ + 'topics' => count($topic_ids), + 'forums' => count($forum_ids), + 'users' => count($user_ids), + ]; +} + +function qi_seed_ids_by_like($db, string $table, string $id_column, string $text_column, string $pattern): array +{ + $sql = "SELECT $id_column + FROM $table + WHERE $text_column LIKE '" . $db->sql_escape($pattern) . "'"; + $result = $db->sql_query($sql); + $ids = []; + while ($row = $db->sql_fetchrow($result)) + { + $ids[] = (int) $row[$id_column]; + } + $db->sql_freeresult($result); + + return $ids; +} + function qi_seed_resolve_counts(array $preset): array { if (empty($preset['randomize'])) From a4b18cee12d0171047b1aec6075c6dec59435d6a Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 29 Jun 2026 15:22:09 -0700 Subject: [PATCH 07/43] Fix user post counts --- docs/modernization-cli.md | 2 +- src/QuickInstall/Modern/SeederWriter.php | 33 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/modernization-cli.md b/docs/modernization-cli.md index 9b4fec80..83c8cc6e 100644 --- a/docs/modernization-cli.md +++ b/docs/modernization-cli.md @@ -138,7 +138,7 @@ load-test 100 users, 4 categories, 20 forums, 100 topics, 20 replies per top random up to 100 users, up to 4 categories, up to 20 forums, up to 100 topics, up to 20 replies per topic ``` -The seeder targets phpBB 3.2+ style boards and uses phpBB APIs inside the `web` container. It creates categories/forums directly, creates users through `user_add()`, and creates topics/replies through `submit_post()`. Topic and reply authors are chosen randomly from the seeded users for the selected seed. Seeded topic titles use the DB topic ID suffix, so phpBB's default demo topic is reflected in numbering. The `random` preset uses `load-test` as caps and chooses counts from `1..cap` for users/categories/forums/topics and `0..cap` for replies. +The seeder targets phpBB 3.2+ style boards and uses phpBB APIs inside the `web` container. It creates categories/forums directly, creates users through `user_add()`, and creates topics/replies through `submit_post()`. Topic and reply authors are chosen randomly from the seeded users for the selected seed. Seeded topic titles use the DB topic ID suffix, so phpBB's default demo topic is reflected in numbering. After seed/reset, user post totals are recalculated from approved counted posts. The `random` preset uses `load-test` as caps and chooses counts from `1..cap` for users/categories/forums/topics and `0..cap` for replies. ## Current Limits diff --git a/src/QuickInstall/Modern/SeederWriter.php b/src/QuickInstall/Modern/SeederWriter.php index 67a404b2..9c5aa289 100644 --- a/src/QuickInstall/Modern/SeederWriter.php +++ b/src/QuickInstall/Modern/SeederWriter.php @@ -78,6 +78,7 @@ private function script(): string echo "Reset seed $seed: {$reset['topics']} topics, {$reset['forums']} forums, {$reset['users']} users\n"; if ($action === 'reset') { + qi_seed_sync_user_post_counts($db); exit(0); } } @@ -97,6 +98,8 @@ private function script(): string } $created_topics = qi_seed_posts($forums, $users, $counts['topics'], $counts['replies'], $seed); +qi_seed_mark_posts_counted($db, $seed); +qi_seed_sync_user_post_counts($db); echo "Seeded preset $preset: " . count($users) . " users available, " . count($forums) . " forums available, $created_topics topics\n"; @@ -129,6 +132,7 @@ function qi_seed_reset($db, int $seed): array user_delete('remove', $user_ids); } + qi_seed_sync_user_post_counts($db); qi_seed_clear_caches(); return [ @@ -154,6 +158,35 @@ function qi_seed_ids_by_like($db, string $table, string $id_column, string $text return $ids; } +function qi_seed_sync_user_post_counts($db): void +{ + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_posts = 0'); + + $sql = 'SELECT poster_id, COUNT(post_id) AS post_count + FROM ' . POSTS_TABLE . ' + WHERE poster_id > 1 + AND post_postcount = 1 + AND post_visibility = ' . ITEM_APPROVED . ' + GROUP BY poster_id'; + $result = $db->sql_query($sql); + while ($row = $db->sql_fetchrow($result)) + { + $db->sql_query('UPDATE ' . USERS_TABLE . ' + SET user_posts = ' . (int) $row['post_count'] . ' + WHERE user_id = ' . (int) $row['poster_id']); + } + $db->sql_freeresult($result); +} + +function qi_seed_mark_posts_counted($db, int $seed): void +{ + $pattern = sprintf('%%QI seeded topic %d-%%', $seed); + $db->sql_query('UPDATE ' . POSTS_TABLE . ' + SET post_postcount = 1 + WHERE post_subject LIKE \'' . $db->sql_escape($pattern) . '\''); +} + function qi_seed_resolve_counts(array $preset): array { if (empty($preset['randomize'])) From d1453f89f22f1d08156695ddcb35f8f177ac9cd0 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 29 Jun 2026 17:54:08 -0700 Subject: [PATCH 08/43] Get phpBB4 working --- src/QuickInstall/Modern/BoardRunner.php | 23 +++++++++++++++++++ .../Modern/DockerComposeWriter.php | 1 - src/QuickInstall/Modern/SourceProvider.php | 11 +++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/QuickInstall/Modern/BoardRunner.php b/src/QuickInstall/Modern/BoardRunner.php index 45e66efe..d6fa77b2 100644 --- a/src/QuickInstall/Modern/BoardRunner.php +++ b/src/QuickInstall/Modern/BoardRunner.php @@ -151,12 +151,35 @@ private function waitUntilInstalled(string $name): void return; } + $state = $this->serviceState($name, 'web'); + if (in_array($state, ['exited', 'dead'], true)) + { + throw new \RuntimeException("Web container exited before phpBB install completed for board: $name. Run: docker compose -f " . $this->project->composePath($name) . " logs web"); + } + usleep(500000); } throw new \RuntimeException("Timed out waiting for phpBB install to complete for board: $name"); } + private function serviceState(string $name, string $service): string + { + $result = $this->capture(['docker', 'compose', '-f', $this->project->composePath($name), 'ps', $service, '--format', 'json']); + if ($result['exit_code'] !== 0 || trim($result['output']) === '') + { + return ''; + } + + $data = json_decode(trim($result['output']), true); + if (isset($data[0]) && is_array($data[0])) + { + $data = $data[0]; + } + + return strtolower((string) ($data['State'] ?? '')); + } + private function runSeeder(string $name, string $preset, int $seed, string $action = 'seed'): void { $writer = new SeederWriter($this->project); diff --git a/src/QuickInstall/Modern/DockerComposeWriter.php b/src/QuickInstall/Modern/DockerComposeWriter.php index 9ccbfcb3..19222138 100644 --- a/src/QuickInstall/Modern/DockerComposeWriter.php +++ b/src/QuickInstall/Modern/DockerComposeWriter.php @@ -67,7 +67,6 @@ private function installConfig(string $name, array $config): string smtp_delivery: false smtp_host: "" smtp_port: 25 - smtp_auth: PLAIN smtp_user: "" smtp_pass: "" server: diff --git a/src/QuickInstall/Modern/SourceProvider.php b/src/QuickInstall/Modern/SourceProvider.php index 24c7a4e7..3f5b7c71 100644 --- a/src/QuickInstall/Modern/SourceProvider.php +++ b/src/QuickInstall/Modern/SourceProvider.php @@ -87,7 +87,13 @@ public function fetch(array $source): void if (is_dir($path) && $this->hasFiles($path)) { - throw new \RuntimeException("Source path already exists and is not empty: $path"); + if (file_exists($path . '/common.php')) + { + return; + } + + echo "Removing incomplete source path: $path\n"; + $this->project->deleteTree($path); } if ($source['type'] === 'git') @@ -104,7 +110,7 @@ public function fetch(array $source): void $path, ], dirname($path)); - $this->run(['composer', 'install', '--no-interaction'], $path); + $this->run(['composer', 'install', '--no-interaction', '--ignore-platform-reqs'], $path); return; } @@ -119,6 +125,7 @@ public function fetch(array $source): void 'phpbb/phpbb', $path, '--no-interaction', + '--ignore-platform-reqs', ]; if (!empty($source['constraint'])) { From ee21da1ba0585c9304a78c1a3cdd23f1b6932534 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 29 Jun 2026 21:17:40 -0700 Subject: [PATCH 09/43] Add extension support by binding to docker container --- .gitignore | 2 + README.md | 6 + bin/qi | 1 + docs/modernization-cli.md | 47 ++++ extensions/.gitkeep | 1 + src/QuickInstall/Modern/Application.php | 101 +++++++ src/QuickInstall/Modern/BoardRunner.php | 12 + .../Modern/DockerComposeWriter.php | 31 ++- src/QuickInstall/Modern/ExtensionManager.php | 259 ++++++++++++++++++ src/QuickInstall/Modern/Project.php | 16 ++ 10 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 extensions/.gitkeep create mode 100644 src/QuickInstall/Modern/ExtensionManager.php diff --git a/.gitignore b/.gitignore index b7aa6aa1..6ff0468b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ sources/* vendor/* build/* .qi/* +extensions/* +!extensions/.gitkeep *~ .idea node_modules diff --git a/README.md b/README.md index 0668e089..e4a510c4 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,12 @@ php bin/qi board:start test The modern CLI targets phpBB 3.2+ installer-based boards. phpBB 3.0/3.1 remain legacy-web-app territory and are not planned for this Docker CLI. +Downloaded extensions can be unzipped into `extensions/` and mounted into boards: + +```bash +php bin/qi ext:mount test extensions/vendor/extname +``` + See [docs/modernization-cli.md](docs/modernization-cli.md). | phpBB | PHP | MySQL | MariaDB | PostgreSQL | SQLite | MS SQL | diff --git a/bin/qi b/bin/qi index 5afa1372..8b9bd1a0 100755 --- a/bin/qi +++ b/bin/qi @@ -9,6 +9,7 @@ require __DIR__ . '/../src/QuickInstall/Modern/SourceProvider.php'; require __DIR__ . '/../src/QuickInstall/Modern/DockerComposeWriter.php'; require __DIR__ . '/../src/QuickInstall/Modern/BoardRunner.php'; require __DIR__ . '/../src/QuickInstall/Modern/SeederWriter.php'; +require __DIR__ . '/../src/QuickInstall/Modern/ExtensionManager.php'; use QuickInstall\Modern\Application; diff --git a/docs/modernization-cli.md b/docs/modernization-cli.md index 83c8cc6e..3b1c20c5 100644 --- a/docs/modernization-cli.md +++ b/docs/modernization-cli.md @@ -113,6 +113,53 @@ test running 3.3.17 PHP 8.1 mariadb populate:extension-dev http://localhos Statuses are `running`, `stopped`, `partial`, `missing`, or `error`. +## Extension Drop Zone + +Downloaded extensions can be unzipped into the visible local extension library: + +```text +extensions/ +``` + +Example layout: + +```text +extensions/phpbb/pages/composer.json +extensions/vendor/extname/composer.json +``` + +Mount an extension into a board: + +```bash +php bin/qi ext:mount test extensions/phpbb/pages +``` + +The CLI reads `composer.json` and uses its `name`, such as `phpbb/pages`, to create the normal phpBB target inside the board container: + +```text +/var/www/html/ext/phpbb/pages +``` + +Mounts use Docker bind mounts by default, so edits in `extensions/phpbb/pages` are reflected in the board immediately and phpBB generates normal web asset paths. To copy files instead: + +```bash +php bin/qi ext:mount test extensions/phpbb/pages --copy +``` + +When a running board is mounted/unmounted, the CLI purges phpBB's cache so the ACP extension list refreshes. + +List mounted extensions: + +```bash +php bin/qi ext:list test +``` + +Unmount an extension from a board: + +```bash +php bin/qi ext:unmount test phpbb/pages +``` + ## Fixture Seeding Seed an installed, running board: diff --git a/extensions/.gitkeep b/extensions/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/extensions/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/QuickInstall/Modern/Application.php b/src/QuickInstall/Modern/Application.php index be405d91..55ceb750 100644 --- a/src/QuickInstall/Modern/Application.php +++ b/src/QuickInstall/Modern/Application.php @@ -61,6 +61,15 @@ public function run(array $argv): int 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); + default: fwrite(STDERR, "Unknown command: $command\n\n"); $this->help(); @@ -189,6 +198,7 @@ private function boardCreate(array $args): int 'admin_pass' => 'password', 'admin_email' => 'admin@example.test', 'board_email' => 'board@example.test', + 'extensions' => [], ]); $this->project->appendBoard([ @@ -198,9 +208,11 @@ private function boardCreate(array $args): int 'phpbb_branch' => $source['phpbb_branch'], 'php' => $runtime['php'], 'db' => $db, + 'port' => $port, 'url' => "http://localhost:$port/", 'path' => $boardDir, 'populate' => $populate, + 'extensions' => [], 'created_at' => gmdate('c'), ]); @@ -296,6 +308,91 @@ private function boardName(array $args, string $usage): string return $name; } + 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]'); + } + + $mounted = (new ExtensionManager($this->project))->mount($board, $source, $cli->has('copy')); + $this->refreshBoardIfRunning($board); + echo "Mounted {$mounted['name']} on $board ({$mounted['mode']})\n"; + echo "Source: {$mounted['source']}\n"; + echo "Target: {$mounted['target']}\n"; + return 0; + } + + 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' => [], + ]; + } + + 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 help(): void { echo << qi board:destroy qi board:seed [--preset tiny|extension-dev|load-test|random] [--seed N] [--reset|--replace] + qi ext:mount [--copy] + qi ext:unmount + qi ext:list Examples: qi source:add 3.3.17 @@ -320,6 +420,7 @@ private function help(): void qi board:create test --phpbb 3.3.17 --db mariadb --port 8081 --populate extension-dev qi board:start test qi board:seed test --preset extension-dev --seed 1 + qi ext:mount test extensions/vendor/extname TXT; } diff --git a/src/QuickInstall/Modern/BoardRunner.php b/src/QuickInstall/Modern/BoardRunner.php index d6fa77b2..05b0f344 100644 --- a/src/QuickInstall/Modern/BoardRunner.php +++ b/src/QuickInstall/Modern/BoardRunner.php @@ -120,6 +120,18 @@ public function seed(string $name, string $preset, int $seed, string $action = ' } } + public function purgeCache(string $name): void + { + $this->project->board($name); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'exec', '-T', 'web', 'sh', '-lc', 'php bin/phpbbcli.php cache:purge']); + } + + public function recreateWeb(string $name): void + { + $this->project->board($name); + $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'up', '-d', '--force-recreate', 'web']); + } + private function seedIfNeeded(string $name, string $preset): void { if ($preset === 'none' || $preset === '') diff --git a/src/QuickInstall/Modern/DockerComposeWriter.php b/src/QuickInstall/Modern/DockerComposeWriter.php index 19222138..3f7b5fd7 100644 --- a/src/QuickInstall/Modern/DockerComposeWriter.php +++ b/src/QuickInstall/Modern/DockerComposeWriter.php @@ -86,6 +86,7 @@ private function compose(string $name, array $config): string $dbService = $this->databaseService($config['db'], $name); $sourcePath = $this->project->sourcePath($config['phpbb_source'] ?? $config['phpbb']); $boardPath = $this->project->boardPath($name); + $extensionVolumes = $this->extensionVolumes($config['extensions'] ?? []); $dbPath = $this->project->workspacePath('db/' . $name); if (!is_dir($dbPath) && !mkdir($dbPath, 0775, true)) { @@ -105,7 +106,7 @@ private function compose(string $name, array $config): string volumes: - {$sourcePath}:/opt/phpbb-source:ro - {$boardPath}:/var/www/html - - ./install-config.yml:/opt/quickinstall/install-config.yml:ro +{$extensionVolumes} - ./install-config.yml:/opt/quickinstall/install-config.yml:ro - ./entrypoint.sh:/opt/quickinstall/entrypoint.sh:ro entrypoint: ["/bin/sh", "/opt/quickinstall/entrypoint.sh"] depends_on: @@ -120,6 +121,34 @@ private function compose(string $name, array $config): string YAML; } + private function extensionVolumes(array $extensions): string + { + $volumes = ''; + foreach ($extensions as $name => $extension) + { + if (($extension['mode'] ?? '') !== 'bind') + { + continue; + } + + $source = $extension['source'] ?? ''; + if ($source === '') + { + continue; + } + + $target = '/var/www/html/ext/' . $name; + $volumes .= ' - ' . $this->yamlString($source . ':' . $target) . "\n"; + } + + return $volumes; + } + + private function yamlString(string $value): string + { + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; + } + private function dockerfile(array $config): string { $extensionInstall = $config['db'] === 'postgres' ? 'docker-php-ext-install pgsql pdo_pgsql' : 'docker-php-ext-install mysqli pdo_mysql'; diff --git a/src/QuickInstall/Modern/ExtensionManager.php b/src/QuickInstall/Modern/ExtensionManager.php new file mode 100644 index 00000000..c1333ff1 --- /dev/null +++ b/src/QuickInstall/Modern/ExtensionManager.php @@ -0,0 +1,259 @@ +project = $project; + } + + public function mount(string $board, string $source, bool $copy = false): array + { + $boardConfig = $this->project->board($board); + $sourcePath = $this->resolvePath($source); + if (!is_dir($sourcePath)) + { + throw new \InvalidArgumentException("Extension source is not a directory: $source"); + } + + $name = $this->extensionName($sourcePath); + [$vendor, $extension] = explode('/', $name, 2); + $target = $this->project->boardPath($board) . '/ext/' . $vendor . '/' . $extension; + $extensions = $boardConfig['extensions'] ?? []; + + if (file_exists($target) || is_link($target)) + { + if (!$copy && (is_link($target) || !is_file($target . '/composer.json'))) + { + $this->project->deleteTree($target); + } + else if (!$copy && isset($extensions[$name]) && ($extensions[$name]['mode'] ?? '') === 'bind') + { + $extensions[$name] = ['mode' => 'bind', 'source' => $sourcePath]; + $boardConfig['extensions'] = $extensions; + $this->project->appendBoard($boardConfig); + + return ['name' => $name, 'source' => $sourcePath, 'target' => '/var/www/html/ext/' . $name, 'mode' => 'bind']; + } + else + { + throw new \RuntimeException("Extension target already exists: $target"); + } + } + + if ($copy) + { + $parent = dirname($target); + if (!is_dir($parent) && !mkdir($parent, 0775, true)) + { + throw new \RuntimeException("Unable to create extension target parent: $parent"); + } + + $this->copyTree($sourcePath, $target); + $mode = 'copy'; + $extensions[$name] = ['mode' => 'copy', 'source' => $target]; + } + else + { + $mode = 'bind'; + $extensions[$name] = ['mode' => 'bind', 'source' => $sourcePath]; + $target = '/var/www/html/ext/' . $name; + } + + $boardConfig['extensions'] = $extensions; + $this->project->appendBoard($boardConfig); + + return ['name' => $name, 'source' => $sourcePath, 'target' => $target, 'mode' => $mode]; + } + + public function unmount(string $board, string $name): string + { + $boardConfig = $this->project->board($board); + $this->assertExtensionName($name); + $target = $this->project->boardPath($board) . '/ext/' . $name; + $extensions = $boardConfig['extensions'] ?? []; + $isBind = isset($extensions[$name]) && ($extensions[$name]['mode'] ?? '') === 'bind'; + + if (!isset($extensions[$name]) && !file_exists($target) && !is_link($target)) + { + throw new \InvalidArgumentException("Extension is not mounted: $name"); + } + + if (!$isBind) + { + $this->project->deleteTree($target); + $this->removeEmptyParents(dirname($target), $this->project->boardPath($board) . '/ext'); + } + + unset($extensions[$name]); + $boardConfig['extensions'] = $extensions; + $this->project->appendBoard($boardConfig); + + return $isBind ? '/var/www/html/ext/' . $name : $target; + } + + public function cleanupStaleTarget(string $board, string $name): void + { + $this->assertExtensionName($name); + $target = $this->project->boardPath($board) . '/ext/' . $name; + $this->project->deleteTree($target); + $this->removeEmptyParents(dirname($target), $this->project->boardPath($board) . '/ext'); + } + + + public function list(string $board): array + { + $boardConfig = $this->project->board($board); + $mounted = []; + foreach (($boardConfig['extensions'] ?? []) as $name => $extension) + { + $mounted[$name] = [ + 'name' => $name, + 'mode' => $extension['mode'] ?? 'bind', + 'target' => '/var/www/html/ext/' . $name, + 'source' => $extension['source'] ?? '', + ]; + } + + $extPath = $this->project->boardPath($board) . '/ext'; + if (!is_dir($extPath)) + { + return array_values($mounted); + } + + foreach (scandir($extPath) ?: [] as $vendor) + { + if ($vendor === '.' || $vendor === '..' || !is_dir($extPath . '/' . $vendor)) + { + continue; + } + + foreach (scandir($extPath . '/' . $vendor) ?: [] as $extension) + { + if ($extension === '.' || $extension === '..') + { + continue; + } + + $path = $extPath . '/' . $vendor . '/' . $extension; + if (!is_dir($path) && !is_link($path)) + { + continue; + } + + $name = $vendor . '/' . $extension; + if (isset($mounted[$name])) + { + continue; + } + + if (!is_file($path . '/composer.json')) + { + continue; + } + + $mounted[$name] = [ + 'name' => $name, + 'mode' => is_link($path) ? 'symlink' : 'copy', + 'target' => $path, + 'source' => is_link($path) ? readlink($path) : $path, + ]; + } + } + + return array_values($mounted); + } + + private function resolvePath(string $path): string + { + $candidates = [ + $path, + $this->project->rootPath($path), + $this->project->extensionsPath() . '/' . ltrim($path, '/'), + ]; + + foreach ($candidates as $candidate) + { + $real = realpath($candidate); + if ($real !== false) + { + return $real; + } + } + + throw new \InvalidArgumentException("Path not found: $path"); + } + + private function extensionName(string $sourcePath): string + { + $composer = $sourcePath . '/composer.json'; + if (!is_file($composer)) + { + throw new \InvalidArgumentException("Extension source must contain composer.json: $sourcePath"); + } + + $data = json_decode((string) file_get_contents($composer), true); + if (!is_array($data) || empty($data['name'])) + { + throw new \InvalidArgumentException("Extension composer.json must contain a name like vendor/extension: $composer"); + } + + $name = strtolower((string) $data['name']); + $this->assertExtensionName($name); + + return $name; + } + + private function assertExtensionName(string $name): void + { + if (!preg_match('/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/', $name)) + { + throw new \InvalidArgumentException("Invalid extension name: $name"); + } + } + + private function copyTree(string $source, string $target): void + { + if (is_dir($target)) + { + throw new \RuntimeException("Copy target already exists: $target"); + } + + if (!mkdir($target, 0775, true)) + { + throw new \RuntimeException("Unable to create copy target: $target"); + } + + foreach (scandir($source) ?: [] as $item) + { + if ($item === '.' || $item === '..') + { + continue; + } + + $src = $source . '/' . $item; + $dst = $target . '/' . $item; + if (is_dir($src) && !is_link($src)) + { + $this->copyTree($src, $dst); + } + else if (!copy($src, $dst)) + { + throw new \RuntimeException("Unable to copy $src to $dst"); + } + } + } + + private function removeEmptyParents(string $path, string $stop): void + { + while ($path !== $stop && is_dir($path) && count(array_diff(scandir($path) ?: [], ['.', '..'])) === 0) + { + rmdir($path); + $path = dirname($path); + } + } +} diff --git a/src/QuickInstall/Modern/Project.php b/src/QuickInstall/Modern/Project.php index 48425183..e9a811b8 100644 --- a/src/QuickInstall/Modern/Project.php +++ b/src/QuickInstall/Modern/Project.php @@ -15,6 +15,12 @@ public function __construct(string $root) public function init(): void { + $extensionsPath = $this->extensionsPath(); + if (!is_dir($extensionsPath) && !mkdir($extensionsPath, 0775, true)) + { + throw new \RuntimeException("Unable to create $extensionsPath"); + } + foreach (['', '/sources', '/boards', '/runtime', '/db', '/cache'] as $dir) { $path = $this->workspace . $dir; @@ -39,6 +45,16 @@ public function workspacePath(string $path = ''): string return $this->workspace . ($path === '' ? '' : '/' . ltrim($path, '/')); } + public function rootPath(string $path = ''): string + { + return $this->root . ($path === '' ? '' : '/' . ltrim($path, '/')); + } + + public function extensionsPath(): string + { + return $this->rootPath('extensions'); + } + public function boardPath(string $name): string { $this->assertName($name, 'board'); From 3ab5400a85b359ccff1fd5f78dbdb197894511fd Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 30 Jun 2026 07:28:49 -0700 Subject: [PATCH 10/43] Fixes for older phpBB 3.2 boards --- src/QuickInstall/Modern/BoardRunner.php | 50 +++++++------------ .../Modern/DockerComposeWriter.php | 17 ++++++- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/QuickInstall/Modern/BoardRunner.php b/src/QuickInstall/Modern/BoardRunner.php index 05b0f344..e73c152b 100644 --- a/src/QuickInstall/Modern/BoardRunner.php +++ b/src/QuickInstall/Modern/BoardRunner.php @@ -48,55 +48,32 @@ public function status(string $name): string return 'missing'; } - $result = $this->capture(['docker', 'compose', '-f', $compose, 'ps', '--format', 'json']); - if ($result['exit_code'] !== 0) + $all = $this->capture(['docker', 'compose', '-f', $compose, 'ps', '-a', '--services']); + if ($all['exit_code'] !== 0) { return 'error'; } - $output = trim($result['output']); - if ($output === '') + $services = $this->lines($all['output']); + if (!$services) { return 'stopped'; } - $containers = []; - foreach (explode("\n", $output) as $line) + $running = $this->capture(['docker', 'compose', '-f', $compose, 'ps', '--services', '--filter', 'status=running']); + if ($running['exit_code'] !== 0) { - $data = json_decode($line, true); - if (is_array($data)) - { - $containers[] = $data; - } - } - - if (!$containers) - { - $data = json_decode($output, true); - $containers = is_array($data) && isset($data[0]) ? $data : []; - } - - if (!$containers) - { - return 'stopped'; + return 'error'; } - $running = 0; - foreach ($containers as $container) - { - $state = strtolower((string) ($container['State'] ?? '')); - if ($state === 'running') - { - $running++; - } - } + $runningServices = $this->lines($running['output']); - if ($running === count($containers)) + if (count($runningServices) === count($services)) { return 'running'; } - return $running > 0 ? 'partial' : 'stopped'; + return $runningServices ? 'partial' : 'stopped'; } public function seed(string $name, string $preset, int $seed, string $action = 'seed'): void @@ -267,4 +244,11 @@ private function capture(array $command): array 'output' => $output . $error, ]; } + + private function lines(string $output): array + { + return array_values(array_filter(array_map('trim', explode("\n", $output)), static function ($line) { + return $line !== ''; + })); + } } diff --git a/src/QuickInstall/Modern/DockerComposeWriter.php b/src/QuickInstall/Modern/DockerComposeWriter.php index 3f7b5fd7..00b46d05 100644 --- a/src/QuickInstall/Modern/DockerComposeWriter.php +++ b/src/QuickInstall/Modern/DockerComposeWriter.php @@ -152,12 +152,14 @@ private function yamlString(string $value): string private function dockerfile(array $config): string { $extensionInstall = $config['db'] === 'postgres' ? 'docker-php-ext-install pgsql pdo_pgsql' : 'docker-php-ext-install mysqli pdo_mysql'; + $aptSourceSetup = $this->aptSourceSetup($config['php']); return <<=')) + { + return ''; + } + + return "sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list \\\n" + . " && sed -i '/debian-security/d' /etc/apt/sources.list \\\n" + . " && echo 'Acquire::Check-Valid-Until \"false\";' > /etc/apt/apt.conf.d/99quickinstall-archive \\\n" + . " && "; + } + private function entrypoint(array $config): string { return <<<'SH' From db3c4a37e3842e2cdd7025b7f2a75771d61a5db9 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 30 Jun 2026 09:52:50 -0700 Subject: [PATCH 11/43] Add styles support by binding to docker container --- .gitignore | 2 + README.md | 6 + bin/qi | 1 + docs/modernization-cli.md | 47 ++++ src/QuickInstall/Modern/Application.php | 72 ++++++ .../Modern/DockerComposeWriter.php | 26 ++- src/QuickInstall/Modern/Project.php | 11 + src/QuickInstall/Modern/StyleManager.php | 209 ++++++++++++++++++ styles/.gitkeep | 1 + 9 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 src/QuickInstall/Modern/StyleManager.php create mode 100644 styles/.gitkeep diff --git a/.gitignore b/.gitignore index 6ff0468b..c74cf776 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ build/* .qi/* extensions/* !extensions/.gitkeep +styles/* +!styles/.gitkeep *~ .idea node_modules diff --git a/README.md b/README.md index e4a510c4..31935673 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ Downloaded extensions can be unzipped into `extensions/` and mounted into boards php bin/qi ext:mount test extensions/vendor/extname ``` +Downloaded styles can be unzipped into `styles/` and mounted into boards: + +```bash +php bin/qi style:mount test styles/stylename +``` + See [docs/modernization-cli.md](docs/modernization-cli.md). | phpBB | PHP | MySQL | MariaDB | PostgreSQL | SQLite | MS SQL | diff --git a/bin/qi b/bin/qi index 8b9bd1a0..ea2e8c7a 100755 --- a/bin/qi +++ b/bin/qi @@ -10,6 +10,7 @@ require __DIR__ . '/../src/QuickInstall/Modern/DockerComposeWriter.php'; require __DIR__ . '/../src/QuickInstall/Modern/BoardRunner.php'; require __DIR__ . '/../src/QuickInstall/Modern/SeederWriter.php'; require __DIR__ . '/../src/QuickInstall/Modern/ExtensionManager.php'; +require __DIR__ . '/../src/QuickInstall/Modern/StyleManager.php'; use QuickInstall\Modern\Application; diff --git a/docs/modernization-cli.md b/docs/modernization-cli.md index 3b1c20c5..335eb5d1 100644 --- a/docs/modernization-cli.md +++ b/docs/modernization-cli.md @@ -160,6 +160,53 @@ Unmount an extension from a board: php bin/qi ext:unmount test phpbb/pages ``` +## Style Drop Zone + +Downloaded styles can be unzipped into the visible local style library: + +```text +styles/ +``` + +Example layout: + +```text +styles/prosilver_se/style.cfg +styles/stylename/style.cfg +``` + +Mount a style into a board: + +```bash +php bin/qi style:mount test styles/stylename +``` + +The CLI uses the style folder name and creates the normal phpBB target inside the board container: + +```text +/var/www/html/styles/stylename +``` + +Mounts use Docker bind mounts by default, so edits in `styles/stylename` are reflected in the board immediately and phpBB generates normal web asset paths. To copy files instead: + +```bash +php bin/qi style:mount test styles/stylename --copy +``` + +When a running board is mounted/unmounted, the CLI recreates the web container and purges phpBB's cache so the ACP style list refreshes. + +List mounted styles: + +```bash +php bin/qi style:list test +``` + +Unmount a style from a board: + +```bash +php bin/qi style:unmount test stylename +``` + ## Fixture Seeding Seed an installed, running board: diff --git a/src/QuickInstall/Modern/Application.php b/src/QuickInstall/Modern/Application.php index 55ceb750..2e586d7c 100644 --- a/src/QuickInstall/Modern/Application.php +++ b/src/QuickInstall/Modern/Application.php @@ -70,6 +70,15 @@ public function run(array $argv): int 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(); @@ -199,6 +208,7 @@ private function boardCreate(array $args): int 'admin_email' => 'admin@example.test', 'board_email' => 'board@example.test', 'extensions' => [], + 'styles' => [], ]); $this->project->appendBoard([ @@ -213,6 +223,7 @@ private function boardCreate(array $args): int 'path' => $boardDir, 'populate' => $populate, 'extensions' => [], + 'styles' => [], 'created_at' => gmdate('c'), ]); @@ -371,6 +382,7 @@ private function runtimeConfig(array $board): array 'board_email' => 'board@example.test', 'populate' => 'none', 'extensions' => [], + 'styles' => [], ]; } @@ -393,6 +405,62 @@ private function extList(array $args): int 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]'); + } + + $mounted = (new StyleManager($this->project))->mount($board, $source, $cli->has('copy')); + $this->refreshBoardIfRunning($board); + echo "Mounted {$mounted['name']} on $board ({$mounted['mode']})\n"; + echo "Source: {$mounted['source']}\n"; + echo "Target: {$mounted['target']}\n"; + return 0; + } + + 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