Skip to content

Commit 25c4aff

Browse files
feat(core): add about command (#1226)
Co-authored-by: Enzo Innocenzi <[email protected]>
1 parent 9c390ea commit 25c4aff

File tree

8 files changed

+388
-0
lines changed

8 files changed

+388
-0
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Commands;
6+
7+
use Stringable;
8+
use Tempest\Console\Console;
9+
use Tempest\Console\ConsoleArgument;
10+
use Tempest\Console\ConsoleCommand;
11+
use Tempest\Console\ExitCode;
12+
use Tempest\Container\Container;
13+
use Tempest\Core\AppConfig;
14+
use Tempest\Core\Insight;
15+
use Tempest\Core\InsightsProvider;
16+
use Tempest\Support\Arr;
17+
use Tempest\Support\Json;
18+
use Tempest\Support\Str;
19+
20+
final readonly class AboutCommand
21+
{
22+
public function __construct(
23+
private readonly Console $console,
24+
private readonly Container $container,
25+
private readonly AppConfig $appConfig,
26+
) {}
27+
28+
#[ConsoleCommand(name: 'about', description: 'Shows insights about the application', aliases: ['insights'])]
29+
public function __invoke(
30+
#[ConsoleArgument(description: 'Formats the outpuyt to JSON', aliases: ['--json'])]
31+
?bool $json = null,
32+
): ExitCode {
33+
if ($json) {
34+
$this->writeInsightsAsJson();
35+
} else {
36+
$this->writeFormattedInsights();
37+
}
38+
39+
return ExitCode::SUCCESS;
40+
}
41+
42+
private function writeFormattedInsights(): void
43+
{
44+
foreach ($this->appConfig->insightsProviders as $class) {
45+
/** @var InsightsProvider $provider */
46+
$provider = $this->container->get($class);
47+
48+
$this->console->header($provider->name);
49+
50+
foreach ($provider->getInsights() as $key => $value) {
51+
$this->console->keyValue($key, $this->formatInsight($value));
52+
}
53+
}
54+
}
55+
56+
private function writeInsightsAsJson(): void
57+
{
58+
$json = [];
59+
60+
foreach ($this->appConfig->insightsProviders as $class) {
61+
/** @var InsightsProvider $provider */
62+
$provider = $this->container->get($class);
63+
64+
$json[Str\to_snake_case($provider->name)] = Arr\map_with_keys(
65+
array: $provider->getInsights(),
66+
map: fn (mixed $value, string $key) => yield Str\to_snake_case($key) => $this->rawInsight($value),
67+
);
68+
}
69+
70+
$this->console->writeRaw(Json\encode($json));
71+
}
72+
73+
private function formatInsight(Stringable|Insight|string $value): string
74+
{
75+
if ($value instanceof Insight) {
76+
return $value->formattedValue;
77+
}
78+
79+
return (string) $value;
80+
}
81+
82+
private function rawInsight(Stringable|Insight|string $value): string
83+
{
84+
if ($value instanceof Insight) {
85+
return $value->value;
86+
}
87+
88+
return Str\strip_tags((string) $value);
89+
}
90+
}

packages/core/src/AppConfig.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ public function __construct(
2020

2121
/** @var class-string<\Tempest\Core\ExceptionProcessor>[] */
2222
public array $exceptionProcessors = [],
23+
24+
/**
25+
* @var array<class-string<\Tempest\Core\InsightsProvider>>
26+
*/
27+
public array $insightsProviders = [],
2328
) {
2429
$this->environment = $environment ?? Environment::fromEnv();
2530

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace Tempest\Core;
4+
5+
use Tempest\Support\Regex;
6+
7+
final class EnvironmentInsightsProvider implements InsightsProvider
8+
{
9+
public string $name = 'Environment';
10+
11+
public function __construct(
12+
private readonly AppConfig $appConfig,
13+
) {}
14+
15+
public function getInsights(): array
16+
{
17+
return [
18+
'Tempest version' => Kernel::VERSION,
19+
'PHP version' => PHP_VERSION,
20+
'Composer version' => $this->getComposerVersion(),
21+
'Operating system' => $this->getOperatingSystem(),
22+
'Environment' => $this->appConfig->environment->value,
23+
'Application URL' => $this->appConfig->baseUri ?: new Insight('Not set', Insight::ERROR),
24+
];
25+
}
26+
27+
private function getComposerVersion(): Insight|string
28+
{
29+
if (! function_exists('shell_exec')) {
30+
return 'shell_exec disabled';
31+
}
32+
33+
$output = shell_exec('composer --version --no-ansi 2>&1');
34+
35+
if (! $output) {
36+
return new Insight('Not installed', Insight::ERROR);
37+
}
38+
39+
return \Tempest\Support\Regex\get_match(
40+
subject: $output,
41+
pattern: "/Composer version (\S+)/",
42+
match: 0,
43+
default: new Insight('Unknown', Insight::ERROR),
44+
);
45+
}
46+
47+
private function getOperatingSystem(): string
48+
{
49+
if (PHP_OS_FAMILY === 'Darwin') {
50+
if ($version = shell_exec('sw_vers -productVersion')) {
51+
return "macOS {$version}";
52+
}
53+
54+
return 'macOS ' . php_uname('r');
55+
}
56+
57+
if (PHP_OS_FAMILY === 'Windows') {
58+
$version = php_uname('r');
59+
60+
return match (substr($version, 0, strcspn($version, ' '))) {
61+
'5.1' => 'Windows XP',
62+
'5.2' => 'Windows XP',
63+
'6.0' => 'Windows Vista',
64+
'6.1' => 'Windows 7',
65+
'6.2' => 'Windows 8',
66+
'6.3' => 'Windows 8.1',
67+
'10.0' => 'Windows 10',
68+
default => "Windows {$version}",
69+
};
70+
}
71+
72+
if (PHP_OS_FAMILY === 'Linux') {
73+
$version = php_uname('r');
74+
75+
if (function_exists('shell_exec') && ($output = shell_exec('lsb_release -a 2>/dev/null'))) {
76+
$version = Regex\get_match($output, "/Description:\s+(?<version>.*)/", match: 'version', default: php_uname('r'));
77+
}
78+
79+
return "Linux {$version}";
80+
}
81+
82+
return PHP_OS_FAMILY;
83+
}
84+
}

packages/core/src/Insight.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Tempest\Core;
4+
5+
use Tempest\Support\Str;
6+
7+
/**
8+
* Represents an insight for the `tempest about` command.
9+
*/
10+
final class Insight
11+
{
12+
public const string ERROR = 'error';
13+
public const string SUCCESS = 'success';
14+
public const string NORMAL = 'normal';
15+
public const string WARNING = 'warning';
16+
17+
public string $formattedValue {
18+
get => match ($this->type) {
19+
self::ERROR => "<style='bold fg-red'>" . mb_strtoupper($this->value) . '</style>',
20+
self::SUCCESS => "<style='bold fg-green'>" . mb_strtoupper($this->value) . '</style>',
21+
self::WARNING => "<style='bold fg-yellow'>" . mb_strtoupper($this->value) . '</style>',
22+
self::NORMAL => $this->value,
23+
};
24+
}
25+
26+
public function __construct(
27+
private(set) string $value,
28+
private string $type = self::NORMAL,
29+
) {
30+
$this->value = $value;
31+
}
32+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Tempest\Core;
4+
5+
/**
6+
* Provide insights on the current state of the application.
7+
*/
8+
interface InsightsProvider
9+
{
10+
/**
11+
* Display name of this provider.
12+
*/
13+
public string $name {
14+
get;
15+
}
16+
17+
/**
18+
* Gets insights in the form of key/value pairs.
19+
*
20+
* @return array<string,mixed>
21+
*/
22+
public function getInsights(): array;
23+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Core;
6+
7+
use Tempest\Core\AppConfig;
8+
use Tempest\Discovery\Discovery;
9+
use Tempest\Discovery\DiscoveryLocation;
10+
use Tempest\Discovery\IsDiscovery;
11+
use Tempest\Reflection\ClassReflector;
12+
13+
final class InsightsProviderDiscovery implements Discovery
14+
{
15+
use IsDiscovery;
16+
17+
public function __construct(
18+
private readonly AppConfig $appConfig,
19+
) {}
20+
21+
public function discover(DiscoveryLocation $location, ClassReflector $class): void
22+
{
23+
if ($class->implements(InsightsProvider::class)) {
24+
$this->discoveryItems->add($location, $class->getName());
25+
}
26+
}
27+
28+
public function apply(): void
29+
{
30+
foreach ($this->discoveryItems as $className) {
31+
$this->appConfig->insightsProviders[] = $className;
32+
}
33+
}
34+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
namespace Tempest\Database;
4+
5+
use Tempest\Core\Insight;
6+
use Tempest\Core\InsightsProvider;
7+
use Tempest\Database\Config\DatabaseConfig;
8+
use Tempest\Database\Config\MysqlConfig;
9+
use Tempest\Database\Config\PostgresConfig;
10+
use Tempest\Database\Config\SQLiteConfig;
11+
use Tempest\Support\Arr;
12+
use Tempest\Support\Regex;
13+
14+
final class DatabaseInsightsProvider implements InsightsProvider
15+
{
16+
public string $name = 'Database';
17+
18+
public function __construct(
19+
private readonly DatabaseConfig $databaseConfig,
20+
private readonly Database $database,
21+
) {}
22+
23+
public function getInsights(): array
24+
{
25+
return [
26+
'Engine' => $this->getDatabaseEngine(),
27+
'Version' => $this->getDatabaseVersion(),
28+
];
29+
}
30+
31+
private function getDatabaseEngine(): string
32+
{
33+
return match (get_class($this->databaseConfig)) {
34+
SQLiteConfig::class => 'SQLite',
35+
PostgresConfig::class => 'PostgreSQL',
36+
MysqlConfig::class => 'MySQL',
37+
default => ['Unknown', null],
38+
};
39+
}
40+
41+
private function getDatabaseVersion(): Insight
42+
{
43+
// TODO: support displaying multiple databases, after cache PR
44+
[$versionQuery, $regex] = match (get_class($this->databaseConfig)) {
45+
SQLiteConfig::class => ['SELECT sqlite_version() AS version;', '/(?<version>.*)/'],
46+
PostgresConfig::class => ['SELECT version() AS version;', '/(?<version>.*)/'],
47+
MysqlConfig::class => ['SELECT version() AS version;', "/PostgreSQL (?<version>\S+)/"],
48+
default => [null, null],
49+
};
50+
51+
if (! $versionQuery) {
52+
return new Insight('Unknown', Insight::ERROR);
53+
}
54+
55+
try {
56+
return new Insight(Regex\get_match(
57+
subject: Arr\get_by_key($this->database->fetchFirst(new Query($versionQuery)), 'version'),
58+
pattern: $regex,
59+
match: 'version',
60+
));
61+
} catch (\Throwable $e) {
62+
return new Insight('Unavailable', Insight::ERROR);
63+
}
64+
}
65+
}

0 commit comments

Comments
 (0)