1d87be7d5e
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
- Update REPO: from MokoStandards-API to moko-platform in 125 files - Fix wrong org path (mokoconsulting-tech → MokoConsulting) in 10 files - Fix SPDX-LICENSE-IDENTIFIER case in 2 template files - Add missing REPO: field to 3 files Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
794 lines
24 KiB
PHP
794 lines
24 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
*
|
|
* This file is part of a Moko Consulting project.
|
|
*
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*
|
|
* FILE INFORMATION
|
|
* DEFGROUP: MokoStandards.Scripts.Deploy
|
|
* INGROUP: MokoStandards
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
* PATH: /deploy/deploy-sftp.php
|
|
* BRIEF: Deploy a repository src/ directory to a remote web server via SFTP
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../vendor/autoload.php';
|
|
|
|
use MokoEnterprise\CLIApp;
|
|
use phpseclib3\Net\SFTP;
|
|
use phpseclib3\Crypt\PublicKeyLoader;
|
|
|
|
/**
|
|
* SFTP deployment script.
|
|
*
|
|
* Reads connection details from a sftp-config.json file (Sublime Text SFTP
|
|
* format with // comments stripped) and recursively uploads the src/
|
|
* directory of a repository to the configured remote path.
|
|
*/
|
|
class DeploySftp extends CLIApp
|
|
{
|
|
/** @var array<string,mixed> Parsed sftp-config.json contents */
|
|
private array $config = [];
|
|
|
|
/** @var int Count of files uploaded in the current run */
|
|
private int $uploaded = 0;
|
|
|
|
/** @var int Count of files skipped due to ignore rules */
|
|
private int $skipped = 0;
|
|
|
|
/** @var int Count of files unchanged (smart deploy) */
|
|
private int $unchanged = 0;
|
|
|
|
/** @var int Count of remote files deleted (smart deploy) */
|
|
private int $deleted = 0;
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct(
|
|
'deploy-sftp',
|
|
'Deploy a repository src/ directory to a remote web server via SFTP',
|
|
'04.00.15'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Print full help including usage examples.
|
|
*
|
|
* Overrides CLIApp::printHelp() to add an EXAMPLES section and
|
|
* document the scripts/keys/ key-resolution convention.
|
|
*/
|
|
protected function printHelp(): void
|
|
{
|
|
parent::printHelp();
|
|
echo <<<'DEPLOY_SFTP_HELP'
|
|
|
|
ARGUMENTS
|
|
--path <dir> Repository root (default: current directory).
|
|
--src-dir <dir> Sub-directory inside the repo to upload (default: src).
|
|
--env <dev|rs> Target environment. Selects the named config file:
|
|
dev → {path}/scripts/sftp-config/sftp-config.dev.json
|
|
rs → {path}/scripts/sftp-config/sftp-config.rs.json
|
|
--config <file> Explicit config path — overrides --env and auto-lookup.
|
|
--key-passphrase <pw> Passphrase for the SSH private key if it is encrypted.
|
|
|
|
DIRECTORY LAYOUT (gitignored — create locally from templates/scripts/deploy/)
|
|
{repo_root}/
|
|
scripts/
|
|
sftp-config/ ← gitignored; place sftp-config.{env}.json files here
|
|
keys/ ← gitignored; place .ppk / PEM key files here
|
|
|
|
KEY RESOLUTION
|
|
ssh_key_file in sftp-config.json may be an absolute path or a bare filename.
|
|
When it is not absolute the script looks for the key under:
|
|
{path}/scripts/keys/{filename}
|
|
before falling back to the raw value as a relative path from CWD.
|
|
|
|
Supported key formats: PuTTY .ppk | OpenSSH PEM (via phpseclib)
|
|
|
|
CONFIG FORMAT
|
|
sftp-config.json follows Sublime Text SFTP plugin conventions.
|
|
// line comments and trailing commas are stripped before parsing.
|
|
|
|
EXAMPLES
|
|
# Dry-run preview of dev deployment
|
|
php deploy/deploy-sftp.php --env dev --dry-run --verbose
|
|
|
|
# Deploy to dev server
|
|
php deploy/deploy-sftp.php --path /repos/mymodule --env dev
|
|
|
|
# Deploy to release/production server
|
|
php deploy/deploy-sftp.php --path /repos/mymodule --env rs
|
|
|
|
# Use a different source directory
|
|
php deploy/deploy-sftp.php --env dev --src-dir htdocs
|
|
|
|
# Explicit config with encrypted key
|
|
php deploy/deploy-sftp.php \
|
|
--path /repos/mymodule \
|
|
--env rs \
|
|
--key-passphrase "my passphrase"
|
|
|
|
# Quiet mode (errors only)
|
|
php deploy/deploy-sftp.php --env dev --quiet
|
|
|
|
EXIT CODES
|
|
0 All files uploaded successfully
|
|
1 Connection failed or one or more files could not be uploaded
|
|
2 Invalid arguments or config file error
|
|
|
|
DEPLOY_SFTP_HELP;
|
|
}
|
|
|
|
/**
|
|
* Register script-specific CLI arguments.
|
|
*
|
|
* @return array<string,string> Option spec => description
|
|
*/
|
|
protected function setupArguments(): array
|
|
{
|
|
return [
|
|
'path:' => 'Path to the repository to deploy (default: current directory)',
|
|
'src-dir:' => 'Source sub-directory to upload (default: src)',
|
|
'env:' => 'Target environment: dev (sftp-config.dev.json) or rs (sftp-config.rs.json)',
|
|
'config:' => 'Explicit config file path — overrides --env and default lookup',
|
|
'key-passphrase:' => 'Passphrase for the SSH private key file (if required)',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Main execution logic.
|
|
*
|
|
* @return int POSIX exit code
|
|
*/
|
|
protected function run(): int
|
|
{
|
|
$repoPath = $this->resolveRepoPath();
|
|
$srcDir = $this->resolveSrcDir($repoPath);
|
|
$configPath = $this->resolveConfigPath($repoPath);
|
|
|
|
$this->log("Repository : {$repoPath}");
|
|
$this->log("Source dir : {$srcDir}");
|
|
$this->log("Config file: {$configPath}");
|
|
|
|
if (!$this->loadConfig($configPath)) {
|
|
return 1;
|
|
}
|
|
|
|
if (!$this->validateConfig()) {
|
|
return 1;
|
|
}
|
|
|
|
$host = (string) $this->config['host'];
|
|
$port = (int) ($this->config['port'] ?? 22);
|
|
$user = (string) $this->config['user'];
|
|
$remotePath = rtrim((string) $this->config['remote_path'], '/');
|
|
// Load .ftpignore from src/ directory (primary) and repo root (fallback)
|
|
$ignores = array_merge(
|
|
$this->buildIgnorePatterns(),
|
|
$this->loadFtpIgnorePatterns($srcDir),
|
|
$this->loadFtpIgnorePatterns($repoPath)
|
|
);
|
|
|
|
$this->log("Connecting to {$user}@{$host}:{$port} ...");
|
|
|
|
if ($this->dryRun) {
|
|
$this->log("[DRY RUN] Would connect and upload {$srcDir} → {$remotePath}");
|
|
return $this->walkAndDryRun($srcDir, $remotePath, $srcDir, $ignores);
|
|
}
|
|
|
|
$sftp = $this->connect($host, $port, $user, $repoPath);
|
|
if ($sftp === null) {
|
|
return 1;
|
|
}
|
|
|
|
$this->log("Connected. Uploading {$srcDir} → {$remotePath}");
|
|
|
|
// Ensure the remote destination directory exists; create it (recursively) if not.
|
|
// phpseclib3 has a channel reuse bug — is_dir() opens the SFTP subsystem, then
|
|
// mkdir() tries to open it again causing "Please close the channel" error.
|
|
// Fix: use nlist() which reuses the channel, then mkdir() works on the same channel.
|
|
$dirCheck = @$sftp->nlist(dirname($remotePath));
|
|
$baseName = basename($remotePath);
|
|
$dirExists = is_array($dirCheck) && in_array($baseName, $dirCheck, true);
|
|
|
|
if (!$dirExists) {
|
|
$this->log("Remote directory not found — creating: {$remotePath}");
|
|
if (!$sftp->mkdir($remotePath, -1, true)) {
|
|
$this->log("Failed to create remote directory: {$remotePath}", 'ERROR');
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
$exitCode = $this->uploadDirectory($sftp, $srcDir, $remotePath, $srcDir, $ignores);
|
|
|
|
$this->log("Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Deleted: {$this->deleted}, Skipped: {$this->skipped}");
|
|
|
|
return $exitCode;
|
|
}
|
|
|
|
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the absolute path to the repository root.
|
|
*
|
|
* @return string Absolute repository path
|
|
* @throws \RuntimeException When the path does not exist
|
|
*/
|
|
private function resolveRepoPath(): string
|
|
{
|
|
$raw = $this->getOption('path', '.');
|
|
$path = realpath($raw);
|
|
|
|
if ($path === false || !is_dir($path)) {
|
|
$this->log("Repository path does not exist or is not a directory: {$raw}", 'ERROR');
|
|
exit(1);
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Resolve the source directory that will be uploaded.
|
|
*
|
|
* @param string $repoPath Absolute repository root
|
|
* @return string Absolute path to the source directory
|
|
*/
|
|
private function resolveSrcDir(string $repoPath): string
|
|
{
|
|
$sub = $this->getOption('src-dir', 'src');
|
|
$dir = $repoPath . DIRECTORY_SEPARATOR . $sub;
|
|
|
|
if (!is_dir($dir)) {
|
|
$this->log("Source directory does not exist: {$dir}", 'ERROR');
|
|
exit(1);
|
|
}
|
|
|
|
return $dir;
|
|
}
|
|
|
|
/** Map of --env values to their sftp-config filename. */
|
|
private const ENV_CONFIG_MAP = [
|
|
'dev' => 'sftp-config.dev.json',
|
|
'rs' => 'sftp-config.rs.json',
|
|
];
|
|
|
|
/**
|
|
* Resolve the absolute path to the sftp-config file.
|
|
*
|
|
* Resolution order (first match wins):
|
|
* 1. --config <file> — explicit override
|
|
* 2. --env dev|rs — maps to sftp-config.{env}.json in {path}/scripts/sftp-config/
|
|
* 3. sftp-config.json — generic fallback in {path}/scripts/sftp-config/
|
|
*
|
|
* @param string $repoPath Absolute repository root
|
|
* @return string Absolute path to the config file
|
|
*/
|
|
private function resolveConfigPath(string $repoPath): string
|
|
{
|
|
$configDir = $repoPath . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'sftp-config';
|
|
|
|
// 1. Explicit --config wins unconditionally
|
|
$explicit = $this->getOption('config', null);
|
|
if ($explicit !== null) {
|
|
$path = realpath($explicit);
|
|
if ($path === false) {
|
|
$this->log("Config file not found: {$explicit}", 'ERROR');
|
|
exit(1);
|
|
}
|
|
return $path;
|
|
}
|
|
|
|
// 2. --env selects the named config file
|
|
$env = $this->getOption('env', null);
|
|
if ($env !== null) {
|
|
$env = strtolower((string) $env);
|
|
if (!isset(self::ENV_CONFIG_MAP[$env])) {
|
|
$valid = implode(', ', array_keys(self::ENV_CONFIG_MAP));
|
|
$this->log("Unknown --env value '{$env}'. Valid values: {$valid}", 'ERROR');
|
|
exit(2);
|
|
}
|
|
$envConfig = $configDir . DIRECTORY_SEPARATOR . self::ENV_CONFIG_MAP[$env];
|
|
if (!file_exists($envConfig)) {
|
|
$this->log("Config file not found for --env {$env}: {$envConfig}", 'ERROR');
|
|
$this->log("Copy templates/scripts/deploy/sftp-config.{$env}.json.example → {$envConfig}", 'ERROR');
|
|
exit(1);
|
|
}
|
|
return $envConfig;
|
|
}
|
|
|
|
// 3. Generic fallback
|
|
$default = $configDir . DIRECTORY_SEPARATOR . 'sftp-config.json';
|
|
if (!file_exists($default)) {
|
|
$this->log("No config file found. Tried: {$default}", 'ERROR');
|
|
$this->log("Use --env dev, --env rs, or --config <path>.", 'ERROR');
|
|
exit(1);
|
|
}
|
|
|
|
return $default;
|
|
}
|
|
|
|
/**
|
|
* Load and parse sftp-config.json, stripping JS-style // comments.
|
|
*
|
|
* The Sublime Text SFTP plugin allows // comments and trailing commas,
|
|
* so we strip those before passing the text to json_decode.
|
|
*
|
|
* @param string $configPath Absolute path to the config file
|
|
* @return bool True on success
|
|
*/
|
|
private function loadConfig(string $configPath): bool
|
|
{
|
|
$raw = file_get_contents($configPath);
|
|
if ($raw === false) {
|
|
$this->log("Cannot read config file: {$configPath}", 'ERROR');
|
|
return false;
|
|
}
|
|
|
|
// Strip // line comments (not inside strings — good enough for this format)
|
|
$stripped = preg_replace('#(?<!:)//[^\r\n]*#', '', $raw);
|
|
// Strip trailing commas before ] or }
|
|
$stripped = preg_replace('/,(\s*[}\]])/', '$1', $stripped ?? '');
|
|
|
|
$decoded = json_decode($stripped ?? '', true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$this->log("Failed to parse config file: " . json_last_error_msg(), 'ERROR');
|
|
return false;
|
|
}
|
|
|
|
$this->config = $decoded;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Validate that required config keys are present.
|
|
*
|
|
* @return bool True when all required fields exist
|
|
*/
|
|
private function validateConfig(): bool
|
|
{
|
|
$required = ['host', 'user', 'remote_path'];
|
|
$missing = [];
|
|
|
|
foreach ($required as $key) {
|
|
if (empty($this->config[$key])) {
|
|
$missing[] = $key;
|
|
}
|
|
}
|
|
|
|
if (empty($this->config['ssh_key_file']) && empty($this->config['password'])) {
|
|
$missing[] = 'ssh_key_file or password';
|
|
}
|
|
|
|
if (!empty($missing)) {
|
|
$this->log("Missing required config fields: " . implode(', ', $missing), 'ERROR');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Build the list of ignore regex patterns from config.
|
|
*
|
|
* @return array<int,string> Array of PCRE patterns
|
|
*/
|
|
private function buildIgnorePatterns(): array
|
|
{
|
|
$raw = $this->config['ignore_regexes'] ?? [];
|
|
return array_values(array_filter(array_map('strval', $raw)));
|
|
}
|
|
|
|
/**
|
|
* Load ignore patterns from a .ftpignore file in the source directory.
|
|
*
|
|
* Follows gitignore syntax: blank lines and # comments are skipped;
|
|
* glob wildcards (* ? **) are converted to PCRE; a trailing slash matches
|
|
* directories; a leading slash anchors the pattern to the upload root.
|
|
*
|
|
* @param string $srcDir Absolute path to the source directory being uploaded
|
|
* @return array<int,string> PCRE patterns ready for shouldIgnore()
|
|
*/
|
|
private function loadFtpIgnorePatterns(string $srcDir): array
|
|
{
|
|
$ignoreFile = $srcDir . DIRECTORY_SEPARATOR . '.ftpignore';
|
|
if (!is_file($ignoreFile)) {
|
|
return [];
|
|
}
|
|
|
|
$this->log("Loading ignore rules from .ftpignore", 'DEBUG');
|
|
|
|
$patterns = [];
|
|
$lines = file($ignoreFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
if ($lines === false) {
|
|
return [];
|
|
}
|
|
|
|
foreach ($lines as $line) {
|
|
// Strip inline comments and trim whitespace
|
|
$line = trim((string) preg_replace('/#.*$/', '', $line));
|
|
if ($line === '' || str_starts_with($line, '#')) {
|
|
continue;
|
|
}
|
|
|
|
// Negation patterns (!) are not supported — log and skip
|
|
if (str_starts_with($line, '!')) {
|
|
$this->log(" .ftpignore: negation patterns not supported, skipping: {$line}", 'DEBUG');
|
|
continue;
|
|
}
|
|
|
|
// A trailing slash means "match directories only"; we match the prefix
|
|
$line = rtrim($line, '/');
|
|
|
|
// A leading slash anchors to the root; strip it and anchor in regex
|
|
$anchored = str_starts_with($line, '/');
|
|
$line = ltrim($line, '/');
|
|
|
|
// Convert glob to PCRE
|
|
$regex = preg_quote($line, '/');
|
|
$regex = str_replace('\*\*', '.*', $regex); // ** → any path segment
|
|
$regex = str_replace('\*', '[^/]*', $regex); // * → any name chars
|
|
$regex = str_replace('\?', '[^/]', $regex); // ? → single char
|
|
|
|
$regex = $anchored ? '^' . $regex : '(^|/)' . $regex;
|
|
|
|
$patterns[] = $regex . '(/|$)';
|
|
$this->log(" .ftpignore rule: {$line} → /{$regex}/i", 'DEBUG');
|
|
}
|
|
|
|
return $patterns;
|
|
}
|
|
|
|
/**
|
|
* Establish an authenticated SFTP connection.
|
|
*
|
|
* @param string $host Remote hostname
|
|
* @param int $port SSH port
|
|
* @param string $user SSH username
|
|
* @param string $repoPath Absolute repository root (for key resolution)
|
|
* @return SFTP|null Authenticated SFTP object, or null on failure
|
|
*/
|
|
private function connect(string $host, int $port, string $user, string $repoPath): ?SFTP
|
|
{
|
|
try {
|
|
$sftp = new SFTP($host, $port, timeout: 30);
|
|
} catch (\Throwable $e) {
|
|
$this->log("Cannot reach {$host}:{$port} — " . $e->getMessage(), 'ERROR');
|
|
return null;
|
|
}
|
|
|
|
$rawKeyFile = $this->config['ssh_key_file'] ?? null;
|
|
|
|
if (!empty($rawKeyFile)) {
|
|
$keyFile = $this->resolveKeyPath((string) $rawKeyFile, $repoPath);
|
|
$this->log("Using SSH key: {$keyFile}", 'DEBUG');
|
|
return $this->authenticateWithKey($sftp, $user, $keyFile);
|
|
}
|
|
|
|
// Password fallback
|
|
$password = (string) ($this->config['password'] ?? '');
|
|
if (!$sftp->login($user, $password)) {
|
|
$this->log("SFTP password authentication failed for {$user}@{$host}", 'ERROR');
|
|
return null;
|
|
}
|
|
|
|
return $sftp;
|
|
}
|
|
|
|
/**
|
|
* Resolve the SSH key file path.
|
|
*
|
|
* If the configured path is not absolute, look for the file under
|
|
* {repo_path}/scripts/keys/ before falling back to the raw value.
|
|
*
|
|
* @param string $configured Raw value from sftp-config.json
|
|
* @param string $repoPath Absolute repository root
|
|
* @return string Resolved absolute path
|
|
*/
|
|
private function resolveKeyPath(string $configured, string $repoPath): string
|
|
{
|
|
// Already absolute — use as-is
|
|
if (str_starts_with($configured, '/') || preg_match('/^[A-Za-z]:[\/\\\\]/', $configured)) {
|
|
return $configured;
|
|
}
|
|
|
|
// Relative path — check scripts/keys/ first
|
|
$keysDir = $repoPath . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'keys';
|
|
$candidate = $keysDir . DIRECTORY_SEPARATOR . ltrim($configured, '/\\');
|
|
if (file_exists($candidate)) {
|
|
return $candidate;
|
|
}
|
|
|
|
// Fall back to relative from CWD
|
|
return $configured;
|
|
}
|
|
|
|
/**
|
|
* Authenticate the SFTP session using a private key file.
|
|
*
|
|
* Supports both PuTTY .ppk keys and OpenSSH PEM keys via phpseclib.
|
|
*
|
|
* @param SFTP $sftp Open SFTP connection
|
|
* @param string $user SSH username
|
|
* @param string $keyFile Path to the private key file
|
|
* @return SFTP|null Authenticated connection, or null on failure
|
|
*/
|
|
private function authenticateWithKey(SFTP $sftp, string $user, string $keyFile): ?SFTP
|
|
{
|
|
if (!file_exists($keyFile)) {
|
|
$this->log("SSH key file not found: {$keyFile}", 'ERROR');
|
|
return null;
|
|
}
|
|
|
|
$passphrase = $this->getOption('key-passphrase', null);
|
|
|
|
try {
|
|
$keyData = file_get_contents($keyFile);
|
|
if ($keyData === false) {
|
|
throw new \RuntimeException("Cannot read key file: {$keyFile}");
|
|
}
|
|
|
|
$key = $passphrase !== null
|
|
? PublicKeyLoader::load($keyData, $passphrase)
|
|
: PublicKeyLoader::load($keyData);
|
|
} catch (\Throwable $e) {
|
|
$this->log("Failed to load SSH key: " . $e->getMessage(), 'ERROR');
|
|
return null;
|
|
}
|
|
|
|
if (!$sftp->login($user, $key)) {
|
|
$this->log("SFTP key authentication failed for {$user}", 'ERROR');
|
|
return null;
|
|
}
|
|
|
|
return $sftp;
|
|
}
|
|
|
|
/**
|
|
* Check whether a relative file path should be ignored.
|
|
*
|
|
* @param string $relativePath Path relative to the upload root
|
|
* @param array<int,string> $patterns PCRE patterns from sftp-config.json
|
|
* @return bool True when the file should be skipped
|
|
*/
|
|
private function shouldIgnore(string $relativePath, array $patterns): bool
|
|
{
|
|
foreach ($patterns as $pattern) {
|
|
if (preg_match('#' . $pattern . '#i', $relativePath) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Recursively upload a local directory to the remote server.
|
|
*
|
|
* @param SFTP $sftp Authenticated SFTP connection
|
|
* @param string $localDir Absolute local directory path
|
|
* @param string $remotePath Absolute remote directory path
|
|
* @param string $srcRoot Absolute local root (for relative-path calculation)
|
|
* @param array<int,string> $ignorePatterns PCRE patterns to skip
|
|
* @return int POSIX exit code (0 = success)
|
|
*/
|
|
/**
|
|
* Smart deploy: upload only changed/new files, delete removed files.
|
|
*
|
|
* Compares local files against remote by size. If sizes match, compares
|
|
* MD5 hashes. Only uploads when content differs. Removes remote files
|
|
* that no longer exist locally (respecting ignore patterns).
|
|
*/
|
|
private function uploadDirectory(
|
|
SFTP $sftp,
|
|
string $localDir,
|
|
string $remotePath,
|
|
string $srcRoot,
|
|
array $ignorePatterns
|
|
): int {
|
|
$entries = scandir($localDir);
|
|
if ($entries === false) {
|
|
$this->log("Cannot read directory: {$localDir}", 'ERROR');
|
|
return 1;
|
|
}
|
|
|
|
// Build set of local entries for deletion detection
|
|
$localEntryNames = [];
|
|
|
|
foreach ($entries as $entry) {
|
|
if ($entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
|
|
if (str_starts_with($entry, '.')) {
|
|
$this->log(" SKIP {$entry} (dotfile)", 'DEBUG');
|
|
$this->skipped++;
|
|
continue;
|
|
}
|
|
|
|
$localEntry = $localDir . DIRECTORY_SEPARATOR . $entry;
|
|
$remoteEntry = $remotePath . '/' . $entry;
|
|
$relative = ltrim(str_replace($srcRoot, '', $localEntry), DIRECTORY_SEPARATOR . '/');
|
|
|
|
if ($this->shouldIgnore($relative, $ignorePatterns)) {
|
|
$this->log(" SKIP {$relative}", 'DEBUG');
|
|
$this->skipped++;
|
|
continue;
|
|
}
|
|
|
|
$localEntryNames[$entry] = true;
|
|
|
|
if (is_dir($localEntry)) {
|
|
$stat = @$sftp->stat($remoteEntry);
|
|
if ($stat === false) {
|
|
$this->log(" MKDIR {$remoteEntry}", 'DEBUG');
|
|
$sftp->mkdir($remoteEntry, -1, true);
|
|
}
|
|
$result = $this->uploadDirectory($sftp, $localEntry, $remoteEntry, $srcRoot, $ignorePatterns);
|
|
if ($result !== 0) {
|
|
return $result;
|
|
}
|
|
} else {
|
|
// Smart diff: compare local vs remote before uploading
|
|
if ($this->isFileUnchanged($sftp, $localEntry, $remoteEntry)) {
|
|
$this->log(" SAME {$relative}", 'DEBUG');
|
|
$this->unchanged++;
|
|
continue;
|
|
}
|
|
|
|
$this->log(" PUT {$relative} → {$remoteEntry}");
|
|
if (!$sftp->put($remoteEntry, $localEntry, SFTP::SOURCE_LOCAL_FILE)) {
|
|
$this->log("Failed to upload: {$relative}", 'ERROR');
|
|
return 1;
|
|
}
|
|
$this->uploaded++;
|
|
}
|
|
}
|
|
|
|
// Delete remote files that no longer exist locally
|
|
$remoteEntries = $sftp->nlist($remotePath);
|
|
if (is_array($remoteEntries)) {
|
|
foreach ($remoteEntries as $remoteEntry) {
|
|
if ($remoteEntry === '.' || $remoteEntry === '..') {
|
|
continue;
|
|
}
|
|
if (!isset($localEntryNames[$remoteEntry])) {
|
|
$remoteFull = $remotePath . '/' . $remoteEntry;
|
|
$relative = ltrim(str_replace($srcRoot, '', $localDir . DIRECTORY_SEPARATOR . $remoteEntry), DIRECTORY_SEPARATOR . '/');
|
|
|
|
// Don't delete files that match ignore patterns
|
|
if ($this->shouldIgnore($relative, $ignorePatterns)) {
|
|
continue;
|
|
}
|
|
|
|
$remoteStat = @$sftp->stat($remoteFull);
|
|
if ($remoteStat !== false && ($remoteStat['type'] ?? 0) === 2) {
|
|
$this->deleteRemoteDirectory($sftp, $remoteFull);
|
|
$this->log(" RMDIR {$remoteFull}");
|
|
} else {
|
|
$sftp->delete($remoteFull);
|
|
$this->log(" DEL {$remoteFull}");
|
|
}
|
|
$this->deleted++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Check if a local file is identical to its remote counterpart.
|
|
*
|
|
* Uses size comparison first (fast), then MD5 hash if sizes match.
|
|
*/
|
|
private function isFileUnchanged(SFTP $sftp, string $localPath, string $remotePath): bool
|
|
{
|
|
$remoteStat = $sftp->stat($remotePath);
|
|
if ($remoteStat === false) {
|
|
return false; // Remote file doesn't exist — needs upload
|
|
}
|
|
|
|
$localSize = filesize($localPath);
|
|
$remoteSize = $remoteStat['size'] ?? -1;
|
|
|
|
if ($localSize !== $remoteSize) {
|
|
return false; // Different sizes — needs upload
|
|
}
|
|
|
|
// Sizes match — compare MD5 hashes
|
|
$localMd5 = md5_file($localPath);
|
|
$remoteContent = $sftp->get($remotePath);
|
|
if ($remoteContent === false) {
|
|
return false;
|
|
}
|
|
|
|
return $localMd5 === md5($remoteContent);
|
|
}
|
|
|
|
/**
|
|
* Recursively delete a remote directory and all its contents.
|
|
*/
|
|
private function deleteRemoteDirectory(SFTP $sftp, string $path): void
|
|
{
|
|
$entries = $sftp->nlist($path);
|
|
if (!is_array($entries)) {
|
|
return;
|
|
}
|
|
foreach ($entries as $entry) {
|
|
if ($entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
$full = "{$path}/{$entry}";
|
|
$entryStat = @$sftp->stat($full);
|
|
if ($entryStat !== false && ($entryStat['type'] ?? 0) === 2) {
|
|
$this->deleteRemoteDirectory($sftp, $full);
|
|
$sftp->rmdir($full);
|
|
} else {
|
|
$sftp->delete($full);
|
|
}
|
|
}
|
|
$sftp->rmdir($path);
|
|
}
|
|
|
|
/**
|
|
* Walk the source directory and log what would be uploaded, without connecting.
|
|
*
|
|
* @param string $localDir Absolute local directory path
|
|
* @param string $remotePath Remote destination path
|
|
* @param string $srcRoot Absolute local root for relative paths
|
|
* @param array<int,string> $ignorePatterns PCRE patterns to skip
|
|
* @return int Always 0 in dry-run mode
|
|
*/
|
|
private function walkAndDryRun(
|
|
string $localDir,
|
|
string $remotePath,
|
|
string $srcRoot,
|
|
array $ignorePatterns
|
|
): int {
|
|
$entries = scandir($localDir);
|
|
if ($entries === false) {
|
|
$this->log("Cannot read directory: {$localDir}", 'ERROR');
|
|
return 1;
|
|
}
|
|
|
|
foreach ($entries as $entry) {
|
|
if ($entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
|
|
if (str_starts_with($entry, '.')) {
|
|
$this->log("[DRY RUN] SKIP {$entry} (dotfile)");
|
|
$this->skipped++;
|
|
continue;
|
|
}
|
|
|
|
$localEntry = $localDir . DIRECTORY_SEPARATOR . $entry;
|
|
$remoteEntry = $remotePath . '/' . $entry;
|
|
$relative = ltrim(str_replace($srcRoot, '', $localEntry), DIRECTORY_SEPARATOR . '/');
|
|
|
|
if ($this->shouldIgnore($relative, $ignorePatterns)) {
|
|
$this->log("[DRY RUN] SKIP {$relative}");
|
|
$this->skipped++;
|
|
continue;
|
|
}
|
|
|
|
if (is_dir($localEntry)) {
|
|
$this->log("[DRY RUN] MKDIR {$remoteEntry}");
|
|
$this->walkAndDryRun($localEntry, $remoteEntry, $srcRoot, $ignorePatterns);
|
|
} else {
|
|
$this->log("[DRY RUN] PUT {$relative} → {$remoteEntry}");
|
|
$this->uploaded++;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
$script = new DeploySftp();
|
|
exit($script->execute());
|