diff --git a/CHANGELOG.md b/CHANGELOG.md index f646ddd..1321ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The editor prompts through `laranail/console` instead of `laravel/prompts`.** + `laranail::env-kit.edit` imported `select()`, `text()`, `confirm()` and `SelectPrompt` from + `laravel/prompts` without declaring the package; it arrived only because `laranail/console` + requires it. The prompts now go through console's `Prompter`, and the can-prompt check is + console's `Prompter::canPrompt()`, which carries the same rule. Behaviour is unchanged: the + same prompts, the same answers, and the same exit `2` without a terminal. An architecture test + fails if `src/` uses `Laravel\Prompts` again. +- **Requires `laranail/console ^0.1.7`**, the first release with `Prompter::canPrompt()` + (raised from `^0.1`). - `illuminate/bus` is now declared in `require` at `^13.0`. `src/` imports it, and it was only arriving transitively. - `composer style` runs the shared Pint config (`@pint`, i.e. `laranail-pint --test`). It ran a bare `pint --test`, which ignores `vendor/laranail/package-tools/pint.json` and so checked a diff --git a/composer.json b/composer.json index 752a228..e62f789 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "require": { "php": "^8.4.1 || ^8.5", "ext-mbstring": "*", - "laranail/console": "^0.1", + "laranail/console": "^0.1.7", "laranail/package-tools": "^0.1", "illuminate/auth": "^13.0", "illuminate/bus": "^13.0", diff --git a/docs/tools/tui.md b/docs/tools/tui.md index 7450581..ad8efb4 100644 --- a/docs/tools/tui.md +++ b/docs/tools/tui.md @@ -1,6 +1,7 @@ # TUI -An interactive, full-screen `.env` editor built on `laravel/prompts`. +An interactive, full-screen `.env` editor built on `laranail/console`'s `Prompter`, which wraps +`laravel/prompts`. ```bash php artisan laranail::env-kit.edit @@ -34,6 +35,16 @@ is not a terminal (a cron job, a CI step, a pipe), it changes nothing and exits `2`, naming the commands that work without one: `laranail::env-kit.set`, `laranail::env-kit.unset` and `laranail::env-kit.rename`. +The check is `laranail/console`'s `Prompter::canPrompt()`: it answers yes when Laravel routes +prompts through its fakeable question-helper fallback (on Windows, and under the test harness), +and otherwise only when stdin is a terminal. + +## Prompts go through `laranail/console` + +env-kit does not declare `laravel/prompts` and never imports it: every prompt goes through +`laranail/console`'s `Prompter`, which does. `tests/Unit/PromptsBoundaryTest.php` fails if a file +under `src/` names `Laravel\Prompts`. + --- [← Docs index](../../README.md#documentation) diff --git a/src/Console/EditCommand.php b/src/Console/EditCommand.php index 0642286..ce3013e 100644 --- a/src/Console/EditCommand.php +++ b/src/Console/EditCommand.php @@ -5,21 +5,13 @@ namespace Simtabi\Laranail\EnvKit\Headless\Console; use Closure; - -use function defined; - -use Laravel\Prompts\SelectPrompt; - -use function Laravel\Prompts\text; -use function Laravel\Prompts\select; -use function Laravel\Prompts\confirm; - use Simtabi\Laranail\EnvKit\Headless\EnvKit; +use Simtabi\Laranail\Console\Prompter\Prompter; use Simtabi\Laranail\EnvKit\Headless\Exceptions\EnvKitException; /** - * The interactive TUI (`env:edit`): a browse/edit loop on laravel/prompts (the - * UI layer of laranail/console). Pure orchestration — every mutation goes + * The interactive TUI (`env:edit`): a browse/edit loop on laranail/console's + * {@see Prompter}, which wraps laravel/prompts. Pure orchestration — every mutation goes * through the same {@see EnvKit} engine, so atomicity/guards/audit/events all * apply. Engine errors are shown inline and the loop keeps running. * @@ -44,7 +36,7 @@ final class EditCommand extends AbstractEnvCommand public function handle(EnvKit $env): int { - if (! $this->canPrompt()) { + if (! Prompter::canPrompt($this->input)) { return $this->failWith( 'laranail::env-kit.edit needs an interactive terminal. To change keys without one, use ' . 'laranail::env-kit.set, laranail::env-kit.unset or laranail::env-kit.rename.', @@ -60,7 +52,7 @@ public function handle(EnvKit $env): int } while (true) { - $choice = (string) select('Choose a key or action', [...$target->keys(), self::ADD, self::QUIT]); + $choice = $this->choose('Choose a key or action', [...$target->keys(), self::ADD, self::QUIT]); if ($choice === self::QUIT) { break; @@ -75,25 +67,30 @@ public function handle(EnvKit $env): int } /** - * Whether the editor's prompts can reach a person. + * One menu choice. A fresh {@see Prompter} per prompt, so one answer never + * stands in for another. * - * Not when input is non-interactive: `--no-interaction`, or Symfony found no - * terminal on stdin. Yes when Laravel answers prompts through its question-helper - * fallback (Windows, and the test harness). Otherwise only when stdin is a terminal, - * the check laravel/prompts makes before it falls back to each prompt's default -- - * which for the editor's menu is empty, and fails as "Required.". + * @param list $options */ - private function canPrompt(): bool + private function choose(string $label, array $options): string { - if (! $this->input->isInteractive()) { - return false; - } + $choice = Prompter::create()->select($label, $options)->getResult(); - if (SelectPrompt::shouldFallback()) { - return true; - } + return is_string($choice) || is_int($choice) ? (string) $choice : ''; + } + + /** One line of text, through a fresh {@see Prompter}. */ + private function askText(string $label, bool $required = false, string $default = ''): string + { + $answer = Prompter::create()->text($label, default: $default, required: $required)->getResult(); - return defined('STDIN') && stream_isatty(STDIN); + return is_string($answer) ? $answer : ''; + } + + /** A yes/no question that defaults to no, through a fresh {@see Prompter}. */ + private function confirmNo(string $label): bool + { + return Prompter::create()->confirm($label, default: false)->getResult() === true; } /** Re-arm the production override before each mutation (each commit consumes it). */ @@ -108,14 +105,14 @@ private function allowing(EnvKit $env): EnvKit private function addKey(EnvKit $env): void { - $key = trim(text('New key name', required: true)); - $this->allowing($env)->set($key, text("Value for {$key}")); + $key = trim($this->askText('New key name', required: true)); + $this->allowing($env)->set($key, $this->askText("Value for {$key}")); $this->info("Set [{$key}]."); } private function editKey(EnvKit $env, string $key): void { - match ((string) select("Edit [{$key}]", ['Edit value', 'Rename', 'Delete', 'Back'])) { + match ($this->choose("Edit [{$key}]", ['Edit value', 'Rename', 'Delete', 'Back'])) { 'Edit value' => $this->editValue($env, $key), 'Rename' => $this->renameKey($env, $key), 'Delete' => $this->deleteKey($env, $key), @@ -125,20 +122,20 @@ private function editKey(EnvKit $env, string $key): void private function editValue(EnvKit $env, string $key): void { - $this->allowing($env)->set($key, text("New value for [{$key}]", default: $env->getString($key) ?? '')); + $this->allowing($env)->set($key, $this->askText("New value for [{$key}]", default: $env->getString($key) ?? '')); $this->info("Updated [{$key}]."); } private function renameKey(EnvKit $env, string $key): void { - $to = trim(text("Rename [{$key}] to", required: true)); + $to = trim($this->askText("Rename [{$key}] to", required: true)); $this->allowing($env)->rename($key, $to); $this->info("Renamed [{$key}] to [{$to}]."); } private function deleteKey(EnvKit $env, string $key): void { - if (confirm("Delete [{$key}]?", default: false)) { + if ($this->confirmNo("Delete [{$key}]?")) { $this->allowing($env)->forget($key); $this->info("Removed [{$key}]."); } diff --git a/tests/Feature/TuiTest.php b/tests/Feature/TuiTest.php index ab4e7cb..4de8064 100644 --- a/tests/Feature/TuiTest.php +++ b/tests/Feature/TuiTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -use Laravel\Prompts\Prompt; use Simtabi\Laranail\EnvKit\Headless\Facades\EnvKit; use Simtabi\Laranail\EnvKit\Headless\Tests\TestCase; +use Simtabi\Laranail\Console\Testing\InteractsWithConsole; use Simtabi\Laranail\EnvKit\Headless\Contracts\EnvKitInterface; -uses(TestCase::class); +uses(TestCase::class, InteractsWithConsole::class); it('quits immediately from the editor', function () { $this->bindEnv("A=1\n"); @@ -110,7 +110,7 @@ // The fallback flag is sticky (fallbackWhen ORs), so this only passed when an // earlier test had already set it -- and failed when run alone or reordered, // as the mutation job does. Set it here so the test stands on its own. - Prompt::fallbackWhen(true); + $this->withPromptFallback(); $this->artisan('laranail::env-kit.edit', ['--force-production' => true]) ->expectsQuestion('Choose a key or action', 'A') diff --git a/tests/Unit/PromptsBoundaryTest.php b/tests/Unit/PromptsBoundaryTest.php new file mode 100644 index 0000000..f5b3918 --- /dev/null +++ b/tests/Unit/PromptsBoundaryTest.php @@ -0,0 +1,37 @@ +expect('Simtabi\Laranail\EnvKit\Headless') + ->not->toUse('Laravel\Prompts'); + +it('names Laravel\Prompts in no file under src/', function () { + // The arch rule reads resolved names. This is the crude check beside it: a raw + // substring over every PHP file, so an import the rule cannot see still fails. + $files = new RegexIterator( + new RecursiveIteratorIterator(new RecursiveDirectoryIterator(dirname(__DIR__, 2) . '/src')), + '/\.php$/', + ); + + $inspected = 0; + $offenders = []; + + foreach ($files as $file) { + $inspected++; + + if (str_contains((string) file_get_contents((string) $file), 'Laravel\Prompts')) { + $offenders[] = (string) $file; + } + } + + // A glob that stops matching would report a clean tree; src/ holds well over 100. + expect($inspected)->toBeGreaterThan(100) + ->and($offenders)->toBe([]); +});