├── LICENSE ├── Makefile ├── composer.json ├── config └── services.php ├── crunz ├── resources └── config │ └── crunz.yml └── src ├── Application.php ├── Application ├── Cron │ ├── CronExpressionFactoryInterface.php │ └── CronExpressionInterface.php ├── Query │ └── TaskInformation │ │ ├── TaskInformation.php │ │ ├── TaskInformationHandler.php │ │ └── TaskInformationView.php └── Service │ ├── ClosureSerializerInterface.php │ ├── ConfigurationInterface.php │ └── LoggerFactoryInterface.php ├── CacheDirectoryFactory └── CacheDirectoryFactory.php ├── Clock ├── Clock.php └── ClockInterface.php ├── Configuration ├── ConfigFileNotExistsException.php ├── ConfigFileNotReadableException.php ├── Configuration.php ├── ConfigurationParser.php ├── ConfigurationParserInterface.php ├── Definition.php └── FileParser.php ├── Console └── Command │ ├── Command.php │ ├── ConfigGeneratorCommand.php │ ├── ScheduleListCommand.php │ ├── ScheduleRunCommand.php │ └── TaskGeneratorCommand.php ├── EnvFlags └── EnvFlags.php ├── Event.php ├── EventRunner.php ├── Exception ├── CrunzException.php ├── EmptyTimezoneException.php ├── MailerException.php ├── NotImplementedException.php ├── TaskNotExistException.php └── WrongTaskNumberException.php ├── Filesystem ├── Filesystem.php └── FilesystemInterface.php ├── Finder ├── Finder.php └── FinderInterface.php ├── HttpClient ├── CurlHttpClient.php ├── FallbackHttpClient.php ├── HttpClientException.php ├── HttpClientInterface.php ├── HttpClientLoggerDecorator.php └── StreamHttpClient.php ├── Infrastructure ├── Dragonmantank │ └── CronExpression │ │ ├── DragonmantankCronExpression.php │ │ └── DragonmantankCronExpressionFactory.php ├── Laravel │ └── LaravelClosureSerializer.php └── Psr │ └── Logger │ ├── EnabledLoggerDecorator.php │ ├── PsrStreamLogger.php │ └── PsrStreamLoggerFactory.php ├── Invoker.php ├── Logger ├── ConsoleLogger.php ├── ConsoleLoggerInterface.php ├── Logger.php └── LoggerFactory.php ├── Mailer.php ├── Output └── OutputFactory.php ├── Path └── Path.php ├── Pinger ├── PingableException.php ├── PingableInterface.php └── PingableTrait.php ├── Process └── Process.php ├── Schedule.php ├── Schedule └── ScheduleFactory.php ├── Stubs └── BasicTask.php ├── Task ├── Collection.php ├── CollectionInterface.php ├── Loader.php ├── LoaderInterface.php ├── TaskException.php ├── TaskNumber.php ├── Timezone.php └── WrongTaskInstanceException.php ├── Timezone ├── Provider.php └── ProviderInterface.php └── UserInterface └── Cli ├── ClosureRunCommand.php └── DebugTaskCommand.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Moe Reza Lavarian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | sh-php: 2 | docker compose exec --user=www-data php81 sh 3 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crunzphp/crunz", 3 | "description": "Schedule your tasks right from the code.", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "scheduler", 8 | "cron jobs", 9 | "cron", 10 | "Task Scheduler", 11 | "PHP Task Scheduler", 12 | "Job Scheduler", 13 | "Job Manager", 14 | "Event Runner" 15 | ], 16 | "authors": [ 17 | { 18 | "name": "Reza M. Lavaryan", 19 | "email": "mrl.8081@gmail.com" 20 | }, 21 | { 22 | "name": "PabloKowalczyk", 23 | "homepage": "https://github.com/PabloKowalczyk", 24 | "role": "Developer" 25 | } 26 | ], 27 | "homepage": "https://github.com/crunzphp/crunz", 28 | "support": { 29 | "issues": "https://github.com/crunzphp/crunz/issues" 30 | }, 31 | "funding": [ 32 | { 33 | "type": "github", 34 | "url": "https://github.com/sponsors/PabloKowalczyk" 35 | } 36 | ], 37 | "require": { 38 | "php": ">=8.1", 39 | "composer-runtime-api": "^2.0", 40 | "dragonmantank/cron-expression": "^3.4.0", 41 | "laravel/serializable-closure": "^2.0", 42 | "psr/log": "^2.0 || ^3.0", 43 | "symfony/config": "^6.4.10 || ^7.1.0", 44 | "symfony/console": "^6.4.10 || ^7.1.0", 45 | "symfony/dependency-injection": "^6.4.10 || ^7.1.0", 46 | "symfony/filesystem": "^6.4.10 || ^7.1.0", 47 | "symfony/lock": "^6.4.10 || ^7.1.0", 48 | "symfony/mailer": "^6.4.10 || ^7.1.0", 49 | "symfony/process": "^6.4.10 || ^7.1.0", 50 | "symfony/string": "^6.4.10 || ^7.1.0", 51 | "symfony/yaml": "^6.4.10 || ^7.1.0" 52 | }, 53 | "require-dev": { 54 | "ext-json": "*", 55 | "ext-mbstring": "*", 56 | "ergebnis/composer-normalize": "2.28.3", 57 | "friendsofphp/php-cs-fixer": "3.64", 58 | "phpstan/phpstan": "2.0.2", 59 | "phpstan/phpstan-phpunit": "2.0.1", 60 | "phpstan/phpstan-strict-rules": "2.0.0", 61 | "phpunit/phpunit": "10.5.38", 62 | "symfony/error-handler": "^6.4.10 || ^7.1.0", 63 | "symfony/phpunit-bridge": "^6.4.10 || ^7.1.0" 64 | }, 65 | "minimum-stability": "beta", 66 | "prefer-stable": true, 67 | "autoload": { 68 | "psr-4": { 69 | "Crunz\\": "src/" 70 | } 71 | }, 72 | "autoload-dev": { 73 | "psr-4": { 74 | "Crunz\\Tests\\": "tests/" 75 | } 76 | }, 77 | "bin": [ 78 | "crunz" 79 | ], 80 | "config": { 81 | "allow-plugins": { 82 | "ergebnis/composer-normalize": true 83 | }, 84 | "sort-packages": true 85 | }, 86 | "scripts": { 87 | "crunz:analyze": [ 88 | "@php vendor/bin/php-cs-fixer fix --diff --dry-run -v", 89 | "@phpstan:check" 90 | ], 91 | "crunz:cs-fix": "@php vendor/bin/php-cs-fixer fix --diff -v --ansi", 92 | "phpstan:check": "@php vendor/bin/phpstan analyse -c phpstan.neon src tests crunz config bootstrap.php" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | Provider::class, 65 | Filesystem::class, 66 | ScheduleFactory::class, 67 | StreamHttpClient::class, 68 | CurlHttpClient::class, 69 | FilesystemInterface::class => CrunzFilesystem::class, 70 | FinderInterface::class => Finder::class, 71 | LoaderInterface::class => Loader::class, 72 | CronExpressionFactoryInterface::class => DragonmantankCronExpressionFactory::class, 73 | ClosureSerializerInterface::class => LaravelClosureSerializer::class, 74 | ClockInterface::class => Clock::class, 75 | ]; 76 | 77 | /* @var ContainerBuilder $container */ 78 | 79 | $container 80 | ->register(ScheduleRunCommand::class, ScheduleRunCommand::class) 81 | ->setPublic(true) 82 | ->setArguments( 83 | [ 84 | new Reference(CollectionInterface::class), 85 | new Reference(ConfigurationInterface::class), 86 | new Reference(EventRunner::class), 87 | new Reference(Timezone::class), 88 | new Reference(ScheduleFactory::class), 89 | new Reference(LoaderInterface::class), 90 | ] 91 | ) 92 | ; 93 | $container 94 | ->register(ClosureRunCommand::class, ClosureRunCommand::class) 95 | ->setArguments( 96 | [ 97 | new Reference(ClosureSerializerInterface::class), 98 | ] 99 | ) 100 | ->setPublic(true) 101 | ; 102 | $container 103 | ->register(ConfigGeneratorCommand::class, ConfigGeneratorCommand::class) 104 | ->setPublic(true) 105 | ->setArguments( 106 | [ 107 | new Reference(ProviderInterface::class), 108 | new Reference(Filesystem::class), 109 | new Reference(FilesystemInterface::class), 110 | ] 111 | ) 112 | ; 113 | $container 114 | ->register(ScheduleListCommand::class, ScheduleListCommand::class) 115 | ->setPublic(true) 116 | ->setArguments( 117 | [ 118 | new Reference(ConfigurationInterface::class), 119 | new Reference(CollectionInterface::class), 120 | new Reference(LoaderInterface::class), 121 | ] 122 | ) 123 | ; 124 | $container 125 | ->register(TaskGeneratorCommand::class, TaskGeneratorCommand::class) 126 | ->setPublic(true) 127 | ->setArguments( 128 | [ 129 | new Reference(ConfigurationInterface::class), 130 | new Reference(FilesystemInterface::class), 131 | ] 132 | ) 133 | ; 134 | $container 135 | ->register(DebugTaskCommand::class, DebugTaskCommand::class) 136 | ->setPublic(true) 137 | ->setArguments( 138 | [ 139 | new Reference(TaskInformationHandler::class), 140 | ] 141 | ) 142 | ; 143 | $container 144 | ->register(CollectionInterface::class, Collection::class) 145 | ->setPublic(false) 146 | ->setArguments( 147 | [ 148 | new Reference(ConfigurationInterface::class), 149 | new Reference(FinderInterface::class), 150 | new Reference(ConsoleLoggerInterface::class), 151 | ] 152 | ) 153 | ; 154 | $container 155 | ->register(FileParser::class, FileParser::class) 156 | ->setPublic(false) 157 | ->setArguments( 158 | [ 159 | new Reference(Yaml::class), 160 | ] 161 | ) 162 | ; 163 | $container 164 | ->register(ConfigurationInterface::class, Configuration::class) 165 | ->setPublic(false) 166 | ->setArguments( 167 | [ 168 | new Reference(ConfigurationParserInterface::class), 169 | new Reference(FilesystemInterface::class), 170 | ] 171 | ) 172 | ; 173 | $container 174 | ->register(Mailer::class, Mailer::class) 175 | ->setPublic(false) 176 | ->setArguments( 177 | [ 178 | new Reference(ConfigurationInterface::class), 179 | ] 180 | ) 181 | ; 182 | $container 183 | ->register(LoggerFactory::class, LoggerFactory::class) 184 | ->setPublic(false) 185 | ->setArguments( 186 | [ 187 | new Reference(ConfigurationInterface::class), 188 | new Reference(Timezone::class), 189 | new Reference(ConsoleLoggerInterface::class), 190 | new Reference(ClockInterface::class), 191 | ] 192 | ) 193 | ; 194 | $container 195 | ->register(EventRunner::class, EventRunner::class) 196 | ->setPublic(false) 197 | ->setArguments( 198 | [ 199 | new Reference(Invoker::class), 200 | new Reference(ConfigurationInterface::class), 201 | new Reference(Mailer::class), 202 | new Reference(LoggerFactory::class), 203 | new Reference(HttpClientInterface::class), 204 | new Reference(ConsoleLoggerInterface::class), 205 | ] 206 | ) 207 | ; 208 | $container 209 | ->register(Timezone::class, Timezone::class) 210 | ->setPublic(false) 211 | ->setArguments( 212 | [ 213 | new Reference(ConfigurationInterface::class), 214 | new Reference(ConsoleLoggerInterface::class), 215 | ] 216 | ) 217 | ; 218 | $container 219 | ->register(OutputInterface::class, ConsoleOutput::class) 220 | ->setPublic(true) 221 | ->setFactory([new Reference(OutputFactory::class), 'createOutput']) 222 | ; 223 | $container 224 | ->register(OutputFactory::class, OutputFactory::class) 225 | ->setPublic(false) 226 | ->setArguments( 227 | [ 228 | new Reference(InputInterface::class), 229 | ] 230 | ) 231 | ; 232 | $container 233 | ->register(InputInterface::class, ArgvInput::class) 234 | ->setPublic(true) 235 | ; 236 | $container 237 | ->register(SymfonyStyle::class, SymfonyStyle::class) 238 | ->setPublic(true) 239 | ->setArguments( 240 | [ 241 | new Reference(InputInterface::class), 242 | new Reference(OutputInterface::class), 243 | ] 244 | ) 245 | ; 246 | $container 247 | ->register(ConsoleLoggerInterface::class, ConsoleLogger::class) 248 | ->setPublic(false) 249 | ->setArguments( 250 | [ 251 | new Reference(SymfonyStyle::class), 252 | ] 253 | ) 254 | ; 255 | $container 256 | ->register(ConsoleLoggerInterface::class, ConsoleLogger::class) 257 | ->setPublic(false) 258 | ->setArguments( 259 | [ 260 | new Reference(SymfonyStyle::class), 261 | ] 262 | ) 263 | ; 264 | $container 265 | ->register(FallbackHttpClient::class, FallbackHttpClient::class) 266 | ->setPublic(false) 267 | ->setArguments( 268 | [ 269 | new Reference(StreamHttpClient::class), 270 | new Reference(CurlHttpClient::class), 271 | new Reference(ConsoleLoggerInterface::class), 272 | ] 273 | ) 274 | ; 275 | $container 276 | ->register(HttpClientInterface::class, HttpClientLoggerDecorator::class) 277 | ->setPublic(false) 278 | ->setArguments( 279 | [ 280 | new Reference(FallbackHttpClient::class), 281 | new Reference(ConsoleLoggerInterface::class), 282 | ] 283 | ) 284 | ; 285 | $container 286 | ->register(ConfigurationParserInterface::class, ConfigurationParser::class) 287 | ->setPublic(false) 288 | ->setArguments( 289 | [ 290 | new Reference(Definition::class), 291 | new Reference(Processor::class), 292 | new Reference(FileParser::class), 293 | new Reference(ConsoleLoggerInterface::class), 294 | new Reference(FilesystemInterface::class), 295 | ] 296 | ) 297 | ; 298 | 299 | $container 300 | ->register(TaskInformationHandler::class, TaskInformationHandler::class) 301 | ->setPublic(false) 302 | ->setArguments( 303 | [ 304 | new Reference(Timezone::class), 305 | new Reference(ConfigurationInterface::class), 306 | new Reference(CollectionInterface::class), 307 | new Reference(LoaderInterface::class), 308 | new Reference(ScheduleFactory::class), 309 | new Reference(CronExpressionFactoryInterface::class), 310 | ] 311 | ) 312 | ; 313 | 314 | foreach ($simpleServices as $id => $simpleService) { 315 | if (!\is_string($id)) { 316 | $id = $simpleService; 317 | } 318 | 319 | $container 320 | ->register($id, $simpleService) 321 | ->setPublic(false) 322 | ; 323 | } 324 | -------------------------------------------------------------------------------- /crunz: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 11 | | For the full copyright and license information, please view the LICENSE 12 | | file that was distributed with this source code. 13 | | 14 | */ 15 | 16 | use Composer\InstalledVersions; 17 | use Crunz\Application; 18 | 19 | if (!\defined('CRUNZ_BIN')) { 20 | \define('CRUNZ_BIN', __FILE__); 21 | } 22 | 23 | $generatePath = static fn(string ...$parts): string => \implode(DIRECTORY_SEPARATOR, $parts); 24 | $autoloadPaths = [ 25 | // Dependency 26 | $generatePath( 27 | \dirname(__DIR__, 2), 28 | 'autoload.php' 29 | ), 30 | // Vendor/Bin 31 | $generatePath( 32 | \dirname(__DIR__), 33 | 'autoload.php' 34 | ), 35 | // Local dev 36 | $generatePath( 37 | __DIR__, 38 | 'vendor', 39 | 'autoload.php' 40 | ), 41 | ]; 42 | $loadAutoloader = static function () use($autoloadPaths): void { 43 | foreach ($autoloadPaths as $autoloadPath) { 44 | if (\file_exists($autoloadPath) === true) { 45 | require_once $autoloadPath; 46 | return; 47 | } 48 | } 49 | 50 | throw new RuntimeException( 51 | \sprintf( 52 | 'Unable to find "vendor/autoload.php" in "%s" paths.', 53 | \implode('", "', $autoloadPaths) 54 | ) 55 | ); 56 | }; 57 | $loadAutoloader(); 58 | 59 | $application = new Application( 60 | 'Crunz Command Line Interface', 61 | InstalledVersions::getPrettyVersion('crunzphp/crunz') ?? '1.0.x-dev', 62 | ); 63 | $application->run(); 64 | -------------------------------------------------------------------------------- /resources/config/crunz.yml: -------------------------------------------------------------------------------- 1 | # Crunz Configuration Settings 2 | 3 | # This option defines where the task files and 4 | # directories reside. 5 | # The path is relative to this config file. 6 | # Trailing slashes will be ignored. 7 | source: tasks 8 | 9 | # The suffix is meant to target the task files inside the ":source" directory. 10 | # Please note if you change this value, you need 11 | # to make sure all the existing tasks files are renamed accordingly. 12 | suffix: Tasks.php 13 | 14 | # Timezone is used to calculate task run time 15 | # This option is very important and not setting it is deprecated 16 | # and will result in exception in 2.0 version. 17 | timezone: ~ 18 | 19 | # This option define which timezone should be used for log files 20 | # If false, system default timezone will be used 21 | # If true, the timezone in config file that is used to calculate task run time will be used 22 | timezone_log: false 23 | 24 | # By default the errors are not logged by Crunz 25 | # You may set the value to true for logging the errors 26 | log_errors: false 27 | 28 | # This is the absolute path to the errors' log file 29 | # You need to make sure you have the required permission to write to this file though. 30 | errors_log_file: ~ 31 | 32 | # By default the output is not logged as they are redirected to the 33 | # null output. 34 | # Set this to true if you want to keep the outputs 35 | log_output: false 36 | 37 | # This is the absolute path to the global output log file 38 | # The events which have dedicated log files (defined with them), won't be 39 | # logged to this file though. 40 | output_log_file: ~ 41 | 42 | # By default line breaks in logs aren't allowed. 43 | # Set the value to true to allow them. 44 | log_allow_line_breaks: false 45 | 46 | # By default empty context arrays are shown in the log. 47 | # Set the value to true to remove them. 48 | log_ignore_empty_context: false 49 | 50 | # This option determines whether the output should be emailed or not. 51 | email_output: false 52 | 53 | # This option determines whether the error messages should be emailed or not. 54 | email_errors: false 55 | 56 | # Global Swift Mailer settings 57 | mailer: 58 | # Possible values: smtp, mail, and sendmail 59 | transport: smtp 60 | recipients: 61 | sender_name: 62 | sender_email: 63 | 64 | 65 | # SMTP settings 66 | smtp: 67 | host: ~ 68 | port: ~ 69 | username: ~ 70 | password: ~ 71 | encryption: ~ 72 | -------------------------------------------------------------------------------- /src/Application.php: -------------------------------------------------------------------------------- 1 | cacheDirectoryFactory = new CacheDirectoryFactory(); 71 | $this->envFlags = new EnvFlags(); 72 | 73 | $this->initializeContainer(); 74 | $this->registerDeprecationHandler(); 75 | 76 | foreach (self::COMMANDS as $commandClass) { 77 | /** @var Command $command */ 78 | $command = $this->container 79 | ->get($commandClass) 80 | ; 81 | 82 | $this->add($command); 83 | } 84 | } 85 | 86 | public function run(?InputInterface $input = null, ?OutputInterface $output = null): int 87 | { 88 | if (null === $output) { 89 | /** @var OutputInterface $outputObject */ 90 | $outputObject = $this->container 91 | ->get(OutputInterface::class); 92 | 93 | $output = $outputObject; 94 | } 95 | 96 | if (null === $input) { 97 | /** @var InputInterface $inputObject */ 98 | $inputObject = $this->container 99 | ->get(InputInterface::class); 100 | 101 | $input = $inputObject; 102 | } 103 | 104 | return parent::run($input, $output); 105 | } 106 | 107 | private function initializeContainer(): void 108 | { 109 | $containerCacheDirWritable = $this->createBaseCacheDirectory(); 110 | $isContainerDebugEnabled = $this->envFlags 111 | ->isContainerDebugEnabled(); 112 | 113 | if ($containerCacheDirWritable) { 114 | $class = 'CrunzContainer'; 115 | $baseClass = 'Container'; 116 | $cachePath = Path::create( 117 | [ 118 | $this->getContainerCacheDir(), 119 | "{$class}.php", 120 | ] 121 | ); 122 | $cache = new ConfigCache($cachePath->toString(), $isContainerDebugEnabled); 123 | 124 | if (!$cache->isFresh()) { 125 | $containerBuilder = $this->buildContainer(); 126 | $containerBuilder->compile(); 127 | 128 | $this->dumpContainer( 129 | $cache, 130 | $containerBuilder, 131 | $class, 132 | $baseClass 133 | ); 134 | } 135 | 136 | require_once $cache->getPath(); 137 | 138 | $this->container = new $class(); 139 | 140 | return; 141 | } 142 | 143 | $containerBuilder = $this->buildContainer(); 144 | $containerBuilder->compile(); 145 | 146 | $this->container = $containerBuilder; 147 | } 148 | 149 | /** 150 | * @return ContainerBuilder 151 | * 152 | * @throws \Exception 153 | */ 154 | private function buildContainer() 155 | { 156 | $containerBuilder = new ContainerBuilder(); 157 | $configDir = Path::create( 158 | [ 159 | __DIR__, 160 | '..', 161 | 'config', 162 | ] 163 | ); 164 | 165 | $phpLoader = new PhpFileLoader($containerBuilder, new FileLocator($configDir->toString())); 166 | $phpLoader->load('services.php'); 167 | 168 | return $containerBuilder; 169 | } 170 | 171 | private function dumpContainer( 172 | ConfigCache $cache, 173 | ContainerBuilder $container, 174 | string $class, 175 | string $baseClass, 176 | ): void { 177 | $dumper = new PhpDumper($container); 178 | 179 | /** @var string $content */ 180 | $content = $dumper->dump( 181 | [ 182 | 'class' => $class, 183 | 'base_class' => $baseClass, 184 | 'file' => $cache->getPath(), 185 | ] 186 | ); 187 | 188 | $cache->write($content, $container->getResources()); 189 | } 190 | 191 | /** 192 | * @return bool 193 | */ 194 | private function createBaseCacheDirectory() 195 | { 196 | $baseCacheDir = $this->getBaseCacheDir(); 197 | 198 | if (!\is_dir($baseCacheDir)) { 199 | $makeDirResult = \mkdir( 200 | $this->getBaseCacheDir(), 201 | 0777, 202 | true 203 | ); 204 | 205 | return $makeDirResult 206 | && \is_dir($baseCacheDir) 207 | && \is_writable($baseCacheDir) 208 | ; 209 | } 210 | 211 | return \is_writable($baseCacheDir); 212 | } 213 | 214 | private function getBaseCacheDir(): string 215 | { 216 | return $this->cacheDirectoryFactory->generate()->toString(); 217 | } 218 | 219 | /** 220 | * @return string 221 | */ 222 | private function getContainerCacheDir() 223 | { 224 | $containerCacheDir = Path::create( 225 | [ 226 | $this->getBaseCacheDir(), 227 | \get_current_user(), 228 | $this->getVersion(), 229 | ] 230 | ); 231 | 232 | return $containerCacheDir->toString(); 233 | } 234 | 235 | private function registerDeprecationHandler(): void 236 | { 237 | $isDeprecationHandlerEnabled = $this->envFlags 238 | ->isDeprecationHandlerEnabled(); 239 | 240 | if (!$isDeprecationHandlerEnabled) { 241 | return; 242 | } 243 | 244 | /** @var SymfonyStyle $io */ 245 | $io = $this->container 246 | ->get(SymfonyStyle::class); 247 | 248 | \set_error_handler( 249 | static function ( 250 | int $errorNumber, 251 | string $errorString, 252 | string $file, 253 | int $line, 254 | ) use ($io): bool { 255 | $io->block( 256 | "{$errorString} File {$file}, line {$line}", 257 | 'Deprecation', 258 | 'bg=yellow;fg=black', 259 | ' ', 260 | true 261 | ); 262 | 263 | return true; 264 | }, 265 | E_USER_DEPRECATED 266 | ); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/Application/Cron/CronExpressionFactoryInterface.php: -------------------------------------------------------------------------------- 1 | taskNumber; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Application/Query/TaskInformation/TaskInformationHandler.php: -------------------------------------------------------------------------------- 1 | configuration 30 | ->getSourcePath() 31 | ; 32 | /** @var \SplFileInfo[] $files */ 33 | $files = $this->taskCollection 34 | ->all($source) 35 | ; 36 | 37 | // List of schedules 38 | $schedules = $this->taskLoader 39 | ->load(...\array_values($files)) 40 | ; 41 | 42 | $timezoneForComparisons = $this->timezone 43 | ->timezoneForComparisons() 44 | ; 45 | $event = $this->scheduleFactory 46 | ->singleTask($taskInformation->taskNumber(), ...$schedules) 47 | ; 48 | 49 | $cronExpression = $this->cronExpressionFactory 50 | ->createFromString($event->getExpression()) 51 | ; 52 | $nextRunTimezone = $timezoneForComparisons; 53 | $eventProperties = $this->getEventProperties($event, ['timezone', 'preventOverlapping']); 54 | $eventTimezone = $eventProperties['timezone']; 55 | if (\is_string($eventTimezone)) { 56 | $eventTimezone = new \DateTimeZone($eventTimezone); 57 | $nextRunTimezone = $eventTimezone; 58 | } 59 | 60 | $nextRuns = $cronExpression->multipleRunDates( 61 | 5, 62 | new \DateTimeImmutable(), 63 | $nextRunTimezone 64 | ); 65 | 66 | return new TaskInformationView( 67 | $event->getCommand(), 68 | $event->description ?? '', 69 | $event->getExpression(), 70 | \filter_var($eventProperties['preventOverlapping'] ?? false, FILTER_VALIDATE_BOOLEAN), 71 | $eventTimezone, 72 | $timezoneForComparisons, 73 | ...$nextRuns 74 | ); 75 | } 76 | 77 | /** 78 | * @param string[] $properties 79 | * 80 | * @return array 81 | */ 82 | private function getEventProperties(Event $event, array $properties): array 83 | { 84 | $propertiesExtractor = function () use ($properties, $event): array { 85 | $values = []; 86 | foreach ($properties as $property) { 87 | if (!\property_exists($event, $property)) { 88 | $class = $event::class; 89 | 90 | throw new \RuntimeException("Property '{$property}' doesn't exists in '{$class}' class."); 91 | } 92 | 93 | $values[$property] = $this->{$property}; 94 | } 95 | 96 | return $values; 97 | }; 98 | 99 | return $propertiesExtractor->bindTo($event, Event::class)(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Application/Query/TaskInformation/TaskInformationView.php: -------------------------------------------------------------------------------- 1 | nextRuns = $nextRuns; 22 | } 23 | 24 | public function command(): string|object 25 | { 26 | return $this->command; 27 | } 28 | 29 | public function description(): string 30 | { 31 | return $this->description; 32 | } 33 | 34 | public function cronExpression(): string 35 | { 36 | return $this->cronExpression; 37 | } 38 | 39 | public function timeZone(): ?\DateTimeZone 40 | { 41 | return $this->timeZone; 42 | } 43 | 44 | public function configTimeZone(): \DateTimeZone 45 | { 46 | return $this->configTimeZone; 47 | } 48 | 49 | /** @return \DateTimeImmutable[] */ 50 | public function nextRuns(): array 51 | { 52 | return $this->nextRuns; 53 | } 54 | 55 | public function preventOverlapping(): bool 56 | { 57 | return $this->preventOverlapping; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Application/Service/ClosureSerializerInterface.php: -------------------------------------------------------------------------------- 1 | */ 14 | private $config; 15 | 16 | public function __construct( 17 | private readonly ConfigurationParserInterface $configurationParser, 18 | private readonly FilesystemInterface $filesystem, 19 | ) { 20 | } 21 | 22 | /** 23 | * Return a parameter based on a key. 24 | */ 25 | public function get(string $key, mixed $default = null): mixed 26 | { 27 | if (null === $this->config) { 28 | $this->config = $this->configurationParser 29 | ->parseConfig(); 30 | } 31 | 32 | if (\array_key_exists($key, $this->config)) { 33 | return $this->config[$key]; 34 | } 35 | 36 | $parts = \explode('.', $key); 37 | 38 | $value = $this->config; 39 | foreach ($parts as $part) { 40 | if (!\is_array($value) || !\array_key_exists($part, $value)) { 41 | return $default; 42 | } 43 | 44 | $value = $value[$part]; 45 | } 46 | 47 | return $value; 48 | } 49 | 50 | /** 51 | * Set a parameter based on key/value. 52 | */ 53 | public function withNewEntry(string $key, mixed $value): ConfigurationInterface 54 | { 55 | $newConfiguration = clone $this; 56 | 57 | if (null === $newConfiguration->config) { 58 | $newConfiguration->config = $newConfiguration->configurationParser 59 | ->parseConfig(); 60 | } 61 | 62 | $parts = \explode('.', $key); 63 | 64 | if (\count($parts) > 1) { 65 | if (\array_key_exists($parts[0], $newConfiguration->config) && \is_array($newConfiguration->config[$parts[0]])) { 66 | $newConfiguration->config[$parts[0]][$parts[1]] = $value; 67 | } else { 68 | $newConfiguration->config[$parts[0]] = [$parts[1] => $value]; 69 | } 70 | } else { 71 | $newConfiguration->config[$key] = $value; 72 | } 73 | 74 | return $newConfiguration; 75 | } 76 | 77 | public function getSourcePath(): string 78 | { 79 | $sourcePath = Path::create( 80 | [ 81 | $this->filesystem 82 | ->getCwd(), 83 | $this->get('source', 'tasks'), 84 | ] 85 | ); 86 | 87 | return $sourcePath->toString(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Configuration/ConfigurationParser.php: -------------------------------------------------------------------------------- 1 | configFilePath(); 33 | $parsedConfig = $this->fileParser 34 | ->parse($configFile); 35 | 36 | $configFileParsed = true; 37 | } catch (ConfigFileNotExistsException $exception) { 38 | $this->consoleLogger 39 | ->debug("Config file not found, exception message: '{$exception->getMessage()}'."); 40 | } catch (ConfigFileNotReadableException $exception) { 41 | $this->consoleLogger 42 | ->debug("Config file is not readable, exception message: '{$exception->getMessage()}'."); 43 | } 44 | 45 | if (false === $configFileParsed) { 46 | $this->consoleLogger 47 | ->verbose('Unable to find/parse config file, fallback to default values.'); 48 | } else { 49 | $this->consoleLogger 50 | ->verbose("Using config file {$configFile}."); 51 | } 52 | 53 | return $this->definitionProcessor 54 | ->processConfiguration( 55 | $this->configurationDefinition, 56 | $parsedConfig 57 | ); 58 | } 59 | 60 | /** @throws ConfigFileNotExistsException */ 61 | private function configFilePath(): string 62 | { 63 | $cwd = $this->filesystem 64 | ->getCwd(); 65 | $configPath = Path::fromStrings($cwd ?? '', ConfigGeneratorCommand::CONFIG_FILE_NAME)->toString(); 66 | $configExists = $this->filesystem 67 | ->fileExists($configPath); 68 | 69 | if ($configExists) { 70 | return $configPath; 71 | } 72 | 73 | throw new ConfigFileNotExistsException( 74 | \sprintf( 75 | 'Unable to find config file "%s".', 76 | $configPath 77 | ) 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Configuration/ConfigurationParserInterface.php: -------------------------------------------------------------------------------- 1 | */ 10 | public function parseConfig(): array; 11 | } 12 | -------------------------------------------------------------------------------- /src/Configuration/Definition.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 18 | $rootNode 19 | 20 | ->children() 21 | 22 | ->scalarNode('source') 23 | ->cannotBeEmpty() 24 | ->info('path to the tasks directory' . PHP_EOL) 25 | ->end() 26 | 27 | ->scalarNode('suffix') 28 | ->defaultValue('Tasks.php') 29 | ->info('The suffix for filenames' . PHP_EOL) 30 | ->end() 31 | 32 | ->scalarNode('timezone') 33 | ->info('Timezone used to calculate task run date') 34 | ->end() 35 | 36 | ->booleanNode('timezone_log') 37 | ->defaultFalse() 38 | ->info('Whether configured "timezone" will be used for logs') 39 | ->end() 40 | 41 | ->scalarNode('logger_factory') 42 | ->defaultValue(PsrStreamLoggerFactory::class) 43 | ->cannotBeEmpty() 44 | ->info("Class name implementing 'LoggerFactoryInterface'. Use it to provider your own logger.") 45 | ->end() 46 | 47 | ->booleanNode('log_errors') 48 | ->defaultFalse() 49 | ->info('Flag for logging errors' . PHP_EOL) 50 | ->end() 51 | 52 | ->scalarNode('errors_log_file') 53 | ->defaultValue('/dev/null') 54 | ->info('Path to errors log' . PHP_EOL) 55 | ->end() 56 | 57 | ->booleanNode('log_output') 58 | ->defaultFalse() 59 | ->info('Flag for logging output' . PHP_EOL) 60 | ->end() 61 | 62 | ->scalarNode('output_log_file') 63 | ->defaultValue('/dev/null') 64 | ->info('Path to output logs' . PHP_EOL) 65 | ->end() 66 | 67 | ->scalarNode('log_allow_line_breaks') 68 | ->defaultFalse() 69 | ->info('Flag for line breaks in logs' . PHP_EOL) 70 | ->end() 71 | 72 | ->scalarNode('log_ignore_empty_context') 73 | ->defaultFalse() 74 | ->info('Flag for empty context in logs' . PHP_EOL) 75 | ->end() 76 | 77 | ->scalarNode('email_output') 78 | ->defaultFalse() 79 | ->info('Email the event\'s output' . PHP_EOL) 80 | ->end() 81 | 82 | ->scalarNode('email_errors') 83 | ->defaultFalse() 84 | ->info('Notify by email in case of an error' . PHP_EOL) 85 | ->end() 86 | 87 | ->arrayNode('mailer') 88 | 89 | ->children() 90 | 91 | ->scalarNode('transport') 92 | ->info('The type the Swift transporter' . PHP_EOL) 93 | ->end() 94 | 95 | ->arrayNode('recipients') 96 | ->prototype('scalar')->end() 97 | ->info('List of the email recipients' . PHP_EOL) 98 | ->end() 99 | 100 | ->scalarNode('sender_name') 101 | ->info('The sender name' . PHP_EOL) 102 | ->end() 103 | 104 | ->scalarNode('sender_email') 105 | ->info('The sender email' . PHP_EOL) 106 | ->end() 107 | 108 | ->end() 109 | 110 | ->end() 111 | 112 | ->arrayNode('smtp') 113 | 114 | ->children() 115 | 116 | ->scalarNode('host') 117 | ->info('SMTP host' . PHP_EOL) 118 | ->end() 119 | 120 | ->scalarNode('port') 121 | ->info('SMTP port' . PHP_EOL) 122 | ->end() 123 | 124 | ->scalarNode('username') 125 | ->info('SMTP username' . PHP_EOL) 126 | ->end() 127 | 128 | ->scalarNode('password') 129 | ->info('SMTP password' . PHP_EOL) 130 | ->end() 131 | 132 | ->scalarNode('encryption') 133 | ->info('SMTP encryption' . PHP_EOL) 134 | ->end() 135 | 136 | ->end() 137 | 138 | ->end() 139 | 140 | ->end() 141 | ; 142 | 143 | return $treeBuilder; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Configuration/FileParser.php: -------------------------------------------------------------------------------- 1 | 17 | * 18 | * @throws ConfigFileNotExistsException 19 | * @throws ConfigFileNotReadableException 20 | */ 21 | public function parse(string $configPath): array 22 | { 23 | if (!\file_exists($configPath)) { 24 | throw ConfigFileNotExistsException::fromFilePath($configPath); 25 | } 26 | 27 | if (!\is_readable($configPath)) { 28 | throw ConfigFileNotReadableException::fromFilePath($configPath); 29 | } 30 | 31 | $yamlParser = $this->yamlParser; 32 | $configContent = \file_get_contents($configPath); 33 | 34 | if (false === $configContent) { 35 | throw ConfigFileNotReadableException::fromFilePath($configPath); 36 | } 37 | 38 | return [$yamlParser::parse($configContent)]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Console/Command/Command.php: -------------------------------------------------------------------------------- 1 | */ 12 | protected $arguments; 13 | 14 | /** @var array */ 15 | protected $options; 16 | 17 | /** 18 | * Input object. 19 | * 20 | * @var \Symfony\Component\Console\Input\InputInterface 21 | */ 22 | protected $input; 23 | 24 | /** 25 | * output object. 26 | * 27 | * @var \Symfony\Component\Console\Output\OutputInterface 28 | */ 29 | protected $output; 30 | } 31 | -------------------------------------------------------------------------------- /src/Console/Command/ConfigGeneratorCommand.php: -------------------------------------------------------------------------------- 1 | setName('publish:config') 35 | ->setDescription("Generates a config file within the project's root directory.") 36 | ->setHelp("This generates a config file in YML format within the project's root directory.") 37 | ; 38 | } 39 | 40 | protected function execute(InputInterface $input, OutputInterface $output): int 41 | { 42 | $symfonyStyleIo = new SymfonyStyle($input, $output); 43 | $cwd = $this->filesystem 44 | ->getCwd(); 45 | $path = Path::create([$cwd, self::CONFIG_FILE_NAME])->toString(); 46 | $destination = \realpath($path) ?: $path; 47 | $configExists = $this->filesystem 48 | ->fileExists($destination) 49 | ; 50 | 51 | $output->writeln( 52 | "Destination config file: '{$destination}'.", 53 | OutputInterface::VERBOSITY_VERBOSE 54 | ); 55 | 56 | if ($configExists) { 57 | $output->writeln( 58 | "The configuration file already exists at '{$destination}'." 59 | ); 60 | 61 | return 0; 62 | } 63 | 64 | $projectRoot = $this->filesystem 65 | ->projectRootDirectory(); 66 | $srcPath = Path::fromStrings( 67 | $projectRoot, 68 | 'resources', 69 | 'config', 70 | self::CONFIG_FILE_NAME 71 | ); 72 | $src = $srcPath->toString(); 73 | $output->writeln( 74 | "Source config file: '{$src}'.", 75 | OutputInterface::VERBOSITY_VERBOSE 76 | ); 77 | $defaultTimezone = $this->askForTimezone($symfonyStyleIo); 78 | $output->writeln( 79 | "Provided timezone: '{$defaultTimezone}'.", 80 | OutputInterface::VERBOSITY_VERBOSE 81 | ); 82 | 83 | $this->updateTimezone( 84 | $destination, 85 | $src, 86 | $defaultTimezone 87 | ); 88 | 89 | $output->writeln('The configuration file was generated successfully.'); 90 | 91 | return 0; 92 | } 93 | 94 | /** 95 | * @return string 96 | */ 97 | protected function askForTimezone(SymfonyStyle $symfonyStyleIo) 98 | { 99 | $defaultTimezone = $this->timezoneProvider 100 | ->defaultTimezone() 101 | ->getName() 102 | ; 103 | $question = new Question( 104 | 'Please provide default timezone for task run date calculations', 105 | $defaultTimezone 106 | ); 107 | $question->setAutocompleterValues(\DateTimeZone::listIdentifiers()); 108 | $question->setValidator( 109 | static function ($answer) { 110 | try { 111 | new \DateTimeZone($answer); 112 | } catch (\Exception) { 113 | throw new \Exception("Timezone '{$answer}' is not valid. Please provide valid timezone."); 114 | } 115 | 116 | return $answer; 117 | } 118 | ); 119 | 120 | return $symfonyStyleIo->askQuestion($question); 121 | } 122 | 123 | private function updateTimezone( 124 | string $destination, 125 | string $src, 126 | string $timezone, 127 | ): void { 128 | $this->symfonyFilesystem 129 | ->dumpFile( 130 | $destination, 131 | \str_replace( 132 | 'timezone: ~', 133 | "timezone: {$timezone}", 134 | $this->filesystem 135 | ->readContent($src) 136 | ) 137 | ) 138 | ; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Console/Command/ScheduleListCommand.php: -------------------------------------------------------------------------------- 1 | setName('schedule:list') 43 | ->setDescription('Displays the list of scheduled tasks.') 44 | ->setDefinition( 45 | [ 46 | new InputArgument( 47 | 'source', 48 | InputArgument::OPTIONAL, 49 | 'The source directory for collecting the tasks.', 50 | $this->configuration 51 | ->getSourcePath() 52 | ), 53 | ] 54 | ) 55 | ->addOption( 56 | 'format', 57 | 'f', 58 | InputOption::VALUE_REQUIRED, 59 | "Tasks list format, possible formats: \"{$possibleFormats}\".", 60 | self::FORMAT_TEXT, 61 | ) 62 | ; 63 | } 64 | 65 | /** 66 | * @throws WrongTaskInstanceException 67 | */ 68 | protected function execute(InputInterface $input, OutputInterface $output): int 69 | { 70 | /** @var string $source */ 71 | $source = $input->getArgument('source'); 72 | $format = $this->resolveFormat($input); 73 | $tasks = $this->tasks($source); 74 | if (!\count($tasks)) { 75 | $output->writeln('No task found!'); 76 | 77 | return 0; 78 | } 79 | 80 | $this->printList( 81 | $output, 82 | $tasks, 83 | $format, 84 | ); 85 | 86 | return 0; 87 | } 88 | 89 | /** 90 | * @return array< 91 | * int, 92 | * array{ 93 | * number: int, 94 | * task: string, 95 | * expression: string, 96 | * command: string, 97 | * }, 98 | * > 99 | */ 100 | private function tasks(string $source): array 101 | { 102 | /** @var \SplFileInfo[] $tasks */ 103 | $tasks = $this->taskCollection 104 | ->all($source) 105 | ; 106 | $schedules = $this->taskLoader 107 | ->load(...\array_values($tasks)) 108 | ; 109 | 110 | $tasksList = []; 111 | $number = 0; 112 | 113 | foreach ($schedules as $schedule) { 114 | $events = $schedule->events(); 115 | foreach ($events as $event) { 116 | $tasksList[] = [ 117 | 'number' => ++$number, 118 | 'task' => $event->description ?? '', 119 | 'expression' => $event->getExpression(), 120 | 'command' => $event->getCommandForDisplay(), 121 | ]; 122 | } 123 | } 124 | 125 | return $tasksList; 126 | } 127 | 128 | private function resolveFormat(InputInterface $input): string 129 | { 130 | /** @var string $format */ 131 | $format = $input->getOption('format'); 132 | $isValidFormat = \in_array( 133 | $format, 134 | self::FORMATS, 135 | true, 136 | ); 137 | 138 | if (false === $isValidFormat) { 139 | throw new CrunzException("Format '{$format}' is not supported."); 140 | } 141 | 142 | return $format; 143 | } 144 | 145 | /** 146 | * @param array< 147 | * int, 148 | * array{ 149 | * number: int, 150 | * task: string, 151 | * expression: string, 152 | * command: string, 153 | * }, 154 | * > $tasks 155 | */ 156 | private function printList( 157 | OutputInterface $output, 158 | array $tasks, 159 | string $format, 160 | ): void { 161 | switch ($format) { 162 | case self::FORMAT_TEXT: 163 | $table = new Table($output); 164 | $table->setHeaders( 165 | [ 166 | '#', 167 | 'Task', 168 | 'Expression', 169 | 'Command to Run', 170 | ] 171 | ); 172 | 173 | foreach ($tasks as $task) { 174 | $table->addRow($task); 175 | } 176 | 177 | $table->render(); 178 | 179 | break; 180 | 181 | case self::FORMAT_JSON: 182 | $output->writeln( 183 | \json_encode( 184 | $tasks, 185 | JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT, 186 | ), 187 | ); 188 | 189 | break; 190 | 191 | default: 192 | throw new CrunzException("Unable to print list in format '{$format}'."); 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Console/Command/ScheduleRunCommand.php: -------------------------------------------------------------------------------- 1 | setName('schedule:run') 39 | ->setDescription('Starts the event runner.') 40 | ->setDefinition( 41 | [ 42 | new InputArgument( 43 | 'source', 44 | InputArgument::OPTIONAL, 45 | 'The source directory for collecting the task files.', 46 | $this->configuration 47 | ->getSourcePath() 48 | ), 49 | ] 50 | ) 51 | ->addOption( 52 | 'force', 53 | 'f', 54 | InputOption::VALUE_NONE, 55 | 'Run all tasks regardless of configured run time.' 56 | ) 57 | ->addOption( 58 | 'task', 59 | 't', 60 | InputOption::VALUE_REQUIRED, 61 | 'Which task to run. Provide task number from schedule:list command.', 62 | null 63 | ) 64 | ->setHelp('This command starts the Crunz event runner.'); 65 | } 66 | 67 | /** 68 | * @throws WrongTaskInstanceException 69 | */ 70 | protected function execute(InputInterface $input, OutputInterface $output): int 71 | { 72 | $this->arguments = $input->getArguments(); 73 | $this->options = $input->getOptions(); 74 | $task = $this->options['task']; 75 | /** @var string $source */ 76 | $source = $input->getArgument('source') ?? ''; 77 | /** @var \SplFileInfo[] $files */ 78 | $files = $this->taskCollection 79 | ->all($source) 80 | ; 81 | 82 | if (!\count($files)) { 83 | $output->writeln('No task found! Please check your source path.'); 84 | 85 | return 0; 86 | } 87 | 88 | // List of schedules 89 | $schedules = $this->taskLoader 90 | ->load(...\array_values($files)) 91 | ; 92 | $tasksTimezone = $this->taskTimezone 93 | ->timezoneForComparisons() 94 | ; 95 | 96 | // Is specified task should be invoked? 97 | if (\is_string($task)) { 98 | $schedules = $this->scheduleFactory 99 | ->singleTaskSchedule(TaskNumber::fromString($task), ...$schedules); 100 | } 101 | 102 | $forceRun = \filter_var($this->options['force'] ?? false, FILTER_VALIDATE_BOOLEAN); 103 | $schedules = \array_map( 104 | static function (Schedule $schedule) use ($tasksTimezone, $forceRun) { 105 | if (false === $forceRun) { 106 | // We keep the events which are due and dismiss the rest. 107 | $schedule->events( 108 | $schedule->dueEvents( 109 | $tasksTimezone 110 | ) 111 | ); 112 | } 113 | 114 | return $schedule; 115 | }, 116 | $schedules 117 | ); 118 | $schedules = \array_filter( 119 | $schedules, 120 | static fn (Schedule $schedule): bool => \count($schedule->events()) > 0 121 | ); 122 | 123 | if (!\count($schedules)) { 124 | $output->writeln('No event is due!'); 125 | 126 | return 0; 127 | } 128 | 129 | // Running the events 130 | $this->eventRunner 131 | ->handle($output, $schedules) 132 | ; 133 | 134 | return 0; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Console/Command/TaskGeneratorCommand.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final public const DEFAULTS = [ 24 | 'frequency' => 'everyThirtyMinutes', 25 | 'constraint' => 'weekdays', 26 | 'in' => 'path/to/your/command', 27 | 'run' => 'command/to/execute', 28 | 'description' => 'Task description', 29 | 'type' => 'basic', 30 | ]; 31 | /** 32 | * Stub content. 33 | * 34 | * @var string 35 | */ 36 | protected $stub; 37 | 38 | public function __construct( 39 | private readonly ConfigurationInterface $config, 40 | private readonly FilesystemInterface $filesystem, 41 | ) { 42 | parent::__construct(); 43 | } 44 | 45 | /** 46 | * Configures the current command. 47 | */ 48 | protected function configure(): void 49 | { 50 | $this 51 | ->setName('make:task') 52 | ->setDescription('Generates a task file with one task.') 53 | ->setDefinition( 54 | [ 55 | new InputArgument( 56 | 'taskfile', 57 | InputArgument::REQUIRED, 58 | 'The task file name' 59 | ), 60 | new InputOption( 61 | 'frequency', 62 | 'f', 63 | InputOption::VALUE_OPTIONAL, 64 | "The task's frequency", 65 | self::DEFAULTS['frequency'] 66 | ), 67 | new InputOption( 68 | 'constraint', 69 | 'c', 70 | InputOption::VALUE_OPTIONAL, 71 | "The task's constraint", 72 | self::DEFAULTS['constraint'] 73 | ), 74 | new InputOption( 75 | 'in', 76 | 'i', 77 | InputOption::VALUE_OPTIONAL, 78 | "The command's path", 79 | self::DEFAULTS['in'] 80 | ), 81 | new InputOption( 82 | 'run', 83 | 'r', 84 | InputOption::VALUE_OPTIONAL, 85 | "The task's command", 86 | self::DEFAULTS['run'] 87 | ), 88 | new InputOption( 89 | 'description', 90 | 'd', 91 | InputOption::VALUE_OPTIONAL, 92 | "The task's description", 93 | self::DEFAULTS['description'] 94 | ), 95 | new InputOption( 96 | 'type', 97 | 't', 98 | InputOption::VALUE_OPTIONAL, 99 | 'The task type', 100 | self::DEFAULTS['type'] 101 | ), 102 | ] 103 | ) 104 | ->setHelp('This command makes a task file skeleton.'); 105 | } 106 | 107 | protected function execute(InputInterface $input, OutputInterface $output): int 108 | { 109 | $this->input = $input; 110 | $this->output = $output; 111 | 112 | $this->arguments = $input->getArguments(); 113 | $this->options = $input->getOptions(); 114 | $this->stub = $this->getStub(); 115 | 116 | if ($this->stub) { 117 | $this 118 | ->replaceFrequency() 119 | ->replaceConstraint() 120 | ->replaceCommand() 121 | ->replacePath() 122 | ->replaceDescription() 123 | ; 124 | } 125 | 126 | if ($this->save()) { 127 | $output->writeln('The task file generated successfully'); 128 | } else { 129 | $output->writeln('There was a problem when generating the file. Please check your command.'); 130 | } 131 | 132 | return 0; 133 | } 134 | 135 | /** 136 | * Save the generate task skeleton into a file. 137 | * 138 | * @return bool 139 | */ 140 | protected function save() 141 | { 142 | $filename = Path::create([$this->outputPath(), $this->outputFile()]); 143 | 144 | return (bool) \file_put_contents($filename->toString(), $this->stub); 145 | } 146 | 147 | /** 148 | * Ask a question. 149 | * 150 | * @param string $question 151 | * 152 | * @return ?string 153 | */ 154 | protected function ask($question) 155 | { 156 | $helper = $this->getHelper('question'); 157 | $question = new Question("{$question}"); 158 | 159 | return $helper->ask($this->input, $this->output, $question); 160 | } 161 | 162 | /** 163 | * Return the output path. 164 | * 165 | * @return string 166 | */ 167 | protected function outputPath() 168 | { 169 | $source = $this->config 170 | ->getSourcePath() 171 | ; 172 | $destination = $this->ask('Where do you want to save the file? (Press enter for the current directory)'); 173 | $outputPath = $destination ?? $source; 174 | 175 | if (!\file_exists($outputPath)) { 176 | \mkdir($outputPath, 0744, true); 177 | } 178 | 179 | return $outputPath; 180 | } 181 | 182 | /** 183 | * Populate the output filename. 184 | * 185 | * @return string 186 | */ 187 | protected function outputFile() 188 | { 189 | /** @var string $suffix */ 190 | $suffix = $this->config 191 | ->get('suffix') 192 | ; 193 | /** @var string $taskFile */ 194 | $taskFile = $this->arguments['taskfile']; 195 | 196 | return \preg_replace('/Tasks|\.php$/', '', $taskFile) . $suffix; 197 | } 198 | 199 | /** 200 | * Get the task stub. 201 | * 202 | * @return string 203 | */ 204 | protected function getStub() 205 | { 206 | $projectRootDirectory = $this->filesystem 207 | ->projectRootDirectory(); 208 | $path = Path::fromStrings( 209 | $projectRootDirectory, 210 | 'src', 211 | 'Stubs', 212 | \ucfirst($this->type() . 'Task.php') 213 | ); 214 | 215 | return $this->filesystem 216 | ->readContent($path->toString()); 217 | } 218 | 219 | /** 220 | * Get the task type. 221 | * 222 | * @return string 223 | */ 224 | protected function type() 225 | { 226 | return $this->options['type']; 227 | } 228 | 229 | /** 230 | * Replace frequency. 231 | */ 232 | protected function replaceFrequency(): self 233 | { 234 | $this->stub = \str_replace('DummyFrequency', \rtrim($this->options['frequency'], '()'), $this->stub); 235 | 236 | return $this; 237 | } 238 | 239 | /** 240 | * Replace constraint. 241 | */ 242 | protected function replaceConstraint(): self 243 | { 244 | $this->stub = \str_replace('DummyConstraint', \rtrim($this->options['constraint'], '()'), $this->stub); 245 | 246 | return $this; 247 | } 248 | 249 | protected function replaceCommand(): self 250 | { 251 | $run = $this->optionString('run'); 252 | $this->stub = \str_replace('DummyCommand', $run, $this->stub); 253 | 254 | return $this; 255 | } 256 | 257 | protected function replacePath(): self 258 | { 259 | $in = $this->optionString('in'); 260 | $this->stub = \str_replace('DummyPath', $in, $this->stub); 261 | 262 | return $this; 263 | } 264 | 265 | protected function replaceDescription(): self 266 | { 267 | $description = $this->optionString('description'); 268 | $this->stub = \str_replace('DummyDescription', $description, $this->stub); 269 | 270 | return $this; 271 | } 272 | 273 | private function optionString(string $name): string 274 | { 275 | $option = $this->options[$name] ?? throw new \RuntimeException("Missing option '{$name}'."); 276 | if (false === \is_string($option)) { 277 | throw new \RuntimeException("Option must be of type 'string'."); 278 | } 279 | 280 | return $option; 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/EnvFlags/EnvFlags.php: -------------------------------------------------------------------------------- 1 | 147 | */ 148 | protected $fieldsPosition = [ 149 | 'minute' => 1, 150 | 'hour' => 2, 151 | 'day' => 3, 152 | 'month' => 4, 153 | 'week' => 5, 154 | ]; 155 | 156 | /** 157 | * Indicates if the command should not overlap itself. 158 | */ 159 | private bool $preventOverlapping = false; 160 | /** @var ClockInterface */ 161 | private static $clock; 162 | private static ?ClosureSerializerInterface $closureSerializer = null; 163 | 164 | /** 165 | * The symfony lock factory that is used to acquire locks. If the value is null, but preventOverlapping = true 166 | * crunz falls back to filesystem locks. 167 | */ 168 | private ?LockFactory $lockFactory = null; 169 | /** @var string[] */ 170 | private array $wholeOutput = []; 171 | /** @var Lock */ 172 | private $lock; 173 | /** @var \Closure[] */ 174 | private array $errorCallbacks = []; 175 | 176 | /** 177 | * Create a new event instance. 178 | * 179 | * @param string|\Closure $command 180 | * @param string|int $id 181 | */ 182 | public function __construct(protected $id, $command) 183 | { 184 | $this->command = $command; 185 | $this->output = $this->getDefaultOutput(); 186 | } 187 | 188 | /** 189 | * Change the current working directory. 190 | * 191 | * @param string $directory 192 | * 193 | * @return self 194 | */ 195 | public function in($directory) 196 | { 197 | $this->cwd = $directory; 198 | 199 | return $this; 200 | } 201 | 202 | /** 203 | * Determine if the event's output is sent to null. 204 | * 205 | * @return bool 206 | */ 207 | public function nullOutput() 208 | { 209 | return 'NUL' === $this->output || '/dev/null' === $this->output; 210 | } 211 | 212 | /** 213 | * Build the command string. 214 | * 215 | * @return string 216 | */ 217 | public function buildCommand() 218 | { 219 | $command = ''; 220 | 221 | if ($this->cwd) { 222 | if ($this->user) { 223 | $command .= $this->sudo($this->user); 224 | } 225 | 226 | // Support changing drives in Windows 227 | $cdParameter = $this->isWindows() ? '/d ' : ''; 228 | $andSign = $this->isWindows() ? ' &' : ';'; 229 | 230 | $command .= "cd {$cdParameter}{$this->cwd}{$andSign} "; 231 | } 232 | 233 | if ($this->user) { 234 | $command .= $this->sudo($this->user); 235 | } 236 | 237 | $command .= \is_string($this->command) 238 | ? $this->command 239 | : $this->serializeClosure($this->command) 240 | ; 241 | 242 | return \trim($command, '& '); 243 | } 244 | 245 | /** 246 | * Determine whether the passed value is a closure or not. 247 | * 248 | * @return bool 249 | */ 250 | public function isClosure() 251 | { 252 | return \is_object($this->command) && ($this->command instanceof \Closure); 253 | } 254 | 255 | /** 256 | * Determine if the given event should run based on the Cron expression. 257 | * 258 | * @return bool 259 | */ 260 | public function isDue(\DateTimeZone $timeZone) 261 | { 262 | return $this->expressionPasses($timeZone) && $this->filtersPass($timeZone); 263 | } 264 | 265 | /** 266 | * Determine if the filters pass for the event. 267 | * 268 | * @return bool 269 | */ 270 | public function filtersPass(\DateTimeZone $timeZone) 271 | { 272 | $invoker = new Invoker(); 273 | 274 | foreach ($this->filters as $callback) { 275 | if (!$invoker->call($callback)) { 276 | return false; 277 | } 278 | } 279 | 280 | foreach ($this->rejects as $callback) { 281 | if ($invoker->call($callback, [$timeZone])) { 282 | return false; 283 | } 284 | } 285 | 286 | return true; 287 | } 288 | 289 | /** @return string */ 290 | public function wholeOutput() 291 | { 292 | return \implode('', $this->wholeOutput); 293 | } 294 | 295 | /** 296 | * Start the event execution. 297 | * 298 | * @return int 299 | */ 300 | public function start() 301 | { 302 | $command = $this->buildCommand(); 303 | $process = Process::fromStringCommand($command); 304 | 305 | $this->setProcess($process); 306 | $this->getProcess()->start( 307 | function ($type, $content): void { 308 | $this->wholeOutput[] = $content; 309 | } 310 | ); 311 | 312 | if ($this->preventOverlapping) { 313 | $this->lock(); 314 | } 315 | 316 | /** @var int $pid */ 317 | $pid = $this->getProcess() 318 | ->getPid(); 319 | 320 | return $pid; 321 | } 322 | 323 | /** 324 | * The Cron expression representing the event's frequency. 325 | * 326 | * @throws TaskException 327 | */ 328 | public function cron(string $expression): self 329 | { 330 | $parts = \preg_split( 331 | '/\s/', 332 | $expression, 333 | -1, 334 | PREG_SPLIT_NO_EMPTY 335 | ); 336 | $parts = false === $parts 337 | ? [] 338 | : $parts 339 | ; 340 | 341 | if (\count($parts) > 5) { 342 | throw new TaskException("Expression '{$expression}' has more than five parts and this is not allowed."); 343 | } 344 | 345 | $this->expression = $expression; 346 | 347 | return $this; 348 | } 349 | 350 | /** 351 | * Schedule the event to run hourly. 352 | */ 353 | public function hourly(): self 354 | { 355 | return $this->hourlyAt(0); 356 | } 357 | 358 | public function hourlyAt(int $minute): self 359 | { 360 | if ($minute < 0) { 361 | throw new CrunzException("Minute cannot be lower than '0'."); 362 | } 363 | 364 | if ($minute > 59) { 365 | throw new CrunzException("Minute cannot be greater than '59'."); 366 | } 367 | 368 | return $this->cron("{$minute} * * * *"); 369 | } 370 | 371 | /** 372 | * Schedule the event to run daily. 373 | */ 374 | public function daily(): self 375 | { 376 | return $this->cron('0 0 * * *'); 377 | } 378 | 379 | /** 380 | * Schedule the event to run on a certain date. 381 | * 382 | * @param string $date 383 | * 384 | * @return $this 385 | */ 386 | public function on($date) 387 | { 388 | $parsedDate = \date_parse($date); 389 | if (false === $parsedDate) { 390 | $parsedDate = []; 391 | } 392 | 393 | $segments = \array_intersect_key($parsedDate, $this->fieldsPosition); 394 | 395 | if ($parsedDate['year']) { 396 | $this->skip(static fn () => (int) \date('Y') !== $parsedDate['year']); 397 | } 398 | 399 | foreach ($segments as $key => $value) { 400 | if (false !== $value) { 401 | $this->spliceIntoPosition($this->fieldsPosition[$key], (string) $value); 402 | } 403 | } 404 | 405 | return $this; 406 | } 407 | 408 | /** 409 | * Schedule the command at a given time. 410 | * 411 | * @param string $time 412 | */ 413 | public function at($time): self 414 | { 415 | return $this->dailyAt($time); 416 | } 417 | 418 | /** 419 | * Schedule the event to run daily at a given time (10:00, 19:30, etc). 420 | * 421 | * @param string $time 422 | */ 423 | public function dailyAt($time): self 424 | { 425 | $segments = \explode(':', $time); 426 | $firstSegment = (int) $segments[0]; 427 | $secondSegment = \count($segments) > 1 428 | ? (int) $segments[1] 429 | : '0' 430 | ; 431 | 432 | return $this 433 | ->spliceIntoPosition(2, (string) $firstSegment) 434 | ->spliceIntoPosition(1, (string) $secondSegment) 435 | ; 436 | } 437 | 438 | /** 439 | * Set Working period. 440 | * 441 | * @param string $from 442 | * @param string $to 443 | * 444 | * @return self 445 | */ 446 | public function between($from, $to) 447 | { 448 | return $this->from($from) 449 | ->to($to); 450 | } 451 | 452 | /** 453 | * Check if event should be on. 454 | * 455 | * @param string $datetime 456 | * 457 | * @return self 458 | */ 459 | public function from($datetime) 460 | { 461 | $this->from = $datetime; 462 | 463 | return $this->skip( 464 | fn (\DateTimeZone $timeZone) => $this->notYet($datetime, $timeZone) 465 | ); 466 | } 467 | 468 | /** 469 | * Check if event should be off. 470 | * 471 | * @param string $datetime 472 | * 473 | * @return self 474 | */ 475 | public function to($datetime) 476 | { 477 | $this->to = $datetime; 478 | 479 | return $this->skip( 480 | fn (\DateTimeZone $timeZone) => $this->past($datetime, $timeZone), 481 | ); 482 | } 483 | 484 | /** 485 | * Schedule the event to run twice daily. 486 | * 487 | * @param int $first 488 | * @param int $second 489 | */ 490 | public function twiceDaily($first = 1, $second = 13): self 491 | { 492 | $hours = $first . ',' . $second; 493 | 494 | return $this 495 | ->spliceIntoPosition(1, '0') 496 | ->spliceIntoPosition(2, $hours) 497 | ; 498 | } 499 | 500 | /** 501 | * Schedule the event to run only on weekdays. 502 | */ 503 | public function weekdays(): self 504 | { 505 | return $this->spliceIntoPosition(5, '1-5'); 506 | } 507 | 508 | /** 509 | * Schedule the event to run only on Mondays. 510 | */ 511 | public function mondays(): self 512 | { 513 | return $this->days(1); 514 | } 515 | 516 | /** 517 | * Schedule the event to run only on Tuesdays. 518 | */ 519 | public function tuesdays(): self 520 | { 521 | return $this->days(2); 522 | } 523 | 524 | /** 525 | * Schedule the event to run only on Wednesdays. 526 | */ 527 | public function wednesdays(): self 528 | { 529 | return $this->days(3); 530 | } 531 | 532 | /** 533 | * Schedule the event to run only on Thursdays. 534 | */ 535 | public function thursdays(): self 536 | { 537 | return $this->days(4); 538 | } 539 | 540 | /** 541 | * Schedule the event to run only on Fridays. 542 | */ 543 | public function fridays(): self 544 | { 545 | return $this->days(5); 546 | } 547 | 548 | /** 549 | * Schedule the event to run only on Saturdays. 550 | */ 551 | public function saturdays(): self 552 | { 553 | return $this->days(6); 554 | } 555 | 556 | /** 557 | * Schedule the event to run only on Sundays. 558 | */ 559 | public function sundays(): self 560 | { 561 | return $this->days(0); 562 | } 563 | 564 | /** 565 | * Schedule the event to run weekly. 566 | */ 567 | public function weekly(): self 568 | { 569 | return $this->cron('0 0 * * 0'); 570 | } 571 | 572 | /** 573 | * Schedule the event to run weekly on a given day and time. 574 | * 575 | * @param string $time 576 | */ 577 | public function weeklyOn(int|string $day, $time = '0:0'): self 578 | { 579 | $this->dailyAt($time); 580 | 581 | return $this->spliceIntoPosition(5, (string) $day); 582 | } 583 | 584 | /** 585 | * Schedule the event to run monthly. 586 | */ 587 | public function monthly(): self 588 | { 589 | return $this->cron('0 0 1 * *'); 590 | } 591 | 592 | /** 593 | * Schedule the event to run quarterly. 594 | */ 595 | public function quarterly(): self 596 | { 597 | return $this->cron('0 0 1 */3 *'); 598 | } 599 | 600 | /** 601 | * Schedule the event to run yearly. 602 | */ 603 | public function yearly(): self 604 | { 605 | return $this->cron('0 0 1 1 *'); 606 | } 607 | 608 | /** 609 | * Set the days of the week the command should run on. 610 | */ 611 | public function days(mixed $days): self 612 | { 613 | $days = \is_array($days) ? $days : \func_get_args(); 614 | 615 | return $this->spliceIntoPosition(5, \implode(',', $days)); 616 | } 617 | 618 | /** 619 | * Set hour for the cron job. 620 | */ 621 | public function hour(mixed $value): self 622 | { 623 | $value = \is_array($value) ? $value : \func_get_args(); 624 | 625 | return $this->spliceIntoPosition(2, \implode(',', $value)); 626 | } 627 | 628 | /** 629 | * Set minute for the cron job. 630 | */ 631 | public function minute(mixed $value): self 632 | { 633 | $value = \is_array($value) ? $value : \func_get_args(); 634 | 635 | return $this->spliceIntoPosition(1, \implode(',', $value)); 636 | } 637 | 638 | /** 639 | * Set hour for the cron job. 640 | */ 641 | public function dayOfMonth(mixed $value): self 642 | { 643 | $value = \is_array($value) ? $value : \func_get_args(); 644 | 645 | return $this->spliceIntoPosition(3, \implode(',', $value)); 646 | } 647 | 648 | /** 649 | * Set hour for the cron job. 650 | */ 651 | public function month(mixed $value): self 652 | { 653 | $value = \is_array($value) ? $value : \func_get_args(); 654 | 655 | return $this->spliceIntoPosition(4, \implode(',', $value)); 656 | } 657 | 658 | /** 659 | * Set hour for the cron job. 660 | */ 661 | public function dayOfWeek(mixed $value): self 662 | { 663 | $value = \is_array($value) ? $value : \func_get_args(); 664 | 665 | return $this->spliceIntoPosition(5, \implode(',', $value)); 666 | } 667 | 668 | /** 669 | * Set the timezone the date should be evaluated on. 670 | * 671 | * @return $this 672 | */ 673 | public function timezone(\DateTimeZone|string $timezone) 674 | { 675 | $this->timezone = $timezone; 676 | 677 | return $this; 678 | } 679 | 680 | /** 681 | * Set which user the command should run as. 682 | * 683 | * @param string $user 684 | * 685 | * @return $this 686 | */ 687 | public function user($user) 688 | { 689 | if ($this->isWindows()) { 690 | throw new NotImplementedException('Changing user on Windows is not implemented.'); 691 | } 692 | 693 | $this->user = $user; 694 | 695 | return $this; 696 | } 697 | 698 | /** 699 | * Do not allow the event to overlap each other. 700 | * 701 | * By default, the lock is acquired through file system locks. Alternatively, you can pass a symfony lock store 702 | * that will be responsible for the locking. 703 | * 704 | * @param PersistingStoreInterface|object $store 705 | * 706 | * @return $this 707 | */ 708 | public function preventOverlapping(?object $store = null) 709 | { 710 | if (null !== $store && !($store instanceof PersistingStoreInterface)) { 711 | $expectedClass = PersistingStoreInterface::class; 712 | $actualClass = $store::class; 713 | 714 | throw new \RuntimeException( 715 | "Instance of '{$expectedClass}' is expected, '{$actualClass}' provided" 716 | ); 717 | } 718 | 719 | $lockStore = $store ?: $this->createDefaultLockStore(); 720 | $this->preventOverlapping = true; 721 | $this->lockFactory = new LockFactory($lockStore); 722 | 723 | // Skip the event if it's locked (processing) 724 | $this->skip(function () { 725 | $lock = $this->createLockObject(); 726 | $lock->acquire(); 727 | 728 | return !$lock->isAcquired(); 729 | }); 730 | 731 | $releaseCallback = function (): void { 732 | $this->releaseLock(); 733 | }; 734 | 735 | // Delete the lock file when the event is completed 736 | $this->after($releaseCallback); 737 | // Or on error 738 | $this->addErrorCallback($releaseCallback); 739 | 740 | return $this; 741 | } 742 | 743 | /** 744 | * Register a callback to further filter the schedule. 745 | * 746 | * @return $this 747 | */ 748 | public function when(\Closure $callback) 749 | { 750 | $this->filters[] = $callback; 751 | 752 | return $this; 753 | } 754 | 755 | /** 756 | * Register a callback to further filter the schedule. 757 | * 758 | * @return $this 759 | */ 760 | public function skip(\Closure $callback) 761 | { 762 | $this->rejects[] = $callback; 763 | 764 | return $this; 765 | } 766 | 767 | /** 768 | * Send the output of the command to a given location. 769 | * 770 | * @param string $location 771 | * @param bool $append 772 | * 773 | * @return $this 774 | */ 775 | public function sendOutputTo($location, $append = false) 776 | { 777 | $this->output = $location; 778 | 779 | $this->shouldAppendOutput = $append; 780 | 781 | return $this; 782 | } 783 | 784 | /** 785 | * Append the output of the command to a given location. 786 | * 787 | * @param string $location 788 | * 789 | * @return $this 790 | */ 791 | public function appendOutputTo($location) 792 | { 793 | return $this->sendOutputTo($location, true); 794 | } 795 | 796 | /** 797 | * Register a callback to be called before the operation. 798 | * 799 | * @return $this 800 | */ 801 | public function before(\Closure $callback) 802 | { 803 | $this->beforeCallbacks[] = $callback; 804 | 805 | return $this; 806 | } 807 | 808 | /** 809 | * Register a callback to be called after the operation. 810 | * 811 | * @return $this 812 | */ 813 | public function after(\Closure $callback) 814 | { 815 | return $this->then($callback); 816 | } 817 | 818 | /** 819 | * Register a callback to be called after the operation. 820 | * 821 | * @return $this 822 | */ 823 | public function then(\Closure $callback) 824 | { 825 | $this->afterCallbacks[] = $callback; 826 | 827 | return $this; 828 | } 829 | 830 | /** 831 | * Set the human-friendly description of the event. 832 | * 833 | * @param string $description 834 | * 835 | * @return $this 836 | */ 837 | public function name($description) 838 | { 839 | return $this->description($description); 840 | } 841 | 842 | /** 843 | * Return the event's process. 844 | * 845 | * @return Process $process 846 | */ 847 | public function getProcess() 848 | { 849 | return $this->process; 850 | } 851 | 852 | /** 853 | * Set the human-friendly description of the event. 854 | * 855 | * @param string $description 856 | * 857 | * @return $this 858 | */ 859 | public function description($description) 860 | { 861 | $this->description = $description; 862 | 863 | return $this; 864 | } 865 | 866 | /** 867 | * Another way to the frequency of the cron job. 868 | * 869 | * @param string $unit 870 | * @param float|int|null $value 871 | */ 872 | public function every($unit = null, $value = null): self 873 | { 874 | if (null === $unit || !isset($this->fieldsPosition[$unit])) { 875 | return $this; 876 | } 877 | 878 | $value = (1 === (int) $value) ? '*' : '*/' . $value; 879 | 880 | return $this->spliceIntoPosition($this->fieldsPosition[$unit], $value) 881 | ->applyMask($unit); 882 | } 883 | 884 | /** 885 | * Return the event's command. 886 | */ 887 | public function getId(): string|int 888 | { 889 | return $this->id; 890 | } 891 | 892 | /** 893 | * Get the summary of the event for display. 894 | * 895 | * @return string 896 | */ 897 | public function getSummaryForDisplay() 898 | { 899 | if (\is_string($this->description)) { 900 | return $this->description; 901 | } 902 | 903 | return $this->buildCommand(); 904 | } 905 | 906 | /** 907 | * Get the command for display. 908 | * 909 | * @return string 910 | */ 911 | public function getCommandForDisplay() 912 | { 913 | return $this->isClosure() ? 'object(Closure)' : $this->buildCommand(); 914 | } 915 | 916 | /** 917 | * Get the Cron expression for the event. 918 | * 919 | * @return string 920 | */ 921 | public function getExpression() 922 | { 923 | return $this->expression; 924 | } 925 | 926 | /** 927 | * Get the 'from' configuration for the event if present. 928 | */ 929 | public function getFrom(): \DateTime|string|null 930 | { 931 | return $this->from; 932 | } 933 | 934 | /** 935 | * Get the 'to' configuration for the event if present. 936 | */ 937 | public function getTo(): \DateTime|string|null 938 | { 939 | return $this->to; 940 | } 941 | 942 | /** 943 | * Set the event's command. 944 | * 945 | * @param string $command 946 | * 947 | * @return $this 948 | */ 949 | public function setCommand($command) 950 | { 951 | $this->command = $command; 952 | 953 | return $this; 954 | } 955 | 956 | /** 957 | * Return the event's command. 958 | */ 959 | public function getCommand(): string|\Closure 960 | { 961 | return $this->command; 962 | } 963 | 964 | /** 965 | * Return the current working directory. 966 | * 967 | * @return string 968 | */ 969 | public function getWorkingDirectory() 970 | { 971 | return $this->cwd; 972 | } 973 | 974 | /** 975 | * Return event's full output. 976 | * 977 | * @return string|null 978 | */ 979 | public function getOutputStream() 980 | { 981 | return $this->outputStream; 982 | } 983 | 984 | /** 985 | * Return all registered before callbacks. 986 | * 987 | * @return \Closure[] 988 | */ 989 | public function beforeCallbacks() 990 | { 991 | return $this->beforeCallbacks; 992 | } 993 | 994 | /** 995 | * Return all registered after callbacks. 996 | * 997 | * @return \Closure[] 998 | */ 999 | public function afterCallbacks() 1000 | { 1001 | return $this->afterCallbacks; 1002 | } 1003 | 1004 | /** @return \Closure[] */ 1005 | public function errorCallbacks() 1006 | { 1007 | return $this->errorCallbacks; 1008 | } 1009 | 1010 | /** 1011 | * If this event is prevented from overlapping, this method should be called regularly to refresh the lock. 1012 | */ 1013 | public function refreshLock(): void 1014 | { 1015 | if (!$this->preventOverlapping) { 1016 | return; 1017 | } 1018 | 1019 | $lock = $this->createLockObject(); 1020 | $remainingLifetime = $lock->getRemainingLifetime(); 1021 | 1022 | // Lock will never expire 1023 | if (null === $remainingLifetime) { 1024 | return; 1025 | } 1026 | 1027 | // Refresh 15s before lock expiration 1028 | $lockRefreshNeeded = $remainingLifetime < 15; 1029 | if ($lockRefreshNeeded) { 1030 | $lock->refresh(); 1031 | } 1032 | } 1033 | 1034 | public function everyMinute(): self 1035 | { 1036 | return $this->cron('* * * * *'); 1037 | } 1038 | 1039 | public function everyTwoMinutes(): self 1040 | { 1041 | return $this->cron('*/2 * * * *'); 1042 | } 1043 | 1044 | public function everyThreeMinutes(): self 1045 | { 1046 | return $this->cron('*/3 * * * *'); 1047 | } 1048 | 1049 | public function everyFourMinutes(): self 1050 | { 1051 | return $this->cron('*/4 * * * *'); 1052 | } 1053 | 1054 | public function everyFiveMinutes(): self 1055 | { 1056 | return $this->cron('*/5 * * * *'); 1057 | } 1058 | 1059 | public function everyTenMinutes(): self 1060 | { 1061 | return $this->cron('*/10 * * * *'); 1062 | } 1063 | 1064 | public function everyFifteenMinutes(): self 1065 | { 1066 | return $this->cron('*/15 * * * *'); 1067 | } 1068 | 1069 | public function everyThirtyMinutes(): self 1070 | { 1071 | return $this->cron('*/30 * * * *'); 1072 | } 1073 | 1074 | public function everyTwoHours(): self 1075 | { 1076 | return $this->cron('0 */2 * * *'); 1077 | } 1078 | 1079 | public function everyThreeHours(): self 1080 | { 1081 | return $this->cron('0 */3 * * *'); 1082 | } 1083 | 1084 | public function everyFourHours(): self 1085 | { 1086 | return $this->cron('0 */4 * * *'); 1087 | } 1088 | 1089 | public function everySixHours(): self 1090 | { 1091 | return $this->cron('0 */6 * * *'); 1092 | } 1093 | 1094 | /** 1095 | * Get the symfony lock object for the task. 1096 | * 1097 | * @return Lock 1098 | */ 1099 | protected function createLockObject() 1100 | { 1101 | $this->checkLockFactory(); 1102 | 1103 | if (null === $this->lock && null !== $this->lockFactory) { 1104 | $ttl = 30; 1105 | 1106 | $this->lock = $this->lockFactory 1107 | ->createLock($this->lockKey(), $ttl); 1108 | } 1109 | 1110 | return $this->lock; 1111 | } 1112 | 1113 | /** 1114 | * Release the lock after the command completed. 1115 | */ 1116 | protected function releaseLock(): void 1117 | { 1118 | $this->checkLockFactory(); 1119 | 1120 | $lock = $this->createLockObject(); 1121 | $lock->release(); 1122 | } 1123 | 1124 | /** 1125 | * Get the default output depending on the OS. 1126 | * 1127 | * @return string 1128 | */ 1129 | protected function getDefaultOutput() 1130 | { 1131 | return (DIRECTORY_SEPARATOR === '\\') ? 'NUL' : '/dev/null'; 1132 | } 1133 | 1134 | /** 1135 | * Add sudo to the command. 1136 | * 1137 | * @param string $user 1138 | * 1139 | * @return string 1140 | */ 1141 | protected function sudo($user) 1142 | { 1143 | return "sudo -u {$user} "; 1144 | } 1145 | 1146 | /** 1147 | * Convert closure to an executable command. 1148 | * 1149 | * @return string 1150 | */ 1151 | protected function serializeClosure(\Closure $closure) 1152 | { 1153 | $closure = $this->closureSerializer() 1154 | ->serialize($closure) 1155 | ; 1156 | $serializedClosure = \http_build_query([$closure]); 1157 | $crunzRoot = CRUNZ_BIN; 1158 | 1159 | return PHP_BINARY . " {$crunzRoot} closure:run {$serializedClosure}"; 1160 | } 1161 | 1162 | /** 1163 | * Determine if the Cron expression passes. 1164 | * 1165 | * @return bool 1166 | */ 1167 | protected function expressionPasses(\DateTimeZone $timeZone) 1168 | { 1169 | $now = $this->getClock() 1170 | ->now(); 1171 | $now = $now->setTimezone($timeZone); 1172 | 1173 | if ($this->timezone) { 1174 | $taskTimeZone = \is_object($this->timezone) && $this->timezone instanceof \DateTimeZone 1175 | ? $this->timezone 1176 | ->getName() 1177 | : $this->timezone 1178 | ; 1179 | 1180 | $now = $now->setTimezone( 1181 | new \DateTimeZone( 1182 | $taskTimeZone 1183 | ) 1184 | ); 1185 | } 1186 | 1187 | return CronExpression::factory($this->expression)->isDue($now->format('Y-m-d H:i:s')); 1188 | } 1189 | 1190 | /** 1191 | * Check if time hasn't arrived. 1192 | * 1193 | * @param string $datetime 1194 | */ 1195 | protected function notYet($datetime, \DateTimeZone $timeZone): bool 1196 | { 1197 | $timeZonedNow = $this->timeZonedNow($timeZone); 1198 | $testedDateTime = new \DateTimeImmutable($datetime, $timeZone); 1199 | 1200 | return $timeZonedNow < $testedDateTime; 1201 | } 1202 | 1203 | /** 1204 | * Check if the time has passed. 1205 | * 1206 | * @param string $datetime 1207 | */ 1208 | protected function past($datetime, \DateTimeZone $timeZone): bool 1209 | { 1210 | $timeZonedNow = $this->timeZonedNow($timeZone); 1211 | $testedDateTime = new \DateTimeImmutable($datetime, $timeZone); 1212 | 1213 | return $timeZonedNow > $testedDateTime; 1214 | } 1215 | 1216 | /** 1217 | * Splice the given value into the given position of the expression. 1218 | * 1219 | * @param int $position 1220 | * @param string $value 1221 | */ 1222 | protected function spliceIntoPosition($position, $value): self 1223 | { 1224 | $segments = \explode(' ', $this->expression); 1225 | 1226 | $segments[$position - 1] = $value; 1227 | 1228 | return $this->cron(\implode(' ', $segments)); 1229 | } 1230 | 1231 | /** 1232 | * Mask a cron expression. 1233 | * 1234 | * @param string $unit 1235 | * 1236 | * @return self 1237 | */ 1238 | protected function applyMask($unit) 1239 | { 1240 | $cron = \explode(' ', $this->expression); 1241 | $mask = ['0', '0', '1', '1', '*', '*']; 1242 | $fpos = $this->fieldsPosition[$unit] - 1; 1243 | 1244 | \array_splice($cron, 0, $fpos, \array_slice($mask, 0, $fpos)); 1245 | 1246 | return $this->cron(\implode(' ', $cron)); 1247 | } 1248 | 1249 | /** 1250 | * Lock the event. 1251 | */ 1252 | protected function lock(): void 1253 | { 1254 | $lock = $this->createLockObject(); 1255 | $lock->acquire(); 1256 | } 1257 | 1258 | private function addErrorCallback(\Closure $closure): void 1259 | { 1260 | $this->errorCallbacks[] = $closure; 1261 | } 1262 | 1263 | /** 1264 | * Set the event's process. 1265 | */ 1266 | private function setProcess(Process $process): void 1267 | { 1268 | $this->process = $process; 1269 | } 1270 | 1271 | /** 1272 | * @return FlockStore 1273 | * 1274 | * @throws CrunzException 1275 | */ 1276 | private function createDefaultLockStore() 1277 | { 1278 | try { 1279 | $lockPath = Path::create( 1280 | [ 1281 | \sys_get_temp_dir(), 1282 | '.crunz', 1283 | ] 1284 | ); 1285 | 1286 | $store = new FlockStore($lockPath->toString()); 1287 | } catch (InvalidArgumentException) { 1288 | // Fallback to system temp dir 1289 | $lockPath = Path::create([\sys_get_temp_dir()]); 1290 | $store = new FlockStore($lockPath->toString()); 1291 | } 1292 | 1293 | return $store; 1294 | } 1295 | 1296 | private function lockKey(): string 1297 | { 1298 | if ($this->isClosure()) { 1299 | /** @var \Closure $closure */ 1300 | $closure = $this->command; 1301 | $command = $this->closureSerializer() 1302 | ->closureCode($closure) 1303 | ; 1304 | } else { 1305 | $command = $this->buildCommand(); 1306 | } 1307 | 1308 | return 'crunz-' . \md5($command); 1309 | } 1310 | 1311 | private function checkLockFactory(): void 1312 | { 1313 | if (null === $this->lockFactory) { 1314 | throw new \BadMethodCallException( 1315 | 'No lock factory. Please call preventOverlapping() first.' 1316 | ); 1317 | } 1318 | } 1319 | 1320 | private function getClock(): ClockInterface 1321 | { 1322 | if (null === self::$clock) { 1323 | self::$clock = new Clock(); 1324 | } 1325 | 1326 | return self::$clock; 1327 | } 1328 | 1329 | private function closureSerializer(): ClosureSerializerInterface 1330 | { 1331 | if (null === self::$closureSerializer) { 1332 | self::$closureSerializer = new LaravelClosureSerializer(); 1333 | } 1334 | 1335 | return self::$closureSerializer; 1336 | } 1337 | 1338 | private function isWindows(): bool 1339 | { 1340 | $osCode = \mb_substr( 1341 | PHP_OS, 1342 | 0, 1343 | 3 1344 | ); 1345 | 1346 | return 'WIN' === $osCode; 1347 | } 1348 | 1349 | private function timeZonedNow(\DateTimeZone $timeZone): \DateTimeImmutable 1350 | { 1351 | $clock = $this->getClock(); 1352 | $now = $clock->now(); 1353 | 1354 | return $now->setTimezone($timeZone); 1355 | } 1356 | } 1357 | -------------------------------------------------------------------------------- /src/EventRunner.php: -------------------------------------------------------------------------------- 1 | schedules = $schedules; 37 | $this->output = $output; 38 | 39 | foreach ($this->schedules as $schedule) { 40 | $this->consoleLogger 41 | ->debug("Invoke Schedule's ping before"); 42 | 43 | $this->pingBefore($schedule); 44 | 45 | // Running the before-callbacks of the current schedule 46 | $this->invoke($schedule->beforeCallbacks()); 47 | 48 | $events = $schedule->events(); 49 | foreach ($events as $event) { 50 | $this->start($event); 51 | } 52 | } 53 | 54 | // Watch events until they are finished 55 | $this->manageStartedEvents(); 56 | } 57 | 58 | protected function start(Event $event): void 59 | { 60 | $this->logger = $this->loggerFactory 61 | ->create() 62 | ; 63 | 64 | // if sendOutputTo or appendOutputTo have been specified 65 | if (!$event->nullOutput()) { 66 | // if sendOutputTo then truncate the log file if it exists 67 | if (!$event->shouldAppendOutput) { 68 | $f = @\fopen($event->output, 'r+'); 69 | if (false !== $f) { 70 | \ftruncate($f, 0); 71 | \fclose($f); 72 | } 73 | } 74 | // Create an instance of the Logger specific to the event 75 | $event->logger = $this->loggerFactory->createEvent($event->output); 76 | } 77 | 78 | $this->consoleLogger 79 | ->debug("Invoke Event's ping before."); 80 | 81 | $this->pingBefore($event); 82 | 83 | // Running the before-callbacks 84 | $event->outputStream = $this->invoke($event->beforeCallbacks()); 85 | $event->start(); 86 | } 87 | 88 | protected function manageStartedEvents(): void 89 | { 90 | while ($this->schedules) { 91 | foreach ($this->schedules as $scheduleKey => $schedule) { 92 | $events = $schedule->events(); 93 | // 10% chance that refresh will be called 94 | $refreshLocks = (\random_int(1, 100) <= 10); 95 | 96 | /** @var Event $event */ 97 | foreach ($events as $eventKey => $event) { 98 | if ($refreshLocks) { 99 | $event->refreshLock(); 100 | } 101 | 102 | $proc = $event->getProcess(); 103 | if ($proc->isRunning()) { 104 | continue; 105 | } 106 | 107 | $runStatus = ''; 108 | 109 | if ($proc->isSuccessful()) { 110 | $this->consoleLogger 111 | ->debug("Invoke Event's ping after."); 112 | $this->pingAfter($event); 113 | 114 | $runStatus = 'success'; 115 | 116 | $event->outputStream .= $event->wholeOutput(); 117 | $event->outputStream .= $this->invoke($event->afterCallbacks()); 118 | 119 | $this->handleOutput($event); 120 | } else { 121 | $runStatus = 'fail'; 122 | 123 | // Invoke error callbacks 124 | $this->invoke($event->errorCallbacks()); 125 | // Calling registered error callbacks with an instance of $event as argument 126 | $this->invoke($schedule->errorCallbacks(), [$event]); 127 | $this->handleError($event); 128 | } 129 | 130 | $id = $event->description ?: $event->getId(); 131 | 132 | $this->consoleLogger 133 | ->debug("Task {$id} status: {$runStatus}."); 134 | 135 | // Dismiss the event if it's finished 136 | $schedule->dismissEvent($eventKey); 137 | } 138 | 139 | // If there's no event left for the Schedule instance, 140 | // run the schedule's after-callbacks and remove 141 | // the Schedule from list of active schedules. zzzwwscxqqqAAAQ11 142 | if (!\count($schedule->events())) { 143 | $this->consoleLogger 144 | ->debug("Invoke Schedule's ping after."); 145 | 146 | $this->pingAfter($schedule); 147 | $this->invoke($schedule->afterCallbacks()); 148 | unset($this->schedules[$scheduleKey]); 149 | } 150 | } 151 | 152 | \usleep(250000); 153 | } 154 | } 155 | 156 | /** 157 | * @param \Closure[] $callbacks 158 | * @param array $parameters 159 | * 160 | * @return string 161 | */ 162 | protected function invoke(array $callbacks = [], array $parameters = []) 163 | { 164 | $output = ''; 165 | foreach ($callbacks as $callback) { 166 | /** @var string $callResult */ 167 | $callResult = $this->invoker->call($callback, $parameters, true); 168 | // Invoke the callback with buffering enabled 169 | $output .= $callResult; 170 | } 171 | 172 | return $output; 173 | } 174 | 175 | protected function handleOutput(Event $event): void 176 | { 177 | $logged = false; 178 | $logOutput = $this->configuration 179 | ->get('log_output') 180 | ; 181 | 182 | if (!$event->nullOutput()) { 183 | $event->logger->info($this->formatEventOutput($event)); 184 | $logged = true; 185 | } 186 | 187 | if ($logOutput && !$logged) { 188 | $this->logger() 189 | ->info($this->formatEventOutput($event)) 190 | ; 191 | $logged = true; 192 | } 193 | 194 | if (!$logged) { 195 | $this->display($event->getOutputStream()); 196 | } 197 | 198 | $emailOutput = $this->configuration 199 | ->get('email_output') 200 | ; 201 | if ($emailOutput && !empty($event->getOutputStream())) { 202 | $this->mailer->send( 203 | 'Crunz: output for event: ' . ($event->description ?? $event->getId()), 204 | $this->formatEventOutput($event) 205 | ); 206 | } 207 | } 208 | 209 | protected function handleError(Event $event): void 210 | { 211 | $logErrors = $this->configuration 212 | ->get('log_errors') 213 | ; 214 | $emailErrors = $this->configuration 215 | ->get('email_errors') 216 | ; 217 | 218 | if ($logErrors) { 219 | $this->logger() 220 | ->error($this->formatEventError($event)) 221 | ; 222 | } else { 223 | $output = $event->wholeOutput(); 224 | 225 | $this->output 226 | ?->write("{$output}") 227 | ; 228 | } 229 | 230 | // Send error as email as configured 231 | if ($emailErrors) { 232 | $this->mailer->send( 233 | 'Crunz: reporting error for event:' . ($event->description ?? $event->getId()), 234 | $this->formatEventError($event) 235 | ); 236 | } 237 | } 238 | 239 | /** @return string */ 240 | protected function formatEventOutput(Event $event) 241 | { 242 | return $event->description 243 | . '(' 244 | . $event->getCommandForDisplay() 245 | . ') ' 246 | . PHP_EOL 247 | . PHP_EOL 248 | . $event->outputStream 249 | . PHP_EOL; 250 | } 251 | 252 | /** @return string */ 253 | protected function formatEventError(Event $event) 254 | { 255 | return $event->description 256 | . '(' 257 | . $event->getCommandForDisplay() 258 | . ') ' 259 | . PHP_EOL 260 | . $event->wholeOutput() 261 | . PHP_EOL; 262 | } 263 | 264 | /** @param string|null $output */ 265 | protected function display($output): void 266 | { 267 | $this->output 268 | ?->write(\is_string($output) ? $output : '') 269 | ; 270 | } 271 | 272 | private function pingBefore(PingableInterface $schedule): void 273 | { 274 | if (!$schedule->hasPingBefore()) { 275 | $this->consoleLogger 276 | ->debug('There is no ping before url.'); 277 | 278 | return; 279 | } 280 | 281 | /** @var non-empty-string $pingBeforeUrl */ 282 | $pingBeforeUrl = $schedule->getPingBeforeUrl(); 283 | $this->httpClient 284 | ->ping($pingBeforeUrl) 285 | ; 286 | } 287 | 288 | private function pingAfter(PingableInterface $schedule): void 289 | { 290 | if (!$schedule->hasPingAfter()) { 291 | $this->consoleLogger 292 | ->debug('There is no ping after url.'); 293 | 294 | return; 295 | } 296 | 297 | /** @var non-empty-string $pingAfterUrl */ 298 | $pingAfterUrl = $schedule->getPingAfterUrl(); 299 | $this->httpClient 300 | ->ping($pingAfterUrl) 301 | ; 302 | } 303 | 304 | private function logger(): Logger 305 | { 306 | if (null === $this->logger) { 307 | $this->logger = $this->loggerFactory 308 | ->create() 309 | ; 310 | } 311 | 312 | return $this->logger; 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/Exception/CrunzException.php: -------------------------------------------------------------------------------- 1 | toString()); 42 | $ignored[$path->toString()] = ''; 43 | } 44 | 45 | $directoryIterator = new \RecursiveDirectoryIterator($directoryPath, \FilesystemIterator::SKIP_DOTS); 46 | $recursiveIterator = new \RecursiveIteratorIterator( 47 | $directoryIterator, 48 | \RecursiveIteratorIterator::CHILD_FIRST 49 | ); 50 | 51 | /** @var \SplFileInfo $path */ 52 | foreach ($recursiveIterator as $path) { 53 | if (\array_key_exists($path->getPathname(), $ignored)) { 54 | ++$ignoredCount; 55 | 56 | continue; 57 | } 58 | 59 | $path->isDir() && !$path->isLink() 60 | ? \rmdir($path->getPathname()) 61 | : \unlink($path->getPathname()) 62 | ; 63 | } 64 | 65 | if (0 === $ignoredCount) { 66 | \rmdir($directoryPath); 67 | } 68 | } 69 | 70 | public function dumpFile($filePath, $content): void 71 | { 72 | $directory = \pathinfo($filePath, \PATHINFO_DIRNAME); 73 | $this->createDirectory($directory); 74 | 75 | \file_put_contents($filePath, $content); 76 | } 77 | 78 | public function createDirectory($directoryPath): void 79 | { 80 | if ($this->fileExists($directoryPath)) { 81 | return; 82 | } 83 | 84 | $created = \mkdir( 85 | $directoryPath, 86 | 0770, 87 | true 88 | ); 89 | 90 | if (!$created && !\is_dir($directoryPath)) { 91 | throw new \RuntimeException("Directory '{$directoryPath}' was not created."); 92 | } 93 | } 94 | 95 | /** 96 | * @param string $sourceFile 97 | * @param string $targetFile 98 | */ 99 | public function copy($sourceFile, $targetFile): void 100 | { 101 | \copy($sourceFile, $targetFile); 102 | } 103 | 104 | public function projectRootDirectory() 105 | { 106 | if (null === $this->projectRootDir) { 107 | $dir = $rootDir = \dirname(__DIR__); 108 | $path = Path::fromStrings($dir, 'composer.json'); 109 | 110 | while (!\file_exists($path->toString())) { 111 | if ($dir === \dirname($dir)) { 112 | return $this->projectRootDir = $rootDir; 113 | } 114 | $dir = \dirname($dir); 115 | $path = Path::fromStrings($dir, 'composer.json'); 116 | } 117 | 118 | $this->projectRootDir = $dir; 119 | } 120 | 121 | return $this->projectRootDir; 122 | } 123 | 124 | /** 125 | * @param string $filePath 126 | * 127 | * @return string 128 | */ 129 | public function readContent($filePath) 130 | { 131 | if (!$this->fileExists($filePath)) { 132 | throw new \RuntimeException("File '{$filePath}' doesn't exists."); 133 | } 134 | 135 | $content = \file_get_contents($filePath); 136 | 137 | if (false === $content) { 138 | throw new \RuntimeException("Unable to get contents of file '{$filePath}'."); 139 | } 140 | 141 | return $content; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Filesystem/FilesystemInterface.php: -------------------------------------------------------------------------------- 1 | toString(), $directoryIteratorFlags); 16 | $recursiveIterator = new \RecursiveIteratorIterator($directoryIterator); 17 | 18 | $regexIterator = new \RegexIterator( 19 | $recursiveIterator, 20 | "/^.+{$quotedSuffix}$/i", 21 | \RecursiveRegexIterator::GET_MATCH 22 | ); 23 | 24 | /** @var \SplFileInfo[] $files */ 25 | $files = \array_map( 26 | static fn (array $file) => new \SplFileInfo(\reset($file)), 27 | \iterator_to_array($regexIterator) 28 | ); 29 | 30 | return $files; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Finder/FinderInterface.php: -------------------------------------------------------------------------------- 1 | chooseHttpClient(); 24 | $httpClient->ping($url); 25 | } 26 | 27 | /** @throws HttpClientException */ 28 | private function chooseHttpClient(): HttpClientInterface 29 | { 30 | if (null !== $this->httpClient) { 31 | return $this->httpClient; 32 | } 33 | 34 | $this->consoleLogger 35 | ->debug('Choosing HttpClient implementation.'); 36 | 37 | if (\function_exists('curl_exec')) { 38 | $this->httpClient = $this->curlHttpClient; 39 | 40 | $this->consoleLogger 41 | ->debug('cURL available, use CurlHttpClient.'); 42 | 43 | return $this->httpClient; 44 | } 45 | 46 | if ('1' === \ini_get('allow_url_fopen')) { 47 | $this->httpClient = $this->streamHttpClient; 48 | 49 | $this->consoleLogger 50 | ->debug("'allow_url_fopen' enabled, use StreamHttpClient"); 51 | 52 | return $this->httpClient; 53 | } 54 | 55 | $this->consoleLogger 56 | ->debug('Choosing HttpClient implementation failed.'); 57 | 58 | throw new HttpClientException( 59 | "Unable to choose HttpClient. Enable cURL extension (preferred) or turn on 'allow_url_fopen' in php.ini." 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/HttpClient/HttpClientException.php: -------------------------------------------------------------------------------- 1 | logger 18 | ->verbose("Trying to ping {$url}."); 19 | 20 | $this->httpClient 21 | ->ping($url); 22 | 23 | $this->logger 24 | ->verbose("Pinging url: {$url} was successful."); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/HttpClient/StreamHttpClient.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'user_agent' => 'Crunz StreamHttpClient', 20 | 'timeout' => 5, 21 | ], 22 | ] 23 | ); 24 | $resource = @\fopen( 25 | $url, 26 | 'rb', 27 | false, 28 | $context 29 | ); 30 | 31 | if (false === $resource) { 32 | throw new HttpClientException('Ping failed.'); 33 | } 34 | 35 | \fclose($resource); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Infrastructure/Dragonmantank/CronExpression/DragonmantankCronExpression.php: -------------------------------------------------------------------------------- 1 | setTimezone($timeZone) 20 | : $now 21 | ; 22 | 23 | $dates = $this->innerCronExpression 24 | ->getMultipleRunDates($total, $timeZoneNow) 25 | ; 26 | 27 | return \array_map( 28 | static fn (\DateTime $runDate): \DateTimeImmutable => \DateTimeImmutable::createFromMutable($runDate), 29 | $dates 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Infrastructure/Dragonmantank/CronExpression/DragonmantankCronExpressionFactory.php: -------------------------------------------------------------------------------- 1 | extractWrapper($serializedClosure); 25 | 26 | return $wrapper->getClosure(); 27 | } 28 | 29 | public function closureCode(\Closure $closure): string 30 | { 31 | $reflector = new ReflectionClosure($closure); 32 | 33 | return $reflector->getCode(); 34 | } 35 | 36 | private function extractWrapper(string $serializedClosure): SerializableClosure 37 | { 38 | return \unserialize( 39 | $serializedClosure, 40 | ['allowed_classes' => true] 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Infrastructure/Psr/Logger/EnabledLoggerDecorator.php: -------------------------------------------------------------------------------- 1 | configuration 26 | ->get('log_output') 27 | ; 28 | 29 | break; 30 | case LogLevel::ERROR: 31 | $loggingEnabled = $this->configuration 32 | ->get('log_errors') 33 | ; 34 | 35 | break; 36 | } 37 | 38 | if (false === $loggingEnabled) { 39 | return; 40 | } 41 | 42 | $this->decoratedLogger 43 | ->log( 44 | $level, 45 | $message, 46 | $context 47 | ) 48 | ; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Infrastructure/Psr/Logger/PsrStreamLogger.php: -------------------------------------------------------------------------------- 1 | outputStreamPath = $outputStreamPath ?? ''; 33 | $this->errorStreamPath = $errorStreamPath ?? ''; 34 | } 35 | 36 | public function __destruct() 37 | { 38 | $this->closeStream($this->outputHandler); 39 | $this->closeStream($this->errorHandler); 40 | } 41 | 42 | public function log( 43 | $level, 44 | string|\Stringable $message, 45 | array $context = [], 46 | ): void { 47 | $resource = match ($level) { 48 | LogLevel::INFO => $this->createInfoHandler(), 49 | LogLevel::ERROR => $this->createErrorHandler(), 50 | default => null, 51 | }; 52 | 53 | if (null === $resource) { 54 | return; 55 | } 56 | 57 | /** @var string $level */ 58 | $date = $this->formatDate(); 59 | $levelFormatted = \mb_strtoupper($level); 60 | $extraString = $this->formatContext([]); 61 | $contextString = $this->formatContext($context); 62 | $formattedMessage = $this->replaceNewlines($message); 63 | $record = "[{$date}] crunz.{$levelFormatted}: {$formattedMessage} {$extraString} {$contextString}"; 64 | 65 | \fwrite($resource, $record . PHP_EOL); 66 | } 67 | 68 | /** @return resource */ 69 | private function createInfoHandler() 70 | { 71 | if (null === $this->outputHandler) { 72 | $this->outputHandler = $this->initializeHandler($this->outputStreamPath); 73 | } 74 | 75 | return $this->outputHandler; 76 | } 77 | 78 | /** @return resource */ 79 | private function createErrorHandler() 80 | { 81 | if (null === $this->errorHandler) { 82 | $this->errorHandler = $this->initializeHandler($this->errorStreamPath); 83 | } 84 | 85 | return $this->errorHandler; 86 | } 87 | 88 | /** @return resource */ 89 | private function initializeHandler(string $path) 90 | { 91 | if ('' === $path) { 92 | throw new CrunzException('Stream path cannot be empty.'); 93 | } 94 | 95 | $directory = $this->dirFromStream($path); 96 | if (null !== $directory) { 97 | if (\is_file($directory)) { 98 | throw new CrunzException( 99 | "Unable to create directory '{$directory}', file at this path already exists." 100 | ); 101 | } 102 | 103 | if (!\file_exists($directory)) { 104 | \mkdir( 105 | $directory, 106 | 0777, 107 | true 108 | ); 109 | } 110 | 111 | if (!\is_dir($directory)) { 112 | throw new CrunzException("Unable to create directory '{$directory}'."); 113 | } 114 | } 115 | 116 | $handler = \fopen($path, 'ab'); 117 | if (false === $handler) { 118 | throw new CrunzException("Unable to open stream for path: '{$path}'."); 119 | } 120 | 121 | return $handler; 122 | } 123 | 124 | /** @param resource|null $stream */ 125 | private function closeStream($stream): void 126 | { 127 | if (!\is_resource($stream)) { 128 | return; 129 | } 130 | 131 | \fclose($stream); 132 | } 133 | 134 | private function dirFromStream(string $stream): ?string 135 | { 136 | $pos = \mb_strpos($stream, '://'); 137 | if (false === $pos) { 138 | return \dirname($stream); 139 | } 140 | 141 | if (\str_starts_with($stream, 'file://')) { 142 | return \dirname( 143 | \mb_substr( 144 | $stream, 145 | 7 146 | ) 147 | ); 148 | } 149 | 150 | return null; 151 | } 152 | 153 | /** @param array $data */ 154 | private function formatContext(array $data): string 155 | { 156 | if ($this->ignoreEmptyContext && empty($data)) { 157 | return ''; 158 | } 159 | 160 | return \json_encode($data, JSON_THROW_ON_ERROR); 161 | } 162 | 163 | private function formatDate(): string 164 | { 165 | $now = $this->clock 166 | ->now() 167 | ; 168 | 169 | if ($this->timezoneLog) { 170 | $now = $now->setTimezone($this->timezone); 171 | } 172 | 173 | return $now->format(self::DATE_FORMAT); 174 | } 175 | 176 | private function replaceNewlines(string $message): string 177 | { 178 | if ($this->allowLineBreaks) { 179 | if (\str_starts_with($message, '{')) { 180 | return \str_replace( 181 | ['\r', '\n'], 182 | ["\r", "\n"], 183 | $message 184 | ); 185 | } 186 | 187 | return $message; 188 | } 189 | 190 | return \str_replace( 191 | [ 192 | "\r\n", 193 | "\r", 194 | "\n", 195 | ], 196 | ' ', 197 | $message 198 | ); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/Infrastructure/Psr/Logger/PsrStreamLoggerFactory.php: -------------------------------------------------------------------------------- 1 | timezoneProvider 22 | ->timezoneForComparisons() 23 | ; 24 | 25 | return new EnabledLoggerDecorator( 26 | new PsrStreamLogger( 27 | $timezone, 28 | $this->clock, 29 | $configuration->get('output_log_file'), 30 | $configuration->get('errors_log_file'), 31 | $configuration->get('log_ignore_empty_context'), 32 | $configuration->get('timezone_log'), 33 | $configuration->get('log_allow_line_breaks') 34 | ), 35 | $configuration 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Invoker.php: -------------------------------------------------------------------------------- 1 | $parameters 15 | */ 16 | public function call($closure, array $parameters = [], $buffer = false): mixed 17 | { 18 | if ($buffer) { 19 | \ob_start(); 20 | } 21 | 22 | $rslt = \call_user_func_array($closure, $parameters); 23 | 24 | if ($buffer) { 25 | return \ob_get_clean(); 26 | } 27 | 28 | return $rslt; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Logger/ConsoleLogger.php: -------------------------------------------------------------------------------- 1 | write($message, self::VERBOSITY_NORMAL); 21 | } 22 | 23 | /** 24 | * @param string $message 25 | */ 26 | public function verbose($message): void 27 | { 28 | $this->write($message, self::VERBOSITY_VERBOSE); 29 | } 30 | 31 | /** 32 | * @param string $message 33 | */ 34 | public function veryVerbose($message): void 35 | { 36 | $this->write($message, self::VERBOSITY_VERY_VERBOSE); 37 | } 38 | 39 | /** 40 | * Detailed debug information. 41 | * 42 | * @param string $message 43 | */ 44 | public function debug($message): void 45 | { 46 | $this->write($message, self::VERBOSITY_DEBUG); 47 | } 48 | 49 | /** 50 | * @param string $message 51 | * @param int $verbosity 52 | */ 53 | private function write($message, $verbosity): void 54 | { 55 | $ioVerbosity = $this->symfonyStyle 56 | ->getVerbosity(); 57 | 58 | if ($ioVerbosity >= $verbosity) { 59 | $this->symfonyStyle 60 | ->writeln($message); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Logger/ConsoleLoggerInterface.php: -------------------------------------------------------------------------------- 1 | log($message, 'info'); 21 | } 22 | 23 | /** 24 | * Log the error is error logging is enabled. 25 | */ 26 | public function error(string $message): void 27 | { 28 | $this->log($message, 'error'); 29 | } 30 | 31 | private function log(string $content, string $level): void 32 | { 33 | $this->psrLogger 34 | ->log($level, $content) 35 | ; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Logger/LoggerFactory.php: -------------------------------------------------------------------------------- 1 | loggerFactory(); 29 | $configuration = $this->configuration; 30 | $innerLogger = $loggerFactory->create($configuration); 31 | 32 | return new Logger($innerLogger); 33 | } 34 | 35 | public function createEvent(string $output): Logger 36 | { 37 | $loggerFactory = $this->loggerFactory(); 38 | $eventConfiguration = $this->configuration->withNewEntry('output_log_file', $output); 39 | $innerLogger = $loggerFactory->create($eventConfiguration); 40 | 41 | return new Logger($innerLogger); 42 | } 43 | 44 | private function loggerFactory(): LoggerFactoryInterface 45 | { 46 | return $this->loggerFactory ??= $this->initializeLoggerFactory(); 47 | } 48 | 49 | private function initializeLoggerFactory(): LoggerFactoryInterface 50 | { 51 | $timezoneLog = $this->configuration 52 | ->get('timezone_log') 53 | ; 54 | 55 | if ($timezoneLog) { 56 | $timezone = $this->timezoneProvider 57 | ->timezoneForComparisons() 58 | ; 59 | 60 | $this->consoleLogger 61 | ->veryVerbose("Timezone for 'timezone_log': '{$timezone->getName()}'") 62 | ; 63 | } 64 | 65 | $this->loggerFactory = $this->createLoggerFactory( 66 | $this->configuration, 67 | $this->timezoneProvider, 68 | $this->clock 69 | ); 70 | 71 | return $this->loggerFactory; 72 | } 73 | 74 | private function createLoggerFactory( 75 | ConfigurationInterface $configuration, 76 | Timezone $timezoneProvider, 77 | ClockInterface $clock, 78 | ): LoggerFactoryInterface { 79 | $params = []; 80 | $loggerFactoryClass = $configuration->get('logger_factory'); 81 | 82 | $this->consoleLogger 83 | ->veryVerbose("Class for 'logger_factory': '{$loggerFactoryClass}'.") 84 | ; 85 | 86 | if (!\class_exists($loggerFactoryClass)) { 87 | throw new CrunzException("Class '{$loggerFactoryClass}' does not exists."); 88 | } 89 | 90 | $isPsrStreamLoggerFactory = \is_a( 91 | $loggerFactoryClass, 92 | PsrStreamLoggerFactory::class, 93 | true 94 | ); 95 | if ($isPsrStreamLoggerFactory) { 96 | $params[] = $timezoneProvider; 97 | $params[] = $clock; 98 | } 99 | 100 | return new $loggerFactoryClass(...$params); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Mailer.php: -------------------------------------------------------------------------------- 1 | getMailer() 31 | ->send( 32 | $this->getMessage($subject, $message) 33 | ) 34 | ; 35 | } 36 | 37 | /** 38 | * Return the proper mailer. 39 | * 40 | * @throws MailerException 41 | */ 42 | private function getMailer(): SymfonyMailer 43 | { 44 | // If the mailer has already been defined via the constructor, return it. 45 | if ($this->mailer) { 46 | return $this->mailer; 47 | } 48 | 49 | // Get the proper transporter 50 | switch ($this->config('mailer.transport')) { 51 | case 'smtp': 52 | $transport = $this->getSmtpTransport(); 53 | 54 | break; 55 | 56 | case 'mail': 57 | throw new MailerException( 58 | "'mail' transport is no longer supported, please use 'smtp' or 'sendmail' transport." 59 | ); 60 | 61 | default: 62 | $transport = $this->getSendMailTransport(); 63 | } 64 | 65 | $this->mailer = new SymfonyMailer($transport); 66 | 67 | return $this->mailer; 68 | } 69 | 70 | private function getSmtpTransport(): Transport\TransportInterface 71 | { 72 | $host = $this->config('smtp.host'); 73 | $port = $this->config('smtp.port'); 74 | $encryption = \filter_var($this->config('smtp.encryption') ?? true, FILTER_VALIDATE_BOOLEAN); 75 | $user = $this->config('smtp.username'); 76 | $password = $this->config('smtp.password'); 77 | $encryptionString = $encryption 78 | ? 1 79 | : 0 80 | ; 81 | $userPart = null !== $user && null !== $password 82 | ? "{$user}:{$password}@" 83 | : '' 84 | ; 85 | 86 | $dsn = "smtp://{$userPart}{$host}:{$port}?verifyPeer={$encryptionString}"; 87 | 88 | return Transport::fromDsn($dsn); 89 | } 90 | 91 | private function getSendMailTransport(): Transport\TransportInterface 92 | { 93 | $dsn = 'sendmail://default'; 94 | 95 | return Transport::fromDsn($dsn); 96 | } 97 | 98 | private function getMessage(string $subject, string $message): Email 99 | { 100 | $from = new Address($this->config('mailer.sender_email'), $this->config('mailer.sender_name')); 101 | $messageObject = new Email(); 102 | $messageObject 103 | ->from($from) 104 | ->subject($subject) 105 | ->text($message) 106 | ; 107 | foreach ($this->config('mailer.recipients') ?? [] as $recipient) { 108 | $messageObject->addTo($recipient); 109 | } 110 | 111 | return $messageObject; 112 | } 113 | 114 | private function config(string $key): mixed 115 | { 116 | return $this->configuration 117 | ->get($key) 118 | ; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Output/OutputFactory.php: -------------------------------------------------------------------------------- 1 | input; 20 | $output = new ConsoleOutput(); 21 | 22 | if (true === $input->hasParameterOption(['--quiet', '-q'])) { 23 | $output->setVerbosity(OutputInterface::VERBOSITY_QUIET); 24 | } elseif ($input->hasParameterOption('-vvv') || $input->hasParameterOption('--verbose=3') || 3 === $input->getParameterOption('--verbose')) { 25 | $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); 26 | } elseif ($input->hasParameterOption('-vv') || $input->hasParameterOption('--verbose=2') || 2 === $input->getParameterOption('--verbose')) { 27 | $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); 28 | } elseif ($input->hasParameterOption('-v') || $input->hasParameterOption('--verbose=1') || $input->hasParameterOption('--verbose') || $input->getParameterOption('--verbose')) { 29 | $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); 30 | } 31 | 32 | return $output; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Path/Path.php: -------------------------------------------------------------------------------- 1 | path; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Pinger/PingableException.php: -------------------------------------------------------------------------------- 1 | checkUrl($url); 17 | 18 | $this->pingBeforeUrl = $url; 19 | 20 | return $this; 21 | } 22 | 23 | public function hasPingBefore() 24 | { 25 | return '' !== $this->pingBeforeUrl; 26 | } 27 | 28 | public function thenPing($url) 29 | { 30 | $this->checkUrl($url); 31 | 32 | $this->pingAfterUrl = $url; 33 | 34 | return $this; 35 | } 36 | 37 | public function hasPingAfter() 38 | { 39 | return '' !== $this->pingAfterUrl; 40 | } 41 | 42 | public function getPingBeforeUrl() 43 | { 44 | if (!$this->hasPingBefore()) { 45 | throw new PingableException('PingBeforeUrl is empty.'); 46 | } 47 | 48 | return $this->pingBeforeUrl; 49 | } 50 | 51 | public function getPingAfterUrl() 52 | { 53 | if (!$this->hasPingAfter()) { 54 | throw new PingableException('PingAfterUrl is empty.'); 55 | } 56 | 57 | return $this->pingAfterUrl; 58 | } 59 | 60 | /** 61 | * @param string $url 62 | * 63 | * @throws PingableException 64 | */ 65 | private function checkUrl($url): void 66 | { 67 | if (!\is_string($url)) { 68 | $type = \gettype($url); 69 | throw new PingableException("Url must be of type string, '{$type}' given."); 70 | } 71 | 72 | if ('' === $url) { 73 | throw new PingableException('Url cannot be empty.'); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Process/Process.php: -------------------------------------------------------------------------------- 1 | process 36 | ->start($callback); 37 | } 38 | 39 | public function wait(): void 40 | { 41 | $this->process 42 | ->wait(); 43 | } 44 | 45 | public function startAndWait(): void 46 | { 47 | $this->process 48 | ->start(); 49 | $this->process 50 | ->wait(); 51 | } 52 | 53 | /** @param array $env */ 54 | public function setEnv(array $env): void 55 | { 56 | $this->process 57 | ->setEnv($env); 58 | } 59 | 60 | public function getPid(): ?int 61 | { 62 | return $this->process 63 | ->getPid(); 64 | } 65 | 66 | public function isRunning(): bool 67 | { 68 | return $this->process 69 | ->isRunning(); 70 | } 71 | 72 | public function isSuccessful(): bool 73 | { 74 | return $this->process 75 | ->isSuccessful(); 76 | } 77 | 78 | public function getOutput(): string 79 | { 80 | return $this->process 81 | ->getOutput(); 82 | } 83 | 84 | public function errorOutput(): string 85 | { 86 | return $this->process 87 | ->getErrorOutput(); 88 | } 89 | 90 | public function commandLine(): string 91 | { 92 | return $this->process 93 | ->getCommandLine() 94 | ; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Schedule.php: -------------------------------------------------------------------------------- 1 | compileParameters($parameters); 55 | } 56 | 57 | $this->events[] = $event = new Event($this->id(), $command); 58 | 59 | return $event; 60 | } 61 | 62 | /** 63 | * Register a callback to be called before the operation. 64 | * 65 | * @return $this 66 | */ 67 | public function before(\Closure $callback) 68 | { 69 | $this->beforeCallbacks[] = $callback; 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * Register a callback to be called after the operation. 76 | * 77 | * @return $this 78 | */ 79 | public function after(\Closure $callback) 80 | { 81 | return $this->then($callback); 82 | } 83 | 84 | /** 85 | * Register a callback to be called after the operation. 86 | * 87 | * @return $this 88 | */ 89 | public function then(\Closure $callback) 90 | { 91 | $this->afterCallbacks[] = $callback; 92 | 93 | return $this; 94 | } 95 | 96 | /** 97 | * Register a callback to call in case of an error. 98 | * 99 | * @return $this 100 | */ 101 | public function onError(\Closure $callback) 102 | { 103 | $this->errorCallbacks[] = $callback; 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * Return all registered before callbacks. 110 | * 111 | * @return \Closure[] 112 | */ 113 | public function beforeCallbacks() 114 | { 115 | return $this->beforeCallbacks; 116 | } 117 | 118 | /** 119 | * Return all registered after callbacks. 120 | * 121 | * @return \Closure[] 122 | */ 123 | public function afterCallbacks() 124 | { 125 | return $this->afterCallbacks; 126 | } 127 | 128 | /** 129 | * Return all registered error callbacks. 130 | * 131 | * @return \Closure[] 132 | */ 133 | public function errorCallbacks() 134 | { 135 | return $this->errorCallbacks; 136 | } 137 | 138 | /** 139 | * Get or set the events of the schedule object. 140 | * 141 | * @param Event[] $events 142 | * 143 | * @return Event[] 144 | */ 145 | public function events(?array $events = null) 146 | { 147 | if (null !== $events) { 148 | return $this->events = $events; 149 | } 150 | 151 | return $this->events; 152 | } 153 | 154 | /** 155 | * Get all of the events on the schedule that are due. 156 | * 157 | * @return Event[] 158 | */ 159 | public function dueEvents(\DateTimeZone $timeZone) 160 | { 161 | return \array_filter( 162 | $this->events, 163 | static fn (Event $event) => $event->isDue($timeZone) 164 | ); 165 | } 166 | 167 | /** 168 | * Dismiss an event after it is finished. 169 | * 170 | * @param int $key 171 | * 172 | * @return $this 173 | */ 174 | public function dismissEvent($key) 175 | { 176 | unset($this->events[$key]); 177 | 178 | return $this; 179 | } 180 | 181 | /** 182 | * Generate a unique task id. 183 | * 184 | * @return string 185 | */ 186 | protected function id() 187 | { 188 | while (true) { 189 | $id = \uniqid('crunz', true); 190 | if (!\array_key_exists($id, $this->events)) { 191 | return $id; 192 | } 193 | } 194 | } 195 | 196 | /** @param array $parameters */ 197 | protected function compileParameters(array $parameters): string 198 | { 199 | $isStrings = \array_reduce( 200 | $parameters, 201 | static fn (bool $carry, $item): bool => $carry && true === \is_string($item), 202 | true, 203 | ); 204 | if (false === $isStrings) { 205 | @\trigger_error( 206 | 'Passing non-string parameters is deprecated since v3.3, convert all parameters to string.', 207 | \E_USER_DEPRECATED 208 | ); 209 | 210 | $parameters = \array_map( 211 | static function ($value): string { 212 | if (true === \is_bool($value)) { 213 | return true === $value 214 | ? '1' 215 | : '0' 216 | ; 217 | } 218 | 219 | return (string) $value; 220 | }, 221 | $parameters, 222 | ); 223 | } 224 | 225 | $flatParameters = []; 226 | /** @var string[] $parameters */ 227 | foreach ($parameters as $key => $value) { 228 | if (false === \is_numeric($key)) { 229 | $flatParameters[] = $key; 230 | } 231 | 232 | $flatParameters[] = $value; 233 | } 234 | 235 | return Process::fromArrayCommand($flatParameters)->commandLine(); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/Schedule/ScheduleFactory.php: -------------------------------------------------------------------------------- 1 | singleTask($taskNumber, ...$schedules); 22 | 23 | $schedule = new Schedule(); 24 | $schedule->events([$event]); 25 | 26 | return [$schedule]; 27 | } 28 | 29 | /** @throws TaskNotExistException */ 30 | public function singleTask(TaskNumber $taskNumber, Schedule ...$schedules): Event 31 | { 32 | $events = \array_map( 33 | static fn (Schedule $schedule) => $schedule->events(), 34 | $schedules 35 | ); 36 | 37 | $flattenEvents = \array_merge(...$events); 38 | 39 | if (!isset($flattenEvents[$taskNumber->asArrayIndex()])) { 40 | $tasksCount = \count($flattenEvents); 41 | throw new TaskNotExistException( 42 | "Task with id '{$taskNumber->asInt()}' was not found. Last task id is '{$tasksCount}'." 43 | ); 44 | } 45 | 46 | return $flattenEvents[$taskNumber->asArrayIndex()]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Stubs/BasicTask.php: -------------------------------------------------------------------------------- 1 | run('DummyCommand'); 19 | $task 20 | ->description('DummyDescription') 21 | ->in('DummyPath') 22 | ->preventOverlapping() 23 | ->DummyFrequency() 24 | ->DummyConstraint() 25 | ; 26 | 27 | return $scheduler; 28 | -------------------------------------------------------------------------------- /src/Task/Collection.php: -------------------------------------------------------------------------------- 1 | consoleLogger 24 | ->debug("Task source path '{$source}'"); 25 | 26 | if (!\file_exists($source)) { 27 | return []; 28 | } 29 | 30 | $suffix = $this->configuration 31 | ->get('suffix') 32 | ; 33 | 34 | $this->consoleLogger 35 | ->debug("Task finder suffix: '{$suffix}'"); 36 | 37 | $realPath = \realpath($source); 38 | if (false !== $realPath) { 39 | $this->consoleLogger 40 | ->verbose("Realpath for '{$source}' is '{$realPath}'"); 41 | } else { 42 | $this->consoleLogger 43 | ->verbose("Realpath resolve for '{$source}' failed."); 44 | } 45 | 46 | $tasks = $this->finder 47 | ->find(Path::fromStrings($source), $suffix) 48 | ; 49 | $tasksCount = \count($tasks); 50 | 51 | $this->consoleLogger 52 | ->debug("Found {$tasksCount} task(s) at path '{$source}'"); 53 | 54 | return $tasks; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Task/CollectionInterface.php: -------------------------------------------------------------------------------- 1 | loadSchedule($file); 22 | if (!$schedule instanceof Schedule) { 23 | throw WrongTaskInstanceException::fromFilePath($file, $schedule); 24 | } 25 | 26 | $schedules[] = $schedule; 27 | } 28 | 29 | return $schedules; 30 | } 31 | 32 | /** @return Schedule|mixed */ 33 | private function loadSchedule(\SplFileInfo $file) 34 | { 35 | return require $file->getRealPath(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Task/LoaderInterface.php: -------------------------------------------------------------------------------- 1 | number = $number; 22 | } 23 | 24 | /** 25 | * @param string $value 26 | * 27 | * @return TaskNumber 28 | * 29 | * @throws WrongTaskNumberException 30 | */ 31 | public static function fromString($value) 32 | { 33 | if (!\is_string($value)) { 34 | throw new WrongTaskNumberException('Passed task number is not string.'); 35 | } 36 | 37 | if (!\is_numeric($value)) { 38 | throw new WrongTaskNumberException("Task number '{$value}' is not numeric."); 39 | } 40 | 41 | $number = (int) $value; 42 | 43 | return new self($number); 44 | } 45 | 46 | public function asInt(): int 47 | { 48 | return $this->number; 49 | } 50 | 51 | public function asArrayIndex(): int 52 | { 53 | return $this->number - 1; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Task/Timezone.php: -------------------------------------------------------------------------------- 1 | timezoneForComparisons) { 25 | return $this->timezoneForComparisons; 26 | } 27 | 28 | $newTimezone = $this->configuration 29 | ->get('timezone') 30 | ; 31 | 32 | $this->consoleLogger 33 | ->debug("Timezone from config: '{$newTimezone}'."); 34 | 35 | if (empty($newTimezone)) { 36 | throw new EmptyTimezoneException('Timezone must be configured. Please add it to your config file.'); 37 | } 38 | 39 | $this->consoleLogger 40 | ->debug("Timezone for comparisons: '{$newTimezone}'."); 41 | 42 | $this->timezoneForComparisons = new \DateTimeZone($newTimezone); 43 | 44 | return $this->timezoneForComparisons; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Task/WrongTaskInstanceException.php: -------------------------------------------------------------------------------- 1 | getRealPath(); 16 | 17 | return new self( 18 | "Task at path '{$path}' returned '{$type}', but '{$expectedInstance}' instance is required." 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Timezone/Provider.php: -------------------------------------------------------------------------------- 1 | setName('closure:run') 27 | ->setDescription('Executes a closure as a process.') 28 | ->setDefinition( 29 | [ 30 | new InputArgument( 31 | 'closure', 32 | InputArgument::REQUIRED, 33 | 'The closure to run' 34 | ), 35 | ] 36 | ) 37 | ->setHelp('This command executes a closure as a separate process.') 38 | ->setHidden(true) 39 | ; 40 | } 41 | 42 | protected function execute(InputInterface $input, OutputInterface $output): int 43 | { 44 | $args = []; 45 | /** @var string $closure */ 46 | $closure = $input->getArgument('closure'); 47 | \parse_str($closure, $args); 48 | $serializedClosure = $args[0] ?? ''; 49 | if (false === \is_string($serializedClosure)) { 50 | $serializedClosure = ''; 51 | } 52 | 53 | $closure = $this->closureSerializer 54 | ->unserialize($serializedClosure) 55 | ; 56 | 57 | \call_user_func_array($closure, []); 58 | 59 | return 0; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/UserInterface/Cli/DebugTaskCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Shows all information about task') 30 | ->addArgument( 31 | 'taskNumber', 32 | InputArgument::REQUIRED, 33 | 'Task number from schedule:list command' 34 | ) 35 | ; 36 | } 37 | 38 | protected function execute(InputInterface $input, OutputInterface $output): int 39 | { 40 | /** @var string|null $rawTaskNumber */ 41 | $rawTaskNumber = $input->getArgument('taskNumber'); 42 | $taskNumber = TaskNumber::fromString((string) $rawTaskNumber); 43 | $taskInformationView = $this->taskInformationHandler 44 | ->handle(new TaskInformation($taskNumber)) 45 | ; 46 | 47 | $table = $this->createTable($taskInformationView, $output, $taskNumber); 48 | $table->render(); 49 | 50 | return 0; 51 | } 52 | 53 | private function createTable( 54 | TaskInformationView $taskInformation, 55 | OutputInterface $output, 56 | TaskNumber $taskNumber, 57 | ): Table { 58 | $command = $taskInformation->command(); 59 | $timeZone = $taskInformation->timeZone(); 60 | $configTimeZone = $taskInformation->configTimeZone(); 61 | $runDates = \array_map( 62 | static fn (\DateTimeImmutable $netRunDate): string => $netRunDate->format('Y-m-d H:i:s e'), 63 | $taskInformation->nextRuns() 64 | ); 65 | 66 | $table = new Table($output); 67 | $table->setHeaders( 68 | [ 69 | new TableCell( 70 | "Debug information for task '{$taskNumber->asInt()}'", 71 | ['colspan' => 2] 72 | ), 73 | ] 74 | ); 75 | $table->addRows( 76 | [ 77 | [ 78 | 'Command to run', 79 | \is_object($command) 80 | ? $command::class 81 | : $command, 82 | ], 83 | [ 84 | 'Description', 85 | $taskInformation->description(), 86 | ], 87 | [ 88 | 'Prevent overlapping', 89 | $taskInformation->preventOverlapping() 90 | ? 'Yes' 91 | : 'No', 92 | ], 93 | new TableSeparator(), 94 | [ 95 | 'Cron expression', 96 | $taskInformation->cronExpression(), 97 | ], 98 | [ 99 | 'Comparisons timezone', 100 | null !== $timeZone 101 | ? "{$timeZone->getName()} (from task)" 102 | : "{$configTimeZone->getName()} (from config)", 103 | ], 104 | new TableSeparator(), 105 | [new TableCell('Example run dates', ['colspan' => 2])], 106 | ] 107 | ); 108 | 109 | $i = 1; 110 | foreach ($runDates as $date) { 111 | $table->addRow( 112 | [ 113 | "#{$i}", 114 | $date, 115 | ] 116 | ); 117 | ++$i; 118 | } 119 | 120 | return $table; 121 | } 122 | } 123 | --------------------------------------------------------------------------------