Skip to content

Commit

Permalink
Merge pull request #1216 from NasirNobin/feature/valet-run
Browse files Browse the repository at this point in the history
PHP version isolation helper for command line
  • Loading branch information
mattstauffer authored Mar 31, 2022
2 parents 5dcd39e + 804a924 commit 0045896
Show file tree
Hide file tree
Showing 6 changed files with 272 additions and 28 deletions.
91 changes: 77 additions & 14 deletions cli/Valet/Brew.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Valet;

use DomainException;
use PhpFpm;

class Brew
{
Expand Down Expand Up @@ -264,16 +265,7 @@ public function getParsedLinkedPhp()

$resolvedPath = $this->files->readLink(BREW_PREFIX.'/bin/php');

/**
* Typical homebrew path resolutions are like:
* "../Cellar/[email protected]/7.4.13/bin/php"
* or older styles:
* "../Cellar/php/7.4.9_2/bin/php
* "../Cellar/php55/bin/php.
*/
preg_match('~\w{3,}/(php)(@?\d\.?\d)?/(\d\.\d)?([_\d\.]*)?/?\w{3,}~', $resolvedPath, $matches);

return $matches;
return $this->parsePhpPath($resolvedPath);
}

/**
Expand Down Expand Up @@ -302,15 +294,51 @@ public function linkedPhp()

return $this->supportedPhpVersions()->first(
function ($version) use ($resolvedPhpVersion) {
$resolvedVersionNormalized = preg_replace('/[^\d]/', '', $resolvedPhpVersion);
$versionNormalized = preg_replace('/[^\d]/', '', $version);

return $resolvedVersionNormalized === $versionNormalized;
return $this->arePhpVersionsEqual($resolvedPhpVersion, $version);
}, function () use ($resolvedPhpVersion) {
throw new DomainException("Unable to determine linked PHP when parsing '$resolvedPhpVersion'");
});
}

/**
* Extract PHP executable path from PHP Version.
*
* @param string $phpVersion For example, "[email protected]"
* @return string
*/
public function getPhpExecutablePath($phpVersion = null)
{
if (! $phpVersion) {
return BREW_PREFIX.'/bin/php';
}

$phpVersion = PhpFpm::normalizePhpVersion($phpVersion);

// Check the default `/opt/homebrew/opt/[email protected]/bin/php` location first
if ($this->files->exists(BREW_PREFIX."/opt/{$phpVersion}/bin/php")) {
return BREW_PREFIX."/opt/{$phpVersion}/bin/php";
}

// Check the `/opt/homebrew/opt/php71/bin/php` location for older installations
$phpVersion = str_replace(['@', '.'], '', $phpVersion); // [email protected] to php81
if ($this->files->exists(BREW_PREFIX."/opt/{$phpVersion}/bin/php")) {
return BREW_PREFIX."/opt/{$phpVersion}/bin/php";
}

// Check if the default PHP is the version we are looking for
if ($this->files->isLink(BREW_PREFIX.'/opt/php')) {
$resolvedPath = $this->files->readLink(BREW_PREFIX.'/opt/php');
$matches = $this->parsePhpPath($resolvedPath);
$resolvedPhpVersion = $matches[3] ?: $matches[2];

if ($this->arePhpVersionsEqual($resolvedPhpVersion, $phpVersion)) {
return BREW_PREFIX.'/opt/php/bin/php';
}
}

return BREW_PREFIX.'/bin/php';
}

/**
* Restart the linked PHP-FPM Homebrew service.
*
Expand Down Expand Up @@ -476,4 +504,39 @@ function ($exitCode, $errorOutput) {
}
);
}

/**
* Parse homebrew PHP Path.
*
* @param string $resolvedPath
* @return array
*/
public function parsePhpPath($resolvedPath)
{
/**
* Typical homebrew path resolutions are like:
* "../Cellar/[email protected]/7.4.13/bin/php"
* or older styles:
* "../Cellar/php/7.4.9_2/bin/php
* "../Cellar/php55/bin/php.
*/
preg_match('~\w{3,}/(php)(@?\d\.?\d)?/(\d\.\d)?([_\d\.]*)?/?\w{3,}~', $resolvedPath, $matches);

return $matches;
}

