diff --git a/app/Commands/Service/DownCommand.php b/app/Commands/Service/DownCommand.php
new file mode 100644
index 0000000..f24d4c7
--- /dev/null
+++ b/app/Commands/Service/DownCommand.php
@@ -0,0 +1,147 @@
+option('odin') === true
+ ? 'docker-compose.odin.yml'
+ : 'docker-compose.yml';
+
+ $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local';
+
+ if (! file_exists(base_path($composeFile))) {
+ render(<<
+
+
✗ Configuration Error
+
Docker Compose file not found: {$composeFile}
+
+
+ HTML);
+
+ return self::FAILURE;
+ }
+
+ // Show warning if removing volumes
+ if ($this->option('volumes') === true && $this->option('force') !== true) {
+ render(<<<'HTML'
+
+
+
+ ⚠
+ Warning: Volume Removal
+
+
This will permanently delete all data stored in volumes
+
+
+ HTML);
+
+ $confirmed = confirm(
+ label: 'Are you sure you want to remove volumes?',
+ default: false,
+ hint: 'This action cannot be undone'
+ );
+
+ if (! $confirmed) {
+ render(<<<'HTML'
+
+ HTML);
+
+ return self::SUCCESS;
+ }
+ }
+
+ // Display shutdown banner
+ render(<<
+
+
+ ■
+ Stopping Knowledge Services
+
+
Environment: {$environment}
+
+
+ HTML);
+
+ $args = ['docker', 'compose', '-f', $composeFile, 'down'];
+
+ if ($this->option('volumes') === true) {
+ $args[] = '-v';
+ }
+
+ $result = Process::forever()
+ ->path(base_path())
+ ->run($args);
+
+ $exitCode = $result->exitCode();
+
+ if ($exitCode === 0) {
+ $volumeText = $this->option('volumes') === true ? ' and volumes removed' : '';
+
+ render(<<
+
+
+ ✓
+ Services Stopped Successfully
+
+
All containers have been stopped{$volumeText}
+
+
+ HTML);
+
+ if ($this->option('volumes') !== true) {
+ render(<<<'HTML'
+
+
+
+ Tip: Use
+ know service:down --volumes
+ to remove data volumes
+
+
+
+ HTML);
+ }
+
+ return self::SUCCESS;
+ }
+
+ render(<<<'HTML'
+
+
+
+ ✗
+ Failed to Stop Services
+
+
Check the error output above for details
+
+
+ HTML);
+
+ return self::FAILURE;
+ }
+}
diff --git a/app/Commands/Service/LogsCommand.php b/app/Commands/Service/LogsCommand.php
new file mode 100644
index 0000000..0a3ceda
--- /dev/null
+++ b/app/Commands/Service/LogsCommand.php
@@ -0,0 +1,143 @@
+option('odin') === true
+ ? 'docker-compose.odin.yml'
+ : 'docker-compose.yml';
+
+ $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local';
+
+ if (! file_exists(base_path($composeFile))) {
+ render(<<
+
+
✗ Configuration Error
+
Docker Compose file not found: {$composeFile}
+
+
+ HTML);
+
+ return self::FAILURE;
+ }
+
+ /** @var string|null $service */
+ $service = $this->argument('service');
+
+ // If no service specified and not following, offer selection
+ if ($service === null && $this->option('follow') !== true) {
+ $service = select(
+ label: 'Which service logs would you like to view?',
+ options: [
+ 'all' => 'All Services',
+ 'qdrant' => 'Qdrant (Vector Database)',
+ 'redis' => 'Redis (Cache)',
+ 'embeddings' => 'Embeddings (ML Service)',
+ 'ollama' => 'Ollama (LLM Engine)',
+ ],
+ default: 'all'
+ );
+
+ if ($service === 'all') {
+ $service = null;
+ }
+ }
+
+ $serviceDisplay = is_string($service) ? ucfirst($service) : 'All Services';
+ $followMode = $this->option('follow') === true ? 'Live' : 'Recent';
+ $tailOption = $this->option('tail');
+ $tailCount = is_string($tailOption) || is_int($tailOption) ? (string) $tailOption : '50';
+
+ // Display logs banner
+ render(<<
+
+
+ 📋
+ Service Logs: {$serviceDisplay}
+
+
+ Environment: {$environment}
+ ·
+ {$followMode}
+ ·
+ Last {$tailCount} lines
+
+
+
+ HTML);
+
+ if ($this->option('follow') === true) {
+ render(<<<'HTML'
+
+
+
+ Press Ctrl+C to stop following logs
+
+
+
+ HTML);
+ }
+
+ $this->newLine();
+
+ $args = ['docker', 'compose', '-f', $composeFile, 'logs'];
+
+ if ($this->option('follow') === true) {
+ $args[] = '-f';
+ }
+
+ $tailOption = $this->option('tail');
+ if (is_string($tailOption) || is_int($tailOption)) {
+ $args[] = '--tail='.(string) $tailOption;
+ }
+
+ if (is_string($service)) {
+ $args[] = $service;
+ }
+
+ $result = Process::forever()
+ ->path(base_path())
+ ->run($args);
+
+ $exitCode = $result->exitCode() ?? self::FAILURE;
+
+ // Only show footer if not following (Ctrl+C will interrupt)
+ if ($this->option('follow') !== true) {
+ $this->newLine();
+ render(<<<'HTML'
+
+
+
+ Tip: Use
+ --follow
+ to stream logs in real-time
+
+
+
+ HTML);
+ }
+
+ return $exitCode;
+ }
+}
diff --git a/app/Commands/Service/StatusCommand.php b/app/Commands/Service/StatusCommand.php
new file mode 100644
index 0000000..04162b3
--- /dev/null
+++ b/app/Commands/Service/StatusCommand.php
@@ -0,0 +1,199 @@
+option('odin') === true
+ ? 'docker-compose.odin.yml'
+ : 'docker-compose.yml';
+
+ $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local';
+
+ // Perform health checks with spinner
+ $healthData = spin(
+ fn () => $this->healthCheck->checkAll(),
+ 'Checking service health...'
+ );
+
+ // Get container status
+ $containerStatus = $this->getContainerStatus($composeFile);
+
+ // Render beautiful dashboard
+ $this->renderDashboard($environment, $healthData, $containerStatus);
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * @return array>
+ */
+ private function getContainerStatus(string $composeFile): array
+ {
+ $result = Process::path(base_path())
+ ->run(['docker', 'compose', '-f', $composeFile, 'ps', '--format', 'json']);
+
+ if (! $result->successful()) {
+ return [];
+ }
+
+ $output = $result->output();
+ if ($output === '') {
+ return [];
+ }
+
+ $containers = [];
+ foreach (explode("\n", trim($output)) as $line) {
+ if ($line === '') {
+ continue;
+ }
+ $data = json_decode($line, true);
+ if (is_array($data) && count($data) > 0) {
+ $containers[] = $data;
+ }
+ }
+
+ return $containers;
+ }
+
+ /**
+ * @param array $healthData
+ * @param array> $containers
+ */
+ private function renderDashboard(string $environment, array $healthData, array $containers): void
+ {
+ $allHealthy = collect($healthData)->every(fn ($service) => $service['healthy']);
+ $healthyCount = collect($healthData)->filter(fn ($service) => $service['healthy'])->count();
+ $totalCount = count($healthData);
+
+ $statusColor = $allHealthy ? 'green' : ($healthyCount > 0 ? 'yellow' : 'red');
+ $statusText = $allHealthy ? 'All Systems Operational' : ($healthyCount > 0 ? 'Partial Outage' : 'Major Outage');
+ $statusIcon = '●';
+
+ render(<<
+
+
+ KNOWLEDGE SERVICE STATUS
+ ·
+ {$environment}
+
+
+ {$statusIcon}
+ {$statusText}
+ {$healthyCount}/{$totalCount}
+
+
+
+ HTML);
+
+ // Service Health Cards
+ render(<<<'HTML'
+
+ HTML);
+
+ foreach ($healthData as $service) {
+ $color = $service['healthy'] ? 'green' : 'red';
+ $icon = $service['healthy'] ? '✓' : '✗';
+ $status = $service['healthy'] ? 'Healthy' : 'Unhealthy';
+
+ $name = $service['name'];
+ $type = $service['type'];
+ $endpoint = $service['endpoint'];
+
+ render(<<
+
+
+ {$icon}
+ {$name}
+ {$status}
+
+
+ {$type}
+ ·
+ {$endpoint}
+
+
+
+ HTML);
+ }
+
+ // Container Status
+ if (count($containers) > 0) {
+ render(<<<'HTML'
+
+ HTML);
+
+ foreach ($containers as $container) {
+ $state = $container['State'] ?? 'unknown';
+ $name = $container['Service'] ?? $container['Name'] ?? 'unknown';
+
+ $stateColor = match ($state) {
+ 'running' => 'green',
+ 'exited' => 'red',
+ 'paused' => 'yellow',
+ default => 'gray',
+ };
+
+ $stateIcon = match ($state) {
+ 'running' => '▶',
+ 'exited' => '■',
+ 'paused' => '❙❙',
+ default => '?',
+ };
+
+ render(<<
+
+
+ {$stateIcon}
+ {$name}
+ {$state}
+
+
+
+ HTML);
+ }
+ }
+
+ // Footer
+ render(<<<'HTML'
+
+
+ Run know service:logs to view service logs
+
+
+ HTML);
+ }
+}
diff --git a/app/Commands/Service/UpCommand.php b/app/Commands/Service/UpCommand.php
new file mode 100644
index 0000000..61ccf5e
--- /dev/null
+++ b/app/Commands/Service/UpCommand.php
@@ -0,0 +1,124 @@
+option('odin') === true
+ ? 'docker-compose.odin.yml'
+ : 'docker-compose.yml';
+
+ $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local';
+
+ if (! file_exists(base_path($composeFile))) {
+ render(<<
+
+
✗ Configuration Error
+
Docker Compose file not found: {$composeFile}
+
+ Run know service:init to initialize
+
+
+
+ HTML);
+
+ return self::FAILURE;
+ }
+
+ // Display startup banner
+ render(<<
+
+
+ ▶
+ Starting Knowledge Services
+
+
Environment: {$environment}
+
+
+ HTML);
+
+ $args = ['docker', 'compose', '-f', $composeFile, 'up'];
+
+ if ($this->option('detach') === true) {
+ $args[] = '-d';
+ }
+
+ $result = Process::forever()
+ ->path(base_path())
+ ->run($args);
+
+ $exitCode = $result->exitCode();
+
+ if ($exitCode === 0) {
+ if ($this->option('detach') === true) {
+ render(<<<'HTML'
+
+
+
+ ✓
+ Services Started Successfully
+
+
All containers are running in detached mode
+
+
+ HTML);
+
+ render(<<<'HTML'
+
+
+
NEXT STEPS
+
+ →
+ Check status:
+ know service:status
+
+
+ →
+ View logs:
+ know service:logs
+
+
+ →
+ Stop services:
+ know service:down
+
+
+
+ HTML);
+ }
+
+ return self::SUCCESS;
+ }
+
+ render(<<<'HTML'
+
+
+
+ ✗
+ Failed to Start Services
+
+
Check the error output above for details
+
+
+ HTML);
+
+ return self::FAILURE;
+ }
+}
diff --git a/app/Contracts/HealthCheckInterface.php b/app/Contracts/HealthCheckInterface.php
new file mode 100644
index 0000000..6842eea
--- /dev/null
+++ b/app/Contracts/HealthCheckInterface.php
@@ -0,0 +1,29 @@
+
+ */
+ public function checkAll(): array;
+
+ /**
+ * Get list of available services.
+ *
+ * @return array
+ */
+ public function getServices(): array;
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 64cc884..cfefc42 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -3,8 +3,10 @@
namespace App\Providers;
use App\Contracts\EmbeddingServiceInterface;
+use App\Contracts\HealthCheckInterface;
use App\Services\DailyLogService;
use App\Services\DeletionTracker;
+use App\Services\HealthCheckService;
use App\Services\KnowledgeCacheService;
use App\Services\KnowledgePathService;
use App\Services\OdinSyncService;
@@ -160,5 +162,8 @@ public function register(): void
$this->app->singleton(DailyLogService::class, fn ($app): \App\Services\DailyLogService => new DailyLogService(
$app->make(KnowledgePathService::class)
));
+
+ // Health check service for service status commands
+ $this->app->singleton(HealthCheckInterface::class, fn (): HealthCheckService => new HealthCheckService);
}
}
diff --git a/app/Services/HealthCheckService.php b/app/Services/HealthCheckService.php
new file mode 100644
index 0000000..4993e44
--- /dev/null
+++ b/app/Services/HealthCheckService.php
@@ -0,0 +1,142 @@
+
+ */
+ private array $services;
+
+ public function __construct()
+ {
+ $this->services = [
+ 'qdrant' => [
+ 'name' => 'Qdrant',
+ 'type' => 'Vector Database',
+ 'checker' => fn () => $this->checkQdrant(),
+ ],
+ 'redis' => [
+ 'name' => 'Redis',
+ 'type' => 'Cache',
+ 'checker' => fn () => $this->checkRedis(),
+ ],
+ 'embeddings' => [
+ 'name' => 'Embeddings',
+ 'type' => 'ML Service',
+ 'checker' => fn () => $this->checkEmbeddings(),
+ ],
+ 'ollama' => [
+ 'name' => 'Ollama',
+ 'type' => 'LLM Engine',
+ 'checker' => fn () => $this->checkOllama(),
+ ],
+ ];
+ }
+
+ public function check(string $service): array
+ {
+ if (! isset($this->services[$service])) {
+ return [
+ 'name' => $service,
+ 'healthy' => false,
+ 'endpoint' => 'unknown',
+ 'type' => 'Unknown',
+ ];
+ }
+
+ $config = $this->services[$service];
+ $endpoint = $this->getEndpoint($service);
+
+ return [
+ 'name' => $config['name'],
+ 'healthy' => ($config['checker'])(),
+ 'endpoint' => $endpoint,
+ 'type' => $config['type'],
+ ];
+ }
+
+ public function checkAll(): array
+ {
+ return array_map(
+ fn (string $service) => $this->check($service),
+ $this->getServices()
+ );
+ }
+
+ public function getServices(): array
+ {
+ return array_keys($this->services);
+ }
+
+ private function getEndpoint(string $service): string
+ {
+ return match ($service) {
+ 'qdrant' => config('search.qdrant.host', 'localhost').':'.config('search.qdrant.port', 6333),
+ 'redis' => config('database.redis.default.host', '127.0.0.1').':'.config('database.redis.default.port', 6380),
+ 'embeddings' => config('search.qdrant.embedding_server', 'http://localhost:8001'),
+ 'ollama' => config('search.ollama.host', 'localhost').':'.config('search.ollama.port', 11434),
+ default => 'unknown',
+ };
+ }
+
+ private function checkQdrant(): bool
+ {
+ $host = config('search.qdrant.host', 'localhost');
+ $port = config('search.qdrant.port', 6333);
+
+ return $this->httpCheck("http://{$host}:{$port}/healthz");
+ }
+
+ private function checkRedis(): bool
+ {
+ if (! extension_loaded('redis')) {
+ return false;
+ }
+
+ try {
+ $redis = new Redis;
+ $host = config('database.redis.default.host', '127.0.0.1');
+ $port = (int) config('database.redis.default.port', 6380);
+
+ return $redis->connect($host, $port, self::TIMEOUT_SECONDS);
+ } catch (\Exception) {
+ return false;
+ }
+ }
+
+ private function checkEmbeddings(): bool
+ {
+ $server = config('search.qdrant.embedding_server', 'http://localhost:8001');
+
+ return $this->httpCheck("{$server}/health");
+ }
+
+ private function checkOllama(): bool
+ {
+ $host = config('search.ollama.host', 'localhost');
+ $port = config('search.ollama.port', 11434);
+
+ return $this->httpCheck("http://{$host}:{$port}/api/tags");
+ }
+
+ private function httpCheck(string $url): bool
+ {
+ $context = stream_context_create([
+ 'http' => [
+ 'timeout' => self::TIMEOUT_SECONDS,
+ 'method' => 'GET',
+ ],
+ ]);
+
+ return @file_get_contents($url, false, $context) !== false;
+ }
+}
diff --git a/tests/Feature/AppServiceProviderTest.php b/tests/Feature/AppServiceProviderTest.php
index 0ff56ef..a32a654 100644
--- a/tests/Feature/AppServiceProviderTest.php
+++ b/tests/Feature/AppServiceProviderTest.php
@@ -3,7 +3,9 @@
declare(strict_types=1);
use App\Contracts\EmbeddingServiceInterface;
+use App\Contracts\HealthCheckInterface;
use App\Services\EmbeddingService;
+use App\Services\HealthCheckService;
use App\Services\KnowledgePathService;
use App\Services\QdrantService;
use App\Services\RuntimeEnvironment;
@@ -88,6 +90,12 @@
expect($service)->toBeInstanceOf(QdrantService::class);
});
+ it('registers HealthCheckService', function (): void {
+ $service = app(HealthCheckInterface::class);
+
+ expect($service)->toBeInstanceOf(HealthCheckService::class);
+ });
+
it('uses custom embedding server configuration for qdrant provider', function (): void {
config([
'search.embedding_provider' => 'qdrant',
diff --git a/tests/Feature/Commands/Service/DownCommandTest.php b/tests/Feature/Commands/Service/DownCommandTest.php
new file mode 100644
index 0000000..a4dc372
--- /dev/null
+++ b/tests/Feature/Commands/Service/DownCommandTest.php
@@ -0,0 +1,112 @@
+ Process::result(output: 'OK', exitCode: 0),
+ ]);
+ });
+
+ describe('configuration file validation', function () {
+ it('fails when local docker-compose file does not exist', function () {
+ $tempDir = sys_get_temp_dir().'/know-test-'.uniqid();
+ mkdir($tempDir, 0755, true);
+ $this->app->setBasePath($tempDir);
+
+ try {
+ $this->artisan('service:down')
+ ->assertFailed();
+ } finally {
+ rmdir($tempDir);
+ }
+ });
+
+ it('fails when odin docker-compose file does not exist', function () {
+ $tempDir = sys_get_temp_dir().'/know-test-'.uniqid();
+ mkdir($tempDir, 0755, true);
+ $this->app->setBasePath($tempDir);
+
+ try {
+ $this->artisan('service:down', ['--odin' => true])
+ ->assertFailed();
+ } finally {
+ rmdir($tempDir);
+ }
+ });
+ });
+
+ describe('docker compose execution', function () {
+ it('runs docker compose down successfully', function () {
+ $this->artisan('service:down')
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker', $process->command)
+ && in_array('down', $process->command));
+ });
+
+ it('runs docker compose down with volumes flag when forced', function () {
+ $this->artisan('service:down', ['--volumes' => true, '--force' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker', $process->command)
+ && in_array('-v', $process->command));
+ });
+
+ it('uses odin compose file when odin flag is set', function () {
+ $this->artisan('service:down', ['--odin' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command));
+ });
+
+ it('returns failure when docker compose fails', function () {
+ Process::fake([
+ '*docker*' => Process::result(
+ errorOutput: 'Docker daemon not running',
+ exitCode: 1,
+ ),
+ ]);
+
+ $this->artisan('service:down')
+ ->assertFailed();
+ });
+
+ it('combines odin and volumes flags correctly when forced', function () {
+ $this->artisan('service:down', ['--odin' => true, '--volumes' => true, '--force' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)
+ && in_array('-v', $process->command));
+ });
+ });
+
+ describe('command signature', function () {
+ it('has correct command signature', function () {
+ $command = new \App\Commands\Service\DownCommand;
+ $reflection = new ReflectionClass($command);
+ $signatureProperty = $reflection->getProperty('signature');
+ $signatureProperty->setAccessible(true);
+ $signature = $signatureProperty->getValue($command);
+
+ expect($signature)->toContain('service:down');
+ expect($signature)->toContain('--volumes');
+ expect($signature)->toContain('--odin');
+ expect($signature)->toContain('--force');
+ });
+
+ it('has correct description', function () {
+ $command = new \App\Commands\Service\DownCommand;
+ $reflection = new ReflectionClass($command);
+ $descProperty = $reflection->getProperty('description');
+ $descProperty->setAccessible(true);
+ $description = $descProperty->getValue($command);
+
+ expect($description)->toBe('Stop knowledge services');
+ });
+ });
+});
diff --git a/tests/Feature/Commands/Service/LogsCommandTest.php b/tests/Feature/Commands/Service/LogsCommandTest.php
new file mode 100644
index 0000000..43d8217
--- /dev/null
+++ b/tests/Feature/Commands/Service/LogsCommandTest.php
@@ -0,0 +1,148 @@
+ Process::result(output: 'Logs output...', exitCode: 0),
+ ]);
+ });
+
+ describe('configuration file validation', function () {
+ it('fails when local docker-compose file does not exist', function () {
+ $tempDir = sys_get_temp_dir().'/know-test-'.uniqid();
+ mkdir($tempDir, 0755, true);
+ $this->app->setBasePath($tempDir);
+
+ try {
+ $this->artisan('service:logs', ['service' => 'qdrant'])
+ ->assertFailed();
+ } finally {
+ rmdir($tempDir);
+ }
+ });
+
+ it('fails when odin docker-compose file does not exist', function () {
+ $tempDir = sys_get_temp_dir().'/know-test-'.uniqid();
+ mkdir($tempDir, 0755, true);
+ $this->app->setBasePath($tempDir);
+
+ try {
+ $this->artisan('service:logs', [
+ 'service' => 'qdrant',
+ '--odin' => true,
+ ])
+ ->assertFailed();
+ } finally {
+ rmdir($tempDir);
+ }
+ });
+ });
+
+ describe('docker compose execution', function () {
+ it('runs docker compose logs for specific service', function () {
+ $this->artisan('service:logs', ['service' => 'qdrant'])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker', $process->command)
+ && in_array('logs', $process->command)
+ && in_array('qdrant', $process->command));
+ });
+
+ it('runs docker compose logs with follow flag', function () {
+ $this->artisan('service:logs', ['service' => 'qdrant', '--follow' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker', $process->command)
+ && in_array('-f', $process->command)
+ && in_array('logs', $process->command));
+ });
+
+ it('runs docker compose logs with custom tail count', function () {
+ $this->artisan('service:logs', ['service' => 'redis', '--tail' => 100])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('--tail=100', $process->command));
+ });
+
+ it('uses odin compose file when odin flag is set', function () {
+ $this->artisan('service:logs', ['service' => 'qdrant', '--odin' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command));
+ });
+
+ it('returns exit code from docker compose', function () {
+ Process::fake([
+ '*docker*' => Process::result(
+ errorOutput: 'Service not found',
+ exitCode: 1,
+ ),
+ ]);
+
+ $this->artisan('service:logs', ['service' => 'nonexistent'])
+ ->assertExitCode(1);
+ });
+
+ it('combines follow and tail flags correctly', function () {
+ $this->artisan('service:logs', [
+ 'service' => 'embeddings',
+ '--follow' => true,
+ '--tail' => 200,
+ ])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('-f', $process->command)
+ && in_array('--tail=200', $process->command));
+ });
+
+ it('skips prompt when following without service', function () {
+ // When --follow is specified without a service, it doesn't prompt
+ $this->artisan('service:logs', ['--follow' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('-f', $process->command)
+ && in_array('logs', $process->command));
+ });
+ });
+
+ describe('command signature', function () {
+ it('has correct command signature', function () {
+ $command = new \App\Commands\Service\LogsCommand;
+ $reflection = new ReflectionClass($command);
+ $signatureProperty = $reflection->getProperty('signature');
+ $signatureProperty->setAccessible(true);
+ $signature = $signatureProperty->getValue($command);
+
+ expect($signature)->toContain('service:logs');
+ expect($signature)->toContain('{service?');
+ expect($signature)->toContain('--f|follow');
+ expect($signature)->toContain('--tail=50');
+ expect($signature)->toContain('--odin');
+ });
+
+ it('has correct description', function () {
+ $command = new \App\Commands\Service\LogsCommand;
+ $reflection = new ReflectionClass($command);
+ $descProperty = $reflection->getProperty('description');
+ $descProperty->setAccessible(true);
+ $description = $descProperty->getValue($command);
+
+ expect($description)->toBe('View service logs');
+ });
+
+ it('makes service argument optional', function () {
+ $command = new \App\Commands\Service\LogsCommand;
+ $reflection = new ReflectionClass($command);
+ $signatureProperty = $reflection->getProperty('signature');
+ $signatureProperty->setAccessible(true);
+ $signature = $signatureProperty->getValue($command);
+
+ expect($signature)->toContain('{service?');
+ });
+ });
+});
diff --git a/tests/Feature/Commands/Service/StatusCommandTest.php b/tests/Feature/Commands/Service/StatusCommandTest.php
new file mode 100644
index 0000000..b741024
--- /dev/null
+++ b/tests/Feature/Commands/Service/StatusCommandTest.php
@@ -0,0 +1,248 @@
+ Process::result(output: '', exitCode: 0),
+ ]);
+
+ // Create a mock that returns fast, predictable responses
+ $healthCheck = mock(HealthCheckInterface::class);
+
+ $healthCheck->shouldReceive('checkAll')
+ ->andReturn([
+ ['name' => 'Qdrant', 'healthy' => true, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database'],
+ ['name' => 'Redis', 'healthy' => false, 'endpoint' => '127.0.0.1:6380', 'type' => 'Cache'],
+ ['name' => 'Embeddings', 'healthy' => true, 'endpoint' => 'http://localhost:8001', 'type' => 'ML Service'],
+ ['name' => 'Ollama', 'healthy' => false, 'endpoint' => 'localhost:11434', 'type' => 'LLM Engine'],
+ ]);
+
+ $healthCheck->shouldReceive('getServices')
+ ->andReturn(['qdrant', 'redis', 'embeddings', 'ollama']);
+
+ $healthCheck->shouldReceive('check')
+ ->with('qdrant')
+ ->andReturn(['name' => 'Qdrant', 'healthy' => true, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database']);
+
+ $healthCheck->shouldReceive('check')
+ ->with('redis')
+ ->andReturn(['name' => 'Redis', 'healthy' => false, 'endpoint' => '127.0.0.1:6380', 'type' => 'Cache']);
+
+ app()->instance(HealthCheckInterface::class, $healthCheck);
+});
+
+afterEach(function () {
+ Mockery::close();
+});
+
+describe('service:status command', function () {
+ describe('successful operations', function () {
+ it('always returns success exit code', function () {
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+
+ it('displays service status dashboard', function () {
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+
+ it('shows environment information', function () {
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+
+ it('shows odin environment with --odin flag', function () {
+ $this->artisan('service:status', ['--odin' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command));
+ });
+ });
+
+ describe('health checks via injected service', function () {
+ it('uses HealthCheckInterface for all service checks', function () {
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+
+ it('displays healthy services correctly', function () {
+ $healthCheck = mock(HealthCheckInterface::class);
+ $healthCheck->shouldReceive('checkAll')
+ ->andReturn([
+ ['name' => 'Qdrant', 'healthy' => true, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database'],
+ ['name' => 'Redis', 'healthy' => true, 'endpoint' => 'localhost:6380', 'type' => 'Cache'],
+ ]);
+
+ app()->instance(HealthCheckInterface::class, $healthCheck);
+
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+
+ it('displays unhealthy services correctly', function () {
+ $healthCheck = mock(HealthCheckInterface::class);
+ $healthCheck->shouldReceive('checkAll')
+ ->andReturn([
+ ['name' => 'Qdrant', 'healthy' => false, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database'],
+ ]);
+
+ app()->instance(HealthCheckInterface::class, $healthCheck);
+
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+
+ it('handles partial outage scenario', function () {
+ $healthCheck = mock(HealthCheckInterface::class);
+ $healthCheck->shouldReceive('checkAll')
+ ->andReturn([
+ ['name' => 'Qdrant', 'healthy' => true, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database'],
+ ['name' => 'Redis', 'healthy' => false, 'endpoint' => 'localhost:6380', 'type' => 'Cache'],
+ ]);
+
+ app()->instance(HealthCheckInterface::class, $healthCheck);
+
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+
+ it('handles major outage scenario', function () {
+ $healthCheck = mock(HealthCheckInterface::class);
+ $healthCheck->shouldReceive('checkAll')
+ ->andReturn([
+ ['name' => 'Qdrant', 'healthy' => false, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database'],
+ ['name' => 'Redis', 'healthy' => false, 'endpoint' => 'localhost:6380', 'type' => 'Cache'],
+ ]);
+
+ app()->instance(HealthCheckInterface::class, $healthCheck);
+
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+ });
+
+ describe('container status via process', function () {
+ it('runs docker compose ps to get container status', function () {
+ Process::fake([
+ '*docker*' => Process::result(
+ output: '{"Service":"qdrant","State":"running"}',
+ exitCode: 0,
+ ),
+ ]);
+
+ $this->artisan('service:status')
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker', $process->command)
+ && in_array('ps', $process->command));
+ });
+
+ it('uses odin compose file with --odin flag', function () {
+ $this->artisan('service:status', ['--odin' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command));
+ });
+
+ it('handles docker compose failure gracefully', function () {
+ Process::fake([
+ '*docker*' => Process::result(
+ errorOutput: 'Docker daemon not running',
+ exitCode: 1,
+ ),
+ ]);
+
+ $this->artisan('service:status')
+ ->assertSuccessful(); // Command still succeeds, just no container info
+ });
+
+ it('displays running containers', function () {
+ Process::fake([
+ '*docker*' => Process::result(
+ output: '{"Service":"qdrant","State":"running"}'."\n".'{"Service":"redis","State":"running"}',
+ exitCode: 0,
+ ),
+ ]);
+
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+
+ it('displays container states correctly', function () {
+ Process::fake([
+ '*docker*' => Process::result(
+ output: '{"Service":"qdrant","State":"running"}'."\n".'{"Service":"redis","State":"exited"}'."\n".'{"Service":"ollama","State":"paused"}',
+ exitCode: 0,
+ ),
+ ]);
+
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+ });
+
+ describe('command signature', function () {
+ it('has correct command signature', function () {
+ $command = app(\App\Commands\Service\StatusCommand::class);
+ $reflection = new ReflectionClass($command);
+ $signatureProperty = $reflection->getProperty('signature');
+ $signatureProperty->setAccessible(true);
+ $signature = $signatureProperty->getValue($command);
+
+ expect($signature)->toContain('service:status');
+ expect($signature)->toContain('--odin');
+ });
+
+ it('has correct description', function () {
+ $command = app(\App\Commands\Service\StatusCommand::class);
+ $reflection = new ReflectionClass($command);
+ $descProperty = $reflection->getProperty('description');
+ $descProperty->setAccessible(true);
+ $description = $descProperty->getValue($command);
+
+ expect($description)->toBe('Check service health status');
+ });
+ });
+
+ describe('output formatting', function () {
+ it('displays service status sections', function () {
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+
+ it('displays tip about viewing logs', function () {
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+
+ it('shows service type for each service', function () {
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+ });
+
+ describe('process execution', function () {
+ it('is instance of Laravel Zero Command', function () {
+ $command = app(\App\Commands\Service\StatusCommand::class);
+
+ expect($command)->toBeInstanceOf(\LaravelZero\Framework\Commands\Command::class);
+ });
+ });
+
+ describe('container status handling', function () {
+ it('handles empty container list gracefully', function () {
+ Process::fake([
+ '*docker*' => Process::result(output: '', exitCode: 0),
+ ]);
+
+ $this->artisan('service:status')
+ ->assertSuccessful();
+ });
+ });
+});
diff --git a/tests/Feature/Commands/Service/UpCommandTest.php b/tests/Feature/Commands/Service/UpCommandTest.php
new file mode 100644
index 0000000..1751d11
--- /dev/null
+++ b/tests/Feature/Commands/Service/UpCommandTest.php
@@ -0,0 +1,111 @@
+ Process::result(output: 'OK', exitCode: 0),
+ ]);
+ });
+
+ describe('configuration file validation', function () {
+ it('fails when local docker-compose file does not exist', function () {
+ $tempDir = sys_get_temp_dir().'/know-test-'.uniqid();
+ mkdir($tempDir, 0755, true);
+ $this->app->setBasePath($tempDir);
+
+ try {
+ $this->artisan('service:up')
+ ->assertFailed();
+ } finally {
+ rmdir($tempDir);
+ }
+ });
+
+ it('fails when odin docker-compose file does not exist', function () {
+ $tempDir = sys_get_temp_dir().'/know-test-'.uniqid();
+ mkdir($tempDir, 0755, true);
+ $this->app->setBasePath($tempDir);
+
+ try {
+ $this->artisan('service:up', ['--odin' => true])
+ ->assertFailed();
+ } finally {
+ rmdir($tempDir);
+ }
+ });
+ });
+
+ describe('docker compose execution', function () {
+ it('runs docker compose up successfully', function () {
+ $this->artisan('service:up')
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker', $process->command)
+ && in_array('up', $process->command));
+ });
+
+ it('runs docker compose up with detach flag', function () {
+ $this->artisan('service:up', ['--detach' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker', $process->command)
+ && in_array('-d', $process->command));
+ });
+
+ it('uses odin compose file when odin flag is set', function () {
+ $this->artisan('service:up', ['--odin' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command));
+ });
+
+ it('returns failure when docker compose fails', function () {
+ Process::fake([
+ '*docker*' => Process::result(
+ errorOutput: 'Docker daemon not running',
+ exitCode: 1,
+ ),
+ ]);
+
+ $this->artisan('service:up')
+ ->assertFailed();
+ });
+
+ it('combines odin and detach flags correctly', function () {
+ $this->artisan('service:up', ['--odin' => true, '--detach' => true])
+ ->assertSuccessful();
+
+ Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)
+ && in_array('-d', $process->command));
+ });
+ });
+
+ describe('command signature', function () {
+ it('has correct command signature', function () {
+ $command = new \App\Commands\Service\UpCommand;
+ $reflection = new ReflectionClass($command);
+ $signatureProperty = $reflection->getProperty('signature');
+ $signatureProperty->setAccessible(true);
+ $signature = $signatureProperty->getValue($command);
+
+ expect($signature)->toContain('service:up');
+ expect($signature)->toContain('--d|detach');
+ expect($signature)->toContain('--odin');
+ });
+
+ it('has correct description', function () {
+ $command = new \App\Commands\Service\UpCommand;
+ $reflection = new ReflectionClass($command);
+ $descProperty = $reflection->getProperty('description');
+ $descProperty->setAccessible(true);
+ $description = $descProperty->getValue($command);
+
+ expect($description)->toContain('Start knowledge services');
+ });
+ });
+});
diff --git a/tests/Unit/Services/HealthCheckServiceTest.php b/tests/Unit/Services/HealthCheckServiceTest.php
new file mode 100644
index 0000000..577c941
--- /dev/null
+++ b/tests/Unit/Services/HealthCheckServiceTest.php
@@ -0,0 +1,137 @@
+getServices();
+
+ expect($services)->toBe(['qdrant', 'redis', 'embeddings', 'ollama']);
+ });
+
+ it('returns unhealthy status for unknown service', function () {
+ $service = new HealthCheckService;
+
+ $result = $service->check('nonexistent');
+
+ expect($result)->toBe([
+ 'name' => 'nonexistent',
+ 'healthy' => false,
+ 'endpoint' => 'unknown',
+ 'type' => 'Unknown',
+ ]);
+ });
+
+ it('checks all services and returns array', function () {
+ $service = new HealthCheckService;
+
+ $results = $service->checkAll();
+
+ expect($results)->toBeArray();
+ expect($results)->toHaveCount(4);
+
+ foreach ($results as $result) {
+ expect($result)->toHaveKeys(['name', 'healthy', 'endpoint', 'type']);
+ expect($result['healthy'])->toBeBool();
+ expect($result['name'])->toBeString();
+ expect($result['endpoint'])->toBeString();
+ expect($result['type'])->toBeString();
+ }
+ });
+
+ it('returns correct structure for qdrant check', function () {
+ $service = new HealthCheckService;
+
+ $result = $service->check('qdrant');
+
+ expect($result['name'])->toBe('Qdrant');
+ expect($result['type'])->toBe('Vector Database');
+ expect($result['healthy'])->toBeBool();
+ expect($result['endpoint'])->toBeString();
+ });
+
+ it('returns correct structure for redis check', function () {
+ $service = new HealthCheckService;
+
+ $result = $service->check('redis');
+
+ expect($result['name'])->toBe('Redis');
+ expect($result['type'])->toBe('Cache');
+ expect($result['healthy'])->toBeBool();
+ expect($result['endpoint'])->toBeString();
+ });
+
+ it('returns correct structure for embeddings check', function () {
+ $service = new HealthCheckService;
+
+ $result = $service->check('embeddings');
+
+ expect($result['name'])->toBe('Embeddings');
+ expect($result['type'])->toBe('ML Service');
+ expect($result['healthy'])->toBeBool();
+ expect($result['endpoint'])->toBeString();
+ });
+
+ it('returns correct structure for ollama check', function () {
+ $service = new HealthCheckService;
+
+ $result = $service->check('ollama');
+
+ expect($result['name'])->toBe('Ollama');
+ expect($result['type'])->toBe('LLM Engine');
+ expect($result['healthy'])->toBeBool();
+ expect($result['endpoint'])->toBeString();
+ });
+
+ it('uses config values for qdrant endpoint', function () {
+ config(['search.qdrant.host' => 'test-host']);
+ config(['search.qdrant.port' => 9999]);
+
+ $service = new HealthCheckService;
+ $result = $service->check('qdrant');
+
+ expect($result['endpoint'])->toBe('test-host:9999');
+ });
+
+ it('uses config values for redis endpoint', function () {
+ config(['database.redis.default.host' => 'redis-host']);
+ config(['database.redis.default.port' => 6380]);
+
+ $service = new HealthCheckService;
+ $result = $service->check('redis');
+
+ expect($result['endpoint'])->toBe('redis-host:6380');
+ });
+
+ it('uses config values for embeddings endpoint', function () {
+ config(['search.qdrant.embedding_server' => 'http://embed-host:8001']);
+
+ $service = new HealthCheckService;
+ $result = $service->check('embeddings');
+
+ expect($result['endpoint'])->toBe('http://embed-host:8001');
+ });
+
+ it('uses config values for ollama endpoint', function () {
+ config(['search.ollama.host' => 'ollama-host']);
+ config(['search.ollama.port' => 11434]);
+
+ $service = new HealthCheckService;
+ $result = $service->check('ollama');
+
+ expect($result['endpoint'])->toBe('ollama-host:11434');
+ });
+
+ it('checkAll returns same count as getServices', function () {
+ $service = new HealthCheckService;
+
+ $services = $service->getServices();
+ $results = $service->checkAll();
+
+ expect(count($results))->toBe(count($services));
+ });
+});