Skip to content

Commit e8670fc

Browse files
Rebuild process execution safely (#19)
* Add package runner contract tests * Add Symfony Console application layer * Remove legacy command base * Convert aliases command to Symfony * Convert check command to Symfony * Convert clean command to Symfony * Convert exec command to Symfony * Convert format command to Symfony * Convert help command to Symfony * Convert list command to Symfony * Convert package fallback command to Symfony * Convert test command to Symfony * Convert tinker command to Symfony * Convert update command to Symfony * Convert upgrade command to Symfony * Convert version command to Symfony * Preserve exec options for file fallback * Fix Symfony console compatibility * Remove the check, format, test, help, and version commands Help and version are provided by Symfony Console out of the box, so the custom commands are gone. The check, format, and test commands are removed too, along with the now-dead Console::exec() and ConsoleException they relied on. * Use negatable boolean options for exec autoloader flags The find-autoloader, load-laravel-bootstrap, and alias-classes options are now VALUE_NEGATABLE with real boolean defaults, so the command reads true booleans and the default lives in one place. The file fallback coerces its parsed value before handing it to Symfony, keeping `--find-autoloader=false` working. * Explain why the package fallback ignores validation errors * Simplify the update target to a string cast * Document what the package command runner does * Add safe argv-based process runner Introduce Cpx\Process\ProcessRunner as the single proc_open() boundary, executing argv arrays with inherited stdio or captured output and returning child exit codes. Add an architecture test that forbids proc_open() outside the runner and bans shell-exec helpers. * Run Composer through argv arrays Replace the shell-string Cpx\Composer wrapper with Cpx\Composer\ComposerRunner, which builds composer invocations as argv tokens (including --working-dir) and surfaces stderr on failure. * Replace Console parser with PackageInvocation DTO Drop the legacy Cpx\Console string parser/executor in favor of an immutable Cpx\Input\PackageInvocation built from raw argv tokens, preserving --, repeated options, and shell metacharacters as literal tokens. * Extract focused Support helpers Replace the generic Utils class with Cpx\Support\Arr and Cpx\Support\Filesystem for the array mapping and recursive directory deletion that are still needed. * Group cache and runtime classes into focused namespaces Move Metadata and PackageMetadata under Cpx\Cache and the PHP execution helpers under Cpx\Runtime. Replace Metadata's string mode flag with intent-named recordRun() and recordUpdate() methods. * Run package binaries safely under Cpx\Packages Move Package, PackageAlias, PackageAliases, and PackageCommandRunner into Cpx\Packages and execute resolved binaries as argv arrays that return child exit codes. Reject path-traversal version constraints during parsing. * Route commands through the new execution boundaries Update the Symfony commands, application wiring, and sandbox bootstrap to build PackageInvocations, call ProcessRunner and ComposerRunner with argv arrays, and propagate child process exit codes. * Harden the package runner and stream execution output - Run subprocesses over reopened stdio so exit codes survive any test harness - Stream Composer output live instead of buffering and discarding it - Thread console output through Package and drop the printColor buffering - Validate package targets against Composer's official name grammar * formatting * Scope the unknown version fallback to a local variable * Trim and reject blank package invocation targets * Name the package update-check interval --------- Co-authored-by: Joe Tannenbaum <joe.tannenbaum@laravel.com>
1 parent 3bf372a commit e8670fc

61 files changed

Lines changed: 2483 additions & 1488 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,24 +37,6 @@ Behind the scenes, cpx will install the package into a separate directory and ru
3737

3838
---
3939

40-
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`.
41-
42-
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.
43-
44-
### cpx check
45-
46-
`cpx check` (or the aliases `cpx analyze` or `cpx analyse`) runs a static analysis tool on your codebase (e.g. PHPStan, Psalm or Phan).
47-
48-
### cpx format
49-
50-
`cpx format` (or the alias `cpx fmt`) Runs a code formatting tool on your codebase (e.g. PHP-CS-Fixer, Pint or PHP_CodeSniffer).
51-
52-
You can pass the `--dry-run` flag to see what changes would be made without actually making them.
53-
54-
### cpx test
55-
56-
`cpx test` runs a testing framework on your codebase (e.g. Pest, PHPUnit or Codeception).
57-
5840
### cpx aliases
5941

6042
`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:
9476

9577
### cpx help
9678

97-
`cpx help` will show a list of all the commands available in cpx.
79+
`cpx help` shows usage information, and `cpx help <command>` shows help for a specific command.
9880

9981
## FAQ:
10082

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
}
3535
},
3636
"require": {
37-
"php": "^8.3"
37+
"php": "^8.3",
38+
"symfony/console": "^7.4|^8.0"
3839
},
3940
"require-dev": {
4041
"laravel/pao": "^1.1",

cpx

Lines changed: 2 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,7 @@
11
#! /usr/bin/env php
22
<?php
33

4-
use Cpx\Console;
5-
use Cpx\Package;
6-
use Cpx\PackageAliases;
7-
use Cpx\Commands\Command;
8-
use Cpx\Commands\ExecCommand;
9-
use Cpx\Commands\HelpCommand;
10-
use Cpx\Commands\ListCommand;
11-
use Cpx\Commands\TestCommand;
12-
use Cpx\Commands\CheckCommand;
13-
use Cpx\Commands\CleanCommand;
14-
use Cpx\Commands\FormatCommand;
15-
use Cpx\Commands\TinkerCommand;
16-
use Cpx\Commands\UpdateCommand;
17-
use Cpx\Commands\AliasesCommand;
18-
use Cpx\Commands\UpgradeCommand;
19-
use Cpx\Commands\VersionCommand;
4+
use Cpx\Application;
205

216
if (isset($GLOBALS['_composer_autoload_path'])) {
227
require_once $GLOBALS['_composer_autoload_path'];
@@ -34,37 +19,4 @@ if (isset($GLOBALS['_composer_autoload_path'])) {
3419
unset($file);
3520
}
3621

37-
array_shift($argv);
38-
$console = Console::parse($argv ?? []);
39-
40-
$command = match (true) {
41-
$console->command === 'list' => ListCommand::class,
42-
$console->command === 'help' => HelpCommand::class,
43-
$console->command === 'clean' => CleanCommand::class,
44-
$console->command === 'aliases' => AliasesCommand::class,
45-
$console->command === 'update' => UpdateCommand::class,
46-
$console->command === 'upgrade' => UpgradeCommand::class,
47-
$console->command === 'exec' => ExecCommand::class,
48-
$console->command === 'format' || $console->command === 'fmt' => FormatCommand::class,
49-
$console->command === 'check' || $console->command === 'analyze' || $console->command === 'analyse' => CheckCommand::class,
50-
$console->command === 'test' => TestCommand::class,
51-
$console->command === 'tinker' => TinkerCommand::class,
52-
$console->command === 'version' => VersionCommand::class,
53-
file_exists(realpath($console->command)) && !is_dir(realpath($console->command)) => (new ExecCommand(Console::parse("exec {$console->command} {$console->getCommandInput()}")))(),
54-
array_key_exists($console->command, PackageAliases::$packages) => Package::parse(PackageAliases::$packages[$console->command]['package'])->runCommand($console),
55-
str_contains($console->command, '/') => Package::parse($console->command)->runCommand($console),
56-
$console->command === '--version' || $console->command === '-v' || $console->hasOption('version') || $console->hasOption('v') => VersionCommand::class,
57-
default => (new HelpCommand($console))(true),
58-
};
59-
60-
try {
61-
if (is_subclass_of($command, Command::class)) {
62-
$command = new $command($console);
63-
$command();
64-
} elseif (is_callable($command)) {
65-
$command();
66-
}
67-
} catch (Exception $e) {
68-
echo Command::BACKGROUND_RED . " {$e->getMessage()} " . Command::COLOR_RESET . PHP_EOL;
69-
exit(1);
70-
}
22+
exit((new Application)->run());

files/psysh-config.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22

3-
use Cpx\PhpExecutionHelper;
3+
use Cpx\Runtime\PhpExecutionHelper;
44

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

pint.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,25 @@
1515
"global_namespace_import": {
1616
"import_classes": true
1717
},
18+
"ordered_class_elements": {
19+
"order": [
20+
"use_trait",
21+
"case",
22+
"constant_public",
23+
"constant_protected",
24+
"constant_private",
25+
"property_public",
26+
"property_protected",
27+
"property_private",
28+
"construct",
29+
"destruct",
30+
"magic",
31+
"method_public",
32+
"method_protected",
33+
"method_private"
34+
],
35+
"sort_algorithm": "none"
36+
},
1837
"trailing_comma_in_multiline": {
1938
"elements": [
2039
"arguments",

src/Application.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cpx;
6+
7+
use Cpx\Commands\AliasesCommand;
8+
use Cpx\Commands\CleanCommand;
9+
use Cpx\Commands\ExecCommand;
10+
use Cpx\Commands\ListCommand;
11+
use Cpx\Commands\RunPackageCommand;
12+
use Cpx\Commands\TinkerCommand;
13+
use Cpx\Commands\UpdateCommand;
14+
use Cpx\Commands\UpgradeCommand;
15+
use Cpx\Packages\PackageCommandRunner;
16+
use Symfony\Component\Console\Application as SymfonyApplication;
17+
use Symfony\Component\Console\Input\ArgvInput;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
21+
class Application extends SymfonyApplication
22+
{
23+
public function __construct(?PackageCommandRunner $packageCommandRunner = null)
24+
{
25+
parent::__construct('cpx', $this->resolveVersion());
26+
$this->setAutoExit(false);
27+
28+
$this->registerCommands($packageCommandRunner ?? new PackageCommandRunner);
29+
}
30+
31+
public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
32+
{
33+
$input ??= new ArgvInput;
34+
35+
if ($input instanceof ArgvInput && $this->shouldRunPackageFallback($input)) {
36+
$input = new ArgvInput(['cpx', RunPackageCommand::NAME, '--', ...$input->getRawTokens()]);
37+
}
38+
39+
return parent::run($input, $output);
40+
}
41+
42+
protected function getCommandName(InputInterface $input): ?string
43+
{
44+
$command = parent::getCommandName($input);
45+
46+
return $command === null || $this->has($command)
47+
? $command
48+
: RunPackageCommand::NAME;
49+
}
50+
51+
private function registerCommands(PackageCommandRunner $packageCommandRunner): void
52+
{
53+
$this->addCommands([
54+
new ListCommand,
55+
new AliasesCommand,
56+
new CleanCommand,
57+
new UpdateCommand,
58+
new UpgradeCommand,
59+
new ExecCommand,
60+
new TinkerCommand,
61+
new RunPackageCommand($packageCommandRunner),
62+
]);
63+
}
64+
65+
private function resolveVersion(): string
66+
{
67+
$contents = file_get_contents(__DIR__.'/../composer.json');
68+
69+
if ($contents === false) {
70+
return 'unknown';
71+
}
72+
73+
$decoded = json_decode($contents, true);
74+
75+
return is_array($decoded) && is_string($decoded['version'] ?? null)
76+
? $decoded['version']
77+
: 'unknown';
78+
}
79+
80+
private function shouldRunPackageFallback(ArgvInput $input): bool
81+
{
82+
$command = $input->getRawTokens()[0] ?? null;
83+
84+
return is_string($command)
85+
&& ! in_array($command, ['--version', '-v'], true)
86+
&& ! $this->has($command);
87+
}
88+
}

src/Cache/Metadata.php

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cpx\Cache;
6+
7+
use Cpx\Packages\Package;
8+
use Cpx\Support\Arr;
9+
10+
class Metadata
11+
{
12+
private const FILE = '.cpx_metadata.json';
13+
14+
/**
15+
* @param array<string, PackageMetadata> $packages
16+
* @param array<string, array{packages?: list<string>, last_updated?: int, last_run?: int}> $execCache
17+
*/
18+
protected function __construct(
19+
public array $packages = [],
20+
public array $execCache = [],
21+
) {}
22+
23+
public static function open(): self
24+
{
25+
$metadataFile = cpx_path(self::FILE);
26+
27+
if (! file_exists($metadataFile)) {
28+
return new self;
29+
}
30+
31+
$contents = file_get_contents($metadataFile);
32+
$json = $contents === false ? [] : json_decode($contents, true);
33+
34+
if (! is_array($json)) {
35+
$json = [];
36+
}
37+
38+
return new self(
39+
packages: Arr::mapWithKeys(
40+
fn (string $key, array $value): array => [
41+
$key => new PackageMetadata(
42+
package: Package::parse($key),
43+
lastUpdatedAt: $value['last_updated'] ?? null,
44+
lastRunAt: $value['last_run'] ?? null,
45+
),
46+
],
47+
is_array($json['packages'] ?? null) ? $json['packages'] : [],
48+
),
49+
execCache: is_array($json['execCache'] ?? null) ? $json['execCache'] : [],
50+
);
51+
}
52+
53+
public function recordRun(Package $package): self
54+
{
55+
$this->forPackage($package)->lastRunAt = date('Y-m-d H:i:s');
56+
57+
return $this;
58+
}
59+
60+
public function recordUpdate(Package $package): self
61+
{
62+
$this->forPackage($package)->lastUpdatedAt = date('Y-m-d H:i:s');
63+
64+
return $this;
65+
}
66+
67+
public function save(): void
68+
{
69+
$metadataFile = cpx_path(self::FILE);
70+
71+
if (! is_dir(dirname($metadataFile))) {
72+
mkdir(dirname($metadataFile), 0755, true);
73+
}
74+
75+
file_put_contents($metadataFile, json_encode($this->toArray(), JSON_PRETTY_PRINT));
76+
}
77+
78+
public function hasPackage(string|Package $package): bool
79+
{
80+
if ($package instanceof Package) {
81+
$package = $package->fullPackageString();
82+
}
83+
84+
return array_key_exists($package, $this->packages);
85+
}
86+
87+
/**
88+
* @return array{
89+
* packages: array<string, array{last_updated: string|null, last_run: string|null}>,
90+
* execCache: array<string, array{packages?: list<string>, last_updated?: int, last_run?: int}>
91+
* }
92+
*/
93+
public function toArray(): array
94+
{
95+
return [
96+
'packages' => Arr::mapWithKeys(
97+
fn (string $key, PackageMetadata $packageMetadata): array => [
98+
$packageMetadata->package->fullPackageString() => [
99+
'last_updated' => $packageMetadata->lastUpdatedAt,
100+
'last_run' => $packageMetadata->lastRunAt,
101+
],
102+
],
103+
$this->packages,
104+
),
105+
'execCache' => $this->execCache,
106+
];
107+
}
108+
109+
private function forPackage(Package $package): PackageMetadata
110+
{
111+
return $this->packages[$package->fullPackageString()] ??= new PackageMetadata($package);
112+
}
113+
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
declare(strict_types=1);
44

5-
namespace Cpx;
5+
namespace Cpx\Cache;
6+
7+
use Cpx\Packages\Package;
68

79
class PackageMetadata
810
{
911
public function __construct(
10-
public Package $package,
12+
public readonly Package $package,
1113
public ?string $lastUpdatedAt = null,
1214
public ?string $lastRunAt = null,
1315
) {}

0 commit comments

Comments
 (0)