From c1077fbbe6023a7ea9f9943af7fd991e20165af9 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 23 Sep 2026 22:03:54 +0200 Subject: [PATCH 1/2] Add the logs and deployments commands `bref logs`, `bref deployments`, `bref deployments:show` and `bref deployments:logs` show the data of an environment from Bref Cloud. They are for humans and for AI agents: an agent gets no colors or spinners, and JSON for the deployments. `--json` gives JSON output for all of them. `--app` and `--team` identify the application without a config file, for these commands and for `secret:create`. The CLI now requires PHP 8.2 (for laravel/agent-detector) and has a PHPUnit test suite. --- .github/workflows/ci.yml | 11 +- .gitignore | 1 + composer.json | 11 +- phpunit.xml.dist | 13 ++ src/Application.php | 48 ++++--- src/BrefCloudClient.php | 73 +++++++++- src/Cli/LogRenderer.php | 133 +++++++++++++++++ src/Cli/OutputMode.php | 25 ++++ src/Cli/TimeRange.php | 32 +++++ src/Commands/ApplicationCommand.php | 29 ++++ src/Commands/Deployments.php | 82 +++++++++++ src/Commands/DeploymentsLogs.php | 68 +++++++++ src/Commands/DeploymentsShow.php | 73 ++++++++++ src/Commands/EnvironmentDataCommand.php | 126 +++++++++++++++++ src/Commands/Logs.php | 172 ++++++++++++++++++++++ src/Commands/SecretCreate.php | 29 +--- tests/Cli/LogRendererTest.php | 131 +++++++++++++++++ tests/Cli/TimeRangeTest.php | 45 ++++++ tests/Commands/CommandTestCase.php | 76 ++++++++++ tests/Commands/DeploymentsLogsTest.php | 56 ++++++++ tests/Commands/DeploymentsShowTest.php | 64 +++++++++ tests/Commands/DeploymentsTest.php | 54 +++++++ tests/Commands/LogsTest.php | 181 ++++++++++++++++++++++++ 23 files changed, 1483 insertions(+), 50 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 src/Cli/LogRenderer.php create mode 100644 src/Cli/OutputMode.php create mode 100644 src/Cli/TimeRange.php create mode 100644 src/Commands/Deployments.php create mode 100644 src/Commands/DeploymentsLogs.php create mode 100644 src/Commands/DeploymentsShow.php create mode 100644 src/Commands/EnvironmentDataCommand.php create mode 100644 src/Commands/Logs.php create mode 100644 tests/Cli/LogRendererTest.php create mode 100644 tests/Cli/TimeRangeTest.php create mode 100644 tests/Commands/CommandTestCase.php create mode 100644 tests/Commands/DeploymentsLogsTest.php create mode 100644 tests/Commands/DeploymentsShowTest.php create mode 100644 tests/Commands/DeploymentsTest.php create mode 100644 tests/Commands/LogsTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f123ee..c7d5abe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,15 +6,15 @@ on: pull_request: jobs: - phpstan: - name: PHPStan - PHP ${{ matrix.php }} ${{ matrix.dependency-version }} + tests: + name: Tests - PHP ${{ matrix.php }} ${{ matrix.dependency-version }} runs-on: ubuntu-latest strategy: matrix: - php: [ '8.1', '8.2', '8.3', '8.4' ] + php: [ '8.2', '8.3', '8.4' ] dependency-version: [ '' ] include: - - php: '8.1' + - php: '8.2' dependency-version: '--prefer-lowest' steps: - name: Checkout @@ -39,3 +39,6 @@ jobs: - name: PHPStan run: vendor/bin/phpstan analyse --no-progress + + - name: PHPUnit + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 3ebbcaa..a6ee0f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ vendor .serverless composer.lock +.phpunit.result.cache diff --git a/composer.json b/composer.json index 4f6ba93..fece3e7 100644 --- a/composer.json +++ b/composer.json @@ -11,14 +11,20 @@ "Bref\\Cli\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Bref\\Cli\\Test\\": "tests/" + } + }, "require": { - "php": "^8.1", + "php": "^8.2", "ext-zip": "*", "amphp/amp": "^3.0", "amphp/file": "^3.2 || ^4.0", "amphp/http-client": "^5.3", "amphp/process": "^2.0", "aws/aws-sdk-php": "^3.319", + "laravel/agent-detector": "^2.0.1", "psy/psysh": "^0.12.0", "revolt/event-loop": "^1.0", "symfony/console": "^5.2 || ^6.2 || ^7 || ^8", @@ -28,7 +34,8 @@ "symfony/yaml": "^5.2 || ^6.2 || ^7 || ^8" }, "require-dev": { - "phpstan/phpstan": "^2" + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^11.5" }, "config": { "sort-packages": true diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..9264db3 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + + + tests + + + diff --git a/src/Application.php b/src/Application.php index 42a0863..333c4fd 100644 --- a/src/Application.php +++ b/src/Application.php @@ -33,6 +33,10 @@ public function __construct() $this->safeAddCommand(new Commands\Cloud); $this->safeAddCommand(new Commands\Tinker); $this->safeAddCommand(new Commands\SecretCreate); + $this->safeAddCommand(new Commands\Logs); + $this->safeAddCommand(new Commands\Deployments); + $this->safeAddCommand(new Commands\DeploymentsShow); + $this->safeAddCommand(new Commands\DeploymentsLogs); } public function safeAddCommand(Command $command): ?Command @@ -65,23 +69,7 @@ public function renderThrowable(Throwable $e, OutputInterface $output): void { IO::spinClear(); - // Prettify Bref Cloud errors - if ($e instanceof ClientException) { - try { - $body = $e->getResponse()->toArray(false); - $message = $body['message'] ?? 'Unknown Bref Cloud error'; - $statusCode = $e->getResponse()->getStatusCode(); - - $message = match ($statusCode) { - 401 => 'Unauthenticated. Please log in with `bref login`.', - 403 => 'Forbidden. You do not have the required permissions. Do you need to login to a different team?', - default => $message, - }; - - $e = new Exception("Bref Cloud API error: [$statusCode] $message", $statusCode); - } catch (Throwable) { - } - } + $e = self::prettifyException($e); // Prettify AWS credentials errors if ($e instanceof CredentialsException && str_contains($e->getMessage(), 'not found in credentials file')) { @@ -95,6 +83,32 @@ public function renderThrowable(Throwable $e, OutputInterface $output): void IO::error($e); } + /** + * Turn Bref Cloud API errors into their message. + */ + public static function prettifyException(Throwable $e): Throwable + { + if (! $e instanceof ClientException) { + return $e; + } + try { + $body = $e->getResponse()->toArray(false); + $message = $body['message'] ?? 'Unknown Bref Cloud error'; + $statusCode = $e->getResponse()->getStatusCode(); + + $message = match ($statusCode) { + 401 => 'Unauthenticated. Please log in with `bref login`.', + 403 => 'Forbidden. You do not have the required permissions. Do you need to login to a different team?', + 429 => 'Too many requests, try again in a minute.', + default => $message, + }; + + return new Exception("Bref Cloud API error: [$statusCode] $message", $statusCode); + } catch (Throwable) { + return $e; + } + } + private function turnWarningsIntoExceptions(): void { set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) { diff --git a/src/BrefCloudClient.php b/src/BrefCloudClient.php index 3400050..6ac0c6e 100644 --- a/src/BrefCloudClient.php +++ b/src/BrefCloudClient.php @@ -2,11 +2,27 @@ namespace Bref\Cli; +use Bref\Cli\Cli\LogRenderer; use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @phpstan-import-type LogRecord from LogRenderer + * @phpstan-type Deployment array{ + * id: int, + * status: string, + * message: string, + * error_message: string|null, + * git_ref: string|null, + * git_message: string|null, + * author: string|null, + * created_at: string|null, + * finished_at: string|null, + * url: string, + * } + */ class BrefCloudClient { private const PRODUCTION_URL = 'https://bref.cloud'; @@ -37,9 +53,16 @@ class BrefCloudClient public readonly string $url; private HttpClientInterface $client; - public function __construct(?string $token = null) + /** + * @param HttpClientInterface|null $client Replaces the HTTP client (and the token), for tests. + */ + public function __construct(?string $token = null, ?HttpClientInterface $client = null) { $this->url = self::getUrl(); + if ($client) { + $this->client = $client; + return; + } if ($token === null) { $token = Token::getToken($this->url); } @@ -122,7 +145,6 @@ public function startDeployment(int $deploymentId): void /** * @return array{ - * deploymentId: int, * status: string, * message: string, * error_message: string|null, @@ -130,19 +152,64 @@ public function startDeployment(int $deploymentId): void * app_url: string|null, * logs: list, * outputs?: array, + * id?: int, + * git_ref?: string|null, + * git_message?: string|null, + * author?: string|null, + * created_at?: string|null, + * finished_at?: string|null, + * environment?: array{id: int, name: string}, + * app?: array{id: int, name: string}, * } + * The keys that are optional were added to Bref Cloud later. * * @throws HttpExceptionInterface * @throws ExceptionInterface */ public function getDeployment(int $deploymentId): array { - /** @var array{deploymentId: int, status: string, message: string, error_message: string|null, url: string, app_url: string|null, logs: list, outputs?: array} $result */ + /** @var array{status: string, message: string, error_message: string|null, url: string, app_url: string|null, logs: list, outputs?: array, id?: int, git_ref?: string|null, git_message?: string|null, author?: string|null, created_at?: string|null, finished_at?: string|null, environment?: array{id: int, name: string}, app?: array{id: int, name: string}} $result */ $result = $this->client->request('GET', "/api/v1/deployments/$deploymentId")->toArray(); return $result; } + /** + * @return list The most recent first. + * + * @throws HttpExceptionInterface + * @throws ExceptionInterface + */ + public function listDeployments(int $environmentId, int $limit): array + { + /** @var list $result */ + $result = $this->client->request('GET', "/api/v1/environments/$environmentId/deployments", [ + 'query' => ['limit' => $limit], + ])->toArray(); + + return $result; + } + + /** + * @param array{since?: int, until?: int, search?: string, regex?: bool, functions?: list, limit?: int, all?: bool, full?: bool} $query + * @return array{from: string, to: string, limit: int, has_more: bool, records: list} + * + * @throws HttpExceptionInterface + * @throws ExceptionInterface + */ + public function getLogs(int $environmentId, array $query): array + { + /** @var array{from: string, to: string, limit: int, has_more: bool, records: list} $result */ + $result = $this->client->request('GET', "/api/v1/environments/$environmentId/logs", [ + // Booleans are sent as 0/1 + 'query' => array_map(fn($value) => is_bool($value) ? (int) $value : $value, $query), + // Searching logs takes several seconds, and up to Bref Cloud's own timeout + 'timeout' => 40, + ])->toArray(); + + return $result; + } + public function pushDeploymentLogs(int $deploymentId, string $newLogs): void { $this->client->request('POST', "/api/v1/deployments/$deploymentId/logs", [ diff --git a/src/Cli/LogRenderer.php b/src/Cli/LogRenderer.php new file mode 100644 index 0000000..e42ef72 --- /dev/null +++ b/src/Cli/LogRenderer.php @@ -0,0 +1,133 @@ +, previous?: array} + * @phpstan-type LogRecord array{timestamp: string, function: string, instance: string, level: string|null, message: string, context?: array, extra?: array, exception?: LogException} + */ +class LogRenderer +{ + private const MAX_CONTEXT_LENGTH = 500; + private const INDENT = ' '; + + public function __construct( + private readonly bool $colors, + private readonly bool $full, + ) {} + + /** + * @param list $records + * @return list + */ + public function render(array $records): array + { + $functionWidth = max([0, ...array_map(fn(array $record) => strlen($record['function']), $records)]); + $levelWidth = max([0, ...array_map(fn(array $record) => strlen($record['level'] ?? ''), $records)]); + + return array_map(fn(array $record) => $this->renderRecord($record, $functionWidth, $levelWidth), $records); + } + + /** + * @param LogRecord $record + */ + private function renderRecord(array $record, int $functionWidth, int $levelWidth): string + { + $columns = [ + $this->gray(str_replace('T', ' ', rtrim($record['timestamp'], 'Z'))), + str_pad($record['function'], $functionWidth), + $this->gray($record['instance']), + ]; + // Only logs written by Bref's Monolog formatter have a level: there is no column for apps that don't use it + if ($levelWidth > 0) { + $columns[] = $this->level(str_pad($record['level'] ?? '', $levelWidth)); + } + $line = implode(' ', $columns) . ' ' . $this->indent($record['message']); + + foreach (['context', 'extra'] as $key) { + if (! empty($record[$key])) { + $line .= ' ' . $this->gray($this->truncate((string) json_encode($record[$key], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))); + } + } + + if (isset($record['exception'])) { + $line .= "\n" . $this->renderException($record['exception']); + } + + return $line; + } + + /** + * @param LogException $exception + */ + private function renderException(array $exception, bool $isPrevious = false): string + { + $prefix = self::INDENT . '↳ ' . ($isPrevious ? 'Caused by ' : ''); + // Bref's runtime errors have no file + $location = $exception['file'] !== '' ? " at {$exception['file']}" : ''; + + if (! $this->full) { + $frames = $exception['frames'] > 0 ? " ({$exception['frames']} frames)" : ''; + + return $prefix . $this->red($exception['class']) . $this->gray($location . $frames); + } + + $lines = [$prefix . $this->red($exception['class']) . ': ' . $this->indent($exception['message'], 2)]; + if ($location !== '') { + $lines[] = self::INDENT . ' ' . $this->gray(ltrim($location)); + } + foreach ($exception['trace'] ?? [] as $i => $frame) { + $lines[] = self::INDENT . ' ' . $this->gray("#$i $frame"); + } + if (isset($exception['previous'])) { + /** @var LogException $previous */ + $previous = $exception['previous']; + $lines[] = $this->renderException($previous, true); + } + + return implode("\n", $lines); + } + + /** + * Indent the continuation lines of a multi-line message, to keep them apart from the next record. + */ + private function indent(string $message, int $extra = 0): string + { + return str_replace("\n", "\n" . self::INDENT . str_repeat(' ', $extra), rtrim($message, "\n")); + } + + private function truncate(string $text): string + { + $length = mb_strlen($text); + if ($this->full || $length <= self::MAX_CONTEXT_LENGTH) { + return $text; + } + + return mb_substr($text, 0, self::MAX_CONTEXT_LENGTH) . '…(+' . ($length - self::MAX_CONTEXT_LENGTH) . ' chars)'; + } + + private function level(string $level): string + { + return match (trim($level)) { + 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY' => $this->red($level), + 'WARNING' => $this->colors ? Styles::yellow($level) : $level, + 'DEBUG' => $this->gray($level), + default => $level, + }; + } + + private function gray(string $text): string + { + return $this->colors ? Styles::gray($text) : $text; + } + + private function red(string $text): string + { + return $this->colors ? Styles::red($text) : $text; + } +} diff --git a/src/Cli/OutputMode.php b/src/Cli/OutputMode.php new file mode 100644 index 0000000..ae39434 --- /dev/null +++ b/src/Cli/OutputMode.php @@ -0,0 +1,25 @@ +isAgent; + } + + /** + * Colors and spinners are for humans in a terminal. An agent may run the CLI in a terminal too. + */ + public static function isHuman(OutputInterface $output): bool + { + return $output->isDecorated() && ! self::isAgent(); + } +} diff --git a/src/Cli/TimeRange.php b/src/Cli/TimeRange.php new file mode 100644 index 0000000..2c2e304 --- /dev/null +++ b/src/Cli/TimeRange.php @@ -0,0 +1,32 @@ + 1, 'm' => 60, 'h' => 3600, 'd' => 86400, 'w' => 604800]; + + /** + * @param string $value A duration before now (`30m`, `2h`, `7d`), or a date or a datetime (UTC unless it has an offset). + * @return int Unix timestamp. + * @throws Exception + */ + public static function parse(string $value, int $now): int + { + $value = trim($value); + + if (preg_match('/^(\d+)\s*([smhdw])$/', $value, $matches) === 1) { + return $now - (int) $matches[1] * self::UNITS[$matches[2]]; + } + + try { + return (new DateTimeImmutable($value, new DateTimeZone('UTC')))->getTimestamp(); + } catch (Exception) { + throw new Exception("Invalid time \"$value\": use a duration like 30m, 2h or 7d, or a date like 2026-09-23 or \"2026-09-23 14:30\" (UTC)."); + } + } +} diff --git a/src/Commands/ApplicationCommand.php b/src/Commands/ApplicationCommand.php index ebfaed7..2ac59f1 100644 --- a/src/Commands/ApplicationCommand.php +++ b/src/Commands/ApplicationCommand.php @@ -3,6 +3,7 @@ namespace Bref\Cli\Commands; use Bref\Cli\Config; +use Exception; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -44,4 +45,32 @@ protected function parseStandardOptions(InputInterface $input): array ...['configFileName' => $configFileName], ]; } + + /** + * For the commands that only need to identify the environment, and that have an `--app` option. + * + * With `--app` and `--team`, no config file is needed: the command works outside the project directory. + * + * @return array{appName: string, environmentName: string, team: string} + */ + protected function parseEnvironmentOptions(InputInterface $input): array + { + /** @var string $environment */ + $environment = $input->getOption('env'); + $app = $input->getOption('app'); + $app = is_string($app) && $app !== '' ? $app : null; + $team = $input->getOption('team'); + $team = is_string($team) && $team !== '' ? $team : null; + + if ($app && $team) { + return ['appName' => $app, 'environmentName' => $environment, 'team' => $team]; + } + + if (! $input->getOption('config') && ! is_file('bref.php') && ! is_file('serverless.yml')) { + throw new Exception('No "bref.php" or "serverless.yml" file in the current directory: run the command in the project directory, or set the application with the --app and --team options.'); + } + ['appName' => $appName, 'team' => $configTeam] = $this->parseStandardOptions($input); + + return ['appName' => $app ?? $appName, 'environmentName' => $environment, 'team' => $configTeam]; + } } diff --git a/src/Commands/Deployments.php b/src/Commands/Deployments.php new file mode 100644 index 0000000..ad4254f --- /dev/null +++ b/src/Commands/Deployments.php @@ -0,0 +1,82 @@ +setName('deployments') + ->setDescription('List the latest deployments of an environment') + ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Number of deployments (up to 100)', '10') + ->setHelp(<<<'HELP' + Lists the latest deployments of an environment, the most recent first. + The output is JSON when the CLI is not run by a human in a terminal (for example by an AI agent). + + Examples: + + bref deployments --env=prod + bref deployments:show --env=prod details of the latest deployment + bref deployments:logs 42 logs of deployment 42 + HELP); + + parent::configure(); + } + + protected function show(InputInterface $input, OutputInterface $output): int + { + $limit = $input->getOption('limit'); + if (! is_numeric($limit) || (int) $limit < 1) { + throw new Exception('The --limit option must be a positive number.'); + } + + $deployments = $this->brefCloud()->listDeployments($this->findEnvironmentId($input), (int) $limit); + + if ($input->getOption('json') || ! OutputMode::isHuman($output)) { + $this->writeJson($output, $deployments); + return 0; + } + + if (! $deployments) { + $output->writeln('This environment has not been deployed yet.'); + return 0; + } + $table = new Table($output); + $table->setStyle('compact'); + foreach ($deployments as $deployment) { + $table->addRow([ + Styles::gray("#{$deployment['id']}"), + $this->status($deployment['status'], $deployment['message']), + $this->formatDate($deployment['created_at']), + $this->formatDuration($deployment['created_at'], $deployment['finished_at']), + Styles::gray(substr((string) $deployment['git_ref'], 0, 7)), + // Escaped: the table interprets console tags like + OutputFormatter::escape(mb_strimwidth(strtok((string) $deployment['git_message'], "\n") ?: '', 0, 60, '…')), + Styles::gray(OutputFormatter::escape((string) $deployment['author'])), + ]); + } + $table->render(); + + return 0; + } + + private function status(string $status, string $label): string + { + return match ($status) { + 'success' => Styles::green($label), + 'failed' => Styles::red($label), + 'queued', 'deploying' => Styles::yellow($label), + default => $label, + }; + } +} diff --git a/src/Commands/DeploymentsLogs.php b/src/Commands/DeploymentsLogs.php new file mode 100644 index 0000000..3d8d1eb --- /dev/null +++ b/src/Commands/DeploymentsLogs.php @@ -0,0 +1,68 @@ +setName('deployments:logs') + ->setDescription('Show the logs of a deployment') + ->addArgument('id', InputArgument::OPTIONAL, 'The deployment ID (default: the latest deployment of the environment)') + ->setHelp(<<<'HELP' + Shows the output of a deployment, for example to find why it failed. + + Examples: + + bref deployments:logs --env=prod the latest deployment of the environment + bref deployments:logs 42 works outside the project directory + HELP); + + parent::configure(); + } + + protected function show(InputInterface $input, OutputInterface $output): int + { + $id = $this->deploymentId($input); + $deployment = $this->brefCloud()->getDeployment($id); + $json = (bool) $input->getOption('json'); + // Colors are only useful in a terminal, and never in JSON + $keepColors = OutputMode::isHuman($output) && ! $json; + $logs = array_map(fn(array $log) => [ + 'line' => $keepColors ? $log['line'] : $this->stripAnsi($log['line']), + 'timestamp' => $log['timestamp'], + ], $deployment['logs']); + + if ($json) { + $this->writeJson($output, [ + 'id' => $id, + 'status' => $deployment['status'], + 'error_message' => $deployment['error_message'], + 'logs' => $logs, + ]); + return 0; + } + + foreach ($logs as $log) { + $output->writeln($log['line'], OutputInterface::OUTPUT_RAW); + } + $summary = "Deployment #$id: {$deployment['message']}"; + if ($deployment['error_message']) { + $summary .= " ({$deployment['error_message']})"; + } + $this->stderr($output)->writeln($summary, OutputInterface::OUTPUT_RAW); + + return 0; + } + + private function stripAnsi(string $text): string + { + return (string) preg_replace('/\e\[[0-9;?]*[A-Za-z]/', '', $text); + } +} diff --git a/src/Commands/DeploymentsShow.php b/src/Commands/DeploymentsShow.php new file mode 100644 index 0000000..fd64b0b --- /dev/null +++ b/src/Commands/DeploymentsShow.php @@ -0,0 +1,73 @@ +setName('deployments:show') + ->setDescription('Show the details of a deployment') + ->addArgument('id', InputArgument::OPTIONAL, 'The deployment ID (default: the latest deployment of the environment)') + ->setHelp(<<<'HELP' + Shows the status, git commit, author and error of a deployment. + The output is JSON when the CLI is not run by a human in a terminal (for example by an AI agent). + + Examples: + + bref deployments:show --env=prod the latest deployment of the environment + bref deployments:show 42 works outside the project directory + bref deployments:logs 42 the logs of the deployment + HELP); + + parent::configure(); + } + + protected function show(InputInterface $input, OutputInterface $output): int + { + $deployment = $this->brefCloud()->getDeployment($this->deploymentId($input)); + // Shown by `deployments:logs` + unset($deployment['logs']); + + if ($input->getOption('json') || ! OutputMode::isHuman($output)) { + $this->writeJson($output, $deployment); + return 0; + } + + $environment = isset($deployment['app'], $deployment['environment']) + ? "{$deployment['app']['name']} / {$deployment['environment']['name']}" + : ''; + $status = match ($deployment['status']) { + 'success' => Styles::green($deployment['message']), + 'failed' => Styles::red($deployment['message']), + default => Styles::yellow($deployment['message']), + }; + $duration = $this->formatDuration($deployment['created_at'] ?? null, $deployment['finished_at'] ?? null); + $git = trim(substr((string) ($deployment['git_ref'] ?? ''), 0, 7) . ' ' . (strtok((string) ($deployment['git_message'] ?? ''), "\n") ?: '')); + + $lines = [ + Styles::bold('Deployment #' . ($deployment['id'] ?? '')) . " $status", + 'environment: ' . $environment, + 'date: ' . $this->formatDate($deployment['created_at'] ?? null) . ($duration ? " (took $duration)" : ''), + 'git: ' . $git, + 'author: ' . ($deployment['author'] ?? ''), + 'url: ' . $deployment['url'], + ]; + if ($deployment['app_url']) { + $lines[] = 'app url: ' . $deployment['app_url']; + } + if ($deployment['error_message']) { + $lines[] = 'error: ' . Styles::red($deployment['error_message']); + } + $output->writeln($lines, OutputInterface::OUTPUT_RAW); + + return 0; + } +} diff --git a/src/Commands/EnvironmentDataCommand.php b/src/Commands/EnvironmentDataCommand.php new file mode 100644 index 0000000..80dfb4b --- /dev/null +++ b/src/Commands/EnvironmentDataCommand.php @@ -0,0 +1,126 @@ +addOption('app', null, InputOption::VALUE_REQUIRED, 'The application name, to use with --team when there is no config file') + ->addOption('json', null, InputOption::VALUE_NONE, 'Output JSON'); + + parent::configure(); + } + + abstract protected function show(InputInterface $input, OutputInterface $output): int; + + protected function execute(InputInterface $input, OutputInterface $output): int + { + // The data is written directly, not through IO::writeln(): same workaround as in IO::safeWrite(), + // Symfony's output silently truncates what it writes to a non-blocking stream + stream_set_blocking(STDOUT, true); + stream_set_blocking(STDERR, true); + + if (! $input->getOption('json')) { + return $this->show($input, $output); + } + + // A program reading JSON cannot parse the error rendered for humans + try { + return $this->show($input, $output); + } catch (Throwable $e) { + $this->writeJson($output, ['error' => ['message' => Application::prettifyException($e)->getMessage()]]); + + return 1; + } + } + + protected function brefCloud(): BrefCloudClient + { + return $this->brefCloud ?? new BrefCloudClient; + } + + /** + * @return int The environment ID. + */ + protected function findEnvironmentId(InputInterface $input): int + { + ['appName' => $appName, 'environmentName' => $environmentName, 'team' => $team] = $this->parseEnvironmentOptions($input); + + return $this->brefCloud()->findEnvironment($team, $appName, $environmentName)['id']; + } + + /** + * The deployment passed as argument, or else the latest deployment of the environment. + */ + protected function deploymentId(InputInterface $input): int + { + $id = $input->getArgument('id'); + if ($id !== null) { + if (! is_string($id) || ! ctype_digit($id)) { + throw new Exception('The deployment ID must be a number, for example 42.'); + } + return (int) $id; + } + + $latest = $this->brefCloud()->listDeployments($this->findEnvironmentId($input), 1)[0] ?? null; + if (! $latest) { + throw new Exception('This environment has not been deployed yet.'); + } + + return $latest['id']; + } + + protected function formatDate(?string $date): string + { + return $date ? (new DateTimeImmutable($date))->format('Y-m-d H:i') . ' UTC' : ''; + } + + protected function formatDuration(?string $start, ?string $end): string + { + if (! $start || ! $end) { + return ''; + } + $seconds = (new DateTimeImmutable($end))->getTimestamp() - (new DateTimeImmutable($start))->getTimestamp(); + + return $seconds >= 60 ? sprintf('%dm %02ds', intdiv($seconds, 60), $seconds % 60) : "{$seconds}s"; + } + + protected function writeJson(OutputInterface $output, mixed $data): void + { + $flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; + if (OutputMode::isHuman($output)) { + $flags |= JSON_PRETTY_PRINT; + } + $output->writeln(json_encode($data, $flags), OutputInterface::OUTPUT_RAW); + } + + /** + * Where to write what is not the data itself (summaries, hints), so that it can be piped. + */ + protected function stderr(OutputInterface $output): OutputInterface + { + return $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } +} diff --git a/src/Commands/Logs.php b/src/Commands/Logs.php new file mode 100644 index 0000000..9c089de --- /dev/null +++ b/src/Commands/Logs.php @@ -0,0 +1,172 @@ +setName('logs') + ->setDescription('Show the logs of an environment') + ->addOption('since', null, InputOption::VALUE_REQUIRED, 'Start of the time range: a duration ago (30m, 2h, 7d) or a date (2026-09-23, "2026-09-23 14:30")', '1h') + ->addOption('until', null, InputOption::VALUE_REQUIRED, 'End of the time range, in the same formats (default: now)') + ->addOption('search', 's', InputOption::VALUE_REQUIRED, 'Only the lines that contain all these words, in any order (case-insensitive)') + ->addOption('regex', null, InputOption::VALUE_NONE, 'Search with a regular expression instead of words') + ->addOption('function', 'f', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Only the logs of this function, e.g. web (can be repeated)') + ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of lines, the most recent ones (up to 1000)', '100') + ->addOption('all', null, InputOption::VALUE_NONE, 'Include the lines that Lambda and PHP-FPM write on every invocation (START, END, REPORT...)') + ->addOption('full', null, InputOption::VALUE_NONE, 'Show long messages in full and the stack traces of exceptions') + ->setHelp(<<<'HELP' + Shows the logs of all the functions of an environment, oldest first. Times are in UTC. + + Laravel logs show their level and context, and exceptions show their class and location + (their stack trace with --full). Other logs are shown as they are, one line per entry. + The lines that Lambda and PHP-FPM write on every invocation are hidden, --all shows them. + + Examples: + + bref logs --env=prod the last hour + bref logs --env=prod --since=1d --search="payment failed" + bref logs --env=prod --function=web --search="timeout|memory" --regex + bref logs --env=prod --since="2026-09-23 14:00" --until="2026-09-23 15:00" + bref logs --env=prod --since=10m --full with stack traces + bref logs --env=prod --app=my-app --team=my-team outside the project directory + HELP); + + parent::configure(); + } + + protected function show(InputInterface $input, OutputInterface $output): int + { + $query = $this->query($input); + $environmentId = $this->findEnvironmentId($input); + + $human = OutputMode::isHuman($output); + if ($human) { + IO::spin('searching logs'); + } + try { + $result = $this->brefCloud()->getLogs($environmentId, $query); + } catch (TimeoutExceptionInterface|ServerExceptionInterface $e) { + throw new Exception('The log search took too long or failed. Narrow the time range with --since and --until, or filter with --search or --function.', previous: $e); + } finally { + if ($human) { + IO::spinClear(); + } + } + + if ($input->getOption('json')) { + $this->writeJson($output, $result); + return 0; + } + + $renderer = new LogRenderer(colors: $human, full: (bool) $input->getOption('full')); + foreach ($renderer->render($result['records']) as $line) { + $output->writeln($line, OutputInterface::OUTPUT_RAW); + } + + $summary = $this->summary($result, $query); + $this->stderr($output)->writeln($human ? Styles::gray($summary) : $summary, OutputInterface::OUTPUT_RAW); + + return 0; + } + + /** + * @return array{since: int, until?: int, search?: string, regex?: bool, functions?: list, limit: int, all?: bool, full?: bool} + */ + private function query(InputInterface $input): array + { + $now = time(); + /** @var string $since */ + $since = $input->getOption('since'); + $query = ['since' => TimeRange::parse($since, $now)]; + $until = $input->getOption('until'); + if (is_string($until)) { + $query['until'] = TimeRange::parse($until, $now); + } + $search = $input->getOption('search'); + if (is_string($search) && $search !== '') { + $query['search'] = $search; + $query['regex'] = (bool) $input->getOption('regex'); + } + /** @var list $functions */ + $functions = $input->getOption('function'); + if ($functions) { + $query['functions'] = $functions; + } + $limit = $input->getOption('limit'); + if (! is_numeric($limit) || (int) $limit < 1) { + throw new Exception('The --limit option must be a positive number.'); + } + $query['limit'] = (int) $limit; + if ($input->getOption('all')) { + $query['all'] = true; + } + if ($input->getOption('full')) { + $query['full'] = true; + } + + return $query; + } + + /** + * @param array{from: string, to: string, limit: int, has_more: bool, records: list} $result + * @param array{search?: string, full?: bool} $query + */ + private function summary(array $result, array $query): string + { + $range = sprintf('between %s and %s UTC', $this->date($result['from']), $this->date($result['to'])); + $count = count($result['records']); + if ($count === 0) { + return "No logs $range" . (isset($query['search']) ? ' matching the search' : '') . '.'; + } + + if ($result['has_more']) { + // Fewer lines than the limit: Bref Cloud stopped at its maximum response size + $raiseLimit = $count >= $result['limit'] ? ', or raise --limit' : ''; + $summary = "The $count most recent lines $range, more lines match: narrow with --since, --search or --function$raiseLimit."; + } else { + $summary = "$count " . ($count === 1 ? 'line' : 'lines') . " $range."; + } + if (! isset($query['full']) && $this->hasTruncatedContent($result['records'])) { + $summary .= ' --full shows long messages in full and stack traces.'; + } + + return $summary; + } + + /** + * @param list $records + */ + private function hasTruncatedContent(array $records): bool + { + foreach ($records as $record) { + if (isset($record['exception']) || preg_match('/…\(\+\d+ chars\)$/u', $record['message']) === 1) { + return true; + } + } + + return false; + } + + private function date(string $timestamp): string + { + return substr(str_replace('T', ' ', $timestamp), 0, 16); + } +} diff --git a/src/Commands/SecretCreate.php b/src/Commands/SecretCreate.php index 6bd8831..88c88b3 100644 --- a/src/Commands/SecretCreate.php +++ b/src/Commands/SecretCreate.php @@ -29,30 +29,11 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - // If --app and --team are provided, we can skip loading the config file - if ($input->getOption('app') && $input->getOption('team')) { - $appName = $input->getOption('app'); - $team = $input->getOption('team'); - $environment = $input->getOption('env'); - if (! is_string($appName) || ! is_string($team) || ! is_string($environment)) { - throw new Exception('Invalid app, team, or environment option'); - } - } else { - [ - 'appName' => $appName, - 'environmentName' => $environment, - 'team' => $team, - ] = $this->parseStandardOptions($input); - - // Override app name if --app option provided - if ($input->getOption('app')) { - $appOption = $input->getOption('app'); - if (! is_string($appOption)) { - throw new Exception('Invalid app name'); - } - $appName = $appOption; - } - } + [ + 'appName' => $appName, + 'environmentName' => $environment, + 'team' => $team, + ] = $this->parseEnvironmentOptions($input); // Get secret name (from argument or prompt) $name = $input->getArgument('name'); diff --git a/tests/Cli/LogRendererTest.php b/tests/Cli/LogRendererTest.php new file mode 100644 index 0000000..bb36721 --- /dev/null +++ b/tests/Cli/LogRendererTest.php @@ -0,0 +1,131 @@ + '2026-09-23T10:12:51.863Z', + 'function' => 'web', + 'instance' => '45f01a', + 'level' => 'ERROR', + 'message' => 'Payment gateway returned 502', + 'exception' => [ + 'class' => 'RuntimeException', + 'message' => 'Payment gateway returned 502', + 'file' => 'app/Billing.php:37', + 'frames' => 2, + ], + ]; + + public function test_one_line_per_record_with_exceptions_summarized(): void + { + $lines = (new LogRenderer(colors: false, full: false))->render([ + self::ERROR, + [ + 'timestamp' => '2026-09-23T10:13:33.169Z', + 'function' => 'jobsWorker', + 'instance' => '382fc3', + 'level' => 'INFO', + 'message' => "Import failed:\nline 12: invalid email", + 'context' => ['user_id' => 42, 'url' => 'https://example.com/a'], + ], + [ + 'timestamp' => '2026-09-23T10:14:00.000Z', + 'function' => 'artisan', + 'instance' => '0f9e8d', + 'level' => null, + 'message' => 'Done.', + ], + ]); + + $this->assertSame([ + "2026-09-23 10:12:51.863 web 45f01a ERROR Payment gateway returned 502\n" + . ' ↳ RuntimeException at app/Billing.php:37 (2 frames)', + "2026-09-23 10:13:33.169 jobsWorker 382fc3 INFO Import failed:\n" + . ' line 12: invalid email {"user_id":42,"url":"https://example.com/a"}', + '2026-09-23 10:14:00.000 artisan 0f9e8d Done.', + ], $lines); + } + + /** + * Only Bref's Monolog formatter gives a level: an app that does not use it gets no empty column. + */ + public function test_no_level_column_when_no_line_has_a_level(): void + { + $lines = (new LogRenderer(colors: false, full: false))->render([ + ['timestamp' => '2026-09-23T10:14:00.000Z', 'function' => 'web', 'instance' => '0f9e8d', 'level' => null, 'message' => 'START processing batch 12 of 40'], + ]); + + $this->assertSame(['2026-09-23 10:14:00.000 web 0f9e8d START processing batch 12 of 40'], $lines); + } + + public function test_full_records_show_the_stack_trace_and_the_previous_exceptions(): void + { + $record = self::ERROR; + $record['exception']['trace'] = ['app/Http/Controllers/CheckoutController.php:21', 'vendor/laravel/framework/src/Illuminate/Routing/Route.php:254']; + $record['exception']['previous'] = [ + 'class' => 'GuzzleHttp\Exception\ServerException', + 'message' => '502 Bad Gateway', + 'file' => 'vendor/guzzlehttp/guzzle/src/Middleware.php:69', + 'frames' => 0, + 'trace' => [], + ]; + + $lines = (new LogRenderer(colors: false, full: true))->render([$record]); + + $this->assertSame([implode("\n", [ + '2026-09-23 10:12:51.863 web 45f01a ERROR Payment gateway returned 502', + ' ↳ RuntimeException: Payment gateway returned 502', + ' at app/Billing.php:37', + ' #0 app/Http/Controllers/CheckoutController.php:21', + ' #1 vendor/laravel/framework/src/Illuminate/Routing/Route.php:254', + ' ↳ Caused by GuzzleHttp\Exception\ServerException: 502 Bad Gateway', + ' at vendor/guzzlehttp/guzzle/src/Middleware.php:69', + ])], $lines); + } + + public function test_an_exception_without_file(): void + { + $lines = (new LogRenderer(colors: false, full: false))->render([[ + 'timestamp' => '2026-09-23T10:13:24.398Z', + 'function' => 'web', + 'instance' => '45f01a', + 'level' => 'ERROR', + 'message' => 'The request timed out after 26999 ms.', + // A Bref runtime error + 'exception' => ['class' => 'Bref\FpmRuntime\FastCgi\Timeout', 'message' => 'The request timed out after 26999 ms.', 'file' => '', 'frames' => 6], + ]]); + + $this->assertSame(["2026-09-23 10:13:24.398 web 45f01a ERROR The request timed out after 26999 ms.\n ↳ Bref\FpmRuntime\FastCgi\Timeout (6 frames)"], $lines); + } + + public function test_long_contexts_are_truncated_unless_full(): void + { + $record = [ + 'timestamp' => '2026-09-23T10:14:00.000Z', + 'function' => 'web', + 'instance' => '0f9e8d', + 'level' => 'INFO', + 'message' => 'Request', + 'context' => ['body' => str_repeat('x', 600)], + ]; + + $truncated = (new LogRenderer(colors: false, full: false))->render([$record])[0]; + $full = (new LogRenderer(colors: false, full: true))->render([$record])[0]; + + $this->assertStringEndsWith('xxx…(+111 chars)', $truncated); + $this->assertStringEndsWith(str_repeat('x', 600) . '"}', $full); + } + + public function test_colors_are_only_added_on_request(): void + { + $colored = (new LogRenderer(colors: true, full: false))->render([self::ERROR])[0]; + + $this->assertStringContainsString("\e[31mERROR\e[39m", $colored); + $this->assertStringNotContainsString("\e[", (new LogRenderer(colors: false, full: false))->render([self::ERROR])[0]); + } +} diff --git a/tests/Cli/TimeRangeTest.php b/tests/Cli/TimeRangeTest.php new file mode 100644 index 0000000..c8c154b --- /dev/null +++ b/tests/Cli/TimeRangeTest.php @@ -0,0 +1,45 @@ +assertSame($expected, gmdate('Y-m-d H:i:s', TimeRange::parse($value, self::NOW))); + } + + /** + * @return array + */ + public static function times(): array + { + return [ + 'seconds' => ['30s', '2026-09-23 10:59:30'], + 'minutes' => ['30m', '2026-09-23 10:30:00'], + 'hours' => ['2h', '2026-09-23 09:00:00'], + 'days' => ['7d', '2026-09-16 11:00:00'], + 'weeks' => ['1w', '2026-09-16 11:00:00'], + 'date, in UTC' => ['2026-09-20', '2026-09-20 00:00:00'], + 'datetime, in UTC' => ['2026-09-20 14:30', '2026-09-20 14:30:00'], + 'datetime with an offset' => ['2026-09-20T14:30:00+02:00', '2026-09-20 12:30:00'], + ]; + } + + public function test_an_invalid_time_is_explained(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid time "yesterdayish": use a duration like 30m, 2h or 7d, or a date like 2026-09-23 or "2026-09-23 14:30" (UTC).'); + + TimeRange::parse('yesterdayish', self::NOW); + } +} diff --git a/tests/Commands/CommandTestCase.php b/tests/Commands/CommandTestCase.php new file mode 100644 index 0000000..3664714 --- /dev/null +++ b/tests/Commands/CommandTestCase.php @@ -0,0 +1,76 @@ + */ + private array $agentVariables = []; + /** @var list Paths and query strings of the requests sent to Bref Cloud */ + protected array $requests = []; + + protected function setUp(): void + { + // The tests may run inside an AI agent (e.g. Claude Code): start from a human + foreach ([...array_keys(AgentDetector::AGENT_ENV_VARS), 'AI_AGENT'] as $variable) { + $this->agentVariables[$variable] = getenv($variable); + putenv($variable); + } + } + + protected function tearDown(): void + { + foreach ($this->agentVariables as $variable => $value) { + putenv($value === false ? $variable : "$variable=$value"); + } + } + + protected function runByAnAgent(): void + { + putenv('AI_AGENT=test-agent'); + } + + /** + * @param array $routes Responses indexed by path, a MockResponse or data to return as JSON. + */ + protected function brefCloud(array $routes): BrefCloudClient + { + $client = new MockHttpClient(function (string $method, string $url) use ($routes): MockResponse { + $path = (string) parse_url($url, PHP_URL_PATH); + $query = (string) parse_url($url, PHP_URL_QUERY); + $this->requests[] = urldecode($path . ($query ? "?$query" : '')); + if (! array_key_exists($path, $routes)) { + $this->fail("Unexpected request: $method $url"); + } + + return $routes[$path] instanceof MockResponse ? $routes[$path] : $this->json($routes[$path]); + }, 'https://bref.cloud'); + + return new BrefCloudClient(client: $client); + } + + /** + * Not `JsonMockResponse`, which Symfony 5 does not have. + */ + protected function json(mixed $data, int $status = 200): MockResponse + { + return new MockResponse((string) json_encode($data), [ + 'http_code' => $status, + 'response_headers' => ['content-type' => 'application/json'], + ]); + } + + /** + * @return array + */ + protected function environment(): array + { + return ['id' => 12, 'name' => 'prod', 'region' => 'us-east-1', 'url' => null, 'outputs' => [], 'app' => ['id' => 3, 'name' => 'shop'], 'aws_account_id' => 1]; + } +} diff --git a/tests/Commands/DeploymentsLogsTest.php b/tests/Commands/DeploymentsLogsTest.php new file mode 100644 index 0000000..738789e --- /dev/null +++ b/tests/Commands/DeploymentsLogsTest.php @@ -0,0 +1,56 @@ +brefCloud([ + '/api/v1/deployments/25' => [ + 'status' => 'success', + 'message' => 'deployed', + 'error_message' => null, + 'url' => 'https://bref.cloud/d/25', + 'app_url' => null, + 'logs' => [['line' => "\e[32m✔\e[39m Packaged", 'timestamp' => 1790157600]], + ], + ]))); + + // A human in a terminal + $tester->execute(['id' => '25', '--json' => true], ['decorated' => true]); + + $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay()); + $this->assertSame( + ['id' => 25, 'status' => 'success', 'error_message' => null, 'logs' => [['line' => '✔ Packaged', 'timestamp' => 1790157600]]], + json_decode($tester->getDisplay(), true), + ); + } + + public function test_agents_get_the_logs_as_text_without_colors(): void + { + $this->runByAnAgent(); + $tester = new CommandTester(new DeploymentsLogs($this->brefCloud([ + '/api/v1/deployments/25' => [ + 'status' => 'failed', + 'message' => 'failed', + 'error_message' => 'The CloudFormation stack failed to update', + 'url' => 'https://bref.cloud/d/25', + 'app_url' => null, + 'logs' => [ + ['line' => "\e[32m✔\e[39m Packaged", 'timestamp' => 1790157600], + ['line' => 'UPDATE_FAILED AWS::Lambda::Function', 'timestamp' => 1790157660], + ], + ], + ]))); + + $tester->execute(['id' => '25'], ['capture_stderr_separately' => true, 'decorated' => true]); + + $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay()); + $this->assertSame("✔ Packaged\nUPDATE_FAILED AWS::Lambda::Function\n", $tester->getDisplay()); + $this->assertSame("Deployment #25: failed (The CloudFormation stack failed to update)\n", $tester->getErrorOutput()); + } +} diff --git a/tests/Commands/DeploymentsShowTest.php b/tests/Commands/DeploymentsShowTest.php new file mode 100644 index 0000000..c9ec1fd --- /dev/null +++ b/tests/Commands/DeploymentsShowTest.php @@ -0,0 +1,64 @@ + 'https://shop.example.com', + 'environment' => ['id' => 12, 'name' => 'prod'], + 'app' => ['id' => 3, 'name' => 'shop'], + 'logs' => [['line' => 'Deploying', 'timestamp' => 1790157600]], + ]; + + public function test_shows_the_latest_deployment_of_the_environment(): void + { + $tester = new CommandTester(new DeploymentsShow($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/environments/12/deployments' => [DeploymentsTest::DEPLOYMENT], + '/api/v1/deployments/25' => self::DEPLOYMENT, + ]))); + + $tester->execute(['--app' => 'shop', '--team' => 'acme', '--json' => true]); + + $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay()); + $this->assertSame('/api/v1/environments/12/deployments?limit=1', $this->requests[1]); + $expected = self::DEPLOYMENT; + // `deployments:logs` shows them + unset($expected['logs']); + $this->assertSame($expected, json_decode($tester->getDisplay(), true)); + } + + /** + * An ID is enough: no config file, app or team needed. + */ + public function test_shows_a_deployment_by_id(): void + { + $tester = new CommandTester(new DeploymentsShow($this->brefCloud([ + '/api/v1/deployments/25' => self::DEPLOYMENT, + ]))); + + $tester->execute(['id' => '25'], ['decorated' => true]); + + $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay()); + $this->assertSame(['/api/v1/deployments/25'], $this->requests); + $display = (string) preg_replace('/\e\[[0-9;]*m/', '', $tester->getDisplay()); + $this->assertStringContainsString("Deployment #25 failed\nenvironment: shop / prod\ndate: 2026-09-23 10:00 UTC (took 1m 20s)\ngit: a1b2c3d Fix the checkout\n", $display); + $this->assertStringContainsString('error: The CloudFormation stack failed to update', $display); + } + + public function test_an_environment_without_deployments(): void + { + $tester = new CommandTester(new DeploymentsShow($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/environments/12/deployments' => [], + ]))); + + $tester->execute(['--app' => 'shop', '--team' => 'acme', '--json' => true]); + + $this->assertSame(['error' => ['message' => 'This environment has not been deployed yet.']], json_decode($tester->getDisplay(), true)); + } +} diff --git a/tests/Commands/DeploymentsTest.php b/tests/Commands/DeploymentsTest.php new file mode 100644 index 0000000..8b78f73 --- /dev/null +++ b/tests/Commands/DeploymentsTest.php @@ -0,0 +1,54 @@ + 25, + 'status' => 'failed', + 'message' => 'failed', + 'error_message' => 'The CloudFormation stack failed to update', + 'git_ref' => 'a1b2c3d4e5f6', + 'git_message' => "Fix the checkout\n\nLonger description", + 'author' => 'Alice', + 'created_at' => '2026-09-23T10:00:00Z', + 'finished_at' => '2026-09-23T10:01:20Z', + 'url' => 'https://bref.cloud/d/25', + ]; + + public function test_agents_get_json(): void + { + $this->runByAnAgent(); + $tester = new CommandTester(new Deployments($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/environments/12/deployments' => [self::DEPLOYMENT], + ]))); + + $tester->execute(['--app' => 'shop', '--team' => 'acme', '--limit' => '5'], ['decorated' => true]); + + $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay()); + $this->assertSame([self::DEPLOYMENT], json_decode($tester->getDisplay(), true)); + $this->assertSame('/api/v1/environments/12/deployments?limit=5', $this->requests[1]); + } + + public function test_humans_get_a_table(): void + { + $tester = new CommandTester(new Deployments($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + // Not a console style + '/api/v1/environments/12/deployments' => [['git_message' => 'Fix the checkout'] + self::DEPLOYMENT], + ]))); + + $tester->execute(['--app' => 'shop', '--team' => 'acme'], ['decorated' => true]); + + $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay()); + $this->assertSame( + '#25 failed 2026-09-23 10:00 UTC 1m 20s a1b2c3d Fix the checkout Alice', + trim((string) preg_replace(['/\e\[[0-9;]*m/', '/ +/'], ['', ' '], $tester->getDisplay())), + ); + } +} diff --git a/tests/Commands/LogsTest.php b/tests/Commands/LogsTest.php new file mode 100644 index 0000000..80fc81b --- /dev/null +++ b/tests/Commands/LogsTest.php @@ -0,0 +1,181 @@ + '2026-09-23T10:00:00.000Z', + 'to' => '2026-09-23T11:00:00.000Z', + 'limit' => 100, + 'has_more' => false, + 'records' => [ + [ + 'timestamp' => '2026-09-23T10:12:51.863Z', + 'function' => 'web', + 'instance' => '45f01a', + 'level' => 'ERROR', + 'message' => 'Payment gateway returned 502', + 'exception' => ['class' => 'RuntimeException', 'message' => 'Payment gateway returned 502', 'file' => 'app/Billing.php:37', 'frames' => 2], + ], + [ + 'timestamp' => '2026-09-23T10:13:00.000Z', + 'function' => 'web', + 'instance' => '45f01a', + 'level' => 'INFO', + // Not interpreted as a console style + 'message' => 'Rendered ', + ], + ], + ]; + + public function test_agents_get_the_logs_as_text_with_a_summary_on_stderr(): void + { + $this->runByAnAgent(); + $tester = new CommandTester(new Logs($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/environments/12/logs' => self::LOGS, + ]))); + + $tester->execute(['--env' => 'prod', '--app' => 'shop', '--team' => 'acme'], ['capture_stderr_separately' => true, 'decorated' => true]); + + $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay()); + $this->assertSame(implode("\n", [ + '2026-09-23 10:12:51.863 web 45f01a ERROR Payment gateway returned 502', + ' ↳ RuntimeException at app/Billing.php:37 (2 frames)', + '2026-09-23 10:13:00.000 web 45f01a INFO Rendered ', + '', + ]), $tester->getDisplay()); + $this->assertSame("2 lines between 2026-09-23 10:00 and 2026-09-23 11:00 UTC. --full shows long messages in full and stack traces.\n", $tester->getErrorOutput()); + $this->assertSame('/api/v1/environments/find?teamSlug=acme&appName=shop&environmentName=prod', $this->requests[0]); + } + + public function test_no_logs_in_the_time_range(): void + { + $tester = new CommandTester(new Logs($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/environments/12/logs' => ['records' => []] + self::LOGS, + ]))); + + $tester->execute(['--app' => 'shop', '--team' => 'acme', '--search' => 'payment'], ['capture_stderr_separately' => true]); + + $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay() . $tester->getErrorOutput()); + $this->assertSame('', $tester->getDisplay()); + $this->assertSame("No logs between 2026-09-23 10:00 and 2026-09-23 11:00 UTC matching the search.\n", $tester->getErrorOutput()); + } + + public function test_more_lines_than_returned(): void + { + $tester = new CommandTester(new Logs($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + // Bref Cloud stopped before the limit, at its maximum response size + '/api/v1/environments/12/logs' => ['has_more' => true] + self::LOGS, + ]))); + + $tester->execute(['--app' => 'shop', '--team' => 'acme', '--full' => true], ['capture_stderr_separately' => true]); + + $this->assertSame( + "The 2 most recent lines between 2026-09-23 10:00 and 2026-09-23 11:00 UTC, more lines match: narrow with --since, --search or --function.\n", + $tester->getErrorOutput(), + ); + } + + public function test_the_options_are_sent_to_bref_cloud(): void + { + $tester = new CommandTester(new Logs($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/environments/12/logs' => self::LOGS, + ]))); + + $tester->execute([ + '--app' => 'shop', + '--team' => 'acme', + '--since' => '2026-09-23 10:00', + '--until' => '2026-09-23 11:00', + '--search' => 'timeout|memory', + '--regex' => true, + '--function' => ['web', 'worker'], + '--limit' => '20', + '--all' => true, + '--full' => true, + ]); + + $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay()); + $this->assertSame( + '/api/v1/environments/12/logs?since=1790157600&until=1790161200&search=timeout|memory®ex=1&functions[0]=web&functions[1]=worker&limit=20&all=1&full=1', + $this->requests[1], + ); + } + + public function test_json_output_is_the_bref_cloud_response(): void + { + $tester = new CommandTester(new Logs($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/environments/12/logs' => self::LOGS, + ]))); + + $tester->execute(['--app' => 'shop', '--team' => 'acme', '--json' => true]); + + $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay()); + $this->assertSame(self::LOGS, json_decode($tester->getDisplay(), true)); + } + + public function test_json_output_reports_errors_as_json(): void + { + $tester = new CommandTester(new Logs($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/environments/12/logs' => $this->json( + ['message' => "The function 'api' does not exist in this environment. Available functions: web, worker."], + 422, + ), + ]))); + + $tester->execute(['--app' => 'shop', '--team' => 'acme', '--function' => ['api'], '--json' => true]); + + $this->assertSame(1, $tester->getStatusCode()); + $this->assertSame( + ['error' => ['message' => "Bref Cloud API error: [422] The function 'api' does not exist in this environment. Available functions: web, worker."]], + json_decode($tester->getDisplay(), true), + ); + } + + /** + * Bref Cloud stops the search when it exceeds its own timeout. + */ + public function test_a_search_that_takes_too_long_suggests_narrowing_it(): void + { + $tester = new CommandTester(new Logs($this->brefCloud([ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/environments/12/logs' => new MockResponse('{"message":"Service Unavailable"}', ['http_code' => 503]), + ]))); + + $tester->execute(['--app' => 'shop', '--team' => 'acme', '--since' => '30d', '--json' => true]); + + $this->assertSame( + ['error' => ['message' => 'The log search took too long or failed. Narrow the time range with --since and --until, or filter with --search or --function.']], + json_decode($tester->getDisplay(), true), + ); + } + + public function test_without_a_config_file_the_app_and_team_are_required(): void + { + $tester = new CommandTester(new Logs($this->brefCloud([]))); + $directory = getcwd(); + chdir(sys_get_temp_dir()); + + try { + $tester->execute(['--app' => 'shop', '--json' => true]); + } finally { + chdir((string) $directory); + } + + $this->assertSame( + ['error' => ['message' => 'No "bref.php" or "serverless.yml" file in the current directory: run the command in the project directory, or set the application with the --app and --team options.']], + json_decode($tester->getDisplay(), true), + ); + } +} From 6cc5a9cbaac924d023d0d8dae07d0eae9a23b58d Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 23 Sep 2026 22:05:38 +0200 Subject: [PATCH 2/2] Only send NextToken when there is one The latest aws/aws-sdk-php types reject a null NextToken, which fails PHPStan. --- src/Helpers/CloudFormation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Helpers/CloudFormation.php b/src/Helpers/CloudFormation.php index dad4fec..05cb45e 100644 --- a/src/Helpers/CloudFormation.php +++ b/src/Helpers/CloudFormation.php @@ -152,7 +152,7 @@ private function getStackEventsForLastDeployment(string $stackName): array /** @var array{StackEvents?: StackEvent[], NextToken?: string} $result */ $result = $this->cloudFormation->describeStackEvents([ 'StackName' => $stackName, - 'NextToken' => $nextToken, + ...($nextToken ? ['NextToken' => $nextToken] : []), ])->toArray(); if (! empty($result['StackEvents'])) {