From 8fcb9aead2d6acc31e0ecb84e8ace486841544a3 Mon Sep 17 00:00:00 2001 From: Zura Sekhniashvili Date: Thu, 30 Oct 2025 23:51:25 +0400 Subject: [PATCH] Initial commit --- .gitignore | 6 + LICENSE | 21 +++ README.md | 195 +++++++++++++++++++ composer.json | 52 ++++++ src/Commands/AutoDeployCommand.php | 235 +++++++++++++++++++++++ src/Commands/DeploySharedCommand.php | 237 ++++++++++++++++++++++++ src/Commands/PublishWorkflowCommand.php | 155 ++++++++++++++++ src/HostingerDeployServiceProvider.php | 43 +++++ src/Services/GitHubActionsService.php | 138 ++++++++++++++ src/Services/SshConnectionService.php | 153 +++++++++++++++ src/config/hostinger-deploy.php | 65 +++++++ stubs/hostinger-deploy.yml | 51 +++++ 12 files changed, 1351 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/Commands/AutoDeployCommand.php create mode 100644 src/Commands/DeploySharedCommand.php create mode 100644 src/Commands/PublishWorkflowCommand.php create mode 100644 src/HostingerDeployServiceProvider.php create mode 100644 src/Services/GitHubActionsService.php create mode 100644 src/Services/SshConnectionService.php create mode 100644 src/config/hostinger-deploy.php create mode 100644 stubs/hostinger-deploy.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbc529b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +composer.lock +.phpunit.result.cache +.phpunit.cache +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8729337 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Zura + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c24d72 --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# Laravel Hostinger Deploy + +A Laravel package for automated deployment to Hostinger shared hosting with GitHub Actions support. + +## Features + +- 🚀 **One-command deployment** to Hostinger shared hosting +- 🔄 **GitHub Actions integration** for automated deployments +- 🔑 **Automatic SSH key management** for secure connections +- 📦 **Laravel-specific optimizations** (composer, migrations, storage links) +- ⚙️ **Configurable deployment options** via config file + +## Installation + +Install the package via Composer: + +```bash +composer require zura/laravel-hostinger-deploy --dev +``` + +## Configuration + +### 1. Environment Variables + +Add the following variables to your `.env` file: + +```env +# Hostinger Deployment Configuration +HOSTINGER_SSH_HOST=your-server-ip +HOSTINGER_SSH_USERNAME=your-username +HOSTINGER_SSH_PORT=22 +HOSTINGER_SITE_DIR=your-website-folder +``` + +### 2. Publish Configuration (Optional) + +Publish the configuration file to customize deployment options: + +```bash +php artisan vendor:publish --tag=hostinger-deploy-config +``` + +This will create `config/hostinger-deploy.php` with customizable options. + +## Usage + +### 1. Deploy to Hostinger + +Deploy your Laravel application to Hostinger shared hosting: + +```bash +php artisan hostinger:deploy-shared +``` + +**Options:** +- `--fresh`: Delete existing files and clone fresh repository +- `--site-dir=`: Override site directory from config + +### 2. Publish GitHub Actions Workflow + +Create a GitHub Actions workflow file for automated deployments: + +```bash +php artisan hostinger:publish-workflow +``` + +**Options:** +- `--branch=`: Override default branch (default: auto-detect) +- `--php-version=`: Override PHP version (default: 8.3) + +### 3. Setup Automated Deployment + +Configure SSH keys and display GitHub secrets for automated deployment: + +```bash +php artisan hostinger:auto-deploy +``` + +This command will: +- Generate SSH keys on your Hostinger server +- Display all required GitHub secrets and variables +- Provide step-by-step instructions for GitHub setup + +## GitHub Actions Setup + +After running `php artisan hostinger:auto-deploy`, you'll need to add the following to your GitHub repository: + +### Secrets (Repository Settings → Secrets and variables → Actions → Secrets) + +- `SSH_HOST`: Your Hostinger server IP address +- `SSH_USERNAME`: Your Hostinger SSH username +- `SSH_PORT`: Your Hostinger SSH port (usually 22) +- `SSH_KEY`: Your private SSH key (displayed by the command) + +### Variables (Repository Settings → Secrets and variables → Actions → Variables) + +- `WEBSITE_FOLDER`: Your Hostinger website folder name + +### Deploy Keys (Repository Settings → Deploy keys) + +Add the public SSH key displayed by the `auto-deploy` command as a deploy key. + +## Workflow + +The generated GitHub Actions workflow will: + +1. **Checkout code** from your repository +2. **Setup PHP** environment +3. **Install dependencies** via Composer +4. **Generate application key** and create storage link +5. **Run database migrations** +6. **Deploy to Hostinger** via SSH +7. **Update code** on the server and run optimizations + +## Configuration Options + +### SSH Settings + +```php +'ssh' => [ + 'host' => env('HOSTINGER_SSH_HOST'), + 'username' => env('HOSTINGER_SSH_USERNAME'), + 'port' => env('HOSTINGER_SSH_PORT', 22), + 'timeout' => 30, +], +``` + +### Deployment Settings + +```php +'deployment' => [ + 'site_dir' => env('HOSTINGER_SITE_DIR'), + 'composer_flags' => '--no-dev --optimize-autoloader', + 'run_migrations' => true, + 'run_storage_link' => true, + 'run_config_cache' => false, + 'run_route_cache' => false, + 'run_view_cache' => false, +], +``` + +### GitHub Actions Settings + +```php +'github' => [ + 'workflow_file' => '.github/workflows/hostinger-deploy.yml', + 'php_version' => '8.3', + 'default_branch' => 'main', +], +``` + +## Requirements + +- PHP ^8.2 +- Laravel ^11.0|^12.0 +- SSH access to Hostinger server +- Git repository with GitHub integration + +## Troubleshooting + +### SSH Connection Issues + +1. Verify your SSH credentials in `.env` +2. Test SSH connection manually: `ssh -p PORT USERNAME@HOST` +3. Ensure SSH key authentication is working + +### GitHub Actions Issues + +1. Verify all secrets and variables are correctly set +2. Check that the deploy key is added to your repository +3. Ensure the workflow file is committed and pushed + +### Deployment Issues + +1. Check that your Hostinger server has Composer installed +2. Verify the site directory exists and is writable +3. Ensure your Laravel application is properly configured + +## Security Notes + +- SSH keys are generated on the server and should be kept secure +- Private keys are displayed for GitHub setup - copy them carefully +- The package uses SSH key authentication for secure deployments + +## License + +This package is open-sourced software licensed under the [MIT license](LICENSE). + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## Support + +If you encounter any issues or have questions, please open an issue on GitHub. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e01054d --- /dev/null +++ b/composer.json @@ -0,0 +1,52 @@ +{ + "name": "zura/laravel-hostinger-deploy", + "description": "Laravel package for automated Hostinger deployment with GitHub Actions support", + "type": "library", + "license": "MIT", + "keywords": [ + "laravel", + "hostinger", + "deployment", + "github-actions", + "ssh" + ], + "authors": [ + { + "name": "Zura", + "email": "zura@example.com" + } + ], + "require": { + "php": "^8.2", + "illuminate/support": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0", + "illuminate/process": "^11.0|^12.0", + "symfony/console": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "orchestra/testbench": "^8.0|^9.0" + }, + "autoload": { + "psr-4": { + "Zura\\HostingerDeploy\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Zura\\HostingerDeploy\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Zura\\HostingerDeploy\\HostingerDeployServiceProvider" + ] + } + }, + "minimum-stability": "stable", + "prefer-stable": true, + "config": { + "sort-packages": true + } +} diff --git a/src/Commands/AutoDeployCommand.php b/src/Commands/AutoDeployCommand.php new file mode 100644 index 0000000..9cc8c28 --- /dev/null +++ b/src/Commands/AutoDeployCommand.php @@ -0,0 +1,235 @@ +github = new GitHubActionsService(); + } + + /** + * Execute the console command. + */ + public function handle(): int + { + $this->info('🔧 Setting up automated deployment...'); + + // Validate configuration + if (!$this->validateConfiguration()) { + return self::FAILURE; + } + + // Get repository information + $repoInfo = $this->getRepositoryInfo(); + if (!$repoInfo) { + $this->error('❌ Could not detect repository information. Please run this command from a Git repository.'); + return self::FAILURE; + } + + $this->info("📦 Repository: {$repoInfo['owner']}/{$repoInfo['name']}"); + + // Setup SSH connection + $this->setupSshConnection(); + + // Test SSH connection + if (!$this->ssh->testConnection()) { + $this->error('❌ SSH connection failed. Please check your SSH configuration.'); + return self::FAILURE; + } + + $this->info('✅ SSH connection successful'); + + // Setup SSH keys + if (!$this->setupSshKeys()) { + $this->error('❌ Failed to setup SSH keys'); + return self::FAILURE; + } + + // Display GitHub secrets + $this->displayGitHubSecrets($repoInfo); + + return self::SUCCESS; + } + + /** + * Validate required configuration. + */ + protected function validateConfiguration(): bool + { + $required = [ + 'HOSTINGER_SSH_HOST' => config('hostinger-deploy.ssh.host'), + 'HOSTINGER_SSH_USERNAME' => config('hostinger-deploy.ssh.username'), + 'HOSTINGER_SITE_DIR' => config('hostinger-deploy.deployment.site_dir'), + ]; + + foreach ($required as $key => $value) { + if (empty($value)) { + $this->error("❌ Missing required environment variable: {$key}"); + $this->info("Please add {$key} to your .env file"); + return false; + } + } + + return true; + } + + /** + * Get repository information. + */ + protected function getRepositoryInfo(): ?array + { + if (!$this->github->isGitRepository()) { + return null; + } + + return $this->github->getRepositoryInfo(); + } + + /** + * Setup SSH connection service. + */ + protected function setupSshConnection(): void + { + $this->ssh = new SshConnectionService( + config('hostinger-deploy.ssh.host'), + config('hostinger-deploy.ssh.username'), + config('hostinger-deploy.ssh.port', 22), + config('hostinger-deploy.ssh.timeout', 30) + ); + } + + /** + * Setup SSH keys on the server. + */ + protected function setupSshKeys(): bool + { + try { + // Generate SSH keys if they don't exist + if (!$this->ssh->sshKeyExists()) { + $this->info('🔑 Generating SSH keys on server...'); + if (!$this->ssh->generateSshKey()) { + $this->error('❌ Failed to generate SSH keys'); + return false; + } + } else { + $this->info('🔑 SSH keys already exist on server'); + } + + // Get the public key + $publicKey = $this->ssh->getPublicKey(); + if (!$publicKey) { + $this->error('❌ Could not retrieve public key from server'); + return false; + } + + // Add public key to authorized_keys + $this->info('🔐 Adding public key to authorized_keys...'); + if (!$this->ssh->addToAuthorizedKeys($publicKey)) { + $this->error('❌ Failed to add public key to authorized_keys'); + return false; + } + + $this->info('✅ SSH keys setup completed'); + + return true; + } catch (\Exception $e) { + $this->error("SSH keys setup error: " . $e->getMessage()); + return false; + } + } + + /** + * Display GitHub secrets and variables for manual setup. + */ + protected function displayGitHubSecrets(array $repoInfo): void + { + $this->line(''); + $this->info('🔒 GitHub Secrets and Variables Setup'); + $this->line(''); + + // Get private key + $privateKey = $this->ssh->getPrivateKey(); + if (!$privateKey) { + $this->error('❌ Could not retrieve private key from server'); + return; + } + + // Display secrets + $this->warn('📋 Add these secrets to your GitHub repository:'); + $this->line('Go to: ' . $repoInfo['secrets_url']); + $this->line(''); + + $secrets = [ + 'SSH_HOST' => config('hostinger-deploy.ssh.host'), + 'SSH_USERNAME' => config('hostinger-deploy.ssh.username'), + 'SSH_PORT' => (string) config('hostinger-deploy.ssh.port', 22), + 'SSH_KEY' => $privateKey, + ]; + + foreach ($secrets as $name => $value) { + $this->line("🔑 {$name}:"); + if ($name === 'SSH_KEY') { + $this->line(' [Copy the private key below]'); + $this->line(''); + $this->line(' ' . str_repeat('-', 50)); + $this->line($value); + $this->line(' ' . str_repeat('-', 50)); + } else { + $this->line(" {$value}"); + } + $this->line(''); + } + + // Display variables + $this->warn('📊 Add this variable to your GitHub repository:'); + $this->line('Go to: ' . $repoInfo['variables_url']); + $this->line(''); + + $this->line("📊 WEBSITE_FOLDER:"); + $this->line(" " . config('hostinger-deploy.deployment.site_dir')); + $this->line(''); + + // Display deploy key information + $this->warn('🔑 Deploy Key Information:'); + $this->line('Go to: ' . $repoInfo['deploy_keys_url']); + $this->line(''); + + $publicKey = $this->ssh->getPublicKey(); + $this->line('Add this public key as a Deploy Key:'); + $this->line(''); + $this->line(' ' . str_repeat('-', 50)); + $this->line($publicKey); + $this->line(' ' . str_repeat('-', 50)); + $this->line(''); + + // Display next steps + $this->info('🎉 Setup completed! Next steps:'); + $this->line(''); + $this->line('1. Add all the secrets and variables shown above to GitHub'); + $this->line('2. Add the deploy key to your repository'); + $this->line('3. Run: php artisan hostinger:publish-workflow'); + $this->line('4. Push your changes to trigger the workflow'); + $this->line(''); + $this->info('🚀 Your repository will now automatically deploy on push!'); + } +} diff --git a/src/Commands/DeploySharedCommand.php b/src/Commands/DeploySharedCommand.php new file mode 100644 index 0000000..b24612c --- /dev/null +++ b/src/Commands/DeploySharedCommand.php @@ -0,0 +1,237 @@ +github = new GitHubActionsService(); + } + + /** + * Execute the console command. + */ + public function handle(): int + { + $this->info('🚀 Starting Hostinger deployment...'); + + // Validate configuration + if (!$this->validateConfiguration()) { + return self::FAILURE; + } + + // Get repository URL + $repoUrl = $this->getRepositoryUrl(); + if (!$repoUrl) { + $this->error('❌ Could not detect Git repository URL. Please run this command from a Git repository.'); + return self::FAILURE; + } + + $this->info("📦 Repository: {$repoUrl}"); + + // Setup SSH connection + $this->setupSshConnection(); + + // Test SSH connection + if (!$this->ssh->testConnection()) { + $this->error('❌ SSH connection failed. Please check your SSH configuration.'); + return self::FAILURE; + } + + $this->info('✅ SSH connection successful'); + + // Deploy to server + if (!$this->deployToServer($repoUrl)) { + $this->error('❌ Deployment failed'); + return self::FAILURE; + } + + $this->info('🎉 Deployment completed successfully!'); + $this->info("🌐 Your Laravel application is now live at: https://{$this->getSiteDir()}"); + + return self::SUCCESS; + } + + /** + * Validate required configuration. + */ + protected function validateConfiguration(): bool + { + $required = [ + 'HOSTINGER_SSH_HOST' => config('hostinger-deploy.ssh.host'), + 'HOSTINGER_SSH_USERNAME' => config('hostinger-deploy.ssh.username'), + 'HOSTINGER_SITE_DIR' => $this->getSiteDir(), + ]; + + foreach ($required as $key => $value) { + if (empty($value)) { + $this->error("❌ Missing required environment variable: {$key}"); + $this->info("Please add {$key} to your .env file"); + return false; + } + } + + return true; + } + + /** + * Get repository URL from Git. + */ + protected function getRepositoryUrl(): ?string + { + $repoUrl = $this->github->getRepositoryUrl(); + + if (!$repoUrl) { + return null; + } + + $this->info("✅ Detected Git repository: {$repoUrl}"); + + if (!$this->confirm('Use this repository for deployment?')) { + $repoUrl = $this->ask('Enter your Git repository URL'); + } + + return $repoUrl; + } + + /** + * Setup SSH connection service. + */ + protected function setupSshConnection(): void + { + $this->ssh = new SshConnectionService( + config('hostinger-deploy.ssh.host'), + config('hostinger-deploy.ssh.username'), + config('hostinger-deploy.ssh.port', 22), + config('hostinger-deploy.ssh.timeout', 30) + ); + } + + /** + * Deploy application to server. + */ + protected function deployToServer(string $repoUrl): bool + { + $siteDir = $this->getSiteDir(); + $isFresh = $this->option('fresh'); + + try { + // Setup SSH keys if needed + $this->setupSshKeys(); + + // Prepare deployment commands + $commands = $this->buildDeploymentCommands($repoUrl, $siteDir, $isFresh); + + // Execute deployment + $this->info('📦 Deploying application...'); + $this->ssh->executeMultiple($commands); + + return true; + } catch (\Exception $e) { + $this->error("Deployment error: " . $e->getMessage()); + return false; + } + } + + /** + * Setup SSH keys on server if needed. + */ + protected function setupSshKeys(): void + { + if (!$this->ssh->sshKeyExists()) { + $this->info('🔑 Generating SSH keys on server...'); + $this->ssh->generateSshKey(); + + $publicKey = $this->ssh->getPublicKey(); + if ($publicKey) { + $this->warn('🔑 Add this SSH key to your GitHub repository:'); + $this->warn(' Settings → Deploy keys → Add deploy key'); + $this->line(''); + $this->line($publicKey); + $this->line(''); + + if (!$this->confirm('Press ENTER after adding the key to GitHub...')) { + throw new \Exception('SSH key setup cancelled'); + } + } + } else { + $this->info('🔑 SSH keys already configured'); + } + } + + /** + * Build deployment commands. + */ + protected function buildDeploymentCommands(string $repoUrl, string $siteDir, bool $isFresh): array + { + $commands = []; + + // Navigate to site directory + $commands[] = "mkdir -p domains/{$siteDir}"; + $commands[] = "cd domains/{$siteDir}"; + + // Remove public_html if exists + $commands[] = "rm -rf public_html"; + + if ($isFresh) { + // Fresh deployment - delete everything and clone + $commands[] = "rm -rf * .[^.]* 2>/dev/null || true"; + $commands[] = "git clone {$repoUrl} ."; + } else { + // Check if repository exists + $commands[] = "if [ -d .git ]; then git pull; else git clone {$repoUrl} .; fi"; + } + + // Install dependencies + $composerFlags = config('hostinger-deploy.deployment.composer_flags', '--no-dev --optimize-autoloader'); + $commands[] = "composer install {$composerFlags}"; + + // Copy .env.example to .env + $commands[] = "if [ -f .env.example ]; then cp .env.example .env; fi"; + + // Create symbolic link for Laravel public folder + $commands[] = "if [ -d public ]; then ln -s public public_html; fi"; + + // Laravel setup + $commands[] = "php artisan key:generate --quiet"; + + if (config('hostinger-deploy.deployment.run_migrations', true)) { + $commands[] = "echo 'yes' | php artisan migrate --quiet"; + } + + if (config('hostinger-deploy.deployment.run_storage_link', true)) { + $commands[] = "php artisan storage:link --quiet"; + } + + return $commands; + } + + /** + * Get site directory from option or config. + */ + protected function getSiteDir(): string + { + return $this->option('site-dir') ?: config('hostinger-deploy.deployment.site_dir'); + } +} diff --git a/src/Commands/PublishWorkflowCommand.php b/src/Commands/PublishWorkflowCommand.php new file mode 100644 index 0000000..d98ef04 --- /dev/null +++ b/src/Commands/PublishWorkflowCommand.php @@ -0,0 +1,155 @@ +github = new GitHubActionsService(); + } + + /** + * Execute the console command. + */ + public function handle(): int + { + $this->info('📄 Publishing GitHub Actions workflow...'); + + // Check if we're in a Git repository + if (!$this->github->isGitRepository()) { + $this->error('❌ Not in a Git repository. Please run this command from a Git repository.'); + return self::FAILURE; + } + + // Get repository information + $repoInfo = $this->github->getRepositoryInfo(); + if (!$repoInfo) { + $this->error('❌ Could not detect repository information.'); + return self::FAILURE; + } + + $this->info("📦 Repository: {$repoInfo['owner']}/{$repoInfo['name']}"); + $this->info("🌿 Branch: {$repoInfo['branch']}"); + + // Get configuration + $branch = $this->option('branch') ?: $this->getBranch(); + $phpVersion = $this->option('php-version') ?: config('hostinger-deploy.github.php_version', '8.3'); + $workflowFile = config('hostinger-deploy.github.workflow_file', '.github/workflows/hostinger-deploy.yml'); + + // Create .github/workflows directory if it doesn't exist + $workflowDir = dirname($workflowFile); + if (!File::exists($workflowDir)) { + File::makeDirectory($workflowDir, 0755, true); + $this->info("📁 Created directory: {$workflowDir}"); + } + + // Generate workflow content + $workflowContent = $this->generateWorkflowContent($branch, $phpVersion); + + // Write workflow file + if (File::put($workflowFile, $workflowContent)) { + $this->info("✅ Workflow file created: {$workflowFile}"); + } else { + $this->error("❌ Failed to create workflow file: {$workflowFile}"); + return self::FAILURE; + } + + // Display next steps + $this->displayNextSteps($repoInfo); + + return self::SUCCESS; + } + + /** + * Get the branch to use for the workflow. + */ + protected function getBranch(): string + { + $currentBranch = $this->github->getCurrentBranch(); + $defaultBranch = config('hostinger-deploy.github.default_branch', 'main'); + + if ($currentBranch && $currentBranch !== $defaultBranch) { + if ($this->confirm("Use current branch '{$currentBranch}' for the workflow? (default: {$defaultBranch})")) { + return $currentBranch; + } + } + + return $defaultBranch; + } + + /** + * Generate workflow content with placeholders replaced. + */ + protected function generateWorkflowContent(string $branch, string $phpVersion): string + { + $stubPath = __DIR__ . '/../../stubs/hostinger-deploy.yml'; + + if (!File::exists($stubPath)) { + throw new \Exception("Workflow stub not found: {$stubPath}"); + } + + $content = File::get($stubPath); + + // Replace placeholders + $content = str_replace('{{BRANCH}}', $branch, $content); + $content = str_replace('{{PHP_VERSION}}', $phpVersion, $content); + + return $content; + } + + /** + * Display next steps for the user. + */ + protected function displayNextSteps(array $repoInfo): void + { + $this->line(''); + $this->info('🎉 GitHub Actions workflow published successfully!'); + $this->line(''); + $this->warn('📋 Next steps:'); + $this->line(''); + $this->line('1. Add the following secrets to your GitHub repository:'); + $this->line(' Go to: ' . $repoInfo['secrets_url']); + $this->line(''); + $this->line(' Required secrets:'); + $this->line(' - SSH_HOST: Your Hostinger server IP address'); + $this->line(' - SSH_USERNAME: Your Hostinger SSH username'); + $this->line(' - SSH_PORT: Your Hostinger SSH port (usually 22)'); + $this->line(' - SSH_KEY: Your private SSH key'); + $this->line(''); + $this->line('2. Add the following variable to your GitHub repository:'); + $this->line(' Go to: ' . $repoInfo['variables_url']); + $this->line(''); + $this->line(' Required variable:'); + $this->line(' - WEBSITE_FOLDER: Your Hostinger website folder name'); + $this->line(''); + $this->line('3. Run the auto-deploy command to get your SSH keys:'); + $this->line(' php artisan hostinger:auto-deploy'); + $this->line(''); + $this->line('4. Push changes to trigger the workflow:'); + $this->line(' git add .'); + $this->line(' git commit -m "Add Hostinger deployment workflow"'); + $this->line(' git push'); + $this->line(''); + $this->info('🚀 Your repository will now automatically deploy on push!'); + } +} diff --git a/src/HostingerDeployServiceProvider.php b/src/HostingerDeployServiceProvider.php new file mode 100644 index 0000000..13565e3 --- /dev/null +++ b/src/HostingerDeployServiceProvider.php @@ -0,0 +1,43 @@ +mergeConfigFrom( + __DIR__.'/config/hostinger-deploy.php', 'hostinger-deploy' + ); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + DeploySharedCommand::class, + PublishWorkflowCommand::class, + AutoDeployCommand::class, + ]); + + $this->publishes([ + __DIR__.'/config/hostinger-deploy.php' => config_path('hostinger-deploy.php'), + ], 'hostinger-deploy-config'); + + $this->publishes([ + __DIR__.'/../stubs/hostinger-deploy.yml' => base_path('.github/workflows/hostinger-deploy.yml'), + ], 'hostinger-deploy-workflow'); + } + } +} diff --git a/src/Services/GitHubActionsService.php b/src/Services/GitHubActionsService.php new file mode 100644 index 0000000..fcad5af --- /dev/null +++ b/src/Services/GitHubActionsService.php @@ -0,0 +1,138 @@ +successful()) { + return null; + } + + return trim($result->output()); + } catch (\Exception $e) { + return null; + } + } + + /** + * Get the current branch name. + */ + public function getCurrentBranch(): string + { + try { + $result = Process::run('git branch --show-current'); + + if (!$result->successful()) { + return 'main'; + } + + $branch = trim($result->output()); + return $branch ?: 'main'; + } catch (\Exception $e) { + return 'main'; + } + } + + /** + * Extract repository owner and name from Git URL. + */ + public function parseRepositoryUrl(string $url): ?array + { + // Handle SSH URLs: git@github.com:owner/repo.git + if (preg_match('/git@github\.com:([^\/]+)\/([^\/]+)\.git/', $url, $matches)) { + return [ + 'owner' => $matches[1], + 'name' => $matches[2], + 'url' => "https://github.com/{$matches[1]}/{$matches[2]}" + ]; + } + + // Handle HTTPS URLs: https://github.com/owner/repo.git + if (preg_match('/https:\/\/github\.com\/([^\/]+)\/([^\/]+)\.git/', $url, $matches)) { + return [ + 'owner' => $matches[1], + 'name' => $matches[2], + 'url' => "https://github.com/{$matches[1]}/{$matches[2]}" + ]; + } + + return null; + } + + /** + * Get GitHub repository settings URL for secrets. + */ + public function getSecretsUrl(string $owner, string $name): string + { + return "https://github.com/{$owner}/{$name}/settings/secrets/actions"; + } + + /** + * Get GitHub repository settings URL for variables. + */ + public function getVariablesUrl(string $owner, string $name): string + { + return "https://github.com/{$owner}/{$name}/settings/variables/actions"; + } + + /** + * Get GitHub repository settings URL for deploy keys. + */ + public function getDeployKeysUrl(string $owner, string $name): string + { + return "https://github.com/{$owner}/{$name}/settings/keys"; + } + + /** + * Check if we're in a Git repository. + */ + public function isGitRepository(): bool + { + try { + $result = Process::run('git rev-parse --git-dir'); + return $result->successful(); + } catch (\Exception $e) { + return false; + } + } + + /** + * Get repository information for display. + */ + public function getRepositoryInfo(): ?array + { + if (!$this->isGitRepository()) { + return null; + } + + $url = $this->getRepositoryUrl(); + if (!$url) { + return null; + } + + $parsed = $this->parseRepositoryUrl($url); + if (!$parsed) { + return null; + } + + return [ + 'url' => $url, + 'owner' => $parsed['owner'], + 'name' => $parsed['name'], + 'branch' => $this->getCurrentBranch(), + 'secrets_url' => $this->getSecretsUrl($parsed['owner'], $parsed['name']), + 'variables_url' => $this->getVariablesUrl($parsed['owner'], $parsed['name']), + 'deploy_keys_url' => $this->getDeployKeysUrl($parsed['owner'], $parsed['name']), + ]; + } +} diff --git a/src/Services/SshConnectionService.php b/src/Services/SshConnectionService.php new file mode 100644 index 0000000..dbddc8e --- /dev/null +++ b/src/Services/SshConnectionService.php @@ -0,0 +1,153 @@ +host = $host; + $this->username = $username; + $this->port = $port; + $this->timeout = $timeout; + } + + /** + * Execute a command on the remote server via SSH. + */ + public function execute(string $command): string + { + $sshCommand = $this->buildSshCommand($command); + + try { + $result = Process::timeout($this->timeout) + ->run($sshCommand); + + if (!$result->successful()) { + throw new ProcessFailedException($result); + } + + return $result->output(); + } catch (ProcessFailedException $e) { + throw new \Exception("SSH command failed: " . $e->getMessage()); + } + } + + /** + * Execute multiple commands on the remote server. + */ + public function executeMultiple(array $commands): string + { + $combinedCommand = implode(' && ', $commands); + return $this->execute($combinedCommand); + } + + /** + * Check if SSH connection is working. + */ + public function testConnection(): bool + { + try { + $this->execute('echo "SSH connection test successful"'); + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Get the public key from the server. + */ + public function getPublicKey(): ?string + { + try { + return trim($this->execute('cat ~/.ssh/id_rsa.pub 2>/dev/null || echo ""')); + } catch (\Exception $e) { + return null; + } + } + + /** + * Get the private key from the server. + */ + public function getPrivateKey(): ?string + { + try { + return trim($this->execute('cat ~/.ssh/id_rsa 2>/dev/null || echo ""')); + } catch (\Exception $e) { + return null; + } + } + + /** + * Generate SSH key pair on the server if it doesn't exist. + */ + public function generateSshKey(): bool + { + try { + $this->execute('ssh-keygen -t rsa -b 4096 -C "github-deploy-key" -N "" -f ~/.ssh/id_rsa'); + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Add a public key to authorized_keys. + */ + public function addToAuthorizedKeys(string $publicKey): bool + { + try { + $this->execute("echo '{$publicKey}' >> ~/.ssh/authorized_keys"); + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Check if SSH key exists on the server. + */ + public function sshKeyExists(): bool + { + try { + $result = $this->execute('test -f ~/.ssh/id_rsa && echo "exists" || echo "not_exists"'); + return trim($result) === 'exists'; + } catch (\Exception $e) { + return false; + } + } + + /** + * Build the SSH command string. + */ + protected function buildSshCommand(string $command): string + { + $sshOptions = [ + '-p ' . $this->port, + '-o ConnectTimeout=' . $this->timeout, + '-o StrictHostKeyChecking=no', + '-o UserKnownHostsFile=/dev/null', + ]; + + $sshCommand = 'ssh ' . implode(' ', $sshOptions) . ' ' . $this->username . '@' . $this->host . ' "' . addslashes($command) . '"'; + + return $sshCommand; + } + + /** + * Get connection details for display. + */ + public function getConnectionString(): string + { + return "ssh -p {$this->port} {$this->username}@{$this->host}"; + } +} diff --git a/src/config/hostinger-deploy.php b/src/config/hostinger-deploy.php new file mode 100644 index 0000000..0da632e --- /dev/null +++ b/src/config/hostinger-deploy.php @@ -0,0 +1,65 @@ + [ + 'host' => env('HOSTINGER_SSH_HOST'), + 'username' => env('HOSTINGER_SSH_USERNAME'), + 'port' => env('HOSTINGER_SSH_PORT', 22), + 'timeout' => 30, + ], + + /* + |-------------------------------------------------------------------------- + | Deployment Settings + |-------------------------------------------------------------------------- + | + | Configuration for the deployment process. + | + */ + 'deployment' => [ + 'site_dir' => env('HOSTINGER_SITE_DIR'), + 'composer_flags' => '--no-dev --optimize-autoloader', + 'run_migrations' => true, + 'run_storage_link' => true, + 'run_config_cache' => false, + 'run_route_cache' => false, + 'run_view_cache' => false, + ], + + /* + |-------------------------------------------------------------------------- + | GitHub Actions Settings + |-------------------------------------------------------------------------- + | + | Configuration for GitHub Actions workflow generation. + | + */ + 'github' => [ + 'workflow_file' => '.github/workflows/hostinger-deploy.yml', + 'php_version' => '8.3', + 'default_branch' => 'main', + ], + + /* + |-------------------------------------------------------------------------- + | Server Paths + |-------------------------------------------------------------------------- + | + | Default paths on the Hostinger server. + | + */ + 'paths' => [ + 'domains' => 'domains', + 'public_html' => 'public_html', + 'public' => 'public', + ], +]; diff --git a/stubs/hostinger-deploy.yml b/stubs/hostinger-deploy.yml new file mode 100644 index 0000000..a8e8bbc --- /dev/null +++ b/stubs/hostinger-deploy.yml @@ -0,0 +1,51 @@ +name: Hostinger Deploy + +on: + push: + branches: [ {{BRANCH}} ] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '{{PHP_VERSION}}' + + - name: Create .env + run: cp .env.example .env + + - name: Install composer Dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Set Application Encryption Key + run: php artisan key:generate --ansi + + - name: Create Storage Link + run: php artisan storage:link + + - name: Run Migrations + run: php artisan migrate --force + + - name: Deploy to Hostinger Server + if: ${{ success() }} + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + port: ${{ secrets.SSH_PORT }} + key: ${{ secrets.SSH_KEY }} + script: | + cd domains/${{ vars.WEBSITE_FOLDER }} + git checkout {{BRANCH}} + git pull + composer install --no-dev --optimize-autoloader + php artisan migrate --force + php artisan config:cache + php artisan route:cache + php artisan view:cache