src/FolderWatcherCommand.php
<?php
namespace Bluora\LaravelFolderWatcher;
use Illuminate\Console\Command;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
/**
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*/
class FolderWatcherCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'watcher
{action? : The action to run: load, background, run, list, kill}
{optional-value? : When running the help action, specify the action you need help on}
{--config-file= : Specify a Yaml config file to load multiple watchers (load)}
{--watch-path= : Specify a path to watch for file system changes (background,run)}
{--binary= : Specify the binary that is called on a file system change (background,run)}
{--script-arguments= : Specify the arguments to run against the binary that is called on a file system change (background,run)}
{--pid= : Specify a process PID so we can kill it (kill)}';
/**
* The console command description.
*
* @var strings
*/
protected $description = 'Watch a folder. Run the given script over the file that changed.';
/**
* Notify instance.
*
* @var array
*/
private $watcher = [];
/**
* Constants for what we need to be notified about.
*
* @var array
*/
private $watch_constants = IN_CLOSE_WRITE | IN_MOVE | IN_CREATE | IN_DELETE;
/**
* Track notification watch to path.
*
* @var array
*/
private $track_watches = [];
/**
* Options for paths.
*
* @var array
*/
private $path_options = [];
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
switch ($this->argument('action')) {
case 'help':
$this->line('');
$this->line('Help per action not yet implemented.');
$this->line('');
return;
case 'log':
if ($this->argument('optional-value') === 'clear') {
return $this->clearLog();
}
$this->requireArguments($this->argument('action'), 'pid');
return $this->logForProcess($this->option('pid'));
case 'load':
$this->requireArguments($this->argument('action'), 'config-file');
$this->loadFolderWatchers($this->option('config-file'));
return $this->listProcesses();
case 'background':
$this->requireArguments($this->argument('action'), 'watch-path', 'binary', 'script-arguments');
return $this->backgroundProcess($this->option('watch-path'), $this->option('binary'), $this->option('script-arguments'));
case 'run':
$this->requireArguments($this->argument('action'), 'watch-path', 'binary', 'script-arguments');
return $this->runProcess($this->option('watch-path'), $this->option('binary'), $this->option('script-arguments'));
case 'list':
return $this->listProcesses();
case 'kill':
$this->requireArguments($this->argument('action'), 'pid');
$this->killProcess($this->option('pid'));
return $this->listProcesses();
}
$this->line('');
$this->error(sprintf('\'%s\' is not a valid action.', $this->argument('action')));
$this->line('');
$this->line('You can view the available actions by reviewing this commands help"');
$this->line('');
$this->line(' \'php artisan watcher --help\'');
$this->line('');
}
/**
* Check given options for action.
*
* @param string $action
* @param array ...$options
*
* @return void
*
* @SuppressWarnings(PHPMD.ExitExpression)
*/
private function requireArguments($action, ...$options)
{
foreach ($options as $name) {
if (empty($this->option($name))) {
$this->line('');
$this->error(sprintf('%s requires %s options. Missing value for %s.', ucfirst($action), count($options), $name));
$this->line('');
$this->line(sprintf('For example: `php artisan watcher %s %s`', $action, implode(' ', array_map(function ($value) {
return '--'.$value.'=x';
}, $options))));
$this->line('');
exit(1);
}
}
}
/**
* Load watchers from a file.
*
* @return int
*/
private function loadFolderWatchers($config_file)
{
if (!file_exists($config_file_path = $config_file)) {
$config_file_path = base_path().'/'.$config_file;
if (!file_exists($config_file_path)) {
$this->line('');
$this->error(sprintf('Config file %s can not be found.', $config_file));
$this->line('');
return 1;
}
}
try {
$config = Yaml::parse(file_get_contents($config_file_path));
} catch (ParseException $e) {
$this->line('');
$this->error(sprintf('Unable to parse %s %s', $config_file, $e->getMessage()));
$this->line('');
return 1;
}
foreach ($config as $folder => $scripts) {
if (!file_exists($folder_path = $folder)) {
$folder_path = base_path().'/'.$folder;
if (!file_exists($config_file_path)) {
$this->line('');
$this->error(sprintf('Folder %s requested to watch does not exist.', $folder));
$this->line('');
return 1;
}
}
foreach ($scripts as $script) {
foreach ($script as $binary => $script_arguments) {
$this->addLog(sprintf('Will watch \'%s\' and run \'%s %s\'', $folder_path, $binary, str_replace('%s', '<file-path>', $script_arguments)), getmypid());
$this->backgroundProcess($folder_path, $binary, $script_arguments);
}
}
}
return 0;
}
/**
* Run the process in the background.
*
* @param string $directory_path
* @param string $command
*
* @return int
*/
private function backgroundProcess($directory_path, $binary, $script_arguments)
{
$this->cleanProcessList();
$data = $this->getProcessList();
$command_hash = hash('sha256', $binary.' '.$script_arguments);
if (!isset($data[$command_hash])) {
$process_output = [];
exec($complete_command = sprintf('nohup php artisan watcher run --watch-path=%s --binary=%s --script-arguments="%s" > /dev/null 2>&1 & echo $!', $directory_path, $binary, $script_arguments, $command_hash), $process_output);
$pid = (int) $process_output[0];
$this->addLog($complete_command, $pid);
if ($pid > 0) {
$this->addProcess($pid, $directory_path, $binary, $script_arguments);
return 0;
}
$this->error('Failed to run this background process.');
return 0;
}
$this->error('Folder watch already exists.');
return 1;
}
/**
* Watch the provided folder and run the given command on files.
*
* @param string $directory_path
* @param string $command
*
* @return int
*/
private function runProcess($directory_path, $binary, $script_arguments)
{
if (!function_exists('inotify_init')) {
$this->error('You need to install PECL inotify to be able to use watcher.');
return 1;
}
$this->command = $binary.' '.$script_arguments;
// Initialize an inotify instance.
$this->watcher = inotify_init();
$this->root_path = $directory_path;
// Add the given path.
$this->addWatchPath($directory_path);
// Listen for notifications.
return $this->listenForEvents();
}
/**
* List the processes that are running in the background.
*
* @return void
*/
private function listProcesses()
{
$this->cleanProcessList();
$data = $this->getProcessList();
$this->line('');
if (count($data)) {
$this->info('Listed below are the active folder watchers.');
$this->line('');
$headers = ['PID', 'Watching folder', 'Binary', 'Script arguments'];
$rows = [];
foreach ($data as $pid => $process) {
if (is_int($pid)) {
$rows[] = [
$pid,
$process['directory_path'],
$process['binary'],
str_replace('%s', '[file-path]', $process['script_arguments']),
];
}
}
$this->table($headers, $rows);
$this->line('');
$this->line('You can view a processes log by running:');
$this->line('');
$this->line(' \'php artisan watcher log --pid=[<pid>]\'');
$this->line('');
$this->line('You can kill a specific or all processes by running the following:');
$this->line('');
$this->line(' \'php artisan watcher kill --pid=[<pid>|all]\'');
$this->line('');
return;
}
$this->line('No active folder watchers.');
$this->line('');
}
/**
* Log for a specific process.
*
* @param int|string $pid
*
* @return void
*/
private function logForProcess($pid)
{
$log_path = $this->logPath();
$size = 0;
while (true) {
clearstatcache();
$current_size = filesize($log_path);
if ($size == $current_size) {
usleep(10000);
continue;
}
$file_handle = fopen($log_path, 'r');
fseek($file_handle, $size);
while ($line = fgets($file_handle)) {
if ($pid === 'all' || stripos($line, '<'.$pid.'>') !== false) {
$this->line(trim($line));
}
}
fclose($file_handle);
$size = $current_size;
}
}
/**
* Kill a background process.
*
* @param int $pid
*
* @return int
*/
private function killProcess($pid)
{
$data = $this->getProcessList();
if ($pid === 'all') {
foreach (array_keys($data) as $pid) {
if (is_int($pid)) {
$this->killProcess($pid);
}
}
return 0;
}
if (is_int($pid) && isset($data[$pid])) {
unset($data[$pid]);
$this->saveProcessList($data);
posix_kill((int) $pid, SIGKILL);
return 0;
}
return 1;
}
/**
* Listen for notification.
*
* @return void
*/
private function listenForEvents()
{
// As long as we have watches that exist, we keep looping.
while (count($this->track_watches)) {
// Check the inotify instance for any change events.
$events = inotify_read($this->watcher);
// One or many events occured.
if ($events !== false && count($events)) {
foreach ($events as $event_detail) {
$this->processEvent($event_detail);
}
}
}
}
/**
* Process the events that have occured.
*
* @param array $event_detail
*
* @return void
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.NPathComplexity)
*/
private function processEvent($event_detail)
{
$is_dir = false;
// Directory events have a different hex, convert to the same number for a file event.
$hex = $event_detail['mask'];
$dechex = (string) dechex($event_detail['mask']);
// Correctly apply for 40.
if ($dechex === '40') {
$event_detail['mask'] = IN_MOVED_FROM;
} elseif (substr($dechex, 0, 1) === '4') {
$dechex[0] = '0';
$event_detail['mask'] = hexdec((int) $dechex);
$is_dir = true;
}
// This event is ignored, obviously.
if ($event_detail['mask'] == IN_IGNORED) {
return;
}
// This event refers to a path that exists.
elseif (isset($this->track_watches[$event_detail['wd']])) {
// File or folder path
$file_path = $this->track_watches[$event_detail['wd']].'/'.$event_detail['name'];
$path_options = $this->path_options[$event_detail['wd']];
$this->addLog(sprintf('%s event: [%s] %s', $is_dir ? 'Folder' : 'File', $event_detail['mask'], $file_path));
if ($is_dir) {
switch ($event_detail['mask']) {
// New folder created.
case IN_CREATE:
// New folder was moved, so need to watch new folders.
// New files will run the command.
case IN_MOVED_TO:
$this->addWatchPath($file_path, $path_options);
break;
// Folder was deleted or moved.
// Each file will trigger and event and so will run the command then.
case IN_DELETE:
case IN_MOVED_FROM:
$this->removeWatchPath($file_path);
break;
}
return;
}
// Check file extension against the specified filter.
$file_extension = pathinfo($file_path, PATHINFO_EXTENSION);
if (isset($path_options['filter']) && $file_extension != '') {
if (count($path_options['filter_allowed']) && !in_array($file_extension, $path_options['filter_allowed'])) {
return;
}
if (count($path_options['filter_not_allowed']) && in_array('!'.$file_extension, $path_options['filter_not_allowed'])) {
return;
}
}
// Run command for all these file events.
switch ($event_detail['mask']) {
case IN_CLOSE_WRITE:
case IN_MOVED_TO:
$this->runCommand($file_path);
break;
case IN_MOVED_FROM:
case IN_DELETE:
$this->runCommand($file_path, true);
break;
}
}
}
/**
* Run the given provided command.
*
* @param string $file_path
*
* @return void
*/
private function runCommand($file_path, $delete = false)
{
$find_replace = [
'file-path' => $file_path,
'root-path' => $this->root_path,
'file-removed' => $delete ? 1 : 0,
];
$find_values = array_map(function ($key) {
return '{{'.$key.'}}';
}, array_keys($find_replace));
$replace_values = array_values($find_replace);
$command = str_replace($find_values, $replace_values, $this->command);
$this->addLog('Running: '.$command);
exec($command);
}
/**
* Add a path to watch.
*
* @param string $path
* @param bool|array $options
*
* @return void
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*/
private function addWatchPath($original_path, $options = false)
{
$path = trim($original_path);
if ($options === false) {
list($path, $options) = self::parseOptions($path);
}
if (isset($options['filter'])) {
$options['filter'] = explode(',', $options['filter']);
$options['filter_allowed'] = array_filter($options['filter'], function ($value) {
return substr($value, 0, 1) !== '!';
});
$options['filter_not_allowed'] = array_filter($options['filter'], function ($value) {
return substr($value, 0, 1) === '!';
});
}
// Watch this folder.
$watch_id = inotify_add_watch($this->watcher, $path, $this->watch_constants);
$this->track_watches[$watch_id] = $path;
$this->path_options[$watch_id] = $options;
if (is_dir($path)) {
$this->addLog('Watching: '.$path);
// Find and watch any children folders.
$folders = $this->scan($path, true, false);
foreach ($folders as $folder_path) {
if (file_exists($path)) {
$this->addLog('Watching: '.$folder_path);
$watch_id = inotify_add_watch($this->watcher, $folder_path, $this->watch_constants);
$this->track_watches[$watch_id] = $folder_path;
$this->path_options[$watch_id] = $options;
}
}
}
}
/**
* Parse options off a string.
*
* @return array
*/
public static function parseOptions($input)
{
$input_array = explode('?', $input);
$string = $input_array[0];
$string_options = !empty($input_array[1]) ? $input_array[1] : '';
$options = [];
parse_str($string_options, $options);
return [$string, $options];
}
/**
* Scan recursively through each folder for all files and folders.
*
* @param string $scan_path
* @param bool $include_folders
* @param bool $include_files
* @param int $depth
*
* @return void
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
*/
public static function scan($scan_path, $include_folders = true, $include_files = true, $depth = -1)
{
$paths = [];
if (substr($scan_path, -1) != '/') {
$scan_path .= '/';
}
$contents = scandir($scan_path);
foreach ($contents as $value) {
if ($value === '.' || $value === '..') {
continue;
}
$absolute_path = $scan_path.$value;
if (is_dir($absolute_path) && $depth != 0) {
$new_paths = self::scan($absolute_path.'/', $include_folders, $include_files, $depth - 1);
$paths = array_merge($paths, $new_paths);
}
if ((is_file($absolute_path) && $include_files) || (is_dir($absolute_path) && $include_folders)) {
$paths[] = $absolute_path;
}
}
return $paths;
}
/**
* Remove path from watching.
*
* @param string $file_path
*
* @return void
*/
private function removeWatchPath($file_path)
{
// Find the watch ID for this path.
$watch_id = array_search($file_path, $this->track_watches);
// Remove the watch for this folder and remove from our tracking array.
if ($watch_id !== false && isset($this->track_watches[$watch_id])) {
$this->addLog('Unwatching: '.$file_path);
try {
inotify_rm_watch($this->watcher, $watch_id);
} catch (\Exception $exception) {
}
unset($this->track_watches[$watch_id]);
unset($this->path_options[$watch_id]);
}
}
/**
* Get the file path that we're using to store our background processes.
*
* @return string
*/
private function processListPath()
{
return $this->getWorkingDirectory('.active_folder_watcher.yml');
}
/**
* Add a background process to the file.
*
* @param int $pid
* @param string $directory_path
* @param string $command
*
* @return void
*/
private function addProcess($pid, $directory_path, $binary, $script_arguments)
{
$data = $this->getProcessList();
$data[$pid] = [
'directory_path' => $directory_path,
'binary' => $binary,
'script_arguments' => $script_arguments,
];
$data[hash('sha256', $binary.' '.$script_arguments)] = $pid;
$this->saveProcessList($data);
}
/**
* Remove any background processes that may have terminated.
*
* @return void
*/
private function cleanProcessList()
{
$data = $this->getProcessList();
$sha_to_pid = [];
foreach ($data as $pid => $process) {
if (is_int($pid)) {
if (!posix_kill($pid, 0)) {
unset($data[$pid]);
$this->addLog('Process was dead', $pid);
}
continue;
}
$sha_to_pid[$process] = $pid;
}
foreach ($sha_to_pid as $pid => $sha) {
if (!isset($data[$pid])) {
unset($data[$sha]);
}
}
$this->saveProcessList($data);
}
/**
* Get the list of processes from the file.
*
* @return array
*
* @SuppressWarnings(PHPMD.ExitExpression)
*/
private function getProcessList()
{
$process_list_path = $this->processListPath();
// Parse the YAML config file.
try {
$result = Yaml::parse(file_get_contents($process_list_path));
return is_array($result) ? $result : [];
} catch (ParseException $e) {
$this->error(sprintf('Unable to parse %s %s', $process_list_path, $e->getMessage()));
exit(1);
}
}
/**
* Save the process to the file.
*
* @param array $data
*
* @return void
*/
private function saveProcessList($data)
{
$process_list_path = $this->processListPath();
file_put_contents($process_list_path, Yaml::dump($data));
}
/**
* Get the file path that we're using to store our process logs.
*
* @return string
*/
private function logPath()
{
return $this->getWorkingDirectory('.log_folder_watcher.yml');
}
/**
* Add text to the log.
*
* @param string $text
* @param int $pid
*
* @return void
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*/
private function addLog($text, $pid = 0)
{
if ($pid === 0) {
$pid = getmypid();
}
$log_path = $this->logPath();
$file_handle = fopen($log_path, 'a+');
fwrite($file_handle, sprintf('[%s] <%s> %s', date('Y-m-d H:i:s'), $pid, $text)."\n");
fclose($file_handle);
}
/**
* Clear the log.
*
* @return void
*/
private function clearLog()
{
$log_path = $this->logPath();
file_put_contents($log_path, '');
$this->line('');
$this->info('Log file has been cleared.');
$this->line('');
}
/**
* Get the working directory to save process and logs.
*
* @param string $file_name
*
* @return string
*/
private function getWorkingDirectory($file_name)
{
$path = env('XDG_RUNTIME_DIR') ? env('XDG_RUNTIME_DIR') : $this->getUserHome();
$path = empty($path) ? $_SERVER['TMPDIR'] : $path;
$path .= '/'.$file_name;
// Create empty file.
if (!file_exists($path)) {
file_put_contents($path, '');
}
return $path;
}
/**
* Return the user's home directory.
*/
private function getUserHome()
{
// Linux home directory
$home = getenv('HOME');
if (!empty($home)) {
$home = rtrim($home, '/');
}
// Windows home directory
elseif (!empty($_SERVER['HOMEDRIVE']) && !empty($_SERVER['HOMEPATH'])) {
$home = rtrim($_SERVER['HOMEDRIVE'].$_SERVER['HOMEPATH'], '\\/');
}
return empty($home) ? null : $home;
}
}