diff --git a/setup/cli/modules/deploy.php b/setup/cli/modules/deploy.php
index 701ab0c677984737af8afff249810e28dcc89ca2..c5100a6a2e74dade52ea39069d8f55022d067579 100644
--- a/setup/cli/modules/deploy.php
+++ b/setup/cli/modules/deploy.php
@@ -180,7 +180,7 @@ class Deployment extends Unpacker {
         $X = implode(' --exclude-per-directory=', $patterns);
         chdir($source.$local);
         if (!($files = proc_open(
-            "git ls-files -s --full-name --exclude-standard --exclude-per-directory=$X -- .",
+            "git ls-files -zs --exclude-standard --exclude-per-directory=$X -- .",
             array(1 => array('pipe', 'w')),
             $pipes
         ))) {
@@ -189,9 +189,9 @@ class Deployment extends Unpacker {
 
         $dryrun = $this->getOption('dry-run', false);
         $verbose = $this->getOption('verbose') || $dryrun;
-        while ($line = stream_get_line($pipes[1], 255, PHP_EOL)) {
+        while ($line = stream_get_line($pipes[1], 255, "\x00")) {
             list($mode, $hash, , $path) = preg_split('/\s+/', $line);
-            $src = $source.$path;
+            $src = $source.$local.$path;
             if ($this->exclude($exclude, $src))
                 continue;
             if (!$this->isChanged($src, $hash))
diff --git a/setup/cli/modules/package.php b/setup/cli/modules/package.php
new file mode 100644
index 0000000000000000000000000000000000000000..77a86a9446382cd010b9972bcf6e854ee9557367
--- /dev/null
+++ b/setup/cli/modules/package.php
@@ -0,0 +1,121 @@
+<?php
+require_once "deploy.php";
+
+class Packager extends Deployment {
+    var $prologue = "Creates an osTicket distribution ZIP file";
+
+    var $epilog =
+        "Packaging is based on the `deploy` and `test` cli apps. After
+        running the tests, the system is deployed into a temporary staging
+        area using the files tracked in git if supported. Afterwards, the
+        staging area is packaged as a ZIP file.";
+
+    var $options = array(
+        'format' => array('-F','--format',
+            'default'=>'zip',
+            'help'=>'Output the package in this format. Supported formats are
+                "zip" (the default), and "targz"'
+        ),
+        'skip-test' => array('-S','--skip-test',
+            'action'=>'store_true', 'default'=>false,
+            'help'=>'Skip regression testing (NOT RECOMMENDED)',
+        ),
+    );
+    var $arguments = array();
+
+    function __construct() {
+        // Skip options added to the deploy — options and arguments are
+        // forced in this module
+        call_user_func_array(array('Module', '__construct'), func_get_args());
+    }
+
+    function run($args, $options) {
+        // Set some forced args and options
+        $temp = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+        $stage_path = $temp . 'osticket'
+            . substr(md5('osticket-stage'.getmypid().getcwd()), -8);
+        $args['install-path'] = $stage_path . '/upload';
+
+        // Deployment will auto-create the staging area
+
+        // Ensure that the staging path is cleaned up on exit
+        register_shutdown_function(function() use ($stage_path) {
+            $delTree = function($dir) use (&$delTree) {
+                $files = array_diff(scandir($dir), array('.','..'));
+                foreach ($files as $file) {
+                    (is_dir("$dir/$file")) ? $delTree("$dir/$file") : unlink("$dir/$file");
+                }
+                return rmdir($dir);
+            };
+            return $delTree($stage_path);
+        });
+
+        $options['setup'] = true;
+        $options['git'] = true;
+        $options['verbose'] = true;
+
+        $options['clean'] = false;
+        $options['dry-run'] = false;
+        $options['include'] = false;
+
+        $this->_args = $args;
+        $this->_options = $options;
+
+        // TODO: Run the testing applet first
+        $root = $this->find_root_folder();
+        if (!$this->getOption('skip-test') && $this->run_tests($root) > 0)
+            $this->fail("Regression tests failed. Cowardly refusing to package");
+
+        // Run the deployment
+        // NOTE: The deployment will change the working directory
+        parent::run($args, $options);
+
+        // Deploy the `setup/scripts` folder to `/scripts`
+        $root = $this->source;
+        Unpacker::unpackage("$root/setup/scripts/{,.}*", "$stage_path/scripts", -1);
+
+        // Package up the staging area
+        $version = exec('git describe');
+        switch (strtolower($this->getOption('format'))) {
+        case 'zip':
+        default:
+            $this->packageZip("$root/osTicket-$version.zip", $stage_path);
+        }
+    }
+
+    function run_tests($root) {
+        return (require "$root/setup/test/run-tests.php");
+    }
+
+    function packageZip($name, $path) {
+        $zip = new ZipArchive();
+        if (!$zip->open($name, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true)
+            return false;
+
+        $php56 = version_compare(phpversion(), '5.6.0', '>');
+        $addFiles = function($dir) use (&$addFiles, $zip, $path, $php56) {
+            $files = array_diff(scandir($dir), array('.','..'));
+            $path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+            foreach ($files as $file) {
+                $full = "$dir/$file";
+                $local = str_replace($path, '', $full);
+                if (is_dir($full))
+                    $addFiles($full);
+                else
+                    // XXX: AddFile() will keep the file open and run
+                    //      out of OS open file handles
+                    $zip->addFromString($local, file_get_contents($full));
+                    // This only works on PHP >= v5.6
+                    if ($php56) {
+                        // Set the Unix mode of the file
+                        $stat = stat($full);
+                        $zip->setExternalAttributesName($local, ZipArchive::OPSYS_UNIX, $stat['mode']);
+                    }
+            }
+        };
+        $addFiles($path);
+        return $zip->close();
+
+    }
+}
+Module::register('package', 'Packager');
diff --git a/setup/cli/package.php b/setup/cli/package.php
deleted file mode 100755
index b758f63c6fa3e40f3fab39b5ff8e56c0582cef28..0000000000000000000000000000000000000000
--- a/setup/cli/package.php
+++ /dev/null
@@ -1,162 +0,0 @@
-#!/usr/bin/env php
-<?php
-
-if (php_sapi_name() != 'cli')
-    die("Only command-line packaging is supported");
-
-$stage_folder = "stage";
-$stage_path = dirname(__file__) . '/' . $stage_folder;
-
-function get_osticket_root_path() {
-    # Hop up to the root folder
-    $start = dirname(__file__);
-    for (;;) {
-        if (file_exists($start . '/main.inc.php')) break;
-        $start .= '/..';
-    }
-    return realpath($start);
-}
-
-function run_tests($root) {
-    return (require "$root/setup/test/run-tests.php");
-}
-
-# Check PHP syntax across all php files
-function glob_recursive($pattern, $flags = 0) {
-    $files = glob($pattern, $flags);
-    foreach (glob(dirname($pattern).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
-        $files = array_merge($files,
-            glob_recursive($dir.'/'.basename($pattern), $flags));
-    }
-    return $files;
-}
-
-$root = get_osticket_root_path();
-
-function exclude($pattern, $match) {
-    if (is_array($pattern)) {
-        foreach ($pattern as $p)
-            if (fnmatch($p, $match))
-                return true;
-    }
-    else
-        return fnmatch($pattern, $match);
-    return false;
-}
-
-function package($pattern, $destination, $recurse=false, $exclude=false) {
-    global $root, $stage_path;
-    $search = $root . '/' . $pattern;
-    echo "Packaging " . $search . "\n";
-    foreach (glob($search, GLOB_BRACE|GLOB_NOSORT) as $file) {
-        if (is_file($file)) {
-            if ($exclude && exclude($exclude, $file))
-                continue;
-            if (!is_dir("$stage_path/$destination"))
-                mkdir("$stage_path/$destination", 0777, true);
-            copy($file, $stage_path . '/' . $destination . '/' . basename($file));
-        }
-    }
-    if ($recurse) {
-        foreach (glob(dirname($search).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
-            if ($exclude && exclude($exclude, $dir))
-                continue;
-            package(dirname($pattern).'/'.basename($dir).'/'.basename($pattern),
-                $destination.'/'.basename($dir),
-                $recurse-1, $exclude);
-        }
-    }
-}
-
-# Run tests before continuing
-if (run_tests($root) > 0)
-    die("Regression tests failed. Cowardly refusing to package\n");
-
-# Create the stage folder for the install files
-if (!is_dir($stage_path))
-    mkdir($stage_path);
-else {
-    $dirs = array();
-    foreach (glob_recursive($stage_path . '/*') as $file)
-        if (is_dir($file))
-            $dirs[] = $file;
-        else
-            unlink($file);
-    sort($dirs);
-    foreach (array_reverse($dirs) as $dir)
-        rmdir($dir);
-}
-
-# Source code goes into 'upload'
-mkdir($stage_path . '/upload');
-
-# Load the root directory files
-package("*.php", 'upload/');
-package("web.config", 'upload/');
-
-# Load the client interface
-foreach (array('assets','css','images','js') as $dir)
-    package("$dir/*", "upload/$dir", -1, "*less");
-
-# Load API and pages
-package('api/{,.}*', 'upload/api');
-package('pages/{,.}*', 'upload/pages');
-
-# Load the knowledgebase
-package("kb/*.php", "upload/kb");
-
-# Load the staff interface
-package("scp/*.php", "upload/scp/", -1);
-foreach (array('css','images','js') as $dir)
-    package("scp/$dir/*", "upload/scp/$dir", -1);
-
-# Load in the scripts
-mkdir("$stage_path/scripts/");
-package("setup/scripts/*", "scripts/", -1, "*stage");
-
-# Load the heart of the system
-package("include/{,.}*", "upload/include", -1, array('*ost-config.php', '*.sw[a-z]','plugins/*'));
-
-# Include the installer
-package("setup/*.{php,txt,html}", "upload/setup", -1, array("*scripts","*test","*stage"));
-foreach (array('css','images','js') as $dir)
-    package("setup/$dir/*", "upload/setup/$dir", -1);
-package("setup/inc/streams/*.sql", "upload/setup/inc/streams", -1);
-
-# Load the license and documentation
-package("*.{txt,md}", "");
-
-#Rename markdown as text TODO: Do html version before rename.
-if(($mds = glob("$stage_path/*.md"))) {
-    foreach($mds as $md)
-        rename($md, preg_replace('/\.md$/', '.txt', $md));
-}
-
-# Make an archive of the stage folder
-$version = exec('git describe');
-$hash = exec('git rev-parse HEAD');
-$short = substr($hash, 0, 7);
-
-$pwd = getcwd();
-chdir($stage_path);
-
-// Replace THIS_VERSION in the stage/ folder
-
-shell_exec("sed -ri -e \"
-    s/( *)define\('THIS_VERSION'.*/\\1define('THIS_VERSION', '$version');/
-    s/( *)define\('GIT_VERSION'.*/\\1define('GIT_VERSION', '$short');/
-    \" upload/bootstrap.php");
-shell_exec("find upload -name '*.php' -print0 | xargs -0 sed -i -e '
-    s:<script\(.*\) src=\"\(.*\).js\"></script>:<script\\1 src=\"\\2.js?$short\"></script>:
-    s:<link\(.*\) href=\"\(.*\)\.css\"\(.*\)*/*>:<link\\1 href=\"\\2.css?$short\"\\3>:
-   '");
-shell_exec("find upload -name '*.php' -print0 | xargs -0 sed -i -e \"
-    s/\( *\)ini_set( *'display_errors'[^])]*);/\\1ini_set('display_errors', 0);/
-    s/\( *\)ini_set( *'display_startup_errors'[^])]*);/\\1ini_set('display_startup_errors', 0);/
-    \"");
-
-shell_exec("tar cjf '$pwd/osTicket-$version.tar.bz2' *");
-shell_exec("zip -r '$pwd/osTicket-$version.zip' *");
-
-chdir($pwd);
-?>
diff --git a/setup/doc/package.md b/setup/doc/package.md
new file mode 100644
index 0000000000000000000000000000000000000000..5570bb60931b910e3281e29ee5d326c938f6eae7
--- /dev/null
+++ b/setup/doc/package.md
@@ -0,0 +1,21 @@
+Creating an osTicket distribution
+=================================
+osTicket is packaged using an included script. Access to the packaging
+system is provided via the `manage.php` CLI app.
+
+The packaging system extends the deployment system in order to remove
+similar code between the two processes. Where possible, the files reported
+to be part of the git repository are used in the packaging process, which
+removes the possibility of experimental files and those ignored by git from
+being added to the distribution.
+
+More information is available via the automated help output.
+
+    php setup/cli/manage.php package --help
+
+Creating the ZIP file
+---------------------
+To package the system using the defaults (as a ZIP file), just invoke the
+packager with no other options.
+
+    php setup/cli/manage.php package