From b342c4b3531a2302762d4bdb67aab778a61eee15 Mon Sep 17 00:00:00 2001 From: Obinna Elvis Okechukwu Date: Tue, 21 Apr 2026 17:36:44 +0100 Subject: [PATCH 1/2] fix(composer): set HOME and COMPOSER_HOME when spawning composer process Installing/removing extensions from the admin panel failed under PHP-FPM with: "The HOME or COMPOSER_HOME environment variable must be set for composer to run correctly". Web-server users (www-data, nginx, etc.) often run with no HOME set, so Composer's Factory::getHomeDir() throws. Merge HOME and COMPOSER_HOME into the env passed to Symfony Process, pointing at the Manager's storagePath (already writable by the web user). Caller-provided env keys still take precedence. --- src/Flame/Composer/Manager.php | 5 ++++ tests/src/Flame/Composer/ManagerTest.php | 35 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/Flame/Composer/Manager.php b/src/Flame/Composer/Manager.php index 381aea6b..fe8c30fe 100644 --- a/src/Flame/Composer/Manager.php +++ b/src/Flame/Composer/Manager.php @@ -269,6 +269,11 @@ protected function restoreComposerFiles(): void protected function getProcess(array $command, array $env = []): Process { + $env = array_merge([ + 'HOME' => $this->storagePath, + 'COMPOSER_HOME' => $this->storagePath, + ], $env); + return (new Process($command, $this->workingPath, $env))->setTimeout(null); } diff --git a/tests/src/Flame/Composer/ManagerTest.php b/tests/src/Flame/Composer/ManagerTest.php index 82a9393a..0c15e38c 100644 --- a/tests/src/Flame/Composer/ManagerTest.php +++ b/tests/src/Flame/Composer/ManagerTest.php @@ -8,8 +8,10 @@ use Exception; use Igniter\Flame\Composer\Manager; use Igniter\Flame\Support\Facades\File; +use ReflectionMethod; use RuntimeException; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; it('loads package version correctly', function() { $version = resolve(Manager::class)->getPackageVersion('some-package'); @@ -267,3 +269,36 @@ $manager = new Manager(__DIR__, '/storage'); expect(fn() => $manager->assertSchema())->toThrow(RuntimeException::class); }); + +it('injects HOME and COMPOSER_HOME into the spawned composer process', function() { + $manager = new Manager('/root', '/storage'); + $method = new ReflectionMethod(Manager::class, 'getProcess'); + $method->setAccessible(true); + + /** @var Process $process */ + $process = $method->invoke($manager, ['composer', 'install'], ['COMPOSER_MEMORY_LIMIT' => '-1']); + + expect($process)->toBeInstanceOf(Process::class) + ->and($process->getEnv())->toMatchArray([ + 'HOME' => '/storage', + 'COMPOSER_HOME' => '/storage', + 'COMPOSER_MEMORY_LIMIT' => '-1', + ]); +}); + +it('allows callers to override HOME and COMPOSER_HOME on the spawned composer process', function() { + $manager = new Manager('/root', '/storage'); + $method = new ReflectionMethod(Manager::class, 'getProcess'); + $method->setAccessible(true); + + /** @var Process $process */ + $process = $method->invoke($manager, ['composer', 'install'], [ + 'HOME' => '/custom/home', + 'COMPOSER_HOME' => '/custom/composer', + ]); + + expect($process->getEnv())->toMatchArray([ + 'HOME' => '/custom/home', + 'COMPOSER_HOME' => '/custom/composer', + ]); +}); From c51f75c1200d2545406a4634ecf899e508c51ddf Mon Sep 17 00:00:00 2001 From: Obinna Elvis Okechukwu Date: Tue, 21 Apr 2026 18:45:40 +0100 Subject: [PATCH 2/2] fix(composer): only default HOME/COMPOSER_HOME when parent env lacks them Previous patch unconditionally forced HOME and COMPOSER_HOME to storagePath, overriding values inherited from the parent process under CLI. That broke: - git/ssh package fetching, which resolves ~/.ssh via HOME - Composer auth.json, OAuth tokens, and package cache under COMPOSER_HOME Now the defaults only apply when the key is missing from both the caller-provided env and the current process environment, so the web-SAPI case still works while CLI behaviour is preserved. --- src/Flame/Composer/Manager.php | 9 ++-- tests/src/Flame/Composer/ManagerTest.php | 59 ++++++++++++++++++------ 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/Flame/Composer/Manager.php b/src/Flame/Composer/Manager.php index fe8c30fe..3fad41e4 100644 --- a/src/Flame/Composer/Manager.php +++ b/src/Flame/Composer/Manager.php @@ -269,10 +269,11 @@ protected function restoreComposerFiles(): void protected function getProcess(array $command, array $env = []): Process { - $env = array_merge([ - 'HOME' => $this->storagePath, - 'COMPOSER_HOME' => $this->storagePath, - ], $env); + foreach (['HOME', 'COMPOSER_HOME'] as $key) { + if (!isset($env[$key]) && !getenv($key)) { + $env[$key] = $this->storagePath; + } + } return (new Process($command, $this->workingPath, $env))->setTimeout(null); } diff --git a/tests/src/Flame/Composer/ManagerTest.php b/tests/src/Flame/Composer/ManagerTest.php index 0c15e38c..14a5b508 100644 --- a/tests/src/Flame/Composer/ManagerTest.php +++ b/tests/src/Flame/Composer/ManagerTest.php @@ -270,20 +270,53 @@ expect(fn() => $manager->assertSchema())->toThrow(RuntimeException::class); }); -it('injects HOME and COMPOSER_HOME into the spawned composer process', function() { - $manager = new Manager('/root', '/storage'); - $method = new ReflectionMethod(Manager::class, 'getProcess'); - $method->setAccessible(true); +it('injects HOME and COMPOSER_HOME when the parent process has neither set', function() { + $originalHome = getenv('HOME'); + $originalComposerHome = getenv('COMPOSER_HOME'); + putenv('HOME'); + putenv('COMPOSER_HOME'); + + try { + $manager = new Manager('/root', '/storage'); + $method = new ReflectionMethod(Manager::class, 'getProcess'); + $method->setAccessible(true); + + /** @var Process $process */ + $process = $method->invoke($manager, ['composer', 'install'], ['COMPOSER_MEMORY_LIMIT' => '-1']); + + expect($process)->toBeInstanceOf(Process::class) + ->and($process->getEnv())->toMatchArray([ + 'HOME' => '/storage', + 'COMPOSER_HOME' => '/storage', + 'COMPOSER_MEMORY_LIMIT' => '-1', + ]); + } finally { + $originalHome === false ? putenv('HOME') : putenv('HOME='.$originalHome); + $originalComposerHome === false ? putenv('COMPOSER_HOME') : putenv('COMPOSER_HOME='.$originalComposerHome); + } +}); - /** @var Process $process */ - $process = $method->invoke($manager, ['composer', 'install'], ['COMPOSER_MEMORY_LIMIT' => '-1']); - - expect($process)->toBeInstanceOf(Process::class) - ->and($process->getEnv())->toMatchArray([ - 'HOME' => '/storage', - 'COMPOSER_HOME' => '/storage', - 'COMPOSER_MEMORY_LIMIT' => '-1', - ]); +it('does not override HOME or COMPOSER_HOME inherited from the parent process', function() { + $originalHome = getenv('HOME'); + $originalComposerHome = getenv('COMPOSER_HOME'); + putenv('HOME=/parent/home'); + putenv('COMPOSER_HOME=/parent/composer'); + + try { + $manager = new Manager('/root', '/storage'); + $method = new ReflectionMethod(Manager::class, 'getProcess'); + $method->setAccessible(true); + + /** @var Process $process */ + $process = $method->invoke($manager, ['composer', 'install']); + + expect($process->getEnv()) + ->not->toHaveKey('HOME') + ->and($process->getEnv())->not->toHaveKey('COMPOSER_HOME'); + } finally { + $originalHome === false ? putenv('HOME') : putenv('HOME='.$originalHome); + $originalComposerHome === false ? putenv('COMPOSER_HOME') : putenv('COMPOSER_HOME='.$originalComposerHome); + } }); it('allows callers to override HOME and COMPOSER_HOME on the spawned composer process', function() {