diff --git a/cli/Valet/CommandLine.php b/cli/Valet/CommandLine.php
index f719867a..c7eeb3e7 100644
--- a/cli/Valet/CommandLine.php
+++ b/cli/Valet/CommandLine.php
@@ -27,6 +27,52 @@ public function shellExec($command) {
return shell_exec($command);
}
+ /**
+ * Stream command output in real time and optionally collect matching lines.
+ *
+ * @param string $command
+ * @param callable|null $lineMatches Callback to check matching lines; must return `true`
+ * to collect the line for post-run analysis.
+ * @param callable|null $lineIsError Callback to check whether a line should be
+ * rendered as an error in real time. If omitted, the capture matcher is reused.
+ *
+ * @return array The collected output lines or an empty array if no lines were collected.
+ */
+ public function streamCommandOutput($command, ?callable $lineMatches = null, ?callable $lineIsError = null): array {
+ $capturedLines = [];
+ $lineIsError = $lineIsError ?: $lineMatches;
+
+ // Open a process to execute the command and read its output.
+ $handle = popen("$command 2>&1", 'r');
+ while ($handle && !feof($handle)) {
+ $line = fgets($handle);
+ if ($line === false) {
+ break;
+ }
+
+ // Keep raw command output unless caller explicitly marks this line as an error.
+ if ($lineIsError && $lineIsError($line)) {
+ error($line, false, false, true);
+ }
+ else {
+ echo $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.
*
diff --git a/cli/Valet/ShareTools/Ngrok.php b/cli/Valet/ShareTools/Ngrok.php
index 010cecba..0c98e205 100644
--- a/cli/Valet/ShareTools/Ngrok.php
+++ b/cli/Valet/ShareTools/Ngrok.php
@@ -38,19 +38,26 @@ public function start(string $site, int $port, array $options = []) {
$ngrok = realpath(valetBinPath() . 'ngrok.exe');
- $ngrokCommand = "\"$ngrok\" http $site:$port " . $this->getConfig() . " $options";
+ // Log to stdout, log level info, and log format term for real-time output.
+ $logging = "--log=stdout --log-level=info --log-format=term";
+
+ $ngrokCommand = "\"$ngrok\" http $site:$port " . $this->getConfig() . " $options $logging";
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");
+ // 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;
+ };
- if ($errors = strstr($output, "ERROR")) {
- error($errors . PHP_EOL);
+ // Pass the same matcher once; CommandLine reuses it for error styling when no separate
+ // error callback is supplied.
+ $errorLines = $this->cli->streamCommandOutput($ngrokCommand, $isErrorLine);
- 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");
- }
+ 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");
}
}
diff --git a/cli/includes/helpers.php b/cli/includes/helpers.php
index 667780c4..93f686e3 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);
}
}
@@ -385,4 +396,4 @@ function str_contains_any($haystack, $needles) {
}
}
return false;
-}
+}
\ No newline at end of file