Skip to content

Commit a107f0f

Browse files
authored
Merge pull request #40 from datpmwork/feat-add-bref-tinker
Add Tinker Command
2 parents 2d8a584 + 498ff89 commit a107f0f

File tree

6 files changed

+303
-2
lines changed

6 files changed

+303
-2
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"amphp/http-client": "^5.3",
2020
"amphp/process": "^2.0",
2121
"aws/aws-sdk-php": "^3.319",
22+
"psy/psysh": "^0.12.0",
2223
"revolt/event-loop": "^1.0",
2324
"symfony/console": "^5.2 || ^6.2 || ^7",
2425
"symfony/filesystem": "^5.2 || ^6.2 || ^7",

src/Application.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public function __construct()
2828
$this->add(new Commands\Connect);
2929
$this->add(new Commands\PreviousLogs);
3030
$this->add(new Commands\Cloud);
31+
$this->add(new Commands\Tinker);
3132
}
3233

3334
public function doRun(InputInterface $input, OutputInterface $output): int
@@ -85,4 +86,4 @@ private function turnWarningsIntoExceptions(): void
8586
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
8687
});
8788
}
88-
}
89+
}

src/Commands/Command.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,4 @@ private function writeErrorDetails(string $output): void
102102
IO::writeln(Styles::red($output));
103103
}
104104
}
105-
}
105+
}

