Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
dd0aa3d
Add package runner contract tests
WendellAdriel Jun 24, 2026
f7a3611
Add Symfony Console application layer
WendellAdriel Jun 25, 2026
a7a92d2
Remove legacy command base
WendellAdriel Jun 25, 2026
46a05e8
Convert aliases command to Symfony
WendellAdriel Jun 25, 2026
1068040
Convert check command to Symfony
WendellAdriel Jun 25, 2026
239ff3a
Convert clean command to Symfony
WendellAdriel Jun 25, 2026
6cd2704
Convert exec command to Symfony
WendellAdriel Jun 25, 2026
d1fa171
Convert format command to Symfony
WendellAdriel Jun 25, 2026
89f1193
Convert help command to Symfony
WendellAdriel Jun 25, 2026
5ca6bb4
Convert list command to Symfony
WendellAdriel Jun 25, 2026
5fab835
Convert package fallback command to Symfony
WendellAdriel Jun 25, 2026
6a665fa
Convert test command to Symfony
WendellAdriel Jun 25, 2026
e9c58c1
Convert tinker command to Symfony
WendellAdriel Jun 25, 2026
284c993
Convert update command to Symfony
WendellAdriel Jun 25, 2026
15e6faf
Convert upgrade command to Symfony
WendellAdriel Jun 25, 2026
d34436a
Convert version command to Symfony
WendellAdriel Jun 25, 2026
b64a5f1
Preserve exec options for file fallback
WendellAdriel Jun 25, 2026
9ea1c74
Fix Symfony console compatibility
WendellAdriel Jun 26, 2026
06ccdc5
Remove the check, format, test, help, and version commands
WendellAdriel Jun 29, 2026
09d9305
Use negatable boolean options for exec autoloader flags
WendellAdriel Jun 29, 2026
ba61217
Explain why the package fallback ignores validation errors
WendellAdriel Jun 29, 2026
8327058
Simplify the update target to a string cast
WendellAdriel Jun 29, 2026
98cfae8
Document what the package command runner does
WendellAdriel Jun 29, 2026
8ed70f4
Add safe argv-based process runner
WendellAdriel Jun 26, 2026
d135e23
Run Composer through argv arrays
WendellAdriel Jun 26, 2026
36150f3
Replace Console parser with PackageInvocation DTO
WendellAdriel Jun 26, 2026
e848172
Extract focused Support helpers
WendellAdriel Jun 26, 2026
d0cd656
Group cache and runtime classes into focused namespaces
WendellAdriel Jun 26, 2026
08aa573
Run package binaries safely under Cpx\Packages
WendellAdriel Jun 26, 2026
27dca09
Route commands through the new execution boundaries
WendellAdriel Jun 26, 2026
abcdffe
Harden the package runner and stream execution output
WendellAdriel Jun 26, 2026
77d67d4
formatting
joetannenbaum Jun 30, 2026
9aff03b
Scope the unknown version fallback to a local variable
WendellAdriel Jun 30, 2026
7c0ea0c
Trim and reject blank package invocation targets
WendellAdriel Jun 30, 2026
a54b5a2
Name the package update-check interval
WendellAdriel Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 1 addition & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <command>` shows help for a specific command.

## FAQ:

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
}
},
"require": {
"php": "^8.3"
"php": "^8.3",
"symfony/console": "^7.4|^8.0"
},
"require-dev": {
"laravel/pao": "^1.1",
Expand Down
52 changes: 2 additions & 50 deletions cpx
Original file line number Diff line number Diff line change
@@ -1,22 +1,7 @@
#! /usr/bin/env php
<?php

use Cpx\Console;
use Cpx\Package;
use Cpx\PackageAliases;
use Cpx\Commands\Command;
use Cpx\Commands\ExecCommand;
use Cpx\Commands\HelpCommand;
use Cpx\Commands\ListCommand;
use Cpx\Commands\TestCommand;
use Cpx\Commands\CheckCommand;
use Cpx\Commands\CleanCommand;
use Cpx\Commands\FormatCommand;
use Cpx\Commands\TinkerCommand;
use Cpx\Commands\UpdateCommand;
use Cpx\Commands\AliasesCommand;
use Cpx\Commands\UpgradeCommand;
use Cpx\Commands\VersionCommand;
use Cpx\Application;

if (isset($GLOBALS['_composer_autoload_path'])) {
require_once $GLOBALS['_composer_autoload_path'];
Expand All @@ -34,37 +19,4 @@ if (isset($GLOBALS['_composer_autoload_path'])) {
unset($file);
}

array_shift($argv);
$console = Console::parse($argv ?? []);

$command = match (true) {
$console->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());
2 changes: 1 addition & 1 deletion files/psysh-config.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

use Cpx\PhpExecutionHelper;
use Cpx\Runtime\PhpExecutionHelper;

require_once __DIR__.'/../vendor/autoload.php';

Expand Down
19 changes: 19 additions & 0 deletions pint.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@
"global_namespace_import": {
"import_classes": true
},
"ordered_class_elements": {
"order": [
"use_trait",
"case",
"constant_public",
"constant_protected",
"constant_private",
"property_public",
"property_protected",
"property_private",
"construct",
"destruct",
"magic",
"method_public",
"method_protected",
"method_private"
],
"sort_algorithm": "none"
},
"trailing_comma_in_multiline": {
"elements": [
"arguments",
Expand Down
88 changes: 88 additions & 0 deletions src/Application.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace Cpx;

use Cpx\Commands\AliasesCommand;
use Cpx\Commands\CleanCommand;
use Cpx\Commands\ExecCommand;
use Cpx\Commands\ListCommand;
use Cpx\Commands\RunPackageCommand;
use Cpx\Commands\TinkerCommand;
use Cpx\Commands\UpdateCommand;
use Cpx\Commands\UpgradeCommand;
use Cpx\Packages\PackageCommandRunner;
use Symfony\Component\Console\Application as SymfonyApplication;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class Application extends SymfonyApplication
{
public function __construct(?PackageCommandRunner $packageCommandRunner = null)
{
parent::__construct('cpx', $this->resolveVersion());
$this->setAutoExit(false);

$this->registerCommands($packageCommandRunner ?? new PackageCommandRunner);
}

public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
{
$input ??= new ArgvInput;

if ($input instanceof ArgvInput && $this->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);
}
}
113 changes: 113 additions & 0 deletions src/Cache/Metadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace Cpx\Cache;

use Cpx\Packages\Package;
use Cpx\Support\Arr;

class Metadata
{
private const FILE = '.cpx_metadata.json';

/**
* @param array<string, PackageMetadata> $packages
* @param array<string, array{packages?: list<string>, 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<string, array{last_updated: string|null, last_run: string|null}>,
* execCache: array<string, array{packages?: list<string>, 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);
}
}
6 changes: 4 additions & 2 deletions src/PackageMetadata.php → src/Cache/PackageMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}
Expand Down
Loading