diff --git a/src/Console/DeployCommand.php b/src/Console/DeployCommand.php new file mode 100644 index 000000000..0dbdb7b82 --- /dev/null +++ b/src/Console/DeployCommand.php @@ -0,0 +1,92 @@ +[default: `NIGHTWATCH_DEPLOY`]} + {--name= : The human-readable name of the deploy} + {--url= : A URL with information related to the deploy} + {--timestamp= : The timestamp of the deploy [default: `now()`]}'; + + /** + * @var string + */ + protected $description = 'Notify Nightwatch of a deployment.'; + + /** + * @var bool + */ + protected $hidden = true; + + public function __construct( + #[SensitiveParameter] private ?string $token, + ) { + parent::__construct(); + } + + public function handle(): int + { + $timestamp = is_string($this->option('timestamp')) ? CarbonImmutable::parse($this->option('timestamp')) : CarbonImmutable::now(); + + $ref = $this->argument('ref') ?? config('nightwatch.deployment'); + + if (! $ref) { + $this->components->error('Please configure the [NIGHTWATCH_DEPLOY] environment variable.'); + + return 0; + } + + if (! $this->token) { + $this->components->error('Please configure the [NIGHTWATCH_TOKEN] environment variable.'); + + return 0; + } + + $baseUrl = ! empty($_SERVER['NIGHTWATCH_BASE_URL']) ? $_SERVER['NIGHTWATCH_BASE_URL'] : 'https://nightwatch.laravel.com'; + + try { + Http::connectTimeout(5) + ->timeout(10) + ->acceptJson() + ->withToken($this->token) + ->post("{$baseUrl}/api/deployments", [ + 'timestamp' => $timestamp->utc()->toDateTimeString('microsecond'), + 'ref' => $ref, + 'name' => $this->option('name'), + 'url' => $this->option('url'), + ]) + ->throw(); + + $this->components->info('Deployment sent to Nightwatch successfully.'); + } catch (RequestException $e) { + $message = Str::limit($e->response->json('message') ?? "[{$e->getCode()}] {$e->response->body()}", 1000, '[...]'); // @phpstan-ignore argument.type + + $this->components->error("Deployment could not be sent to Nightwatch: {$message}"); + } catch (Throwable $e) { + $this->components->error("Deployment could not be sent to Nightwatch: {$e->getMessage()}"); + } + + return 0; + } +} diff --git a/src/NightwatchServiceProvider.php b/src/NightwatchServiceProvider.php index 87d22564e..553370a0a 100644 --- a/src/NightwatchServiceProvider.php +++ b/src/NightwatchServiceProvider.php @@ -40,6 +40,7 @@ use Illuminate\Support\Facades\Context; use Illuminate\Support\ServiceProvider; use Laravel\Nightwatch\Console\AgentCommand; +use Laravel\Nightwatch\Console\DeployCommand; use Laravel\Nightwatch\Facades\Nightwatch; use Laravel\Nightwatch\Factories\Logger; use Laravel\Nightwatch\Hooks\ArtisanStartingListener; @@ -195,6 +196,7 @@ private function registerBindings(): void $this->registerLogger(); $this->registerMiddleware(); $this->registerAgentCommand(); + $this->registerDeployCommand(); $this->buildAndRegisterCore(); } @@ -229,6 +231,13 @@ private function registerAgentCommand(): void )); } + private function registerDeployCommand(): void + { + $this->app->singleton(DeployCommand::class, fn () => new DeployCommand( + token: $this->nightwatchConfig['token'] ?? null, + )); + } + private function buildAndRegisterCore(): void { $clock = new Clock; @@ -301,6 +310,7 @@ private function registerCommands(): void $this->commands([ Console\AgentCommand::class, Console\StatusCommand::class, + Console\DeployCommand::class, ]); } diff --git a/tests/Feature/Console/DeployCommandTest.php b/tests/Feature/Console/DeployCommandTest.php new file mode 100644 index 000000000..16d5a6756 --- /dev/null +++ b/tests/Feature/Console/DeployCommandTest.php @@ -0,0 +1,112 @@ +freezeTime(); + Http::fake([ + '*/api/deployments' => function (Request $request) { + $this->assertEquals(['Bearer '.env('NIGHTWATCH_TOKEN')], $request->header('Authorization')); + $this->assertEquals([ + 'timestamp' => now()->toDateTimeString('microsecond'), + 'ref' => 'v1.2.3', + 'name' => null, + 'url' => '', + ], $request->data()); + + return Http::response('OK'); + }, + ]); + + $this->artisan('nightwatch:deploy') + ->expectsOutputToContain('Deployment sent to Nightwatch successfully.') + ->assertExitCode(0); + } + + #[WithEnv('NIGHTWATCH_TOKEN', 'test-token')] + public function test_it_accepts_arguments_and_options(): void + { + Http::fake([ + '*/api/deployments' => function (Request $request) { + $this->assertEquals(['Bearer '.env('NIGHTWATCH_TOKEN')], $request->header('Authorization')); + $this->assertEquals([ + 'timestamp' => '2025-12-22 15:30:45.123456', + 'ref' => 'v1.2.3', + 'name' => 'Happy Friday!', + 'url' => 'https://example.com/deployments/123', + ], $request->data()); + + return Http::response('OK'); + }, + ]); + + $this->artisan('nightwatch:deploy v1.2.3 --timestamp="2025-12-22 15:30:45.123456" --name="Happy Friday!" --url="https://example.com/deployments/123"') + ->expectsOutputToContain('Deployment sent to Nightwatch successfully.') + ->assertExitCode(0); + } + + #[WithEnv('NIGHTWATCH_DEPLOY', 'v1.2.3')] + public function test_it_fails_when_the_deploy_command_is_run_without_a_token(): void + { + $this->app->singleton(DeployCommand::class, fn () => new DeployCommand(token: null)); + + $this->artisan('nightwatch:deploy') + ->expectsOutputToContain('Please configure the [NIGHTWATCH_TOKEN] environment variable.') + ->assertExitCode(0); + } + + #[WithEnv('NIGHTWATCH_TOKEN', 'test-token')] + #[WithEnv('NIGHTWATCH_DEPLOY', 'v1.2.3')] + public function test_it_handles_error_responses(): void + { + Http::fake([ + '*/api/deployments' => Http::response(json_encode(['message' => 'Invalid environment token.']), 403), + ]); + + $this->artisan('nightwatch:deploy') + ->expectsOutputToContain('Deployment could not be sent to Nightwatch: Invalid environment token.') + ->assertExitCode(0); + } + + #[WithEnv('NIGHTWATCH_TOKEN', 'test-token')] + #[WithEnv('NIGHTWATCH_DEPLOY', 'v1.2.3')] + public function test_it_handles_http_errors(): void + { + Http::fake([ + '*/api/deployments' => Http::response('Whoops!', 500), + ]); + + $this->artisan('nightwatch:deploy') + ->expectsOutputToContain('Deployment could not be sent to Nightwatch: [500] Whoops!') + ->assertExitCode(0); + } + + #[WithEnv('NIGHTWATCH_TOKEN', 'test-token')] + #[WithEnv('NIGHTWATCH_DEPLOY', 'v1.2.3')] + public function test_it_handles_connection_errors(): void + { + Http::fake([ + '*/api/deployments' => fn () => throw new ConnectionException('Connection timeout.'), + ]); + + $this->artisan('nightwatch:deploy') + ->expectsOutputToContain('Deployment could not be sent to Nightwatch: Connection timeout.') + ->assertExitCode(0); + } +}