diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4f6ae085..28985eda 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -24,7 +24,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: posix, dom, curl, libxml, fileinfo, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick + extensions: dom, curl, libxml, fileinfo, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, sockets coverage: none - name: Install dependencies diff --git a/README.md b/README.md index 13f540f9..f79ba9ef 100644 --- a/README.md +++ b/README.md @@ -32,19 +32,19 @@ The recommended way to install Takeout is the dockerized version via an alias (a On Linux or macOS, use: ```bash -alias takeout="docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -it tighten/takeout:latest" +alias takeout="docker run --rm -v /var/run/docker.sock:/var/run/docker.sock --add-host=host.docker.internal:host-gateway -it tighten/takeout:latest" ``` -On Windows 10, if you're using Bash, use: +On Windows 10|11, if you're using Bash, use: ```bash -alias takeout="docker run --rm -v //var/run/docker.sock:/var/run/docker.sock -it tighten/takeout:latest" +alias takeout="docker run --rm -v //var/run/docker.sock:/var/run/docker.sock --add-host=host.docker.internal:host-gateway -it tighten/takeout:latest" ``` -On Windows 10, if you're using PowerShell, use: +On Windows 10|11, if you're using PowerShell, use: ```bash -function takeout { docker run --rm -v //var/run/docker.sock:/var/run/docker.sock -it tighten/takeout:latest $args } +function takeout { docker run --rm -v //var/run/docker.sock:/var/run/docker.sock --add-host=host.docker.internal:host-gateway -it tighten/takeout:latest $args } ``` That's it. You may now use Takeout on your terminal. The first time you use this alias, it will pull the Takeout image from Docker Hub. diff --git a/app/Commands/DisableCommand.php b/app/Commands/DisableCommand.php index 956dc93c..021535ba 100644 --- a/app/Commands/DisableCommand.php +++ b/app/Commands/DisableCommand.php @@ -5,8 +5,13 @@ use App\InitializesCommands; use App\Shell\Docker; use App\Shell\Environment; +use Illuminate\Support\Collection; use LaravelZero\Framework\Commands\Command; use Throwable; +use Illuminate\Support\Str; + +use function Laravel\Prompts\confirm; +use function Laravel\Prompts\select; class DisableCommand extends Command { @@ -25,17 +30,18 @@ public function handle(Docker $docker, Environment $environment) $this->docker = $docker; $this->environment = $environment; $this->initializeCommand(); - $this->disableableServices = $this->disableableServices(); + + $disableableServices = $this->disableableServices(); if ($this->option('all')) { - foreach ($this->disableableServices as $containerId => $name) { + $disableableServices->keys()->each(function ($containerId) { $this->disableByContainerId($containerId); - } + }); return; } - if (empty($this->disableableServices)) { + if ($disableableServices->isEmpty()) { $this->info("There are no containers to disable.\n"); return; @@ -43,82 +49,56 @@ public function handle(Docker $docker, Environment $environment) if (filled($services = $this->argument('serviceNames'))) { foreach ($services as $service) { - $this->disableByServiceName($service); + $this->disableByServiceName($service, $disableableServices); } return; } - $this->showDisableServiceMenu(); + $this->disableByContainerId( + $this->selectOptions($disableableServices), + ); } - public function disableableServices(): array + private function disableableServices(): Collection { return $this->docker->takeoutContainers()->mapWithKeys(function ($container) { return [$container['container_id'] => str_replace('TO--', '', $container['names'])]; - })->toArray(); + }); } - public function disableByServiceName(string $service): void + private function disableByServiceName(string $service, Collection $disableableServices): void { - $serviceMatches = collect($this->disableableServices) - ->filter(function ($containerName) use ($service) { - return substr($containerName, 0, strlen($service)) === $service; - }); - - switch ($serviceMatches->count()) { - case 0: - $this->error("\nCannot find a Takeout-managed instance of {$service}."); + $serviceMatches = $disableableServices->filter(function ($containerName) use ($service) { + return Str::startsWith($containerName, $service); + }); - return; - case 1: - $serviceContainerId = $serviceMatches->flip()->first(); - break; - default: // > 1 - $serviceContainerId = $this->selectMenu($this->disableableServices); + if ($serviceMatches->isEmpty()) { + $this->error("\nCannot find a Takeout-managed instance of {$service}."); - if (! $serviceContainerId) { - return; - } + return; } - $this->disableByContainerId($serviceContainerId); - } - - public function showDisableServiceMenu($disableableServices = null): void - { - if ($serviceContainerId = $this->selectMenu($disableableServices ?? $this->disableableServices)) { - $this->disableByContainerId($serviceContainerId); - } - } + if ($serviceMatches->count() === 1) { + $this->disableByContainerId($serviceMatches->flip()->first()); - private function selectMenu($disableableServices): ?string - { - if ($this->environment->isWindowsOs()) { - return $this->windowsMenu($disableableServices); + return; } - return $this->defaultMenu($disableableServices); + $this->disableByContainerId( + $this->selectOptions($disableableServices), + ); } - private function defaultMenu($disableableServices): ?string + private function selectOptions(Collection $disableableServices) { - return $this->menu(self::MENU_TITLE, $disableableServices) - ->addLineBreak('', 1) - ->setPadding(2, 5) - ->open(); + return select( + label: self::MENU_TITLE, + options: $disableableServices + ); } - private function windowsMenu($disableableServices): ?string - { - array_push($disableableServices, 'Exit'); - - $choice = $this->choice(self::MENU_TITLE, array_values($disableableServices)); - - return array_search($choice, $disableableServices); - } - - public function disableByContainerId(string $containerId): void + private function disableByContainerId(string $containerId): void { try { $volumeName = $this->docker->attachedVolumeName($containerId); @@ -134,16 +114,7 @@ public function disableByContainerId(string $containerId): void if (count($this->docker->allContainers()) === 0) { $question = 'No containers are running. Turn off Docker?'; - if ($this->environment->isWindowsOs()) { - $option = $this->confirm($question); - } else { - $option = $this->menu($question, [ - 'Yes', - 'No', - ])->disableDefaultItems()->open(); - } - - if ($option === 0 || $option === true) { + if (confirm($question)) { $this->task('Stopping Docker service ', $this->docker->stopDockerService()); } } diff --git a/app/Commands/EnableCommand.php b/app/Commands/EnableCommand.php index 57bb9ef5..4b1a447f 100644 --- a/app/Commands/EnableCommand.php +++ b/app/Commands/EnableCommand.php @@ -5,11 +5,12 @@ use App\InitializesCommands; use App\Services; use App\Shell\Environment; -use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; -use Illuminate\Support\Str; use LaravelZero\Framework\Commands\Command; +use function Laravel\Prompts\search; + class EnableCommand extends Command { use InitializesCommands; @@ -46,13 +47,9 @@ public function handle(Environment $environment, Services $services): void return; } - $option = $this->selectService(); + $service = $this->selectService($this->availableServices()); - if (! $option) { - return; - } - - $this->enable($option, $useDefaults, $passthroughOptions); + $this->enable($service, $useDefaults, $passthroughOptions); } /** @@ -60,7 +57,7 @@ public function handle(Environment $environment, Services $services): void * $this->argument, we have to do our own manual overriding for testing scenarios, * because pulling $_SERVER['argv'] won't give the right results in testing. */ - public function serverArguments(): array + private function serverArguments(): array { if (App::environment() === 'testing') { $string = array_merge(['takeout', 'enable'], $this->argument('serviceNames')); @@ -104,7 +101,7 @@ public function extractPassthroughOptions(array $arguments): array public function removeOptions(array $arguments): array { $arguments = collect($arguments) - ->reject(fn ($argument) => str_starts_with($argument, '--') && strlen($argument) > 2) + ->reject(fn($argument) => str_starts_with($argument, '--') && strlen($argument) > 2) ->values() ->toArray(); @@ -119,91 +116,36 @@ public function removeOptions(array $arguments): array return array_slice($arguments, $start); } - private function selectService(): ?string + private function availableServices(): Collection { - if ($this->environment->isWindowsOs()) { - return $this->windowsMenu(); - } - - return $this->defaultMenu(); + return $this->enableableServicesByCategory()->flatMap(function ($services, $category) { + return $this->menuItemsForServices($services)->mapWithKeys(function ($row, $key) use ($category) { + return [$key => "{$category}: {$row}"]; + })->toArray(); + }); } - private function defaultMenu(): ?string + private function selectService(Collection $servicesList): ?string { - $option = $this->menu(self::MENU_TITLE)->setTitleSeparator('='); - - foreach ($this->enableableServicesByCategory() as $category => $services) { - $separator = str_repeat('-', 1 + Str::length($category)); - - $option->addStaticItem("{$category}:") - ->addStaticItem($separator) - ->addOptions($this->menuItemsForServices($services)) - ->addLineBreak('', 1); - } - - return $option->open(); + return search( + label: self::MENU_TITLE, + options: fn(string $value) => strlen($value) > 0 + ? $servicesList->filter(function ($row) use ($value) { + return str($row)->lower()->contains(str($value)->lower()); + })->toArray() + : $servicesList->toArray(), + scroll: 10 + ); } - private function windowsMenu($category = null): ?string - { - $choices = []; - $groupedServices = $this->enableableServicesByCategory(); - - if ($category) { - $groupedServices = Arr::where($groupedServices, function ($value, $key) use ($category) { - return Str::contains($category, strtoupper($key)); - }); - } - - foreach ($groupedServices as $serviceCategory => $services) { - $serviceCategoryMenuItem = ' ' . (Str::upper($serviceCategory)) . ' '; - array_push($choices, $serviceCategoryMenuItem); - - foreach ($this->menuItemsForServices($services) as $menuItemKey => $menuItemName) { - array_push($choices, $menuItemName); - } - } - - if ($category) { - array_push($choices, 'Back'); - } - - array_push($choices, 'Exit'); - - $choice = $this->choice(self::MENU_TITLE, $choices); - - if (Str::contains($choice, 'Back')) { - return $this->windowsMenu(); - } - - if (Str::contains($choice, 'Exit')) { - return null; - } - - foreach ($this->enableableServices() as $shortName => $fqcn) { - if ($choice === $fqcn) { - return $shortName; - } - } - - return $this->windowsMenu($choice); - } - - private function menuItemsForServices($services): array + private function menuItemsForServices($services): Collection { return collect($services)->mapWithKeys(function ($service) { return [$service['shortName'] => $service['name']]; - })->toArray(); + }); } - public function enableableServices(): array - { - return collect($this->services->all())->mapWithKeys(function ($fqcn, $shortName) { - return [$shortName => $fqcn::name()]; - })->toArray(); - } - - public function enableableServicesByCategory(): array + private function enableableServicesByCategory(): Collection { return collect($this->services->all()) ->mapToGroups(function ($fqcn, $shortName) { @@ -214,11 +156,10 @@ public function enableableServicesByCategory(): array ], ]; }) - ->sortKeys() - ->toArray(); + ->sortKeys(); } - public function enable( + private function enable( string $service, bool $useDefaults = false, array $passthroughOptions = [], diff --git a/app/Commands/ListCommand.php b/app/Commands/ListCommand.php index 183a7573..f0b548c7 100644 --- a/app/Commands/ListCommand.php +++ b/app/Commands/ListCommand.php @@ -7,6 +7,9 @@ use App\Shell\Docker; use LaravelZero\Framework\Commands\Command; +use function Laravel\Prompts\info; +use function Laravel\Prompts\table; + class ListCommand extends Command { use InitializesCommands; @@ -31,7 +34,7 @@ public function handle(Docker $docker): void } if ($containersCollection->isEmpty()) { - $this->info("No Takeout containers are enabled.\n"); + info('No Takeout containers are enabled.'); return; } @@ -39,7 +42,6 @@ public function handle(Docker $docker): void $containers = $containersCollection->toArray(); $columns = array_map('App\title_from_slug', array_keys(reset($containers))); - $this->line("\n"); - $this->table($columns, $containers); + table($columns, $containers); } } diff --git a/app/Commands/LogCommand.php b/app/Commands/LogCommand.php index 798b8466..a2c1f2ac 100644 --- a/app/Commands/LogCommand.php +++ b/app/Commands/LogCommand.php @@ -4,13 +4,18 @@ use App\InitializesCommands; use App\Shell\Docker; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use LaravelZero\Framework\Commands\Command; +use function Laravel\Prompts\select; + class LogCommand extends Command { use InitializesCommands; + const MENU_TITLE = 'Takeout containers logs'; + protected $signature = 'logs {containerId?}'; protected $description = 'Display container logs.'; protected $docker; @@ -20,17 +25,24 @@ public function handle(Docker $docker): void $this->docker = $docker; $this->initializeCommand(); - $container = $this->argument('containerId'); + $loggableContainers = $this->loggableContainers(); - if (! $container) { - $this->error("Please pass a valid container ID.\n"); + if ($loggableContainers->isEmpty()) { + $this->info("No Takeout containers available.\n"); return; } - $this->logs($container); + if (filled($service = $this->argument('containerId'))) { + $this->logsByServiceNameOrContainerId($service, $loggableContainers); + + return; + } + + $this->logs($this->selectOptions($loggableContainers)); } + public function logs(string $container): void { if (Str::contains($container, ' -')) { @@ -39,4 +51,41 @@ public function logs(string $container): void $this->docker->logContainer($container); } + + private function loggableContainers(): Collection + { + return $this->docker->activeTakeoutContainers()->mapWithKeys(function ($container) { + return [$container['container_id'] => str_replace('TO--', '', $container['names'])]; + }); + } + + private function selectOptions($stoppableContainers) + { + return select( + label: self::MENU_TITLE, + options: $stoppableContainers + ); + } + + private function logsByServiceNameOrContainerId(string $service, Collection $loggableContainers): void + { + $containersByServiceName = $loggableContainers + ->filter(function ($containerName, $key) use ($service) { + return Str::startsWith($containerName, $service) || "{$key}" === $service; + }); + + if ($containersByServiceName->isEmpty()) { + $this->info('No containers found for ' . $service); + + return; + } + + if ($containersByServiceName->count() === 1) { + $this->logs($containersByServiceName->keys()->first()); + + return; + } + + $this->logs($this->selectOptions($containersByServiceName)); + } } diff --git a/app/Commands/StartCommand.php b/app/Commands/StartCommand.php index 0191835b..975ace16 100644 --- a/app/Commands/StartCommand.php +++ b/app/Commands/StartCommand.php @@ -5,10 +5,11 @@ use App\InitializesCommands; use App\Shell\Docker; use App\Shell\Environment; -use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use LaravelZero\Framework\Commands\Command; -use PhpSchool\CliMenu\CliMenu; + +use function Laravel\Prompts\select; class StartCommand extends Command { @@ -26,89 +27,63 @@ public function handle(Docker $docker, Environment $environment): void $this->docker = $docker; $this->environment = $environment; $this->initializeCommand(); + $startableContainers = $this->startableContainers(); - $containers = $this->argument('containerId'); - - if (filled($containers)) { - foreach ($containers as $container) { - $this->startByServiceNameOrContainerId($container); - } + if ($this->option('all')) { + $startableContainers->keys()->each(function ($containerId) { + $this->start($containerId); + }); return; } - if ($this->option('all')) { - foreach ($this->docker->startableTakeoutContainers() as $startableContainer) { - $this->start($startableContainer['container_id']); - } + if ($startableContainers->isEmpty()) { + $this->info("No Takeout containers available to start.\n"); return; } - if (! $startableContainers = $this->startableContainers()) { - $this->info("No Takeout containers available to start.\n"); + if (filled($services = $this->argument('containerId'))) { + foreach ($services as $service) { + $this->startByServiceNameOrContainerId($service, $startableContainers); + } return; } - $this->loadMenu($startableContainers); + $this->start($this->selectOptions($startableContainers)); } - public function startableContainers(): array + public function startableContainers(): Collection { - return $this->docker->startableTakeoutContainers()->map(function ($container) { - $label = sprintf('%s - %s', $container['container_id'], $container['names']); - - return [ - $label, - $this->loadMenuItem($container, $label), - ]; - }, collect())->toArray(); + return $this->docker->startableTakeoutContainers()->mapWithKeys(function ($container) { + return [$container['container_id'] => str_replace('TO--', '', $container['names'])]; + }); } - public function startByServiceNameOrContainerId(string $serviceNameOrContainerId): void + public function startByServiceNameOrContainerId(string $service, Collection $startableContainers): void { - $containersByServiceName = $this->docker->startableTakeoutContainers() - ->map(function ($container) { - return [ - 'container' => $container, - 'label' => str_replace('TO--', '', $container['names']), - ]; - }) - ->filter(function ($item) use ($serviceNameOrContainerId) { - return Str::startsWith($item['label'], $serviceNameOrContainerId); + $containersByServiceName = $startableContainers + ->filter(function ($serviceName, $key) use ($service) { + return Str::startsWith($serviceName, $service) || "{$key}" === $service; }); // If we don't get any container by the service name, that probably means // the user is trying to start a container using its container ID, so // we will just forward that down to the underlying start method. - if ($containersByServiceName->isEmpty()) { - $this->start($serviceNameOrContainerId); + $this->info('No containers found for ' . $service); return; } if ($containersByServiceName->count() === 1) { - $this->start($containersByServiceName->first()['container']['container_id']); - - return; - } - - $selectedItem = $this->loadMenu($containersByServiceName->map(function ($item) { - $label = $item['container']['container_id'] . ' - ' . $item['label']; + $this->start($containersByServiceName->keys()->first()); - return [ - $label, - $this->loadMenuItem($item['container'], $label), - ]; - })->all()); - - if (! $selectedItem) { return; } - $this->start($selectedItem); + $this->start($this->selectOptions($containersByServiceName)); } public function start(string $container): void @@ -120,86 +95,11 @@ public function start(string $container): void $this->docker->startContainer($container); } - private function loadMenu($startableContainers) - { - if ($this->environment->isWindowsOs()) { - return $this->windowsMenu($startableContainers); - } - - return $this->defaultMenu($startableContainers); - } - - private function defaultMenu($startableContainers) - { - return $this->menu(self::MENU_TITLE) - ->addItems($startableContainers) - ->addLineBreak('', 1) - ->open(); - } - - private function windowsMenu($startableContainers) + private function selectOptions(Collection $startableContainers) { - if (! $startableContainers) { - return; - } - - $choices = Arr::flatten($startableContainers); - $choices = Arr::where($choices, function ($value, $key) { - return is_string($value); - }); - array_push($choices, 'Exit'); - - $choice = $this->choice(self::MENU_TITLE, array_values($choices)); - - if (Str::contains($choice, 'Exit')) { - return; - } - - $chosenStartableContainer = Arr::where($startableContainers, function ($value, $key) use ($choice) { - return $value[0] === $choice; - }); - - return call_user_func(array_values($chosenStartableContainer)[0][1]); - } - - private function loadMenuItem($container, $label): callable - { - if ($this->environment->isWindowsOs()) { - return $this->windowsMenuItem($container, $label); - } - - return $this->defaultMenuItem($container, $label); - } - - private function windowsMenuItem($container, $label): callable - { - return function () use ($container, $label) { - $this->start($label); - - $startableContainers = $this->startableContainers(); - - return $this->windowsMenu($startableContainers); - }; - } - - private function defaultMenuItem($container, $label): callable - { - return function (CliMenu $menu) use ($container, $label) { - $this->start($menu->getSelectedItem()->getText()); - - foreach ($menu->getItems() as $item) { - if ($item->getText() === $label) { - $menu->removeItem($item); - } - } - - if (count($menu->getItems()) === 3) { - $menu->close(); - - return; - } - - $menu->redraw(); - }; + return select( + label: self::MENU_TITLE, + options: $startableContainers + ); } } diff --git a/app/Commands/StopCommand.php b/app/Commands/StopCommand.php index 03a77d46..362edf27 100644 --- a/app/Commands/StopCommand.php +++ b/app/Commands/StopCommand.php @@ -5,10 +5,11 @@ use App\InitializesCommands; use App\Shell\Docker; use App\Shell\Environment; -use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use LaravelZero\Framework\Commands\Command; -use PhpSchool\CliMenu\CliMenu; + +use function Laravel\Prompts\select; class StopCommand extends Command { @@ -23,87 +24,64 @@ class StopCommand extends Command public function handle(Docker $docker, Environment $environment): void { + $this->docker = $docker; $this->environment = $environment; $this->initializeCommand(); + $stoppableContainers = $this->stoppableContainers(); - $containers = $this->argument('containerId'); - - if (filled($containers)) { - foreach ($containers as $container) { - $this->stopByServiceNameOrContainerId($container); - } + if ($this->option('all')) { + $stoppableContainers->keys()->each(function ($containerId) { + $this->stop($containerId); + }); return; } - if ($this->option('all')) { - foreach ($this->docker->stoppableTakeoutContainers() as $stoppableContainer) { - $this->stop($stoppableContainer['container_id']); - } + if ($stoppableContainers->isEmpty()) { + $this->info("No Takeout containers available to stop.\n"); return; } - if (! $stoppableContainers = $this->stoppableContainers()) { - $this->info("No Takeout containers available to stop.\n"); + if (filled($services = $this->argument('containerId'))) { + foreach ($services as $service) { + $this->stopByServiceNameOrContainerId($service, $stoppableContainers); + } return; } - $this->loadMenu($stoppableContainers); + $this->stop($this->selectOptions($stoppableContainers)); } - public function stoppableContainers(): array + private function stoppableContainers(): Collection { - return $this->docker->stoppableTakeoutContainers()->map(function ($container) { - $label = sprintf('%s - %s', $container['container_id'], $container['names']); - - return [ - $label, - $this->loadMenuItem($container, $label), - ]; - }, collect())->toArray(); + return $this->docker->activeTakeoutContainers()->mapWithKeys(function ($container) { + return [$container['container_id'] => str_replace('TO--', '', $container['names'])]; + }); } - public function stopByServiceNameOrContainerId(string $serviceNameOrContainerId): void + private function stopByServiceNameOrContainerId(string $service, Collection $stoppableContainers): void { - $containersByServiceName = $this->docker->stoppableTakeoutContainers() - ->map(function ($container) { - return [ - 'container' => $container, - 'label' => str_replace('TO--', '', $container['names']), - ]; - }) - ->filter(function ($item) use ($serviceNameOrContainerId) { - return Str::startsWith($item['label'], $serviceNameOrContainerId); + $containersByServiceName = $stoppableContainers + ->filter(function ($containerName, $key) use ($service) { + return Str::startsWith($containerName, $service) || "{$key}" === $service; }); if ($containersByServiceName->isEmpty()) { - $this->start($serviceNameOrContainerId); + $this->info('No containers found for ' . $service); return; } if ($containersByServiceName->count() === 1) { - $this->stop($containersByServiceName->first()['container']['container_id']); - return; - } + $this->stop($containersByServiceName->keys()->first()); - $selectedContainer = $this->loadMenu($containersByServiceName->map(function ($item) { - $label = $item['container']['container_id'] . ' - ' . $item['label']; - - return [ - $label, - $this->loadMenuItem($item['container'], $label), - ]; - })->all()); - - if (! $selectedContainer) { return; } - $this->stop($selectedContainer); + $this->stop($this->selectOptions($containersByServiceName)); } public function stop(string $container): void @@ -115,86 +93,11 @@ public function stop(string $container): void $this->docker->stopContainer($container); } - private function loadMenu($stoppableContainers) - { - if ($this->environment->isWindowsOs()) { - return $this->windowsMenu($stoppableContainers); - } - - return $this->defaultMenu($stoppableContainers); - } - - private function defaultMenu($stoppableContainers) - { - return $this->menu(self::MENU_TITLE) - ->addItems($stoppableContainers) - ->addLineBreak('', 1) - ->open(); - } - - private function windowsMenu($stoppableContainers) - { - if (! $stoppableContainers) { - return; - } - - $choices = Arr::flatten($stoppableContainers); - $choices = Arr::where($choices, function ($value, $key) { - return is_string($value); - }); - array_push($choices, 'Exit'); - - $choice = $this->choice(self::MENU_TITLE, array_values($choices)); - - if (Str::contains($choice, 'Exit')) { - return; - } - - $chosenStoppableContainer = Arr::where($stoppableContainers, function ($value, $key) use ($choice) { - return $value[0] === $choice; - }); - - return call_user_func(array_values($chosenStoppableContainer)[0][1]); - } - - private function loadMenuItem($container, $label): callable + private function selectOptions($stoppableContainers) { - if ($this->environment->isWindowsOs()) { - return $this->windowsMenuItem($container, $label); - } - - return $this->defaultMenuItem($container, $label); - } - - private function windowsMenuItem($container, $label): callable - { - return function () use ($container, $label) { - $this->stop($label); - - $stoppableContainers = $this->stoppableContainers(); - - return $this->windowsMenu($stoppableContainers); - }; - } - - private function defaultMenuItem($container, $label): callable - { - return function (CliMenu $menu) use ($container, $label) { - $this->stop($menu->getSelectedItem()->getText()); - - foreach ($menu->getItems() as $item) { - if ($item->getText() === $label) { - $menu->removeItem($item); - } - } - - if (count($menu->getItems()) === 3) { - $menu->close(); - - return; - } - - $menu->redraw(); - }; + return select( + label: self::MENU_TITLE, + options: $stoppableContainers + ); } } diff --git a/app/Exceptions/DockerContainerMissingException.php b/app/Exceptions/DockerContainerMissingException.php index edb8a7a0..df1d7f60 100644 --- a/app/Exceptions/DockerContainerMissingException.php +++ b/app/Exceptions/DockerContainerMissingException.php @@ -2,9 +2,10 @@ namespace App\Exceptions; -use App\Shell\Shell; use Exception; +use function Laravel\Prompts\error; + class DockerContainerMissingException extends Exception { public function __construct(string $containerId) @@ -14,10 +15,6 @@ public function __construct(string $containerId) public function render($request = null): void { - $console = app('console'); - $shell = app(Shell::class); - - $console->line(''); - $console->line($shell->formatErrorMessage($this->getMessage())); + error($this->getMessage()); } } diff --git a/app/Exceptions/DockerMissingException.php b/app/Exceptions/DockerMissingException.php index c11cd2aa..1584216a 100644 --- a/app/Exceptions/DockerMissingException.php +++ b/app/Exceptions/DockerMissingException.php @@ -2,20 +2,16 @@ namespace App\Exceptions; -use App\Shell\Shell; use Exception; +use function Laravel\Prompts\error; +use function Laravel\Prompts\note; + class DockerMissingException extends Exception { public function render($request = null): void { - $console = app('console'); - $shell = app(Shell::class); - - $console->line(''); - $console->line($shell->formatErrorMessage('Docker is not installed.')); - $console->line(''); - $console->line($shell->formatErrorMessage('Please visit https://docs.docker.com/get-docker/')); - $console->line($shell->formatErrorMessage('for information on how to install Docker for your machine.')); + error('Docker is not installed.'); + note('Please visit https://docs.docker.com/get-docker/' . PHP_EOL . 'for information on how to install Docker for your machine.'); } } diff --git a/app/Exceptions/DockerNotAvailableException.php b/app/Exceptions/DockerNotAvailableException.php index fe0a5488..ad871ad1 100644 --- a/app/Exceptions/DockerNotAvailableException.php +++ b/app/Exceptions/DockerNotAvailableException.php @@ -3,56 +3,48 @@ namespace App\Exceptions; use App\Shell\Environment; -use App\Shell\Shell; use Exception; +use function Laravel\Prompts\error; +use function Laravel\Prompts\note; + class DockerNotAvailableException extends Exception { public function render($request = null): void { - $console = app('console'); - $shell = app(Shell::class); - - $console->line(''); - $console->line($shell->formatErrorMessage('Docker is not available.')); - $console->line(''); - - if (in_array(PHP_OS_FAMILY, ['Darwin', 'Linux', 'Windows'])) { - $osSpecificHelp = 'helpFor' . ucfirst(PHP_OS_FAMILY); - $this->$osSpecificHelp($console); + error('Docker is not available.'); + + if (PHP_OS_FAMILY === 'Darwin') { + note(implode(PHP_EOL, [ + 'Open Docker for Mac or start it from the terminal running:', + '', + ' open --background -a Docker', + ])); + } elseif (PHP_OS_FAMILY === 'Windows') { + note('Open Docker for Windows to start the Docker service.'); + } else { + note(implode(PHP_EOL, [ + 'Verify that the Docker service is running:', + '', + ' sudo systemctl status docker', + '', + 'Start the Docker service:', + '', + ' sudo systemctl start docker', + ])); + + $environment = app(Environment::class); + + if (! $environment->userIsInDockerGroup()) { + error('You are not in the docker group.'); + note(implode(PHP_EOL, [ + 'You need to be in that group to run Docker as non-root. Add it by running:', + '', + ' sudo usermod -aG docker ${USER}', + '', + 'and restart your session.', + ])); + } } } - - protected function helpForDarwin($console) - { - $console->line('Open Docker for Mac or run:'); - $console->line(' open --background -a Docker'); - $console->line('to start the Docker service.'); - } - - protected function helpForLinux($console) - { - $environment = app(Environment::class); - $shell = app(Shell::class); - - $console->line('Verify that the Docker service is running:'); - $console->line(' sudo systemctl status docker'); - $console->line('Start the Docker service:'); - $console->line(' sudo systemctl start docker'); - - if (! $environment->userIsInDockerGroup()) { - $console->line(''); - $console->line($shell->formatErrorMessage('You are not in the docker group.')); - $console->line(''); - $console->line('This is required to run Docker as a non-root user.'); - $console->line('Add your user to the docker group by running:'); - $console->line(' sudo usermod -aG docker ${USER}'); - $console->line('and restart your session.'); - } - } - - protected function helpForWindows($console) - { - $console->line('Open Docker for Windows to start the Docker service.'); - } } diff --git a/app/Exceptions/InvalidServiceShortnameException.php b/app/Exceptions/InvalidServiceShortnameException.php index 7f60ff5f..4fa47dc5 100644 --- a/app/Exceptions/InvalidServiceShortnameException.php +++ b/app/Exceptions/InvalidServiceShortnameException.php @@ -2,17 +2,14 @@ namespace App\Exceptions; -use App\Shell\Shell; use Exception; +use function Laravel\Prompts\error; + class InvalidServiceShortnameException extends Exception { public function render($request = null) { - $console = app('console'); - $shell = app(Shell::class); - - $console->line(''); - $console->line($shell->formatErrorMessage($this->getMessage())); + error($this->getMessage()); } } diff --git a/app/InitializesCommands.php b/app/InitializesCommands.php index 113c43e2..b0848e42 100644 --- a/app/InitializesCommands.php +++ b/app/InitializesCommands.php @@ -6,6 +6,9 @@ use App\Exceptions\DockerNotAvailableException; use App\Shell\Docker; +use function Laravel\Prompts\error; +use function Laravel\Prompts\text; + trait InitializesCommands { public function initializeCommand(): void @@ -22,4 +25,39 @@ public function initializeCommand(): void throw new DockerNotAvailableException; } } + + public function askPromptQuestion(string $question, $default = null, $validate = null) + { + return text(label: $question, default: $default, validate: $validate); + } + + public function errorPrompt(string $message): void + { + error($message); + } + + public function alertPrompt(string $message): void + { + $this->components->alert($message); + } + + public function warnPrompt(string $message): void + { + $this->components->warn($message); + } + + public function linePrompt(string $message): void + { + $this->components->line($message); + } + + public function infoPrompt(string $message): void + { + $this->components->info($message); + } + + public function taskPrompt(string $message, $callable): void + { + $this->components->task($message, $callable); + } } diff --git a/app/Services/BaseService.php b/app/Services/BaseService.php index 62a68a6e..0165942e 100644 --- a/app/Services/BaseService.php +++ b/app/Services/BaseService.php @@ -160,29 +160,30 @@ protected function prompts(): void { $items = []; - foreach ($this->defaultPrompts as $prompt) { - $this->askQuestion($prompt, $this->useDefaults); - - while ($prompt['shortname'] === 'port' && ! $this->environment->portIsAvailable($this->promptResponses['port'])) { - app('console')->error("Port {$this->promptResponses['port']} is already in use. Please select a different port."); - $this->askQuestion($prompt); - } - } - - foreach ($this->prompts as $prompt) { - $this->askQuestion($prompt, $this->useDefaults); - - while ($prompt['shortname'] === 'volume' && ! $this->docker->volumeIsAvailable($this->promptResponses['volume'])) { - app('console')->error("Volume {$this->promptResponses['volume']} is already in use. Please select a different volume."); - $this->askQuestion($prompt); - } - - while (Str::contains($prompt['shortname'], 'port') && ! $this->environment->portIsAvailable($this->promptResponses[$prompt['shortname']])) { - app('console')->error("Port {$this->promptResponses[$prompt['shortname']]} is already in use. Please select a different port."); - $this->askQuestion($prompt); - } - - $items[] = $prompt; + $questions = array_merge($this->defaultPrompts, $this->prompts); + + foreach ($questions as $prompt) { + $items[] = match (true) { + Str::contains($prompt['shortname'], 'port') => $this->askQuestion($prompt, $this->useDefaults, validate: function (string $port) { + if (! is_numeric($port)) { + return 'Sorry, the port must be a valid number.'; + } + + if (! $this->environment->portIsAvailable($port)) { + return "Port {$port} is already in use. Please select a different port."; + } + + return null; + }), + Str::contains($prompt['shortname'], 'volume') => $this->askQuestion($prompt, $this->useDefaults, validate: function (string $volume) { + if (! $this->docker->volumeIsAvailable($volume)) { + return "Volume {$volume} is already in use. Please select a different volume."; + } + + return null; + }), + default => $this->askQuestion($prompt, $this->useDefaults), + }; } // Allow users to pass custom docker images (e.g. "postgis/postgis:latest") when we ask for the tag @@ -194,12 +195,12 @@ protected function prompts(): void $this->tag = $this->resolveTag($this->promptResponses['tag']); } - protected function askQuestion(array $prompt, $useDefaults = false): void + protected function askQuestion(array $prompt, $useDefaults = false, $validate = null): void { $this->promptResponses[$prompt['shortname']] = $prompt['default'] ?? null; if (! $useDefaults) { - $this->promptResponses[$prompt['shortname']] = app('console')->ask(sprintf($prompt['prompt'], $this->imageName), $prompt['default'] ?? null); + $this->promptResponses[$prompt['shortname']] = $this->ask(sprintf($prompt['prompt'], $this->imageName), $prompt['default'] ?? null, $validate); } } diff --git a/app/Shell/Docker.php b/app/Shell/Docker.php index d183ad3e..21ed5275 100644 --- a/app/Shell/Docker.php +++ b/app/Shell/Docker.php @@ -28,7 +28,7 @@ public function __construct( public function removeContainer(string $containerId): void { - if ($this->stoppableTakeoutContainers()->contains(function ($container) use ($containerId) { + if ($this->activeTakeoutContainers()->contains(function ($container) use ($containerId) { return $container['container_id'] === $containerId; })) { $this->stopContainer($containerId); @@ -43,7 +43,7 @@ public function removeContainer(string $containerId): void public function stopContainer(string $containerId): void { - if (! $this->stoppableTakeoutContainers()->contains(function ($container) use ($containerId) { + if (! $this->activeTakeoutContainers()->contains(function ($container) use ($containerId) { return $container['container_id'] === $containerId; })) { throw new DockerContainerMissingException($containerId); @@ -58,13 +58,13 @@ public function stopContainer(string $containerId): void public function logContainer(string $containerId): void { - if (! $this->stoppableTakeoutContainers()->contains(function ($container) use ($containerId) { + if (! $this->activeTakeoutContainers()->contains(function ($container) use ($containerId) { return $container['container_id'] === $containerId; })) { throw new DockerContainerMissingException($containerId); } - $process = $this->shell->exec('docker logs -f ' . $containerId); + $process = $this->shell->exec('docker logs -f ' . $containerId, plain: true); if (! $process->isSuccessful()) { throw new Exception('Failed to log container ' . $containerId); @@ -116,7 +116,7 @@ public function startableTakeoutContainers(): Collection }); } - public function stoppableTakeoutContainers(): Collection + public function activeTakeoutContainers(): Collection { return $this->takeoutContainers()->filter(function ($container) { return Str::contains($container['status'], 'Up'); diff --git a/app/Shell/Environment.php b/app/Shell/Environment.php index d0d071f1..a04ab2db 100644 --- a/app/Shell/Environment.php +++ b/app/Shell/Environment.php @@ -2,8 +2,6 @@ namespace App\Shell; -use Illuminate\Support\Str; - class Environment { protected $shell; @@ -28,35 +26,25 @@ public function isWindowsOs(): bool return PHP_OS_FAMILY === 'Windows'; } - public function portIsAvailable($port): bool + public function portIsAvailable(int $port): bool { - // E.g. Win/Linux: 127.0.0.1:3306 , macOS: 127.0.0.1.3306 - $portText = $this->isLinuxOs() ? "\:{$port}\s" : "\.{$port}\s"; + // To check if the port is available, we'll attempt to open a socket connection to it. + // Note that the logic here is flipped: successfully openning the socket connection + // means something is using it. If it fails to open, that port is likely unused. + $socket = @fsockopen($this->localhost(), $port, $errorCode, $errorMessage, timeout: 5); - $netstatCmd = $this->netstatCmd(); + if (! $socket) { + return true; + } - // Check to see if the system is running a service with the desired port - $process = $this->shell->execQuietly("{$netstatCmd} -vanp tcp \n - | grep '{$portText}' | grep -v 'TIME_WAIT' | grep -v 'CLOSE_WAIT' | grep -v 'FIN_WAIT'"); + fclose($socket); - // A successful netstat command means a port in use was found - return ! $process->isSuccessful(); + return false; } - public function netstatCmd(): string + public function isTakeoutRunningInsideDocker(): bool { - $netstatCmd = 'netstat'; - - if ($this->isLinuxOs()) { - $linuxVersion = $this->shell->execQuietly('cat /proc/version'); - $isWSL = Str::contains($linuxVersion->getOutput(), 'microsoft'); - - if ($isWSL) { - $netstatCmd = 'netstat.exe'; - } - } - - return $netstatCmd; + return boolval($_SERVER['TAKEOUT_CONTAINER'] ?? false); } public function userIsInDockerGroup(): bool @@ -79,4 +67,9 @@ public function homeDirectory(): string return '~'; } + + private function localhost(): string + { + return $this->isTakeoutRunningInsideDocker() ? 'host.docker.internal' : 'localhost'; + } } diff --git a/app/Shell/Shell.php b/app/Shell/Shell.php index f56d16ec..767d7ff7 100644 --- a/app/Shell/Shell.php +++ b/app/Shell/Shell.php @@ -5,6 +5,9 @@ use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Process\Process; +use function Laravel\Prompts\error; +use function Laravel\Prompts\note; + class Shell { protected $output; @@ -14,23 +17,26 @@ public function __construct(ConsoleOutput $output) $this->output = $output; } - public function exec(string $command, array $parameters = [], bool $quiet = false): Process + public function exec(string $command, array $parameters = [], bool $quiet = false, bool $plain = false): Process { - $didAnything = false; - $process = $this->buildProcess($command); - $process->run(function ($type, $buffer) use ($quiet, $didAnything) { + $process->run(function ($type, $buffer) use ($quiet, $plain) { if (empty($buffer) || $buffer === PHP_EOL || $quiet) { return; } - $this->output->writeLn($this->formatMessage($buffer, $type === process::ERR)); - $didAnything = true; - }, $parameters); + if ($plain) { + note($buffer); + return; + } - if ($didAnything) { - $this->output->writeLn("\n"); - } + if ($type === Process::ERR) { + error('Something went wrong.'); + note(' ERR ' . $this->formatMessage($buffer)); + } else { + note(' OUT ' . $this->formatMessage($buffer)); + } + }, $parameters); return $process; } @@ -48,17 +54,10 @@ public function execQuietly(string $command, array $parameters = []): Process return $this->exec($command, $parameters, $quiet = true); } - public function formatMessage(string $buffer, $isError = false): string + private function formatMessage(string $buffer): string { - $pre = $isError ? ' ERR %s' : ' OUT %s'; - - return rtrim(collect(explode("\n", trim($buffer)))->reduce(function ($carry, $line) use ($pre) { - return $carry .= trim(sprintf($pre, $line)) . "\n"; + return rtrim(collect(explode("\n", trim($buffer)))->reduce(function ($carry, $line) { + return $carry .= trim($line) . "\n"; }, '')); } - - public function formatErrorMessage(string $buffer) - { - return $this->formatMessage($buffer, true); - } } diff --git a/app/WritesToConsole.php b/app/WritesToConsole.php index 4e7ddc17..0bee5b46 100644 --- a/app/WritesToConsole.php +++ b/app/WritesToConsole.php @@ -6,31 +6,36 @@ trait WritesToConsole { public function alert(string $message): void { - app('console')->alert($message); + app('console')->alertPrompt($message); } public function warn(string $message): void { - app('console')->warn($message); + app('console')->warnPrompt($message); } public function error(string $message): void { - app('console')->error($message); + app('console')->errorPrompt($message); } public function line(string $message): void { - app('console')->line($message); + app('console')->linePrompt($message); } public function info(string $message): void { - app('console')->info($message); + app('console')->infoPrompt($message); } public function task(string $message, $callable): void { - app('console')->task($message, $callable); + app('console')->taskPrompt($message, $callable); + } + + public function ask(string $message, $default = null, $validate = null) + { + return app('console')->askPromptQuestion($message, $default, $validate); } } diff --git a/composer.json b/composer.json index 5f187b00..4efc79eb 100644 --- a/composer.json +++ b/composer.json @@ -18,16 +18,15 @@ "require": { "php": "^8.2", "ext-json": "*", - "ext-pcntl": "*", - "ext-posix": "*", + "ext-sockets": "*", "composer/semver": "^3.4", - "guzzlehttp/psr7": "^2.6" + "guzzlehttp/psr7": "^2.6", + "laravel/prompts": "^0.3.2" }, "require-dev": { "guzzlehttp/guzzle": "^7.5", "laravel-zero/framework": "^11.0", "mockery/mockery": "^1.3.1", - "nunomaduro/laravel-console-menu": "^3.4", "phpunit/phpunit": "^10.5", "squizlabs/php_codesniffer": "^3.5", "tightenco/tlint": "^9.3" diff --git a/tests/Feature/BaseServiceTest.php b/tests/Feature/BaseServiceTest.php index 44037150..4eebb7e6 100644 --- a/tests/Feature/BaseServiceTest.php +++ b/tests/Feature/BaseServiceTest.php @@ -28,14 +28,14 @@ public function it_enables_services() app()->instance('console', M::mock(Command::class, function ($mock) use ($service) { $defaultPort = $service->defaultPort(); - $mock->shouldReceive('ask') - ->with('Which host port would you like meilisearch to use?', $defaultPort) + $mock->shouldReceive('askPromptQuestion') + ->with('Which host port would you like meilisearch to use?', $defaultPort, M::any()) ->andReturn(7700); - $mock->shouldReceive('ask') - ->with('What is the Docker volume name?', 'meili_data') + $mock->shouldReceive('askPromptQuestion') + ->with('What is the Docker volume name?', 'meili_data', M::any()) ->andReturn('meili_data'); - $mock->shouldReceive('ask') - ->with('Which tag (version) of meilisearch would you like to use?', 'latest') + $mock->shouldReceive('askPromptQuestion') + ->with('Which tag (version) of meilisearch would you like to use?', 'latest', M::any()) ->andReturn('v1.1.1'); $mock->shouldIgnoreMissing(); })); @@ -79,14 +79,14 @@ public function it_can_receive_a_custom_image_in_the_tag() app()->instance('console', M::mock(Command::class, function ($mock) use ($service) { $defaultPort = $service->defaultPort(); - $mock->shouldReceive('ask') - ->with('Which host port would you like postgres to use?', $defaultPort) + $mock->shouldReceive('askPromptQuestion') + ->with('Which host port would you like postgres to use?', $defaultPort, M::any()) ->andReturn(5432); - $mock->shouldReceive('ask') - ->with('Which tag (version) of postgres would you like to use?', 'latest') + $mock->shouldReceive('askPromptQuestion') + ->with('Which tag (version) of postgres would you like to use?', 'latest', M::any()) ->andReturn('timescale/timescaledb:latest-pg12'); - $mock->shouldReceive('ask') - ->with('What is the Docker volume name?', 'postgres_data') + $mock->shouldReceive('askPromptQuestion') + ->with('What is the Docker volume name?', 'postgres_data', M::any()) ->andReturn('postgres_data'); $mock->shouldIgnoreMissing(); })); @@ -132,11 +132,11 @@ public function it_can_receive_container_arguments_via_passthrough() app()->instance('console', M::mock(Command::class, function ($mock) use ($service) { $defaultPort = $service->defaultPort(); - $mock->shouldReceive('ask') - ->with('Which host port would you like _test_image to use?', $defaultPort) + $mock->shouldReceive('askPromptQuestion') + ->with('Which host port would you like _test_image to use?', $defaultPort, M::any()) ->andReturn(12345); - $mock->shouldReceive('ask') - ->with('Which tag (version) of _test_image would you like to use?', 'latest') + $mock->shouldReceive('askPromptQuestion') + ->with('Which tag (version) of _test_image would you like to use?', 'latest', M::any()) ->andReturn('latest'); $mock->shouldIgnoreMissing(); })); @@ -185,11 +185,11 @@ public function it_accepts_run_options_and_passthrough_options() app()->instance('console', M::mock(Command::class, function ($mock) use ($service) { $defaultPort = $service->defaultPort(); - $mock->shouldReceive('ask') - ->with('Which host port would you like _test_image to use?', $defaultPort) + $mock->shouldReceive('askPromptQuestion') + ->with('Which host port would you like _test_image to use?', $defaultPort, M::any()) ->andReturn(12345); - $mock->shouldReceive('ask') - ->with('Which tag (version) of _test_image would you like to use?', 'latest') + $mock->shouldReceive('askPromptQuestion') + ->with('Which tag (version) of _test_image would you like to use?', 'latest', M::any()) ->andReturn('latest'); $mock->shouldIgnoreMissing(); })); diff --git a/tests/Feature/DisableCommandTest.php b/tests/Feature/DisableCommandTest.php index e1d719e5..82bf19da 100644 --- a/tests/Feature/DisableCommandTest.php +++ b/tests/Feature/DisableCommandTest.php @@ -3,35 +3,21 @@ namespace Tests\Feature; use App\Shell\Docker; -use Illuminate\Console\Command; use Illuminate\Support\Collection; -use NunoMaduro\LaravelConsoleMenu\Menu; -use PHPUnit\Framework\Assert; use Tests\TestCase; class DisableCommandTest extends TestCase { - - function isWindows() - { - return PHP_OS_FAMILY === 'Windows'; - } - - function isLinux() - { - return PHP_OS_FAMILY === 'Linux'; - } - /** @test */ function it_can_disable_a_service_from_menu() { $services = Collection::make([ [ 'container_id' => $postgressId = '1234', - 'names' => $postgressName = 'postgress', + 'names' => 'postgress', ], [ - 'container_id' =>'12345', + 'container_id' => '12345', 'names' => 'meilisearch', ], ]); @@ -47,39 +33,9 @@ function it_can_disable_a_service_from_menu() ->with($postgressId)->once(); }); - if ($this->isWindows()) { - $disableableServices = $services->mapWithKeys(function ($item) { - return [$item['container_id'] => $item['names']]; - })->toArray(); - - array_push($disableableServices, 'Exit'); - - $this->artisan('disable') - ->expectsChoice('Takeout containers to disable', $postgressName, $disableableServices) - ->assertExitCode(0); - } else { - $menuMock = $this->mock(Menu::class, function ($mock) use ($postgressId) { - $mock->shouldReceive('addLineBreak')->andReturnSelf(); - $mock->shouldReceive('setPadding')->andReturnSelf(); - $mock->shouldReceive('open')->andReturn($postgressId)->once(); - }); - - Command::macro( - 'menu', - function (string $title, array $options) use ($services, $menuMock) { - Assert::assertEquals('Takeout containers to disable', $title); - Assert::assertEquals( - $services->mapWithKeys(function ($container) { - return [$container['container_id'] => $container['names']]; - })->toArray(), - $options - ); - return $menuMock; - } - ); - - $this->artisan('disable'); - } + $this->artisan('disable') + ->expectsQuestion('Takeout containers to disable', $postgressId) + ->assertExitCode(0); } /** @test */ @@ -189,47 +145,23 @@ function it_will_try_to_stop_docker_service_if_no_containers_are_running() ], ]); - if ($this->isLinux() || $this->isWindows()) { - $this->mock(Docker::class, function ($mock) use ($services, $postgressId) { - $mock->shouldReceive('isInstalled')->andReturn(true); - $mock->shouldReceive('isDockerServiceRunning')->andReturn(true); - $mock->shouldReceive('takeoutContainers')->andReturn($services); - - $mock->shouldReceive('attachedVolumeName') - ->with($postgressId)->andReturnNull()->once(); - $mock->shouldReceive('removeContainer') - ->with($postgressId)->once(); - - $mock->shouldReceive('allContainers')->andReturn(new Collection)->once(); - - if ($this->isWindows() || $this->isLinux()) { - $mock->shouldReceive('stopDockerService')->once(); - } - }); - - if ($this->isWindows()) { - $this->artisan('disable postgress') - ->expectsConfirmation('No containers are running. Turn off Docker?', 'Yes') - ->assertExitCode(0); - } else { - $menuMock = $this->mock(Menu::class, function ($mock) { - $mock->shouldReceive('disableDefaultItems')->andReturnSelf(); - $mock->shouldReceive('open')->andReturn(0)->once(); - }); - - Command::macro( - 'menu', - function (string $title, array $options) use ($menuMock) { - Assert::assertEquals('No containers are running. Turn off Docker?', $title); - Assert::assertEquals(['Yes', 'No'], $options); - return $menuMock; - } - ); - - $this->artisan('disable postgress'); - } - } else { - $this->assertTrue(true); - } + $this->mock(Docker::class, function ($mock) use ($services, $postgressId) { + $mock->shouldReceive('isInstalled')->andReturn(true); + $mock->shouldReceive('isDockerServiceRunning')->andReturn(true); + $mock->shouldReceive('takeoutContainers')->andReturn($services); + + $mock->shouldReceive('attachedVolumeName') + ->with($postgressId)->andReturnNull()->once(); + $mock->shouldReceive('removeContainer') + ->with($postgressId)->once(); + + $mock->shouldReceive('allContainers')->andReturn(new Collection)->once(); + + $mock->shouldReceive('stopDockerService')->once(); + }); + + $this->artisan('disable postgress') + ->expectsConfirmation('No containers are running. Turn off Docker?', 'Yes') + ->assertExitCode(0); } } diff --git a/tests/Feature/EnableCommandTest.php b/tests/Feature/EnableCommandTest.php index d32ec890..9727b658 100644 --- a/tests/Feature/EnableCommandTest.php +++ b/tests/Feature/EnableCommandTest.php @@ -8,39 +8,18 @@ use App\Services\MeiliSearch; use App\Services\PostgreSql; use App\Shell\Docker; -use Illuminate\Console\Command; -use NunoMaduro\LaravelConsoleMenu\Menu; -use PHPUnit\Framework\Assert; use Tests\TestCase; class EnableCommandTest extends TestCase { - function isWindows() - { - return PHP_OS_FAMILY === 'Windows'; - } - - function isLinux() - { - return PHP_OS_FAMILY === 'Linux'; - } - /** @test */ - function it_can_enable_a_service_from_menu() + function it_can_filter_options_based_on_search_term() { $services = [ 'meilisearch' => 'App\Services\MeiliSearch', $postgres = 'postgresql' => $fqcn = 'App\Services\PostgreSql', ]; - $menuItems = [ - ' DATABASE ', - 'PostgreSQL', - ' SEARCH ', - 'MeiliSearch', - 'Exit', - ]; - $this->mock(Docker::class, function ($mock) { $mock->shouldReceive('isInstalled')->andReturn(true); $mock->shouldReceive('isDockerServiceRunning')->andReturn(true); @@ -55,68 +34,13 @@ function it_can_enable_a_service_from_menu() $mock->shouldReceive('enable')->once(); }); - if ($this->isWindows()) { - $this->artisan('enable') - ->expectsChoice('Takeout containers to enable', 'PostgreSQL', $menuItems) - ->assertExitCode(0); - } else { - $menuMock = $this->mock(Menu::class, function ($mock) use ($postgres) { - $mock->shouldReceive('setTitleSeparator')->andReturnSelf(); - $mock->shouldReceive('addStaticItem')->andReturnSelf()->times(4); - $mock->shouldReceive('addOptions')->andReturnSelf(); - $mock->shouldReceive('addLineBreak')->andReturnSelf(); - $mock->shouldReceive('open')->andReturn($postgres)->once(); - }); - - Command::macro( - 'menu', - function (string $title) use ($menuMock) { - Assert::assertEquals('Takeout containers to enable', $title); - - return $menuMock; - } - ); - - $this->artisan('enable'); - } - } + $menuItems = [ + $postgres => 'Database: PostgreSQL', + ]; - /** @test */ - function it_can_navigate_a_submenu_in_windows() - { - if ($this->isWindows()) { - $services = [ - 'meilisearch' => 'App\Services\MeiliSearch', - 'postgresql' => 'App\Services\PostgreSql', - ]; - - $menuItems = [ - $category = ' DATABASE ', - 'PostgreSQL', - ' SEARCH ', - 'MeiliSearch', - $exit = 'Exit', - ]; - - $submenuItems = [ - ' DATABASE ', - 'PostgreSQL', - $back = 'Back', - 'Exit', - ]; - - $this->mock(Services::class, function ($mock) use ($services) { - $mock->shouldReceive('all')->andReturn($services); - }); - - $this->artisan('enable') - ->expectsChoice('Takeout containers to enable', $category, $menuItems) - ->expectsChoice('Takeout containers to enable', $back, $submenuItems) - ->expectsChoice('Takeout containers to enable', $exit, $menuItems) - ->assertExitCode(0); - } else { - $this->assertTrue(true); - } + $this->artisan('enable') + ->expectsSearch('Takeout containers to enable', $postgres, 'postgres', $menuItems) + ->assertExitCode(0); } /** @test */ diff --git a/tests/Feature/EnvironmentTest.php b/tests/Feature/EnvironmentTest.php index 4485b3c5..75f68b2f 100644 --- a/tests/Feature/EnvironmentTest.php +++ b/tests/Feature/EnvironmentTest.php @@ -3,64 +3,40 @@ namespace Tests\Feature; use App\Shell\Environment; -use App\Shell\Shell; -use LaravelZero\Framework\Commands\Command; -use Mockery as M; -use Symfony\Component\Process\Process; use Tests\TestCase; class EnvironmentTest extends TestCase { - function isLinux() - { - return PHP_OS_FAMILY === 'Linux'; - } - /** @test **/ - function it_detects_a_port_conflict() + public function it_detects_a_port_conflict() { - app()->instance('console', M::mock(Command::class, function ($mock) { - $mock->shouldIgnoreMissing(); - })); + if (! extension_loaded('sockets')) { + $this->markTestSkipped('Sockets extension is required (should be included in PHP by default).'); + } - $this->mock(Shell::class, function ($mock) { - $process = M::mock(Process::class); - $process->shouldReceive('isSuccessful')->once()->andReturn(true); - $process->shouldReceive('getOutput')->andReturn(''); - - $times = 1; - if ($this->isLinux()) { - $times = 2; - } + $environment = app(Environment::class); - $mock->shouldReceive('execQuietly')->times($times)->andReturn($process); - }); + $port = $this->withFakeProcessRunning( + fn($port) => $this->assertFalse($environment->portIsAvailable($port), "Expected port {$port} to be taken, but it was available."), + ); - $environment = app(Environment::class); - $this->assertFalse($environment->portIsAvailable(1234)); + $this->assertTrue($environment->portIsAvailable($port), "Expected port {$port} to be avaialble, but it was taken."); } - /** @test **/ - function it_detects_a_port_is_available() + private function withFakeProcessRunning($closure) { - app()->instance('console', M::mock(Command::class, function ($mock) { - $mock->shouldIgnoreMissing(); - })); + // Passing zero to it will make PHP select a free port... + $socket = socket_create_listen(0); - $this->mock(Shell::class, function ($mock) { - $process = M::mock(Process::class); - $process->shouldReceive('isSuccessful')->once()->andReturn(false); - $process->shouldReceive('getOutput')->andReturn(''); + // Extract the host and port (we only care about the port) so we can use it... + socket_getsockname($socket, $host, $port); - $times = 1; - if ($this->isLinux()) { - $times = 2; - } + try { + $closure($port); + } finally { + socket_close($socket); + } - $mock->shouldReceive('execQuietly')->times($times)->andReturn($process); - }); - - $environment = app(Environment::class); - $this->assertTrue($environment->portIsAvailable(1234)); + return $port; } } diff --git a/tests/Feature/LogCommandTest.php b/tests/Feature/LogCommandTest.php new file mode 100644 index 00000000..93ac03c2 --- /dev/null +++ b/tests/Feature/LogCommandTest.php @@ -0,0 +1,68 @@ + $containerId = '12345', + 'names' => 'TO--mysql--8.0.22--3306', + 'status' => 'Up 27 minutes', + 'ports' => '0.0.0.0:3306->3306/tcp, 33060/tcp', + 'base_alias' => 'mysql', + 'full_alias' => 'mysql8.0', + ], + ]); + + $this->mock(Docker::class, function ($mock) use ($services, $containerId) { + $mock->shouldReceive('isInstalled')->andReturn(true); + $mock->shouldReceive('isDockerServiceRunning')->andReturn(true); + $mock->shouldReceive('activeTakeoutContainers')->andReturn($services, new Collection); + $mock->shouldReceive('logContainer')->with($containerId); + }); + + $this->artisan('logs') + ->expectsQuestion('Takeout containers logs', $containerId) + ->assertExitCode(0); + } + + /** + * @test + * + * @testWith ["12345"] + * ["mysql"] + * + */ + public function it_can_access_logs_from_containers_by_service_name_or_id($arg) + { + $services = Collection::make([ + [ + 'container_id' => $containerId = '12345', + 'names' => 'TO--mysql--8.0.22--3306', + 'status' => 'Up 27 minutes', + 'ports' => '0.0.0.0:3306->3306/tcp, 33060/tcp', + 'base_alias' => 'mysql', + 'full_alias' => 'mysql8.0', + ], + ]); + + $this->mock(Docker::class, function ($mock) use ($services, $containerId) { + $mock->shouldReceive('isInstalled')->andReturn(true); + $mock->shouldReceive('isDockerServiceRunning')->andReturn(true); + $mock->shouldReceive('activeTakeoutContainers')->andReturn($services, new Collection); + $mock->shouldReceive('logContainer')->with($containerId); + }); + + + $this->artisan('logs', ['containerId' => $arg]) + ->assertExitCode(0); + } +} diff --git a/tests/Feature/StartCommandTest.php b/tests/Feature/StartCommandTest.php index d5e0a641..4b854ec7 100644 --- a/tests/Feature/StartCommandTest.php +++ b/tests/Feature/StartCommandTest.php @@ -3,31 +3,18 @@ namespace Tests\Feature; use App\Shell\Docker; -use Illuminate\Console\Command; use Illuminate\Support\Collection; -use NunoMaduro\LaravelConsoleMenu\Menu; -use PHPUnit\Framework\Assert; use Tests\TestCase; class StartCommandTest extends TestCase { - function isWindows() - { - return PHP_OS_FAMILY === 'Windows'; - } - - function isLinux() - { - return PHP_OS_FAMILY === 'Linux'; - } - /** @test */ function it_can_start_a_service_from_menu() { $services = Collection::make([ [ 'container_id' => $containerId = '12345', - 'names' => $containerName = 'TO--mysql--8.0.22--3306', + 'names' => 'TO--mysql--8.0.22--3306', 'status' => 'Exited (0) 8 days ago', 'ports' => '', 'base_alias' => 'mysql', @@ -35,11 +22,6 @@ function it_can_start_a_service_from_menu() ], ]); - $menuItems = [ - $mysql = $containerId . ' - ' . $containerName, - 'Exit', - ]; - $this->mock(Docker::class, function ($mock) use ($services, $containerId) { $mock->shouldReceive('isInstalled')->andReturn(true); $mock->shouldReceive('isDockerServiceRunning')->andReturn(true); @@ -47,33 +29,19 @@ function it_can_start_a_service_from_menu() $mock->shouldReceive('startContainer')->with($containerId); }); - if ($this->isWindows()) { - $this->artisan('start') - ->expectsChoice('Takeout containers to start', $mysql, $menuItems) - ->assertExitCode(0); - } else { - $menuMock = $this->mock(Menu::class, function ($mock) use ($mysql) { - $mock->shouldReceive('setTitleSeparator')->andReturnSelf(); - $mock->shouldReceive('addItems')->andReturnSelf(); - $mock->shouldReceive('addLineBreak')->andReturnSelf(); - $mock->shouldReceive('open')->andReturn($mysql)->once(); - }); - - Command::macro( - 'menu', - function (string $title) use ($menuMock) { - Assert::assertEquals('Takeout containers to start', $title); - - return $menuMock; - } - ); - - $this->artisan('start'); - } + $this->artisan('start') + ->expectsQuestion('Takeout containers to start', $containerId) + ->assertExitCode(0); } - /** @test */ - function it_can_start_containers_by_name() + /** + * @test + * + * @testWith ["12345"] + * ["mysql"] + * + */ + function it_can_start_containers_by_name_or_id($arg) { $services = Collection::make([ [ @@ -93,7 +61,7 @@ function it_can_start_containers_by_name() $mock->shouldReceive('startContainer')->once()->with($containerId); }); - $this->artisan('start', ['containerId' => ['mysql']]) + $this->artisan('start', ['containerId' => [$arg]]) ->assertExitCode(0); } @@ -102,8 +70,8 @@ function it_can_start_containers_by_name_when_there_are_multiple() { $services = Collection::make([ [ - 'container_id' => $firstContainerId = '12345', - 'names' => $firstContainerName = 'TO--mysql--8.0.22--3306', + 'container_id' => '12345', + 'names' => 'TO--mysql--8.0.22--3306', 'status' => 'Exited (0) 8 days ago', 'ports' => '', 'base_alias' => 'mysql', @@ -111,7 +79,7 @@ function it_can_start_containers_by_name_when_there_are_multiple() ], [ 'container_id' => $secondContainerId = '67890', - 'names' => $secondContainerName = 'TO--mysql--8.0.20-3306', + 'names' => 'TO--mysql--8.0.20-3306', 'status' => 'Exited (0) 8 days ago', 'ports' => '', 'base_alias' => 'mysql', @@ -126,34 +94,8 @@ function it_can_start_containers_by_name_when_there_are_multiple() $mock->shouldReceive('startContainer')->with($secondContainerId)->once(); }); - $menuItems = [ - $firstContainerId . ' - ' . $firstContainerName, - $mysql = $secondContainerId . ' - ' . $secondContainerName, - 'Exit', - ]; - - if ($this->isWindows()) { - $this->artisan('start') - ->expectsChoice('Takeout containers to start', $mysql, $menuItems) - ->assertExitCode(0); - } else { - $menuMock = $this->mock(Menu::class, function ($mock) use ($mysql) { - $mock->shouldReceive('setTitleSeparator')->andReturnSelf(); - $mock->shouldReceive('addItems')->andReturnSelf(); - $mock->shouldReceive('addLineBreak')->andReturnSelf(); - $mock->shouldReceive('open')->andReturn($mysql)->once(); - }); - - Command::macro( - 'menu', - function (string $title) use ($menuMock) { - Assert::assertEquals('Takeout containers to start', $title); - - return $menuMock; - } - ); - - $this->artisan('start', ['containerId' => ['mysql']]); - } + $this->artisan('start') + ->expectsQuestion('Takeout containers to start', $secondContainerId) + ->assertExitCode(0); } } diff --git a/tests/Feature/StopCommandTest.php b/tests/Feature/StopCommandTest.php index ab85955c..4c9d31c4 100644 --- a/tests/Feature/StopCommandTest.php +++ b/tests/Feature/StopCommandTest.php @@ -3,31 +3,18 @@ namespace Tests\Feature; use App\Shell\Docker; -use Illuminate\Console\Command; use Illuminate\Support\Collection; -use NunoMaduro\LaravelConsoleMenu\Menu; -use PHPUnit\Framework\Assert; use Tests\TestCase; class StopCommandTest extends TestCase { - function isWindows() - { - return PHP_OS_FAMILY === 'Windows'; - } - - function isLinux() - { - return PHP_OS_FAMILY === 'Linux'; - } - /** @test */ function it_can_stop_a_service_from_menu() { $services = Collection::make([ [ 'container_id' => $containerId = '12345', - 'names' => $containerName = 'TO--mysql--8.0.22--3306', + 'names' => 'TO--mysql--8.0.22--3306', 'status' => 'Up 27 minutes', 'ports' => '0.0.0.0:3306->3306/tcp, 33060/tcp', 'base_alias' => 'mysql', @@ -35,45 +22,26 @@ function it_can_stop_a_service_from_menu() ], ]); - $menuItems = [ - $mysql = $containerId . ' - ' . $containerName, - 'Exit', - ]; - $this->mock(Docker::class, function ($mock) use ($services, $containerId) { $mock->shouldReceive('isInstalled')->andReturn(true); $mock->shouldReceive('isDockerServiceRunning')->andReturn(true); - $mock->shouldReceive('stoppableTakeoutContainers')->andReturn($services, new Collection); + $mock->shouldReceive('activeTakeoutContainers')->andReturn($services, new Collection); $mock->shouldReceive('stopContainer')->with($containerId); }); - if ($this->isWindows()) { - $this->artisan('stop') - ->expectsChoice('Takeout containers to stop', $mysql, $menuItems) - ->assertExitCode(0); - } else { - $menuMock = $this->mock(Menu::class, function ($mock) use ($mysql) { - $mock->shouldReceive('setTitleSeparator')->andReturnSelf(); - $mock->shouldReceive('addItems')->andReturnSelf(); - $mock->shouldReceive('addLineBreak')->andReturnSelf(); - $mock->shouldReceive('open')->andReturn($mysql)->once(); - }); - - Command::macro( - 'menu', - function (string $title) use ($menuMock, $services) { - Assert::assertEquals('Takeout containers to stop', $title); - - return $menuMock; - } - ); - - $this->artisan('stop'); - } + $this->artisan('stop') + ->expectsQuestion('Takeout containers to stop', $containerId) + ->assertExitCode(0); } - /** @test */ - function it_can_stop_containers_by_service_name() + /** + * @test + * + * @testWith ["12345"] + * ["mysql"] + * + */ + function it_can_stop_containers_by_service_name_or_id($arg) { $services = Collection::make([ [ @@ -89,11 +57,11 @@ function it_can_stop_containers_by_service_name() $this->mock(Docker::class, function ($mock) use ($services, $containerId) { $mock->shouldReceive('isInstalled')->andReturn(true); $mock->shouldReceive('isDockerServiceRunning')->andReturn(true); - $mock->shouldReceive('stoppableTakeoutContainers')->andReturn($services, new Collection); + $mock->shouldReceive('activeTakeoutContainers')->andReturn($services, new Collection); $mock->shouldReceive('stopContainer')->with($containerId)->once(); }); - $this->artisan('stop', ['containerId' => ['mysql']]) + $this->artisan('stop', ['containerId' => [$arg]]) ->assertExitCode(0); } @@ -102,8 +70,8 @@ function it_can_stop_a_service_from_menu_when_there_are_multiple() { $services = Collection::make([ [ - 'container_id' => $firstContainerId = '12345', - 'names' => $firstContainerName = 'TO--mysql--8.0.22--3306', + 'container_id' => '12345', + 'names' => 'TO--mysql--8.0.22--3306', 'status' => 'Up 27 minutes', 'ports' => '0.0.0.0:3306->3306/tcp, 33060/tcp', 'base_alias' => 'mysql', @@ -111,7 +79,7 @@ function it_can_stop_a_service_from_menu_when_there_are_multiple() ], [ 'container_id' => $secondContainerId = '67890', - 'names' => $secondContainerName = 'TO--mysql--8.0.20--3306', + 'names' => 'TO--mysql--8.0.20--3306', 'status' => 'Up 27 minutes', 'ports' => '0.0.0.0:3306->3306/tcp, 33060/tcp', 'base_alias' => 'mysql', @@ -119,41 +87,15 @@ function it_can_stop_a_service_from_menu_when_there_are_multiple() ], ]); - $menuItems = [ - $firstContainerId . ' - ' . $firstContainerName, - $mysql = $secondContainerId . ' - ' . $secondContainerName, - 'Exit', - ]; - $this->mock(Docker::class, function ($mock) use ($services, $secondContainerId) { $mock->shouldReceive('isInstalled')->andReturn(true); $mock->shouldReceive('isDockerServiceRunning')->andReturn(true); - $mock->shouldReceive('stoppableTakeoutContainers')->andReturn($services, new Collection); + $mock->shouldReceive('activeTakeoutContainers')->andReturn($services, new Collection); $mock->shouldReceive('stopContainer')->with($secondContainerId)->once(); }); - if ($this->isWindows()) { - $this->artisan('stop') - ->expectsChoice('Takeout containers to stop', $mysql, $menuItems) - ->assertExitCode(0); - } else { - $menuMock = $this->mock(Menu::class, function ($mock) use ($mysql) { - $mock->shouldReceive('setTitleSeparator')->andReturnSelf(); - $mock->shouldReceive('addItems')->andReturnSelf(); - $mock->shouldReceive('addLineBreak')->andReturnSelf(); - $mock->shouldReceive('open')->andReturn($mysql)->once(); - }); - - Command::macro( - 'menu', - function (string $title) use ($menuMock, $services) { - Assert::assertEquals('Takeout containers to stop', $title); - - return $menuMock; - } - ); - - $this->artisan('stop', ['containerId' => ['mysql']]); - } + $this->artisan('stop') + ->expectsQuestion('Takeout containers to stop', $secondContainerId) + ->assertExitCode(0); } }