diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2cbb188 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md + +## Project Scope +- Repository contains a small PHP CLI utility for SFTP-based sync/delete operations. +- Main executable: `src/SFTPsync.php` +- Connection layer: `src/SFTPconnection.php` + +## Goals When Editing +- Preserve backward-compatible CLI behavior unless the user explicitly requests a breaking change. +- Keep the tool dependency-free (only PHP core + `ext-ssh2`). +- Prefer minimal, readable changes. + +## Important Behavior to Preserve +- Actions are repeatable and executed in the same order as provided on CLI. +- Exit codes: + - `0` success + - `1` runtime/SFTP error + - `2` argument/usage error +- Missing `--host`, `--user`, `--password` should still support interactive prompt mode. +- `--skip` and `--skip-delete` matching semantics should remain stable. +- `--delete-dir` safety guard against dangerous paths (`/`, empty path, dot paths) must remain intact. + +## Coding Conventions +- Target PHP 8+ compatibility. +- Keep strict types in `src/SFTPsync.php`. +- Avoid adding external libraries or framework structure. +- Use clear runtime exceptions for operational failures. + +## Validation Checklist +- Lint changed PHP files: + - `php -l src/SFTPsync.php` + - `php -l src/SFTPconnection.php` +- If CLI options are changed, update `README.md` in the same change. +- Ensure examples in `README.md` remain executable from repository root. + +## Documentation Rules +- Keep `README.md` in English. +- Document user-visible flags/behavior changes in README immediately. diff --git a/README.md b/README.md index e394959..41002c9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,110 @@ # SFTPsync -PHP utility for synchronize local and remote directories over SFTP. \ No newline at end of file +Small PHP CLI utility to synchronize directories and manage files on a remote server over SFTP. + +## What It Does + +- Sync local directory to remote (`--sync`) +- Sync remote directory to local (`--sync-down`) +- Delete one remote file (`--delete`) +- Delete remote directory recursively (`--delete-dir`) +- Combine multiple actions in one run (executed in CLI order) +- Skip selected paths during sync or delete using repeatable rules + +## Requirements + +- PHP 8+ (CLI) +- PHP `ssh2` extension enabled (`ssh2_connect`, `ssh2_sftp`, ...) +- Network access from your machine to the target SFTP host + +## Quick Start + +From repository root: + +```bash +php src/SFTPsync.php --host example.com --user myuser --password mypass --sync ./local /var/www/app +``` + +If `--host`, `--user`, or `--password` is missing, the script asks for it interactively (TTY only). + +## CLI Usage + +```text +php src/SFTPsync.php --host --user --password [--port ] +``` + +### Actions (repeatable) + +- `--sync `: upload local changes to remote +- `--sync-down `: download remote changes to local +- `--delete `: delete one remote file +- `--delete-dir `: delete remote directory recursively (with safety checks) + +### Options + +- `--host `: required (or prompted) +- `--user `: required (or prompted) +- `--password `: required (or prompted) +- `--port `: optional, default `22` +- `--print-relative`: show paths relative to action root in logs +- `--skip `: repeatable, applied to `--sync` and `--sync-down` +- `--skip-delete `: repeatable, applied to `--delete` and `--delete-dir` +- `-h`, `--help`: show help + +## Examples + +```bash +# Upload local -> remote +php src/SFTPsync.php --host example.com --user u --password p --sync ./app /srv/app + +# Download remote -> local +php src/SFTPsync.php --host example.com --user u --password p --sync-down /srv/backups ./backups + +# Multiple actions in one run (executed left-to-right) +php src/SFTPsync.php --host example.com --user u --password p \ + --sync ./a /remote/a \ + --delete /remote/a/old.zip \ + --sync-down /remote/logs ./logs + +# Skip selected entries during sync +php src/SFTPsync.php --host example.com --user u --password p \ + --skip .git --skip node_modules --skip cache/tmp \ + --sync ./app /srv/app + +# Delete remote directory but keep selected subpaths +php src/SFTPsync.php --host example.com --user u --password p \ + --skip-delete .well-known --skip-delete uploads/keep \ + --delete-dir /srv/app +``` + +## Sync Behavior + +For each file pair, transfer happens when: + +- target file does not exist, or +- file size differs, or +- source mtime is newer than target mtime + +After upload/download, mtime is propagated to the target when possible. + +## Skip Rule Matching + +- Rule without slash (example: `node_modules`) matches any path segment with that name. +- Rule with slash (example: `cache/tmp`) matches that subpath within a relative path. +- Rules are normalized to forward slashes. + +## Safety Notes + +- `--delete-dir` refuses dangerous roots such as empty path, `/`, `.`, `..`, and similar dot paths. +- Delete operations run only on the remote side. +- Path handling normalizes slashes and trims duplicate separators. + +## Exit Codes + +- `0` success +- `1` runtime/SFTP error +- `2` invalid CLI arguments + +## Output + +The script prints status lines (`MKDIR`, `UPLOAD`, `DOWNLOAD`, `DELETE`, `RMDIR`, `SKIP`, `ERROR`) and a final summary with operation counters. diff --git a/doc/prompt.txt b/doc/prompt.txt new file mode 100644 index 0000000..009a63b --- /dev/null +++ b/doc/prompt.txt @@ -0,0 +1,106 @@ +----- 2026-02-15 14:10:18 ----------------------------------------------------- +**Úloha:** Vytvor CLI PHP skript `sftpsync2.php` na synchronizáciu cez SFTP. Skript musí používať triedu/vrstvu z `SFTPconnection.php` na pripojenie k SFTP (Secure FTP cez SSH). Neimplementuj pripojenie “od nuly”, iba ho používaj. + +### 1) Požiadavky na CLI rozhranie + +Skript musí spracovať argumenty z `argv` v štýle GNU: + +**Povinné:** + +* `--host ` +* `--user ` +* `--password ` + +**Nepovinné:** + +* `--port ` (default `22`) + +**Opakovateľné akcie (môžu byť zadané viackrát, v ľubovoľnom poradí):** + +* `--sync ` + Synchronizácia **lokál → vzdialené** (upload). +* `--sync-down ` + Synchronizácia **vzdialené → lokál** (download). +* `--delete ` + Zmaže vzdialený súbor. +* `--delete-dir ` + Zmaže vzdialený adresár (rekurzívne). + +**Pravidlo:** Minimálne jedna akcia musí byť zadaná, inak vypíš help a skonči chybou. + +### 2) Pomoc a validácia + +* `--help` alebo `-h` vypíše použitie + príklady. +* Validuj povinné parametre a formát vstupov. +* Pri chybe argumentov vráť exit code `2`. +* Pri zlyhaní pripojenia alebo SFTP operácie vráť exit code `1`. +* Pri úspechu `0`. + +### 3) Poradie spracovania akcií + +* Spracuj akcie **presne v poradí**, v akom boli zadané v CLI. +* Ak jedna akcia zlyhá, skonči a ďalšie už nespúšťaj (fail-fast), pokiaľ nie je explicitne uvedené inak. + +### 4) Synchronizácia: špecifikácia správania + +Pre `--sync` (upload) a `--sync-down` (download): + +* Rekurzívne prechádzaj zdrojový adresár. +* Prenášaj súbory, ktoré: + + * neexistujú na cieli, alebo + * majú odlišnú veľkosť, alebo + * majú novší `mtime` na zdroji (ak sa dá zistiť na oboch stranách; ak nie, používaj veľkosť + checksum nie je povinný). +* Vytváraj chýbajúce adresáre na cieli. +* Zachovaj relatívnu štruktúru ciest. +* Loguj pre každý súbor jednu z akcií: `SKIP`, `UPLOAD`, `DOWNLOAD`, `MKDIR`, `DELETE`, `RMDIR`, `ERROR`. + +**Poznámka:** Nevyžadujem “mirror delete” (mazanie súborov, ktoré nie sú na zdroji) — sync je iba kopírovanie/aktualizácia. Mazanie sa robí výlučne cez `--delete` a `--delete-dir`. + +### 5) Mazanie + +* `--delete `: + + * Skontroluj existenciu (ak sa dá); ak neexistuje, loguj `SKIP` a pokračuj. +* `--delete-dir `: + + * Rekurzívne zmaž obsah (súbory aj podadresáre), potom samotný adresár. + * Chráň sa pred nebezpečnými cestami: odmietni `""`, `/`, `.` a podobné “root-like” hodnoty (bezpečnostná poistka). + +### 6) Integrácia so SFTPconnection.php + +* Predpokladaj, že `SFTPconnection.php` poskytuje objekt na: + + * pripojenie (`connect()`), + * upload/download súboru, + * listovanie adresára, + * vytváranie adresára, + * zisťovanie `stat` (min. veľkosť a mtime ak dostupné), + * mazanie súboru a adresára. +* Ak API nie je úplne jasné, sprav adaptér/obalové metódy v `sftpsync2.php`, ktoré volajú existujúce metódy a centralizujú rozdiely. + +### 7) Kódové štandardy + +* PHP 8+. +* Žiadne externé balíčky. +* Striktné typy (`declare(strict_types=1);`). +* Prehľadná štruktúra: parser argumentov, runner akcií, sync funkcie, pomocné utily (cesty, logovanie). +* Logovanie na STDOUT, chyby na STDERR. +* Jasné komentáre tam, kde je to neintuitívne. + +### 8) Príklady použitia (musíš ich uviesť v help) + +* Upload sync: + + * `php sftpsync2.php --host example.com --user u --password p --sync ./local /var/www` +* Download sync: + + * `php sftpsync2.php --host example.com --user u --password p --sync-down /var/backups ./backups` +* Viac akcií: + + * `php sftpsync2.php --host example.com --user u --password p --sync ./a /remote/a --delete /remote/a/old.zip --sync-down /remote/logs ./logs` +* Delete dir: + + * `php sftpsync2.php --host example.com --user u --password p --delete-dir /tmp/testdir` + +**Výstup:** Vygeneruj kompletný obsah súboru `sftpsync2.php` (jeden súbor), pripravený na spustenie. diff --git a/src/SFTPconnection.php b/src/SFTPconnection.php new file mode 100644 index 0000000..731e932 --- /dev/null +++ b/src/SFTPconnection.php @@ -0,0 +1,276 @@ +connection; + } + + public function __construct($host, $port = 22) + { + $this->connection = @ssh2_connect($host, $port); + if (! $this->connection) + throw new Exception("Could not connect to SFTP host $host on port $port."); + } + + public function login($username, $password) + { + if (! @ssh2_auth_password($this->connection, $username, $password)) + throw new Exception("Could not authenticate with username $username " . "and password $password."); + $this->sftp = ssh2_sftp($this->connection); + return $this->sftp !== false; + } + + public function uploadFile($local_file, $remote_file) + { + $sftp = $this->sftp; + $remote_dir = dirname($remote_file); + if (!file_exists("ssh2.sftp://$sftp$remote_dir")) { + mkdir("ssh2.sftp://$sftp$remote_dir", 0777, true); + } + $stream = @fopen("ssh2.sftp://$sftp$remote_file", 'w'); + if (! $stream) + throw new Exception("Could not open SFTP file for writing: $remote_file"); + $data_to_send = @file_get_contents($local_file); + if ($data_to_send === false) + throw new Exception("Could not open local file: $local_file."); + if (@fwrite($stream, $data_to_send) === false) + throw new Exception("Could not send data from file: $local_file."); + @fclose($stream); + return true; + } + + public function sendFile($local_file, $remote_file) + { + $sftp = $this->sftp; + $remote_dir = dirname($remote_file); + if (!file_exists("ssh2.sftp://$sftp$remote_dir")) { + mkdir("ssh2.sftp://$sftp$remote_dir", 0777, true); + } + return ssh2_scp_send ($this->connection, $local_file, $remote_file); + } + + public function uploadFileContent($content, $remote_file) + { + $sftp = $this->sftp; + $remote_dir = dirname($remote_file); + if (!file_exists("ssh2.sftp://$sftp$remote_dir")) { + mkdir("ssh2.sftp://$sftp$remote_dir", 0777, true); + } + $stream = @fopen("ssh2.sftp://$sftp$remote_file", 'w'); + if (! $stream) + throw new Exception("Could not open SFTP file for writing: $remote_file"); + if (@fwrite($stream, $content) === false) + throw new Exception("Could not send data to file: $remote_file."); + @fclose($stream); + return true; + } + + public function changeTimestamp($remote_file, $timestamp) + { + $date = date("YmdHi.s", is_numeric($timestamp) ? $timestamp : strtotime($timestamp)); + $cmd = "touch -t $date \"$remote_file\""; + $stream = @ssh2_exec($this->connection, $cmd); + if ($stream === false) return false; + stream_set_blocking($stream, true); + // $ret = stream_get_contents($stream); print_r($ret); + fclose($stream); + } + + public function changeTimestamp2($remote_file, $timestamp) + { + $date = date("YmdHi.s", is_numeric($timestamp) ? $timestamp : strtotime($timestamp)); + return touch('ssh2://'.$this->sftp.$remote_file, $date); + } + + function scanFilesystem($remote_file) + { + $sftp = $this->sftp; + $dir = "ssh2.sftp://$sftp$remote_file"; + $tempArray = array(); + + if (is_dir($dir)) { + if ($dh = opendir($dir)) { + while (($file = readdir($dh)) !== false) { + $filetype = filetype($dir . $file); + if ($filetype == "dir") { + $tmp = $this->scanFilesystem($remote_file . $file . "/"); + foreach ($tmp as $t) { + $tempArray[] = $file . "/" . $t; + } + } else { + $tempArray[] = $file; + } + } + closedir($dh); + } + } + + return $tempArray; + } + + public function receiveFile($remote_file, $local_file) + { + $sftp = $this->sftp; + $stream = @fopen("ssh2.sftp://$sftp$remote_file", 'r'); + if (! $stream) + throw new Exception("Could not open SFTP file for reading: $remote_file"); + $contents = fread($stream, filesize("ssh2.sftp://$sftp$remote_file")); + file_put_contents($local_file, $contents); + @fclose($stream); + } + + public function statFile($remote_file) + { + $statinfo = @ssh2_sftp_stat($this->sftp, $remote_file); + return $statinfo; + } + + public function hashFile($remote_file) + { + $sftp = $this->sftp; + $hash = hash_file('sha256', "ssh2.sftp://$sftp$remote_file"); + return $hash; + } + + public function fileExists($remote_file) + { + $statinfo = @ssh2_sftp_stat($this->sftp, $remote_file); + return $statinfo !== false; + } + + public function deleteFile($remote_file) + { + $sftp = $this->sftp; + unlink("ssh2.sftp://$sftp$remote_file"); + } + + public function deleteDir($remote_dir) + { + $sftp = $this->sftp; + $remote_dir = rtrim($remote_dir, "/"); + + if ($remote_dir === '' || $remote_dir === '.') { + throw new Exception("Remote directory path is empty."); + } + + $dir = "ssh2.sftp://$sftp$remote_dir"; + $items = @scandir($dir); + if ($items === false) { + throw new Exception("Could not open remote directory: $remote_dir"); + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $child_remote_path = $remote_dir . '/' . $item; + $child = "ssh2.sftp://$sftp$child_remote_path"; + + if (is_dir($child)) { + $this->deleteDir($child_remote_path); + } else { + if (! @unlink($child)) { + throw new Exception("Could not delete remote file: $child_remote_path"); + } + } + } + + if (! @rmdir($dir)) { + throw new Exception("Could not delete remote directory: $remote_dir"); + } + + return true; + } + + public function dirSync($local_dir, $remote_dir, $ignore = array(), $print = false, $print_only_upload = false) + { + $local_dir = rtrim($local_dir, "/"); + $remote_dir = rtrim($remote_dir, "/"); + $files = scandir($local_dir); + $ignore_exts = array(); + if (is_array($ignore)) foreach ($ignore as $key => $value) { + if (substr($value, 0, 1) == '*') { + $ignore_exts[] = substr($value, 1); + } + } + $msg = ''; + foreach ($files as $file) { + if ($file == "." + || $file == ".." + || in_array($file, $ignore) + || in_array(strtolower(substr($file, strrpos($file, "."))), $ignore_exts)) + { + continue; + } + if (is_dir($local_dir . "/" . $file)) { + $this->dirSync($local_dir . "/" . $file, $remote_dir . "/" . $file, $ignore, $print, $print_only_upload); + } else { + $local_file_size = filesize($local_dir . "/" . $file); + $local_file_mtime = filemtime($local_dir . "/" . $file); + $remote_file_info = $this->statFile($remote_dir . "/" . $file); + if ($print) { + if ($print_only_upload) { + echo str_repeat(' ', strlen($msg)) . "\r"; + } + $msg = $local_file_size . " " . $local_file_mtime . ' ' . date('c', $local_file_mtime) . ' ' . $local_dir . "/" . $file; + if ($remote_file_info !== false) { + $msg2 = $remote_file_info["size"] . " " . $remote_file_info["mtime"] . ' ' . date('c', $remote_file_info["mtime"]) . ' ' . $remote_dir . "/" . $file; + } else { + $msg2 = '--- no exists ' . $remote_dir . "/" . $file; + } + if ($print_only_upload) { + echo $msg . "\r"; + } else { + echo $msg . "\n" . $msg2 . "\n"; + } + } + if ($remote_file_info == false + || $local_file_size != $remote_file_info["size"] + || $local_file_mtime > $remote_file_info["mtime"]) + { + if ($print_only_upload) { + echo "\n" .$msg2 . "\n"; + } + if ($print) echo 'Upload: ' . $local_dir . "/" . $file . ' ... '; + $suc = $this->uploadFile($local_dir . "/" . $file, $remote_dir . "/" . $file); + if ($print) echo ($suc ? "✅ OK\n" : "⛔ ERROR\n"); + $this->changeTimestamp($remote_dir . "/" . $file, $local_file_mtime); + if ($print) echo "\n"; + } + if ($print && ! $print_only_upload) echo "\n"; + } + } + if ($print_only_upload) { + echo str_repeat(' ', strlen($msg)) . "\r"; + } + } + + public function fileSync($local_file, $remote_file, $print = false) + { + if ($print) echo '➡️ Check file: ' . $local_file . ' ... '; + $local_file_size = filesize($local_file); + $local_file_mtime = filemtime($local_file); + $remote_file_info = $this->statFile($remote_file); + if ($remote_file_info == false + || $local_file_size != $remote_file_info["size"] + || $local_file_mtime > $remote_file_info["mtime"]) + { + if ($print) echo "🟠 changed\n"; + if ($print) echo '⬆️ Upload: ' . $local_file . ' ... '; + $suc = $this->uploadFile($local_file, $remote_file); + if ($print) echo ($suc ? "✅ OK\n" : "⛔ ERROR\n"); + $this->changeTimestamp($remote_file, filemtime($local_file)); + if ($print) echo "\n"; + } else { + if ($print) echo "🟢 OK\n"; + } + } +} diff --git a/src/SFTPsync.php b/src/SFTPsync.php new file mode 100644 index 0000000..ae2c3b4 --- /dev/null +++ b/src/SFTPsync.php @@ -0,0 +1,1152 @@ +#!/usr/bin/env php +client = new SFTPConnection($this->host, $this->port); + $loggedIn = $this->client->login($this->user, $this->password); + if ($loggedIn === false) { + throw new RuntimeException('SFTP login failed.'); + } + $sftp = @ssh2_sftp($this->client->getConnection()); + if ($sftp === false) { + throw new RuntimeException('Could not initialize SFTP subsystem.'); + } + $this->sftp = $sftp; + } + + public function stat(string $remotePath): ?array + { + $stat = $this->client->statFile(normalizeRemotePath($remotePath)); + if ($stat === false) { + return null; + } + return is_array($stat) ? $stat : null; + } + + public function exists(string $remotePath): bool + { + return $this->stat($remotePath) !== null; + } + + public function isDir(string $remotePath): bool + { + return @is_dir($this->toUrl($remotePath)); + } + + /** @return list */ + public function listDir(string $remotePath): array + { + $items = @scandir($this->toUrl($remotePath)); + if ($items === false) { + throw new RuntimeException('Cannot list remote directory: ' . normalizeRemotePath($remotePath)); + } + + $result = []; + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $result[] = $item; + } + + return $result; + } + + public function ensureRemoteDir(string $remoteDir, callable $onCreate): void + { + $remoteDir = normalizeRemotePath($remoteDir); + if ($remoteDir === '/' || $remoteDir === '') { + return; + } + + $parts = explode('/', trim($remoteDir, '/')); + $current = str_starts_with($remoteDir, '/') ? '' : ''; + + foreach ($parts as $part) { + if ($part === '') { + continue; + } + $current = $current === '' ? '/' . $part : $current . '/' . $part; + if ($this->isDir($current)) { + continue; + } + + if (!@mkdir($this->toUrl($current), 0777) && !$this->isDir($current)) { + throw new RuntimeException('Cannot create remote directory: ' . $current); + } + $onCreate($current); + } + } + + public function removeEmptyDir(string $remoteDir): void + { + $remoteDir = normalizeRemotePath($remoteDir); + if (!@rmdir($this->toUrl($remoteDir))) { + throw new RuntimeException('Cannot remove remote directory: ' . $remoteDir); + } + } + + public function uploadFile(string $localFile, string $remoteFile): void + { + $remoteFile = normalizeRemotePath($remoteFile); + $ok = $this->client->uploadFile($localFile, $remoteFile); + if ($ok !== true) { + throw new RuntimeException('Upload failed: ' . $localFile . ' -> ' . $remoteFile); + } + } + + public function downloadFile(string $remoteFile, string $localFile): void + { + $remoteFile = normalizeRemotePath($remoteFile); + $this->client->receiveFile($remoteFile, $localFile); + } + + public function deleteFile(string $remoteFile): void + { + $this->client->deleteFile(normalizeRemotePath($remoteFile)); + } + + public function changeRemoteMtime(string $remoteFile, int $timestamp): void + { + $this->client->changeTimestamp(normalizeRemotePath($remoteFile), $timestamp); + } + + private function toUrl(string $remotePath): string + { + $remotePath = normalizeRemotePath($remotePath); + if ($remotePath === '') { + $remotePath = '/'; + } + if (!str_starts_with($remotePath, '/')) { + $remotePath = '/' . $remotePath; + } + + return 'ssh2.sftp://' . $this->sftp . $remotePath; + } +} + +/** + * @return array{help:bool,host:string,user:string,password:string,port:int,print_relative:bool,skip:list,skip_delete:list,actions:list>} + */ +function parseArguments(array $argv): array +{ + $parsed = [ + 'help' => false, + 'host' => '', + 'user' => '', + 'password' => '', + 'port' => 22, + 'print_relative' => false, + 'skip' => [], + 'skip_delete' => [], + 'actions' => [], + ]; + + $argc = count($argv); + for ($i = 1; $i < $argc; $i++) { + $arg = $argv[$i]; + + switch ($arg) { + case '--help': + case '-h': + $parsed['help'] = true; + return $parsed; + + case '--host': + $parsed['host'] = readCliValue($argv, ++$i, '--host'); + break; + + case '--user': + $parsed['user'] = readCliValue($argv, ++$i, '--user'); + break; + + case '--password': + $parsed['password'] = readCliValue($argv, ++$i, '--password'); + break; + + case '--port': + $portRaw = readCliValue($argv, ++$i, '--port'); + if (!ctype_digit($portRaw)) { + throw new CliUsageException('Invalid --port value: ' . $portRaw); + } + $port = (int)$portRaw; + if ($port < 1 || $port > 65535) { + throw new CliUsageException('Port must be in range 1-65535.'); + } + $parsed['port'] = $port; + break; + + case '--print-relative': + $parsed['print_relative'] = true; + break; + + case '--skip': + $skipValue = readCliValue($argv, ++$i, '--skip '); + $parsed['skip'][] = normalizeSkipRule($skipValue); + break; + + case '--skip-delete': + $skipDeleteValue = readCliValue($argv, ++$i, '--skip-delete '); + $parsed['skip_delete'][] = normalizeSkipRule($skipDeleteValue); + break; + + case '--sync': + $localDir = readCliValue($argv, ++$i, '--sync '); + $remoteDir = readCliValue($argv, ++$i, '--sync '); + if ($localDir === '' || $remoteDir === '') { + throw new CliUsageException('--sync requires non-empty paths.'); + } + $parsed['actions'][] = [ + 'type' => 'sync', + 'local_dir' => $localDir, + 'remote_dir' => $remoteDir, + ]; + break; + + case '--sync-down': + $remoteDir = readCliValue($argv, ++$i, '--sync-down '); + $localDir = readCliValue($argv, ++$i, '--sync-down '); + if ($localDir === '' || $remoteDir === '') { + throw new CliUsageException('--sync-down requires non-empty paths.'); + } + $parsed['actions'][] = [ + 'type' => 'sync-down', + 'remote_dir' => $remoteDir, + 'local_dir' => $localDir, + ]; + break; + + case '--delete': + $remoteFile = readCliValue($argv, ++$i, '--delete '); + if ($remoteFile === '') { + throw new CliUsageException('--delete requires non-empty .'); + } + $parsed['actions'][] = [ + 'type' => 'delete', + 'remote_file' => $remoteFile, + ]; + break; + + case '--delete-dir': + $remoteDir = readCliValue($argv, ++$i, '--delete-dir '); + if ($remoteDir === '') { + throw new CliUsageException('--delete-dir requires non-empty .'); + } + $parsed['actions'][] = [ + 'type' => 'delete-dir', + 'remote_dir' => $remoteDir, + ]; + break; + + default: + throw new CliUsageException('Unknown option: ' . $arg); + } + } + + if ($parsed['actions'] === []) { + throw new CliUsageException('At least one action is required (--sync, --sync-down, --delete, --delete-dir).'); + } + + return $parsed; +} + +function readCliValue(array $argv, int $index, string $optionName): string +{ + if (!array_key_exists($index, $argv)) { + throw new CliUsageException('Missing value for ' . $optionName); + } + + $value = (string)$argv[$index]; + if ($value === '' || str_starts_with($value, '--')) { + throw new CliUsageException('Missing value for ' . $optionName); + } + + return $value; +} + +function printHelp($stream): void +{ + $help = << --user --password [--port ] + +Actions (can be repeated and are executed in the given order): + --sync Sync local -> remote (upload) + --sync-down Sync remote -> local (download) + --delete Delete remote file + --delete-dir Delete remote directory recursively + +Options: + --host Required + --user Required + --password Required + --port Optional, default 22 + --print-relative Show logged paths relative to action local/remote roots + --skip Repeatable, skip matching names/paths in --sync and --sync-down + --skip-delete Repeatable, skip matching names/paths in --delete and --delete-dir + -h, --help Show this help + +Examples: + php SFTPsync.php --host example.com --user u --password p --sync ./local /var/www + php SFTPsync.php --host example.com --user u --password p --sync-down /var/backups ./backups + php SFTPsync.php --host example.com --user u --password p --sync ./a /remote/a --delete /remote/a/old.zip --sync-down /remote/logs ./logs + php SFTPsync.php --host example.com --user u --password p --delete-dir /tmp/testdir + php SFTPsync.php --host example.com --user u --password p --skip .git --skip node_modules --sync ./app /srv/app --skip-delete .well-known --delete-dir /srv/app + php SFTPsync.php --host example.com --user u --password p --print-relative --sync ./local /var/www + +Exit codes: + 0 = success + 1 = runtime/SFTP error + 2 = invalid CLI arguments + +If --host / --user / --password is missing, script asks for it interactively in CLI. +TXT; + + fwrite($stream, $help . PHP_EOL); +} + +function run(array $config): int +{ + $adapter = new SftpAdapter($config['host'], $config['port'], $config['user'], $config['password']); + + try { + $adapter->connect(); + } catch (Throwable $e) { + logError('Connection failed: ' . $e->getMessage()); + return EXIT_RUNTIME_ERROR; + } + + foreach ($config['actions'] as $action) { + try { + runAction($adapter, $action, $config['skip'], $config['skip_delete'], $config['print_relative']); + } catch (Throwable $e) { + logErrorAction(describeAction($action) . ': ' . $e->getMessage()); + return EXIT_RUNTIME_ERROR; + } + } + + return EXIT_OK; +} + +/** @param array $action */ +function runAction( + SftpAdapter $adapter, + array $action, + array $skipRules, + array $skipDeleteRules, + bool $printRelative +): void { + switch ($action['type']) { + case 'sync': + syncUp($adapter, $action['local_dir'], $action['remote_dir'], $skipRules, $printRelative); + return; + + case 'sync-down': + syncDown($adapter, $action['remote_dir'], $action['local_dir'], $skipRules, $printRelative); + return; + + case 'delete': + deleteRemoteFile($adapter, $action['remote_file'], $skipDeleteRules, $printRelative); + return; + + case 'delete-dir': + deleteRemoteDir($adapter, $action['remote_dir'], $skipDeleteRules, $printRelative); + return; + + default: + throw new RuntimeException('Unsupported action type: ' . $action['type']); + } +} + +function syncUp( + SftpAdapter $adapter, + string $localDir, + string $remoteDir, + array $skipRules, + bool $printRelative +): void { + if (!is_dir($localDir)) { + throw new RuntimeException('Local directory does not exist: ' . $localDir); + } + + $localBase = realpath($localDir); + if ($localBase === false) { + throw new RuntimeException('Cannot resolve local directory: ' . $localDir); + } + + $remoteBase = normalizeRemotePath($remoteDir); + if ($remoteBase === '') { + throw new RuntimeException('Remote directory is empty.'); + } + + $adapter->ensureRemoteDir($remoteBase, static function (string $createdPath) use ($remoteBase, $printRelative): void { + logAction('MKDIR', formatRemoteLogPath($createdPath, $remoteBase, $printRelative)); + }); + + $normalizedLocalBase = normalizeLocalPath($localBase); + $baseLen = strlen($normalizedLocalBase); + $directoryIterator = new RecursiveDirectoryIterator($localBase, FilesystemIterator::SKIP_DOTS); + $filteredIterator = new RecursiveCallbackFilterIterator( + $directoryIterator, + static function (SplFileInfo $current, mixed $key, $iterator) use ($normalizedLocalBase, $baseLen, $skipRules, $localBase, $printRelative): bool { + $localPath = normalizeLocalPath($current->getPathname()); + $relative = ltrim(substr($localPath, $baseLen), '/'); + if ($relative === '') { + return true; + } + if (isPathSkipped($relative, $skipRules)) { + logAction('SKIP', formatLocalLogPath($localPath, $localBase, $printRelative) . ' (matches --skip)'); + return false; + } + return true; + } + ); + + $iterator = new RecursiveIteratorIterator($filteredIterator, RecursiveIteratorIterator::SELF_FIRST); + + foreach ($iterator as $item) { + /** @var SplFileInfo $item */ + $localPath = normalizeLocalPath($item->getPathname()); + $relative = ltrim(substr($localPath, $baseLen), '/'); + if ($relative === '') { + continue; + } + + if ($item->isDir()) { + $remotePath = joinRemotePath($remoteBase, $relative); + $adapter->ensureRemoteDir($remotePath, static function (string $createdPath) use ($remoteBase, $printRelative): void { + logAction('MKDIR', formatRemoteLogPath($createdPath, $remoteBase, $printRelative)); + }); + continue; + } + + $remoteFile = joinRemotePath($remoteBase, $relative); + $remoteParent = dirnameRemotePath($remoteFile); + $adapter->ensureRemoteDir($remoteParent, static function (string $createdPath) use ($remoteBase, $printRelative): void { + logAction('MKDIR', formatRemoteLogPath($createdPath, $remoteBase, $printRelative)); + }); + + $localSize = filesize($item->getPathname()); + if ($localSize === false) { + throw new RuntimeException('Cannot read local file size: ' . $item->getPathname()); + } + $localMtime = filemtime($item->getPathname()); + if ($localMtime === false) { + $localMtime = null; + } + + $remoteStat = $adapter->stat($remoteFile); + $remoteSize = statInt($remoteStat, 'size'); + $remoteMtime = statInt($remoteStat, 'mtime'); + $localDisplayPath = formatLocalLogPath($item->getPathname(), $localBase, $printRelative); + $remoteDisplayPath = formatRemoteLogPath($remoteFile, $remoteBase, $printRelative); + + if (!shouldTransfer((int)$localSize, $localMtime, $remoteSize, $remoteMtime, $remoteStat !== null)) { + logAction('SKIP', $localDisplayPath . ' -> ' . $remoteDisplayPath); + continue; + } + + $adapter->uploadFile($item->getPathname(), $remoteFile); + if ($localMtime !== null) { + $adapter->changeRemoteMtime($remoteFile, $localMtime); + } + logAction('UPLOAD', $localDisplayPath . ' -> ' . $remoteDisplayPath); + } +} + +function syncDown( + SftpAdapter $adapter, + string $remoteDir, + string $localDir, + array $skipRules, + bool $printRelative +): void { + $remoteBase = normalizeRemotePath($remoteDir); + if ($remoteBase === '') { + throw new RuntimeException('Remote directory is empty.'); + } + if (!$adapter->exists($remoteBase)) { + throw new RuntimeException('Remote directory does not exist: ' . $remoteBase); + } + if (!$adapter->isDir($remoteBase)) { + throw new RuntimeException('Remote path is not a directory: ' . $remoteBase); + } + + $localBase = normalizeLocalPath($localDir); + ensureLocalDir($localDir, static function (string $createdPath) use ($localBase, $printRelative): void { + logAction('MKDIR', formatLocalLogPath($createdPath, $localBase, $printRelative)); + }); + + syncDownRecursive($adapter, $remoteBase, $localDir, $skipRules, $remoteBase, $localBase, $printRelative); +} + +function syncDownRecursive( + SftpAdapter $adapter, + string $remoteDir, + string $localDir, + array $skipRules, + string $remoteDisplayBase, + string $localDisplayBase, + bool $printRelative, + string $relativeBase = '' +): void { + foreach ($adapter->listDir($remoteDir) as $item) { + $relativePath = $relativeBase === '' ? $item : $relativeBase . '/' . $item; + $remotePath = joinRemotePath($remoteDir, $item); + $localPath = rtrim($localDir, DIRECTORY_SEPARATOR . '/\\') . DIRECTORY_SEPARATOR . $item; + + if (isPathSkipped($relativePath, $skipRules)) { + logAction('SKIP', formatRemoteLogPath($remotePath, $remoteDisplayBase, $printRelative) . ' (matches --skip)'); + continue; + } + + if ($adapter->isDir($remotePath)) { + ensureLocalDir($localPath, static function (string $createdPath) use ($localDisplayBase, $printRelative): void { + logAction('MKDIR', formatLocalLogPath($createdPath, $localDisplayBase, $printRelative)); + }); + syncDownRecursive( + $adapter, + $remotePath, + $localPath, + $skipRules, + $remoteDisplayBase, + $localDisplayBase, + $printRelative, + $relativePath + ); + continue; + } + + $remoteStat = $adapter->stat($remotePath); + if ($remoteStat === null) { + throw new RuntimeException('Cannot stat remote file: ' . $remotePath); + } + + $remoteSize = statInt($remoteStat, 'size'); + if ($remoteSize === null) { + throw new RuntimeException('Missing remote size for file: ' . $remotePath); + } + $remoteMtime = statInt($remoteStat, 'mtime'); + + $localExists = is_file($localPath); + $localSize = null; + $localMtime = null; + + if ($localExists) { + $tmpSize = filesize($localPath); + if ($tmpSize !== false) { + $localSize = (int)$tmpSize; + } + + $tmpMtime = filemtime($localPath); + if ($tmpMtime !== false) { + $localMtime = $tmpMtime; + } + } + + if (!shouldTransfer($remoteSize, $remoteMtime, $localSize, $localMtime, $localExists)) { + logAction( + 'SKIP', + formatRemoteLogPath($remotePath, $remoteDisplayBase, $printRelative) + . ' -> ' + . formatLocalLogPath($localPath, $localDisplayBase, $printRelative) + ); + continue; + } + + $parent = dirname($localPath); + ensureLocalDir($parent, static function (string $createdPath) use ($localDisplayBase, $printRelative): void { + logAction('MKDIR', formatLocalLogPath($createdPath, $localDisplayBase, $printRelative)); + }); + + $adapter->downloadFile($remotePath, $localPath); + if ($remoteMtime !== null) { + @touch($localPath, $remoteMtime); + } + logAction( + 'DOWNLOAD', + formatRemoteLogPath($remotePath, $remoteDisplayBase, $printRelative) + . ' -> ' + . formatLocalLogPath($localPath, $localDisplayBase, $printRelative) + ); + } +} + +function deleteRemoteFile( + SftpAdapter $adapter, + string $remoteFile, + array $skipDeleteRules, + bool $printRelative +): void { + $remoteFile = normalizeRemotePath($remoteFile); + if ($remoteFile === '') { + throw new RuntimeException('Remote file path is empty.'); + } + $displayBase = dirnameRemotePath($remoteFile); + $displayPath = formatRemoteLogPath($remoteFile, $displayBase, $printRelative); + if (isPathSkipped($remoteFile, $skipDeleteRules)) { + logAction('SKIP', $displayPath . ' (matches --skip-delete)'); + return; + } + + $stat = $adapter->stat($remoteFile); + if ($stat === null) { + logAction('SKIP', $displayPath . ' (not found)'); + return; + } + if ($adapter->isDir($remoteFile)) { + throw new RuntimeException('Path is a directory, use --delete-dir: ' . $displayPath); + } + + $adapter->deleteFile($remoteFile); + logAction('DELETE', $displayPath); +} + +function deleteRemoteDir( + SftpAdapter $adapter, + string $remoteDir, + array $skipDeleteRules, + bool $printRelative +): void { + $remoteDir = normalizeRemotePath($remoteDir); + if (isDangerousRemoteDir($remoteDir)) { + throw new RuntimeException('Refusing dangerous --delete-dir path: ' . ($remoteDir === '' ? '(empty)' : $remoteDir)); + } + $displayBase = $remoteDir; + $displayPath = formatRemoteLogPath($remoteDir, $displayBase, $printRelative); + if (isPathSkipped($remoteDir, $skipDeleteRules)) { + logAction('SKIP', $displayPath . ' (matches --skip-delete)'); + return; + } + + if (!$adapter->exists($remoteDir)) { + logAction('SKIP', $displayPath . ' (not found)'); + return; + } + if (!$adapter->isDir($remoteDir)) { + throw new RuntimeException('Remote path is not a directory: ' . $displayPath); + } + + $canRemoveRoot = deleteRemoteDirRecursive($adapter, $remoteDir, $skipDeleteRules, $displayBase, $printRelative); + if ($canRemoveRoot) { + $adapter->removeEmptyDir($remoteDir); + logAction('RMDIR', $displayPath); + return; + } + + logAction('SKIP', $displayPath . ' (contains --skip-delete entries)'); +} + +function deleteRemoteDirRecursive( + SftpAdapter $adapter, + string $remoteDir, + array $skipDeleteRules, + string $displayBase, + bool $printRelative, + string $relativeBase = '' +): bool { + $canRemoveCurrent = true; + foreach ($adapter->listDir($remoteDir) as $item) { + $relativePath = $relativeBase === '' ? $item : $relativeBase . '/' . $item; + $child = joinRemotePath($remoteDir, $item); + $childDisplay = formatRemoteLogPath($child, $displayBase, $printRelative); + if (isPathSkipped($relativePath, $skipDeleteRules)) { + logAction('SKIP', $childDisplay . ' (matches --skip-delete)'); + $canRemoveCurrent = false; + continue; + } + + if ($adapter->isDir($child)) { + $canRemoveChild = deleteRemoteDirRecursive( + $adapter, + $child, + $skipDeleteRules, + $displayBase, + $printRelative, + $relativePath + ); + if ($canRemoveChild) { + $adapter->removeEmptyDir($child); + logAction('RMDIR', $childDisplay); + } else { + $canRemoveCurrent = false; + } + continue; + } + + $adapter->deleteFile($child); + logAction('DELETE', $childDisplay); + } + + return $canRemoveCurrent; +} + +function shouldTransfer( + int $sourceSize, + ?int $sourceMtime, + ?int $targetSize, + ?int $targetMtime, + bool $targetExists +): bool { + if (!$targetExists) { + return true; + } + + if ($targetSize === null || $sourceSize !== $targetSize) { + return true; + } + + if ($sourceMtime !== null && $targetMtime !== null && $sourceMtime > $targetMtime) { + return true; + } + + return false; +} + +function statInt(?array $stat, string $key): ?int +{ + if ($stat === null || !array_key_exists($key, $stat)) { + return null; + } + + $value = $stat[$key]; + if (is_int($value)) { + return $value; + } + if (is_numeric($value)) { + return (int)$value; + } + + return null; +} + +function ensureLocalDir(string $path, callable $onCreate): void +{ + if ($path === '' || $path === '.') { + return; + } + + if (is_dir($path)) { + return; + } + + if (!@mkdir($path, 0777, true) && !is_dir($path)) { + throw new RuntimeException('Cannot create local directory: ' . $path); + } + + $onCreate($path); +} + +function isDangerousRemoteDir(string $remoteDir): bool +{ + $candidate = trim($remoteDir); + if ($candidate === '' || $candidate === '/' || $candidate === '.' || $candidate === '..') { + return true; + } + + if (preg_match('#^/+\.*$#', $candidate) === 1) { + return true; + } + + $parts = explode('/', trim($candidate, '/')); + foreach ($parts as $part) { + if ($part === '.' || $part === '..') { + return true; + } + } + + return false; +} + +function normalizeSkipRule(string $rule): string +{ + $rule = trim(str_replace('\\', '/', $rule)); + if ($rule === '') { + throw new CliUsageException('Skip rule cannot be empty.'); + } + + $rule = preg_replace('#/+#', '/', $rule) ?? $rule; + $rule = trim($rule, '/'); + if ($rule === '' || $rule === '.' || $rule === '..') { + throw new CliUsageException('Invalid skip rule: ' . $rule); + } + + return $rule; +} + +function isPathSkipped(string $path, array $skipRules): bool +{ + if ($skipRules === []) { + return false; + } + + $normalizedPath = trim(str_replace('\\', '/', $path), '/'); + if ($normalizedPath === '') { + return false; + } + + $segments = explode('/', $normalizedPath); + $pathWithGuards = '/' . $normalizedPath . '/'; + foreach ($skipRules as $rule) { + $normalizedRule = trim(str_replace('\\', '/', (string)$rule), '/'); + if ($normalizedRule === '') { + continue; + } + if (!str_contains($normalizedRule, '/')) { + if (in_array($normalizedRule, $segments, true)) { + return true; + } + continue; + } + + $ruleWithGuards = '/' . trim($normalizedRule, '/') . '/'; + if (str_contains($pathWithGuards, $ruleWithGuards)) { + return true; + } + } + + return false; +} + +function formatLocalLogPath(string $path, string $basePath, bool $printRelative): string +{ + $normalizedPath = normalizeLocalPath($path); + if (!$printRelative) { + return $normalizedPath; + } + + return makeRelativePath($normalizedPath, normalizeLocalPath($basePath), false); +} + +function formatRemoteLogPath(string $path, string $basePath, bool $printRelative): string +{ + $normalizedPath = normalizeRemotePath($path); + if (!$printRelative) { + return $normalizedPath; + } + + return makeRelativePath($normalizedPath, normalizeRemotePath($basePath), true); +} + +function makeRelativePath(string $path, string $basePath, bool $isRemote): string +{ + if ($path === '' || $basePath === '') { + return $path; + } + + if ($isRemote && $basePath === '/') { + if ($path === '/') { + return '.'; + } + return ltrim($path, '/'); + } + + $pathCmp = strtolower($path); + $baseCmp = strtolower($basePath); + if ($pathCmp === $baseCmp) { + return '.'; + } + + $basePrefix = rtrim($basePath, '/') . '/'; + $basePrefixCmp = strtolower($basePrefix); + if (str_starts_with($pathCmp, $basePrefixCmp)) { + return substr($path, strlen($basePrefix)); + } + + return $path; +} + +function normalizeRemotePath(string $path): string +{ + $path = str_replace('\\', '/', trim($path)); + if ($path === '') { + return ''; + } + + $path = preg_replace('#/+#', '/', $path) ?? $path; + if ($path !== '/') { + $path = rtrim($path, '/'); + } + + return $path === '' ? '/' : $path; +} + +function normalizeLocalPath(string $path): string +{ + return str_replace('\\', '/', $path); +} + +function joinRemotePath(string $base, string $relative): string +{ + $base = normalizeRemotePath($base); + $relative = trim(str_replace('\\', '/', $relative), '/'); + + if ($relative === '') { + return $base; + } + if ($base === '' || $base === '/') { + return '/' . $relative; + } + + return $base . '/' . $relative; +} + +function dirnameRemotePath(string $path): string +{ + $path = normalizeRemotePath($path); + if ($path === '/' || $path === '') { + return '/'; + } + + $dir = str_replace('\\', '/', dirname($path)); + if ($dir === '.' || $dir === '\\') { + return '/'; + } + + return normalizeRemotePath($dir); +} + +/** @param array $action */ +function describeAction(array $action): string +{ + return match ($action['type']) { + 'sync' => '--sync ' . $action['local_dir'] . ' ' . $action['remote_dir'], + 'sync-down' => '--sync-down ' . $action['remote_dir'] . ' ' . $action['local_dir'], + 'delete' => '--delete ' . $action['remote_file'], + 'delete-dir' => '--delete-dir ' . $action['remote_dir'], + default => $action['type'], + }; +} + +function logAction(string $status, string $message): void +{ + incrementStat($status); + writeStatusLine(STDOUT, $status, $message); +} + +function logError(string $message): void +{ + incrementStat('ERROR'); + writeStatusLine(STDERR, 'ERROR', $message); +} + +function logErrorAction(string $message): void +{ + incrementStat('ERROR'); + writeStatusLine(STDERR, 'ERROR', $message); + writeStatusLine(STDOUT, 'ERROR', $message); +} + +function writeStatusLine($stream, string $status, string $message): void +{ + fwrite($stream, formatStatusLabel($status) . ' ' . $message . PHP_EOL); +} + +function formatStatusLabel(string $status): string +{ + $meta = getStatusMeta($status); + $label = str_pad($status, 8, ' ', STR_PAD_RIGHT); + $text = $meta['icon'] . ' ' . $label; + + if (!supportsAnsiColors()) { + return $text; + } + + return "\033[" . $meta['color'] . "m" . $text . "\033[0m"; +} + +/** + * @return array{icon:string,color:string} + */ +function getStatusMeta(string $status): array +{ + return match ($status) { + 'MKDIR' => ['icon' => '📁', 'color' => '36'], + 'RMDIR' => ['icon' => '🧹', 'color' => '35'], + 'UPLOAD' => ['icon' => '⬆️', 'color' => '32'], + 'DOWNLOAD' => ['icon' => '⬇️', 'color' => '34'], + 'DELETE' => ['icon' => '🗑️', 'color' => '31'], + 'SKIP' => ['icon' => '⏭️', 'color' => '33'], + 'ERROR' => ['icon' => '❌', 'color' => '91'], + default => ['icon' => 'ℹ️', 'color' => '37'], + }; +} + +function supportsAnsiColors(): bool +{ + if (getenv('NO_COLOR') !== false) { + return false; + } + + if (!defined('STDOUT')) { + return false; + } + + if (function_exists('stream_isatty')) { + return @stream_isatty(STDOUT); + } + + return true; +} + +function incrementStat(string $status): void +{ + $stats = &getStats(); + if (!array_key_exists($status, $stats)) { + $stats[$status] = 0; + } + $stats[$status]++; +} + +/** + * @return array + */ +function &getStats(): array +{ + static $stats = [ + 'MKDIR' => 0, + 'RMDIR' => 0, + 'UPLOAD' => 0, + 'DOWNLOAD' => 0, + 'DELETE' => 0, + 'SKIP' => 0, + 'ERROR' => 0, + ]; + + return $stats; +} + +function printStatsSummary(): void +{ + $stats = getStats(); + + fwrite(STDOUT, PHP_EOL . '=== SUMMARY ===' . PHP_EOL); + fwrite(STDOUT, formatStatusLabel('MKDIR') . ' created_directories: ' . ($stats['MKDIR'] ?? 0) . PHP_EOL); + fwrite(STDOUT, formatStatusLabel('RMDIR') . ' removed_directories: ' . ($stats['RMDIR'] ?? 0) . PHP_EOL); + fwrite(STDOUT, formatStatusLabel('UPLOAD') . ' uploaded_files: ' . ($stats['UPLOAD'] ?? 0) . PHP_EOL); + fwrite(STDOUT, formatStatusLabel('DOWNLOAD') . ' downloaded_files: ' . ($stats['DOWNLOAD'] ?? 0) . PHP_EOL); + fwrite(STDOUT, formatStatusLabel('DELETE') . ' deleted_files: ' . ($stats['DELETE'] ?? 0) . PHP_EOL); + fwrite(STDOUT, formatStatusLabel('SKIP') . ' skipped_items: ' . ($stats['SKIP'] ?? 0) . PHP_EOL); + fwrite(STDOUT, formatStatusLabel('ERROR') . ' errors: ' . ($stats['ERROR'] ?? 0) . PHP_EOL); + + $changedItems = ($stats['UPLOAD'] ?? 0) + + ($stats['DOWNLOAD'] ?? 0) + + ($stats['DELETE'] ?? 0) + + ($stats['MKDIR'] ?? 0) + + ($stats['RMDIR'] ?? 0); + + fwrite(STDOUT, 'TOTAL changed_items: ' . $changedItems . PHP_EOL); +} + +function main(array $argv): int +{ + if (PHP_SAPI !== 'cli') { + logError('This script must run in CLI mode.'); + return EXIT_RUNTIME_ERROR; + } + + try { + $config = parseArguments($argv); + } catch (CliUsageException $e) { + logError($e->getMessage()); + printHelp(STDOUT); + return EXIT_ARGUMENT_ERROR; + } + + if ($config['help']) { + printHelp(STDOUT); + return EXIT_OK; + } + + try { + $config = askForMissingRequiredOptions($config); + } catch (CliUsageException $e) { + logError($e->getMessage()); + return EXIT_ARGUMENT_ERROR; + } + + $exitCode = run($config); + printStatsSummary(); + + return $exitCode; +} + +/** + * @param array{help:bool,host:string,user:string,password:string,port:int,print_relative:bool,skip:list,skip_delete:list,actions:list>} $config + * @return array{help:bool,host:string,user:string,password:string,port:int,print_relative:bool,skip:list,skip_delete:list,actions:list>} + */ +function askForMissingRequiredOptions(array $config): array +{ + if ($config['host'] === '') { + $config['host'] = promptValue('host'); + } + if ($config['user'] === '') { + $config['user'] = promptValue('user'); + } + if ($config['password'] === '') { + $config['password'] = promptValue('password'); + } + + return $config; +} + +function promptValue(string $name): string +{ + if (!isInteractiveStdin()) { + throw new CliUsageException('Missing required option --' . $name . ' and no interactive input available.'); + } + + while (true) { + fwrite(STDOUT, 'Enter ' . $name . ': '); + $line = fgets(STDIN); + if ($line === false) { + throw new CliUsageException('Could not read --' . $name . ' from interactive input.'); + } + + $value = trim($line); + if ($value !== '') { + return $value; + } + + logError('Value for --' . $name . ' cannot be empty.'); + } +} + +function isInteractiveStdin(): bool +{ + if (!defined('STDIN')) { + return false; + } + + if (function_exists('stream_isatty')) { + return @stream_isatty(STDIN); + } + + return true; +} + +exit(main($argv));