diff --git a/.codacy.yml b/.codacy.yml index c340014..f27de9c 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -1,5 +1,5 @@ -analysis: - exclude_paths: - - "example/**" - - "test/**" +exclude_paths: + - ".github/**" + - "example/**" + - "test/**" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7b2b4e..70a1405 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: with: name: build-artifact-${{ matrix.php }} path: /tmp/github-actions + retention-days: 1 phpunit: runs-on: ubuntu-latest @@ -92,6 +93,7 @@ jobs: uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + slug: ${{ github.repository }} phpstan: runs-on: ubuntu-latest @@ -163,21 +165,3 @@ jobs: php_version: ${{ matrix.php }} path: src/ standard: phpcs.xml - - remove_old_artifacts: - runs-on: ubuntu-latest - - permissions: - actions: write - - steps: - - name: Remove old artifacts for prior workflow runs on this repository - env: - GH_TOKEN: ${{ github.token }} - run: | - gh api "/repos/${{ github.repository }}/actions/artifacts" | jq ".artifacts[] | select(.name | startswith(\"build-artifact\")) | .id" > artifact-id-list.txt - while read id - do - echo -n "Deleting artifact ID $id ... " - gh api --method DELETE /repos/${{ github.repository }}/actions/artifacts/$id && echo "Done" - done Code quality - + Code coverage @@ -35,6 +35,10 @@ Start the Runner: `vendor/bin/cron`. If you're using [WebEngine](https://php.gt/webengine), the Cron Runner is automatically started for you by running `gt run`. +## Examples + +There is an [example](example/README.md) directory with numbered scripts that can be run directly with `php`. Each one embeds its own crontab string so the schedule and output stay together. + # Proudly sponsored by [JetBrains Open Source sponsorship program](https://www.jetbrains.com/community/opensource/) diff --git a/composer.json b/composer.json index aa93378..5e07e70 100644 --- a/composer.json +++ b/composer.json @@ -4,8 +4,7 @@ "require": { "php": ">=8.1", - "phpgt/cli": "1.*", - "dragonmantank/cron-expression": "^2.2" + "phpgt/cli": "1.*" }, "require-dev": { "phpstan/phpstan": "^1.10", @@ -29,6 +28,20 @@ "bin/cron" ], + "scripts": { + "phpunit": "vendor/bin/phpunit --configuration phpunit.xml", + "phpunit:coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --configuration phpunit.xml --coverage-text", + "phpstan": "vendor/bin/phpstan analyse --level 6 src", + "phpcs": "vendor/bin/phpcs src --standard=phpcs.xml", + "phpmd": "vendor/bin/phpmd src/ text phpmd.xml", + "test": [ + "@phpunit", + "@phpstan", + "@phpcs", + "@phpmd" + ] + }, + "funding": [ { "type": "github", diff --git a/composer.lock b/composer.lock index a7e09ff..c18b40d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,72 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "21924b3fa55478f5a1ce1170b712a626", + "content-hash": "e1552c5d5d8058c04bc2fcc9b38861bc", "packages": [ - { - "name": "dragonmantank/cron-expression", - "version": "v2.3.1", - "source": { - "type": "git", - "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/65b2d8ee1f10915efb3b55597da3404f096acba2", - "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2", - "shasum": "" - }, - "require": { - "php": "^7.0|^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^6.4|^7.0|^8.0|^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.3-dev" - } - }, - "autoload": { - "psr-4": { - "Cron\\": "src/Cron/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Chris Tankersley", - "email": "chris@ctankersley.com", - "homepage": "https://github.com/dragonmantank" - } - ], - "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", - "keywords": [ - "cron", - "schedule" - ], - "support": { - "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v2.3.1" - }, - "funding": [ - { - "url": "https://github.com/dragonmantank", - "type": "github" - } - ], - "time": "2020-10-13T00:52:37+00:00" - }, { "name": "phpgt/cli", "version": "v1.3.4", @@ -2319,34 +2255,34 @@ }, { "name": "symfony/config", - "version": "v6.4.34", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "ce9cb0c0d281aaf188b802d4968e42bfb60701e9" + "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/ce9cb0c0d281aaf188b802d4968e42bfb60701e9", - "reference": "ce9cb0c0d281aaf188b802d4968e42bfb60701e9", + "url": "https://api.github.com/repos/symfony/config/zipball/6c17162555bfb58957a55bb0e43e00035b6ae3d5", + "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^7.1|^8.0", "symfony/polyfill-ctype": "~1.8" }, "conflict": { - "symfony/finder": "<5.4", + "symfony/finder": "<6.4", "symfony/service-contracts": "<2.5" }, "require-dev": { - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2374,7 +2310,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.34" + "source": "https://github.com/symfony/config/tree/v7.4.7" }, "funding": [ { @@ -2394,44 +2330,43 @@ "type": "tidelift" } ], - "time": "2026-02-24T17:34:50+00:00" + "time": "2026-03-06T10:41:14+00:00" }, { "name": "symfony/dependency-injection", - "version": "v6.4.35", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "d95712d0e9446b9f244b64811ffb6af7b7434213" + "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/d95712d0e9446b9f244b64811ffb6af7b7434213", - "reference": "d95712d0e9446b9f244b64811ffb6af7b7434213", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", + "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/service-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4.20|^7.2.5" + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" }, "conflict": { "ext-psr": "<1.1|>=2", - "symfony/config": "<6.1", - "symfony/finder": "<5.4", - "symfony/proxy-manager-bridge": "<6.3", - "symfony/yaml": "<5.4" + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "psr/container-implementation": "1.1|2.0", "symfony/service-implementation": "1.1|2.0|3.0" }, "require-dev": { - "symfony/config": "^6.1|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2459,7 +2394,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.35" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.7" }, "funding": [ { @@ -2479,7 +2414,7 @@ "type": "tidelift" } ], - "time": "2026-02-26T12:16:01+00:00" + "time": "2026-03-03T07:48:48+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2550,25 +2485,25 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.34", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3" + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^5.4|^6.4|^7.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2596,7 +2531,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.34" + "source": "https://github.com/symfony/filesystem/tree/v7.4.6" }, "funding": [ { @@ -2616,7 +2551,7 @@ "type": "tidelift" } ], - "time": "2026-02-24T17:51:06+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2875,26 +2810,26 @@ }, { "name": "symfony/var-exporter", - "version": "v6.4.26", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "466fcac5fa2e871f83d31173f80e9c2684743bfc" + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/466fcac5fa2e871f83d31173f80e9c2684743bfc", - "reference": "466fcac5fa2e871f83d31173f80e9c2684743bfc", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/property-access": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2932,7 +2867,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.26" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" }, "funding": [ { @@ -2952,7 +2887,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T09:57:09+00:00" + "time": "2025-09-11T10:15:23+00:00" }, { "name": "theseer/tokenizer", diff --git a/phpmd.xml b/phpmd.xml index d978035..2749cff 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -6,11 +6,7 @@ xsi:noNamespaceSchemaLocation="http://pmd.sf.net/ruleset_xml_schema.xsd"> Custom ruleset - - - - - + diff --git a/src/Cli/RunCommand.php b/src/Cli/RunCommand.php index 0eed591..39e3f1c 100644 --- a/src/Cli/RunCommand.php +++ b/src/Cli/RunCommand.php @@ -8,6 +8,7 @@ use Gt\Cli\Parameter\Parameter; use Gt\Cli\Stream; use Gt\Cron\CronException; +use Gt\Cron\CrontabNotFoundException; use Gt\Cron\FunctionExecutionException; use Gt\Cron\RunnerFactory; use Gt\Cron\ScriptExecutionException; @@ -22,11 +23,15 @@ public function run(?ArgumentValueList $arguments = null):void { ]); try { - $runner = RunnerFactory::createForProject( + $runner = (new RunnerFactory())->createForProject( getcwd(), $filename ); } + catch(CrontabNotFoundException) { + $this->stream->writeLine("Skipping cron as there is no crontab file."); + return; + } catch(CronException $exception) { $this->stream->writeLine( $exception->getMessage(), diff --git a/src/CronExpression.php b/src/CronExpression.php new file mode 100644 index 0000000..1836cc6 --- /dev/null +++ b/src/CronExpression.php @@ -0,0 +1,149 @@ + */ + private const NICKNAME_MAP = [ + "@yearly" => "0 0 1 1 *", + "@annually" => "0 0 1 1 *", + "@monthly" => "0 0 1 * *", + "@weekly" => "0 0 * * 0", + "@daily" => "0 0 * * *", + "@hourly" => "0 * * * *", + ]; + + /** @var array */ + private const MONTH_MAP = [ + "JAN" => 1, + "FEB" => 2, + "MAR" => 3, + "APR" => 4, + "MAY" => 5, + "JUN" => 6, + "JUL" => 7, + "AUG" => 8, + "SEP" => 9, + "OCT" => 10, + "NOV" => 11, + "DEC" => 12, + ]; + + /** @var array */ + private const WEEKDAY_MAP = [ + "SUN" => 0, + "MON" => 1, + "TUE" => 2, + "WED" => 3, + "THU" => 4, + "FRI" => 5, + "SAT" => 6, + ]; + + private const MAX_LOOKAHEAD_MINUTES = 525600 * 5; + + /** @var array */ + private array $minuteSet; + /** @var array */ + private array $hourSet; + /** @var array */ + private array $dayOfMonthSet; + /** @var array */ + private array $monthSet; + /** @var array */ + private array $dayOfWeekSet; + + private bool $dayOfMonthWildcard; + private bool $dayOfWeekWildcard; + private CronFieldParser $fieldParser; + + public function __construct(string $expression) { + $this->fieldParser = new CronFieldParser(); + $expression = $this->expandNickname($expression); + $parts = preg_split('/\s+/', trim($expression)); + + if(!$parts || count($parts) !== 5) { + throw new InvalidArgumentException("$expression is not a valid CRON expression"); + } + + $this->minuteSet = $this->fieldParser->parseField($parts[0], 0, 59); + $this->hourSet = $this->fieldParser->parseField($parts[1], 0, 23); + [$this->dayOfMonthSet, $this->dayOfMonthWildcard] = $this->fieldParser->parseFieldWithWildcard($parts[2], 1, 31); + $this->monthSet = $this->fieldParser->parseField($parts[3], 1, 12, self::MONTH_MAP); + [$this->dayOfWeekSet, $this->dayOfWeekWildcard] = $this->fieldParser->parseFieldWithWildcard( + $parts[4], + 0, + 7, + self::WEEKDAY_MAP, + true + ); + } + + public function isDue(DateTime $now):bool { + $candidate = clone $now; + $candidate->setTime( + (int)$candidate->format("H"), + (int)$candidate->format("i"), + 0 + ); + + return $this->matches($candidate); + } + + public function getNextRunDate(?DateTime $now = null):DateTime { + $candidate = clone ($now ?? new DateTime()); + $candidate->setTime( + (int)$candidate->format("H"), + (int)$candidate->format("i"), + 0 + ); + $candidate->modify("+1 minute"); + + for($i = 0; $i < self::MAX_LOOKAHEAD_MINUTES; $i++) { + if($this->matches($candidate)) { + return clone $candidate; + } + + $candidate->modify("+1 minute"); + } + + throw new RuntimeException("Unable to calculate next run date"); + } + + private function expandNickname(string $expression):string { + $expression = trim($expression); + return self::NICKNAME_MAP[$expression] ?? $expression; + } + + private function matches(DateTime $candidate):bool { + $minute = (int)$candidate->format("i"); + $hour = (int)$candidate->format("G"); + $dayOfMonth = (int)$candidate->format("j"); + $month = (int)$candidate->format("n"); + $dayOfWeek = (int)$candidate->format("w"); + + if(!isset($this->minuteSet[$minute]) || !isset($this->hourSet[$hour]) || !isset($this->monthSet[$month])) { + return false; + } + + $dayOfMonthMatches = isset($this->dayOfMonthSet[$dayOfMonth]); + $dayOfWeekMatches = isset($this->dayOfWeekSet[$dayOfWeek]); + + if($this->dayOfMonthWildcard && $this->dayOfWeekWildcard) { + return true; + } + + if($this->dayOfMonthWildcard) { + return $dayOfWeekMatches; + } + + if($this->dayOfWeekWildcard) { + return $dayOfMonthMatches; + } + + return $dayOfMonthMatches || $dayOfWeekMatches; + } +} diff --git a/src/CronFieldParser.php b/src/CronFieldParser.php new file mode 100644 index 0000000..6e3df40 --- /dev/null +++ b/src/CronFieldParser.php @@ -0,0 +1,154 @@ + $nameMap + * @return array + */ + public function parseField( + string $field, + int $min, + int $max, + array $nameMap = [], + bool $normaliseWeekday = false + ):array { + [$set] = $this->parseFieldWithWildcard($field, $min, $max, $nameMap, $normaliseWeekday); + return $set; + } + + /** + * @param array $nameMap + * @return array{0:array,1:bool} + */ + public function parseFieldWithWildcard( + string $field, + int $min, + int $max, + array $nameMap = [], + bool $normaliseWeekday = false + ):array { + $field = strtoupper(trim($field)); + $isWildcard = $field === "*" || $field === "?"; + $set = []; + + foreach(explode(",", $field) as $segment) { + $segment = trim($segment); + if($segment === "") { + throw new InvalidArgumentException("Invalid CRON field value $field"); + } + + foreach($this->expandSegment($segment, $min, $max, $nameMap, $normaliseWeekday) as $value) { + $set[$value] = true; + } + } + + return [$set, $isWildcard]; + } + + /** + * @param array $nameMap + * @return array + */ + private function expandSegment( + string $segment, + int $min, + int $max, + array $nameMap, + bool $normaliseWeekday + ):array { + [$segment, $step] = $this->parseSegmentStep($segment); + [$start, $end] = $this->resolveSegmentRange( + $segment, + $min, + $max, + $nameMap, + $normaliseWeekday + ); + + $values = []; + for($value = $start; $value <= $end; $value += $step) { + array_push($values, $normaliseWeekday && $value === 7 ? 0 : $value); + } + + return $values; + } + + /** @return array{0:string,1:int} */ + private function parseSegmentStep(string $segment):array { + if(!str_contains($segment, "/")) { + return [$segment, 1]; + } + + [$rangeSegment, $stepPart] = explode("/", $segment, 2); + if($stepPart === "" || !ctype_digit($stepPart) || (int)$stepPart < 1) { + throw new InvalidArgumentException("Invalid CRON field value $rangeSegment/$stepPart"); + } + + return [$rangeSegment, (int)$stepPart]; + } + + /** + * @param array $nameMap + * @return array{0:int,1:int} + */ + private function resolveSegmentRange( + string $segment, + int $min, + int $max, + array $nameMap, + bool $normaliseWeekday + ):array { + if($segment === "*" || $segment === "?") { + return [$min, $max]; + } + + if(!str_contains($segment, "-")) { + $value = $this->normaliseValue($segment, $min, $max, $nameMap, $normaliseWeekday); + return [$value, $value]; + } + + [$startPart, $endPart] = explode("-", $segment, 2); + $start = $this->normaliseValue($startPart, $min, $max, $nameMap, $normaliseWeekday); + $end = $this->normaliseValue($endPart, $min, $max, $nameMap, $normaliseWeekday); + if($end < $start) { + throw new InvalidArgumentException("Invalid CRON field value $segment"); + } + + return [$start, $end]; + } + + /** + * @param array $nameMap + */ + private function normaliseValue( + string $value, + int $min, + int $max, + array $nameMap, + bool $normaliseWeekday + ):int { + $value = strtoupper(trim($value)); + + if(isset($nameMap[$value])) { + return $nameMap[$value]; + } + + if(!preg_match('/^\d+$/', $value)) { + throw new InvalidArgumentException("Invalid CRON field value $value"); + } + + $intValue = (int)$value; + if($normaliseWeekday && $intValue === 7) { + return 7; + } + + if($intValue < $min || $intValue > $max) { + throw new InvalidArgumentException("Invalid CRON field value $value"); + } + + return $intValue; + } +} diff --git a/src/CrontabParser.php b/src/CrontabParser.php new file mode 100644 index 0000000..24d9b54 --- /dev/null +++ b/src/CrontabParser.php @@ -0,0 +1,66 @@ +expressionFactory ??= new ExpressionFactory(); + } + + public function parseIntoQueue( + string $contents, + Queue $queue, + JobRepository $jobRepository + ):int { + $numJobs = 0; + + foreach(explode("\n", $contents) as $line) { + $line = trim($line); + if($line === "" || $line[0] === "#") { + continue; + } + + [$crontab, $command] = $this->parseLine($line); + + try { + $queue->add( + $jobRepository->create( + $this->expressionFactory->create($crontab), + $command + ) + ); + } + catch(InvalidArgumentException $exception) { + throw new ParseException("Invalid syntax: $line"); + } + + $numJobs++; + } + + return $numJobs; + } + + /** @return array{0:string,1:string} */ + public function parseLine(string $line):array { + preg_match( + "/^(?P@\S+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(?P.+)$/", + $line, + $matches + ); + + $crontab = $matches["crontab"] ?? null; + $command = $matches["command"] ?? null; + + if(is_null($crontab) || is_null($command)) { + throw new ParseException("Invalid syntax: $line"); + } + + return [ + trim($crontab), + trim($command), + ]; + } +} diff --git a/src/Expression.php b/src/Expression.php new file mode 100644 index 0000000..6308882 --- /dev/null +++ b/src/Expression.php @@ -0,0 +1,9 @@ +scriptCommandResolver = new ScriptCommandResolver(); $this->expression = $expression; $this->command = $command; $this->hasRun = false; + $this->scriptOutputMode = $scriptOutputMode; + $this->stdout = ""; + $this->stderr = ""; } public function isDue(?DateTime $now = null):bool { @@ -34,14 +45,24 @@ public function getCommand():string { return $this->command; } + public function getStdout():string { + return $this->stdout; + } + + public function getStderr():string { + return $this->stderr; + } + public function run():void { $this->hasRun = true; + $this->stdout = ""; + $this->stderr = ""; if($this->isFunction()) { $this->executeFunction(); } else { -// Assume the command is a shell command. + // Assume the command is a shell command. $this->executeScript(); } } @@ -79,7 +100,7 @@ protected function executeFunction():void { $bracketPos ); $argsString = trim($argsString, " ();"); - $args = str_getcsv($argsString); + $args = str_getcsv($argsString, ",", "\"", "\\"); $command = substr( $command, @@ -98,12 +119,8 @@ protected function executeFunction():void { } protected function executeScript():void { - $command = $this->resolveScriptCommand(); - $descriptor = [ - 0 => ["pipe", "r"], - 1 => ["pipe", "w"], - 2 => ["pipe", "w"], - ]; + $command = $this->scriptCommandResolver->resolve($this->command); + $descriptor = $this->createScriptDescriptor(); $pipes = []; $proc = proc_open( @@ -124,6 +141,10 @@ protected function executeScript():void { } }while($status["running"]); + if($proc) { + $this->captureProcessOutput($pipes); + } + if($status["exitcode"] > 0) { throw new ScriptExecutionException( $this->command @@ -131,72 +152,63 @@ protected function executeScript():void { } if($proc) { + $this->closePipes($pipes); proc_close($proc); } } - protected function resolveScriptCommand():string { - $scriptParts = $this->parseScriptCommand($this->command); - if(is_null($scriptParts)) { - return $this->command; - } + /** @return array */ + protected function createScriptDescriptor():array { + $stdin = ["pipe", "r"]; + + return match($this->scriptOutputMode) { + ScriptOutputMode::INHERIT => [ + 0 => $stdin, + 1 => ["file", "php://stdout", "w"], + 2 => ["file", "php://stderr", "w"], + ], + ScriptOutputMode::CAPTURE => [ + 0 => $stdin, + 1 => ["pipe", "w"], + 2 => ["pipe", "w"], + ], + default => [ + 0 => $stdin, + 1 => ["file", $this->nullDevice(), "w"], + 2 => ["file", $this->nullDevice(), "w"], + ], + }; + } - $script = $this->normaliseScriptName($scriptParts["script"]); - if(!$this->isValidScriptName($script)) { - return $this->command; + /** @param array $pipes */ + protected function captureProcessOutput(array $pipes):void { + if($this->scriptOutputMode !== ScriptOutputMode::CAPTURE) { + return; } - $scriptPath = $this->getLocalCronScriptPath($script); - if(!is_file($scriptPath)) { - return $this->command; + if(isset($pipes[1]) && is_resource($pipes[1])) { + $this->stdout = stream_get_contents($pipes[1]) ?: ""; } - return PHP_BINARY - . " " - . escapeshellarg($scriptPath) - . $scriptParts["args"]; - } - - /** @return null|array{script:string,args:string} */ - protected function parseScriptCommand(string $command):?array { - $matches = []; - if(!preg_match( - "/^(?P