diff --git a/composer.json b/composer.json index 96d45db..b2d77bf 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "require-dev": { "ext-pdo_sqlite": "*", "flightphp/container": "^1.0", - "flightphp/runway": "^0.2.3 || ^1.0", + "flightphp/runway": "^1.2", "league/container": "^4.2", "level-2/dice": "^4.0", "phpstan/extension-installer": "^1.4", @@ -62,21 +62,22 @@ "sort-packages": true }, "scripts": { - "test": "vendor/bin/phpunit-watcher watch", + "test": "phpunit", + "test-watcher": "phpunit-watcher watch", "test-ci": "phpunit", "test-coverage": "rm -f clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", "test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/", "test-server-v2": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server-v2/", "test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100", - "test-performance": [ - "echo \"Running Performance Tests...\"", - "php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", - "sleep 2", - "bash tests/performance/performance_tests.sh", - "kill `cat server.pid`", - "rm server.pid", - "echo \"Performance Tests Completed.\"" - ], + "test-performance": [ + "echo \"Running Performance Tests...\"", + "php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", + "sleep 2", + "bash tests/performance/performance_tests.sh", + "kill `cat server.pid`", + "rm server.pid", + "echo \"Performance Tests Completed.\"" + ], "lint": "phpstan --no-progress --memory-limit=256M -cphpstan.neon", "beautify": "phpcbf --standard=phpcs.xml", "phpcs": "phpcs --standard=phpcs.xml -n", @@ -92,4 +93,4 @@ "replace": { "mikecao/flight": "2.0.2" } -} +} \ No newline at end of file diff --git a/flight/Engine.php b/flight/Engine.php index 0cfaf68..fe88301 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -621,7 +621,7 @@ class Engine $response->write(ob_get_clean()); } - // Run any before middlewares + // Run any after middlewares if (count($route->middleware) > 0) { // process the middleware in reverse order now $atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'after'); diff --git a/flight/commands/AiGenerateInstructionsCommand.php b/flight/commands/AiGenerateInstructionsCommand.php index 90dd7cb..cb78e9a 100644 --- a/flight/commands/AiGenerateInstructionsCommand.php +++ b/flight/commands/AiGenerateInstructionsCommand.php @@ -4,24 +4,23 @@ declare(strict_types=1); namespace flight\commands; -use Ahc\Cli\Input\Command; - /** - * @property-read ?string $credsFile + * @property-read ?string $configFile * @property-read ?string $baseDir */ -class AiGenerateInstructionsCommand extends Command +class AiGenerateInstructionsCommand extends AbstractBaseCommand { /** * Constructor for the AiGenerateInstructionsCommand class. * * Initializes a new instance of the command. + * + * @param array $config Config from config.php */ - public function __construct() + public function __construct(array $config) { - parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions'); - $this->option('--creds-file', 'Path to .runway-creds.json file', null, ''); - $this->option('--base-dir', 'Project base directory (for testing or custom use)', null, ''); + parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions', $config); + $this->option('--config-file', 'Path to .runway-config.json file (deprecated, use config.php instead)', null, ''); } /** @@ -37,12 +36,19 @@ class AiGenerateInstructionsCommand extends Command public function execute() { $io = $this->app()->io(); - $baseDir = $this->baseDir ? rtrim($this->baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : getcwd() . DIRECTORY_SEPARATOR; - $runwayCredsFile = $this->credsFile ?: $baseDir . '.runway-creds.json'; - // Check for runway creds - if (!file_exists($runwayCredsFile)) { - $io->error('Missing .runway-creds.json. Please run the \'ai:init\' command first.', true); + if (empty($this->config['runway'])) { + $configFile = $this->configFile; + $io = $this->app()->io(); + $io->warn('The --config-file option is deprecated. Move your config values to the \'runway\' key in the config.php file for configuration.', true); + $runwayConfig = json_decode(file_get_contents($configFile), true) ?? []; + } else { + $runwayConfig = $this->config['runway']; + } + + // Check for runway creds ai + if (empty($runwayConfig['ai'])) { + $io->error('Missing AI configuration. Please run the \'ai:init\' command first.', true); return 1; } @@ -61,8 +67,8 @@ class AiGenerateInstructionsCommand extends Command $other = $io->prompt('Any other important requirements or context? (optional)', 'no'); // Prepare prompt for LLM - $contextFile = $baseDir . '.github/copilot-instructions.md'; - $context = file_exists($contextFile) ? file_get_contents($contextFile) : ''; + $contextFile = $this->projectRoot . '.github/copilot-instructions.md'; + $context = file_exists($contextFile) === true ? file_get_contents($contextFile) : ''; $userDetails = [ 'Project Description' => $projectDesc, 'Database' => $database, @@ -80,7 +86,7 @@ class AiGenerateInstructionsCommand extends Command $detailsText .= "$k: $v\n"; } $prompt = <<info('Updating .github/copilot-instructions.md, .cursor/rules/project-overview.mdc, and .windsurfrules...', true); - if (!is_dir($baseDir . '.github')) { - mkdir($baseDir . '.github', 0755, true); + $io->info('Updating .github/copilot-instructions.md, .cursor/rules/project-overview.mdc, .gemini/GEMINI.md and .windsurfrules...', true); + if (!is_dir($this->projectRoot . '.github')) { + mkdir($this->projectRoot . '.github', 0755, true); + } + if (!is_dir($this->projectRoot . '.cursor/rules')) { + mkdir($this->projectRoot . '.cursor/rules', 0755, true); } - if (!is_dir($baseDir . '.cursor/rules')) { - mkdir($baseDir . '.cursor/rules', 0755, true); + if (!is_dir($this->projectRoot . '.gemini')) { + mkdir($this->projectRoot . '.gemini', 0755, true); } - file_put_contents($baseDir . '.github/copilot-instructions.md', $instructions); - file_put_contents($baseDir . '.cursor/rules/project-overview.mdc', $instructions); - file_put_contents($baseDir . '.windsurfrules', $instructions); + file_put_contents($this->projectRoot . '.github/copilot-instructions.md', $instructions); + file_put_contents($this->projectRoot . '.cursor/rules/project-overview.mdc', $instructions); + file_put_contents($this->projectRoot . '.gemini/GEMINI.md', $instructions); + file_put_contents($this->projectRoot . '.windsurfrules', $instructions); $io->ok('AI instructions updated successfully.', true); return 0; } diff --git a/flight/commands/AiInitCommand.php b/flight/commands/AiInitCommand.php index f60ce95..f24332d 100644 --- a/flight/commands/AiInitCommand.php +++ b/flight/commands/AiInitCommand.php @@ -4,25 +4,22 @@ declare(strict_types=1); namespace flight\commands; -use Ahc\Cli\Input\Command; - /** - * @property-read ?string $gitignoreFile - * @property-read ?string $credsFile + * @property-read ?string $credsFile Deprecated, use config.php instead */ -class AiInitCommand extends Command +class AiInitCommand extends AbstractBaseCommand { /** * Constructor for the AiInitCommand class. * * Initializes the command instance and sets up any required dependencies. + * + * @param array $config Config from config.php */ - public function __construct() + public function __construct(array $config) { - parent::__construct('ai:init', 'Initialize LLM API credentials and settings'); - $this - ->option('--gitignore-file', 'Path to .gitignore file', null, '') - ->option('--creds-file', 'Path to .runway-creds.json file', null, ''); + parent::__construct('ai:init', 'Initialize LLM API credentials and settings', $config); + $this->option('--creds-file', 'Path to .runway-creds.json file (deprecated, use config.php instead)', null, ''); } /** @@ -36,21 +33,6 @@ class AiInitCommand extends Command $io->info('Welcome to AI Init!', true); - $baseDir = getcwd() . DIRECTORY_SEPARATOR; - $runwayCredsFile = $this->credsFile ?: $baseDir . '.runway-creds.json'; - $gitignoreFile = $this->gitignoreFile ?: $baseDir . '.gitignore'; - - // make sure the .runway-creds.json file is not already present - if (file_exists($runwayCredsFile)) { - $io->error('.runway-creds.json file already exists. Please remove it before running this command.', true); - // prompt to overwrite - $overwrite = $io->confirm('Do you want to overwrite the existing .runway-creds.json file?', 'n'); - if ($overwrite === false) { - $io->info('Exiting without changes.', true); - return 0; - } - } - // Prompt for API provider with validation $allowedApis = [ '1' => 'openai', @@ -88,50 +70,26 @@ class AiInitCommand extends Command // Validate model input switch ($api) { case 'openai': - $defaultModel = 'gpt-4o'; + $defaultModel = 'gpt-5'; break; case 'grok': - $defaultModel = 'grok-3-beta'; + $defaultModel = 'grok-4.1-fast-non-reasoning'; break; case 'claude': - $defaultModel = 'claude-3-opus'; + $defaultModel = 'claude-sonnet-4-5'; break; } - $model = trim($io->prompt('Enter the model name you want to use (e.g. gpt-4, claude-3-opus, etc)', $defaultModel)); + $model = trim($io->prompt('Enter the model name you want to use (e.g. gpt-5, claude-sonnet-4-5, etc)', $defaultModel)); - $creds = [ + $runwayAiConfig = [ 'provider' => $api, 'api_key' => $apiKey, 'model' => $model, 'base_url' => $baseUrl, ]; + $this->setRunwayConfigValue('ai', $runwayAiConfig); - $json = json_encode($creds, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $file = $runwayCredsFile; - file_put_contents($file, $json); - - // change permissions to 600 - chmod($file, 0600); - - $io->ok('Credentials saved to ' . $file, true); - - // run a check to make sure that the creds file is in the .gitignore file - // use $gitignoreFile instead of hardcoded path - if (!file_exists($gitignoreFile)) { - // create the .gitignore file if it doesn't exist - file_put_contents($gitignoreFile, basename($runwayCredsFile) . "\n"); - $io->info(basename($gitignoreFile) . ' file created and ' . basename($runwayCredsFile) . ' added to it.', true); - } else { - // check if the creds file is already in the .gitignore file - $gitignoreContents = file_get_contents($gitignoreFile); - if (strpos($gitignoreContents, basename($runwayCredsFile)) === false) { - // add the creds file to the .gitignore file - file_put_contents($gitignoreFile, "\n" . basename($runwayCredsFile) . "\n", FILE_APPEND); - $io->info(basename($runwayCredsFile) . ' added to ' . basename($gitignoreFile) . ' file.', true); - } else { - $io->info(basename($runwayCredsFile) . ' is already in the ' . basename($gitignoreFile) . ' file.', true); - } - } + $io->ok('Credentials saved to app/config/config.php', true); return 0; } diff --git a/tests/classes/NoExitInteractor.php b/tests/classes/NoExitInteractor.php new file mode 100644 index 0000000..737f49c --- /dev/null +++ b/tests/classes/NoExitInteractor.php @@ -0,0 +1,21 @@ +writer()->error($text, 0); + return $this; + } + public function warn(string $text, bool $exit = false): self + { + $this->writer()->warn($text, 0); + return $this; + } +} diff --git a/tests/commands/AiGenerateInstructionsCommandTest.php b/tests/commands/AiGenerateInstructionsCommandTest.php index 6e09484..ea842ed 100644 --- a/tests/commands/AiGenerateInstructionsCommandTest.php +++ b/tests/commands/AiGenerateInstructionsCommandTest.php @@ -5,16 +5,15 @@ declare(strict_types=1); namespace tests\commands; use Ahc\Cli\Application; -use Ahc\Cli\IO\Interactor; use flight\commands\AiGenerateInstructionsCommand; use PHPUnit\Framework\TestCase; +use tests\classes\NoExitInteractor; class AiGenerateInstructionsCommandTest extends TestCase { protected static $in; protected static $ou; protected $baseDir; - protected $runwayCredsFile; public function setUp(): void { @@ -26,16 +25,6 @@ class AiGenerateInstructionsCommandTest extends TestCase if (!is_dir($this->baseDir)) { mkdir($this->baseDir, 0777, true); } - $this->runwayCredsFile = $this->baseDir . 'dummy-creds.json'; - if (file_exists($this->runwayCredsFile)) { - unlink($this->runwayCredsFile); - } - @unlink($this->baseDir . '.github/copilot-instructions.md'); - @unlink($this->baseDir . '.cursor/rules/project-overview.mdc'); - @unlink($this->baseDir . '.windsurfrules'); - @rmdir($this->baseDir . '.github'); - @rmdir($this->baseDir . '.cursor/rules'); - @rmdir($this->baseDir . '.cursor'); } public function tearDown(): void @@ -46,27 +35,19 @@ class AiGenerateInstructionsCommandTest extends TestCase if (file_exists(self::$ou)) { unlink(self::$ou); } - if (file_exists($this->runwayCredsFile)) { - unlink($this->runwayCredsFile); - } - @unlink($this->baseDir . '.github/copilot-instructions.md'); - @unlink($this->baseDir . '.cursor/rules/project-overview.mdc'); - @unlink($this->baseDir . '.windsurfrules'); - @rmdir($this->baseDir . '.github'); - @rmdir($this->baseDir . '.cursor/rules'); - @rmdir($this->baseDir . '.cursor'); - if (is_dir($this->baseDir . '.cursor/rules')) { - @rmdir($this->baseDir . '.cursor/rules'); - } - if (is_dir($this->baseDir . '.cursor')) { - @rmdir($this->baseDir . '.cursor'); - } - if (is_dir($this->baseDir . '.github')) { - @rmdir($this->baseDir . '.github'); + $this->recursiveRmdir($this->baseDir); + } + + protected function recursiveRmdir($dir) + { + if (!is_dir($dir)) { + return; } - if (is_dir($this->baseDir)) { - @rmdir($this->baseDir); + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->recursiveRmdir("$dir/$file") : unlink("$dir/$file"); } + return rmdir($dir); } protected function newApp($command): Application @@ -74,7 +55,7 @@ class AiGenerateInstructionsCommandTest extends TestCase $app = new Application('test', '0.0.1', function ($exitCode) { return $exitCode; }); - $app->io(new Interactor(self::$in, self::$ou)); + $app->io(new NoExitInteractor(self::$in, self::$ou)); $app->add($command); return $app; } @@ -84,22 +65,51 @@ class AiGenerateInstructionsCommandTest extends TestCase file_put_contents(self::$in, implode("\n", $lines) . "\n"); } - public function testFailsIfCredsFileMissing() + protected function setProjectRoot($command, $path) + { + $reflection = new \ReflectionClass(get_class($command)); + $property = null; + $currentClass = $reflection; + while ($currentClass && !$property) { + try { + $property = $currentClass->getProperty('projectRoot'); + } catch (\ReflectionException $e) { + $currentClass = $currentClass->getParentClass(); + } + } + if ($property) { + $property->setAccessible(true); + $property->setValue($command, $path); + } + } + + public function testFailsIfAiConfigMissing() { $this->setInput([ - 'desc', 'none', 'latte', 'y', 'y', 'none', 'Docker', '1', 'n', 'no' + 'desc', + 'none', + 'latte', + 'y', + 'y', + 'none', + 'Docker', + '1', + 'n', + 'no' ]); + // Provide 'runway' with dummy data to avoid deprecated configFile logic $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([['runway' => ['dummy' => true]]]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); + $this->assertStringContainsString('Missing AI configuration', file_get_contents(self::$ou)); } public function testWritesInstructionsToFiles() @@ -109,14 +119,28 @@ class AiGenerateInstructionsCommandTest extends TestCase 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - file_put_contents($this->runwayCredsFile, json_encode($creds)); $this->setInput([ - 'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' ]); $mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker."; $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $cmd->expects($this->once()) ->method('callLlmApi') ->willReturn(json_encode([ @@ -126,17 +150,14 @@ class AiGenerateInstructionsCommandTest extends TestCase ])); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(0, $result); $this->assertFileExists($this->baseDir . '.github/copilot-instructions.md'); $this->assertFileExists($this->baseDir . '.cursor/rules/project-overview.mdc'); + $this->assertFileExists($this->baseDir . '.gemini/GEMINI.md'); $this->assertFileExists($this->baseDir . '.windsurfrules'); - $this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.github/copilot-instructions.md')); - $this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.cursor/rules/project-overview.mdc')); - $this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.windsurfrules')); } public function testNoInstructionsReturnedFromLlm() @@ -146,13 +167,27 @@ class AiGenerateInstructionsCommandTest extends TestCase 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - file_put_contents($this->runwayCredsFile, json_encode($creds)); $this->setInput([ - 'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' ]); $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $cmd->expects($this->once()) ->method('callLlmApi') ->willReturn(json_encode([ @@ -162,12 +197,10 @@ class AiGenerateInstructionsCommandTest extends TestCase ])); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); } public function testLlmApiCallFails() @@ -177,23 +210,83 @@ class AiGenerateInstructionsCommandTest extends TestCase 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - file_put_contents($this->runwayCredsFile, json_encode($creds)); $this->setInput([ - 'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' ]); $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $cmd->expects($this->once()) ->method('callLlmApi') ->willReturn(false); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); + } + + public function testUsesDeprecatedConfigFile() + { + $creds = [ + 'ai' => [ + 'api_key' => 'key', + 'model' => 'gpt-4o', + 'base_url' => 'https://api.openai.com', + ] + ]; + $configFile = $this->baseDir . 'old-config.json'; + file_put_contents($configFile, json_encode($creds)); + $this->setInput([ + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' + ]); + $mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker."; + // runway key is MISSING from config to trigger deprecated logic + $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['callLlmApi']) + ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); + $cmd->expects($this->once()) + ->method('callLlmApi') + ->willReturn(json_encode([ + 'choices' => [ + ['message' => ['content' => $mockInstructions]] + ] + ])); + $app = $this->newApp($cmd); + $result = $app->handle([ + 'runway', + 'ai:generate-instructions', + '--config-file=' . $configFile + ]); + $this->assertSame(0, $result); + $this->assertStringContainsString('The --config-file option is deprecated', file_get_contents(self::$ou)); + $this->assertFileExists($this->baseDir . '.github/copilot-instructions.md'); } } diff --git a/tests/commands/AiInitCommandTest.php b/tests/commands/AiInitCommandTest.php index 1e7dd51..cb46c55 100644 --- a/tests/commands/AiInitCommandTest.php +++ b/tests/commands/AiInitCommandTest.php @@ -13,9 +13,6 @@ class AiInitCommandTest extends TestCase { protected static $in; protected static $ou; - protected $baseDir; - protected $runwayCredsFile; - protected $gitignoreFile; public function setUp(): void { @@ -23,15 +20,6 @@ class AiInitCommandTest extends TestCase self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt'; file_put_contents(self::$in, ''); file_put_contents(self::$ou, ''); - $this->baseDir = getcwd() . DIRECTORY_SEPARATOR; - $this->runwayCredsFile = __DIR__ . DIRECTORY_SEPARATOR . 'dummy-creds-' . uniqid('', true) . '.json'; - $this->gitignoreFile = __DIR__ . DIRECTORY_SEPARATOR . 'dummy-gitignore-' . uniqid('', true); - if (file_exists($this->runwayCredsFile)) { - unlink($this->runwayCredsFile); - } - if (file_exists($this->gitignoreFile)) { - unlink($this->gitignoreFile); - } } public function tearDown(): void @@ -42,24 +30,15 @@ class AiInitCommandTest extends TestCase if (file_exists(self::$ou)) { unlink(self::$ou); } - if (file_exists($this->runwayCredsFile)) { - if (is_dir($this->runwayCredsFile)) { - rmdir($this->runwayCredsFile); - } else { - unlink($this->runwayCredsFile); - } - } - if (file_exists($this->gitignoreFile)) { - unlink($this->gitignoreFile); - } } - protected function newApp(): Application + protected function newApp($command): Application { $app = new Application('test', '0.0.1', function ($exitCode) { return $exitCode; }); $app->io(new Interactor(self::$in, self::$ou)); + $app->add($command); return $app; } @@ -68,135 +47,55 @@ class AiInitCommandTest extends TestCase file_put_contents(self::$in, implode("\n", $lines) . "\n"); } - public function testInitCreatesCredsAndGitignore() + public function testInitSavesCreds() { $this->setInput([ - '1', // provider + '1', // provider (openai) '', // accept default base url 'test-key', // api key '', // accept default model ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $this->assertFileExists($this->runwayCredsFile); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('openai', $creds['provider']); - $this->assertSame('test-key', $creds['api_key']); - $this->assertSame('gpt-4o', $creds['model']); - $this->assertSame('https://api.openai.com', $creds['base_url']); - $this->assertFileExists($this->gitignoreFile); - $this->assertStringContainsString(basename($this->runwayCredsFile), file_get_contents($this->gitignoreFile)); - } - - public function testInitWithExistingCredsNoOverwrite() - { - file_put_contents($this->runwayCredsFile, '{}'); - $this->setInput([ - 'n', // do not overwrite - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->once()) + ->method('setRunwayConfigValue') + ->with('ai', [ + 'provider' => 'openai', + 'api_key' => 'test-key', + 'model' => 'gpt-5', + 'base_url' => 'https://api.openai.com', + ]); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(0, $result); - $this->assertSame('{}', file_get_contents($this->runwayCredsFile)); + $this->assertStringContainsString('Credentials saved', file_get_contents(self::$ou)); } - public function testInitWithExistingCredsOverwrite() + public function testInitWithGrokProvider() { - file_put_contents($this->runwayCredsFile, '{}'); $this->setInput([ - 'y', // overwrite - '2', // provider + '2', // provider (grok) '', // accept default base url 'grok-key', // api key '', // accept default model ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->once()) + ->method('setRunwayConfigValue') + ->with('ai', [ + 'provider' => 'grok', + 'api_key' => 'grok-key', + 'model' => 'grok-4.1-fast-non-reasoning', + 'base_url' => 'https://api.x.ai', + ]); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(0, $result); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('grok', $creds['provider']); - $this->assertSame('grok-key', $creds['api_key']); - $this->assertSame('grok-3-beta', $creds['model']); - $this->assertSame('https://api.x.ai', $creds['base_url']); - } - - public function testEmptyApiKeyPromptsAgain() - { - $this->setInput([ - '1', - '', // accept default base url - '', // empty api key, should error and exit - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->runwayCredsFile); - } - - public function testEmptyModelPrompts() - { - $this->setInput([ - '1', - '', - 'key', - '', // accept default model (should use default) - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('gpt-4o', $creds['model']); - } - - public function testGitignoreAlreadyHasCreds() - { - file_put_contents($this->gitignoreFile, basename($this->runwayCredsFile) . "\n"); - $this->setInput([ - '1', - '', - 'key', - '', - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $this->assertFileExists($this->gitignoreFile); - $lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES); - $this->assertContains(basename($this->runwayCredsFile), $lines); - $this->assertCount(1, array_filter($lines, function ($l) { - return trim($l) === basename($this->runwayCredsFile); - })); } public function testInitWithClaudeProvider() @@ -207,44 +106,41 @@ class AiInitCommandTest extends TestCase 'claude-key', // api key '', // accept default model ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->once()) + ->method('setRunwayConfigValue') + ->with('ai', [ + 'provider' => 'claude', + 'api_key' => 'claude-key', + 'model' => 'claude-sonnet-4-5', + 'base_url' => 'https://api.anthropic.com', + ]); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(0, $result); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('claude', $creds['provider']); - $this->assertSame('claude-key', $creds['api_key']); - $this->assertSame('claude-3-opus', $creds['model']); - $this->assertSame('https://api.anthropic.com', $creds['base_url']); } - public function testAddsCredsFileToExistingGitignoreIfMissing() + public function testEmptyApiKeyFails() { - // .gitignore exists but does not contain creds file - file_put_contents($this->gitignoreFile, "vendor\nnode_modules\n.DS_Store\n"); $this->setInput([ - '1', // provider + '1', '', // accept default base url - 'test-key', // api key - '', // accept default model - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES); - $this->assertContains(basename($this->runwayCredsFile), $lines); - $this->assertCount(1, array_filter($lines, function ($l) { - return trim($l) === basename($this->runwayCredsFile); - })); + '', // empty api key + ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->never()) + ->method('setRunwayConfigValue'); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); + // Since $io->error(..., true) exits, Ahc\Cli will return the exit code. + // If it exits with 1, it should be 1. + $this->assertSame(1, $result); } public function testInvalidBaseUrlFails() @@ -253,14 +149,14 @@ class AiInitCommandTest extends TestCase '1', // provider 'not-a-valid-url', // invalid base url ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->never()) + ->method('setRunwayConfigValue'); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->runwayCredsFile); } }