1
0
mirror of https://github.com/php/pie.git synced 2026-03-23 23:12:17 +01:00

Merge pull request #519 from asgrim/517-change-pie-install-to-invoke-subcommand-for-interactivity

517: fix inability to provide sudo prompt when using "pie install" on a PHP project
This commit is contained in:
James Titcumb
2026-03-02 08:16:46 +00:00
committed by GitHub
12 changed files with 248 additions and 126 deletions

View File

@@ -270,18 +270,6 @@ parameters:
count: 2
path: src/Installing/InstallForPhpProject/FindMatchingPackages.php
-
message: '#^Parameter \#2 \$callback of function array_walk expects callable\(array\|float\|int\|string\|true, int\|string\)\: mixed, Closure\(string, string\)\: void given\.$#'
identifier: argument.type
count: 1
path: src/Installing/InstallForPhpProject/InstallSelectedPackage.php
-
message: '#^Parameter \#2 \$workingDirectory of static method Php\\Pie\\Util\\Process\:\:run\(\) expects string\|null, string\|false given\.$#'
identifier: argument.type
count: 1
path: src/Installing/InstallForPhpProject/InstallSelectedPackage.php
-
message: '#^Negated boolean expression is always false\.$#'
identifier: booleanNot.alwaysFalse
@@ -480,12 +468,6 @@ parameters:
count: 1
path: test/unit/Installing/Ini/StandardSinglePhpIniTest.php
-
message: '#^Parameter \#1 \$originalCwd of class Php\\Pie\\File\\FullPathToSelf constructor expects string, string\|false given\.$#'
identifier: argument.type
count: 1
path: test/unit/Installing/InstallForPhpProject/InstallSelectedPackageTest.php
-
message: '#^Method Php\\PieUnitTest\\SelfManage\\Verify\\FallbackVerificationUsingOpenSslTest\:\:prepareCertificateAndSignature\(\) should return array\{string, string\} but returns array\{mixed, mixed\}\.$#'
identifier: return.type

View File

@@ -12,6 +12,7 @@ use OutOfRangeException;
use Php\Pie\ComposerIntegration\PieComposerFactory;
use Php\Pie\ComposerIntegration\PieComposerRequest;
use Php\Pie\ComposerIntegration\PieJsonEditor;
use Php\Pie\DependencyResolver\RequestedPackageAndVersion;
use Php\Pie\ExtensionName;
use Php\Pie\ExtensionType;
use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject;
@@ -28,6 +29,7 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use Webmozart\Assert\Assert;
use function array_column;
use function array_key_exists;
@@ -268,20 +270,26 @@ final class InstallExtensionsForProjectCommand extends Command
$selectedPackageName = $matches[0]['name'];
}
$requestInstallConstraint = '';
if ($linkRequiresConstraint !== '*') {
$requestInstallConstraint = ':' . $linkRequiresConstraint;
}
assert($selectedPackageName !== '');
$requestedPackageAndVersion = new RequestedPackageAndVersion(
$selectedPackageName,
$linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint,
);
try {
$this->io->write(
sprintf('Invoking pie install of %s%s', $selectedPackageName, $requestInstallConstraint),
sprintf('Invoking pie install of %s', $requestedPackageAndVersion->prettyNameAndVersion()),
verbosity: IOInterface::VERBOSE,
);
$this->installSelectedPackage->withPieCli(
$selectedPackageName . $requestInstallConstraint,
$input,
$this->io,
Assert::same(
0,
$this->installSelectedPackage->withSubCommand(
ExtensionName::normaliseFromString($link->getTarget()),
$requestedPackageAndVersion,
$this,
$input,
),
'Non-zero exit code %s whilst installing ' . $requestedPackageAndVersion->package,
);
} catch (Throwable $t) {
$anyErrorsHappened = true;

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Php\Pie\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -29,6 +30,7 @@ class InvokeSubCommand
Command $command,
array $subCommandInput,
InputInterface $originalCommandInput,
OutputFormatter|null $formatter = null,
): int {
$originalSuppliedOptions = array_filter($originalCommandInput->getOptions());
$installForProjectInput = new ArrayInput(array_merge(
@@ -42,6 +44,19 @@ class InvokeSubCommand
$application = $command->getApplication();
Assert::notNull($application);
return $application->doRun($installForProjectInput, $this->output);
if ($formatter instanceof OutputFormatter) {
$oldFormatter = $this->output->getFormatter();
$this->output->setFormatter($formatter);
}
try {
$result = $application->doRun($installForProjectInput, $this->output);
} finally {
if ($formatter instanceof OutputFormatter) {
$this->output->setFormatter($oldFormatter);
}
}
return $result;
}
}

View File

@@ -27,4 +27,13 @@ final class RequestedPackageAndVersion
throw InvalidPackageName::fromMissingForwardSlash($this);
}
}
public function prettyNameAndVersion(): string
{
if ($this->version === null) {
return $this->package;
}
return $this->package . ':' . $this->version;
}
}

View File

