From dd0aa3dd2f14dbd6216e6cfa69b829bb31a9cae6 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Wed, 24 Jun 2026 13:43:57 +0100 Subject: [PATCH 01/35] Add package runner contract tests --- src/Metadata.php | 5 ++ src/Package.php | 18 ++--- src/Utils.php | 2 +- src/functions.php | 2 +- tests/Feature/BinaryResolutionTest.php | 13 +++ tests/Feature/CleanCommandTest.php | 79 ++++++++++++++++++ tests/Feature/CliArgumentForwardingTest.php | 17 ++++ tests/Feature/ExampleTest.php | 5 -- tests/Feature/LocalBinaryPreferenceTest.php | 17 ++++ tests/Pest.php | 2 +- tests/TestCase.php | 90 ++++++++++++++++++++- tests/Unit/ConsoleTest.php | 37 +++++++++ tests/Unit/CpxPathTest.php | 7 ++ tests/Unit/ExampleTest.php | 5 -- tests/Unit/MetadataTest.php | 44 ++++++++++ tests/Unit/PackageAliasesTest.php | 31 +++++++ tests/Unit/PackageTest.php | 57 +++++++++++++ tests/Unit/TestSupportTest.php | 9 +++ 18 files changed, 415 insertions(+), 25 deletions(-) create mode 100644 tests/Feature/BinaryResolutionTest.php create mode 100644 tests/Feature/CleanCommandTest.php create mode 100644 tests/Feature/CliArgumentForwardingTest.php delete mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/LocalBinaryPreferenceTest.php create mode 100644 tests/Unit/ConsoleTest.php create mode 100644 tests/Unit/CpxPathTest.php delete mode 100644 tests/Unit/ExampleTest.php create mode 100644 tests/Unit/MetadataTest.php create mode 100644 tests/Unit/PackageAliasesTest.php create mode 100644 tests/Unit/PackageTest.php create mode 100644 tests/Unit/TestSupportTest.php diff --git a/src/Metadata.php b/src/Metadata.php index 25a2382..bbf47d3 100644 --- a/src/Metadata.php +++ b/src/Metadata.php @@ -66,6 +66,11 @@ public function updateLastCheckTime(Package $package, string $type = 'run'): Met public function save(): void { $metadataFile = cpx_path('.cpx_metadata.json'); + + if (! is_dir(dirname($metadataFile))) { + mkdir(dirname($metadataFile), 0755, true); + } + file_put_contents($metadataFile, json_encode($this->toArray(), JSON_PRETTY_PRINT)); } diff --git a/src/Package.php b/src/Package.php index 6b3c9bb..3ecc75b 100644 --- a/src/Package.php +++ b/src/Package.php @@ -21,19 +21,15 @@ public static function parse(string $str): Package throw new InvalidArgumentException('A package name must be provided.'); } - if (! str_contains($str, '/')) { - throw new InvalidArgumentException('A package name should be in the format "/'); + if (preg_match('/\A(?[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?)\/(?[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?)(?::(?[a-zA-Z0-9_.@~^*<>!=|,-]+))?\z/', $str, $matches) !== 1) { + throw new InvalidArgumentException('A package name should be in the format "/[:version]".'); } - $parts = explode(':', str_replace('@', ':', $str)); - [$vendor, $name] = explode('/', $parts[0]); - $version = $parts[1] ?? null; - - if ($version === '') { - $version = null; - } - - return new Package($vendor, $name, $version); + return new Package( + vendor: $matches['vendor'], + name: $matches['name'], + version: $matches['version'] ?? null, + ); } public function folder(): string diff --git a/src/Utils.php b/src/Utils.php index 218ae15..ae82d2a 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -23,7 +23,7 @@ class Utils */ public static function arrayMapAssoc(callable $f, array $a): array { - return array_merge(...array_map($f, array_keys($a), $a)); + return $a === [] ? [] : array_merge(...array_map($f, array_keys($a), $a)); } public static function deleteDirectory(string $directory): void diff --git a/src/functions.php b/src/functions.php index 215caf1..8424d42 100644 --- a/src/functions.php +++ b/src/functions.php @@ -76,7 +76,7 @@ function composer_require(string ...$packages): void if (! function_exists('cpx_path')) { function cpx_path(string $path = ''): string { - $home = $_SERVER['HOME'] ?? __DIR__; + $home = $_SERVER['COMPOSER_HOME'] ?? getenv('COMPOSER_HOME') ?: ($_SERVER['HOME'] ?? __DIR__); return "{$home}/.cpx/".trim($path, '/'); } diff --git a/tests/Feature/BinaryResolutionTest.php b/tests/Feature/BinaryResolutionTest.php new file mode 100644 index 0000000..d64c6fe --- /dev/null +++ b/tests/Feature/BinaryResolutionTest.php @@ -0,0 +1,13 @@ +todo( + 'Enable when package binaries are executed through the safe process runner.', +); + +test('a package with multiple binaries uses the first forwarded argument as the binary name')->todo( + 'Enable when multiple-binary packages are resolved through validated targets.', +); + +test('ambiguous multiple-binary packages list the available binaries')->todo( + 'Enable when multiple-binary packages return actionable ambiguity errors.', +); diff --git a/tests/Feature/CleanCommandTest.php b/tests/Feature/CleanCommandTest.php new file mode 100644 index 0000000..98c2d2a --- /dev/null +++ b/tests/Feature/CleanCommandTest.php @@ -0,0 +1,79 @@ +useIsolatedComposerHome(); + + $packageDirectory = cpx_path('laravel/pint/latest'); + $execDirectory = cpx_path('.exec_cache/sandbox'); + + mkdir($packageDirectory, 0755, true); + mkdir($execDirectory, 0755, true); + + if (! is_dir(dirname(cpx_path('.cpx_metadata.json')))) { + mkdir(dirname(cpx_path('.cpx_metadata.json')), 0755, true); + } + file_put_contents(cpx_path('.cpx_metadata.json'), json_encode([ + 'packages' => [ + 'laravel/pint' => [ + 'last_updated' => '2024-01-01 00:00:00', + 'last_run' => '2024-01-01 00:00:00', + ], + ], + 'execCache' => [ + 'sandbox' => [ + 'packages' => ['laravel/pint'], + 'last_updated' => 1, + 'last_run' => 1, + ], + ], + ], JSON_THROW_ON_ERROR)); + + ob_start(); + (new CleanCommand(Console::parse(['clean', '--all'])))->__invoke(); + ob_end_clean(); + + expect(is_dir($packageDirectory))->toBeFalse() + ->and(is_dir($execDirectory))->toBeFalse() + ->and(json_decode((string) file_get_contents(cpx_path('.cpx_metadata.json')), true))->toBe([ + 'packages' => [], + 'execCache' => [], + ]); +}); + +test('it preserves fresh tracked package cache directories', function () { + $this->useIsolatedComposerHome(); + + $packageDirectory = cpx_path('laravel/pint/latest'); + + mkdir($packageDirectory, 0755, true); + + if (! is_dir(dirname(cpx_path('.cpx_metadata.json')))) { + mkdir(dirname(cpx_path('.cpx_metadata.json')), 0755, true); + } + file_put_contents(cpx_path('.cpx_metadata.json'), json_encode([ + 'packages' => [ + 'laravel/pint' => [ + 'last_updated' => date('Y-m-d H:i:s'), + 'last_run' => date('Y-m-d H:i:s'), + ], + ], + 'execCache' => [], + ], JSON_THROW_ON_ERROR)); + + ob_start(); + (new CleanCommand(Console::parse(['clean'])))->__invoke(); + ob_end_clean(); + + expect(is_dir($packageDirectory))->toBeTrue(); +}); + +test('orphaned package directories are detected and cleaned')->todo( + 'Enable when cleanup scans for cache directories missing from metadata.', +); + +test('cleanup refuses to delete paths outside the cpx cache root')->todo( + 'Enable when cleanup validates every deletion stays inside the cpx cache root.', +); diff --git a/tests/Feature/CliArgumentForwardingTest.php b/tests/Feature/CliArgumentForwardingTest.php new file mode 100644 index 0000000..dfdd316 --- /dev/null +++ b/tests/Feature/CliArgumentForwardingTest.php @@ -0,0 +1,17 @@ +todo( + 'Enable when the process runner forwards argv tokens without shell reconstruction.', +); + +test('short flags, long flags, repeated options, and option values are preserved')->todo( + 'Enable when forwarded package options are preserved as original argv tokens.', +); + +test('a target binary exit code becomes the cpx exit code')->todo( + 'Enable when child process exit codes are propagated through the cpx executable.', +); + +test('a missing target binary returns a non-zero status with an actionable error')->todo( + 'Enable when missing binaries produce explicit non-zero failures.', +); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 61cd84c..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Feature/LocalBinaryPreferenceTest.php b/tests/Feature/LocalBinaryPreferenceTest.php new file mode 100644 index 0000000..2069225 --- /dev/null +++ b/tests/Feature/LocalBinaryPreferenceTest.php @@ -0,0 +1,17 @@ +todo( + 'Enable when local project binaries are resolved before isolated package installs.', +); + +test('it discovers a custom composer bin-dir before remote package resolution')->todo( + 'Enable when local Composer bin-dir discovery is implemented.', +); + +test('local binary execution preserves forwarded arguments and exit codes')->todo( + 'Enable when local binary execution uses argv tokens and propagates exit codes.', +); + +test('missing local binaries fall back to alias or package resolution')->todo( + 'Enable when local binary lookup has a documented remote fallback path.', +); diff --git a/tests/Pest.php b/tests/Pest.php index afcf075..4809776 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,4 +2,4 @@ use Tests\TestCase; -pest()->extend(TestCase::class)->in('Feature'); +pest()->extend(TestCase::class)->in('Feature', 'Unit'); diff --git a/tests/TestCase.php b/tests/TestCase.php index cfb05b6..a3c55b9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,8 +3,96 @@ namespace Tests; use PHPUnit\Framework\TestCase as BaseTestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; abstract class TestCase extends BaseTestCase { - // + /** @var array */ + private array $environment = []; + + /** @var array */ + private array $server = []; + + /** @var list */ + private array $temporaryDirectories = []; + + protected function tearDown(): void + { + foreach (array_reverse($this->temporaryDirectories) as $directory) { + $this->deleteDirectory($directory); + } + + foreach ($this->environment as $name => $value) { + $value === false ? putenv($name) : putenv("{$name}={$value}"); + } + + foreach ($this->server as $name => $value) { + if ($value === null) { + unset($_SERVER[$name]); + + continue; + } + + $_SERVER[$name] = $value; + } + + parent::tearDown(); + } + + protected function temporaryDirectory(string $prefix = 'cpx-test'): string + { + $directory = sys_get_temp_dir().'/'.$prefix.'-'.bin2hex(random_bytes(8)); + + mkdir($directory, 0755, true); + + $this->temporaryDirectories[] = $directory; + + return $directory; + } + + protected function useIsolatedComposerHome(): string + { + $home = $this->temporaryDirectory('cpx-home'); + $composerHome = "{$home}/composer"; + + mkdir($composerHome, 0755, true); + + $this->setEnvironmentVariable('HOME', $home); + $this->setEnvironmentVariable('COMPOSER_HOME', $composerHome); + + return $composerHome; + } + + protected function setEnvironmentVariable(string $name, string $value): void + { + if (! array_key_exists($name, $this->environment)) { + $this->environment[$name] = getenv($name); + } + + if (! array_key_exists($name, $this->server)) { + $this->server[$name] = $_SERVER[$name] ?? null; + } + + putenv("{$name}={$value}"); + $_SERVER[$name] = $value; + } + + private function deleteDirectory(string $directory): void + { + if (! is_dir($directory)) { + return; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + + rmdir($directory); + } } diff --git a/tests/Unit/ConsoleTest.php b/tests/Unit/ConsoleTest.php new file mode 100644 index 0000000..2f732d4 --- /dev/null +++ b/tests/Unit/ConsoleTest.php @@ -0,0 +1,37 @@ +command)->toBe('laravel/pint') + ->and($console->arguments)->toBe(['app', 'two words']) + ->and($console->hasFlag('test'))->toBeTrue(); +}); + +test('it keeps repeated option values in order', function () { + $console = Console::parse(['tool', '--filter=one', '--filter=two']); + + expect($console->options['filter'])->toBe(['one', 'two']) + ->and($console->getOption('filter'))->toBe('one'); +}); + +test('it maps configured short options and flags', function () { + $console = Console::parse( + ['tool', '-v', '-c', 'phpstan.neon'], + shortOptions: ['c' => 'configuration', 'v' => 'verbose'], + flagOptions: ['verbose'], + ); + + expect($console->hasFlag('verbose'))->toBeTrue() + ->and($console->getOption('configuration'))->toBe('phpstan.neon'); +}); + +test('the double-dash separator preserves all following target arguments')->todo( + 'Enable when the process runner forwards argv tokens without shell reconstruction.', +); + +test('child process exit codes are returned through the command runner')->todo( + 'Enable when child process exit codes are propagated through the command runner.', +); diff --git a/tests/Unit/CpxPathTest.php b/tests/Unit/CpxPathTest.php new file mode 100644 index 0000000..6d5e37a --- /dev/null +++ b/tests/Unit/CpxPathTest.php @@ -0,0 +1,7 @@ +useIsolatedComposerHome(); + + expect(cpx_path('metadata.json'))->toBe("{$composerHome}/.cpx/metadata.json"); +}); diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 61cd84c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Unit/MetadataTest.php b/tests/Unit/MetadataTest.php new file mode 100644 index 0000000..ff022bf --- /dev/null +++ b/tests/Unit/MetadataTest.php @@ -0,0 +1,44 @@ +useIsolatedComposerHome(); + + $metadata = Metadata::open(); + + expect($metadata->packages)->toBe([]) + ->and($metadata->execCache)->toBe([]); +}); + +test('it saves readable metadata json and round trips package timestamps', function () { + $this->useIsolatedComposerHome(); + + Metadata::open() + ->updateLastCheckTime(Package::parse('laravel/pint'), 'updated') + ->updateLastCheckTime(Package::parse('laravel/pint')) + ->save(); + + $metadataFile = cpx_path('.cpx_metadata.json'); + $contents = file_get_contents($metadataFile); + $metadata = Metadata::open(); + + expect($contents)->not->toBeFalse() + ->and(json_decode((string) $contents, true))->toHaveKey('packages') + ->and($metadata->hasPackage('laravel/pint'))->toBeTrue() + ->and($metadata->packages['laravel/pint']->lastUpdatedAt)->not->toBeNull() + ->and($metadata->packages['laravel/pint']->lastRunAt)->not->toBeNull(); +}); + +test('it handles invalid metadata json as an empty state', function () { + $this->useIsolatedComposerHome(); + + mkdir(dirname(cpx_path('.cpx_metadata.json')), 0755, true); + file_put_contents(cpx_path('.cpx_metadata.json'), '{invalid'); + + $metadata = Metadata::open(); + + expect($metadata->packages)->toBe([]) + ->and($metadata->execCache)->toBe([]); +}); diff --git a/tests/Unit/PackageAliasesTest.php b/tests/Unit/PackageAliasesTest.php new file mode 100644 index 0000000..0f0b599 --- /dev/null +++ b/tests/Unit/PackageAliasesTest.php @@ -0,0 +1,31 @@ +toBeTrue() + ->and(PackageAliases::$packages[$alias]['package'])->toBe($package) + ->and(PackageAliases::$packages[$alias]['command'])->toBe($command); +})->with([ + ['pint', 'laravel/pint', 'pint'], + ['phpstan', 'phpstan/phpstan', 'phpstan'], + ['rector', 'rector/rector', 'rector'], +]); + +test('each default alias has the required package runner fields', function () { + foreach (PackageAliases::$packages as $alias => $package) { + expect(array_keys($package))->toContain('name', 'description', 'command', 'package') + ->and($package['name'])->not->toBe('') + ->and($package['description'])->not->toBe('') + ->and($package['command'])->not->toBe('') + ->and($package['package'])->toContain('/'); + } +}); + +test('alias dispatch uses the alias command field when selecting among multiple binaries')->todo( + 'Enable when binary selection uses validated alias metadata.', +); + +test('aliases can be added or overridden through user-managed config')->todo( + 'Enable when aliases move from hardcoded defaults to user-managed config.', +); diff --git a/tests/Unit/PackageTest.php b/tests/Unit/PackageTest.php new file mode 100644 index 0000000..f0e3e7e --- /dev/null +++ b/tests/Unit/PackageTest.php @@ -0,0 +1,57 @@ +vendor)->toBe('laravel') + ->and($package->name)->toBe('pint') + ->and($package->version)->toBeNull() + ->and($package->versionName())->toBe('latest') + ->and($package->fullPackageString())->toBe('laravel/pint'); +}); + +test('it preserves version constraints and stability flags', function (string $target, string $version) { + $package = Package::parse($target); + + expect($package->version)->toBe($version) + ->and($package->fullPackageString())->toBe($target); +})->with([ + ['laravel/pint:^1.2', '^1.2'], + ['laravel/pint:1.0.0', '1.0.0'], + ['laravel/pint:dev-main', 'dev-main'], + ['laravel/pint:^1@dev', '^1@dev'], +]); + +test('it accepts composer package names with supported punctuation', function (string $target) { + $package = Package::parse($target); + + expect($package->fullPackageString())->toBe($target); +})->with([ + 'vendor-name/package_name', + 'vendor.name/package-name', + 'vendor123/package.456', +]); + +test('it rejects invalid package targets', function (string $target) { + Package::parse($target); +})->with([ + '', + 'laravel', + '/pint', + 'laravel/', + 'Laravel/pint', + 'laravel/Pint', + 'laravel/pint extra', + '../laravel/pint', + 'laravel/../pint', + 'laravel/pint;rm -rf', + 'laravel/pint|cat', + 'laravel/pint/name', + 'laravel/pint:', +])->throws(InvalidArgumentException::class); + +test('package cache keys are derived from validated identifiers or stable safe hashes')->todo( + 'Enable when cache keys are redesigned to avoid raw constraint punctuation.', +); diff --git a/tests/Unit/TestSupportTest.php b/tests/Unit/TestSupportTest.php new file mode 100644 index 0000000..f39c1b9 --- /dev/null +++ b/tests/Unit/TestSupportTest.php @@ -0,0 +1,9 @@ +useIsolatedComposerHome(); + + expect(getenv('COMPOSER_HOME'))->toBe($composerHome) + ->and($_SERVER['COMPOSER_HOME'])->toBe($composerHome) + ->and(is_dir($composerHome))->toBeTrue(); +}); From f7a3611f209035a0a9745c08516668ebc4a0f365 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 10:24:14 +0100 Subject: [PATCH 02/35] Add Symfony Console application layer --- composer.json | 3 +- cpx | 52 +----- src/Application.php | 93 ++++++++++ src/PackageCommandRunner.php | 55 ++++++ tests/Feature/CommandBehaviorTest.php | 239 ++++++++++++++++++++++++++ tests/TestCase.php | 13 ++ 6 files changed, 404 insertions(+), 51 deletions(-) create mode 100644 src/Application.php create mode 100644 src/PackageCommandRunner.php create mode 100644 tests/Feature/CommandBehaviorTest.php diff --git a/composer.json b/composer.json index 3c58859..e6346e8 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ } }, "require": { - "php": "^8.3" + "php": "^8.3", + "symfony/console": "^8.1" }, "require-dev": { "laravel/pao": "^1.1", diff --git a/cpx b/cpx index 824075b..0cf8580 100755 --- a/cpx +++ b/cpx @@ -1,22 +1,7 @@ #! /usr/bin/env php command === 'list' => ListCommand::class, - $console->command === 'help' => HelpCommand::class, - $console->command === 'clean' => CleanCommand::class, - $console->command === 'aliases' => AliasesCommand::class, - $console->command === 'update' => UpdateCommand::class, - $console->command === 'upgrade' => UpgradeCommand::class, - $console->command === 'exec' => ExecCommand::class, - $console->command === 'format' || $console->command === 'fmt' => FormatCommand::class, - $console->command === 'check' || $console->command === 'analyze' || $console->command === 'analyse' => CheckCommand::class, - $console->command === 'test' => TestCommand::class, - $console->command === 'tinker' => TinkerCommand::class, - $console->command === 'version' => VersionCommand::class, - file_exists(realpath($console->command)) && !is_dir(realpath($console->command)) => (new ExecCommand(Console::parse("exec {$console->command} {$console->getCommandInput()}")))(), - array_key_exists($console->command, PackageAliases::$packages) => Package::parse(PackageAliases::$packages[$console->command]['package'])->runCommand($console), - str_contains($console->command, '/') => Package::parse($console->command)->runCommand($console), - $console->command === '--version' || $console->command === '-v' || $console->hasOption('version') || $console->hasOption('v') => VersionCommand::class, - default => (new HelpCommand($console))(true), -}; - -try { - if (is_subclass_of($command, Command::class)) { - $command = new $command($console); - $command(); - } elseif (is_callable($command)) { - $command(); - } -} catch (Exception $e) { - echo Command::BACKGROUND_RED . " {$e->getMessage()} " . Command::COLOR_RESET . PHP_EOL; - exit(1); -} +exit((new Application)->run()); diff --git a/src/Application.php b/src/Application.php new file mode 100644 index 0000000..5df90b1 --- /dev/null +++ b/src/Application.php @@ -0,0 +1,93 @@ +setAutoExit(false); + + $this->registerCommands($packageCommandRunner ?? new PackageCommandRunner); + } + + public function run(?InputInterface $input = null, ?OutputInterface $output = null): int + { + $input ??= new ArgvInput; + + if ($input instanceof ArgvInput && $this->isVersionRequest($input)) { + $input = new ArgvInput(['cpx', 'version']); + } + + return parent::run($input, $output); + } + + protected function getCommandName(InputInterface $input): ?string + { + if ($this->isEmptyRequest($input)) { + return 'help'; + } + + if ($this->isVersionRequest($input)) { + return 'version'; + } + + $command = parent::getCommandName($input); + + return $command === null || $this->has($command) + ? $command + : RunPackageCommand::Name; + } + + private function registerCommands(PackageCommandRunner $packageCommandRunner): void + { + $this->addCommands([ + new HelpCommand, + new ListCommand, + new AliasesCommand, + new CleanCommand, + new UpdateCommand, + new UpgradeCommand, + new ExecCommand, + new FormatCommand, + new CheckCommand, + new TestCommand, + new TinkerCommand, + new VersionCommand, + new RunPackageCommand($packageCommandRunner), + ]); + } + + private function isVersionRequest(InputInterface $input): bool + { + return ! $input instanceof ArgvInput + ? false + : in_array($input->getRawTokens()[0] ?? null, ['--version', '-v'], true); + } + + private function isEmptyRequest(InputInterface $input): bool + { + return $input instanceof ArgvInput && $input->getRawTokens() === []; + } +} diff --git a/src/PackageCommandRunner.php b/src/PackageCommandRunner.php new file mode 100644 index 0000000..869d364 --- /dev/null +++ b/src/PackageCommandRunner.php @@ -0,0 +1,55 @@ +isFile($console->command)) { + (new ExecCommand($this->fileConsole($console)))(); + + return SymfonyCommand::SUCCESS; + } + + if (array_key_exists($console->command, PackageAliases::$packages)) { + Package::parse(PackageAliases::$packages[$console->command]['package'])->runCommand($console); + + return SymfonyCommand::SUCCESS; + } + + if (str_contains($console->command, '/')) { + Package::parse($console->command)->runCommand($console); + + return SymfonyCommand::SUCCESS; + } + + (new HelpCommand($console))(true); + + return SymfonyCommand::FAILURE; + } + + private function isFile(string $path): bool + { + $realPath = realpath($path); + + return $realPath !== false && file_exists($realPath) && ! is_dir($realPath); + } + + private function fileConsole(Console $console): Console + { + return new Console( + rawInput: $console->rawInput, + command: 'exec', + arguments: [$console->command, ...$console->arguments], + options: $console->options, + flags: $console->flags, + ); + } +} diff --git a/tests/Feature/CommandBehaviorTest.php b/tests/Feature/CommandBehaviorTest.php new file mode 100644 index 0000000..cd3833e --- /dev/null +++ b/tests/Feature/CommandBehaviorTest.php @@ -0,0 +1,239 @@ +run(new ArgvInput(['cpx', ...$arguments]), $output); + + return [$status, $output->fetch()]; +} + +function writeExecutable(string $path, string $contents): void +{ + file_put_contents($path, $contents); + chmod($path, 0755); +} + +test('help shows the cpx usage guide', function () { + [$status, $output] = runCpxCommand(['help']); + + expect($status)->toBe(0) + ->and($output)->toContain('cpx - A Composer package runner') + ->and($output)->toContain('cpx [args]'); +}); + +test('it can run through Symfony tester utilities without exiting', function () { + $tester = new ApplicationTester(new Application); + + $status = $tester->run(['command' => 'help']); + + expect($status)->toBe(0) + ->and($tester->getDisplay())->toContain('cpx - A Composer package runner'); +}); + +test('empty invocations show cpx help instead of Symfony command listings', function () { + [$status, $output] = runCpxCommand([]); + + expect($status)->toBe(0) + ->and($output)->toContain('cpx - A Composer package runner') + ->and($output)->not->toContain('Available commands'); +}); + +test('list shows when no packages are installed', function () { + $this->useIsolatedComposerHome(); + + [$status, $output] = runCpxCommand(['list']); + + expect($status)->toBe(0) + ->and($output)->toContain('There are no installed packages.') + ->and($output)->not->toContain('Available commands'); +}); + +test('aliases lists aliased package commands', function () { + [$status, $output] = runCpxCommand(['aliases']); + + expect($status)->toBe(0) + ->and($output)->toContain('Aliased packages:') + ->and($output)->toContain('cpx pint'); +}); + +test('version prints cpx and php versions', function () { + [$status, $output] = runCpxCommand(['version']); + + expect($status)->toBe(0) + ->and($output)->toContain('cpx version:') + ->and($output)->toContain('php version:'); +}); + +test('top-level version options keep cpx version behavior', function (string $option) { + [$status, $output] = runCpxCommand([$option]); + + expect($status)->toBe(0) + ->and($output)->toContain('cpx version:'); +})->with(['--version', '-v']); + +test('clean reports when there are no packages to clean', function () { + $this->useIsolatedComposerHome(); + + [$status, $output] = runCpxCommand(['clean']); + + expect($status)->toBe(0) + ->and($output)->toContain('There were no packages to clean.'); +}); + +test('update reports when there are no packages to update', function () { + $this->useIsolatedComposerHome(); + + [$status, $output] = runCpxCommand(['update']); + + expect($status)->toBe(0) + ->and($output)->toContain('There are no packages to update.'); +}); + +test('upgrade runs the composer global update command', function () { + $binDirectory = $this->temporaryDirectory('cpx-bin'); + $logFile = $this->temporaryDirectory('cpx-log').'/composer.log'; + + writeExecutable($binDirectory.'/composer', "#!/usr/bin/env php\nsetEnvironmentVariable('PATH', $binDirectory.PATH_SEPARATOR.getenv('PATH')); + + [$status, $output] = runCpxCommand(['upgrade']); + + expect($status)->toBe(0) + ->and($output)->toContain('Updating') + ->and(file_get_contents($logFile))->toContain('global update cpx/cpx'); +}); + +test('exec runs inline php code', function () { + $directory = $this->temporaryDirectory('cpx-exec'); + $this->useWorkingDirectory($directory); + + [$status, $output] = runCpxCommand(['exec', '-r', 'echo "hello";']); + + expect($status)->toBe(0) + ->and($output)->toContain('hello'); +}); + +test('format fails clearly when no formatter exists in the project', function () { + $this->useWorkingDirectory($this->temporaryDirectory('cpx-format')); + + [$status, $output] = runCpxCommand(['format']); + + expect($status)->toBe(1) + ->and($output)->toContain('No code formatters found in the project.'); +}); + +test('check fails clearly when no analyzer exists in the project', function () { + $this->useWorkingDirectory($this->temporaryDirectory('cpx-check')); + + [$status, $output] = runCpxCommand(['check']); + + expect($status)->toBe(1) + ->and($output)->toContain('No static analyzers found in the project.'); +}); + +test('test fails clearly when no test runner exists in the project', function () { + $this->useWorkingDirectory($this->temporaryDirectory('cpx-test-runner')); + + [$status, $output] = runCpxCommand(['test']); + + expect($status)->toBe(1) + ->and($output)->toContain('No test runner found in the project.'); +}); + +test('tinker runs the cached psysh package with the bundled config', function () { + $this->useIsolatedComposerHome(); + + $packageDirectory = cpx_path('psy/psysh/latest/vendor/psy/psysh'); + mkdir($packageDirectory, 0755, true); + + file_put_contents($packageDirectory.'/composer.json', json_encode([ + 'bin' => ['psysh'], + ], JSON_THROW_ON_ERROR)); + writeExecutable($packageDirectory.'/psysh', "#!/usr/bin/env php\n [ + 'psy/psysh' => [ + 'last_updated' => date('Y-m-d H:i:s'), + 'last_run' => date('Y-m-d H:i:s'), + ], + ], + 'execCache' => [], + ], JSON_THROW_ON_ERROR)); + + [$status, $output] = runCpxCommand(['tinker']); + + expect($status)->toBe(0) + ->and($output)->toContain('Running psysh from psy/psysh'); +}); + +test('unknown package targets route to the package fallback command', function () { + $runner = new class extends PackageCommandRunner + { + public ?Console $console = null; + + public function run(Console $console): int + { + $this->console = $console; + + return 0; + } + }; + $application = new Application($runner); + + $status = $application->run(new ArgvInput(['cpx', 'vendor/package', '--flag', 'value']), new BufferedOutput); + + expect($status)->toBe(0) + ->and($runner->console)->toBeInstanceOf(Console::class) + ->and($runner->console?->command)->toBe('vendor/package') + ->and($runner->console?->hasOption('flag'))->toBeTrue() + ->and($runner->console?->getOption('flag'))->toBe('value'); +}); + +test('package fallback accepts arbitrary package options without Symfony validation errors', function () { + $runner = new class extends PackageCommandRunner + { + public ?Console $console = null; + + public function run(Console $console): int + { + $this->console = $console; + + return 0; + } + }; + $application = new Application($runner); + + $status = $application->run(new ArgvInput([ + 'cpx', + 'vendor/package', + '--unknown', + 'value', + '-x', + '--filter=one', + '--filter=two', + '--', + '--literal', + ]), new BufferedOutput); + + expect($status)->toBe(0) + ->and($runner->console?->command)->toBe('vendor/package') + ->and($runner->console?->getOption('unknown'))->toBe('value') + ->and($runner->console?->options['filter'] ?? null)->toBe(['one', 'two']); +}); + +test('invalid fallback commands return a failure status with help output', function () { + [$status, $output] = runCpxCommand(['not-a-package']); + + expect($status)->toBe(1) + ->and($output)->toContain('Unrecognised command not-a-package'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index a3c55b9..45d2531 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -17,8 +17,14 @@ abstract class TestCase extends BaseTestCase /** @var list */ private array $temporaryDirectories = []; + private ?string $workingDirectory = null; + protected function tearDown(): void { + if ($this->workingDirectory !== null) { + chdir($this->workingDirectory); + } + foreach (array_reverse($this->temporaryDirectories) as $directory) { $this->deleteDirectory($directory); } @@ -78,6 +84,13 @@ protected function setEnvironmentVariable(string $name, string $value): void $_SERVER[$name] = $value; } + protected function useWorkingDirectory(string $directory): void + { + $this->workingDirectory ??= getcwd() ?: null; + + chdir($directory); + } + private function deleteDirectory(string $directory): void { if (! is_dir($directory)) { From a7a92d2534d0721d8cc429a6cfa8ee36460e52c1 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:12:10 +0100 Subject: [PATCH 03/35] Remove legacy command base --- src/Commands/Command.php | 72 --------------------------- src/Console.php | 3 +- src/PackageCommandRunner.php | 23 +++------ tests/Feature/CleanCommandTest.php | 13 ++--- tests/Feature/CommandBehaviorTest.php | 17 ++++--- 5 files changed, 22 insertions(+), 106 deletions(-) delete mode 100644 src/Commands/Command.php diff --git a/src/Commands/Command.php b/src/Commands/Command.php deleted file mode 100644 index a479679..0000000 --- a/src/Commands/Command.php +++ /dev/null @@ -1,72 +0,0 @@ -line($message, Command::BACKGROUND_GREEN); - } - - protected function error(string $message): void - { - $this->line($message, Command::BACKGROUND_RED); - } - - protected function info(string $message): void - { - $this->line($message, Command::BACKGROUND_CYAN); - } -} diff --git a/src/Console.php b/src/Console.php index 0cf8ab4..50d114f 100644 --- a/src/Console.php +++ b/src/Console.php @@ -4,7 +4,6 @@ namespace Cpx; -use Cpx\Commands\Command; use Cpx\Exceptions\ConsoleException; class Console @@ -182,7 +181,7 @@ public function exec(bool $verbose = false): void ]; if ($verbose) { - echo Command::BACKGROUND_CYAN." Running command: '{$this}' ".Command::COLOR_RESET; + echo "\033[46m Running command: '{$this}' \033[0m"; } $process = proc_open((string) $this, $descriptors, $pipes); diff --git a/src/PackageCommandRunner.php b/src/PackageCommandRunner.php index 869d364..134f7ba 100644 --- a/src/PackageCommandRunner.php +++ b/src/PackageCommandRunner.php @@ -7,15 +7,17 @@ use Cpx\Commands\ExecCommand; use Cpx\Commands\HelpCommand; use Symfony\Component\Console\Command\Command as SymfonyCommand; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\OutputInterface; class PackageCommandRunner { - public function run(Console $console): int + public function run(Console $console, OutputInterface $output): int { if ($this->isFile($console->command)) { - (new ExecCommand($this->fileConsole($console)))(); - - return SymfonyCommand::SUCCESS; + return (new ExecCommand)->run(new ArrayInput([ + 'file' => $console->command, + ]), $output); } if (array_key_exists($console->command, PackageAliases::$packages)) { @@ -30,7 +32,7 @@ public function run(Console $console): int return SymfonyCommand::SUCCESS; } - (new HelpCommand($console))(true); + HelpCommand::render($output, $console->command); return SymfonyCommand::FAILURE; } @@ -41,15 +43,4 @@ private function isFile(string $path): bool return $realPath !== false && file_exists($realPath) && ! is_dir($realPath); } - - private function fileConsole(Console $console): Console - { - return new Console( - rawInput: $console->rawInput, - command: 'exec', - arguments: [$console->command, ...$console->arguments], - options: $console->options, - flags: $console->flags, - ); - } } diff --git a/tests/Feature/CleanCommandTest.php b/tests/Feature/CleanCommandTest.php index 98c2d2a..a824ba0 100644 --- a/tests/Feature/CleanCommandTest.php +++ b/tests/Feature/CleanCommandTest.php @@ -1,7 +1,8 @@ useIsolatedComposerHome(); @@ -31,9 +32,7 @@ ], ], JSON_THROW_ON_ERROR)); - ob_start(); - (new CleanCommand(Console::parse(['clean', '--all'])))->__invoke(); - ob_end_clean(); + (new Application)->run(new ArgvInput(['cpx', 'clean', '--all']), new BufferedOutput); expect(is_dir($packageDirectory))->toBeFalse() ->and(is_dir($execDirectory))->toBeFalse() @@ -63,9 +62,7 @@ 'execCache' => [], ], JSON_THROW_ON_ERROR)); - ob_start(); - (new CleanCommand(Console::parse(['clean'])))->__invoke(); - ob_end_clean(); + (new Application)->run(new ArgvInput(['cpx', 'clean']), new BufferedOutput); expect(is_dir($packageDirectory))->toBeTrue(); }); diff --git a/tests/Feature/CommandBehaviorTest.php b/tests/Feature/CommandBehaviorTest.php index cd3833e..dc9a094 100644 --- a/tests/Feature/CommandBehaviorTest.php +++ b/tests/Feature/CommandBehaviorTest.php @@ -5,6 +5,7 @@ use Cpx\PackageCommandRunner; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\ApplicationTester; function runCpxCommand(array $arguments): array @@ -123,23 +124,23 @@ function writeExecutable(string $path, string $contents): void ->and($output)->toContain('hello'); }); -test('format fails clearly when no formatter exists in the project', function () { +test('format fails clearly when no formatter exists in the project', function (string $command) { $this->useWorkingDirectory($this->temporaryDirectory('cpx-format')); - [$status, $output] = runCpxCommand(['format']); + [$status, $output] = runCpxCommand([$command]); expect($status)->toBe(1) ->and($output)->toContain('No code formatters found in the project.'); -}); +})->with(['format', 'fmt']); -test('check fails clearly when no analyzer exists in the project', function () { +test('check fails clearly when no analyzer exists in the project', function (string $command) { $this->useWorkingDirectory($this->temporaryDirectory('cpx-check')); - [$status, $output] = runCpxCommand(['check']); + [$status, $output] = runCpxCommand([$command]); expect($status)->toBe(1) ->and($output)->toContain('No static analyzers found in the project.'); -}); +})->with(['check', 'analyze', 'analyse']); test('test fails clearly when no test runner exists in the project', function () { $this->useWorkingDirectory($this->temporaryDirectory('cpx-test-runner')); @@ -181,7 +182,7 @@ function writeExecutable(string $path, string $contents): void { public ?Console $console = null; - public function run(Console $console): int + public function run(Console $console, OutputInterface $output): int { $this->console = $console; @@ -204,7 +205,7 @@ public function run(Console $console): int { public ?Console $console = null; - public function run(Console $console): int + public function run(Console $console, OutputInterface $output): int { $this->console = $console; From 46a05e83ae1a92be0c9b5f3c71bd8e77d68b120a Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:23:35 +0100 Subject: [PATCH 04/35] Convert aliases command to Symfony --- src/Commands/AliasesCommand.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Commands/AliasesCommand.php b/src/Commands/AliasesCommand.php index 7c51391..22ab6d2 100644 --- a/src/Commands/AliasesCommand.php +++ b/src/Commands/AliasesCommand.php @@ -5,18 +5,28 @@ namespace Cpx\Commands; use Cpx\PackageAliases; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +#[AsCommand( + name: 'aliases', + description: 'Show aliased package commands', +)] class AliasesCommand extends Command { - public function __invoke(): void + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->line('Aliased packages:'.PHP_EOL); + $output->writeln('Aliased packages:'.PHP_EOL); $packages = PackageAliases::$packages; usort($packages, fn (array $a, array $b): int => strcmp($a['command'], $b['command'])); foreach ($packages as $package) { $paddedCommand = str_pad($package['command'], 15); - $this->line(' '.Command::COLOR_GREEN.'cpx '.$paddedCommand.Command::COLOR_RESET.' '.$package['description']); + $output->writeln(' cpx '.$paddedCommand.' '.$package['description']); } + + return self::SUCCESS; } } From 10680400d32aa105e85d91332ba6f671dcdc465d Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:23:43 +0100 Subject: [PATCH 05/35] Convert check command to Symfony --- src/Commands/CheckCommand.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Commands/CheckCommand.php b/src/Commands/CheckCommand.php index 348cb75..c2d62b6 100644 --- a/src/Commands/CheckCommand.php +++ b/src/Commands/CheckCommand.php @@ -6,17 +6,26 @@ use Cpx\Console; use Cpx\Exceptions\ConsoleException; - +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'check', + description: 'Run a static analysis tool over a project', + aliases: ['analyze', 'analyse'], +)] class CheckCommand extends Command { - public function __invoke(): void + protected function execute(InputInterface $input, OutputInterface $output): int { if (file_exists('vendor/bin/phpstan')) { $command = 'vendor/bin/phpstan analyse'; Console::parse($command)->exec(); - return; + return self::SUCCESS; } if (file_exists('vendor/bin/psalm')) { @@ -24,7 +33,7 @@ public function __invoke(): void Console::parse($command)->exec(); - return; + return self::SUCCESS; } if (file_exists('vendor/bin/phan')) { @@ -32,7 +41,7 @@ public function __invoke(): void Console::parse($command)->exec(); - return; + return self::SUCCESS; } throw new ConsoleException('No static analyzers found in the project.'); From 239ff3a7728a0f0c93c7f817273eb485e14f472d Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:23:49 +0100 Subject: [PATCH 06/35] Convert clean command to Symfony --- src/Commands/CleanCommand.php | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/Commands/CleanCommand.php b/src/Commands/CleanCommand.php index a088c21..ea57be1 100644 --- a/src/Commands/CleanCommand.php +++ b/src/Commands/CleanCommand.php @@ -7,12 +7,27 @@ use Cpx\Metadata; use Cpx\Package; use Cpx\Utils; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +#[AsCommand( + name: 'clean', + description: 'Clean unused cpx package caches', +)] class CleanCommand extends Command { - public function __invoke(): void + protected function configure(): void { - $days = (int) ($this->console->getOption('days') ?? 30); + $this->addOption('all', null, InputOption::VALUE_NONE, 'Clean all packages'); + $this->addOption('days', null, InputOption::VALUE_REQUIRED, 'Clean packages older than this number of days', '30'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $days = (int) $input->getOption('days'); $metadata = Metadata::open(); $timeLimit = time() - ($days * 24 * 3600); @@ -21,9 +36,9 @@ public function __invoke(): void foreach ($metadata->packages as $packageKey => $packageMetadata) { $lastRun = strtotime($packageMetadata->lastRunAt ?? '1970-01-01 00:00:00'); - if ($this->console->hasOption('all') || $lastRun < $timeLimit) { + if ($input->getOption('all') === true || $lastRun < $timeLimit) { $package = Package::parse($packageKey); - $this->line(Command::COLOR_GREEN."Removing unused package {$package}..."); + $output->writeln("Removing unused package {$package}..."); $package->delete(); unset($metadata->packages[$packageKey]); $cleanedSomething = true; @@ -33,10 +48,10 @@ public function __invoke(): void foreach ($metadata->execCache as $sandboxDir => $packageMetadata) { $lastRun = $packageMetadata['last_run'] ?? 0; - if ($this->console->hasOption('all') || $lastRun < $timeLimit) { + if ($input->getOption('all') === true || $lastRun < $timeLimit) { $packageDirectory = cpx_path(".exec_cache/{$sandboxDir}"); Utils::deleteDirectory($packageDirectory); - $this->line(Command::COLOR_GREEN."Removing exec sandbox cache {$sandboxDir}..."); + $output->writeln("Removing exec sandbox cache {$sandboxDir}..."); unset($metadata->execCache[$sandboxDir]); $cleanedSomething = true; } @@ -45,7 +60,9 @@ public function __invoke(): void $metadata->save(); if (! $cleanedSomething) { - $this->success('There were no packages to clean.'); + $output->writeln('There were no packages to clean.'); } + + return self::SUCCESS; } } From 6cd270407341fe7bb4b9b15153e1ee5cb9df7f58 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:23:57 +0100 Subject: [PATCH 07/35] Convert exec command to Symfony --- src/Commands/ExecCommand.php | 88 ++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/src/Commands/ExecCommand.php b/src/Commands/ExecCommand.php index 0da7209..dfdca1e 100644 --- a/src/Commands/ExecCommand.php +++ b/src/Commands/ExecCommand.php @@ -5,20 +5,54 @@ namespace Cpx\Commands; use Cpx\PhpExecutionHelper; - +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'exec', + description: 'Invoke a PHP file or inline PHP code', +)] class ExecCommand extends Command { public string $path; - public function __invoke(): void + protected function configure(): void + { + $this->addArgument('file', InputArgument::OPTIONAL, 'PHP file to invoke'); + $this->addOption('run', 'r', InputOption::VALUE_REQUIRED, 'Run PHP code without tags'); + $this->addOption('find-autoloader', null, InputOption::VALUE_OPTIONAL, 'Find and load the nearest Composer autoloader', 'true'); + $this->addOption('load-laravel-bootstrap', null, InputOption::VALUE_OPTIONAL, 'Load Laravel bootstrap files when available', 'true'); + $this->addOption('alias-classes', null, InputOption::VALUE_OPTIONAL, 'Alias classes from loaded autoloaders', 'true'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { - if ($this->console->hasOption('r')) { - $code = $this->console->getOption('r'); + ob_start(); - if (empty($code)) { - $this->error('Please supply code to execute with the -r option.'); + try { + return $this->executeInput($input, $output); + } finally { + $contents = ob_get_clean(); - return; + if ($contents !== false) { + $output->write($contents); + } + } + } + + private function executeInput(InputInterface $input, OutputInterface $output): int + { + if ($input->getOption('run') !== null) { + $code = $input->getOption('run'); + + if (! is_string($code) || $code === '') { + $output->writeln('Please supply code to execute with the -r option.'); + + return self::FAILURE; } if (str_starts_with($code, 'error('Unable to determine the current working directory.'); + $output->writeln('Unable to determine the current working directory.'); - return; + return self::FAILURE; } - $this->autoload($directory); + $this->autoload($directory, $input, $output); eval($code); echo PHP_EOL; - return; + return self::SUCCESS; } - if (empty($this->console->arguments[0])) { - $this->error('Please supply the path to a file to execute.'); + $file = $input->getArgument('file'); + + if (! is_string($file) || $file === '') { + $output->writeln('Please supply the path to a file to execute.'); - return; + return self::FAILURE; } - $path = realpath($this->console->arguments[0]); + $path = realpath($file); if ($path === false || ! file_exists($path)) { - $this->error("File does not exist at '{$this->console->arguments[0]}'"); + $output->writeln("File does not exist at '{$file}'"); - return; + return self::FAILURE; } $this->path = $path; - $this->autoload(dirname($this->path)); + $this->autoload(dirname($this->path), $input, $output); $this->runFile(); + + return self::SUCCESS; } - protected function autoload(string $directory): void + protected function autoload(string $directory, InputInterface $input, OutputInterface $output): void { - $shouldFindAutoloader = $this->booleanOption('find-autoloader', true); - $shouldLoadLaravelBootstrap = $this->booleanOption('load-laravel-bootstrap', true); - $shouldAliasClasses = $this->booleanOption('alias-classes', true); - $shouldBeVerbose = $this->booleanOption('verbose', false); + $shouldFindAutoloader = $this->booleanOption($input, 'find-autoloader', true); + $shouldLoadLaravelBootstrap = $this->booleanOption($input, 'load-laravel-bootstrap', true); + $shouldAliasClasses = $this->booleanOption($input, 'alias-classes', true); + $shouldBeVerbose = $output->isVerbose(); PhpExecutionHelper::init($directory, $shouldFindAutoloader, $shouldLoadLaravelBootstrap, $shouldAliasClasses, $shouldBeVerbose); } - protected function booleanOption(string $option, bool $default): bool + protected function booleanOption(InputInterface $input, string $option, bool $default): bool { - $value = $this->console->getOption($option); + $value = $input->getOption($option); if ($value === null) { return $default; From d1fa17155a93bca754d5769f12b3f2a6d03fbba5 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:24:05 +0100 Subject: [PATCH 08/35] Convert format command to Symfony --- src/Commands/FormatCommand.php | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/Commands/FormatCommand.php b/src/Commands/FormatCommand.php index c92b6f4..b518117 100644 --- a/src/Commands/FormatCommand.php +++ b/src/Commands/FormatCommand.php @@ -6,29 +6,47 @@ use Cpx\Console; use Cpx\Exceptions\ConsoleException; - +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'format', + description: 'Run a code formatter over a project', + aliases: ['fmt'], +)] class FormatCommand extends Command { - public function __invoke(): void + protected function configure(): void + { + $this->addArgument('directory', InputArgument::OPTIONAL, 'Directory to format', '.'); + $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report formatting changes without writing them'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { - $directory = $this->console->arguments[0] ?? '.'; + $directory = $input->getArgument('directory'); + $directory = is_string($directory) ? $directory : '.'; if (file_exists('vendor/bin/pint')) { $command = "vendor/bin/pint {$directory}"; - if ($this->console->hasOption('dry-run')) { + if ($input->getOption('dry-run') === true) { $command .= ' --test'; } Console::parse($command)->exec(); - return; + return self::SUCCESS; } if (file_exists('vendor/bin/php-cs-fixer')) { $command = "vendor/bin/php-cs-fixer fix {$directory}"; - if ($this->console->hasOption('dry-run')) { + if ($input->getOption('dry-run') === true) { $command .= ' --dry-run'; } @@ -36,7 +54,7 @@ public function __invoke(): void Console::parse($command)->exec(); - return; + return self::SUCCESS; } if (file_exists('vendor/bin/phpcbf')) { @@ -44,7 +62,7 @@ public function __invoke(): void Console::parse($command)->exec(); - return; + return self::SUCCESS; } throw new ConsoleException('No code formatters found in the project.'); From 89f119382c4a3d681e82f9bac6cf22f60e118ef3 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:24:11 +0100 Subject: [PATCH 09/35] Convert help command to Symfony --- src/Commands/HelpCommand.php | 54 +++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/src/Commands/HelpCommand.php b/src/Commands/HelpCommand.php index 0851125..ca453fb 100644 --- a/src/Commands/HelpCommand.php +++ b/src/Commands/HelpCommand.php @@ -4,29 +4,45 @@ namespace Cpx\Commands; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'help', + description: 'Show the cpx help message', +)] class HelpCommand extends Command { - public function __invoke(bool $unknownCommand = false): void + protected function execute(InputInterface $input, OutputInterface $output): int + { + self::render($output); + + return self::SUCCESS; + } + + public static function render(OutputInterface $output, ?string $unknownCommand = null): void { - if ($unknownCommand) { - $this->error("Unrecognised command {$this->console->command}"); + if ($unknownCommand !== null) { + $output->writeln("Unrecognised command {$unknownCommand}"); } - $this->success('cpx - A Composer package runner with on-demand execution and package management.'); - $this->line('Usage:'); - $this->line(' '.Command::COLOR_GREEN.'cpx [args] '.Command::COLOR_RESET.'Run a Composer package\'s bin command'); - $this->line(' '.Command::COLOR_GREEN.'cpx check '.Command::COLOR_RESET.'Run a static analysis tool over a project'); - $this->line(' '.Command::COLOR_GREEN.'cpx test '.Command::COLOR_RESET.'Run a testing framework over a project'); - $this->line(' '.Command::COLOR_GREEN.'cpx format '.Command::COLOR_RESET.'Run a code formatter over a project'); - $this->line(' '.Command::COLOR_GREEN.'cpx update '.Command::COLOR_RESET.'Update all packages'); - $this->line(' '.Command::COLOR_GREEN.'cpx update '.Command::COLOR_RESET.'Update all versions of a package'); - $this->line(' '.Command::COLOR_GREEN.'cpx clean '.Command::COLOR_RESET.'Clean unused packages (older than 30 days)'); - $this->line(' '.Command::COLOR_GREEN.'cpx clean --all '.Command::COLOR_RESET.'Clean all packages'); - $this->line(' '.Command::COLOR_GREEN.'cpx exec '.Command::COLOR_RESET.'Invoke a PHP file'); - $this->line(' '.Command::COLOR_GREEN.'cpx exec -r '.Command::COLOR_RESET.'Run PHP code without tags'); - $this->line(' '.Command::COLOR_GREEN.'cpx tinker '.Command::COLOR_RESET.'Open an interactive REPL'); - $this->line(' '.Command::COLOR_GREEN.'cpx list '.Command::COLOR_RESET.'List all installed packages'); - $this->line(' '.Command::COLOR_GREEN.'cpx aliases '.Command::COLOR_RESET.'Show aliased package names to run via `cpx `'); - $this->line(' '.Command::COLOR_GREEN.'cpx help '.Command::COLOR_RESET.'Show this help message'); + $output->writeln('cpx - A Composer package runner with on-demand execution and package management.'); + $output->writeln('Usage:'); + $output->writeln(' cpx [args] Run a Composer package\'s bin command'); + $output->writeln(' cpx check Run a static analysis tool over a project'); + $output->writeln(' cpx test Run a testing framework over a project'); + $output->writeln(' cpx format Run a code formatter over a project'); + $output->writeln(' cpx update Update all packages'); + $output->writeln(' cpx update Update all versions of a package'); + $output->writeln(' cpx clean Clean unused packages (older than 30 days)'); + $output->writeln(' cpx clean --all Clean all packages'); + $output->writeln(' cpx exec Invoke a PHP file'); + $output->writeln(' cpx exec -r Run PHP code without tags'); + $output->writeln(' cpx tinker Open an interactive REPL'); + $output->writeln(' cpx list List all installed packages'); + $output->writeln(' cpx aliases Show aliased package names to run via `cpx `'); + $output->writeln(' cpx help Show this help message'); } } From 5ca6bb4152e85d238abb667688e62f6ee0a0f66e Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:24:19 +0100 Subject: [PATCH 10/35] Convert list command to Symfony --- src/Commands/ListCommand.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Commands/ListCommand.php b/src/Commands/ListCommand.php index afab44b..b250534 100644 --- a/src/Commands/ListCommand.php +++ b/src/Commands/ListCommand.php @@ -5,23 +5,33 @@ namespace Cpx\Commands; use Cpx\Metadata; - +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'list', + description: 'List installed cpx packages', +)] class ListCommand extends Command { - public function __invoke(): void + protected function execute(InputInterface $input, OutputInterface $output): int { $metadata = Metadata::open(); if (empty($metadata->packages)) { - $this->line('There are no installed packages.'); + $output->writeln('There are no installed packages.'); - return; + return self::SUCCESS; } - $this->line('Installed Packages:'); + $output->writeln('Installed Packages:'); foreach ($metadata->packages as $packageKey => $packageMetadata) { - $this->line(Command::COLOR_GREEN." {$packageMetadata->package->fullPackageString()}".Command::COLOR_RESET.' (Last Run: '.($packageMetadata->lastRunAt ?? 'N/A').')'); + $output->writeln(" {$packageMetadata->package->fullPackageString()} (Last Run: ".($packageMetadata->lastRunAt ?? 'N/A').')'); } + + return self::SUCCESS; } } From 5fab835029a2ef05d744714a625c5aed44c90e17 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:24:27 +0100 Subject: [PATCH 11/35] Convert package fallback command to Symfony --- src/Commands/RunPackageCommand.php | 44 ++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/Commands/RunPackageCommand.php b/src/Commands/RunPackageCommand.php index 6aabfb7..f120c6a 100644 --- a/src/Commands/RunPackageCommand.php +++ b/src/Commands/RunPackageCommand.php @@ -4,7 +4,47 @@ namespace Cpx\Commands; -class RunPackageCommand extends Command +use Cpx\Console; +use Cpx\PackageCommandRunner; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command as SymfonyCommand; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: self::Name, + hidden: true, +)] +class RunPackageCommand extends SymfonyCommand { - public function __invoke(): void {} + public const Name = '__cpx_run_package'; + + public function __construct( + private PackageCommandRunner $packageCommandRunner, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->ignoreValidationErrors(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $console = Console::parse($input instanceof ArgvInput ? $input->getRawTokens() : []); + + ob_start(); + + try { + return $this->packageCommandRunner->run($console, $output); + } finally { + $contents = ob_get_clean(); + + if ($contents !== false) { + $output->write($contents); + } + } + } } From 6a665fa498445af7f74b7db74b060606c1737d67 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:24:35 +0100 Subject: [PATCH 12/35] Convert test command to Symfony --- src/Commands/TestCommand.php | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Commands/TestCommand.php b/src/Commands/TestCommand.php index 14a0d1a..a50bdb8 100644 --- a/src/Commands/TestCommand.php +++ b/src/Commands/TestCommand.php @@ -6,33 +6,41 @@ use Cpx\Console; use Cpx\Exceptions\ConsoleException; - +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'test', + description: 'Run a testing framework over a project', +)] class TestCommand extends Command { - public function __invoke(): void + protected function execute(InputInterface $input, OutputInterface $output): int { if (file_exists('vendor/bin/pest')) { Console::parse('vendor/bin/pest')->exec(); - return; + return self::SUCCESS; } if (file_exists('bin/phpunit')) { Console::parse('bin/phpunit')->exec(); - return; + return self::SUCCESS; } if (file_exists('vendor/bin/phpunit')) { Console::parse('vendor/bin/phpunit')->exec(); - return; + return self::SUCCESS; } if (file_exists('vendor/bin/codecept')) { Console::parse('vendor/bin/codecept')->exec(); - return; + return self::SUCCESS; } throw new ConsoleException('No test runner found in the project.'); From e9c58c1664844e0a4dc2dd848540fb6c0dda7cd6 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:24:42 +0100 Subject: [PATCH 13/35] Convert tinker command to Symfony --- src/Commands/TinkerCommand.php | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/Commands/TinkerCommand.php b/src/Commands/TinkerCommand.php index d140be8..2fa9ce2 100644 --- a/src/Commands/TinkerCommand.php +++ b/src/Commands/TinkerCommand.php @@ -6,19 +6,39 @@ use Cpx\Console; use Cpx\Package; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +#[AsCommand( + name: 'tinker', + description: 'Open an interactive REPL', +)] class TinkerCommand extends Command { - public function __invoke(): void + protected function execute(InputInterface $input, OutputInterface $output): int { $psyshConfig = realpath(__DIR__.'/../../files/psysh-config.php'); if ($psyshConfig === false) { - $this->error('Unable to find the PsySH configuration file.'); + $output->writeln('Unable to find the PsySH configuration file.'); - return; + return self::FAILURE; } - Package::parse('psy/psysh')->runCommand(Console::parse("psysh --config {$psyshConfig}")); + ob_start(); + + try { + Package::parse('psy/psysh')->runCommand(Console::parse("psysh --config {$psyshConfig}")); + } finally { + $contents = ob_get_clean(); + + if ($contents !== false) { + $output->write($contents); + } + } + + return self::SUCCESS; } } From 284c9931c75f9ebc01db99d56d8a11367038ebad Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:24:49 +0100 Subject: [PATCH 14/35] Convert update command to Symfony --- src/Commands/UpdateCommand.php | 51 +++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/Commands/UpdateCommand.php b/src/Commands/UpdateCommand.php index e4af241..862d5ae 100644 --- a/src/Commands/UpdateCommand.php +++ b/src/Commands/UpdateCommand.php @@ -6,48 +6,67 @@ use Cpx\Composer; use Cpx\Package; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +#[AsCommand( + name: 'update', + description: 'Update installed cpx packages', +)] class UpdateCommand extends Command { - public function __invoke(): void + protected function configure(): void { + $this->addArgument('target', InputArgument::OPTIONAL, 'Package or vendor to update'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $target = $input->getArgument('target'); + $target = is_string($target) ? $target : ''; + match (true) { - str_contains($this->console->arguments[0] ?? '', '/') => $this->updatePackage(Package::parse($this->console->arguments[0])), - ! empty($this->console->arguments[0]) => $this->updateVendor($this->console->arguments[0]), - default => $this->updateAllPackages(), + str_contains($target, '/') => $this->updatePackage(Package::parse($target), $output), + $target !== '' => $this->updateVendor($target, $output), + default => $this->updateAllPackages($output), }; + + return self::SUCCESS; } - protected function updateAllPackages(): void + protected function updateAllPackages(OutputInterface $output): void { $packageDirectories = glob(cpx_path('*/*/*'), GLOB_ONLYDIR) ?: []; if (empty($packageDirectories)) { - $this->line('There are no packages to update.'); + $output->writeln('There are no packages to update.'); } else { foreach ($packageDirectories as $directory) { - $this->updateDirectory($directory); + $this->updateDirectory($directory, $output); } } } - protected function updateVendor(string $vendor): void + protected function updateVendor(string $vendor, OutputInterface $output): void { $packageDirectories = glob(cpx_path("{$vendor}/*/*"), GLOB_ONLYDIR) ?: []; if (empty($packageDirectories)) { - $this->line("There are no packages in vendor '{$vendor}' to update."); + $output->writeln("There are no packages in vendor '{$vendor}' to update."); } else { foreach ($packageDirectories as $directory) { - $this->updateDirectory($directory); + $this->updateDirectory($directory, $output); } } } - protected function updatePackage(Package $package): void + protected function updatePackage(Package $package, OutputInterface $output): void { if ($package->version) { - $this->updateDirectory(cpx_path($package->folder())); + $this->updateDirectory(cpx_path($package->folder()), $output); return; } @@ -55,17 +74,17 @@ protected function updatePackage(Package $package): void $packageDirectories = glob(cpx_path("{$package->vendor}/{$package->name}/*"), GLOB_ONLYDIR) ?: []; if (empty($packageDirectories)) { - $this->line("There are no installed versions of '{$package->vendor}/{$package->name}' to update."); + $output->writeln("There are no installed versions of '{$package->vendor}/{$package->name}' to update."); } else { foreach ($packageDirectories as $directory) { - $this->updateDirectory($directory); + $this->updateDirectory($directory, $output); } } } - protected function updateDirectory(string $directory): void + protected function updateDirectory(string $directory, OutputInterface $output): void { - $this->line('Updating '.Command::COLOR_GREEN.str_replace(cpx_path(), '', $directory)); + $output->writeln('Updating '.str_replace(cpx_path(), '', $directory).''); Composer::runCommand('update', $directory); } } From 15e6faf283dbebf6a8901fde80ace9918b8ac34b Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:24:58 +0100 Subject: [PATCH 15/35] Convert upgrade command to Symfony --- src/Commands/UpgradeCommand.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Commands/UpgradeCommand.php b/src/Commands/UpgradeCommand.php index 201465b..f30c592 100644 --- a/src/Commands/UpgradeCommand.php +++ b/src/Commands/UpgradeCommand.php @@ -5,12 +5,22 @@ namespace Cpx\Commands; use Cpx\Composer; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +#[AsCommand( + name: 'upgrade', + description: 'Upgrade cpx itself', +)] class UpgradeCommand extends Command { - public function __invoke(): void + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->line('Updating '.Command::COLOR_GREEN.'cpx'); + $output->writeln('Updating cpx'); Composer::runCommand('global update cpx/cpx'); + + return self::SUCCESS; } } From d34436ab9071ffbe777cf94df6b653ee16693011 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 11:25:05 +0100 Subject: [PATCH 16/35] Convert version command to Symfony --- src/Commands/VersionCommand.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Commands/VersionCommand.php b/src/Commands/VersionCommand.php index f389412..5200e56 100644 --- a/src/Commands/VersionCommand.php +++ b/src/Commands/VersionCommand.php @@ -4,9 +4,18 @@ namespace Cpx\Commands; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'version', + description: 'Show cpx and PHP versions', +)] class VersionCommand extends Command { - public function __invoke(): void + protected function execute(InputInterface $input, OutputInterface $output): int { $contents = file_get_contents(__DIR__.'/../../composer.json'); $composerData = []; @@ -21,7 +30,9 @@ public function __invoke(): void $cpxVersion = is_string($composerData['version'] ?? null) ? $composerData['version'] : 'unknown'; - $this->line('cpx version: '.Command::COLOR_GREEN.$cpxVersion); - $this->line('php version: '.Command::COLOR_GREEN.PHP_VERSION); + $output->writeln("cpx version: {$cpxVersion}"); + $output->writeln('php version: '.PHP_VERSION.''); + + return self::SUCCESS; } } From b64a5f19bc43dbfaafc910602dcc5eb7c99db386 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Thu, 25 Jun 2026 12:31:53 +0100 Subject: [PATCH 17/35] Preserve exec options for file fallback --- src/PackageCommandRunner.php | 17 ++++++++++++++--- tests/Feature/CommandBehaviorTest.php | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/PackageCommandRunner.php b/src/PackageCommandRunner.php index 134f7ba..f7dacbc 100644 --- a/src/PackageCommandRunner.php +++ b/src/PackageCommandRunner.php @@ -15,9 +15,7 @@ class PackageCommandRunner public function run(Console $console, OutputInterface $output): int { if ($this->isFile($console->command)) { - return (new ExecCommand)->run(new ArrayInput([ - 'file' => $console->command, - ]), $output); + return (new ExecCommand)->run($this->fileInput($console), $output); } if (array_key_exists($console->command, PackageAliases::$packages)) { @@ -43,4 +41,17 @@ private function isFile(string $path): bool return $realPath !== false && file_exists($realPath) && ! is_dir($realPath); } + + private function fileInput(Console $console): ArrayInput + { + $input = ['file' => $console->command]; + + foreach (['find-autoloader', 'load-laravel-bootstrap', 'alias-classes'] as $option) { + if ($console->hasOption($option)) { + $input["--{$option}"] = $console->getOption($option) ?? true; + } + } + + return new ArrayInput($input); + } } diff --git a/tests/Feature/CommandBehaviorTest.php b/tests/Feature/CommandBehaviorTest.php index dc9a094..f261dbf 100644 --- a/tests/Feature/CommandBehaviorTest.php +++ b/tests/Feature/CommandBehaviorTest.php @@ -124,6 +124,20 @@ function writeExecutable(string $path, string $contents): void ->and($output)->toContain('hello'); }); +test('file fallback preserves exec options', function () { + $directory = $this->temporaryDirectory('cpx-file-fallback'); + $this->useWorkingDirectory($directory); + + mkdir($directory.'/vendor', 0755, true); + file_put_contents($directory.'/vendor/autoload.php', 'toBe(0) + ->and($output)->toContain('not-loaded'); +}); + test('format fails clearly when no formatter exists in the project', function (string $command) { $this->useWorkingDirectory($this->temporaryDirectory('cpx-format')); From 9ea1c74a96fa261c03f6e62ed08ddc1ef11da9e8 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 26 Jun 2026 11:40:46 +0100 Subject: [PATCH 18/35] Fix Symfony console compatibility --- composer.json | 2 +- src/Commands/HelpCommand.php | 31 ++++++++++++++++++++++++++- tests/Feature/CommandBehaviorTest.php | 8 +++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e6346e8..096d56b 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ }, "require": { "php": "^8.3", - "symfony/console": "^8.1" + "symfony/console": "^7.4|^8.0" }, "require-dev": { "laravel/pao": "^1.1", diff --git a/src/Commands/HelpCommand.php b/src/Commands/HelpCommand.php index ca453fb..bf3b46b 100644 --- a/src/Commands/HelpCommand.php +++ b/src/Commands/HelpCommand.php @@ -6,6 +6,7 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Command\HelpCommand as SymfonyHelpCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -13,10 +14,38 @@ name: 'help', description: 'Show the cpx help message', )] -class HelpCommand extends Command +class HelpCommand extends SymfonyHelpCommand { + private ?Command $commandForHelp = null; + + protected function configure(): void + { + parent::configure(); + + $this->setDescription('Show the cpx help message'); + } + + public function setCommand(Command $command): void + { + $this->commandForHelp = $command; + + parent::setCommand($command); + } + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($this->commandForHelp !== null) { + try { + return parent::execute($input, $output); + } finally { + $this->commandForHelp = null; + } + } + + if ($input->getArgument('command_name') !== 'help') { + return parent::execute($input, $output); + } + self::render($output); return self::SUCCESS; diff --git a/tests/Feature/CommandBehaviorTest.php b/tests/Feature/CommandBehaviorTest.php index f261dbf..189cbe9 100644 --- a/tests/Feature/CommandBehaviorTest.php +++ b/tests/Feature/CommandBehaviorTest.php @@ -40,6 +40,14 @@ function writeExecutable(string $path, string $contents): void ->and($tester->getDisplay())->toContain('cpx - A Composer package runner'); }); +test('command help options use Symfony command help', function () { + [$status, $output] = runCpxCommand(['list', '--help']); + + expect($status)->toBe(0) + ->and($output)->toContain('List installed cpx packages') + ->and($output)->toContain('Usage:'); +}); + test('empty invocations show cpx help instead of Symfony command listings', function () { [$status, $output] = runCpxCommand([]); From 06ccdc56b1e9a981e3734e516041cb34a6d7a413 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Mon, 29 Jun 2026 13:20:42 +0100 Subject: [PATCH 19/35] Remove the check, format, test, help, and version commands Help and version are provided by Symfony Console out of the box, so the custom commands are gone. The check, format, and test commands are removed too, along with the now-dead Console::exec() and ConsoleException they relied on. --- README.md | 20 +------ src/Application.php | 52 +++++------------- src/Commands/CheckCommand.php | 49 ----------------- src/Commands/FormatCommand.php | 70 ------------------------ src/Commands/HelpCommand.php | 77 --------------------------- src/Commands/TestCommand.php | 48 ----------------- src/Commands/VersionCommand.php | 38 ------------- src/Console.php | 23 -------- src/Exceptions/ConsoleException.php | 9 ---- src/PackageCommandRunner.php | 3 +- tests/Feature/CommandBehaviorTest.php | 59 ++------------------ 11 files changed, 19 insertions(+), 429 deletions(-) delete mode 100644 src/Commands/CheckCommand.php delete mode 100644 src/Commands/FormatCommand.php delete mode 100644 src/Commands/HelpCommand.php delete mode 100644 src/Commands/TestCommand.php delete mode 100644 src/Commands/VersionCommand.php delete mode 100644 src/Exceptions/ConsoleException.php diff --git a/README.md b/README.md index e2345db..bf408aa 100644 --- a/README.md +++ b/README.md @@ -37,24 +37,6 @@ Behind the scenes, cpx will install the package into a separate directory and ru --- -As different projects may use different tools to do the same job, cpx provides a set of commands to normalise common tasks, using the packages a project has installed; `cpx check`, `cpx format` and `cpx test`. - -For each of these commands, you can continue to pass through additional arguments and flags to the underlying tool as if you were running it directly. - -### cpx check - -`cpx check` (or the aliases `cpx analyze` or `cpx analyse`) runs a static analysis tool on your codebase (e.g. PHPStan, Psalm or Phan). - -### cpx format - -`cpx format` (or the alias `cpx fmt`) Runs a code formatting tool on your codebase (e.g. PHP-CS-Fixer, Pint or PHP_CodeSniffer). - -You can pass the `--dry-run` flag to see what changes would be made without actually making them. - -### cpx test - -`cpx test` runs a testing framework on your codebase (e.g. Pest, PHPUnit or Codeception). - ### cpx aliases `cpx aliases` will show a list of popular packages that have been aliased to make them easier to run. You can use these aliases to run a package without needing to remember the full vendor, package and command name. @@ -94,7 +76,7 @@ When using these commands, you get the following benefits: ### cpx help -`cpx help` will show a list of all the commands available in cpx. +`cpx help` shows usage information, and `cpx help ` shows help for a specific command. ## FAQ: diff --git a/src/Application.php b/src/Application.php index 5df90b1..d8af61a 100644 --- a/src/Application.php +++ b/src/Application.php @@ -5,54 +5,28 @@ namespace Cpx; use Cpx\Commands\AliasesCommand; -use Cpx\Commands\CheckCommand; use Cpx\Commands\CleanCommand; use Cpx\Commands\ExecCommand; -use Cpx\Commands\FormatCommand; -use Cpx\Commands\HelpCommand; use Cpx\Commands\ListCommand; use Cpx\Commands\RunPackageCommand; -use Cpx\Commands\TestCommand; use Cpx\Commands\TinkerCommand; use Cpx\Commands\UpdateCommand; use Cpx\Commands\UpgradeCommand; -use Cpx\Commands\VersionCommand; use Symfony\Component\Console\Application as SymfonyApplication; -use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; class Application extends SymfonyApplication { public function __construct(?PackageCommandRunner $packageCommandRunner = null) { - parent::__construct('cpx'); + parent::__construct('cpx', $this->resolveVersion()); $this->setAutoExit(false); $this->registerCommands($packageCommandRunner ?? new PackageCommandRunner); } - public function run(?InputInterface $input = null, ?OutputInterface $output = null): int - { - $input ??= new ArgvInput; - - if ($input instanceof ArgvInput && $this->isVersionRequest($input)) { - $input = new ArgvInput(['cpx', 'version']); - } - - return parent::run($input, $output); - } - protected function getCommandName(InputInterface $input): ?string { - if ($this->isEmptyRequest($input)) { - return 'help'; - } - - if ($this->isVersionRequest($input)) { - return 'version'; - } - $command = parent::getCommandName($input); return $command === null || $this->has($command) @@ -63,31 +37,29 @@ protected function getCommandName(InputInterface $input): ?string private function registerCommands(PackageCommandRunner $packageCommandRunner): void { $this->addCommands([ - new HelpCommand, new ListCommand, new AliasesCommand, new CleanCommand, new UpdateCommand, new UpgradeCommand, new ExecCommand, - new FormatCommand, - new CheckCommand, - new TestCommand, new TinkerCommand, - new VersionCommand, new RunPackageCommand($packageCommandRunner), ]); } - private function isVersionRequest(InputInterface $input): bool + private function resolveVersion(): string { - return ! $input instanceof ArgvInput - ? false - : in_array($input->getRawTokens()[0] ?? null, ['--version', '-v'], true); - } + $contents = file_get_contents(__DIR__.'/../composer.json'); - private function isEmptyRequest(InputInterface $input): bool - { - return $input instanceof ArgvInput && $input->getRawTokens() === []; + if ($contents === false) { + return 'unknown'; + } + + $decoded = json_decode($contents, true); + + return is_array($decoded) && is_string($decoded['version'] ?? null) + ? $decoded['version'] + : 'unknown'; } } diff --git a/src/Commands/CheckCommand.php b/src/Commands/CheckCommand.php deleted file mode 100644 index c2d62b6..0000000 --- a/src/Commands/CheckCommand.php +++ /dev/null @@ -1,49 +0,0 @@ -exec(); - - return self::SUCCESS; - } - - if (file_exists('vendor/bin/psalm')) { - $command = 'vendor/bin/psalm'; - - Console::parse($command)->exec(); - - return self::SUCCESS; - } - - if (file_exists('vendor/bin/phan')) { - $command = 'vendor/bin/phan'; - - Console::parse($command)->exec(); - - return self::SUCCESS; - } - - throw new ConsoleException('No static analyzers found in the project.'); - } -} diff --git a/src/Commands/FormatCommand.php b/src/Commands/FormatCommand.php deleted file mode 100644 index b518117..0000000 --- a/src/Commands/FormatCommand.php +++ /dev/null @@ -1,70 +0,0 @@ -addArgument('directory', InputArgument::OPTIONAL, 'Directory to format', '.'); - $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report formatting changes without writing them'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $directory = $input->getArgument('directory'); - $directory = is_string($directory) ? $directory : '.'; - - if (file_exists('vendor/bin/pint')) { - $command = "vendor/bin/pint {$directory}"; - - if ($input->getOption('dry-run') === true) { - $command .= ' --test'; - } - - Console::parse($command)->exec(); - - return self::SUCCESS; - } - - if (file_exists('vendor/bin/php-cs-fixer')) { - $command = "vendor/bin/php-cs-fixer fix {$directory}"; - - if ($input->getOption('dry-run') === true) { - $command .= ' --dry-run'; - } - - $command .= ' --allow-risky=yes'; - - Console::parse($command)->exec(); - - return self::SUCCESS; - } - - if (file_exists('vendor/bin/phpcbf')) { - $command = "vendor/bin/phpcbf {$directory}"; - - Console::parse($command)->exec(); - - return self::SUCCESS; - } - - throw new ConsoleException('No code formatters found in the project.'); - } -} diff --git a/src/Commands/HelpCommand.php b/src/Commands/HelpCommand.php deleted file mode 100644 index bf3b46b..0000000 --- a/src/Commands/HelpCommand.php +++ /dev/null @@ -1,77 +0,0 @@ -setDescription('Show the cpx help message'); - } - - public function setCommand(Command $command): void - { - $this->commandForHelp = $command; - - parent::setCommand($command); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - if ($this->commandForHelp !== null) { - try { - return parent::execute($input, $output); - } finally { - $this->commandForHelp = null; - } - } - - if ($input->getArgument('command_name') !== 'help') { - return parent::execute($input, $output); - } - - self::render($output); - - return self::SUCCESS; - } - - public static function render(OutputInterface $output, ?string $unknownCommand = null): void - { - if ($unknownCommand !== null) { - $output->writeln("Unrecognised command {$unknownCommand}"); - } - - $output->writeln('cpx - A Composer package runner with on-demand execution and package management.'); - $output->writeln('Usage:'); - $output->writeln(' cpx [args] Run a Composer package\'s bin command'); - $output->writeln(' cpx check Run a static analysis tool over a project'); - $output->writeln(' cpx test Run a testing framework over a project'); - $output->writeln(' cpx format Run a code formatter over a project'); - $output->writeln(' cpx update Update all packages'); - $output->writeln(' cpx update Update all versions of a package'); - $output->writeln(' cpx clean Clean unused packages (older than 30 days)'); - $output->writeln(' cpx clean --all Clean all packages'); - $output->writeln(' cpx exec Invoke a PHP file'); - $output->writeln(' cpx exec -r Run PHP code without tags'); - $output->writeln(' cpx tinker Open an interactive REPL'); - $output->writeln(' cpx list List all installed packages'); - $output->writeln(' cpx aliases Show aliased package names to run via `cpx `'); - $output->writeln(' cpx help Show this help message'); - } -} diff --git a/src/Commands/TestCommand.php b/src/Commands/TestCommand.php deleted file mode 100644 index a50bdb8..0000000 --- a/src/Commands/TestCommand.php +++ /dev/null @@ -1,48 +0,0 @@ -exec(); - - return self::SUCCESS; - } - - if (file_exists('bin/phpunit')) { - Console::parse('bin/phpunit')->exec(); - - return self::SUCCESS; - } - - if (file_exists('vendor/bin/phpunit')) { - Console::parse('vendor/bin/phpunit')->exec(); - - return self::SUCCESS; - } - - if (file_exists('vendor/bin/codecept')) { - Console::parse('vendor/bin/codecept')->exec(); - - return self::SUCCESS; - } - - throw new ConsoleException('No test runner found in the project.'); - } -} diff --git a/src/Commands/VersionCommand.php b/src/Commands/VersionCommand.php deleted file mode 100644 index 5200e56..0000000 --- a/src/Commands/VersionCommand.php +++ /dev/null @@ -1,38 +0,0 @@ -writeln("cpx version: {$cpxVersion}"); - $output->writeln('php version: '.PHP_VERSION.''); - - return self::SUCCESS; - } -} diff --git a/src/Console.php b/src/Console.php index 50d114f..3344353 100644 --- a/src/Console.php +++ b/src/Console.php @@ -4,8 +4,6 @@ namespace Cpx; -use Cpx\Exceptions\ConsoleException; - class Console { /** @@ -171,25 +169,4 @@ public function getFlagsString(): string { return implode(' ', $this->flags); } - - public function exec(bool $verbose = false): void - { - $descriptors = [ - 0 => STDIN, - 1 => STDOUT, - 2 => STDERR, - ]; - - if ($verbose) { - echo "\033[46m Running command: '{$this}' \033[0m"; - } - - $process = proc_open((string) $this, $descriptors, $pipes); - - if (is_resource($process)) { - proc_close($process); - } else { - throw new ConsoleException("Failed to run command '{$this->getCommandInput()}'"); - } - } } diff --git a/src/Exceptions/ConsoleException.php b/src/Exceptions/ConsoleException.php deleted file mode 100644 index 6c56cb4..0000000 --- a/src/Exceptions/ConsoleException.php +++ /dev/null @@ -1,9 +0,0 @@ -command); + $output->writeln("Unrecognised command {$console->command}"); return SymfonyCommand::FAILURE; } diff --git a/tests/Feature/CommandBehaviorTest.php b/tests/Feature/CommandBehaviorTest.php index 189cbe9..53e8e04 100644 --- a/tests/Feature/CommandBehaviorTest.php +++ b/tests/Feature/CommandBehaviorTest.php @@ -23,21 +23,13 @@ function writeExecutable(string $path, string $contents): void chmod($path, 0755); } -test('help shows the cpx usage guide', function () { - [$status, $output] = runCpxCommand(['help']); - - expect($status)->toBe(0) - ->and($output)->toContain('cpx - A Composer package runner') - ->and($output)->toContain('cpx [args]'); -}); - test('it can run through Symfony tester utilities without exiting', function () { $tester = new ApplicationTester(new Application); $status = $tester->run(['command' => 'help']); expect($status)->toBe(0) - ->and($tester->getDisplay())->toContain('cpx - A Composer package runner'); + ->and($tester->getDisplay())->toContain('Usage:'); }); test('command help options use Symfony command help', function () { @@ -48,12 +40,13 @@ function writeExecutable(string $path, string $contents): void ->and($output)->toContain('Usage:'); }); -test('empty invocations show cpx help instead of Symfony command listings', function () { +test('empty invocations run the default list command', function () { + $this->useIsolatedComposerHome(); + [$status, $output] = runCpxCommand([]); expect($status)->toBe(0) - ->and($output)->toContain('cpx - A Composer package runner') - ->and($output)->not->toContain('Available commands'); + ->and($output)->toContain('There are no installed packages.'); }); test('list shows when no packages are installed', function () { @@ -74,21 +67,6 @@ function writeExecutable(string $path, string $contents): void ->and($output)->toContain('cpx pint'); }); -test('version prints cpx and php versions', function () { - [$status, $output] = runCpxCommand(['version']); - - expect($status)->toBe(0) - ->and($output)->toContain('cpx version:') - ->and($output)->toContain('php version:'); -}); - -test('top-level version options keep cpx version behavior', function (string $option) { - [$status, $output] = runCpxCommand([$option]); - - expect($status)->toBe(0) - ->and($output)->toContain('cpx version:'); -})->with(['--version', '-v']); - test('clean reports when there are no packages to clean', function () { $this->useIsolatedComposerHome(); @@ -146,33 +124,6 @@ function writeExecutable(string $path, string $contents): void ->and($output)->toContain('not-loaded'); }); -test('format fails clearly when no formatter exists in the project', function (string $command) { - $this->useWorkingDirectory($this->temporaryDirectory('cpx-format')); - - [$status, $output] = runCpxCommand([$command]); - - expect($status)->toBe(1) - ->and($output)->toContain('No code formatters found in the project.'); -})->with(['format', 'fmt']); - -test('check fails clearly when no analyzer exists in the project', function (string $command) { - $this->useWorkingDirectory($this->temporaryDirectory('cpx-check')); - - [$status, $output] = runCpxCommand([$command]); - - expect($status)->toBe(1) - ->and($output)->toContain('No static analyzers found in the project.'); -})->with(['check', 'analyze', 'analyse']); - -test('test fails clearly when no test runner exists in the project', function () { - $this->useWorkingDirectory($this->temporaryDirectory('cpx-test-runner')); - - [$status, $output] = runCpxCommand(['test']); - - expect($status)->toBe(1) - ->and($output)->toContain('No test runner found in the project.'); -}); - test('tinker runs the cached psysh package with the bundled config', function () { $this->useIsolatedComposerHome(); From 09d930592fae51d5ba21ba382ea5a13c9f69c621 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Mon, 29 Jun 2026 13:34:25 +0100 Subject: [PATCH 20/35] Use negatable boolean options for exec autoloader flags The find-autoloader, load-laravel-bootstrap, and alias-classes options are now VALUE_NEGATABLE with real boolean defaults, so the command reads true booleans and the default lives in one place. The file fallback coerces its parsed value before handing it to Symfony, keeping `--find-autoloader=false` working. --- src/Commands/ExecCommand.php | 23 ++++++----------------- src/PackageCommandRunner.php | 2 +- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/Commands/ExecCommand.php b/src/Commands/ExecCommand.php index dfdca1e..7bd5d79 100644 --- a/src/Commands/ExecCommand.php +++ b/src/Commands/ExecCommand.php @@ -24,9 +24,9 @@ protected function configure(): void { $this->addArgument('file', InputArgument::OPTIONAL, 'PHP file to invoke'); $this->addOption('run', 'r', InputOption::VALUE_REQUIRED, 'Run PHP code without tags'); - $this->addOption('find-autoloader', null, InputOption::VALUE_OPTIONAL, 'Find and load the nearest Composer autoloader', 'true'); - $this->addOption('load-laravel-bootstrap', null, InputOption::VALUE_OPTIONAL, 'Load Laravel bootstrap files when available', 'true'); - $this->addOption('alias-classes', null, InputOption::VALUE_OPTIONAL, 'Alias classes from loaded autoloaders', 'true'); + $this->addOption('find-autoloader', null, InputOption::VALUE_NEGATABLE, 'Find and load the nearest Composer autoloader', true); + $this->addOption('load-laravel-bootstrap', null, InputOption::VALUE_NEGATABLE, 'Load Laravel bootstrap files when available', true); + $this->addOption('alias-classes', null, InputOption::VALUE_NEGATABLE, 'Alias classes from loaded autoloaders', true); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -110,25 +110,14 @@ private function executeInput(InputInterface $input, OutputInterface $output): i protected function autoload(string $directory, InputInterface $input, OutputInterface $output): void { - $shouldFindAutoloader = $this->booleanOption($input, 'find-autoloader', true); - $shouldLoadLaravelBootstrap = $this->booleanOption($input, 'load-laravel-bootstrap', true); - $shouldAliasClasses = $this->booleanOption($input, 'alias-classes', true); + $shouldFindAutoloader = $input->getOption('find-autoloader') === true; + $shouldLoadLaravelBootstrap = $input->getOption('load-laravel-bootstrap') === true; + $shouldAliasClasses = $input->getOption('alias-classes') === true; $shouldBeVerbose = $output->isVerbose(); PhpExecutionHelper::init($directory, $shouldFindAutoloader, $shouldLoadLaravelBootstrap, $shouldAliasClasses, $shouldBeVerbose); } - protected function booleanOption(InputInterface $input, string $option, bool $default): bool - { - $value = $input->getOption($option); - - if ($value === null) { - return $default; - } - - return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? $default; - } - public function runFile(): void { require $this->path; diff --git a/src/PackageCommandRunner.php b/src/PackageCommandRunner.php index d768bc0..068805f 100644 --- a/src/PackageCommandRunner.php +++ b/src/PackageCommandRunner.php @@ -47,7 +47,7 @@ private function fileInput(Console $console): ArrayInput foreach (['find-autoloader', 'load-laravel-bootstrap', 'alias-classes'] as $option) { if ($console->hasOption($option)) { - $input["--{$option}"] = $console->getOption($option) ?? true; + $input["--{$option}"] = filter_var($console->getOption($option), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? true; } } From ba61217f1ffc1286b0fa1b7092d4a6eafd5164ea Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Mon, 29 Jun 2026 14:01:25 +0100 Subject: [PATCH 21/35] Explain why the package fallback ignores validation errors --- src/Commands/RunPackageCommand.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Commands/RunPackageCommand.php b/src/Commands/RunPackageCommand.php index f120c6a..1333437 100644 --- a/src/Commands/RunPackageCommand.php +++ b/src/Commands/RunPackageCommand.php @@ -28,6 +28,8 @@ public function __construct( protected function configure(): void { + // Let undeclared package args/options pass through instead of failing validation; + // execute() reads the raw tokens and forwards them to the package. $this->ignoreValidationErrors(); } From 832705806dab2d6a9fe88dbc0e96f8769866e3dd Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Mon, 29 Jun 2026 14:04:31 +0100 Subject: [PATCH 22/35] Simplify the update target to a string cast --- src/Commands/UpdateCommand.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Commands/UpdateCommand.php b/src/Commands/UpdateCommand.php index 862d5ae..890410d 100644 --- a/src/Commands/UpdateCommand.php +++ b/src/Commands/UpdateCommand.php @@ -25,8 +25,7 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $target = $input->getArgument('target'); - $target = is_string($target) ? $target : ''; + $target = (string) $input->getArgument('target'); match (true) { str_contains($target, '/') => $this->updatePackage(Package::parse($target), $output), From 98cfae8ab852b9335bb26a1916b35991933eba5a Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Mon, 29 Jun 2026 14:08:54 +0100 Subject: [PATCH 23/35] Document what the package command runner does --- src/PackageCommandRunner.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PackageCommandRunner.php b/src/PackageCommandRunner.php index 068805f..85ff5fb 100644 --- a/src/PackageCommandRunner.php +++ b/src/PackageCommandRunner.php @@ -9,6 +9,10 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\OutputInterface; +/** + * Runs a non-built-in cpx target by resolving it to a local PHP file, a package + * alias, or a vendor/package and executing it. + */ class PackageCommandRunner { public function run(Console $console, OutputInterface $output): int From 8ed70f48a2d5b9947c4373b63570b30f9826ad3f Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 26 Jun 2026 17:00:13 +0100 Subject: [PATCH 24/35] Add safe argv-based process runner Introduce Cpx\Process\ProcessRunner as the single proc_open() boundary, executing argv arrays with inherited stdio or captured output and returning child exit codes. Add an architecture test that forbids proc_open() outside the runner and bans shell-exec helpers. --- src/Process/ProcessRunner.php | 59 ++++++++++++++++++++++++++++++++ tests/ArchTest.php | 45 ++++++++++++++++++++++++ tests/Unit/ProcessRunnerTest.php | 50 +++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 src/Process/ProcessRunner.php create mode 100644 tests/Unit/ProcessRunnerTest.php diff --git a/src/Process/ProcessRunner.php b/src/Process/ProcessRunner.php new file mode 100644 index 0000000..21248a6 --- /dev/null +++ b/src/Process/ProcessRunner.php @@ -0,0 +1,59 @@ + $command + */ + public function run(array $command): int + { + $process = @proc_open($command, [STDIN, STDOUT, STDERR], $pipes); + + if (! is_resource($process)) { + return self::COULD_NOT_EXECUTE; + } + + return proc_close($process); + } + + /** + * @param list $command + * @return array{exitCode: int, stdout: string, stderr: string} + */ + public function capture(array $command): array + { + $process = @proc_open($command, [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + + if (! is_resource($process)) { + return [ + 'exitCode' => self::COULD_NOT_EXECUTE, + 'stdout' => '', + 'stderr' => 'Failed to start process.', + ]; + } + + fclose($pipes[0]); + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + + fclose($pipes[1]); + fclose($pipes[2]); + + return [ + 'exitCode' => proc_close($process), + 'stdout' => $stdout === false ? '' : $stdout, + 'stderr' => $stderr === false ? '' : $stderr, + ]; + } +} diff --git a/tests/ArchTest.php b/tests/ArchTest.php index 027fa27..5369d27 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -35,3 +35,48 @@ arch('the package source declares strict types') ->expect('Cpx') ->toUseStrictTypes(); + +test('only the process runner calls proc_open', function () { + $offenders = sourceFilesContaining('proc_open', except: ['src/Process/ProcessRunner.php']); + + expect($offenders)->toBe([]); +}); + +/** + * @return list + */ +function sourceFilesContaining(string $needle, array $except = []): array +{ + $files = []; + $root = realpath(__DIR__.'/..'); + + if ($root === false) { + return []; + } + + $except[] = 'tests/ArchTest.php'; + + foreach (['src', 'tests'] as $directory) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator("{$root}/{$directory}")); + + foreach ($iterator as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $path = str_replace($root.DIRECTORY_SEPARATOR, '', $file->getPathname()); + + if (in_array($path, $except, true)) { + continue; + } + + if (str_contains((string) file_get_contents($file->getPathname()), $needle)) { + $files[] = $path; + } + } + } + + sort($files); + + return $files; +} diff --git a/tests/Unit/ProcessRunnerTest.php b/tests/Unit/ProcessRunnerTest.php new file mode 100644 index 0000000..40f3c5d --- /dev/null +++ b/tests/Unit/ProcessRunnerTest.php @@ -0,0 +1,50 @@ +temporaryDirectory('cpx-process'); + $binary = "{$directory}/exit-code"; + + writeExecutable($binary, "#!/usr/bin/env php\nrun([$binary]))->toBe(37); +}); + +test('it captures stdout and stderr for commands that need output', function () { + $directory = $this->temporaryDirectory('cpx-process'); + $binary = "{$directory}/capture"; + + writeExecutable($binary, "#!/usr/bin/env php\ncapture([$binary]); + + expect($result['exitCode'])->toBe(0) + ->and($result['stdout'])->toBe('out') + ->and($result['stderr'])->toBe('err'); +}); + +test('it delivers shell metacharacters as literal argv tokens', function () { + $directory = $this->temporaryDirectory('cpx-process'); + $binary = "{$directory}/argv"; + $logFile = "{$directory}/argv.json"; + + writeExecutable($binary, "#!/usr/bin/env php\nrun([$binary, 'two words', 'semi;colon', 'pipe|value', '$(touch injected)']); + + expect($status)->toBe(0) + ->and(json_decode((string) file_get_contents($logFile), true))->toBe([ + 'two words', + 'semi;colon', + 'pipe|value', + '$(touch injected)', + ]) + ->and(file_exists("{$directory}/injected"))->toBeFalse(); +}); + +test('it reports a missing executable as a could-not-execute exit code', function () { + $directory = $this->temporaryDirectory('cpx-process'); + + expect((new ProcessRunner)->run(["{$directory}/missing"]))->toBe(ProcessRunner::COULD_NOT_EXECUTE); +}); From d135e239f0fb903d6939efc22208710b92dcb43a Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 26 Jun 2026 17:00:28 +0100 Subject: [PATCH 25/35] Run Composer through argv arrays Replace the shell-string Cpx\Composer wrapper with Cpx\Composer\ComposerRunner, which builds composer invocations as argv tokens (including --working-dir) and surfaces stderr on failure. --- src/Composer.php | 97 ------------------------------- src/Composer/ComposerRunner.php | 83 ++++++++++++++++++++++++++ tests/Unit/ComposerRunnerTest.php | 49 ++++++++++++++++ 3 files changed, 132 insertions(+), 97 deletions(-) delete mode 100644 src/Composer.php create mode 100644 src/Composer/ComposerRunner.php create mode 100644 tests/Unit/ComposerRunnerTest.php diff --git a/src/Composer.php b/src/Composer.php deleted file mode 100644 index 6c77ef5..0000000 --- a/src/Composer.php +++ /dev/null @@ -1,97 +0,0 @@ - */ - public static function runCommand(string $command, ?string $directory = null): array - { - $workingDirectory = $directory ? "--working-dir={$directory}" : ''; - $process = proc_open( - "composer {$command} --no-interaction --quiet {$workingDirectory}", - [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], - $pipes, - ); - - if (! is_resource($process)) { - throw new Exception("Composer command failed: {$command}"); - } - - fclose($pipes[0]); - - $output = stream_get_contents($pipes[1]); - $errorOutput = stream_get_contents($pipes[2]); - - fclose($pipes[1]); - fclose($pipes[2]); - - $resultCode = proc_close($process); - - if ($resultCode !== 0) { - $message = trim($errorOutput) ?: "Composer command failed: {$command}"; - - throw new Exception($message); - } - - if ($output === false || trim($output) === '') { - return []; - } - - return explode(PHP_EOL, trim($output)); - } - - /** - * Get a list of bin scripts from a package's composer.json file - * - * @return string[] - */ - public static function detectBinFromComposer(string $directory): array - { - $composerFile = "{$directory}/composer.json"; - - if (file_exists($composerFile)) { - $contents = file_get_contents($composerFile); - - if ($contents === false) { - return []; - } - - $composerData = json_decode($contents, true); - - if (is_array($composerData) && isset($composerData['bin'])) { - return (array) $composerData['bin']; - } - } - - return []; - } - - public static function getCurrentVersion(string $directory): string - { - $composerLock = "{$directory}/composer.lock"; - - if (file_exists($composerLock)) { - $contents = file_get_contents($composerLock); - - if ($contents === false) { - return 'unknown'; - } - - $lockData = json_decode($contents, true); - $version = is_array($lockData) ? ($lockData['packages'][0]['version'] ?? null) : null; - - return is_string($version) ? $version : 'unknown'; - } - - return 'unknown'; - } -} diff --git a/src/Composer/ComposerRunner.php b/src/Composer/ComposerRunner.php new file mode 100644 index 0000000..d450c8c --- /dev/null +++ b/src/Composer/ComposerRunner.php @@ -0,0 +1,83 @@ + $arguments + * @return list + */ + public static function run(array $arguments, ?string $directory = null): array + { + $command = ['composer', ...$arguments, '--no-interaction', '--quiet']; + + if ($directory !== null) { + $command[] = "--working-dir={$directory}"; + } + + $result = (new ProcessRunner)->capture($command); + + if ($result['exitCode'] !== Command::SUCCESS) { + $message = trim($result['stderr']) ?: 'Composer command failed: '.implode(' ', $arguments); + + throw new Exception($message); + } + + $output = trim($result['stdout']); + + return $output === '' ? [] : explode(PHP_EOL, $output); + } + + /** @return list */ + public static function detectBinFromComposer(string $directory): array + { + $composerFile = "{$directory}/composer.json"; + + if (! file_exists($composerFile)) { + return []; + } + + $contents = file_get_contents($composerFile); + + if ($contents === false) { + return []; + } + + $composerData = json_decode($contents, true); + + if (! is_array($composerData) || ! isset($composerData['bin'])) { + return []; + } + + return array_values((array) $composerData['bin']); + } + + public static function getCurrentVersion(string $directory): string + { + $composerLock = "{$directory}/composer.lock"; + + if (! file_exists($composerLock)) { + return self::UNKNOWN_VERSION; + } + + $contents = file_get_contents($composerLock); + + if ($contents === false) { + return self::UNKNOWN_VERSION; + } + + $lockData = json_decode($contents, true); + $version = is_array($lockData) ? ($lockData['packages'][0]['version'] ?? null) : null; + + return is_string($version) ? $version : self::UNKNOWN_VERSION; + } +} diff --git a/tests/Unit/ComposerRunnerTest.php b/tests/Unit/ComposerRunnerTest.php new file mode 100644 index 0000000..d7fea0c --- /dev/null +++ b/tests/Unit/ComposerRunnerTest.php @@ -0,0 +1,49 @@ +temporaryDirectory('cpx-composer-bin'); + $workingDirectory = $this->temporaryDirectory('cpx-composer working;dir'); + $logFile = $this->temporaryDirectory('cpx-composer-log').'/argv.json'; + + writeExecutable($binDirectory.'/composer', "#!/usr/bin/env php\nsetEnvironmentVariable('PATH', $binDirectory.PATH_SEPARATOR.getenv('PATH')); + + $output = ComposerRunner::run(['require', 'vendor/package:^1@dev', '--no-progress'], $workingDirectory); + + expect($output)->toBe(['installed']) + ->and(json_decode((string) file_get_contents($logFile), true))->toBe([ + 'require', + 'vendor/package:^1@dev', + '--no-progress', + '--no-interaction', + '--quiet', + "--working-dir={$workingDirectory}", + ]); +}); + +test('it includes stderr when composer exits non-zero', function () { + $binDirectory = $this->temporaryDirectory('cpx-composer-bin'); + + writeExecutable($binDirectory.'/composer', "#!/usr/bin/env php\nsetEnvironmentVariable('PATH', $binDirectory.PATH_SEPARATOR.getenv('PATH')); + + ComposerRunner::run(['update']); +})->throws(Exception::class, 'composer failed'); + +test('package installation calls composer with argv arrays', function () { + $this->useIsolatedComposerHome(); + + $binDirectory = $this->temporaryDirectory('cpx-composer-bin'); + $logFile = $this->temporaryDirectory('cpx-composer-log').'/argv.json'; + + writeExecutable($binDirectory.'/composer', "#!/usr/bin/env php\nsetEnvironmentVariable('PATH', $binDirectory.PATH_SEPARATOR.getenv('PATH')); + + Package::parse('vendor/package:^1@dev')->installOrUpdatePackage(updateCheck: false); + + expect(json_decode((string) file_get_contents($logFile), true))->toContain('vendor/package:^1@dev') + ->and(json_decode((string) file_get_contents($logFile), true))->not->toContain('vendor/package:^1@dev --no-interaction'); +}); From 36150f31574b1ed678e47ce5479211fe197fb5d4 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 26 Jun 2026 17:00:48 +0100 Subject: [PATCH 26/35] Replace Console parser with PackageInvocation DTO Drop the legacy Cpx\Console string parser/executor in favor of an immutable Cpx\Input\PackageInvocation built from raw argv tokens, preserving --, repeated options, and shell metacharacters as literal tokens. --- src/Console.php | 172 --------------------------- src/Input/PackageInvocation.php | 120 +++++++++++++++++++ tests/Unit/ConsoleTest.php | 37 ------ tests/Unit/PackageInvocationTest.php | 63 ++++++++++ 4 files changed, 183 insertions(+), 209 deletions(-) delete mode 100644 src/Console.php create mode 100644 src/Input/PackageInvocation.php delete mode 100644 tests/Unit/ConsoleTest.php create mode 100644 tests/Unit/PackageInvocationTest.php diff --git a/src/Console.php b/src/Console.php deleted file mode 100644 index 3344353..0000000 --- a/src/Console.php +++ /dev/null @@ -1,172 +0,0 @@ - $arguments - * @param array> $options - * @param list $flags - */ - public function __construct( - public string $rawInput, - public string $command, - public array $arguments = [], - public array $options = [], - public array $flags = [], - ) {} - - /** - * Parses $argv to get the command, arguments, options, and flags. - * - * @param string|list $input The $argv variable. - * @param array $shortOptions Optional. An array with keys set to short options and their values set to the long option they're assigned to. - * @param list $flagOptions Optional. An array of options to be treated as flags. If a flag is not defined here, it will be treated as an option. - */ - public static function parse(string|array $input, array $shortOptions = [], array $flagOptions = []): Console - { - if (empty($input)) { - return new Console('', ''); - } - - if (is_string($input)) { - $input = trim($input); - $parts = preg_split('/\s+(?=([^"]*"[^"]*")*[^"]*$)/', $input); - $input = array_map(function (string $item): string { - return trim($item, '"\''); - }, $parts === false ? [] : $parts); - } - - $command = array_shift($input) ?? ''; - $arguments = []; - $options = []; - $flags = []; - $lastOption = null; - - foreach ($input as $arg) { - $value = null; - - if (substr($arg, 0, 1) !== '-') { - if ($lastOption) { - $value = $arg; - $arg = $lastOption; - } else { - $arguments[] = $arg; - $lastOption = null; - - continue; - } - } else { - $argSplit = []; - - if (preg_match('/^--?([A-Z\d\-_]+)=?(.+)?$/i', $arg, $argSplit) !== 1) { - $arguments[] = $arg; - $lastOption = null; - - continue; - } - - $arg = $argSplit[1]; - - if (isset($argSplit[2])) { - $value = $argSplit[2]; - } - } - - if (array_key_exists($arg, $shortOptions)) { - $arg = $shortOptions[$arg]; - } - - if (in_array($arg, $flagOptions, true)) { - if (! in_array($arg, $flags, true)) { - $flags[] = $arg; - } - - $lastOption = null; - } else { - if (array_key_exists($arg, $options)) { - if (is_array($options[$arg])) { - $options[$arg][] = $value; - } else { - if (is_null($options[$arg])) { - $options[$arg] = $value; - } elseif (! is_null($value)) { - $options[$arg] = [$options[$arg], $value]; - } - } - } else { - $options[$arg] = $value; - } - - $lastOption = $value ? null : $arg; - } - } - - return new Console(implode(' ', $input), $command, $arguments, $options, $flags); - } - - public function hasOption(string $option): bool - { - return array_key_exists($option, $this->options); - } - - public function getOption(string $option): ?string - { - $value = $this->options[$option] ?? null; - - if (! is_array($value)) { - return $value; - } - - foreach ($value as $optionValue) { - if ($optionValue !== null) { - return $optionValue; - } - } - - return null; - } - - public function hasFlag(string $flag): bool - { - return in_array($flag, $this->flags, true); - } - - public function __toString(): string - { - return trim("{$this->command} {$this->getCommandInput()}"); - } - - public function getCommandInput(): string - { - return implode(' ', [$this->getArgumentsString(), $this->getOptionsString(), $this->getFlagsString()]); - } - - public function getArgumentsString(): string - { - return implode(' ', $this->arguments); - } - - public function getOptionsString(): string - { - $options = []; - - foreach ($this->options as $key => $value) { - $values = is_array($value) ? $value : [$value]; - - foreach ($values as $optionValue) { - $options[] = $optionValue === null ? "--{$key}" : "--{$key}=".escapeshellarg($optionValue); - } - } - - return implode(' ', $options); - } - - public function getFlagsString(): string - { - return implode(' ', $this->flags); - } -} diff --git a/src/Input/PackageInvocation.php b/src/Input/PackageInvocation.php new file mode 100644 index 0000000..c76257d --- /dev/null +++ b/src/Input/PackageInvocation.php @@ -0,0 +1,120 @@ + $forwardedTokens + */ + public function __construct( + public string $target, + private array $forwardedTokens = [], + ) { + if ($this->target === '') { + throw new InvalidArgumentException('A package invocation target must be provided.'); + } + } + + /** + * @param list $tokens + */ + public static function fromRawTokens(array $tokens): self + { + $target = array_shift($tokens); + + return new self(is_string($target) ? $target : '', $tokens); + } + + /** @return list */ + public function forwardedTokens(): array + { + return $this->forwardedTokens; + } + + public function firstForwardedToken(): ?string + { + return $this->forwardedTokens[0] ?? null; + } + + public function withoutFirstForwardedToken(): self + { + return new self($this->target, array_slice($this->forwardedTokens, 1)); + } + + public function hasOption(string $option): bool + { + return array_key_exists($option, $this->options()); + } + + public function option(string $option): ?string + { + $value = $this->options()[$option] ?? null; + + if (! is_array($value)) { + return $value; + } + + foreach ($value as $optionValue) { + if ($optionValue !== null) { + return $optionValue; + } + } + + return null; + } + + /** @return array> */ + private function options(): array + { + $options = []; + $tokens = $this->forwardedTokens; + + for ($index = 0; $index < count($tokens); $index++) { + $token = $tokens[$index]; + + if ($token === '--') { + break; + } + + if (preg_match('/^--(?[A-Z\d\-_]+)(?:=(?.*))?$/i', $token, $matches) !== 1) { + continue; + } + + $value = array_key_exists('value', $matches) ? $matches['value'] : null; + + if ($value === null && isset($tokens[$index + 1]) && ! str_starts_with($tokens[$index + 1], '-')) { + $value = $tokens[$index + 1]; + $index++; + } + + $this->addOption($options, $matches['name'], $value); + } + + return $options; + } + + /** + * @param array> $options + */ + private function addOption(array &$options, string $name, ?string $value): void + { + if (! array_key_exists($name, $options)) { + $options[$name] = $value; + + return; + } + + if (is_array($options[$name])) { + $options[$name][] = $value; + + return; + } + + $options[$name] = [$options[$name], $value]; + } +} diff --git a/tests/Unit/ConsoleTest.php b/tests/Unit/ConsoleTest.php deleted file mode 100644 index 2f732d4..0000000 --- a/tests/Unit/ConsoleTest.php +++ /dev/null @@ -1,37 +0,0 @@ -command)->toBe('laravel/pint') - ->and($console->arguments)->toBe(['app', 'two words']) - ->and($console->hasFlag('test'))->toBeTrue(); -}); - -test('it keeps repeated option values in order', function () { - $console = Console::parse(['tool', '--filter=one', '--filter=two']); - - expect($console->options['filter'])->toBe(['one', 'two']) - ->and($console->getOption('filter'))->toBe('one'); -}); - -test('it maps configured short options and flags', function () { - $console = Console::parse( - ['tool', '-v', '-c', 'phpstan.neon'], - shortOptions: ['c' => 'configuration', 'v' => 'verbose'], - flagOptions: ['verbose'], - ); - - expect($console->hasFlag('verbose'))->toBeTrue() - ->and($console->getOption('configuration'))->toBe('phpstan.neon'); -}); - -test('the double-dash separator preserves all following target arguments')->todo( - 'Enable when the process runner forwards argv tokens without shell reconstruction.', -); - -test('child process exit codes are returned through the command runner')->todo( - 'Enable when child process exit codes are propagated through the command runner.', -); diff --git a/tests/Unit/PackageInvocationTest.php b/tests/Unit/PackageInvocationTest.php new file mode 100644 index 0000000..e4be4dc --- /dev/null +++ b/tests/Unit/PackageInvocationTest.php @@ -0,0 +1,63 @@ +target)->toBe('vendor/package') + ->and($invocation->forwardedTokens())->toBe(['--flag', 'value']); +}); + +test('it preserves the double-dash separator for the target command', function () { + $invocation = PackageInvocation::fromRawTokens(['vendor/package', '--', '--literal', '-x']); + + expect($invocation->forwardedTokens())->toBe(['--', '--literal', '-x']) + ->and($invocation->hasOption('literal'))->toBeFalse(); +}); + +test('it preserves forwarded tokens without shell interpretation', function () { + $tokens = [ + '--flag', + '-x', + '--filter=one', + '--filter=two', + 'two words', + '"quoted"', + 'semi;colon', + 'pipe|value', + '$(touch injected)', + ]; + + $invocation = PackageInvocation::fromRawTokens(['vendor/package', ...$tokens]); + + expect($invocation->forwardedTokens())->toBe($tokens); +}); + +test('it exposes file fallback options without rebuilding a shell command', function () { + $invocation = PackageInvocation::fromRawTokens([ + 'script.php', + '--find-autoloader=false', + '--load-laravel-bootstrap', + '--alias-classes', + '0', + ]); + + expect($invocation->hasOption('find-autoloader'))->toBeTrue() + ->and($invocation->option('find-autoloader'))->toBe('false') + ->and($invocation->hasOption('load-laravel-bootstrap'))->toBeTrue() + ->and($invocation->option('load-laravel-bootstrap'))->toBeNull() + ->and($invocation->option('alias-classes'))->toBe('0'); +}); + +test('it can consume the first forwarded token for multi-binary packages', function () { + $invocation = PackageInvocation::fromRawTokens(['vendor/package', 'bin-name', '--flag']); + + expect($invocation->firstForwardedToken())->toBe('bin-name') + ->and($invocation->withoutFirstForwardedToken()->forwardedTokens())->toBe(['--flag']) + ->and($invocation->forwardedTokens())->toBe(['bin-name', '--flag']); +}); + +test('it rejects empty invocations before package execution', function () { + PackageInvocation::fromRawTokens([]); +})->throws(InvalidArgumentException::class, 'A package invocation target must be provided.'); From e848172b97243c02744a9d7733a4518206fe28a5 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 26 Jun 2026 17:01:00 +0100 Subject: [PATCH 27/35] Extract focused Support helpers Replace the generic Utils class with Cpx\Support\Arr and Cpx\Support\Filesystem for the array mapping and recursive directory deletion that are still needed. --- src/Support/Arr.php | 29 ++++++++++++++++++++++ src/Support/Filesystem.php | 34 +++++++++++++++++++++++++ src/Utils.php | 51 -------------------------------------- 3 files changed, 63 insertions(+), 51 deletions(-) create mode 100644 src/Support/Arr.php create mode 100644 src/Support/Filesystem.php delete mode 100644 src/Utils.php diff --git a/src/Support/Arr.php b/src/Support/Arr.php new file mode 100644 index 0000000..6ea6b92 --- /dev/null +++ b/src/Support/Arr.php @@ -0,0 +1,29 @@ + $callback + * @param array $array + * @return array + */ + public static function mapWithKeys(callable $callback, array $array): array + { + $result = []; + + foreach ($array as $key => $value) { + $result += $callback($key, $value); + } + + return $result; + } +} diff --git a/src/Support/Filesystem.php b/src/Support/Filesystem.php new file mode 100644 index 0000000..034b865 --- /dev/null +++ b/src/Support/Filesystem.php @@ -0,0 +1,34 @@ +isDir() && ! $file->isLink() + ? rmdir($file->getPathname()) + : unlink($file->getPathname()); + } + + rmdir($directory); + } +} diff --git a/src/Utils.php b/src/Utils.php deleted file mode 100644 index ae82d2a..0000000 --- a/src/Utils.php +++ /dev/null @@ -1,51 +0,0 @@ - $f - * @param array $a - * @return array - */ - public static function arrayMapAssoc(callable $f, array $a): array - { - return $a === [] ? [] : array_merge(...array_map($f, array_keys($a), $a)); - } - - public static function deleteDirectory(string $directory): void - { - if (! is_dir($directory)) { - return; - } - - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($files as $file) { - /** @var SplFileInfo $file */ - if ($file->isDir() && ! $file->isLink()) { - rmdir($file->getPathname()); - } else { - unlink($file->getPathname()); - } - } - - rmdir($directory); - } -} From d0cd65683efbc8a5814f080d68d47038677a10e1 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 26 Jun 2026 17:01:09 +0100 Subject: [PATCH 28/35] Group cache and runtime classes into focused namespaces Move Metadata and PackageMetadata under Cpx\Cache and the PHP execution helpers under Cpx\Runtime. Replace Metadata's string mode flag with intent-named recordRun() and recordUpdate() methods. --- src/{ => Cache}/Metadata.php | 76 +++++++++--------- src/{ => Cache}/PackageMetadata.php | 6 +- src/ClassAliasAutoloader.php | 96 ----------------------- src/Runtime/ClassAliasAutoloader.php | 98 ++++++++++++++++++++++++ src/{ => Runtime}/PhpExecutionHelper.php | 47 ++++++------ tests/Unit/MetadataTest.php | 8 +- 6 files changed, 171 insertions(+), 160 deletions(-) rename src/{ => Cache}/Metadata.php (53%) rename src/{ => Cache}/PackageMetadata.php (67%) delete mode 100644 src/ClassAliasAutoloader.php create mode 100644 src/Runtime/ClassAliasAutoloader.php rename src/{ => Runtime}/PhpExecutionHelper.php (56%) diff --git a/src/Metadata.php b/src/Cache/Metadata.php similarity index 53% rename from src/Metadata.php rename to src/Cache/Metadata.php index bbf47d3..ef5170c 100644 --- a/src/Metadata.php +++ b/src/Cache/Metadata.php @@ -2,7 +2,10 @@ declare(strict_types=1); -namespace Cpx; +namespace Cpx\Cache; + +use Cpx\Packages\Package; +use Cpx\Support\Arr; class Metadata { @@ -15,50 +18,46 @@ protected function __construct( public array $execCache = [], ) {} - public static function open(): Metadata + public static function open(): self { $metadataFile = cpx_path('.cpx_metadata.json'); - if (file_exists($metadataFile)) { - $contents = file_get_contents($metadataFile); - $json = $contents === false ? [] : json_decode($contents, true); - - if (! is_array($json)) { - $json = []; - } - - return new Metadata( - packages: Utils::arrayMapAssoc( - fn (string $key, array $value): array => [ - $key => new PackageMetadata( - package: Package::parse($key), - lastUpdatedAt: $value['last_updated'] ?? null, - lastRunAt: $value['last_run'] ?? null, - ), - ], - is_array($json['packages'] ?? null) ? $json['packages'] : [], - ), - execCache: is_array($json['execCache'] ?? null) ? $json['execCache'] : [], - ); + if (! file_exists($metadataFile)) { + return new self; + } + + $contents = file_get_contents($metadataFile); + $json = $contents === false ? [] : json_decode($contents, true); + + if (! is_array($json)) { + $json = []; } - return new Metadata; + return new self( + packages: Arr::mapWithKeys( + fn (string $key, array $value): array => [ + $key => new PackageMetadata( + package: Package::parse($key), + lastUpdatedAt: $value['last_updated'] ?? null, + lastRunAt: $value['last_run'] ?? null, + ), + ], + is_array($json['packages'] ?? null) ? $json['packages'] : [], + ), + execCache: is_array($json['execCache'] ?? null) ? $json['execCache'] : [], + ); } - public function updateLastCheckTime(Package $package, string $type = 'run'): Metadata + public function recordRun(Package $package): self { - $packageKey = $package->fullPackageString(); - $currentTime = date('Y-m-d H:i:s'); + $this->forPackage($package)->lastRunAt = date('Y-m-d H:i:s'); - if (! isset($this->packages[$packageKey])) { - $this->packages[$packageKey] = new PackageMetadata($package); - } + return $this; + } - if ($type === 'run') { - $this->packages[$packageKey]->lastRunAt = $currentTime; - } else { - $this->packages[$packageKey]->lastUpdatedAt = $currentTime; - } + public function recordUpdate(Package $package): self + { + $this->forPackage($package)->lastUpdatedAt = date('Y-m-d H:i:s'); return $this; } @@ -92,7 +91,7 @@ public function hasPackage(string|Package $package): bool public function toArray(): array { return [ - 'packages' => Utils::arrayMapAssoc( + 'packages' => Arr::mapWithKeys( fn (string $key, PackageMetadata $packageMetadata): array => [ $packageMetadata->package->fullPackageString() => [ 'last_updated' => $packageMetadata->lastUpdatedAt, @@ -104,4 +103,9 @@ public function toArray(): array 'execCache' => $this->execCache, ]; } + + private function forPackage(Package $package): PackageMetadata + { + return $this->packages[$package->fullPackageString()] ??= new PackageMetadata($package); + } } diff --git a/src/PackageMetadata.php b/src/Cache/PackageMetadata.php similarity index 67% rename from src/PackageMetadata.php rename to src/Cache/PackageMetadata.php index 4ed452c..f613996 100644 --- a/src/PackageMetadata.php +++ b/src/Cache/PackageMetadata.php @@ -2,12 +2,14 @@ declare(strict_types=1); -namespace Cpx; +namespace Cpx\Cache; + +use Cpx\Packages\Package; class PackageMetadata { public function __construct( - public Package $package, + public readonly Package $package, public ?string $lastUpdatedAt = null, public ?string $lastRunAt = null, ) {} diff --git a/src/ClassAliasAutoloader.php b/src/ClassAliasAutoloader.php deleted file mode 100644 index 68111ee..0000000 --- a/src/ClassAliasAutoloader.php +++ /dev/null @@ -1,96 +0,0 @@ - */ - protected array $classes = []; - - public function __construct( - protected bool $shouldBeVerbose = false, - ) {} - - public function addAliases(string $autoloadRootDirectory): void - { - if (file_exists("{$autoloadRootDirectory}/vendor/composer/autoload_classmap.php")) { - $classes = require "{$autoloadRootDirectory}/vendor/composer/autoload_classmap.php"; - - foreach ($classes as $class => $path) { - if (! str_contains($class, '\\')) { - continue; - } - - $name = basename(str_replace('\\', '/', $class)); - - if (! isset($this->classes[$name]) && class_exists($name)) { - $this->classes[$name] = $class; - } - } - } - - if (file_exists("{$autoloadRootDirectory}/vendor/composer/autoload_psr4.php")) { - $psr4 = require "{$autoloadRootDirectory}/vendor/composer/autoload_psr4.php"; - - foreach ($psr4 as $namespace => $directories) { - foreach ($directories as $directory) { - if (! file_exists($directory)) { - continue; - } - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory), - RecursiveIteratorIterator::LEAVES_ONLY, - ); - - foreach ($iterator as $file) { - /** @var SplFileInfo $file */ - if ($file->isFile() && $file->getExtension() === 'php') { - $classNamespace = $namespace; - - $relativePath = str_replace($directory, '', $file->getPath()); - - if (! empty($relativePath)) { - $classNamespace .= strtr($relativePath, DIRECTORY_SEPARATOR, '\\').'\\'; - } - - $basename = $file->getBasename('.php'); - $class = str_replace('\\\\', '\\', $classNamespace.$basename); - - if (str_ends_with($basename, 'Test')) { - continue; - } - - $this->classes[$basename] = $class; - } - } - } - } - } - } - - /** Find the closest class by name. */ - public function aliasClass(string $class): void - { - if (str_contains($class, '\\')) { - return; - } - - $fullName = $this->classes[$class] ?? false; - - if ($fullName) { - if (class_exists($fullName)) { - if ($this->shouldBeVerbose) { - echo "Aliasing '{$class}' to '{$fullName}'\n"; - } - - class_alias($fullName, $class); - } - } - } -} diff --git a/src/Runtime/ClassAliasAutoloader.php b/src/Runtime/ClassAliasAutoloader.php new file mode 100644 index 0000000..d5201c8 --- /dev/null +++ b/src/Runtime/ClassAliasAutoloader.php @@ -0,0 +1,98 @@ + */ + protected array $classes = []; + + public function __construct( + protected bool $shouldBeVerbose = false, + ) {} + + public function addAliases(string $autoloadRootDirectory): void + { + if (file_exists("{$autoloadRootDirectory}/vendor/composer/autoload_classmap.php")) { + $classes = require "{$autoloadRootDirectory}/vendor/composer/autoload_classmap.php"; + + foreach ($classes as $class => $path) { + if (! str_contains($class, '\\')) { + continue; + } + + $name = basename(str_replace('\\', '/', $class)); + + if (! isset($this->classes[$name]) && class_exists($name)) { + $this->classes[$name] = $class; + } + } + } + + if (! file_exists("{$autoloadRootDirectory}/vendor/composer/autoload_psr4.php")) { + return; + } + + $psr4 = require "{$autoloadRootDirectory}/vendor/composer/autoload_psr4.php"; + + foreach ($psr4 as $namespace => $directories) { + foreach ($directories as $directory) { + if (! file_exists($directory)) { + continue; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory), + RecursiveIteratorIterator::LEAVES_ONLY, + ); + + foreach ($iterator as $file) { + /** @var SplFileInfo $file */ + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $classNamespace = $namespace; + $relativePath = str_replace($directory, '', $file->getPath()); + + if (! empty($relativePath)) { + $classNamespace .= strtr($relativePath, DIRECTORY_SEPARATOR, '\\').'\\'; + } + + $basename = $file->getBasename('.php'); + + if (str_ends_with($basename, 'Test')) { + continue; + } + + $this->classes[$basename] = str_replace('\\\\', '\\', $classNamespace.$basename); + } + } + } + } + + public function aliasClass(string $class): void + { + if (str_contains($class, '\\')) { + return; + } + + $fullName = $this->classes[$class] ?? false; + + if (! $fullName || ! class_exists($fullName)) { + return; + } + + if ($this->shouldBeVerbose) { + echo "Aliasing '{$class}' to '{$fullName}'".PHP_EOL; + } + + class_alias($fullName, $class); + } +} diff --git a/src/PhpExecutionHelper.php b/src/Runtime/PhpExecutionHelper.php similarity index 56% rename from src/PhpExecutionHelper.php rename to src/Runtime/PhpExecutionHelper.php index d35870b..227a87d 100644 --- a/src/PhpExecutionHelper.php +++ b/src/Runtime/PhpExecutionHelper.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Cpx; +namespace Cpx\Runtime; class PhpExecutionHelper { @@ -20,7 +20,6 @@ public static function init( } $autoloadRootDirectory = $path; - $autoloadFileSuffix = '/vendor/autoload.php'; $autoloadFile = $autoloadRootDirectory.$autoloadFileSuffix; @@ -39,34 +38,38 @@ public static function init( } } - if (file_exists($autoloadFile)) { - if ($shouldBeVerbose) { - echo "Found autoload file at '{$autoloadFile}'".PHP_EOL; - } + if (! file_exists($autoloadFile)) { + return; + } - require_once $autoloadFile; + if ($shouldBeVerbose) { + echo "Found autoload file at '{$autoloadFile}'".PHP_EOL; + } - if ($shouldLoadLaravelBootstrap && file_exists($autoloadRootDirectory.'/bootstrap/app.php')) { - if ($shouldBeVerbose) { - echo "Found Laravel bootstrap file at '{$autoloadRootDirectory}/bootstrap/app.php'".PHP_EOL; - } + require_once $autoloadFile; - if (! defined('LARAVEL_START')) { - define('LARAVEL_START', microtime(true)); - } + if ($shouldLoadLaravelBootstrap && file_exists($autoloadRootDirectory.'/bootstrap/app.php')) { + if ($shouldBeVerbose) { + echo "Found Laravel bootstrap file at '{$autoloadRootDirectory}/bootstrap/app.php'".PHP_EOL; + } - require_once $autoloadRootDirectory.'/bootstrap/app.php'; + if (! defined('LARAVEL_START')) { + define('LARAVEL_START', microtime(true)); } - if ($shouldAliasClasses) { - if ($shouldBeVerbose) { - echo 'Aliasing classes'.PHP_EOL; - } + require_once $autoloadRootDirectory.'/bootstrap/app.php'; + } - static::getClassAliasAutoloader($shouldBeVerbose)->addAliases($autoloadRootDirectory); - spl_autoload_register(static::getClassAliasAutoloader($shouldBeVerbose)->aliasClass(...)); - } + if (! $shouldAliasClasses) { + return; } + + if ($shouldBeVerbose) { + echo 'Aliasing classes'.PHP_EOL; + } + + static::getClassAliasAutoloader($shouldBeVerbose)->addAliases($autoloadRootDirectory); + spl_autoload_register(static::getClassAliasAutoloader($shouldBeVerbose)->aliasClass(...)); } public static function getClassAliasAutoloader(bool $shouldBeVerbose = false): ClassAliasAutoloader diff --git a/tests/Unit/MetadataTest.php b/tests/Unit/MetadataTest.php index ff022bf..2967717 100644 --- a/tests/Unit/MetadataTest.php +++ b/tests/Unit/MetadataTest.php @@ -1,7 +1,7 @@ useIsolatedComposerHome(); @@ -16,8 +16,8 @@ $this->useIsolatedComposerHome(); Metadata::open() - ->updateLastCheckTime(Package::parse('laravel/pint'), 'updated') - ->updateLastCheckTime(Package::parse('laravel/pint')) + ->recordUpdate(Package::parse('laravel/pint')) + ->recordRun(Package::parse('laravel/pint')) ->save(); $metadataFile = cpx_path('.cpx_metadata.json'); From 08aa573d40298baed9aa9cda4d48ed176cb47322 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 26 Jun 2026 17:01:20 +0100 Subject: [PATCH 29/35] Run package binaries safely under Cpx\Packages Move Package, PackageAlias, PackageAliases, and PackageCommandRunner into Cpx\Packages and execute resolved binaries as argv arrays that return child exit codes. Reject path-traversal version constraints during parsing. --- src/Package.php | 201 -------------------- src/PackageAliases.php | 252 ------------------------- src/PackageCommandRunner.php | 60 ------ src/Packages/Package.php | 205 ++++++++++++++++++++ src/Packages/PackageAlias.php | 15 ++ src/Packages/PackageAliases.php | 258 ++++++++++++++++++++++++++ src/Packages/PackageCommandRunner.php | 67 +++++++ tests/Unit/PackageAliasesTest.php | 21 ++- tests/Unit/PackageTest.php | 4 +- 9 files changed, 559 insertions(+), 524 deletions(-) delete mode 100644 src/Package.php delete mode 100644 src/PackageAliases.php delete mode 100644 src/PackageCommandRunner.php create mode 100644 src/Packages/Package.php create mode 100644 src/Packages/PackageAlias.php create mode 100644 src/Packages/PackageAliases.php create mode 100644 src/Packages/PackageCommandRunner.php diff --git a/src/Package.php b/src/Package.php deleted file mode 100644 index 3ecc75b..0000000 --- a/src/Package.php +++ /dev/null @@ -1,201 +0,0 @@ -[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?)\/(?[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?)(?::(?[a-zA-Z0-9_.@~^*<>!=|,-]+))?\z/', $str, $matches) !== 1) { - throw new InvalidArgumentException('A package name should be in the format "/[:version]".'); - } - - return new Package( - vendor: $matches['vendor'], - name: $matches['name'], - version: $matches['version'] ?? null, - ); - } - - public function folder(): string - { - return "{$this->vendor}/{$this->name}/{$this->versionName()}"; - } - - public function versionName(): string - { - return $this->version ?? 'latest'; - } - - public function fullPackageString(): string - { - return "{$this->vendor}/{$this->name}" - .($this->version ? ':'.$this->version : ''); - } - - public function delete(): void - { - Utils::deleteDirectory(cpx_path("{$this->folder()}")); - } - - public function runCommand(Console $console, bool $autoUpdate = true): void - { - $installDir = $this->installOrUpdatePackage($autoUpdate); - - $binScripts = Composer::detectBinFromComposer("{$installDir}/vendor/{$this->vendor}/{$this->name}"); - - if (empty($binScripts)) { - throw new RuntimeException("No bin command found in {$this}."); - } - - $binScripts = Utils::arrayMapAssoc(fn (int $_, string $value): array => [basename($value) => $value], $binScripts); - - if (count($binScripts) > 1) { - $possibleCommands = array_values(array_unique(array_filter([ - $console->command, - $console->arguments[0] ?? null, - str_contains($console->command, '/') ? Package::parse($console->command)->name : null, - ]))); - - foreach ($possibleCommands as $possibleCommand) { - if (in_array($possibleCommand, $binScripts, true)) { - if (($console->arguments[0] ?? null) === $possibleCommand) { - $console->arguments = array_slice($console->arguments, 1); - } - $command = $possibleCommand; - - break; - } elseif (array_key_exists($possibleCommand, $binScripts)) { - if (($console->arguments[0] ?? null) === $possibleCommand) { - $console->arguments = array_slice($console->arguments, 1); - } - $command = $binScripts[$possibleCommand]; - - break; - } - } - - if (! isset($command)) { - throw new RuntimeException("More than 1 bin command found for {$this}: ".implode(', ', array_keys($binScripts)).'.'); - } - } else { - $command = $binScripts[array_key_first($binScripts)]; - } - - $binPath = "$installDir/vendor/{$this->vendor}/{$this->name}/$command"; - - if (file_exists($binPath)) { - Metadata::open()->updateLastCheckTime($this)->save(); - - // Prepare the command to run - $cmd = "{$binPath} {$console->getCommandInput()}"; - - // Use proc_open for better control of the process and to maintain colors and interactivity - $descriptors = [ - 0 => STDIN, - 1 => STDOUT, - 2 => STDERR, - ]; - - printColor("Running {$command} from {$this}"); - - $process = proc_open($cmd, $descriptors, $pipes); - - if (is_resource($process)) { - proc_close($process); - } - } else { - echo "Error: Command $command not found in {$this}.\n"; - } - } - - public function installOrUpdatePackage(bool $updateCheck = true): string - { - $installDir = cpx_path($this->folder()); - - if (! is_dir($installDir)) { - mkdir($installDir, 0755, true); - } - - if (! is_dir("$installDir/vendor")) { - printColor("Installing {$this}..."); - file_put_contents("{$installDir}/composer.json", json_encode([ - 'name' => "cpx-{$this->vendor}/cpx-{$this->name}", - 'version' => '1.0.0', - 'config' => [ - 'allow-plugins' => true, - ], - ])); - // Composer::runCommand("init --name=cpx-{$package->name} --version=1.0.0 --no-interaction", $installDir); - - if ($this->version === null) { - Composer::runCommand("require {$this->vendor}/{$this->name} --no-interaction --no-progress", $installDir); - } else { - Composer::runCommand("require {$this->vendor}/{$this->name}:{$this->version} --no-interaction --no-progress", $installDir); - } - - Metadata::open()->updateLastCheckTime($this, 'updated')->save(); - } elseif ($updateCheck && $this->shouldCheckForUpdates()) { - printColor("Checking for updates for {$this}..."); - $previousVersion = Composer::getCurrentVersion($installDir); - Composer::runCommand('update', $installDir); - $newVersion = Composer::getCurrentVersion($installDir); - - if ($previousVersion !== $newVersion) { - printColor("{$this} was upgraded from $previousVersion to $newVersion."); - } else { - printColor("{$this} is already up-to-date."); - } - - Metadata::open()->updateLastCheckTime($this, 'updated')->save(); - } else { - printColor("{$this} is already installed and doesn't need updating."); - } - - return $installDir; - } - - public function shouldCheckForUpdates(): bool - { - $metadata = Metadata::open(); - $packageKey = $this->fullPackageString(); - - if (! $metadata->hasPackage($this)) { - return true; - } - - $lastUpdatedAt = $metadata->packages[$packageKey]->lastUpdatedAt; - - if ($lastUpdatedAt === null) { - return true; - } - - $lastCheck = strtotime($lastUpdatedAt); - - if ($lastCheck === false) { - return true; - } - - return (time() - $lastCheck) > 3600; // 1 hour - } - - public function __toString(): string - { - return $this->fullPackageString(); - } -} diff --git a/src/PackageAliases.php b/src/PackageAliases.php deleted file mode 100644 index 5a166c7..0000000 --- a/src/PackageAliases.php +++ /dev/null @@ -1,252 +0,0 @@ - */ - public static array $packages = [ - 'psalm' => [ - 'name' => 'Psalm', - 'description' => 'A static analysis tool for PHP, focusing on improving code quality and detecting bugs.', - 'command' => 'psalm', - 'package' => 'vimeo/psalm', - ], - 'phpcs' => [ - 'name' => 'PHP_CodeSniffer', - 'description' => 'Detects violations of coding standards.', - 'command' => 'phpcs', - 'package' => 'squizlabs/php_codesniffer', - ], - 'phpstan' => [ - 'name' => 'PHPStan', - 'description' => 'A static analysis tool for finding bugs in PHP code without actually running it.', - 'command' => 'phpstan', - 'package' => 'phpstan/phpstan', - ], - 'phploc' => [ - 'name' => 'PHPLoc', - 'description' => 'A tool for quickly measuring the size and analyzing the structure of a PHP project.', - 'command' => 'phploc', - 'package' => 'cmgmyr/phploc', - ], - 'rector' => [ - 'name' => 'Rector', - 'description' => 'An automated refactoring tool that simplifies upgrades and cleanups in your PHP codebase.', - 'command' => 'rector', - 'package' => 'rector/rector', - ], - 'phpmetrics' => [ - 'name' => 'PHPMetrics', - 'description' => 'A static analysis tool that provides metrics and quality assessments of your PHP code.', - 'command' => 'phpmetrics', - 'package' => 'phpmetrics/phpmetrics', - ], - 'php-cs-fixer' => [ - 'name' => 'PHP-CS-Fixer', - 'description' => 'A tool to automatically fix coding standards in PHP files.', - 'command' => 'php-cs-fixer', - 'package' => 'friendsofphp/php-cs-fixer', - ], - 'pint' => [ - 'name' => 'Pint', - 'description' => 'An opinionated code styler for Laravel.', - 'command' => 'pint', - 'package' => 'laravel/pint', - ], - 'phinx' => [ - 'name' => 'Phinx', - 'description' => 'A database migration tool for PHP.', - 'command' => 'phinx', - 'package' => 'robmorgan/phinx', - ], - 'box' => [ - 'name' => 'Box', - 'description' => 'A tool to generate PHAR files from a PHP project.', - 'command' => 'box', - 'package' => 'humbug/box', - ], - 'pdepend' => [ - 'name' => 'Pdepend', - 'description' => 'A static analysis tool that generates software metrics like complexity and inheritance information.', - 'command' => 'pdepend', - 'package' => 'pdepend/pdepend', - ], - 'dep' => [ - 'name' => 'Deployer', - 'description' => 'A deployment tool for PHP applications.', - 'command' => 'dep', - 'package' => 'deployer/deployer', - ], - 'phpbench' => [ - 'name' => 'PHPBench', - 'description' => 'A benchmark framework for PHP.', - 'command' => 'phpbench', - 'package' => 'phpbench/phpbench', - ], - 'phing' => [ - 'name' => 'Phing', - 'description' => 'A PHP project build system or automation tool similar to Apache Ant.', - 'command' => 'phing', - 'package' => 'phing/phing', - ], - 'captainhook' => [ - 'name' => 'CaptainHook', - 'description' => 'A tool to manage and configure git hooks for your project.', - 'command' => 'captainhook', - 'package' => 'captainhook/captainhook', - ], - 'infection' => [ - 'name' => 'Infection', - 'description' => 'A mutation testing framework for PHP.', - 'command' => 'infection', - 'package' => 'infection/infection', - ], - 'grumphp' => [ - 'name' => 'GrumPHP', - 'description' => 'A task runner tool to enforce code quality by running tasks on commit (like PHPUnit, PHPStan, etc.).', - 'command' => 'grumphp', - 'package' => 'phpro/grumphp', - ], - 'laravel' => [ - 'name' => 'Laravel Installer', - 'description' => 'A tool for quickly creating new Laravel applications via CLI.', - 'command' => 'laravel', - 'package' => 'laravel/installer', - ], - 'wp' => [ - 'name' => 'Wp-CLI', - 'description' => 'Command line interface for WordPress, allowing you to manage WordPress installations.', - 'command' => 'wp', - 'package' => 'wp-cli/wp-cli', - ], - 'bref' => [ - 'name' => 'Bref', - 'description' => 'A tool to deploy PHP applications to AWS Lambda.', - 'command' => 'bref', - 'package' => 'bref/bref', - ], - 'phpspec' => [ - 'name' => 'PhpSpec', - 'description' => 'A behavior-driven development (BDD) testing framework.', - 'command' => 'phpspec', - 'package' => 'phpspec/phpspec', - ], - 'psysh' => [ - 'name' => 'PsySH', - 'description' => 'A PHP interactive shell with a powerful REPL.', - 'command' => 'psysh', - 'package' => 'psy/psysh', - ], - 'composer-require-checker' => [ - 'name' => 'Composer Require Checker', - 'description' => 'A tool to check whether all dependencies required in composer.json are actually used in your code.', - 'command' => 'composer-require-checker', - 'package' => 'maglnet/composer-require-checker', - ], - 'monorepo-builder' => [ - 'name' => 'Monorepo Builder', - 'description' => 'A tool for managing PHP monorepo projects.', - 'command' => 'monorepo-builder', - 'package' => 'symplify/monorepo-builder', - ], - 'churn' => [ - 'name' => 'Churn PHP', - 'description' => 'A tool that helps identify PHP files in a project that have a high churn and complexity.', - 'command' => 'churn', - 'package' => 'bmitch/churn-php', - ], - 'sculpin' => [ - 'name' => 'Sculpin', - 'description' => 'A static site generator written in PHP.', - 'command' => 'sculpin', - 'package' => 'sculpin/sculpin', - ], - 'robo' => [ - 'name' => 'Robo.li', - 'description' => 'A PHP task runner for automating tasks in PHP applications.', - 'command' => 'robo', - 'package' => 'consolidation/robo', - ], - 'phpdox' => [ - 'name' => 'PHPDox', - 'description' => 'A documentation generator for PHP, focused on unit test coverage and code analysis.', - 'command' => 'phpdox', - 'package' => 'theseer/phpdox', - ], - 'phpinsights' => [ - 'name' => 'PHP Insights', - 'description' => 'Provides metrics and insights about your PHP project\'s code quality, complexity, and architecture.', - 'command' => 'phpinsights', - 'package' => 'nunomaduro/phpinsights', - ], - 'couscous' => [ - 'name' => 'Couscous', - 'description' => 'Static site generator for generating documentation from Markdown files.', - 'command' => 'couscous', - 'package' => 'couscous/couscous', - ], - 'valet' => [ - 'name' => 'Valet', - 'description' => 'Manage a Laravel development environment with minimal configuration.', - 'command' => 'valet', - 'package' => 'laravel/valet', - ], - 'deptrac' => [ - 'name' => 'Deptrac', - 'description' => 'Static analysis that defines and enforces architectural layers.', - 'command' => 'deptrac', - 'package' => 'qossmic/deptrac', - ], - 'php-scoper' => [ - 'name' => 'PHP-Scoper', - 'description' => 'Isolate a PHP library\'s dependencies, useful for creating PHAR files.', - 'command' => 'php-scoper', - 'package' => 'humbug/php-scoper', - ], - 'phpcbf' => [ - 'name' => 'Phpcbf', - 'description' => 'Automatically fix coding standards issues.', - 'command' => 'phpcbf', - 'package' => 'squizlabs/php_codesniffer', - ], - 'phpmd' => [ - 'name' => 'PHPMD', - 'description' => 'Analyze PHP code for potential mess and problems.', - 'command' => 'phpmd', - 'package' => 'phpmd/phpmd', - ], - 'ecs' => [ - 'name' => 'Easy Coding Standard', - 'description' => 'Check and fix coding standards in PHP code.', - 'command' => 'ecs', - 'package' => 'symplify/easy-coding-standard', - ], - 'config-transformer' => [ - 'name' => 'Config Transformer', - 'description' => 'Transform configuration files from one format to another.', - 'command' => 'config-transformer', - 'package' => 'symplify/config-transformer', - ], - 'class-leak' => [ - 'name' => 'ClassLeak', - 'description' => 'Spot unused classes you can remove.', - 'command' => 'class-leak', - 'package' => 'tomasvotruba/class-leak', - ], - 'composer-dependency-analyser' => [ - 'name' => 'Composer Dependency Analyser', - 'description' => 'Detect unused dependencies, transitional dependencies, missing classes and more.', - 'command' => 'composer-dependency-analyser', - 'package' => 'shipmonk/composer-dependency-analyser', - ], - 'swiss-knife' => [ - 'name' => 'Swiss Knife', - 'description' => 'Finalize classes without children, make class constants private and more.', - 'command' => 'swiss-knife', - 'package' => 'rector/swiss-knife', - ], - ]; -} diff --git a/src/PackageCommandRunner.php b/src/PackageCommandRunner.php deleted file mode 100644 index 85ff5fb..0000000 --- a/src/PackageCommandRunner.php +++ /dev/null @@ -1,60 +0,0 @@ -isFile($console->command)) { - return (new ExecCommand)->run($this->fileInput($console), $output); - } - - if (array_key_exists($console->command, PackageAliases::$packages)) { - Package::parse(PackageAliases::$packages[$console->command]['package'])->runCommand($console); - - return SymfonyCommand::SUCCESS; - } - - if (str_contains($console->command, '/')) { - Package::parse($console->command)->runCommand($console); - - return SymfonyCommand::SUCCESS; - } - - $output->writeln("Unrecognised command {$console->command}"); - - return SymfonyCommand::FAILURE; - } - - private function isFile(string $path): bool - { - $realPath = realpath($path); - - return $realPath !== false && file_exists($realPath) && ! is_dir($realPath); - } - - private function fileInput(Console $console): ArrayInput - { - $input = ['file' => $console->command]; - - foreach (['find-autoloader', 'load-laravel-bootstrap', 'alias-classes'] as $option) { - if ($console->hasOption($option)) { - $input["--{$option}"] = filter_var($console->getOption($option), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? true; - } - } - - return new ArrayInput($input); - } -} diff --git a/src/Packages/Package.php b/src/Packages/Package.php new file mode 100644 index 0000000..678ca44 --- /dev/null +++ b/src/Packages/Package.php @@ -0,0 +1,205 @@ +fullPackageString(); + } + + public static function parse(string $str): self + { + if (empty($str)) { + throw new InvalidArgumentException('A package name must be provided.'); + } + + if (preg_match('/\A(?[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?)\/(?[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?)(?::(?(?![.]+\z)[a-zA-Z0-9_.@~^*<>!=|,-]+))?\z/', $str, $matches) !== 1) { + throw new InvalidArgumentException('A package name should be in the format "/[:version]".'); + } + + return new self( + vendor: $matches['vendor'], + name: $matches['name'], + version: $matches['version'] ?? null, + ); + } + + public function folder(): string + { + return "{$this->vendor}/{$this->name}/{$this->versionName()}"; + } + + public function versionName(): string + { + return $this->version ?? 'latest'; + } + + public function fullPackageString(): string + { + return "{$this->vendor}/{$this->name}" + .($this->version ? ':'.$this->version : ''); + } + + public function delete(): void + { + Filesystem::deleteDirectory(cpx_path($this->folder())); + } + + public function runCommand(PackageInvocation $invocation, bool $autoUpdate = true): int + { + $installDir = $this->installOrUpdatePackage($autoUpdate); + $packageDir = "{$installDir}/vendor/{$this->vendor}/{$this->name}"; + $binScripts = ComposerRunner::detectBinFromComposer($packageDir); + + if (empty($binScripts)) { + echo "No bin command found in {$this}.".PHP_EOL; + + return Command::FAILURE; + } + + $binScripts = Arr::mapWithKeys(fn (int $_, string $value): array => [basename($value) => $value], $binScripts); + $resolved = $this->resolveBinCommand($binScripts, $invocation); + + if ($resolved === null) { + echo "More than 1 bin command found for {$this}: ".implode(', ', array_keys($binScripts)).'.'.PHP_EOL; + + return Command::FAILURE; + } + + [$command, $invocation] = $resolved; + $binPath = "{$packageDir}/{$command}"; + + if (! file_exists($binPath)) { + echo 'Command '.basename($command)." not found in {$this}.".PHP_EOL; + + return Command::FAILURE; + } + + Metadata::open()->recordRun($this)->save(); + printColor('Running '.basename($command)." from {$this}"); + + return (new ProcessRunner)->run([$binPath, ...$invocation->forwardedTokens()]); + } + + public function installOrUpdatePackage(bool $updateCheck = true): string + { + $installDir = cpx_path($this->folder()); + + if (! is_dir($installDir)) { + mkdir($installDir, 0755, true); + } + + match (true) { + ! is_dir("{$installDir}/vendor") => $this->installPackage($installDir), + $updateCheck && $this->shouldCheckForUpdates() => $this->updatePackage($installDir), + default => printColor("{$this} is already installed and doesn't need updating."), + }; + + return $installDir; + } + + public function shouldCheckForUpdates(): bool + { + $metadata = Metadata::open(); + $packageKey = $this->fullPackageString(); + + if (! $metadata->hasPackage($this)) { + return true; + } + + $lastUpdatedAt = $metadata->packages[$packageKey]->lastUpdatedAt; + + if ($lastUpdatedAt === null) { + return true; + } + + $lastCheck = strtotime($lastUpdatedAt); + + return $lastCheck === false || (time() - $lastCheck) > 3600; + } + + /** + * @param array $binScripts + * @return array{0: string, 1: PackageInvocation}|null + */ + private function resolveBinCommand(array $binScripts, PackageInvocation $invocation): ?array + { + if (count($binScripts) === 1) { + return [$binScripts[array_key_first($binScripts)], $invocation]; + } + + $possibleCommands = array_values(array_unique(array_filter([ + $invocation->target, + $invocation->firstForwardedToken(), + $this->name, + ]))); + + foreach ($possibleCommands as $possibleCommand) { + $command = $binScripts[$possibleCommand] ?? null; + + if ($command === null && in_array($possibleCommand, $binScripts, true)) { + $command = $possibleCommand; + } + + if ($command === null) { + continue; + } + + return $invocation->firstForwardedToken() === $possibleCommand + ? [$command, $invocation->withoutFirstForwardedToken()] + : [$command, $invocation]; + } + + return null; + } + + private function installPackage(string $installDir): void + { + printColor("Installing {$this}..."); + file_put_contents("{$installDir}/composer.json", json_encode([ + 'name' => "cpx-{$this->vendor}/cpx-{$this->name}", + 'version' => '1.0.0', + 'config' => [ + 'allow-plugins' => true, + ], + ])); + + ComposerRunner::run(['require', $this->fullPackageString()], $installDir); + Metadata::open()->recordUpdate($this)->save(); + } + + private function updatePackage(string $installDir): void + { + printColor("Checking for updates for {$this}..."); + $previousVersion = ComposerRunner::getCurrentVersion($installDir); + ComposerRunner::run(['update'], $installDir); + $newVersion = ComposerRunner::getCurrentVersion($installDir); + + if ($previousVersion !== $newVersion) { + printColor("{$this} was upgraded from {$previousVersion} to {$newVersion}."); + } else { + printColor("{$this} is already up-to-date."); + } + + Metadata::open()->recordUpdate($this)->save(); + } +} diff --git a/src/Packages/PackageAlias.php b/src/Packages/PackageAlias.php new file mode 100644 index 0000000..861ee25 --- /dev/null +++ b/src/Packages/PackageAlias.php @@ -0,0 +1,15 @@ +|null */ + private static ?array $packages = null; + + /** @return array */ + public static function all(): array + { + return self::$packages ??= [ + 'psalm' => new PackageAlias( + name: 'Psalm', + description: 'A static analysis tool for PHP, focusing on improving code quality and detecting bugs.', + command: 'psalm', + package: 'vimeo/psalm', + ), + 'phpcs' => new PackageAlias( + name: 'PHP_CodeSniffer', + description: 'Detects violations of coding standards.', + command: 'phpcs', + package: 'squizlabs/php_codesniffer', + ), + 'phpstan' => new PackageAlias( + name: 'PHPStan', + description: 'A static analysis tool for finding bugs in PHP code without actually running it.', + command: 'phpstan', + package: 'phpstan/phpstan', + ), + 'phploc' => new PackageAlias( + name: 'PHPLoc', + description: 'A tool for quickly measuring the size and analyzing the structure of a PHP project.', + command: 'phploc', + package: 'cmgmyr/phploc', + ), + 'rector' => new PackageAlias( + name: 'Rector', + description: 'An automated refactoring tool that simplifies upgrades and cleanups in your PHP codebase.', + command: 'rector', + package: 'rector/rector', + ), + 'phpmetrics' => new PackageAlias( + name: 'PHPMetrics', + description: 'A static analysis tool that provides metrics and quality assessments of your PHP code.', + command: 'phpmetrics', + package: 'phpmetrics/phpmetrics', + ), + 'php-cs-fixer' => new PackageAlias( + name: 'PHP-CS-Fixer', + description: 'A tool to automatically fix coding standards in PHP files.', + command: 'php-cs-fixer', + package: 'friendsofphp/php-cs-fixer', + ), + 'pint' => new PackageAlias( + name: 'Pint', + description: 'An opinionated code styler for Laravel.', + command: 'pint', + package: 'laravel/pint', + ), + 'phinx' => new PackageAlias( + name: 'Phinx', + description: 'A database migration tool for PHP.', + command: 'phinx', + package: 'robmorgan/phinx', + ), + 'box' => new PackageAlias( + name: 'Box', + description: 'A tool to generate PHAR files from a PHP project.', + command: 'box', + package: 'humbug/box', + ), + 'pdepend' => new PackageAlias( + name: 'Pdepend', + description: 'A static analysis tool that generates software metrics like complexity and inheritance information.', + command: 'pdepend', + package: 'pdepend/pdepend', + ), + 'dep' => new PackageAlias( + name: 'Deployer', + description: 'A deployment tool for PHP applications.', + command: 'dep', + package: 'deployer/deployer', + ), + 'phpbench' => new PackageAlias( + name: 'PHPBench', + description: 'A benchmark framework for PHP.', + command: 'phpbench', + package: 'phpbench/phpbench', + ), + 'phing' => new PackageAlias( + name: 'Phing', + description: 'A PHP project build system or automation tool similar to Apache Ant.', + command: 'phing', + package: 'phing/phing', + ), + 'captainhook' => new PackageAlias( + name: 'CaptainHook', + description: 'A tool to manage and configure git hooks for PHP.', + command: 'captainhook', + package: 'captainhook/captainhook', + ), + 'infection' => new PackageAlias( + name: 'Infection', + description: 'A mutation testing framework for PHP.', + command: 'infection', + package: 'infection/infection', + ), + 'grumphp' => new PackageAlias( + name: 'GrumPHP', + description: 'A task runner tool to enforce code quality by running tasks on commit (like PHPUnit, PHPStan, etc.).', + command: 'grumphp', + package: 'phpro/grumphp', + ), + 'laravel' => new PackageAlias( + name: 'Laravel Installer', + description: 'A tool for quickly creating new Laravel applications via CLI.', + command: 'laravel', + package: 'laravel/installer', + ), + 'wp' => new PackageAlias( + name: 'Wp-CLI', + description: 'Command line interface for WordPress, allowing you to manage WordPress installations.', + command: 'wp', + package: 'wp-cli/wp-cli', + ), + 'bref' => new PackageAlias( + name: 'Bref', + description: 'A tool to deploy PHP applications to AWS Lambda.', + command: 'bref', + package: 'bref/bref', + ), + 'phpspec' => new PackageAlias( + name: 'PhpSpec', + description: 'A behavior-driven development (BDD) testing framework.', + command: 'phpspec', + package: 'phpspec/phpspec', + ), + 'psysh' => new PackageAlias( + name: 'PsySH', + description: 'A PHP interactive shell with a powerful REPL.', + command: 'psysh', + package: 'psy/psysh', + ), + 'composer-require-checker' => new PackageAlias( + name: 'Composer Require Checker', + description: 'A tool to check whether all dependencies required in composer.json are actually used in your code.', + command: 'composer-require-checker', + package: 'maglnet/composer-require-checker', + ), + 'monorepo-builder' => new PackageAlias( + name: 'Monorepo Builder', + description: 'A tool for managing PHP monorepo projects.', + command: 'monorepo-builder', + package: 'symplify/monorepo-builder', + ), + 'churn' => new PackageAlias( + name: 'Churn PHP', + description: 'A tool that helps identify PHP files in a project that have a high churn and complexity.', + command: 'churn', + package: 'bmitch/churn-php', + ), + 'sculpin' => new PackageAlias( + name: 'Sculpin', + description: 'A static site generator written in PHP.', + command: 'sculpin', + package: 'sculpin/sculpin', + ), + 'robo' => new PackageAlias( + name: 'Robo.li', + description: 'A PHP task runner for automating tasks in PHP applications.', + command: 'robo', + package: 'consolidation/robo', + ), + 'phpdox' => new PackageAlias( + name: 'PHPDox', + description: 'A documentation generator for PHP, focused on unit test coverage and code analysis.', + command: 'phpdox', + package: 'theseer/phpdox', + ), + 'phpinsights' => new PackageAlias( + name: 'PHP Insights', + description: 'Provides metrics and insights about PHP code quality, complexity, and architecture.', + command: 'phpinsights', + package: 'nunomaduro/phpinsights', + ), + 'couscous' => new PackageAlias( + name: 'Couscous', + description: 'Static site generator for generating documentation from Markdown files.', + command: 'couscous', + package: 'couscous/couscous', + ), + 'valet' => new PackageAlias( + name: 'Valet', + description: 'Manage a Laravel development environment with minimal configuration.', + command: 'valet', + package: 'laravel/valet', + ), + 'deptrac' => new PackageAlias( + name: 'Deptrac', + description: 'Static analysis that defines and enforces architectural layers.', + command: 'deptrac', + package: 'qossmic/deptrac', + ), + 'php-scoper' => new PackageAlias( + name: 'PHP-Scoper', + description: 'Isolate a PHP library\'s dependencies, useful for creating PHAR files.', + command: 'php-scoper', + package: 'humbug/php-scoper', + ), + 'phpcbf' => new PackageAlias( + name: 'Phpcbf', + description: 'Automatically fix coding standards issues.', + command: 'phpcbf', + package: 'squizlabs/php_codesniffer', + ), + 'phpmd' => new PackageAlias( + name: 'PHPMD', + description: 'Analyze PHP code for potential mess and problems.', + command: 'phpmd', + package: 'phpmd/phpmd', + ), + 'ecs' => new PackageAlias( + name: 'Easy Coding Standard', + description: 'Check and fix coding standards in PHP code.', + command: 'ecs', + package: 'symplify/easy-coding-standard', + ), + 'config-transformer' => new PackageAlias( + name: 'Config Transformer', + description: 'Transform configuration files from one format to another.', + command: 'config-transformer', + package: 'symplify/config-transformer', + ), + 'class-leak' => new PackageAlias( + name: 'ClassLeak', + description: 'Spot unused classes you can remove.', + command: 'class-leak', + package: 'tomasvotruba/class-leak', + ), + 'composer-dependency-analyser' => new PackageAlias( + name: 'Composer Dependency Analyser', + description: 'Detect unused dependencies, transitional dependencies, missing classes and more.', + command: 'composer-dependency-analyser', + package: 'shipmonk/composer-dependency-analyser', + ), + 'swiss-knife' => new PackageAlias( + name: 'Swiss Knife', + description: 'Finalize classes without children, make class constants private and more.', + command: 'swiss-knife', + package: 'rector/swiss-knife', + ), + ]; + } +} diff --git a/src/Packages/PackageCommandRunner.php b/src/Packages/PackageCommandRunner.php new file mode 100644 index 0000000..bee098b --- /dev/null +++ b/src/Packages/PackageCommandRunner.php @@ -0,0 +1,67 @@ +isFile($invocation->target)) { + return (new ExecCommand)->run($this->fileInput($invocation), $output); + } + + $aliases = PackageAliases::all(); + + if (array_key_exists($invocation->target, $aliases)) { + return Package::parse($aliases[$invocation->target]->package)->runCommand($invocation); + } + + if (str_contains($invocation->target, '/')) { + try { + return Package::parse($invocation->target)->runCommand($invocation); + } catch (InvalidArgumentException) { + HelpCommand::render($output, $invocation->target); + + return SymfonyCommand::FAILURE; + } + } + + HelpCommand::render($output, $invocation->target); + + return SymfonyCommand::FAILURE; + } + + private function isFile(string $path): bool + { + $realPath = realpath($path); + + return $realPath !== false && file_exists($realPath) && ! is_dir($realPath); + } + + private function fileInput(PackageInvocation $invocation): ArrayInput + { + $input = ['file' => $invocation->target]; + + foreach (['find-autoloader', 'load-laravel-bootstrap', 'alias-classes'] as $option) { + if ($invocation->hasOption($option)) { + $input["--{$option}"] = filter_var($invocation->option($option), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? true; + } + } + + return new ArrayInput($input); + } +} diff --git a/tests/Unit/PackageAliasesTest.php b/tests/Unit/PackageAliasesTest.php index 0f0b599..620f087 100644 --- a/tests/Unit/PackageAliasesTest.php +++ b/tests/Unit/PackageAliasesTest.php @@ -1,11 +1,12 @@ toBeTrue() - ->and(PackageAliases::$packages[$alias]['package'])->toBe($package) - ->and(PackageAliases::$packages[$alias]['command'])->toBe($command); + expect(array_key_exists($alias, PackageAliases::all()))->toBeTrue() + ->and(PackageAliases::all()[$alias]->package)->toBe($package) + ->and(PackageAliases::all()[$alias]->command)->toBe($command); })->with([ ['pint', 'laravel/pint', 'pint'], ['phpstan', 'phpstan/phpstan', 'phpstan'], @@ -13,12 +14,12 @@ ]); test('each default alias has the required package runner fields', function () { - foreach (PackageAliases::$packages as $alias => $package) { - expect(array_keys($package))->toContain('name', 'description', 'command', 'package') - ->and($package['name'])->not->toBe('') - ->and($package['description'])->not->toBe('') - ->and($package['command'])->not->toBe('') - ->and($package['package'])->toContain('/'); + foreach (PackageAliases::all() as $package) { + expect($package)->toBeInstanceOf(PackageAlias::class) + ->and($package->name)->not->toBe('') + ->and($package->description)->not->toBe('') + ->and($package->command)->not->toBe('') + ->and($package->package)->toContain('/'); } }); diff --git a/tests/Unit/PackageTest.php b/tests/Unit/PackageTest.php index f0e3e7e..7c17c00 100644 --- a/tests/Unit/PackageTest.php +++ b/tests/Unit/PackageTest.php @@ -1,6 +1,6 @@ throws(InvalidArgumentException::class); test('package cache keys are derived from validated identifiers or stable safe hashes')->todo( From 27dca097354762acbf2a7d26c5c15e788a0767c0 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 26 Jun 2026 17:01:40 +0100 Subject: [PATCH 30/35] Route commands through the new execution boundaries Update the Symfony commands, application wiring, and sandbox bootstrap to build PackageInvocations, call ProcessRunner and ComposerRunner with argv arrays, and propagate child process exit codes. --- files/psysh-config.php | 2 +- pint.json | 19 +++++ src/Application.php | 25 +++++- src/Commands/AliasesCommand.php | 11 +-- src/Commands/CleanCommand.php | 8 +- src/Commands/ExecCommand.php | 26 +++--- src/Commands/ListCommand.php | 2 +- src/Commands/RunPackageCommand.php | 25 ++++-- src/Commands/TinkerCommand.php | 7 +- src/Commands/UpdateCommand.php | 6 +- src/Commands/UpgradeCommand.php | 4 +- src/Packages/PackageCommandRunner.php | 5 +- src/functions.php | 10 +-- tests/Feature/BinaryResolutionTest.php | 49 +++++++++--- tests/Feature/CliArgumentForwardingTest.php | 74 ++++++++++++++--- tests/Feature/CommandBehaviorTest.php | 88 ++++++++++++++------- tests/Pest.php | 51 ++++++++++++ 17 files changed, 313 insertions(+), 99 deletions(-) diff --git a/files/psysh-config.php b/files/psysh-config.php index beeac03..7ad2887 100644 --- a/files/psysh-config.php +++ b/files/psysh-config.php @@ -1,6 +1,6 @@ registerCommands($packageCommandRunner ?? new PackageCommandRunner); } + public function run(?InputInterface $input = null, ?OutputInterface $output = null): int + { + $input ??= new ArgvInput; + + if ($input instanceof ArgvInput && $this->shouldRunPackageFallback($input)) { + $input = new ArgvInput(['cpx', RunPackageCommand::NAME, '--', ...$input->getRawTokens()]); + } + + return parent::run($input, $output); + } + protected function getCommandName(InputInterface $input): ?string { $command = parent::getCommandName($input); return $command === null || $this->has($command) ? $command - : RunPackageCommand::Name; + : RunPackageCommand::NAME; } private function registerCommands(PackageCommandRunner $packageCommandRunner): void @@ -62,4 +76,13 @@ private function resolveVersion(): string ? $decoded['version'] : 'unknown'; } + + private function shouldRunPackageFallback(ArgvInput $input): bool + { + $command = $input->getRawTokens()[0] ?? null; + + return is_string($command) + && ! in_array($command, ['--version', '-v'], true) + && ! $this->has($command); + } } diff --git a/src/Commands/AliasesCommand.php b/src/Commands/AliasesCommand.php index 22ab6d2..9089ab0 100644 --- a/src/Commands/AliasesCommand.php +++ b/src/Commands/AliasesCommand.php @@ -4,7 +4,8 @@ namespace Cpx\Commands; -use Cpx\PackageAliases; +use Cpx\Packages\PackageAlias; +use Cpx\Packages\PackageAliases; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -19,12 +20,12 @@ class AliasesCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('Aliased packages:'.PHP_EOL); - $packages = PackageAliases::$packages; - usort($packages, fn (array $a, array $b): int => strcmp($a['command'], $b['command'])); + $packages = PackageAliases::all(); + usort($packages, fn (PackageAlias $a, PackageAlias $b): int => strcmp($a->command, $b->command)); foreach ($packages as $package) { - $paddedCommand = str_pad($package['command'], 15); - $output->writeln(' cpx '.$paddedCommand.' '.$package['description']); + $paddedCommand = str_pad($package->command, 15); + $output->writeln(' cpx '.$paddedCommand.' '.$package->description); } return self::SUCCESS; diff --git a/src/Commands/CleanCommand.php b/src/Commands/CleanCommand.php index ea57be1..370e1ce 100644 --- a/src/Commands/CleanCommand.php +++ b/src/Commands/CleanCommand.php @@ -4,9 +4,9 @@ namespace Cpx\Commands; -use Cpx\Metadata; -use Cpx\Package; -use Cpx\Utils; +use Cpx\Cache\Metadata; +use Cpx\Packages\Package; +use Cpx\Support\Filesystem; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -50,7 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($input->getOption('all') === true || $lastRun < $timeLimit) { $packageDirectory = cpx_path(".exec_cache/{$sandboxDir}"); - Utils::deleteDirectory($packageDirectory); + Filesystem::deleteDirectory($packageDirectory); $output->writeln("Removing exec sandbox cache {$sandboxDir}..."); unset($metadata->execCache[$sandboxDir]); $cleanedSomething = true; diff --git a/src/Commands/ExecCommand.php b/src/Commands/ExecCommand.php index 7bd5d79..c31ac47 100644 --- a/src/Commands/ExecCommand.php +++ b/src/Commands/ExecCommand.php @@ -4,7 +4,7 @@ namespace Cpx\Commands; -use Cpx\PhpExecutionHelper; +use Cpx\Runtime\PhpExecutionHelper; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -18,7 +18,7 @@ )] class ExecCommand extends Command { - public string $path; + private string $path; protected function configure(): void { @@ -44,6 +44,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } + protected function autoload(string $directory, InputInterface $input, OutputInterface $output): void + { + $shouldFindAutoloader = $input->getOption('find-autoloader') === true; + $shouldLoadLaravelBootstrap = $input->getOption('load-laravel-bootstrap') === true; + $shouldAliasClasses = $input->getOption('alias-classes') === true; + $shouldBeVerbose = $output->isVerbose(); + + PhpExecutionHelper::init($directory, $shouldFindAutoloader, $shouldLoadLaravelBootstrap, $shouldAliasClasses, $shouldBeVerbose); + } + private function executeInput(InputInterface $input, OutputInterface $output): int { if ($input->getOption('run') !== null) { @@ -108,17 +118,7 @@ private function executeInput(InputInterface $input, OutputInterface $output): i return self::SUCCESS; } - protected function autoload(string $directory, InputInterface $input, OutputInterface $output): void - { - $shouldFindAutoloader = $input->getOption('find-autoloader') === true; - $shouldLoadLaravelBootstrap = $input->getOption('load-laravel-bootstrap') === true; - $shouldAliasClasses = $input->getOption('alias-classes') === true; - $shouldBeVerbose = $output->isVerbose(); - - PhpExecutionHelper::init($directory, $shouldFindAutoloader, $shouldLoadLaravelBootstrap, $shouldAliasClasses, $shouldBeVerbose); - } - - public function runFile(): void + private function runFile(): void { require $this->path; } diff --git a/src/Commands/ListCommand.php b/src/Commands/ListCommand.php index b250534..29a474d 100644 --- a/src/Commands/ListCommand.php +++ b/src/Commands/ListCommand.php @@ -4,7 +4,7 @@ namespace Cpx\Commands; -use Cpx\Metadata; +use Cpx\Cache\Metadata; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; diff --git a/src/Commands/RunPackageCommand.php b/src/Commands/RunPackageCommand.php index 1333437..5caba07 100644 --- a/src/Commands/RunPackageCommand.php +++ b/src/Commands/RunPackageCommand.php @@ -4,8 +4,9 @@ namespace Cpx\Commands; -use Cpx\Console; -use Cpx\PackageCommandRunner; +use Cpx\Input\PackageInvocation; +use Cpx\Packages\PackageCommandRunner; +use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Input\ArgvInput; @@ -13,12 +14,12 @@ use Symfony\Component\Console\Output\OutputInterface; #[AsCommand( - name: self::Name, + name: self::NAME, hidden: true, )] class RunPackageCommand extends SymfonyCommand { - public const Name = '__cpx_run_package'; + public const NAME = '__cpx_run_package'; public function __construct( private PackageCommandRunner $packageCommandRunner, @@ -35,12 +36,24 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $console = Console::parse($input instanceof ArgvInput ? $input->getRawTokens() : []); + $tokens = $input instanceof ArgvInput ? $input->getRawTokens() : []; + + if (($tokens[0] ?? null) === self::NAME) { + array_shift($tokens); + } + + if (($tokens[0] ?? null) === '--') { + array_shift($tokens); + } ob_start(); try { - return $this->packageCommandRunner->run($console, $output); + return $this->packageCommandRunner->run(PackageInvocation::fromRawTokens($tokens), $output); + } catch (InvalidArgumentException $e) { + $output->writeln("{$e->getMessage()}"); + + return SymfonyCommand::FAILURE; } finally { $contents = ob_get_clean(); diff --git a/src/Commands/TinkerCommand.php b/src/Commands/TinkerCommand.php index 2fa9ce2..b2c2f1d 100644 --- a/src/Commands/TinkerCommand.php +++ b/src/Commands/TinkerCommand.php @@ -4,8 +4,8 @@ namespace Cpx\Commands; -use Cpx\Console; -use Cpx\Package; +use Cpx\Input\PackageInvocation; +use Cpx\Packages\Package; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -30,7 +30,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ob_start(); try { - Package::parse('psy/psysh')->runCommand(Console::parse("psysh --config {$psyshConfig}")); + return Package::parse('psy/psysh')->runCommand(PackageInvocation::fromRawTokens(['psysh', '--config', $psyshConfig])); } finally { $contents = ob_get_clean(); @@ -39,6 +39,5 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - return self::SUCCESS; } } diff --git a/src/Commands/UpdateCommand.php b/src/Commands/UpdateCommand.php index 890410d..af5bdbb 100644 --- a/src/Commands/UpdateCommand.php +++ b/src/Commands/UpdateCommand.php @@ -4,8 +4,8 @@ namespace Cpx\Commands; -use Cpx\Composer; -use Cpx\Package; +use Cpx\Composer\ComposerRunner; +use Cpx\Packages\Package; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -84,6 +84,6 @@ protected function updatePackage(Package $package, OutputInterface $output): voi protected function updateDirectory(string $directory, OutputInterface $output): void { $output->writeln('Updating '.str_replace(cpx_path(), '', $directory).''); - Composer::runCommand('update', $directory); + ComposerRunner::run(['update'], $directory); } } diff --git a/src/Commands/UpgradeCommand.php b/src/Commands/UpgradeCommand.php index f30c592..abd81dc 100644 --- a/src/Commands/UpgradeCommand.php +++ b/src/Commands/UpgradeCommand.php @@ -4,7 +4,7 @@ namespace Cpx\Commands; -use Cpx\Composer; +use Cpx\Composer\ComposerRunner; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -19,7 +19,7 @@ class UpgradeCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('Updating cpx'); - Composer::runCommand('global update cpx/cpx'); + ComposerRunner::run(['global', 'update', 'cpx/cpx']); return self::SUCCESS; } diff --git a/src/Packages/PackageCommandRunner.php b/src/Packages/PackageCommandRunner.php index bee098b..aef2a1e 100644 --- a/src/Packages/PackageCommandRunner.php +++ b/src/Packages/PackageCommandRunner.php @@ -5,7 +5,6 @@ namespace Cpx\Packages; use Cpx\Commands\ExecCommand; -use Cpx\Commands\HelpCommand; use Cpx\Input\PackageInvocation; use InvalidArgumentException; use Symfony\Component\Console\Command\Command as SymfonyCommand; @@ -34,13 +33,13 @@ public function run(PackageInvocation $invocation, OutputInterface $output): int try { return Package::parse($invocation->target)->runCommand($invocation); } catch (InvalidArgumentException) { - HelpCommand::render($output, $invocation->target); + $output->writeln("Unrecognised command {$invocation->target}"); return SymfonyCommand::FAILURE; } } - HelpCommand::render($output, $invocation->target); + $output->writeln("Unrecognised command {$invocation->target}"); return SymfonyCommand::FAILURE; } diff --git a/src/functions.php b/src/functions.php index 8424d42..614d217 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use Cpx\Composer; +use Cpx\Cache\Metadata; +use Cpx\Composer\ComposerRunner; use Cpx\Exceptions\ComposerInstallException; -use Cpx\Metadata; -use Cpx\PhpExecutionHelper; +use Cpx\Runtime\PhpExecutionHelper; if (! function_exists('composer_require')) { /** @@ -37,7 +37,7 @@ function composer_require(string ...$packages): void // Run `composer require` for each package foreach ($packages as $package) { try { - Composer::runCommand("require {$package} --no-interaction --quiet", $sandboxDir); + ComposerRunner::run(['require', $package], $sandboxDir); $metadata->execCache[$hash]['last_updated'] = time(); } catch (Exception $e) { throw new ComposerInstallException("Failed to install package: {$package}."); @@ -47,7 +47,7 @@ function composer_require(string ...$packages): void if (isset($metadata->execCache[$hash]['last_updated']) && time() - $metadata->execCache[$hash]['last_updated'] >= 3600) { // Composer update was not run within the last hour try { - Composer::runCommand('update --no-interaction --quiet', $sandboxDir); + ComposerRunner::run(['update'], $sandboxDir); $metadata->execCache[$hash]['last_updated'] = time(); } catch (Exception $e) { // Update failed, let's just use the existing folder. diff --git a/tests/Feature/BinaryResolutionTest.php b/tests/Feature/BinaryResolutionTest.php index d64c6fe..62d7d55 100644 --- a/tests/Feature/BinaryResolutionTest.php +++ b/tests/Feature/BinaryResolutionTest.php @@ -1,13 +1,44 @@ todo( - 'Enable when package binaries are executed through the safe process runner.', -); +test('a package with one binary runs without requiring a binary name', function () { + $this->useIsolatedComposerHome(); + $logFile = $this->temporaryDirectory('cpx-log').'/argv.json'; -test('a package with multiple binaries uses the first forwarded argument as the binary name')->todo( - 'Enable when multiple-binary packages are resolved through validated targets.', -); + prepareCachedPackage('vendor/package', ['package'], [ + 'package' => "#!/usr/bin/env php\ntodo( - 'Enable when multiple-binary packages return actionable ambiguity errors.', -); + [$status] = runCpxCommand(['vendor/package', '--flag']); + + expect($status)->toBe(0) + ->and(json_decode((string) file_get_contents($logFile), true))->toBe(['--flag']); +}); + +test('a package with multiple binaries uses the first forwarded argument as the binary name', function () { + $this->useIsolatedComposerHome(); + $logFile = $this->temporaryDirectory('cpx-log').'/argv.json'; + + prepareCachedPackage('vendor/package', ['foo', 'bar'], [ + 'foo' => "#!/usr/bin/env php\n "#!/usr/bin/env php\ntoBe(0) + ->and(json_decode((string) file_get_contents($logFile), true))->toBe(['--flag']); +}); + +test('ambiguous multiple-binary packages list the available binaries', function () { + $this->useIsolatedComposerHome(); + + prepareCachedPackage('vendor/package', ['foo', 'bar'], [ + 'foo' => "#!/usr/bin/env php\n "#!/usr/bin/env php\ntoBe(1) + ->and($output)->toContain('More than 1 bin command found for vendor/package: foo, bar.'); +}); diff --git a/tests/Feature/CliArgumentForwardingTest.php b/tests/Feature/CliArgumentForwardingTest.php index dfdd316..8a9d3a9 100644 --- a/tests/Feature/CliArgumentForwardingTest.php +++ b/tests/Feature/CliArgumentForwardingTest.php @@ -1,17 +1,67 @@ todo( - 'Enable when the process runner forwards argv tokens without shell reconstruction.', -); +test('package arguments are forwarded exactly as argv tokens', function () { + $this->useIsolatedComposerHome(); + $logFile = $this->temporaryDirectory('cpx-log').'/argv.json'; -test('short flags, long flags, repeated options, and option values are preserved')->todo( - 'Enable when forwarded package options are preserved as original argv tokens.', -); + prepareCachedPackage('vendor/package', ['package'], [ + 'package' => "#!/usr/bin/env php\ntodo( - 'Enable when child process exit codes are propagated through the cpx executable.', -); + [$status] = runCpxCommand(['vendor/package', '--name=two words', '--', '--literal', '-x']); -test('a missing target binary returns a non-zero status with an actionable error')->todo( - 'Enable when missing binaries produce explicit non-zero failures.', -); + expect($status)->toBe(0) + ->and(json_decode((string) file_get_contents($logFile), true))->toBe([ + '--name=two words', + '--', + '--literal', + '-x', + ]); +}); + +test('short flags, long flags, repeated options, and option values are preserved', function () { + $this->useIsolatedComposerHome(); + $logFile = $this->temporaryDirectory('cpx-log').'/argv.json'; + + prepareCachedPackage('vendor/package', ['package'], [ + 'package' => "#!/usr/bin/env php\ntoBe(0) + ->and(json_decode((string) file_get_contents($logFile), true))->toBe([ + '-x', + '--flag', + '--filter=one', + '--filter=two', + 'value with spaces', + 'semi;colon', + 'pipe|value', + '$(touch injected)', + ]) + ->and(file_exists(dirname($logFile).'/injected'))->toBeFalse(); +}); + +test('a target binary exit code becomes the cpx exit code', function () { + $this->useIsolatedComposerHome(); + + prepareCachedPackage('vendor/package', ['package'], [ + 'package' => "#!/usr/bin/env php\ntoBe(23); +}); + +test('a missing target binary returns a non-zero status with an actionable error', function () { + $this->useIsolatedComposerHome(); + + prepareCachedPackage('vendor/package', ['missing']); + + [$status, $output] = runCpxCommand(['vendor/package']); + + expect($status)->toBe(1) + ->and($output)->toContain('Command missing not found in vendor/package.'); +}); diff --git a/tests/Feature/CommandBehaviorTest.php b/tests/Feature/CommandBehaviorTest.php index 53e8e04..658a278 100644 --- a/tests/Feature/CommandBehaviorTest.php +++ b/tests/Feature/CommandBehaviorTest.php @@ -1,28 +1,13 @@ run(new ArgvInput(['cpx', ...$arguments]), $output); - - return [$status, $output->fetch()]; -} - -function writeExecutable(string $path, string $contents): void -{ - file_put_contents($path, $contents); - chmod($path, 0755); -} - test('it can run through Symfony tester utilities without exiting', function () { $tester = new ApplicationTester(new Application); @@ -153,11 +138,11 @@ function writeExecutable(string $path, string $contents): void test('unknown package targets route to the package fallback command', function () { $runner = new class extends PackageCommandRunner { - public ?Console $console = null; + public ?PackageInvocation $invocation = null; - public function run(Console $console, OutputInterface $output): int + public function run(PackageInvocation $invocation, OutputInterface $output): int { - $this->console = $console; + $this->invocation = $invocation; return 0; } @@ -167,20 +152,19 @@ public function run(Console $console, OutputInterface $output): int $status = $application->run(new ArgvInput(['cpx', 'vendor/package', '--flag', 'value']), new BufferedOutput); expect($status)->toBe(0) - ->and($runner->console)->toBeInstanceOf(Console::class) - ->and($runner->console?->command)->toBe('vendor/package') - ->and($runner->console?->hasOption('flag'))->toBeTrue() - ->and($runner->console?->getOption('flag'))->toBe('value'); + ->and($runner->invocation)->toBeInstanceOf(PackageInvocation::class) + ->and($runner->invocation?->target)->toBe('vendor/package') + ->and($runner->invocation?->forwardedTokens())->toBe(['--flag', 'value']); }); test('package fallback accepts arbitrary package options without Symfony validation errors', function () { $runner = new class extends PackageCommandRunner { - public ?Console $console = null; + public ?PackageInvocation $invocation = null; - public function run(Console $console, OutputInterface $output): int + public function run(PackageInvocation $invocation, OutputInterface $output): int { - $this->console = $console; + $this->invocation = $invocation; return 0; } @@ -200,9 +184,53 @@ public function run(Console $console, OutputInterface $output): int ]), new BufferedOutput); expect($status)->toBe(0) - ->and($runner->console?->command)->toBe('vendor/package') - ->and($runner->console?->getOption('unknown'))->toBe('value') - ->and($runner->console?->options['filter'] ?? null)->toBe(['one', 'two']); + ->and($runner->invocation?->target)->toBe('vendor/package') + ->and($runner->invocation?->forwardedTokens())->toBe([ + '--unknown', + 'value', + '-x', + '--filter=one', + '--filter=two', + '--', + '--literal', + ]); +}); + +test('package-target version options are forwarded instead of rendering cpx version', function () { + $runner = new class extends PackageCommandRunner + { + public ?PackageInvocation $invocation = null; + + public function run(PackageInvocation $invocation, OutputInterface $output): int + { + $this->invocation = $invocation; + + return 0; + } + }; + $application = new Application($runner); + $output = new BufferedOutput; + + $status = $application->run(new ArgvInput(['cpx', 'pint', '--version']), $output); + + expect($status)->toBe(0) + ->and($runner->invocation?->target)->toBe('pint') + ->and($runner->invocation?->forwardedTokens())->toBe(['--version']) + ->and($output->fetch())->not->toContain('cpx version:'); +}); + +test('package-looking values with shell metacharacters fail before composer execution', function () { + $binDirectory = $this->temporaryDirectory('cpx-bin'); + $logFile = $this->temporaryDirectory('cpx-log').'/composer.log'; + + writeExecutable($binDirectory.'/composer', "#!/usr/bin/env php\nsetEnvironmentVariable('PATH', $binDirectory.PATH_SEPARATOR.getenv('PATH')); + + [$status, $output] = runCpxCommand(['vendor/package;touch injected']); + + expect($status)->toBe(1) + ->and($output)->toContain('Unrecognised command vendor/package;touch injected') + ->and(file_exists($logFile))->toBeFalse(); }); test('invalid fallback commands return a failure status with help output', function () { diff --git a/tests/Pest.php b/tests/Pest.php index 4809776..cdf00c0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,56 @@ extend(TestCase::class)->in('Feature', 'Unit'); + +/** + * @param list $arguments + * @return array{0: int, 1: string} + */ +function runCpxCommand(array $arguments): array +{ + $application = new Application; + $output = new BufferedOutput; + $status = $application->run(new ArgvInput(['cpx', ...$arguments]), $output); + + return [$status, $output->fetch()]; +} + +function writeExecutable(string $path, string $contents): void +{ + file_put_contents($path, $contents); + chmod($path, 0755); +} + +/** + * @param list $bins + * @param array $executables + */ +function prepareCachedPackage(string $package, array $bins, array $executables = []): string +{ + [$vendor, $name] = explode('/', $package); + $packageDirectory = cpx_path("{$vendor}/{$name}/latest/vendor/{$vendor}/{$name}"); + + mkdir($packageDirectory, 0755, true); + file_put_contents($packageDirectory.'/composer.json', json_encode(['bin' => $bins], JSON_THROW_ON_ERROR)); + + foreach ($executables as $bin => $contents) { + writeExecutable("{$packageDirectory}/{$bin}", $contents); + } + + file_put_contents(cpx_path('.cpx_metadata.json'), json_encode([ + 'packages' => [ + $package => [ + 'last_updated' => date('Y-m-d H:i:s'), + 'last_run' => date('Y-m-d H:i:s'), + ], + ], + 'execCache' => [], + ], JSON_THROW_ON_ERROR)); + + return $packageDirectory; +} From abcdffeb71d1c4d24762cbe2c14dad6f9149b168 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 26 Jun 2026 18:36:55 +0100 Subject: [PATCH 31/35] Harden the package runner and stream execution output - Run subprocesses over reopened stdio so exit codes survive any test harness - Stream Composer output live instead of buffering and discarding it - Thread console output through Package and drop the printColor buffering - Validate package targets against Composer's official name grammar --- src/Cache/Metadata.php | 6 +- src/Commands/RunPackageCommand.php | 8 --- src/Commands/TinkerCommand.php | 16 ++---- src/Composer/ComposerRunner.php | 17 ++---- src/Input/PackageInvocation.php | 13 ++++- src/Packages/Package.php | 83 ++++++++++++++++----------- src/Packages/PackageCommandRunner.php | 4 +- src/Packages/ResolvedBin.php | 15 +++++ src/Process/ProcessRunner.php | 58 +++++++++---------- src/Support/Filesystem.php | 14 +++-- src/functions.php | 14 ++--- tests/Unit/ComposerRunnerTest.php | 14 ++--- tests/Unit/ProcessRunnerTest.php | 13 ----- 13 files changed, 141 insertions(+), 134 deletions(-) create mode 100644 src/Packages/ResolvedBin.php diff --git a/src/Cache/Metadata.php b/src/Cache/Metadata.php index ef5170c..d9028df 100644 --- a/src/Cache/Metadata.php +++ b/src/Cache/Metadata.php @@ -9,6 +9,8 @@ class Metadata { + private const FILE = '.cpx_metadata.json'; + /** * @param array $packages * @param array, last_updated?: int, last_run?: int}> $execCache @@ -20,7 +22,7 @@ protected function __construct( public static function open(): self { - $metadataFile = cpx_path('.cpx_metadata.json'); + $metadataFile = cpx_path(self::FILE); if (! file_exists($metadataFile)) { return new self; @@ -64,7 +66,7 @@ public function recordUpdate(Package $package): self public function save(): void { - $metadataFile = cpx_path('.cpx_metadata.json'); + $metadataFile = cpx_path(self::FILE); if (! is_dir(dirname($metadataFile))) { mkdir(dirname($metadataFile), 0755, true); diff --git a/src/Commands/RunPackageCommand.php b/src/Commands/RunPackageCommand.php index 5caba07..6b30c63 100644 --- a/src/Commands/RunPackageCommand.php +++ b/src/Commands/RunPackageCommand.php @@ -46,20 +46,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int array_shift($tokens); } - ob_start(); - try { return $this->packageCommandRunner->run(PackageInvocation::fromRawTokens($tokens), $output); } catch (InvalidArgumentException $e) { $output->writeln("{$e->getMessage()}"); return SymfonyCommand::FAILURE; - } finally { - $contents = ob_get_clean(); - - if ($contents !== false) { - $output->write($contents); - } } } } diff --git a/src/Commands/TinkerCommand.php b/src/Commands/TinkerCommand.php index b2c2f1d..ab10cdf 100644 --- a/src/Commands/TinkerCommand.php +++ b/src/Commands/TinkerCommand.php @@ -27,17 +27,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::FAILURE; } - ob_start(); - - try { - return Package::parse('psy/psysh')->runCommand(PackageInvocation::fromRawTokens(['psysh', '--config', $psyshConfig])); - } finally { - $contents = ob_get_clean(); - - if ($contents !== false) { - $output->write($contents); - } - } - + return Package::parse('psy/psysh')->runCommand( + PackageInvocation::fromRawTokens(['psysh', '--config', $psyshConfig]), + $output, + ); } } diff --git a/src/Composer/ComposerRunner.php b/src/Composer/ComposerRunner.php index d450c8c..8acb51a 100644 --- a/src/Composer/ComposerRunner.php +++ b/src/Composer/ComposerRunner.php @@ -14,27 +14,22 @@ class ComposerRunner /** * @param list $arguments - * @return list */ - public static function run(array $arguments, ?string $directory = null): array + public static function run(array $arguments, ?string $directory = null): int { - $command = ['composer', ...$arguments, '--no-interaction', '--quiet']; + $command = ['composer', ...$arguments, '--no-interaction']; if ($directory !== null) { $command[] = "--working-dir={$directory}"; } - $result = (new ProcessRunner)->capture($command); + $exitCode = (new ProcessRunner)->run($command); - if ($result['exitCode'] !== Command::SUCCESS) { - $message = trim($result['stderr']) ?: 'Composer command failed: '.implode(' ', $arguments); - - throw new Exception($message); + if ($exitCode !== Command::SUCCESS) { + throw new Exception('Composer command failed: '.implode(' ', $arguments)); } - $output = trim($result['stdout']); - - return $output === '' ? [] : explode(PHP_EOL, $output); + return $exitCode; } /** @return list */ diff --git a/src/Input/PackageInvocation.php b/src/Input/PackageInvocation.php index c76257d..86d44bc 100644 --- a/src/Input/PackageInvocation.php +++ b/src/Input/PackageInvocation.php @@ -8,6 +8,9 @@ class PackageInvocation { + /** @var array>|null */ + private ?array $parsedOptions = null; + /** * @param list $forwardedTokens */ @@ -51,6 +54,10 @@ public function hasOption(string $option): bool return array_key_exists($option, $this->options()); } + /** + * Returns a single value for the option. When an option is repeated + * (e.g. --filter=one --filter=two) the first non-null value wins. + */ public function option(string $option): ?string { $value = $this->options()[$option] ?? null; @@ -71,6 +78,10 @@ public function option(string $option): ?string /** @return array> */ private function options(): array { + if ($this->parsedOptions !== null) { + return $this->parsedOptions; + } + $options = []; $tokens = $this->forwardedTokens; @@ -95,7 +106,7 @@ private function options(): array $this->addOption($options, $matches['name'], $value); } - return $options; + return $this->parsedOptions = $options; } /** diff --git a/src/Packages/Package.php b/src/Packages/Package.php index 678ca44..c4fa085 100644 --- a/src/Packages/Package.php +++ b/src/Packages/Package.php @@ -12,9 +12,17 @@ use Cpx\Support\Filesystem; use InvalidArgumentException; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Output\OutputInterface; class Package { + /** + * Composer's official package-name grammar (vendor/name), extended with an optional ":version" constraint. + */ + private const PACKAGE_PATTERN = '/\A(?[a-z0-9](?:[_.-]?[a-z0-9]+)*)\/(?[a-z0-9](?:(?:[_.]?|-{0,2})[a-z0-9]+)*)(?::(?(?![.]+\z)[a-zA-Z0-9_.@~^*<>!=|,-]+))?\z/'; + + private const SCAFFOLD_VERSION = '1.0.0'; + protected function __construct( public string $vendor, public string $name, @@ -32,7 +40,7 @@ public static function parse(string $str): self throw new InvalidArgumentException('A package name must be provided.'); } - if (preg_match('/\A(?[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?)\/(?[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?)(?::(?(?![.]+\z)[a-zA-Z0-9_.@~^*<>!=|,-]+))?\z/', $str, $matches) !== 1) { + if (preg_match(self::PACKAGE_PATTERN, $str, $matches) !== 1) { throw new InvalidArgumentException('A package name should be in the format "/[:version]".'); } @@ -64,14 +72,14 @@ public function delete(): void Filesystem::deleteDirectory(cpx_path($this->folder())); } - public function runCommand(PackageInvocation $invocation, bool $autoUpdate = true): int + public function runCommand(PackageInvocation $invocation, OutputInterface $output, bool $autoUpdate = true): int { - $installDir = $this->installOrUpdatePackage($autoUpdate); + $installDir = $this->installOrUpdatePackage($output, $autoUpdate); $packageDir = "{$installDir}/vendor/{$this->vendor}/{$this->name}"; $binScripts = ComposerRunner::detectBinFromComposer($packageDir); if (empty($binScripts)) { - echo "No bin command found in {$this}.".PHP_EOL; + $output->writeln("No bin command found in {$this}."); return Command::FAILURE; } @@ -80,27 +88,26 @@ public function runCommand(PackageInvocation $invocation, bool $autoUpdate = tru $resolved = $this->resolveBinCommand($binScripts, $invocation); if ($resolved === null) { - echo "More than 1 bin command found for {$this}: ".implode(', ', array_keys($binScripts)).'.'.PHP_EOL; + $output->writeln("More than 1 bin command found for {$this}: ".implode(', ', array_keys($binScripts)).'.'); return Command::FAILURE; } - [$command, $invocation] = $resolved; - $binPath = "{$packageDir}/{$command}"; + $binPath = "{$packageDir}/{$resolved->command}"; if (! file_exists($binPath)) { - echo 'Command '.basename($command)." not found in {$this}.".PHP_EOL; + $output->writeln('Command '.basename($resolved->command)." not found in {$this}."); return Command::FAILURE; } Metadata::open()->recordRun($this)->save(); - printColor('Running '.basename($command)." from {$this}"); + $output->writeln('Running '.basename($resolved->command)." from {$this}"); - return (new ProcessRunner)->run([$binPath, ...$invocation->forwardedTokens()]); + return (new ProcessRunner)->run([$binPath, ...$resolved->invocation->forwardedTokens()]); } - public function installOrUpdatePackage(bool $updateCheck = true): string + public function installOrUpdatePackage(OutputInterface $output, bool $updateCheck = true): string { $installDir = cpx_path($this->folder()); @@ -109,9 +116,9 @@ public function installOrUpdatePackage(bool $updateCheck = true): string } match (true) { - ! is_dir("{$installDir}/vendor") => $this->installPackage($installDir), - $updateCheck && $this->shouldCheckForUpdates() => $this->updatePackage($installDir), - default => printColor("{$this} is already installed and doesn't need updating."), + ! is_dir("{$installDir}/vendor") => $this->installPackage($output, $installDir), + $updateCheck && $this->shouldCheckForUpdates() => $this->updatePackage($output, $installDir), + default => $output->writeln("{$this} is already installed and doesn't need updating."), }; return $installDir; @@ -139,46 +146,54 @@ public function shouldCheckForUpdates(): bool /** * @param array $binScripts - * @return array{0: string, 1: PackageInvocation}|null */ - private function resolveBinCommand(array $binScripts, PackageInvocation $invocation): ?array + private function resolveBinCommand(array $binScripts, PackageInvocation $invocation): ?ResolvedBin { if (count($binScripts) === 1) { - return [$binScripts[array_key_first($binScripts)], $invocation]; + return new ResolvedBin($binScripts[array_key_first($binScripts)], $invocation); } - $possibleCommands = array_values(array_unique(array_filter([ + $candidates = array_values(array_unique(array_filter([ $invocation->target, $invocation->firstForwardedToken(), $this->name, ]))); - foreach ($possibleCommands as $possibleCommand) { - $command = $binScripts[$possibleCommand] ?? null; - - if ($command === null && in_array($possibleCommand, $binScripts, true)) { - $command = $possibleCommand; - } + foreach ($candidates as $candidate) { + $command = $this->matchBin($binScripts, $candidate); if ($command === null) { continue; } - return $invocation->firstForwardedToken() === $possibleCommand - ? [$command, $invocation->withoutFirstForwardedToken()] - : [$command, $invocation]; + return $invocation->firstForwardedToken() === $candidate + ? new ResolvedBin($command, $invocation->withoutFirstForwardedToken()) + : new ResolvedBin($command, $invocation); } return null; } - private function installPackage(string $installDir): void + /** + * @param array $binScripts + */ + private function matchBin(array $binScripts, string $candidate): ?string + { + if (array_key_exists($candidate, $binScripts)) { + return $binScripts[$candidate]; + } + + return in_array($candidate, $binScripts, true) ? $candidate : null; + } + + private function installPackage(OutputInterface $output, string $installDir): void { - printColor("Installing {$this}..."); + $output->writeln("Installing {$this}..."); file_put_contents("{$installDir}/composer.json", json_encode([ 'name' => "cpx-{$this->vendor}/cpx-{$this->name}", - 'version' => '1.0.0', + 'version' => self::SCAFFOLD_VERSION, 'config' => [ + // Requested packages may ship Composer plugins (binaries, installers). 'allow-plugins' => true, ], ])); @@ -187,17 +202,17 @@ private function installPackage(string $installDir): void Metadata::open()->recordUpdate($this)->save(); } - private function updatePackage(string $installDir): void + private function updatePackage(OutputInterface $output, string $installDir): void { - printColor("Checking for updates for {$this}..."); + $output->writeln("Checking for updates for {$this}..."); $previousVersion = ComposerRunner::getCurrentVersion($installDir); ComposerRunner::run(['update'], $installDir); $newVersion = ComposerRunner::getCurrentVersion($installDir); if ($previousVersion !== $newVersion) { - printColor("{$this} was upgraded from {$previousVersion} to {$newVersion}."); + $output->writeln("{$this} was upgraded from {$previousVersion} to {$newVersion}."); } else { - printColor("{$this} is already up-to-date."); + $output->writeln("{$this} is already up-to-date."); } Metadata::open()->recordUpdate($this)->save(); diff --git a/src/Packages/PackageCommandRunner.php b/src/Packages/PackageCommandRunner.php index aef2a1e..78ba25a 100644 --- a/src/Packages/PackageCommandRunner.php +++ b/src/Packages/PackageCommandRunner.php @@ -26,12 +26,12 @@ public function run(PackageInvocation $invocation, OutputInterface $output): int $aliases = PackageAliases::all(); if (array_key_exists($invocation->target, $aliases)) { - return Package::parse($aliases[$invocation->target]->package)->runCommand($invocation); + return Package::parse($aliases[$invocation->target]->package)->runCommand($invocation, $output); } if (str_contains($invocation->target, '/')) { try { - return Package::parse($invocation->target)->runCommand($invocation); + return Package::parse($invocation->target)->runCommand($invocation, $output); } catch (InvalidArgumentException) { $output->writeln("Unrecognised command {$invocation->target}"); diff --git a/src/Packages/ResolvedBin.php b/src/Packages/ResolvedBin.php new file mode 100644 index 0000000..1e53457 --- /dev/null +++ b/src/Packages/ResolvedBin.php @@ -0,0 +1,15 @@ +closeAll($stdin, $stdout, $stderr); + return self::COULD_NOT_EXECUTE; } - return proc_close($process); + try { + $process = @proc_open($command, [$stdin, $stdout, $stderr], $pipes); + + if (! is_resource($process)) { + return self::COULD_NOT_EXECUTE; + } + + $exitCode = proc_close($process); + + return $exitCode === -1 ? self::COULD_NOT_EXECUTE : $exitCode; + } finally { + $this->closeAll($stdin, $stdout, $stderr); + } } /** - * @param list $command - * @return array{exitCode: int, stdout: string, stderr: string} + * @param resource|false ...$streams */ - public function capture(array $command): array + private function closeAll(...$streams): void { - $process = @proc_open($command, [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], $pipes); - - if (! is_resource($process)) { - return [ - 'exitCode' => self::COULD_NOT_EXECUTE, - 'stdout' => '', - 'stderr' => 'Failed to start process.', - ]; + foreach ($streams as $stream) { + if (is_resource($stream)) { + fclose($stream); + } } - - fclose($pipes[0]); - - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - - fclose($pipes[1]); - fclose($pipes[2]); - - return [ - 'exitCode' => proc_close($process), - 'stdout' => $stdout === false ? '' : $stdout, - 'stderr' => $stderr === false ? '' : $stderr, - ]; } } diff --git a/src/Support/Filesystem.php b/src/Support/Filesystem.php index 034b865..bffcd77 100644 --- a/src/Support/Filesystem.php +++ b/src/Support/Filesystem.php @@ -7,6 +7,7 @@ use FilesystemIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use RuntimeException; use SplFileInfo; class Filesystem @@ -24,11 +25,16 @@ public static function deleteDirectory(string $directory): void foreach ($files as $file) { /** @var SplFileInfo $file */ - $file->isDir() && ! $file->isLink() - ? rmdir($file->getPathname()) - : unlink($file->getPathname()); + $path = $file->getPathname(); + $removed = $file->isDir() && ! $file->isLink() ? rmdir($path) : unlink($path); + + if (! $removed) { + throw new RuntimeException("Unable to remove {$path}."); + } } - rmdir($directory); + if (! rmdir($directory)) { + throw new RuntimeException("Unable to remove directory {$directory}."); + } } } diff --git a/src/functions.php b/src/functions.php index 614d217..89fccd3 100644 --- a/src/functions.php +++ b/src/functions.php @@ -76,15 +76,13 @@ function composer_require(string ...$packages): void if (! function_exists('cpx_path')) { function cpx_path(string $path = ''): string { - $home = $_SERVER['COMPOSER_HOME'] ?? getenv('COMPOSER_HOME') ?: ($_SERVER['HOME'] ?? __DIR__); + $composerHome = $_SERVER['COMPOSER_HOME'] ?? getenv('COMPOSER_HOME'); - return "{$home}/.cpx/".trim($path, '/'); - } -} + if (! is_string($composerHome) || $composerHome === '') { + $home = $_SERVER['HOME'] ?? null; + $composerHome = is_string($home) && $home !== '' ? $home : __DIR__; + } -if (! function_exists('printColor')) { - function printColor(string $message, string $color = "\033[1;32m"): void - { - echo $color.$message."\033[0m".PHP_EOL; + return "{$composerHome}/.cpx/".trim($path, '/'); } } diff --git a/tests/Unit/ComposerRunnerTest.php b/tests/Unit/ComposerRunnerTest.php index d7fea0c..5e36971 100644 --- a/tests/Unit/ComposerRunnerTest.php +++ b/tests/Unit/ComposerRunnerTest.php @@ -2,36 +2,36 @@ use Cpx\Composer\ComposerRunner; use Cpx\Packages\Package; +use Symfony\Component\Console\Output\NullOutput; test('it passes composer arguments as argv tokens', function () { $binDirectory = $this->temporaryDirectory('cpx-composer-bin'); $workingDirectory = $this->temporaryDirectory('cpx-composer working;dir'); $logFile = $this->temporaryDirectory('cpx-composer-log').'/argv.json'; - writeExecutable($binDirectory.'/composer', "#!/usr/bin/env php\nsetEnvironmentVariable('PATH', $binDirectory.PATH_SEPARATOR.getenv('PATH')); - $output = ComposerRunner::run(['require', 'vendor/package:^1@dev', '--no-progress'], $workingDirectory); + $exitCode = ComposerRunner::run(['require', 'vendor/package:^1@dev', '--no-progress'], $workingDirectory); - expect($output)->toBe(['installed']) + expect($exitCode)->toBe(0) ->and(json_decode((string) file_get_contents($logFile), true))->toBe([ 'require', 'vendor/package:^1@dev', '--no-progress', '--no-interaction', - '--quiet', "--working-dir={$workingDirectory}", ]); }); -test('it includes stderr when composer exits non-zero', function () { +test('it throws when composer exits non-zero', function () { $binDirectory = $this->temporaryDirectory('cpx-composer-bin'); writeExecutable($binDirectory.'/composer', "#!/usr/bin/env php\nsetEnvironmentVariable('PATH', $binDirectory.PATH_SEPARATOR.getenv('PATH')); ComposerRunner::run(['update']); -})->throws(Exception::class, 'composer failed'); +})->throws(Exception::class, 'Composer command failed: update'); test('package installation calls composer with argv arrays', function () { $this->useIsolatedComposerHome(); @@ -42,7 +42,7 @@ writeExecutable($binDirectory.'/composer', "#!/usr/bin/env php\nsetEnvironmentVariable('PATH', $binDirectory.PATH_SEPARATOR.getenv('PATH')); - Package::parse('vendor/package:^1@dev')->installOrUpdatePackage(updateCheck: false); + Package::parse('vendor/package:^1@dev')->installOrUpdatePackage(new NullOutput, updateCheck: false); expect(json_decode((string) file_get_contents($logFile), true))->toContain('vendor/package:^1@dev') ->and(json_decode((string) file_get_contents($logFile), true))->not->toContain('vendor/package:^1@dev --no-interaction'); diff --git a/tests/Unit/ProcessRunnerTest.php b/tests/Unit/ProcessRunnerTest.php index 40f3c5d..23244ec 100644 --- a/tests/Unit/ProcessRunnerTest.php +++ b/tests/Unit/ProcessRunnerTest.php @@ -11,19 +11,6 @@ expect((new ProcessRunner)->run([$binary]))->toBe(37); }); -test('it captures stdout and stderr for commands that need output', function () { - $directory = $this->temporaryDirectory('cpx-process'); - $binary = "{$directory}/capture"; - - writeExecutable($binary, "#!/usr/bin/env php\ncapture([$binary]); - - expect($result['exitCode'])->toBe(0) - ->and($result['stdout'])->toBe('out') - ->and($result['stderr'])->toBe('err'); -}); - test('it delivers shell metacharacters as literal argv tokens', function () { $directory = $this->temporaryDirectory('cpx-process'); $binary = "{$directory}/argv"; From 77d67d444b1f9760bcf7e34179b3e01680c886cc Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Mon, 29 Jun 2026 20:07:47 -0400 Subject: [PATCH 32/35] formatting --- src/Packages/Package.php | 4 +++- src/Packages/PackageAlias.php | 4 +++- src/Packages/ResolvedBin.php | 4 +++- src/Process/ProcessRunner.php | 1 - src/Runtime/ClassAliasAutoloader.php | 4 +++- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Packages/Package.php b/src/Packages/Package.php index c4fa085..1d21fb0 100644 --- a/src/Packages/Package.php +++ b/src/Packages/Package.php @@ -27,7 +27,9 @@ protected function __construct( public string $vendor, public string $name, public ?string $version = null, - ) {} + ) { + // + } public function __toString(): string { diff --git a/src/Packages/PackageAlias.php b/src/Packages/PackageAlias.php index 861ee25..3f86f18 100644 --- a/src/Packages/PackageAlias.php +++ b/src/Packages/PackageAlias.php @@ -11,5 +11,7 @@ public function __construct( public string $description, public string $command, public string $package, - ) {} + ) { + // + } } diff --git a/src/Packages/ResolvedBin.php b/src/Packages/ResolvedBin.php index 1e53457..a66dfc8 100644 --- a/src/Packages/ResolvedBin.php +++ b/src/Packages/ResolvedBin.php @@ -11,5 +11,7 @@ public function __construct( public string $command, public PackageInvocation $invocation, - ) {} + ) { + // + } } diff --git a/src/Process/ProcessRunner.php b/src/Process/ProcessRunner.php index fd20be6..045ddb3 100644 --- a/src/Process/ProcessRunner.php +++ b/src/Process/ProcessRunner.php @@ -13,7 +13,6 @@ class ProcessRunner */ public function run(array $command): int { - $stdin = fopen('php://fd/0', 'r'); $stdout = fopen('php://fd/1', 'w'); $stderr = fopen('php://fd/2', 'w'); diff --git a/src/Runtime/ClassAliasAutoloader.php b/src/Runtime/ClassAliasAutoloader.php index d5201c8..07fc750 100644 --- a/src/Runtime/ClassAliasAutoloader.php +++ b/src/Runtime/ClassAliasAutoloader.php @@ -15,7 +15,9 @@ class ClassAliasAutoloader public function __construct( protected bool $shouldBeVerbose = false, - ) {} + ) { + // + } public function addAliases(string $autoloadRootDirectory): void { From 9aff03be52e31b90cb8a816ceab23c7917b5e08e Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Tue, 30 Jun 2026 11:29:29 +0100 Subject: [PATCH 33/35] Scope the unknown version fallback to a local variable --- src/Composer/ComposerRunner.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Composer/ComposerRunner.php b/src/Composer/ComposerRunner.php index 8acb51a..a33165c 100644 --- a/src/Composer/ComposerRunner.php +++ b/src/Composer/ComposerRunner.php @@ -10,8 +10,6 @@ class ComposerRunner { - public const UNKNOWN_VERSION = 'unknown'; - /** * @param list $arguments */ @@ -58,21 +56,23 @@ public static function detectBinFromComposer(string $directory): array public static function getCurrentVersion(string $directory): string { + $unknown = 'unknown'; + $composerLock = "{$directory}/composer.lock"; if (! file_exists($composerLock)) { - return self::UNKNOWN_VERSION; + return $unknown; } $contents = file_get_contents($composerLock); if ($contents === false) { - return self::UNKNOWN_VERSION; + return $unknown; } $lockData = json_decode($contents, true); $version = is_array($lockData) ? ($lockData['packages'][0]['version'] ?? null) : null; - return is_string($version) ? $version : self::UNKNOWN_VERSION; + return is_string($version) ? $version : $unknown; } } From 7c0ea0cc74b1f6394a3828f3055a7f0218299350 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Tue, 30 Jun 2026 11:34:19 +0100 Subject: [PATCH 34/35] Trim and reject blank package invocation targets --- src/Input/PackageInvocation.php | 2 ++ tests/Unit/PackageInvocationTest.php | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/Input/PackageInvocation.php b/src/Input/PackageInvocation.php index 86d44bc..53b91e8 100644 --- a/src/Input/PackageInvocation.php +++ b/src/Input/PackageInvocation.php @@ -18,6 +18,8 @@ public function __construct( public string $target, private array $forwardedTokens = [], ) { + $this->target = trim($this->target); + if ($this->target === '') { throw new InvalidArgumentException('A package invocation target must be provided.'); } diff --git a/tests/Unit/PackageInvocationTest.php b/tests/Unit/PackageInvocationTest.php index e4be4dc..c24fd06 100644 --- a/tests/Unit/PackageInvocationTest.php +++ b/tests/Unit/PackageInvocationTest.php @@ -61,3 +61,14 @@ test('it rejects empty invocations before package execution', function () { PackageInvocation::fromRawTokens([]); })->throws(InvalidArgumentException::class, 'A package invocation target must be provided.'); + +test('it rejects whitespace-only targets before package execution', function () { + PackageInvocation::fromRawTokens([' ']); +})->throws(InvalidArgumentException::class, 'A package invocation target must be provided.'); + +test('it trims surrounding whitespace from the target', function () { + $invocation = PackageInvocation::fromRawTokens([' vendor/package ', '--flag']); + + expect($invocation->target)->toBe('vendor/package') + ->and($invocation->forwardedTokens())->toBe(['--flag']); +}); From a54b5a2ec9baed0bac7b73e228a2f00c40415e82 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Tue, 30 Jun 2026 11:38:32 +0100 Subject: [PATCH 35/35] Name the package update-check interval --- src/Packages/Package.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Packages/Package.php b/src/Packages/Package.php index 1d21fb0..0f7657b 100644 --- a/src/Packages/Package.php +++ b/src/Packages/Package.php @@ -23,6 +23,8 @@ class Package private const SCAFFOLD_VERSION = '1.0.0'; + private const UPDATE_CHECK_INTERVAL = 60 * 60; + protected function __construct( public string $vendor, public string $name, @@ -143,7 +145,7 @@ public function shouldCheckForUpdates(): bool $lastCheck = strtotime($lastUpdatedAt); - return $lastCheck === false || (time() - $lastCheck) > 3600; + return $lastCheck === false || (time() - $lastCheck) > self::UPDATE_CHECK_INTERVAL; } /**