diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index 5c2d9c7..d86a183 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -9,15 +9,16 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Str; -use Laravel\Boost\Install\ApplicationDetector; +use Laravel\Boost\Contracts\Agent; +use Laravel\Boost\Contracts\Ide; use Laravel\Boost\Install\Cli\DisplayHelper; +use Laravel\Boost\Install\CodeEnvironmentsDetector; use Laravel\Boost\Install\GuidelineComposer; use Laravel\Boost\Install\GuidelineConfig; use Laravel\Boost\Install\GuidelineWriter; use Laravel\Boost\Install\Herd; use Laravel\Prompts\Concerns\Colors; use Laravel\Prompts\Terminal; -use Laravel\Roster\Roster; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Finder\Finder; @@ -25,72 +26,65 @@ use function Laravel\Prompts\multiselect; use function Laravel\Prompts\note; use function Laravel\Prompts\select; -use function Laravel\Prompts\text; #[AsCommand('boost:install', 'Install Laravel Boost')] class InstallCommand extends Command { use Colors; - private ApplicationDetector $appDetector; + private CodeEnvironmentsDetector $codeEnvironmentsDetector; private Herd $herd; - private Roster $roster; - private Terminal $terminal; - /** @var Collection */ - private Collection $agentsToInstallTo; + /** @var Collection */ + private Collection $selectedTargetAgents; - /** @var Collection */ - private Collection $idesToInstallTo; + /** @var Collection */ + private Collection $selectedTargetIdes; - private Collection $boostToInstall; + /** @var Collection */ + private Collection $selectedBoostFeatures; private string $projectName; - private string $projectPurpose = ''; - /** @var array */ - private array $installedIdes = []; + private array $systemInstalledCodeEnvironments = []; - private array $detectedProjectIdes = []; + private array $projectInstalledCodeEnvironments = []; private bool $enforceTests = true; - private array $boostToolsToDisable = []; - - private array $detectedProjectAgents = []; + private array $projectInstalledAgents = []; private string $greenTick; private string $redCross; - public function handle(ApplicationDetector $appDetector, Herd $herd, Roster $roster, Terminal $terminal): void + public function handle(CodeEnvironmentsDetector $codeEnvironmentsDetector, Herd $herd, Terminal $terminal): void { - $this->bootstrapBoost($appDetector, $herd, $roster, $terminal); + $this->bootstrap($codeEnvironmentsDetector, $herd, $terminal); $this->displayBoostHeader(); - $this->detect(); - $this->query(); + $this->discoverEnvironment(); + $this->collectInstallationPreferences(); $this->enact(); $this->outro(); } - private function bootstrapBoost(ApplicationDetector $appDetector, Herd $herd, Roster $roster, Terminal $terminal): void + private function bootstrap(CodeEnvironmentsDetector $codeEnvironmentsDetector, Herd $herd, Terminal $terminal): void { - $this->appDetector = $appDetector; + $this->codeEnvironmentsDetector = $codeEnvironmentsDetector; $this->herd = $herd; - $this->roster = $roster; $this->terminal = $terminal; $this->terminal->initDimensions(); $this->greenTick = $this->green('✓'); $this->redCross = $this->red('✗'); - $this->agentsToInstallTo = collect(); - $this->idesToInstallTo = collect(); + $this->selectedTargetAgents = collect(); + $this->selectedTargetIdes = collect(); $this->projectName = basename(base_path()); } @@ -115,56 +109,34 @@ private function boostLogo(): string HEADER; } - private function detect() + private function discoverEnvironment(): void { - $this->installedIdes = $this->detectInstalledIdes(); - $this->detectedProjectIdes = $this->detectIdesUsedInProject(); - $this->detectedProjectAgents = $this->detectProjectAgents(); + $this->systemInstalledCodeEnvironments = $this->codeEnvironmentsDetector->discoverSystemInstalledCodeEnvironments(); + $this->projectInstalledCodeEnvironments = $this->codeEnvironmentsDetector->discoverProjectInstalledCodeEnvironments(base_path()); + $this->projectInstalledAgents = $this->discoverProjectAgents(); } - private function query() + private function collectInstallationPreferences(): void { - // Which parts of boost should we install - $this->boostToInstall = $this->boostToInstall(); - // $this->boostToolsToDisable = $this->boostToolsToDisable(); // Not useful to start - - // $this->projectPurpose = $this->projectPurpose(); - $this->enforceTests = $this->shouldEnforceTests(ask: false); - - $this->idesToInstallTo = $this->idesToInstallTo(); // To add boost:mcp to the correct file - $this->agentsToInstallTo = $this->agentsToInstallTo(); // AI Guidelines, which file do they go, are they separated, or all in one file? + $this->selectedBoostFeatures = $this->selectBoostFeatures(); + $this->enforceTests = $this->determineTestEnforcement(ask: false); + $this->selectedTargetIdes = $this->selectTargetIdes(); + $this->selectedTargetAgents = $this->selectTargetAgents(); } private function enact(): void { - if ($this->installingGuidelines() && ! empty($this->agentsToInstallTo)) { + if ($this->installingGuidelines() && ! empty($this->selectedTargetAgents)) { $this->enactGuidelines(); } usleep(750000); - if (($this->installingMcp() || $this->installingHerdMcp()) && $this->idesToInstallTo->isNotEmpty()) { + if (($this->installingMcp() || $this->installingHerdMcp()) && $this->selectedTargetIdes->isNotEmpty()) { $this->enactMcpServers(); } } - /** - * Which IDEs are installed on this developer's machine? - */ - private function detectInstalledIdes(): array - { - return $this->appDetector->detectInstalled(); - } - - /** - * Specifically want to detect what's in use in _this_ project. - * Just because they have claude code installed doesn't mean they're using it. - */ - private function detectIdesUsedInProject(): array - { - return $this->appDetector->detectInProject(base_path()); - } - private function discoverTools(): array { $tools = []; @@ -193,9 +165,9 @@ private function outro(): void // Build install data - CSV format with type prefixes $data = []; - $ideNames = $this->idesToInstallTo->map(fn ($ide) => 'i:'.class_basename($ide))->toArray(); - $agentNames = $this->agentsToInstallTo->map(fn ($agent) => 'a:'.class_basename($agent))->toArray(); - $boostFeatures = $this->boostToInstall->map(fn ($feature) => 'b:'.$feature)->toArray(); + $ideNames = $this->selectedTargetIdes->map(fn ($ide) => 'i:'.class_basename($ide))->toArray(); + $agentNames = $this->selectedTargetAgents->map(fn ($agent) => 'a:'.class_basename($agent))->toArray(); + $boostFeatures = $this->selectedBoostFeatures->map(fn ($feature) => 'b:'.$feature)->toArray(); // Guidelines installed (prefix: g) $guidelines = []; @@ -227,45 +199,35 @@ private function hyperlink(string $label, string $url): string return "\033]8;;{$url}\007{$label}\033]8;;\033\\"; } - protected function projectPurpose(): string - { - return text( - label: sprintf('What does the %s project do? (optional)', $this->projectName), - placeholder: 'i.e. SaaS platform selling concert tickets, integrates with Stripe and Twilio, lots of CS using Nova backend', - default: config('boost.project_purpose') ?? '', - hint: 'This helps guides AI. How would you explain it to a new developer?' - ); - } - /** * We shouldn't add an AI guideline enforcing tests if they don't have a basic test setup. - * This would likely just create headaches for them, or be a waste of time as they + * This would likely just create headaches for them or be a waste of time as they * won't have the CI setup to make use of them anyway, so we're just wasting their * tokens/money by enforcing them. */ - protected function shouldEnforceTests(bool $ask = true): bool + protected function determineTestEnforcement(bool $ask = true): bool { - $enforce = Finder::create() + $hasMinimumTests = Finder::create() ->in(base_path('tests')) ->files() ->name('*.php') ->count() > 6; - if ($enforce === false && $ask === true) { - $enforce = select( + if (! $hasMinimumTests && $ask) { + $hasMinimumTests = select( label: 'Should AI always create tests?', options: ['Yes', 'No'], default: 'Yes' ) === 'Yes'; } - return $enforce; + return $hasMinimumTests; } /** * @return Collection */ - protected function boostToInstall(): Collection + private function selectBoostFeatures(): Collection { $defaultToInstallOptions = ['mcp_server', 'ai_guidelines']; $toInstallOptions = [ @@ -303,17 +265,16 @@ protected function boostToolsToDisable(): array /** * @return array */ - protected function detectProjectAgents(): array + private function discoverProjectAgents(): array { $agents = []; - $projectAgents = $this->appDetector->detectInProject(base_path()); + $projectAgents = $this->codeEnvironmentsDetector->discoverProjectInstalledCodeEnvironments(base_path()); // Map IDE detections to their corresponding agents $ideToAgentMap = [ 'phpstorm' => 'junie', 'claudecode' => 'claudecode', 'cursor' => 'cursor', - 'windsurf' => 'windsurf', 'copilot' => 'copilot', ]; @@ -324,7 +285,7 @@ protected function detectProjectAgents(): array } // Also check installed IDEs that might not have project files yet - foreach ($this->installedIdes as $ide) { + foreach ($this->systemInstalledCodeEnvironments as $ide) { if (isset($ideToAgentMap[$ide]) && ! in_array($ideToAgentMap[$ide], $agents)) { $agents[] = $ideToAgentMap[$ide]; } @@ -334,9 +295,9 @@ protected function detectProjectAgents(): array } /** - * @return Collection + * @return Collection */ - protected function idesToInstallTo(): Collection + private function selectTargetIdes(): Collection { $ides = []; if (! $this->installingMcp() && ! $this->installingHerdMcp()) { @@ -356,7 +317,7 @@ protected function idesToInstallTo(): Collection if (class_exists($className)) { $reflection = new \ReflectionClass($className); - if ($reflection->implementsInterface(\Laravel\Boost\Contracts\Ide::class) && ! $reflection->isAbstract()) { + if ($reflection->implementsInterface(Ide::class) && ! $reflection->isAbstract()) { $ides[$className] = Str::headline($ideFile->getBasename('.php')); } } @@ -366,7 +327,7 @@ protected function idesToInstallTo(): Collection // Map detected IDE keys to class names $detectedClasses = []; - foreach ($this->detectedProjectIdes as $ideKey) { + foreach ($this->projectInstalledCodeEnvironments as $ideKey) { foreach ($ides as $className => $displayName) { if (strtolower($ideKey) === strtolower(class_basename($className))) { $detectedClasses[] = $className; @@ -388,9 +349,9 @@ protected function idesToInstallTo(): Collection } /** - * @return Collection + * @return Collection */ - protected function agentsToInstallTo(): Collection + private function selectTargetAgents(): Collection { $agents = []; if (! $this->installingGuidelines()) { @@ -410,7 +371,7 @@ protected function agentsToInstallTo(): Collection if (class_exists($className)) { $reflection = new \ReflectionClass($className); - if ($reflection->implementsInterface(\Laravel\Boost\Contracts\Agent::class)) { + if ($reflection->implementsInterface(Agent::class)) { $agents[$className] = Str::headline($agentFile->getBasename('.php')); } } @@ -418,15 +379,9 @@ protected function agentsToInstallTo(): Collection ksort($agents); - // Filter agents to only show those that are installed (for Windsurf) - $filteredAgents = $agents; - if (! in_array('windsurf', $this->installedIdes) && ! in_array('windsurf', $this->detectedProjectAgents)) { - unset($filteredAgents['Laravel\\Boost\\Install\\Agents\\Windsurf']); - } - // Map detected agent keys to class names $detectedClasses = []; - foreach ($this->detectedProjectAgents as $agentKey) { + foreach ($this->projectInstalledAgents as $agentKey) { foreach ($agents as $className => $displayName) { if (strtolower($agentKey) === strtolower(class_basename($className))) { $detectedClasses[] = $className; @@ -437,7 +392,7 @@ protected function agentsToInstallTo(): Collection $selectedAgentClasses = collect(multiselect( label: sprintf('Which agents need AI guidelines for %s?', $this->projectName), - options: $filteredAgents, + options: $agents, default: $detectedClasses, scroll: 4, ))->sort(); @@ -451,7 +406,7 @@ protected function enactGuidelines(): void return; } - if ($this->agentsToInstallTo->isEmpty()) { + if ($this->selectedTargetAgents->isEmpty()) { $this->info('No agents selected for guideline installation.'); return; @@ -475,8 +430,8 @@ protected function enactGuidelines(): void $failed = []; $composedAiGuidelines = $composer->compose(); - $longestAgentName = max(1, ...$this->agentsToInstallTo->map(fn ($agent) => Str::length(class_basename($agent)))->toArray()); - foreach ($this->agentsToInstallTo as $agent) { + $longestAgentName = max(1, ...$this->selectedTargetAgents->map(fn ($agent) => Str::length(class_basename($agent)))->toArray()); + foreach ($this->selectedTargetAgents as $agent) { $agentName = class_basename($agent); $displayAgentName = str_pad($agentName, $longestAgentName, ' ', STR_PAD_RIGHT); $this->output->write(" {$displayAgentName}... "); @@ -507,22 +462,22 @@ protected function enactGuidelines(): void protected function installingGuidelines(): bool { - return $this->boostToInstall->contains('ai_guidelines'); + return $this->selectedBoostFeatures->contains('ai_guidelines'); } protected function installingStyleGuidelines(): bool { - return $this->boostToInstall->contains('style_guidelines'); + return $this->selectedBoostFeatures->contains('style_guidelines'); } protected function installingMcp(): bool { - return $this->boostToInstall->contains('mcp_server'); + return $this->selectedBoostFeatures->contains('mcp_server'); } protected function installingHerdMcp(): bool { - return $this->boostToInstall->contains('herd_mcp'); + return $this->selectedBoostFeatures->contains('herd_mcp'); } protected function publishAndUpdateConfig(): void @@ -580,9 +535,9 @@ protected function enactMcpServers(): void usleep(750000); $failed = []; - $longestIdeName = max(1, ...$this->idesToInstallTo->map(fn ($ide) => Str::length(class_basename($ide)))->toArray()); + $longestIdeName = max(1, ...$this->selectedTargetIdes->map(fn ($ide) => Str::length(class_basename($ide)))->toArray()); - foreach ($this->idesToInstallTo as $ide) { + foreach ($this->selectedTargetIdes as $ide) { $ideName = class_basename($ide); $ideDisplay = str_pad($ideName, $longestIdeName, ' ', STR_PAD_RIGHT); $this->output->write(" {$ideDisplay}... "); diff --git a/src/Install/ApplicationDetector.php b/src/Install/ApplicationDetector.php deleted file mode 100644 index b42e234..0000000 --- a/src/Install/ApplicationDetector.php +++ /dev/null @@ -1,323 +0,0 @@ ->>> - */ - protected array $detectionConfig = [ - 'darwin' => [ - 'phpstorm' => [ - 'paths' => ['/Applications/PhpStorm.app'], - 'type' => 'directory', - ], - 'cursor' => [ - 'paths' => ['/Applications/Cursor.app'], - 'type' => 'directory', - ], - 'zed' => [ - 'paths' => ['/Applications/Zed.app'], - 'type' => 'directory', - ], - 'vscode' => [ - 'paths' => ['/Applications/Visual Studio Code.app'], - 'type' => 'directory', - ], - 'windsurf' => [ - 'paths' => ['/Applications/Windsurf.app'], - 'type' => 'directory', - ], - 'claudecode' => [ - 'command' => 'which claude', - 'type' => 'command', - ], - ], - 'linux' => [ - 'phpstorm' => [ - 'paths' => [ - '/opt/phpstorm', - '/opt/PhpStorm*', - '/usr/local/bin/phpstorm', - '~/.local/share/JetBrains/Toolbox/apps/PhpStorm/ch-*', - ], - 'type' => 'directory', - ], - 'vscode' => [ - 'command' => 'which code', - 'type' => 'command', - ], - 'cursor' => [ - 'paths' => [ - '/opt/cursor', - '/usr/local/bin/cursor', - '~/.local/bin/cursor', - ], - 'type' => 'directory', - ], - 'windsurf' => [ - 'paths' => [ - '/opt/windsurf', - '/usr/local/bin/windsurf', - '~/.local/bin/windsurf', - ], - 'type' => 'directory', - ], - 'claudecode' => [ - 'command' => 'which claude', - 'type' => 'command', - ], - ], - 'windows' => [ - 'phpstorm' => [ - 'paths' => [ - '%ProgramFiles%\\JetBrains\\PhpStorm*', - '%LOCALAPPDATA%\\JetBrains\\Toolbox\\apps\\PhpStorm\\ch-*', - ], - 'type' => 'directory', - ], - 'vscode' => [ - 'paths' => [ - '%ProgramFiles%\\Microsoft VS Code', - '%LOCALAPPDATA%\\Programs\\Microsoft VS Code', - ], - 'type' => 'directory', - ], - 'cursor' => [ - 'paths' => [ - '%ProgramFiles%\\Cursor', - '%LOCALAPPDATA%\\Programs\\Cursor', - ], - 'type' => 'directory', - ], - 'windsurf' => [ - 'paths' => [ - '%ProgramFiles%\\Windsurf', - '%ProgramFiles(x86)%\\Windsurf', - '%LOCALAPPDATA%\\Programs\\Windsurf', - ], - 'type' => 'directory', - ], - 'claudecode' => [ - 'command' => 'where claude 2>nul', - 'type' => 'command', - ], - ], - ]; - - /** - * Project-specific detection patterns. - * - * @var array>> - */ - protected array $projectDetectionConfig = [ - 'phpstorm' => [ - 'paths' => ['.idea', '.junie'], - 'type' => 'directory', - ], - 'vscode' => [ - 'paths' => ['.vscode'], - 'type' => 'directory', - ], - 'cursor' => [ - 'paths' => ['.cursor'], - 'type' => 'directory', - ], - 'claudecode' => [ - 'paths' => ['.claude'], - 'files' => ['CLAUDE.md'], - 'type' => 'mixed', - ], - 'windsurf' => [ - 'files' => ['.windsurfrules.md'], - 'type' => 'file', - ], - 'copilot' => [ - 'files' => ['.github/copilot-instructions.md'], - 'type' => 'file', - ], - ]; - - /** - * Detect installed applications on the current platform. - * - * @return array - */ - public function detectInstalled(): array - { - $platform = $this->getPlatform(); - $detected = []; - - if (! isset($this->detectionConfig[$platform])) { - return []; - } - - foreach ($this->detectionConfig[$platform] as $app => $config) { - if ($this->isAppInstalled($config, $platform)) { - $detected[] = $app; - } - } - - return array_unique($detected); - } - - /** - * Detect applications used in the current project. - * - * @return array - */ - public function detectInProject(string $basePath): array - { - $detected = []; - - foreach ($this->projectDetectionConfig as $app => $config) { - if ($this->isAppUsedInProject($config, $basePath)) { - $detected[] = $app; - } - } - - return array_unique($detected); - } - - /** - * Check if an application is installed based on its configuration. - * - * @param array> $config - */ - protected function isAppInstalled(array $config, string $platform): bool - { - if ($config['type'] === 'command') { - return Process::run($config['command'])->successful(); - } - - if ($config['type'] === 'directory' && isset($config['paths'])) { - foreach ($config['paths'] as $path) { - $expandedPath = $this->expandPath($path, $platform); - - // Handle wildcards - if (strpos($expandedPath, '*') !== false) { - $matches = glob($expandedPath, GLOB_ONLYDIR); - if (! empty($matches)) { - return true; - } - } elseif (is_dir($expandedPath)) { - return true; - } - } - } - - return false; - } - - /** - * Check if an application is used in the current project. - * - * @param array> $config - */ - protected function isAppUsedInProject(array $config, string $basePath): bool - { - if ($config['type'] === 'directory' && isset($config['paths'])) { - foreach ($config['paths'] as $path) { - if (is_dir($basePath.DIRECTORY_SEPARATOR.$path)) { - return true; - } - } - } - - if ($config['type'] === 'file' && isset($config['files'])) { - foreach ($config['files'] as $file) { - if (file_exists($basePath.DIRECTORY_SEPARATOR.$file)) { - return true; - } - } - } - - if ($config['type'] === 'mixed') { - if (isset($config['paths'])) { - foreach ($config['paths'] as $path) { - if (is_dir($basePath.DIRECTORY_SEPARATOR.$path)) { - return true; - } - } - } - if (isset($config['files'])) { - foreach ($config['files'] as $file) { - if (file_exists($basePath.DIRECTORY_SEPARATOR.$file)) { - return true; - } - } - } - } - - return false; - } - - /** - * Expand environment variables and user home directory in paths. - */ - protected function expandPath(string $path, string $platform): string - { - if ($platform === 'windows') { - // Expand Windows environment variables - $path = preg_replace_callback('/%([^%]+)%/', function ($matches) { - return getenv($matches[1]) ?: $matches[0]; - }, $path); - } else { - // Expand Unix home directory - if (strpos($path, '~') === 0) { - $home = getenv('HOME'); - if ($home) { - $path = str_replace('~', $home, $path); - } - } - } - - return $path; - } - - /** - * Get the current platform identifier. - */ - protected function getPlatform(): string - { - return match (PHP_OS_FAMILY) { - 'Windows' => 'windows', - 'Darwin' => 'darwin', - default => 'linux', - }; - } - - /** - * Add custom detection configuration for an application. - * - * @param array>> $config - */ - public function addDetectionConfig(string $app, array $config, ?string $platform = null): void - { - if ($platform === null) { - // Add to all platforms - foreach (['darwin', 'linux', 'windows'] as $p) { - $this->detectionConfig[$p][$app] = $config[$p] ?? $config; - } - } else { - $this->detectionConfig[$platform][$app] = $config; - } - } - - /** - * Add custom project detection configuration for an application. - * - * @param array> $config - */ - public function addProjectDetectionConfig(string $app, array $config): void - { - $this->projectDetectionConfig[$app] = $config; - } -} diff --git a/src/Install/CodeEnvironment/ClaudeCode.php b/src/Install/CodeEnvironment/ClaudeCode.php new file mode 100644 index 0000000..cfd02af --- /dev/null +++ b/src/Install/CodeEnvironment/ClaudeCode.php @@ -0,0 +1,40 @@ + [ + 'command' => 'which claude', + ], + Platform::Windows => [ + 'command' => 'where claude 2>null', + ], + }; + } + + public function projectDetectionConfig(): array + { + return [ + 'paths' => ['.claude'], + 'files' => ['CLAUDE.md'], + ]; + } +} diff --git a/src/Install/CodeEnvironment/CodeEnvironment.php b/src/Install/CodeEnvironment/CodeEnvironment.php new file mode 100644 index 0000000..95c6299 --- /dev/null +++ b/src/Install/CodeEnvironment/CodeEnvironment.php @@ -0,0 +1,73 @@ +systemDetectionConfig($platform); + $strategy = $this->strategyFactory->makeFromConfig($config); + + return $strategy->detect($config, $platform); + } + + /** + * Determine if this code environment is being used in a specific project. + * + * @param string $basePath + * @return bool + */ + public function detectInProject(string $basePath): bool + { + $config = array_merge($this->projectDetectionConfig(), ['basePath' => $basePath]); + $strategy = $this->strategyFactory->makeFromConfig($config); + + return $strategy->detect($config); + } +} diff --git a/src/Install/CodeEnvironment/Copilot.php b/src/Install/CodeEnvironment/Copilot.php new file mode 100644 index 0000000..a5a03f5 --- /dev/null +++ b/src/Install/CodeEnvironment/Copilot.php @@ -0,0 +1,41 @@ + [], + ]; + } + + public function projectDetectionConfig(): array + { + return [ + 'files' => ['.github/copilot-instructions.md'], + ]; + } + + public function detectOnSystem(Platform $platform): bool + { + return false; + } + +} diff --git a/src/Install/CodeEnvironment/Cursor.php b/src/Install/CodeEnvironment/Cursor.php new file mode 100644 index 0000000..17d36d7 --- /dev/null +++ b/src/Install/CodeEnvironment/Cursor.php @@ -0,0 +1,50 @@ + [ + 'paths' => ['/Applications/Cursor.app'], + ], + Platform::Linux => [ + 'paths' => [ + '/opt/cursor', + '/usr/local/bin/cursor', + '~/.local/bin/cursor', + ], + ], + Platform::Windows => [ + 'paths' => [ + '%ProgramFiles%\\Cursor', + '%LOCALAPPDATA%\\Programs\\Cursor', + ], + ], + }; + } + + public function projectDetectionConfig(): array + { + return [ + 'paths' => ['.cursor'], + ]; + } + +} diff --git a/src/Install/CodeEnvironment/PhpStorm.php b/src/Install/CodeEnvironment/PhpStorm.php new file mode 100644 index 0000000..4bc7d8e --- /dev/null +++ b/src/Install/CodeEnvironment/PhpStorm.php @@ -0,0 +1,51 @@ + [ + 'paths' => ['/Applications/PhpStorm.app'], + ], + Platform::Linux => [ + 'paths' => [ + '/opt/phpstorm', + '/opt/PhpStorm*', + '/usr/local/bin/phpstorm', + '~/.local/share/JetBrains/Toolbox/apps/PhpStorm/ch-*', + ], + ], + Platform::Windows => [ + 'paths' => [ + '%ProgramFiles%\\JetBrains\\PhpStorm*', + '%LOCALAPPDATA%\\JetBrains\\Toolbox\\apps\\PhpStorm\\ch-*', + ], + ], + }; + } + + public function projectDetectionConfig(): array + { + return [ + 'paths' => ['.idea', '.junie'], + ]; + } + +} diff --git a/src/Install/CodeEnvironment/VSCode.php b/src/Install/CodeEnvironment/VSCode.php new file mode 100644 index 0000000..49fd9e6 --- /dev/null +++ b/src/Install/CodeEnvironment/VSCode.php @@ -0,0 +1,46 @@ + [ + 'paths' => ['/Applications/Visual Studio Code.app'], + ], + Platform::Linux => [ + 'command' => 'which code', + ], + Platform::Windows => [ + 'paths' => [ + '%ProgramFiles%\\Microsoft VS Code', + '%LOCALAPPDATA%\\Programs\\Microsoft VS Code', + ], + ], + }; + } + + public function projectDetectionConfig(): array + { + return [ + 'paths' => ['.vscode'], + ]; + } + +} diff --git a/src/Install/CodeEnvironment/Zed.php b/src/Install/CodeEnvironment/Zed.php new file mode 100644 index 0000000..3717b22 --- /dev/null +++ b/src/Install/CodeEnvironment/Zed.php @@ -0,0 +1,50 @@ + [ + 'paths' => ['/Applications/Zed.app'], + ], + Platform::Linux => [ + 'paths' => [ + '/opt/zed', + '/usr/local/bin/zed', + '~/.local/bin/zed', + ], + ], + Platform::Windows => [ + 'paths' => [ + '%ProgramFiles%\\Zed', + '%LOCALAPPDATA%\\Programs\\Zed', + ], + ], + }; + } + + public function projectDetectionConfig(): array + { + return [ + 'paths' => ['.zed'], + ]; + } + +} diff --git a/src/Install/CodeEnvironmentsDetector.php b/src/Install/CodeEnvironmentsDetector.php new file mode 100644 index 0000000..4f243a1 --- /dev/null +++ b/src/Install/CodeEnvironmentsDetector.php @@ -0,0 +1,76 @@ +> */ + private array $programs = [ + 'phpstorm' => PhpStorm::class, + 'vscode' => VSCode::class, + 'cursor' => Cursor::class, + 'claudecode' => ClaudeCode::class, + 'zed' => Zed::class, + 'copilot' => Copilot::class, + ]; + + public function __construct( + private readonly Container $container + ) { + } + + /** + * Detect installed applications on the current platform. + * + * @return array + */ + public function discoverSystemInstalledCodeEnvironments(): array + { + $platform = Platform::current(); + + return $this->getAllPrograms() + ->filter(fn (CodeEnvironment $program) => $program->detectOnSystem($platform)) + ->map(fn (CodeEnvironment $program) => $program->name()) + ->values() + ->toArray(); + } + + /** + * Detect applications used in the current project. + * + * @return array + */ + public function discoverProjectInstalledCodeEnvironments(string $basePath): array + { + return $this->getAllPrograms() + ->filter(fn ($program) => $program->detectInProject($basePath)) + ->map(fn ($program) => $program->name()) + ->values() + ->toArray(); + } + + /** + * Get all registered programs. + * + * @return Collection + */ + private function getAllPrograms(): Collection + { + return collect($this->programs)->map( + fn (string $className) => $this->container->make($className) + ); + } +} diff --git a/src/Install/Contracts/DetectionStrategy.php b/src/Install/Contracts/DetectionStrategy.php new file mode 100644 index 0000000..4e70f5a --- /dev/null +++ b/src/Install/Contracts/DetectionStrategy.php @@ -0,0 +1,19 @@ +, ?paths:array} $config + * @param ?Platform $platform + * @return bool + */ + public function detect(array $config, ?Platform $platform = null): bool; +} diff --git a/src/Install/Detection/CommandDetectionStrategy.php b/src/Install/Detection/CommandDetectionStrategy.php new file mode 100644 index 0000000..829c1c2 --- /dev/null +++ b/src/Install/Detection/CommandDetectionStrategy.php @@ -0,0 +1,21 @@ +successful(); + } +} diff --git a/src/Install/Detection/CompositeDetectionStrategy.php b/src/Install/Detection/CompositeDetectionStrategy.php new file mode 100644 index 0000000..8b0b15a --- /dev/null +++ b/src/Install/Detection/CompositeDetectionStrategy.php @@ -0,0 +1,29 @@ +strategies as $strategy) { + if ($strategy->detect($config, $platform)) { + return true; + } + } + + return false; + } +} diff --git a/src/Install/Detection/DetectionStrategyFactory.php b/src/Install/Detection/DetectionStrategyFactory.php new file mode 100644 index 0000000..09f5a30 --- /dev/null +++ b/src/Install/Detection/DetectionStrategyFactory.php @@ -0,0 +1,65 @@ + $this->make($singleType, $config), $type) + ); + } + + return match ($type) { + self::TYPE_DIRECTORY => $this->container->make(DirectoryDetectionStrategy::class), + self::TYPE_COMMAND => $this->container->make(CommandDetectionStrategy::class), + self::TYPE_FILE => $this->container->make(FileDetectionStrategy::class), + default => throw new InvalidArgumentException("Unknown detection type: {$type}"), + }; + } + + public function makeFromConfig(array $config): DetectionStrategy + { + $type = $this->inferTypeFromConfig($config); + + return $this->make($type, $config); + } + + private function inferTypeFromConfig(array $config): string|array + { + $typeMap = [ + 'files' => self::TYPE_FILE, + 'paths' => self::TYPE_DIRECTORY, + 'command' => self::TYPE_COMMAND, + ]; + + $types = collect($typeMap) + ->only(array_keys($config)) + ->values() + ->all(); + + if (empty($types)) { + throw new InvalidArgumentException( + 'Cannot infer detection type from config keys. Expected one of: '.collect($typeMap)->keys()->join(', ') + ); + } + + return count($types) > 1 ? $types : reset($types); + } +} diff --git a/src/Install/Detection/DirectoryDetectionStrategy.php b/src/Install/Detection/DirectoryDetectionStrategy.php new file mode 100644 index 0000000..6c8ec18 --- /dev/null +++ b/src/Install/Detection/DirectoryDetectionStrategy.php @@ -0,0 +1,65 @@ +expandPath($path, $platform); + + // If basePath is provided, prepend it to relative paths + if ($basePath && ! $this->isAbsolutePath($expandedPath)) { + $expandedPath = $basePath.DIRECTORY_SEPARATOR.$expandedPath; + } + + if (str_contains($expandedPath, '*')) { + $matches = glob($expandedPath, GLOB_ONLYDIR); + if (! empty($matches)) { + return true; + } + } elseif (is_dir($expandedPath)) { + return true; + } + } + + return false; + } + + private function expandPath(string $path, ?Platform $platform = null): string + { + if ($platform === Platform::Windows) { + return preg_replace_callback('/%([^%]+)%/', function ($matches) { + return getenv($matches[1]) ?: $matches[0]; + }, $path); + } + + if (str_starts_with($path, '~')) { + $home = getenv('HOME'); + if ($home) { + return str_replace('~', $home, $path); + } + } + + return $path; + } + + private function isAbsolutePath(string $path): bool + { + return str_starts_with($path, '/') || + str_starts_with($path, '\\') || + (strlen($path) > 1 && $path[1] === ':'); // Windows C: + } +} diff --git a/src/Install/Detection/FileDetectionStrategy.php b/src/Install/Detection/FileDetectionStrategy.php new file mode 100644 index 0000000..7436be0 --- /dev/null +++ b/src/Install/Detection/FileDetectionStrategy.php @@ -0,0 +1,26 @@ + self::Windows, + 'Darwin' => self::Darwin, + default => self::Linux, + }; + } +} diff --git a/tests/Feature/Console/InstallCommandMultiselectTest.php b/tests/Feature/Console/InstallCommandMultiselectTest.php index 574acd8..1027efd 100644 --- a/tests/Feature/Console/InstallCommandMultiselectTest.php +++ b/tests/Feature/Console/InstallCommandMultiselectTest.php @@ -72,7 +72,7 @@ public function test_multiselect_returns_values_for_indexed_array(): void */ public function test_multiselect_behavior_matches_install_command_expectations(): void { - // Test the exact same structure used in InstallCommand::boostToInstall() + // Test the exact same structure used in InstallCommand::selectBoostFeatures() // Note: mcp_server and ai_guidelines are already selected by default Prompt::fake([ Key::DOWN, // Move to ai_guidelines (already selected) diff --git a/tests/Feature/Install/Detection/CommandDetectionStrategyTest.php b/tests/Feature/Install/Detection/CommandDetectionStrategyTest.php new file mode 100644 index 0000000..6467550 --- /dev/null +++ b/tests/Feature/Install/Detection/CommandDetectionStrategyTest.php @@ -0,0 +1,79 @@ +strategy = new CommandDetectionStrategy(); +}); + +test('detects command with successful exit code', function () { + Process::fake([ + 'which php' => Process::result(exitCode: 0), + ]); + + $result = $this->strategy->detect([ + 'command' => 'which php', + ]); + + expect($result)->toBeTrue(); +}); + +test('fails for command with non zero exit code', function () { + Process::fake([ + 'which nonexistent' => Process::result(exitCode: 1), + ]); + + $result = $this->strategy->detect([ + 'command' => 'which nonexistent', + ]); + + expect($result)->toBeFalse(); +}); + +test('returns false when no command config', function () { + $result = $this->strategy->detect([ + 'other_config' => 'value', + ]); + + expect($result)->toBeFalse(); +}); + +test('handles command with output', function () { + Process::fake([ + 'echo test' => Process::result(output: 'test', exitCode: 0), + ]); + + $result = $this->strategy->detect([ + 'command' => 'echo test', + ]); + + expect($result)->toBeTrue(); +}); + +test('handles command with error output', function () { + Process::fake([ + 'invalid-command' => Process::result(errorOutput: 'command not found', exitCode: 127), + ]); + + $result = $this->strategy->detect([ + 'command' => 'invalid-command', + ]); + + expect($result)->toBeFalse(); +}); + +test('works with different platforms parameter', function () { + Process::fake([ + 'where code' => Process::result(exitCode: 0), + ]); + + $result = $this->strategy->detect([ + 'command' => 'where code', + ], Platform::Windows); + + expect($result)->toBeTrue(); +}); diff --git a/tests/Unit/Install/ApplicationDetectorTest.php b/tests/Unit/Install/ApplicationDetectorTest.php deleted file mode 100644 index 93ad912..0000000 --- a/tests/Unit/Install/ApplicationDetectorTest.php +++ /dev/null @@ -1,223 +0,0 @@ -detector = new ApplicationDetector; -}); - -test('getPlatform returns correct platform identifier', function () { - $reflection = new ReflectionClass($this->detector); - $method = $reflection->getMethod('getPlatform'); - $method->setAccessible(true); - - $platform = $method->invoke($this->detector); - - expect($platform)->toBeIn(['darwin', 'linux', 'windows']); -}); - -test('expandPath expands Windows environment variables', function () { - $reflection = new ReflectionClass($this->detector); - $method = $reflection->getMethod('expandPath'); - $method->setAccessible(true); - - // Mock environment variable - putenv('TEST_VAR=C:\\TestPath'); - - $expanded = $method->invoke($this->detector, '%TEST_VAR%\\SubFolder', 'windows'); - - expect($expanded)->toBe('C:\\TestPath\\SubFolder'); - - // Clean up - putenv('TEST_VAR'); -}); - -test('expandPath expands Unix home directory', function () { - $reflection = new ReflectionClass($this->detector); - $method = $reflection->getMethod('expandPath'); - $method->setAccessible(true); - - // Mock HOME environment variable - $originalHome = getenv('HOME'); - putenv('HOME=/home/testuser'); - - $expanded = $method->invoke($this->detector, '~/.config/app', 'linux'); - - expect($expanded)->toBe('/home/testuser/.config/app'); - - // Restore original HOME - if ($originalHome) { - putenv("HOME=$originalHome"); - } -}); - -test('detectInProject detects applications by directory', function () { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.vscode'); - - $detected = $this->detector->detectInProject($tempDir); - - expect($detected)->toContain('vscode'); - - // Cleanup - rmdir($tempDir.'/.vscode'); - rmdir($tempDir); -}); - -test('detectInProject detects applications by file', function () { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - file_put_contents($tempDir.'/.windsurfrules.md', 'test'); - - $detected = $this->detector->detectInProject($tempDir); - - expect($detected)->toContain('windsurf'); - - // Cleanup - unlink($tempDir.'/.windsurfrules.md'); - rmdir($tempDir); -}); - -test('detectInProject detects applications with mixed type', function () { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - file_put_contents($tempDir.'/CLAUDE.md', 'test'); - - $detected = $this->detector->detectInProject($tempDir); - - expect($detected)->toContain('claudecode'); - - // Cleanup - unlink($tempDir.'/CLAUDE.md'); - rmdir($tempDir); -}); - -test('detectInProject detects copilot with nested file path', function () { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.github'); - file_put_contents($tempDir.'/.github/copilot-instructions.md', 'test'); - - $detected = $this->detector->detectInProject($tempDir); - - expect($detected)->toContain('copilot'); - - // Cleanup - unlink($tempDir.'/.github/copilot-instructions.md'); - rmdir($tempDir.'/.github'); - rmdir($tempDir); -}); - -test('addDetectionConfig adds configuration for specific platform', function () { - $this->detector->addDetectionConfig('testapp', [ - 'paths' => ['/test/path'], - 'type' => 'directory', - ], 'darwin'); - - $reflection = new ReflectionClass($this->detector); - $property = $reflection->getProperty('detectionConfig'); - $property->setAccessible(true); - $config = $property->getValue($this->detector); - - expect($config['darwin'])->toHaveKey('testapp'); - expect($config['darwin']['testapp']['paths'])->toContain('/test/path'); -}); - -test('addDetectionConfig adds configuration for all platforms when platform is null', function () { - $this->detector->addDetectionConfig('testapp', [ - 'paths' => ['/test/path'], - 'type' => 'directory', - ]); - - $reflection = new ReflectionClass($this->detector); - $property = $reflection->getProperty('detectionConfig'); - $property->setAccessible(true); - $config = $property->getValue($this->detector); - - expect($config['darwin'])->toHaveKey('testapp'); - expect($config['linux'])->toHaveKey('testapp'); - expect($config['windows'])->toHaveKey('testapp'); -}); - -test('addProjectDetectionConfig adds project detection configuration', function () { - $this->detector->addProjectDetectionConfig('testapp', [ - 'files' => ['.testapp'], - 'type' => 'file', - ]); - - $reflection = new ReflectionClass($this->detector); - $property = $reflection->getProperty('projectDetectionConfig'); - $property->setAccessible(true); - $config = $property->getValue($this->detector); - - expect($config)->toHaveKey('testapp'); - expect($config['testapp']['files'])->toContain('.testapp'); -}); - -test('detectInstalled respects platform-specific configuration', function () { - $detector = new ApplicationDetector; - - // Get the actual platform - $reflection = new ReflectionClass($detector); - $getPlatform = $reflection->getMethod('getPlatform'); - $getPlatform->setAccessible(true); - $platform = $getPlatform->invoke($detector); - - // Set up a test configuration with a non-existent path - $property = $reflection->getProperty('detectionConfig'); - $property->setAccessible(true); - $property->setValue($detector, [ - $platform => [ - 'testapp' => [ - 'paths' => ['/this/path/does/not/exist/testapp'], - 'type' => 'directory', - ], - ], - // Different platform should not be detected - 'other_platform' => [ - 'otherapp' => [ - 'paths' => ['/some/other/path'], - 'type' => 'directory', - ], - ], - ]); - - $detected = $detector->detectInstalled(); - - expect($detected)->not->toContain('testapp'); - expect($detected)->not->toContain('otherapp'); -}); - -test('detectInstalled returns empty array for unsupported platform', function () { - $detector = new ApplicationDetector; - - // Clear detection config - $reflection = new ReflectionClass($detector); - $property = $reflection->getProperty('detectionConfig'); - $property->setAccessible(true); - $property->setValue($detector, []); - - $detected = $detector->detectInstalled(); - - expect($detected)->toBeEmpty(); -}); - -test('isAppInstalled handles wildcards in paths', function () { - $reflection = new ReflectionClass($this->detector); - $method = $reflection->getMethod('isAppInstalled'); - $method->setAccessible(true); - - // This test would need to mock glob() function, which is difficult - // In a real scenario, you might use a virtual file system - $config = [ - 'paths' => ['/nonexistent/path/*'], - 'type' => 'directory', - ]; - - $result = $method->invoke($this->detector, $config, 'darwin'); - - expect($result)->toBeFalse(); -}); diff --git a/tests/Unit/Install/CodeEnvironmentsDetectorTest.php b/tests/Unit/Install/CodeEnvironmentsDetectorTest.php new file mode 100644 index 0000000..bc59436 --- /dev/null +++ b/tests/Unit/Install/CodeEnvironmentsDetectorTest.php @@ -0,0 +1,240 @@ +container = new \Illuminate\Container\Container(); + $this->detector = new CodeEnvironmentsDetector($this->container); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('discoverSystemInstalledCodeEnvironments returns detected programs', function () { + // Create mock programs + $program1 = Mockery::mock(CodeEnvironment::class); + $program1->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(true); + $program1->shouldReceive('name')->andReturn('phpstorm'); + + $program2 = Mockery::mock(CodeEnvironment::class); + $program2->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); + $program2->shouldReceive('name')->andReturn('vscode'); + + $program3 = Mockery::mock(CodeEnvironment::class); + $program3->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(true); + $program3->shouldReceive('name')->andReturn('cursor'); + + // Mock all other programs that might be instantiated + $otherProgram = Mockery::mock(CodeEnvironment::class); + $otherProgram->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); + $otherProgram->shouldReceive('name')->andReturn('other'); + + // Bind mocked programs to container + $container = new \Illuminate\Container\Container(); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\PhpStorm::class, fn () => $program1); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $program2); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\Cursor::class, fn () => $program3); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\ClaudeCode::class, fn () => $otherProgram); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\Zed::class, fn () => $otherProgram); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\Copilot::class, fn () => $otherProgram); + + $detector = new CodeEnvironmentsDetector($container); + $detected = $detector->discoverSystemInstalledCodeEnvironments(); + + expect($detected)->toBe(['phpstorm', 'cursor']); +}); + +test('discoverSystemInstalledCodeEnvironments returns empty array when no programs detected', function () { + $program1 = Mockery::mock(CodeEnvironment::class); + $program1->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); + $program1->shouldReceive('name')->andReturn('phpstorm'); + + // Mock all other programs that might be instantiated + $otherProgram = Mockery::mock(CodeEnvironment::class); + $otherProgram->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); + $otherProgram->shouldReceive('name')->andReturn('other'); + + // Bind mocked program to container + $container = new \Illuminate\Container\Container(); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\PhpStorm::class, fn () => $program1); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $otherProgram); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\Cursor::class, fn () => $otherProgram); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\ClaudeCode::class, fn () => $otherProgram); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\Zed::class, fn () => $otherProgram); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\Copilot::class, fn () => $otherProgram); + + $detector = new CodeEnvironmentsDetector($container); + $detected = $detector->discoverSystemInstalledCodeEnvironments(); + + expect($detected)->toBeEmpty(); +}); + +test('discoverProjectInstalledCodeEnvironments detects programs in project', function () { + $basePath = '/path/to/project'; + + $program1 = Mockery::mock(CodeEnvironment::class); + $program1->shouldReceive('detectInProject')->with($basePath)->andReturn(true); + $program1->shouldReceive('name')->andReturn('vscode'); + + $program2 = Mockery::mock(CodeEnvironment::class); + $program2->shouldReceive('detectInProject')->with($basePath)->andReturn(false); + $program2->shouldReceive('name')->andReturn('phpstorm'); + + $program3 = Mockery::mock(CodeEnvironment::class); + $program3->shouldReceive('detectInProject')->with($basePath)->andReturn(true); + $program3->shouldReceive('name')->andReturn('claudecode'); + + // Bind mocked programs to container + $container = new \Illuminate\Container\Container(); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $program1); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\PhpStorm::class, fn () => $program2); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\ClaudeCode::class, fn () => $program3); + + $detector = new CodeEnvironmentsDetector($container); + $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); + + expect($detected)->toBe(['vscode', 'claudecode']); +}); + +test('discoverProjectInstalledCodeEnvironments returns empty array when no programs detected in project', function () { + $basePath = '/path/to/project'; + + $program1 = Mockery::mock(CodeEnvironment::class); + $program1->shouldReceive('detectInProject')->with($basePath)->andReturn(false); + $program1->shouldReceive('name')->andReturn('vscode'); + + // Bind mocked program to container + $container = new \Illuminate\Container\Container(); + $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $program1); + + $detector = new CodeEnvironmentsDetector($container); + $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); + + expect($detected)->toBeEmpty(); +}); + +test('discoverProjectInstalledCodeEnvironments detects applications by directory', function () { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + mkdir($tempDir); + mkdir($tempDir.'/.vscode'); + + $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + + expect($detected)->toContain('vscode'); + + // Cleanup + rmdir($tempDir.'/.vscode'); + rmdir($tempDir); +}); + +test('discoverProjectInstalledCodeEnvironments detects applications with mixed type', function () { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + mkdir($tempDir); + file_put_contents($tempDir.'/CLAUDE.md', 'test'); + + $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + + expect($detected)->toContain('claudecode'); + + // Cleanup + unlink($tempDir.'/CLAUDE.md'); + rmdir($tempDir); +}); + +test('discoverProjectInstalledCodeEnvironments detects copilot with nested file path', function () { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + mkdir($tempDir); + mkdir($tempDir.'/.github'); + file_put_contents($tempDir.'/.github/copilot-instructions.md', 'test'); + + $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + + expect($detected)->toContain('copilot'); + + // Cleanup + unlink($tempDir.'/.github/copilot-instructions.md'); + rmdir($tempDir.'/.github'); + rmdir($tempDir); +}); + +test('discoverProjectInstalledCodeEnvironments detects claude code with directory', function () { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + mkdir($tempDir); + mkdir($tempDir.'/.claude'); + + $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + + expect($detected)->toContain('claudecode'); + + // Cleanup + rmdir($tempDir.'/.claude'); + rmdir($tempDir); +}); + +test('discoverProjectInstalledCodeEnvironments detects phpstorm with idea directory', function () { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + mkdir($tempDir); + mkdir($tempDir.'/.idea'); + + $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + + expect($detected)->toContain('phpstorm'); + + // Cleanup + rmdir($tempDir.'/.idea'); + rmdir($tempDir); +}); + +test('discoverProjectInstalledCodeEnvironments detects phpstorm with junie directory', function () { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + mkdir($tempDir); + mkdir($tempDir.'/.junie'); + + $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + + expect($detected)->toContain('phpstorm'); + + // Cleanup + rmdir($tempDir.'/.junie'); + rmdir($tempDir); +}); + +test('discoverProjectInstalledCodeEnvironments detects cursor with cursor directory', function () { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + mkdir($tempDir); + mkdir($tempDir.'/.cursor'); + + $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + + expect($detected)->toContain('cursor'); + + // Cleanup + rmdir($tempDir.'/.cursor'); + rmdir($tempDir); +}); + +test('discoverProjectInstalledCodeEnvironments handles multiple detections', function () { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + mkdir($tempDir); + mkdir($tempDir.'/.vscode'); + mkdir($tempDir.'/.cursor'); + file_put_contents($tempDir.'/CLAUDE.md', 'test'); + + $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + + expect($detected)->toContain('vscode'); + expect($detected)->toContain('cursor'); + expect($detected)->toContain('claudecode'); + expect(count($detected))->toBeGreaterThanOrEqual(3); + + // Cleanup + rmdir($tempDir.'/.vscode'); + rmdir($tempDir.'/.cursor'); + unlink($tempDir.'/CLAUDE.md'); + rmdir($tempDir); +}); diff --git a/tests/Unit/Install/Detection/CommandDetectionStrategyTest.php b/tests/Unit/Install/Detection/CommandDetectionStrategyTest.php new file mode 100644 index 0000000..6c3d01b --- /dev/null +++ b/tests/Unit/Install/Detection/CommandDetectionStrategyTest.php @@ -0,0 +1,79 @@ +strategy = new CommandDetectionStrategy(); +}); + + test('detects command with successful exit code', function () { + Process::fake([ + 'which php' => Process::result(exitCode: 0), + ]); + + $result = $this->strategy->detect([ + 'command' => 'which php', + ]); + + expect($result)->toBeTrue(); + })->skip(); + + test('fails for command with non zero exit code', function () { + Process::fake([ + 'which nonexistent' => Process::result(exitCode: 1), + ]); + + $result = $this->strategy->detect([ + 'command' => 'which nonexistent', + ]); + + expect($result)->toBeFalse(); + })->skip(); + + test('returns false when no command config', function () { + $result = $this->strategy->detect([ + 'other_config' => 'value', + ]); + + expect($result)->toBeFalse(); + })->skip(); + + test('handles command with output', function () { + Process::fake([ + 'echo test' => Process::result(output: 'test', exitCode: 0), + ]); + + $result = $this->strategy->detect([ + 'command' => 'echo test', + ]); + + expect($result)->toBeTrue(); + })->skip(); + + test('handles command with error output', function () { + Process::fake([ + 'invalid-command' => Process::result(errorOutput: 'command not found', exitCode: 127), + ]); + + $result = $this->strategy->detect([ + 'command' => 'invalid-command', + ]); + + expect($result)->toBeFalse(); + })->skip(); + + test('works with different platforms parameter', function () { + Process::fake([ + 'where code' => Process::result(exitCode: 0), + ]); + + $result = $this->strategy->detect([ + 'command' => 'where code', + ], Platform::Windows); + + expect($result)->toBeTrue(); + })->skip(); diff --git a/tests/Unit/Install/Detection/CompositeDetectionStrategyTest.php b/tests/Unit/Install/Detection/CompositeDetectionStrategyTest.php new file mode 100644 index 0000000..d587bab --- /dev/null +++ b/tests/Unit/Install/Detection/CompositeDetectionStrategyTest.php @@ -0,0 +1,206 @@ +firstStrategy = Mockery::mock(DetectionStrategy::class); + $this->secondStrategy = Mockery::mock(DetectionStrategy::class); + $this->thirdStrategy = Mockery::mock(DetectionStrategy::class); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('returns true when first strategy succeeds', function () { + $this->firstStrategy + ->shouldReceive('detect') + ->once() + ->with(['config' => 'value'], null) + ->andReturn(true); + + $this->secondStrategy + ->shouldNotReceive('detect'); + + $composite = new CompositeDetectionStrategy([ + $this->firstStrategy, + $this->secondStrategy, + ]); + + $result = $composite->detect(['config' => 'value']); + + expect($result)->toBeTrue(); +}); + +test('returns true when second strategy succeeds', function () { + $this->firstStrategy + ->shouldReceive('detect') + ->once() + ->with(['config' => 'value'], null) + ->andReturn(false); + + $this->secondStrategy + ->shouldReceive('detect') + ->once() + ->with(['config' => 'value'], null) + ->andReturn(true); + + $composite = new CompositeDetectionStrategy([ + $this->firstStrategy, + $this->secondStrategy, + ]); + + $result = $composite->detect(['config' => 'value']); + + expect($result)->toBeTrue(); +}); + +test('returns false when all strategies fail', function () { + $this->firstStrategy + ->shouldReceive('detect') + ->once() + ->with(['config' => 'value'], Platform::Linux) + ->andReturn(false); + + $this->secondStrategy + ->shouldReceive('detect') + ->once() + ->with(['config' => 'value'], Platform::Linux) + ->andReturn(false); + + $this->thirdStrategy + ->shouldReceive('detect') + ->once() + ->with(['config' => 'value'], Platform::Linux) + ->andReturn(false); + + $composite = new CompositeDetectionStrategy([ + $this->firstStrategy, + $this->secondStrategy, + $this->thirdStrategy, + ]); + + $result = $composite->detect(['config' => 'value'], Platform::Linux); + + expect($result)->toBeFalse(); +}); + +test('stops execution after first success', function () { + $this->firstStrategy + ->shouldReceive('detect') + ->once() + ->with(['paths' => ['test']], Platform::Darwin) + ->andReturn(false); + + $this->secondStrategy + ->shouldReceive('detect') + ->once() + ->with(['paths' => ['test']], Platform::Darwin) + ->andReturn(true); + + $this->thirdStrategy + ->shouldNotReceive('detect'); + + $composite = new CompositeDetectionStrategy([ + $this->firstStrategy, + $this->secondStrategy, + $this->thirdStrategy, + ]); + + $result = $composite->detect(['paths' => ['test']], Platform::Darwin); + + expect($result)->toBeTrue(); +}); + +test('handles empty strategies array', function () { + $composite = new CompositeDetectionStrategy([]); + + $result = $composite->detect(['config' => 'value']); + + expect($result)->toBeFalse(); +}); + +test('handles single strategy', function () { + $this->firstStrategy + ->shouldReceive('detect') + ->once() + ->with(['single' => 'test'], null) + ->andReturn(true); + + $composite = new CompositeDetectionStrategy([ + $this->firstStrategy, + ]); + + $result = $composite->detect(['single' => 'test']); + + expect($result)->toBeTrue(); +}); + +test('passes platform parameter to all strategies', function () { + $this->firstStrategy + ->shouldReceive('detect') + ->once() + ->with(['config' => 'test'], Platform::Windows) + ->andReturn(false); + + $this->secondStrategy + ->shouldReceive('detect') + ->once() + ->with(['config' => 'test'], Platform::Windows) + ->andReturn(false); + + $composite = new CompositeDetectionStrategy([ + $this->firstStrategy, + $this->secondStrategy, + ]); + + $result = $composite->detect(['config' => 'test'], Platform::Windows); + + expect($result)->toBeFalse(); +}); + +test('handles null platform parameter', function () { + $this->firstStrategy + ->shouldReceive('detect') + ->once() + ->with(['config' => 'test'], null) + ->andReturn(true); + + $composite = new CompositeDetectionStrategy([ + $this->firstStrategy, + ]); + + $result = $composite->detect(['config' => 'test'], null); + + expect($result)->toBeTrue(); +}); + +test('handles mixed strategy types', function () { + // This test simulates real-world usage where different strategy types + // might be combined (directory, file, command, etc.) + + $this->firstStrategy + ->shouldReceive('detect') + ->once() + ->with(['paths' => ['.vscode']], null) + ->andReturn(false); + + $this->secondStrategy + ->shouldReceive('detect') + ->once() + ->with(['paths' => ['.vscode']], null) + ->andReturn(true); + + $composite = new CompositeDetectionStrategy([ + $this->firstStrategy, + $this->secondStrategy, + ]); + + $result = $composite->detect(['paths' => ['.vscode']]); + + expect($result)->toBeTrue(); +}); diff --git a/tests/Unit/Install/Detection/DetectionStrategyFactoryTest.php b/tests/Unit/Install/Detection/DetectionStrategyFactoryTest.php new file mode 100644 index 0000000..c487ade --- /dev/null +++ b/tests/Unit/Install/Detection/DetectionStrategyFactoryTest.php @@ -0,0 +1,107 @@ +container = new Container(); + $this->factory = new DetectionStrategyFactory($this->container); +}); + +test('creates directory strategy from string', function () { + $strategy = $this->factory->make('directory'); + + expect($strategy)->toBeInstanceOf(DirectoryDetectionStrategy::class); +}); + +test('creates file strategy from string', function () { + $strategy = $this->factory->make('file'); + + expect($strategy)->toBeInstanceOf(FileDetectionStrategy::class); +}); + +test('creates command strategy from string', function () { + $strategy = $this->factory->make('command'); + + expect($strategy)->toBeInstanceOf(CommandDetectionStrategy::class); +}); + +test('creates composite strategy from array of strings', function () { + $strategy = $this->factory->make([ + 'directory', + 'file', + ]); + + expect($strategy)->toBeInstanceOf(CompositeDetectionStrategy::class); +}); + +test('creates composite strategy from mixed array', function () { + $strategy = $this->factory->make([ + 'directory', + 'file', + 'command', + ]); + + expect($strategy)->toBeInstanceOf(CompositeDetectionStrategy::class); +}); + +test('throws exception for unknown string type', function () { + expect(fn () => $this->factory->make('unknown')) + ->toThrow(InvalidArgumentException::class); +}); + +test('empty array creates composite strategy', function () { + $strategy = $this->factory->make([]); + + expect($strategy)->toBeInstanceOf(CompositeDetectionStrategy::class); +}); + +test('makeFromConfig infers directory type from paths key', function () { + $strategy = $this->factory->makeFromConfig([ + 'paths' => ['/some/path'], + ]); + + expect($strategy)->toBeInstanceOf(DirectoryDetectionStrategy::class); +}); + +test('makeFromConfig infers file type from files key', function () { + $strategy = $this->factory->makeFromConfig([ + 'files' => ['file.txt'], + ]); + + expect($strategy)->toBeInstanceOf(FileDetectionStrategy::class); +}); + +test('makeFromConfig infers command type from command key', function () { + $strategy = $this->factory->makeFromConfig([ + 'command' => 'which code', + ]); + + expect($strategy)->toBeInstanceOf(CommandDetectionStrategy::class); +}); + +test('makeFromConfig creates composite strategy from multiple keys', function () { + $strategy = $this->factory->makeFromConfig([ + 'paths' => ['.claude'], + 'files' => ['CLAUDE.md'], + ]); + + expect($strategy)->toBeInstanceOf(CompositeDetectionStrategy::class); +}); + +test('makeFromConfig throws exception for unknown config keys', function () { + expect(fn () => $this->factory->makeFromConfig([ + 'unknown_key' => 'value', + ]))->toThrow(InvalidArgumentException::class, 'Cannot infer detection type from config keys'); +}); + +test('makeFromConfig throws exception for empty config', function () { + expect(fn () => $this->factory->makeFromConfig([])) + ->toThrow(InvalidArgumentException::class, 'Cannot infer detection type from config keys'); +}); diff --git a/tests/Unit/Install/Detection/DirectoryDetectionStrategyTest.php b/tests/Unit/Install/Detection/DirectoryDetectionStrategyTest.php new file mode 100644 index 0000000..9e1ee3b --- /dev/null +++ b/tests/Unit/Install/Detection/DirectoryDetectionStrategyTest.php @@ -0,0 +1,209 @@ +strategy = new DirectoryDetectionStrategy(); + $this->tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + mkdir($this->tempDir); +}); + +afterEach(function () { + if (is_dir($this->tempDir)) { + removeDirectory($this->tempDir); + } +}); + +test('detects existing directory', function () { + $testDir = $this->tempDir.'/test_app'; + mkdir($testDir); + + $result = $this->strategy->detect([ + 'paths' => ['test_app'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeTrue(); +}); + +test('fails for non existent directory', function () { + $result = $this->strategy->detect([ + 'paths' => ['non_existent'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeFalse(); +}); + +test('detects absolute path', function () { + $testDir = $this->tempDir.'/absolute_test'; + mkdir($testDir); + + $result = $this->strategy->detect([ + 'paths' => [$testDir], + ]); + + expect($result)->toBeTrue(); +}); + +test('detects multiple paths first exists', function () { + $testDir = $this->tempDir.'/first_exists'; + mkdir($testDir); + + $result = $this->strategy->detect([ + 'paths' => ['first_exists', 'second_missing'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeTrue(); +}); + +test('detects multiple paths second exists', function () { + $testDir = $this->tempDir.'/second_exists'; + mkdir($testDir); + + $result = $this->strategy->detect([ + 'paths' => ['first_missing', 'second_exists'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeTrue(); +}); + +test('fails when no paths exist', function () { + $result = $this->strategy->detect([ + 'paths' => ['missing1', 'missing2'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeFalse(); +}); + +test('returns false when no paths config', function () { + $result = $this->strategy->detect([ + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeFalse(); +}); + +test('uses current directory when no base path', function () { + // This test creates a directory in the current working directory + $currentDir = getcwd(); + $testDir = $currentDir.'/temp_test_dir'; + mkdir($testDir); + + try { + $result = $this->strategy->detect([ + 'paths' => ['temp_test_dir'], + ]); + + expect($result)->toBeTrue(); + } finally { + rmdir($testDir); + } +}); + +test('detects with glob pattern', function () { + // Create test directories with patterns + mkdir($this->tempDir.'/app_v1'); + mkdir($this->tempDir.'/app_v2'); + + $result = $this->strategy->detect([ + 'paths' => ['app_v*'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeTrue(); +}); + +test('fails with glob pattern no matches', function () { + $result = $this->strategy->detect([ + 'paths' => ['nonexistent_*'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeFalse(); +}); + +test('expands tilde home directory', function () { + // Mock HOME environment variable + $originalHome = getenv('HOME'); + putenv('HOME='.$this->tempDir); + + mkdir($this->tempDir.'/test_home'); + + try { + $result = $this->strategy->detect([ + 'paths' => ['~/test_home'], + ]); + + expect($result)->toBeTrue(); + } finally { + // Restore original HOME + if ($originalHome !== false) { + putenv('HOME='.$originalHome); + } else { + putenv('HOME'); + } + } +}); + +test('expands windows environment variables', function () { + // Mock environment variable for Windows + putenv('TESTVAR='.$this->tempDir); + mkdir($this->tempDir.'/windows_test'); + + try { + $result = $this->strategy->detect([ + 'paths' => ['%TESTVAR%/windows_test'], + ], Platform::Windows); + + expect($result)->toBeTrue(); + } finally { + putenv('TESTVAR'); + } +}); + +test('handles missing environment variable on windows', function () { + $result = $this->strategy->detect([ + 'paths' => ['%NONEXISTENT%/test'], + ], Platform::Windows); + + expect($result)->toBeFalse(); +}); + +test('identifies absolute paths correctly', function () { + $reflectionClass = new \ReflectionClass($this->strategy); + $isAbsolutePathMethod = $reflectionClass->getMethod('isAbsolutePath'); + $isAbsolutePathMethod->setAccessible(true); + + // Unix absolute paths + expect($isAbsolutePathMethod->invoke($this->strategy, '/usr/local/bin'))->toBeTrue(); + + // Windows absolute paths + expect($isAbsolutePathMethod->invoke($this->strategy, 'C:\\Program Files'))->toBeTrue(); + expect($isAbsolutePathMethod->invoke($this->strategy, 'D:\\test'))->toBeTrue(); + + // Relative paths + expect($isAbsolutePathMethod->invoke($this->strategy, 'relative/path'))->toBeFalse(); + expect($isAbsolutePathMethod->invoke($this->strategy, './relative'))->toBeFalse(); + expect($isAbsolutePathMethod->invoke($this->strategy, '../relative'))->toBeFalse(); +}); + +function removeDirectory(string $dir): void +{ + if (! is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir.DIRECTORY_SEPARATOR.$file; + is_dir($path) ? removeDirectory($path) : unlink($path); + } + rmdir($dir); +} diff --git a/tests/Unit/Install/Detection/FileDetectionStrategyTest.php b/tests/Unit/Install/Detection/FileDetectionStrategyTest.php new file mode 100644 index 0000000..db0a517 --- /dev/null +++ b/tests/Unit/Install/Detection/FileDetectionStrategyTest.php @@ -0,0 +1,139 @@ +strategy = new FileDetectionStrategy(); + $this->tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + mkdir($this->tempDir); +}); + +afterEach(function () { + if (is_dir($this->tempDir)) { + removeDirectoryForFileTests($this->tempDir); + } +}); + +test('detects existing file', function () { + file_put_contents($this->tempDir.'/test.txt', 'test content'); + + $result = $this->strategy->detect([ + 'files' => ['test.txt'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeTrue(); +}); + +test('fails for non existent file', function () { + $result = $this->strategy->detect([ + 'files' => ['non_existent.txt'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeFalse(); +}); + +test('detects multiple files first exists', function () { + file_put_contents($this->tempDir.'/first.txt', 'content'); + + $result = $this->strategy->detect([ + 'files' => ['first.txt', 'second.txt'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeTrue(); +}); + +test('detects multiple files second exists', function () { + file_put_contents($this->tempDir.'/second.txt', 'content'); + + $result = $this->strategy->detect([ + 'files' => ['first.txt', 'second.txt'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeTrue(); +}); + +test('fails when no files exist', function () { + $result = $this->strategy->detect([ + 'files' => ['missing1.txt', 'missing2.txt'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeFalse(); +}); + +test('returns false when no files config', function () { + $result = $this->strategy->detect([ + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeFalse(); +}); + +test('uses current directory when no base path', function () { + // This test creates a file in the current working directory + $currentDir = getcwd(); + $testFile = $currentDir.'/temp_test_file.txt'; + file_put_contents($testFile, 'test'); + + try { + $result = $this->strategy->detect([ + 'files' => ['temp_test_file.txt'], + ]); + + expect($result)->toBeTrue(); + } finally { + unlink($testFile); + } +}); + +test('detects files in subdirectories', function () { + mkdir($this->tempDir.'/subdir'); + file_put_contents($this->tempDir.'/subdir/nested.txt', 'content'); + + $result = $this->strategy->detect([ + 'files' => ['subdir/nested.txt'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeTrue(); +}); + +test('handles empty files array', function () { + $result = $this->strategy->detect([ + 'files' => [], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeFalse(); +}); + +test('detects files with special characters', function () { + file_put_contents($this->tempDir.'/file-with_special.chars.txt', 'content'); + + $result = $this->strategy->detect([ + 'files' => ['file-with_special.chars.txt'], + 'basePath' => $this->tempDir, + ]); + + expect($result)->toBeTrue(); +}); + +function removeDirectoryForFileTests(string $dir): void +{ + if (! is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir.DIRECTORY_SEPARATOR.$file; + is_dir($path) ? removeDirectoryForFileTests($path) : unlink($path); + } + rmdir($dir); +}