@@ -4,71 +4,37 @@ declare(strict_types=1);
namespace Php\Pie\Installing\InstallForPhpProject;
use Composer\IO\IOInterface;
use Php\Pie\Command\CommandHelper;
use Php\Pie\File\FullPathToSelf;
use Php\Pie\Util\Process;
use Php\Pie\Command\InvokeSubCommand;
use Php\Pie\DependencyResolver\RequestedPackageAndVersion;
use Php\Pie\ExtensionName;
use Php\Pie\Util\OutputFormatterWithPrefix;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use function array_filter;
use function array_walk;
use function getcwd;
use function in_array;
use const ARRAY_FILTER_USE_BOTH;
/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
class InstallSelectedPackage
{
public function __construct(private readonly FullPathToSelf $fullPathToSelf)
{
public function __construct(
private readonly InvokeSubCommand $invokeSubCommand,
) {
}
public function withPieCli(string $selectedPackage, InputInterface $input, IOInterface $io): void
{
$process = [
($this->fullPathToSelf)(),
'install',
$selectedPackage,
public function withSubCommand(
ExtensionName $ext,
RequestedPackageAndVersion $selectedPackage,
Command $command,
InputInterface $input,
): int {
$params = [
'command' => 'install',
'requested-package-and-version' => $selectedPackage->prettyNameAndVersion(),
];
$phpPathOptions = array_filter(
$input->getOptions(),
static function (mixed $value, string|int $key): bool {
return $value !== null
&& $value !== false
&& in_array(
$key,
[
CommandHelper::OPTION_WITH_PHP_CONFIG,
CommandHelper::OPTION_WITH_PHP_PATH,
CommandHelper::OPTION_WITH_PHPIZE_PATH,
],
);
},
ARRAY_FILTER_USE_BOTH,
);
array_walk(
$phpPathOptions,
static function (string $value, string $key) use (&$process): void {
$process[] = '--' . $key;
$process[] = $value;
},
);
Process::run(
$process,
getcwd(),
outputCallback: static function (string $outOrErr, string $message) use ($io): void {
if ($outOrErr === \Symfony\Component\Process\Process::ERR) {
$io->writeError(' > ' . $message);
return;
}
$io->write(' > ' . $message);
},
return ($this->invokeSubCommand)(
$command,
$params,
$input,
OutputFormatterWithPrefix::newWithPrefix(' ' . $ext->name() . '> '),
);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Php\Pie\Util;
use Composer\Factory;
use Symfony\Component\Console\Formatter\OutputFormatter;
class OutputFormatterWithPrefix extends OutputFormatter
{
/**
* @param non-empty-string $linePrefix
*
* @inheritDoc
*/
public function __construct(private readonly string $linePrefix, bool $decorated = false, array $styles = [])
{
parent::__construct($decorated, $styles);
}
/** @param non-empty-string $linePrefix */
public static function newWithPrefix(string $linePrefix): self
{
return new self($linePrefix, false, Factory::createAdditionalStyles());
}
public function format(string|null $message): string|null
{
$formatted = parent::format($message);
if ($formatted === null) {
return null;
}
return $this->linePrefix . $formatted;
}
}

View File

@@ -1,5 +0,0 @@
@echo off
echo Params passed: %*
exit /b 0

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env bash
echo "Params passed: ${*}"
exit 0

View File

@@ -17,6 +17,8 @@ use Php\Pie\ComposerIntegration\MinimalHelperSet;
use Php\Pie\ComposerIntegration\PieJsonEditor;
use Php\Pie\ComposerIntegration\QuieterConsoleIO;
use Php\Pie\Container;
use Php\Pie\DependencyResolver\RequestedPackageAndVersion;
use Php\Pie\ExtensionName;
use Php\Pie\ExtensionType;
use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject;
use Php\Pie\Installing\InstallForPhpProject\DetermineExtensionsRequired;
@@ -125,8 +127,15 @@ final class InstallExtensionsForProjectCommandTest extends TestCase
$this->questionHelper->method('ask')->willReturn('vendor1/foobar: The official foobar implementation');
$this->installSelectedPackage->expects(self::once())
->method('withPieCli')
->with('vendor1/foobar:^1.2');
->method('withSubCommand')
->with(
ExtensionName::normaliseFromString('foobar'),
new RequestedPackageAndVersion(
'vendor1/foobar',
'^1.2',
),
)
->willReturn(0);
$this->commandTester->execute(
['--allow-non-interactive-project-install' => true],
@@ -173,7 +182,7 @@ final class InstallExtensionsForProjectCommandTest extends TestCase
$this->questionHelper->method('ask')->willReturn('vendor1/foobar: The official foobar implementation');
$this->installSelectedPackage->expects(self::never())
->method('withPieCli');
->method('withSubCommand');
$this->commandTester->execute(
['--allow-non-interactive-project-install' => true],

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Php\PieUnitTest\Command;
use Php\Pie\Command\InvokeSubCommand;
use Php\Pie\Util\OutputFormatterWithPrefix;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface;
use function trim;
#[CoversClass(InvokeSubCommand::class)]
final class InvokeSubCommandTest extends TestCase
{
public function testInvokeWithNoOutputFormatterRunsSubCommand(): void
{
$inputDefinition = new InputDefinition();
$inputDefinition->addOption(new InputOption('verbose', 'v', InputOption::VALUE_NONE, 'Verbose option'));
$input = new ArrayInput(['--verbose' => true], $inputDefinition);
$output = new BufferedOutput();
$application = $this->createMock(Application::class);
$application->expects(self::once())
->method('doRun')
->willReturnCallback(static function (ArrayInput $newInput, OutputInterface $output) {
self::assertSame('foo --verbose=1', (string) $newInput);
$output->writeln('command output here');
return 0;
});
$command = $this->createMock(Command::class);
$command->method('getApplication')->willReturn($application);
$invoker = new InvokeSubCommand($output);
self::assertSame(0, ($invoker)($command, ['command' => 'foo'], $input));
self::assertSame('command output here', trim($output->fetch()));
}
public function testInvokeWithPrefixOutputFormatterRunsSubCommand(): void
{
$inputDefinition = new InputDefinition();
$inputDefinition->addOption(new InputOption('verbose', 'v', InputOption::VALUE_NONE, 'Verbose option'));
$input = new ArrayInput(['--verbose' => true], $inputDefinition);
$output = new BufferedOutput();
$application = $this->createMock(Application::class);
$application->expects(self::once())
->method('doRun')
->willReturnCallback(static function (ArrayInput $newInput, OutputInterface $output) {
self::assertSame('foo --verbose=1', (string) $newInput);
$output->writeln('command output here');
return 0;
});
$command = $this->createMock(Command::class);
$command->method('getApplication')->willReturn($application);
$invoker = new InvokeSubCommand($output);
self::assertSame(0, ($invoker)($command, ['command' => 'foo'], $input, new OutputFormatterWithPrefix('prefix> ')));
self::assertSame('prefix> command output here', trim($output->fetch()));
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Php\PieUnitTest\DependencyResolver;
use Php\Pie\DependencyResolver\RequestedPackageAndVersion;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(RequestedPackageAndVersion::class)]
final class RequestedPackageAndVersionTest extends TestCase
{
public function testPrettyNameAndVersionWithVersion(): void
{
self::assertSame(
'foo/foo:^1.2.3',
(new RequestedPackageAndVersion('foo/foo', '^1.2.3'))->prettyNameAndVersion(),
);
}
public function testPrettyNameAndVersionWithoutVersion(): void
{
self::assertSame(
'foo/foo',
(new RequestedPackageAndVersion('foo/foo', null))->prettyNameAndVersion(),
);
}
}

View File

@@ -4,46 +4,46 @@ declare(strict_types=1);
namespace Php\PieUnitTest\Installing\InstallForPhpProject;
use Composer\IO\BufferIO;
use Composer\Util\Platform;
use Php\Pie\File\FullPathToSelf;
use Php\Pie\Command\InvokeSubCommand;
use Php\Pie\DependencyResolver\RequestedPackageAndVersion;
use Php\Pie\ExtensionName;
use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage;
use Php\Pie\Util\OutputFormatterWithPrefix;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
use function getcwd;
use function trim;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
#[CoversClass(InstallSelectedPackage::class)]
final class InstallSelectedPackageTest extends TestCase
{
private const FAKE_HAPPY_SH = __DIR__ . '/../../../assets/fake-pie-cli/happy.sh';
private const FAKE_HAPPY_BAT = __DIR__ . '/../../../assets/fake-pie-cli/happy.bat';
public function testWithPieCli(): void
public function testSubCommandIsInvoked(): void
{
$_SERVER['PHP_SELF'] = Platform::isWindows() ? self::FAKE_HAPPY_BAT : self::FAKE_HAPPY_SH;
$command = $this->createMock(Command::class);
$input = $this->createMock(InputInterface::class);
$invoker = $this->createMock(InvokeSubCommand::class);
$invoker->expects(self::once())
->method('__invoke')
->with(
$command,
[
'command' => 'install',
'requested-package-and-version' => 'foo/foo:^1.0',
],
$input,
self::isInstanceOf(OutputFormatterWithPrefix::class),
)
->willReturn(0);
$input = new ArrayInput(
['--with-php-config' => '/path/to/php/config'],
new InputDefinition([
new InputOption('with-php-config', null, InputOption::VALUE_REQUIRED),
]),
);
$io = new BufferIO();
(new InstallSelectedPackage(new FullPathToSelf(getcwd())))->withPieCli(
'foo/bar',
$installer = new InstallSelectedPackage($invoker);
$installer->withSubCommand(
ExtensionName::normaliseFromString('foo'),
new RequestedPackageAndVersion(
'foo/foo',
'^1.0',
),
$command,
$input,
$io,
);
self::assertSame(
'> Params passed: install foo/bar --with-php-config /path/to/php/config',
trim($io->getOutput()),
);
}
}