├── .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 |
--------------------------------------------------------------------------------