Skip to content

Commit 50644e7

Browse files
committed
517: fix inability to provide sudo prompt when using "pie install" on a PHP project
Previously, the `pie install` would invoke another instance of `pie`. However, since this would always be run in a non-interactive shell, `sudo` prompts will never work. I've changed this instead to use `InvokeSubCommand` which copies the input params into the sub command, so it is all run in the same PIE process and therefore retains the interactivity (or lack thereof) of the shell it was invoked from.
1 parent 70def3c commit 50644e7

12 files changed

Lines changed: 249 additions & 127 deletions

File tree

phpstan-baseline.neon

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -270,18 +270,6 @@ parameters:
270270
count: 2
271271
path: src/Installing/InstallForPhpProject/FindMatchingPackages.php
272272

273-
-
274-
message: '#^Parameter \#2 \$callback of function array_walk expects callable\(array\|float\|int\|string\|true, int\|string\)\: mixed, Closure\(string, string\)\: void given\.$#'
275-
identifier: argument.type
276-
count: 1
277-
path: src/Installing/InstallForPhpProject/InstallSelectedPackage.php
278-
279-
-
280-
message: '#^Parameter \#2 \$workingDirectory of static method Php\\Pie\\Util\\Process\:\:run\(\) expects string\|null, string\|false given\.$#'
281-
identifier: argument.type
282-
count: 1
283-
path: src/Installing/InstallForPhpProject/InstallSelectedPackage.php
284-
285273
-
286274
message: '#^Negated boolean expression is always false\.$#'
287275
identifier: booleanNot.alwaysFalse
@@ -480,12 +468,6 @@ parameters:
480468
count: 1
481469
path: test/unit/Installing/Ini/StandardSinglePhpIniTest.php
482470

483-
-
484-
message: '#^Parameter \#1 \$originalCwd of class Php\\Pie\\File\\FullPathToSelf constructor expects string, string\|false given\.$#'
485-
identifier: argument.type
486-
count: 1
487-
path: test/unit/Installing/InstallForPhpProject/InstallSelectedPackageTest.php
488-
489471
-
490472
message: '#^Method Php\\PieUnitTest\\SelfManage\\Verify\\FallbackVerificationUsingOpenSslTest\:\:prepareCertificateAndSignature\(\) should return array\{string, string\} but returns array\{mixed, mixed\}\.$#'
491473
identifier: return.type

src/Command/InstallExtensionsForProjectCommand.php

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Php\Pie\ComposerIntegration\PieComposerFactory;
1313
use Php\Pie\ComposerIntegration\PieComposerRequest;
1414
use Php\Pie\ComposerIntegration\PieJsonEditor;
15+
use Php\Pie\DependencyResolver\RequestedPackageAndVersion;
1516
use Php\Pie\ExtensionName;
1617
use Php\Pie\ExtensionType;
1718
use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject;
@@ -28,6 +29,7 @@
2829
use Symfony\Component\Console\Input\InputInterface;
2930
use Symfony\Component\Console\Output\OutputInterface;
3031
use Throwable;
32+
use Webmozart\Assert\Assert;
3133

3234
use function array_column;
3335
use function array_key_exists;
@@ -268,20 +270,26 @@ static function (array $match): string {
268270
$selectedPackageName = $matches[0]['name'];
269271
}
270272

271-
$requestInstallConstraint = '';
272-
if ($linkRequiresConstraint !== '*') {
273-
$requestInstallConstraint = ':' . $linkRequiresConstraint;
274-
}
273+
assert($selectedPackageName !== '');
274+
$requestedPackageAndVersion = new RequestedPackageAndVersion(
275+
$selectedPackageName,
276+
$linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint,
277+
);
275278

