initialize commit

This commit is contained in:
2026-02-16 07:23:55 +01:00
parent 06fd9007a7
commit c2dee5004f
5 changed files with 1680 additions and 1 deletions

38
AGENTS.md Normal file
View File

@ -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.

109
README.md
View File

@ -1,3 +1,110 @@
# SFTPsync
PHP utility for synchronize local and remote directories over SFTP.
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 <host> --user <user> --password <password> [--port <port>] <actions...>
```
### Actions (repeatable)
- `--sync <local_dir> <remote_dir>`: upload local changes to remote
- `--sync-down <remote_dir> <local_dir>`: download remote changes to local
- `--delete <remote_file>`: delete one remote file
- `--delete-dir <remote_dir>`: delete remote directory recursively (with safety checks)
### Options
- `--host <host>`: required (or prompted)
- `--user <user>`: required (or prompted)
- `--password <password>`: required (or prompted)
- `--port <port>`: optional, default `22`
- `--print-relative`: show paths relative to action root in logs
- `--skip <file_or_dir>`: repeatable, applied to `--sync` and `--sync-down`
- `--skip-delete <file_or_dir>`: 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.

106
doc/prompt.txt Normal file
View File

@ -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 <host>`
* `--user <user>`
* `--password <password>`
**Nepovinné:**
* `--port <port>` (default `22`)
**Opakovateľné akcie (môžu byť zadané viackrát, v ľubovoľnom poradí):**
* `--sync <local_dir> <remote_dir>`
Synchronizácia **lokál → vzdialené** (upload).
* `--sync-down <remote_dir> <local_dir>`
Synchronizácia **vzdialené → lokál** (download).
* `--delete <remote_file>`
Zmaže vzdialený súbor.
* `--delete-dir <remote_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 <remote_file>`:
* Skontroluj existenciu (ak sa dá); ak neexistuje, loguj `SKIP` a pokračuj.
* `--delete-dir <remote_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.

276
src/SFTPconnection.php Normal file
View File

@ -0,0 +1,276 @@
<?php
// https://www.php.net/manual/en/function.ssh2-sftp.php#120853
class SFTPConnection
{
private $connection;
private $sftp;
public function getConnection()
{
return $this->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";
}
}
}

1152
src/SFTPsync.php Normal file

File diff suppressed because it is too large Load Diff