├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── dependabot.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── VERSION ├── application.php ├── bin ├── gitify └── gitify.cmd ├── composer.json ├── composer.lock └── src ├── BaseCommand.php ├── Command ├── BackupCommand.php ├── BuildCommand.php ├── ClearCacheCommand.php ├── DownloadModxCommand.php ├── ExtractCommand.php ├── InitCommand.php ├── InstallModxCommand.php ├── InstallPackageCommand.php ├── RestoreCommand.php └── UpgradeModxCommand.php ├── Gitify.php └── Mixins └── DownloadModx.php /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | Quick summary what's this issue about. 3 | 4 | ### Step to reproduce 5 | How to reproduce the issue, including custom code if needed. 6 | 7 | ### Observed behavior 8 | How it behaved after following steps above. 9 | 10 | ### Expected behavior 11 | How it should behave after following steps above. 12 | 13 | ### Environment 14 | Gitify version, MODX version, Operating System, MySQL version, PHP version, etc. 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What does it do ? 2 | Describe the changes you did. 3 | 4 | ### Why is it needed ? 5 | Describe the issue you are solving. 6 | 7 | ### Related issue(s)/PR(s) 8 | Let us know if this is related to any issue/pull request 9 | (see https://github.com/blog/1506-closing-issues-via-pull-requests) 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: phpunit/phpunit 10 | versions: 11 | - ">= 9.a, < 10" 12 | - dependency-name: symfony/console 13 | versions: 14 | - ">= 3.a, < 4" 15 | - dependency-name: symfony/process 16 | versions: 17 | - ">= 5.a, < 6" 18 | - dependency-name: symfony/yaml 19 | versions: 20 | - ">= 3.a, < 4" 21 | - dependency-name: symfony/process 22 | versions: 23 | - 4.4.19 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | config.core.php 3 | data/ 4 | dev/ 5 | /vendor/ 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Gitify Changelog 2 | 3 | Changes that may have an impact on backwards compatibility (i.e. they may break existing workflows) are marked with `[BC]`. 4 | 5 | ## 2.1.0 - 2023-12-11 6 | - Bump symfony/console to 5.4.32 7 | - Bump symfony/yaml to 5.4.31 8 | - Fix non-English filename generation (thanks @livingroot) [#442] 9 | - Add new config option 'category_ids_in_elements' to extract category ids instead of category names (thanks @wuuti) [#443] 10 | - Add ability to duplicate contexts with resources (thanks @bezumkin) [#439] 11 | - Add handling for packages using the new xPDO v3 model structure when extracting and building [#444] 12 | 13 | ## 2.0.1 - 2023-03-24 14 | - Switch base 'config' option (introduced in 2.0.0) to 'dotfile', to prevent conflict with 'config' option in modx:install command. (26f6c3c) 15 | 16 | ## 2.0.0 - 2023-03-23 17 | - Prevent E_WARN errors in build 18 | - Fix fallback Gitify-Cache-Folder 19 | - Bump symfony/console to 5.3.7 20 | - Bump symfony/process to 5.3.7 21 | - Bump symfony/yaml to 5.3.6 22 | - Add --no-tablespaces option for "backup" command. 23 | - Remove deprecated CLI install arguments 24 | - Add "local" option to package:install (thanks @hugopeek) (#261) 25 | - Check for unmet dependencies when installing local packages. 26 | - Allow for custom core path and a renamed manager directory. (thanks @it-scripter) (#223) 27 | - Add ability to install using a config xml file via the --config parameter. (thanks @hugopeek) (#219) 28 | - Use --core-path param for all installs to ensure the correct core path is used in config.inc.php. 29 | - Download the latest versions of packages more reliably. (thanks @hugopeek) (#327) 30 | - Backup and restore commands can now handle compressed gzip files. (thanks @rchouinard) (#378) 31 | - Fix fatal type error in ClearCacheCommand. (thanks @jgullege19) (#414) 32 | - Trigger MODX into setup mode during build. (thanks @matdave) (#406) 33 | - Fix package:install not working for MODX 3.x (#415) 34 | - Add ability to specify a config file to use (thanks @rtripault) (#417) 35 | - Add ability to limit number of extracted resources per parent (thanks @rtripault) (#418) 36 | - Force refreshing namespace cache after build (thanks @rtripault) (#422) 37 | - Prevent content attribute being added/removed intermittently, by unsetting content for static elements when extracting (thanks @rtripault) (#423) 38 | - Fix undefined array key 'service' warning on PHP 8.x (thanks @hugopeek) (#427) 39 | - Automatically update the list of packages with versions during extract + improve install (#430) 40 | 41 | ## 0.12.0 - 2015-12-17 42 | - Add `exclude_tvs` option to the `content` data type to allow excluding certain TVs 43 | - Add `credential_file` option to providers to contain the `username` and `api_key` (#155) 44 | - Fix GITIFY_WORKING_DIR constant on windows (#149) 45 | 46 | ## 0.11.0 - 2015-11-04 47 | - Fix E_STRICT error in loadConfig (#136) 48 | - Fix file path comparisons to work properly across unix and windows (#99) 49 | - Cache MODX packages so it doesn't have to download every time (#133) 50 | - Change the way Git.php dependency is loaded and version number is managed (#135) 51 | - Fix stupid bug in Gitify->getEnvironment causing www to not get stripped off properly 52 | - Allow setting the path to a git binary in a `gitify.git_path` setting 53 | - Add optional `--overwrite` flag to backup to overwrite a named backup file 54 | - Fix broken error message in backup command if file already exists 55 | 56 | ## 0.10.0 - 2015-09-15 57 | - Make sure `modTemplateVar` is set before content in the gitify file (#88) 58 | - Add `modTemplateVarResource` to default `truncate_on_force` for content to make sure the DB is cleaned properly on force (#111) 59 | - Store installed packages with the full signature instead of just the package name (#110) 60 | - Add `modx:upgrade` command to download a newer version and to run the upgrade (#116) 61 | - Prevent `PHP Warning: mkdir(): File exists` errors during init if backup and data folders already exist (#128) 62 | - Output result from command line install during `modx:install` (#127) 63 | - Fix unzip in `modx:install` on certain systems (#126) 64 | - Added support to get the git repository and environment specific options 65 | - Added symfony2/process and git.php dependencies 66 | - Implement/improve support for `where` attributes on content and other objects 67 | 68 | ## 0.9.0 - 2015-05-15 69 | - Implement automatic ID Conflict resolution during build, which will fix duplicate ID errors automatically. (#86, related to #69, #53) 70 | - [BC] Implement orphan handling during build, which removes any object that no longer exists in file automatically. 71 | - Add `no-cleanup` flag to build to allow bypassing the orphan handling. 72 | - Fix several issues using Gitify on Windows: 73 | \ - Due to inconsistent directory separators, certain files would be removed on extract. 74 | \ - Ensure line endings are normalized (LF \n) to prevent issues reading files/parsing yaml 75 | - `Gitify backup` and `Gitify restore` now also pass the host to the command, so should now work with non-localhost databases (#80, #82) 76 | - Extend `Gitify init` with more recommended options to include in the data, and automatically listing installed packages (#41) 77 | - Improved formatting of command output somewhat in several commands 78 | 79 | ## 0.8.0 - 2015-04-14 80 | - [BC] Rename `Gitify install:package` to `Gitify package:install`, and `Gitify install:modx` to `Gitify modx:install`. Aliases are in place so they will continue to work for now, but those will be removed in v1. 81 | - Extract instantiation logic out of Gitify file into application.php for easier integration with non-CLI PHP. 82 | - Small speed optimization (~ 10%) to `Gitify build --force` (#78) 83 | - Passing arguments to `Gitify build` and `Gitify extract` now restricts building/extracting to those specific data partitions/folders (#51, #26) 84 | - Escape password in backup/restore commands so they work with special characters in the password 85 | 86 | ## 0.7.0 - 2015-03-31 87 | - Add new `Gitify backup` and `Gitify restore` commands (#39) 88 | - Fix issue with installing packages where it proceeds to install FormIt2db if FormIt is already installed (same with MIGX) 89 | - Make sure installing packages works properly when the package name isn't the same as the signature (#74) 90 | 91 | ## 0.6.0 - 2015-03-30 92 | - Fix silly `` in output from install:modx 93 | - Add new `truncate_on_force` option that accepts an array of class names that need to be truncated before building (#73) - [see the wiki](https://github.com/modmore/Gitify/wiki/3.-The-.gitify-File#dealing-with-closures) 94 | - Show a helpful warning if the user forgot to run composer install (#67) 95 | - With install:package, prefer exact matches over provider order to ensure the right packages are installed (#58) 96 | 97 | ## 0.5.2 - 2015-03-05 98 | - Add new --interactive (-i) flag to `install:package --all` command to interactively install all packages defined in .gitify file 99 | - Fix issue where composite primary keys have the default value (e.g. sources.odMediaSourceElement) and it can't build those objects (#65) 100 | - Fix issue with modCategory objects and force building (#66) 101 | - Make sure TVs are inserted into the resource file alphabetically (#54) 102 | 103 | ## 0.5.1 - 2015-02-26 104 | - Fix duplicate paths in file name if alias contains a folder structure 105 | - Fix issue where in some cases, if the content was empty any field that can be compared to empty is removed from the file 106 | 107 | ## 0.5.0 - 2015-02-08 108 | 109 | - Add package support with `install:package [packagename]` and `install:package --all` based off the .gitify file (#3) 110 | - Apply the --force flag to all content types (#47) 111 | 112 | ## 0.4.1 - 2015-01-27 113 | 114 | - Fix install:modx command being called in Gitify init (#42) 115 | - Don't exclude `createdby`/`createdon` keys from resources by default; could result in lost data on `build --force` 116 | 117 | ## 0.4 - 2014-12-31 118 | 119 | - Add time and memory usage statistics (#34) 120 | - Fix PHP warning: Illegal offset type when using composite primary keys (#36) 121 | - Fix issue where resources in different contexts don't get built because of conflicting URIs (#35) 122 | - Make sure cases where saving a resource/object fails get logged to the terminal 123 | 124 | ## 0.3 - 2014-12-23 125 | 126 | - `[BC]` Fix `build` issue with ContentBlocks (and probably other extras) caused by automatically expanding JSON 127 | - Improve information provided during `build` and `extract`, including more verbose logging with `-v` or `--verbose` flag 128 | - Fix undefined method issue in `build` command 129 | - Catch YAML parse exceptions so it doesn't kill the entire process on an invalid file 130 | - Prevent Gitify from trying to build dotfiles 131 | - Ensure file path transliteration/filters also works pre MODX 2.3 132 | - Simplify the number of options that are added by default to all commands. 133 | - Add ability to specify the MODX version to download/install in `Gitify install:modx [modx_version]` 134 | - Add support for TVs on resources, automatically added on extract. 135 | - Add ability to read composite primary keys, when `primary` is set to an array in the `.gitify` data object. (thanks @ahaller) 136 | - Skip modStaticResource in the content extraction to prevent issues with binary file content (thanks @ahaller) 137 | - `[BC]` Apply transliteration and other filters to file names to ensure the names are safe to be used. 138 | 139 | ## 0.2 - 2014-12-19 140 | 141 | - Refactor based on Symfony's Console component and Composer 142 | - `[BC]` Slight change in the content separator (an extra `\n`) so there is now an empty line before and after the 5 dashes. 143 | 144 | ## 0.1 145 | 146 | - Early alpha, first prototype presented at the MODX Weekend 147 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 modmore | More for MODX 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gitify 2 | ====== 3 | 4 | The goal of Gitify is to provide a **two-way sync** of data typically stored in the MODX database, making it versionable with Git. To do this, it creates a representation of MODX objects in files. These files follow a certain [human and machine friendly format](https://gist.github.com/Mark-H/5acafdc1c364f70fa4e7), built from a block of YAML, followed by a separator, and then the main content (if there's a specific content field) below that. 5 | 6 | The project configuration, which determines what data is written to file and build to the database, is stored in a `.gitify` file in the project root. 7 | 8 | ## Upgrading to v2 9 | 10 | Gitify v2 brings updated dependencies, additional functionality, and easier installation/updates via Composer. 11 | 12 | The data file structure is unchanged, so you can safely update to v2. 13 | 14 | 1. To upgrade **with the intention of contributing to Gitify**, you can keep your exiting git installation. 15 | 1. Bring it up to date with the master branch (`git fetch origin && git reset --hard origin/master`, or `git fetch upstream && git reset --hard upstream/master`) 16 | 2. Install updated dependencies (`composer install`) 17 | 3. Update your `$PATH` to point to the `bin` directory. This may be in your `~/.bash_profile` or `~/.zshrc` file. 18 | 2. To upgrade **simply to use Gitify**, it's recommended to remove the v1 git-based installation completely, and instead install Gitify globally with Composer as described in the installation section below. 19 | 20 | **Important to know:** 21 | 22 | - Gitify v2 is now compatible with Gitify Watch v2. Make sure you've upgraded to the latest version. 23 | - The minimum PHP version has been increased to 7.2.5. 24 | - `Gitify` has changed to `gitify` and is now in a /bin subdirectory. 25 | 26 | ## Installation 27 | 28 | ````bash 29 | composer global require modmore/gitify:^2 30 | ```` 31 | 32 | If that does not make `gitify` available on your path, add the output of `composer global config bin-dir --absolute` to your path (i.e. in the `~/.bash_profile` or `~/.zshrc` file on Mac/Linux). 33 | 34 | To update, use `composer global update modmore/gitify`. 35 | 36 | Alternatively, you can install Gitify local to a project with `composer require modmore/gitify:^2`. In that case you'll need to use `vendor/bin/gitify` as the command. 37 | 38 | When installing an alpha/dev version, if you haven't modified your global composer config before, it's possible you'll 39 | get an error message pertaining to your minimum-stability setting. (Composer defaults to stable, and we want an unstable version!) 40 | To fix this, you'll need to set your global minimum stability with the following command: 41 | ``` 42 | composer global config minimum-stability alpha 43 | ``` 44 | 45 | ### Manual Installation 46 | 47 | Use the manual installation to build from source, useful if you intend to help make Gitify better. 48 | 49 | ````bash 50 | $ git clone https://github.com/modmore/Gitify.git Gitify 51 | $ cd Gitify 52 | $ composer install --no-dev 53 | $ chmod +x bin/gitify 54 | ```` 55 | 56 | Please see [the Installation documentation](https://docs.modmore.com/en/Open_Source/Gitify/Installation/index.html) for more details. 57 | 58 | 59 | ## Documentation 60 | 61 | [Check the modmore Gitify documentation!](https://docs.modmore.com/en/Open_Source/Gitify/index.html) It contains information about the available commands and the way you would go about setting up a suitable workflow. 62 | 63 | Please feel free to contribute to the wiki by editing existing pages, or adding new pages with extra information not covered elsewhere yet. 64 | 65 | ## Changes & History 66 | 67 | Gitify adheres to [semver](http://semver.org). As we are before v1 right now, expect breaking changes and refactorings before the API stabilises. 68 | 69 | For changes, please see the commit log or the [Changelog](CHANGELOG.md). 70 | 71 | ## License 72 | 73 | The MIT License (MIT) 74 | 75 | Copyright (c) 2014-2015 modmore | More for MODX 76 | 77 | Permission is hereby granted, free of charge, to any person obtaining a copy 78 | of this software and associated documentation files (the "Software"), to deal 79 | in the Software without restriction, including without limitation the rights 80 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 81 | copies of the Software, and to permit persons to whom the Software is 82 | furnished to do so, subject to the following conditions: 83 | 84 | The above copyright notice and this permission notice shall be included in all 85 | copies or substantial portions of the Software. 86 | 87 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 88 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 89 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 90 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 91 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 92 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 93 | SOFTWARE. 94 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.1.0-pl 2 | -------------------------------------------------------------------------------- /application.php: -------------------------------------------------------------------------------- 1 | = 0) { 36 | $tz = @ini_get('date.timezone'); 37 | if (empty($tz)) { 38 | date_default_timezone_set(@date_default_timezone_get()); 39 | } 40 | } 41 | 42 | /** 43 | * Specify the working directory, if it hasn't been set yet. 44 | */ 45 | if (!defined('GITIFY_WORKING_DIR')) { 46 | $cwd = getcwd() . DIRECTORY_SEPARATOR; 47 | $cwd = str_replace('\\', '/', $cwd); 48 | define('GITIFY_WORKING_DIR', $cwd); 49 | } 50 | 51 | /** 52 | * Specify the user home directory, for save cache folder of gitify 53 | */ 54 | if (!defined('GITIFY_CACHE_DIR')) { 55 | $cacheDir = '.gitify'; 56 | 57 | $home = rtrim(getenv('HOME'), DIRECTORY_SEPARATOR); 58 | if (!$home && isset($_SERVER['HOME'])) { 59 | $home = rtrim($_SERVER['HOME'], DIRECTORY_SEPARATOR); 60 | } 61 | if (!$home && isset($_SERVER['HOMEDRIVE']) && isset($_SERVER['HOMEPATH'])) { 62 | // compatibility to Windows 63 | $home = rtrim($_SERVER['HOMEDRIVE'] . $_SERVER['HOMEPATH'], DIRECTORY_SEPARATOR); 64 | } 65 | if (!$home || !is_writable($home)) { 66 | // fallback to working directory, if home directory can not be determined 67 | $home = rtrim(GITIFY_WORKING_DIR, DIRECTORY_SEPARATOR); 68 | // in working directory .gitify file contains the main configuration, 69 | // cache folder cannot be with the same name (file systems restricts) 70 | $cacheDir = '.gitify-cache'; 71 | } 72 | 73 | define('GITIFY_CACHE_DIR', implode(DIRECTORY_SEPARATOR, [$home, $cacheDir, ''])); 74 | } 75 | 76 | /** 77 | * Load all the commands and create the Gitify instance 78 | */ 79 | 80 | use modmore\Gitify\Command\BackupCommand; 81 | use modmore\Gitify\Command\BuildCommand; 82 | use modmore\Gitify\Command\ClearCacheCommand; 83 | use modmore\Gitify\Command\DownloadModxCommand; 84 | use modmore\Gitify\Command\ExtractCommand; 85 | use modmore\Gitify\Command\InitCommand; 86 | use modmore\Gitify\Command\InstallModxCommand; 87 | use modmore\Gitify\Command\InstallPackageCommand; 88 | use modmore\Gitify\Command\RestoreCommand; 89 | use modmore\Gitify\Command\UpgradeModxCommand; 90 | use modmore\Gitify\Gitify; 91 | 92 | $version = trim(@file_get_contents(__DIR__ . '/VERSION')); 93 | 94 | $application = new Gitify('Gitify', $version); 95 | $application->add(new InitCommand); 96 | $application->add(new BuildCommand); 97 | $application->add(new DownloadModxCommand); 98 | $application->add(new ExtractCommand); 99 | $application->add(new InstallModxCommand); 100 | $application->add(new UpgradeModxCommand); 101 | $application->add(new InstallPackageCommand); 102 | $application->add(new BackupCommand); 103 | $application->add(new RestoreCommand); 104 | $application->add(new ClearCacheCommand); 105 | /** 106 | * We return it so the CLI controller in /bin/gitify can run it, or for other integrations to 107 | * work with the gitify api directly. 108 | */ 109 | return $application; 110 | -------------------------------------------------------------------------------- /bin/gitify: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); -------------------------------------------------------------------------------- /bin/gitify.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | php %~dp0Gitify %* 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "modmore/gitify", 3 | "description": "Command line toolkit to make managing a MODX site in git a lot easier", 4 | "license": "MIT", 5 | "require": { 6 | "php": ">=7.2.5", 7 | "symfony/console": "5.4.*", 8 | "symfony/yaml": "5.4.*", 9 | "symfony/process": "5.3.*" 10 | }, 11 | "autoload": { 12 | "psr-4": { 13 | "modmore\\Gitify\\": "src/" 14 | } 15 | }, 16 | "bin": [ 17 | "bin/gitify" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "728882893595bb19b216346d2daeafd9", 8 | "packages": [ 9 | { 10 | "name": "psr/container", 11 | "version": "1.1.1", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/php-fig/container.git", 15 | "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf", 20 | "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "php": ">=7.2.0" 25 | }, 26 | "type": "library", 27 | "autoload": { 28 | "psr-4": { 29 | "Psr\\Container\\": "src/" 30 | } 31 | }, 32 | "notification-url": "https://packagist.org/downloads/", 33 | "license": [ 34 | "MIT" 35 | ], 36 | "authors": [ 37 | { 38 | "name": "PHP-FIG", 39 | "homepage": "https://www.php-fig.org/" 40 | } 41 | ], 42 | "description": "Common Container Interface (PHP FIG PSR-11)", 43 | "homepage": "https://github.com/php-fig/container", 44 | "keywords": [ 45 | "PSR-11", 46 | "container", 47 | "container-interface", 48 | "container-interop", 49 | "psr" 50 | ], 51 | "support": { 52 | "issues": "https://github.com/php-fig/container/issues", 53 | "source": "https://github.com/php-fig/container/tree/1.1.1" 54 | }, 55 | "time": "2021-03-05T17:36:06+00:00" 56 | }, 57 | { 58 | "name": "symfony/console", 59 | "version": "v5.4.32", 60 | "source": { 61 | "type": "git", 62 | "url": "https://github.com/symfony/console.git", 63 | "reference": "c70df1ffaf23a8d340bded3cfab1b86752ad6ed7" 64 | }, 65 | "dist": { 66 | "type": "zip", 67 | "url": "https://api.github.com/repos/symfony/console/zipball/c70df1ffaf23a8d340bded3cfab1b86752ad6ed7", 68 | "reference": "c70df1ffaf23a8d340bded3cfab1b86752ad6ed7", 69 | "shasum": "" 70 | }, 71 | "require": { 72 | "php": ">=7.2.5", 73 | "symfony/deprecation-contracts": "^2.1|^3", 74 | "symfony/polyfill-mbstring": "~1.0", 75 | "symfony/polyfill-php73": "^1.9", 76 | "symfony/polyfill-php80": "^1.16", 77 | "symfony/service-contracts": "^1.1|^2|^3", 78 | "symfony/string": "^5.1|^6.0" 79 | }, 80 | "conflict": { 81 | "psr/log": ">=3", 82 | "symfony/dependency-injection": "<4.4", 83 | "symfony/dotenv": "<5.1", 84 | "symfony/event-dispatcher": "<4.4", 85 | "symfony/lock": "<4.4", 86 | "symfony/process": "<4.4" 87 | }, 88 | "provide": { 89 | "psr/log-implementation": "1.0|2.0" 90 | }, 91 | "require-dev": { 92 | "psr/log": "^1|^2", 93 | "symfony/config": "^4.4|^5.0|^6.0", 94 | "symfony/dependency-injection": "^4.4|^5.0|^6.0", 95 | "symfony/event-dispatcher": "^4.4|^5.0|^6.0", 96 | "symfony/lock": "^4.4|^5.0|^6.0", 97 | "symfony/process": "^4.4|^5.0|^6.0", 98 | "symfony/var-dumper": "^4.4|^5.0|^6.0" 99 | }, 100 | "suggest": { 101 | "psr/log": "For using the console logger", 102 | "symfony/event-dispatcher": "", 103 | "symfony/lock": "", 104 | "symfony/process": "" 105 | }, 106 | "type": "library", 107 | "autoload": { 108 | "psr-4": { 109 | "Symfony\\Component\\Console\\": "" 110 | }, 111 | "exclude-from-classmap": [ 112 | "/Tests/" 113 | ] 114 | }, 115 | "notification-url": "https://packagist.org/downloads/", 116 | "license": [ 117 | "MIT" 118 | ], 119 | "authors": [ 120 | { 121 | "name": "Fabien Potencier", 122 | "email": "fabien@symfony.com" 123 | }, 124 | { 125 | "name": "Symfony Community", 126 | "homepage": "https://symfony.com/contributors" 127 | } 128 | ], 129 | "description": "Eases the creation of beautiful and testable command line interfaces", 130 | "homepage": "https://symfony.com", 131 | "keywords": [ 132 | "cli", 133 | "command-line", 134 | "console", 135 | "terminal" 136 | ], 137 | "support": { 138 | "source": "https://github.com/symfony/console/tree/v5.4.32" 139 | }, 140 | "funding": [ 141 | { 142 | "url": "https://symfony.com/sponsor", 143 | "type": "custom" 144 | }, 145 | { 146 | "url": "https://github.com/fabpot", 147 | "type": "github" 148 | }, 149 | { 150 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 151 | "type": "tidelift" 152 | } 153 | ], 154 | "time": "2023-11-18T18:23:04+00:00" 155 | }, 156 | { 157 | "name": "symfony/deprecation-contracts", 158 | "version": "v2.5.2", 159 | "source": { 160 | "type": "git", 161 | "url": "https://github.com/symfony/deprecation-contracts.git", 162 | "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66" 163 | }, 164 | "dist": { 165 | "type": "zip", 166 | "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66", 167 | "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66", 168 | "shasum": "" 169 | }, 170 | "require": { 171 | "php": ">=7.1" 172 | }, 173 | "type": "library", 174 | "extra": { 175 | "branch-alias": { 176 | "dev-main": "2.5-dev" 177 | }, 178 | "thanks": { 179 | "name": "symfony/contracts", 180 | "url": "https://github.com/symfony/contracts" 181 | } 182 | }, 183 | "autoload": { 184 | "files": [ 185 | "function.php" 186 | ] 187 | }, 188 | "notification-url": "https://packagist.org/downloads/", 189 | "license": [ 190 | "MIT" 191 | ], 192 | "authors": [ 193 | { 194 | "name": "Nicolas Grekas", 195 | "email": "p@tchwork.com" 196 | }, 197 | { 198 | "name": "Symfony Community", 199 | "homepage": "https://symfony.com/contributors" 200 | } 201 | ], 202 | "description": "A generic function and convention to trigger deprecation notices", 203 | "homepage": "https://symfony.com", 204 | "support": { 205 | "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.2" 206 | }, 207 | "funding": [ 208 | { 209 | "url": "https://symfony.com/sponsor", 210 | "type": "custom" 211 | }, 212 | { 213 | "url": "https://github.com/fabpot", 214 | "type": "github" 215 | }, 216 | { 217 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 218 | "type": "tidelift" 219 | } 220 | ], 221 | "time": "2022-01-02T09:53:40+00:00" 222 | }, 223 | { 224 | "name": "symfony/polyfill-ctype", 225 | "version": "v1.28.0", 226 | "source": { 227 | "type": "git", 228 | "url": "https://github.com/symfony/polyfill-ctype.git", 229 | "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" 230 | }, 231 | "dist": { 232 | "type": "zip", 233 | "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", 234 | "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", 235 | "shasum": "" 236 | }, 237 | "require": { 238 | "php": ">=7.1" 239 | }, 240 | "provide": { 241 | "ext-ctype": "*" 242 | }, 243 | "suggest": { 244 | "ext-ctype": "For best performance" 245 | }, 246 | "type": "library", 247 | "extra": { 248 | "branch-alias": { 249 | "dev-main": "1.28-dev" 250 | }, 251 | "thanks": { 252 | "name": "symfony/polyfill", 253 | "url": "https://github.com/symfony/polyfill" 254 | } 255 | }, 256 | "autoload": { 257 | "files": [ 258 | "bootstrap.php" 259 | ], 260 | "psr-4": { 261 | "Symfony\\Polyfill\\Ctype\\": "" 262 | } 263 | }, 264 | "notification-url": "https://packagist.org/downloads/", 265 | "license": [ 266 | "MIT" 267 | ], 268 | "authors": [ 269 | { 270 | "name": "Gert de Pagter", 271 | "email": "BackEndTea@gmail.com" 272 | }, 273 | { 274 | "name": "Symfony Community", 275 | "homepage": "https://symfony.com/contributors" 276 | } 277 | ], 278 | "description": "Symfony polyfill for ctype functions", 279 | "homepage": "https://symfony.com", 280 | "keywords": [ 281 | "compatibility", 282 | "ctype", 283 | "polyfill", 284 | "portable" 285 | ], 286 | "support": { 287 | "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" 288 | }, 289 | "funding": [ 290 | { 291 | "url": "https://symfony.com/sponsor", 292 | "type": "custom" 293 | }, 294 | { 295 | "url": "https://github.com/fabpot", 296 | "type": "github" 297 | }, 298 | { 299 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 300 | "type": "tidelift" 301 | } 302 | ], 303 | "time": "2023-01-26T09:26:14+00:00" 304 | }, 305 | { 306 | "name": "symfony/polyfill-intl-grapheme", 307 | "version": "v1.28.0", 308 | "source": { 309 | "type": "git", 310 | "url": "https://github.com/symfony/polyfill-intl-grapheme.git", 311 | "reference": "875e90aeea2777b6f135677f618529449334a612" 312 | }, 313 | "dist": { 314 | "type": "zip", 315 | "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/875e90aeea2777b6f135677f618529449334a612", 316 | "reference": "875e90aeea2777b6f135677f618529449334a612", 317 | "shasum": "" 318 | }, 319 | "require": { 320 | "php": ">=7.1" 321 | }, 322 | "suggest": { 323 | "ext-intl": "For best performance" 324 | }, 325 | "type": "library", 326 | "extra": { 327 | "branch-alias": { 328 | "dev-main": "1.28-dev" 329 | }, 330 | "thanks": { 331 | "name": "symfony/polyfill", 332 | "url": "https://github.com/symfony/polyfill" 333 | } 334 | }, 335 | "autoload": { 336 | "files": [ 337 | "bootstrap.php" 338 | ], 339 | "psr-4": { 340 | "Symfony\\Polyfill\\Intl\\Grapheme\\": "" 341 | } 342 | }, 343 | "notification-url": "https://packagist.org/downloads/", 344 | "license": [ 345 | "MIT" 346 | ], 347 | "authors": [ 348 | { 349 | "name": "Nicolas Grekas", 350 | "email": "p@tchwork.com" 351 | }, 352 | { 353 | "name": "Symfony Community", 354 | "homepage": "https://symfony.com/contributors" 355 | } 356 | ], 357 | "description": "Symfony polyfill for intl's grapheme_* functions", 358 | "homepage": "https://symfony.com", 359 | "keywords": [ 360 | "compatibility", 361 | "grapheme", 362 | "intl", 363 | "polyfill", 364 | "portable", 365 | "shim" 366 | ], 367 | "support": { 368 | "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.28.0" 369 | }, 370 | "funding": [ 371 | { 372 | "url": "https://symfony.com/sponsor", 373 | "type": "custom" 374 | }, 375 | { 376 | "url": "https://github.com/fabpot", 377 | "type": "github" 378 | }, 379 | { 380 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 381 | "type": "tidelift" 382 | } 383 | ], 384 | "time": "2023-01-26T09:26:14+00:00" 385 | }, 386 | { 387 | "name": "symfony/polyfill-intl-normalizer", 388 | "version": "v1.28.0", 389 | "source": { 390 | "type": "git", 391 | "url": "https://github.com/symfony/polyfill-intl-normalizer.git", 392 | "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92" 393 | }, 394 | "dist": { 395 | "type": "zip", 396 | "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", 397 | "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", 398 | "shasum": "" 399 | }, 400 | "require": { 401 | "php": ">=7.1" 402 | }, 403 | "suggest": { 404 | "ext-intl": "For best performance" 405 | }, 406 | "type": "library", 407 | "extra": { 408 | "branch-alias": { 409 | "dev-main": "1.28-dev" 410 | }, 411 | "thanks": { 412 | "name": "symfony/polyfill", 413 | "url": "https://github.com/symfony/polyfill" 414 | } 415 | }, 416 | "autoload": { 417 | "files": [ 418 | "bootstrap.php" 419 | ], 420 | "psr-4": { 421 | "Symfony\\Polyfill\\Intl\\Normalizer\\": "" 422 | }, 423 | "classmap": [ 424 | "Resources/stubs" 425 | ] 426 | }, 427 | "notification-url": "https://packagist.org/downloads/", 428 | "license": [ 429 | "MIT" 430 | ], 431 | "authors": [ 432 | { 433 | "name": "Nicolas Grekas", 434 | "email": "p@tchwork.com" 435 | }, 436 | { 437 | "name": "Symfony Community", 438 | "homepage": "https://symfony.com/contributors" 439 | } 440 | ], 441 | "description": "Symfony polyfill for intl's Normalizer class and related functions", 442 | "homepage": "https://symfony.com", 443 | "keywords": [ 444 | "compatibility", 445 | "intl", 446 | "normalizer", 447 | "polyfill", 448 | "portable", 449 | "shim" 450 | ], 451 | "support": { 452 | "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.28.0" 453 | }, 454 | "funding": [ 455 | { 456 | "url": "https://symfony.com/sponsor", 457 | "type": "custom" 458 | }, 459 | { 460 | "url": "https://github.com/fabpot", 461 | "type": "github" 462 | }, 463 | { 464 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 465 | "type": "tidelift" 466 | } 467 | ], 468 | "time": "2023-01-26T09:26:14+00:00" 469 | }, 470 | { 471 | "name": "symfony/polyfill-mbstring", 472 | "version": "v1.28.0", 473 | "source": { 474 | "type": "git", 475 | "url": "https://github.com/symfony/polyfill-mbstring.git", 476 | "reference": "42292d99c55abe617799667f454222c54c60e229" 477 | }, 478 | "dist": { 479 | "type": "zip", 480 | "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", 481 | "reference": "42292d99c55abe617799667f454222c54c60e229", 482 | "shasum": "" 483 | }, 484 | "require": { 485 | "php": ">=7.1" 486 | }, 487 | "provide": { 488 | "ext-mbstring": "*" 489 | }, 490 | "suggest": { 491 | "ext-mbstring": "For best performance" 492 | }, 493 | "type": "library", 494 | "extra": { 495 | "branch-alias": { 496 | "dev-main": "1.28-dev" 497 | }, 498 | "thanks": { 499 | "name": "symfony/polyfill", 500 | "url": "https://github.com/symfony/polyfill" 501 | } 502 | }, 503 | "autoload": { 504 | "files": [ 505 | "bootstrap.php" 506 | ], 507 | "psr-4": { 508 | "Symfony\\Polyfill\\Mbstring\\": "" 509 | } 510 | }, 511 | "notification-url": "https://packagist.org/downloads/", 512 | "license": [ 513 | "MIT" 514 | ], 515 | "authors": [ 516 | { 517 | "name": "Nicolas Grekas", 518 | "email": "p@tchwork.com" 519 | }, 520 | { 521 | "name": "Symfony Community", 522 | "homepage": "https://symfony.com/contributors" 523 | } 524 | ], 525 | "description": "Symfony polyfill for the Mbstring extension", 526 | "homepage": "https://symfony.com", 527 | "keywords": [ 528 | "compatibility", 529 | "mbstring", 530 | "polyfill", 531 | "portable", 532 | "shim" 533 | ], 534 | "support": { 535 | "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0" 536 | }, 537 | "funding": [ 538 | { 539 | "url": "https://symfony.com/sponsor", 540 | "type": "custom" 541 | }, 542 | { 543 | "url": "https://github.com/fabpot", 544 | "type": "github" 545 | }, 546 | { 547 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 548 | "type": "tidelift" 549 | } 550 | ], 551 | "time": "2023-07-28T09:04:16+00:00" 552 | }, 553 | { 554 | "name": "symfony/polyfill-php73", 555 | "version": "v1.28.0", 556 | "source": { 557 | "type": "git", 558 | "url": "https://github.com/symfony/polyfill-php73.git", 559 | "reference": "fe2f306d1d9d346a7fee353d0d5012e401e984b5" 560 | }, 561 | "dist": { 562 | "type": "zip", 563 | "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fe2f306d1d9d346a7fee353d0d5012e401e984b5", 564 | "reference": "fe2f306d1d9d346a7fee353d0d5012e401e984b5", 565 | "shasum": "" 566 | }, 567 | "require": { 568 | "php": ">=7.1" 569 | }, 570 | "type": "library", 571 | "extra": { 572 | "branch-alias": { 573 | "dev-main": "1.28-dev" 574 | }, 575 | "thanks": { 576 | "name": "symfony/polyfill", 577 | "url": "https://github.com/symfony/polyfill" 578 | } 579 | }, 580 | "autoload": { 581 | "files": [ 582 | "bootstrap.php" 583 | ], 584 | "psr-4": { 585 | "Symfony\\Polyfill\\Php73\\": "" 586 | }, 587 | "classmap": [ 588 | "Resources/stubs" 589 | ] 590 | }, 591 | "notification-url": "https://packagist.org/downloads/", 592 | "license": [ 593 | "MIT" 594 | ], 595 | "authors": [ 596 | { 597 | "name": "Nicolas Grekas", 598 | "email": "p@tchwork.com" 599 | }, 600 | { 601 | "name": "Symfony Community", 602 | "homepage": "https://symfony.com/contributors" 603 | } 604 | ], 605 | "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", 606 | "homepage": "https://symfony.com", 607 | "keywords": [ 608 | "compatibility", 609 | "polyfill", 610 | "portable", 611 | "shim" 612 | ], 613 | "support": { 614 | "source": "https://github.com/symfony/polyfill-php73/tree/v1.28.0" 615 | }, 616 | "funding": [ 617 | { 618 | "url": "https://symfony.com/sponsor", 619 | "type": "custom" 620 | }, 621 | { 622 | "url": "https://github.com/fabpot", 623 | "type": "github" 624 | }, 625 | { 626 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 627 | "type": "tidelift" 628 | } 629 | ], 630 | "time": "2023-01-26T09:26:14+00:00" 631 | }, 632 | { 633 | "name": "symfony/polyfill-php80", 634 | "version": "v1.28.0", 635 | "source": { 636 | "type": "git", 637 | "url": "https://github.com/symfony/polyfill-php80.git", 638 | "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5" 639 | }, 640 | "dist": { 641 | "type": "zip", 642 | "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5", 643 | "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5", 644 | "shasum": "" 645 | }, 646 | "require": { 647 | "php": ">=7.1" 648 | }, 649 | "type": "library", 650 | "extra": { 651 | "branch-alias": { 652 | "dev-main": "1.28-dev" 653 | }, 654 | "thanks": { 655 | "name": "symfony/polyfill", 656 | "url": "https://github.com/symfony/polyfill" 657 | } 658 | }, 659 | "autoload": { 660 | "files": [ 661 | "bootstrap.php" 662 | ], 663 | "psr-4": { 664 | "Symfony\\Polyfill\\Php80\\": "" 665 | }, 666 | "classmap": [ 667 | "Resources/stubs" 668 | ] 669 | }, 670 | "notification-url": "https://packagist.org/downloads/", 671 | "license": [ 672 | "MIT" 673 | ], 674 | "authors": [ 675 | { 676 | "name": "Ion Bazan", 677 | "email": "ion.bazan@gmail.com" 678 | }, 679 | { 680 | "name": "Nicolas Grekas", 681 | "email": "p@tchwork.com" 682 | }, 683 | { 684 | "name": "Symfony Community", 685 | "homepage": "https://symfony.com/contributors" 686 | } 687 | ], 688 | "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", 689 | "homepage": "https://symfony.com", 690 | "keywords": [ 691 | "compatibility", 692 | "polyfill", 693 | "portable", 694 | "shim" 695 | ], 696 | "support": { 697 | "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0" 698 | }, 699 | "funding": [ 700 | { 701 | "url": "https://symfony.com/sponsor", 702 | "type": "custom" 703 | }, 704 | { 705 | "url": "https://github.com/fabpot", 706 | "type": "github" 707 | }, 708 | { 709 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 710 | "type": "tidelift" 711 | } 712 | ], 713 | "time": "2023-01-26T09:26:14+00:00" 714 | }, 715 | { 716 | "name": "symfony/process", 717 | "version": "v5.3.7", 718 | "source": { 719 | "type": "git", 720 | "url": "https://github.com/symfony/process.git", 721 | "reference": "38f26c7d6ed535217ea393e05634cb0b244a1967" 722 | }, 723 | "dist": { 724 | "type": "zip", 725 | "url": "https://api.github.com/repos/symfony/process/zipball/38f26c7d6ed535217ea393e05634cb0b244a1967", 726 | "reference": "38f26c7d6ed535217ea393e05634cb0b244a1967", 727 | "shasum": "" 728 | }, 729 | "require": { 730 | "php": ">=7.2.5", 731 | "symfony/polyfill-php80": "^1.16" 732 | }, 733 | "type": "library", 734 | "autoload": { 735 | "psr-4": { 736 | "Symfony\\Component\\Process\\": "" 737 | }, 738 | "exclude-from-classmap": [ 739 | "/Tests/" 740 | ] 741 | }, 742 | "notification-url": "https://packagist.org/downloads/", 743 | "license": [ 744 | "MIT" 745 | ], 746 | "authors": [ 747 | { 748 | "name": "Fabien Potencier", 749 | "email": "fabien@symfony.com" 750 | }, 751 | { 752 | "name": "Symfony Community", 753 | "homepage": "https://symfony.com/contributors" 754 | } 755 | ], 756 | "description": "Executes commands in sub-processes", 757 | "homepage": "https://symfony.com", 758 | "support": { 759 | "source": "https://github.com/symfony/process/tree/v5.3.7" 760 | }, 761 | "funding": [ 762 | { 763 | "url": "https://symfony.com/sponsor", 764 | "type": "custom" 765 | }, 766 | { 767 | "url": "https://github.com/fabpot", 768 | "type": "github" 769 | }, 770 | { 771 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 772 | "type": "tidelift" 773 | } 774 | ], 775 | "time": "2021-08-04T21:20:46+00:00" 776 | }, 777 | { 778 | "name": "symfony/service-contracts", 779 | "version": "v2.5.2", 780 | "source": { 781 | "type": "git", 782 | "url": "https://github.com/symfony/service-contracts.git", 783 | "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c" 784 | }, 785 | "dist": { 786 | "type": "zip", 787 | "url": "https://api.github.com/repos/symfony/service-contracts/zipball/4b426aac47d6427cc1a1d0f7e2ac724627f5966c", 788 | "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c", 789 | "shasum": "" 790 | }, 791 | "require": { 792 | "php": ">=7.2.5", 793 | "psr/container": "^1.1", 794 | "symfony/deprecation-contracts": "^2.1|^3" 795 | }, 796 | "conflict": { 797 | "ext-psr": "<1.1|>=2" 798 | }, 799 | "suggest": { 800 | "symfony/service-implementation": "" 801 | }, 802 | "type": "library", 803 | "extra": { 804 | "branch-alias": { 805 | "dev-main": "2.5-dev" 806 | }, 807 | "thanks": { 808 | "name": "symfony/contracts", 809 | "url": "https://github.com/symfony/contracts" 810 | } 811 | }, 812 | "autoload": { 813 | "psr-4": { 814 | "Symfony\\Contracts\\Service\\": "" 815 | } 816 | }, 817 | "notification-url": "https://packagist.org/downloads/", 818 | "license": [ 819 | "MIT" 820 | ], 821 | "authors": [ 822 | { 823 | "name": "Nicolas Grekas", 824 | "email": "p@tchwork.com" 825 | }, 826 | { 827 | "name": "Symfony Community", 828 | "homepage": "https://symfony.com/contributors" 829 | } 830 | ], 831 | "description": "Generic abstractions related to writing services", 832 | "homepage": "https://symfony.com", 833 | "keywords": [ 834 | "abstractions", 835 | "contracts", 836 | "decoupling", 837 | "interfaces", 838 | "interoperability", 839 | "standards" 840 | ], 841 | "support": { 842 | "source": "https://github.com/symfony/service-contracts/tree/v2.5.2" 843 | }, 844 | "funding": [ 845 | { 846 | "url": "https://symfony.com/sponsor", 847 | "type": "custom" 848 | }, 849 | { 850 | "url": "https://github.com/fabpot", 851 | "type": "github" 852 | }, 853 | { 854 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 855 | "type": "tidelift" 856 | } 857 | ], 858 | "time": "2022-05-30T19:17:29+00:00" 859 | }, 860 | { 861 | "name": "symfony/string", 862 | "version": "v5.4.32", 863 | "source": { 864 | "type": "git", 865 | "url": "https://github.com/symfony/string.git", 866 | "reference": "91bf4453d65d8231688a04376c3a40efe0770f04" 867 | }, 868 | "dist": { 869 | "type": "zip", 870 | "url": "https://api.github.com/repos/symfony/string/zipball/91bf4453d65d8231688a04376c3a40efe0770f04", 871 | "reference": "91bf4453d65d8231688a04376c3a40efe0770f04", 872 | "shasum": "" 873 | }, 874 | "require": { 875 | "php": ">=7.2.5", 876 | "symfony/polyfill-ctype": "~1.8", 877 | "symfony/polyfill-intl-grapheme": "~1.0", 878 | "symfony/polyfill-intl-normalizer": "~1.0", 879 | "symfony/polyfill-mbstring": "~1.0", 880 | "symfony/polyfill-php80": "~1.15" 881 | }, 882 | "conflict": { 883 | "symfony/translation-contracts": ">=3.0" 884 | }, 885 | "require-dev": { 886 | "symfony/error-handler": "^4.4|^5.0|^6.0", 887 | "symfony/http-client": "^4.4|^5.0|^6.0", 888 | "symfony/translation-contracts": "^1.1|^2", 889 | "symfony/var-exporter": "^4.4|^5.0|^6.0" 890 | }, 891 | "type": "library", 892 | "autoload": { 893 | "files": [ 894 | "Resources/functions.php" 895 | ], 896 | "psr-4": { 897 | "Symfony\\Component\\String\\": "" 898 | }, 899 | "exclude-from-classmap": [ 900 | "/Tests/" 901 | ] 902 | }, 903 | "notification-url": "https://packagist.org/downloads/", 904 | "license": [ 905 | "MIT" 906 | ], 907 | "authors": [ 908 | { 909 | "name": "Nicolas Grekas", 910 | "email": "p@tchwork.com" 911 | }, 912 | { 913 | "name": "Symfony Community", 914 | "homepage": "https://symfony.com/contributors" 915 | } 916 | ], 917 | "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", 918 | "homepage": "https://symfony.com", 919 | "keywords": [ 920 | "grapheme", 921 | "i18n", 922 | "string", 923 | "unicode", 924 | "utf-8", 925 | "utf8" 926 | ], 927 | "support": { 928 | "source": "https://github.com/symfony/string/tree/v5.4.32" 929 | }, 930 | "funding": [ 931 | { 932 | "url": "https://symfony.com/sponsor", 933 | "type": "custom" 934 | }, 935 | { 936 | "url": "https://github.com/fabpot", 937 | "type": "github" 938 | }, 939 | { 940 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 941 | "type": "tidelift" 942 | } 943 | ], 944 | "time": "2023-11-26T13:43:46+00:00" 945 | }, 946 | { 947 | "name": "symfony/yaml", 948 | "version": "v5.4.31", 949 | "source": { 950 | "type": "git", 951 | "url": "https://github.com/symfony/yaml.git", 952 | "reference": "f387675d7f5fc4231f7554baa70681f222f73563" 953 | }, 954 | "dist": { 955 | "type": "zip", 956 | "url": "https://api.github.com/repos/symfony/yaml/zipball/f387675d7f5fc4231f7554baa70681f222f73563", 957 | "reference": "f387675d7f5fc4231f7554baa70681f222f73563", 958 | "shasum": "" 959 | }, 960 | "require": { 961 | "php": ">=7.2.5", 962 | "symfony/deprecation-contracts": "^2.1|^3", 963 | "symfony/polyfill-ctype": "^1.8" 964 | }, 965 | "conflict": { 966 | "symfony/console": "<5.3" 967 | }, 968 | "require-dev": { 969 | "symfony/console": "^5.3|^6.0" 970 | }, 971 | "suggest": { 972 | "symfony/console": "For validating YAML files using the lint command" 973 | }, 974 | "bin": [ 975 | "Resources/bin/yaml-lint" 976 | ], 977 | "type": "library", 978 | "autoload": { 979 | "psr-4": { 980 | "Symfony\\Component\\Yaml\\": "" 981 | }, 982 | "exclude-from-classmap": [ 983 | "/Tests/" 984 | ] 985 | }, 986 | "notification-url": "https://packagist.org/downloads/", 987 | "license": [ 988 | "MIT" 989 | ], 990 | "authors": [ 991 | { 992 | "name": "Fabien Potencier", 993 | "email": "fabien@symfony.com" 994 | }, 995 | { 996 | "name": "Symfony Community", 997 | "homepage": "https://symfony.com/contributors" 998 | } 999 | ], 1000 | "description": "Loads and dumps YAML files", 1001 | "homepage": "https://symfony.com", 1002 | "support": { 1003 | "source": "https://github.com/symfony/yaml/tree/v5.4.31" 1004 | }, 1005 | "funding": [ 1006 | { 1007 | "url": "https://symfony.com/sponsor", 1008 | "type": "custom" 1009 | }, 1010 | { 1011 | "url": "https://github.com/fabpot", 1012 | "type": "github" 1013 | }, 1014 | { 1015 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 1016 | "type": "tidelift" 1017 | } 1018 | ], 1019 | "time": "2023-11-03T14:41:28+00:00" 1020 | } 1021 | ], 1022 | "packages-dev": [], 1023 | "aliases": [], 1024 | "minimum-stability": "stable", 1025 | "stability-flags": [], 1026 | "prefer-stable": false, 1027 | "prefer-lowest": false, 1028 | "platform": { 1029 | "php": ">=7.2.5" 1030 | }, 1031 | "platform-dev": [], 1032 | "plugin-api-version": "2.6.0" 1033 | } 1034 | -------------------------------------------------------------------------------- /src/BaseCommand.php: -------------------------------------------------------------------------------- 1 | startTime = microtime(true); 52 | $this->input = $input; 53 | $this->output = $output; 54 | 55 | if ($this->loadConfig) 56 | { 57 | $this->config = Gitify::loadConfig($input->getOption('dotfile')); 58 | } 59 | if ($this->loadMODX) 60 | { 61 | $this->modx = Gitify::loadMODX(); 62 | 63 | $modxVersion = $this->modx->getVersionData(); 64 | if (version_compare($modxVersion['full_version'], '3.0.0-dev', '>=')) { 65 | $this->isMODX3 = true; 66 | } 67 | 68 | // If we're on MODX 3, set up some class aliases. 69 | if ($this->isMODX3) { 70 | class_alias(modTransportProvider::class, 'modTransportProvider'); 71 | class_alias(modTransportPackage::class, 'modTransportPackage'); 72 | class_alias(modContext::class, 'modContext'); 73 | class_alias(modElement::class, 'modElement'); 74 | class_alias(modStaticResource::class, 'modStaticResource'); 75 | class_alias(modDashboardWidget::class, 'modDashboardWidget'); 76 | class_alias(modTemplateVar::class, 'modTemplateVar'); 77 | class_alias(modCategory::class, 'modCategory'); 78 | 79 | // Avoid warnings in xPDO 3.x if $_SESSION isn't available. 80 | if (!isset($_SESSION)) { 81 | session_start(); 82 | } 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * @return string 89 | */ 90 | public function getRunStats() 91 | { 92 | $curTime = microtime(true); 93 | $duration = $curTime - $this->startTime; 94 | 95 | $output = 'Time: ' . number_format($duration * 1000, 0) . 'ms | '; 96 | $output .= 'Memory Usage: ' . $this->convertBytes(memory_get_usage(false)) . ' | '; 97 | $output .= 'Peak Memory Usage: ' . $this->convertBytes(memory_get_peak_usage(false)); 98 | return $output; 99 | } 100 | 101 | /** 102 | * @param $bytes 103 | * @return string 104 | */ 105 | public function convertBytes($bytes) 106 | { 107 | $unit = array('b','kb','mb','gb','tb','pb'); 108 | return @round($bytes/pow(1024,($i=floor(log($bytes,1024)))),2).' '.$unit[$i]; 109 | } 110 | 111 | /** 112 | * @param $path 113 | * @return string 114 | */ 115 | public function normalizePath($path) 116 | { 117 | $normalized_path = str_replace('\\', Gitify::$directorySeparator, $path); 118 | 119 | return $normalized_path; 120 | } 121 | 122 | /** 123 | * @param string $partition 124 | * @return array|null 125 | */ 126 | public function getPartitionCriteria($partition) 127 | { 128 | if (!isset($this->config['data']) || !isset($this->config['data'][$partition])) { 129 | return null; 130 | } 131 | $options = $this->config['data'][$partition]; 132 | 133 | if (isset($options['where']) && !empty($options['where'])) { 134 | return $options['where']; 135 | } 136 | 137 | return null; 138 | } 139 | 140 | /** 141 | * Loads a package (xPDO Model) by its name 142 | * 143 | * @param $package 144 | * @param array $options 145 | */ 146 | public function getPackage($package, array $options = []): void 147 | { 148 | // Check if this package is specified to use the newer xPDO v3 namespaced model structure 149 | $xpdo3 = !empty($options['namespace']); 150 | 151 | $path = (isset($options['package_path'])) ? $options['package_path'] : false; 152 | if (!$path) { 153 | $path = $this->modx->getOption($package . '.core_path', null, $this->modx->getOption('core_path') . 'components/' . $package . '/', true); 154 | $path .= $xpdo3 ? 'src/' : 'model/'; 155 | } 156 | 157 | // If the package uses the xPDO v3 namespaced model structure, add package with namespace and model options. 158 | if ($xpdo3) { 159 | $this->modx->addPackage($options['model'], $path, null, $options['namespace'] . '\\'); 160 | return; 161 | } 162 | 163 | // Load packages using the older model structure 164 | if (isset($options['service'])) { 165 | $path .= $package . '/'; 166 | $this->modx->getService($package, $options['service'], $path); 167 | } else { 168 | $this->modx->addPackage($package, $path); 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Command/BackupCommand.php: -------------------------------------------------------------------------------- 1 | setName('backup') 30 | ->setDescription('Creates a quick backup of the entire MODX database. Runs automatically when using `gitify build --force`, but can also be used manually.') 31 | ->addArgument( 32 | 'name', 33 | InputArgument::OPTIONAL, 34 | 'Optionally the name of the backup file, useful for milestone backups. If not specified the file name will be a full timestamp.' 35 | ) 36 | ->addOption( 37 | 'overwrite', 38 | 'o', 39 | InputOption::VALUE_NONE, 40 | 'When specified, a backup with the same name will be overwritten if it exists.' 41 | ) 42 | ->addOption( // https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-21.html#mysqld-8-0-21-security 43 | 'no-tablespaces', 44 | 'ntbs', 45 | InputOption::VALUE_NONE, 46 | 'As of MySQL 8.0.21 (and MySQL 5.7.31), the PROCESS privilege is required to backup tablespaces. To ignore tablespaces in your backup, include this option.' 47 | ) 48 | ->addOption( 49 | 'compress', 50 | 'c', 51 | InputOption::VALUE_NONE, 52 | 'When specified, resulting backup file will be gzip compressed.' 53 | ); 54 | } 55 | 56 | /** 57 | * Runs the command. 58 | * 59 | * @param InputInterface $input 60 | * @param OutputInterface $output 61 | * @return int 62 | */ 63 | protected function execute(InputInterface $input, OutputInterface $output) 64 | { 65 | /** 66 | * @var $database_type 67 | * @var $database_server 68 | * @var $database_user 69 | * @var $database_password 70 | * @var $dbase 71 | * @var 72 | */ 73 | include MODX_CORE_PATH . 'config/' . MODX_CONFIG_KEY . '.inc.php'; 74 | 75 | if ($database_type !== 'mysql') { 76 | $output->writeln('Sorry, only MySQL is supported as database driver currently.'); 77 | return 1; 78 | } 79 | 80 | // Grab the directory to place the backup 81 | $backupDirectory = isset($this->config['backup_directory']) ? $this->config['backup_directory'] : '_backup/'; 82 | $targetDirectory = GITIFY_WORKING_DIR . $backupDirectory; 83 | 84 | // Make sure the directory exists 85 | if (!is_dir($targetDirectory)) { 86 | mkdir($targetDirectory); 87 | if (!is_dir($targetDirectory)) { 88 | $output->writeln('Could not create {$backupDirectory} folder'); 89 | return 1; 90 | } 91 | } 92 | 93 | // Compute the name 94 | $file = $input->getArgument('name'); 95 | if (!empty($file)) { 96 | $file = $this->modx->filterPathSegment($file); 97 | } 98 | else { 99 | $file = str_replace(':', '', date(DATE_ATOM)); 100 | } 101 | 102 | if (substr($file, -4) !== '.sql') { 103 | $file .= '.sql'; 104 | } 105 | 106 | if ($input->getOption('compress') && substr($file, -3) !== '.gz') { 107 | $file .= '.gz'; 108 | } 109 | 110 | // Full target directory and file 111 | $targetFile = $targetDirectory . $file; 112 | 113 | if (file_exists($targetFile)) { 114 | $overwrite = $input->getOption('overwrite'); 115 | if (!$overwrite) { 116 | $output->writeln("A file with the name {$file} already exists in {$backupDirectory}."); 117 | return 1; 118 | } 119 | else { 120 | $output->writeln("Removing existing file {$file}."); 121 | unlink($targetFile); 122 | } 123 | } 124 | 125 | $output->writeln('Writing database backup to ' . $file . '...'); 126 | $database_password = str_replace("'", '\'', $database_password); 127 | 128 | $password_parameter = ''; 129 | if ($database_password != '') { 130 | $password_parameter = "-p'{$database_password}'"; 131 | } 132 | 133 | $tablespaces = $input->getOption('no-tablespaces') ? ' --no-tablespaces' : ''; 134 | $gzip = $input->getOption('compress') ? '| gzip - ' : ''; 135 | 136 | exec("mysqldump{$tablespaces} -u {$database_user} {$password_parameter} -h {$database_server} {$dbase} {$gzip}> {$targetFile} "); 137 | 138 | 139 | return 0; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Command/BuildCommand.php: -------------------------------------------------------------------------------- 1 | setName('build') 36 | ->setDescription('Builds a MODX site from the files and configuration.') 37 | 38 | ->addOption( 39 | 'skip-clear-cache', 40 | null, 41 | InputOption::VALUE_NONE, 42 | 'When specified, it will skip clearing the cache after building.' 43 | ) 44 | 45 | ->addOption( 46 | 'force', 47 | 'f', 48 | InputOption::VALUE_NONE, 49 | 'When specified, all existing content will be removed before rebuilding. Can be useful when having dealt with complex conflicts.' 50 | ) 51 | 52 | ->addOption( 53 | 'no-backup', 54 | null, 55 | InputOption::VALUE_NONE, 56 | 'When using the --force attribute, Gitify will automatically create a full database backup first. Specify --no-backup to skip creating the backup, at your own risk.' 57 | ) 58 | 59 | ->addOption( 60 | 'no-cleanup', 61 | null, 62 | InputOption::VALUE_NONE, 63 | 'With --no-cleanup specified the built-in orphan handling is disabled for this build. The orphan handling removes objects that no longer exist in files from the database. ' 64 | ) 65 | 66 | ->addArgument( 67 | 'partitions', 68 | InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 69 | 'Specify the data partition key (folder name), or keys separated by a space, that you want to build. ' 70 | ) 71 | ; 72 | } 73 | 74 | /** 75 | * Runs the command. 76 | * 77 | * @param InputInterface $input 78 | * @param OutputInterface $output 79 | * @return int 80 | */ 81 | protected function execute(InputInterface $input, OutputInterface $output) 82 | { 83 | $this->isForce = $input->getOption('force'); 84 | if ($this->isForce && !$input->getOption('no-backup')) { 85 | $backup = $this->getApplication()->find('backup'); 86 | $arguments = array( 87 | 'command' => 'backup' 88 | ); 89 | $backupInput = new ArrayInput($arguments); 90 | if ($backup->run($backupInput, $output) !== 0) { 91 | $output->writeln('Could not write backup. Try building without --force, or specify the --no-backup flag to force build without writing a backup.'); 92 | return 1; 93 | } 94 | } 95 | 96 | $partitions = $input->getArgument('partitions'); 97 | if (!$partitions || empty($partitions)) { 98 | $partitions = array_keys($this->config['data']); 99 | } 100 | foreach ($this->config['data'] as $folder => $type) { 101 | if (!in_array($folder, $partitions, true)) { 102 | if ($output->isVerbose()) { 103 | $output->writeln('Skipping ' . $folder); 104 | } 105 | continue; 106 | } 107 | 108 | $type['folder'] = $folder; 109 | 110 | switch (true) { 111 | case (!empty($type['type']) && $type['type'] == 'content'): 112 | // "content" is a shorthand for contexts + resources 113 | $output->writeln("Building content from $folder/..."); 114 | $this->buildContent($this->config['data_directory'] . $folder, $type); 115 | 116 | break; 117 | 118 | case (!empty($type['class'])): 119 | $doing = $this->isForce ? 'Force building' : 'Building'; 120 | $output->writeln("{$doing} {$type['class']} from {$folder}/..."); 121 | if (isset($type['package'])) { 122 | $this->getPackage($type['package'], $type); 123 | } 124 | $this->buildObjects($this->config['data_directory'] . $folder, $type); 125 | 126 | break; 127 | } 128 | } 129 | 130 | if (!$input->getOption('skip-clear-cache')) { 131 | $output->writeln('Clearing cache...'); 132 | $this->modx->getCacheManager()->refresh(); 133 | } 134 | 135 | $output->writeln('Done! ' . $this->getRunStats()); 136 | return 0; 137 | } 138 | 139 | /** 140 | * Loads the Content, handling uris for naming etc. 141 | * 142 | * @param $folder 143 | * @param $type 144 | */ 145 | public function buildContent($folder, $type) 146 | { 147 | if ($this->isForce) { 148 | $this->output->writeln('Forcing build, removing prior Resources...'); 149 | $forceCriteria = $this->getPartitionCriteria($type['folder']); 150 | if (is_null($forceCriteria)) { 151 | $forceCriteria = array(); 152 | } 153 | $this->modx->removeCollection('modResource', $forceCriteria); 154 | 155 | if (isset($type['truncate_on_force'])) { 156 | foreach ($type['truncate_on_force'] as $class) { 157 | $this->output->writeln('> Truncating ' . $class . ' before force building Resources...'); 158 | $this->modx->removeCollection($class, array()); 159 | } 160 | } 161 | } 162 | 163 | // Conflict handling 164 | $this->resetConflicts(); 165 | $this->getExistingObjects('modResource', $this->getPartitionCriteria($type['folder'])); 166 | 167 | $folder = GITIFY_WORKING_DIR . $folder; 168 | 169 | $directory = new \DirectoryIterator($folder); 170 | foreach ($directory as $path => $info) { 171 | /** @var \SplFileInfo $info */ 172 | $name = $info->getFilename(); 173 | 174 | // Ignore dotfiles/folders 175 | if (substr($name, 0, 1) == '.') continue; 176 | 177 | if (!$info->isDir()) { 178 | //$output->writeln('Expecting directory, got ' . $info->getType() . ': ' . $name); 179 | continue; 180 | } 181 | 182 | $context = $this->modx->getObject('modContext', array('key' => $name)); 183 | if (!$context) { 184 | $this->output->writeln('- Context ' . $name . ' does not exist. Perhaps you\'re missing contexts data?'); 185 | continue; 186 | } 187 | 188 | $this->output->writeln('- Building ' . $name . ' context...'); 189 | 190 | $path = $info->getRealPath(); 191 | $this->buildResources($name, new \RecursiveDirectoryIterator($path)); 192 | } 193 | 194 | $type['class'] = 'modResource'; 195 | $this->removeOrphans($type, 'uri'); 196 | $this->resolveConflicts($folder, $type, true); 197 | } 198 | 199 | /** 200 | * Loops over resource files to create the resources. 201 | * 202 | * @param $context 203 | * @param $iterator 204 | */ 205 | public function buildResources($context, $iterator) 206 | { 207 | $resources = array(); 208 | $parents = array(); 209 | foreach ($iterator as $fileInfo) { 210 | if (substr($fileInfo->getFilename(), 0, 1) == '.') { 211 | continue; 212 | } 213 | /** @var \SplFileInfo $fileInfo */ 214 | if ($fileInfo->isDir()) { 215 | $parents[] = $fileInfo; 216 | } 217 | elseif ($fileInfo->isFile()) { 218 | $resources[] = $fileInfo; 219 | } 220 | } 221 | 222 | // create the resources first 223 | /** @var \SplFileInfo $resource */ 224 | foreach ($resources as $resource) { 225 | $file = file_get_contents($resource->getRealPath()); 226 | 227 | // Normalize line-endings to \n to ensure consistency 228 | $file = str_replace("\r\n", "\n", $file); 229 | $file = str_replace("\r", "\n", $file); 230 | // Check if delimiter exists, otherwise add it to avoid WARN in explode() 231 | // (WARN @ Gitify/src/Command/BuildCommand.php : 246) PHP notice: Undefined offset: 1 232 | if (strpos($file, Gitify::$contentSeparator) === false) { 233 | $file = $file . Gitify::$contentSeparator; 234 | } 235 | list($rawData, $content) = explode(Gitify::$contentSeparator, $file); 236 | 237 | try { 238 | $data = Gitify::fromYAML($rawData); 239 | } catch (ParseException $Exception) { 240 | $this->output->writeln('Could not parse ' . $resource->getFilename() . ': ' . $Exception->getMessage() .''); 241 | continue; 242 | } 243 | if (!empty($content)) { 244 | $data['content'] = $content; 245 | } 246 | $data['context_key'] = $context; 247 | 248 | $this->buildSingleResource($data); 249 | } 250 | 251 | // Then loop over all subs 252 | foreach ($parents as $parentResource) { 253 | $this->buildResources($context, new \RecursiveDirectoryIterator($parentResource.'/')); 254 | } 255 | } 256 | 257 | /** 258 | * Creates or updates a single Resource 259 | * 260 | * @param $data 261 | * @param bool $isConflictResolution 262 | */ 263 | public function buildSingleResource($data, $isConflictResolution = false) { 264 | $this->modx->setOption(\xPDO::OPT_SETUP, true); 265 | // Figure out the primary key - it's either uri or id in the case of a resource. 266 | if (!empty($data['uri'])) { 267 | $primary = array('uri' => $data['uri'], 'context_key' => $data['context_key']); 268 | $primaryKey = array('uri', 'context_key'); 269 | $method = 'uri'; 270 | } 271 | else { 272 | $primary = $data['id']; 273 | $primaryKey = 'id'; 274 | $method = 'id'; 275 | } 276 | 277 | if (!$isConflictResolution && $this->hasConflict('modResource', $primaryKey, $primary, $data)) { 278 | return; 279 | } 280 | 281 | // Grab the resource, or create a new one. 282 | $new = false; 283 | $oldId = null; 284 | $object = ($this->isForce) ? false : $this->modx->getObject('modResource', $primary); 285 | if (!($object instanceof \modResource)) { 286 | $object = $this->modx->newObject('modResource'); 287 | $new = true; 288 | 289 | // Attempt to duplicate existing resource to another context or uri 290 | if ($method === 'uri' && $this->modx->getCount('modResource', $data['id'])) { 291 | $oldId = $data['id']; 292 | unset($data['id']); 293 | 294 | $data['parent'] = isset($data['parent'], $this->_resourceIds[$data['parent']]) 295 | ? $this->_resourceIds[$data['parent']] 296 | : 0; 297 | } 298 | } 299 | 300 | // Ensure all fields have a value 301 | foreach ($object->_fieldMeta as $field => $meta) { 302 | if (!isset($data[$field])) $data[$field] = isset($meta['default']) ? $meta['default'] : null; 303 | } 304 | 305 | // Set the fields on the resource 306 | $object->fromArray($data, '', true, true); 307 | 308 | // Process stored TVs 309 | if (isset($data['tvs'])) { 310 | foreach($data['tvs'] as $key => $value) { 311 | $object->setTVValue($key, $value); 312 | } 313 | } 314 | 315 | // Save it! 316 | if ($object->save()) { 317 | if ($oldId) { 318 | $this->_resourceIds[$oldId] = $object->get('id'); 319 | } 320 | if ($this->output->isVerbose()) { 321 | $new = ($new) ? 'Created new' : 'Updated'; 322 | $this->output->writeln("- {$new} resource from {$method}: {$data[$method]}"); 323 | } 324 | 325 | $pk = $object->getPrimaryKey(); 326 | if (is_array($pk)) { 327 | $pk = json_encode($pk); 328 | } 329 | if (isset($this->orphanedObjects[$pk])) { 330 | unset($this->orphanedObjects[$pk]); 331 | } 332 | } 333 | else { 334 | $new = ($new) ? 'new' : 'updated'; 335 | $this->output->writeln("- Could not save {$new} resource from {$method}: {$data[$method]}"); 336 | } 337 | } 338 | 339 | /** 340 | * Loops over an object folder and parses the files to pass to buildSingleObject 341 | * 342 | * @param $folder 343 | * @param $type 344 | */ 345 | public function buildObjects($folder, $type) 346 | { 347 | if (!file_exists(GITIFY_WORKING_DIR . $folder)) { 348 | $this->output->writeln('> Skipping ' . $type['class'] . ', ' . $folder. ' does not exist.'); 349 | return; 350 | } 351 | 352 | $criteria = $this->getPartitionCriteria($type['folder']); 353 | if (is_null($criteria)) { 354 | $criteria = array(); 355 | } 356 | 357 | if ($this->isForce) { 358 | $this->modx->removeCollection($type['class'], $criteria); 359 | 360 | if (isset($type['truncate_on_force'])) { 361 | foreach ($type['truncate_on_force'] as $class) { 362 | $this->output->writeln('> Truncating ' . $class . ' before force building ' . $type['class'] . '...'); 363 | $this->modx->removeCollection($class, array()); 364 | } 365 | } 366 | 367 | /** 368 | * @deprecated 2015-03-30 369 | * 370 | * Deprecated in favour of specifying truncate_on_force in the .gitify file. 371 | */ 372 | switch ($type['class']) { 373 | // $modx->removeCollection does not automatically remove related objects, which in the case 374 | // of modCategory results in orphaned modCategoryClosure objects. Normally, this is okay, because 375 | // Gitify recreates the objects with the same ID. But Categories automatically add the closure on 376 | // save, which then throws a bunch of errors about duplicate IDs. Worst of all, it _removes_ the 377 | // category object if it can't save the closure, causing the IDs to go all over the place. 378 | // So in this case, we make sure all category closures are wiped too. 379 | case 'modCategory': 380 | $this->modx->removeCollection('modCategoryClosure', array()); 381 | break; 382 | } 383 | } 384 | 385 | $directory = new \DirectoryIterator(GITIFY_WORKING_DIR . $folder); 386 | 387 | // Reset the conflicts so we're good to go on new ones 388 | $this->resetConflicts(); 389 | $this->getExistingObjects($type['class'], $criteria); 390 | 391 | foreach ($directory as $file) { 392 | /** @var \SplFileInfo $file */ 393 | $name = $file->getFilename(); 394 | 395 | // Ignore dotfiles/folders 396 | if (substr($name, 0, 1) == '.') continue; 397 | 398 | if (!$file->isFile()) { 399 | $this->output->writeln('- Skipping ' . $file->getType() . ': ' . $name); 400 | continue; 401 | } 402 | 403 | // Load the file contents 404 | $file = file_get_contents($file->getRealPath()); 405 | 406 | // Normalize line-endings to \n to ensure consistency 407 | $file = str_replace("\r\n", "\n", $file); 408 | $file = str_replace("\r", "\n", $file); 409 | 410 | // Check if delimiter exists, otherwise add it to avoid WARN in explode() 411 | // (WARN @ Gitify/src/Command/BuildCommand.php : 407) PHP notice: Undefined offset: 1 412 | if (strpos($file, Gitify::$contentSeparator) === false) { 413 | $file = $file . Gitify::$contentSeparator; 414 | } 415 | // Get the raw data, and the content 416 | list($rawData, $content) = explode(Gitify::$contentSeparator, $file); 417 | 418 | // Turn the raw YAML data into an array 419 | $data = Gitify::fromYAML($rawData); 420 | if (!empty($content)) { 421 | $data['content'] = $content; 422 | } 423 | 424 | $this->buildSingleObject($data, $type); 425 | } 426 | 427 | $this->removeOrphans($type); 428 | 429 | $this->resolveConflicts($folder, $type); 430 | 431 | // Due to autoloading via namespaces (bootstrap.php) in MODX 3.x, rebuild namespace cache. 432 | $namespaces = [ 433 | 'modNamespace', 434 | '\modNamespace', 435 | 'MODX\Revolution\modNamespace', 436 | '\MODX\Revolution\modNamespace', 437 | ]; 438 | if (class_exists('\MODX\Revolution\modNamespace') && in_array($type['class'], $namespaces, true)) { 439 | $this->modx->getCacheManager()->generateNamespacesCache('namespaces'); 440 | \MODX\Revolution\modNamespace::loadCache($this->modx); 441 | } 442 | } 443 | 444 | /** 445 | * Creates or updates a single xPDOObject. 446 | * 447 | * @param $data 448 | * @param $type 449 | * @param bool $isConflictResolution 450 | */ 451 | public function buildSingleObject($data, $type, $isConflictResolution = false) { 452 | $this->modx->setOption(\xPDO::OPT_SETUP, true); 453 | $primaryKey = !empty($type['primary']) ? $type['primary'] : 'id'; 454 | $class = $type['class']; 455 | 456 | $primary = $this->_getPrimaryKey($class, $primaryKey, $data); 457 | $showPrimary = (is_array($primary)) ? json_encode($primary) : $primary; 458 | 459 | if (!$isConflictResolution && $this->hasConflict($class, $primaryKey, $primary, $data)) { 460 | return; 461 | } 462 | 463 | $new = false; 464 | /** @var \xPDOObject|bool $object */ 465 | if (!is_array($primaryKey)) { 466 | $primary = array($primaryKey => $primary); 467 | } 468 | $object = ($this->isForce) ? false : $this->modx->getObject($class, $primary); 469 | if (!($object instanceof \xPDOObject)) { 470 | $object = $this->modx->newObject($class); 471 | $new = true; 472 | } 473 | 474 | if ($object instanceof \modElement && !($object instanceof \modCategory)) { 475 | // Handle string-based category names automagically 476 | if (isset($data['category']) && !empty($data['category']) && !is_numeric($data['category'])) { 477 | $catName = $data['category']; 478 | $data['category'] = $this->getCategoryId($catName); 479 | } 480 | } 481 | 482 | $object->fromArray($data, '', true, true); 483 | 484 | $prefix = $isConflictResolution ? ' \ ' : '- '; 485 | if ($object->save()) { 486 | if ($this->output->isVerbose()) { 487 | $new = ($new) ? 'Created new' : 'Updated'; 488 | $this->output->writeln("{$prefix}{$new} {$class}: {$showPrimary}"); 489 | } 490 | 491 | $pk = $object->getPrimaryKey(); 492 | if (is_array($pk)) { 493 | $pk = json_encode($pk); 494 | } 495 | if (isset($this->orphanedObjects[$pk])) { 496 | unset($this->orphanedObjects[$pk]); 497 | } 498 | } 499 | else { 500 | $new = ($new) ? 'new' : 'updated'; 501 | $this->output->writeln("{$prefix}Could not save {$new} {$class}: {$showPrimary}"); 502 | } 503 | } 504 | 505 | /** 506 | * Grabs a category ID (or creates one!) for a category name 507 | * 508 | * @param $name 509 | * @return int 510 | */ 511 | public function getCategoryId($name) 512 | { 513 | // Hashing it as md5 to make sure invalid characters don't mess with our array 514 | if (isset($this->categories[md5($name)])) { 515 | return $this->categories[md5($name)]; 516 | } 517 | $category = $this->modx->getObject('modCategory', array('category' => $name)); 518 | if (!$category) { 519 | $category = $this->modx->newObject('modCategory'); 520 | $category->fromArray(array( 521 | 'category' => $name, 522 | )); 523 | if (!$category->save()) { 524 | return 0; 525 | } 526 | } 527 | if ($category) { 528 | $this->categories[md5($name)] = $category->get('id'); 529 | return $this->categories[md5($name)]; 530 | } 531 | return 0; 532 | } 533 | 534 | /** 535 | * Looks at the field meta to find the default value. 536 | * 537 | * @param string $class The xPDOObject to grab adefault value for 538 | * @param string $field The field in the xPDOObject to grab a default value for 539 | * @return null 540 | */ 541 | protected function _getDefaultForField($class, $field) 542 | { 543 | if (!isset($this->_metaCache[$class])) { 544 | $this->_metaCache[$class] = $this->modx->getFieldMeta($class); 545 | } 546 | 547 | if (isset($this->_metaCache[$class][$field]) && isset($this->_metaCache[$class][$field]['default'])) { 548 | return $this->_metaCache[$class][$field]['default']; 549 | } 550 | return null; 551 | } 552 | 553 | /** 554 | * Returns an array of all current objects in the database, per key => array 555 | * 556 | * @param $class 557 | * @param array $criteria 558 | * @return array 559 | */ 560 | public function getExistingObjects($class, $criteria = array()) 561 | { 562 | $this->existingObjects = array(); 563 | $this->orphanedObjects = array(); 564 | 565 | if (!$this->isForce) { 566 | $iterator = $this->modx->getIterator($class, $criteria); 567 | foreach ($iterator as $object) { 568 | /** @var \xPDOObject $object */ 569 | $key = $pk = $object->getPrimaryKey(); 570 | if (is_array($key)) { 571 | $key = implode('--', $key); 572 | $pk = json_encode($pk); 573 | } 574 | $this->existingObjects[$key] = $object->toArray(); 575 | $this->orphanedObjects[$pk] = 1; 576 | } 577 | } 578 | } 579 | 580 | public function resetConflicts() 581 | { 582 | $this->updatedObjects = array(); 583 | $this->conflictingObjects = array(); 584 | } 585 | 586 | /** 587 | * @param $folder 588 | * @param $type 589 | * @param bool $isResource 590 | * @throws \Exception 591 | */ 592 | public function resolveConflicts($folder, $type, $isResource = false) 593 | { 594 | if (!empty($this->conflictingObjects)) { 595 | $runExtract = false; 596 | foreach ($this->conflictingObjects as $conflict) { 597 | $showOriginalPrimary = is_array($conflict['existing_object_primary']) ? json_encode($conflict['existing_object_primary']) : $conflict['existing_object_primary']; 598 | $this->output->writeln('- Attempting to resolve ID Conflict for ' . $showOriginalPrimary . ' with ' . count($conflict['conflicts']) . ' duplicate(s).'); 599 | 600 | 601 | // Get the original/master copy of this conflict 602 | $getPrimary = $conflict['existing_object_primary']; 603 | if (!is_array($getPrimary)) { 604 | $getPrimary = array('id' => $getPrimary); 605 | } 606 | $original = $this->modx->getObject($type['class'], $getPrimary); 607 | if ($original instanceof \xPDOObject) { 608 | // Get the primary key definition of the class 609 | $objectPrimaryKey = $original->getPK(); 610 | 611 | foreach ($conflict['conflicts'] as $dupe) { 612 | $resolved = false; 613 | $duplicateObject = $dupe['data']; 614 | if (is_string($objectPrimaryKey) && $objectPrimaryKey === 'id') { 615 | unset($duplicateObject[$objectPrimaryKey]); 616 | 617 | $this->output->writeln(" \\ Duplicate #{$dupe['idx']}: resolving primary key conflict by building object with new auto incremented primary key. "); 618 | 619 | if ($isResource) { 620 | $this->buildSingleResource($duplicateObject, true); 621 | } 622 | else { 623 | $this->buildSingleObject($duplicateObject, $type, true); 624 | } 625 | $resolved = $runExtract = true; 626 | } 627 | 628 | if (!$resolved) { 629 | $this->output->writeln(" \ Unable to resolve ID conflict. The ID conflict will need to be solved manually. "); 630 | } 631 | } 632 | } 633 | else { 634 | $this->output->writeln(" \ Can't load original {$type['class']} with primary {$showOriginalPrimary} assuming conflict was due to an orphaned object."); 635 | $conflict = reset($conflict['conflicts']); 636 | $duplicateObject = $conflict['data']; 637 | if ($isResource) { 638 | $this->buildSingleResource($duplicateObject, true); 639 | } 640 | else { 641 | $this->buildSingleObject($duplicateObject, $type, true); 642 | } 643 | } 644 | } 645 | 646 | if ($runExtract) { 647 | $this->output->writeln('- Re-extracting ' . basename($folder) . '; you will need to commit the changes manually.'); 648 | $command = $this->getApplication()->find('extract'); 649 | $inputArray = array( 650 | 'command' => 'extract', 651 | 'partitions' => array(basename($folder)), 652 | ); 653 | $input = new ArrayInput($inputArray); 654 | $output = new BufferedOutput(); 655 | $command->run($input, $output); 656 | 657 | $cmdOutput = $output->fetch(); 658 | $cmdOutput = explode("\n", $cmdOutput); 659 | $cmdOutput = array_map(function ($n) { 660 | return ' \ ' . $n; 661 | }, $cmdOutput); 662 | $cmdOutput = implode("\n", $cmdOutput); 663 | 664 | $this->output->write($cmdOutput); 665 | } 666 | } 667 | } 668 | 669 | /** 670 | * @param $class 671 | * @param $primaryKey 672 | * @param $data 673 | * @return array 674 | */ 675 | protected function _getPrimaryKey($class, $primaryKey, $data) 676 | { 677 | if (is_array($primaryKey)) { 678 | $primary = array(); 679 | foreach ($primaryKey as $pkVal) { 680 | $primary[$pkVal] = isset($data[$pkVal]) ? $data[$pkVal] : $this->_getDefaultForField($class, $pkVal); 681 | } 682 | } else { 683 | $primary = $data[$primaryKey]; 684 | } 685 | return $primary; 686 | } 687 | 688 | /** 689 | * @param $data 690 | * @param $internalPrimary 691 | * @param $primary 692 | */ 693 | protected function registerConflict($data, $internalPrimary, $primary) 694 | { 695 | if (!isset($this->conflictingObjects[$internalPrimary])) { 696 | $this->conflictingObjects[$internalPrimary] = array( 697 | 'existing_object_primary' => $primary, 698 | 'conflicts' => array(), 699 | ); 700 | } 701 | $this->conflictingObjects[$internalPrimary]['conflicts'][] = array( 702 | 'idx' => count($this->conflictingObjects[$internalPrimary]['conflicts']) + 1, 703 | 'data' => $data, 704 | ); 705 | } 706 | 707 | /** 708 | * Checks against conflicts in the source and database. Returns true if there was a conflict, false if all's good. 709 | * 710 | * @param $class 711 | * @param $primaryKey 712 | * @param $primary 713 | * @param $data 714 | * @return bool 715 | */ 716 | public function hasConflict($class, $primaryKey, $primary, $data) 717 | { 718 | $showPrimary = (is_array($primary)) ? json_encode($primary) : $primary; 719 | 720 | // Get the primary to match for ID conflict resolution 721 | $classPrimary = $this->modx->getPK($class); 722 | if (is_array($classPrimary)) { 723 | $internalPrimary = array(); 724 | foreach ($classPrimary as $classPrimaryField) { 725 | $fieldMeta = $this->modx->getFieldMeta($class); 726 | $default = isset($fieldMeta[$classPrimaryField]['default']) ? $fieldMeta[$classPrimaryField]['default'] : null; 727 | $internalPrimary[$classPrimaryField] = (isset($data[$classPrimaryField])) ? $data[$classPrimaryField] : $default; 728 | } 729 | $internalPrimary = implode('--', $internalPrimary); 730 | } else { 731 | $internalPrimary = $data[$classPrimary]; 732 | } 733 | 734 | $prefix = ($class === 'modResource') ? ' \ ' : '- '; 735 | // First check - have we came across an object with this primary key before? 736 | if (isset($this->updatedObjects[$internalPrimary])) { 737 | $this->registerConflict($data, $internalPrimary, $primary); 738 | $this->output->writeln("$prefixPrimary Key Duplicate found: duplicate {$class} with primary {$showPrimary}"); 739 | 740 | return true; 741 | } 742 | // Second check - see if the object already exists in the database with a different real primary keys 743 | elseif (isset($this->existingObjects[$internalPrimary])) { 744 | $existingObjPrimary = $this->_getPrimaryKey($class, $primaryKey, $this->existingObjects[$internalPrimary]); 745 | if ($primary !== $existingObjPrimary) { 746 | $this->registerConflict($data, $internalPrimary, $existingObjPrimary); 747 | $showExistingObjPrimary = (is_array($existingObjPrimary)) ? json_encode($existingObjPrimary) : $existingObjPrimary; 748 | $this->output->writeln("{$prefix}Primary Key Conflict found: {$class} {$showPrimary} has the same primary key as {$showExistingObjPrimary}"); 749 | return true; 750 | } 751 | } 752 | 753 | $this->updatedObjects[$internalPrimary] = $primary; 754 | return false; 755 | } 756 | 757 | /** 758 | * @param $type 759 | * @param bool $primary 760 | */ 761 | public function removeOrphans($type, $primary = false) 762 | { 763 | if ($this->input->getOption('no-cleanup')) { 764 | $orphans = count($this->orphanedObjects); 765 | if ($orphans > 0) { 766 | $this->output->writeln("- Found {$orphans} orphaned {$type['class']} object(s), but the --no-cleanup flag was specified."); 767 | } 768 | return; 769 | } 770 | 771 | if (!$primary) { 772 | $primary = $type['primary']; 773 | } 774 | foreach ($this->orphanedObjects as $pk => $val) { 775 | $getPrimary = (json_decode($pk)) ? json_decode($pk) : $pk; 776 | $obj = $this->modx->getObject($type['class'], $getPrimary); 777 | if ($obj instanceof \xPDOObject) { 778 | $showPrimary = $this->_getPrimaryKey($type['class'], $primary, $obj->toArray()); 779 | $showPrimary = (is_array($showPrimary)) ? json_encode($showPrimary) : $showPrimary; 780 | if ($obj->remove()) { 781 | $this->output->writeln("- Removed orphaned {$type['class']} with primary {$showPrimary}"); 782 | } else { 783 | $this->output->writeln("- Could not remove orphaned {$type['class']} with primary {$showPrimary}"); 784 | } 785 | } 786 | } 787 | } 788 | } 789 | -------------------------------------------------------------------------------- /src/Command/ClearCacheCommand.php: -------------------------------------------------------------------------------- 1 | setName('clearcache') 18 | ->setDescription('Clears the internal Gitify cache.'); 19 | } 20 | 21 | protected function execute(InputInterface $input, OutputInterface $output) 22 | { 23 | if (file_exists(GITIFY_CACHE_DIR)) { 24 | exec("rm -rf " . GITIFY_CACHE_DIR); 25 | $output->writeln('Cleared the Gitify cache.'); 26 | } 27 | 28 | return 0; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Command/DownloadModxCommand.php: -------------------------------------------------------------------------------- 1 | setName('modx:download') 30 | ->setDescription('Downloads a fresh MODX installation.') 31 | ->addArgument( 32 | 'version', 33 | InputArgument::OPTIONAL, 34 | 'The version of MODX to download, in the format 2.3.2-pl. Leave empty or specify "latest" to download the last stable release.', 35 | 'latest' 36 | ) 37 | ->addOption( 38 | 'download', 39 | 'd', 40 | InputOption::VALUE_NONE, 41 | 'Force download the MODX package even if it already exists in the cache folder.' 42 | ); 43 | } 44 | 45 | /** 46 | * Runs the command. 47 | * 48 | * @param InputInterface $input 49 | * @param OutputInterface $output 50 | * @return int 51 | */ 52 | protected function execute(InputInterface $input, OutputInterface $output) 53 | { 54 | $version = $this->input->getArgument('version'); 55 | $forced = $this->input->getOption('download'); 56 | 57 | if (!$this->getMODX($version, $forced)) { 58 | return 1; // exit 59 | } 60 | 61 | $output->writeln('Done! ' . $this->getRunStats()); 62 | return 0; 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/Command/ExtractCommand.php: -------------------------------------------------------------------------------- 1 | setName('extract') 32 | ->setDescription('Extracts data from the MODX site, and stores it in human readable files for editing and committing to a VCS.') 33 | 34 | ->addArgument( 35 | 'partitions', 36 | InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 37 | 'Specify the data partition key (folder name), or keys separated by a space, that you want to extract. ' 38 | ) 39 | ->addOption( 40 | 'packages', 41 | 'p', 42 | InputOption::VALUE_NEGATABLE, 43 | 'Skip extracting installed package when set.', 44 | true 45 | ) 46 | ; 47 | } 48 | 49 | /** 50 | * Runs the command. 51 | * 52 | * @param InputInterface $input 53 | * @param OutputInterface $output 54 | * @return int 55 | */ 56 | protected function execute(InputInterface $input, OutputInterface $output) 57 | { 58 | // load modResource dependency 59 | $this->modx->loadClass('modResource'); 60 | 61 | $partitions = $input->getArgument('partitions'); 62 | if (!$partitions || empty($partitions)) { 63 | $partitions = array_keys($this->config['data']); 64 | } 65 | foreach ($this->config['data'] as $folder => $options) { 66 | if (!in_array($folder, $partitions, true)) { 67 | if ($output->isVerbose()) { 68 | $output->writeln('Skipping ' . $folder); 69 | } 70 | continue; 71 | } 72 | $options['folder'] = $folder; 73 | 74 | switch (true) { 75 | case (!empty($options['type']) && $options['type'] == 'content'): 76 | // "content" is a shorthand for contexts + resources 77 | $this->extractContent(GITIFY_WORKING_DIR . $this->config['data_directory'] . $folder, $options); 78 | 79 | break; 80 | 81 | case (!empty($options['class'])): 82 | if (isset($options['package'])) { 83 | $this->getPackage($options['package'], $options); 84 | } 85 | $this->extractObjects(GITIFY_WORKING_DIR . $this->config['data_directory'] . $folder, $options); 86 | 87 | break; 88 | } 89 | } 90 | 91 | if ($input->getOption('packages')) { 92 | $this->extractPackages($input->getOption('dotfile')); 93 | } 94 | 95 | $output->writeln('Done! ' . $this->getRunStats()); 96 | return 0; 97 | } 98 | 99 | /** 100 | * Loads the Content, handling uris for naming etc. 101 | * 102 | * @param $folder 103 | * @param $options 104 | */ 105 | public function extractContent($folder, $options) 106 | { 107 | // Read the current files 108 | $before = $this->getAllFiles($folder); 109 | $after = array(); 110 | $extension = (isset($options['extension'])) ? $options['extension'] : '.html'; 111 | 112 | $criteria = $this->getPartitionCriteria($options['folder']); 113 | 114 | // Display what we're about to do here 115 | $resourceCount = $this->modx->getCount('modResource', $criteria); 116 | $contextCount = $this->modx->getCount('modContext', array('key:!=' => 'mgr')); 117 | $this->output->writeln("Extracting content into {$options['folder']} ({$resourceCount} resources across {$contextCount} contexts)..."); 118 | 119 | // Grab the contexts 120 | $contexts = $this->modx->getIterator('modContext'); 121 | foreach ($contexts as $context) { 122 | /** @var \modContext $context */ 123 | $contextKey = $context->get('key'); 124 | 125 | // Prepare the criteria for this context 126 | $contextCriteria = ($criteria) ? $criteria : array(); 127 | $contextCriteria['context_key'] = $contextKey; 128 | 129 | // Grab the count 130 | $count = $this->modx->getCount('modResource', $contextCriteria); 131 | $this->output->writeln("- Extracting resources from {$contextKey} context ({$count} resources)..."); 132 | 133 | // Grab the resources in the context 134 | $c = $this->modx->newQuery('modResource'); 135 | $c->where($contextCriteria); 136 | $c->sortby('uri', 'ASC'); 137 | $resources = $this->modx->getIterator('modResource', $c); 138 | if (isset($options['limit_per_parent'])) { 139 | $resources = $this->limitPerParent($options['limit_per_parent'], $resources); 140 | } 141 | foreach ($resources as $resource) { 142 | /** @var \modResource $resource */ 143 | $file = $this->generate($resource, $options); 144 | 145 | // Somewhat normalize uris into something we can use as file path that makes (human) sense 146 | $uri = $resource->uri; 147 | // Trim trailing slash if there is one 148 | if (substr($uri, -1) == '/') 149 | { 150 | $uri = rtrim($uri, '/'); 151 | } 152 | else 153 | { 154 | // Get rid of the extension by popping off the last part, and adding just the alias back. 155 | $uri = explode('/', $uri); 156 | array_pop($uri); 157 | 158 | // The alias might contain slashes too, so cover that 159 | $alias = explode('/', trim($resource->alias, '/')); 160 | $uri[] = end($alias); 161 | $uri = implode(DIRECTORY_SEPARATOR, $uri); 162 | } 163 | 164 | if (empty($uri)) $uri = $resource->id; 165 | 166 | // Write the file 167 | $fn = $folder . DIRECTORY_SEPARATOR . $contextKey . DIRECTORY_SEPARATOR . $uri . $extension; 168 | 169 | $fn = $this->normalizePath($fn); 170 | 171 | $after[] = $fn; 172 | 173 | // Only write stuff if it doesn't exist already, or is not the same 174 | $written = false; 175 | if (!file_exists($fn) || file_get_contents($fn) != $file) { 176 | $this->modx->cacheManager->writeFile($fn, $file); 177 | $written = true; 178 | } 179 | 180 | // If we're in verbose mode (-v/--verbose), output a message with what we did 181 | if ($this->output->isVerbose()) { 182 | $this->output->writeln(' \ ' . ($written ? "Generated {$uri}{$extension}" : "Skipped {$uri}{$extension}, no change")); 183 | } 184 | } 185 | } 186 | 187 | // Clean up removed object files 188 | $old = array_diff($before, $after); 189 | foreach ($old as $oldFile) 190 | { 191 | unlink($oldFile); 192 | // If in verbose mode, let it be known to the world 193 | if ($this->output->isVerbose()) { 194 | $oldFileName = substr($oldFile, strlen($folder)); 195 | $this->output->writeln(" \\ Removed {$oldFileName}, no longer exists"); 196 | } 197 | } 198 | } 199 | 200 | /** 201 | * @param array{limit?: int, sort_by?: string, sort_dir?: string} $options 202 | * @param \xPDOIterator|xPDOIterator $resources 203 | * @return array|\xPDOIterator|xPDOIterator 204 | */ 205 | private function limitPerParent(array $options, $resources) 206 | { 207 | if (!is_numeric($options['limit']) || iterator_count($resources) === 0) { 208 | return $resources; 209 | } 210 | $limit = $options['limit']; 211 | $sortField = $options['sort_by'] ?? 'id'; 212 | $sortDir = strtolower($options['sort_dir'] ?? 'asc'); 213 | if (!in_array($sortDir, ['desc', 'asc'], true)) { 214 | $sortDir = 'asc'; 215 | } 216 | // group by parent 217 | $grouped = []; 218 | foreach ($resources as $resource) { 219 | $grouped[$resource->get('parent')][] = $resource; 220 | } 221 | // sort 222 | foreach ($grouped as &$toSort) { 223 | uasort( 224 | $toSort, 225 | static function (\modResource $resourceA, \modResource $resourceB) use ($sortField, $sortDir): int { 226 | $fieldA = $resourceA->get($sortField); 227 | $fieldB = $resourceB->get($sortField); 228 | if ($fieldA === $fieldB) { 229 | return 0; 230 | } 231 | if ($sortDir === 'asc') { 232 | // a is "less", we want it first (so lower score) 233 | return $fieldA < $fieldB ? -1 : 1; 234 | } 235 | 236 | // a is less, we want it after (descending) 237 | return $fieldA < $fieldB ? 1 : -1; 238 | } 239 | ); 240 | } 241 | 242 | // keep only needed amount 243 | $kept = []; 244 | foreach ($grouped as $children) { 245 | array_push($kept, ...array_slice($children, 0, $limit)); 246 | } 247 | 248 | return $kept; 249 | } 250 | 251 | /** 252 | * Loads all objects for a specified class, first clearing out the current data. 253 | * 254 | * @param $folder 255 | * @param $options 256 | */ 257 | public function extractObjects($folder, array $options = array()) 258 | { 259 | $criteria = $this->getPartitionCriteria($options['folder']); 260 | 261 | $count = $this->modx->getCount($options['class'], $criteria); 262 | $this->output->writeln("Extracting {$options['class']} into {$options['folder']} ({$count} records)..."); 263 | 264 | // Read the current files 265 | $before = $this->getAllFiles($folder); 266 | $after = array(); 267 | 268 | // Grab the stuff 269 | $c = $this->modx->newQuery($options['class']); 270 | if (!empty($criteria)) { 271 | $c->where(array($criteria)); 272 | } 273 | $collection = $this->modx->getCollection($options['class'], $c); 274 | 275 | $this->modx->getCacheManager(); 276 | 277 | // Loop over stuff to generate 278 | $pk = isset($options['primary']) ? $options['primary'] : ''; 279 | foreach ($collection as $object) { 280 | /** @var \xPDOObject $object */ 281 | $file = $this->generate($object, $options); 282 | 283 | // Grab the primary key on the object, including support for composite primary keys 284 | if (empty($pk)) { 285 | $path = $object->getPrimaryKey(); 286 | } 287 | elseif (is_array($pk)) { 288 | $paths = array(); 289 | foreach ($pk as $pkVal) { 290 | $paths[] = $object->get($pkVal); 291 | } 292 | $path = implode('.' , $paths); 293 | } 294 | else { 295 | $path = $object->get($pk); 296 | } 297 | 298 | $path = $this->filterPathSegment($path); 299 | $path = str_replace('/', '-', $path); 300 | 301 | $ext = (isset($options['extension'])) ? $options['extension'] : '.yaml'; 302 | $fn = $folder . DIRECTORY_SEPARATOR . $path . $ext; 303 | 304 | $fn = $this->normalizePath($fn); 305 | 306 | $after[] = $fn; 307 | 308 | $written = false; 309 | if (!file_exists($fn) || file_get_contents($fn) != $file) { 310 | $this->modx->cacheManager->writeFile($fn, $file); 311 | $written = true; 312 | } 313 | if ($this->output->isVerbose()) { 314 | $this->output->writeln($written ? "- Generated {$path}{$ext}" : "- Skipped {$path}{$ext}, no change"); 315 | } 316 | } 317 | 318 | // Clean up removed object files 319 | $old = array_diff($before, $after); 320 | foreach ($old as $oldFile) 321 | { 322 | unlink($oldFile); 323 | // If in verbose mode, let it be known to the world 324 | if ($this->output->isVerbose()) { 325 | $oldFileName = substr($oldFile, strlen($folder)); 326 | $this->output->writeln("- Removed {$oldFileName}, no longer exists"); 327 | } 328 | } 329 | } 330 | 331 | /** 332 | * @param \xPDOObject|\modElement $object 333 | * @param array $options 334 | * @return string 335 | */ 336 | public function generate($object, array $options = array()) 337 | { 338 | // Strip out keys that have the same value as the default, or are excluded per the .gitify 339 | $excludes = (isset($options['exclude_keys']) && is_array($options['exclude_keys'])) ? $options['exclude_keys'] : array(); 340 | 341 | $fieldMeta = $object->_fieldMeta; 342 | $data = $this->objectToArray($object, $options); 343 | 344 | // If there's a dedicated content field, we put that below the yaml for easier managing, 345 | // unless the object is a modStaticResource, calling getContent on a static resource can break the 346 | // extracting because it tries to return the (possibly binary) file. 347 | // the same problem with modDashboardWidget, it's have custom getContent method 348 | $content = ''; 349 | if (method_exists($object, 'getContent') 350 | && !($object instanceof \modStaticResource) 351 | && !($object instanceof \modDashboardWidget) 352 | && !in_array('content', $excludes) 353 | ) { 354 | $content = $object->getContent(); 355 | 356 | if (!empty($content)) { 357 | unset($data['content']); 358 | foreach ($data as $key => $value) { 359 | if ($value === $content) unset($data[$key]); 360 | } 361 | } 362 | } 363 | 364 | foreach ($data as $key => $value) { 365 | if ( 366 | (isset($fieldMeta[$key]['default']) && $value === $fieldMeta[$key]['default']) //@fixme 367 | || in_array($key, $excludes) 368 | ) 369 | { 370 | unset($data[$key]); 371 | } 372 | } 373 | 374 | $out = Gitify::toYAML($data); 375 | 376 | if (!empty($content)) { 377 | $out .= Gitify::$contentSeparator . $content; 378 | } 379 | return $out; 380 | } 381 | 382 | /** 383 | * Loops over a folder to get all the files in it. Uses for cleaning up old files. 384 | * 385 | * @param $folder 386 | * @return array 387 | */ 388 | public function getAllFiles($folder) 389 | { 390 | $files = array(); 391 | try { 392 | $di = new \RecursiveDirectoryIterator($folder, \RecursiveDirectoryIterator::SKIP_DOTS); 393 | $it = new \RecursiveIteratorIterator($di); 394 | } catch (\Exception $e) { 395 | return array(); 396 | } 397 | 398 | foreach($it as $file) 399 | { 400 | /** @var \SplFileInfo $file */ 401 | $file_path = $file->getPath() . DIRECTORY_SEPARATOR . $file->getFilename(); 402 | $files[] = $this->normalizePath($file_path); 403 | } 404 | return $files; 405 | } 406 | 407 | 408 | /** 409 | * Turns a category ID into a name 410 | * 411 | * @param $id 412 | * @return string 413 | */ 414 | public function getCategoryName($id) { 415 | if (isset($this->categories[$id])) return $this->categories[$id]; 416 | 417 | $category = $this->modx->getObject('modCategory', $id); 418 | if ($category) { 419 | $this->categories[$id] = $category->get('category'); 420 | return $this->categories[$id]; 421 | } 422 | return ''; 423 | } 424 | 425 | /** 426 | * Turns the object into an array, and do some more processing depending on the object. 427 | * 428 | * @param \xPDOObject $object 429 | * @pram array $options 430 | * @return array 431 | */ 432 | protected function objectToArray(\xPDOObject $object, array $options = array()) 433 | { 434 | $data = $object->toArray('', true, true); 435 | switch (true) { 436 | // Handle TVs for resources automatically 437 | case $object instanceof \modResource: 438 | /** @var \modResource $object */ 439 | $tvs = array(); 440 | $templateVars = $object->getTemplateVars(); 441 | foreach ($templateVars as $tv) { 442 | /** @var \modTemplateVar $tv */ 443 | $name = $tv->get('name'); 444 | if (isset($options['exclude_tvs']) && is_array($options['exclude_tvs'])) { 445 | if (!in_array($name, $options['exclude_tvs'])) { 446 | $tvs[$tv->get('name')] = $tv->get('value'); 447 | } 448 | } 449 | else { 450 | $tvs[$tv->get('name')] = $tv->get('value'); 451 | } 452 | } 453 | ksort($tvs); 454 | $data['tvs'] = $tvs; 455 | break; 456 | 457 | // Handle string-based categories automagically on elements 458 | case $object instanceof \modElement && !($object instanceof \modCategory): 459 | if (!(isset($this->config['category_ids_in_elements']) && $this->config['category_ids_in_elements'] === true)) { 460 | if (!empty($data['category']) && is_numeric($data['category'])) { 461 | $data['category'] = $this->getCategoryName($data['category']); 462 | } 463 | } 464 | break; 465 | } 466 | 467 | return $data; 468 | } 469 | 470 | /** 471 | * Uses the modResource::filterPathSegment method if available for cleaning a file path. 472 | * When it is not available (pre MODX 2.3) it uses a fake resource to call its cleanAlias method 473 | * 474 | * @param $path 475 | * @return string 476 | */ 477 | protected function filterPathSegment($path) 478 | { 479 | if ($this->_useResource === null) { 480 | $resource = $this->modx->newObject('modResource'); 481 | if (method_exists($resource, 'filterPathSegment')) { 482 | $this->_useResource = false; 483 | } 484 | else { 485 | $this->_useResource = true; 486 | $this->_resource = $resource; 487 | } 488 | } 489 | 490 | $options = array( 491 | 'friendly_alias_lowercase_only' => false, 492 | ); 493 | 494 | if ($this->_useResource) { 495 | return $this->_resource->cleanAlias($path, $options); 496 | } 497 | return \modResource::filterPathSegment($this->modx, $path, $options); 498 | } 499 | 500 | private function extractPackages(string $file = null): void 501 | { 502 | $this->output->writeln('Extracting installed packages...'); 503 | $data = Gitify::loadConfig($file); 504 | $file = GITIFY_WORKING_DIR . $file; 505 | 506 | $result = $this->modx->call('transport.modTransportPackage', 'listPackages', [ 507 | &$this->modx, 508 | 1, 509 | 0, 510 | 0, 511 | '' 512 | ]); 513 | 514 | $providers = []; 515 | /** @var modTransportPackage $package */ 516 | foreach ($result['collection'] as $package) { 517 | $signature = $package->get('signature'); 518 | $sig = xPDOTransport::parseSignature($signature); 519 | 520 | if (!$provider = $package->getOne('Provider')) { 521 | $this->output->writeln("- Package {$sig[0]} is not assigned to a provider, skipping"); 522 | continue; 523 | } 524 | 525 | $providerKey = $provider->get('name'); 526 | if (!isset($providers[$providerKey])) { 527 | $providers[$providerKey] = [ 528 | 'service_url' => $provider->get('service_url') 529 | ]; 530 | if ($provider->get('description')) { 531 | $providers[$providerKey]['description'] = $provider->get('description'); 532 | } 533 | if ($provider->get('username')) { 534 | $providers[$providerKey]['username'] = $provider->get('username'); 535 | } 536 | if ($provider->get('api_key') && !file_exists(GITIFY_WORKING_DIR . '.' . $providerKey . '.key')) { 537 | $key = $provider->get('api_key'); 538 | file_put_contents(GITIFY_WORKING_DIR . '.' . $providerKey . '.key', $key); 539 | $providers[$providerKey]['api_key'] = '.' . $providerKey . '.key'; 540 | } 541 | } 542 | 543 | $providers[$providerKey]['packages'][] = $signature; 544 | } 545 | $data['packages'] = $providers; 546 | 547 | file_put_contents($file, Gitify::toYAML($data)); 548 | $this->output->writeln('Packages updated.'); 549 | } 550 | } 551 | -------------------------------------------------------------------------------- /src/Command/InitCommand.php: -------------------------------------------------------------------------------- 1 | setName('init') 29 | ->setDescription('Generates the .gitify file to set up a new Gitify project. Optionally installs MODX as well.') 30 | 31 | ->addOption( 32 | 'overwrite', 33 | null, 34 | InputOption::VALUE_NONE, 35 | 'When a .gitify file already exists, and this flag is set, it will be overwritten.' 36 | ) 37 | ; 38 | } 39 | 40 | /** 41 | * Runs the command. 42 | * 43 | * @param InputInterface $input 44 | * @param OutputInterface $output 45 | * @return int 46 | */ 47 | protected function execute(InputInterface $input, OutputInterface $output) 48 | { 49 | // Make sure we're not overwriting existing configuration by checking for existing .gitify files 50 | if (file_exists(GITIFY_WORKING_DIR . '.gitify')) 51 | { 52 | // If the overwrite option is set we'll warn the user but continue anyway 53 | if ($input->getOption('overwrite')) 54 | { 55 | $output->writeln('A Gitify project already exists in this directory. If you continue, this will be overwritten.'); 56 | } 57 | // .. otherwise, error out. 58 | else 59 | { 60 | $output->writeln('Error: a Gitify project already exists in this directory. If you wish to continue anyway, specify the --overwrite flag.'); 61 | return 1; 62 | } 63 | } 64 | 65 | // Make sure the working directory is writable 66 | if (!is_writable(GITIFY_WORKING_DIR)) { 67 | $output->writeln('Error: the current directory is not writable! Please check the directory\'s permissions.'); 68 | return 1; 69 | } 70 | 71 | $helper = $this->getHelper('question'); 72 | 73 | // Where we'll store the configuration 74 | $data = array(); 75 | 76 | /** 77 | * Ask the user for the data directory to store object files in 78 | */ 79 | $question = new Question('Please enter the name of the data directory (defaults to _data/): ', '_data'); 80 | $directory = $helper->ask($input, $output, $question); 81 | if (empty($directory)) $directory = '_data/'; 82 | $directory = trim($directory, '/') . '/'; 83 | $data['data_directory'] = $directory; 84 | if (!file_exists($directory)) { 85 | mkdir($directory); 86 | } 87 | 88 | /** 89 | * Ask the user for a backup directory to store database backups in 90 | */ 91 | $question = new Question('Please enter the name of the backup directory (defaults to _backup/): ', '_backup'); 92 | $directory = $helper->ask($input, $output, $question); 93 | if (empty($directory)) $directory = '_backup/'; 94 | $directory = trim($directory, '/') . '/'; 95 | $data['backup_directory'] = $directory; 96 | if (!file_exists($directory)) { 97 | mkdir($directory); 98 | } 99 | 100 | /** 101 | * Ask if we want to include some default data types 102 | */ 103 | $dataTypes = array(); 104 | 105 | $question = new ConfirmationQuestion('Would you like to include Contexts? (Y/n) ', true); 106 | if ($helper->ask($input, $output, $question)) { 107 | $dataTypes['contexts'] = array( 108 | 'class' => 'modContext', 109 | 'primary' => 'key', 110 | ); 111 | $dataTypes['context_settings'] = array( 112 | 'class' => 'modContextSetting', 113 | 'primary' => array('context_key', 'key') 114 | ); 115 | } 116 | 117 | $question = new ConfirmationQuestion('Would you like to include Template Variables? (Y/n) ', true); 118 | if ($helper->ask($input, $output, $question)) { 119 | $dataTypes['template_variables'] = array( 120 | 'class' => 'modTemplateVar', 121 | 'primary' => 'name', 122 | ); 123 | $dataTypes['template_variables_access'] = array( 124 | 'class' => 'modTemplateVarTemplate', 125 | 'primary' => array('tmplvarid', 'templateid') 126 | ); 127 | } 128 | 129 | $question = new ConfirmationQuestion('Would you like to include Content? (Y/n) ', true); 130 | if ($helper->ask($input, $output, $question)) { 131 | $dataTypes['content'] = array( 132 | 'type' => 'content', 133 | 'exclude_keys' => array('editedby', 'editedon'), 134 | 'truncate_on_force' => array('modTemplateVarResource'), 135 | ); 136 | } 137 | 138 | $question = new ConfirmationQuestion('Would you like to include Categories? (Y/n) ', true); 139 | if ($helper->ask($input, $output, $question)) { 140 | $dataTypes['categories'] = array( 141 | 'class' => 'modCategory', 142 | 'primary' => 'category', 143 | 'truncate_on_force' => array('modCategoryClosure'), 144 | ); 145 | } 146 | 147 | $question = new ConfirmationQuestion('Would you like to include Templates? (Y/n) ', true); 148 | if ($helper->ask($input, $output, $question)) { 149 | $dataTypes['templates'] = array( 150 | 'class' => 'modTemplate', 151 | 'primary' => 'templatename', 152 | 'extension' => '.html', 153 | ); 154 | } 155 | 156 | $question = new ConfirmationQuestion('Would you like to include Chunks? (Y/n) ', true); 157 | if ($helper->ask($input, $output, $question)) { 158 | $dataTypes['chunks'] = array( 159 | 'class' => 'modChunk', 160 | 'primary' => 'name', 161 | 'extension' => '.html' 162 | ); 163 | } 164 | 165 | $question = new ConfirmationQuestion('Would you like to include Snippets? (Y/n) ', true); 166 | if ($helper->ask($input, $output, $question)) { 167 | $dataTypes['snippets'] = array( 168 | 'class' => 'modSnippet', 169 | 'primary' => 'name', 170 | 'extension' => '.php' 171 | ); 172 | } 173 | 174 | $question = new ConfirmationQuestion('Would you like to include Plugins? (Y/n) ', true); 175 | if ($helper->ask($input, $output, $question)) { 176 | $dataTypes['plugins'] = array( 177 | 'class' => 'modPlugin', 178 | 'primary' => 'name', 179 | 'extension' => '.php' 180 | ); 181 | $dataTypes['plugin_events'] = array( 182 | 'class' => 'modPluginEvent', 183 | 'primary' => array('pluginid', 'event') 184 | ); 185 | $dataTypes['events'] = array( 186 | 'class' => 'modEvent', 187 | 'primary' => 'name' 188 | ); 189 | } 190 | 191 | $question = new ConfirmationQuestion('Would you like to include Namespaces, Extension Packages and System Settings? (Y/n) ', true); 192 | if ($helper->ask($input, $output, $question)) { 193 | $dataTypes['namespaces'] = array( 194 | 'class' => 'modNamespace', 195 | 'primary' => 'name' 196 | ); 197 | $dataTypes['system_settings'] = array( 198 | 'class' => 'modSystemSetting', 199 | 'primary' => 'key', 200 | 'exclude_keys' => array('editedon') 201 | ); 202 | $dataTypes['extension_packages'] = array( 203 | 'class' => 'modExtensionPackage', 204 | 'primary' => 'namespace', 205 | 'exclude_keys' => array('created_at', 'updated_at') 206 | ); 207 | } 208 | 209 | $question = new ConfirmationQuestion('Would you like to include Form Customization? (Y/n) ', true); 210 | if ($helper->ask($input, $output, $question)) { 211 | $dataTypes['fc_sets'] = array( 212 | 'class' => 'modFormCustomizationSet', 213 | 'primary' => 'id' 214 | ); 215 | $dataTypes['fc_profiles'] = array( 216 | 'class' => 'modFormCustomizationProfile', 217 | 'primary' => 'id' 218 | ); 219 | $dataTypes['fc_profile_usergroups'] = array( 220 | 'class' => 'modFormCustomizationProfileUserGroup', 221 | 'primary' => array('usergroup', 'profile') 222 | ); 223 | $dataTypes['fc_action_dom'] = array( 224 | 'class' => 'modActionDom', 225 | 'primary' => array('set', 'name') 226 | ); 227 | } 228 | 229 | $question = new ConfirmationQuestion('Would you like to include Media Sources? (Y/N) ', true); 230 | if ($helper->ask($input, $output, $question)) { 231 | $dataTypes['mediasources'] = array( 232 | 'class' => 'modMediaSource', 233 | 'primary' => 'id' 234 | ); 235 | $dataTypes['mediasource_elements'] = array( 236 | 'class' => 'sources.modMediaSourceElement', 237 | 'primary' => array('source', 'object_class', 'object', 'context_key'), 238 | ); 239 | } 240 | 241 | $question = new ConfirmationQuestion('Would you like to include Dashboards? (Y/n) ', true); 242 | if ($helper->ask($input, $output, $question)) { 243 | $dataTypes['dashboards'] = array( 244 | 'class' => 'modDashboard', 245 | 'primary' => array('id', 'name') 246 | ); 247 | $dataTypes['dashboard_widgets'] = array( 248 | 'class' => 'modDashboardWidget', 249 | 'primary' => 'id' 250 | ); 251 | $dataTypes['dashboard_widget_placement'] = array( 252 | 'class' => 'modDashboardWidgetPlacement', 253 | 'primary' => array('dashboard', 'widget') 254 | ); 255 | } 256 | 257 | $data['data'] = $dataTypes; 258 | 259 | if (file_exists(GITIFY_WORKING_DIR . 'config.core.php')) { 260 | $question = new ConfirmationQuestion('Would you like to include a list of Currently Installed Packages? (Y/n) ', true); 261 | if ($helper->ask($input, $output, $question)) { 262 | $modx = false; 263 | try { 264 | $modx = Gitify::loadMODX(); 265 | } catch (\RuntimeException $e) { 266 | $output->writeln('Could not get a list of packages because MODX could not be loaded: ' . $e->getMessage() . ''); 267 | } 268 | 269 | if ($modx) { 270 | $providers = array(); 271 | 272 | foreach ($modx->getIterator('transport.modTransportProvider') as $provider) { 273 | /** @var \modTransportProvider $provider */ 274 | $name = $provider->get('name'); 275 | $providers[$name] = array( 276 | 'service_url' => $provider->get('service_url') 277 | ); 278 | if ($provider->get('description')) { 279 | $providers[$name]['description'] = $provider->get('description'); 280 | } 281 | if ($provider->get('username')) { 282 | $providers[$name]['username'] = $provider->get('username'); 283 | } 284 | if ($provider->get('api_key')) { 285 | $key = $provider->get('api_key'); 286 | file_put_contents(GITIFY_WORKING_DIR . '.' . $name . '.key', $key); 287 | $providers[$name]['api_key'] = '.' . $name . '.key'; 288 | } 289 | 290 | $c = $modx->newQuery('transport.modTransportPackage'); 291 | $c->where(array('provider' => $provider->get('id'))); 292 | $c->groupby('package_name'); 293 | foreach ($modx->getIterator('transport.modTransportPackage', $c) as $package) { 294 | $packageName = $package->get('signature'); 295 | $providers[$name]['packages'][] = $packageName; 296 | } 297 | } 298 | 299 | $data['packages'] = $providers; 300 | } 301 | } 302 | } 303 | 304 | /** 305 | * Turn the configuration into YAML, and write the file. 306 | */ 307 | $config = Gitify::toYAML($data); 308 | file_put_contents(GITIFY_WORKING_DIR . '.gitify', $config); 309 | $output->writeln('Gitify Project initiated and .gitify file written.'); 310 | 311 | /** 312 | * Check if we already have MODX installed, and if not, offer to install it right away. 313 | */ 314 | if (!file_exists(GITIFY_WORKING_DIR . 'config.core.php')) { 315 | 316 | $question = new ConfirmationQuestion('No MODX installation found in the current directory. Would you like to install the latest stable version? (y/N) ', false); 317 | if ($helper->ask($input, $output, $question)) { 318 | 319 | $command = $this->getApplication()->find('modx:install'); 320 | $arguments = array( 321 | 'command' => 'modx:install' 322 | ); 323 | $input = new ArrayInput($arguments); 324 | return $command->run($input, $output); 325 | } 326 | 327 | } 328 | 329 | return 0; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/Command/InstallModxCommand.php: -------------------------------------------------------------------------------- 1 | setName('modx:install') 30 | ->setDescription('Downloads, configures and installs a fresh MODX installation.') 31 | ->addArgument( 32 | 'version', 33 | InputArgument::OPTIONAL, 34 | 'The version of MODX to install, in the format 2.8.3-pl. Leave empty or specify "latest" to install the last stable release.', 35 | 'latest' 36 | ) 37 | ->addOption( 38 | 'config', 39 | 'c', 40 | InputArgument::OPTIONAL, 41 | 'Path to XML configuration file. If specified, Gitify won\'t ask for configuration details through the command line.' 42 | ) 43 | ->addOption( 44 | 'download', 45 | 'd', 46 | InputOption::VALUE_NONE, 47 | 'Force download the MODX package even if it already exists in the cache folder.' 48 | ); 49 | } 50 | 51 | /** 52 | * Runs the command. 53 | * 54 | * @param InputInterface $input 55 | * @param OutputInterface $output 56 | * @return int 57 | */ 58 | protected function execute(InputInterface $input, OutputInterface $output): int 59 | { 60 | $version = $this->input->getArgument('version'); 61 | $configFile = $this->input->getOption('config'); 62 | $forced = $this->input->getOption('download'); 63 | 64 | if (!$this->getMODX($version, $forced)) { 65 | return 1; // exit 66 | } 67 | 68 | // Variables for running the setup 69 | $tz = date_default_timezone_get(); 70 | $wd = GITIFY_WORKING_DIR; 71 | $configXmlFile = $wd . 'config.xml'; 72 | $providedConfig = []; 73 | 74 | // Create the XML config and config array 75 | if ($configFile && !file_exists($configFile)) { 76 | $output->writeln("Unable to load specified config file."); 77 | return 1; 78 | } 79 | 80 | // Load XML config from file 81 | if ($configFile && file_exists($configFile)) { 82 | $configXmlFile = $configFile; 83 | $xml = simplexml_load_file($configXmlFile, "SimpleXMLElement", LIBXML_NOCDATA); 84 | $json = json_encode($xml); 85 | $providedConfig = json_decode($json, true); 86 | unset($providedConfig['comment']); 87 | } 88 | 89 | $config = $this->createMODXConfig($providedConfig); 90 | 91 | $output->writeln("Running MODX Setup..."); 92 | 93 | // Move core to alternative location if specified 94 | $corePathParameter = '--core_path=' . $config['core_path_full']; 95 | if ($config['core_path_full'] !== $wd . 'core/') { 96 | if (!file_exists($config['core_path'])) { 97 | mkdir($config['core_path'], 0777, true); 98 | } 99 | $corePathParameter = '--core_path=' . $config['core_path'] . $config['core_name'] . '/'; 100 | if (!rename($wd . 'core', $config['core_path'] . $config['core_name'])) { 101 | $output->writeln("Moving core folder wasn't possible"); 102 | return 0; 103 | } 104 | } 105 | 106 | // Only the manager directory name can be changed on install. It can't be moved. 107 | if ($config['context_mgr_path'] !== $wd . 'manager/') { 108 | if (!rename($wd . 'manager', $config['context_mgr_path'])) { 109 | $output->writeln("Renaming manager folder wasn't possible"); 110 | return 0; 111 | } 112 | } 113 | 114 | // Actually run the CLI setup 115 | exec("php -d date.timezone={$tz} {$wd}setup/index.php --installmode=new --config={$configXmlFile} {$corePathParameter}", $setupOutput); 116 | $output->writeln("{$setupOutput[0]}"); 117 | 118 | // Try to clean up the config file 119 | if (!$configFile && !unlink($configXmlFile)) { 120 | $output->writeln("Warning:: could not clean up the setup config file, please remove this manually."); 121 | } 122 | 123 | $output->writeln('Done! ' . $this->getRunStats()); 124 | return 0; 125 | } 126 | 127 | /** 128 | * Asks the user to complete a bunch of details and creates a MODX CLI config xml file 129 | * @param array $providedConfig 130 | * @return array 131 | */ 132 | protected function createMODXConfig(array $providedConfig): array 133 | { 134 | $directory = GITIFY_WORKING_DIR; 135 | 136 | // Creating config xml to install MODX with 137 | $this->output->writeln("Please complete following details to install MODX. Leave empty to use the [default]."); 138 | 139 | $helper = $this->getHelper('question'); 140 | 141 | $dbHost = $providedConfig['database_server'] ?? ''; 142 | if (!$dbHost) { 143 | $defaultDbHost = 'localhost'; 144 | $question = new Question("Database Host [{$defaultDbHost}]: ", $defaultDbHost); 145 | $dbHost = $helper->ask($this->input, $this->output, $question); 146 | } 147 | 148 | $dbName = $providedConfig['database'] ?? ''; 149 | if (!$dbName) { 150 | $defaultDbName = basename(GITIFY_WORKING_DIR); 151 | $question = new Question("Database Name [{$defaultDbName}]: ", $defaultDbName); 152 | $dbName = $helper->ask($this->input, $this->output, $question); 153 | } 154 | 155 | $dbUser = $providedConfig['database_user'] ?? ''; 156 | if (!$dbUser) { 157 | $question = new Question('Database User [root]: ', 'root'); 158 | $dbUser = $helper->ask($this->input, $this->output, $question); 159 | } 160 | 161 | $dbPass = $providedConfig['database_password'] ?? ''; 162 | if (!$dbPass) { 163 | $question = new Question('Database Password: '); 164 | $question->setHidden(true); 165 | $dbPass = $helper->ask($this->input, $this->output, $question); 166 | } 167 | 168 | $dbConnectionCharset = $providedConfig['database_connection_charset'] ?? ''; 169 | if (!$dbConnectionCharset) { 170 | $question = new Question('Database Connection Charset [utf8mb4]: ', 'utf8mb4'); 171 | $dbConnectionCharset = $helper->ask($this->input, $this->output, $question); 172 | } 173 | 174 | $dbCharset = $providedConfig['database_charset'] ?? ''; 175 | if (!$dbCharset) { 176 | $question = new Question('Database Charset [utf8mb4]: ', 'utf8mb4'); 177 | $dbCharset = $helper->ask($this->input, $this->output, $question); 178 | } 179 | 180 | $dbCollation = $providedConfig['database_collation'] ?? ''; 181 | if (!$dbCollation) { 182 | $question = new Question('Database Collation [utf8mb4_general_ci]: ', 'utf8mb4_general_ci'); 183 | $dbCollation = $helper->ask($this->input, $this->output, $question); 184 | } 185 | 186 | $dbPrefix = $providedConfig['table_prefix'] ?? ''; 187 | if (!$dbPrefix) { 188 | $question = new Question('Database Prefix [modx_]: ', 'modx_'); 189 | $dbPrefix = $helper->ask($this->input, $this->output, $question); 190 | } 191 | 192 | $host = $providedConfig['http_host'] ?? ''; 193 | if (!$host) { 194 | $question = new Question('Hostname [' . gethostname() . ']: ', gethostname()); 195 | $host = $helper->ask($this->input, $this->output, $question); 196 | $host = rtrim(trim($host), '/'); 197 | } 198 | 199 | $baseUrl = $providedConfig['context_web_url'] ?? ''; 200 | if (!$baseUrl) { 201 | $defaultBaseUrl = '/'; 202 | $question = new Question('Base URL [' . $defaultBaseUrl . ']: ', $defaultBaseUrl); 203 | $baseUrl = $helper->ask($this->input, $this->output, $question); 204 | $baseUrl = '/' . trim(trim($baseUrl), '/') . '/'; 205 | $baseUrl = str_replace('//', '/', $baseUrl); 206 | } 207 | 208 | $language = $providedConfig['language'] ?? ''; 209 | if (!$language) { 210 | $question = new Question('Manager Language [en]: ', 'en'); 211 | $language = $helper->ask($this->input, $this->output, $question); 212 | } 213 | 214 | $managerUser = $providedConfig['cmsadmin'] ?? ''; 215 | if (!$managerUser) { 216 | $defaultMgrUser = basename(GITIFY_WORKING_DIR) . '_admin'; 217 | $question = new Question('Manager User [' . $defaultMgrUser . ']: ', $defaultMgrUser); 218 | $managerUser = $helper->ask($this->input, $this->output, $question); 219 | } 220 | 221 | $managerPass = $providedConfig['cmspassword'] ?? ''; 222 | if (!$managerPass) { 223 | $question = new Question('Manager User Password [generated]: ', 'generate'); 224 | $question->setHidden(true); 225 | $question->setValidator(function ($value) { 226 | if (empty($value) || strlen($value) < 8) { 227 | throw new \RuntimeException( 228 | 'Please specify a password of at least 8 characters to continue.' 229 | ); 230 | } 231 | 232 | return $value; 233 | }); 234 | $managerPass = $helper->ask($this->input, $this->output, $question); 235 | } 236 | 237 | if ($managerPass == 'generate') { 238 | $managerPass = substr(str_shuffle(md5(microtime(true))), 0, rand(8, 15)); 239 | $this->output->writeln("Generated Manager Password: {$managerPass}"); 240 | } 241 | 242 | $managerEmail = $providedConfig['cmsadminemail'] ?? ''; 243 | if (!$managerEmail) { 244 | $question = new Question('Manager Email: '); 245 | $managerEmail = $helper->ask($this->input, $this->output, $question); 246 | } 247 | 248 | $corePath = $providedConfig['core_path'] ?? ''; 249 | $defaultCorePath = 'core/'; 250 | if (!$corePath) { 251 | $question = new Question('Core Path [' . $defaultCorePath . ']: ', $defaultCorePath); 252 | $corePath = $helper->ask($this->input, $this->output, $question); 253 | } 254 | $corePathData = $this->buildPath($corePath, $directory, $defaultCorePath); 255 | 256 | $managerPath = $providedConfig['context_mgr_url'] ?? ''; 257 | $defaultManagerPath = 'manager/'; 258 | if (!$managerPath) { 259 | $question = new Question('Manager Directory [' . $defaultManagerPath . ']: ', $defaultManagerPath); 260 | $managerPath = $helper->ask($this->input, $this->output, $question); 261 | } 262 | $managerPathData = $this->buildPath(trim($managerPath, '/'), $directory, $defaultManagerPath); 263 | $managerUrl = $baseUrl . trim($managerPathData['name'], '/') . '/'; 264 | 265 | $config = [ 266 | 'database_type' => 'mysql', 267 | 'database_server' => $dbHost, 268 | 'database' => $dbName, 269 | 'database_user' => $dbUser, 270 | 'database_password' => $dbPass, 271 | 'database_connection_charset' => $dbConnectionCharset, 272 | 'database_charset' => $dbCharset, 273 | 'database_collation' => $dbCollation, 274 | 'table_prefix' => $dbPrefix, 275 | 'https_port' => 443, 276 | 'http_host' => $host, 277 | 'cache_disabled' => 0, 278 | 'inplace' => 1, 279 | 'unpacked' => 0, 280 | 'language' => $language, 281 | 'cmsadmin' => $managerUser, 282 | 'cmspassword' => $managerPass, 283 | 'cmsadminemail' => $managerEmail, 284 | 'core_name' => $corePathData['name'], 285 | 'core_path' => $corePathData['path'], 286 | 'core_path_full' => $corePathData['full_path'], 287 | 'context_mgr_path' => $managerPathData['full_path'], 288 | 'context_mgr_url' => $managerUrl, 289 | 'context_connectors_path' => $directory . 'connectors/', 290 | 'context_connectors_url' => $baseUrl . 'connectors/', 291 | 'context_web_path' => $directory, 292 | 'context_web_url' => $baseUrl, 293 | 'remove_setup_directory' => true 294 | ]; 295 | 296 | $xml = new \DOMDocument('1.0', 'utf-8'); 297 | $modx = $xml->createElement('modx'); 298 | 299 | foreach ($config as $key => $value) { 300 | $modx->appendChild($xml->createElement($key, htmlentities($value, ENT_QUOTES|ENT_XML1))); 301 | } 302 | 303 | $xml->appendChild($modx); 304 | 305 | $fh = fopen($directory . 'config.xml', "w+"); 306 | fwrite($fh, $xml->saveXML()); 307 | fclose($fh); 308 | 309 | return $config; 310 | } 311 | 312 | /** 313 | * @param $path 314 | * @param $directory 315 | * @param $defaultPath 316 | * @return array 317 | */ 318 | protected function buildPath($path, $directory, $defaultPath): array 319 | { 320 | if (empty($path)) { 321 | $path = $directory . $defaultPath; 322 | } elseif (substr($path, 0, 1) == '/') { 323 | // absolute 324 | $path = '/' . trim($path, '/') . '/'; 325 | } else { 326 | // relative 327 | $path = $directory . trim($path, '/') . '/'; 328 | } 329 | 330 | // Remove directory name from path and return separately. 331 | $parts = explode('/', trim($path, '/')); 332 | $coreDirectoryName = array_pop($parts); 333 | 334 | return [ 335 | 'full_path' => $path, 336 | 'path' => '/' . implode('/', $parts) . '/', 337 | 'name' => $coreDirectoryName 338 | ]; 339 | } 340 | 341 | } 342 | -------------------------------------------------------------------------------- /src/Command/InstallPackageCommand.php: -------------------------------------------------------------------------------- 1 | setName('package:install') 32 | ->setDescription('Downloads and installs MODX packages.') 33 | ->addArgument( 34 | 'package_name', 35 | InputArgument::OPTIONAL, 36 | 'Name of package to search and install. By default the latest available version will be installed.' 37 | ) 38 | ->addOption( 39 | 'all', 40 | null, 41 | InputOption::VALUE_NONE, 42 | 'When specified, all packages defined in the .gitify config will be installed.' 43 | ) 44 | ->addOption( 45 | 'local', 46 | 'l', 47 | InputOption::VALUE_NONE, 48 | 'When specified, any packages inside the /core/packages folder will be installed.' 49 | ) 50 | ->addOption( 51 | 'interactive', 52 | 'i', 53 | InputOption::VALUE_NONE, 54 | 'When --all and --interactive are specified, all packages defined in the .gitify config will be installed interactively.' 55 | ); 56 | } 57 | 58 | /** 59 | * Runs the command. 60 | * 61 | * @param InputInterface $input 62 | * @param OutputInterface $output 63 | * @return int 64 | */ 65 | protected function execute(InputInterface $input, OutputInterface $output) 66 | { 67 | $this->modx->setLogTarget('ECHO'); 68 | $this->modx->setLogLevel(\modX::LOG_LEVEL_INFO); 69 | 70 | if ($input->getOption('all')) { 71 | // check list and run install for each 72 | $packages = isset($this->config['packages']) ? $this->config['packages'] : []; 73 | foreach ($packages as $provider_name => $provider_data) { 74 | // Try to load the provider from the database 75 | $provider = $this->modx->getObject('transport.modTransportProvider', ["name" => $provider_name]); 76 | 77 | // If no provider found, then we'll create it 78 | if (!$provider) { 79 | $credentials = [ 80 | 'username' => isset($provider_data['username']) ? $provider_data['username'] : '', 81 | 'api_key' => '' 82 | ]; 83 | 84 | // Try to look for a file with the API Key from a file within the gitify working directory 85 | if (!empty($provider_data['api_key']) && file_exists(GITIFY_WORKING_DIR . '/' . $provider_data['api_key'])) { 86 | $credentials['api_key'] = trim(file_get_contents(GITIFY_WORKING_DIR . '/' . $provider_data['api_key'])); 87 | } 88 | 89 | // load provider credentials from file 90 | if (!empty($provider_data['credential_file']) && file_exists(GITIFY_WORKING_DIR . '/' . $provider_data['credential_file'])) { 91 | $credentials_content = trim(file_get_contents(GITIFY_WORKING_DIR . '/' . $provider_data['credential_file'])); 92 | $credentials = Gitify::fromYAML($credentials_content); 93 | } 94 | 95 | /** @var \modTransportProvider $provider */ 96 | $provider = $this->modx->newObject('transport.modTransportProvider'); 97 | $provider->fromArray([ 98 | 'name' => $provider_name, 99 | 'service_url' => $provider_data['service_url'], 100 | 'description' => isset($provider_data['description']) ? $provider_data['description'] : '', 101 | 'username' => $credentials['username'], 102 | 'api_key' => $credentials['api_key'], 103 | ]); 104 | $provider->save(); 105 | } 106 | 107 | foreach ($provider_data['packages'] as $package) { 108 | if (!$input->getOption('interactive')) { 109 | $this->setInteractive(false); 110 | } 111 | $this->install($package, $provider); 112 | } 113 | } 114 | 115 | $this->output->writeln("Done!"); 116 | return 0; 117 | } 118 | 119 | // check for packages that were manually added to the core/packages folder 120 | if ($input->getOption('local')) { 121 | // most of this code is copied from: 122 | // core/model/modx/processors/workspace/packages/scanlocal.class.php 123 | $corePackagesDirectory = $this->modx->getOption('core_path').'packages/'; 124 | $corePackagesDirectoryObject = dir($corePackagesDirectory); 125 | 126 | while (false !== ($name = $corePackagesDirectoryObject->read())) { 127 | if (in_array($name,['.','..','.svn','.git','_notes'])) continue; 128 | $packageFilename = $corePackagesDirectory.'/'.$name; 129 | 130 | // dont add in unreadable files or directories 131 | if (!is_readable($packageFilename) || is_dir($packageFilename)) continue; 132 | 133 | // must be a .transport.zip file 134 | if (strlen($name) < 14 || substr($name,strlen($name)-14,strlen($name)) != '.transport.zip') continue; 135 | $packageSignature = substr($name,0,strlen($name)-14); 136 | 137 | // must have a name and version at least 138 | $p = explode('-',$packageSignature); 139 | if (count($p) < 2) continue; 140 | 141 | // install if package was not found in database 142 | if ($this->modx->getCount('transport.modTransportPackage', ['signature' => $packageSignature])) { 143 | $this->output->writeln("Package $packageSignature is already installed."); 144 | 145 | } else { 146 | $this->output->writeln("Installing $packageSignature..."); 147 | 148 | $package = $this->modx->newObject('transport.modTransportPackage'); 149 | $package->set('signature', $packageSignature); 150 | $package->set('state', 1); 151 | $package->set('created',strftime('%Y-%m-%d %H:%M:%S')); 152 | $package->set('workspace', 1); 153 | 154 | // set package version data 155 | $sig = explode('-',$packageSignature); 156 | if (is_array($sig)) { 157 | $package->set('package_name',$sig[0]); 158 | if (!empty($sig[1])) { 159 | $v = explode('.',$sig[1]); 160 | if (isset($v[0])) $package->set('version_major',$v[0]); 161 | if (isset($v[1])) $package->set('version_minor',$v[1]); 162 | if (isset($v[2])) $package->set('version_patch',$v[2]); 163 | } 164 | if (!empty($sig[2])) { 165 | $r = preg_split('/([0-9]+)/',$sig[2],-1,PREG_SPLIT_DELIM_CAPTURE); 166 | if (is_array($r) && !empty($r)) { 167 | $package->set('release',$r[0]); 168 | $package->set('release_index',(isset($r[1]) ? $r[1] : '0')); 169 | } else { 170 | $package->set('release',$sig[2]); 171 | } 172 | } 173 | } 174 | 175 | // Determine if there are any package dependencies 176 | $package->getTransport(); 177 | $package->getOne('Workspace'); 178 | $wc = isset($package->Workspace->config) && is_array($package->Workspace->config) ? $package->Workspace->config : []; 179 | $at = is_array($package->get('attributes')) ? $package->get('attributes') : []; 180 | $attributes = array_merge($wc, $at); 181 | $requires = isset($attributes['requires']) && is_array($attributes['requires']) 182 | ? $attributes['requires'] 183 | : []; 184 | $unsatisfied = $package->checkDependencies($requires); 185 | 186 | if (empty($unsatisfied)) { 187 | $package->save(); 188 | $package->install(); 189 | $this->output->writeln("Package $packageSignature successfully installed."); 190 | } 191 | // If dependencies exist, output an error message and list the packages needed. 192 | else { 193 | $this->output->writeln("\nUnable to install $packageSignature! There are currently unmet dependencies:"); 194 | foreach ($unsatisfied as $dependency => $v) { 195 | $this->output->writeln(" - $dependency"); 196 | } 197 | $this->output->writeln("\n$packageSignature has been added to the MODX package management grid, but is not yet installed.\n"); 198 | } 199 | } 200 | } 201 | 202 | return 0; 203 | } 204 | 205 | // install defined package 206 | $this->install($this->input->getArgument('package_name')); 207 | 208 | return 0; 209 | } 210 | 211 | /** 212 | * @param $package 213 | * @param int|\modTransportProvider $provider 214 | * @param array $installOptions 215 | * @return bool 216 | */ 217 | private function install($package, $provider = 0, array $installOptions = []) 218 | { 219 | if (!$this->isMODX3) { 220 | $this->modx->addPackage('modx.transport', MODX_CORE_PATH . 'model/'); 221 | } 222 | 223 | if (!($provider instanceof \modTransportProvider) && is_numeric($provider) && $provider > 0) 224 | { 225 | $provider = $this->modx->getObject('transport.modTransportProvider', $provider); 226 | } 227 | if (!($provider instanceof \modTransportProvider)) 228 | { 229 | $c = $this->modx->newQuery('transport.modTransportProvider'); 230 | $c->sortby('id', 'ASC'); 231 | $provider = $this->modx->getObject('transport.modTransportProvider', $c); 232 | } 233 | if (!($provider instanceof \modTransportProvider)) 234 | { 235 | $this->output->writeln("Cannot load Provider to install $package"); 236 | return false; 237 | } 238 | 239 | $verbosity = $this->input->getOption('all') ? OutputInterface::VERBOSITY_VERBOSE : OutputInterface::VERBOSITY_NORMAL; 240 | 241 | $installed = $this->modx->getObject('transport.modTransportPackage', [ 242 | 'signature' => $package, 243 | 'provider' => $provider->get('id'), 244 | ]); 245 | if ($installed) { 246 | $this->output->writeln("- $package is already installed, skipping", $verbosity); 247 | return true; 248 | } 249 | 250 | [$name, $version] = xPDOTransport::parseSignature($package); 251 | $c = $this->modx->newQuery('transport.modTransportPackage'); 252 | $c->where([ 253 | 'provider' => $provider->get('id'), 254 | 'signature:LIKE' => $name . '-%', 255 | ]); 256 | $c->sortby('installed', 'DESC'); // @todo is this sufficient to get highest installed version 257 | 258 | /** @var \modTransportPackage $lastVersion */ 259 | $lastVersion = $this->modx->getObject('transport.modTransportPackage', $c); 260 | if ($lastVersion) { 261 | $sig = xPDOTransport::parseSignature($lastVersion->get('signature')); 262 | $installedVersion = $sig[1]; 263 | 264 | if (version_compare($installedVersion, $version, '>=')) { 265 | $this->output->writeln("- $package, found higher version {$installedVersion} already installed", $verbosity); 266 | return true; 267 | } 268 | } 269 | 270 | // Download and install the package from the chosen provider 271 | $completed = $this->download($package, $provider, $installOptions); 272 | if (!$completed) { 273 | $this->output->writeln("Cannot install package $package."); 274 | 275 | return false; 276 | } 277 | 278 | return true; 279 | } 280 | 281 | 282 | /** 283 | * Download and install the package from the provider 284 | * 285 | * @param string $packageName 286 | * @param \modTransportProvider $provider 287 | * @param array $options 288 | * @return bool 289 | */ 290 | private function download($packageName, $provider, $options = []) { 291 | $this->modx->getVersionData(); 292 | $product_version = $this->modx->version['code_name'] . '-' . $this->modx->version['full_version']; 293 | 294 | $response = $provider->verify(); 295 | if ($response !== true) { 296 | $this->output->writeln("Could not download $packageName because the provider cannot be verified."); 297 | $error = $response; 298 | if (!empty($error) && is_string($error)) { 299 | $this->output->writeln("Message from Provider: $error"); 300 | } 301 | 302 | return false; 303 | } 304 | 305 | $helper = $this->getHelper('question'); 306 | $selectedFromMultiVersions = false; 307 | 308 | $provider->getClient(); 309 | $this->output->writeln("Searching {$provider->get('name')} for $packageName..."); 310 | 311 | // The droid we are looking for 312 | $packageName = strtolower($packageName); 313 | 314 | // Collect potential matches in array 315 | $packages = []; 316 | 317 | // First try to find an exact match via signature from the chosen package provider 318 | $response = $provider->request('package', 'GET', [ 319 | 'supports' => $product_version, 320 | 'signature' => $packageName, 321 | ]); 322 | 323 | // When we got a match (non 404), extract package information 324 | if ($this->isMODX3) { 325 | $error = $response->getStatusCode() !== 200; 326 | } 327 | else { 328 | $error = $response->isError(); 329 | } 330 | 331 | if (!$error) { 332 | $responseBody = $response instanceof Response ? $response->getBody()->getContents() : $response->response; 333 | $foundPkg = simplexml_load_string($responseBody); 334 | 335 | // Verify that signature matches (mismatches are known to occur!) 336 | if ($foundPkg->signature == $packageName) { 337 | $packages[strtolower((string) $foundPkg->name)] = [ 338 | 'name' => (string) $foundPkg->name, 339 | 'version' => (string) $foundPkg->version, 340 | 'location' => (string) $foundPkg->location, 341 | 'signature' => (string) $foundPkg->signature 342 | ]; 343 | } 344 | // Try again from a different angle 345 | else { 346 | $this->output->writeln("Returned signature {$foundPkg->signature} doesn't match the package name."); 347 | $this->output->writeln("Trying again from a different angle..."); 348 | 349 | // Query for name instead, without version number 350 | $name = explode('-', $packageName); 351 | $response = $provider->request('package', 'GET', [ 352 | 'supports' => $product_version, 353 | 'query' => $name[0], 354 | ]); 355 | 356 | if (!empty($response)) { 357 | $responseBody = $response instanceof Response ? $response->getBody()->getContents() : $response->response; 358 | $foundPackages = simplexml_load_string($responseBody); 359 | 360 | foreach ($foundPackages as $foundPkg) { 361 | // Only accept exact match on signature 362 | if ($foundPkg->signature == $packageName) { 363 | $packages[strtolower((string) $foundPkg->name)] = [ 364 | 'name' => (string) $foundPkg->name, 365 | 'version' => (string) $foundPkg->version, 366 | 'location' => (string) $foundPkg->location, 367 | 'signature' => (string) $foundPkg->signature 368 | ]; 369 | } 370 | } 371 | } 372 | } 373 | } 374 | 375 | // If no exact match, try it via query 376 | if (empty($packages)) { 377 | $response = $provider->request('package', 'GET', [ 378 | 'supports' => $product_version, 379 | 'query' => $packageName, 380 | ]); 381 | 382 | // Check for a proper response 383 | if (!empty($response)) { 384 | $responseBody = $response instanceof Response ? $response->getBody()->getContents() : $response->response; 385 | $foundPackages = simplexml_load_string($responseBody); 386 | 387 | // No matches, simply return 388 | if ($foundPackages['total'] == 0) { 389 | return true; 390 | } 391 | 392 | // Collect multiple versions of the same package in array 393 | $packageVersions = []; 394 | 395 | foreach ($foundPackages as $foundPkg) { 396 | $name = strtolower((string)$foundPkg->name); 397 | 398 | // Only accept exact match on name 399 | if ($name === $packageName) { 400 | $packages[$name] = array ( 401 | 'name' => (string) $foundPkg->name, 402 | 'version' => (string) $foundPkg->version, 403 | 'location' => (string) $foundPkg->location, 404 | 'signature' => (string) $foundPkg->signature 405 | ); 406 | $packageVersions[(string)$foundPkg->signature] = [ 407 | 'name' => (string) $foundPkg->name, 408 | 'version' => (string) $foundPkg->version, 409 | 'release' => (string) $foundPkg->release, 410 | 'location' => (string) $foundPkg->location, 411 | 'signature' => (string) $foundPkg->signature 412 | ]; 413 | } 414 | } 415 | 416 | if (count($packageVersions) > 1) { 417 | if($this->interactive) { 418 | // If in interactive mode and more than one package, let user select which version to install. 419 | $selectPackages = []; 420 | foreach ($packageVersions as $k => $version) { 421 | $selectPackages[] = $k; 422 | } 423 | $question = new ChoiceQuestion( 424 | 'Choose a package version to install...', 425 | $selectPackages, 426 | 0 427 | ); 428 | $question->setErrorMessage('Please select a valid option.'); 429 | $answer = $helper->ask($this->input, $this->output, $question); 430 | $this->output->writeln('Installing: ' . $answer); 431 | 432 | $packages[$packageName] = $packageVersions[$answer]; 433 | $selectedFromMultiVersions = true; 434 | } 435 | else { 436 | // If there are multiple versions of the same package and not interactive, use the latest 437 | $i = 0; 438 | $latest = ''; 439 | 440 | // Compare versions 441 | foreach (array_keys($packageVersions) as $version) { 442 | if ($i === 0) { 443 | // First iteration 444 | $latest = $version; 445 | } else { 446 | // Replace latest version with current one if it's higher 447 | if (version_compare($version, $latest, '>=')) { 448 | $latest = $version; 449 | } 450 | } 451 | $i++; 452 | } 453 | 454 | // Use latest 455 | $packages[$packageName] = $packageVersions[$latest]; 456 | } 457 | } 458 | 459 | // If there's still no match, revisit the response and just grab all hits... 460 | if (empty($packages)) { 461 | foreach ($foundPackages as $foundPkg) { 462 | $packages[strtolower((string)$foundPkg->name)] = [ 463 | 'name' => (string)$foundPkg->name, 464 | 'version' => (string)$foundPkg->version, 465 | 'location' => (string)$foundPkg->location, 466 | 'signature' => (string)$foundPkg->signature, 467 | ]; 468 | } 469 | } 470 | } 471 | } 472 | 473 | // Process found packages 474 | if (!empty($packages)) { 475 | 476 | if (!$selectedFromMultiVersions) { 477 | $this->output->writeln('Found ' . count($packages) . ' package(s).'); 478 | } 479 | 480 | foreach ($packages as $package) { 481 | if ($this->modx->getCount('transport.modTransportPackage', ['signature' => $package['signature']])) { 482 | $this->output->writeln("Package {$package['name']} {$package['version']} is already installed."); 483 | 484 | if ($this->interactive) { 485 | continue; 486 | } 487 | else { 488 | return true; 489 | } 490 | } 491 | 492 | if ($this->interactive && !$selectedFromMultiVersions) { 493 | $question = new ConfirmationQuestion( 494 | "Do you want to install {$package['name']} ({$package['version']})? [Y/n]: ", true); 495 | if (!$helper->ask($this->input, $this->output, $question)) { 496 | continue; 497 | } 498 | } 499 | 500 | // Run the core processor to download the package from the provider 501 | $this->output->writeln("Downloading {$package['name']} ({$package['version']})..."); 502 | $response = $this->modx->runProcessor('workspace/packages/rest/download', [ 503 | 'provider' => $provider->get('id'), 504 | 'info' => join('::', [$package['location'], $package['signature']]) 505 | ]); 506 | 507 | // If we have an error, show it and cancel. 508 | if ($response->isError()) { 509 | $this->output->writeln("Could not download package {$package['name']}. Reason: {$response->getMessage()}"); 510 | return false; 511 | } 512 | 513 | $this->output->writeln("Installing {$package['name']}..."); 514 | 515 | // Grab the package object 516 | $obj = $response->getObject(); 517 | /** @var \modTransportPackage $package */ 518 | if ($package = $this->modx->getObject('transport.modTransportPackage', ['signature' => $obj['signature']])) { 519 | // Install the package 520 | return $package->install($options); 521 | } 522 | } 523 | } 524 | 525 | return true; 526 | } 527 | 528 | /** 529 | * Sets the internal interactive flag 530 | * 531 | * @param $value 532 | */ 533 | public function setInteractive($value) 534 | { 535 | $this->interactive = $value; 536 | } 537 | } 538 | -------------------------------------------------------------------------------- /src/Command/RestoreCommand.php: -------------------------------------------------------------------------------- 1 | setName('restore') 31 | ->setDescription('Restores the MODX database from a database dump created by `gitify backup`') 32 | 33 | ->addArgument( 34 | 'file', 35 | InputArgument::OPTIONAL, 36 | 'The file name of the backup to restore; if left empty you will be provided a list of available backups. Specify "last" to use the last backup, based on the file modification time.' 37 | ) 38 | ; 39 | } 40 | 41 | /** 42 | * Runs the command. 43 | * 44 | * @param InputInterface $input 45 | * @param OutputInterface $output 46 | * @return int 47 | */ 48 | protected function execute(InputInterface $input, OutputInterface $output) 49 | { 50 | /** 51 | * @var $database_type 52 | * @var $database_server 53 | * @var $database_user 54 | * @var $database_password 55 | * @var $dbase 56 | * @var 57 | */ 58 | include MODX_CORE_PATH . 'config/' . MODX_CONFIG_KEY . '.inc.php'; 59 | 60 | if ($database_type !== 'mysql') { 61 | $output->writeln('Sorry, only MySQL is supported as database driver currently.'); 62 | return 1; 63 | } 64 | 65 | // Grab the directory the backups are in 66 | $backupDirectory = isset($this->config['backup_directory']) ? $this->config['backup_directory'] : '_backup/'; 67 | $targetDirectory = GITIFY_WORKING_DIR . $backupDirectory; 68 | 69 | // Make sure the directory exists 70 | if (!is_dir($targetDirectory) || !is_readable($targetDirectory)) { 71 | $output->writeln('Cannot read the {$backupDirectory} folder.'); 72 | return 1; 73 | } 74 | 75 | // Grab available backups 76 | $backups = array(); 77 | $directory = new \DirectoryIterator($targetDirectory); 78 | foreach ($directory as $path => $info) { 79 | /** @var \SplFileInfo $info */ 80 | $name = $info->getFilename(); 81 | // Ignore dotfiles/folders 82 | if (substr($name, 0, 1) === '.') { 83 | continue; 84 | } 85 | 86 | if ($info->isDir()) { 87 | continue; 88 | } 89 | 90 | $modified = $info->getMTime(); 91 | 92 | $backups[$name] = array( 93 | 'name' => $name, 94 | 'last_modified' => $modified 95 | ); 96 | } 97 | 98 | uasort($backups, function($a, $b) { 99 | if ($a['last_modified'] === $b['last_modified']) { 100 | return 0; 101 | } 102 | return ($a['last_modified'] < $b['last_modified']) ? 1 : -1; 103 | }); 104 | 105 | $file = false; 106 | $fileInput = $input->getArgument('file'); 107 | if (!empty($fileInput)) { 108 | if (array_key_exists($fileInput, $backups)) { 109 | $file = $fileInput; 110 | } 111 | elseif (array_key_exists($fileInput . '.sql', $backups)) { 112 | $file = $fileInput . '.sql'; 113 | } 114 | elseif (array_key_exists($fileInput . '.sql.gz', $backups)) { 115 | $file = $fileInput . '.sql.gz'; 116 | } 117 | elseif ($fileInput === 'last') { 118 | $file = reset(array_keys($backups)); 119 | } 120 | 121 | } 122 | 123 | if (!$file) { 124 | $helper = $this->getHelper('question'); 125 | $question = new ChoiceQuestion( 126 | 'Please choose the backup to restore (defaults to option 0): ', 127 | array_keys($backups), 128 | 0 129 | ); 130 | $question->setErrorMessage('There is no backup with the name %s.'); 131 | 132 | $file = $helper->ask($input, $output, $question); 133 | } 134 | 135 | $output->writeln('Restoring from backup ' . $file . '...'); 136 | 137 | $database_password = str_replace("'", '\'', $database_password); 138 | 139 | if (substr($file, -3) !== '.gz') { 140 | exec("mysql -u {$database_user} -p'{$database_password}' -h {$database_server} {$dbase} < \"{$targetDirectory}{$file}\" "); 141 | } 142 | else { 143 | exec("zcat \"{$targetDirectory}{$file}\" | mysql -u {$database_user} -p'{$database_password}' -h {$database_server} {$dbase}"); 144 | } 145 | 146 | return 0; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Command/UpgradeModxCommand.php: -------------------------------------------------------------------------------- 1 | setName('modx:upgrade') 28 | ->setDescription('Downloads, configures and updates the current MODX installation.') 29 | ->addArgument( 30 | 'version', 31 | InputArgument::OPTIONAL, 32 | 'The version of MODX to upgrade, in the format 2.3.2-pl. Leave empty or specify "latest" to install the last stable release.', 33 | 'latest' 34 | ) 35 | ->addOption( 36 | 'download', 37 | 'd', 38 | InputOption::VALUE_NONE, 39 | 'Force download the MODX package even if it already exists in the cache folder.' 40 | ); 41 | } 42 | 43 | protected function execute(InputInterface $input, OutputInterface $output) 44 | { 45 | $version = $this->input->getArgument('version'); 46 | $forced = $this->input->getOption('download'); 47 | 48 | if (!$this->getMODX($version, $forced)) { 49 | return 1; // exit 50 | } 51 | 52 | // Create the XML config 53 | $config = $this->createMODXConfig(); 54 | 55 | // Variables for running the setup 56 | $tz = date_default_timezone_get(); 57 | $wd = GITIFY_WORKING_DIR; 58 | $output->writeln("Running MODX Upgrade..."); 59 | 60 | // Actually run the CLI setup 61 | exec("php -d date.timezone={$tz} {$wd}setup/index.php --installmode=upgrade --config={$config}", $setupOutput); 62 | 63 | // Try to clean up the config file 64 | if (!unlink($config)) { 65 | $output->writeln("Warning:: could not clean up the setup config file, please remove this manually."); 66 | } 67 | 68 | // Remove setup directory if upgrade process left it there. 69 | if (is_dir("{$wd}setup/")) { 70 | exec("rm -rf {$wd}setup/"); 71 | } 72 | 73 | $output->writeln('Done! ' . $this->getRunStats()); 74 | 75 | return 0; 76 | } 77 | 78 | protected function createMODXConfig() 79 | { 80 | $directory = GITIFY_WORKING_DIR; 81 | 82 | $config = array( 83 | 'inplace' => 1, 84 | 'unpacked' => 0, 85 | 'language' => $this->modx->getOption('manager_language'), 86 | 'core_path' => $this->modx->getOption('core_path'), 87 | 'remove_setup_directory' => true 88 | ); 89 | 90 | $xml = new \DOMDocument('1.0', 'utf-8'); 91 | $modx = $xml->createElement('modx'); 92 | 93 | foreach ($config as $key => $value) { 94 | $modx->appendChild($xml->createElement($key, $value)); 95 | } 96 | 97 | $xml->appendChild($modx); 98 | 99 | $fh = fopen($directory . 'config.xml', "w+"); 100 | fwrite($fh, $xml->saveXML()); 101 | fclose($fh); 102 | 103 | return $directory . 'config.xml'; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Gitify.php: -------------------------------------------------------------------------------- 1 | initialize('mgr'); 81 | $modx->getService('error', 'error.modError', '', ''); 82 | $modx->setLogTarget('ECHO'); 83 | 84 | self::$modx = $modx; 85 | 86 | return $modx; 87 | } 88 | 89 | /** 90 | * @throws \RuntimeException 91 | */ 92 | public static function loadConfig($file = null) 93 | { 94 | if (null === $file) { 95 | $file = '.gitify'; 96 | } 97 | if (!file_exists(GITIFY_WORKING_DIR . $file)) { 98 | throw new \RuntimeException("Directory is not a Gitify directory: " . GITIFY_WORKING_DIR); 99 | } 100 | 101 | $config = Gitify::fromYAML(file_get_contents(GITIFY_WORKING_DIR . $file)); 102 | if (!$config || !is_array($config)) { 103 | throw new \RuntimeException("Error: " . GITIFY_WORKING_DIR . "{$file} file is not valid YAML, or is empty."); 104 | } 105 | 106 | return $config; 107 | } 108 | 109 | /** 110 | * Returns the current environment based on the HTTP HOST. 111 | * 112 | * @return array 113 | */ 114 | public function getEnvironment () 115 | { 116 | if (!empty($this->environment)) { 117 | return $this->environment; 118 | } 119 | 120 | $config = static::loadConfig(); 121 | 122 | $envs = array(); 123 | 124 | if (isset($config['environments']) && is_array($config['environments'])) { 125 | $envs = $config['environments']; 126 | } 127 | 128 | $defaults = array( 129 | 'name' => '-unidentified environment-', 130 | 'branch' => 'develop', 131 | 'auto_commit_and_push' => true, 132 | 'remote' => 'origin', 133 | 'partitions' => array( 134 | 'modResource' => 'content', 135 | 'modTemplate' => 'templates', 136 | 'modCategory' => 'categories', 137 | 'modTemplateVar' => 'template_variables', 138 | 'modChunk' => 'chunks', 139 | 'modSnippet' => 'snippets', 140 | 'modPlugin' => 'plugins' 141 | ) 142 | ); 143 | 144 | if (isset($envs['defaults']) && is_array($envs['defaults'])) { 145 | $defaults = array_merge($defaults, $envs['defaults']); 146 | } 147 | 148 | $host = (isset($_SERVER['HTTP_HOST'])) ? $_SERVER['HTTP_HOST'] : MODX_HTTP_HOST; 149 | if (substr($host, 0, 4) == 'www.') { 150 | $host = substr($host, 4); 151 | } 152 | 153 | $environment = (isset($envs[$host])) ? $envs[$host] : array(); 154 | $environment = array_merge($defaults, $environment); 155 | $this->environment = $environment; 156 | return $environment; 157 | } 158 | 159 | /** 160 | * Gets the default input definition. 161 | * 162 | * @return InputDefinition An InputDefinition instance 163 | */ 164 | protected function getDefaultInputDefinition() 165 | { 166 | return new InputDefinition(array( 167 | new InputArgument('command', InputArgument::REQUIRED, 'The command to execute'), 168 | 169 | new InputOption('--help', '-h', InputOption::VALUE_NONE, 'Display this help message.'), 170 | new InputOption('--verbose', '-v|vv|vvv', InputOption::VALUE_NONE, 'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug.'), 171 | new InputOption('--version', '-V', InputOption::VALUE_NONE, 'Display the Gitify version.'), 172 | new InputOption('--dotfile', null, InputOption::VALUE_REQUIRED, 'Gitify YAML file to use.', '.gitify'), 173 | )); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Mixins/DownloadModx.php: -------------------------------------------------------------------------------- 1 | output->writeln('Looking up latest MODX version...'); 22 | $link = $this->fetchUrl($version); 23 | $version = basename($link, '.zip'); 24 | $this->output->writeln('Latest version: ' . $version . ''); 25 | } 26 | 27 | // Force download the MODX package 28 | if ($download) { 29 | $this->output->writeln('Ignoring local cache, downloading MODX package...'); 30 | if (!$this->download($version)) { 31 | return false; 32 | } 33 | } 34 | 35 | // Copy the files from the local cache (and download version if necessary) 36 | $this->retrieveFromCache($version); 37 | 38 | return true; 39 | } 40 | 41 | /** 42 | * Downloads specified package to local storage 43 | * 44 | * @param $version 45 | * @return bool 46 | */ 47 | protected function download($version) 48 | { 49 | $link = $this->fetchUrl($version); 50 | 51 | if (!file_exists(GITIFY_CACHE_DIR)) { 52 | mkdir(GITIFY_CACHE_DIR); 53 | } 54 | 55 | $this->removeOutdatedArchive($version); // remove old files 56 | 57 | $zip = GITIFY_CACHE_DIR . $version . '.zip'; 58 | $this->output->writeln("Downloading {$version} from {$link}..."); 59 | exec("curl -Lo $zip $link -#"); 60 | 61 | if (!file_exists($zip)) { 62 | $this->output->writeln('Error: Could not download the MODX zip'); 63 | 64 | return false; 65 | } 66 | 67 | $this->unzip($zip); 68 | 69 | return true; 70 | } 71 | 72 | /** 73 | * Unzips the package zip to the current directory 74 | * 75 | * @param $package 76 | * @throws \Exception 77 | */ 78 | protected function unzip($package) 79 | { 80 | $this->output->writeln("Extracting package... "); 81 | 82 | $destination = dirname($package); 83 | $zipArchive = new \ZipArchive(); 84 | if ($zipArchive->open($package)) { 85 | $zipArchive->extractTo($destination); 86 | $zipArchive->close(); 87 | $this->output->writeln("Package extracted successfully."); 88 | } else { 89 | throw new \Exception("Error opening the package: $package"); 90 | } 91 | } 92 | 93 | /** 94 | * Fetches the real download link for a MODX package version 95 | * 96 | * @param $version 97 | * @return mixed 98 | */ 99 | protected function fetchUrl($version) 100 | { 101 | if ($this->output->isVerbose()) { 102 | $this->output->writeln('Fetching download URL for MODX ' . $version); 103 | } 104 | $version = str_replace('modx-', '', $version); 105 | $url = empty($version) || $version == 'latest' 106 | ? 'http://modx.com/download/latest/' 107 | : 'http://modx.com/download/direct/modx-' . $version . '.zip'; 108 | 109 | $ch = curl_init(); 110 | curl_setopt($ch, CURLOPT_URL, $url); 111 | curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); 112 | curl_setopt($ch, CURLOPT_TIMEOUT, 30); 113 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1); 114 | curl_setopt($ch, CURLOPT_NOBODY, 1); 115 | curl_exec($ch); 116 | $direct = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); 117 | curl_close($ch); 118 | 119 | if ($this->output->isVerbose()) { 120 | $this->output->writeln('Download URL found: ' . $direct); 121 | } 122 | return $direct; 123 | } 124 | 125 | /** 126 | * Copies the MODX files from the local cache into the current directory, or downloads the specified version 127 | * if it isn't cached yet. 128 | * 129 | * @param $version 130 | */ 131 | protected function retrieveFromCache($version) 132 | { 133 | $version = 'modx-' . str_replace('modx-', '', $version); 134 | 135 | $path = GITIFY_CACHE_DIR . $version; 136 | 137 | if (!file_exists($path) || !is_dir($path)) { 138 | $this->download($version); 139 | } 140 | 141 | // Handle potential custom paths on upgrade 142 | if ($this->isUpgrade) { 143 | require_once './config.core.php'; 144 | if (MODX_CORE_PATH) { 145 | require_once MODX_CORE_PATH . 'config/config.inc.php'; 146 | // Need to avoid the easier route with rsync as it may not be installed 147 | $paths = [ 148 | 'core' => MODX_CORE_PATH, 149 | 'connectors' => MODX_CONNECTORS_PATH, 150 | 'manager' => MODX_MANAGER_PATH 151 | ]; 152 | foreach ($paths as $k => $customPath) { 153 | // Throw out default config files 154 | if (file_exists("$path/$k/config.core.php")) { 155 | unlink("$path/$k/config.core.php"); 156 | } 157 | // Copy each dir to path specified in config file then remove that dir from source 158 | exec("cp -r $path/$k/* $customPath"); 159 | exec("rm -rf $path/$k"); 160 | } 161 | 162 | unlink("$path/config.core.php"); 163 | 164 | // Now copy remaining contents 165 | exec("cp -r $path/* ./"); 166 | 167 | // Hard wipe cache 168 | if (is_dir(MODX_CORE_PATH . 'cache/')) { 169 | exec('rm -rf ' . MODX_CORE_PATH . 'cache/*'); 170 | } 171 | 172 | // Re-extract package to source dir, so it's ready for another run. 173 | $this->unzip($path . '.zip'); 174 | } 175 | } 176 | else { 177 | exec("cp -r $path/* ./"); 178 | } 179 | } 180 | 181 | /** 182 | * Removes old cache folders 183 | * 184 | * @param $version 185 | */ 186 | protected function removeOutdatedArchive($version) 187 | { 188 | $folder = GITIFY_CACHE_DIR . $version; 189 | $package = $folder . '.zip'; 190 | 191 | exec("rm -rf $folder"); 192 | exec("rm -rf $package"); 193 | } 194 | } 195 | --------------------------------------------------------------------------------