From 19566644f1e4ad4de2e1cfe77028a40103f11326 Mon Sep 17 00:00:00 2001 From: Joe Jollands Date: Sun, 15 Feb 2026 10:54:02 +0000 Subject: [PATCH] Harden interactive input and gate demo command registration --- README.md | 6 +- config/tart.php | 12 ++++ src/Concerns/HasEnhancedInput.php | 12 ++-- src/Laravel/TartServiceProvider.php | 28 ++++++--- src/Support/MultiSelect.php | 58 +++++++++++-------- src/Support/Select.php | 50 +++++++++------- tests/Integration/TartServiceProviderTest.php | 13 ++++- tests/Unit/SelectTest.php | 13 +++++ 8 files changed, 130 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 8e26718..4247fd4 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ TART ships with a default theme, but you can customize colors, highlight behavio ### Theme Options -- `class` - The theme class to use (defaults to `IGC\Tart\Themes\Theme`) +- `class` - The theme class to use (defaults to `IGC\Tart\Themes\DefaultTheme`) - `color` - Primary color used by blocks and accents (default: `blue`) - `text_color` - Default text color inside themed blocks (default: `white`) - `highlight_color` - Accent/highlight color (default: `yellow`) @@ -263,13 +263,13 @@ Need manual control? Add TART to Composer's `dont-discover` list and register th #### Publish Configuration -Publish the default configuration to tweak the base theme, logo colors, or auto-answer behavior: +Publish the default configuration to tweak the base theme, logo colors, auto-answer behavior, and demo-command registration: ```bash php artisan vendor:publish --tag=tart-config ``` -`config/tart.php` lets you point to a custom `ThemeInterface` implementation or adjust the palette/width used by the bundled `Theme` class. +`config/tart.php` lets you point to a custom `ThemeInterface` implementation, adjust palette/width used by the bundled `Theme` class, and control `register_demo_commands` (defaults to `false`). ## 📖 Core Features diff --git a/config/tart.php b/config/tart.php index 15c64e5..4232c90 100644 --- a/config/tart.php +++ b/config/tart.php @@ -15,6 +15,18 @@ */ 'auto_answer' => false, + + /* + |-------------------------------------------------------------------------- + | Demo Command Registration + |-------------------------------------------------------------------------- + | + | Controls whether package demo commands are registered with Artisan. + | Disable in production to keep your command namespace clean. + | + */ + 'register_demo_commands' => false, + /* |-------------------------------------------------------------------------- | Theme Defaults diff --git a/src/Concerns/HasEnhancedInput.php b/src/Concerns/HasEnhancedInput.php index 696c510..504adf9 100644 --- a/src/Concerns/HasEnhancedInput.php +++ b/src/Concerns/HasEnhancedInput.php @@ -4,6 +4,7 @@ use Closure; use IGC\Tart\Support\FrameRenderer; +use IGC\Tart\Support\TerminalMode; trait HasEnhancedInput { @@ -90,13 +91,16 @@ public function password(string $question): string */ protected function getHiddenInputUnix(): string { - $sttyMode = shell_exec('stty -g'); + $terminal = new TerminalMode(); + $terminal->enableRawMode(); shell_exec('stty -echo'); - $answer = trim((string) fgets(STDIN)); - shell_exec(sprintf('stty %s', $sttyMode)); - return $answer; + try { + return trim((string) fgets(STDIN)); + } finally { + $terminal->restore(); + } } /** diff --git a/src/Laravel/TartServiceProvider.php b/src/Laravel/TartServiceProvider.php index e9253d3..bd9c182 100644 --- a/src/Laravel/TartServiceProvider.php +++ b/src/Laravel/TartServiceProvider.php @@ -29,14 +29,26 @@ public function boot(): void self::CONFIG_PATH => $this->configPath(), ], 'tart-config'); - $this->commands([ - Commands\TartDemoCommand::class, - Commands\TartDemoFullCommand::class, - Commands\TartFluentDemoCommand::class, - Commands\TartTestCommand::class, - Commands\TartNewFeaturesDemo::class, - Commands\TartInteractiveDemoCommand::class, - ]); + if ($this->shouldRegisterDemoCommands()) { + $this->commands([ + Commands\TartDemoCommand::class, + Commands\TartDemoFullCommand::class, + Commands\TartFluentDemoCommand::class, + Commands\TartTestCommand::class, + Commands\TartNewFeaturesDemo::class, + Commands\TartInteractiveDemoCommand::class, + ]); + } + } + + protected function shouldRegisterDemoCommands(): bool + { + $configured = $this->app['config']->get('tart.register_demo_commands'); + if ($configured !== null) { + return (bool) $configured; + } + + return $this->app->environment(['local', 'testing']); } protected function configPath(): string diff --git a/src/Support/MultiSelect.php b/src/Support/MultiSelect.php index 10d3c00..ca5e0d1 100644 --- a/src/Support/MultiSelect.php +++ b/src/Support/MultiSelect.php @@ -117,47 +117,55 @@ protected function render(array $keys, array $values): void */ protected function readInput(array $keys, array $values): array { - $sttyMode = shell_exec('stty -g'); + $terminal = new TerminalMode(); + $input = new TerminalInput(); - system('stty -icanon -echo'); + $terminal->enableRawMode(); - while (true) { - $char = fread(STDIN, 1); + try { + while (true) { + $keyPress = $input->readKey(); - if ($char === "\n") { - if ($this->minRequired > 0 && count($this->selected) < $this->minRequired) { + if ($keyPress === null) { continue; } - break; - } - if ($char === ' ') { - $key = $keys[$this->cursorIndex]; - $index = array_search($key, $this->selected, true); + if ($keyPress->type === KeyPress::ENTER) { + if ($this->minRequired > 0 && count($this->selected) < $this->minRequired) { + continue; + } - if ($index !== false) { - unset($this->selected[$index]); - $this->selected = array_values($this->selected); - } else { - $this->selected[] = $key; + break; } - $this->updateDisplay($keys, $values); - } elseif ($char === "\033") { - $arrow = fread(STDIN, 2); + if ($keyPress->type === KeyPress::SPACE) { + $key = $keys[$this->cursorIndex]; + $index = array_search($key, $this->selected, true); + + if ($index !== false) { + unset($this->selected[$index]); + $this->selected = array_values($this->selected); + } else { + $this->selected[] = $key; + } + + $this->updateDisplay($keys, $values); + continue; + } - if ($arrow === '[A') { + if ($keyPress->type === KeyPress::UP) { $this->cursorIndex = max(0, $this->cursorIndex - 1); $this->updateDisplay($keys, $values); - } elseif ($arrow === '[B') { + continue; + } + + if ($keyPress->type === KeyPress::DOWN) { $this->cursorIndex = min(count($this->options) - 1, $this->cursorIndex + 1); $this->updateDisplay($keys, $values); } } - } - - if ($sttyMode !== null && $sttyMode !== false) { - system("stty {$sttyMode}"); + } finally { + $terminal->restore(); } return $this->selected; diff --git a/src/Support/Select.php b/src/Support/Select.php index af350a7..b4a2f7a 100644 --- a/src/Support/Select.php +++ b/src/Support/Select.php @@ -4,6 +4,7 @@ use IGC\Tart\Contracts\ThemeInterface; use IGC\Tart\Themes\DefaultTheme; +use RuntimeException; use Symfony\Component\Console\Output\OutputInterface; class Select @@ -56,6 +57,10 @@ public function setRequired(bool $required): self public function ask(): ?string { if (empty($this->options)) { + if ($this->required) { + throw new RuntimeException('Selection is required but no options were provided.'); + } + return null; } @@ -105,42 +110,45 @@ protected function render(array $keys, array $values): void */ protected function readInput(array $keys): ?string { - $sttyMode = shell_exec('stty -g'); + $terminal = new TerminalMode(); + $input = new TerminalInput(); - system('stty -icanon -echo'); + $terminal->enableRawMode(); - while (true) { - $char = fread(STDIN, 3); - - if ($char === "\n") { - break; - } - - if ($char === "\033") { - $arrow = fread(STDIN, 2); + try { + while (true) { + $key = $input->readKey(); + if ($key === null) { + continue; + } - if ($arrow === '[A') { + if ($key->type === KeyPress::UP) { $this->selectedIndex = max(0, $this->selectedIndex - 1); - $this->updateDisplay($keys, array_values($this->options)); - } elseif ($arrow === '[B') { + $this->updateDisplay(array_values($this->options)); + continue; + } + + if ($key->type === KeyPress::DOWN) { $this->selectedIndex = min(count($this->options) - 1, $this->selectedIndex + 1); - $this->updateDisplay($keys, array_values($this->options)); + $this->updateDisplay(array_values($this->options)); + continue; } - } - } - if ($sttyMode !== null && $sttyMode !== false) { - system("stty {$sttyMode}"); + if ($key->type === KeyPress::ENTER || $key->type === KeyPress::ESCAPE) { + break; + } + } + } finally { + $terminal->restore(); } return $keys[$this->selectedIndex] ?? null; } /** - * @param array $keys * @param array $values */ - protected function updateDisplay(array $keys, array $values): void + protected function updateDisplay(array $values): void { $this->output->write(TerminalControl::moveUp(count($this->options))); diff --git a/tests/Integration/TartServiceProviderTest.php b/tests/Integration/TartServiceProviderTest.php index 5cc99c9..17e9cbd 100644 --- a/tests/Integration/TartServiceProviderTest.php +++ b/tests/Integration/TartServiceProviderTest.php @@ -14,8 +14,19 @@ protected function getPackageProviders($app) ]; } - public function test_demo_command_is_available(): void + public function test_demo_command_is_available_when_enabled(): void { + $this->app['config']->set('tart.register_demo_commands', true); + $this->artisan('tart:demo')->assertExitCode(0); } + + public function test_demo_command_is_not_registered_when_disabled(): void + { + $this->app['config']->set('tart.register_demo_commands', false); + + $this->artisan('tart:demo') + ->expectsOutputToContain('Command "tart:demo" is not defined') + ->assertExitCode(1); + } } diff --git a/tests/Unit/SelectTest.php b/tests/Unit/SelectTest.php index e3a420f..6e6190f 100644 --- a/tests/Unit/SelectTest.php +++ b/tests/Unit/SelectTest.php @@ -4,6 +4,7 @@ use IGC\Tart\Support\Select; use PHPUnit\Framework\TestCase; +use RuntimeException; use Symfony\Component\Console\Output\BufferedOutput; class SelectTest extends TestCase @@ -49,6 +50,18 @@ public function test_select_returns_null_for_empty_options(): void $this->assertNull($result); } + + public function test_select_required_throws_when_options_are_empty(): void + { + $this->expectException(RuntimeException::class); + + $output = new BufferedOutput(); + + $select = new Select($output, 'Choose option', []); + $select->setRequired(true); + $select->ask(); + } + public function test_select_can_set_required(): void { $output = new BufferedOutput();