Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion docs/tools/tui.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
63 changes: 30 additions & 33 deletions src/Console/EditCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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.',
Expand All @@ -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;
Expand All @@ -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<string> $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). */
Expand All @@ -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),
Expand All @@ -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}].");
}
Expand Down
6 changes: 3 additions & 3 deletions tests/Feature/TuiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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')
Expand Down
37 changes: 37 additions & 0 deletions tests/Unit/PromptsBoundaryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

/*
* env-kit prompts through laranail/console, which wraps laravel/prompts. It does
* not declare laravel/prompts, so a direct import would lean on a package it never
* asked for and bypass the wrapper's API, such as Prompter::canPrompt().
*/

arch('src/ does not use laravel/prompts directly')
->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([]);
});
Loading