From 1fc8291be00451e0f98cd035b080aa558617ddb3 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Thu, 16 Jul 2026 10:54:12 -0700 Subject: [PATCH 1/9] Fixed recursive remount behavior for extensions and styles --- src/QuickInstall/Sandbox/ExtensionManager.php | 10 +++++----- src/QuickInstall/Sandbox/StyleManager.php | 10 +++++----- tests/Unit/ExtensionManagerTest.php | 16 ++++++++++++++++ tests/Unit/StyleManagerTest.php | 16 ++++++++++++++++ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/QuickInstall/Sandbox/ExtensionManager.php b/src/QuickInstall/Sandbox/ExtensionManager.php index dbaafce3..2b46708f 100644 --- a/src/QuickInstall/Sandbox/ExtensionManager.php +++ b/src/QuickInstall/Sandbox/ExtensionManager.php @@ -40,11 +40,7 @@ public function mount(string $board, string $source, bool $copy = false, bool $a 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') + if (!$copy && isset($extensions[$name]) && ($extensions[$name]['mode'] ?? '') === 'bind') { $extensions[$name] = ['mode' => 'bind', 'source' => $sourcePath]; $boardConfig['extensions'] = $extensions; @@ -52,6 +48,10 @@ public function mount(string $board, string $source, bool $copy = false, bool $a return ['name' => $name, 'source' => $sourcePath, 'target' => '/var/www/html/ext/' . $name, 'mode' => 'bind']; } + else if (!$copy && (is_link($target) || !is_file($target . '/composer.json'))) + { + $this->project->deleteTree($target); + } else { throw new RuntimeException("Extension target already exists: $target"); diff --git a/src/QuickInstall/Sandbox/StyleManager.php b/src/QuickInstall/Sandbox/StyleManager.php index e1409bd1..45daa211 100644 --- a/src/QuickInstall/Sandbox/StyleManager.php +++ b/src/QuickInstall/Sandbox/StyleManager.php @@ -46,11 +46,7 @@ public function mount(string $board, string $source, bool $copy = false, bool $a if (file_exists($target) || is_link($target)) { - if (!$copy && (is_link($target) || !$this->isStylePath($target))) - { - $this->project->deleteTree($target); - } - else if (!$copy && isset($styles[$name]) && ($styles[$name]['mode'] ?? '') === 'bind') + if (!$copy && isset($styles[$name]) && ($styles[$name]['mode'] ?? '') === 'bind') { $styles[$name] = ['mode' => 'bind', 'source' => $sourcePath]; $boardConfig['styles'] = $styles; @@ -58,6 +54,10 @@ public function mount(string $board, string $source, bool $copy = false, bool $a return ['name' => $name, 'source' => $sourcePath, 'target' => '/var/www/html/styles/' . $name, 'mode' => 'bind']; } + else if (!$copy && (is_link($target) || !$this->isStylePath($target))) + { + $this->project->deleteTree($target); + } else { throw new RuntimeException("Style target already exists: $target"); diff --git a/tests/Unit/ExtensionManagerTest.php b/tests/Unit/ExtensionManagerTest.php index 67d33a1c..5b8aa84f 100644 --- a/tests/Unit/ExtensionManagerTest.php +++ b/tests/Unit/ExtensionManagerTest.php @@ -69,6 +69,22 @@ public function testUnmountBindExtensionRemovesMetadataOnly(): void self::assertSame([], $manager->list('demo')); } + public function testRemountPreservesRegisteredBindTarget(): void + { + [$project, $root] = $this->projectWithBoard('demo'); + $source = $this->extension($root, 'vendor/bound', 'customisations/vendor/bound'); + $manager = new ExtensionManager($project); + $manager->mount('demo', $source); + $target = $project->boardPath('demo') . '/ext/vendor/bound'; + mkdir($target, 0775, true); + file_put_contents($target . '/mountpoint.txt', 'preserve'); + + $mounted = $manager->mount('demo', $source); + + self::assertSame('bind', $mounted['mode']); + self::assertFileExists($target . '/mountpoint.txt'); + } + public function testListDiscoversCopiedExtensionWithoutMetadata(): void { [$project, $root] = $this->projectWithBoard('demo'); diff --git a/tests/Unit/StyleManagerTest.php b/tests/Unit/StyleManagerTest.php index 6dc152cb..27cdd77a 100644 --- a/tests/Unit/StyleManagerTest.php +++ b/tests/Unit/StyleManagerTest.php @@ -69,6 +69,22 @@ public function testUnmountBindStyleRemovesMetadataOnly(): void self::assertSame([], $manager->list('demo')); } + public function testRemountPreservesRegisteredBindTarget(): void + { + [$project, $root] = $this->projectWithBoard('demo'); + $source = $this->style($root, 'bound', 'customisations/styles/bound'); + $manager = new StyleManager($project); + $manager->mount('demo', $source); + $target = $project->boardPath('demo') . '/styles/bound'; + mkdir($target, 0775, true); + file_put_contents($target . '/mountpoint.txt', 'preserve'); + + $mounted = $manager->mount('demo', $source); + + self::assertSame('bind', $mounted['mode']); + self::assertFileExists($target . '/mountpoint.txt'); + } + public function testCleanupStaleTargetRemovesStyleDirectoryAndParents(): void { [$project, $root] = $this->projectWithBoard('demo'); From 7b0787147c8590e41a3d0cbe21962ff4efc8e3b3 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Thu, 16 Jul 2026 14:10:18 -0700 Subject: [PATCH 2/9] Group doctor with init under workspace in help commands --- src/QuickInstall/Sandbox/Application.php | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index b1c7f7c1..4cd6e236 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -982,17 +982,6 @@ private function printHelpRows(array $rows): void private function helpCommands(): array { return [ - 'Setup commands' => [ - 'doctor' => [ - 'title' => 'doctor', - 'usage' => 'doctor', - 'summary' => 'Check local PHP, Docker, Git, and Composer requirements.', - 'description' => 'Checks that required host tools are available, Docker Desktop is running, Docker Compose works, and Docker is using Linux containers.', - 'examples' => [ - 'doctor', - ], - ], - ], 'Board commands' => [ 'board:create' => [ 'title' => 'board:create', @@ -1296,6 +1285,15 @@ private function helpCommands(): array 'init', ], ], + 'doctor' => [ + 'title' => 'doctor', + 'usage' => 'doctor', + 'summary' => 'Check local PHP, Docker, Git, and Composer requirements.', + 'description' => 'Checks that required host tools are available, Docker Desktop is running, Docker Compose works, and Docker is using Linux containers.', + 'examples' => [ + 'doctor', + ], + ], ], ]; } From 0a47b94285b9ae274456e831387c148447547636 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Thu, 16 Jul 2026 16:12:07 -0700 Subject: [PATCH 3/9] uninstall styles and extensions before unmounting them --- src/QuickInstall/Sandbox/Application.php | 14 +-- src/QuickInstall/Sandbox/BoardRunner.php | 22 ++++ .../Sandbox/CustomisationManagerInterface.php | 23 ++++ .../Sandbox/CustomisationMountService.php | 4 +- .../Sandbox/CustomisationUnmountService.php | 73 ++++++++++++ src/QuickInstall/Sandbox/ExtensionManager.php | 2 +- src/QuickInstall/Sandbox/StyleManager.php | 2 +- .../Sandbox/StyleUninstallerWriter.php | 93 +++++++++++++++ src/QuickInstall/Sandbox/Web/Application.php | 12 +- src/QuickInstall/Sandbox/bootstrap.php | 3 + tests/Unit/BoardRunnerTest.php | 24 ++++ tests/Unit/CustomisationMountServiceTest.php | 12 +- .../Unit/CustomisationUnmountServiceTest.php | 111 ++++++++++++++++++ 13 files changed, 374 insertions(+), 21 deletions(-) create mode 100644 src/QuickInstall/Sandbox/CustomisationManagerInterface.php create mode 100644 src/QuickInstall/Sandbox/CustomisationUnmountService.php create mode 100644 src/QuickInstall/Sandbox/StyleUninstallerWriter.php create mode 100644 tests/Unit/CustomisationUnmountServiceTest.php diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index 4cd6e236..299b9207 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -611,9 +611,7 @@ private function extUnmount(array $args): int } $extensions = new ExtensionManager($this->project); - $target = $extensions->unmount($board, $name); - $this->refreshBoardIfRunning($board); - $extensions->cleanupStaleTarget($board, $name); + $target = (new CustomisationUnmountService($this->project))->extension($extensions, $board, $name); echo "Unmounted $name from $board\n"; echo "Removed: $target\n"; return 0; @@ -794,9 +792,7 @@ private function styleUnmount(array $args): int } $styles = new StyleManager($this->project); - $target = $styles->unmount($board, $name); - $this->refreshBoardIfRunning($board); - $styles->cleanupStaleTarget($board, $name); + $target = (new CustomisationUnmountService($this->project))->style($styles, $board, $name); echo "Unmounted $name from $board\n"; echo "Removed: $target\n"; return 0; @@ -878,7 +874,7 @@ private function sandboxOutput(): Output return new StreamOutput($stdout, $this->stderr); } - private function mountResources(string $type, object $manager, string $board, string $source, bool $copy, bool $recursive, bool $allowExternal): int + private function mountResources(string $type, CustomisationManagerInterface $manager, string $board, string $source, bool $copy, bool $recursive, bool $allowExternal): int { $result = (new CustomisationMountService($this->project))->mount($manager, $board, $source, $copy, $recursive, $allowExternal); @@ -1096,7 +1092,7 @@ private function helpCommands(): array 'title' => 'ext:unmount', 'usage' => 'ext:unmount ', 'summary' => 'Remove a mounted extension from a board.', - 'description' => 'Removes the extension mount or copied extension files, refreshes the board if running, and clears stale targets.', + 'description' => 'Disables and purges the extension from phpBB before removing its mount or copied files. Installed boards must be running.', 'arguments' => [ '' => 'Required board name.', '' => 'Extension name from composer.json, such as phpbb/foo.', @@ -1142,7 +1138,7 @@ private function helpCommands(): array 'title' => 'style:unmount', 'usage' => 'style:unmount