/**
* Check if two PHP versions are equal.
*
* @param string $versionA
* @param string $versionB
* @return bool
*/
public function arePhpVersionsEqual($versionA, $versionB)
{
$versionANormalized = preg_replace('/[^\d]/', '', $versionA);
$versionBNormalized = preg_replace('/[^\d]/', '', $versionB);

return $versionANormalized === $versionBNormalized;
}
}
18 changes: 18 additions & 0 deletions cli/Valet/Site.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Valet;

use DomainException;
use PhpFpm;

class Site
{
Expand Down Expand Up @@ -1108,4 +1109,21 @@ public function replaceSockFile($siteConf, $phpVersion)

return '# '.ISOLATED_PHP_VERSION.'='.$phpVersion.PHP_EOL.$siteConf;
}

/**
* Get PHP version from .valetphprc for a site.
*
* @param string $site
* @return string|null
*/
public function phpRcVersion($site)
{
if ($site = $this->parked()->merge($this->links())->where('site', $site)->first()) {
$path = data_get($site, 'path').'/.valetphprc';

if ($this->files->exists($path)) {
return PhpFpm::normalizePhpVersion(trim($this->files->get($path)));
}
}
}
}
47 changes: 41 additions & 6 deletions cli/valet.php
Original file line number Diff line number Diff line change
Expand Up @@ -506,18 +506,19 @@
*/
$app->command('use [phpVersion] [--force]', function ($phpVersion, $force) {
if (! $phpVersion) {
$path = getcwd().'/.valetphprc';
$site = basename(getcwd());
$linkedVersion = Brew::linkedPhp();
if (! file_exists($path)) {
$phpVersion = Site::phpRcVersion($site);

if (! $phpVersion) {
return info("Valet is using {$linkedVersion}.");
}

$phpVersion = trim(file_get_contents($path));
info("Found '{$path}' specifying version: {$phpVersion}");

if ($linkedVersion == $phpVersion) {
if ($linkedVersion == $phpVersion && ! $force) {
return info("Valet is already using {$linkedVersion}.");
}

info("Found '{$site}/.valetphprc' specifying version: {$phpVersion}");
}

PhpFpm::useVersion($phpVersion, $force);
Expand Down Expand Up @@ -561,6 +562,40 @@
table(['Path', 'PHP Version'], $sites->all());
})->descriptions('List all sites using isolated versions of PHP.');

/**
* Get the PHP executable path for a site.
*/
$app->command('which-php [site]', function ($site) {
$host = Site::host($site ?: getcwd()).'.'.Configuration::read()['tld'];
$phpVersion = Site::customPhpVersion($host);

if (! $phpVersion) {
$phpVersion = Site::phpRcVersion($site ?: basename(getcwd()));
}

return output(Brew::getPhpExecutablePath($phpVersion));
})->descriptions('Get the PHP executable path for a given site', [
'site' => 'The site to get the PHP executable path for',
]);

/**
* Proxy commands through to an isolated site's version of PHP.
*/
$app->command('php [command]', function ($command) {
warning('It looks like you are running `cli/valet.php` directly; please use the `valet` script in the project root instead.');
})->descriptions("Proxy PHP commands with isolated site's PHP executable", [
'command' => "Command to run with isolated site's PHP executable",
]);

/**
* Proxy commands through to an isolated site's version of Composer.
*/
$app->command('composer [command]', function ($command) {
warning('It looks like you are running `cli/valet.php` directly; please use the `valet` script in the project root instead.');
})->descriptions("Proxy Composer commands with isolated site's PHP executable", [
'command' => "Composer command to run with isolated site's PHP executable",
]);

/**
* Tail log file.
*/
Expand Down
65 changes: 65 additions & 0 deletions tests/BrewTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,71 @@ public function test_restart_linked_php_will_pass_through_linked_php_formula_to_
$brewMock->restartLinkedPhp();
}

public function test_it_can_get_php_binary_path_from_php_version()
{
// Check the default `/opt/homebrew/opt/[email protected]/bin/php` location first
$brewMock = Mockery::mock(Brew::class, [
Mockery::mock(CommandLine::class),
$files = Mockery::mock(Filesystem::class),
])->makePartial();

$files->shouldReceive('exists')->once()->with(BREW_PREFIX.'/opt/[email protected]/bin/php')->andReturn(true);
$files->shouldNotReceive('exists')->with(BREW_PREFIX.'/opt/php@74/bin/php');
$this->assertEquals(BREW_PREFIX.'/opt/[email protected]/bin/php', $brewMock->getPhpExecutablePath('[email protected]'));

// Check the `/opt/homebrew/opt/php71/bin/php` location for older installations
$brewMock = Mockery::mock(Brew::class, [
Mockery::mock(CommandLine::class),
$files = Mockery::mock(Filesystem::class),
])->makePartial();

$files->shouldReceive('exists')->once()->with(BREW_PREFIX.'/opt/[email protected]/bin/php')->andReturn(false);
$files->shouldReceive('exists')->with(BREW_PREFIX.'/opt/php74/bin/php')->andReturn(true);
$this->assertEquals(BREW_PREFIX.'/opt/php74/bin/php', $brewMock->getPhpExecutablePath('[email protected]'));

// When the default PHP is the version we are looking for
$brewMock = Mockery::mock(Brew::class, [
Mockery::mock(CommandLine::class),
$files = Mockery::mock(Filesystem::class),
])->makePartial();

$files->shouldReceive('exists')->once()->with(BREW_PREFIX.'/opt/[email protected]/bin/php')->andReturn(false);
$files->shouldReceive('exists')->with(BREW_PREFIX.'/opt/php74/bin/php')->andReturn(false);
$files->shouldReceive('isLink')->with(BREW_PREFIX.'/opt/php')->andReturn(true);
$files->shouldReceive('readLink')->with(BREW_PREFIX.'/opt/php')->andReturn('../Cellar/[email protected]/7.4.13/bin/php');
$this->assertEquals(BREW_PREFIX.'/opt/php/bin/php', $brewMock->getPhpExecutablePath('[email protected]'));

// When the default PHP is not the version we are looking for
$brewMock = Mockery::mock(Brew::class, [
Mockery::mock(CommandLine::class),
$files = Mockery::mock(Filesystem::class),
])->makePartial();

$files->shouldReceive('exists')->once()->with(BREW_PREFIX.'/opt/[email protected]/bin/php')->andReturn(false);
$files->shouldReceive('exists')->with(BREW_PREFIX.'/opt/php74/bin/php')->andReturn(false);
$files->shouldReceive('isLink')->with(BREW_PREFIX.'/opt/php')->andReturn(true);
$files->shouldReceive('readLink')->with(BREW_PREFIX.'/opt/php')->andReturn('../Cellar/[email protected]/8.1.13/bin/php');
$this->assertEquals(BREW_PREFIX.'/bin/php', $brewMock->getPhpExecutablePath('[email protected]')); // Could not find a version, so retuned the default binary

// When no PHP Version is provided
$brewMock = Mockery::mock(Brew::class, [
Mockery::mock(CommandLine::class),
Mockery::mock(Filesystem::class),
])->makePartial();

$this->assertEquals(BREW_PREFIX.'/bin/php', $brewMock->getPhpExecutablePath(null));
}

public function test_it_can_compare_two_php_versions()
{
$this->assertTrue(resolve(Brew::class)->arePhpVersionsEqual('php71', '[email protected]'));
$this->assertTrue(resolve(Brew::class)->arePhpVersionsEqual('php71', 'php@71'));
$this->assertTrue(resolve(Brew::class)->arePhpVersionsEqual('php71', '71'));

$this->assertFalse(resolve(Brew::class)->arePhpVersionsEqual('php71', 'php@70'));
$this->assertFalse(resolve(Brew::class)->arePhpVersionsEqual('php71', '72'));
}

/**
* Provider of php links and their expected split matches.
*
Expand Down
49 changes: 49 additions & 0 deletions tests/SiteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,55 @@ public function test_it_returns_secured_sites()

$this->assertSame(['helloworld.tld'], $sites);
}

public function test_it_can_read_php_rc_version()
{
$config = Mockery::mock(Configuration::class);
$files = Mockery::mock(Filesystem::class);

swap(Configuration::class, $config);
swap(Filesystem::class, $files);

$siteMock = Mockery::mock(Site::class, [
resolve(Configuration::class),
resolve(CommandLine::class),
resolve(Filesystem::class),
])->makePartial();

swap(Site::class, $siteMock);

$config->shouldReceive('read')
->andReturn(['tld' => 'test', 'loopback' => VALET_LOOPBACK, 'paths' => []]);

$siteMock->shouldReceive('parked')
->andReturn(collect([
'site1' => [
'site' => 'site1',
'secured' => '',
'url' => 'http://site1.test',
'path' => '/Users/name/code/site1',
],
]));

$siteMock->shouldReceive('links')->andReturn(collect([
'site2' => [
'site' => 'site2',
'secured' => 'X',
'url' => 'http://site2.test',
'path' => '/Users/name/some-other-directory/site2',
],
]));

$files->shouldReceive('exists')->with('/Users/name/code/site1/.valetphprc')->andReturn(true);
$files->shouldReceive('get')->with('/Users/name/code/site1/.valetphprc')->andReturn('[email protected]');

$files->shouldReceive('exists')->with('/Users/name/some-other-directory/site2/.valetphprc')->andReturn(true);
$files->shouldReceive('get')->with('/Users/name/some-other-directory/site2/.valetphprc')->andReturn('[email protected]');

$this->assertEquals('[email protected]', $siteMock->phpRcVersion('site1'));
$this->assertEquals('[email protected]', $siteMock->phpRcVersion('site2'));
$this->assertEquals(null, $siteMock->phpRcVersion('site3')); // Site doesn't exists
}
}

class CommandLineFake extends CommandLine
Expand Down
30 changes: 22 additions & 8 deletions valet
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,6 @@ then
DIR=$(php -r "echo realpath('$DIR/../laravel/valet');")
fi

if [[ "$EUID" -ne 0 ]]
then
sudo USER="$USER" --preserve-env "$SOURCE" "$@"
exit
fi

# If the command is the "share" command we will need to resolve out any
# symbolic links for the site. Before starting Ngrok, we will fire a
# process to retrieve the live Ngrok tunnel URL in the background.
Expand Down Expand Up @@ -81,16 +75,36 @@ then
ARCH=$(uname -m)

if [[ $ARCH == 'arm64' ]]; then
sudo -u "$USER" "$DIR/bin/ngrok-arm" http "$HOST.$TLD:$PORT" -host-header=rewrite $PARAMS
"$DIR/bin/ngrok-arm" http "$HOST.$TLD:$PORT" -host-header=rewrite $PARAMS
else
sudo -u "$USER" "$DIR/bin/ngrok" http "$HOST.$TLD:$PORT" -host-header=rewrite $PARAMS
"$DIR/bin/ngrok" http "$HOST.$TLD:$PORT" -host-header=rewrite $PARAMS
fi

exit

# Proxy PHP commands to the "php" executable on the isolated site
elif [[ "$1" = "php" ]]
then
$(php "$DIR/cli/valet.php" which-php) "${@:2}"

exit

# Proxy Composer commands with the "php" executable on the isolated site
elif [[ "$1" = "composer" ]]
then
$(php "$DIR/cli/valet.php" which-php) $(which composer) "${@:2}"

exit

# Finally, for every other command we will just proxy into the PHP tool
# and let it handle the request. These are commands which can be run
# without sudo and don't require taking over terminals like Ngrok.
else
if [[ "$EUID" -ne 0 ]]
then
sudo USER="$USER" --preserve-env "$SOURCE" "$@"
exit
fi

php "$DIR/cli/valet.php" "$@"
fi

0 comments on commit 0045896

Please sign in to comment.