Skip to content

Commit c9d3976

Browse files
committed
feat: add artisan command
1 parent e970657 commit c9d3976

2 files changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir command-line tool.
7+
*
8+
* (c) Carl Alexander <support@ymirapp.com>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Cli\Command\Laravel;
15+
16+
use Symfony\Component\Console\Command\Command;
17+
use Symfony\Component\Console\Input\InputArgument;
18+
use Symfony\Component\Console\Input\InputOption;
19+
use Ymir\Cli\Command\AbstractCommand;
20+
use Ymir\Cli\Command\HandlesInvocationTrait;
21+
use Ymir\Cli\Command\LocalProjectCommandInterface;
22+
use Ymir\Cli\Command\ParsesConsoleCommandTrait;
23+
use Ymir\Cli\Exception\InvalidInputException;
24+
use Ymir\Cli\Exception\Project\UnsupportedProjectException;
25+
use Ymir\Cli\Project\Type\LaravelProjectType;
26+
use Ymir\Cli\Resource\Model\Environment;
27+
use Ymir\Cli\Resource\Model\Project;
28+
29+
class ArtisanCommand extends AbstractCommand implements LocalProjectCommandInterface
30+
{
31+
use HandlesInvocationTrait;
32+
use ParsesConsoleCommandTrait;
33+
34+
/**
35+
* The name of the command.
36+
*
37+
* @var string
38+
*/
39+
public const NAME = 'artisan';
40+
41+
/**
42+
* {@inheritdoc}
43+
*/
44+
protected function configure()
45+
{
46+
$this
47+
->setName(self::NAME)
48+
->setDescription('Execute a Laravel artisan command')
49+
->addArgument('artisan-command', InputArgument::IS_ARRAY, 'The artisan command to execute')
50+
->addOption('environment', null, InputOption::VALUE_REQUIRED, 'The environment name')
51+
->addOption('async', null, InputOption::VALUE_NONE, 'Execute the command asynchronously')
52+
->addHiddenOption('yolo', null, InputOption::VALUE_NONE);
53+
}
54+
55+
/**
56+
* Invokes the environment console function with the given Artisan command and returns the output.
57+
*/
58+
protected function invokeArtisanCommand(Project $project, string $command, Environment $environment, ?int $timeout = null): array
59+
{
60+
$command = $this->stripCommandPrefix($command, ['php artisan', 'artisan']);
61+
62+
return $this->invokeEnvironmentFunction($project, $environment, [
63+
'php' => sprintf('artisan %s', $command),
64+
], $timeout);
65+
}
66+
67+
/**
68+
* {@inheritDoc}
69+
*/
70+
protected function perform()
71+
{
72+
if (!$this->getProjectConfiguration()->getProjectType() instanceof LaravelProjectType) {
73+
throw new UnsupportedProjectException('You can only use this command with Laravel projects');
74+
}
75+
76+
$environment = $this->resolve(Environment::class, 'Which <comment>%s</comment> environment would you like to execute the Artisan command on?');
77+
78+
$async = $this->input->getBooleanOption('async') || $this->input->getBooleanOption('yolo');
79+
$command = implode(' ', $this->input->getArrayArgument('artisan-command'));
80+
$exitCode = Command::SUCCESS;
81+
82+
if (empty($command)) {
83+
$command = $this->output->ask('What Artisan command would you like to run?');
84+
}
85+
86+
$command = $this->stripCommandPrefix($command, ['php artisan', 'artisan']);
87+
88+
$commandParts = $this->parseCommand($command);
89+
$commandName = $commandParts[0] ?? '';
90+
91+
if ('tinker' === $commandName) {
92+
throw new InvalidInputException(sprintf('The "artisan %s" command isn\'t available remotely', $command));
93+
}
94+
95+
$this->output->info(sprintf('Running "<comment>php artisan %s</comment>" %s "<comment>%s</comment>" environment', $command, $async ? 'asynchronously on' : 'on', $environment->getName()));
96+
97+
$result = $this->invokeArtisanCommand($this->getProject(), $command, $environment, $async ? 0 : null);
98+
99+
if (!$async) {
100+
$this->output->newLine();
101+
$this->output->write("{$result['output']}");
102+
103+
$exitCode = $result['exitCode'];
104+
}
105+
106+
return $exitCode;
107+
}
108+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir command-line tool.
7+
*
8+
* (c) Carl Alexander <support@ymirapp.com>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Cli\Tests\Integration\Command\Laravel;
15+
16+
use Ymir\Cli\Command\Laravel\ArtisanCommand;
17+
use Ymir\Cli\Exception\InvalidInputException;
18+
use Ymir\Cli\Exception\Project\UnsupportedProjectException;
19+
use Ymir\Cli\Project\Type\LaravelProjectType;
20+
use Ymir\Cli\Resource\Definition\EnvironmentDefinition;
21+
use Ymir\Cli\Resource\Model\Environment;
22+
use Ymir\Cli\Resource\Model\Project;
23+
use Ymir\Cli\Resource\ResourceCollection;
24+
use Ymir\Cli\Tests\Factory\EnvironmentFactory;
25+
use Ymir\Cli\Tests\Integration\Command\TestCase;
26+
27+
class ArtisanCommandTest extends TestCase
28+
{
29+
public function testPerformExecutesArtisanCommand(): void
30+
{
31+
$this->setupActiveTeam();
32+
$project = $this->setupValidProject(1, 'project', ['production' => []], 'laravel', LaravelProjectType::class);
33+
$environment = EnvironmentFactory::create(['name' => 'production']);
34+
35+
$this->apiClient->shouldReceive('getEnvironments')->with(\Mockery::type(Project::class))->andReturn(new ResourceCollection([$environment]));
36+
$this->apiClient->shouldReceive('createInvocation')->with(\Mockery::type(Project::class), $environment, ['php' => 'artisan migrate:status'])->andReturn(collect(['id' => 123]));
37+
$this->apiClient->shouldReceive('getInvocation')->with(123)->andReturn(collect([
38+
'status' => 'completed',
39+
'result' => [
40+
'exitCode' => 0,
41+
'output' => 'artisan output',
42+
],
43+
]));
44+
45+
$this->bootApplication([new ArtisanCommand($this->apiClient, $this->createExecutionContextFactory([
46+
Environment::class => function () { return new EnvironmentDefinition(); },
47+
]))]);
48+
49+
$tester = $this->executeCommand(ArtisanCommand::NAME, ['artisan-command' => ['migrate:status'], '--environment' => 'production']);
50+
51+
$this->assertStringContainsString('Running "php artisan migrate:status" on "production" environment', $tester->getDisplay());
52+
$this->assertStringContainsString('artisan output', $tester->getDisplay());
53+
}
54+
55+
public function testPerformExecutesArtisanCommandAsynchronously(): void
56+
{
57+
$this->setupActiveTeam();
58+
$project = $this->setupValidProject(1, 'project', ['production' => []], 'laravel', LaravelProjectType::class);
59+
$environment = EnvironmentFactory::create(['name' => 'production']);
60+
61+
$this->apiClient->shouldReceive('getEnvironments')->with(\Mockery::type(Project::class))->andReturn(new ResourceCollection([$environment]));
62+
$this->apiClient->shouldReceive('createInvocation')->with(\Mockery::type(Project::class), $environment, ['php' => 'artisan cache:clear'])->andReturn(collect(['id' => 123]));
63+
64+
$this->bootApplication([new ArtisanCommand($this->apiClient, $this->createExecutionContextFactory([
65+
Environment::class => function () { return new EnvironmentDefinition(); },
66+
]))]);
67+
68+
$tester = $this->executeCommand(ArtisanCommand::NAME, ['artisan-command' => ['cache:clear'], '--environment' => 'production', '--async' => true]);
69+
70+
$this->assertStringContainsString('Running "php artisan cache:clear" asynchronously on "production" environment', $tester->getDisplay());
71+
}
72+
73+
public function testPerformExecutesArtisanCommandInteractively(): void
74+
{
75+
$this->setupActiveTeam();
76+
$project = $this->setupValidProject(1, 'project', ['production' => []], 'laravel', LaravelProjectType::class);
77+
$environment = EnvironmentFactory::create(['name' => 'production']);
78+
79+
$this->apiClient->shouldReceive('getEnvironments')->with(\Mockery::type(Project::class))->andReturn(new ResourceCollection([$environment]));
80+
$this->apiClient->shouldReceive('createInvocation')->with(\Mockery::type(Project::class), $environment, ['php' => 'artisan route:list'])->andReturn(collect(['id' => 123]));
81+
$this->apiClient->shouldReceive('getInvocation')->with(123)->andReturn(collect([
82+
'status' => 'completed',
83+
'result' => [
84+
'exitCode' => 0,
85+
'output' => 'route list output',
86+
],
87+
]));
88+
89+
$this->bootApplication([new ArtisanCommand($this->apiClient, $this->createExecutionContextFactory([
90+
Environment::class => function () { return new EnvironmentDefinition(); },
91+
]))]);
92+
93+
$tester = $this->executeCommand(ArtisanCommand::NAME, [], ['production', 'route:list']);
94+
95+
$this->assertStringContainsString('Running "php artisan route:list" on "production" environment', $tester->getDisplay());
96+
$this->assertStringContainsString('route list output', $tester->getDisplay());
97+
}
98+
99+
public function testPerformRemovesPhpArtisanPrefixFromCommand(): void
100+
{
101+
$this->setupActiveTeam();
102+
$project = $this->setupValidProject(1, 'project', ['production' => []], 'laravel', LaravelProjectType::class);
103+
$environment = EnvironmentFactory::create(['name' => 'production']);
104+
105+
$this->apiClient->shouldReceive('getEnvironments')->with(\Mockery::type(Project::class))->andReturn(new ResourceCollection([$environment]));
106+
$this->apiClient->shouldReceive('createInvocation')->with(\Mockery::type(Project::class), $environment, ['php' => 'artisan migrate'])->andReturn(collect(['id' => 123]));
107+
$this->apiClient->shouldReceive('getInvocation')->with(123)->andReturn(collect([
108+
'status' => 'completed',
109+
'result' => [
110+
'exitCode' => 0,
111+
'output' => 'migrate output',
112+
],
113+
]));
114+
115+
$this->bootApplication([new ArtisanCommand($this->apiClient, $this->createExecutionContextFactory([
116+
Environment::class => function () { return new EnvironmentDefinition(); },
117+
]))]);
118+
119+
$tester = $this->executeCommand(ArtisanCommand::NAME, ['artisan-command' => ['php', 'artisan', 'migrate'], '--environment' => 'production']);
120+
121+
$this->assertStringContainsString('Running "php artisan migrate" on "production" environment', $tester->getDisplay());
122+
}
123+
124+
public function testPerformThrowsExceptionIfCommandIsTinker(): void
125+
{
126+
$this->expectException(InvalidInputException::class);
127+
$this->expectExceptionMessage('The "artisan tinker" command isn\'t available remotely');
128+
129+
$this->setupActiveTeam();
130+
$project = $this->setupValidProject(1, 'project', [], 'laravel', LaravelProjectType::class);
131+
$environment = EnvironmentFactory::create(['name' => 'production']);
132+
133+
$this->apiClient->shouldReceive('getEnvironments')->with(\Mockery::type(Project::class))->andReturn(new ResourceCollection([$environment]));
134+
135+
$this->bootApplication([new ArtisanCommand($this->apiClient, $this->createExecutionContextFactory([
136+
Environment::class => function () { return new EnvironmentDefinition(); },
137+
]))]);
138+
139+
$this->executeCommand(ArtisanCommand::NAME, ['artisan-command' => ['tinker'], '--environment' => 'production']);
140+
}
141+
142+
public function testPerformThrowsExceptionIfCommandIsTinkerWithArguments(): void
143+
{
144+
$this->expectException(InvalidInputException::class);
145+
$this->expectExceptionMessage('The "artisan tinker --execute="dump(1);"" command isn\'t available remotely');
146+
147+
$this->setupActiveTeam();
148+
$project = $this->setupValidProject(1, 'project', [], 'laravel', LaravelProjectType::class);
149+
$environment = EnvironmentFactory::create(['name' => 'production']);
150+
151+
$this->apiClient->shouldReceive('getEnvironments')->with(\Mockery::type(Project::class))->andReturn(new ResourceCollection([$environment]));
152+
153+
$this->bootApplication([new ArtisanCommand($this->apiClient, $this->createExecutionContextFactory([
154+
Environment::class => function () { return new EnvironmentDefinition(); },
155+
]))]);
156+
157+
$this->executeCommand(ArtisanCommand::NAME, ['artisan-command' => ['tinker', '--execute="dump(1);"'], '--environment' => 'production']);
158+
}
159+
160+
public function testPerformThrowsExceptionIfProjectIsNotLaravel(): void
161+
{
162+
$this->expectException(UnsupportedProjectException::class);
163+
$this->expectExceptionMessage('You can only use this command with Laravel projects');
164+
165+
$this->setupValidProject(1, 'project', [], 'wordpress');
166+
167+
$this->bootApplication([new ArtisanCommand($this->apiClient, $this->createExecutionContextFactory())]);
168+
169+
$this->executeCommand(ArtisanCommand::NAME);
170+
}
171+
}

0 commit comments

Comments
 (0)