From 303181d322d66467bbd455b1c2b8b4dc6c87f711 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 26 Oct 2026 17:26:56 +0000 Subject: [PATCH] Add scheduled-command commands Adds scheduled-command:list, get, create, update, and delete, backed by the public API's /instances/{instance}/scheduled-commands and /scheduled-commands/{scheduledCommand} endpoints. Amp-Thread-ID: https://ampcode.com/threads/T-01a1792f-26b6-77ff-89c9-2276898bb989 Co-authored-by: Ryan Chandler --- README.md | 14 +- app/Client/Connector.php | 6 + app/Client/README.md | 7 + .../CreateScheduledCommandRequestData.php | 24 +++ .../UpdateScheduledCommandRequestData.php | 24 +++ .../CreateScheduledCommandRequest.php | 39 ++++ .../DeleteScheduledCommandRequest.php | 22 +++ .../GetScheduledCommandRequest.php | 32 ++++ .../ListScheduledCommandsRequest.php | 36 ++++ .../UpdateScheduledCommandRequest.php | 39 ++++ .../Resources/ScheduledCommandsResource.php | 52 ++++++ app/Commands/ScheduledCommandCreate.php | 90 +++++++++ app/Commands/ScheduledCommandDelete.php | 46 +++++ app/Commands/ScheduledCommandGet.php | 39 ++++ app/Commands/ScheduledCommandList.php | 58 ++++++ app/Commands/ScheduledCommandUpdate.php | 127 +++++++++++++ app/Dto/ScheduledCommand.php | 38 ++++ app/Resolvers/Resolvers.php | 5 + app/Resolvers/ScheduledCommandResolver.php | 79 ++++++++ skills/deploying-to-cloud/SKILL.md | 2 +- .../Feature/ScheduledCommandCommandsTest.php | 172 ++++++++++++++++++ 21 files changed, 949 insertions(+), 2 deletions(-) create mode 100644 app/Client/Requests/CreateScheduledCommandRequestData.php create mode 100644 app/Client/Requests/UpdateScheduledCommandRequestData.php create mode 100644 app/Client/Resources/ScheduledCommands/CreateScheduledCommandRequest.php create mode 100644 app/Client/Resources/ScheduledCommands/DeleteScheduledCommandRequest.php create mode 100644 app/Client/Resources/ScheduledCommands/GetScheduledCommandRequest.php create mode 100644 app/Client/Resources/ScheduledCommands/ListScheduledCommandsRequest.php create mode 100644 app/Client/Resources/ScheduledCommands/UpdateScheduledCommandRequest.php create mode 100644 app/Client/Resources/ScheduledCommandsResource.php create mode 100644 app/Commands/ScheduledCommandCreate.php create mode 100644 app/Commands/ScheduledCommandDelete.php create mode 100644 app/Commands/ScheduledCommandGet.php create mode 100644 app/Commands/ScheduledCommandList.php create mode 100644 app/Commands/ScheduledCommandUpdate.php create mode 100644 app/Dto/ScheduledCommand.php create mode 100644 app/Resolvers/ScheduledCommandResolver.php create mode 100644 tests/Feature/ScheduledCommandCommandsTest.php diff --git a/README.md b/README.md index b752978..6192781 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,19 @@ Without an environment argument, `resend:attach` offers only environments withou | `cloud background-process:update` | Update a background process | | `cloud background-process:delete` | Delete a background process | -### Commands (scheduled/one-off) +### Scheduled commands + +| Command | Description | +| -------------------------------- | --------------------------------------- | +| `cloud scheduled-command:list` | List scheduled commands for an instance | +| `cloud scheduled-command:get` | Get scheduled command details | +| `cloud scheduled-command:create` | Create a scheduled command | +| `cloud scheduled-command:update` | Update a scheduled command | +| `cloud scheduled-command:delete` | Delete a scheduled command | + +Schedules are five-field cron expressions evaluated in UTC, such as `0 0 * * *`. The `@hourly`, `@daily`, `@weekly`, `@monthly`, and `@yearly` descriptors also work. + +### Commands (one-off) | Command | Description | | -------------------- | -------------------------------- | diff --git a/app/Client/Connector.php b/app/Client/Connector.php index 352d75a..e2980e1 100644 --- a/app/Client/Connector.php +++ b/app/Client/Connector.php @@ -22,6 +22,7 @@ use App\Client\Resources\MetaResource; use App\Client\Resources\ObjectStorageBucketsResource; use App\Client\Resources\ResendResource; +use App\Client\Resources\ScheduledCommandsResource; use App\Client\Resources\SecretsResource; use App\Client\Resources\UsageResource; use App\Client\Resources\WebSocketApplicationsResource; @@ -185,6 +186,11 @@ public function backgroundProcesses(): BackgroundProcessesResource return new BackgroundProcessesResource($this); } + public function scheduledCommands(): ScheduledCommandsResource + { + return new ScheduledCommandsResource($this); + } + public function databaseClusters(): DatabaseClustersResource { return new DatabaseClustersResource($this); diff --git a/app/Client/README.md b/app/Client/README.md index 849aa76..42cb298 100644 --- a/app/Client/README.md +++ b/app/Client/README.md @@ -79,6 +79,13 @@ $data = $response->json(); - `UpdateBackgroundProcessRequest` - Update a background process - `DeleteBackgroundProcessRequest` - Delete a background process +#### Scheduled Commands +- `ListScheduledCommandsRequest` - List scheduled commands for an instance +- `GetScheduledCommandRequest` - Get a specific scheduled command +- `CreateScheduledCommandRequest` - Create a new scheduled command +- `UpdateScheduledCommandRequest` - Update a scheduled command +- `DeleteScheduledCommandRequest` - Delete a scheduled command + #### Database Clusters - `ListDatabaseClustersRequest` - List all database clusters - `GetDatabaseClusterRequest` - Get a specific database cluster diff --git a/app/Client/Requests/CreateScheduledCommandRequestData.php b/app/Client/Requests/CreateScheduledCommandRequestData.php new file mode 100644 index 0000000..2aaf50e --- /dev/null +++ b/app/Client/Requests/CreateScheduledCommandRequestData.php @@ -0,0 +1,24 @@ +filter([ + 'name' => $this->name, + 'command' => $this->command, + 'schedule' => $this->schedule, + ]); + } +} diff --git a/app/Client/Requests/UpdateScheduledCommandRequestData.php b/app/Client/Requests/UpdateScheduledCommandRequestData.php new file mode 100644 index 0000000..a36c21e --- /dev/null +++ b/app/Client/Requests/UpdateScheduledCommandRequestData.php @@ -0,0 +1,24 @@ +filter([ + 'name' => $this->name, + 'command' => $this->command, + 'schedule' => $this->schedule, + ]); + } +} diff --git a/app/Client/Resources/ScheduledCommands/CreateScheduledCommandRequest.php b/app/Client/Resources/ScheduledCommands/CreateScheduledCommandRequest.php new file mode 100644 index 0000000..57bc882 --- /dev/null +++ b/app/Client/Resources/ScheduledCommands/CreateScheduledCommandRequest.php @@ -0,0 +1,39 @@ +data->instanceId}/scheduled-commands"; + } + + protected function defaultBody(): array + { + return $this->data->toRequestData(); + } + + public function createDtoFromResponse(Response $response): mixed + { + return ScheduledCommand::createFromResponse($response->json()); + } +} diff --git a/app/Client/Resources/ScheduledCommands/DeleteScheduledCommandRequest.php b/app/Client/Resources/ScheduledCommands/DeleteScheduledCommandRequest.php new file mode 100644 index 0000000..fec96cd --- /dev/null +++ b/app/Client/Resources/ScheduledCommands/DeleteScheduledCommandRequest.php @@ -0,0 +1,22 @@ +scheduledCommandId}"; + } +} diff --git a/app/Client/Resources/ScheduledCommands/GetScheduledCommandRequest.php b/app/Client/Resources/ScheduledCommands/GetScheduledCommandRequest.php new file mode 100644 index 0000000..3beb3d7 --- /dev/null +++ b/app/Client/Resources/ScheduledCommands/GetScheduledCommandRequest.php @@ -0,0 +1,32 @@ +scheduledCommandId}"; + } + + public function createDtoFromResponse(Response $response): mixed + { + return ScheduledCommand::createFromResponse($response->json()); + } +} diff --git a/app/Client/Resources/ScheduledCommands/ListScheduledCommandsRequest.php b/app/Client/Resources/ScheduledCommands/ListScheduledCommandsRequest.php new file mode 100644 index 0000000..08f7afe --- /dev/null +++ b/app/Client/Resources/ScheduledCommands/ListScheduledCommandsRequest.php @@ -0,0 +1,36 @@ +instanceId}/scheduled-commands"; + } + + public function createDtoFromResponse(Response $response): mixed + { + return array_map(fn ($scheduledCommand) => ScheduledCommand::createFromResponse([ + 'data' => $scheduledCommand, + 'included' => $response->json('included', []), + ]), $response->json('data')); + } +} diff --git a/app/Client/Resources/ScheduledCommands/UpdateScheduledCommandRequest.php b/app/Client/Resources/ScheduledCommands/UpdateScheduledCommandRequest.php new file mode 100644 index 0000000..a50208e --- /dev/null +++ b/app/Client/Resources/ScheduledCommands/UpdateScheduledCommandRequest.php @@ -0,0 +1,39 @@ +data->scheduledCommandId}"; + } + + protected function defaultBody(): array + { + return $this->data->toRequestData(); + } + + public function createDtoFromResponse(Response $response): mixed + { + return ScheduledCommand::createFromResponse($response->json()); + } +} diff --git a/app/Client/Resources/ScheduledCommandsResource.php b/app/Client/Resources/ScheduledCommandsResource.php new file mode 100644 index 0000000..b7791cb --- /dev/null +++ b/app/Client/Resources/ScheduledCommandsResource.php @@ -0,0 +1,52 @@ +paginate($request); + } + + public function get(string $scheduledCommandId): ScheduledCommand + { + $request = new GetScheduledCommandRequest($scheduledCommandId); + $response = $this->send($request); + + return $request->createDtoFromResponse($response); + } + + public function create(CreateScheduledCommandRequestData $data): ScheduledCommand + { + $request = new CreateScheduledCommandRequest($data); + $response = $this->send($request); + + return $request->createDtoFromResponse($response); + } + + public function update(UpdateScheduledCommandRequestData $data): ScheduledCommand + { + $request = new UpdateScheduledCommandRequest($data); + $response = $this->send($request); + + return $request->createDtoFromResponse($response); + } + + public function delete(string $scheduledCommandId): void + { + $this->send(new DeleteScheduledCommandRequest($scheduledCommandId)); + } +} diff --git a/app/Commands/ScheduledCommandCreate.php b/app/Commands/ScheduledCommandCreate.php new file mode 100644 index 0000000..6594eaf --- /dev/null +++ b/app/Commands/ScheduledCommandCreate.php @@ -0,0 +1,90 @@ +ensureClient(); + + intro('Creating Scheduled Command'); + + $instance = $this->resolvers()->instance()->from($this->argument('instance')); + + $scheduledCommand = $this->loopUntilValid(fn () => $this->createScheduledCommand($instance)); + + $this->outputJsonIfWanted($scheduledCommand); + + success("Scheduled command created: {$scheduledCommand->id}"); + } + + protected function createScheduledCommand(EnvironmentInstance $instance): ScheduledCommand + { + $this->form()->prompt( + 'command', + fn ($resolver) => $resolver->fromInput( + fn (?string $value) => text( + label: 'Command', + default: $value ?? 'php artisan ', + required: true, + ), + ), + ); + + $this->form()->prompt( + 'schedule', + fn ($resolver) => $resolver->fromInput( + fn (?string $value) => text( + label: 'Schedule', + placeholder: '0 0 * * *', + default: $value ?? '', + required: true, + hint: 'A five-field cron expression, evaluated in UTC. @hourly, @daily, @weekly, @monthly, and @yearly also work.', + ), + ), + ); + + $this->form()->prompt( + 'name', + fn ($resolver) => $resolver + ->fromInput(fn (?string $value) => text( + label: 'Name', + default: $value ?? '', + hint: 'Optional', + )) + // The name is optional, so an empty default keeps headless runs from failing. + ->nonInteractively(fn () => ''), + ); + + return spin( + fn () => $this->client->scheduledCommands()->create( + new CreateScheduledCommandRequestData( + instanceId: $instance->id, + command: $this->form()->get('command'), + schedule: $this->form()->get('schedule'), + name: $this->form()->get('name') ?: null, + ), + ), + 'Creating scheduled command...', + ); + } +} diff --git a/app/Commands/ScheduledCommandDelete.php b/app/Commands/ScheduledCommandDelete.php new file mode 100644 index 0000000..58b4d02 --- /dev/null +++ b/app/Commands/ScheduledCommandDelete.php @@ -0,0 +1,46 @@ +ensureClient(); + + intro('Deleting Scheduled Command'); + + $scheduledCommand = $this->resolvers()->scheduledCommand()->from($this->argument('scheduled-command')); + + $this->confirmDestructive('Delete scheduled command?'); + + try { + spin( + fn () => $this->client->scheduledCommands()->delete($scheduledCommand->id), + 'Deleting scheduled command...', + ); + + $this->outputJsonIfWanted('Scheduled command deleted.'); + + success('Scheduled command deleted.'); + + return self::SUCCESS; + } catch (RequestException $e) { + error('Failed to delete scheduled command: '.$e->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/app/Commands/ScheduledCommandGet.php b/app/Commands/ScheduledCommandGet.php new file mode 100644 index 0000000..6393092 --- /dev/null +++ b/app/Commands/ScheduledCommandGet.php @@ -0,0 +1,39 @@ +ensureClient(); + + intro('Scheduled Command Details'); + + $scheduledCommand = $this->resolvers()->scheduledCommand()->from($this->argument('scheduled-command')); + + $this->outputJsonIfWanted($scheduledCommand); + + info("Scheduled Command: {$scheduledCommand->id}"); + + dataList([ + 'ID' => $scheduledCommand->id, + 'Name' => $scheduledCommand->name ?? '—', + 'Command' => $scheduledCommand->command, + 'Schedule (UTC)' => $scheduledCommand->schedule, + 'Created At' => $scheduledCommand->createdAt?->format('Y-m-d H:i:s') ?? '—', + 'Instance ID' => $scheduledCommand->instanceId ?? '—', + ]); + } +} diff --git a/app/Commands/ScheduledCommandList.php b/app/Commands/ScheduledCommandList.php new file mode 100644 index 0000000..3177b80 --- /dev/null +++ b/app/Commands/ScheduledCommandList.php @@ -0,0 +1,58 @@ +ensureClient(); + + intro('Scheduled Commands'); + + $instance = $this->resolvers()->instance()->from($this->argument('instance')); + + $items = $this->fetchList( + fn () => $this->client->scheduledCommands()->list($instance->id), + 'Fetching scheduled commands...', + ); + + $this->outputJsonIfWanted($items); + + if ($items->isEmpty()) { + warning('No scheduled commands found.'); + + return self::SUCCESS; + } + + dataTable( + headers: ['ID', 'Name', 'Command', 'Schedule'], + rows: $items->map(fn (ScheduledCommand $scheduledCommand) => [ + $scheduledCommand->id, + $scheduledCommand->name ?? '—', + str($scheduledCommand->command)->limit(40)->toString(), + $scheduledCommand->schedule, + ])->toArray(), + actions: [ + Key::ENTER => [ + fn ($row) => $this->call('scheduled-command:get', ['scheduled-command' => $row[0]]), + 'View', + ], + ], + ); + } +} diff --git a/app/Commands/ScheduledCommandUpdate.php b/app/Commands/ScheduledCommandUpdate.php new file mode 100644 index 0000000..06e0036 --- /dev/null +++ b/app/Commands/ScheduledCommandUpdate.php @@ -0,0 +1,127 @@ +ensureClient(); + + intro('Updating Scheduled Command'); + + $scheduledCommand = $this->resolvers()->scheduledCommand()->from($this->argument('scheduled-command')); + + $this->defineFields($scheduledCommand); + + foreach ($this->form()->filled() as $value) { + $this->reportChange( + $value->label(), + $value->previousValue(), + $value->value(), + ); + } + + $updated = $this->runUpdate( + fn () => $this->updateScheduledCommand($scheduledCommand), + fn () => $this->collectDataAndUpdate($scheduledCommand), + ); + + $this->outputJsonIfWanted($updated); + + success("Scheduled command updated: {$updated->id}"); + } + + protected function updateScheduledCommand(ScheduledCommand $scheduledCommand): ScheduledCommand + { + return spin( + fn () => $this->client->scheduledCommands()->update( + new UpdateScheduledCommandRequestData( + scheduledCommandId: $scheduledCommand->id, + name: $this->form()->get('name'), + command: $this->form()->get('command'), + schedule: $this->form()->get('schedule'), + ), + ), + 'Updating scheduled command...', + ); + } + + protected function defineFields(ScheduledCommand $scheduledCommand): void + { + $this->form()->define( + 'name', + fn ($resolver) => $resolver->fromInput( + fn ($value) => text( + label: 'Name', + default: $value ?? $scheduledCommand->name ?? '', + ), + ), + )->setPreviousValue($scheduledCommand->name); + + $this->form()->define( + 'command', + fn ($resolver) => $resolver->fromInput( + fn ($value) => text( + label: 'Command', + required: true, + default: $value ?? $scheduledCommand->command, + ), + ), + )->setPreviousValue($scheduledCommand->command); + + $this->form()->define( + 'schedule', + fn ($resolver) => $resolver->fromInput( + fn ($value) => text( + label: 'Schedule', + required: true, + default: $value ?? $scheduledCommand->schedule, + hint: 'A five-field cron expression, evaluated in UTC. @hourly, @daily, @weekly, @monthly, and @yearly also work.', + ), + ), + )->setPreviousValue($scheduledCommand->schedule); + } + + protected function collectDataAndUpdate(ScheduledCommand $scheduledCommand): ScheduledCommand + { + $selection = multiselect( + label: 'What do you want to update?', + options: collect($this->form()->defined())->mapWithKeys(fn ($field) => [ + $field->key => $field->label(), + ])->toArray(), + ); + + if (empty($selection)) { + $this->outputErrorOrThrow('No fields to update. Select at least one option.'); + + throw new CommandExitException(self::FAILURE); + } + + foreach ($selection as $optionName) { + $this->form()->prompt($optionName); + } + + return $this->updateScheduledCommand($scheduledCommand); + } +} diff --git a/app/Dto/ScheduledCommand.php b/app/Dto/ScheduledCommand.php new file mode 100644 index 0000000..65c6751 --- /dev/null +++ b/app/Dto/ScheduledCommand.php @@ -0,0 +1,38 @@ + $data['id'], + 'command' => $attributes['command'], + 'schedule' => $attributes['schedule'], + 'name' => $attributes['name'] ?? null, + 'createdAt' => $attributes['created_at'] ?? null, + 'instanceId' => $data['relationships']['instance']['data']['id'] ?? null, + ]); + } +} diff --git a/app/Resolvers/Resolvers.php b/app/Resolvers/Resolvers.php index de0c8a6..a6b9b30 100644 --- a/app/Resolvers/Resolvers.php +++ b/app/Resolvers/Resolvers.php @@ -35,6 +35,11 @@ public function backgroundProcess(): BackgroundProcessResolver return $this->make(BackgroundProcessResolver::class); } + public function scheduledCommand(): ScheduledCommandResolver + { + return $this->make(ScheduledCommandResolver::class); + } + public function command(): CommandResolver { return $this->make(CommandResolver::class); diff --git a/app/Resolvers/ScheduledCommandResolver.php b/app/Resolvers/ScheduledCommandResolver.php new file mode 100644 index 0000000..d0f9198 --- /dev/null +++ b/app/Resolvers/ScheduledCommandResolver.php @@ -0,0 +1,79 @@ +fromIdentifier($idOrName) : null) + ?? $this->fromInput(); + + if (! $scheduledCommand) { + $this->failAndExit('Unable to resolve scheduled command: '.($idOrName ?? 'Provide a valid scheduled command ID.').'. Run `cloud scheduled-command:list --json` to see available scheduled commands.'); + } + + $this->displayResolved('Scheduled Command', $scheduledCommand->name ?? $scheduledCommand->command, $scheduledCommand->id); + + return $scheduledCommand; + } + + public function fromIdentifier(string $identifier): ?ScheduledCommand + { + return $this->resolveFromIdentifier( + $identifier, + fn () => spin( + fn () => $this->client->scheduledCommands()->get($identifier), + 'Fetching scheduled command...', + ), + ); + } + + public function fromInput(): ?ScheduledCommand + { + $instance = $this->resolvers() + ->instance() + ->withApplication($this->application()) + ->resolve(); + + $scheduledCommands = $this->client->scheduledCommands()->list($instance->id)->collect(); + + if ($scheduledCommands->isEmpty()) { + $this->failAndExit('No scheduled commands found for instance '.$instance->name); + } + + if ($scheduledCommands->hasSole()) { + return $scheduledCommands->first(); + } + + $options = $scheduledCommands->mapWithKeys(fn (ScheduledCommand $scheduledCommand) => [ + $scheduledCommand->id => str($scheduledCommand->name ?? $scheduledCommand->command)->limit(50)->toString()." ({$scheduledCommand->schedule})", + ])->toArray(); + + $this->ensureInteractive('Multiple scheduled commands found. Provide a scheduled command ID.', ['options' => $options]); + + $selected = select( + label: 'Scheduled Command', + options: $options, + info: fn ($id) => $id, + ); + + // The select above already shows the chosen command. + $this->displayResolved = false; + + return $scheduledCommands->firstWhere('id', $selected); + } + + protected function idPrefix(): string + { + return 'schcmd-'; + } +} diff --git a/skills/deploying-to-cloud/SKILL.md b/skills/deploying-to-cloud/SKILL.md index ac0c5d4..a328add 100644 --- a/skills/deploying-to-cloud/SKILL.md +++ b/skills/deploying-to-cloud/SKILL.md @@ -69,7 +69,7 @@ If a project-local installation is not available, install the CLI globally with Commands follow a CRUD pattern: `resource:list`, `resource:get`, `resource:create`, `resource:update`, `resource:delete`. -Available resources: `application`, `environment`, `instance`, `database-cluster`, `database`, `cache`, `bucket`, `domain`, `websocket-cluster`, `background-process`, `secret`, `command`, `deployment`. +Available resources: `application`, `environment`, `instance`, `database-cluster`, `database`, `cache`, `bucket`, `domain`, `websocket-cluster`, `background-process`, `scheduled-command`, `secret`, `command`, `deployment`. Some resources have additional commands (e.g., `domain:verify`, `database:open`, `instance:sizes`, `cache:types`, `database:types`, `:metrics`). Discover these via `cloud -h`. diff --git a/tests/Feature/ScheduledCommandCommandsTest.php b/tests/Feature/ScheduledCommandCommandsTest.php new file mode 100644 index 0000000..9875f77 --- /dev/null +++ b/tests/Feature/ScheduledCommandCommandsTest.php @@ -0,0 +1,172 @@ +mockConfig = Mockery::mock(ConfigRepository::class); + $this->mockConfig->shouldReceive('apiTokens')->andReturn(collect(['test-api-token'])); + $this->app->instance(ConfigRepository::class, $this->mockConfig); +}); + +afterEach(function () { + MockClient::destroyGlobal(); +}); + +function scheduledCommandResponse(array $attributes = []): array +{ + return [ + 'id' => 'schcmd-1', + 'type' => 'scheduled_commands', + 'attributes' => array_merge([ + 'name' => 'Prune models', + 'command' => 'php artisan model:prune', + 'schedule' => '0 0 * * *', + 'created_at' => '2026-01-01T00:00:00.000000Z', + ], $attributes), + 'relationships' => ['instance' => ['data' => ['id' => 'inst-1', 'type' => 'instances']]], + ]; +} + +function scheduledCommandInstanceResponse(): array +{ + return ['data' => [ + 'id' => 'inst-1', + 'type' => 'instances', + 'attributes' => [ + 'name' => 'web', + 'type' => 'app', + 'size' => 'flex.c-1vcpu-256mb', + 'scaling_type' => 'none', + 'min_replicas' => 1, + 'max_replicas' => 1, + 'uses_scheduler' => true, + ], + ]]; +} + +function setupScheduledCommandMocks(): stdClass +{ + $sent = new stdClass; + $sent->body = null; + $sent->deleted = false; + + MockClient::global([ + GetOrganizationRequest::class => MockResponse::make(organizationResponse(), 200), + GetInstanceRequest::class => MockResponse::make(scheduledCommandInstanceResponse(), 200), + ListScheduledCommandsRequest::class => MockResponse::make([ + 'data' => [ + scheduledCommandResponse(), + scheduledCommandResponse(['name' => null, 'command' => 'php artisan backup:run', 'schedule' => '@hourly']), + ], + 'links' => ['next' => null], + ], 200), + GetScheduledCommandRequest::class => MockResponse::make(['data' => scheduledCommandResponse()], 200), + CreateScheduledCommandRequest::class => function (PendingRequest $request) use ($sent) { + $sent->body = $request->body()->all(); + $sent->url = $request->getUrl(); + + return MockResponse::make(['data' => scheduledCommandResponse($sent->body)], 201); + }, + UpdateScheduledCommandRequest::class => function (PendingRequest $request) use ($sent) { + $sent->body = $request->body()->all(); + $sent->url = $request->getUrl(); + + return MockResponse::make(['data' => scheduledCommandResponse($sent->body)], 200); + }, + DeleteScheduledCommandRequest::class => function (PendingRequest $request) use ($sent) { + $sent->deleted = true; + $sent->url = $request->getUrl(); + + return MockResponse::make([], 204); + }, + ]); + + return $sent; +} + +it('lists scheduled commands for an instance as json', function () { + setupScheduledCommandMocks(); + + Artisan::call('scheduled-command:list', ['instance' => 'inst-1', '--json' => true, '--no-interaction' => true]); + + expect(collect(json_decode(Artisan::output(), true))->pluck('schedule')->all())->toBe(['0 0 * * *', '@hourly']); +}); + +it('gets a scheduled command with its instance', function () { + setupScheduledCommandMocks(); + + Artisan::call('scheduled-command:get', ['scheduled-command' => 'schcmd-1', '--json' => true, '--no-interaction' => true]); + + expect(json_decode(Artisan::output(), true))->toMatchArray([ + 'id' => 'schcmd-1', + 'name' => 'Prune models', + 'command' => 'php artisan model:prune', + 'schedule' => '0 0 * * *', + 'instanceId' => 'inst-1', + ]); +}); + +it('creates a scheduled command without a name', function () { + $sent = setupScheduledCommandMocks(); + + $this->artisan('scheduled-command:create', [ + 'instance' => 'inst-1', + '--command' => 'php artisan backup:run', + '--schedule' => '*/5 * * * *', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sent->url)->toEndWith('/instances/inst-1/scheduled-commands'); + expect($sent->body)->toBe(['command' => 'php artisan backup:run', 'schedule' => '*/5 * * * *']); +}); + +it('requires a schedule when creating non-interactively', function () { + $sent = setupScheduledCommandMocks(); + + $this->artisan('scheduled-command:create', [ + 'instance' => 'inst-1', + '--command' => 'php artisan backup:run', + '--no-interaction' => true, + ])->assertFailed(); + + expect($sent->body)->toBeNull(); +}); + +it('sends only the changed fields on update', function () { + $sent = setupScheduledCommandMocks(); + + $this->artisan('scheduled-command:update', [ + 'scheduled-command' => 'schcmd-1', + '--schedule' => '@daily', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sent->url)->toEndWith('/scheduled-commands/schcmd-1'); + expect($sent->body)->toBe(['schedule' => '@daily']); +}); + +it('requires --force to delete non-interactively', function () { + $sent = setupScheduledCommandMocks(); + + $this->artisan('scheduled-command:delete', ['scheduled-command' => 'schcmd-1', '--no-interaction' => true]) + ->assertFailed(); + + expect($sent->deleted)->toBeFalse(); + + $this->artisan('scheduled-command:delete', ['scheduled-command' => 'schcmd-1', '--force' => true, '--no-interaction' => true]) + ->assertSuccessful(); + + expect($sent->deleted)->toBeTrue(); + expect($sent->url)->toEndWith('/scheduled-commands/schcmd-1'); +});