diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6150b28 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2011 Corey Ballou + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ea4550 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +About Watchdog +-------------- + +Watchdog is a PHP class implementing inotify which watches for changes to files in +any given number of directories specified in the input. It will not +recursively check directories, so you must implicitely specify each directory +you want watched. + +Although it was developed for the purpose of monitoring dynamic css files for +changes and auto-regenerating their output, it can easily be transformed into +a watchdog for just about anything. + +Requirements +------------ + +Watchdog requires you to have the PHP PEAR library inotify installed. If you have +PEAR installed, you may run the following from the command line to install inotify: + +```bash +sudo pecl install inotify +``` + +Prerequisites +------------- + +Watchdog is merely an intermediary between you and your dynamic CSS files. It +does not handle any form of parsing internally, and subsequently passes off +proper executable commands to the command line for compiling. For these reasons, +watchdog assumes that you have one or more of the following modules installed +(depending on your particular necessities): + +* SASS +* LESS +* Stylus + +Each of these applications have their own set of dependencies. + +* SASS - Requires Ruby and RubyGems +* LESS - Requires nodejs and npm +* Stylus - Requires nodejs and npm + +"sass: command not found" +--------------------- + +If you are receiving the error "sass: command not found", you will need to +ensure you have the SASS ruby gem installed: + +```bash +sudo gem install sass` +``` + +"lessc: command not found" +--------------------- + +If you are receiving the error "lessc: command not found", you will need to +ensure you have the LESS npm module installed globally: + +```bash +sudo npm install -g less +``` + +"stylus: command not found" +--------------------- + +If you are receiving the error "stylus: command not found", you will need to +ensure you have the stylus npm module installed globally: + +```bash +sudo npm install -g stylus +``` + +Otherwise, you are left with modules installed to your base installation directory. + +I'm still getting command not found +--------------------- +For Node.JS NPM modules (stylus and less), ensure you have the NODE_PATH variable +appropriately set: + +```bash +echo 'export NODE_PATH=/usr/local/lib/node_modules' >> ~/.profile +source ~/.profile +``` diff --git a/cli.php b/cli.php new file mode 100644 index 0000000..995c695 --- /dev/null +++ b/cli.php @@ -0,0 +1,161 @@ + 1, + 'scss' => 1, + 'styl' => 1, + 'less' => 1 +); + +// retrieve options +$options = getopt($shortopts, $longopts); + +// check for help +if (isset($options['h']) || isset($options['help'])) { + $output = "Watchdog is a monitor for popular dynamic stylesheet languages. + +Usage: + +watchdog [options] + +Options: + + -t\t--types \t\tSpecify the file extensions to watch, separated by commas with no spaces (supports sass, scss, less, styl). + -w\t--watch \t\tSpecify the directories to watch for file changes, separated by commas with no spaces. + -c\t--config \t\t\tSpecify an absolute/relative path to a config file to load options. + -m\t--minify\t\t\tFlag to indicate you wish to minify/compress output. + -h\t--help\t\t\t\tThis help documentation. + +Example Config File (written in PHP, requires return): + + array('sass', 'scss', 'less', 'styl'), + 'watch' => array( + '/path/to/public/css', + '/path/to/alternate/css' + ), + 'minify' => true +); +"; + die($output); +} + +// check for a config file to parse +if (!empty($options['c']) || !empty($options['config'])) { + if (!empty($options['c'])) { + if (!is_file($options['c'])) { + die('The configuration file ' . $options['c'] . ' does not exist.'); + } + $conf = $options['c']; + } else if (!empty($options['config'])) { + if (!is_file($options['config'])) { + die('The configuration file ' . $options['config'] . ' does not exist.'); + } + $conf = $options['config']; + } + + // load the config file + $config = include_once($conf); + + // console log + echo 'Loading the config file ' . $conf . PHP_EOL; +} + +// determine directories to watch +if (!empty($config['watch'])) { + $directoryWatch = $config['watch']; +} else if (!empty($options['w'])) { + $directoryWatch = $options['w']; +} else if (!empty($options['watch'])) { + $directoryWatch = $options['watch']; +} else { + $directoryWatch = './'; +} + +if (!is_array($directoryWatch)) { + $directoryWatch = explode(',', $directoryWatch); +} + +// do some basic validation on the watch directories +foreach ($directoryWatch as $k => $d) { + if (!is_file($d) && !is_dir($d)) { + unset($directoryWatch[$k]); + echo $d . ' is not a valid file or directory. Removing from watchdog.' . PHP_EOL; + } +} + +if (empty($directoryWatch)) { + die('No valid directories are set to be watched. Aborting.'); +} + +// console log +echo 'Watching the following directories:' . PHP_EOL; +echo ' ' . implode(PHP_EOL . ' ', $directoryWatch) . PHP_EOL . PHP_EOL; + +// determine file extensions to watch for +if (!empty($config['types'])) { + $fileExtensions = $config['types']; +} else if (!empty($options['t'])) { + $fileExtensions = $options['t']; +} else if (!empty($options['types'])) { + $fileExtensions = $options['types']; +} else { + $fileExtensions = array( + 'sass', + 'scss', + 'styl', + 'less' + ); +} + +if (!is_array($fileExtensions)) { + $fileExtensions = explode(',', $fileExtensions); +} + +// do some basic validation on the file extensions +foreach ($fileExtensions as $k => $f) { + if (!isset($validExtensions[$f])) { + unset($fileExtensions[$k]); + echo $f . ' is not a valid file extension. Removing from watchdog.' . PHP_EOL; + } +} + +if (empty($fileExtensions)) { + die('No valid file extensions are set to be watched. Aborting.'); +} + +// console log +echo 'Watching the following file extensions: ' . implode(', ', $fileExtensions) . PHP_EOL . PHP_EOL; + +// determine if we're minifying file output +$minified = false; +if (!empty($config['minify'])) { + $minified = true; +} else if (isset($options['m'])) { + $minified = true; +} else if (isset($options['minify'])) { + $minified = true; +} diff --git a/watchdog b/watchdog new file mode 100755 index 0000000..022907a --- /dev/null +++ b/watchdog @@ -0,0 +1,450 @@ +#!/usr/bin/php +_watchedFileExtensions = $fileExtensions; + + // store any ignored files + $this->_ignoredFiles = $ignoredFiles; + + // store minify flag + $this->_minify = $minify; + + // initialize an inotify instance + $this->_inotify = inotify_init(); + + // don't block the stream + stream_set_blocking($this->_inotify, self::MODE_NONBLOCKING); + + // watch files within specific directories (on modify) + foreach ($directories as $d) { + $this->add($d); + } + + // initialize the watchdog + $this->init(); + } + + /** + * Continuous watching. + * + * @access public + * @return void + */ + public function init() + { + pcntl_signal(SIGTERM, array(&$this, "signal_handler")); + pcntl_signal(SIGINT, array(&$this, "signal_handler")); + + // begin the watchdog + while(1) { + // watch + $this->watch(); + + // pause before checking again + usleep(self::PAUSE_MS); + } + } + + /** + * Perform the actual check for file changes. + * + * @access public + * @return void + */ + public function watch() + { + // check for any changes + $events = $this->read(); + if (!empty($events)) { + // ensure we don't have duplicate events (it happens...) + $events = $this->_array_unique($events); + + // iterate over events + foreach ($events as $e) { + // handle the event + $filename = $e['name']; + + // check if we actually need to do anything with the file + $ext = strtolower(substr($filename, strrpos($filename, '.') + 1)); + if (in_array($ext, $this->_watchedFileExtensions)) { + + // generate the full path to the file + $dirpath = + rtrim($this->_pathLookup[$e['wd']], DIRECTORY_SEPARATOR) . + DIRECTORY_SEPARATOR; + + $filepath = $dirpath . $filename; + + // notify of detected change + echo 'Change detected in: ' . $filepath . PHP_EOL; + + // perform the update on the command line + switch ($ext) { + case 'sass': + case 'scss': + $output = $this->_executeSassCmd($filepath, $dirpath, $ext); + break; + case 'less': + $output = $this->_executeLessCmd($filepath, $dirpath, $ext); + break; + case 'styl': + $output = $this->_executeStylusCmd($filepath, $dirpath, $ext); + break; + } + + // display to STDOUT + echo $output . PHP_EOL; + } + } + } + } + + /** + * Add a new file/folder to watch. The return value is a unique (inotify + * instance wide) watch descriptor. + * + * @access public + * @param string $path + * @param array $events A masked integer of all events + * + */ + public function add($path, $events = IN_MODIFY) + { + // check if we need to first remove a matching path + if (isset($this->_watched[$path])) { + $this->remove($path); + } + + // add the new watch + $descriptor = inotify_add_watch($this->_inotify, $path, $events); + + // create the array + $info = array( + 'events' => $events, + 'descriptor' => $descriptor + ); + + // create a reverse lookup on the descriptor + $this->_pathLookup[$descriptor] = $path; + + // add to watched list + $this->_watched[$path] = $info; + } + + /** + * Removes an inotify watch. + * + * @access public + * @param string $path + * @return mixed + */ + public function remove($path) + { + if (isset($this->_watched[$path])) { + if (inotify_rm_watch($this->_inotify, $this->_watched[$path]['descriptor'])) { + unset($this->_pathLookup[ $this->_watched[$path]['descriptor'] ]); + unset($this->_watched[$path]); + return true; + } + } + + return false; + } + + /** + * Remove all inotify watches. + * + * @access public + * @return void + */ + public function removeAll() + { + foreach ($this->_watched as $path => $watch) { + if (inotify_rm_watch($this->_inotify, $watch['descriptor'])) { + unset($this->_pathLookup[ $watch['descriptor'] ]); + unset($this->_watched[$path]); + } + } + } + + /** + * Read events from an inotify instance. Returns an array of inotify events + * or FALSE if no events was pending and inotify_instance is non-blocking. + * Each event is an array with the following keys: + * + * - wd is a watch descriptor returned by inotify_add_watch() + * - mask is a bit mask of events + * - cookie is a unique id to connect related events (e.g. IN_MOVE_FROM and IN_MOVE_TO) + * - name is the name of a file (e.g. if a file was modified in a watched directory) + * + * @access public + * @return mixed + */ + public function read() + { + return inotify_read($this->_inotify); + } + + /** + * Retrieves the number of pending events. + * + * @access public + * @return int + */ + public function getPendingEventsCount() + { + return inotify_queue_len($this->_inotify); + } + + /** + * Handle the signals. Gracefully removes all inotify watches before closing. + * + * @access public + * @param int $signal + */ + public function signal_handler($signal) + { + switch($signal) { + case SIGTERM: + $this->removeAll(); + echo 'Caught the signal SIGTERM. Now exiting.' . PHP_EOL; + exit; + case SIGKILL: + $this->removeAll(); + print 'Caught the signal SIGKILL. Now exiting.' . PHP_EOL; + exit; + case SIGINT: + $this->removeAll(); + print 'Caught the signal SIGINT. Now exiting.' . PHP_EOL; + exit; + } + } + + /** + * Execute the SASS command for generating output. As a side note, SASS + * can output styles of nested (default), compact, compressed, or expanded. + * We currently only handle one case. + * + * @access public + * @param string $inFile + * @param string $path + * @param string $ext + * @return void + */ + protected function _executeSassCmd($inFile, $path, $ext) + { + // generate the outfile name + $outFile = $this->_generateOutFile($inFile, $ext); + + // create the command + if ($this->_minify) { + $cmd = 'sass -I ' . $path . ' --style compressed ' . $inFile . ' ' . $outFile; + } else { + $cmd = 'sass -I ' . $path . ' ' . $inFile . ' ' . $outFile; + } + + // execute the command and display output + return shell_exec($cmd); + } + + /** + * Execute the LESS command for generating output. Note that the include + * path needs to be relative to the directory that lessc is executing in. + * + * @access public + * @param string $inFile + * @param string $path + * @param string $ext + * @return void + */ + protected function _executeLessCmd($inFile, $path, $ext) + { + // generate the outfile name + $outFile = $this->_generateOutFile($inFile, $ext); + + // create the command + if ($this->_minify) { + $cmd = 'lessc -x --include-path ' . $path . ' ' . $inFile . ' > ' . $outFile; + } else { + $cmd = 'lessc --include-path ' . $path . ' ' . $inFile . ' > ' . $outFile; + } + + // execute the command and display output + return shell_exec($cmd); + } + + /** + * Execute the Stylus command for generating output. More documentation on + * CLI arguments for stylus are available at: + * + * http://learnboost.github.com/stylus/docs/executable.html + * + * @access public + * @param string $inFile + * @param string $path + * @param string $ext + * @return void + */ + protected function _executeStylusCmd($inFile, $path, $ext) + { + // generate the outfile name + $outFile = $this->_generateOutFile($inFile, $ext); + + // create the command + if ($this->_minify) { + $cmd = 'stylus -c -I ' . $path . ' < ' . $inFile . ' > ' . $outFile; + } else { + $cmd = 'stylus -I ' . $path . ' < ' . $inFile . ' > ' . $outFile; + } + + // execute the command and display output + return shell_exec($cmd); + } + + /** + * Given a full path to a file, return the same file with a + * CSS extension. + * + * @access protected + * @param string $inFile + * @param string $ext + * @return string + */ + protected function _generateOutFile($inFile, $ext) + { + return substr($inFile, 0, strrpos($inFile, '.')) . '.css'; + } + + /** + * De-dupe a multi-dimensional array. + * + * @access protected + * @param array $arr + * @return array + */ + protected function _array_unique($arr) { + return array_map( + 'unserialize', + array_unique(array_map('serialize', $arr)) + ); + } + +} + +// load the watchdog +$watchdog = new Watchdog($directoryWatch, $fileExtensions, $minified);