src/Commands/Tinker.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
namespace Bref\Cli\Commands;
4+
5+
use Bref\Cli\Cli\IO;
6+
use Bref\Cli\Cli\Styles;
7+
use Bref\Cli\Tinker\BrefTinkerShell;
8+
use Psy\Configuration;
9+
use Symfony\Component\Console\Input\InputInterface;
10+
use Symfony\Component\Console\Output\OutputInterface;
11+
12+
class Tinker extends ApplicationCommand
13+
{
14+
protected function configure(): void
15+
{
16+
ini_set('memory_limit', '512M');
17+
18+
$this
19+
->setName('tinker')
20+
->setDescription('Run Laravel Tinker in AWS Lambda');
21+
parent::configure();
22+
}
23+
24+
protected function execute(InputInterface $input, OutputInterface $output): int
25+
{
26+
if (!$this->isLaravelApplication()) {
27+
IO::writeln(Styles::red('This command can only be run in a Laravel application.'));
28+
return 1;
29+
}
30+
31+
IO::writeln([Styles::brefHeader(), '']);
32+
33+
$brefCloudConfig = $this->parseStandardOptions($input);
34+
35+
// Auto enable verbose to avoid verbose async listener in VerboseModeEnabler which will cause issue when executing multiple commands
36+
IO::enableVerbose();
37+
IO::writeln(sprintf(
38+
"Starting Interactive Shell Session for [%s] in the [%s] environment",
39+
Styles::green($brefCloudConfig['appName']),
40+
Styles::red($brefCloudConfig['environmentName']),
41+
));
42+
43+
$shellConfig = Configuration::fromInput($input);
44+
$shellOutput = $shellConfig->getOutput();
45+
46+
$shell = new BrefTinkerShell($shellConfig, $brefCloudConfig);
47+
$shell->setRawOutput($shellOutput);
48+
49+
try {
50+
return $shell->run();
51+
} catch (\Throwable $e) {
52+
IO::writeln(Styles::red($e->getMessage()));
53+
return 1;
54+
}
55+
}
56+
57+
protected function isLaravelApplication(): bool
58+
{
59+
$composerContent = file_get_contents('composer.json');
60+
if ($composerContent === false) {
61+
return false;
62+
}
63+
64+
$composerJson = json_decode($composerContent, true);
65+
$requires = $composerJson['require'] ?? [];
66+
$requiresDev = $composerJson['require-dev'] ?? [];
67+
return isset($requires['laravel/framework']) || isset($requiresDev['laravel/framework']);
68+
}
69+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
namespace Bref\Cli\Tinker;
4+
5+
use Bref\Cli\BrefCloudClient;
6+
use Bref\Cli\Cli\IO;
7+
use Bref\Cli\Cli\Styles;
8+
use Psy\Exception\BreakException;
9+
use Psy\Exception\ThrowUpException;
10+
use Psy\ExecutionClosure;
11+
use Psy\ExecutionLoop\AbstractListener;
12+
use Psy\Shell;
13+
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
14+
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
15+
use function Amp\delay;
16+
17+
class BrefTinkerLoopListener extends AbstractListener
18+
{
19+
/**
20+
* @var array{appName: string, environmentName: string, team: string}
21+
*/
22+
protected array $brefConfig;
23+
24+
/**
25+
* @var array{
26+
* id: int,
27+
* name: string,
28+
* region: string|null,
29+
* url: string|null,
30+
* outputs: array<string, string>,
31+
* app: array{id: int, name: string},
32+
* }
33+
*/
34+
protected array $environment;
35+
36+
protected BrefCloudClient $brefCloudClient;
37+
38+
/**
39+
* @param array<string, string> $brefConfig
40+
* @throws ExceptionInterface
41+
* @throws HttpExceptionInterface
42+
*/
43+
public function __construct(array $brefConfig)
44+
{
45+
$this->brefConfig = $brefConfig;
46+
[
47+
'appName' => $appName,
48+
'environmentName' => $environmentName,
49+
'team' => $team,
50+
] = $brefConfig;
51+
$this->brefCloudClient = new BrefCloudClient;
52+
$this->environment = $this->brefCloudClient->findEnvironment($team, $appName, $environmentName);
53+
}
54+
55+
public static function isSupported(): bool
56+
{
57+
return true;
58+
}
59+
60+
/**
61+
* @param BrefTinkerShell $shell
62+
* @throws BreakException
63+
* @throws ThrowUpException
64+
*/
65+
public function onExecute(Shell $shell, string $code)
66+
{
67+
if ($code == '\Psy\Exception\BreakException::exitShell();') {
68+
return $code;
69+
}
70+
71+
$vars = $shell->getScopeVariables(false);
72+
$context = $vars['_context'] ?? base64_encode(serialize(["_" => null]));
73+
// Evaluate the current code buffer
74+
try {
75+
[$resultCode, $resultOutput] = $this->evaluateCode($code, $context);
76+
if ($resultCode !== 0) {
77+
$shell->rawOutput->writeln($resultOutput);
78+
throw new BreakException("The remote tinker shell returned an error (code $resultCode).");
79+
}
80+
81+
$extractedOutput = $shell->extractContextData($resultOutput);
82+
if (is_null($extractedOutput)) {
83+
$shell->rawOutput->writeln(' <info> INFO </info> Please upgrade <string>laravel-bridge</string> package to latest version.');
84+
throw new BreakException("The remote tinker shell returned an invalid payload");
85+
}
86+
87+
if ([$output, $context, $return] = $extractedOutput) {
88+
if (!empty($output)) {
89+
$shell->rawOutput->writeln($output);
90+
}
91+
if (!empty($return)) {
92+
$shell->rawOutput->writeln($return);
93+
}
94+
if (!empty($context)) {
95+
// Extract _context into shell's scope variables for next code execution
96+
// Return NoValue as output and return value were printed out
97+
return "extract(['_context' => '{$context}']); return new \Psy\CodeCleaner\NoReturnValue();";
98+
} else {
99+
// Return NoValue as output and return value were printed out
100+
return "return new \Psy\CodeCleaner\NoReturnValue();";
101+
}
102+
}
103+
104+
return ExecutionClosure::NOOP_INPUT;
105+
} catch (\Throwable $_e) {
106+
throw new BreakException($_e->getMessage());
107+
}
108+
}
109+
110+
/**
111+
* @return array{0: int, 1: string} [exitCode, output]
112+
* @throws ExceptionInterface
113+
* @throws HttpExceptionInterface
114+
*/
115+
protected function evaluateCode(string $code, string $context): array
116+
{
117+
$command = implode(" ", [
118+
'bref:tinker',
119+
'--execute=\"'.base64_encode($code).'\"',
120+
'--context=\"'.$context.'\"',
121+
]);
122+
$id = $this->brefCloudClient->startCommand($this->environment['id'], $command);
123+
124+
// Timeout after 2 minutes and 10 seconds
125+
$timeout = 130;
126+
$startTime = time();
127+
128+
while (true) {
129+
$invocation = $this->brefCloudClient->getCommand($id);
130+
131+
if ($invocation['status'] === 'success') {
132+
return [0, $invocation['output']];
133+
}
134+
135+
if ($invocation['status'] === 'failed') {
136+
return [1, $invocation['output']];
137+
}
138+
139+
if ((time() - $startTime) > $timeout) {
140+
IO::writeln(Styles::red('Timed out'));
141+
IO::writeln(Styles::gray('The execution timed out after 2 minutes, the command might still be running'));
142+
return [1, 'Timed out'];
143+
}
144+
145+
delay(0.5);
146+
}
147+
}
148+
}

src/Tinker/BrefTinkerShell.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
namespace Bref\Cli\Tinker;
4+
5+
use Psy\Configuration;
6+
use Psy\ExecutionLoop\AbstractListener;
7+
use Psy\Output\ShellOutput;
8+
use Psy\Shell;
9+
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
10+
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
11+
12+
class BrefTinkerShell extends Shell
13+
{
14+
public ShellOutput $rawOutput;
15+
16+
/**
17+
* @var array<string, string>
18+
*/
19+
protected array $brefCloudConfig;
20+
21+
public function __construct(?Configuration $config = null, array $brefCloudConfig = [])
22+
{
23+
$this->brefCloudConfig = $brefCloudConfig;
24+
25+
parent::__construct($config);
26+
}
27+
28+
public function setRawOutput($rawOutput): self
29+
{
30+
$this->rawOutput = $rawOutput;
31+
32+
return $this;
33+
}
34+
35+
/**
36+
* Gets the default command loop listeners.
37+
*
38+
* @return array<AbstractListener> An array of Execution Loop Listener instances
39+
* @throws ExceptionInterface
40+
* @throws HttpExceptionInterface
41+
*/
42+
protected function getDefaultLoopListeners(): array
43+
{
44+
$listeners = parent::getDefaultLoopListeners();
45+
46+
$listeners[] = new BrefTinkerLoopListener($this->brefCloudConfig);
47+
48+
return $listeners;
49+
}
50+
51+
/**
52+
* @return list<string>|null
53+
*/
54+
public function extractContextData(string $output): ?array
55+
{
56+
$output = trim($output);
57+
// First, extract RETURN section if it exists
58+
if (preg_match('/\[RETURN\](.*?)\[END_RETURN\]/s', $output, $returnMatches)) {
59+
$returnValue = $returnMatches[1];
60+
// Remove RETURN section to work with the rest
61+
$output = (string) preg_replace('/\[RETURN\].*?\[END_RETURN\]/s', '', $output);
62+
} else {
63+
$returnValue = '';
64+
}
65+
66+
// Then extract CONTEXT section if it exists
67+
if (preg_match('/\[CONTEXT\](.*?)\[END_CONTEXT\]/s', $output, $contextMatches)) {
68+
$context = $contextMatches[1];
69+
// Remove CONTEXT section to get the before part
70+
$output = (string) preg_replace('/\[CONTEXT\].*?\[END_CONTEXT\]\n?/s', '', $output);
71+
} else {
72+
$context = '';
73+
}
74+
75+
// Only return null if we couldn't find any meaningful structure
76+
if (empty($output) && empty($context) && empty($returnValue)) {
77+
return null;
78+
}
79+
80+
return [$output, $context, $returnValue];
81+
}
82+
}

0 commit comments

Comments
 (0)