Files
Jonathan Miller 1d87be7d5e
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
fix: standardize file headers — REPO rename, SPDX case, missing fields
- 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>
2026-05-11 17:01:17 -05:00

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());