diff --git a/composer.json b/composer.json index 6eb1637..ec9d815 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "amphp/http-client": "^5.3", "amphp/process": "^2.0", "aws/aws-sdk-php": "^3.319", + "psy/psysh": "^0.12.0", "revolt/event-loop": "^1.0", "symfony/console": "^5.2 || ^6.2 || ^7", "symfony/filesystem": "^5.2 || ^6.2 || ^7", diff --git a/src/Application.php b/src/Application.php index 8d1fd8c..52fe3ec 100644 --- a/src/Application.php +++ b/src/Application.php @@ -28,6 +28,7 @@ public function __construct() $this->add(new Commands\Connect); $this->add(new Commands\PreviousLogs); $this->add(new Commands\Cloud); + $this->add(new Commands\Tinker); } public function doRun(InputInterface $input, OutputInterface $output): int @@ -85,4 +86,4 @@ private function turnWarningsIntoExceptions(): void throw new ErrorException($errstr, 0, $errno, $errfile, $errline); }); } -} \ No newline at end of file +} diff --git a/src/Commands/Command.php b/src/Commands/Command.php index 198bedb..830aca0 100644 --- a/src/Commands/Command.php +++ b/src/Commands/Command.php @@ -102,4 +102,4 @@ private function writeErrorDetails(string $output): void IO::writeln(Styles::red($output)); } } -} \ No newline at end of file +} diff --git a/src/Commands/Tinker.php b/src/Commands/Tinker.php new file mode 100644 index 0000000..80750ba --- /dev/null +++ b/src/Commands/Tinker.php @@ -0,0 +1,69 @@ +setName('tinker') + ->setDescription('Run Laravel Tinker in AWS Lambda'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (!$this->isLaravelApplication()) { + IO::writeln(Styles::red('This command can only be run in a Laravel application.')); + return 1; + } + + IO::writeln([Styles::brefHeader(), '']); + + $brefCloudConfig = $this->parseStandardOptions($input); + + // Auto enable verbose to avoid verbose async listener in VerboseModeEnabler which will cause issue when executing multiple commands + IO::enableVerbose(); + IO::writeln(sprintf( + "Starting Interactive Shell Session for [%s] in the [%s] environment", + Styles::green($brefCloudConfig['appName']), + Styles::red($brefCloudConfig['environmentName']), + )); + + $shellConfig = Configuration::fromInput($input); + $shellOutput = $shellConfig->getOutput(); + + $shell = new BrefTinkerShell($shellConfig, $brefCloudConfig); + $shell->setRawOutput($shellOutput); + + try { + return $shell->run(); + } catch (\Throwable $e) { + IO::writeln(Styles::red($e->getMessage())); + return 1; + } + } + + protected function isLaravelApplication(): bool + { + $composerContent = file_get_contents('composer.json'); + if ($composerContent === false) { + return false; + } + + $composerJson = json_decode($composerContent, true); + $requires = $composerJson['require'] ?? []; + $requiresDev = $composerJson['require-dev'] ?? []; + return isset($requires['laravel/framework']) || isset($requiresDev['laravel/framework']); + } +} diff --git a/src/Tinker/BrefTinkerLoopListener.php b/src/Tinker/BrefTinkerLoopListener.php new file mode 100644 index 0000000..2b2c4cd --- /dev/null +++ b/src/Tinker/BrefTinkerLoopListener.php @@ -0,0 +1,148 @@ +, + * app: array{id: int, name: string}, + * } + */ + protected array $environment; + + protected BrefCloudClient $brefCloudClient; + + /** + * @param array $brefConfig + * @throws ExceptionInterface + * @throws HttpExceptionInterface + */ + public function __construct(array $brefConfig) + { + $this->brefConfig = $brefConfig; + [ + 'appName' => $appName, + 'environmentName' => $environmentName, + 'team' => $team, + ] = $brefConfig; + $this->brefCloudClient = new BrefCloudClient; + $this->environment = $this->brefCloudClient->findEnvironment($team, $appName, $environmentName); + } + + public static function isSupported(): bool + { + return true; + } + + /** + * @param BrefTinkerShell $shell + * @throws BreakException + * @throws ThrowUpException + */ + public function onExecute(Shell $shell, string $code) + { + if ($code == '\Psy\Exception\BreakException::exitShell();') { + return $code; + } + + $vars = $shell->getScopeVariables(false); + $context = $vars['_context'] ?? base64_encode(serialize(["_" => null])); + // Evaluate the current code buffer + try { + [$resultCode, $resultOutput] = $this->evaluateCode($code, $context); + if ($resultCode !== 0) { + $shell->rawOutput->writeln($resultOutput); + throw new BreakException("The remote tinker shell returned an error (code $resultCode)."); + } + + $extractedOutput = $shell->extractContextData($resultOutput); + if (is_null($extractedOutput)) { + $shell->rawOutput->writeln(' INFO Please upgrade laravel-bridge package to latest version.'); + throw new BreakException("The remote tinker shell returned an invalid payload"); + } + + if ([$output, $context, $return] = $extractedOutput) { + if (!empty($output)) { + $shell->rawOutput->writeln($output); + } + if (!empty($return)) { + $shell->rawOutput->writeln($return); + } + if (!empty($context)) { + // Extract _context into shell's scope variables for next code execution + // Return NoValue as output and return value were printed out + return "extract(['_context' => '{$context}']); return new \Psy\CodeCleaner\NoReturnValue();"; + } else { + // Return NoValue as output and return value were printed out + return "return new \Psy\CodeCleaner\NoReturnValue();"; + } + } + + return ExecutionClosure::NOOP_INPUT; + } catch (\Throwable $_e) { + throw new BreakException($_e->getMessage()); + } + } + + /** + * @return array{0: int, 1: string} [exitCode, output] + * @throws ExceptionInterface + * @throws HttpExceptionInterface + */ + protected function evaluateCode(string $code, string $context): array + { + $command = implode(" ", [ + 'bref:tinker', + '--execute=\"'.base64_encode($code).'\"', + '--context=\"'.$context.'\"', + ]); + $id = $this->brefCloudClient->startCommand($this->environment['id'], $command); + + // Timeout after 2 minutes and 10 seconds + $timeout = 130; + $startTime = time(); + + while (true) { + $invocation = $this->brefCloudClient->getCommand($id); + + if ($invocation['status'] === 'success') { + return [0, $invocation['output']]; + } + + if ($invocation['status'] === 'failed') { + return [1, $invocation['output']]; + } + + if ((time() - $startTime) > $timeout) { + IO::writeln(Styles::red('Timed out')); + IO::writeln(Styles::gray('The execution timed out after 2 minutes, the command might still be running')); + return [1, 'Timed out']; + } + + delay(0.5); + } + } +} diff --git a/src/Tinker/BrefTinkerShell.php b/src/Tinker/BrefTinkerShell.php new file mode 100644 index 0000000..e8cccd1 --- /dev/null +++ b/src/Tinker/BrefTinkerShell.php @@ -0,0 +1,82 @@ + + */ + protected array $brefCloudConfig; + + public function __construct(?Configuration $config = null, array $brefCloudConfig = []) + { + $this->brefCloudConfig = $brefCloudConfig; + + parent::__construct($config); + } + + public function setRawOutput($rawOutput): self + { + $this->rawOutput = $rawOutput; + + return $this; + } + + /** + * Gets the default command loop listeners. + * + * @return array An array of Execution Loop Listener instances + * @throws ExceptionInterface + * @throws HttpExceptionInterface + */ + protected function getDefaultLoopListeners(): array + { + $listeners = parent::getDefaultLoopListeners(); + + $listeners[] = new BrefTinkerLoopListener($this->brefCloudConfig); + + return $listeners; + } + + /** + * @return list|null + */ + public function extractContextData(string $output): ?array + { + $output = trim($output); + // First, extract RETURN section if it exists + if (preg_match('/\[RETURN\](.*?)\[END_RETURN\]/s', $output, $returnMatches)) { + $returnValue = $returnMatches[1]; + // Remove RETURN section to work with the rest + $output = (string) preg_replace('/\[RETURN\].*?\[END_RETURN\]/s', '', $output); + } else { + $returnValue = ''; + } + + // Then extract CONTEXT section if it exists + if (preg_match('/\[CONTEXT\](.*?)\[END_CONTEXT\]/s', $output, $contextMatches)) { + $context = $contextMatches[1]; + // Remove CONTEXT section to get the before part + $output = (string) preg_replace('/\[CONTEXT\].*?\[END_CONTEXT\]\n?/s', '', $output); + } else { + $context = ''; + } + + // Only return null if we couldn't find any meaningful structure + if (empty($output) && empty($context) && empty($returnValue)) { + return null; + } + + return [$output, $context, $returnValue]; + } +}