From 218a5ba0322b5234bdba52170bd776a9faf23fa0 Mon Sep 17 00:00:00 2001 From: Bipin Kareparambil Date: Sun, 19 Apr 2026 23:07:59 +0400 Subject: [PATCH] feat(login): support --token-file and long tokens via non-canonical stdin Forge API tokens can exceed the kernel's MAX_CANON limit (~1024 bytes), so pasting them into the interactive `forge login` prompt silently truncates the value. This adds three additional input paths and fixes the prompt: * `--token-file=PATH` reads the token from a file (recommended for long tokens; also keeps the token out of shell history). * `FORGE_API_TOKEN` env var fallback, matching Laravel Forge SDK's convention. * Resolution precedence: `--token` > `--token-file` > env > prompt. When the prompt is used and STDIN is a TTY on a POSIX system, a small `TtyReader` service temporarily switches the terminal into non-canonical mode (`stty -icanon min 1`), captured/restored via `stty -g`, so long input is not truncated by the line discipline. The toggle is guarded by `posix_isatty(STDIN)`, a Windows check, and `stty` availability, and is wrapped in try/finally so the terminal state is always restored. The service is injectable and mockable for testing. The resolved token is trimmed and rejected if empty. If it came from the prompt and is >= 1023 bytes the user is warned that the input may have been truncated and is pointed at `--token-file`. Adds Unit coverage for the file/env/prompt resolution, missing-file errors, the truncation warning branch, and the TtyReader's toggle/restore behavior. The stty path itself is not exercised in CI. --- app/Commands/LoginCommand.php | 76 ++++++++++- app/Support/TtyReader.php | 149 ++++++++++++++++++++++ tests/Unit/Commands/LoginCommandTest.php | 127 +++++++++++++++++++ tests/Unit/Support/TtyReaderTest.php | 155 +++++++++++++++++++++++ 4 files changed, 501 insertions(+), 6 deletions(-) create mode 100644 app/Support/TtyReader.php create mode 100644 tests/Unit/Commands/LoginCommandTest.php create mode 100644 tests/Unit/Support/TtyReaderTest.php diff --git a/app/Commands/LoginCommand.php b/app/Commands/LoginCommand.php index ae3a43d..a3effe9 100644 --- a/app/Commands/LoginCommand.php +++ b/app/Commands/LoginCommand.php @@ -2,6 +2,8 @@ namespace App\Commands; +use App\Support\TtyReader; + class LoginCommand extends Command { /** @@ -9,26 +11,36 @@ class LoginCommand extends Command * * @var string */ - protected $signature = 'login {--token= : Forge API token}'; + protected $signature = 'login + {--token= : Forge API token} + {--token-file= : Path to a file containing the Forge API token (recommended for long tokens)}'; /** * The description of the command. * * @var string */ - protected $description = 'Authenticate with Laravel Forge'; + protected $description = 'Authenticate with Laravel Forge (accepts --token, --token-file, the FORGE_API_TOKEN env var, or an interactive prompt)'; /** * Execute the console command. * + * @param \App\Support\TtyReader $tty * @return void */ - public function handle() + public function handle(TtyReader $tty) { - $token = $this->option('token'); + [$token, $source] = $this->resolveToken($tty); + + $token = trim((string) $token); - if ($token === null) { - $token = $this->askStep('Please enter your Forge API token'); + abort_if($token === '', 1, 'A Forge API token is required.'); + + if ($source === 'prompt' && strlen($token) >= 1023) { + $this->warnStep( + 'The pasted token may have been truncated by the terminal (input was 1023+ bytes). '. + 'Re-run with --token-file=PATH or set FORGE_API_TOKEN.' + ); } $this->config->set('token', $token); @@ -40,6 +52,58 @@ public function handle() $this->successfulStep("Authenticated successfully as [$email]"); } + /** + * Resolve the API token from the highest-priority available source. + * + * Precedence: --token > --token-file > FORGE_API_TOKEN > interactive prompt. + * + * @param \App\Support\TtyReader $tty + * @return array{0: string, 1: string} [token, source] + */ + protected function resolveToken(TtyReader $tty) + { + $flag = $this->option('token'); + + if ($flag !== null && $flag !== '') { + return [$flag, 'flag']; + } + + $file = $this->option('token-file'); + + if ($file !== null && $file !== '') { + return [$this->readTokenFile($file), 'file']; + } + + $env = getenv('FORGE_API_TOKEN'); + + if ($env !== false && $env !== '') { + return [$env, 'env']; + } + + $prompted = $tty->read(function () { + return $this->askStep('Please enter your Forge API token'); + }); + + return [$prompted, 'prompt']; + } + + /** + * Read the token from the given file path. + * + * @param string $path + * @return string + */ + protected function readTokenFile($path) + { + abort_if(! is_file($path) || ! is_readable($path), 1, "Unable to read token file: [{$path}]"); + + $contents = @file_get_contents($path); + + abort_if($contents === false, 1, "Unable to read token file: [{$path}]"); + + return $contents; + } + /** * Gets user's email. * diff --git a/app/Support/TtyReader.php b/app/Support/TtyReader.php new file mode 100644 index 0000000..dc96c5b --- /dev/null +++ b/app/Support/TtyReader.php @@ -0,0 +1,149 @@ +shouldDisableCanonicalMode()) { + return (string) $reader(); + } + + $previousState = $this->captureState(); + + if ($previousState === null) { + return (string) $reader(); + } + + try { + $this->disableCanonicalMode(); + + return (string) $reader(); + } finally { + $this->restoreState($previousState); + } + } + + /** + * Determine whether the terminal can be switched to non-canonical mode. + * + * @return bool + */ + protected function shouldDisableCanonicalMode() + { + if (PHP_OS_FAMILY === 'Windows') { + return false; + } + + if (! defined('STDIN') || ! function_exists('posix_isatty')) { + return false; + } + + try { + if (! @posix_isatty(STDIN)) { + return false; + } + } catch (Throwable $e) { + return false; + } + + return $this->sttyAvailable(); + } + + /** + * Determine whether the `stty` binary is available on this system. + * + * @return bool + */ + protected function sttyAvailable() + { + [$exitCode] = $this->runStty(['-g']); + + return $exitCode === 0; + } + + /** + * Capture the current terminal state, suitable for later restoration. + * + * @return string|null + */ + protected function captureState() + { + [$exitCode, $stdout] = $this->runStty(['-g']); + + if ($exitCode !== 0 || $stdout === '') { + return null; + } + + return trim($stdout); + } + + /** + * Switch the terminal into non-canonical mode (no line buffering / cap). + * + * @return void + */ + protected function disableCanonicalMode() + { + $this->runStty(['-icanon', 'min', '1']); + } + + /** + * Restore the terminal to a previously captured state. + * + * @param string $state + * @return void + */ + protected function restoreState($state) + { + $this->runStty([$state]); + } + + /** + * Invoke the `stty` binary with the given arguments via proc_open so we + * never go through a shell (no quoting, no injection surface). + * + * @param array $args + * @return array{0: int, 1: string} [exit code, stdout] + */ + protected function runStty(array $args) + { + $descriptors = [ + 0 => ['file', '/dev/tty', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = @proc_open( + array_merge(['stty'], $args), + $descriptors, + $pipes + ); + + if (! is_resource($process)) { + return [1, '']; + } + + $stdout = stream_get_contents($pipes[1]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + return [$exitCode, $stdout]; + } +} diff --git a/tests/Unit/Commands/LoginCommandTest.php b/tests/Unit/Commands/LoginCommandTest.php new file mode 100644 index 0000000..3ca8a08 --- /dev/null +++ b/tests/Unit/Commands/LoginCommandTest.php @@ -0,0 +1,127 @@ +config->flush(); + putenv('FORGE_API_TOKEN'); + + $this->client->shouldReceive('user')->andReturn((object) [ + 'email' => 'nuno@laravel.com', + ]); + + $this->client->shouldReceive('servers')->andReturn([ + (object) ['id' => 1], + ]); +}); + +afterEach(function () { + putenv('FORGE_API_TOKEN'); +}); + +it('reads the token from --token-file', function () { + $path = tempnam(sys_get_temp_dir(), 'forge-token-'); + file_put_contents($path, " filetoken-abc\n"); + + try { + $this->artisan("login --token-file={$path}") + ->expectsOutput('==> Authenticated Successfully As [nuno@laravel.com]'); + + expect($this->config->get('token'))->toBe('filetoken-abc'); + } finally { + @unlink($path); + } +}); + +it('errors when --token-file points at a missing path', function () { + $missing = sys_get_temp_dir().'/forge-token-does-not-exist-'.uniqid(); + + $this->artisan("login --token-file={$missing}"); +})->throws('Unable to read token file'); + +it('falls back to FORGE_API_TOKEN when no flag or file is provided', function () { + putenv('FORGE_API_TOKEN=envtoken-xyz'); + + $this->artisan('login') + ->expectsOutput('==> Authenticated Successfully As [nuno@laravel.com]'); + + expect($this->config->get('token'))->toBe('envtoken-xyz'); +}); + +it('prefers --token over FORGE_API_TOKEN', function () { + putenv('FORGE_API_TOKEN=envtoken-xyz'); + + $this->artisan('login --token=flagtoken') + ->expectsOutput('==> Authenticated Successfully As [nuno@laravel.com]'); + + expect($this->config->get('token'))->toBe('flagtoken'); +}); + +it('prefers --token-file over FORGE_API_TOKEN', function () { + putenv('FORGE_API_TOKEN=envtoken-xyz'); + + $path = tempnam(sys_get_temp_dir(), 'forge-token-'); + file_put_contents($path, 'filetoken-abc'); + + try { + $this->artisan("login --token-file={$path}") + ->expectsOutput('==> Authenticated Successfully As [nuno@laravel.com]'); + + expect($this->config->get('token'))->toBe('filetoken-abc'); + } finally { + @unlink($path); + } +}); + +it('prefers --token over --token-file', function () { + $path = tempnam(sys_get_temp_dir(), 'forge-token-'); + file_put_contents($path, 'filetoken-abc'); + + try { + $this->artisan("login --token=flagtoken --token-file={$path}") + ->expectsOutput('==> Authenticated Successfully As [nuno@laravel.com]'); + + expect($this->config->get('token'))->toBe('flagtoken'); + } finally { + @unlink($path); + } +}); + +it('warns when an interactively pasted token may have been truncated', function () { + $longToken = str_repeat('a', 1024); + + $this->artisan('login') + ->expectsQuestion( + 'Please Enter Your Forge API Token', + $longToken + ) + ->expectsOutputToContain('May Have Been Truncated'); + + expect($this->config->get('token'))->toBe($longToken); +}); + +it('does not warn about truncation when a long token comes from --token-file', function () { + $longToken = str_repeat('a', 2048); + + $path = tempnam(sys_get_temp_dir(), 'forge-token-'); + file_put_contents($path, $longToken); + + try { + $this->artisan("login --token-file={$path}") + ->doesntExpectOutputToContain('May Have Been Truncated'); + + expect($this->config->get('token'))->toBe($longToken); + } finally { + @unlink($path); + } +}); + +it('rejects an empty token', function () { + putenv('FORGE_API_TOKEN'); + + $this->artisan('login') + ->expectsQuestion( + 'Please Enter Your Forge API Token', + ' ' + ); +})->throws('A Forge API token is required.'); diff --git a/tests/Unit/Support/TtyReaderTest.php b/tests/Unit/Support/TtyReaderTest.php new file mode 100644 index 0000000..b5e83f3 --- /dev/null +++ b/tests/Unit/Support/TtyReaderTest.php @@ -0,0 +1,155 @@ +captureCalls++; + + return null; + } + + protected function disableCanonicalMode() + { + $this->disableCalls++; + } + + protected function restoreState($state) + { + $this->restoreCalls++; + } + }; + + $result = $reader->read(fn () => 'hello-token'); + + expect($result)->toBe('hello-token') + ->and($reader->captureCalls)->toBe(0) + ->and($reader->disableCalls)->toBe(0) + ->and($reader->restoreCalls)->toBe(0); +}); + +it('toggles canonical mode around the read and always restores state', function () { + $reader = new class extends TtyReader + { + public array $events = []; + + protected function shouldDisableCanonicalMode() + { + $this->events[] = 'check'; + + return true; + } + + protected function captureState() + { + $this->events[] = 'capture'; + + return 'previous-state'; + } + + protected function disableCanonicalMode() + { + $this->events[] = 'disable'; + } + + protected function restoreState($state) + { + $this->events[] = "restore:{$state}"; + } + }; + + $result = $reader->read(function () use ($reader) { + $reader->events[] = 'read'; + + return 'token-value'; + }); + + expect($result)->toBe('token-value') + ->and($reader->events)->toBe([ + 'check', + 'capture', + 'disable', + 'read', + 'restore:previous-state', + ]); +}); + +it('restores terminal state even when the reader throws', function () { + $reader = new class extends TtyReader + { + public bool $restored = false; + + protected function shouldDisableCanonicalMode() + { + return true; + } + + protected function captureState() + { + return 'previous-state'; + } + + protected function disableCanonicalMode() + { + // + } + + protected function restoreState($state) + { + $this->restored = true; + } + }; + + expect(fn () => $reader->read(function () { + throw new RuntimeException('boom'); + }))->toThrow(RuntimeException::class, 'boom'); + + expect($reader->restored)->toBeTrue(); +}); + +it('skips the toggle when state cannot be captured', function () { + $reader = new class extends TtyReader + { + public int $disableCalls = 0; + + public int $restoreCalls = 0; + + protected function shouldDisableCanonicalMode() + { + return true; + } + + protected function captureState() + { + return null; + } + + protected function disableCanonicalMode() + { + $this->disableCalls++; + } + + protected function restoreState($state) + { + $this->restoreCalls++; + } + }; + + expect($reader->read(fn () => 'token'))->toBe('token') + ->and($reader->disableCalls)->toBe(0) + ->and($reader->restoreCalls)->toBe(0); +});