├── .gitignore ├── Dockerfile.test ├── bin └── phploy ├── build ├── composer.json ├── composer.lock ├── contributing.md ├── dist └── phploy.phar ├── docker-compose.yml ├── phploy.bat ├── phploy.ini ├── phploy.php ├── phpunit.xml ├── readme.md ├── src ├── Cli.php ├── Config.php ├── Connection.php ├── Deployment.php ├── Git.php ├── PHPloy.php ├── Traits │ └── DebugTrait.php └── utils.php └── tests ├── Feature ├── ExampleTest.php ├── FtpDeploymentTest.php ├── MultipleServerDeploymentTest.php ├── RevisionFileTest.php └── SftpDeploymentTest.php ├── Pest.php ├── TestCase.php └── Unit └── ExampleTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | .DS_Store 4 | /.idea 5 | /.vscode 6 | /.settings 7 | /.clinerules 8 | /memory-bank 9 | /vendor 10 | /dev 11 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM php:8.2-cli 2 | 3 | # Install Git and other dependencies 4 | RUN apt-get update && apt-get install -y \ 5 | git \ 6 | zip \ 7 | unzip \ 8 | libssh2-1-dev \ 9 | && docker-php-ext-install ftp \ 10 | && pecl install ssh2-1.3.1 \ 11 | && docker-php-ext-enable ssh2 12 | 13 | # Configure Git 14 | RUN git config --global user.email "test@phploy.org" \ 15 | && git config --global user.name "PHPloy Test" 16 | 17 | WORKDIR /app 18 | 19 | # Install composer 20 | COPY --from=composer:latest /usr/bin/composer /usr/bin/composer 21 | 22 | # Copy composer files 23 | COPY composer.json composer.lock ./ 24 | 25 | # Install dependencies 26 | RUN composer install --no-interaction --no-plugins --no-scripts 27 | 28 | # Copy the rest of the application 29 | COPY . . 30 | -------------------------------------------------------------------------------- /bin/phploy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getMessage()}\r\n"; 17 | // Return 1 to indicate error to caller 18 | exit(1); 19 | } -------------------------------------------------------------------------------- /build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | startBuffering(); 25 | 26 | // Add all files from vendor and src 27 | $phar->buildFromDirectory(dirname(__FILE__), '/^(?!.*\/(\.git|tests|build|dist)).*$/'); 28 | 29 | 30 | // Adding main files 31 | $phar->addFile('phploy.php'); 32 | $phar->addFile('phploy.ini'); 33 | $phar->addFile('src/utils.php'); 34 | $phar->addFile('src/Cli.php'); 35 | $phar->addFile('src/Git.php'); 36 | $phar->addFile('src/Config.php'); 37 | $phar->addFile('src/PHPloy.php'); 38 | $phar->addFile('src/Connection.php'); 39 | $phar->addFile('src/Deployment.php'); 40 | $phar->addFile('src/Traits/DebugTrait.php'); 41 | // Autoloader 42 | $phar->addFile('vendor/autoload.php'); 43 | 44 | // Create a stub and add the shebang 45 | $default = $phar->createDefaultStub('phploy.php'); 46 | $stub = "#!/usr/bin/env php \n" . $default; 47 | $phar->setStub($stub); 48 | 49 | $phar->compressFiles(Phar::GZ); 50 | $phar->stopBuffering(); 51 | 52 | // Set file permissions 53 | chmod($output_file, 0755); 54 | 55 | echo 'Build was successful: dist/phploy.phar' . PHP_EOL; 56 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "banago/phploy", 3 | "version": "5.0.0-beta", 4 | "description": "PHPloy - Incremental Git (S)FTP deployment tool that supports submodules, multiple servers and rollbacks.", 5 | "license": "MIT", 6 | "keywords": ["deploy", "ftp", "sftp", "git"], 7 | "authors": [ 8 | { 9 | "name": "Baki Goxhaj", 10 | "email": "banago@gmail.com", 11 | "homepage": "http://wplancer.com", 12 | "role": "Developer" 13 | } 14 | ], 15 | "require-dev": { 16 | "pestphp/pest": "^2.0" 17 | }, 18 | "require": { 19 | "php": "^8.0", 20 | "league/climate": "^3.0", 21 | "league/flysystem": "^3.0", 22 | "league/flysystem-sftp-v3": "^3.0", 23 | "league/flysystem-ftp": "^3.0" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Banago\\PHPloy\\": "src" 28 | }, 29 | "files": [ 30 | "src/utils.php" 31 | ] 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Tests\\": "tests/" 36 | } 37 | }, 38 | "scripts": { 39 | "test": "docker compose up -d && docker compose exec phploy-test ./vendor/bin/pest && docker compose down", 40 | "test:up": "docker compose up -d", 41 | "test:down": "docker compose down", 42 | "test:exec": "docker compose exec phploy-test ./vendor/bin/pest", 43 | "lint": "phpcs --standard=PSR12 src/ tests/", 44 | "lint:fix": "phpcbf --standard=PSR12 src/ tests/", 45 | "analyze": "phpstan analyze src/ tests/ --level=max" 46 | }, 47 | "bin": ["bin/phploy"], 48 | "config": { 49 | "allow-plugins": { 50 | "pestphp/pest-plugin": true 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Before proposing a pull request, please check the following: 5 | 6 | * Your code should follow the [PSR-12 coding standard](https://www.php-fig.org/psr/psr-12/). Use `composer lint` to check for violations and `composer lint:fix` to automatically fix them. 7 | * If you commit a new feature, be prepared to help maintaining it. Watch the project on GitHub, and please comment on issues or PRs regarding the feature you contributed. 8 | * You should test your feature well. 9 | * You should run `php build` and check the PHAR file works before submitting your pull request. You may need to change `phar.readonly` php.ini setting to `0` or run the command as `php -d phar.readonly=0 build`. 10 | 11 | Once your code is merged, it is available for free to everybody under the MIT License. Publishing your pull request on the PHPloy GitHub repository means that you agree with this license for your contribution. 12 | 13 | Thank you for your contribution! PHPloy wouldn't be so great without you. 14 | 15 | ## Dependencies 16 | The project requires PHP 8.2 or higher. Dependencies are managed through Composer: 17 | ```bash 18 | composer install 19 | ``` 20 | 21 | ## Testing 22 | 23 | The project uses Pest PHP testing framework with Docker-based integration tests. The test environment includes: 24 | - FTP server for FTP protocol testing 25 | - SFTP server for SFTP protocol testing 26 | - PHP test container for running the tests 27 | 28 | ### Executing tests 29 | 30 | 1. Install [Docker Engine](https://docs.docker.com/engine/installation/) and Docker Compose 31 | 32 | 2. Run the tests using one of these methods: 33 | 34 | ```bash 35 | # Run full test suite (starts Docker, runs tests, stops Docker) 36 | composer test 37 | 38 | # Or run services separately for development: 39 | composer test:up # Start Docker containers 40 | composer test:exec # Run tests 41 | composer test:down # Stop Docker containers 42 | ``` 43 | 44 | ### Writing Tests 45 | 46 | Tests are written using Pest PHP, a delightful testing framework built on top of PHPUnit. Example test structure: 47 | 48 | ```php 49 | test('creates revision file on first deployment', function () { 50 | // 1. Setup test repository and config 51 | $testDir = '/tmp/phploy-test-' . uniqid(); 52 | mkdir($testDir); 53 | chdir($testDir); 54 | 55 | // 2. Run PHPloy 56 | $output = shell_exec('php /app/bin/phploy --fresh 2>&1'); 57 | 58 | // 3. Assert results 59 | expect($ftpConnection->has('.revision'))->toBeTrue(); 60 | 61 | // Cleanup 62 | shell_exec('rm -rf ' . $testDir); 63 | }); 64 | ``` 65 | 66 | ### Code Quality 67 | 68 | The project uses several tools to maintain code quality: 69 | 70 | ```bash 71 | # Check PSR-12 coding standards 72 | composer lint 73 | 74 | # Fix PSR-12 violations automatically 75 | composer lint:fix 76 | 77 | # Run static analysis 78 | composer analyze 79 | ``` 80 | -------------------------------------------------------------------------------- /dist/phploy.phar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banago/PHPloy/e43b92bd81b6a9c17a75f2f3b8c87afafd8f5ee9/dist/phploy.phar -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ftp-server: 3 | image: fauria/vsftpd 4 | environment: 5 | - FTP_USER=testuser 6 | - FTP_PASS=testpass 7 | ports: 8 | - "21:21" 9 | - "21100-21110:21100-21110" 10 | 11 | sftp-server: 12 | image: atmoz/sftp 13 | command: testuser:testpass:::upload 14 | ports: 15 | - "22:22" 16 | 17 | phploy-test: 18 | build: 19 | context: . 20 | dockerfile: Dockerfile.test 21 | volumes: 22 | - .:/app 23 | command: tail -f /dev/null 24 | depends_on: 25 | - ftp-server 26 | - sftp-server 27 | -------------------------------------------------------------------------------- /phploy.bat: -------------------------------------------------------------------------------- 1 | :: To run phploy globally (from any folder), either add this folder to your system's PATH 2 | :: or copy and edit this BAT file somewhere into your system's PATH, eg. C:\WINDOWS 3 | :: 4 | :: Note you will need PHP.exe somewhere on your system also, and if it's not also 5 | :: in your PATH variable, you will need to specify the full path to it below 6 | :: 7 | :: If you're not sure how to edit your system's path variable: 8 | :: - Press WIN+PAUSE to open the System Control Panel screen, 9 | :: - Choose "Advanced System Settings" 10 | :: - Click "Environment Variables" 11 | :: - Find "Path" in the bottom section, and add the necessary folder(s) to the list, 12 | :: separated by semi-colons 13 | :: eg. C:\Windows;C:\Windows\System32;C:\path\to\php.exe;C:\path\to\phploy 14 | 15 | @ECHO OFF 16 | 17 | :: Set the console code page to use UTF-8 18 | chcp 65001 > NUL 19 | 20 | :: error_reporting integer value of E_ALL & ~E_NOTICE 21 | for /f %%i in ('php -r "echo E_ALL & ~E_NOTICE;"') do set ER=%%i 22 | 23 | :: %~dp0 is the shell variable for the script directory, equivalent to the PHP "__DIR__" 24 | :: magic constant. You can replace it with your phploy installation directory if you 25 | :: moved this bat file from it. 26 | php -d error_reporting=%ER% "%~dp0\dist\phploy.phar" %* 27 | -------------------------------------------------------------------------------- /phploy.ini: -------------------------------------------------------------------------------- 1 | ; This is a sample phploy.ini file. You can specify as many 2 | ; servers as you need and use normal or quickmode configuration. 3 | ; 4 | ; NOTE: If a value in the .ini file contains any non-alphanumeric 5 | ; characters it needs to be enclosed in double-quotes ("). 6 | 7 | 8 | ; The special '*' configuration is shared between all other configurations (think include) 9 | [*] 10 | exclude[] = 'src/*' 11 | include[] = "dist/app.css" 12 | 13 | [staging] 14 | quickmode = ftp://example:password@production-example.com:21/path/to/installation 15 | 16 | [production] 17 | scheme = sftp 18 | host = staging-example.com 19 | path = /path/to/installation 20 | port = 22 21 | user = example 22 | pass = password 23 | purge-before[] = "dist/" 24 | purge[] = "cache/" 25 | pre-deploy[] = "wget -q -O - http://staging-example.com/pre-deploy/test.php" 26 | post-deploy[] = "wget -q -O - http://staging-example.com/post-deploy/test.php" 27 | pre-deploy-remote[] = "whoami" 28 | post-deploy-remote[] = "date" 29 | -------------------------------------------------------------------------------- /phploy.php: -------------------------------------------------------------------------------- 1 | getMessage()}\r\n"; 17 | // Return 1 to indicate error to caller 18 | exit(1); 19 | } 20 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | ./app 15 | ./src 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # PHPloy 2 | **Version 5.0.0** 3 | 4 | PHPloy is an incremental Git FTP and SFTP deployment tool. By keeping track of the state of the remote server(s) it deploys only the files that were committed since the last deployment. PHPloy supports submodules, sub-submodules, deploying to multiple servers and rollbacks. PHPloy requires **PHP 8.0+** (PHP 8.2+ for development/testing) and **Git 1.8+**. 5 | 6 | ## What's New in 5.0.0 7 | 8 | - **Improved File Structure**: Better organized codebase with clear separation of concerns 9 | - **Flysystem 3.0 Upgrade**: Enhanced file system abstraction with latest Flysystem version 10 | - **Modern Testing Suite**: 11 | - Replaced legacy Vagrant setup with Docker Compose 12 | - Migrated from PHPUnit to Pest PHP for more expressive tests 13 | - Added FTP and SFTP test servers for comprehensive integration testing 14 | - **Code Quality Tools**: 15 | - PSR-12 coding standards enforcement 16 | - Static analysis with PHPStan 17 | - Automated code style fixing 18 | 19 | ## How it works 20 | 21 | PHPloy stores a file called `.revision` on your server. This file contains the hash of the commit that you have deployed to that server. When you run phploy, it downloads that file and compares the commit reference in it with the commit you are trying to deploy to find out which files to upload. PHPloy also stores a `.revision` file for each submodule in your repository. 22 | 23 | ## Install 24 | 25 | ### Via Composer 26 | 27 | If you have composer installed in your machine, you can pull PHPloy globally like this: 28 | 29 | ```bash 30 | composer global require "banago/phploy" 31 | ``` 32 | 33 | Make sure to place the `$HOME/.composer/vendor/bin` directory (or the [equivalent directory](http://stackoverflow.com/a/40470979/512277) for your OS) 34 | in your `$PATH` so the PHPloy executable can be located by your system. 35 | 36 | ### Via Phar Archive 37 | 38 | You can install PHPloy via a phar archive: 39 | 40 | 1. **Download**: Get the latest `phploy.phar` from our releases page 41 | 2. **Install**: 42 | - **Globally:** Move to `/usr/local/bin/phploy` and make executable: 43 | ```bash 44 | sudo mv phploy.phar /usr/local/bin/phploy 45 | sudo chmod +x /usr/local/bin/phploy 46 | ``` 47 | - **Locally:** Move to your project directory and use as: 48 | ```bash 49 | php phploy.phar 50 | ``` 51 | 52 | ## Usage 53 | 54 | *When using PHPloy locally, proceed the command with `php `* 55 | 56 | 1. Run `phploy --init` in the terminal to create the `phploy.ini` file or create one manually. 57 | 2. Run `phploy` in terminal to deploy. 58 | 59 | Windows Users: [Installing PHPloy globally on Windows](https://github.com/banago/PHPloy/issues/214) 60 | 61 | ## phploy.ini 62 | 63 | The `phploy.ini` file holds your project configuration. It should be located in the root directory of the project. `phploy.ini` is never uploaded to server. Check the sample below for all available options: 64 | 65 | ```ini 66 | ; This is a sample deploy.ini file. You can specify as many 67 | ; servers as you need and use normal or quickmode configuration. 68 | ; 69 | ; NOTE: If a value in the .ini file contains any non-alphanumeric 70 | ; characters it needs to be enclosed in double-quotes ("). 71 | 72 | [staging] 73 | scheme = sftp 74 | user = example 75 | ; When connecting via SFTP, you can opt for password-based authentication: 76 | pass = password 77 | ; Or private key-based authentication: 78 | privkey = 'path/to/or/contents/of/privatekey' 79 | host = staging-example.com 80 | path = /path/to/installation 81 | port = 22 82 | ; You can specify a branch to deploy from 83 | branch = develop 84 | ; File permission set on the uploaded files/directories 85 | permissions = 0700 86 | ; File permissions set on newly created directories 87 | directoryPerm = 0775 88 | ; Deploy only this directory as base directory 89 | base = 'directory-name/' 90 | ; Files that should be ignored and not uploaded to your server, but still tracked in your repository 91 | exclude[] = 'src/*.scss' 92 | exclude[] = '*.ini' 93 | ; Files that are ignored by Git, but you want to send the the server 94 | include[] = 'js/scripts.min.js' 95 | include[] = 'directory-name/' 96 | ; conditional include - if source file has changed, include file 97 | include[] = 'css/style.min.css:src/style.css' 98 | ; Directories that should be copied after deploy, from->to 99 | copy[] = 'public->www' 100 | ; Directories that should be purged before deploy 101 | purge-before[] = "dist/" 102 | ; Directories that should be purged after deploy 103 | purge[] = "cache/" 104 | ; Pre- and Post-deploy hooks 105 | ; Use "DQOUTE" inside your double-quoted strings to insert a literal double quote 106 | ; Use 'QUOTE' inside your qouted strings to insert a literal quote 107 | ; For example pre-deploy[] = 'echo "that'QUOTE's nice"' to get a literal "that's". 108 | ; That workaround is based on http://php.net/manual/de/function.parse-ini-file.php#70847 109 | pre-deploy[] = "wget http://staging-example.com/pre-deploy/test.php --spider --quiet" 110 | post-deploy[] = "wget http://staging-example.com/post-deploy/test.php --spider --quiet" 111 | ; Works only via SSH2 connection 112 | pre-deploy-remote[] = "touch .maintenance" 113 | post-deploy-remote[] = "mv cache cache2" 114 | post-deploy-remote[] = "rm .maintenance" 115 | ; You can specify a timeout for the underlying connection which might be useful for long running remote 116 | ; operations (cache clear, dependency update, etc.) 117 | timeout = 60 118 | 119 | [production] 120 | quickmode = ftp://example:password@production-example.com:21/path/to/installation 121 | passive = true 122 | ssl = false 123 | ; You can specify a branch to deploy from 124 | branch = master 125 | ; File permission set on the uploaded files/directories 126 | permissions = 0774 127 | ; File permissions set on newly created directories 128 | directoryPerm = 0755 129 | ; Files that should be ignored and not uploaded to your server, but still tracked in your repository 130 | exclude[] = 'libs/*' 131 | exclude[] = 'config/*' 132 | exclude[] = 'src/*.scss' 133 | ; Files that are ignored by Git, but you want to send the the server 134 | include[] = 'js/scripts.min.js' 135 | include[] = 'js/style.min.css' 136 | include[] = 'directory-name/' 137 | purge-before[] = "dist/" 138 | purge[] = "cache/" 139 | pre-deploy[] = "wget http://staging-example.com/pre-deploy/test.php --spider --quiet" 140 | post-deploy[] = "wget http://staging-example.com/post-deploy/test.php --spider --quiet" 141 | ``` 142 | 143 | If your password is missing in the `phploy.ini` file or the `PHPLOY_PASS` environment variable, PHPloy will interactively ask you for your password. 144 | There is also an option to store the user and password in a file called `.phploy`. 145 | 146 | ``` 147 | [staging] 148 | user="theUser" 149 | pass="thePassword" 150 | 151 | [production] 152 | user="theUser" 153 | pass="thePassword" 154 | ``` 155 | 156 | This feature is especially useful if you would like to share your phploy.ini via Git but hide your password from the public. 157 | 158 | You can also use environment variables to deploy without storing your credentials in a file. 159 | These variables will be used if they do not exist in the `phploy.ini` file: 160 | ``` 161 | PHPLOY_HOST 162 | PHPLOY_PORT 163 | PHPLOY_PASS 164 | PHPLOY_PATH 165 | PHPLOY_USER 166 | PHPLOY_PRIVKEY 167 | ``` 168 | 169 | These variables can be used like this; 170 | ``` 171 | $ PHPLOY_PORT="21" PHPLOY_HOST="myftphost.com" PHPLOY_USER="ftp" PHPLOY_PASS="ftp-password" PHPLOY_PATH="/home/user/public_html/example.com" phploy -s servername 172 | ``` 173 | 174 | Or export them like this, the script will automatically use them: 175 | ``` 176 | $ export PHPLOY_PORT="21" 177 | $ export PHPLOY_HOST="myftphost.com" 178 | $ export PHPLOY_USER="ftp" 179 | $ export PHPLOY_PASS="ftp-password" 180 | $ export PHPLOY_PATH="/home/user/public_html/example.com" 181 | $ export PHPLOY_PRIVKEY="path/to/or/contents/of/privatekey" 182 | $ phploy -s servername 183 | ``` 184 | 185 | ## Multiple servers 186 | 187 | PHPloy allows you to configure multiple servers in the deploy file and deploy to any of them with ease. 188 | 189 | By default PHPloy will deploy to *ALL* specified servers. Alternatively, if an entry named 'default' exists in your server configuration, PHPloy will default to that server configuration. To specify one single server, run: 190 | 191 | phploy -s servername 192 | 193 | or: 194 | 195 | phploy --server servername 196 | 197 | `servername` stands for the name you have given to the server in the `phploy.ini` configuration file. 198 | 199 | If you have a 'default' server configured, you can specify to deploy to all configured servers by running: 200 | 201 | phploy --all 202 | 203 | ## Shared configuration (custom defaults) 204 | 205 | If you specify a server configuration named `*`, all options configured in this section will be shared with other 206 | servers. This basically allows you to inject custom default values. 207 | 208 | ```ini 209 | ; The special '*' configuration is shared between all other configurations (think include) 210 | [*] 211 | exclude[] = 'src/*' 212 | include[] = "dist/app.css" 213 | 214 | ; As a result both shard1 and shard2 will have the same exclude[] and include[] "default" values 215 | [shard1] 216 | quickmode = ftp://example:password@shard1-example.com:21/path/to/installation 217 | 218 | [shard2] 219 | quickmode = ftp://example:password@shard2-example.com:21/path/to/installation 220 | ``` 221 | 222 | ## Rollbacks 223 | 224 | **Warning: the --rollback option does not currently update your submodules correctly.** 225 | 226 | PHPloy allows you to roll back to an earlier version when you need to. Rolling back is very easy. 227 | 228 | To roll back to the previous commit, you just run: 229 | 230 | phploy --rollback 231 | 232 | To roll back to whatever commit you want, you run: 233 | 234 | phploy --rollback commit-hash-goes-here 235 | 236 | When you run a rollback, the files in your working copy will revert **temporarily** to the version of the rollback you are deploying. When the deployment has finished, everything will go back as it was. 237 | 238 | Note that there is not a short version of `--rollback`. 239 | 240 | 241 | ## Listing changed files 242 | 243 | PHPloy allows you to see what files are going to be uploaded/deleted before you actually push them. Just run: 244 | 245 | phploy -l 246 | 247 | Or: 248 | 249 | phploy --list 250 | 251 | ## Updating or "syncing" the remote revision 252 | 253 | If you want to update the `.revision` file on the server to match your current local revision, run: 254 | 255 | phploy --sync 256 | 257 | If you want to set it to a previous commit revision, just specify the revision like this: 258 | 259 | phploy --sync your-revision-hash-here 260 | 261 | ## Creating deployment directory on first deploy 262 | 263 | If the deployment directory does not exits, you can instruct PHPloy to create it for you: 264 | 265 | phploy --force 266 | 267 | ## Manual fresh upload 268 | 269 | If you want to do a fresh upload, even if you have deployed earlier, use the `--fresh` argument like this: 270 | 271 | phploy --fresh 272 | 273 | ## Submodules 274 | 275 | Submodules are supported, but are turned off by default since you don't expect them to change very often and you only update them once in a while. To run a deployment with submodule scanning, add the `--submodules` parameter to the command: 276 | 277 | phploy --submodules 278 | 279 | ## Purging 280 | 281 | In many cases, we need to purge the contents of a directory after a deployment. This can be achieved by specifying the directories in `phploy.ini` like this: 282 | 283 | ; relative to the deployment path 284 | purge[] = "cache/" 285 | 286 | To purge a directory before deployment, specify the directories in `phploy.ini` like this: 287 | 288 | ; relative to the deployment path 289 | purge-before[] = "dist/" 290 | 291 | ## Hooks 292 | 293 | PHPloy allows you to execute commands before and after the deployment. For example you can use `wget` call a script on my server to execute a `composer update`. 294 | 295 | ; To execute before deployment 296 | pre-deploy[] = "wget http://staging-example.com/pre-deploy/test.php --spider --quiet" 297 | ; To execute after deployment 298 | post-deploy[] = "wget http://staging-example.com/post-deploy/test.php --spider --quiet" 299 | 300 | ## Logging 301 | 302 | PHPloy supports simple logging of the activity. Logging is saved in a `phploy.log` file in your project in the following format: 303 | 304 | 2016-03-28 08:12:37+02:00 --- INFO: [SHA: 59a387c26641f731df6f0d1098aaa86cd55f4382] Deployment to server: "default" from branch "master". 2 files uploaded; 0 files deleted. 305 | 306 | To turn logging on, add this to `phploy.ini`: 307 | 308 | [production] 309 | logger = on 310 | 311 | ## Contribute 312 | 313 | CaContributions are very welcome; PHPloy is great because of the contributors. Please check out the [issues](https://github.com/banago/PHPloy/issues). 314 | 315 | ## Credits 316 | 317 | * [Baki Goxhaj](https://twitter.com/banago) 318 | * [Contributors](https://github.com/banago/PHPloy/graphs/contributors?type=a) 319 | 320 | ## Version history 321 | 322 | Please check [release history](https://github.com/banago/PHPloy/releases) for details. 323 | 324 | ## License 325 | 326 | PHPloy is licensed under the MIT License (MIT). 327 | -------------------------------------------------------------------------------- /src/Cli.php: -------------------------------------------------------------------------------- 1 | climate = new CLImate(); 23 | $this->registerArguments(); 24 | } 25 | 26 | /** 27 | * Register available CLI arguments. 28 | */ 29 | protected function registerArguments(): void 30 | { 31 | $this->climate->description('PHPloy - Incremental Git FTP/SFTP deployment tool that supports multiple servers, submodules and rollbacks.'); 32 | 33 | $this->climate->arguments->add([ 34 | 'list' => [ 35 | 'prefix' => 'l', 36 | 'longPrefix' => 'list', 37 | 'description' => 'Lists the files and directories to be uploaded or deleted', 38 | 'noValue' => true, 39 | ], 40 | 'server' => [ 41 | 'prefix' => 's', 42 | 'longPrefix' => 'server', 43 | 'description' => 'Deploy to the given server', 44 | ], 45 | 'rollback' => [ 46 | 'longPrefix' => 'rollback', 47 | 'description' => 'Rolls the deployment back to a given version', 48 | 'defaultValue' => 'HEAD^', 49 | ], 50 | 'sync' => [ 51 | 'longPrefix' => 'sync', 52 | 'description' => 'Syncs revision to a given version', 53 | 'defaultValue' => 'LAST', 54 | ], 55 | 'submodules' => [ 56 | 'prefix' => 'm', 57 | 'longPrefix' => 'submodules', 58 | 'description' => 'Includes submodules in next deployment', 59 | 'noValue' => true, 60 | ], 61 | 'init' => [ 62 | 'longPrefix' => 'init', 63 | 'description' => 'Creates sample deploy.ini file', 64 | 'noValue' => true, 65 | ], 66 | 'force' => [ 67 | 'longPrefix' => 'force', 68 | 'description' => 'Creates directory to the deployment path if it does not exist', 69 | 'noValue' => true, 70 | ], 71 | 'fresh' => [ 72 | 'longPrefix' => 'fresh', 73 | 'description' => 'Deploys all files even if some already exist on server. Ignores server revision.', 74 | 'noValue' => true, 75 | ], 76 | 'all' => [ 77 | 'longPrefix' => 'all', 78 | 'description' => 'Deploys to all specified servers when a default exists', 79 | 'noValue' => true, 80 | ], 81 | 'debug' => [ 82 | 'prefix' => 'd', 83 | 'longPrefix' => 'debug', 84 | 'description' => 'Shows verbose output for debugging', 85 | 'noValue' => true, 86 | ], 87 | 'version' => [ 88 | 'prefix' => 'v', 89 | 'longPrefix' => 'version', 90 | 'description' => 'Shows PHPloy version', 91 | 'noValue' => true, 92 | ], 93 | 'help' => [ 94 | 'prefix' => 'h', 95 | 'longPrefix' => 'help', 96 | 'description' => 'Lists commands and their usage', 97 | 'noValue' => true, 98 | ], 99 | 'dryrun' => [ 100 | 'longPrefix' => 'dryrun', 101 | 'description' => 'Stops after parsing arguments and do not alter the remote servers', 102 | 'noValue' => true, 103 | ], 104 | 'remote-list' => [ 105 | 'longPrefix' => 'remote-list', 106 | 'description' => 'Lists the contents of the remote deployment path to verify it exists', 107 | 'noValue' => true, 108 | ] 109 | ]); 110 | 111 | $this->climate->arguments->parse(); 112 | } 113 | 114 | /** 115 | * Show welcome message. 116 | */ 117 | public function showWelcome(): void 118 | { 119 | $this->climate->backgroundGreen()->bold()->out('-------------------------------------------------'); 120 | $this->climate->backgroundGreen()->bold()->out('| PHPloy |'); 121 | $this->climate->backgroundGreen()->bold()->out('-------------------------------------------------'); 122 | } 123 | 124 | /** 125 | * Show help message. 126 | */ 127 | public function showHelp(): void 128 | { 129 | $this->climate->usage(); 130 | } 131 | 132 | /** 133 | * Show version message. 134 | */ 135 | public function showVersion(string $version): void 136 | { 137 | $this->climate->bold()->info('PHPloy v' . $version); 138 | } 139 | 140 | /** 141 | * Show dry run message. 142 | */ 143 | public function showDryRunMessage(): void 144 | { 145 | $this->climate->bold()->yellow('DRY RUN, PHPloy will not check or alter the remote servers'); 146 | } 147 | 148 | /** 149 | * Show list mode message. 150 | */ 151 | public function showListModeMessage(): void 152 | { 153 | $this->climate->lightYellow('LIST mode: No remote files will be modified.'); 154 | } 155 | 156 | /** 157 | * Check if an argument is defined. 158 | */ 159 | public function hasArgument(string $name): bool 160 | { 161 | return $this->climate->arguments->defined($name); 162 | } 163 | 164 | /** 165 | * Get an argument value. 166 | * 167 | * @return mixed 168 | */ 169 | public function getArgument(string $name) 170 | { 171 | return $this->climate->arguments->get($name); 172 | } 173 | 174 | /** 175 | * Output an error message. 176 | */ 177 | public function error(string $message): void 178 | { 179 | $this->climate->bold()->error($message); 180 | } 181 | 182 | /** 183 | * Output a success message. 184 | */ 185 | public function success(string $message): void 186 | { 187 | $this->climate->bold()->green($message); 188 | } 189 | 190 | /** 191 | * Output an info message. 192 | */ 193 | public function info(string $message): void 194 | { 195 | $this->climate->info($message); 196 | } 197 | 198 | /** 199 | * Output a warning message. 200 | */ 201 | public function warning(string $message): void 202 | { 203 | $this->climate->bold()->yellow($message); 204 | } 205 | 206 | /** 207 | * Output a debug message. 208 | */ 209 | public function debug(string $message): void 210 | { 211 | $this->climate->comment($message); 212 | } 213 | 214 | /** 215 | * Log a deployment event 216 | */ 217 | public function logDeployment( 218 | string $sha, 219 | string $server, 220 | string $branch, 221 | int $filesUploaded, 222 | int $filesDeleted 223 | ): void { 224 | $message = sprintf( 225 | '[SHA: %s] Deployment to server: "%s" from branch "%s". %d files uploaded; %d files deleted.', 226 | $sha, 227 | $server, 228 | $branch, 229 | $filesUploaded, 230 | $filesDeleted 231 | ); 232 | 233 | $this->info($message); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | cli = $cli; 48 | $this->loadConfig(); 49 | } 50 | 51 | /** 52 | * Load configuration from phploy.ini 53 | */ 54 | protected function loadConfig(): void 55 | { 56 | $iniFile = getcwd() . DIRECTORY_SEPARATOR . $this->iniFile; 57 | 58 | if (!file_exists($iniFile)) { 59 | throw new \Exception("'{$this->iniFile}' does not exist."); 60 | } 61 | 62 | $config = parse_ini_file($iniFile, true); 63 | if (!$config) { 64 | throw new \Exception("'{$this->iniFile}' is not a valid .ini file."); 65 | } 66 | 67 | // Get shared config if exists 68 | $shared = $config['*'] ?? []; 69 | unset($config['*']); 70 | 71 | // Process each server 72 | foreach ($config as $name => $options) { 73 | if (! is_array($options)) { 74 | throw new \Exception("No options could be parsed. Please name your server on your '{$this->iniFile}'."); 75 | } 76 | $this->servers[$name] = $this->processServerConfig($name, $options, $shared); 77 | } 78 | } 79 | 80 | /** 81 | * Process server configuration 82 | */ 83 | protected function processServerConfig(string $name, array $options, array $shared): array 84 | { 85 | // Start with default values 86 | $config = [ 87 | 'scheme' => 'ftp', 88 | 'host' => '', 89 | 'user' => '', 90 | 'pass' => '', 91 | 'path' => '/', 92 | 'privkey' => '', 93 | 'port' => null, 94 | 'passive' => null, 95 | 'timeout' => null, 96 | 'ssl' => false, 97 | 'visibility' => 'public', 98 | 'permPublic' => 0774, 99 | 'permPrivate' => 0700, 100 | 'permissions' => null, 101 | 'directoryPerm' => 0755, 102 | 'branch' => '', 103 | 'include' => [], 104 | 'exclude' => array_merge($this->globalFilesToExclude, [$this->iniFile]), 105 | 'copy' => [], 106 | 'purge' => [], 107 | 'purge-before' => [], 108 | 'pre-deploy' => [], 109 | 'post-deploy' => [], 110 | 'pre-deploy-remote' => [], 111 | 'post-deploy-remote' => [], 112 | ]; 113 | 114 | // Merge shared config 115 | foreach ($shared as $key => $value) { 116 | if (isset($config[$key]) && is_array($config[$key])) { 117 | $config[$key] = array_merge($config[$key], (array)$value); 118 | } else { 119 | $config[$key] = $value; 120 | } 121 | } 122 | 123 | // Merge server specific config 124 | foreach ($options as $key => $value) { 125 | if (isset($config[$key]) && is_array($config[$key])) { 126 | $config[$key] = array_merge($config[$key], (array)$value); 127 | } else { 128 | $config[$key] = $value; 129 | } 130 | } 131 | 132 | // Check if the quickmode URL is correct. 133 | $parsed_url = parse_url($options['quickmode']); 134 | if ($parsed_url === false) { 135 | throw new \Exception('Your quickmode URL cannot be parsed. Please fix it.'); 136 | } 137 | 138 | // Merge parsed quickmode details 139 | if (isset($options['quickmode'])) { 140 | $config = array_merge($config, $parsed_url); 141 | } 142 | 143 | // Handle environment variables 144 | $this->processEnvironmentVariables($config); 145 | 146 | // Handle password file 147 | $this->processPasswordFile($name, $config); 148 | 149 | return $config; 150 | } 151 | 152 | /** 153 | * Process environment variables 154 | */ 155 | protected function processEnvironmentVariables(array &$config): void 156 | { 157 | $envVars = [ 158 | 'PHPLOY_HOST' => 'host', 159 | 'PHPLOY_PORT' => 'port', 160 | 'PHPLOY_USER' => 'user', 161 | 'PHPLOY_PASS' => 'pass', 162 | 'PHPLOY_PATH' => 'path', 163 | 'PHPLOY_PRIVKEY' => 'privkey', 164 | ]; 165 | 166 | foreach ($envVars as $env => $key) { 167 | $value = getenv($env); 168 | if ($value !== false && empty($config[$key])) { 169 | $config[$key] = $value; 170 | } 171 | } 172 | } 173 | 174 | /** 175 | * Process password file 176 | */ 177 | protected function processPasswordFile(string $name, array &$config): void 178 | { 179 | $passFile = getcwd() . DIRECTORY_SEPARATOR . $this->passFile; 180 | 181 | if (!file_exists($passFile)) { 182 | return; 183 | } 184 | 185 | $passConfig = parse_ini_file($passFile, true); 186 | if (!$passConfig || !isset($passConfig[$name])) { 187 | return; 188 | } 189 | 190 | if (empty($config['user']) && isset($passConfig[$name]['user'])) { 191 | $config['user'] = $passConfig[$name]['user']; 192 | } 193 | 194 | if (empty($config['pass']) && isset($passConfig[$name]['pass'])) { 195 | $config['pass'] = $passConfig[$name]['pass']; 196 | } 197 | 198 | if (empty($config['privkey']) && isset($passConfig[$name]['privkey'])) { 199 | $config['privkey'] = $passConfig[$name]['privkey']; 200 | } 201 | 202 | // Handle legacy 'password' key 203 | if (isset($passConfig[$name]['password'])) { 204 | throw new \Exception('Please rename password to pass in ' . $this->passFile); 205 | } 206 | } 207 | 208 | /** 209 | * Get all servers configuration 210 | */ 211 | public function getServers(): array 212 | { 213 | return $this->servers; 214 | } 215 | 216 | /** 217 | * Get specific server configuration 218 | */ 219 | public function getServer(string $name): ?array 220 | { 221 | return $this->servers[$name] ?? null; 222 | } 223 | 224 | /** 225 | * Create sample config file 226 | */ 227 | public function createSampleConfig(): void 228 | { 229 | $iniFile = getcwd() . DIRECTORY_SEPARATOR . $this->iniFile; 230 | 231 | if (file_exists($iniFile)) { 232 | $this->cli->info("\nphploy.ini file already exists.\n"); 233 | return; 234 | } 235 | 236 | $sample = <<cli->info("\nSample phploy.ini file created.\n"); 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | server = $this->connectToFtp($server); 47 | } elseif ($server['scheme'] === 'sftp') { 48 | $this->server = $this->connectToSftp($server); 49 | } else { 50 | throw new \Exception("Please provide a known connection protocol such as 'ftp' or 'sftp'."); 51 | } 52 | } 53 | 54 | private function getCommonOptions($server) 55 | { 56 | $options = [ 57 | 'host' => $server['host'], 58 | 'username' => $server['user'], 59 | 'password' => $server['pass'], 60 | 'root' => $server['path'], 61 | 'timeout' => ($server['timeout'] ?: 30), 62 | 'visibility' => $server['visibility'] ?? 'public', 63 | 'permPublic' => $server['permPublic'] ?? 0644, 64 | 'permPrivate' => $server['permPrivate'] ?? 0600, 65 | 'directoryPerm' => $server['directoryPerm'] ?? 0755, 66 | ]; 67 | 68 | return $options; 69 | } 70 | 71 | /** 72 | * Connects to the FTP Server. 73 | * 74 | * @param array $server 75 | * 76 | * @throws \Exception if it can't connect to FTP server 77 | * 78 | * @return Filesystem|null 79 | */ 80 | protected function connectToFtp($server) 81 | { 82 | try { 83 | $options = $this->getCommonOptions($server); 84 | 85 | $config = new FtpConnectionOptions( 86 | $options['host'], 87 | $options['root'], 88 | $options['username'], 89 | $options['password'], 90 | $server['port'] ?? 21, 91 | $server['ssl'] ?? false, 92 | $options['timeout'] ?? 90, 93 | false, //utf8 94 | $server['passive'] ?? true, 95 | FTP_BINARY, // transferMode 96 | null, // ignorePassiveAddress 97 | 30, // timestampsOnUnixListingsEnabled 98 | true // recurseManually 99 | ); 100 | 101 | $visibility = PortableVisibilityConverter::fromArray([ 102 | 'file' => [ 103 | 'public' => $options['permPublic'], 104 | 'private' => $options['permPrivate'], 105 | ], 106 | 'dir' => [ 107 | 'public' => $options['directoryPerm'], 108 | 'private' => $options['directoryPerm'], 109 | ], 110 | ]); 111 | 112 | return new Filesystem(new FtpAdapter($config, null, null, $visibility)); 113 | } catch (\Exception $e) { 114 | echo "\r\nOh Snap: {$e->getMessage()}\r\n"; 115 | throw $e; 116 | } 117 | } 118 | 119 | /** 120 | * Connects to the SFTP Server. 121 | * 122 | * @param array $server 123 | * 124 | * @throws \Exception if it can't connect to SFTP server 125 | * 126 | * @return Filesystem|null 127 | */ 128 | protected function connectToSftp($server) 129 | { 130 | try { 131 | $options = $this->getCommonOptions($server); 132 | 133 | if (!empty($server['privkey']) && '~' === $server['privkey'][0] && getenv('HOME') !== null) { 134 | $options['privkey'] = substr_replace($server['privkey'], getenv('HOME'), 0, 1); 135 | } 136 | 137 | if (!empty($options['privkey']) && !is_file($options['privkey']) && "---" !== substr($options['privkey'], 0, 3)) { 138 | throw new \Exception("Private key {$options['privkey']} doesn't exists."); 139 | } 140 | 141 | $connectionProvider = new SftpConnectionProvider( 142 | $options['host'], 143 | $options['username'], 144 | $options['password'], 145 | $options['privkey'] ?? null, // privkey 146 | $options['passphrase'] ?? null, // passphrase 147 | $options['port'] ?? 22, 148 | false, // use agent 149 | 30, // timeout 150 | 3, // max tries 151 | null, // host fingerprint 152 | null // connectivity checker 153 | ); 154 | 155 | $visibility = PortableVisibilityConverter::fromArray([ 156 | 'file' => [ 157 | 'public' => $options['permPublic'], 158 | 'private' => $options['permPrivate'], 159 | ], 160 | 'dir' => [ 161 | 'public' => $options['directoryPerm'], 162 | 'private' => $options['directoryPerm'], 163 | ], 164 | ]); 165 | 166 | return new Filesystem(new SftpAdapter($connectionProvider, $options['root'], $visibility)); 167 | } catch (\Exception $e) { 168 | echo "\r\nOh Snap: {$e->getMessage()}\r\n"; 169 | throw $e; 170 | } 171 | } 172 | 173 | /** 174 | * Check if a file exists 175 | * 176 | * @param string $path 177 | * @return bool 178 | */ 179 | public function has($path) 180 | { 181 | return $this->server->fileExists($path); 182 | } 183 | 184 | /** 185 | * Check if a directory exists 186 | * 187 | * @param string $path 188 | * @return bool 189 | */ 190 | public function directoryExists($path) 191 | { 192 | try { 193 | return $this->server->directoryExists($path); 194 | } catch (\Exception $e) { 195 | return false; 196 | } 197 | } 198 | 199 | /** 200 | * Read a file 201 | * 202 | * @param string $path 203 | * @return string 204 | */ 205 | public function read($path) 206 | { 207 | try { 208 | return $this->server->read($path); 209 | } catch (UnableToReadFile $e) { 210 | return ''; 211 | } 212 | } 213 | 214 | /** 215 | * Write a file 216 | * 217 | * @param string $path 218 | * @param string $contents 219 | * @return bool 220 | */ 221 | public function put($path, $contents) 222 | { 223 | try { 224 | $this->server->write($path, $contents); 225 | return true; 226 | } catch (UnableToWriteFile $e) { 227 | return false; 228 | } 229 | } 230 | 231 | /** 232 | * Delete a file 233 | * 234 | * @param string $path 235 | * @return bool 236 | */ 237 | public function delete($path) 238 | { 239 | try { 240 | $this->server->delete($path); 241 | return true; 242 | } catch (UnableToDeleteFile $e) { 243 | return false; 244 | } 245 | } 246 | 247 | /** 248 | * Delete a directory 249 | * 250 | * @param string $path 251 | * @return bool 252 | */ 253 | public function deleteDir($path) 254 | { 255 | try { 256 | $this->server->deleteDirectory($path); 257 | return true; 258 | } catch (UnableToDeleteDirectory $e) { 259 | return false; 260 | } 261 | } 262 | 263 | /** 264 | * Create a directory 265 | * 266 | * @param string $path 267 | * @return bool 268 | */ 269 | public function createDir($path) 270 | { 271 | try { 272 | $this->server->createDirectory($path); 273 | return true; 274 | } catch (UnableToCreateDirectory $e) { 275 | return false; 276 | } 277 | } 278 | 279 | /** 280 | * List directory contents 281 | * 282 | * @param string $path 283 | * @param bool $recursive 284 | * @return array 285 | */ 286 | public function listContents($path, $recursive = false) 287 | { 288 | $contents = []; 289 | $listing = $this->server->listContents($path, $recursive); 290 | 291 | foreach ($listing as $item) { 292 | $contents[] = [ 293 | 'path' => $item->path(), 294 | 'type' => $item->isFile() ? 'file' : 'dir', 295 | ]; 296 | } 297 | 298 | return $contents; 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/Deployment.php: -------------------------------------------------------------------------------- 1 | cli = $cli; 111 | $this->git = $git; 112 | $this->config = $config; 113 | 114 | $this->setDebug($cli->hasArgument('debug')); 115 | 116 | $this->repo = getcwd(); 117 | $this->mainRepo = $this->repo; 118 | 119 | $this->fresh = $cli->hasArgument('fresh'); 120 | $this->listFiles = $cli->hasArgument('list'); 121 | $this->remoteList = $cli->hasArgument('remote-list'); 122 | $this->scanSubmodules = $cli->hasArgument('submodules'); 123 | $this->sync = $cli->hasArgument('sync') ? $cli->getArgument('sync') : false; 124 | $this->revision = $cli->hasArgument('rollback') ? $cli->getArgument('rollback') : 'HEAD'; 125 | } 126 | 127 | /** 128 | * Run deployment process 129 | */ 130 | public function run(): void 131 | { 132 | $servers = $this->config->getServers(); 133 | $hasDefaultServer = isset($servers['default']); 134 | $deployAll = $this->cli->hasArgument('all') || ! $hasDefaultServer; 135 | 136 | // Get target server if specified 137 | $targetServer = $this->cli->getArgument('server'); 138 | if ($targetServer && ! isset($servers[ $targetServer ])) { 139 | throw new \Exception("The server \"{$targetServer}\" is not defined in phploy.ini."); 140 | } 141 | 142 | // Check for submodules 143 | $this->checkSubmodules(); 144 | 145 | // Deploy to each server 146 | foreach ($servers as $name => $server) { 147 | // Skip if: 148 | // 1. A specific server was requested and this isn't it, or 149 | // 2. No specific server was requested, --all wasn't specified, 150 | // a default server exists, and this isn't the default server 151 | if ( 152 | ( $targetServer && $targetServer !== $name ) || 153 | ( ! $targetServer && ! $deployAll && $hasDefaultServer && $name !== 'default' ) 154 | ) { 155 | continue; 156 | } 157 | 158 | $this->deployToServer($name, $server); 159 | } 160 | } 161 | 162 | /** 163 | * Deploy to a specific server 164 | */ 165 | protected function deployToServer(string $name, array $server): void 166 | { 167 | $this->currentServerName = $name; 168 | $this->currentServerInfo = $server; 169 | 170 | // Connect to server 171 | $this->connection = new Connection($server); 172 | 173 | // Check if remote path exists 174 | if (!$this->connection->directoryExists('')) { 175 | $this->cli->error("\r\nSERVER: " . $name); 176 | $this->cli->error("Remote path does not exist: " . $server['path']); 177 | $this->cli->info("Deployment skipped."); 178 | return; 179 | } 180 | 181 | // Handle sync mode 182 | if ($this->sync) { 183 | $this->setRevision(); 184 | return; 185 | } 186 | 187 | // Handle remote-list mode 188 | if ($this->remoteList) { 189 | $this->listRemotePath(); 190 | return; 191 | } 192 | 193 | // Get files to deploy 194 | $files = $this->compare(); 195 | 196 | $this->cli->info("\r\nSERVER: " . $name); 197 | 198 | if ($this->listFiles) { 199 | $this->listFiles($files); 200 | $this->handleSubmodules($files); 201 | } else { 202 | $this->push($files); 203 | $this->handleSubmodules($files); 204 | } 205 | 206 | // Show deployment size 207 | if (! $this->listFiles && $this->deploymentSize > 0) { 208 | $this->cli->success( 209 | sprintf( 210 | "\r\n|---------------[ %s Deployed ]---------------|", 211 | human_filesize($this->deploymentSize) 212 | ) 213 | ); 214 | $this->deploymentSize = 0; 215 | } 216 | } 217 | 218 | /** 219 | * Handle submodule deployment 220 | */ 221 | protected function handleSubmodules(array $files): void 222 | { 223 | if (! $this->scanSubmodules || empty($this->submodules)) { 224 | return; 225 | } 226 | 227 | foreach ($this->submodules as $submodule) { 228 | $this->repo = $submodule['path']; 229 | $this->currentSubmoduleName = $submodule['name']; 230 | 231 | $this->cli->info('SUBMODULE: ' . $this->currentSubmoduleName); 232 | $subFiles = $this->compare($submodule['revision']); 233 | 234 | if ($this->listFiles) { 235 | $this->listFiles($subFiles); 236 | } else { 237 | $this->push($subFiles, $submodule['revision']); 238 | } 239 | } 240 | 241 | // Reset after submodule deployment 242 | $this->repo = $this->mainRepo; 243 | $this->currentSubmoduleName = ''; 244 | } 245 | 246 | /** 247 | * Check for submodules 248 | */ 249 | protected function checkSubmodules(): void 250 | { 251 | if (! $this->scanSubmodules) { 252 | return; 253 | } 254 | 255 | $this->cli->info('Scanning repository...'); 256 | 257 | $output = $this->git->command('submodule status', $this->repo); 258 | $count = count($output); 259 | 260 | $this->cli->info("Found {$count} submodules."); 261 | 262 | if ($count > 0) { 263 | foreach ($output as $line) { 264 | $line = explode(' ', trim($line)); 265 | 266 | $this->submodules[] = array( 267 | 'revision' => $line[0], 268 | 'name' => $line[1], 269 | 'path' => $this->repo . '/' . $line[1], 270 | ); 271 | 272 | $this->cli->info( 273 | sprintf( 274 | 'Found submodule %s. %s', 275 | $line[1], 276 | $this->scanSubSubmodules ? "\nScanning for sub-submodules..." : '' 277 | ) 278 | ); 279 | 280 | if ($this->scanSubSubmodules) { 281 | $this->checkSubSubmodules($line[1]); 282 | } 283 | } 284 | } 285 | } 286 | 287 | /** 288 | * Check for sub-submodules 289 | */ 290 | protected function checkSubSubmodules(string $name): void 291 | { 292 | $output = $this->git->command('submodule foreach git submodule status', $this->repo); 293 | 294 | foreach ($output as $line) { 295 | if (strpos($line, 'Entering') === 0) { 296 | continue; 297 | } 298 | 299 | $line = explode(' ', trim($line)); 300 | 301 | $this->submodules[] = array( 302 | 'revision' => $line[0], 303 | 'name' => $name . '/' . $line[1], 304 | 'path' => $this->repo . '/' . $name . '/' . $line[1], 305 | ); 306 | 307 | $this->cli->info("Found sub-submodule {$name}/{$line[1]}"); 308 | } 309 | } 310 | 311 | /** 312 | * Compare revisions and get files to deploy 313 | */ 314 | protected function compare(string $localRevision = null): array 315 | { 316 | if ($localRevision === null) { 317 | $localRevision = $this->revision; 318 | } 319 | 320 | $remoteRevision = null; 321 | $dotRevision = $this->currentSubmoduleName 322 | ? $this->currentSubmoduleName . '/.revision' 323 | : '.revision'; 324 | 325 | // Get remote revision 326 | if (! $this->fresh && $this->connection->has($dotRevision)) { 327 | $remoteRevision = $this->connection->read($dotRevision); 328 | $this->debug("Remote revision: {$remoteRevision}"); 329 | } else { 330 | $this->cli->info('No revision found. Fresh upload...'); 331 | } 332 | 333 | // Handle branch checkout 334 | if (! empty($this->currentServerInfo['branch'])) { 335 | $this->checkoutBranch($this->currentServerInfo['branch']); 336 | } 337 | 338 | // Get changed files 339 | $output = $this->git->diff($remoteRevision, $localRevision, $this->repo); 340 | $this->debug(implode("\r\n", $output)); 341 | 342 | return $this->processGitDiff($output, $remoteRevision); 343 | } 344 | 345 | /** 346 | * Process git diff output 347 | */ 348 | protected function processGitDiff(array $output, ?string $remoteRevision): array 349 | { 350 | $filesToUpload = array(); 351 | $filesToDelete = array(); 352 | 353 | if (empty($remoteRevision)) { 354 | $filesToUpload = $output; 355 | } else { 356 | foreach ($output as $line) { 357 | $status = $line[0]; 358 | 359 | if ( 360 | strpos($line, 'warning: CRLF') !== false || 361 | strpos($line, 'original line endings') !== false 362 | ) { 363 | continue; 364 | } 365 | 366 | switch ($status) { 367 | case 'A': 368 | case 'C': 369 | case 'M': 370 | case 'T': 371 | $filesToUpload[] = trim(substr($line, 1)); 372 | break; 373 | 374 | case 'D': 375 | $filesToDelete[] = trim(substr($line, 1)); 376 | break; 377 | 378 | case 'R': 379 | list(, $oldFile, $newFile) = preg_split('/\s+/', $line); 380 | $filesToDelete[] = trim($oldFile); 381 | $filesToUpload[] = trim($newFile); 382 | break; 383 | 384 | default: 385 | throw new \Exception( 386 | "Unknown git-diff status. Use '--sync' to update remote revision or use '--debug' to see what's wrong." 387 | ); 388 | } 389 | } 390 | } 391 | 392 | return array( 393 | 'upload' => $filesToUpload, 394 | 'delete' => $filesToDelete, 395 | ); 396 | } 397 | 398 | /** 399 | * Push files to server 400 | */ 401 | protected function push(array $files, string $localRevision = null): void 402 | { 403 | if ($localRevision === null) { 404 | $localRevision = $this->git->revision; 405 | } 406 | 407 | $initialBranch = $this->git->branch; 408 | 409 | // Handle rollback 410 | if ($this->revision !== 'HEAD') { 411 | $this->cli->info('Rolling back working copy'); 412 | $this->git->command('checkout ' . $this->revision, $this->repo); 413 | } 414 | 415 | // Upload files 416 | foreach ($files['upload'] as $i => $file) { 417 | $this->uploadFile($file, $i + 1, count($files['upload'])); 418 | } 419 | 420 | // Delete files 421 | foreach ($files['delete'] as $i => $file) { 422 | $this->deleteFile($file, $i + 1, count($files['delete'])); 423 | } 424 | 425 | // Update revision 426 | if (! empty($files['upload']) || ! empty($files['delete'])) { 427 | if ($this->revision !== 'HEAD') { 428 | $revision = $this->git->command('rev-parse HEAD')[0]; 429 | $this->setRevision($revision); 430 | } else { 431 | $this->setRevision($localRevision); 432 | } 433 | 434 | // Log deployment 435 | $this->cli->logDeployment( 436 | $localRevision, 437 | $this->currentServerName, 438 | $initialBranch ?: 'master', 439 | count($files['upload']), 440 | count($files['delete']) 441 | ); 442 | } else { 443 | $this->cli->info('No files to upload or delete.'); 444 | } 445 | 446 | // Restore branch after rollback 447 | if ($this->revision !== 'HEAD') { 448 | $this->git->command('checkout ' . ( $initialBranch ?: 'master' )); 449 | } 450 | } 451 | 452 | /** 453 | * Upload a file 454 | */ 455 | protected function uploadFile(string $file, int $number, int $total): void 456 | { 457 | if ($this->currentSubmoduleName) { 458 | $file = $this->currentSubmoduleName . '/' . $file; 459 | } 460 | 461 | $filePath = $this->repo . '/' . ( $this->currentSubmoduleName 462 | ? str_replace($this->currentSubmoduleName . '/', '', $file) 463 | : $file 464 | ); 465 | 466 | $data = @file_get_contents($filePath); 467 | if ($data === false) { 468 | $this->cli->error("File not found - please check path: {$filePath}"); 469 | return; 470 | } 471 | 472 | try { 473 | $this->connection->put($file, $data); 474 | $this->deploymentSize += filesize($filePath); 475 | 476 | $fileNo = str_pad((string) $number, strlen((string) $total), ' ', STR_PAD_LEFT); 477 | $this->cli->success(" ^ {$fileNo} of {$total} {$file}"); 478 | } catch (\Exception $e) { 479 | $this->cli->error("Failed to upload {$file}: " . $e->getMessage()); 480 | 481 | if (! $this->connection) { 482 | $this->cli->info('Connection lost, trying to reconnect...'); 483 | $this->connection = new Connection($this->currentServerInfo); 484 | 485 | try { 486 | $this->connection->put($file, $data); 487 | $this->deploymentSize += filesize($filePath); 488 | 489 | $fileNo = str_pad((string) $number, strlen((string) $total), ' ', STR_PAD_LEFT); 490 | $this->cli->success(" ^ {$fileNo} of {$total} {$file}"); 491 | } catch (\Exception $e) { 492 | $this->cli->error("Failed to upload {$file} after reconnect: " . $e->getMessage()); 493 | } 494 | } 495 | } 496 | } 497 | 498 | /** 499 | * Delete a file 500 | */ 501 | protected function deleteFile(string $file, int $number, int $total): void 502 | { 503 | if ($this->currentSubmoduleName) { 504 | $file = $this->currentSubmoduleName . '/' . $file; 505 | } 506 | 507 | $fileNo = str_pad((string) $number, strlen((string) $total), ' ', STR_PAD_LEFT); 508 | 509 | try { 510 | if ($this->connection->has($file)) { 511 | $this->connection->delete($file); 512 | $this->cli->info(" × {$fileNo} of {$total} {$file}"); 513 | } else { 514 | $this->cli->warning(" ! {$fileNo} of {$total} {$file} not found"); 515 | } 516 | } catch (\Exception $e) { 517 | $this->cli->error("Failed to delete {$file}: " . $e->getMessage()); 518 | } 519 | } 520 | 521 | /** 522 | * Set revision on server 523 | */ 524 | protected function setRevision(?string $localRevision = null): void 525 | { 526 | if ($localRevision === null) { 527 | $localRevision = $this->git->revision; 528 | } 529 | 530 | if ($this->sync && $this->sync !== 'LAST') { 531 | $localRevision = $this->sync; 532 | } 533 | 534 | $dotRevision = $this->currentSubmoduleName 535 | ? $this->currentSubmoduleName . '/.revision' 536 | : '.revision'; 537 | 538 | if ($this->sync) { 539 | $this->cli->info("Setting remote revision to: {$localRevision}"); 540 | } 541 | 542 | $this->connection->put($dotRevision, $localRevision); 543 | } 544 | 545 | /** 546 | * Checkout branch 547 | */ 548 | protected function checkoutBranch(string $branch): void 549 | { 550 | $output = $this->git->checkout($branch, $this->repo); 551 | 552 | if (! empty($output[0])) { 553 | if (strpos($output[0], 'error') === 0) { 554 | throw new \Exception('Stash your modifications before deploying.'); 555 | } 556 | $this->cli->info($output[0]); 557 | } 558 | 559 | if (! empty($output[1]) && $output[1][0] === 'M') { 560 | throw new \Exception('Stash your modifications before deploying.'); 561 | } 562 | } 563 | 564 | /** 565 | * List remote path contents 566 | */ 567 | protected function listRemotePath(): void 568 | { 569 | try { 570 | $this->cli->info("\r\nChecking remote path: " . $this->currentServerInfo['path']); 571 | 572 | // Check if the directory exists 573 | if (!$this->connection->directoryExists('')) { 574 | $this->cli->error("Remote path does not exist: " . $this->currentServerInfo['path']); 575 | return; 576 | } 577 | 578 | // Try to list contents of the root directory 579 | $contents = $this->connection->listContents('', false); 580 | 581 | if (empty($contents)) { 582 | $this->cli->warning("Remote path exists but is empty."); 583 | } else { 584 | $this->cli->success("Remote path exists and contains " . count($contents) . " items:"); 585 | foreach ($contents as $item) { 586 | $this->cli->info(" " . $item['type'] . ": " . $item['path']); 587 | } 588 | } 589 | } catch (\Exception $e) { 590 | $this->cli->error("Remote path does not exist or is not accessible: " . $e->getMessage()); 591 | } 592 | } 593 | 594 | /** 595 | * List files to be deployed 596 | */ 597 | protected function listFiles(array $files): void 598 | { 599 | if (empty($files['upload']) && empty($files['delete'])) { 600 | $this->cli->info('No files to upload.'); 601 | return; 602 | } 603 | 604 | if (! empty($files['delete'])) { 605 | $this->cli->warning('Files that will be deleted in next deployment:'); 606 | foreach ($files['delete'] as $file) { 607 | $this->cli->info(" {$file}"); 608 | } 609 | } 610 | 611 | if (! empty($files['upload'])) { 612 | $this->cli->success('Files that will be uploaded in next deployment:'); 613 | foreach ($files['upload'] as $file) { 614 | $this->cli->info(" {$file}"); 615 | } 616 | } 617 | } 618 | } 619 | -------------------------------------------------------------------------------- /src/Git.php: -------------------------------------------------------------------------------- 1 | repo = $repo; 38 | try { 39 | $this->branch = $this->command('rev-parse --abbrev-ref HEAD')[0]; 40 | $this->revision = $this->command('rev-parse HEAD')[0]; 41 | } catch (\Exception $e) { 42 | throw new \Exception("Failed to initialize Git: " . $e->getMessage()); 43 | } 44 | } 45 | 46 | /** 47 | * Executes a console command and returns the output (as an array). 48 | * 49 | * @param string $command Command to execute 50 | * @param boolean $onErrorStopExecution If there is a problem, stop execution of the code 51 | * 52 | * @return array of all lines that were output to the console during the command (STDOUT) 53 | * 54 | * @throws \Exception 55 | */ 56 | public function exec($command, $onErrorStopExecution = false) 57 | { 58 | $output = null; 59 | 60 | exec('(' . $command . ') 2>&1', $output, $exitcode); 61 | 62 | if ($onErrorStopExecution && $exitcode !== 0) { 63 | throw new \Exception('Command [' . $command . '] failed with exit code ' . $exitcode); 64 | } 65 | 66 | return $output; 67 | } 68 | 69 | /** 70 | * Runs a git command and returns the output (as an array). 71 | * 72 | * @param string $command "git [your-command-here]" 73 | * @param string|null $repoPath Defaults to $this->repo 74 | * 75 | * @return array Lines of the output 76 | * @throws \Exception 77 | */ 78 | public function command($command, $repoPath = null) 79 | { 80 | if (!$repoPath) { 81 | $repoPath = $this->repo; 82 | } 83 | 84 | if (!is_dir($repoPath)) { 85 | throw new \Exception("Repository path does not exist: {$repoPath}"); 86 | } 87 | 88 | if (!is_dir($repoPath . '/.git')) { 89 | throw new \Exception("Not a git repository: {$repoPath}"); 90 | } 91 | 92 | // "-c core.quotepath=false" fixes special characters issue like ë, ä, ü etc., in file names 93 | $command = 'git -c core.quotepath=false --git-dir="' . $repoPath . '/.git" --work-tree="' . $repoPath . '" ' . $command; 94 | 95 | $output = $this->exec($command); 96 | $this->debug("Git command: {$command}\nOutput: " . implode("\n", $output)); 97 | return $output; 98 | } 99 | 100 | /** 101 | * Diff versions. 102 | * 103 | * @param string|null $remoteRevision 104 | * @param string $localRevision 105 | * @param string|null $repoPath 106 | * 107 | * @return array 108 | * @throws \Exception 109 | */ 110 | public function diff($remoteRevision, $localRevision, $repoPath = null) 111 | { 112 | if (empty($remoteRevision)) { 113 | $command = 'ls-files'; 114 | } else { 115 | $command = 'diff --name-status ' . $remoteRevision . ' ' . $localRevision; 116 | } 117 | 118 | return $this->command($command, $repoPath); 119 | } 120 | 121 | /** 122 | * Checkout given $branch. 123 | * 124 | * @param string $branch 125 | * @param string|null $repoPath 126 | * 127 | * @return array 128 | * @throws \Exception 129 | */ 130 | public function checkout($branch, $repoPath = null) 131 | { 132 | if (empty($branch)) { 133 | throw new \Exception("Branch name cannot be empty"); 134 | } 135 | 136 | $command = 'checkout ' . $branch; 137 | 138 | return $this->command($command, $repoPath); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/PHPloy.php: -------------------------------------------------------------------------------- 1 | 9 | * @link https://github.com/banago/PHPloy 10 | * @licence MIT Licence 11 | * @version 5.0.0-beta 12 | */ 13 | class PHPloy 14 | { 15 | /** 16 | * @var string 17 | */ 18 | protected $version = '5.0.0-beta'; 19 | 20 | /** 21 | * @var Cli 22 | */ 23 | protected $cli; 24 | 25 | /** 26 | * @var Config 27 | */ 28 | protected $config; 29 | 30 | /** 31 | * @var Git 32 | */ 33 | protected $git; 34 | 35 | /** 36 | * @var Deployment 37 | */ 38 | protected $deployment; 39 | 40 | /** 41 | * Constructor. 42 | * 43 | * @throws \Exception 44 | */ 45 | public function __construct() 46 | { 47 | // Initialize components 48 | $this->cli = new Cli(); 49 | 50 | // Show welcome message 51 | $this->cli->showWelcome(); 52 | 53 | // Handle early exit commands 54 | if ($this->handleEarlyExitCommands()) { 55 | return; 56 | } 57 | 58 | // Initialize configuration 59 | $this->config = new Config($this->cli); 60 | 61 | // Check if repository is valid 62 | if (!$this->isValidRepository()) { 63 | throw new \Exception("'" . getcwd() . "' is not a Git repository."); 64 | } 65 | 66 | // Initialize Git 67 | $this->git = new Git(getcwd()); 68 | 69 | // Initialize deployment 70 | $this->deployment = new Deployment( 71 | $this->cli, 72 | $this->git, 73 | $this->config 74 | ); 75 | 76 | // Run deployment 77 | $this->deploy(); 78 | } 79 | 80 | /** 81 | * Handle commands that exit early (help, version, init) 82 | */ 83 | protected function handleEarlyExitCommands(): bool 84 | { 85 | // Show help 86 | if ($this->cli->hasArgument('help')) { 87 | $this->cli->showHelp(); 88 | return true; 89 | } 90 | 91 | // Show version 92 | if ($this->cli->hasArgument('version')) { 93 | $this->cli->showVersion($this->version); 94 | return true; 95 | } 96 | 97 | // Create sample config 98 | if ($this->cli->hasArgument('init')) { 99 | $this->config->createSampleConfig(); 100 | return true; 101 | } 102 | 103 | // Handle dry run 104 | if ($this->cli->hasArgument('dryrun')) { 105 | $this->cli->showDryRunMessage(); 106 | return true; 107 | } 108 | 109 | return false; 110 | } 111 | 112 | /** 113 | * Check if current directory is a valid Git repository 114 | */ 115 | protected function isValidRepository(): bool 116 | { 117 | return file_exists(getcwd() . '/.git'); 118 | } 119 | 120 | /** 121 | * Run the deployment process 122 | */ 123 | protected function deploy(): void 124 | { 125 | // Show list mode message if needed 126 | if ($this->cli->hasArgument('list')) { 127 | $this->cli->showListModeMessage(); 128 | } 129 | 130 | // Run deployment 131 | $this->deployment->run(); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Traits/DebugTrait.php: -------------------------------------------------------------------------------- 1 | debug = $debug; 18 | } 19 | 20 | /** 21 | * Output debug message 22 | */ 23 | protected function debug(string $message): void 24 | { 25 | if ($this->debug && isset($this->cli)) { 26 | $this->cli->debug($message); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils.php: -------------------------------------------------------------------------------- 1 | 0) { 86 | fwrite(STDOUT, "\x08 \x08"); 87 | $pass = substr($pass, 0, -1); 88 | } 89 | } else { 90 | fwrite(STDOUT, '*'); 91 | $pass .= $char; 92 | } 93 | } 94 | 95 | shell_exec('stty ' . $original); 96 | 97 | return $pass; 98 | } 99 | 100 | /** 101 | * Return a human readable filesize. 102 | * 103 | * @param int $bytes 104 | * @param int $decimals 105 | * 106 | * @return string 107 | */ 108 | function human_filesize($bytes, $decimals = 2) 109 | { 110 | $sz = 'BKMGTP'; 111 | $factor = floor((strlen($bytes) - 1) / 3); 112 | 113 | return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @$sz[$factor]; 114 | } 115 | 116 | /** 117 | * Glob the file path. 118 | * 119 | * @param string $pattern 120 | * @param string $string 121 | * 122 | * @return string 123 | */ 124 | function pattern_match($pattern, $string) 125 | { 126 | return preg_match('#^' . strtr(preg_quote($pattern, '#'), ['\*' => '.*', '\?' => '.']) . '$#i', $string); 127 | } 128 | -------------------------------------------------------------------------------- /tests/Feature/ExampleTest.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/Feature/FtpDeploymentTest.php: -------------------------------------------------------------------------------- 1 | 'ftp', 8 | 'host' => 'ftp-server', 9 | 'user' => 'testuser', 10 | 'pass' => 'testpass', 11 | 'path' => '/', 12 | 'timeout' => 30 13 | ]; 14 | 15 | $connection = new Connection($server); 16 | expect($connection)->toBeObject(); 17 | }); 18 | 19 | test('deploys file to ftp server', function () { 20 | // 1. Setup test repository 21 | $testDir = '/tmp/phploy-test-' . uniqid(); 22 | mkdir($testDir); 23 | chdir($testDir); 24 | 25 | // Initialize git and create test file 26 | shell_exec('git init'); 27 | shell_exec('git config user.email "test@phploy.org"'); 28 | shell_exec('git config user.name "PHPloy Test"'); 29 | file_put_contents('test.txt', 'Hello PHPloy!'); 30 | shell_exec('git add test.txt'); 31 | shell_exec('git commit -m "Initial commit"'); 32 | 33 | // 2. Create and configure phploy.ini 34 | $phployConfig = <<&1'); 49 | echo "PHPloy output: " . $output . PHP_EOL; 50 | 51 | // Give it time to finish deployment 52 | sleep(2); 53 | 54 | // 4. Verify deployment 55 | $ftpServer = [ 56 | 'scheme' => 'ftp', 57 | 'host' => 'ftp-server', 58 | 'user' => 'testuser', 59 | 'pass' => 'testpass', 60 | 'path' => '/', 61 | 'timeout' => 30 62 | ]; 63 | $ftpConnection = new Connection($ftpServer); 64 | expect($ftpConnection->has('test.txt'))->toBeTrue(); 65 | 66 | // Cleanup 67 | shell_exec('rm -rf ' . $testDir); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/Feature/MultipleServerDeploymentTest.php: -------------------------------------------------------------------------------- 1 | &1'); 42 | echo "PHPloy output: " . $output . PHP_EOL; 43 | 44 | // Give it time to finish deployment 45 | sleep(2); 46 | 47 | // 4. Verify deployment - only default server should have the file 48 | $ftpServer = [ 49 | 'scheme' => 'ftp', 50 | 'host' => 'ftp-server', 51 | 'user' => 'testuser', 52 | 'pass' => 'testpass', 53 | 'path' => '/', 54 | 'timeout' => 30 55 | ]; 56 | $ftpConnection = new Connection($ftpServer); 57 | expect($ftpConnection->has('test.txt'))->toBeTrue(); 58 | 59 | $sftpServer = [ 60 | 'scheme' => 'sftp', 61 | 'host' => 'sftp-server', 62 | 'user' => 'testuser', 63 | 'pass' => 'testpass', 64 | 'path' => '/upload', 65 | 'timeout' => 30 66 | ]; 67 | $sftpConnection = new Connection($sftpServer); 68 | expect($sftpConnection->has('test.txt'))->toBeFalse(); 69 | 70 | // Cleanup 71 | shell_exec('rm -rf ' . $testDir); 72 | }); 73 | 74 | test('deploys to all servers when --all flag is used', function () { 75 | // 1. Setup test repository 76 | $testDir = '/tmp/phploy-test-' . uniqid(); 77 | mkdir($testDir); 78 | chdir($testDir); 79 | 80 | // Initialize git and create test file 81 | shell_exec('git init'); 82 | shell_exec('git config user.email "test@phploy.org"'); 83 | shell_exec('git config user.name "PHPloy Test"'); 84 | file_put_contents('test.txt', 'Hello PHPloy!'); 85 | shell_exec('git add test.txt'); 86 | shell_exec('git commit -m "Initial commit"'); 87 | 88 | // 2. Create and configure phploy.ini with default server 89 | $phployConfig = <<&1'); 111 | echo "PHPloy output: " . $output . PHP_EOL; 112 | 113 | // Give it time to finish deployment 114 | sleep(2); 115 | 116 | // 4. Verify deployment 117 | $ftpServer = [ 118 | 'scheme' => 'ftp', 119 | 'host' => 'ftp-server', 120 | 'user' => 'testuser', 121 | 'pass' => 'testpass', 122 | 'path' => '/', 123 | 'timeout' => 30 124 | ]; 125 | $ftpConnection = new Connection($ftpServer); 126 | expect($ftpConnection->has('test.txt'))->toBeTrue(); 127 | 128 | $sftpServer = [ 129 | 'scheme' => 'sftp', 130 | 'host' => 'sftp-server', 131 | 'user' => 'testuser', 132 | 'pass' => 'testpass', 133 | 'path' => '/upload', 134 | 'timeout' => 30 135 | ]; 136 | $sftpConnection = new Connection($sftpServer); 137 | expect($sftpConnection->has('test.txt'))->toBeTrue(); 138 | 139 | // Cleanup 140 | shell_exec('rm -rf ' . $testDir); 141 | }); 142 | -------------------------------------------------------------------------------- /tests/Feature/RevisionFileTest.php: -------------------------------------------------------------------------------- 1 | &1'); 36 | echo "PHPloy output: " . $output . PHP_EOL; 37 | 38 | // Give it time to finish deployment 39 | sleep(5); 40 | 41 | // 4. Verify .revision file exists and contains correct commit hash 42 | $ftpServer = [ 43 | 'scheme' => 'ftp', 44 | 'host' => 'ftp-server', 45 | 'user' => 'testuser', 46 | 'pass' => 'testpass', 47 | 'path' => '/', 48 | 'timeout' => 30 49 | ]; 50 | $ftpConnection = new Connection($ftpServer); 51 | expect($ftpConnection->has('.revision'))->toBeTrue(); 52 | 53 | $revisionContent = $ftpConnection->read('.revision'); 54 | expect($revisionContent)->toBe($commitHash); 55 | 56 | // Cleanup 57 | shell_exec('rm -rf ' . $testDir); 58 | }); 59 | 60 | test('updates revision file on subsequent deployments', function () { 61 | // 1. Setup test repository 62 | $testDir = '/tmp/phploy-test-' . uniqid(); 63 | mkdir($testDir); 64 | chdir($testDir); 65 | 66 | // Initialize git and create test file 67 | shell_exec('git init'); 68 | shell_exec('git config user.email "test@phploy.org"'); 69 | shell_exec('git config user.name "PHPloy Test"'); 70 | file_put_contents('test.txt', 'Hello PHPloy!'); 71 | shell_exec('git add test.txt'); 72 | shell_exec('git commit -m "Initial commit"'); 73 | $firstCommitHash = trim(shell_exec('git rev-parse HEAD')); 74 | 75 | // 2. Create and configure phploy.ini 76 | $phployConfig = <<&1'); 91 | echo "First deployment output: " . $output . PHP_EOL; 92 | sleep(2); 93 | 94 | // 4. Make second commit 95 | file_put_contents('test.txt', 'Updated content!'); 96 | shell_exec('git add test.txt'); 97 | shell_exec('git commit -m "Second commit"'); 98 | $secondCommitHash = trim(shell_exec('git rev-parse HEAD')); 99 | 100 | // 5. Second deployment 101 | $output = shell_exec('php /app/bin/phploy --debug 2>&1'); 102 | echo "Second deployment output: " . $output . PHP_EOL; 103 | sleep(5); 104 | 105 | // 6. Verify .revision file is updated 106 | $ftpServer = [ 107 | 'scheme' => 'ftp', 108 | 'host' => 'ftp-server', 109 | 'user' => 'testuser', 110 | 'pass' => 'testpass', 111 | 'path' => '/', 112 | 'timeout' => 30 113 | ]; 114 | $ftpConnection = new Connection($ftpServer); 115 | expect($ftpConnection->has('.revision'))->toBeTrue(); 116 | 117 | $revisionContent = $ftpConnection->read('.revision'); 118 | expect($revisionContent)->toBe($secondCommitHash); 119 | 120 | // Cleanup 121 | shell_exec('rm -rf ' . $testDir); 122 | }); 123 | -------------------------------------------------------------------------------- /tests/Feature/SftpDeploymentTest.php: -------------------------------------------------------------------------------- 1 | 'sftp', 8 | 'host' => 'sftp-server', 9 | 'user' => 'testuser', 10 | 'pass' => 'testpass', 11 | 'path' => '/', 12 | 'timeout' => 30 13 | ]; 14 | 15 | $connection = new Connection($server); 16 | expect($connection)->toBeObject(); 17 | }); 18 | 19 | test('deploys file to sftp server', function () { 20 | // 1. Setup test repository 21 | $testDir = '/tmp/phploy-test-' . uniqid(); 22 | mkdir($testDir); 23 | chdir($testDir); 24 | 25 | // Initialize git and create test file 26 | shell_exec('git init'); 27 | shell_exec('git config user.email "test@phploy.org"'); 28 | shell_exec('git config user.name "PHPloy Test"'); 29 | file_put_contents('test.txt', 'Hello PHPloy!'); 30 | shell_exec('git add test.txt'); 31 | shell_exec('git commit -m "Initial commit"'); 32 | 33 | // 2. Create and configure phploy.ini 34 | $phployConfig = <<&1'); 49 | echo "PHPloy output: " . $output . PHP_EOL; 50 | 51 | // Give it time to finish deployment 52 | sleep(2); 53 | 54 | // 4. Verify deployment 55 | $sftpServer = [ 56 | 'scheme' => 'sftp', 57 | 'host' => 'sftp-server', 58 | 'user' => 'testuser', 59 | 'pass' => 'testpass', 60 | 'path' => '/upload', 61 | 'timeout' => 30 62 | ]; 63 | $sftpConnection = new Connection($sftpServer); 64 | expect($sftpConnection->has('test.txt'))->toBeTrue(); 65 | 66 | // Cleanup 67 | shell_exec('rm -rf ' . $testDir); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('Feature'); 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Expectations 19 | |-------------------------------------------------------------------------- 20 | | 21 | | When you're writing tests, you often need to check that values meet certain conditions. The 22 | | "expect()" function gives you access to a set of "expectations" methods that you can use 23 | | to assert different things. Of course, you may extend the Expectation API at any time. 24 | | 25 | */ 26 | 27 | expect()->extend('toBeOne', function () { 28 | return $this->toBe(1); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 5 | }); 6 | --------------------------------------------------------------------------------