From 074b6659bcd6d199384b580d1fbb4ae89bb7fe83 Mon Sep 17 00:00:00 2001 From: Vincent Bergeron Date: Tue, 29 Sep 2026 13:58:52 -0400 Subject: [PATCH] Add support for new endpoints --- README.md | 64 ++++++++++ app/Commands/BaseCommand.php | 2 +- app/Commands/Concerns/HasSiteIdParameter.php | 45 +++++++ app/Commands/Concerns/InteractsWithIO.php | 2 +- app/Commands/Concerns/ParsesOptions.php | 99 +++++++++++++++ .../Sites/Backups/ScheduleCommand.php | 109 ++++++++++++++++ .../Sites/Backups/SettingsCommand.php | 62 ++++++++++ .../Sites/BasicAuth/DisableCommand.php | 33 +++++ .../Sites/BasicAuth/EnableCommand.php | 58 +++++++++ .../Sites/BasicAuth/UpdateCommand.php | 43 +++++++ app/Commands/Sites/Cron/DisableCommand.php | 33 +++++ app/Commands/Sites/Cron/EnableCommand.php | 52 ++++++++ app/Commands/Sites/Cron/UpdateCommand.php | 52 ++++++++ app/Commands/Sites/DeleteCommand.php | 20 ++- app/Commands/Sites/Git/ConnectCommand.php | 104 ++++++++++++++++ app/Commands/Sites/Git/DisconnectCommand.php | 33 +++++ app/Commands/Sites/Git/UpdateCommand.php | 47 +++++++ app/Commands/Sites/GitDeployCommand.php | 21 ++-- app/Commands/Sites/NginxCommand.php | 57 +++++++++ .../Sites/PageCache/DisableCommand.php | 33 +++++ .../Sites/PageCache/EnableCommand.php | 34 +++++ app/Commands/Sites/PageCache/PageCache.php | 24 ++++ .../Sites/PageCache/UpdateCommand.php | 36 ++++++ app/Commands/Sites/PurgeCommand.php | 27 ++-- app/Commands/Sites/Redirects/AddCommand.php | 67 ++++++++++ .../Sites/Redirects/DeleteCommand.php | 79 ++++++++++++ app/Commands/Sites/Redirects/ListCommand.php | 47 +++++++ app/Commands/Sites/Redirects/Redirects.php | 25 ++++ app/Commands/Sites/SiteUserCommand.php | 117 ++++++++++++++++++ app/Commands/Sites/Sites.php | 6 + app/Commands/Sites/SshCommand.php | 9 +- app/Commands/SshKey/ListCommand.php | 45 +++++++ app/Commands/SshKey/SshKeys.php | 23 ++++ app/Commands/StorageProviders/ListCommand.php | 40 ++++++ .../StorageProviders/StorageProviders.php | 22 ++++ app/Helpers/OptionsHelper.php | 5 + app/Repositories/SpinupWpRepository.php | 25 ++++ composer.json | 2 +- .../CustomServerCreateCommandTest.php | 4 +- .../Commands/ServersDeleteCommandTest.php | 4 +- .../SitesBackupsScheduleCommandTest.php | 57 +++++++++ .../SitesBackupsSettingsCommandTest.php | 43 +++++++ .../SitesBasicAuthDisableCommandTest.php | 30 +++++ .../SitesBasicAuthEnableCommandTest.php | 40 ++++++ .../SitesBasicAuthUpdateCommandTest.php | 39 ++++++ .../Commands/SitesCreateCommandTest.php | 8 +- .../Commands/SitesCronDisableCommandTest.php | 41 ++++++ .../Commands/SitesCronEnableCommandTest.php | 68 ++++++++++ .../Commands/SitesCronUpdateCommandTest.php | 40 ++++++ .../Commands/SitesDeleteCommandTest.php | 14 ++- .../Feature/Commands/SitesGetCommandTest.php | 18 +++ .../Commands/SitesGitConnectCommandTest.php | 91 ++++++++++++++ .../Commands/SitesGitDeployCommandTest.php | 14 ++- .../SitesGitDisconnectCommandTest.php | 45 +++++++ .../Commands/SitesGitUpdateCommandTest.php | 50 ++++++++ .../Commands/SitesNginxCommandTest.php | 43 +++++++ .../SitesPageCacheDisableCommandTest.php | 26 ++++ .../SitesPageCacheEnableCommandTest.php | 54 ++++++++ .../SitesPageCacheUpdateCommandTest.php | 36 ++++++ .../Commands/SitesPurgeCommandTest.php | 24 ++++ .../Commands/SitesRedirectsAddCommandTest.php | 53 ++++++++ .../SitesRedirectsDeleteCommandTest.php | 45 +++++++ .../SitesRedirectsListCommandTest.php | 53 ++++++++ .../Commands/SitesSiteUserCommandTest.php | 84 +++++++++++++ .../Commands/SshKeyListCommandTest.php | 54 ++++++++ .../StorageProvidersListCommandTest.php | 49 ++++++++ 66 files changed, 2669 insertions(+), 60 deletions(-) create mode 100644 app/Commands/Concerns/HasSiteIdParameter.php create mode 100644 app/Commands/Concerns/ParsesOptions.php create mode 100644 app/Commands/Sites/Backups/ScheduleCommand.php create mode 100644 app/Commands/Sites/Backups/SettingsCommand.php create mode 100644 app/Commands/Sites/BasicAuth/DisableCommand.php create mode 100644 app/Commands/Sites/BasicAuth/EnableCommand.php create mode 100644 app/Commands/Sites/BasicAuth/UpdateCommand.php create mode 100644 app/Commands/Sites/Cron/DisableCommand.php create mode 100644 app/Commands/Sites/Cron/EnableCommand.php create mode 100644 app/Commands/Sites/Cron/UpdateCommand.php create mode 100644 app/Commands/Sites/Git/ConnectCommand.php create mode 100644 app/Commands/Sites/Git/DisconnectCommand.php create mode 100644 app/Commands/Sites/Git/UpdateCommand.php create mode 100644 app/Commands/Sites/NginxCommand.php create mode 100644 app/Commands/Sites/PageCache/DisableCommand.php create mode 100644 app/Commands/Sites/PageCache/EnableCommand.php create mode 100644 app/Commands/Sites/PageCache/PageCache.php create mode 100644 app/Commands/Sites/PageCache/UpdateCommand.php create mode 100644 app/Commands/Sites/Redirects/AddCommand.php create mode 100644 app/Commands/Sites/Redirects/DeleteCommand.php create mode 100644 app/Commands/Sites/Redirects/ListCommand.php create mode 100644 app/Commands/Sites/Redirects/Redirects.php create mode 100644 app/Commands/Sites/SiteUserCommand.php create mode 100644 app/Commands/SshKey/ListCommand.php create mode 100644 app/Commands/SshKey/SshKeys.php create mode 100644 app/Commands/StorageProviders/ListCommand.php create mode 100644 app/Commands/StorageProviders/StorageProviders.php create mode 100644 tests/Feature/Commands/SitesBackupsScheduleCommandTest.php create mode 100644 tests/Feature/Commands/SitesBackupsSettingsCommandTest.php create mode 100644 tests/Feature/Commands/SitesBasicAuthDisableCommandTest.php create mode 100644 tests/Feature/Commands/SitesBasicAuthEnableCommandTest.php create mode 100644 tests/Feature/Commands/SitesBasicAuthUpdateCommandTest.php create mode 100644 tests/Feature/Commands/SitesCronDisableCommandTest.php create mode 100644 tests/Feature/Commands/SitesCronEnableCommandTest.php create mode 100644 tests/Feature/Commands/SitesCronUpdateCommandTest.php create mode 100644 tests/Feature/Commands/SitesGitConnectCommandTest.php create mode 100644 tests/Feature/Commands/SitesGitDisconnectCommandTest.php create mode 100644 tests/Feature/Commands/SitesGitUpdateCommandTest.php create mode 100644 tests/Feature/Commands/SitesNginxCommandTest.php create mode 100644 tests/Feature/Commands/SitesPageCacheDisableCommandTest.php create mode 100644 tests/Feature/Commands/SitesPageCacheEnableCommandTest.php create mode 100644 tests/Feature/Commands/SitesPageCacheUpdateCommandTest.php create mode 100644 tests/Feature/Commands/SitesRedirectsAddCommandTest.php create mode 100644 tests/Feature/Commands/SitesRedirectsDeleteCommandTest.php create mode 100644 tests/Feature/Commands/SitesRedirectsListCommandTest.php create mode 100644 tests/Feature/Commands/SitesSiteUserCommandTest.php create mode 100644 tests/Feature/Commands/SshKeyListCommandTest.php create mode 100644 tests/Feature/Commands/StorageProvidersListCommandTest.php diff --git a/README.md b/README.md index 0fcf485..1cb50a0 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,55 @@ Nested properties should use dot notation, for example, `database.server`. # Start an SSH session as the site user spinupwp sites:ssh + # Connect a Git repository + spinupwp sites:git:connect --repo= --branch= --push-to-deploy + + # Connect a Git repository using a unique deploy key for the site + spinupwp sites:git:connect --repo= --branch= \ + --private-key=~/.ssh/deploy_key --public-key=~/.ssh/deploy_key.pub + + # Update Git settings + spinupwp sites:git:update --branch=production --push-to-deploy=false + + # Disconnect Git + spinupwp sites:git:disconnect + + # Enable, update, or disable the page cache + spinupwp sites:page-cache:enable + spinupwp sites:page-cache:update --duration=1 --duration-unit=h --path-exclusion=/cart/ --path-exclusion=/checkout/ + spinupwp sites:page-cache:disable + + # Update Nginx settings + spinupwp sites:nginx --uploads-directory-protected=true --xmlrpc-protected=true + + # Enable, update, or disable the server-level WP cron + spinupwp sites:cron:enable --interval=5 + spinupwp sites:cron:update --interval=15 + spinupwp sites:cron:disable + + # Enable, update, or disable basic auth + spinupwp sites:basic-auth:enable --username= --password= + spinupwp sites:basic-auth:update --username= + spinupwp sites:basic-auth:disable + + # Manage path redirects + spinupwp sites:redirects:list + spinupwp sites:redirects:add --from=/old-path --to=/new-path --type=permanent + spinupwp sites:redirects:delete --from=/old-path --to=/new-path + + # Update backup settings + spinupwp sites:backups:settings --storage-provider-id=1 --bucket= --region=nyc3 --exclude-path=node_modules + + # Update the backup schedule (daily at 2am and 2pm, monthly on the first Monday at 4am) + spinupwp sites:backups:schedule --daily-times=2,14 --daily-database=true --daily-files=true \ + --daily-retention=30 --monthly-days=first:1 --monthly-time=4 + + # Disable weekly backups + spinupwp sites:backups:schedule --weekly-days=none + + # Update the site user's SSH authentication + spinupwp sites:site-user --authentication=publickey --ssh-key-id=1 --ssh-key-id=2 + 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`. @@ -130,11 +179,26 @@ Nested properties should use dot notation, for example, `backups.next_run_time` --wp-admin-email="" --wp-admin-user="" \ --wp-admin-pass="" --php-version="" --page-cache-enabled +Update commands only change the settings you pass. Boolean flags accept `true` or `false`, and repeatable flags such as `--path-exclusion` can be passed multiple times. + A full reference of the available flags is available anytime. # Display available Arguments and Options for site:create spinupwp sites:create --help +### SSH Keys + + # Get SpinupWP's SSH key + spinupwp ssh-key:get + + # List your team's SSH keys + spinupwp ssh-key:list + +### Storage Providers + + # List your team's storage providers + spinupwp storage-providers:list + ## Upgrade To update the SpinupWP CLI to the latest version, run: diff --git a/app/Commands/BaseCommand.php b/app/Commands/BaseCommand.php index 9d1cb60..4145feb 100644 --- a/app/Commands/BaseCommand.php +++ b/app/Commands/BaseCommand.php @@ -101,7 +101,7 @@ protected function profile(): string protected function applyValidationLabel(string $key): string { if (empty($this->validationLabels) || !array_key_exists($key, $this->validationLabels)) { - return Str::title(str_replace('_', ' ', $key)); + return Str::title(str_replace(['_', '.'], ' ', $key)); } return $this->validationLabels[$key]; diff --git a/app/Commands/Concerns/HasSiteIdParameter.php b/app/Commands/Concerns/HasSiteIdParameter.php new file mode 100644 index 0000000..3bed8f3 --- /dev/null +++ b/app/Commands/Concerns/HasSiteIdParameter.php @@ -0,0 +1,45 @@ +argument('site_id'); + + if (empty($siteId)) { + $siteId = $this->askToSelectSite($question, $filter); + } + + if (empty($siteId)) { + $this->warn($noSitesMessage); + return null; + } + + return $this->spinupwp->getSite((int) $siteId); + } + + /** + * @param int|string|null $eventId + */ + protected function displaySiteEvent(Site $site, $eventId, string $queuedMessage, string $savedMessage = ''): void + { + if (is_null($eventId)) { + $this->successfulStep($savedMessage ?: $queuedMessage); + return; + } + + $this->successfulStep($queuedMessage); + + $this->stepTable([ + 'Event ID', + 'Site', + ], [[ + $eventId, + $site->domain, + ]]); + } +} diff --git a/app/Commands/Concerns/InteractsWithIO.php b/app/Commands/Concerns/InteractsWithIO.php index 09dabc5..9a62250 100644 --- a/app/Commands/Concerns/InteractsWithIO.php +++ b/app/Commands/Concerns/InteractsWithIO.php @@ -98,7 +98,7 @@ protected function toTable($resource): void $resource->each(function ($item) use (&$rows) { if (!is_array($item)) { - $item->toArray(); + $item = $item->toArray(); } $row = array_map(function ($value) { diff --git a/app/Commands/Concerns/ParsesOptions.php b/app/Commands/Concerns/ParsesOptions.php new file mode 100644 index 0000000..f72f4a9 --- /dev/null +++ b/app/Commands/Concerns/ParsesOptions.php @@ -0,0 +1,99 @@ +option($name); + + return is_string($value) ? $value : null; + } + + protected function intOption(string $name): ?int + { + $value = $this->stringOption($name); + + if (is_null($value)) { + return null; + } + + if (!is_numeric($value) || (string) (int) $value !== $value) { + throw new Exception("The --{$name} option must be an integer."); + } + + return (int) $value; + } + + protected function boolOption(string $name): ?bool + { + $value = $this->stringOption($name); + + if (is_null($value)) { + return null; + } + + $bool = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if (is_null($bool)) { + throw new Exception("The --{$name} option must be true or false."); + } + + return $bool; + } + + /** + * Parse a comma-separated list of integers. Passing "none" returns an empty list. + */ + protected function intListOption(string $name): ?array + { + $value = $this->stringOption($name); + + if (is_null($value)) { + return null; + } + + if ($value === 'none') { + return []; + } + + return array_map(function ($item) use ($name) { + $item = trim($item); + + if (!is_numeric($item) || (string) (int) $item !== $item) { + throw new Exception("The --{$name} option must be a comma-separated list of integers."); + } + + return (int) $item; + }, explode(',', $value)); + } + + /** + * Join the values of a repeatable option into a newline-separated string. + */ + protected function multilineOption(string $name): ?string + { + $values = $this->option($name); + + if (!is_array($values) || empty($values)) { + return null; + } + + return implode("\n", array_filter($values, fn ($value) => is_string($value) && $value !== '')); + } + + protected function withoutNulls(array $payload): array + { + return array_filter($payload, fn ($value) => !is_null($value)); + } + + protected function ensureOptionsProvided(array $payload): void + { + if (empty($payload)) { + throw new Exception("At least one option must be provided. Run '{$this->getName()} --help' to see the available options."); + } + } +} diff --git a/app/Commands/Sites/Backups/ScheduleCommand.php b/app/Commands/Sites/Backups/ScheduleCommand.php new file mode 100644 index 0000000..677c24a --- /dev/null +++ b/app/Commands/Sites/Backups/ScheduleCommand.php @@ -0,0 +1,109 @@ + $this->schedulePayload('daily', [ + 'time_of_day' => $this->intListOption('daily-times'), + ]), + 'weekly_schedule' => $this->schedulePayload('weekly', [ + 'day_of_week' => $this->intListOption('weekly-days'), + 'time_of_day' => $this->intOption('weekly-time'), + ]), + 'monthly_schedule' => $this->schedulePayload('monthly', [ + 'day_of_month' => $this->monthlyDaysOption(), + 'time_of_day' => $this->intOption('monthly-time'), + ]), + ]); + + $this->ensureOptionsProvided($payload); + + $site = $this->selectSite('Which site would you like to update the backup schedule for'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $site->updateBackupSchedule($payload); + + $this->successfulStep('Backup schedule updated.'); + + return self::SUCCESS; + } + + protected function schedulePayload(string $frequency, array $timing): array + { + $schedule = $this->withoutNulls(array_merge($timing, [ + 'backup_database' => $this->boolOption("{$frequency}-database"), + 'backup_files' => $this->boolOption("{$frequency}-files"), + ])); + + if ($this->stringOption("{$frequency}-retention") === 'none') { + $schedule['retention_period'] = null; + } elseif (!is_null($retention = $this->intOption("{$frequency}-retention"))) { + $schedule['retention_period'] = $retention; + } + + return $schedule; + } + + protected function monthlyDaysOption(): ?array + { + $value = $this->stringOption('monthly-days'); + + if (is_null($value)) { + return null; + } + + if ($value === 'none') { + return []; + } + + return array_map(function ($pair) { + $parts = explode(':', trim($pair)); + + if (count($parts) !== 2 || !in_array($parts[0], self::WEEKS_OF_MONTH, true) || !ctype_digit($parts[1])) { + throw new Exception('The --monthly-days option must be a comma-separated list of week:weekday pairs, for example "first:1,last:5".'); + } + + return [ + 'week_of_month' => $parts[0], + 'day_of_week' => (int) $parts[1], + ]; + }, explode(',', $value)); + } +} diff --git a/app/Commands/Sites/Backups/SettingsCommand.php b/app/Commands/Sites/Backups/SettingsCommand.php new file mode 100644 index 0000000..fabd019 --- /dev/null +++ b/app/Commands/Sites/Backups/SettingsCommand.php @@ -0,0 +1,62 @@ + 'Bucket', + 'storage_provider_region' => 'Region', + 'storage_provider_class' => 'Storage Class', + ]; + + protected function action(): int + { + $payload = $this->withoutNulls([ + 'storage_provider_id' => $this->intOption('storage-provider-id'), + 'storage_provider_bucket' => $this->stringOption('bucket'), + 'storage_provider_region' => $this->stringOption('region'), + 'storage_provider_class' => $this->stringOption('storage-class'), + 'paths_to_exclude' => $this->multilineOption('exclude-path'), + ]); + + if ($this->stringOption('database') === 'none') { + $payload['database'] = null; + } elseif (!is_null($database = $this->intOption('database'))) { + $payload['database'] = $database; + } + + $this->ensureOptionsProvided($payload); + + $site = $this->selectSite('Which site would you like to update the backup settings for'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $site->updateBackupSettings($payload); + + $this->successfulStep('Backup settings updated.'); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/BasicAuth/DisableCommand.php b/app/Commands/Sites/BasicAuth/DisableCommand.php new file mode 100644 index 0000000..f43e2a1 --- /dev/null +++ b/app/Commands/Sites/BasicAuth/DisableCommand.php @@ -0,0 +1,33 @@ +selectSite('Which site would you like to disable basic auth for', fn ($site) => $site->basic_auth['enabled'], 'There are no sites with basic auth enabled.'); + + if (is_null($site) || !$this->forceOrConfirm("Are you sure you want to disable basic auth for \"{$site->domain}\"?")) { + return self::SUCCESS; + } + + $eventId = $site->disableBasicAuth(); + + $this->displaySiteEvent($site, $eventId, 'Basic auth queued for disabling.'); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/BasicAuth/EnableCommand.php b/app/Commands/Sites/BasicAuth/EnableCommand.php new file mode 100644 index 0000000..64db834 --- /dev/null +++ b/app/Commands/Sites/BasicAuth/EnableCommand.php @@ -0,0 +1,58 @@ +selectSite('Which site would you like to enable basic auth for', fn ($site) => !$site->basic_auth['enabled'], 'There are no sites with basic auth disabled.'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $answers = $this->askQuestions($this->nonInteractive()); + + $eventId = $site->enableBasicAuth([ + 'username' => $answers['username'], + 'password' => $answers['password'], + ]); + + $this->displaySiteEvent($site, $eventId, 'Basic auth queued for enabling.'); + + return self::SUCCESS; + } + + /** + * @return Question[] + */ + public function questions(): array + { + return [ + Ask::make('Username') + ->withFlag('username'), + + Ask::make('Password') + ->withFlag('password'), + ]; + } +} diff --git a/app/Commands/Sites/BasicAuth/UpdateCommand.php b/app/Commands/Sites/BasicAuth/UpdateCommand.php new file mode 100644 index 0000000..398e056 --- /dev/null +++ b/app/Commands/Sites/BasicAuth/UpdateCommand.php @@ -0,0 +1,43 @@ +withoutNulls([ + 'username' => $this->stringOption('username'), + 'password' => $this->stringOption('password'), + ]); + + $this->ensureOptionsProvided($payload); + + $site = $this->selectSite('Which site would you like to update the basic auth credentials for', fn ($site) => $site->basic_auth['enabled'], 'There are no sites with basic auth enabled.'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $eventId = $site->updateBasicAuth($payload); + + $this->displaySiteEvent($site, $eventId, 'Basic auth credentials queued for update.', 'Basic auth credentials are already up to date.'); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/Cron/DisableCommand.php b/app/Commands/Sites/Cron/DisableCommand.php new file mode 100644 index 0000000..841475a --- /dev/null +++ b/app/Commands/Sites/Cron/DisableCommand.php @@ -0,0 +1,33 @@ +selectSite('Which site would you like to disable the WP cron for', fn ($site) => $site->cron['enabled'], 'There are no sites with WP cron enabled.'); + + if (is_null($site) || !$this->forceOrConfirm("Are you sure you want to disable the WP cron for \"{$site->domain}\"?")) { + return self::SUCCESS; + } + + $eventId = $site->disableCron(); + + $this->displaySiteEvent($site, $eventId, 'WP cron queued for disabling.'); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/Cron/EnableCommand.php b/app/Commands/Sites/Cron/EnableCommand.php new file mode 100644 index 0000000..3f15ab9 --- /dev/null +++ b/app/Commands/Sites/Cron/EnableCommand.php @@ -0,0 +1,52 @@ +selectSite('Which site would you like to enable the WP cron for', fn ($site) => !$site->cron['enabled'], 'There are no sites with WP cron disabled.'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $answers = $this->askQuestions($this->nonInteractive()); + + $eventId = $site->enableCron(['interval' => (int) $answers['interval']]); + + $this->displaySiteEvent($site, $eventId, 'WP cron queued for enabling.'); + + return self::SUCCESS; + } + + /** + * @return Question[] + */ + public function questions(): array + { + return [ + Ask::make('How often should the WP cron run (1, 2, 5, 10, 15, 30, or 60 minutes)?') + ->withFlag('interval') + ->withDefault('5'), + ]; + } +} diff --git a/app/Commands/Sites/Cron/UpdateCommand.php b/app/Commands/Sites/Cron/UpdateCommand.php new file mode 100644 index 0000000..e37ac51 --- /dev/null +++ b/app/Commands/Sites/Cron/UpdateCommand.php @@ -0,0 +1,52 @@ +selectSite('Which site would you like to update the WP cron interval for', fn ($site) => $site->cron['enabled'], 'There are no sites with WP cron enabled.'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $answers = $this->askQuestions($this->nonInteractive()); + + $eventId = $site->updateCron(['interval' => (int) $answers['interval']]); + + $this->displaySiteEvent($site, $eventId, 'WP cron interval queued for update.'); + + return self::SUCCESS; + } + + /** + * @return Question[] + */ + public function questions(): array + { + return [ + Ask::make('How often should the WP cron run (1, 2, 5, 10, 15, 30, or 60 minutes)?') + ->withFlag('interval') + ->withDefault('5'), + ]; + } +} diff --git a/app/Commands/Sites/DeleteCommand.php b/app/Commands/Sites/DeleteCommand.php index 156b0ee..e8f8953 100644 --- a/app/Commands/Sites/DeleteCommand.php +++ b/app/Commands/Sites/DeleteCommand.php @@ -3,9 +3,12 @@ namespace App\Commands\Sites; use App\Commands\BaseCommand; +use App\Commands\Concerns\HasSiteIdParameter; class DeleteCommand extends BaseCommand { + use HasSiteIdParameter; + protected $signature = "sites:delete {site_id? : The site to delete} {--d|delete-database : Delete the site's associated database} @@ -17,13 +20,12 @@ class DeleteCommand extends BaseCommand public function action(): int { - $siteId = $this->argument('site_id'); + $site = $this->selectSite('Which site would you like to delete'); - if (empty($siteId)) { - $siteId = $this->askToSelectSite('Which site would you like to delete'); + if (is_null($site)) { + return self::SUCCESS; } - $site = $this->spinupwp->getSite((int) $siteId); $force = $this->option('force'); if (!$force) { @@ -37,15 +39,7 @@ public function action(): int (bool) $this->option('delete-backups'), ); - $this->successfulStep('Site queued for deletion.'); - - $this->stepTable([ - 'Event ID', - 'Domain', - ], [[ - $eventId, - $site->domain, - ]]); + $this->displaySiteEvent($site, $eventId, 'Site queued for deletion.'); } return self::SUCCESS; diff --git a/app/Commands/Sites/Git/ConnectCommand.php b/app/Commands/Sites/Git/ConnectCommand.php new file mode 100644 index 0000000..22bc290 --- /dev/null +++ b/app/Commands/Sites/Git/ConnectCommand.php @@ -0,0 +1,104 @@ + 'Private Key', + 'deploy_key.publickey' => 'Public Key', + ]; + + protected function action(): int + { + $site = $this->selectSite('Which site would you like to connect a Git repository to', fn ($site) => !$site->git['enabled'], 'There are no sites with Git disabled.'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $answers = $this->askQuestions($this->nonInteractive()); + + $payload = $this->withoutNulls([ + 'repo' => $answers['repo'], + 'branch' => $answers['branch'], + 'deploy_script' => $this->stringOption('deploy-script'), + 'push_to_deploy' => (bool) $this->option('push-to-deploy'), + 'deploy_key' => $this->deployKey(), + ]); + + $eventId = $site->connectGit($payload); + + $this->displaySiteEvent($site, $eventId, 'Git repository queued for connection.'); + + return self::SUCCESS; + } + + /** + * @return Question[] + */ + public function questions(): array + { + return [ + Ask::make('Repository') + ->withFlag('repo'), + + Ask::make('Branch') + ->withFlag('branch') + ->withDefault('main'), + ]; + } + + protected function deployKey(): ?array + { + $privateKeyPath = $this->stringOption('private-key'); + $publicKeyPath = $this->stringOption('public-key'); + + if (is_null($privateKeyPath) && is_null($publicKeyPath)) { + return null; + } + + if (is_null($privateKeyPath) || is_null($publicKeyPath)) { + throw new Exception('The --private-key and --public-key options must be provided together.'); + } + + return [ + 'privatekey' => $this->readKeyFile($privateKeyPath), + 'publickey' => $this->readKeyFile($publicKeyPath), + ]; + } + + protected function readKeyFile(string $path): string + { + if (!is_file($path) || !is_readable($path)) { + throw new Exception("Unable to read the key file \"{$path}\"."); + } + + return trim((string) file_get_contents($path)); + } +} diff --git a/app/Commands/Sites/Git/DisconnectCommand.php b/app/Commands/Sites/Git/DisconnectCommand.php new file mode 100644 index 0000000..c1fdf98 --- /dev/null +++ b/app/Commands/Sites/Git/DisconnectCommand.php @@ -0,0 +1,33 @@ +selectSite('Which site would you like to disconnect Git from', fn ($site) => $site->git['enabled'], 'There are no sites with Git enabled.'); + + if (is_null($site) || !$this->forceOrConfirm("Are you sure you want to disconnect Git from \"{$site->domain}\"?", false)) { + return self::SUCCESS; + } + + $eventId = $site->disconnectGit(); + + $this->displaySiteEvent($site, $eventId, 'Git queued for disconnection.'); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/Git/UpdateCommand.php b/app/Commands/Sites/Git/UpdateCommand.php new file mode 100644 index 0000000..9ae2c8d --- /dev/null +++ b/app/Commands/Sites/Git/UpdateCommand.php @@ -0,0 +1,47 @@ +withoutNulls([ + 'repo' => $this->stringOption('repo'), + 'branch' => $this->stringOption('branch'), + 'deploy_script' => $this->stringOption('deploy-script'), + 'push_to_deploy' => $this->boolOption('push-to-deploy'), + ]); + + $this->ensureOptionsProvided($payload); + + $site = $this->selectSite('Which site would you like to update the Git settings for', fn ($site) => $site->git['enabled'], 'There are no sites with Git enabled.'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $eventId = $site->updateGit($payload); + + $this->displaySiteEvent($site, $eventId, 'Git settings queued for update.', 'Git settings updated.'); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/GitDeployCommand.php b/app/Commands/Sites/GitDeployCommand.php index b087608..eb60062 100644 --- a/app/Commands/Sites/GitDeployCommand.php +++ b/app/Commands/Sites/GitDeployCommand.php @@ -3,10 +3,13 @@ namespace App\Commands\Sites; use App\Commands\BaseCommand; +use App\Commands\Concerns\HasSiteIdParameter; use Exception; class GitDeployCommand extends BaseCommand { + use HasSiteIdParameter; + protected $signature = 'sites:deploy {site_id? : The site to deploy} {--profile= : The SpinupWP configuration profile to use}'; @@ -15,14 +18,12 @@ class GitDeployCommand extends BaseCommand public function action(): int { - $siteId = $this->argument('site_id'); + $site = $this->selectSite('Which site would you like to deploy', fn ($site) => $site->git['enabled'], 'There are no sites with Git enabled.'); - if (empty($siteId)) { - $siteId = $this->askToSelectSite('Which site would you like to deploy', fn ($site) => $site->git['enabled']); + if (is_null($site)) { + return self::SUCCESS; } - $site = $this->spinupwp->sites->get((int) $siteId); - if (!$site->git['enabled']) { $this->warn('This site is not configured for Git deployments.'); return self::SUCCESS; @@ -35,15 +36,7 @@ public function action(): int return self::SUCCESS; } - $this->successfulStep('Site queued for deployment.'); - - $this->stepTable([ - 'Event ID', - 'Site', - ], [[ - $eventId, - $site->domain, - ]]); + $this->displaySiteEvent($site, $eventId, 'Site queued for deployment.'); return self::SUCCESS; } diff --git a/app/Commands/Sites/NginxCommand.php b/app/Commands/Sites/NginxCommand.php new file mode 100644 index 0000000..c512766 --- /dev/null +++ b/app/Commands/Sites/NginxCommand.php @@ -0,0 +1,57 @@ +withoutNulls([ + 'uploads_directory_protected' => $this->boolOption('uploads-directory-protected'), + 'xmlrpc_protected' => $this->boolOption('xmlrpc-protected'), + 'subdirectory_rewrite_in_place' => $this->boolOption('subdirectory-rewrite'), + 'public_folder' => $this->stringOption('public-folder'), + ]); + + $this->ensureOptionsProvided($payload); + + $site = $this->selectSite('Which site would you like to update the Nginx settings for'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $eventIds = $site->updateNginx($payload); + + if (empty($eventIds)) { + $this->successfulStep('Nginx settings are already up to date.'); + return self::SUCCESS; + } + + $this->successfulStep('Nginx settings queued for update.'); + + $this->stepTable([ + 'Event ID', + 'Site', + ], array_map(fn ($eventId) => [$eventId, $site->domain], $eventIds)); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/PageCache/DisableCommand.php b/app/Commands/Sites/PageCache/DisableCommand.php new file mode 100644 index 0000000..b5666c4 --- /dev/null +++ b/app/Commands/Sites/PageCache/DisableCommand.php @@ -0,0 +1,33 @@ +selectSite('Which site would you like to disable the page cache for', fn ($site) => $site->page_cache['enabled'], 'There are no sites with page cache enabled.'); + + if (is_null($site) || !$this->forceOrConfirm("Are you sure you want to disable the page cache for \"{$site->domain}\"?")) { + return self::SUCCESS; + } + + $eventId = $site->disablePageCache(); + + $this->displaySiteEvent($site, $eventId, 'Page cache queued for disabling.'); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/PageCache/EnableCommand.php b/app/Commands/Sites/PageCache/EnableCommand.php new file mode 100644 index 0000000..a3d1701 --- /dev/null +++ b/app/Commands/Sites/PageCache/EnableCommand.php @@ -0,0 +1,34 @@ +settingsPayload(); + + $site = $this->selectSite('Which site would you like to enable the page cache for', fn ($site) => !$site->page_cache['enabled'], 'There are no sites with page cache disabled.'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $eventId = $site->enablePageCache($payload); + + $this->displaySiteEvent($site, $eventId, 'Page cache queued for enabling.'); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/PageCache/PageCache.php b/app/Commands/Sites/PageCache/PageCache.php new file mode 100644 index 0000000..b22b6e6 --- /dev/null +++ b/app/Commands/Sites/PageCache/PageCache.php @@ -0,0 +1,24 @@ +withoutNulls([ + 'duration' => $this->intOption('duration'), + 'duration_unit' => $this->stringOption('duration-unit'), + 'path_exclusions' => $this->multilineOption('path-exclusion'), + 'cookie_exclusions' => $this->multilineOption('cookie-exclusion'), + 'ignored_query_params' => $this->multilineOption('ignored-query-param'), + ]); + } +} diff --git a/app/Commands/Sites/PageCache/UpdateCommand.php b/app/Commands/Sites/PageCache/UpdateCommand.php new file mode 100644 index 0000000..d54d3f5 --- /dev/null +++ b/app/Commands/Sites/PageCache/UpdateCommand.php @@ -0,0 +1,36 @@ +settingsPayload(); + + $this->ensureOptionsProvided($payload); + + $site = $this->selectSite('Which site would you like to update the page cache settings for', fn ($site) => $site->page_cache['enabled'], 'There are no sites with page cache enabled.'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $eventId = $site->updatePageCache($payload); + + $this->displaySiteEvent($site, $eventId, 'Page cache settings queued for update.'); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/PurgeCommand.php b/app/Commands/Sites/PurgeCommand.php index d5f7ddf..a47cc4b 100644 --- a/app/Commands/Sites/PurgeCommand.php +++ b/app/Commands/Sites/PurgeCommand.php @@ -2,10 +2,14 @@ namespace App\Commands\Sites; +use App\Commands\BaseCommand; +use App\Commands\Concerns\HasSiteIdParameter; use Illuminate\Support\Collection; -class PurgeCommand extends \App\Commands\BaseCommand +class PurgeCommand extends BaseCommand { + use HasSiteIdParameter; + protected $signature = 'sites:purge {site_id? : The site to purge} {--cache= : The cache to purge} {--all : Purge cache on all sites} @@ -31,24 +35,17 @@ protected function action(): int return self::SUCCESS; } - $siteId = $this->argument('site_id'); - - if (empty($siteId)) { - $siteId = $this->askToSelectSite('Which site do you want to purge the page cache for', function ($site) use ($cacheToPurge) { - if ($cacheToPurge === 'page') { - return $site->page_cache['enabled']; - } - return $site->is_wordpress; - }); - } + $site = $this->selectSite("Which site do you want to purge the {$cacheToPurge} cache for", function ($site) use ($cacheToPurge) { + if ($cacheToPurge === 'page') { + return $site->page_cache['enabled']; + } + return $site->is_wordpress; + }, "There are no sites with {$cacheToPurge} cache enabled."); - if ($siteId === 0) { - $this->warn("There are no sites with {$cacheToPurge} cache enabled."); + if (is_null($site)) { return self::SUCCESS; } - $site = $this->spinupwp->sites->get(intval($siteId)); - $this->purgeCache(collect([$site]), $cacheToPurge); return self::SUCCESS; diff --git a/app/Commands/Sites/Redirects/AddCommand.php b/app/Commands/Sites/Redirects/AddCommand.php new file mode 100644 index 0000000..af81987 --- /dev/null +++ b/app/Commands/Sites/Redirects/AddCommand.php @@ -0,0 +1,67 @@ +selectSite('Which site would you like to add a path redirect to'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $answers = $this->askQuestions($this->nonInteractive()); + + $eventId = $site->addPathRedirect([ + 'from' => $answers['from'], + 'to' => $answers['to'], + 'type' => $answers['type'], + ]); + + $this->displaySiteEvent($site, $eventId, 'Path redirect queued for creation.'); + + return self::SUCCESS; + } + + /** + * @return Question[] + */ + public function questions(): array + { + return [ + Ask::make('Redirect from') + ->withFlag('from'), + + Ask::make('Redirect to') + ->withFlag('to'), + + Choice::make('Redirect type') + ->withFlag('type') + ->withChoices(OptionsHelper::REDIRECT_TYPES) + ->withDefault('permanent'), + ]; + } +} diff --git a/app/Commands/Sites/Redirects/DeleteCommand.php b/app/Commands/Sites/Redirects/DeleteCommand.php new file mode 100644 index 0000000..2b9ac50 --- /dev/null +++ b/app/Commands/Sites/Redirects/DeleteCommand.php @@ -0,0 +1,79 @@ +selectSite('Which site would you like to delete a path redirect from'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $redirect = $this->selectRedirect($site); + + if (is_null($redirect)) { + return self::SUCCESS; + } + + if (!$this->forceOrConfirm("Are you sure you want to delete the redirect from \"{$redirect['from']}\" to \"{$redirect['to']}\"?", false)) { + return self::SUCCESS; + } + + $eventId = $site->deletePathRedirect($redirect); + + $this->displaySiteEvent($site, $eventId, 'Path redirect queued for deletion.'); + + return self::SUCCESS; + } + + protected function selectRedirect(Site $site): ?array + { + $from = $this->stringOption('from'); + $to = $this->stringOption('to'); + + if (!is_null($from) && !is_null($to)) { + return ['from' => $from, 'to' => $to]; + } + + if (!is_null($from) || !is_null($to)) { + throw new Exception('The --from and --to options must be provided together.'); + } + + $redirects = $this->spinupwp->listPathRedirects($site->id)->values(); + + if ($redirects->isEmpty()) { + $this->warn('No path redirects found.'); + return null; + } + + $selected = $this->askToSelect( + 'Which path redirect would you like to delete', + $redirects->map(fn ($redirect) => "{$redirect->from} → {$redirect->to}")->toArray(), + ); + + $redirect = $redirects->get($selected); + + return ['from' => $redirect->from, 'to' => $redirect->to]; + } +} diff --git a/app/Commands/Sites/Redirects/ListCommand.php b/app/Commands/Sites/Redirects/ListCommand.php new file mode 100644 index 0000000..afb7b59 --- /dev/null +++ b/app/Commands/Sites/Redirects/ListCommand.php @@ -0,0 +1,47 @@ +selectSite('Which site would you like to list path redirects for'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $redirects = $this->spinupwp->listPathRedirects($site->id); + + if ($redirects->isEmpty()) { + $this->warn('No path redirects found.'); + return self::SUCCESS; + } + + if ($this->shouldSpecifyFields()) { + $this->saveFieldsFilter(); + $redirects->transform(fn ($redirect) => $this->specifyFields($redirect)); + $this->format($redirects); + return self::SUCCESS; + } + + if ($this->displayFormat() === 'table') { + $redirects->transform(fn ($redirect) => $this->specifyFields($redirect)); + } + + $this->format($redirects); + + return self::SUCCESS; + } +} diff --git a/app/Commands/Sites/Redirects/Redirects.php b/app/Commands/Sites/Redirects/Redirects.php new file mode 100644 index 0000000..b0ea8b9 --- /dev/null +++ b/app/Commands/Sites/Redirects/Redirects.php @@ -0,0 +1,25 @@ +fieldsMap = [ + (new Field('From', 'from')), + (new Field('To', 'to')), + (new Field('Type', 'type')) + ->withTransformRule(fn ($value) => $value === 'permanent' ? 'Permanent (301)' : 'Temporary (302)'), + (new Field('Created At', 'created_at')), + ]; + } +} diff --git a/app/Commands/Sites/SiteUserCommand.php b/app/Commands/Sites/SiteUserCommand.php new file mode 100644 index 0000000..de40d0c --- /dev/null +++ b/app/Commands/Sites/SiteUserCommand.php @@ -0,0 +1,117 @@ + 'SSH Keys', + ]; + + protected function action(): int + { + $site = $this->selectSite('Which site would you like to update the site user for'); + + if (is_null($site)) { + return self::SUCCESS; + } + + $answers = $this->askQuestions($this->nonInteractive()); + + $payload = ['authentication' => $answers['authentication']]; + + if ($answers['authentication'] === 'publickey') { + $payload['ssh_key_ids'] = $this->sshKeyIds(); + } + + if ($answers['authentication'] === 'password') { + $payload['password'] = $answers['password']; + } + + $eventId = $site->updateSiteUser($payload); + + $this->displaySiteEvent($site, $eventId, 'Site user queued for update.', 'Site user updated.'); + + return self::SUCCESS; + } + + /** + * @return Question[] + */ + public function questions(): array + { + return [ + Choice::make('How should the site user authenticate over SSH?') + ->withFlag('authentication') + ->withChoices([ + 'publickey' => 'SSH keys', + 'password' => 'Password', + ]) + ->withDefault('publickey'), + + Ask::make('Password') + ->withFlag('password') + ->unless(fn (array $answers): bool => $answers['authentication'] !== 'password'), + ]; + } + + protected function sshKeyIds(): array + { + $ids = $this->option('ssh-key-id'); + + if (is_array($ids) && !empty($ids)) { + return array_map(function ($id) { + if (!ctype_digit((string) $id)) { + throw new Exception('The --ssh-key-id option must be an integer.'); + } + + return (int) $id; + }, $ids); + } + + if ($this->nonInteractive()) { + throw new Exception('The --ssh-key-id option is required when running with --force.'); + } + + $sshKeys = $this->spinupwp->listSshKeys(); + + if ($sshKeys->isEmpty()) { + throw new Exception('No SSH keys found. Add an SSH key to your team in SpinupWP first.'); + } + + $question = new class('Which SSH keys should be authorized for the site user? (comma-separated)', $sshKeys->keyBy('id')->map(fn ($sshKey) => $sshKey->name)->toArray()) extends ChoiceQuestion { + public function isAssoc(array $array): bool + { + return true; + } + }; + + $question->setMultiselect(true); + + return array_map('intval', (array) $this->output->askQuestion($question)); + } +} diff --git a/app/Commands/Sites/Sites.php b/app/Commands/Sites/Sites.php index db4bc9f..519716c 100644 --- a/app/Commands/Sites/Sites.php +++ b/app/Commands/Sites/Sites.php @@ -86,6 +86,12 @@ protected function setUp(): void ->withAliases(['basic_auth.username']) ->withIgnoreRule(fn ($value) => !$value['enabled']) ->withTransformRule(fn ($value) => $value['username']), + (new Field('WP Cron', 'cron')) + ->couldBeEnabledOrDisabled(), + (new Field('WP Cron Interval', 'cron')) + ->withAliases(['cron.interval']) + ->withIgnoreRule(fn ($value) => !$value['enabled']) + ->withTransformRule(fn ($value) => $value['interval'] . ' minutes'), (new Field('Created At', 'created_at')), (new Field('Status', 'status')) ->withFirstCharUpperCase(), diff --git a/app/Commands/Sites/SshCommand.php b/app/Commands/Sites/SshCommand.php index 6a96cbd..b82e6a7 100644 --- a/app/Commands/Sites/SshCommand.php +++ b/app/Commands/Sites/SshCommand.php @@ -3,10 +3,12 @@ namespace App\Commands\Sites; use App\Commands\BaseCommand; +use App\Commands\Concerns\HasSiteIdParameter; use App\Commands\Concerns\InteractsWithRemote; class SshCommand extends BaseCommand { + use HasSiteIdParameter; use InteractsWithRemote; protected $signature = 'sites:ssh @@ -19,13 +21,12 @@ class SshCommand extends BaseCommand protected function action(): int { - $siteId = $this->argument('site_id'); + $site = $this->selectSite('Which site would you like to start an SSH session for'); - if (empty($siteId)) { - $siteId = $this->askToSelectSite('Which site would you like to start an SSH session for'); + if (is_null($site)) { + return self::SUCCESS; } - $site = $this->spinupwp->getSite((int) $siteId); $server = $this->spinupwp->getServer($site->server_id); $this->line("Establishing a secure connection to [{$server->name}] as [{$site->site_user}]..."); diff --git a/app/Commands/SshKey/ListCommand.php b/app/Commands/SshKey/ListCommand.php new file mode 100644 index 0000000..adcb361 --- /dev/null +++ b/app/Commands/SshKey/ListCommand.php @@ -0,0 +1,45 @@ +spinupwp->listSshKeys(); + + if ($sshKeys->isEmpty()) { + $this->warn('No SSH keys found.'); + return self::SUCCESS; + } + + if ($this->shouldSpecifyFields()) { + $this->saveFieldsFilter(); + $sshKeys->transform(fn ($sshKey) => $this->specifyFields($sshKey)); + $this->format($sshKeys); + return self::SUCCESS; + } + + if ($this->displayFormat() === 'table') { + $sshKeys->transform(fn ($sshKey) => $this->specifyFields($sshKey, [ + 'id', + 'name', + 'fingerprint', + 'created_at', + ])); + } + + $this->format($sshKeys); + + return self::SUCCESS; + } +} diff --git a/app/Commands/SshKey/SshKeys.php b/app/Commands/SshKey/SshKeys.php new file mode 100644 index 0000000..b8152ec --- /dev/null +++ b/app/Commands/SshKey/SshKeys.php @@ -0,0 +1,23 @@ +fieldsMap = [ + (new Field('ID', 'id')), + (new Field('Name', 'name')), + (new Field('Fingerprint', 'fingerprint')), + (new Field('Public Key', 'publickey')), + (new Field('Created At', 'created_at')), + ]; + } +} diff --git a/app/Commands/StorageProviders/ListCommand.php b/app/Commands/StorageProviders/ListCommand.php new file mode 100644 index 0000000..cf99892 --- /dev/null +++ b/app/Commands/StorageProviders/ListCommand.php @@ -0,0 +1,40 @@ +spinupwp->listStorageProviders(); + + if ($storageProviders->isEmpty()) { + $this->warn('No storage providers found.'); + return self::SUCCESS; + } + + if ($this->shouldSpecifyFields()) { + $this->saveFieldsFilter(); + $storageProviders->transform(fn ($storageProvider) => $this->specifyFields($storageProvider)); + $this->format($storageProviders); + return self::SUCCESS; + } + + if ($this->displayFormat() === 'table') { + $storageProviders->transform(fn ($storageProvider) => $this->specifyFields($storageProvider)); + } + + $this->format($storageProviders); + + return self::SUCCESS; + } +} diff --git a/app/Commands/StorageProviders/StorageProviders.php b/app/Commands/StorageProviders/StorageProviders.php new file mode 100644 index 0000000..02842a2 --- /dev/null +++ b/app/Commands/StorageProviders/StorageProviders.php @@ -0,0 +1,22 @@ +fieldsMap = [ + (new Field('ID', 'id')), + (new Field('Name', 'name')), + (new Field('Service', 'service')), + (new Field('Created At', 'created_at')), + ]; + } +} diff --git a/app/Helpers/OptionsHelper.php b/app/Helpers/OptionsHelper.php index 58eb91b..e2a5f6d 100644 --- a/app/Helpers/OptionsHelper.php +++ b/app/Helpers/OptionsHelper.php @@ -11,4 +11,9 @@ class OptionsHelper ]; public const PHP_VERSIONS = ['8.0', '7.4']; + + public const REDIRECT_TYPES = [ + 'permanent' => 'Permanent (301)', + 'redirect' => 'Temporary (302)', + ]; } diff --git a/app/Repositories/SpinupWpRepository.php b/app/Repositories/SpinupWpRepository.php index 9759a62..c9e84f2 100644 --- a/app/Repositories/SpinupWpRepository.php +++ b/app/Repositories/SpinupWpRepository.php @@ -7,6 +7,8 @@ use SpinupWp\Endpoints\Event; use SpinupWp\Endpoints\Server; use SpinupWp\Endpoints\Site; +use SpinupWp\Endpoints\SshKey; +use SpinupWp\Endpoints\StorageProvider; use SpinupWp\Resources\Server as ServerResource; use SpinupWp\Resources\Site as SiteResource; use SpinupWp\SpinupWp; @@ -19,6 +21,8 @@ * @property Event $events * @property Server $servers * @property Site $sites + * @property SshKey $sshKeys + * @property StorageProvider $storageProviders */ class SpinupWpRepository { @@ -69,6 +73,27 @@ public function getSshKey(): string return $this->spinupwp->sshKeys->get(); } + public function listSshKeys(): Collection + { + return collect($this->spinupwp->sshKeys->list(1, [ + 'limit' => self::PAGINATION_LIMIT, + ])); + } + + public function listStorageProviders(): Collection + { + return collect($this->spinupwp->storageProviders->list(1, [ + 'limit' => self::PAGINATION_LIMIT, + ])); + } + + public function listPathRedirects(int $siteId): Collection + { + return collect($this->spinupwp->sites->listPathRedirects($siteId, 1, [ + 'limit' => self::PAGINATION_LIMIT, + ])); + } + public function listSites(int $serverId = null): Collection { $params = [ diff --git a/composer.json b/composer.json index f65b555..79e52de 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "license": "MIT", "require": { "php": "^8.0", - "spinupwp/spinupwp-php-sdk": "^1.0.0" + "spinupwp/spinupwp-php-sdk": "^1.3.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", diff --git a/tests/Feature/Commands/CustomServerCreateCommandTest.php b/tests/Feature/Commands/CustomServerCreateCommandTest.php index 01b4d64..340c592 100644 --- a/tests/Feature/Commands/CustomServerCreateCommandTest.php +++ b/tests/Feature/Commands/CustomServerCreateCommandTest.php @@ -23,7 +23,7 @@ ]; $this->clientMock->shouldReceive('request')->with('POST', 'servers/custom', [ - 'form_params' => $params, + 'json' => $params, ])->andReturn( new Response(422, [], json_encode([ 'message' => 'The given data was invalid.', @@ -55,7 +55,7 @@ ]; $this->clientMock->shouldReceive('request')->with('POST', 'servers/custom', [ - 'form_params' => $params, + 'json' => $params, ])->andReturn( new Response(200, [], json_encode([ 'event_id' => '100', diff --git a/tests/Feature/Commands/ServersDeleteCommandTest.php b/tests/Feature/Commands/ServersDeleteCommandTest.php index 4bec2f5..f5cdb25 100644 --- a/tests/Feature/Commands/ServersDeleteCommandTest.php +++ b/tests/Feature/Commands/ServersDeleteCommandTest.php @@ -10,7 +10,7 @@ ); $this->clientMock->shouldReceive('request')->with('DELETE', 'servers/1', [ - 'form_params' => ['delete_server_on_provider' => false], + 'json' => ['delete_server_on_provider' => false], ])->andReturn( new Response(200, [], json_encode(['event_id' => '100'])) ); @@ -38,7 +38,7 @@ test('delete a server on provider', function () { $this->clientMock->shouldReceive('request')->with('DELETE', 'servers/1', [ - 'form_params' => ['delete_server_on_provider' => true], + 'json' => ['delete_server_on_provider' => true], ])->andReturn( new Response(200, [], json_encode(['event_id' => '100'])) ); diff --git a/tests/Feature/Commands/SitesBackupsScheduleCommandTest.php b/tests/Feature/Commands/SitesBackupsScheduleCommandTest.php new file mode 100644 index 0000000..7518bcb --- /dev/null +++ b/tests/Feature/Commands/SitesBackupsScheduleCommandTest.php @@ -0,0 +1,57 @@ +site = ['id' => 1, 'domain' => 'hellfishmedia.com']; + + $this->clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => $this->site])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('update backup schedule', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/backup-schedule', [ + 'json' => [ + 'daily_schedule' => [ + 'time_of_day' => [2, 14], + 'backup_database' => true, + 'backup_files' => false, + 'retention_period' => 30, + ], + 'weekly_schedule' => [ + 'day_of_week' => [], + ], + 'monthly_schedule' => [ + 'day_of_month' => [ + ['week_of_month' => 'first', 'day_of_week' => 1], + ['week_of_month' => 'last', 'day_of_week' => 5], + ], + 'time_of_day' => 4, + 'retention_period' => null, + ], + ], + ])->andReturn(new Response(200, [], json_encode(['data' => $this->site]))); + + $this->artisan('sites:backups:schedule 1 --daily-times=2,14 --daily-database=true --daily-files=false --daily-retention=30 --weekly-days=none --monthly-days=first:1,last:5 --monthly-time=4 --monthly-retention=none') + ->expectsOutput('==> Backup schedule updated.') + ->assertExitCode(0); +}); + +test('update backup schedule rejects invalid monthly days', function () { + $this->artisan('sites:backups:schedule 1 --monthly-days=fourth:1') + ->expectsOutput('The --monthly-days option must be a comma-separated list of week:weekday pairs, for example "first:1,last:5".') + ->assertExitCode(1); +}); + +test('update backup schedule requires an option', function () { + $this->artisan('sites:backups:schedule 1') + ->expectsOutput("At least one option must be provided. Run 'sites:backups:schedule --help' to see the available options.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/Commands/SitesBackupsSettingsCommandTest.php b/tests/Feature/Commands/SitesBackupsSettingsCommandTest.php new file mode 100644 index 0000000..a3feb6e --- /dev/null +++ b/tests/Feature/Commands/SitesBackupsSettingsCommandTest.php @@ -0,0 +1,43 @@ +site = ['id' => 1, 'domain' => 'hellfishmedia.com']; + + $this->clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => $this->site])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('update backup settings', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/backup-settings', [ + 'json' => [ + 'storage_provider_id' => 1, + 'storage_provider_bucket' => 'turnipjuice-media', + 'storage_provider_region' => 'nyc3', + 'paths_to_exclude' => "node_modules\n/files/vendor", + 'database' => 2, + ], + ])->andReturn(new Response(200, [], json_encode(['data' => $this->site]))); + + $this->artisan('sites:backups:settings 1 --storage-provider-id=1 --bucket=turnipjuice-media --region=nyc3 --database=2 --exclude-path=node_modules --exclude-path=/files/vendor') + ->expectsOutput('==> Backup settings updated.') + ->assertExitCode(0); +}); + +test('clear the backup database', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/backup-settings', [ + 'json' => ['database' => null], + ])->andReturn(new Response(200, [], json_encode(['data' => $this->site]))); + + $this->artisan('sites:backups:settings 1 --database=none') + ->expectsOutput('==> Backup settings updated.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesBasicAuthDisableCommandTest.php b/tests/Feature/Commands/SitesBasicAuthDisableCommandTest.php new file mode 100644 index 0000000..101400e --- /dev/null +++ b/tests/Feature/Commands/SitesBasicAuthDisableCommandTest.php @@ -0,0 +1,30 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => [ + 'id' => 1, + 'domain' => 'hellfishmedia.com', + 'basic_auth' => ['enabled' => true], + ]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('disable basic auth', function () { + $this->clientMock->shouldReceive('request')->once()->with('DELETE', 'sites/1/basic-auth', [])->andReturn( + new Response(200, [], json_encode(['event_id' => 100])) + ); + + $this->artisan('sites:basic-auth:disable 1') + ->expectsConfirmation('Are you sure you want to disable basic auth for "hellfishmedia.com"?', 'yes') + ->expectsOutput('==> Basic auth queued for disabling.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesBasicAuthEnableCommandTest.php b/tests/Feature/Commands/SitesBasicAuthEnableCommandTest.php new file mode 100644 index 0000000..ba71123 --- /dev/null +++ b/tests/Feature/Commands/SitesBasicAuthEnableCommandTest.php @@ -0,0 +1,40 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => [ + 'id' => 1, + 'domain' => 'hellfishmedia.com', + 'basic_auth' => ['enabled' => true], + ]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('enable basic auth', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/basic-auth', [ + 'json' => ['username' => 'turnipjuice', 'password' => 'secret'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:basic-auth:enable 1 --username=turnipjuice') + ->expectsQuestion('Password', 'secret') + ->expectsOutput('==> Basic auth queued for enabling.') + ->assertExitCode(0); +}); + +test('enable basic auth with force option does not prompt', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/basic-auth', [ + 'json' => ['username' => 'turnipjuice', 'password' => 'secret'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:basic-auth:enable 1 --username=turnipjuice --password=secret --force') + ->expectsOutput('==> Basic auth queued for enabling.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesBasicAuthUpdateCommandTest.php b/tests/Feature/Commands/SitesBasicAuthUpdateCommandTest.php new file mode 100644 index 0000000..fb65fba --- /dev/null +++ b/tests/Feature/Commands/SitesBasicAuthUpdateCommandTest.php @@ -0,0 +1,39 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => [ + 'id' => 1, + 'domain' => 'hellfishmedia.com', + 'basic_auth' => ['enabled' => true], + ]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('update basic auth', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/basic-auth', [ + 'json' => ['username' => 'newuser'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:basic-auth:update 1 --username=newuser') + ->expectsOutput('==> Basic auth credentials queued for update.') + ->assertExitCode(0); +}); + +test('update basic auth without changes', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/basic-auth', [ + 'json' => ['username' => 'turnipjuice'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => null]))); + + $this->artisan('sites:basic-auth:update 1 --username=turnipjuice') + ->expectsOutput('==> Basic auth credentials are already up to date.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesCreateCommandTest.php b/tests/Feature/Commands/SitesCreateCommandTest.php index 5476037..6a1cbc2 100644 --- a/tests/Feature/Commands/SitesCreateCommandTest.php +++ b/tests/Feature/Commands/SitesCreateCommandTest.php @@ -41,7 +41,7 @@ ]); $this->clientMock->shouldReceive('request')->with('POST', 'sites', [ - 'form_params' => $params, + 'json' => $params, ])->andReturn( new Response(422, [], json_encode([ 'message' => 'The given data was invalid.', @@ -69,7 +69,7 @@ ]); $this->clientMock->shouldReceive('request')->with('POST', 'sites', [ - 'form_params' => $params, + 'json' => $params, ])->andReturn(new Response(200, [], json_encode([ 'event_id' => '100', 'data' => [ @@ -102,7 +102,7 @@ ]); $this->clientMock->shouldReceive('request')->with('POST', 'sites', [ - 'form_params' => $params, + 'json' => $params, ])->andReturn( new Response(422, [], json_encode([ 'message' => 'The given data was invalid.', @@ -138,7 +138,7 @@ ]); $this->clientMock->shouldReceive('request')->with('POST', 'sites', [ - 'form_params' => $params, + 'json' => $params, ])->andReturn( new Response(200, [], json_encode([ 'event_id' => '100', diff --git a/tests/Feature/Commands/SitesCronDisableCommandTest.php b/tests/Feature/Commands/SitesCronDisableCommandTest.php new file mode 100644 index 0000000..94ddb84 --- /dev/null +++ b/tests/Feature/Commands/SitesCronDisableCommandTest.php @@ -0,0 +1,41 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => [ + 'id' => 1, + 'domain' => 'hellfishmedia.com', + 'basic_auth' => ['enabled' => true], + ]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('disable wp cron', function () { + $this->clientMock->shouldReceive('request')->once()->with('DELETE', 'sites/1/cron', [])->andReturn( + new Response(200, [], json_encode(['event_id' => 100])) + ); + + $this->artisan('sites:cron:disable 1 --force') + ->expectsOutput('==> WP cron queued for disabling.') + ->assertExitCode(0); +}); + +test('disable wp cron when no sites have it enabled', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 1, 'domain' => 'hellfishmedia.com', 'cron' => ['enabled' => false]], + ])) + ); + + $this->artisan('sites:cron:disable') + ->expectsOutput('There are no sites with WP cron enabled.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesCronEnableCommandTest.php b/tests/Feature/Commands/SitesCronEnableCommandTest.php new file mode 100644 index 0000000..f430c8a --- /dev/null +++ b/tests/Feature/Commands/SitesCronEnableCommandTest.php @@ -0,0 +1,68 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => [ + 'id' => 1, + 'domain' => 'hellfishmedia.com', + 'basic_auth' => ['enabled' => true], + ]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('enable wp cron', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/cron', [ + 'json' => ['interval' => 5], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:cron:enable 1 --interval=5') + ->expectsOutput('==> WP cron queued for enabling.') + ->assertExitCode(0); +}); + +test('enable wp cron for a selected site only lists sites with it disabled', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 1, 'domain' => 'hellfishmedia.com', 'cron' => ['enabled' => false]], + ['id' => 2, 'domain' => 'staging.hellfishmedia.com', 'cron' => ['enabled' => true, 'interval' => 5]], + ])) + ); + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/cron', [ + 'json' => ['interval' => 5], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:cron:enable --interval=5') + ->expectsChoice('Which site would you like to enable the WP cron for', '1', [1, 'hellfishmedia.com']) + ->expectsOutput('==> WP cron queued for enabling.') + ->assertExitCode(0); +}); + +test('enable wp cron when all sites already have it enabled', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 1, 'domain' => 'hellfishmedia.com', 'cron' => ['enabled' => true, 'interval' => 5]], + ])) + ); + + $this->artisan('sites:cron:enable') + ->expectsOutput('There are no sites with WP cron disabled.') + ->assertExitCode(0); +}); + +test('enable wp cron with force option uses the default interval', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/cron', [ + 'json' => ['interval' => 5], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:cron:enable 1 --force') + ->expectsOutput('==> WP cron queued for enabling.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesCronUpdateCommandTest.php b/tests/Feature/Commands/SitesCronUpdateCommandTest.php new file mode 100644 index 0000000..a12bb3a --- /dev/null +++ b/tests/Feature/Commands/SitesCronUpdateCommandTest.php @@ -0,0 +1,40 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => [ + 'id' => 1, + 'domain' => 'hellfishmedia.com', + 'basic_auth' => ['enabled' => true], + ]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('update wp cron interactively', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/cron', [ + 'json' => ['interval' => 15], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:cron:update 1') + ->expectsQuestion('How often should the WP cron run (1, 2, 5, 10, 15, 30, or 60 minutes)?', '15') + ->expectsOutput('==> WP cron interval queued for update.') + ->assertExitCode(0); +}); + +test('update wp cron with interval and force option', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/cron', [ + 'json' => ['interval' => 30], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:cron:update 1 --interval=30 --force') + ->expectsOutput('==> WP cron interval queued for update.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesDeleteCommandTest.php b/tests/Feature/Commands/SitesDeleteCommandTest.php index ee8e4de..77535d5 100644 --- a/tests/Feature/Commands/SitesDeleteCommandTest.php +++ b/tests/Feature/Commands/SitesDeleteCommandTest.php @@ -10,7 +10,7 @@ ); $this->clientMock->shouldReceive('request')->with('DELETE', 'sites/1', [ - 'form_params' => [ + 'json' => [ 'delete_database' => false, 'delete_backups' => false, ], @@ -42,7 +42,7 @@ test('delete a site with delete-database and delete-backups options', function () { $this->clientMock->shouldReceive('request')->with('DELETE', 'sites/1', [ - 'form_params' => [ + 'json' => [ 'delete_database' => true, 'delete_backups' => true, ], @@ -53,3 +53,13 @@ $this->artisan('sites:delete 1 --delete-database --delete-backups --force') ->expectsOutput('==> Site queued for deletion.'); }); + +test('delete a site when there are no sites', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([])) + ); + + $this->artisan('sites:delete') + ->expectsOutput('No sites found.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesGetCommandTest.php b/tests/Feature/Commands/SitesGetCommandTest.php index 1183650..45f879e 100644 --- a/tests/Feature/Commands/SitesGetCommandTest.php +++ b/tests/Feature/Commands/SitesGetCommandTest.php @@ -140,3 +140,21 @@ 'domain' => 'hellfish.media', ], JSON_PRETTY_PRINT)); }); + +test('site table get command with wp cron fields', function () { + $this->clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => [ + 'id' => 1, + 'domain' => 'hellfish.media', + 'cron' => ['enabled' => true, 'interval' => 15], + ]])) + ); + + $this->artisan('sites:get 1 --format=table --fields=domain,cron') + ->expectsConfirmation('Do you want to save the specified fields as the default for this command?', 'no') + ->expectsTable([], [ + ['Domain', 'hellfish.media'], + ['WP Cron', 'Enabled'], + ['WP Cron Interval', '15 minutes'], + ]); +}); diff --git a/tests/Feature/Commands/SitesGitConnectCommandTest.php b/tests/Feature/Commands/SitesGitConnectCommandTest.php new file mode 100644 index 0000000..db49704 --- /dev/null +++ b/tests/Feature/Commands/SitesGitConnectCommandTest.php @@ -0,0 +1,91 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => ['id' => 1, 'domain' => 'hellfishmedia.com', 'git' => ['enabled' => false]]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('connect git with options', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/git', [ + 'json' => [ + 'repo' => 'git@github.com:spinupwp/spinupwp-composer-site.git', + 'branch' => 'main', + 'deploy_script' => 'composer install', + 'push_to_deploy' => true, + ], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:git:connect 1 --repo=git@github.com:spinupwp/spinupwp-composer-site.git --branch=main --deploy-script="composer install" --push-to-deploy') + ->expectsOutput('==> Git repository queued for connection.') + ->assertExitCode(0); +}); + +test('connect git interactively', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/git', [ + 'json' => [ + 'repo' => 'git@github.com:spinupwp/spinupwp-composer-site.git', + 'branch' => 'develop', + 'push_to_deploy' => false, + ], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:git:connect 1') + ->expectsQuestion('Repository', 'git@github.com:spinupwp/spinupwp-composer-site.git') + ->expectsQuestion('Branch', 'develop') + ->expectsOutput('==> Git repository queued for connection.') + ->assertExitCode(0); +}); + +test('connect git with a deploy key', function () { + $dir = sys_get_temp_dir(); + file_put_contents("{$dir}/spinupwp_test_key", "PRIVATE\n"); + file_put_contents("{$dir}/spinupwp_test_key.pub", "PUBLIC\n"); + + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/git', [ + 'json' => [ + 'repo' => 'repo', + 'branch' => 'main', + 'push_to_deploy' => false, + 'deploy_key' => [ + 'privatekey' => 'PRIVATE', + 'publickey' => 'PUBLIC', + ], + ], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan("sites:git:connect 1 --repo=repo --branch=main --private-key={$dir}/spinupwp_test_key --public-key={$dir}/spinupwp_test_key.pub") + ->expectsOutput('==> Git repository queued for connection.') + ->assertExitCode(0); + + unlink("{$dir}/spinupwp_test_key"); + unlink("{$dir}/spinupwp_test_key.pub"); +}); + +test('connect git requires both deploy key files', function () { + $this->artisan('sites:git:connect 1 --repo=repo --branch=main --private-key=/tmp/key') + ->expectsOutput('The --private-key and --public-key options must be provided together.') + ->assertExitCode(1); +}); + +test('connect git with force option uses the default branch', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/git', [ + 'json' => [ + 'repo' => 'git@github.com:spinupwp/spinupwp-composer-site.git', + 'branch' => 'main', + 'push_to_deploy' => false, + ], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:git:connect 1 --repo=git@github.com:spinupwp/spinupwp-composer-site.git --force') + ->expectsOutput('==> Git repository queued for connection.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesGitDeployCommandTest.php b/tests/Feature/Commands/SitesGitDeployCommandTest.php index 1449886..5e1286a 100644 --- a/tests/Feature/Commands/SitesGitDeployCommandTest.php +++ b/tests/Feature/Commands/SitesGitDeployCommandTest.php @@ -5,7 +5,7 @@ beforeEach(function () { setTestConfigFile(); - $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites/1', [])->andReturn( + $this->clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( new Response(200, [], json_encode([ 'data' => [ 'id' => 1, @@ -53,3 +53,15 @@ ->expectsOutput('==> Site queued for deployment.') ->assertExitCode(0); }); + +test('site git deploy command when no sites have git enabled', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 2, 'domain' => 'staging.hellfishmedia.com', 'git' => ['enabled' => false]], + ])) + ); + + $this->artisan('sites:deploy') + ->expectsOutput('There are no sites with Git enabled.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesGitDisconnectCommandTest.php b/tests/Feature/Commands/SitesGitDisconnectCommandTest.php new file mode 100644 index 0000000..4a0db17 --- /dev/null +++ b/tests/Feature/Commands/SitesGitDisconnectCommandTest.php @@ -0,0 +1,45 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => ['id' => 1, 'domain' => 'hellfishmedia.com', 'git' => ['enabled' => false]]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('disconnect git', function () { + $this->clientMock->shouldReceive('request')->once()->with('DELETE', 'sites/1/git', [])->andReturn( + new Response(200, [], json_encode(['event_id' => 100])) + ); + + $this->artisan('sites:git:disconnect 1') + ->expectsConfirmation('Are you sure you want to disconnect Git from "hellfishmedia.com"?', 'yes') + ->expectsOutput('==> Git queued for disconnection.') + ->assertExitCode(0); +}); + +test('disconnect git with force option', function () { + $this->clientMock->shouldReceive('request')->once()->with('DELETE', 'sites/1/git', [])->andReturn( + new Response(200, [], json_encode(['event_id' => 100])) + ); + + $this->artisan('sites:git:disconnect 1 --force') + ->expectsOutput('==> Git queued for disconnection.') + ->assertExitCode(0); +}); + +test('cancel disconnecting git', function () { + $this->clientMock->shouldNotReceive('request')->with('DELETE', 'sites/1/git', []); + + $this->artisan('sites:git:disconnect 1') + ->expectsConfirmation('Are you sure you want to disconnect Git from "hellfishmedia.com"?', 'no') + ->doesntExpectOutput('==> Git queued for disconnection.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesGitUpdateCommandTest.php b/tests/Feature/Commands/SitesGitUpdateCommandTest.php new file mode 100644 index 0000000..1f3d694 --- /dev/null +++ b/tests/Feature/Commands/SitesGitUpdateCommandTest.php @@ -0,0 +1,50 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => ['id' => 1, 'domain' => 'hellfishmedia.com', 'git' => ['enabled' => false]]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('update git settings', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/git', [ + 'json' => [ + 'branch' => 'production', + 'push_to_deploy' => false, + ], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:git:update 1 --branch=production --push-to-deploy=false') + ->expectsOutput('==> Git settings queued for update.') + ->assertExitCode(0); +}); + +test('update git settings without server changes', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/git', [ + 'json' => ['push_to_deploy' => true], + ])->andReturn(new Response(200, [], json_encode(['event_id' => null]))); + + $this->artisan('sites:git:update 1 --push-to-deploy=yes') + ->expectsOutput('==> Git settings updated.') + ->assertExitCode(0); +}); + +test('update git settings requires an option', function () { + $this->artisan('sites:git:update 1') + ->expectsOutput("At least one option must be provided. Run 'sites:git:update --help' to see the available options.") + ->assertExitCode(1); +}); + +test('update git settings rejects invalid booleans', function () { + $this->artisan('sites:git:update 1 --push-to-deploy=maybe') + ->expectsOutput('The --push-to-deploy option must be true or false.') + ->assertExitCode(1); +}); diff --git a/tests/Feature/Commands/SitesNginxCommandTest.php b/tests/Feature/Commands/SitesNginxCommandTest.php new file mode 100644 index 0000000..4d3b142 --- /dev/null +++ b/tests/Feature/Commands/SitesNginxCommandTest.php @@ -0,0 +1,43 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => [ + 'id' => 1, + 'domain' => 'hellfishmedia.com', + 'basic_auth' => ['enabled' => true], + ]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('update nginx settings', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/nginx', [ + 'json' => [ + 'uploads_directory_protected' => true, + 'xmlrpc_protected' => false, + 'public_folder' => '/public', + ], + ])->andReturn(new Response(200, [], json_encode(['event_ids' => [100, 101]]))); + + $this->artisan('sites:nginx 1 --uploads-directory-protected=true --xmlrpc-protected=false --public-folder=/public') + ->expectsOutput('==> Nginx settings queued for update.') + ->assertExitCode(0); +}); + +test('update nginx settings without changes', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/nginx', [ + 'json' => ['subdirectory_rewrite_in_place' => true], + ])->andReturn(new Response(200, [], json_encode(['event_ids' => []]))); + + $this->artisan('sites:nginx 1 --subdirectory-rewrite=1') + ->expectsOutput('==> Nginx settings are already up to date.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesPageCacheDisableCommandTest.php b/tests/Feature/Commands/SitesPageCacheDisableCommandTest.php new file mode 100644 index 0000000..20c5611 --- /dev/null +++ b/tests/Feature/Commands/SitesPageCacheDisableCommandTest.php @@ -0,0 +1,26 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => ['id' => 1, 'domain' => 'hellfishmedia.com', 'page_cache' => ['enabled' => true]]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('disable page cache', function () { + $this->clientMock->shouldReceive('request')->once()->with('DELETE', 'sites/1/page-cache', [])->andReturn( + new Response(200, [], json_encode(['event_id' => 100])) + ); + + $this->artisan('sites:page-cache:disable 1') + ->expectsConfirmation('Are you sure you want to disable the page cache for "hellfishmedia.com"?', 'yes') + ->expectsOutput('==> Page cache queued for disabling.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesPageCacheEnableCommandTest.php b/tests/Feature/Commands/SitesPageCacheEnableCommandTest.php new file mode 100644 index 0000000..3f2faa0 --- /dev/null +++ b/tests/Feature/Commands/SitesPageCacheEnableCommandTest.php @@ -0,0 +1,54 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => ['id' => 1, 'domain' => 'hellfishmedia.com', 'page_cache' => ['enabled' => true]]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('enable page cache', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/page-cache', [])->andReturn( + new Response(200, [], json_encode(['event_id' => 100])) + ); + + $this->artisan('sites:page-cache:enable 1') + ->expectsOutput('==> Page cache queued for enabling.') + ->assertExitCode(0); +}); + +test('enable page cache for a selected site', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 1, 'domain' => 'hellfishmedia.com', 'page_cache' => ['enabled' => false]], + ['id' => 2, 'domain' => 'staging.hellfishmedia.com', 'page_cache' => ['enabled' => true]], + ])) + ); + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/page-cache', [])->andReturn( + new Response(200, [], json_encode(['event_id' => 100])) + ); + + $this->artisan('sites:page-cache:enable') + ->expectsQuestion('Which site would you like to enable the page cache for', '1') + ->expectsOutput('==> Page cache queued for enabling.') + ->assertExitCode(0); +}); + +test('enable page cache when all sites already have it enabled', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 1, 'domain' => 'hellfishmedia.com', 'page_cache' => ['enabled' => true]], + ])) + ); + + $this->artisan('sites:page-cache:enable') + ->expectsOutput('There are no sites with page cache disabled.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesPageCacheUpdateCommandTest.php b/tests/Feature/Commands/SitesPageCacheUpdateCommandTest.php new file mode 100644 index 0000000..9a524ff --- /dev/null +++ b/tests/Feature/Commands/SitesPageCacheUpdateCommandTest.php @@ -0,0 +1,36 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => ['id' => 1, 'domain' => 'hellfishmedia.com', 'page_cache' => ['enabled' => true]]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('update page cache settings', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/page-cache', [ + 'json' => [ + 'duration' => 1, + 'duration_unit' => 'h', + 'path_exclusions' => "/wp-admin/\n/cart/", + 'cookie_exclusions' => '', + ], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:page-cache:update 1 --duration=1 --duration-unit=h --path-exclusion=/wp-admin/ --path-exclusion=/cart/ --cookie-exclusion=') + ->expectsOutput('==> Page cache settings queued for update.') + ->assertExitCode(0); +}); + +test('update page cache settings rejects a non-integer duration', function () { + $this->artisan('sites:page-cache:update 1 --duration=1.5') + ->expectsOutput('The --duration option must be an integer.') + ->assertExitCode(1); +}); diff --git a/tests/Feature/Commands/SitesPurgeCommandTest.php b/tests/Feature/Commands/SitesPurgeCommandTest.php index cd8be24..2108977 100644 --- a/tests/Feature/Commands/SitesPurgeCommandTest.php +++ b/tests/Feature/Commands/SitesPurgeCommandTest.php @@ -68,3 +68,27 @@ $this->artisan('sites:purge --all --cache=object') ->expectsOutput('==> Sites queued for object cache purge.'); }); + +test('purge page cache when no sites have it enabled', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 1, 'domain' => 'hellfishmedia.com', 'page_cache' => ['enabled' => false], 'is_wordpress' => true], + ])) + ); + + $this->artisan('sites:purge --cache=page') + ->expectsOutput('There are no sites with page cache enabled.') + ->assertExitCode(0); +}); + +test('purge object cache for a selected site', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 1, 'domain' => 'hellfishmedia.com', 'page_cache' => ['enabled' => false], 'is_wordpress' => true], + ])) + ); + $this->artisan('sites:purge --cache=object') + ->expectsQuestion('Which site do you want to purge the object cache for', '1') + ->expectsOutput('==> Site queued for object cache purge.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesRedirectsAddCommandTest.php b/tests/Feature/Commands/SitesRedirectsAddCommandTest.php new file mode 100644 index 0000000..0941a30 --- /dev/null +++ b/tests/Feature/Commands/SitesRedirectsAddCommandTest.php @@ -0,0 +1,53 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => ['id' => 1, 'domain' => 'hellfishmedia.com']])) + ); + + $this->redirects = [ + ['from' => '/old-path', 'to' => '/new-path', 'type' => 'permanent', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ['from' => '/blog', 'to' => 'https://blog.hellfishmedia.com', 'type' => 'redirect', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ]; +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('add a path redirect', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/path-redirects', [ + 'json' => ['from' => '/old-path', 'to' => '/new-path', 'type' => 'permanent'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:redirects:add 1 --from=/old-path --to=/new-path --type=permanent') + ->expectsOutput('==> Path redirect queued for creation.') + ->assertExitCode(0); +}); + +test('add a path redirect interactively', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/path-redirects', [ + 'json' => ['from' => '/old-path', 'to' => '/new-path', 'type' => 'redirect'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:redirects:add 1') + ->expectsQuestion('Redirect from', '/old-path') + ->expectsQuestion('Redirect to', '/new-path') + ->expectsQuestion('Redirect type', 'redirect') + ->expectsOutput('==> Path redirect queued for creation.') + ->assertExitCode(0); +}); + +test('add a path redirect with force option uses the default type', function () { + $this->clientMock->shouldReceive('request')->once()->with('POST', 'sites/1/path-redirects', [ + 'json' => ['from' => '/old-path', 'to' => '/new-path', 'type' => 'permanent'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:redirects:add 1 --from=/old-path --to=/new-path --force') + ->expectsOutput('==> Path redirect queued for creation.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesRedirectsDeleteCommandTest.php b/tests/Feature/Commands/SitesRedirectsDeleteCommandTest.php new file mode 100644 index 0000000..fd0dfb1 --- /dev/null +++ b/tests/Feature/Commands/SitesRedirectsDeleteCommandTest.php @@ -0,0 +1,45 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => ['id' => 1, 'domain' => 'hellfishmedia.com']])) + ); + + $this->redirects = [ + ['from' => '/old-path', 'to' => '/new-path', 'type' => 'permanent', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ['from' => '/blog', 'to' => 'https://blog.hellfishmedia.com', 'type' => 'redirect', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ]; +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('delete a path redirect', function () { + $this->clientMock->shouldReceive('request')->once()->with('DELETE', 'sites/1/path-redirects', [ + 'json' => ['from' => '/old-path', 'to' => '/new-path'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:redirects:delete 1 --from=/old-path --to=/new-path --force') + ->expectsOutput('==> Path redirect queued for deletion.') + ->assertExitCode(0); +}); + +test('delete a path redirect selected interactively', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites/1/path-redirects?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson($this->redirects)) + ); + $this->clientMock->shouldReceive('request')->once()->with('DELETE', 'sites/1/path-redirects', [ + 'json' => ['from' => '/blog', 'to' => 'https://blog.hellfishmedia.com'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:redirects:delete 1') + ->expectsQuestion('Which path redirect would you like to delete', '1') + ->expectsConfirmation('Are you sure you want to delete the redirect from "/blog" to "https://blog.hellfishmedia.com"?', 'yes') + ->expectsOutput('==> Path redirect queued for deletion.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesRedirectsListCommandTest.php b/tests/Feature/Commands/SitesRedirectsListCommandTest.php new file mode 100644 index 0000000..0cfe5a8 --- /dev/null +++ b/tests/Feature/Commands/SitesRedirectsListCommandTest.php @@ -0,0 +1,53 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => ['id' => 1, 'domain' => 'hellfishmedia.com']])) + ); + + $this->redirects = [ + ['from' => '/old-path', 'to' => '/new-path', 'type' => 'permanent', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ['from' => '/blog', 'to' => 'https://blog.hellfishmedia.com', 'type' => 'redirect', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ]; +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('list path redirects', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites/1/path-redirects?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson($this->redirects)) + ); + + $this->artisan('sites:redirects:list 1') + ->expectsOutput(json_encode($this->redirects, JSON_PRETTY_PRINT)) + ->assertExitCode(0); +}); + +test('list path redirects as a table', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites/1/path-redirects?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson($this->redirects)) + ); + + $this->artisan('sites:redirects:list 1 --format=table') + ->expectsTable(['From', 'To', 'Type', 'Created At'], [ + ['/old-path', '/new-path', 'Permanent (301)', '2024-07-01T12:00:00.000000Z'], + ['/blog', 'https://blog.hellfishmedia.com', 'Temporary (302)', '2024-07-01T12:00:00.000000Z'], + ]) + ->assertExitCode(0); +}); + +test('list path redirects when there are none', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'sites/1/path-redirects?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([])) + ); + + $this->artisan('sites:redirects:list 1') + ->expectsOutput('No path redirects found.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SitesSiteUserCommandTest.php b/tests/Feature/Commands/SitesSiteUserCommandTest.php new file mode 100644 index 0000000..880e2bf --- /dev/null +++ b/tests/Feature/Commands/SitesSiteUserCommandTest.php @@ -0,0 +1,84 @@ +clientMock->shouldReceive('request')->with('GET', 'sites/1', [])->andReturn( + new Response(200, [], json_encode(['data' => [ + 'id' => 1, + 'domain' => 'hellfishmedia.com', + 'basic_auth' => ['enabled' => true], + ]])) + ); +}); + +afterEach(function () { + deleteTestConfigFile(); +}); + +test('update site user with ssh keys', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/site-user', [ + 'json' => ['authentication' => 'publickey', 'ssh_key_ids' => [1, 3]], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:site-user 1 --authentication=publickey --ssh-key-id=1 --ssh-key-id=3') + ->expectsOutput('==> Site user queued for update.') + ->assertExitCode(0); +}); + +test('update site user selecting ssh keys interactively', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'ssh-keys?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 4, 'name' => 'Laptop'], + ['id' => 7, 'name' => 'Desktop'], + ])) + ); + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/site-user', [ + 'json' => ['authentication' => 'publickey', 'ssh_key_ids' => [4, 7]], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:site-user 1') + ->expectsQuestion('How should the site user authenticate over SSH?', 'publickey') + ->expectsQuestion('Which SSH keys should be authorized for the site user? (comma-separated)', ['4', '7']) + ->expectsOutput('==> Site user queued for update.') + ->assertExitCode(0); +}); + +test('update site user with a password', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/site-user', [ + 'json' => ['authentication' => 'password', 'password' => 'supersecret'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:site-user 1 --authentication=password --password=supersecret') + ->expectsOutput('==> Site user queued for update.') + ->assertExitCode(0); +}); + +test('update site user asks for a password', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/site-user', [ + 'json' => ['authentication' => 'password', 'password' => 'supersecret'], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:site-user 1 --authentication=password') + ->expectsQuestion('Password', 'supersecret') + ->expectsOutput('==> Site user queued for update.') + ->assertExitCode(0); +}); + +test('update site user with force option requires ssh key ids', function () { + $this->artisan('sites:site-user 1 --force') + ->expectsOutput('The --ssh-key-id option is required when running with --force.') + ->assertExitCode(1); +}); + +test('update site user with force option and ssh key ids', function () { + $this->clientMock->shouldReceive('request')->once()->with('PUT', 'sites/1/site-user', [ + 'json' => ['authentication' => 'publickey', 'ssh_key_ids' => [1]], + ])->andReturn(new Response(200, [], json_encode(['event_id' => 100]))); + + $this->artisan('sites:site-user 1 --ssh-key-id=1 --force') + ->expectsOutput('==> Site user queued for update.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/SshKeyListCommandTest.php b/tests/Feature/Commands/SshKeyListCommandTest.php new file mode 100644 index 0000000..00146bf --- /dev/null +++ b/tests/Feature/Commands/SshKeyListCommandTest.php @@ -0,0 +1,54 @@ + 1, 'name' => 'Laptop', 'fingerprint' => 'SHA256:abc', 'publickey' => 'ssh-ed25519 AAAA', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ]; + + $this->clientMock->shouldReceive('request')->once()->with('GET', 'ssh-keys?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson($sshKeys)) + ); + + $this->artisan('ssh-key:list') + ->expectsOutput(json_encode($sshKeys, JSON_PRETTY_PRINT)) + ->assertExitCode(0); +}); + +test('list ssh keys as a table', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'ssh-keys?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 1, 'name' => 'Laptop', 'fingerprint' => 'SHA256:abc', 'publickey' => 'ssh-ed25519 AAAA', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ])) + ); + + $this->artisan('ssh-key:list --format=table') + ->expectsTable(['ID', 'Name', 'Fingerprint', 'Created At'], [ + [1, 'Laptop', 'SHA256:abc', '2024-07-01T12:00:00.000000Z'], + ]) + ->assertExitCode(0); +}); + +test('list ssh keys with specified fields', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'ssh-keys?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 1, 'name' => 'Laptop', 'fingerprint' => 'SHA256:abc', 'publickey' => 'ssh-ed25519 AAAA', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ])) + ); + + $this->artisan('ssh-key:list --format=table --fields=id,publickey') + ->expectsConfirmation('Do you want to save the specified fields as the default for this command?', 'no') + ->expectsTable(['ID', 'Public Key'], [ + [1, 'ssh-ed25519 AAAA'], + ]) + ->assertExitCode(0); +}); diff --git a/tests/Feature/Commands/StorageProvidersListCommandTest.php b/tests/Feature/Commands/StorageProvidersListCommandTest.php new file mode 100644 index 0000000..47e78ca --- /dev/null +++ b/tests/Feature/Commands/StorageProvidersListCommandTest.php @@ -0,0 +1,49 @@ + 1, 'service' => 'aws-s3', 'name' => 'Production backups', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ]; + + $this->clientMock->shouldReceive('request')->once()->with('GET', 'storage-providers?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson($storageProviders)) + ); + + $this->artisan('storage-providers:list') + ->expectsOutput(json_encode($storageProviders, JSON_PRETTY_PRINT)) + ->assertExitCode(0); +}); + +test('list storage providers when there are none', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'storage-providers?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([])) + ); + + $this->artisan('storage-providers:list') + ->expectsOutput('No storage providers found.') + ->assertExitCode(0); +}); + +test('list storage providers as a table', function () { + $this->clientMock->shouldReceive('request')->once()->with('GET', 'storage-providers?page=1&limit=100', [])->andReturn( + new Response(200, [], listResponseJson([ + ['id' => 1, 'service' => 'aws-s3', 'name' => 'Production backups', 'created_at' => '2024-07-01T12:00:00.000000Z'], + ])) + ); + + $this->artisan('storage-providers:list --format=table') + ->expectsTable(['ID', 'Name', 'Service', 'Created At'], [ + [1, 'Production backups', 'aws-s3', '2024-07-01T12:00:00.000000Z'], + ]) + ->assertExitCode(0); +});