From a3d2da8222543aa451d42a043b8dde5ec0a15213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Mon, 17 Nov 2025 20:04:57 +0200 Subject: [PATCH 1/3] feat: add site:shared push pull commands for shared files --- app/Console/Site/SiteSharedPullCommand.php | 238 +++++++++++++++++++++ app/Console/Site/SiteSharedPushCommand.php | 212 ++++++++++++++++++ app/Traits/ServersTrait.php | 59 +++-- app/Traits/SiteSharedPathsTrait.php | 48 +++++ app/Traits/SitesTrait.php | 30 ++- 5 files changed, 558 insertions(+), 29 deletions(-) create mode 100644 app/Console/Site/SiteSharedPullCommand.php create mode 100644 app/Console/Site/SiteSharedPushCommand.php create mode 100644 app/Traits/SiteSharedPathsTrait.php diff --git a/app/Console/Site/SiteSharedPullCommand.php b/app/Console/Site/SiteSharedPullCommand.php new file mode 100644 index 00000000..d96c8b23 --- /dev/null +++ b/app/Console/Site/SiteSharedPullCommand.php @@ -0,0 +1,238 @@ +addOption('domain', null, InputOption::VALUE_REQUIRED, 'Site domain') + ->addOption('remote', null, InputOption::VALUE_REQUIRED, 'Remote filename (relative to shared/)') + ->addOption('local', null, InputOption::VALUE_REQUIRED, 'Local destination file path'); + } + + // ---- + // Execution + // ---- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->heading('Download Shared File'); + + // + // Select site + // ---- + + $site = $this->selectSite(); + + if (is_int($site)) { + return $site; + } + + $this->displaySiteDeets($site); + + // + // Get server for site + // ---- + + $server = $this->getServerForSite($site); + + if (is_int($server)) { + return $server; + } + + $this->displayServerDeets($server); + + // + // Get server info (verifies SSH connection and validates distribution & permissions) + // ---- + + $info = $this->serverInfo($server); + + if (is_int($info)) { + return $info; + } + + // + // Resolve paths + // ---- + + $remoteRelative = $this->resolveRemotePath(); + + if ($remoteRelative === null) { + return Command::FAILURE; + } + + $remotePath = $this->buildSharedPath($site, $remoteRelative); + + // + // Verify remote file exists + // ---- + + try { + if (! $this->remoteFileExists($server, $remotePath)) { + $this->nay("Remote file not found: {$remoteRelative}"); + + return Command::FAILURE; + } + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + + return Command::FAILURE; + } + + $localPath = $this->resolveLocalPath($remoteRelative); + + if ($localPath === null) { + return Command::FAILURE; + } + + // + // Check local file overwrite + // ---- + + if ($this->fs->exists($localPath)) { + /** @var bool $overwrite */ + $overwrite = $this->io->promptConfirm( + label: "Local file {$localPath} exists. Overwrite?", + default: false + ); + + if (! $overwrite) { + $this->io->warning('Download cancelled.'); + + return Command::SUCCESS; + } + } + + // + // Download file + // ---- + + $this->io->info("Downloading {$remotePath} to {$localPath}"); + $this->io->writeln(''); + + try { + $this->ssh->downloadFile($server, $remotePath, $localPath); + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + + return Command::FAILURE; + } + + $this->yay('Shared file downloaded'); + + // + // Show command replay + // ---- + + $this->showCommandReplay('site:shared:pull', [ + 'domain' => $site->domain, + 'remote' => $remoteRelative, + 'local' => $localPath, + ]); + + return Command::SUCCESS; + } + + // ---- + // Helpers + // ---- + + private function resolveRemotePath(): ?string + { + /** @var string|null $remoteInput */ + $remoteInput = $this->io->getOptionOrPrompt( + 'remote', + fn (): string => $this->io->promptText( + label: 'Remote filename (relative to shared/):', + placeholder: '.env', + required: true + ) + ); + + $normalized = $this->normalizeRelativePath($remoteInput ?? ''); + + if ($normalized === null) { + return null; + } + + return $normalized; + } + + private function resolveLocalPath(string $remoteRelative): ?string + { + $default = basename($remoteRelative) ?: $remoteRelative; + + /** @var string $localInput */ + $localInput = $this->io->getOptionOrPrompt( + 'local', + fn (): string => $this->io->promptText( + label: 'Local destination path:', + default: $default, + required: true + ) + ); + + try { + return $this->fs->expandPath($localInput); + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + + return null; + } + } + + private function remoteFileExists(ServerDTO $server, string $remotePath): bool + { + $result = $this->ssh->executeCommand( + $server, + sprintf('test -f %s', escapeshellarg($remotePath)) + ); + + if ($result['exit_code'] === 0) { + return true; + } + + if ($result['exit_code'] === 1) { + return false; + } + + $output = trim((string) $result['output']); + $message = $output === '' ? "Failed checking remote file: {$remotePath}" : $output; + + throw new \RuntimeException($message); + } +} diff --git a/app/Console/Site/SiteSharedPushCommand.php b/app/Console/Site/SiteSharedPushCommand.php new file mode 100644 index 00000000..f58bff5e --- /dev/null +++ b/app/Console/Site/SiteSharedPushCommand.php @@ -0,0 +1,212 @@ +addOption('domain', null, InputOption::VALUE_REQUIRED, 'Site domain') + ->addOption('local', null, InputOption::VALUE_REQUIRED, 'Local file path to upload') + ->addOption('remote', null, InputOption::VALUE_REQUIRED, 'Remote filename (relative to shared/)'); + } + + // ---- + // Execution + // ---- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->heading('Upload Shared File'); + + // + // Select site + // ---- + + $site = $this->selectSite(); + + if (is_int($site)) { + return $site; + } + + $this->displaySiteDeets($site); + + // + // Get server for site + // ---- + + $server = $this->getServerForSite($site); + + if (is_int($server)) { + return $server; + } + + $this->displayServerDeets($server); + + // + // Get server info (verifies SSH connection and validates distribution & permissions) + // ---- + + $info = $this->serverInfo($server); + + if (is_int($info)) { + return $info; + } + + // + // Resolve paths + // ---- + + $localPath = $this->resolveLocalPath(); + + if ($localPath === null) { + return Command::FAILURE; + } + + $remoteRelative = $this->resolveRemotePath($localPath); + if ($remoteRelative === null) { + return Command::FAILURE; + } + + $remotePath = $this->buildSharedPath($site, $remoteRelative); + $remoteDir = dirname($remotePath); + + // + // Upload file + // ---- + + $this->io->info("Uploading {$localPath} to {$remotePath}"); + $this->io->writeln(''); + + try { + $this->runRemoteCommand($server, sprintf('mkdir -p %s', escapeshellarg($remoteDir))); + $this->ssh->uploadFile($server, $localPath, $remotePath); + $this->runRemoteCommand($server, sprintf('chmod 640 %s', escapeshellarg($remotePath))); + + if ($server->username !== 'deployer') { + $this->runRemoteCommand( + $server, + sprintf('chown deployer:deployer %s', escapeshellarg($remotePath)) + ); + } + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + + return Command::FAILURE; + } + + $this->yay('Shared file uploaded'); + + // + // Show command replay + // ---- + + $this->showCommandReplay('site:shared:push', [ + 'domain' => $site->domain, + 'local' => $localPath, + 'remote' => $remoteRelative, + ]); + + return Command::SUCCESS; + } + + // ---- + // Helpers + // ---- + + private function resolveLocalPath(): ?string + { + /** @var string $localInput */ + $localInput = $this->io->getOptionOrPrompt( + 'local', + fn (): string => $this->io->promptText( + label: 'Local file path:', + placeholder: '.env.production', + required: true + ) + ); + + try { + $expanded = $this->fs->expandPath($localInput); + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + + return null; + } + + if (! $this->fs->exists($expanded) || ! is_file($expanded)) { + $this->nay("Local file not found: {$expanded}"); + + return null; + } + + return $expanded; + } + + private function resolveRemotePath(string $localPath): ?string + { + $defaultName = basename($localPath); + + /** @var string|null $remoteInput */ + $remoteInput = $this->io->getOptionOrPrompt( + 'remote', + fn (): string => $this->io->promptText( + label: 'Remote filename (relative to shared/):', + placeholder: $defaultName === '' ? '.env' : $defaultName, + default: $defaultName === '' ? '.env' : $defaultName, + required: true + ) + ); + + $normalized = $this->normalizeRelativePath($remoteInput ?? ''); + + if ($normalized === null) { + return null; + } + + return $normalized; + } + + private function runRemoteCommand(ServerDTO $server, string $command): void + { + $result = $this->ssh->executeCommand($server, $command); + if ($result['exit_code'] !== 0) { + $output = trim((string) $result['output']); + $message = $output === '' ? "Remote command failed: {$command}" : $output; + + throw new \RuntimeException($message); + } + } +} diff --git a/app/Traits/ServersTrait.php b/app/Traits/ServersTrait.php index d6b3a48b..22c0209e 100644 --- a/app/Traits/ServersTrait.php +++ b/app/Traits/ServersTrait.php @@ -41,7 +41,7 @@ trait ServersTrait * Automatically displays server info and validates that the server is running a supported distribution (Debian/Ubuntu) * and has sufficient permissions (root or sudo). * - * @param ServerDTO $server Server to get information for + * @param ServerDTO $server Server to get information for * @return array|int Returns parsed server info or failure code on failure */ protected function serverInfo(ServerDTO $server): array|int @@ -73,7 +73,7 @@ protected function serverInfo(ServerDTO $server): array|int /** * Validate that server is running a supported distribution. * - * @param array $info Server information array from server-info playbook + * @param array $info Server information array from server-info playbook * @return array|int Returns validated server info or failure code */ protected function validateServerDistribution(array $info): array|int @@ -90,7 +90,7 @@ protected function validateServerDistribution(array $info): array|int $distroName = $distribution->displayName(); - if (!$distribution->isSupported()) { + if (! $distribution->isSupported()) { $this->nay("Unsupported distribution: {$distroName}. Only Debian and Ubuntu are supported."); return Command::FAILURE; @@ -102,14 +102,14 @@ protected function validateServerDistribution(array $info): array|int /** * Validate that server has sufficient permissions (root or sudo). * - * @param array $info Server information array from server-info playbook + * @param array $info Server information array from server-info playbook * @return array|int Returns validated server info or failure code */ protected function validateServerPermissions(array $info): array|int { $permissions = $info['permissions'] ?? null; - if (!is_string($permissions) || !in_array($permissions, ['root', 'sudo'])) { + if (! is_string($permissions) || ! in_array($permissions, ['root', 'sudo'])) { $this->nay('Server requires root or passwordless sudo permissions'); $this->io->writeln([ '', @@ -130,7 +130,7 @@ protected function validateServerPermissions(array $info): array|int /** * Display formatted server information. * - * @param array $info + * @param array $info */ protected function displayServerInfo(array $info): void { @@ -310,7 +310,7 @@ protected function displayServerInfo(array $info): void // Display PHP-FPM information if available (multiple versions) if (isset($info['php_fpm']) && is_array($info['php_fpm']) && count($info['php_fpm']) > 0) { foreach ($info['php_fpm'] as $version => $fpmData) { - if (!is_array($fpmData) || !is_string($version)) { + if (! is_array($fpmData) || ! is_string($version)) { continue; } @@ -427,7 +427,7 @@ private function formatUptime(int $seconds): string /** * Display a warning to add a server if no servers are available. Otherwise, return all servers. * - * @param array|null $servers Optional pre-fetched servers; if null, fetches from repository + * @param array|null $servers Optional pre-fetched servers; if null, fetches from repository * @return array|int Returns array of servers or Command::FAILURE if no servers available */ protected function ensureServersAvailable(?array $servers = null): array|int @@ -458,7 +458,7 @@ protected function ensureServersAvailable(?array $servers = null): array|int /** * Select a server from inventory by name option or interactive prompt. * - * @param array|null $servers Optional pre-fetched servers; if null, fetches from repository + * @param array|null $servers Optional pre-fetched servers; if null, fetches from repository * @return ServerDTO|int Returns ServerDTO on success, or Command::FAILURE on error */ protected function selectServer(?array $servers = null): ServerDTO|int @@ -526,6 +526,22 @@ protected function displayServerDeets(ServerDTO $server): void $this->io->writeln(''); } + /** + * Resolve the server associated with a site, handling error output. + */ + protected function getServerForSite(SiteDTO $site): ServerDTO|int + { + $server = $this->servers->findByName($site->server); + + if ($server === null) { + $this->nay("Server '{$site->server}' not found in inventory"); + + return Command::FAILURE; + } + + return $server; + } + /** * Verify SSH connection to a server with proper error handling. * @@ -563,8 +579,8 @@ protected function verifySSHConnection(ServerDTO $server): int $this->io->writeln([ '', 'The server will be added to the inventory regardless. You can either:', - ' • Wait a minute and run server:info --server=' . $server->name . ' to check again', - ' • Or run server:install --server=' . $server->name . ' to install software when ready', + ' • Wait a minute and run server:info --server='.$server->name.' to check again', + ' • Or run server:install --server='.$server->name.' to install software when ready', '', ]); @@ -589,13 +605,13 @@ protected function isDigitalOceanServer(ServerDTO $server): bool // ---- /** - * Validate server name format and uniqueness. - * - * @return string|null Error message if invalid, null if valid - */ + * Validate server name format and uniqueness. + * + * @return string|null Error message if invalid, null if valid + */ protected function validateServerName(mixed $name): ?string { - if (!is_string($name)) { + if (! is_string($name)) { return 'Server name must be a string'; } @@ -605,7 +621,7 @@ protected function validateServerName(mixed $name): ?string } // Validate format: alphanumeric, hyphens, underscores only - if (!preg_match('/^[a-zA-Z0-9_-]+$/', $name)) { + if (! preg_match('/^[a-zA-Z0-9_-]+$/', $name)) { return 'Server name can only contain letters, numbers, hyphens, and underscores'; } @@ -625,7 +641,7 @@ protected function validateServerName(mixed $name): ?string */ protected function validateServerHost(mixed $host): ?string { - if (!is_string($host)) { + if (! is_string($host)) { return 'Host must be a string'; } @@ -633,7 +649,7 @@ protected function validateServerHost(mixed $host): ?string $isValidIp = filter_var($host, FILTER_VALIDATE_IP) !== false; $isValidDomain = filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; - if (!$isValidIp && !$isValidDomain) { + if (! $isValidIp && ! $isValidDomain) { return 'Must be a valid IP address or domain name (e.g., 192.168.1.100, example.com)'; } @@ -653,11 +669,11 @@ protected function validateServerHost(mixed $host): ?string */ protected function validateServerPort(mixed $portString): ?string { - if (!is_string($portString)) { + if (! is_string($portString)) { return 'Port must be a string'; } - if (!ctype_digit($portString)) { + if (! ctype_digit($portString)) { return 'Port must be a number'; } @@ -668,5 +684,4 @@ protected function validateServerPort(mixed $portString): ?string return null; } - } diff --git a/app/Traits/SiteSharedPathsTrait.php b/app/Traits/SiteSharedPathsTrait.php new file mode 100644 index 00000000..a6032882 --- /dev/null +++ b/app/Traits/SiteSharedPathsTrait.php @@ -0,0 +1,48 @@ +nay('Remote filename is required.'); + + return null; + } + + $cleaned = ltrim($cleaned, '/'); + + if ($cleaned === '' || str_contains($cleaned, '..')) { + $this->nay('Remote filename must be relative to the shared/ directory and cannot contain "..".'); + + return null; + } + + return $cleaned; + } + + private function buildSharedPath(SiteDTO $site, string $relative = ''): string + { + $sharedRoot = $this->getSiteSharedPath($site); + + if ($relative === '') { + return $sharedRoot; + } + + return rtrim((string) $sharedRoot, '/').'/'.ltrim($relative, '/'); + } +} + diff --git a/app/Traits/SitesTrait.php b/app/Traits/SitesTrait.php index 42d1c59f..d854f6fa 100644 --- a/app/Traits/SitesTrait.php +++ b/app/Traits/SitesTrait.php @@ -34,7 +34,7 @@ trait SitesTrait /** * Display a warning to add a site if no sites are available. Otherwise, return all sites. * - * @param array|null $sites Optional pre-fetched sites; if null, fetches from repository + * @param array|null $sites Optional pre-fetched sites; if null, fetches from repository * @return array|int Returns array of sites or Command::SUCCESS if no sites available */ protected function ensureSitesAvailable(?array $sites = null): array|int @@ -64,7 +64,7 @@ protected function ensureSitesAvailable(?array $sites = null): array|int /** * Select a site from inventory by domain option or interactive prompt. * - * @param array|null $sites Optional pre-fetched sites; if null, fetches from repository + * @param array|null $sites Optional pre-fetched sites; if null, fetches from repository * @return SiteDTO|int Returns SiteDTO on success, or Command::SUCCESS if empty inventory, or Command::FAILURE if not found */ protected function selectSite(?array $sites = null): SiteDTO|int @@ -133,13 +133,13 @@ protected function displaySiteDeets(SiteDTO $site): void */ protected function validateSiteDomain(mixed $domain): ?string { - if (!is_string($domain)) { + if (! is_string($domain)) { return 'Domain must be a string'; } // Check format $isValid = filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; - if (!$isValid) { + if (! $isValid) { return 'Must be a valid domain name (e.g., example.com, subdomain.example.com)'; } @@ -159,7 +159,7 @@ protected function validateSiteDomain(mixed $domain): ?string */ protected function validateSiteBranch(mixed $branch): ?string { - if (!is_string($branch)) { + if (! is_string($branch)) { return 'Branch name must be a string'; } @@ -177,7 +177,7 @@ protected function validateSiteBranch(mixed $branch): ?string */ protected function validateSiteRepo(mixed $repo): ?string { - if (!is_string($repo)) { + if (! is_string($repo)) { return 'Repository URL must be a string'; } @@ -197,10 +197,26 @@ protected function validateSiteRepo(mixed $repo): ?string } } - if (!$hasValidPrefix) { + if (! $hasValidPrefix) { return 'Repository URL must start with git@, https://, http://, or ssh://'; } return null; } + + /** + * Get the remote root path for a site. + */ + protected function getSiteRootPath(SiteDTO $site): string + { + return '/home/deployer/sites/'.$site->domain; + } + + /** + * Get the remote shared directory path for a site. + */ + protected function getSiteSharedPath(SiteDTO $site): string + { + return $this->getSiteRootPath($site).'/shared'; + } } From da9fd3240c1c2c97ed4ae89d53918b461b4244ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Mon, 17 Nov 2025 21:22:51 +0200 Subject: [PATCH 2/3] fixup: pint issue --- app/Traits/SiteSharedPathsTrait.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Traits/SiteSharedPathsTrait.php b/app/Traits/SiteSharedPathsTrait.php index a6032882..a55d2f1a 100644 --- a/app/Traits/SiteSharedPathsTrait.php +++ b/app/Traits/SiteSharedPathsTrait.php @@ -45,4 +45,3 @@ private function buildSharedPath(SiteDTO $site, string $relative = ''): string return rtrim((string) $sharedRoot, '/').'/'.ltrim($relative, '/'); } } - From 0a3154bd890776749af31469719bf510c9e2941b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Mon, 17 Nov 2025 21:42:06 +0200 Subject: [PATCH 3/3] fix: improve error message for preg_replace failure in normalizeRelativePath The null check catches rare preg_replace() errors, not empty input. Empty input is already handled separately on line 28. Updated message to accurately reflect processing failure rather than missing input. --- app/Traits/SiteSharedPathsTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Traits/SiteSharedPathsTrait.php b/app/Traits/SiteSharedPathsTrait.php index a55d2f1a..164b48f0 100644 --- a/app/Traits/SiteSharedPathsTrait.php +++ b/app/Traits/SiteSharedPathsTrait.php @@ -18,7 +18,7 @@ private function normalizeRelativePath(string $path): ?string $cleaned = preg_replace('#/+#', '/', $cleaned); if ($cleaned === null) { - $this->nay('Remote filename is required.'); + $this->nay('Failed to process path. Please check the path format.'); return null; }