From 4c1fc43cda5b2d73fd69a04c2733437f4eacefa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Tue, 18 Nov 2025 14:35:28 +0200 Subject: [PATCH 1/8] feat(site-shared): add shared paths pull/push commands --- app/Console/Site/SiteSharedPullCommand.php | 10 +++++ app/Console/Site/SiteSharedPushCommand.php | 10 +++++ app/SymfonyApp.php | 12 +++++- app/Traits/SitesTrait.php | 44 +++++++++++++++++++++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/app/Console/Site/SiteSharedPullCommand.php b/app/Console/Site/SiteSharedPullCommand.php index d96c8b23..4c32165d 100644 --- a/app/Console/Site/SiteSharedPullCommand.php +++ b/app/Console/Site/SiteSharedPullCommand.php @@ -85,6 +85,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $info; } + // + // Validate site is provisioned on server + // ---- + + $validationResult = $this->validateSiteProvisioned($server, $site); + + if (is_int($validationResult)) { + return $validationResult; + } + // // Resolve paths // ---- diff --git a/app/Console/Site/SiteSharedPushCommand.php b/app/Console/Site/SiteSharedPushCommand.php index f58bff5e..f5f9beca 100644 --- a/app/Console/Site/SiteSharedPushCommand.php +++ b/app/Console/Site/SiteSharedPushCommand.php @@ -85,6 +85,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $info; } + // + // Validate site is provisioned on server + // ---- + + $validationResult = $this->validateSiteProvisioned($server, $site); + + if (is_int($validationResult)) { + return $validationResult; + } + // // Resolve paths // ---- diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index b915c754..32cf27d8 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -8,6 +8,7 @@ use Bigpixelrocket\DeployerPHP\Console\Key\KeyAddDigitalOceanCommand; use Bigpixelrocket\DeployerPHP\Console\Key\KeyDeleteDigitalOceanCommand; use Bigpixelrocket\DeployerPHP\Console\Key\KeyListDigitalOceanCommand; +use Bigpixelrocket\DeployerPHP\Console\ScaffoldHooksCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerDeleteCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerInfoCommand; @@ -19,6 +20,8 @@ use Bigpixelrocket\DeployerPHP\Console\Site\SiteAddCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteDeleteCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteListCommand; +use Bigpixelrocket\DeployerPHP\Console\Site\SiteSharedPullCommand; +use Bigpixelrocket\DeployerPHP\Console\Site\SiteSharedPushCommand; use Bigpixelrocket\DeployerPHP\Services\VersionService; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command; @@ -112,7 +115,7 @@ private function displayBanner(): void '', ' The Server & Site Deployment Tool for PHP', '╰───────────────────────────────────────────────', - '' + '', ]; // Display the banner @@ -129,6 +132,11 @@ private function registerCommands(): void $commands = [ HelloCommand::class, + // + // Scaffolding + + ScaffoldHooksCommand::class, + // // Key management @@ -156,6 +164,8 @@ private function registerCommands(): void SiteAddCommand::class, SiteDeleteCommand::class, SiteListCommand::class, + SiteSharedPushCommand::class, + SiteSharedPullCommand::class, ]; foreach ($commands as $command) { diff --git a/app/Traits/SitesTrait.php b/app/Traits/SitesTrait.php index d854f6fa..0f7a77e5 100644 --- a/app/Traits/SitesTrait.php +++ b/app/Traits/SitesTrait.php @@ -4,22 +4,25 @@ namespace Bigpixelrocket\DeployerPHP\Traits; +use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; use Bigpixelrocket\DeployerPHP\Services\IOService; use Bigpixelrocket\DeployerPHP\Services\ProcessService; +use Bigpixelrocket\DeployerPHP\Services\SSHService; use Symfony\Component\Console\Command\Command; /** * Reusable site things. * - * Requires classes using this trait to have IOService, ProcessService, ServerRepository, and SiteRepository properties. + * Requires classes using this trait to have IOService, ProcessService, ServerRepository, SiteRepository, and SSHService properties. * * @property IOService $io * @property ProcessService $proc * @property ServerRepository $servers * @property SiteRepository $sites + * @property SSHService $ssh */ trait SitesTrait { @@ -204,6 +207,45 @@ protected function validateSiteRepo(mixed $repo): ?string return null; } + /** + * Validate that site has been provisioned on the server. + * + * Checks for: + * - Site directory structure exists at /home/deployer/sites/{domain} + * - Caddy configuration file exists + * + * @return int|null Returns Command::FAILURE if validation fails, null if successful + */ + protected function validateSiteProvisioned(ServerDTO $server, SiteDTO $site): ?int + { + try { + $result = $this->ssh->executeCommand( + $server, + sprintf( + 'test -d /home/deployer/sites/%s && test -f /etc/caddy/conf.d/sites/%s.caddy', + escapeshellarg($site->domain), + escapeshellarg($site->domain) + ) + ); + + if ($result['exit_code'] !== 0) { + $this->nay("Site '{$site->domain}' has not been provisioned on the server"); + $this->io->writeln([ + 'Run site:add to provision the site first.', + '', + ]); + + return Command::FAILURE; + } + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + + return Command::FAILURE; + } + + return null; + } + /** * Get the remote root path for a site. */ From d0bf89a6cf73c9aa0db7cc0096607af3d9d4b89c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Tue, 18 Nov 2025 14:35:31 +0200 Subject: [PATCH 2/8] feat(git): add remote file existence checking --- app/Services/GitService.php | 55 +++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/app/Services/GitService.php b/app/Services/GitService.php index 67b67853..5723d222 100644 --- a/app/Services/GitService.php +++ b/app/Services/GitService.php @@ -11,8 +11,10 @@ */ final readonly class GitService { - public function __construct(private ProcessService $proc) - { + public function __construct( + private ProcessService $proc, + private FilesystemService $fs, + ) { } // @@ -47,6 +49,55 @@ public function detectCurrentBranch(?string $workingDir = null): ?string ); } + // + // Remote Repository + // ---- + + /** + * Check if files exist in a remote git repository without full clone. + * + * Uses shallow clone with depth=1 to minimize data transfer. + * + * @param string $repo Git repository URL + * @param string $branch Branch to check + * @param list $paths File paths to check (relative to repo root) + * @return array Map of path => exists + * @throws \RuntimeException If git operations fail + */ + public function checkRemoteFilesExist(string $repo, string $branch, array $paths): array + { + $tempDir = sys_get_temp_dir().'/deployer-git-check-'.bin2hex(random_bytes(8)); + + try { + // Shallow clone with depth=1 to minimize data transfer + $process = $this->proc->run( + ['git', 'clone', '--depth', '1', '--branch', $branch, '--single-branch', $repo, $tempDir], + sys_get_temp_dir(), + 30.0 + ); + + if (! $process->isSuccessful()) { + throw new \RuntimeException( + "Failed to access git repository '{$repo}' branch '{$branch}': ".trim($process->getErrorOutput()) + ); + } + + // Check each path + $results = []; + foreach ($paths as $path) { + $fullPath = $tempDir.'/'.ltrim($path, '/'); + $results[$path] = $this->fs->exists($fullPath); + } + + return $results; + } finally { + // Clean up temp directory + if ($this->fs->isDirectory($tempDir)) { + $this->fs->remove($tempDir); + } + } + } + // // Helpers // ---- From 50685637d311051505c84fc21675dec8bc6defe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Tue, 18 Nov 2025 14:35:34 +0200 Subject: [PATCH 3/8] feat(fs): add remove method to filesystem service --- app/Services/FilesystemService.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/Services/FilesystemService.php b/app/Services/FilesystemService.php index 34c41e73..8e31b276 100644 --- a/app/Services/FilesystemService.php +++ b/app/Services/FilesystemService.php @@ -64,6 +64,17 @@ public function dumpFile(string $path, string $content): void $this->fs->dumpFile($path, $content); } + /** + * Remove files or directories. + * + * @param string|iterable $files A filename, an array of files, or a \Traversable instance to remove + * @throws \RuntimeException If removal fails + */ + public function remove(string|iterable $files): void + { + $this->fs->remove($files); + } + // // Gap-Filling Methods (Native PHP Functions) // ---- From 881ffdfddfde4261d1188ceecf73bf7777297bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Tue, 18 Nov 2025 14:35:37 +0200 Subject: [PATCH 4/8] feat(playbook): configure deployer sudo permissions --- playbooks/install-deployer.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/playbooks/install-deployer.sh b/playbooks/install-deployer.sh index cd328b08..5bcc39fe 100644 --- a/playbooks/install-deployer.sh +++ b/playbooks/install-deployer.sh @@ -146,6 +146,37 @@ setup_deployer() { fi } +# +# Configure passwordless sudo for deployer user +# ---- + +configure_deployer_sudo() { + local sudoers_file="/etc/sudoers.d/deployer" + + echo "→ Configuring sudo permissions..." + + if ! run_cmd tee "$sudoers_file" > /dev/null <<- 'EOF'; then + deployer ALL=(ALL) NOPASSWD: /bin/systemctl reload caddy + deployer ALL=(ALL) NOPASSWD: /bin/systemctl restart caddy + deployer ALL=(ALL) NOPASSWD: /bin/systemctl reload php*-fpm + deployer ALL=(ALL) NOPASSWD: /bin/systemctl restart php*-fpm + EOF + echo "Error: Failed to write sudoers configuration" >&2 + exit 1 + fi + + if ! run_cmd chmod 440 "$sudoers_file"; then + echo "Error: Failed to set permissions on sudoers file" >&2 + exit 1 + fi + + if ! run_cmd visudo -c -f "$sudoers_file"; then + echo "Error: sudoers validation failed" >&2 + run_cmd rm -f "$sudoers_file" + exit 1 + fi +} + # # Ensure proper permissions on deploy directories # ---- @@ -186,6 +217,7 @@ main() { # Execute deployer setup tasks setup_deployer "$deployer_home" setup_deploy_directories "$deployer_home" + configure_deployer_sudo # Get deploy public key if ! deploy_public_key=$(run_cmd cat "${deployer_home}/.ssh/id_ed25519.pub" 2>&1); then From 794e62e1253e06df29f4e3e905a8c1170c0e69c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Tue, 18 Nov 2025 14:35:40 +0200 Subject: [PATCH 5/8] chore(deps): switch to bun package manager --- bun.lock | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 bun.lock diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..78aca2d6 --- /dev/null +++ b/bun.lock @@ -0,0 +1,14 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "dependencies": { + "prettier": "^3.6.2", + }, + }, + }, + "packages": { + "prettier": ["prettier@3.6.2", "", { "bin": "bin/prettier.cjs" }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + } +} From 539e0a965621e78c811ae7935a0f2fa2b4b22aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Tue, 18 Nov 2025 14:35:51 +0200 Subject: [PATCH 6/8] refactor(site-add): streamline site creation workflow --- app/Console/Site/SiteAddCommand.php | 31 +++++++++++++++-------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php index 2cd8307c..d5f56091 100644 --- a/app/Console/Site/SiteAddCommand.php +++ b/app/Console/Site/SiteAddCommand.php @@ -91,21 +91,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $validationResult; } - // - // Select PHP version - // ---- - - $phpVersion = $this->selectPhpVersion($info); - - if (is_int($phpVersion)) { - return $phpVersion; - } - // // Gather site details // ---- - $siteInfo = $this->gatherSiteInfo(); + $siteInfo = $this->gatherSiteInfo($info); if ($siteInfo === null) { return Command::FAILURE; @@ -115,6 +105,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'domain' => $domain, 'repo' => $repo, 'branch' => $branch, + 'phpVersion' => $phpVersion, ] = $siteInfo; // @@ -172,12 +163,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int // ---- $this->io->writeln([ - '', 'Next steps:', ' • Site is accessible at https://' . $domain . '', ' • Update DNS records to point ' . $domain . ' to ' . $server->host . '', ' • Deploy your application with site:deploy', - '', ]); // @@ -303,9 +292,10 @@ private function selectPhpVersion(array $info): string|int /** * Gather site details from user input or CLI options. * - * @return array{domain: string, repo: string, branch: string}|null + * @param array $info Server information from serverInfo() + * @return array{domain: string, repo: string, branch: string, phpVersion: string}|null */ - protected function gatherSiteInfo(): ?array + protected function gatherSiteInfo(array $info): ?array { /** @var string|null $domain */ $domain = $this->io->getValidatedOptionOrPrompt( @@ -365,10 +355,21 @@ protected function gatherSiteInfo(): ?array return null; } + // + // Select PHP version + // ---- + + $phpVersion = $this->selectPhpVersion($info); + + if (is_int($phpVersion)) { + return null; + } + return [ 'domain' => $domain, 'repo' => $repo, 'branch' => $branch, + 'phpVersion' => $phpVersion, ]; } } From 30c89f1833d79acf3062adb4950fc24e03b073fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Tue, 18 Nov 2025 14:37:23 +0200 Subject: [PATCH 7/8] fixup: missing scaffold hooks command --- app/SymfonyApp.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index 32cf27d8..46fcc9d3 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -8,7 +8,6 @@ use Bigpixelrocket\DeployerPHP\Console\Key\KeyAddDigitalOceanCommand; use Bigpixelrocket\DeployerPHP\Console\Key\KeyDeleteDigitalOceanCommand; use Bigpixelrocket\DeployerPHP\Console\Key\KeyListDigitalOceanCommand; -use Bigpixelrocket\DeployerPHP\Console\ScaffoldHooksCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerDeleteCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerInfoCommand; @@ -135,7 +134,7 @@ private function registerCommands(): void // // Scaffolding - ScaffoldHooksCommand::class, + // ScaffoldHooksCommand::class, // // Key management From 230913c2133e958a75678de7f70747e9a1921cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Tue, 18 Nov 2025 15:05:37 +0200 Subject: [PATCH 8/8] fix(playbooks): correct sudoers systemctl path mismatch Sudoers rules specified /bin/systemctl but run_cmd invokes unqualified systemctl commands. Sudoers requires exact path matches, so the rules would deny access at runtime. Remove /bin/ prefix from all four systemctl sudoers rules to match actual command invocations across all playbooks. --- playbooks/install-deployer.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/playbooks/install-deployer.sh b/playbooks/install-deployer.sh index 5bcc39fe..698c2d4b 100644 --- a/playbooks/install-deployer.sh +++ b/playbooks/install-deployer.sh @@ -156,10 +156,10 @@ configure_deployer_sudo() { echo "→ Configuring sudo permissions..." if ! run_cmd tee "$sudoers_file" > /dev/null <<- 'EOF'; then - deployer ALL=(ALL) NOPASSWD: /bin/systemctl reload caddy - deployer ALL=(ALL) NOPASSWD: /bin/systemctl restart caddy - deployer ALL=(ALL) NOPASSWD: /bin/systemctl reload php*-fpm - deployer ALL=(ALL) NOPASSWD: /bin/systemctl restart php*-fpm + deployer ALL=(ALL) NOPASSWD: systemctl reload caddy + deployer ALL=(ALL) NOPASSWD: systemctl restart caddy + deployer ALL=(ALL) NOPASSWD: systemctl reload php*-fpm + deployer ALL=(ALL) NOPASSWD: systemctl restart php*-fpm EOF echo "Error: Failed to write sudoers configuration" >&2 exit 1