diff --git a/README.md b/README.md index c801b1b..7c937f5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ To get started, require the package globally via [Composer](https://getcomposer. composer global require spinupwp/spinupwp-cli -In addition, you should make sure the `/vendor/bin` directory in your global Composer home directory is in your system's "PATH". Depending on your operating system this could be either `~/.composer/` or `~/.config/composer/`. You can use the `composer config --global home` command to check this location. +In addition, you should make sure the `/vendor/bin` directory in your global Composer home directory is in your system's "PATH". Depending on your operating system this could +be either `~/.composer/` or `~/.config/composer/`. You can use the `composer config --global home` command to check this location. ## Usage @@ -46,6 +47,11 @@ If no profile is supplied, your default profile will be used (if configured). # List all servers spinupwp servers:list --fields=id,name,ip_address,ubuntu_version,database.server + # Search all servers + spinupwp servers:search + spinupwp servers:search + spinupwp servers:search + # Reboot a server spinupwp servers:reboot @@ -95,6 +101,11 @@ Nested properties should use dot notation, for example, `database.server`. # List all sites spinupwp sites:list --fields=id,server_id,domain,site_user,php_version,page_cache,https + # Search all sites + spinupwp sites:search domain.com + spinupwp sites:search + spinupwp sites:search + # Purge the page cache for a site spinupwp sites:purge --cache=page @@ -113,7 +124,6 @@ Nested properties should use dot notation, for example, `database.server`. You can pass any properties of the [Site Schema](https://api.spinupwp.com/?shell#tocS_Site) to the `--fields` flag. Nested properties should use dot notation, for example, `backups.next_run_time` or `git.branch`. - # Create a site using field flags instead of interactive prompts spinupwp sites:create --installation-method="" \ --domain="" --https-enabled --site-user="" --db-name="" \ diff --git a/app/Commands/Concerns/SpecifyFields.php b/app/Commands/Concerns/SpecifyFields.php index aba1183..41b47a4 100644 --- a/app/Commands/Concerns/SpecifyFields.php +++ b/app/Commands/Concerns/SpecifyFields.php @@ -29,7 +29,7 @@ protected function specifyFields(Resource $resource, array $fieldsFilter = []): $this->applyFilter($fieldsFilter); collect($this->fieldsMap)->each(function (Field $field) use ($resource, &$fields) { - $label = $field->getDisplayLabel($this->displayFormat() === 'table'); + $label = $field->getDisplayLabel($this->shouldShowNiceLabel()); if (!property_exists($resource, $field->getName())) { return; @@ -73,7 +73,7 @@ protected function saveFieldsFilter(bool $saveConfiguration = false): void protected function applyFilter(?array $fieldsFilter): void { if (!empty($fieldsFilter)) { - $this->fieldsMap = array_filter($this->fieldsMap, fn (Field $field) => $field->isInFilter($fieldsFilter)); + $this->fieldsMap = array_filter($this->fieldsMap, fn(Field $field) => $field->isInFilter($fieldsFilter)); } } @@ -83,6 +83,11 @@ protected function shouldSpecifyFields(): bool return $this->option('fields') || (isset($commandOptions['fields']) && !empty($commandOptions['fields'])); } + protected function shouldShowNiceLabel(): bool + { + return in_array($this->displayFormat(), ['table', 'list']); + } + private function getCommandFieldsConfiguration(string $command, string $profile): ?array { $commandFields = data_get($this->config->getCommandConfiguration($command, $profile), 'fields'); diff --git a/app/Commands/Servers/SearchCommand.php b/app/Commands/Servers/SearchCommand.php new file mode 100644 index 0000000..19e3728 --- /dev/null +++ b/app/Commands/Servers/SearchCommand.php @@ -0,0 +1,127 @@ +keyword = $this->argument('keyword'); + + $servers = collect($this->spinupwp->listServers()); + + if ($servers->isEmpty()) { + $this->warn('No servers found.'); + return self::SUCCESS; + } + + $servers->transform( + fn ($server) => $this->specifyFields($server, [ + 'id', + 'name', + 'ip_address', + 'ubuntu_version', + 'database.server', + ])); + + if (! in_array($this->displayFormat(), self::SUPPORTED_FORMATS)) { + $this->info("Format not supported"); + return self::FAILURE; + } + + $this->promptForSearch($servers); + + return self::SUCCESS; + } + + protected function promptForSearch(Collection $servers): void + { + if (! $this->keyword) { + $this->keyword = $this->ask('Enter a search term'); + } + + while (true) { + $filteredServers = $this->filterServers($servers); + + if ($filteredServers->isEmpty()) { + $this->info('No matching servers found.'); + $this->keyword = $this->ask('Enter a search term'); + continue; + } + + $this->info("Matching servers:"); + + if ($this->displayFormat() === 'table') { + $this->format($filteredServers); + } else { + $filteredServers->each(function ($server, $key) { + $this->line(sprintf("%d: %s (%s)", $key + 1, $server['Name'], $server['IP Address'])); + }); + } + + if ($filteredServers->count() > 1) { + $selection = $this->ask('Enter the number of the server to view'); + + if ($selection === '') { + $this->keyword = null; + continue; + } + + if (is_numeric($selection) && $selection > 0 && $selection <= $filteredServers->count()) { + $selectedServer = $filteredServers[$selection - 1]; + + $this->handleServerSelection($selectedServer); + + break; + } + } + + if ($filteredServers->count() === 1) { + $selectedServer = $filteredServers[0]; + + $this->handleServerSelection($selectedServer); + + break; + } + + $this->error('Invalid selection. Please try again.'); + } + } + + private function filterServers(Collection $servers): Collection + { + return $servers->filter(function ($server) { + return Str::contains($server['Name'], $this->keyword, true) || + Str::contains($server['IP Address'], $this->keyword, true) || + Str::contains($server['Server ID'], $this->keyword, true); + })->values()->map(function ($site, $index) { + return array_merge(['Match' => $index + 1], $site); + }); + } + + private function handleServerSelection($selectedServer): void + { + if ($this->confirm(sprintf("Do you want to SSH into: %s (%s)", + $selectedServer['Name'], + $selectedServer['IP Address'] + ))) { + $this->call('servers:ssh', ['server_id' => $selectedServer['Server ID']]); + } + } +} diff --git a/app/Commands/Servers/Servers.php b/app/Commands/Servers/Servers.php index f3ac7bd..727df2b 100644 --- a/app/Commands/Servers/Servers.php +++ b/app/Commands/Servers/Servers.php @@ -13,7 +13,7 @@ abstract class Servers extends BaseCommand protected function setup(): void { $this->fieldsMap = [ - (new Field('ID', 'id')), + (new Field('Server ID', 'id')), (new Field('Name', 'name')), (new Field('Provider Name', 'provider_name')), (new Field('IP Address', 'ip_address')), diff --git a/app/Commands/Sites/SearchCommand.php b/app/Commands/Sites/SearchCommand.php new file mode 100644 index 0000000..807c62a --- /dev/null +++ b/app/Commands/Sites/SearchCommand.php @@ -0,0 +1,138 @@ +keyword = $this->argument('keyword'); + + $serverId = $this->argument('server_id'); + + if ($serverId) { + $sites = $this->spinupwp->listSites((int) $serverId); + } else { + $sites = $this->spinupwp->listSites(); + } + + if ($sites->isEmpty()) { + $this->warn('No sites found.'); + return self::SUCCESS; + } + + $sites->transform( + fn($site) => $this->specifyFields($site, [ + 'id', + 'server_id', + 'domain', + 'site_user', + 'php_version', + 'page_cache', + 'https', + ]) + ); + + if (!in_array($this->displayFormat(), self::SUPPORTED_FORMATS)) { + $this->info("Format not supported"); + return self::FAILURE; + } + + $this->promptForSearch($sites); + + return self::SUCCESS; + } + + protected function promptForSearch(Collection $sites): void + { + if (!$this->keyword) { + $this->keyword = $this->ask('Enter a search term'); + } + + while (true) { + $filteredSites = $this->filterSites($sites); + + if ($filteredSites->isEmpty()) { + $this->info('No matching sites found.'); + $this->keyword = $this->ask('Enter a search term'); + continue; + } + + $this->info("Matching sites:"); + + if ($this->displayFormat() === 'table') { + $this->format($filteredSites); + } else { + $filteredSites->each(function ($site, $key) { + $this->line(sprintf("%d: %s (%s)", $key + 1, $site['Domain'], $site['Site User'])); + }); + } + + if ($filteredSites->count() > 1) { + $selection = $this->ask('Enter the number of the site to view'); + + if ($selection === '') { + $this->keyword = null; + continue; + } + + if (is_numeric($selection) && $selection > 0 && $selection <= $filteredSites->count()) { + $selectedSite = $filteredSites[$selection - 1]; + + $this->handleServerSelection($selectedSite); + + break; + } + } + + if ($filteredSites->count() === 1) { + $selectedSite = $filteredSites[0]; + + $this->handleServerSelection($selectedSite); + + break; + } + + $this->error('Invalid selection. Please try again.'); + } + } + + private function filterSites(Collection $sites): Collection + { + return $sites->filter(function ($site) { + return Str::contains($site['Domain'], $this->keyword, true) || + Str::contains($site['Site User'], $this->keyword, true) || + Str::contains($site['Site ID'], $this->keyword, true); + })->values()->map(function ($site, $index) { + return array_merge(['Match' => $index + 1], $site); + }); + } + + private function handleServerSelection($selectedSite): void + { + if ($this->confirm(sprintf("Do you want to SSH into: %s (%s)", + $selectedSite['Domain'], + $selectedSite['Site User'] + ))) { + $this->call('site:ssh', ['site_id' => $selectedSite['Site ID']]); + } + } + +} diff --git a/app/Commands/Sites/Sites.php b/app/Commands/Sites/Sites.php index db4bc9f..4b50cc2 100644 --- a/app/Commands/Sites/Sites.php +++ b/app/Commands/Sites/Sites.php @@ -13,7 +13,8 @@ abstract class Sites extends BaseCommand protected function setUp(): void { $this->fieldsMap = [ - (new Field('ID', 'id')), +// (new Field('ID', 'id')), + (new Field('Site ID', 'id')), (new Field('Server ID', 'server_id')), (new Field('Domain', 'domain')), (new Field('Additional Domains', 'additional_domains')) diff --git a/builds/spinupwp b/builds/spinupwp index 926bc4e..4dcfa12 100755 Binary files a/builds/spinupwp and b/builds/spinupwp differ diff --git a/tests/Feature/Commands/ServerSearchCommandTest.php b/tests/Feature/Commands/ServerSearchCommandTest.php new file mode 100644 index 0000000..5ad3361 --- /dev/null +++ b/tests/Feature/Commands/ServerSearchCommandTest.php @@ -0,0 +1,77 @@ + 1, + 'name' => 'hellfish-media', + 'provider_name' => 'DigitalOcean', + 'ubuntu_version' => '20.04', + 'ip_address' => '127.0.0.1', + 'disk_space' => [ + 'total' => 25210576000, + 'available' => 17549436000, + 'used' => 7661140000, + 'updated_at' => '2021-11-03T16:52:48.000000Z', + ], + 'database' => [ + 'server' => 'mysql-8.0', + ], + ], + [ + 'id' => 2, + 'name' => 'staging.hellfish-media', + 'provider_name' => 'DigitalOcean', + 'ubuntu_version' => '20.04', + 'ip_address' => '127.0.0.1', + 'disk_space' => [ + 'total' => 25210576000, + 'available' => 17549436000, + 'used' => 7661140000, + 'updated_at' => '2021-11-03T16:52:48.000000Z', + ], + 'database' => [ + 'server' => 'mysql-8.0', + ], + ], +]; +beforeEach(function () use ($response) { + setTestConfigFile(); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +it('list command with no api token configured', function () { + $this->spinupwp->setApiKey(''); + $this->artisan('servers:search --profile=johndoe') + ->assertExitCode(1); +}); + +test('sites search does not support json formatting', function () use ($response) { + $this->clientMock->shouldReceive('request')->with('GET', 'servers?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson($response)) + ); + $this->artisan('servers:search --format json')->expectsOutput('Format not supported'); +}); + +test('empty server search', function () { + $this->clientMock->shouldReceive('request')->with('GET', 'servers?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([])) + ); + $this->artisan('servers:search')->expectsOutput('No servers found.'); +}); + + +test('search prompts for term and server selection', function () use ($response) { + $this->clientMock->shouldReceive('request')->with('GET', 'servers?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson($response)) + ); + + $this->artisan('servers:search --format table') + ->expectsQuestion('Enter a search term', 'hellfish-media') + ->expectsQuestion('Enter the number of the server to view', 1) + ->expectsConfirmation('Do you want to SSH into: hellfish-media (127.0.0.1)', 'yes'); +}); diff --git a/tests/Feature/Commands/ServersGetCommandTest.php b/tests/Feature/Commands/ServersGetCommandTest.php index a2104e0..47008da 100644 --- a/tests/Feature/Commands/ServersGetCommandTest.php +++ b/tests/Feature/Commands/ServersGetCommandTest.php @@ -58,7 +58,7 @@ new Response(200, [], json_encode(['data' => $response])) ); $this->artisan('servers:get 1 --format=table')->expectsTable([], [ - ['ID', '1'], + ['Server ID', '1'], ['Name', 'hellfish-media'], ['IP Address', '127.0.0.1'], ['Ubuntu', '20.04'], @@ -73,7 +73,7 @@ $this->artisan('servers:get 1 --format=table --fields=id,name,ip_address') ->expectsConfirmation('Do you want to save the specified fields as the default for this command?', 'yes') ->expectsTable([], [ - ['ID', '1'], + ['Server ID', '1'], ['Name', 'hellfish-media'], ['IP Address', '127.0.0.1'], ]); @@ -88,7 +88,7 @@ $this->artisan('servers:get 1 --format=table') ->expectsTable([], [ - ['ID', '1'], + ['Server ID', '1'], ['Name', 'hellfish-media'], ]); diff --git a/tests/Feature/Commands/ServersListCommandTest.php b/tests/Feature/Commands/ServersListCommandTest.php index 25fe2ea..2038d11 100644 --- a/tests/Feature/Commands/ServersListCommandTest.php +++ b/tests/Feature/Commands/ServersListCommandTest.php @@ -64,7 +64,7 @@ new Response(200, [], listResponseJson($response)) ); $this->artisan('servers:list --format table')->expectsTable( - ['ID', 'Name', 'IP Address', 'Ubuntu', 'Database Server'], + ['Server ID', 'Name', 'IP Address', 'Ubuntu', 'Database Server'], [ [ '1', @@ -89,7 +89,7 @@ new Response(200, [], listResponseJson($response)) ); $this->artisan('servers:list --format=table --fields=id,name,ip_address')->expectsConfirmation('Do you want to save the specified fields as the default for this command?', 'yes')->expectsTable( - ['ID', 'Name', 'IP Address'], + ['Server ID', 'Name', 'IP Address'], [ [ '1', @@ -115,7 +115,7 @@ resolve(Configuration::class)->setCommandConfiguration('servers:list', 'fields', 'id,name'); $this->artisan('servers:list --format=table')->expectsTable( - ['ID', 'Name'], + ['Server ID', 'Name'], [ [ '1', diff --git a/tests/Feature/Commands/SitesGetCommandTest.php b/tests/Feature/Commands/SitesGetCommandTest.php index 1183650..2f57234 100644 --- a/tests/Feature/Commands/SitesGetCommandTest.php +++ b/tests/Feature/Commands/SitesGetCommandTest.php @@ -89,7 +89,7 @@ new Response(200, [], json_encode(['data' => $response])) ); $this->artisan('sites:get 1 --format=table')->expectsTable([], [ - ['ID', '1'], + ['Site ID', '1'], ['Server ID', '1'], ['Domain', 'hellfish.media'], ['Site User', 'hellfishmedia'], @@ -131,7 +131,7 @@ $this->artisan('sites:get 1 --format=table') ->expectsTable([], [ - ['ID', '1'], + ['Site ID', '1'], ['Domain', 'hellfish.media'], ]); diff --git a/tests/Feature/Commands/SitesListCommandTest.php b/tests/Feature/Commands/SitesListCommandTest.php index 9cacf9e..f97dc45 100644 --- a/tests/Feature/Commands/SitesListCommandTest.php +++ b/tests/Feature/Commands/SitesListCommandTest.php @@ -57,7 +57,7 @@ new Response(200, [], listResponseJson($response)) ); $this->artisan('sites:list --format table')->expectsTable( - ['ID', 'Server ID', 'Domain', 'Site User', 'PHP Version', 'Page Cache', 'HTTPS'], + ['Site ID', 'Server ID', 'Domain', 'Site User', 'PHP Version', 'Page Cache', 'HTTPS'], [ [ 1, @@ -88,7 +88,7 @@ $this->artisan('sites:list --format table --fields=id,domain,site_user') ->expectsConfirmation('Do you want to save the specified fields as the default for this command?', 'yes') ->expectsTable( - ['ID', 'Domain', 'Site User'], + ['Site ID', 'Domain', 'Site User'], [ [ 1, @@ -114,7 +114,7 @@ resolve(Configuration::class)->setCommandConfiguration('sites:list', 'fields', 'id,domain'); $this->artisan('sites:list --format=table')->expectsTable( - ['ID', 'Domain'], + ['Site ID', 'Domain'], [ [ 1, diff --git a/tests/Feature/Commands/SitesSearchCommandTest.php b/tests/Feature/Commands/SitesSearchCommandTest.php new file mode 100644 index 0000000..3ffecf5 --- /dev/null +++ b/tests/Feature/Commands/SitesSearchCommandTest.php @@ -0,0 +1,71 @@ + 1, + 'server_id' => 1, + 'domain' => 'hellfishmedia.com', + 'site_user' => 'hellfish', + 'php_version' => '8.0', + 'page_cache' => [ + 'enabled' => true, + ], + 'https' => [ + 'enabled' => true, + ], + ], + [ + 'id' => 2, + 'server_id' => 2, + 'domain' => 'staging.hellfishmedia.com', + 'site_user' => 'staging-hellfish', + 'php_version' => '8.0', + 'page_cache' => [ + 'enabled' => false, + ], + 'https' => [ + 'enabled' => false, + ], + ], +]; +beforeEach(function () use ($response) { + setTestConfigFile(); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +it('list command with no api token configured', function () { + $this->spinupwp->setApiKey(''); + $this->artisan('sites:search --profile=johndoe') + ->assertExitCode(1); +}); + +test('sites search does not support json formatting', function () use ($response) { + $this->clientMock->shouldReceive('request')->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson($response)) + ); + $this->artisan('sites:search --format json')->expectsOutput('Format not supported'); +}); + +test('empty sites search', function () { + $this->clientMock->shouldReceive('request')->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([])) + ); + $this->artisan('sites:search')->expectsOutput('No sites found.'); +}); + + +test('search prompts for term and site selection', function () use ($response) { + $this->clientMock->shouldReceive('request')->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson($response)) + ); + + $this->artisan('sites:search --format table') + ->expectsQuestion('Enter a search term', 'hellfish') + ->expectsQuestion('Enter the number of the site to view', 1) + ->expectsConfirmation('Do you want to SSH into: hellfishmedia.com (hellfish)', 'yes'); +});