Browse Source

CopyShell

euromark 12 years ago
parent
commit
cb27c4abf9
2 changed files with 724 additions and 0 deletions
  1. 702 0
      Console/Command/CopyShell.php
  2. 22 0
      Test/Case/Console/Command/CopyShellTest.php

+ 702 - 0
Console/Command/CopyShell.php

@@ -0,0 +1,702 @@
+<?php
+App::uses('Folder', 'Utility');
+App::uses('File', 'Utility');
+App::uses('AppShell', 'Console/Command');
+
+/** Valid characters: letters,numbers,underscores,hyphens only */
+if (!defined('VALID_ALPHANUMERIC_HYPHEN_UNDERSCORE')) {
+	define('VALID_ALPHANUMERIC_HYPHEN_UNDERSCORE','/^[\da-zA-Z_-]+$/');
+}
+if (!defined('NL')) {
+	define('NL', PHP_EOL);
+}
+if (!defined('WINDOWS')) {
+	define('WINDOWS', substr(PHP_OS, 0, 3) === 'WIN' ? true : false);
+}
+
+/**
+ * A Copy Shell to update changes to your life site.
+ * It can also be used for backups and other "copy changes only" services.
+ *
+ * Using a PPTP tunnel it is also highly secure while being as fast as ftp tools
+ * like scp or rsnyc or sitecopy can possibly be.
+ *
+ * tested on Console:
+ * - Windows (XP, Vista, Win7, Win8)
+ *
+ * tested with:
+ * - FTP update (updating a remote server)
+ *
+ * does not work with:
+ * - SFTP (although supported in newest version there are errors)
+ *
+ * based on:
+ * - sitecopy [linux] (maybe switch to "rsync" some time...? way more powerful and reliable!)
+ *
+ * @author Mark Scherer
+ * @license MIT
+ * @cakephp 2.x
+ * 2011-11-26 ms
+ */
+class CopyShell extends AppShell {
+
+	public $scriptFolder = null;
+
+	public $sitecopyFolder = null;
+
+	public $sitecopyFolderName = 'copy';
+
+	public $configCustomFile = 'sitecopy.txt'; // inside /config
+
+	public $configGlobalFile = 'sitecopy_global.txt'; // inside /config
+
+	public $configCount = 0; # total count of all configs (all types)
+
+	const TYPE_APP = 0;
+	const TYPE_CAKE = 1;
+	const TYPE_PLUGIN = 2;
+	const TYPE_VENDOR = 3;
+	const TYPE_CUSTOM = 4;
+
+	public $types = array(
+		self::TYPE_APP => 'app',
+		self::TYPE_CAKE => 'cake',
+		self::TYPE_VENDOR => 'vendor',
+		self::TYPE_PLUGIN => 'plugin',
+		self::TYPE_CUSTOM => 'custom'
+	);
+	public $matches = array(
+		self::TYPE_CAKE => 'lib/Cake',
+		self::TYPE_VENDOR => 'vendors', # in root dir
+		self::TYPE_PLUGIN => 'plugins', # in root dir
+	);
+
+	public $type = self::TYPE_APP;
+	public $configName = null; # like "test" in "app_test" or "123" in "custom_123"
+	# both typeName and configName form the "site" name: typeName_configName
+
+	public $configCustom = array(); # configFile Content
+	public $configGlobal = array(); # configFile Content
+
+	public $tmpFolder = null;
+	public $tmpFile = null;
+	public $logFile = null;
+	public $localFolder = APP;
+	public $remoteFolder = null;
+
+	public function startup() {
+		$this->scriptFolder = dirname(__FILE__).DS;
+		$this->sitecopyFolder = $this->scriptFolder . $this->sitecopyFolderName . DS;
+		$this->tmpFolder = TMP . 'cache' . DS . 'copy' . DS;
+
+		/*
+		TODO - garbige clean up of log file
+		if (file_exists($this->tmpFolder.'log.txt') && (int)round(filesize($this->tmpFolder.'log.txt')/1024) > 2000) { # > 2 MB
+			unlink($this->tmpFolder.'log.txt');
+		}
+		//echo (int)round(filesize($this->tmpFolder.'log.txt')/1024);
+		*/
+		parent::startup();
+	}
+
+	/**
+	 * Main method to run updates
+	 * to use params you need to explicitly call "Tools.Copy main -params"
+	 *
+	 * @return void
+	 * 2009-05-04 ms
+	 */
+	public function run() {
+		$configContent = $this->getConfigs();
+
+		# change type if param given
+		if (!empty($this->params['cake'])) { # cake core
+			$this->type = self::TYPE_CAKE;
+		} elseif (!empty($this->params['vendors'])) {
+			$this->type = self::TYPE_VENDOR;
+		} elseif (!empty($this->params['plugins'])) {
+			$this->type = self::TYPE_PLUGIN;
+		}
+		$this->out($this->types[$this->type]);
+
+		# find all mathing configs to this type
+		$configs = array();
+		if (!empty($configContent)) {
+			$configs = $this->getConfigNames($configContent);
+		}
+		$this->out(''.count($configs).' of '.$this->configCount.' configs match:');
+		$this->out('');
+
+		$connections = array();
+
+		if (!empty($configs)) {
+			//$connections = array_keys($configs); # problems with 0 (mistake in shell::in())
+			foreach ($configs as $key => $config) {
+				$this->out(($key+1).': '.$config);
+				$connections[] = $key+1;
+			}
+		} else {
+			$this->out('No configs found in /config/'.$this->configCustomFile.'!');
+		}
+
+		if (false) {
+		/*
+		if (count($connections) == 1) {
+			$connection = 1;
+		} elseif (isset($this->args[0])) {
+			$tryConnection = array_search($this->args[0], $configs);
+			if ($tryConnection !== false) {
+				$connection = $tryConnection+1;
+			}
+		*/
+		} else {
+			array_unshift($connections, 'q', 'h');
+			$connection = $this->in(__('Use Sitecopy Config ([q] to quit, [h] for help)').':', $connections, 'q');
+		}
+
+		$this->out('');
+
+		if (empty($connection) || $connection === 'q') {
+			$this->error('Aborted!');
+		} elseif ($connection === 'h') {
+			$this->help();
+			return;
+		}
+
+		if (in_array($connection, $connections) && is_numeric($connection)) {
+			$configuration = $this->getConfig($configs[$connection-1], $configContent);
+			//$this->typeName :: $this->types[$this->type]
+			$configName = explode('_', $configs[$connection-1], 2);
+			if (!empty($configName[1])) {
+				$this->configName = $configName[1];
+			} else {
+				$this->error('Invalid config name \''.$configs[$connection-1].'\'');
+			}
+		}
+
+		# allow c, v and p only with app configs -> set params (by splitting app_configName into app and configName)
+		if ($this->type > 3 || $this->type > 0 && $configName[0] !== 'app') {
+			$this->error('"-c" (-cake), "-v" (-vendor) and "-p" (-plugin) only possible with app configs (not with custom ones)');
+		}
+
+		if (empty($configuration)) {
+			$this->error('Error...');
+		} else {
+			$this->out('... Config \''.$this->types[$this->type].'_'.$this->configName.'\' selected ...');
+		}
+
+		$hasLocalPath = false;
+		$this->out('');
+		# display global content (if available)
+		if (!empty($this->configGlobal)) {
+			//$this->out('GLOBAL CONFIG:');
+			foreach ($this->configGlobal as $c) {
+				if ($rF = $this->isRemotePath($c)) {
+					$this->remoteFolder = $rF;
+				} elseif ($this->isLocalPath($c)) {
+					$hasLocalPath = true;
+				}
+				//$this->out($c);
+			}
+		}
+
+		# display custom content
+		//$this->out('CUSTOM CONFIG (may override global config):');
+		$this->credentials = array();
+
+		foreach ($configuration as $c) {
+			if ($rF = $this->isRemotePath($c)) {
+				$this->remoteFolder = $rF;
+			} elseif ($lF = $this->isLocalPath($c)) {
+				$this->localFolder = $lF;
+				$hasLocalPath = true;
+			} elseif ($cr = $this->areCredentials($c)) {
+				$this->credentials[] = $cr;
+			}
+		}
+
+		# "vendor" or "cake"? -> change both localFolder and remoteFolder and add them to to the config array
+		if ($this->type > 0) {
+			$configuration = $this->getConfig($this->types[$this->type], $configContent);
+			//pr($configuration);
+			$folder = $this->types[$this->type];
+			if (!empty($this->matches[$this->type])) {
+				$folder = $this->matches[$this->type];
+			}
+
+			# working with different OS - best to always use / slash
+			$this->localFolder = dirname($this->localFolder).DS.$folder;
+			$this->localFolder = str_replace(DS, '/', $this->localFolder);
+
+			$this->remoteFolder = dirname($this->remoteFolder).DS.$folder;
+			$this->remoteFolder = str_replace(DS, '/', $this->remoteFolder);
+
+			foreach ($this->credentials as $c) {
+				$configuration[] = $c;
+			}
+			$configuration[] = $this->localFolder;
+			$configuration[] = $this->remoteFolder;
+		}
+		/*
+		if (!$hasLocalPath) {
+			# add the automatically found app folder as local path (default if no specific local path was given)
+			$localPath = 'local '.TB.TB.$this->localFolder;
+			$this->out($localPath);
+			$configuration[] = $localPath;
+		}
+		*/
+
+		$this->tmpFile = 'config_'.$this->types[$this->type].'_'.$this->configName.'.tmp';
+
+		$this->logFile = 'log_'.$this->types[$this->type].'_'.$this->configName.'.txt';
+
+		# create tmp config file (adding the current APP path, of no local path was given inside the config file)
+		$File = new File($this->tmpFolder.$this->tmpFile, true, 0770);
+		//$File->open();
+		$configTotal = array();
+		# extract "side xyz" from config, add global and then the rest of custom
+		$configTotal[] = 'site '.$this->types[$this->type].'_'.$this->configName;//$configuration[0];
+		unset($configuration[0]);
+		foreach ($this->configGlobal as $c) {
+			$configTotal[] = $c;
+		}
+		foreach ($configuration as $c) {
+			$configTotal[] = $c;
+		}
+
+		foreach ($configTotal as $key => $val) {
+			$this->out($val);
+		}
+
+		$File->write(implode(NL, $configTotal), 'w', true);
+
+		while (true) {
+			$this->out('');
+			$this->out('Type: '.$this->types[$this->type]);
+			$this->out('');
+			$allowedActions = array('i', 'c', 'l', 'f', 'u', 's');
+
+			if (isset($this->args[1])) {
+				$action = strtolower(trim($this->args[1]));
+				$this->args[1] = null; # only the first time
+			} elseif (isset($this->args[0])) {
+				if (mb_strlen(trim($this->args[0])) == 1) {
+					$action = strtolower(trim($this->args[0]));
+				}
+				$this->args[0] = null; # only the first time
+			}
+			if (empty($action) || !in_array($action, $allowedActions)) {
+				$action = strtolower($this->in(__('Init, Catchup, List, Fetch, Update, Synch (or [q] to quit)').':', array_merge($allowedActions, array('q')), 'l'));
+			}
+
+			if ($action === 'q') {
+				$this->error('Aborted!');
+			}
+			if (in_array($action, $allowedActions)) {
+				# synch can destroy local information that might not have been saved yet, so confirm
+				if ($action === 's') {
+					$continue = $this->in(__('Local files might be overridden... Continue?'), array('y', 'n'), 'n');
+					if (strtolower($continue) !== 'y' && strtolower($continue) !== 'yes') {
+						$action = '';
+						continue;
+					}
+				}
+
+				$options = array();
+				$options[] = '--show-progress';
+
+				if (!empty($this->params['force'])) {
+					$options[] = '--keep-going';
+				}
+
+				$name = $this->types[$this->type] . '_' . $this->configName;
+				$this->_execute($name, $action, $options);
+			}
+
+			$action = '';
+		}
+	}
+
+	/**
+	 * only main functions covered - see "sitecopy --help" for more information
+	 * 2009-05-04 ms
+	 */
+	protected function _execute($config = null, $action = null, $options = array()) {
+		$options[] = '--debug=ftp,socket --rcfile=' . $this->tmpFolder . $this->tmpFile .
+			' --storepath=' . $this->tmpFolder . ' --logfile=' . $this->tmpFolder . $this->logFile;
+		if (!empty($action)) {
+			if ($action === 'i') {
+				$options[] = '--initialize';
+			} elseif ($action === 'c') {
+				$options[] = '--catchup';
+			} elseif ($action === 'l') {
+				$options[] = '--list';
+			} elseif ($action === 'f') {
+				$options[] = '--fetch';
+			} elseif ($action === 'u') {
+				$options[] = '--update';
+			} elseif ($action === 's') {
+				$options[] = '--synchronize';
+			}
+		}
+
+		#last
+		if (!empty($config)) {
+			$options[] = $config;
+			//pr($options);
+		}
+
+		$this->_exec(false, $options);
+
+		# "Job Done"-Sound for the time comsuming actions (could be other sounds as well?)
+		if ($action === 'f' || $action === 'u') {
+			$this->_beep();
+		}
+		$this->out('... done ...');
+	}
+
+	/**
+	 * @return boolean $isLocalPath (true/false)
+	 */
+	protected function isLocalPath($line) {
+		if (mb_strlen($line) > 7 && trim(mb_substr($line, 0, 6)) === 'local') {
+			$config = trim(str_replace('local ', '', $line));
+
+			if (!empty($config)) {
+				return $config;
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * @return string $path on success, boolean FALSE otherwise
+	 */
+	protected function isRemotePath($line) {
+		if (mb_strlen($line) > 8 && trim(mb_substr($line, 0, 7)) === 'remote') {
+			$config = trim(str_replace('remote ', '', $line));
+
+			if (!empty($config)) {
+				return $config;
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * @return string $path on success, boolean FALSE otherwise
+	 */
+	protected function areCredentials($line) {
+		if (mb_strlen($line) > 8) {
+			if (trim(mb_substr($line, 0, 7)) === 'server') {
+				$config = trim(str_replace('server ','', $line));
+			} elseif (trim(mb_substr($line, 0, 9)) === 'username') {
+				$config = trim(str_replace('username ','', $line));
+			} elseif (trim(mb_substr($line, 0, 9)) === 'password') {
+				$config = trim(str_replace('password ','', $line));
+			}
+
+			if (!empty($config)) {
+				return $config;
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Make a small sound to inform the user accustically about the success.
+	 *
+	 * @return void
+	 */
+	protected function _beep() {
+		if ($this->params['silent']) {
+			return;
+		}
+		# seems to work only on windows systems - advantage: sound does not need to be on
+		$File = new File($this->scriptFolder.'files'.DS.'beep.bat');
+		$sound = $File->read();
+		system($sound);
+		# seems to work on only on windows xp systems + where sound is on
+		//$sound = 'sndrec32 /play /close "'.$this->scriptFolder.'files'.DS.'notify.wav';
+		//system($sound);
+		if (WINDOWS) {
+
+		} else {
+			exec('echo -e "\a"');
+		}
+	}
+
+	/**
+	 * @return bool $success
+	 */
+	protected function _exec($silent = true, $options = array()) {
+		# make sure, folder exists
+		$Folder = new Folder($this->tmpFolder, true, 0770);
+
+		$f = (WINDOWS ? $this->sitecopyFolder : '') . 'sitecopy ';
+		$f .= implode(' ', $options);
+
+		if (!empty($this->params['debug'])) {
+			$this->hr();
+			$this->out($f);
+			$this->hr();
+			return true;
+		}
+		if ($silent !== false) {
+			$res = exec($f);
+			return $res === 0;
+		}
+		$res = system($f);
+		return $res !== false;
+	}
+
+	/**
+	 * Displays help contents
+	 *
+	 * @access public
+	 * 2009-05-04 ms
+	 */
+	public function help() {
+		$this->hr();
+		$this->out("Usage: cake copy <arg1> <arg2>...");
+		$this->hr();
+		$this->out('Commands [arg1]:');
+		$this->out("\tName of Configuration (only neccessarry if there are more than 1)");
+		$this->out("\nCommands [arg2]:");
+		$this->out("\ti: Init (Mark all files and directories as not updated)");
+		$this->out("\tc: Catchup (Mark all files and directories as updated)");
+		$this->out("\tl: List (Show differences)");
+		$this->out("\tf: Fetch (Get differences)");
+		$this->out("\tu: Update (Copy local content to remote location)");
+		$this->out("\ts: Synch (Get remote content and override local files");
+		$this->out("");
+
+		$continue = $this->in(__('Show script help, too?'), array('y','n'), 'y');
+		if (strtolower($continue) === 'y' || strtolower($continue) === 'yes') {
+			# somehow does not work yet (inside cake console anyway...)
+			$this->_exec(false, array('--help'));
+			$this->out('');
+			$this->_exec(false, array('--version'));
+		}
+		$this->_stop();
+	}
+
+	/**
+	 * read out config file and parse it to an array
+	 * 2009-05-02 ms
+	 */
+	protected function getConfigs() {
+		# global file (may be present)
+		$File = new File($this->localFolder.'config'.DS.$this->configGlobalFile);
+		if ($File->exists()) {
+			$File->open('r');
+			$content = (string)$File->read();
+			$content = explode(NL, $content);
+
+			if (!empty($content)) {
+				$configGlobal = array();
+				foreach ($content as $line => $c) {
+					$c = trim($c);
+					if (!empty($c)) {
+						$configGlobal[] = $c;
+					}
+				}
+				$this->configGlobal = $configGlobal;
+			}
+		}
+
+		# custom file (must be present)
+		$File = new File($this->localFolder.'config'.DS.$this->configCustomFile);
+
+		if (!$File->exists()) {
+			$this->error('No config file present (/config/'.$this->configCustomFile.')!');
+		}
+		$File->open('r');
+
+		# Read out configs
+		$content = $File->read();
+		if (empty($content)) {
+			return array();
+		}
+		$content = explode(NL, $content);
+
+		if (empty($content)) {
+			return array();
+		}
+
+		$configContent = array();
+		foreach ($content as $line => $c) {
+			$c = trim($c);
+			if (!empty($c)) {
+				$configContent[] = $c;
+			}
+		}
+		return $configContent;
+	}
+
+	/**
+	 * get a list with available configs
+	 * @param array $content
+	 * checks on whether all config names are valid!
+	 * 2009-05-02 ms
+	 */
+	protected function getConfigNames($content) {
+		$configs = array();
+		foreach ($content as $c) {
+			if (mb_strlen($c) > 6 && trim(mb_substr($c, 0, 5)) === 'site') {
+				$config = trim(str_replace('site ','', $c));
+				if (!empty($config)) {
+					if (!$this->isValidConfigName($config)) {
+						$this->error('Invalid Config Name \''.$config.'\' in /config/'.$this->configCustomFile.'!'.NL.'Allowed: [app|custom]+\'_\'+{a-z0-9-} or [cake|vendor|plugin]');
+					}
+
+					if ($this->typeMatchesConfigName($config, $this->type)) {
+						$configs[] = $config;
+					}
+					$this->configCount++;
+				}
+			}
+		}
+
+		return $configs;
+	}
+
+	/**
+	 * makes sure nothing strange happens if there is an invalid config name
+	 * (like updating right away on "cake copy u", if u is supposed to be the config name...)
+	 * 2009-05-04 ms
+	 */
+	protected function isValidConfigName($name) {
+		$reservedWords = array('i', 'c', 'l', 'f', 'u', 's');
+		if (in_array($name, $reservedWords)) {
+			return false;
+		}
+		if (!preg_match(VALID_ALPHANUMERIC_HYPHEN_UNDERSCORE, $name)) {
+			return false;
+		}
+		if ($name !== 'cake' && $name !== 'vendor' && $name !== 'plugin' && substr($name, 0, 4) !== 'app_' && substr($name, 0, 7) !== 'custom_') {
+			return false;
+		}
+		return true;
+	}
+
+	/**
+	 * makes sure type matches config name (app = only app configs, no cake or vendor or custom configs!)
+	 * 2009-05-28 ms
+	 * @return string $type on success, otherwise boolean false
+	 */
+	protected function typeMatchesConfigName($name, $type) {
+		if (array_key_exists($type, $this->types) && $name !== 'cake' && $name !== 'vendor' && $name !== 'plugin') {
+			$splits = explode('_', $name, 2); # cake_eee_sss = cake & eee_sss
+			if (!empty($splits[0]) && in_array(trim($splits[0]), $this->types)) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * return the specific config of a config name
+	 * @param string $config name
+	 * @param array $content
+	 * 2009-05-02 ms
+	 */
+	protected function getConfig($config, $content) {
+		$configs = array();
+		$started = false;
+		foreach ($content as $c) {
+			if (mb_strlen($c) > 6 && substr($c, 0, 5) === 'site ') {
+				$currentConfig = trim(str_replace('site ', '', $c));
+				if (!empty($currentConfig) && $currentConfig == $config) {
+					# start
+					if (!$started) {
+						# prevent problems with 2 configs with the same alias (but shouldnt happen anyway)
+						$currentConfig = null;
+					}
+					$started = true;
+				}
+			}
+
+			if ($started && !empty($currentConfig)) {
+				# done
+				break;
+			}
+
+			if ($started) {
+				$configs[] = $c;
+			}
+		}
+		return $configs;
+	}
+
+	public function getOptionParser() {
+		$subcommandParser = array(
+			'options' => array(
+				/*
+				'plugin' => array(
+					'short' => 'g',
+					'help' => __d('cake_console', 'The plugin to update. Only the specified plugin will be updated.'),
+					'default' => ''
+				),
+				'dry-run'=> array(
+					'short' => 'd',
+					'help' => __d('cake_console', 'Dry run the update, no files will actually be modified.'),
+					'boolean' => true
+				),
+				'log'=> array(
+					'short' => 'l',
+					'help' => __d('cake_console', 'Log all ouput to file log.txt in TMP dir'),
+					'boolean' => true
+				),
+				*/
+				'silent'=> array(
+					'short' => 's',
+					'help' => __d('cake_console', 'Silent mode (no beep sound)'),
+					'boolean' => true
+				),
+				'vendors' => array(
+					'short' => 'e',
+					'help' => __d('cake_console', 'ROOT/vendor'),
+					'boolean' => true
+				),
+				'cake' => array(
+					'short' => 'c',
+					'help' => __d('cake_console', 'ROOT/lib/Cake'),
+					'boolean' => true
+				),
+				'app' => array(
+					'short' => 'a',
+					'help' => __d('cake_console', 'ROOT/app'),
+					'boolean' => true
+				),
+				'plugins' => array(
+					'short' => 'p',
+					'help' => __d('cake_console', 'ROOT/plugin'),
+					'boolean' => true
+				),
+				'custom' => array(
+					'short' => 'u',
+					'help' => __d('cake_console', 'custom'),
+					'boolean' => true
+				),
+				'force' => array(
+					'short' => 'f',
+					'help' => __d('cake_console', 'force (keep going regardless of errors)'),
+					'boolean' => true
+				),
+				'debug' => array(
+					'help' => __d('cake_console', 'Debug output only'),
+					'boolean' => true
+				)
+			)
+		);
+
+		return parent::getOptionParser()
+			->description(__d('cake_console', "A shell to quickly upload modified files (diff) only."))
+			->addSubcommand('run', array(
+				'help' => __d('cake_console', 'Update'),
+				'parser' => $subcommandParser
+			));
+	}
+
+}

+ 22 - 0
Test/Case/Console/Command/CopyShellTest.php

@@ -0,0 +1,22 @@
+<?php
+
+App::uses('CopyShell', 'Tools.Console/Command');
+App::uses('MyCakeTestCase', 'Tools.TestSuite');
+
+class CopyShellTest extends MyCakeTestCase {
+
+	public $CopyShell;
+
+	public function setUp() {
+		parent::setUp();
+
+		$this->CopyShell = new CopyShell();
+	}
+
+	public function testObject() {
+		$this->assertTrue(is_object($this->CopyShell));
+		$this->assertInstanceOf('CopyShell', $this->CopyShell);
+	}
+
+	//TODO
+}