diff --git a/cli/Valet/CommandLine.php b/cli/Valet/CommandLine.php index f719867a5..8c0d7320c 100644 --- a/cli/Valet/CommandLine.php +++ b/cli/Valet/CommandLine.php @@ -27,6 +27,63 @@ public function shellExec($command) { return shell_exec($command); } + /** + * Stream command output in real time and optionally collect matching lines. + * + * @param string $command + * @param array $callbacks Optional callbacks: + * - onLine (callable): receives every raw line after it is written. + * - matches (callable): return true to collect line for post-run analysis. + * - isError (callable): return true to render line as an error. Defaults to matches. + * + * @return array The collected output lines or an empty array if no lines were collected. + */ + public function streamCommandOutput($command, array $callbacks = []): array { + $lineHandler = $callbacks['onLine'] ?? null; + $lineMatches = $callbacks['matches'] ?? null; + $lineIsError = $callbacks['isError'] ?? $lineMatches; + + $capturedLines = []; + + // Open a process to execute the command and read its output. + // 2>&1 redirects stderr to stdout so we can capture both. + $handle = popen("$command 2>&1", 'r'); + while ($handle && !feof($handle)) { + $line = fgets($handle); + if ($line === false) { + break; + } + + // If the line is an error, output it as an error. + if ($lineIsError && $lineIsError($line)) { + error($line, false, false, true); + } + // Otherwise, output it normally. + else { + output($line, false); + } + + // Invoke the optional line handler after writing output so callers can append + // follow-up messages in display order. + if ($lineHandler) { + $lineHandler($line); + } + + // If a callback is provided and the line matches the condition, + // then collect the line for post-run analysis. + if ($lineMatches && $lineMatches($line)) { + $capturedLines[] = trim($line); + } + } + + // Close the process. + if ($handle) { + pclose($handle); + } + + return $capturedLines; + } + /** * Pass the given Valet command to the command line with elevated privileges using gsudo. * @@ -151,4 +208,4 @@ public function runCommand($command, ?callable $onError = null, $realTimeOutput return new ProcessOutput($process); } } -} \ No newline at end of file +} diff --git a/cli/Valet/ShareTools/Ngrok.php b/cli/Valet/ShareTools/Ngrok.php index 010cecba0..854c5cc14 100644 --- a/cli/Valet/ShareTools/Ngrok.php +++ b/cli/Valet/ShareTools/Ngrok.php @@ -22,16 +22,31 @@ class Ngrok extends ShareTool { */ public function start(string $site, int $port, array $options = []) { if ($port === 443 && !$this->hasAuthToken()) { - output('Forwarding to local port 443 or a local https:// URL is only available after you sign up. -Sign up at: https://ngrok.com/signup -Then use: valet set-ngrok-token [token]'); + output("Forwarding to local port 443 or a local https:// URL is only available after you sign up.\nSign up at: https://ngrok.com/signup\nThen use: valet set-ngrok-token [token]"); exit(1); } - // If host-header is not specified, - // then set it into the array with a default value of rewrite. - if (!stripos(json_encode($options), 'host-header')) { - array_push($options, "host-header=rewrite"); + // Apply defaults for various options the user has not already specified. + $defaults = [ + 'host-header' => 'rewrite', + // Logging options: log to stdout at info level, enables real-time output + // and post-run error analysis. + // Logging options are undocumented for the http command, but is defined as + // API flags but still works for the http command. See ngrok docs for more details: + // https://ngrok.com/docs/agent/cli-api#flags-2 + // + // (Note: Both `stdout` and `stderr` values capture the same output since + // `CommandLine::streamCommandOutput` method uses `2>&1` to redirect stderr to stdout.) + 'log' => 'stdout', + 'log-level' => 'info', + 'log-format' => 'term' + ]; + + // Merge defaults with user-specified options, giving precedence to user-specified options. + foreach ($defaults as $key => $value) { + if (!array_filter($options, fn($opt) => strpos($opt, "$key=") === 0)) { + $options[] = "$key=$value"; + } } $options = prefixOptions($options); @@ -41,16 +56,49 @@ public function start(string $site, int $port, array $options = []) { $ngrokCommand = "\"$ngrok\" http $site:$port " . $this->getConfig() . " $options"; info("Sharing $site...\n"); - info("To output the public URL, please open a new terminal and run `valet fetch-share-url $site`"); - $output = $this->cli->shellExec("$ngrokCommand 2>&1"); + // If the options string doesn't contain the `--log` option with values of either `stdout` + // or `stderr`,then inform the user that they can fetch the public URL in a new terminal. + if (strpos($options, '--log=stdout') === false && strpos($options, '--log=stderr') === false) { + info("To output the public URL, please open a new terminal and run `valet fetch-share-url $site`"); + } + + // Stream ngrok output in real time and collect error lines for post-run analysis. + // Shared matcher: use the same rule for live error styling and for post-run capture. + $isErrorLine = function ($line) { + return strpos($line, 'ERROR:') !== false; + }; + + $didOutputShareUrl = false; - if ($errors = strstr($output, "ERROR")) { - error($errors . PHP_EOL); + // Line handler: check each line for the "started tunnel" log line to find and + // extract the public URL. + $lineHandler = function ($line) use ($site, &$didOutputShareUrl) { + // If the share URL has already been output, skip further processing. + if ($didOutputShareUrl) { + return; + } + + // If the line contains the 'msg="started tunnel"' message AND has a 'url=' key... + if (strpos($line, 'msg="started tunnel"') !== false && preg_match('/\burl=(\S+)/', $line, $matches)) { + // Set the flag to true to avoid further processing of lines. + $didOutputShareUrl = true; + // Output an info message with extracted public URL. + info("The public URL for $site is: $matches[1]"); - if (strpos($errors, 'ERR_NGROK_121') !== false) { - info("To update ngrok yourself, please run `valet ngrok update` and then upgrade the config file by running `valet ngrok config upgrade`\n"); + // Copy the public URL to the clipboard for ease. + $this->copyUrlToClipboard($matches[1]); } + }; + + // Stream ngrok output in real time and collect error lines for post-run analysis. + $errorLines = $this->cli->streamCommandOutput($ngrokCommand, [ + 'onLine' => $lineHandler, + 'matches' => $isErrorLine + ]); + + if (!empty($errorLines) && strpos(implode("\n", $errorLines), 'ERR_NGROK_121') !== false) { + info("\nTo update ngrok yourself, please run `valet ngrok update` and then upgrade the config file by running `valet ngrok config upgrade`\n"); } } @@ -124,4 +172,4 @@ protected function hasAuthToken(): bool { } return false; } -} +} \ No newline at end of file diff --git a/cli/Valet/ShareTools/ShareTool.php b/cli/Valet/ShareTools/ShareTool.php index e29e914d0..b22afe991 100644 --- a/cli/Valet/ShareTools/ShareTool.php +++ b/cli/Valet/ShareTools/ShareTool.php @@ -90,8 +90,7 @@ public function currentTunnelUrl(string $site) { if (isset($body->tunnels) && count($body->tunnels) > 0) { // If the tunnel URL is NOT null, return the URL. if ($tunnelUrl = $this->findHttpTunnelUrl($body->tunnels, $site)) { - // Use | clip to copy the URL to the clipboard. - $this->cli->passthru("echo $tunnelUrl | clip"); + $this->copyUrlToClipboard($tunnelUrl); return $tunnelUrl; } @@ -132,4 +131,14 @@ public function findHttpTunnelUrl(array $tunnels, ?string $site = null) { } return null; } + + /** + * Copy the public URL to the clipboard. + * + * @param string $url The public URL to copy. + */ + public function copyUrlToClipboard(string $url) { + $this->cli->passthru("echo $url | clip"); + info("It has been copied to your clipboard."); + } } diff --git a/cli/includes/helpers.php b/cli/includes/helpers.php index 667780c45..1f8d5ee1f 100644 --- a/cli/includes/helpers.php +++ b/cli/includes/helpers.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Helper\TableSeparator; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Formatter\OutputFormatter; if (!isset($_SERVER['HOME'])) { $_SERVER['HOME'] = $_SERVER['USERPROFILE']; @@ -62,11 +63,16 @@ function warning($output) { * * @param string $output * @param bool $exception Optionally pass a boolean to indicate whether to throw an exception. If `true`, the error will be thrown as a `ValetException`. [default: `false`] + * @param bool $newline Whether to append a newline after the error output. [default: `true`] + * @param bool $escapeOutput Whether to escape the output to prevent formatting issues. [default: `false`] * * @throws RuntimeException * @throws ValetException */ -function error(string $output, $exception = false) { +function error(string $output, bool $exception = false, bool $newline = true, bool $escapeOutput = false) { + + $errorOutput = (new ConsoleOutput())->getErrorOutput(); + if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'testing') { throw new RuntimeException($output); } @@ -78,12 +84,17 @@ function error(string $output, $exception = false) { usleep(1); // Print the error message to the console. - (new ConsoleOutput())->getErrorOutput()->writeln("\n\n$errors"); + $errorOutput->write("\n\n$errors", $newline); exit(); } else { - (new ConsoleOutput())->getErrorOutput()->writeln("$output"); + // If escapeOutput is true, then escape the output to prevent any formatting issues. + if ($escapeOutput) { + $output = OutputFormatter::escape($output); + } + + $errorOutput->write("$output", $newline); } } @@ -91,12 +102,13 @@ function error(string $output, $exception = false) { * Output the given text to the console. * * @param string $output + * @param bool $newline Whether to append a newline after the output. [default: `true`] */ -function output($output) { +function output($output, bool $newline = true) { if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'testing') { return; } - (new ConsoleOutput())->writeln($output); + (new ConsoleOutput())->write($output, $newline); } /** diff --git a/cli/valet.php b/cli/valet.php index 1a23e90aa..2c49eaf9b 100644 --- a/cli/valet.php +++ b/cli/valet.php @@ -978,7 +978,6 @@ $url = Share::shareTool()->currentTunnelUrl($site); info("The public URL for $site is: $url"); - info("It has been copied to your clipboard."); })->setAliases(["url"])->descriptions('Get and copy the public URL of the current working directory site that is currently being shared', [ "site" => "Optionally, specify a site"