276279
try {
277280
$this->io->write(
278-
sprintf('Invoking pie install of %s%s', $selectedPackageName, $requestInstallConstraint),
281+
sprintf('Invoking pie install of %s', $requestedPackageAndVersion->prettyNameAndVersion()),
279282
verbosity: IOInterface::VERBOSE,
280283
);
281-
$this->installSelectedPackage->withPieCli(
282-
$selectedPackageName . $requestInstallConstraint,
283-
$input,
284-
$this->io,
284+
Assert::same(
285+
0,
286+
$this->installSelectedPackage->withSubCommand(
287+
ExtensionName::normaliseFromString($link->getTarget()),
288+
$requestedPackageAndVersion,
289+
$this,
290+
$input,
291+
),
292+
'Non-zero exit code %s whilst installing ' . $requestedPackageAndVersion->package,
285293
);
286294
} catch (Throwable $t) {
287295
$anyErrorsHappened = true;

src/Command/InvokeSubCommand.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Php\Pie\Command;
66

77
use Symfony\Component\Console\Command\Command;
8+
use Symfony\Component\Console\Formatter\OutputFormatter;
89
use Symfony\Component\Console\Input\ArrayInput;
910
use Symfony\Component\Console\Input\InputInterface;
1011
use Symfony\Component\Console\Output\OutputInterface;
@@ -29,6 +30,7 @@ public function __invoke(
2930
Command $command,
3031
array $subCommandInput,
3132
InputInterface $originalCommandInput,
33+
OutputFormatter|null $formatter = null,
3234
): int {
3335
$originalSuppliedOptions = array_filter($originalCommandInput->getOptions());
3436
$installForProjectInput = new ArrayInput(array_merge(
@@ -42,6 +44,19 @@ public function __invoke(
4244
$application = $command->getApplication();
4345
Assert::notNull($application);
4446

45-
return $application->doRun($installForProjectInput, $this->output);
47+
if ($formatter instanceof OutputFormatter) {
48+
$oldFormatter = $this->output->getFormatter();
49+
$this->output->setFormatter($formatter);
50+
}
51+
52+
try {
53+
$result = $application->doRun($installForProjectInput, $this->output);
54+
} finally {
55+
if ($formatter instanceof OutputFormatter) {
56+
$this->output->setFormatter($oldFormatter);
57+
}
58+
}
59+
60+
return $result;
4661
}
4762
}

src/DependencyResolver/RequestedPackageAndVersion.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,13 @@ public function __construct(
2727
throw InvalidPackageName::fromMissingForwardSlash($this);
2828
}
2929
}
30+
31+
public function prettyNameAndVersion(): string
32+
{
33+
if ($this->version === null) {
34+
return $this->package;
35+
}
36+
37+
return $this->package . ':' . $this->version;
38+
}
3039
}

src/Installing/InstallForPhpProject/InstallSelectedPackage.php

Lines changed: 22 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,71 +4,37 @@
44

55
namespace Php\Pie\Installing\InstallForPhpProject;
66

7-
use Composer\IO\IOInterface;
8-
use Php\Pie\Command\CommandHelper;
9-
use Php\Pie\File\FullPathToSelf;
10-
use Php\Pie\Util\Process;
7+
use Php\Pie\Command\InvokeSubCommand;
8+
use Php\Pie\DependencyResolver\RequestedPackageAndVersion;
9+
use Php\Pie\ExtensionName;
10+
use Php\Pie\Util\OutputFormatterWithPrefix;
11+
use Symfony\Component\Console\Command\Command;
1112
use Symfony\Component\Console\Input\InputInterface;
1213

13-
use function array_filter;
14-
use function array_walk;
15-
use function getcwd;
16-
use function in_array;
17-
18-
use const ARRAY_FILTER_USE_BOTH;
19-
2014
/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
2115
class InstallSelectedPackage
2216
{
23-
public function __construct(private readonly FullPathToSelf $fullPathToSelf)
24-
{
17+
public function __construct(
18+
private readonly InvokeSubCommand $invokeSubCommand,
19+
) {
2520
}
2621

27-
public function withPieCli(string $selectedPackage, InputInterface $input, IOInterface $io): void
28-
{
29-
$process = [
30-
($this->fullPathToSelf)(),
31-
'install',
32-
$selectedPackage,
22+
public function withSubCommand(
23+
ExtensionName $ext,
24+
RequestedPackageAndVersion $selectedPackage,
25+
Command $command,
26+
InputInterface $input,
27+
): int {
28+
$params = [
29+
'command' => 'install',
30+
'requested-package-and-version' => $selectedPackage->prettyNameAndVersion(),
3331
];
3432

35-
$phpPathOptions = array_filter(
36-
$input->getOptions(),
37-
static function (mixed $value, string|int $key): bool {
38-
return $value !== null
39-
&& $value !== false
40-
&& in_array(
41-
$key,
42-
[
43-
CommandHelper::OPTION_WITH_PHP_CONFIG,
44-
CommandHelper::OPTION_WITH_PHP_PATH,
45-
CommandHelper::OPTION_WITH_PHPIZE_PATH,
46-
],
47-
);
48-
},
49-
ARRAY_FILTER_USE_BOTH,
50-
);
51-
52-
array_walk(
53-
$phpPathOptions,
54-
static function (string $value, string $key) use (&$process): void {
55-
$process[] = '--' . $key;
56-
$process[] = $value;
57-
},
58-
);
59-
60-
Process::run(
61-
$process,
62-
getcwd(),
63-
outputCallback: static function (string $outOrErr, string $message) use ($io): void {
64-
if ($outOrErr === \Symfony\Component\Process\Process::ERR) {
65-
$io->writeError(' > ' . $message);
66-
67-
return;
68-
}
69-
70-
$io->write(' > ' . $message);
71-
},
33+
return ($this->invokeSubCommand)(
34+
$command,
35+
$params,
36+
$input,
37+
OutputFormatterWithPrefix::newWithPrefix(' ' . $ext->name() . '> '),
7238
);
7339
}
7440
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Php\Pie\Util;
6+
7+
use Composer\Factory;
8+
use Symfony\Component\Console\Formatter\OutputFormatter;
9+
10+
class OutputFormatterWithPrefix extends OutputFormatter
11+
{
12+
/**
13+
* @param non-empty-string $linePrefix
14+
*
15+
* @inheritDoc
16+
*/
17+
public function __construct(private readonly string $linePrefix, bool $decorated = false, array $styles = [])
18+
{
19+
parent::__construct($decorated, $styles);
20+
}
21+
22+
/** @param non-empty-string $linePrefix */
23+
public static function newWithPrefix(string $linePrefix): self
24+
{
25+
return new self($linePrefix, false, Factory::createAdditionalStyles());
26+
}
27+
28+
public function format(string|null $message): string|null
29+
{
30+
$formatted = parent::format($message);
31+
32+
if ($formatted === null) {
33+
return null;
34+
}
35+
36+
return $this->linePrefix . $formatted;
37+
}
38+
}

test/assets/fake-pie-cli/happy.bat

Lines changed: 0 additions & 5 deletions
This file was deleted.

test/assets/fake-pie-cli/happy.sh

Lines changed: 0 additions & 4 deletions
This file was deleted.

test/integration/Command/InstallExtensionsForProjectCommandTest.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
use Php\Pie\ComposerIntegration\PieJsonEditor;
1818
use Php\Pie\ComposerIntegration\QuieterConsoleIO;
1919
use Php\Pie\Container;
20+
use Php\Pie\DependencyResolver\RequestedPackageAndVersion;
21+
use Php\Pie\ExtensionName;
2022
use Php\Pie\ExtensionType;
2123
use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject;
2224
use Php\Pie\Installing\InstallForPhpProject\DetermineExtensionsRequired;
@@ -125,8 +127,15 @@ public function testInstallingExtensionsForPhpProject(): void
125127
$this->questionHelper->method('ask')->willReturn('vendor1/foobar: The official foobar implementation');
126128

127129
$this->installSelectedPackage->expects(self::once())
128-
->method('withPieCli')
129-
->with('vendor1/foobar:^1.2');
130+
->method('withSubCommand')
131+
->with(
132+
ExtensionName::normaliseFromString('foobar'),
133+
new RequestedPackageAndVersion(
134+
'vendor1/foobar',
135+
'^1.2',
136+
),
137+
)
138+
->willReturn(0);
130139

131140
$this->commandTester->execute(
132141
['--allow-non-interactive-project-install' => true],
@@ -173,7 +182,7 @@ public function testInstallingExtensionsForPhpProjectWithMultipleMatches(): void
173182
$this->questionHelper->method('ask')->willReturn('vendor1/foobar: The official foobar implementation');
174183

175184
$this->installSelectedPackage->expects(self::never())
176-
->method('withPieCli');
185+
->method('withSubCommand');
177186

178187
$this->commandTester->execute(
179188
['--allow-non-interactive-project-install' => true],
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Php\PieUnitTest\Command;
6+
7+
use Php\Pie\Command\InvokeSubCommand;
8+
use Php\Pie\Util\OutputFormatterWithPrefix;
9+
use PHPUnit\Framework\Attributes\CoversClass;
10+
use PHPUnit\Framework\TestCase;
11+
use Symfony\Component\Console\Application;
12+
use Symfony\Component\Console\Command\Command;
13+
use Symfony\Component\Console\Input\ArrayInput;
14+
use Symfony\Component\Console\Input\InputDefinition;
15+
use Symfony\Component\Console\Input\InputOption;
16+
use Symfony\Component\Console\Output\BufferedOutput;
17+
use Symfony\Component\Console\Output\OutputInterface;
18+
19+
use function trim;
20+
21+
#[CoversClass(InvokeSubCommand::class)]
22+
final class InvokeSubCommandTest extends TestCase
23+
{
24+
public function testInvokeWithNoOutputFormatterRunsSubCommand(): void
25+
{
26+
$inputDefinition = new InputDefinition();
27+
$inputDefinition->addOption(new InputOption('verbose', 'v', InputOption::VALUE_NONE, 'Verbose option'));
28+
$input = new ArrayInput(['--verbose' => true], $inputDefinition);
29+
30+
$output = new BufferedOutput();
31+
32+
$application = $this->createMock(Application::class);
33+
$application->expects(self::once())
34+
->method('doRun')
35+
->willReturnCallback(static function (ArrayInput $newInput, OutputInterface $output) {
36+
self::assertSame('foo --verbose=1', (string) $newInput);
37+
$output->writeln('command output here');
38+
39+
return 0;
40+
});
41+
42+
$command = $this->createMock(Command::class);
43+
$command->method('getApplication')->willReturn($application);
44+
45+
$invoker = new InvokeSubCommand($output);
46+
self::assertSame(0, ($invoker)($command, ['command' => 'foo'], $input));
47+
self::assertSame('command output here', trim($output->fetch()));
48+
}
49+
50+
public function testInvokeWithPrefixOutputFormatterRunsSubCommand(): void
51+
{
52+
$inputDefinition = new InputDefinition();
53+
$inputDefinition->addOption(new InputOption('verbose', 'v', InputOption::VALUE_NONE, 'Verbose option'));
54+
$input = new ArrayInput(['--verbose' => true], $inputDefinition);
55+
56+
$output = new BufferedOutput();
57+
58+
$application = $this->createMock(Application::class);
59+
$application->expects(self::once())
60+
->method('doRun')
61+
->willReturnCallback(static function (ArrayInput $newInput, OutputInterface $output) {
62+
self::assertSame('foo --verbose=1', (string) $newInput);
63+
$output->writeln('command output here');
64+
65+
return 0;
66+
});
67+
68+
$command = $this->createMock(Command::class);
69+
$command->method('getApplication')->willReturn($application);
70+
71+
$invoker = new InvokeSubCommand($output);
72+
self::assertSame(0, ($invoker)($command, ['command' => 'foo'], $input, new OutputFormatterWithPrefix('prefix> ')));
73+
self::assertSame('prefix> command output here', trim($output->fetch()));
74+
}
75+
}

0 commit comments

Comments
 (0)