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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,4 +86,4 @@ private function turnWarningsIntoExceptions(): void
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
}
}
}
2 changes: 1 addition & 1 deletion src/Commands/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,4 @@ private function writeErrorDetails(string $output): void
IO::writeln(Styles::red($output));
}
}
}
}
69 changes: 69 additions & 0 deletions src/Commands/Tinker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace Bref\Cli\Commands;

use Bref\Cli\Cli\IO;
use Bref\Cli\Cli\Styles;
use Bref\Cli\Tinker\BrefTinkerShell;
use Psy\Configuration;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class Tinker extends ApplicationCommand
{
protected function configure(): void
{
ini_set('memory_limit', '512M');

$this
->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']);
}
}
148 changes: 148 additions & 0 deletions src/Tinker/BrefTinkerLoopListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

namespace Bref\Cli\Tinker;

use Bref\Cli\BrefCloudClient;
use Bref\Cli\Cli\IO;
use Bref\Cli\Cli\Styles;
use Psy\Exception\BreakException;
use Psy\Exception\ThrowUpException;
use Psy\ExecutionClosure;
use Psy\ExecutionLoop\AbstractListener;
use Psy\Shell;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use function Amp\delay;

class BrefTinkerLoopListener extends AbstractListener
{
/**
* @var array{appName: string, environmentName: string, team: string}
*/
protected array $brefConfig;

/**
* @var array{
* id: int,
* name: string,
* region: string|null,
* url: string|null,
* outputs: array<string, string>,
* app: array{id: int, name: string},
* }
*/
protected array $environment;

protected BrefCloudClient $brefCloudClient;

/**
* @param array<string, string> $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> INFO </info> Please upgrade <string>laravel-bridge</string> 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);
}
}
}
82 changes: 82 additions & 0 deletions src/Tinker/BrefTinkerShell.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace Bref\Cli\Tinker;

use Psy\Configuration;
use Psy\ExecutionLoop\AbstractListener;
use Psy\Output\ShellOutput;
use Psy\Shell;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;

class BrefTinkerShell extends Shell
{
public ShellOutput $rawOutput;

/**
* @var array<string, string>
*/
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<AbstractListener> 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<string>|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];
}
}