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/composer.json b/composer.json index 3c58859..096d56b 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ } }, "require": { - "php": "^8.3" + "php": "^8.3", + "symfony/console": "^7.4|^8.0" }, "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/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 @@ 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->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; + } + + private function registerCommands(PackageCommandRunner $packageCommandRunner): void + { + $this->addCommands([ + new ListCommand, + new AliasesCommand, + new CleanCommand, + new UpdateCommand, + new UpgradeCommand, + new ExecCommand, + new TinkerCommand, + new RunPackageCommand($packageCommandRunner), + ]); + } + + private function resolveVersion(): string + { + $contents = file_get_contents(__DIR__.'/../composer.json'); + + if ($contents === false) { + return 'unknown'; + } + + $decoded = json_decode($contents, true); + + return is_array($decoded) && is_string($decoded['version'] ?? null) + ? $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/Cache/Metadata.php b/src/Cache/Metadata.php new file mode 100644 index 0000000..d9028df --- /dev/null +++ b/src/Cache/Metadata.php @@ -0,0 +1,113 @@ + $packages + * @param array, last_updated?: int, last_run?: int}> $execCache + */ + protected function __construct( + public array $packages = [], + public array $execCache = [], + ) {} + + public static function open(): self + { + $metadataFile = cpx_path(self::FILE); + + 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 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 recordRun(Package $package): self + { + $this->forPackage($package)->lastRunAt = date('Y-m-d H:i:s'); + + return $this; + } + + public function recordUpdate(Package $package): self + { + $this->forPackage($package)->lastUpdatedAt = date('Y-m-d H:i:s'); + + return $this; + } + + public function save(): void + { + $metadataFile = cpx_path(self::FILE); + + if (! is_dir(dirname($metadataFile))) { + mkdir(dirname($metadataFile), 0755, true); + } + + file_put_contents($metadataFile, json_encode($this->toArray(), JSON_PRETTY_PRINT)); + } + + public function hasPackage(string|Package $package): bool + { + if ($package instanceof Package) { + $package = $package->fullPackageString(); + } + + return array_key_exists($package, $this->packages); + } + + /** + * @return array{ + * packages: array, + * execCache: array, last_updated?: int, last_run?: int}> + * } + */ + public function toArray(): array + { + return [ + 'packages' => Arr::mapWithKeys( + fn (string $key, PackageMetadata $packageMetadata): array => [ + $packageMetadata->package->fullPackageString() => [ + 'last_updated' => $packageMetadata->lastUpdatedAt, + 'last_run' => $packageMetadata->lastRunAt, + ], + ], + $this->packages, + ), + '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/Commands/AliasesCommand.php b/src/Commands/AliasesCommand.php index 7c51391..9089ab0 100644 --- a/src/Commands/AliasesCommand.php +++ b/src/Commands/AliasesCommand.php @@ -4,19 +4,30 @@ 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; +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); - $packages = PackageAliases::$packages; - usort($packages, fn (array $a, array $b): int => strcmp($a['command'], $b['command'])); + $output->writeln('Aliased packages:'.PHP_EOL); + $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); - $this->line(' '.Command::COLOR_GREEN.'cpx '.$paddedCommand.Command::COLOR_RESET.' '.$package['description']); + $paddedCommand = str_pad($package->command, 15); + $output->writeln(' cpx '.$paddedCommand.' '.$package->description); } + + return self::SUCCESS; } } diff --git a/src/Commands/CheckCommand.php b/src/Commands/CheckCommand.php deleted file mode 100644 index 348cb75..0000000 --- a/src/Commands/CheckCommand.php +++ /dev/null @@ -1,40 +0,0 @@ -exec(); - - return; - } - - if (file_exists('vendor/bin/psalm')) { - $command = 'vendor/bin/psalm'; - - Console::parse($command)->exec(); - - return; - } - - if (file_exists('vendor/bin/phan')) { - $command = 'vendor/bin/phan'; - - Console::parse($command)->exec(); - - return; - } - - throw new ConsoleException('No static analyzers found in the project.'); - } -} diff --git a/src/Commands/CleanCommand.php b/src/Commands/CleanCommand.php index a088c21..370e1ce 100644 --- a/src/Commands/CleanCommand.php +++ b/src/Commands/CleanCommand.php @@ -4,15 +4,30 @@ 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; +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}..."); + Filesystem::deleteDirectory($packageDirectory); + $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; } } 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/Commands/ExecCommand.php b/src/Commands/ExecCommand.php index 0da7209..c31ac47 100644 --- a/src/Commands/ExecCommand.php +++ b/src/Commands/ExecCommand.php @@ -4,21 +4,65 @@ 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; +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; + private string $path; + + 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_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); + } - public function __invoke(): void + 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); + } + } + } + + 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) { + $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'); - return; + if (! is_string($file) || $file === '') { + $output->writeln('Please supply the path to a file to execute.'); + + 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(); - } - - protected function autoload(string $directory): 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); - - PhpExecutionHelper::init($directory, $shouldFindAutoloader, $shouldLoadLaravelBootstrap, $shouldAliasClasses, $shouldBeVerbose); - } - - protected function booleanOption(string $option, bool $default): bool - { - $value = $this->console->getOption($option); - - if ($value === null) { - return $default; - } - return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? $default; + return self::SUCCESS; } - public function runFile(): void + private function runFile(): void { require $this->path; } diff --git a/src/Commands/FormatCommand.php b/src/Commands/FormatCommand.php deleted file mode 100644 index c92b6f4..0000000 --- a/src/Commands/FormatCommand.php +++ /dev/null @@ -1,52 +0,0 @@ -console->arguments[0] ?? '.'; - - if (file_exists('vendor/bin/pint')) { - $command = "vendor/bin/pint {$directory}"; - - if ($this->console->hasOption('dry-run')) { - $command .= ' --test'; - } - - Console::parse($command)->exec(); - - return; - } - - if (file_exists('vendor/bin/php-cs-fixer')) { - $command = "vendor/bin/php-cs-fixer fix {$directory}"; - - if ($this->console->hasOption('dry-run')) { - $command .= ' --dry-run'; - } - - $command .= ' --allow-risky=yes'; - - Console::parse($command)->exec(); - - return; - } - - if (file_exists('vendor/bin/phpcbf')) { - $command = "vendor/bin/phpcbf {$directory}"; - - Console::parse($command)->exec(); - - return; - } - - 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 0851125..0000000 --- a/src/Commands/HelpCommand.php +++ /dev/null @@ -1,32 +0,0 @@ -error("Unrecognised command {$this->console->command}"); - } - - $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'); - } -} diff --git a/src/Commands/ListCommand.php b/src/Commands/ListCommand.php index afab44b..29a474d 100644 --- a/src/Commands/ListCommand.php +++ b/src/Commands/ListCommand.php @@ -4,24 +4,34 @@ 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; +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; } } diff --git a/src/Commands/RunPackageCommand.php b/src/Commands/RunPackageCommand.php index 6aabfb7..6b30c63 100644 --- a/src/Commands/RunPackageCommand.php +++ b/src/Commands/RunPackageCommand.php @@ -4,7 +4,54 @@ namespace Cpx\Commands; -class RunPackageCommand extends Command +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; +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 + { + // Let undeclared package args/options pass through instead of failing validation; + // execute() reads the raw tokens and forwards them to the package. + $this->ignoreValidationErrors(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $tokens = $input instanceof ArgvInput ? $input->getRawTokens() : []; + + if (($tokens[0] ?? null) === self::NAME) { + array_shift($tokens); + } + + if (($tokens[0] ?? null) === '--') { + array_shift($tokens); + } + + try { + return $this->packageCommandRunner->run(PackageInvocation::fromRawTokens($tokens), $output); + } catch (InvalidArgumentException $e) { + $output->writeln("{$e->getMessage()}"); + + return SymfonyCommand::FAILURE; + } + } } diff --git a/src/Commands/TestCommand.php b/src/Commands/TestCommand.php deleted file mode 100644 index 14a0d1a..0000000 --- a/src/Commands/TestCommand.php +++ /dev/null @@ -1,40 +0,0 @@ -exec(); - - return; - } - - if (file_exists('bin/phpunit')) { - Console::parse('bin/phpunit')->exec(); - - return; - } - - if (file_exists('vendor/bin/phpunit')) { - Console::parse('vendor/bin/phpunit')->exec(); - - return; - } - - if (file_exists('vendor/bin/codecept')) { - Console::parse('vendor/bin/codecept')->exec(); - - return; - } - - throw new ConsoleException('No test runner found in the project.'); - } -} diff --git a/src/Commands/TinkerCommand.php b/src/Commands/TinkerCommand.php index d140be8..ab10cdf 100644 --- a/src/Commands/TinkerCommand.php +++ b/src/Commands/TinkerCommand.php @@ -4,21 +4,32 @@ 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; +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}")); + return Package::parse('psy/psysh')->runCommand( + PackageInvocation::fromRawTokens(['psysh', '--config', $psyshConfig]), + $output, + ); } } diff --git a/src/Commands/UpdateCommand.php b/src/Commands/UpdateCommand.php index e4af241..af5bdbb 100644 --- a/src/Commands/UpdateCommand.php +++ b/src/Commands/UpdateCommand.php @@ -4,50 +4,68 @@ 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; +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 = (string) $input->getArgument('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 +73,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)); - Composer::runCommand('update', $directory); + $output->writeln('Updating '.str_replace(cpx_path(), '', $directory).''); + ComposerRunner::run(['update'], $directory); } } diff --git a/src/Commands/UpgradeCommand.php b/src/Commands/UpgradeCommand.php index 201465b..abd81dc 100644 --- a/src/Commands/UpgradeCommand.php +++ b/src/Commands/UpgradeCommand.php @@ -4,13 +4,23 @@ 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; +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'); - Composer::runCommand('global update cpx/cpx'); + $output->writeln('Updating cpx'); + ComposerRunner::run(['global', 'update', 'cpx/cpx']); + + return self::SUCCESS; } } diff --git a/src/Commands/VersionCommand.php b/src/Commands/VersionCommand.php deleted file mode 100644 index f389412..0000000 --- a/src/Commands/VersionCommand.php +++ /dev/null @@ -1,27 +0,0 @@ -line('cpx version: '.Command::COLOR_GREEN.$cpxVersion); - $this->line('php version: '.Command::COLOR_GREEN.PHP_VERSION); - } -} 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..a33165c --- /dev/null +++ b/src/Composer/ComposerRunner.php @@ -0,0 +1,78 @@ + $arguments + */ + public static function run(array $arguments, ?string $directory = null): int + { + $command = ['composer', ...$arguments, '--no-interaction']; + + if ($directory !== null) { + $command[] = "--working-dir={$directory}"; + } + + $exitCode = (new ProcessRunner)->run($command); + + if ($exitCode !== Command::SUCCESS) { + throw new Exception('Composer command failed: '.implode(' ', $arguments)); + } + + return $exitCode; + } + + /** @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 + { + $unknown = 'unknown'; + + $composerLock = "{$directory}/composer.lock"; + + if (! file_exists($composerLock)) { + return $unknown; + } + + $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; + } +} diff --git a/src/Console.php b/src/Console.php deleted file mode 100644 index 0cf8ab4..0000000 --- a/src/Console.php +++ /dev/null @@ -1,196 +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); - } - - public function exec(bool $verbose = false): void - { - $descriptors = [ - 0 => STDIN, - 1 => STDOUT, - 2 => STDERR, - ]; - - if ($verbose) { - echo Command::BACKGROUND_CYAN." Running command: '{$this}' ".Command::COLOR_RESET; - } - - $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 @@ ->|null */ + private ?array $parsedOptions = null; + + /** + * @param list $forwardedTokens + */ + 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.'); + } + } + + /** + * @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()); + } + + /** + * 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; + + if (! is_array($value)) { + return $value; + } + + foreach ($value as $optionValue) { + if ($optionValue !== null) { + return $optionValue; + } + } + + return null; + } + + /** @return array> */ + private function options(): array + { + if ($this->parsedOptions !== null) { + return $this->parsedOptions; + } + + $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 $this->parsedOptions = $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/src/Metadata.php b/src/Metadata.php deleted file mode 100644 index 25a2382..0000000 --- a/src/Metadata.php +++ /dev/null @@ -1,102 +0,0 @@ - $packages - * @param array, last_updated?: int, last_run?: int}> $execCache - */ - protected function __construct( - public array $packages = [], - public array $execCache = [], - ) {} - - public static function open(): Metadata - { - $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'] : [], - ); - } - - return new Metadata; - } - - public function updateLastCheckTime(Package $package, string $type = 'run'): Metadata - { - $packageKey = $package->fullPackageString(); - $currentTime = date('Y-m-d H:i:s'); - - if (! isset($this->packages[$packageKey])) { - $this->packages[$packageKey] = new PackageMetadata($package); - } - - if ($type === 'run') { - $this->packages[$packageKey]->lastRunAt = $currentTime; - } else { - $this->packages[$packageKey]->lastUpdatedAt = $currentTime; - } - - return $this; - } - - public function save(): void - { - $metadataFile = cpx_path('.cpx_metadata.json'); - file_put_contents($metadataFile, json_encode($this->toArray(), JSON_PRETTY_PRINT)); - } - - public function hasPackage(string|Package $package): bool - { - if ($package instanceof Package) { - $package = $package->fullPackageString(); - } - - return array_key_exists($package, $this->packages); - } - - /** - * @return array{ - * packages: array, - * execCache: array, last_updated?: int, last_run?: int}> - * } - */ - public function toArray(): array - { - return [ - 'packages' => Utils::arrayMapAssoc( - fn (string $key, PackageMetadata $packageMetadata): array => [ - $packageMetadata->package->fullPackageString() => [ - 'last_updated' => $packageMetadata->lastUpdatedAt, - 'last_run' => $packageMetadata->lastRunAt, - ], - ], - $this->packages, - ), - 'execCache' => $this->execCache, - ]; - } -} diff --git a/src/Package.php b/src/Package.php deleted file mode 100644 index 6b3c9bb..0000000 --- a/src/Package.php +++ /dev/null @@ -1,205 +0,0 @@ -/'); - } - - $parts = explode(':', str_replace('@', ':', $str)); - [$vendor, $name] = explode('/', $parts[0]); - $version = $parts[1] ?? null; - - if ($version === '') { - $version = null; - } - - return new Package($vendor, $name, $version); - } - - 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/Packages/Package.php b/src/Packages/Package.php new file mode 100644 index 0000000..0f7657b --- /dev/null +++ b/src/Packages/Package.php @@ -0,0 +1,224 @@ +[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'; + + private const UPDATE_CHECK_INTERVAL = 60 * 60; + + protected function __construct( + public string $vendor, + public string $name, + public ?string $version = null, + ) { + // + } + + public function __toString(): string + { + return $this->fullPackageString(); + } + + public static function parse(string $str): self + { + if (empty($str)) { + throw new InvalidArgumentException('A package name must be provided.'); + } + + if (preg_match(self::PACKAGE_PATTERN, $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, OutputInterface $output, bool $autoUpdate = true): int + { + $installDir = $this->installOrUpdatePackage($output, $autoUpdate); + $packageDir = "{$installDir}/vendor/{$this->vendor}/{$this->name}"; + $binScripts = ComposerRunner::detectBinFromComposer($packageDir); + + if (empty($binScripts)) { + $output->writeln("No bin command found in {$this}."); + + return Command::FAILURE; + } + + $binScripts = Arr::mapWithKeys(fn (int $_, string $value): array => [basename($value) => $value], $binScripts); + $resolved = $this->resolveBinCommand($binScripts, $invocation); + + if ($resolved === null) { + $output->writeln("More than 1 bin command found for {$this}: ".implode(', ', array_keys($binScripts)).'.'); + + return Command::FAILURE; + } + + $binPath = "{$packageDir}/{$resolved->command}"; + + if (! file_exists($binPath)) { + $output->writeln('Command '.basename($resolved->command)." not found in {$this}."); + + return Command::FAILURE; + } + + Metadata::open()->recordRun($this)->save(); + $output->writeln('Running '.basename($resolved->command)." from {$this}"); + + return (new ProcessRunner)->run([$binPath, ...$resolved->invocation->forwardedTokens()]); + } + + public function installOrUpdatePackage(OutputInterface $output, 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($output, $installDir), + $updateCheck && $this->shouldCheckForUpdates() => $this->updatePackage($output, $installDir), + default => $output->writeln("{$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) > self::UPDATE_CHECK_INTERVAL; + } + + /** + * @param array $binScripts + */ + private function resolveBinCommand(array $binScripts, PackageInvocation $invocation): ?ResolvedBin + { + if (count($binScripts) === 1) { + return new ResolvedBin($binScripts[array_key_first($binScripts)], $invocation); + } + + $candidates = array_values(array_unique(array_filter([ + $invocation->target, + $invocation->firstForwardedToken(), + $this->name, + ]))); + + foreach ($candidates as $candidate) { + $command = $this->matchBin($binScripts, $candidate); + + if ($command === null) { + continue; + } + + return $invocation->firstForwardedToken() === $candidate + ? new ResolvedBin($command, $invocation->withoutFirstForwardedToken()) + : new ResolvedBin($command, $invocation); + } + + return null; + } + + /** + * @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 + { + $output->writeln("Installing {$this}..."); + file_put_contents("{$installDir}/composer.json", json_encode([ + 'name' => "cpx-{$this->vendor}/cpx-{$this->name}", + 'version' => self::SCAFFOLD_VERSION, + 'config' => [ + // Requested packages may ship Composer plugins (binaries, installers). + 'allow-plugins' => true, + ], + ])); + + ComposerRunner::run(['require', $this->fullPackageString()], $installDir); + Metadata::open()->recordUpdate($this)->save(); + } + + private function updatePackage(OutputInterface $output, string $installDir): void + { + $output->writeln("Checking for updates for {$this}..."); + $previousVersion = ComposerRunner::getCurrentVersion($installDir); + ComposerRunner::run(['update'], $installDir); + $newVersion = ComposerRunner::getCurrentVersion($installDir); + + if ($previousVersion !== $newVersion) { + $output->writeln("{$this} was upgraded from {$previousVersion} to {$newVersion}."); + } else { + $output->writeln("{$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..3f86f18 --- /dev/null +++ b/src/Packages/PackageAlias.php @@ -0,0 +1,17 @@ +|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..78ba25a --- /dev/null +++ b/src/Packages/PackageCommandRunner.php @@ -0,0 +1,66 @@ +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, $output); + } + + if (str_contains($invocation->target, '/')) { + try { + return Package::parse($invocation->target)->runCommand($invocation, $output); + } catch (InvalidArgumentException) { + $output->writeln("Unrecognised command {$invocation->target}"); + + return SymfonyCommand::FAILURE; + } + } + + $output->writeln("Unrecognised command {$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/src/Packages/ResolvedBin.php b/src/Packages/ResolvedBin.php new file mode 100644 index 0000000..a66dfc8 --- /dev/null +++ b/src/Packages/ResolvedBin.php @@ -0,0 +1,17 @@ + $command + */ + public function run(array $command): int + { + $stdin = fopen('php://fd/0', 'r'); + $stdout = fopen('php://fd/1', 'w'); + $stderr = fopen('php://fd/2', 'w'); + + if ($stdin === false || $stdout === false || $stderr === false) { + $this->closeAll($stdin, $stdout, $stderr); + + return self::COULD_NOT_EXECUTE; + } + + 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 resource|false ...$streams + */ + private function closeAll(...$streams): void + { + foreach ($streams as $stream) { + if (is_resource($stream)) { + fclose($stream); + } + } + } +} diff --git a/src/Runtime/ClassAliasAutoloader.php b/src/Runtime/ClassAliasAutoloader.php new file mode 100644 index 0000000..07fc750 --- /dev/null +++ b/src/Runtime/ClassAliasAutoloader.php @@ -0,0 +1,100 @@ + */ + 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/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..bffcd77 --- /dev/null +++ b/src/Support/Filesystem.php @@ -0,0 +1,40 @@ +getPathname(); + $removed = $file->isDir() && ! $file->isLink() ? rmdir($path) : unlink($path); + + if (! $removed) { + throw new RuntimeException("Unable to remove {$path}."); + } + } + + if (! rmdir($directory)) { + throw new RuntimeException("Unable to remove directory {$directory}."); + } + } +} diff --git a/src/Utils.php b/src/Utils.php deleted file mode 100644 index 218ae15..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 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); - } -} diff --git a/src/functions.php b/src/functions.php index 215caf1..89fccd3 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. @@ -76,15 +76,13 @@ function composer_require(string ...$packages): void if (! function_exists('cpx_path')) { function cpx_path(string $path = ''): string { - $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/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/Feature/BinaryResolutionTest.php b/tests/Feature/BinaryResolutionTest.php new file mode 100644 index 0000000..62d7d55 --- /dev/null +++ b/tests/Feature/BinaryResolutionTest.php @@ -0,0 +1,44 @@ +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(['--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/CleanCommandTest.php b/tests/Feature/CleanCommandTest.php new file mode 100644 index 0000000..a824ba0 --- /dev/null +++ b/tests/Feature/CleanCommandTest.php @@ -0,0 +1,76 @@ +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)); + + (new Application)->run(new ArgvInput(['cpx', 'clean', '--all']), new BufferedOutput); + + 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)); + + (new Application)->run(new ArgvInput(['cpx', 'clean']), new BufferedOutput); + + 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..8a9d3a9 --- /dev/null +++ b/tests/Feature/CliArgumentForwardingTest.php @@ -0,0 +1,67 @@ +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([ + '--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 new file mode 100644 index 0000000..658a278 --- /dev/null +++ b/tests/Feature/CommandBehaviorTest.php @@ -0,0 +1,241 @@ +run(['command' => 'help']); + + expect($status)->toBe(0) + ->and($tester->getDisplay())->toContain('Usage:'); +}); + +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 run the default list command', function () { + $this->useIsolatedComposerHome(); + + [$status, $output] = runCpxCommand([]); + + expect($status)->toBe(0) + ->and($output)->toContain('There are no installed packages.'); +}); + +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('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('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('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 ?PackageInvocation $invocation = null; + + public function run(PackageInvocation $invocation, OutputInterface $output): int + { + $this->invocation = $invocation; + + return 0; + } + }; + $application = new Application($runner); + + $status = $application->run(new ArgvInput(['cpx', 'vendor/package', '--flag', 'value']), new BufferedOutput); + + expect($status)->toBe(0) + ->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 ?PackageInvocation $invocation = null; + + public function run(PackageInvocation $invocation, OutputInterface $output): int + { + $this->invocation = $invocation; + + 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->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 () { + [$status, $output] = runCpxCommand(['not-a-package']); + + expect($status)->toBe(1) + ->and($output)->toContain('Unrecognised command not-a-package'); +}); 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..cdf00c0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,56 @@ extend(TestCase::class)->in('Feature'); +pest()->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; +} diff --git a/tests/TestCase.php b/tests/TestCase.php index cfb05b6..45d2531 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,8 +3,109 @@ 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 = []; + + 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); + } + + 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; + } + + protected function useWorkingDirectory(string $directory): void + { + $this->workingDirectory ??= getcwd() ?: null; + + chdir($directory); + } + + 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/ComposerRunnerTest.php b/tests/Unit/ComposerRunnerTest.php new file mode 100644 index 0000000..5e36971 --- /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')); + + $exitCode = ComposerRunner::run(['require', 'vendor/package:^1@dev', '--no-progress'], $workingDirectory); + + expect($exitCode)->toBe(0) + ->and(json_decode((string) file_get_contents($logFile), true))->toBe([ + 'require', + 'vendor/package:^1@dev', + '--no-progress', + '--no-interaction', + "--working-dir={$workingDirectory}", + ]); +}); + +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 command failed: update'); + +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(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/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..2967717 --- /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() + ->recordUpdate(Package::parse('laravel/pint')) + ->recordRun(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..620f087 --- /dev/null +++ b/tests/Unit/PackageAliasesTest.php @@ -0,0 +1,32 @@ +toBeTrue() + ->and(PackageAliases::all()[$alias]->package)->toBe($package) + ->and(PackageAliases::all()[$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::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('/'); + } +}); + +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/PackageInvocationTest.php b/tests/Unit/PackageInvocationTest.php new file mode 100644 index 0000000..c24fd06 --- /dev/null +++ b/tests/Unit/PackageInvocationTest.php @@ -0,0 +1,74 @@ +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.'); + +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']); +}); diff --git a/tests/Unit/PackageTest.php b/tests/Unit/PackageTest.php new file mode 100644 index 0000000..7c17c00 --- /dev/null +++ b/tests/Unit/PackageTest.php @@ -0,0 +1,59 @@ +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:', + 'laravel/pint:.', + '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/ProcessRunnerTest.php b/tests/Unit/ProcessRunnerTest.php new file mode 100644 index 0000000..23244ec --- /dev/null +++ b/tests/Unit/ProcessRunnerTest.php @@ -0,0 +1,37 @@ +temporaryDirectory('cpx-process'); + $binary = "{$directory}/exit-code"; + + writeExecutable($binary, "#!/usr/bin/env php\nrun([$binary]))->toBe(37); +}); + +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); +}); 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(); +});