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/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'])) {
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),
+ );
+ }
+}