diff --git a/.cursor/cli.json b/.cursor/cli.json deleted file mode 100644 index aa2a1431..00000000 --- a/.cursor/cli.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "permissions": { - "allow": [], - "deny": ["Shell(git push)", "Shell(gh pr create)", "Write(**)"] - } -} diff --git a/.cursor/commands/_analyze.md b/.cursor/commands/_analyze.md deleted file mode 100644 index fa4ce551..00000000 --- a/.cursor/commands/_analyze.md +++ /dev/null @@ -1 +0,0 @@ -meticulously catalog and analyze all the changes diff --git a/.cursor/commands/_create-branch-and-commits.md b/.cursor/commands/_create-branch-and-commits.md deleted file mode 100644 index f2a7b48b..00000000 --- a/.cursor/commands/_create-branch-and-commits.md +++ /dev/null @@ -1,26 +0,0 @@ -create a new branch with a suitable name. - -Create the branch only (no commits yet). Use Conventional Commit types as branch prefixes: -feat/, fix/, docs/, style/, refactor/, perf/, test/, build/, ci/, chore/, revert/. - -Keep the branch name short (≤ 50 chars) yet informative. Do not push, pull, or rebase. - -Examples: - -- feat/parser-add-php-84-attributes -- fix/ci-matrix-php-versions -- chore/deps-bump-composer-installers-2-3 - -Then, create one or more commits with suitable titles. - -Use Conventional Commits to group related changes into cohesive commits (commits should be independently meaningful). - -Keep titles short (≤ 72 chars), imperative, no trailing period. Do not push, pull, or rebase. - -Examples: - -- feat(parser): add support for PHP 8.4 attributes -- fix(ci): correct matrix PHP versions in build workflow -- chore(deps): bump composer/installers to ^2.3 - -Body (optional): explain motivation, context, and breaking changes (use BREAKING CHANGE:). diff --git a/.cursor/commands/_create-branch.md b/.cursor/commands/_create-branch.md deleted file mode 100644 index fe829633..00000000 --- a/.cursor/commands/_create-branch.md +++ /dev/null @@ -1,12 +0,0 @@ -create a new branch with a suitable name. - -Create the branch only (no commits yet). Use Conventional Commit types as branch prefixes: -feat/, fix/, docs/, style/, refactor/, perf/, test/, build/, ci/, chore/, revert/. - -Keep the branch name short (≤ 50 chars) yet informative. Do not push, pull, or rebase. - -Examples: - -- feat/parser-add-php-84-attributes -- fix/ci-matrix-php-versions -- chore/deps-bump-composer-installers-2-3 diff --git a/.cursor/commands/_create-commits.md b/.cursor/commands/_create-commits.md deleted file mode 100644 index b2aa932d..00000000 --- a/.cursor/commands/_create-commits.md +++ /dev/null @@ -1,13 +0,0 @@ -create one or more commits with suitable titles. - -Use Conventional Commits to group related changes into cohesive commits (commits should be independently meaningful). - -Keep titles short (≤ 72 chars), imperative, no trailing period. Do not push, pull, or rebase. - -Examples: - -- feat(parser): add support for PHP 8.4 attributes -- fix(ci): correct matrix PHP versions in build workflow -- chore(deps): bump composer/installers to ^2.3 - -Body (optional): explain motivation, context, and breaking changes (use BREAKING CHANGE:). diff --git a/.cursor/commands/_in-branch.md b/.cursor/commands/_in-branch.md deleted file mode 100644 index 693478cd..00000000 --- a/.cursor/commands/_in-branch.md +++ /dev/null @@ -1 +0,0 @@ -in this branch, compared to the branch it's based on diff --git a/.cursor/commands/_in-diff.md b/.cursor/commands/_in-diff.md deleted file mode 100644 index 50cc58c5..00000000 --- a/.cursor/commands/_in-diff.md +++ /dev/null @@ -1 +0,0 @@ -in this Git working tree, staged or unstaged diff --git a/.cursor/commands/_report.md b/.cursor/commands/_report.md deleted file mode 100644 index 9423d750..00000000 --- a/.cursor/commands/_report.md +++ /dev/null @@ -1 +0,0 @@ -provide a detailed report but don't make any changes yet. diff --git a/.cursor/commands/_review.md b/.cursor/commands/_review.md deleted file mode 100644 index 0325e1af..00000000 --- a/.cursor/commands/_review.md +++ /dev/null @@ -1 +0,0 @@ -review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules as well as finding potential bugs and regressions diff --git a/.cursor/commands/create-branch-and-commits.md b/.cursor/commands/create-branch-and-commits.md deleted file mode 100644 index ea391a19..00000000 --- a/.cursor/commands/create-branch-and-commits.md +++ /dev/null @@ -1,27 +0,0 @@ -Meticulously catalog and analyze all the changes in this Git working tree, staged or unstaged -and create a new branch with a suitable name. - -Create the branch only (no commits yet). Use Conventional Commit types as branch prefixes: -feat/, fix/, docs/, style/, refactor/, perf/, test/, build/, ci/, chore/, revert/. - -Keep the branch name short (≤ 50 chars) yet informative. Do not push, pull, or rebase. - -Examples: - -- feat/parser-add-php-84-attributes -- fix/ci-matrix-php-versions -- chore/deps-bump-composer-installers-2-3 - -Then, create one or more commits with suitable titles. - -Use Conventional Commits to group related changes into cohesive commits (commits should be independently meaningful). - -Keep titles short (≤ 72 chars), imperative, no trailing period. Do not push, pull, or rebase. - -Examples: - -- feat(parser): add support for PHP 8.4 attributes -- fix(ci): correct matrix PHP versions in build workflow -- chore(deps): bump composer/installers to ^2.3 - -Body (optional): explain motivation, context, and breaking changes (use BREAKING CHANGE:). diff --git a/.cursor/commands/create-branch.md b/.cursor/commands/create-branch.md index 25956302..813d3c49 100644 --- a/.cursor/commands/create-branch.md +++ b/.cursor/commands/create-branch.md @@ -1,5 +1,4 @@ -Meticulously catalog and analyze all the changes in this Git working tree, staged or unstaged -and create a new branch with a suitable name. +Based on the changes made to this repository create a new branch with a suitable name. Create the branch only (no commits yet). Use Conventional Commit types as branch prefixes: feat/, fix/, docs/, style/, refactor/, perf/, test/, build/, ci/, chore/, revert/. diff --git a/.cursor/commands/create-commits.md b/.cursor/commands/create-commits.md index 3f068980..68eb700c 100644 --- a/.cursor/commands/create-commits.md +++ b/.cursor/commands/create-commits.md @@ -1,5 +1,4 @@ -Meticulously catalog and analyze all the changes in this Git working tree, staged or unstaged -and create one or more commits with suitable titles. +Based on the changes made to this repository create one or more commits with suitable titles. Use Conventional Commits to group related changes into cohesive commits (commits should be independently meaningful). diff --git a/.cursor/commands/deslop.md b/.cursor/commands/deslop.md new file mode 100644 index 00000000..f1eafd10 --- /dev/null +++ b/.cursor/commands/deslop.md @@ -0,0 +1,12 @@ +# Remove AI code slop + +Check the diff against main, and remove all AI-generated slop introduced in this branch. + +This includes: + +- Extra comments that a human wouldn't add or are inconsistent with the rest of the file +- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths) +- Casts to any to get around type issues +- Any other style that is inconsistent with the file + +Report at the end with only a 1-3 sentence summary of what you changed diff --git a/.cursor/rules/00-main.mdc b/.cursor/rules/00-main.mdc index aea25374..5331ec5b 100644 --- a/.cursor/rules/00-main.mdc +++ b/.cursor/rules/00-main.mdc @@ -10,7 +10,7 @@ All rules MANDATORY. Build Deployer PHP: Composer package and CLI tool simplifying server provisioning and deployment across multiple Cloud providers. -### Before ANY Task +### References - Check `composer.json` and `package.json` for installed packages - Plan with features from installed major versions @@ -31,8 +31,8 @@ Build Deployer PHP: Composer package and CLI tool simplifying server provisionin - Catalog like a librarian - Group related functions into comment-separated sections -- Prefer alphabetical ordering when no logical grouping exists -- Code should be functional and visually appealing +- Order alphabetically after grouping logically +- Code should be both functional and visually appealing **Consistency:** @@ -47,8 +47,8 @@ Build Deployer PHP: Composer package and CLI tool simplifying server provisionin 2. STEP BY STEP - break into logical steps 3. ACT - implement systematically -### Tests +### Test later Don't run or create or update tests UNLESS explicitly instructed to do so. -Test are something we need to focus on separately from building features. +Tests are something we will focus on separately from building features. diff --git a/.cursor/rules/01-architecture.mdc b/.cursor/rules/01-architecture.mdc index 13289df6..91c568b1 100644 --- a/.cursor/rules/01-architecture.mdc +++ b/.cursor/rules/01-architecture.mdc @@ -35,7 +35,7 @@ Always add `use` statements for vendor packages and project classes. Root namesp ```php // ✅ CORRECT use Symfony\Component\Filesystem\Filesystem; -use Bigpixelrocket\DeployerPHP\Services\IOService; +use PHPDeployer\Services\IOService; $fs = new Filesystem(); throw new \InvalidArgumentException('Error'); diff --git a/.cursor/rules/02-tests.mdc b/.cursor/rules/02-tests.mdc index 3d7a3940..bac3bd0b 100644 --- a/.cursor/rules/02-tests.mdc +++ b/.cursor/rules/02-tests.mdc @@ -8,7 +8,7 @@ All rules MANDATORY. **Philosophy:** "A test that never fails is not a test, it's a lie." -**Framework:** Pest exclusively, `it()` syntax, 80%+ coverage +**Framework:** Pest exclusively, `it()` syntax, 70%+ coverage ### Running Tests diff --git a/.cursor/rules/03-commands.mdc b/.cursor/rules/03-commands.mdc index e5bd9a10..36100df1 100644 --- a/.cursor/rules/03-commands.mdc +++ b/.cursor/rules/03-commands.mdc @@ -6,6 +6,10 @@ alwaysApply: true All rules MANDATORY. Fix violating code, not these rules. +### Core Principle + +Every command MUST be fully runnable non-interactively via CLI options. Every prompt MUST have a corresponding CLI option. Test by running the command with only CLI options (no TTY). + ### Output Method Philosophy NEVER use Symfony IO methods directly - use BaseCommand methods exclusively. @@ -16,15 +20,18 @@ All console output flows through custom methods in BaseCommand for consistent TU ```php // Output -$this->writeln(['Multiple', 'lines']); -$this->io->hr(); -$this->io->h1('Section Heading'); +$this->out(['Multiple', 'lines']); +$this->hr(); +$this->h1('Section Heading'); +$this->displayDeets(['Key' => 'value']); // Status messages -$this->success('Operation completed'); // Green checkmark -$this->error('Operation failed'); // Red X -$this->warning('Skipping step'); // Yellow warning -$this->info('Configuration loaded'); // Cyan info +$this->yay('Operation completed'); // ✓ checkmark +$this->nay('Operation failed'); // ✗ red X +$this->warn('Skipping step'); // ! warning +$this->info('Configuration loaded'); // ℹ info +$this->ul(['Item 1', 'Item 2']); // • bullet list +$this->ol(['Step 1', 'Step 2']); // 1. numbered list ``` **Missing a method?** Add to BaseCommand with modern styling. @@ -34,7 +41,7 @@ $this->info('Configuration loaded'); // Cyan info - Base: BaseCommand.php - Output: ConsoleOutputTrait.php - Input: ConsoleInputTrait.php -- Methods: `writeln()`, `info()`, `hr()`, `h1()`, `success()`, `error()`, `warning()`, `getOptionOrPrompt()`, `getValidatedOptionOrPrompt()`, `promptText()`, `promptPassword()`, `promptConfirm()`, `promptSelect()`, `promptMultiselect()`, `promptSuggest()`, `promptSearch()`, `promptPause()`, `promptSpin()`, `showCommandReplay()` +- Methods: `out()`, `hr()`, `h1()`, `info()`, `yay()`, `nay()`, `warn()`, `ul()`, `ol()`, `displayDeets()`, `commandReplay()`, `getOptionOrPrompt()`, `getValidatedOptionOrPrompt()`, `promptText()`, `promptPassword()`, `promptConfirm()`, `promptSelect()`, `promptMultiselect()`, `promptSuggest()`, `promptSearch()`, `promptPause()`, `promptSpin()` **Trait Organization:** @@ -63,46 +70,43 @@ Commands handle ALL user interaction. Services return plain data with NO console Support both interactive prompts AND CLI options using `getOptionOrPrompt()`. -**Pattern:** +**Basic Pattern:** ```php protected function configure(): void { parent::configure(); $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'Server name'); - $this->addOption('host', null, InputOption::VALUE_REQUIRED, 'Host/IP address'); - $this->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation'); + $this->addOption('environment', null, InputOption::VALUE_REQUIRED, 'Environment'); } protected function execute(InputInterface $input, OutputInterface $output): int { - // Checks CLI option first, prompts if not provided - $name = $this->getOptionOrPrompt( + // Text input - checks CLI option first, prompts if not provided + $name = $this->io->getOptionOrPrompt( 'name', - fn() => $this->promptText( + fn () => $this->io->promptText( label: 'Server name:', - placeholder: 'production-web-01', + placeholder: 'web1', required: true ) ); - $host = $this->getOptionOrPrompt( - 'host', - fn() => $this->promptText('Host/IP:', placeholder: '192.168.1.100', required: true) - ); - - $skipConfirm = $this->getOptionOrPrompt( - 'yes', - fn() => $this->promptConfirm('Skip verification?', default: false) + // Select input + $env = $this->io->getOptionOrPrompt( + 'environment', + fn () => $this->io->promptSelect( + label: 'Environment:', + options: ['dev', 'staging', 'prod'], + default: 'prod' + ) ); - // ... process command ... - return Command::SUCCESS; } ``` -**Signature:** `getOptionOrPrompt(string $optionName, Closure $promptCallback): mixed` +**Signature:** `$this->io->getOptionOrPrompt(string $optionName, Closure $promptCallback): mixed` -Prompt wrappers (`promptText()`, `promptSelect()`, etc.) automatically suppress extra spacing for clean output. +Prompt wrappers (`$this->io->promptText()`, `$this->io->promptSelect()`, etc.) automatically suppress extra spacing for clean output. ### Input Validation Pattern @@ -141,14 +145,14 @@ protected function validateName(string $name): void **Usage:** ```php -$name = $this->getValidatedOptionOrPrompt( +$name = $this->io->getValidatedOptionOrPrompt( 'name', - fn ($validate) => $this->promptText( + fn ($validate) => $this->io->promptText( label: 'Server name:', placeholder: 'web1', - validate: $validate + validate: $validate // Pass validator to prompt ), - fn ($value) => $this->validateNameInput($value) + fn ($value) => $this->validateNameInput($value) // Returns ?string ); if ($name === null) { @@ -165,17 +169,213 @@ Validator injected into prompt callback, validates interactively and on CLI opti **Examples:** ServerValidationTrait.php, SiteValidationTrait.php -**Testing Validation Traits:** +### Boolean Flags + +**VALUE_NONE - Simple flags:** + +```php +// --yes or -y +$this->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation'); + +$confirmed = $this->io->getOptionOrPrompt( + 'yes', + fn () => $this->io->promptConfirm('Deploy now?', default: true) +); +``` + +**VALUE_NEGATABLE - Tri-state flags:** + +```php +// --php-default or --no-php-default +$this->addOption('php-default', null, InputOption::VALUE_NEGATABLE, 'Set as default PHP'); + +// Prompts if neither --flag nor --no-flag provided +$setDefault = $this->io->getOptionOrPrompt( + 'php-default', + fn () => $this->io->promptConfirm('Set as default?', default: false) +); +``` + +### Multiselect with Comma-Separated CLI Input + +Multiselect prompts return arrays, but CLI options are strings. Handle both: + +```php +$this->addOption('databases', null, InputOption::VALUE_REQUIRED, 'Comma-separated databases'); + +$selected = $this->io->getOptionOrPrompt( + 'databases', + fn () => $this->io->promptMultiselect( + label: 'Database servers (optional):', + options: ['postgresql' => 'PostgreSQL', 'mysql' => 'MySQL', 'mongodb' => 'MongoDB'], + default: [], + hint: 'Space to select, Enter to confirm' + ) +); + +// Handle both array (from prompt) and string (from CLI option) +if (is_string($selected)) { + $selected = array_filter( + array_map(trim(...), explode(',', $selected)), + static fn (string $item): bool => $item !== '' + ); +} + +// Validate CLI-provided values against allowed options +$unknown = array_diff($selected, array_keys($allowedOptions)); +if ($unknown !== []) { + $this->nay('Unknown options: ' . implode(', ', $unknown)); + return Command::FAILURE; +} +``` + +### Multi-Path Prompts + +When a prompt offers choices leading to different execution paths, create separate options for each path: + +```php +// ❌ WRONG - Only one option, can't skip the choice prompt non-interactively +$this->addOption('deploy-key', null, InputOption::VALUE_REQUIRED, 'Path to deploy key'); + +// ✅ CORRECT - Separate options for each path +$this->addOption('generate-deploy-key', null, InputOption::VALUE_NONE, 'Use server-generated deploy key'); +$this->addOption('custom-deploy-key', null, InputOption::VALUE_REQUIRED, 'Path to custom deploy key'); +``` + +Handle the branching logic explicitly: ```php -class TestValidator { use ServerValidationTrait; - public function testValidateHost(mixed $host): ?string { - return $this->validateHostInput($host); +/** @var bool $generateKey */ +$generateKey = $input->getOption('generate-deploy-key'); +/** @var string|null $customKeyPath */ +$customKeyPath = $input->getOption('custom-deploy-key'); + +// Check for conflicting options +if ($generateKey && $customKeyPath !== null) { + $this->nay('Cannot use both --generate-deploy-key and --custom-deploy-key'); + return Command::FAILURE; +} + +if ($generateKey) { + $deployKeyPath = null; // Use server-generated +} elseif ($customKeyPath !== null) { + $deployKeyPath = $customKeyPath; // Use custom +} else { + // Interactive: prompt for choice, then conditionally prompt for path + $choice = $this->io->promptSelect( + label: 'Deploy key:', + options: [ + 'generate' => 'Use server-generated key pair', + 'custom' => 'Use your own key pair', + ], + default: 'generate' + ); + + if ($choice === 'generate') { + $deployKeyPath = null; + } else { + $deployKeyPath = $this->io->promptText( + label: 'Path to private key:', + placeholder: '~/.ssh/deploy_key', + required: true + ); } } +``` + +### Confirmation Patterns + +**Simple Confirmation (`--yes`):** + +```php +$this->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation'); + +$confirmed = $this->io->getOptionOrPrompt( + 'yes', + fn () => $this->io->promptConfirm('Are you sure?', default: false) +); -expect($validator->testValidateHost('192.168.1.100'))->toBeNull(); -expect($validator->testValidateHost('invalid'))->toContain('valid'); +if (!$confirmed) { + $this->warn('Operation cancelled'); + return Command::SUCCESS; +} +``` + +**Type-to-Confirm (`--force`) - For destructive operations:** + +```php +$this->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip typing confirmation'); + +/** @var bool $forceSkip */ +$forceSkip = $input->getOption('force') ?? false; + +if (!$forceSkip) { + $typedName = $this->io->promptText( + label: "Type the server name '{$server->name}' to confirm deletion:", + required: true + ); + + if ($typedName !== $server->name) { + $this->nay('Name does not match. Operation cancelled.'); + return Command::FAILURE; + } +} +``` + +### Post-CLI Validation + +When using `getOptionOrPrompt()` (not validated version), validate CLI values explicitly: + +```php +$phpVersion = (string) $this->io->getOptionOrPrompt( + 'php-version', + fn () => $this->io->promptSelect( + label: 'PHP version:', + options: $availableVersions, + default: $defaultVersion + ) +); + +// Validate CLI-provided version exists +if (!in_array($phpVersion, $availableVersions, true)) { + $this->nay( + "PHP {$phpVersion} not available. Available: " . implode(', ', $availableVersions) + ); + return Command::FAILURE; +} +``` + +### Resource Selection Pattern + +For selecting from existing resources: + +```php +protected function selectServer(): ServerDTO|int +{ + $servers = $this->ensureServersAvailable(); + if (is_int($servers)) { + return Command::FAILURE; + } + + $serverNames = array_map(fn (ServerDTO $s) => $s->name, $servers); + + $name = (string) $this->io->getOptionOrPrompt( + 'server', + fn () => $this->io->promptSelect( + label: 'Select server:', + options: $serverNames, + ) + ); + + // Validate CLI-provided name exists + $server = $this->servers->findByName($name); + if ($server === null) { + $this->nay("Server '{$name}' not found in inventory"); + return Command::FAILURE; + } + + return $server; +} ``` ### Command Options & Input @@ -204,18 +404,19 @@ Prevents conflicts in commands working with multiple resource types. - Use OPTIONS only, never ARGUMENTS (enables `getOptionOrPrompt()`) - Pair all options with `getOptionOrPrompt()` for dual-mode support -- Boolean flags: `VALUE_NONE` +- Boolean flags: `VALUE_NONE` or `VALUE_NEGATABLE` - Data inputs: `VALUE_REQUIRED` - Only `--yes` (`-y`) and `--force` (`-f`) get short flags +- Add type annotations on option retrievals: `/** @var bool $flag */` See: ServerDeleteCommand.php, ServerAddCommand.php ### Command Completion -Always call `showCommandReplay()` before returning SUCCESS to teach non-interactive usage: +Always call `commandReplay()` before returning SUCCESS to teach non-interactive usage: ```php -$this->showCommandReplay('command:name', [ +$this->commandReplay('command:name', [ 'option1' => $value1, 'option2' => $value2, ]); @@ -223,6 +424,27 @@ $this->showCommandReplay('command:name', [ return Command::SUCCESS; ``` +**For conditional options, only include if applicable:** + +```php +$replayOptions = [ + 'server' => $server->name, + 'php-version' => $phpVersion, +]; + +if ($deployKeyPath !== null) { + $replayOptions['custom-deploy-key'] = $deployKeyPath; +} else { + $replayOptions['generate-deploy-key'] = true; +} + +if ($defaultPrompted) { + $replayOptions['php-default'] = $setDefault; +} + +$this->commandReplay('server:install', $replayOptions); +``` + Teaches CLI syntax, improves DX, reduces support questions. **Output:** @@ -236,3 +458,15 @@ Teaches CLI syntax, improves DX, reduces support questions. ``` See: ServerDeleteCommand.php + +### Summary Checklist + +When building commands, verify: + +- [ ] Every prompt has a corresponding `addOption()` +- [ ] Multi-path prompts have separate options for each path +- [ ] Conflicting options are detected and rejected +- [ ] CLI values are validated against allowed options +- [ ] Multiselect handles both array and comma-separated string +- [ ] `commandReplay()` called before SUCCESS return +- [ ] Type annotations on option retrievals (`/** @var bool $flag */`) diff --git a/.cursor/rules/06-playbooks.mdc b/.cursor/rules/06-playbooks.mdc index d22d225d..e70ea557 100644 --- a/.cursor/rules/06-playbooks.mdc +++ b/.cursor/rules/06-playbooks.mdc @@ -258,7 +258,7 @@ Use `→` (Unicode rightwards arrow U+2192) to indicate an operation is about to ```bash # ✅ CORRECT - Explicit details (path, package name, repository name) echo "→ Creating /var/www/app directory..." -echo "→ Installing PHP 8.3..." +echo "→ Installing PHP 8.5..." echo "→ Adding Caddy GPG key..." # ❌ WRONG - Too generic diff --git a/.cursor/rules/rules.mdc b/.cursor/rules/rules.mdc index 40d83021..278e1bf0 100644 --- a/.cursor/rules/rules.mdc +++ b/.cursor/rules/rules.mdc @@ -124,18 +124,7 @@ Use `$container->build(Class::class)` for all object creation. Exceptions: DTOs, value objects, pure data structures. ``` -## Rule Density Guidelines - -**Target ratios (lines of rules per file):** - -- Meta/philosophy: 30-50 lines -- Architecture/patterns: 80-120 lines -- Testing: 80-120 lines -- Console/CLI: 180-220 lines -- Language style (Bash/PHP): 60-100 lines -- Project-specific (Playbooks): 100-140 lines - -**Token budget awareness:** +## Token Budget Awareness - AI agents may have 8K-32K context windows - Rules should consume <20% of available tokens diff --git a/.gitignore b/.gitignore index 50d40b8b..56a54847 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .idea/ .vscode/ node_modules/ +scripts/ vendor/ .DS_Store .env diff --git a/README.md b/README.md index a145c1c5..530c392d 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,8 @@ ``` - ╭────────────────────────────────────────── - ┌┬┐┌─┐┌─┐┬ ┌─┐┬ ┬┌─┐┬─┐ - ││├┤ ├─┘│ │ │└┬┘├┤ ├┬┘ - ─┴┘└─┘┴ ┴─┘└─┘ ┴ └─┘┴└─PHP - - The Server & Site Deployment Tool for PHP - ╰────────────────────────────────────────── +▒ PHP Deployer • dev-main • Server & Site Deployment for PHP +▒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` -[![Tests](https://img.shields.io/badge/tests-passing-brightgreen.svg)](https://github.com/bigpixelrocket/deployer-php) +[![Tests](https://img.shields.io/badge/tests-passing-brightgreen.svg)](https://github.com/php-deployer/php-deployer) [![PHP Version](https://img.shields.io/badge/php-%5E8.2-blue.svg)](https://php.net) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) diff --git a/app/Console/HelloCommand.php b/app/Console/HelloCommand.php index e1010823..7df660c9 100644 --- a/app/Console/HelloCommand.php +++ b/app/Console/HelloCommand.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console; +namespace PHPDeployer\Console; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; +use PHPDeployer\Contracts\BaseCommand; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; diff --git a/app/Console/Key/KeyAddDigitalOceanCommand.php b/app/Console/Key/KeyAddDigitalOceanCommand.php index 4ee1e369..99a4eef1 100644 --- a/app/Console/Key/KeyAddDigitalOceanCommand.php +++ b/app/Console/Key/KeyAddDigitalOceanCommand.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Key; +namespace PHPDeployer\Console\Key; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanTrait; -use Bigpixelrocket\DeployerPHP\Traits\KeysTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\Traits\DigitalOceanTrait; +use PHPDeployer\Traits\KeysTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -43,7 +43,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Add SSH Key to DigitalOcean'); + $this->h1('Add SSH Key to DigitalOcean'); // // Retrieve DigitalOcean account data @@ -89,7 +89,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('key:add:digitalocean', [ + $this->commandReplay('key:add:digitalocean', [ 'public-key-path' => $publicKeyPath, 'name' => $keyName, ]); diff --git a/app/Console/Key/KeyDeleteDigitalOceanCommand.php b/app/Console/Key/KeyDeleteDigitalOceanCommand.php index da9bcfdb..38ea1277 100644 --- a/app/Console/Key/KeyDeleteDigitalOceanCommand.php +++ b/app/Console/Key/KeyDeleteDigitalOceanCommand.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Key; +namespace PHPDeployer\Console\Key; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanTrait; -use Bigpixelrocket\DeployerPHP\Traits\KeysTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\Traits\DigitalOceanTrait; +use PHPDeployer\Traits\KeysTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -44,7 +44,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Delete a public SSH key from DigitalOcean'); + $this->h1('Delete a public SSH key from DigitalOcean'); // // Retrieve DigitalOcean account data @@ -73,14 +73,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Display key // ---- - $this->io->hr(); - - $this->io->displayDeets([ + $this->displayDeets([ 'ID' => (string) $keyId, 'Name' => $keyDescription, ]); - $this->io->writeln(''); + $this->out('───'); + $this->io->write('', true); // // Confirm deletion with extra safety @@ -90,8 +89,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $forceSkip = $input->getOption('force') ?? false; if (!$forceSkip) { - $this->io->writeln(''); - $typedKeyId = $this->io->promptText( label: "Type the key ID '{$keyId}' to confirm deletion:", required: true @@ -114,8 +111,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); if (!$confirmed) { - $this->io->warning('Cancelled deleting public SSH key'); - $this->io->writeln(''); + $this->warn('Cancelled deleting public SSH key'); return Command::SUCCESS; } @@ -141,7 +137,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('key:delete:digitalocean', [ + $this->commandReplay('key:delete:digitalocean', [ 'key' => (string) $keyId, 'yes' => $confirmed, 'force' => true, diff --git a/app/Console/Key/KeyListDigitalOceanCommand.php b/app/Console/Key/KeyListDigitalOceanCommand.php index dc379c4e..313ee464 100644 --- a/app/Console/Key/KeyListDigitalOceanCommand.php +++ b/app/Console/Key/KeyListDigitalOceanCommand.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Key; +namespace PHPDeployer\Console\Key; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\Traits\DigitalOceanTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -27,7 +27,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('List Public SSH Keys in DigitalOcean'); + $this->h1('List Public SSH Keys in DigitalOcean'); // // Retrieve DigitalOcean account data @@ -47,11 +47,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Display keys // ---- - foreach ($keys as $keyId => $description) { - $this->io->writeln(" {$keyId} - {$description}"); - } + $this->displayDeets($keys); + + // + // Show command replay + // ---- - $this->io->writeln(''); + $this->commandReplay('key:list:digitalocean', []); return Command::SUCCESS; } diff --git a/app/Console/ScaffoldHooksCommand.php b/app/Console/ScaffoldHooksCommand.php index 0aff5179..7ca64b54 100644 --- a/app/Console/ScaffoldHooksCommand.php +++ b/app/Console/ScaffoldHooksCommand.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console; +namespace PHPDeployer\Console; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; +use PHPDeployer\Contracts\BaseCommand; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -36,7 +36,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Scaffold Deployment Hooks'); + $this->h1('Scaffold Deployment Hooks'); // // Get destination directory @@ -74,7 +74,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->yay('Created deployment hooks in the destination directory'); - $this->showCommandReplay('scaffold:hooks', [ + $this->commandReplay('scaffold:hooks', [ 'destination' => $destinationDir, ]); @@ -102,6 +102,8 @@ private function createHookTemplates(string $destination): void } $entries = scandir($scaffoldsPath) ?: []; + $status = []; + foreach ($entries as $entry) { $source = $scaffoldsPath.'/'.$entry; $target = $destination.'/'.$entry; @@ -117,9 +119,10 @@ private function createHookTemplates(string $destination): void $skipped = false; } - $this->io->writeln('' . ($skipped ? 'skipped' : 'created') . ' ' . $entry); + $status[$entry] = $skipped ? 'skipped' : 'created'; } - $this->io->writeln(''); + $this->displayDeets($status); + $this->out(''); } } diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php index 3dfc921c..604c2d9d 100644 --- a/app/Console/Server/ServerAddCommand.php +++ b/app/Console/Server/ServerAddCommand.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Server; +namespace PHPDeployer\Console\Server; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Traits\KeysTrait; -use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\Traits\KeysTrait; +use PHPDeployer\Traits\PlaybooksTrait; +use PHPDeployer\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -49,7 +49,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Add New Server'); + $this->h1('Add New Server'); // // Gather server details @@ -69,28 +69,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'privateKeyPath' => $privateKeyPath, ] = $deets; - // - // Display server details - // ---- - - $server = new ServerDTO( + // Create server DTO + $server = $this->serverInfo(new ServerDTO( name: $name, host: $host, port: $port, username: $username, privateKeyPath: $privateKeyPath - ); - - $this->displayServerDeets($server); + )); - // - // Get server info (verifies SSH connection and validates distribution & permissions) - // ---- - - $info = $this->serverInfo($server); - - if (is_int($info)) { - return $info; + if (is_int($server)) { + return Command::FAILURE; } // @@ -111,7 +100,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('server:add', [ + $this->commandReplay('server:add', [ 'name' => $name, 'host' => $host, 'port' => $port, diff --git a/app/Console/Server/ServerDeleteCommand.php b/app/Console/Server/ServerDeleteCommand.php index 9ce49669..7f2c85d0 100644 --- a/app/Console/Server/ServerDeleteCommand.php +++ b/app/Console/Server/ServerDeleteCommand.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Server; +namespace PHPDeployer\Console\Server; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\Traits\DigitalOceanTrait; +use PHPDeployer\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -15,7 +15,7 @@ #[AsCommand( name: 'server:delete', - description: 'Delete a server from the inventory' + description: 'Delete a server from inventory' )] class ServerDeleteCommand extends BaseCommand { @@ -44,7 +44,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Delete Server'); + $this->h1('Delete Server'); // // Select server & display details @@ -56,31 +56,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $server; } - $this->displayServerDeets($server); - - // - // Get associated sites - // ---- + $isDigitalOceanServer = $server->provider === 'digitalocean' && $server->dropletId !== null; $serverSites = $this->sites->findByServer($server->name); - // - // Initialize provider API - // ---- - - $isDigitalOceanServer = $this->isDigitalOceanServer($server); - if ($isDigitalOceanServer && Command::FAILURE === $this->initializeDigitalOceanAPI()) { - $this->nay('Cannot delete server: DigitalOcean API authentication failed.'); - $this->io->writeln([ - '', - 'You must authenticate with DigitalOcean to delete provisioned servers.', - 'The server will not be removed from inventory to prevent orphaned cloud resources.', - '', - ]); - - return Command::FAILURE; - } - // // Prepare site deletion info // ---- @@ -99,35 +78,37 @@ protected function execute(InputInterface $input, OutputInterface $output): int // ---- if ($isDigitalOceanServer) { - $this->io->warning('This is a DigitalOcean server.'); - $this->io->writeln([ - " Droplet ID: {$server->dropletId}", - '', - ]); - - $this->io->warning('This will:'); + $this->info('This is a DigitalOcean droplet (ID: ' . $server->dropletId . ')'); $messages = [ - ' • Destroy the droplet on DigitalOcean', - ' • Remove the server from inventory', + 'Destroy the droplet on DigitalOcean', + 'Remove the server from inventory', ]; if ($hasSites) { - $messages[] = " • Delete {$siteCount} associated site(s): {$sitesList}"; + $messages[] = "Delete {$siteCount} associated site(s): {$sitesList}"; } - $messages[] = ''; - - $this->io->writeln($messages); + $this->info('This will:'); + $this->ul($messages); } elseif ($hasSites) { - $this->io->warning('This will:'); - $this->io->writeln([ - ' • Remove the server from inventory', - " • Delete {$siteCount} associated site(s): {$sitesList}", - '', + $this->info('This will:'); + $this->ul([ + 'Remove the server from inventory', + "Delete {$siteCount} associated site(s): {$sitesList}", ]); } + // + // Initialize provider API + // ---- + + if ($isDigitalOceanServer && Command::FAILURE === $this->initializeDigitalOceanAPI()) { + $this->nay('Cannot delete server: DigitalOcean API authentication failed.'); + + return Command::FAILURE; + } + // // Confirm deletion with extra safety // ---- @@ -158,8 +139,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); if (!$confirmed) { - $this->io->warning('Cancelled deleting server'); - $this->io->writeln(''); + $this->warn('Cancelled deleting server'); return Command::SUCCESS; } @@ -181,7 +161,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $destroyed = true; } catch (\RuntimeException $e) { $this->nay($e->getMessage()); - $this->io->writeln(''); + $this->out(''); $continueAnyway = $this->io->promptConfirm( label: 'Remove from inventory anyway?', @@ -216,11 +196,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if (!$destroyed) { - $this->io->warning('Your server may still be running and incurring costs!'); - $this->io->writeln([ - '', + $this->info('Your server may still be running and incurring costs:'); + $this->out([ 'Check with your cloud provider to ensure it is fully terminated.', - '', ]); } @@ -228,7 +206,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('server:delete', [ + $this->commandReplay('server:delete', [ 'server' => $server->name, 'yes' => $confirmed, 'force' => true, diff --git a/app/Console/Server/ServerInfoCommand.php b/app/Console/Server/ServerInfoCommand.php index bdf2fc81..d6ca87d5 100644 --- a/app/Console/Server/ServerInfoCommand.php +++ b/app/Console/Server/ServerInfoCommand.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Server; +namespace PHPDeployer\Console\Server; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\Enums\Distribution; +use PHPDeployer\Traits\PlaybooksTrait; +use PHPDeployer\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -41,7 +42,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Server Information'); + $this->h1('Server Information'); // // Select server & display details @@ -49,31 +50,331 @@ protected function execute(InputInterface $input, OutputInterface $output): int $server = $this->selectServer(); - if (is_int($server)) { - return $server; + if (is_int($server) || $server->info === null) { + return Command::FAILURE; } - $this->displayServerDeets($server); - - // - // Get server info (verifies SSH connection and validates distribution & permissions) - // ---- - - $info = $this->serverInfo($server); - - if (is_int($info)) { - return $info; - } + $this->displayServerInfo($server->info, $server->host); // // Show command replay // ---- - $this->showCommandReplay('server:info', [ + $this->commandReplay('server:info', [ 'server' => $server->name, ]); return Command::SUCCESS; } + // ---- + // Helpers + // ---- + + /** + * Display formatted server information. + * + * @param array $info + */ + private function displayServerInfo(array $info, string $host): void + { + /** @var string $distroSlug */ + $distroSlug = $info['distro'] ?? 'unknown'; + $distribution = Distribution::tryFrom($distroSlug); + $distroName = $distribution?->displayName() ?? 'Unknown'; + + $permissionsText = match ($info['permissions'] ?? 'none') { + 'root' => 'root', + 'sudo' => 'sudo', + default => 'insufficient', + }; + + $deets = [ + 'Distro' => $distroName, + 'User' => $permissionsText, + ]; + + $this->displayDeets($deets); + + // Display hardware information if available + if (isset($info['hardware']) && is_array($info['hardware'])) { + $hardwareItems = []; + + if (isset($info['hardware']['cpu_cores'])) { + /** @var int|string $cpuCores */ + $cpuCores = $info['hardware']['cpu_cores']; + $coresText = $cpuCores === '1' || $cpuCores === 1 ? '1 core' : "{$cpuCores} cores"; + $hardwareItems['CPU'] = $coresText; + } + + if (isset($info['hardware']['ram_mb'])) { + /** @var int|string $ramMb */ + $ramMb = $info['hardware']['ram_mb']; + $ramGb = round((int) $ramMb / 1024, 1); + $ramText = $ramGb >= 1 ? "{$ramGb} GB" : "{$ramMb} MB"; + $hardwareItems['RAM'] = $ramText; + } + + if (isset($info['hardware']['disk_type'])) { + /** @var string $diskType */ + $diskType = $info['hardware']['disk_type']; + $diskText = strtoupper($diskType); + $hardwareItems['Disk'] = $diskText; + } + + if (count($hardwareItems) > 0) { + $this->displayDeets(['Hardware' => $hardwareItems]); + } + } + + $services = []; + + // Add listening ports if any + if (isset($info['ports']) && is_array($info['ports']) && count($info['ports']) > 0) { + $portsList = []; + foreach ($info['ports'] as $port => $process) { + if (is_numeric($port) && is_string($process)) { + $portsList["Port {$port}"] = $process; + } + } + + if (count($portsList) > 0) { + $services = $portsList; + } + } + + $this->displayDeets(['Services' => $services]); + + // Display Caddy information if available + if (isset($info['caddy']) && is_array($info['caddy']) && ($info['caddy']['available'] ?? false) === true) { + $caddyItems = []; + + if (isset($info['caddy']['version']) && $info['caddy']['version'] !== 'unknown') { + /** @var string $version */ + $version = $info['caddy']['version']; + $caddyItems['Version'] = $version; + } + + if (isset($info['caddy']['uptime_seconds'])) { + /** @var int|string|float $rawUptime */ + $rawUptime = $info['caddy']['uptime_seconds']; + /** @var int $uptimeSeconds */ + $uptimeSeconds = (int) $rawUptime; + $caddyItems['Uptime'] = $this->formatUptime($uptimeSeconds); + } + + if (isset($info['caddy']['total_requests'])) { + /** @var int|string|float $rawTotalReq */ + $rawTotalReq = $info['caddy']['total_requests']; + /** @var int $totalReq */ + $totalReq = (int) $rawTotalReq; + $caddyItems['Total Req'] = number_format($totalReq); + } + + if (isset($info['caddy']['active_requests'])) { + /** @var int|string $activeRequests */ + $activeRequests = $info['caddy']['active_requests']; + $caddyItems['Active Req'] = $activeRequests; + } + + if (isset($info['caddy']['memory_mb']) && $info['caddy']['memory_mb'] !== '0') { + /** @var string $memoryMb */ + $memoryMb = $info['caddy']['memory_mb']; + $caddyItems['Memory'] = $memoryMb.' MB'; + } + + if (count($caddyItems) > 0) { + $this->displayDeets(['Caddy' => $caddyItems]); + } + } + + // Display PHP versions if available + if (isset($info['php']) && is_array($info['php'])) { + $phpItems = []; + + if (isset($info['php']['versions']) && is_array($info['php']['versions']) && count($info['php']['versions']) > 0) { + $versions = $info['php']['versions']; + $defaultVersion = $info['php']['default'] ?? null; + + foreach ($versions as $versionData) { + // Handle both old format (string) and new format (array with version/extensions) + if (is_array($versionData) && isset($versionData['version'])) { + /** @var string|int|float */ + $version = $versionData['version']; + $versionStr = (string) $version; + $extensions = $versionData['extensions'] ?? []; + + // Build version label + $isDefault = false; + if ($defaultVersion !== null && (is_string($defaultVersion) || is_numeric($defaultVersion))) { + /** @var string|int|float $defaultVersion */ + $isDefault = $versionStr === (string) $defaultVersion; + } + + $versionLabel = "PHP {$versionStr}"; + if ($isDefault) { + $versionLabel .= ' (default)'; + } + + // Use version as key, extensions as value + if (is_array($extensions) && count($extensions) > 0) { + $phpItems[$versionLabel] = implode(', ', $extensions); + } else { + $phpItems[$versionLabel] = 'no extensions'; + } + } elseif (is_string($versionData) || is_numeric($versionData)) { + // Fallback for old format (simple string/numeric version) + $versionStr = (string) $versionData; + $isDefault = false; + if ($defaultVersion !== null && (is_string($defaultVersion) || is_numeric($defaultVersion))) { + /** @var string|int|float $defaultVersion */ + $isDefault = $versionStr === (string) $defaultVersion; + } + + $versionLabel = "PHP {$versionStr}"; + if ($isDefault) { + $versionLabel .= ' (default)'; + } + $phpItems[$versionLabel] = ''; + } + } + } + + if (count($phpItems) > 0) { + $this->displayDeets(['PHP' => $phpItems]); + } + } + + // 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)) { + continue; + } + + $phpFpmItems = []; + + if (isset($fpmData['pool']) && $fpmData['pool'] !== 'unknown') { + /** @var string $pool */ + $pool = $fpmData['pool']; + $phpFpmItems['Pool'] = $pool; + } + + if (isset($fpmData['process_manager']) && $fpmData['process_manager'] !== 'unknown') { + /** @var string $processManager */ + $processManager = $fpmData['process_manager']; + $phpFpmItems['Processes'] = $processManager; + } + + if (isset($fpmData['active_processes'])) { + /** @var int|string $activeProcesses */ + $activeProcesses = $fpmData['active_processes']; + $phpFpmItems['Active'] = $activeProcesses.' processes'; + } + + if (isset($fpmData['idle_processes'])) { + /** @var int|string $idleProcesses */ + $idleProcesses = $fpmData['idle_processes']; + $phpFpmItems['Idle'] = $idleProcesses.' processes'; + } + + if (isset($fpmData['total_processes'])) { + /** @var int|string $totalProcesses */ + $totalProcesses = $fpmData['total_processes']; + $phpFpmItems['Total'] = $totalProcesses.' processes'; + } + + if (isset($fpmData['listen_queue'])) { + /** @var int|string|float $rawQueue */ + $rawQueue = $fpmData['listen_queue']; + /** @var int $queue */ + $queue = (int) $rawQueue; + $phpFpmItems['Queue'] = $queue > 0 ? "{$queue} waiting" : '0 waiting'; + } + + if (isset($fpmData['accepted_conn'])) { + /** @var int|string|float $rawAccepted */ + $rawAccepted = $fpmData['accepted_conn']; + /** @var int $accepted */ + $accepted = (int) $rawAccepted; + $phpFpmItems['Accepted'] = number_format($accepted); + } + + if (isset($fpmData['max_children_reached'])) { + /** @var int|string|float $rawMaxChildren */ + $rawMaxChildren = $fpmData['max_children_reached']; + /** @var int $maxChildren */ + $maxChildren = (int) $rawMaxChildren; + if ($maxChildren > 0) { + $phpFpmItems['Max Children Reached'] = $maxChildren; + } + } + + if (isset($fpmData['slow_requests'])) { + /** @var int|string|float $rawSlowReqs */ + $rawSlowReqs = $fpmData['slow_requests']; + /** @var int $slowReqsInt */ + $slowReqsInt = (int) $rawSlowReqs; + if ($slowReqsInt > 0) { + $phpFpmItems['Slow Requests'] = number_format($slowReqsInt); + } + } + + if (count($phpFpmItems) > 0) { + $this->displayDeets(["PHP-FPM {$version}" => $phpFpmItems]); + } + } + } + + // Display Sites Configuration if available + if (isset($info['sites_config']) && is_array($info['sites_config']) && count($info['sites_config']) > 0) { + $sitesItems = []; + foreach (array_keys($info['sites_config']) as $domain) { + $config = $this->getSiteConfig($info, (string) $domain); + + if ($config === null) { + continue; + } + + $php = $config['php_version'] === 'unknown' ? '?' : $config['php_version']; + $url = $config['https_enabled'] ? "https://{$domain}" : "http://{$domain}"; + $color = $config['https_enabled'] ? 'green' : 'yellow'; + + $sitesItems[(string) $domain] = "{$url} PHP {$php}"; + } + + if (count($sitesItems) > 0) { + $this->displayDeets(['Sites' => $sitesItems]); + } + } + } + + /** + * Format uptime seconds into human-readable string. + */ + private function formatUptime(int $seconds): string + { + if ($seconds < 60) { + return "{$seconds}s"; + } + + if ($seconds < 3600) { + $minutes = floor($seconds / 60); + + return "{$minutes}m"; + } + + if ($seconds < 86400) { + $hours = floor($seconds / 3600); + $minutes = floor(($seconds % 3600) / 60); + + return "{$hours}h {$minutes}m"; + } + + $days = floor($seconds / 86400); + $hours = floor(($seconds % 86400) / 3600); + + return "{$days}d {$hours}h"; + } + } diff --git a/app/Console/Server/ServerInstallCommand.php b/app/Console/Server/ServerInstallCommand.php index 407eaed3..9bd92bc4 100644 --- a/app/Console/Server/ServerInstallCommand.php +++ b/app/Console/Server/ServerInstallCommand.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Server; +namespace PHPDeployer\Console\Server; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\Traits\KeysTrait; +use PHPDeployer\Traits\PlaybooksTrait; +use PHPDeployer\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -20,6 +21,7 @@ )] class ServerInstallCommand extends BaseCommand { + use KeysTrait; use PlaybooksTrait; use ServersTrait; @@ -32,6 +34,8 @@ protected function configure(): void parent::configure(); $this->addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name'); + $this->addOption('generate-deploy-key', null, InputOption::VALUE_NONE, 'Use server-generated deploy key'); + $this->addOption('custom-deploy-key', null, InputOption::VALUE_REQUIRED, 'Path to custom deploy key (public key expected at same path + .pub)'); $this->addOption('php-version', null, InputOption::VALUE_REQUIRED, 'PHP version to install'); $this->addOption('php-default', null, InputOption::VALUE_NEGATABLE, 'Set as default PHP version'); $this->addOption('php-extensions', null, InputOption::VALUE_REQUIRED, 'Comma-separated PHP extensions'); @@ -45,34 +49,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Install Server'); + $this->h1('Install Server'); // - // Select server & display details + // Select server // ---- $server = $this->selectServer(); - 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; + if (is_int($server) || $server->info === null) { + return Command::FAILURE; } [ 'distro' => $distro, 'permissions' => $permissions, - ] = $info; + ] = $server->info; /** @var string $distro */ /** @var string $permissions */ @@ -90,7 +82,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'DEPLOYER_PERMS' => $permissions, 'DEPLOYER_GATHER_PHP' => 'true', ], - true ); if (is_int($packageList)) { @@ -103,13 +94,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $result = $this->executePlaybook( $server, - 'install-base', + 'base-install', 'Installing base packages...', [ 'DEPLOYER_DISTRO' => $distro, 'DEPLOYER_PERMS' => $permissions, ], - true ); if (is_int($result)) { @@ -120,7 +110,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Install PHP // ---- - $phpResult = $this->installPhp($server, $info, $packageList); + $phpResult = $this->installPhp($server, $server->info, $packageList); if (is_int($phpResult)) { return $phpResult; @@ -138,12 +128,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $bunResult = $this->executePlaybook( $server, - 'install-bun', + 'bun-install', 'Installing Bun...', [ 'DEPLOYER_PERMS' => $permissions, ], - true ); if (is_int($bunResult)) { @@ -154,97 +143,164 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Setup deployer user // ---- - $deployerResult = $this->executePlaybook( - $server, - 'install-deployer', - 'Setting up deployer user...', - [ - 'DEPLOYER_DISTRO' => $distro, - 'DEPLOYER_PERMS' => $permissions, - 'DEPLOYER_SERVER_NAME' => $server->name, - ], - true - ); + $deployKeyResult = $this->setupDeployerUser($input, $server, $distro, $permissions); - if (is_int($deployerResult)) { - $this->nay('Deployer user setup failed'); - - return $deployerResult; + if (is_int($deployKeyResult)) { + return $deployKeyResult; } - $this->yay('Deployer user setup successful'); + /** @var array{deploy_key_path: string|null, deploy_public_key: string} $deployKeyResult */ + $deployKeyPath = $deployKeyResult['deploy_key_path']; + $deployPublicKey = $deployKeyResult['deploy_public_key']; + + $this->yay('Server installation completed successfully'); + + $this->ul([ + 'Run <|cyan>site:add to add a new site', + 'Add the following <|yellow>public key to your Git provider (GitHub, GitLab, etc.) to enable deployments:', + ]); + + // Intentionally not using $this->out() here to make the key stand out from the rest of the command output + $this->io->write([ + '', + '' . $deployPublicKey . '', + ], true); // - // Setup demo site + // Show command replay // ---- - /** @var string $permissions */ - $demoResult = $this->executePlaybook( - $server, - 'demo-site', - 'Setting up demo site...', - [ - 'DEPLOYER_DISTRO' => $distro, - 'DEPLOYER_PERMS' => $permissions, - ], - true - ); + $replayOptions = [ + 'server' => $server->name, + 'php-version' => $phpVersion, + 'php-extensions' => $phpExtensions, + ]; - if (is_int($demoResult)) { - $this->nay('Demo site setup failed'); + if ($deployKeyPath !== null) { + $replayOptions['custom-deploy-key'] = $deployKeyPath; + } else { + $replayOptions['generate-deploy-key'] = true; + } - return Command::FAILURE; + if ($phpDefaultPrompted) { + $replayOptions['php-default'] = $phpDefault; } - $this->yay('Demo site setup successful'); + $this->commandReplay('server:install', $replayOptions); + + return Command::SUCCESS; + } + + // + // Deployer User Setup + // ---- + /** + * Setup deployer user and SSH deploy key. + * + * Handles deploy key generation or custom key upload based on user input. + * Custom keys always overwrite existing; auto-generated keys preserve existing. + * + * @return array{deploy_key_path: string|null, deploy_public_key: string}|int + */ + private function setupDeployerUser(InputInterface $input, ServerDTO $server, string $distro, string $permissions): array|int + { // - // Verify installation + // Get deploy key configuration // ---- - // IPv6 addresses must be wrapped in brackets for URLs - $host = $server->host; - if (str_contains($host, ':')) { - // Any IPv6 literal must be wrapped in brackets - $url = "http://[{$host}]"; - } else { - $url = "http://{$host}"; - } - /** @var string|null $deployKey */ - $deployKey = $deployerResult['deploy_public_key'] ?? null; + /** @var bool $generateKey */ + $generateKey = $input->getOption('generate-deploy-key'); + /** @var string|null $customKeyPath */ + $customKeyPath = $input->getOption('custom-deploy-key'); - $verification = $this->io->promptSpin( - fn () => $this->verifyInstallation($url, $deployKey), - 'Verifying installation...' - ); + if ($generateKey && $customKeyPath !== null) { + $this->nay('Cannot use both --generate-deploy-key and --custom-deploy-key'); - if ($verification['status'] === 'success') { - $this->yay($verification['message']); - } else { - $this->io->warning($verification['message']); + return Command::FAILURE; } - if ($verification['lines'] !== []) { - $this->io->writeln($verification['lines']); + if ($generateKey) { + $deployKeyPath = null; + } elseif ($customKeyPath !== null) { + $deployKeyPath = $customKeyPath; + } else { + $choice = $this->io->promptSelect( + label: 'Deploy key:', + options: [ + 'generate' => 'Use server-generated key pair', + 'custom' => 'Use your own key pair', + ], + default: 'generate' + ); + + if ($choice === 'generate') { + $deployKeyPath = null; + } else { + $deployKeyPath = $this->io->promptText( + label: 'Path to private key:', + placeholder: '~/.ssh/deploy_key', + required: true, + hint: 'Public key expected at same path + .pub' + ); + } } // - // Show command replay + // Prepare playbook variables // ---- - $replayOptions = [ - 'server' => $server->name, - 'php-version' => $phpVersion, - 'php-extensions' => $phpExtensions, + $playbookVars = [ + 'DEPLOYER_DISTRO' => $distro, + 'DEPLOYER_PERMS' => $permissions, + 'DEPLOYER_SERVER_NAME' => $server->name, ]; - if ($phpDefaultPrompted) { - $replayOptions['php-default'] = $phpDefault; + if ($deployKeyPath !== null && $deployKeyPath !== '') { + $validationError = $this->validateDeployKeyPairInput($deployKeyPath); + + if ($validationError !== null) { + $this->nay($validationError); + + return Command::FAILURE; + } + + $expandedPath = $this->fs->expandPath($deployKeyPath); + $privateKeyContent = $this->fs->readFile($expandedPath); + $publicKeyContent = $this->fs->readFile($expandedPath . '.pub'); + + $playbookVars['DEPLOYER_KEY_PRIVATE'] = base64_encode($privateKeyContent); + $playbookVars['DEPLOYER_KEY_PUBLIC'] = base64_encode($publicKeyContent); + } + + // + // Execute playbook + // ---- + + $deployerResult = $this->executePlaybook( + $server, + 'user-install', + 'Setting up deployer user...', + $playbookVars, + ); + + if (is_int($deployerResult)) { + return $deployerResult; } - $this->showCommandReplay('server:install', $replayOptions); + /** @var string|null $deployPublicKey */ + $deployPublicKey = $deployerResult['deploy_public_key'] ?? null; - return Command::SUCCESS; + if ($deployPublicKey === null) { + $this->nay('Failed to retrieve deploy key'); + + return Command::FAILURE; + } + + return [ + 'deploy_key_path' => $deployKeyPath, + 'deploy_public_key' => $deployPublicKey, + ]; } // @@ -279,19 +335,22 @@ private function installPhp(ServerDTO $server, array $info, array $packageList): // Extract available PHP versions // ---- - if (!isset($packageList['php']) || !is_array($packageList['php']) || empty($packageList['php'])) { + /** @var array $phpPackages */ + $phpPackages = $packageList['php'] ?? []; + + if ($phpPackages === []) { $this->nay('No PHP versions available in package list'); return Command::FAILURE; } - $phpVersions = array_keys($packageList['php']); - - // Filter for PHP 8.x only - $phpVersions = array_filter($phpVersions, fn ($v) => str_starts_with((string) $v, '8.')); + $phpVersions = array_filter( + array_keys($phpPackages), + fn ($v) => str_starts_with((string) $v, '8.') + ); rsort($phpVersions, SORT_NATURAL); // Newest first - if (empty($phpVersions)) { + if ($phpVersions === []) { $this->nay('No PHP 8.x versions available in package list'); return Command::FAILURE; @@ -349,9 +408,10 @@ private function installPhp(ServerDTO $server, array $info, array $packageList): $phpData = $packageList['php']; /** @var mixed $versionData */ $versionData = $phpData[$phpVersion] ?? null; - if (is_array($versionData) && isset($versionData['extensions']) && is_array($versionData['extensions'])) { + + if (is_array($versionData)) { /** @var array $extensions */ - $extensions = $versionData['extensions']; + $extensions = $versionData['extensions'] ?? []; $availableExtensions = $extensions; } @@ -447,8 +507,8 @@ private function installPhp(ServerDTO $server, array $info, array $packageList): $result = $this->executePlaybook( $server, - 'install-php', - "Installing PHP {$phpVersion}...", + 'php-install', + "Installing PHP...", [ 'DEPLOYER_DISTRO' => $distro, 'DEPLOYER_PERMS' => $permissions, @@ -456,7 +516,6 @@ private function installPhp(ServerDTO $server, array $info, array $packageList): 'DEPLOYER_PHP_SET_DEFAULT' => $setAsDefault ? 'true' : 'false', 'DEPLOYER_PHP_EXTENSIONS' => implode(',', $selectedExtensions), ], - true ); if (is_int($result)) { @@ -465,9 +524,6 @@ private function installPhp(ServerDTO $server, array $info, array $packageList): return Command::FAILURE; } - $defaultStatus = $setAsDefault ? ' (set as default)' : ''; - $this->yay("Installed PHP {$phpVersion} successfully{$defaultStatus}"); - return [ 'status' => Command::SUCCESS, 'php_version' => $phpVersion, @@ -476,65 +532,4 @@ private function installPhp(ServerDTO $server, array $info, array $packageList): 'php_extensions' => implode(',', $selectedExtensions), ]; } - - // - // HTTP Verification - // ---- - - /** - * Verify demo site is responding with expected content. - * - * @return array{status: 'success'|'warning', message: string, lines: array} - */ - private function verifyInstallation(string $url, ?string $deployKey): array - { - $result = $this->http->verifyUrl($url); - - if (!$result['success']) { - // Network errors return status_code: 0 - if (0 === $result['status_code']) { - return [ - 'status' => 'warning', - 'message' => "Could not connect to demo site: {$result['body']}", - 'lines' => [], - ]; - } - - // HTTP protocol errors (got response but wrong status code) - return [ - 'status' => 'warning', - 'message' => "Demo site returned HTTP {$result['status_code']} (expected 200)", - 'lines' => [], - ]; - } - - if (!str_contains($result['body'], 'hello, world')) { - return [ - 'status' => 'warning', - 'message' => 'Demo site is responding but content verification failed', - 'lines' => [], - ]; - } - - $nextSteps = [ - 'Next steps:', - ' • Caddy running at ' . $url . '', - ' • Run site:add to deploy your first application', - ]; - - if ($deployKey !== null) { - $nextSteps[] = ' • Add this key to your Git provider (GitHub, GitLab, etc.) to enable deployments:'; - $nextSteps[] = ''; - $nextSteps[] = '' . $deployKey . ''; - } - - $nextSteps[] = ''; - - return [ - 'status' => 'success', - 'message' => 'Server installation completed successfully', - 'lines' => $nextSteps, - ]; - } - } diff --git a/app/Console/Server/ServerListCommand.php b/app/Console/Server/ServerListCommand.php index 49afe713..dbddd41d 100644 --- a/app/Console/Server/ServerListCommand.php +++ b/app/Console/Server/ServerListCommand.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Server; +namespace PHPDeployer\Console\Server; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; -use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\Traits\ServersTrait; +use PHPDeployer\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -29,7 +29,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('List Servers'); + $this->h1('List Servers'); // // Get all servers @@ -45,15 +45,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Display servers // ---- - foreach ($allServers as $count => $server) { + foreach ($allServers as $server) { $this->displayServerDeets($server); - - if ($count < count($allServers) - 1) { - $this->io->writeln([ - ' ───', - '', - ]); - } } return Command::SUCCESS; diff --git a/app/Console/Server/ServerLogsCommand.php b/app/Console/Server/ServerLogsCommand.php index e9d3101c..28048a13 100644 --- a/app/Console/Server/ServerLogsCommand.php +++ b/app/Console/Server/ServerLogsCommand.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Server; +namespace PHPDeployer\Console\Server; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\Traits\PlaybooksTrait; +use PHPDeployer\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -34,7 +34,8 @@ class ServerLogsCommand extends BaseCommand */ private ?array $processedServices = null; - // ---- Configuration + // ---- + // Configuration // ---- protected function configure(): void @@ -46,14 +47,15 @@ protected function configure(): void $this->addOption('service', 's', InputOption::VALUE_REQUIRED, 'Service name (all|system|php-fpm|site|detected service name)'); } - // ---- Execution + // ---- + // Execution // ---- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Server Logs'); + $this->h1('Server Logs'); // // Select server & display details @@ -61,20 +63,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $server = $this->selectServer(); - 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; + if (is_int($server) || $server->info === null) { + return Command::FAILURE; } // @@ -90,7 +80,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ) ); - $processed = $this->getProcessedServices($info); + $processed = $this->getProcessedServices($server->info); /** @var array $options */ $options = $processed['options']; @@ -108,13 +98,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Retrieve logs // ---- - $this->displayServiceLogs($server, (string) $service, (int) $lines, $info); + $this->displayServiceLogs($server, (string) $service, (int) $lines, $server->info); // // Show command replay // ---- - $this->showCommandReplay('server:logs', [ + $this->commandReplay('server:logs', [ 'server' => $server->name, 'lines' => $lines, 'service' => $service, @@ -123,7 +113,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - // ---- Helpers + // ---- + // Helpers // ---- /** @@ -316,10 +307,7 @@ protected function displayServiceLogs(ServerDTO $server, string $service, int $l */ protected function retrieveServiceLogs(ServerDTO $server, string $service, string $unit, int $lines): void { - $this->io->writeln([ - "{$service} Logs", - '', - ]); + $this->h2($service); try { if ($unit === '') { @@ -341,25 +329,23 @@ protected function retrieveServiceLogs(ServerDTO $server, string $service, strin if ($result['exit_code'] !== 0 && !$serviceNotFound) { $this->nay("Failed to retrieve {$service} logs"); - $this->io->writeln($this->highlightErrors($output)); - $this->io->writeln(''); + $this->io->write($this->highlightErrors($output), true); + $this->out('───'); return; } if ($serviceNotFound || $noData) { $this->tryTraditionalLogs($server, $service, $lines); - $this->io->writeln(''); return; } - $this->io->writeln($this->highlightErrors($output)); + $this->io->write($this->highlightErrors($output), true); + $this->out('───'); } catch (\RuntimeException $e) { $this->nay($e->getMessage()); } - - $this->io->writeln(''); } /** @@ -367,21 +353,17 @@ protected function retrieveServiceLogs(ServerDTO $server, string $service, strin */ protected function retrieveFileLogs(ServerDTO $server, string $title, string $filepath, int $lines): void { - $this->io->writeln([ - "{$title} Logs", - "File: {$filepath}", - '', - ]); + $this->h2($title); + $this->out("<|gray>File: {$filepath}"); $content = $this->readLogFile($server, $filepath, $lines); if ($content !== null) { - $this->io->writeln($this->highlightErrors($content)); + $this->io->write($this->highlightErrors($content), true); + $this->out('───'); } else { - $this->io->writeln('No logs found or file does not exist.'); + $this->out('No logs found or file does not exist.'); } - - $this->io->writeln(''); } /** @@ -395,7 +377,7 @@ protected function tryTraditionalLogs(ServerDTO $server, string $service, int $l $result = $this->ssh->executeCommand($server, $findCommand); if ($result['exit_code'] !== 0 || trim($result['output']) === '') { - $this->io->writeln("No {$service} logs found"); + $this->out("No {$service} logs found"); return; } @@ -406,19 +388,17 @@ protected function tryTraditionalLogs(ServerDTO $server, string $service, int $l $logContent = $this->readLogFile($server, $logFile, $lines); if ($logContent !== null) { - $this->io->writeln([ - "From {$logFile}:", - '', - $this->highlightErrors($logContent), - ]); + $this->out("From {$logFile}:"); + $this->io->write($this->highlightErrors($logContent), true); + $this->out('───'); return; } } - $this->io->writeln("No {$service} logs found"); + $this->out("No {$service} logs found"); } catch (\RuntimeException) { - $this->io->writeln("No {$service} logs found"); + $this->out("No {$service} logs found"); } } diff --git a/app/Console/Server/ServerProvisionDigitalOceanCommand.php b/app/Console/Server/ServerProvisionDigitalOceanCommand.php index dbcc678c..d0e60dd7 100644 --- a/app/Console/Server/ServerProvisionDigitalOceanCommand.php +++ b/app/Console/Server/ServerProvisionDigitalOceanCommand.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Server; +namespace PHPDeployer\Console\Server; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanTrait; -use Bigpixelrocket\DeployerPHP\Traits\KeysTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\Traits\DigitalOceanTrait; +use PHPDeployer\Traits\KeysTrait; +use PHPDeployer\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -54,7 +54,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Provision DigitalOcean Droplet'); + $this->h1('Provision DigitalOcean Droplet'); // // Retrieve DigitalOcean account data @@ -152,7 +152,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $ipAddress = $this->digitalOcean->droplet->getDropletIp($dropletId); // Create server DTO - $server = new ServerDTO( + $server = $this->serverInfo(new ServerDTO( name: $name, host: $ipAddress, port: 22, @@ -160,19 +160,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int privateKeyPath: $privateKeyPath, provider: 'digitalocean', dropletId: $dropletId - ); - - $this->displayServerDeets($server); - - // Get server info (verifies SSH connection and validates distribution & permissions) - $info = $this->serverInfo($server); + )); - if (is_int($info)) { - throw new \RuntimeException('Failed to validate server distribution'); + if (is_int($server)) { + return Command::FAILURE; } // Add to inventory $this->servers->create($server); + $this->yay('Server added to inventory'); $shouldKeepDroplet = true; @@ -192,7 +188,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('server:provision:digitalocean', [ + $this->commandReplay('server:provision:digitalocean', [ 'name' => $name, 'region' => $region, 'size' => $size, @@ -242,7 +238,9 @@ protected function gatherProvisioningDeets(array $accountData): ?array fn ($validate) => $this->io->promptSelect( label: 'Select region:', options: $accountData['regions'], - hint: 'Choose the datacenter location' + hint: 'Choose the datacenter location', + default: '', + scroll: 15 ), fn ($value) => $this->validateDigitalOceanRegion($value, $accountData['regions']) ); @@ -257,7 +255,9 @@ protected function gatherProvisioningDeets(array $accountData): ?array fn ($validate) => $this->io->promptSelect( label: 'Select droplet size:', options: $accountData['sizes'], - hint: 'Choose CPU, RAM, and storage' + hint: 'Choose CPU, RAM, and storage', + default: '', + scroll: 15 ), fn ($value) => $this->validateDigitalOceanDropletSize($value, $accountData['sizes']) ); @@ -272,7 +272,9 @@ protected function gatherProvisioningDeets(array $accountData): ?array fn ($validate) => $this->io->promptSelect( label: 'Select OS image:', options: $accountData['images'], - hint: 'Supported Linux distributions' + hint: 'Supported Linux distributions', + default: 'ubuntu-24-04-x64', + scroll: 15 ), fn ($value) => $this->validateDigitalOceanDropletImage($value, $accountData['images']) ); @@ -412,9 +414,9 @@ protected function rollbackDroplet(int $dropletId): void 'Rolling back droplet...' ); - $this->io->warning('Rolled back droplet'); + $this->warn('Rolled back droplet'); } catch (\Throwable $cleanupError) { - $this->io->warning($cleanupError->getMessage()); + $this->warn($cleanupError->getMessage()); } } } diff --git a/app/Console/Server/ServerRunCommand.php b/app/Console/Server/ServerRunCommand.php index 81cee6bd..1b15350f 100644 --- a/app/Console/Server/ServerRunCommand.php +++ b/app/Console/Server/ServerRunCommand.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Server; +namespace PHPDeployer\Console\Server; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -20,7 +20,8 @@ class ServerRunCommand extends BaseCommand { use ServersTrait; - // ---- Configuration + // ---- + // Configuration // ---- protected function configure(): void @@ -31,14 +32,15 @@ protected function configure(): void $this->addOption('command', null, InputOption::VALUE_REQUIRED, 'Command to execute'); } - // ---- Execution + // ---- + // Execution // ---- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Run Command on Server'); + $this->h1('Run Command on Server'); // // Select server & display details @@ -50,10 +52,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $server; } - $this->displayServerDeets($server); - // - // Get command to execute + // Gather command to execute // ---- $command = $this->io->getOptionOrPrompt( @@ -75,10 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Execute command with real-time output streaming // ---- - $this->io->writeln([ - "Executing command...", - '', - ]); + $this->out('$> ' . $command); try { $result = $this->ssh->executeCommand( @@ -87,15 +84,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int fn (string $chunk) => $this->io->write($chunk) ); - $this->io->writeln(''); + $this->out('───'); if ($result['exit_code'] !== 0) { - $this->nay("Command failed with exit code {$result['exit_code']}"); - - return Command::FAILURE; + throw new \RuntimeException("Command failed with exit code {$result['exit_code']}"); } - - $this->yay('Command executed successfully'); } catch (\RuntimeException $e) { $this->nay($e->getMessage()); @@ -106,7 +99,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('server:run', [ + $this->commandReplay('server:run', [ 'server' => $server->name, 'command' => $command, ]); diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php index 4771ee85..f5bc31ad 100644 --- a/app/Console/Site/SiteAddCommand.php +++ b/app/Console/Site/SiteAddCommand.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Site; +namespace PHPDeployer\Console\Site; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; -use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; -use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\DTOs\SiteDTO; +use PHPDeployer\Traits\PlaybooksTrait; +use PHPDeployer\Traits\ServersTrait; +use PHPDeployer\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -50,7 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Add New Site'); + $this->h1('Add New Site'); // // Select server @@ -58,26 +58,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $server = $this->selectServer(); - 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; + if (is_int($server) || $server->info === null) { + return Command::FAILURE; } [ 'distro' => $distro, 'permissions' => $permissions, - ] = $info; + ] = $server->info; /** @var string $distro */ /** @var string $permissions */ @@ -86,7 +74,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Validate server is ready for site provisioning // ---- - $validationResult = $this->validateServerReady($info); + $validationResult = $this->validateServerReady($server->info); if (is_int($validationResult)) { return $validationResult; @@ -96,7 +84,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Gather site details // ---- - $siteInfo = $this->gatherSiteInfo($info); + $siteInfo = $this->gatherSiteInfo($server->info); if ($siteInfo === null) { return Command::FAILURE; @@ -127,7 +115,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Provision site on server // ---- - $result = $this->executePlaybook( + $result = $this->executePlaybookSilently( $server, 'site-add', 'Provisioning site...', @@ -137,8 +125,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'DEPLOYER_SITE_DOMAIN' => $domain, 'DEPLOYER_PHP_VERSION' => $phpVersion, 'DEPLOYER_WWW_MODE' => $wwwMode, - ], - true + ] ); if (is_int($result)) { @@ -169,7 +156,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ? 'http://www.' . $domain : 'http://' . $domain; - $this->io->writeln([ + $this->out([ 'Next steps:', ' • Site is accessible at ' . $displayUrl . '', ' • Update DNS records:', @@ -184,7 +171,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('site:add', [ + $this->commandReplay('site:add', [ 'domain' => $domain, 'repo' => $repo, 'branch' => $branch, @@ -220,7 +207,7 @@ private function validateServerReady(array $info): ?int if (!$caddyInstalled || !$phpInstalled) { $this->nay('Looks like the server was not installed as expected'); - $this->io->writeln([ + $this->out([ 'Run server:install to install required software.', '', ]); diff --git a/app/Console/Site/SiteDeleteCommand.php b/app/Console/Site/SiteDeleteCommand.php index 60957b49..632c49fd 100644 --- a/app/Console/Site/SiteDeleteCommand.php +++ b/app/Console/Site/SiteDeleteCommand.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Site; +namespace PHPDeployer\Console\Site; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; -use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\Traits\PlaybooksTrait; +use PHPDeployer\Traits\ServersTrait; +use PHPDeployer\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -16,7 +16,7 @@ #[AsCommand( name: 'site:delete', - description: 'Remove a site from the server and delete it from the inventory' + description: 'Remove a site from the server and delete it from inventory' )] class SiteDeleteCommand extends BaseCommand { @@ -46,7 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Delete Site'); + $this->h1('Delete Site'); // // Select site & display details @@ -68,7 +68,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $forceSkip = $input->getOption('force') ?? false; if (!$forceSkip) { - $this->io->writeln(''); + $this->out(''); $typedDomain = $this->io->promptText( label: "Type the site domain '{$site->domain}' to confirm deletion:", @@ -92,8 +92,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); if (!$confirmed) { - $this->io->warning('Cancelled deleting site'); - $this->io->writeln(''); + $this->warn('Cancelled deleting site'); + $this->out(''); return Command::SUCCESS; } @@ -106,29 +106,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int $server = $this->servers->findByName($site->server); if ($server === null) { - $this->io->warning("Server '{$site->server}' not found in inventory"); - $this->io->writeln([ + $this->warn("Server '{$site->server}' not found in inventory"); + $this->out([ '', 'The server may have been deleted.', '', ]); } else { // Get server info (verifies SSH connection) - $info = $this->serverInfo($server); + $server = $this->serverInfo($server); - if (is_int($info)) { - $this->io->warning('Could not connect to server'); + if (is_int($server) || $server->info === null) { + $this->warn('Could not connect to server'); } else { [ 'distro' => $distro, 'permissions' => $permissions, - ] = $info; + ] = $server->info; /** @var string $distro */ /** @var string $permissions */ // Execute site deletion playbook - $result = $this->executePlaybook( + $result = $this->executePlaybookSilently( $server, 'site-delete', 'Removing site from server...', @@ -136,12 +136,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'DEPLOYER_DISTRO' => $distro, 'DEPLOYER_PERMS' => $permissions, 'DEPLOYER_SITE_DOMAIN' => $site->domain, - ], - true + ] ); if (is_int($result)) { - $this->io->warning('Failed to remove site from server'); + $this->warn('Failed to remove site from server'); } else { $removedFromServer = true; } @@ -183,7 +182,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('site:delete', [ + $this->commandReplay('site:delete', [ 'domain' => $site->domain, 'yes' => $confirmed, 'force' => true, diff --git a/app/Console/Site/SiteDeployCommand.php b/app/Console/Site/SiteDeployCommand.php index 1b070bf7..99d7d8d0 100644 --- a/app/Console/Site/SiteDeployCommand.php +++ b/app/Console/Site/SiteDeployCommand.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Site; +namespace PHPDeployer\Console\Site; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; -use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; -use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\DTOs\SiteDTO; +use PHPDeployer\Traits\PlaybooksTrait; +use PHPDeployer\Traits\ServersTrait; +use PHPDeployer\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -56,7 +56,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Deploy Site'); + $this->h1('Deploy Site'); // // Select site & display details @@ -83,12 +83,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ($missingHooks !== []) { - $this->io->warning('Missing deployment hooks in repository:'); + $this->warn('Missing deployment hooks in repository:'); foreach ($missingHooks as $hook) { - $this->io->writeln(' • ' . $hook); + $this->out(' • ' . $hook); } - $this->io->writeln([ + $this->out([ ' • Run scaffold:hooks to create them', ' • Or continue deployment anyway...', '', @@ -103,7 +103,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $this->io->writeln(''); + $this->out(''); } // @@ -119,15 +119,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Get server info (verifies SSH and validates distro & permissions) // ---- - $info = $this->serverInfo($server); - if (is_int($info)) { - return $info; + $server = $this->serverInfo($server); + + if (is_int($server) || $server->info === null) { + return Command::FAILURE; } [ 'distro' => $distro, 'permissions' => $permissions, - ] = $info; + ] = $server->info; /** @var string $distro */ /** @var string $permissions */ @@ -148,11 +149,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $branch = $site->branch; $keepReleases = $this->resolveKeepReleases($input); + if ($keepReleases === null) { return Command::FAILURE; } - $phpVersion = $this->resolvePhpVersion($info); + $phpVersion = $this->resolvePhpVersion($server->info); if ($phpVersion === null) { return Command::FAILURE; } @@ -171,8 +173,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); if (! $confirmed) { - $this->io->warning('Deployment cancelled.'); - $this->io->writeln(''); + $this->warn('Deployment cancelled.'); + $this->out(''); return Command::SUCCESS; } @@ -181,7 +183,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Execute deployment playbook // ---- - $result = $this->executePlaybook( + $result = $this->executePlaybookSilently( $server, 'site-deploy', 'Deploying site...', @@ -193,8 +195,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'DEPLOYER_SITE_BRANCH' => $branch, 'DEPLOYER_PHP_VERSION' => (string) $phpVersion, 'DEPLOYER_KEEP_RELEASES' => (string) $keepReleases, - ], - true + ] ); if (is_int($result)) { @@ -208,7 +209,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->yay('Deployment completed'); $this->displayDeploymentSummary($result, $branch, (string) $phpVersion); - $this->io->writeln([ + $this->out([ 'Next steps:', ' • Run site:shared:push to upload shared files (e.g. .env)', ' • View deployment logs with server:logs', @@ -219,7 +220,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('site:deploy', [ + $this->commandReplay('site:deploy', [ 'domain' => $site->domain, 'keep-releases' => $keepReleases, 'yes' => true, @@ -256,8 +257,8 @@ private function displayDeploymentSummary(array $result, string $branch, string $lines['Current Symlink'] = $result['current_path']; } - $this->io->displayDeets($lines); - $this->io->writeln(''); + $this->displayDeets($lines); + $this->out(''); } private function resolveKeepReleases(InputInterface $input): ?int diff --git a/app/Console/Site/SiteHttpsCommand.php b/app/Console/Site/SiteHttpsCommand.php index 04f3cb24..c3aa5537 100644 --- a/app/Console/Site/SiteHttpsCommand.php +++ b/app/Console/Site/SiteHttpsCommand.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Site; +namespace PHPDeployer\Console\Site; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; -use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\Traits\PlaybooksTrait; +use PHPDeployer\Traits\ServersTrait; +use PHPDeployer\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -43,14 +43,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Enable HTTPS'); + $this->h1('Enable HTTPS'); // // Select site // ---- - $site = $this->selectSite() - ; + $site = $this->selectSite(); + if (is_int($site)) { return $site; } @@ -65,22 +65,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $server; } - $this->displayServerDeets($server); - // // Get server info (verifies SSH connection and validates distribution & permissions) // ---- - $info = $this->serverInfo($server); + $server = $this->serverInfo($server); - if (is_int($info)) { - return $info; + if (is_int($server) || $server->info === null) { + return Command::FAILURE; } [ 'distro' => $distro, 'permissions' => $permissions, - ] = $info; + ] = $server->info; /** @var string $distro */ /** @var string $permissions */ @@ -89,11 +87,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Get site configuration // ---- - $config = $this->getSiteConfig($info, $site->domain); + $config = $this->getSiteConfig($server->info, $site->domain); if ($config === null) { - $this->io->warning("Site '{$site->domain}' configuration not found on server"); - $this->io->writeln([ + $this->warn("Site '{$site->domain}' configuration not found on server"); + $this->out([ '', 'It looks like this site has not been provisioned yet.', 'Run site:add to provision the site first.', @@ -113,7 +111,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Execute playbook // ---- - $result = $this->executePlaybook( + $result = $this->executePlaybookSilently( $server, 'site-https', 'Enabling HTTPS...', @@ -123,8 +121,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'DEPLOYER_SITE_DOMAIN' => $site->domain, 'DEPLOYER_PHP_VERSION' => $config['php_version'], 'DEPLOYER_WWW_MODE' => $config['www_mode'], - ], - true + ] ); if (is_int($result)) { @@ -141,12 +138,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int ? 'https://www.' . $site->domain : 'https://' . $site->domain; - $this->io->writeln([ + $this->out([ 'Your site is now accessible over HTTPS:', ' ' . $displayUrl . '', '', ]); + // + // Show command replay + // ---- + + $this->commandReplay('site:https', [ + 'domain' => $site->domain, + ]); + return Command::SUCCESS; } } diff --git a/app/Console/Site/SiteListCommand.php b/app/Console/Site/SiteListCommand.php index dc1e98d0..79bad07d 100644 --- a/app/Console/Site/SiteListCommand.php +++ b/app/Console/Site/SiteListCommand.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Site; +namespace PHPDeployer\Console\Site; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -27,7 +27,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('List Sites'); + $this->h1('List Sites'); // // Get all sites @@ -43,15 +43,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Display sites // ---- - foreach ($allSites as $count => $site) { + foreach ($allSites as $site) { $this->displaySiteDeets($site); - - if ($count < count($allSites) - 1) { - $this->io->writeln([ - ' ───', - '', - ]); - } } return Command::SUCCESS; diff --git a/app/Console/Site/SiteSharedPullCommand.php b/app/Console/Site/SiteSharedPullCommand.php index 4c32165d..c0e71135 100644 --- a/app/Console/Site/SiteSharedPullCommand.php +++ b/app/Console/Site/SiteSharedPullCommand.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Site; - -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; -use Bigpixelrocket\DeployerPHP\Traits\SiteSharedPathsTrait; -use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; +namespace PHPDeployer\Console\Site; + +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\Traits\PlaybooksTrait; +use PHPDeployer\Traits\ServersTrait; +use PHPDeployer\Traits\SiteSharedPathsTrait; +use PHPDeployer\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -49,7 +49,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Download Shared File'); + $this->h1('Download Shared File'); // // Select site @@ -73,16 +73,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $server; } - $this->displayServerDeets($server); - // // Get server info (verifies SSH connection and validates distribution & permissions) // ---- - $info = $this->serverInfo($server); + $server = $this->serverInfo($server); - if (is_int($info)) { - return $info; + if (is_int($server) || $server->info === null) { + return Command::FAILURE; } // @@ -141,7 +139,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); if (! $overwrite) { - $this->io->warning('Download cancelled.'); + $this->warn('Download cancelled.'); return Command::SUCCESS; } @@ -151,8 +149,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Download file // ---- - $this->io->info("Downloading {$remotePath} to {$localPath}"); - $this->io->writeln(''); + $this->info("Downloading {$remotePath} to {$localPath}"); + $this->out(''); try { $this->ssh->downloadFile($server, $remotePath, $localPath); @@ -168,7 +166,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('site:shared:pull', [ + $this->commandReplay('site:shared:pull', [ 'domain' => $site->domain, 'remote' => $remoteRelative, 'local' => $localPath, diff --git a/app/Console/Site/SiteSharedPushCommand.php b/app/Console/Site/SiteSharedPushCommand.php index f8e24241..ba17774f 100644 --- a/app/Console/Site/SiteSharedPushCommand.php +++ b/app/Console/Site/SiteSharedPushCommand.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Console\Site; - -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; -use Bigpixelrocket\DeployerPHP\Traits\SiteSharedPathsTrait; -use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; +namespace PHPDeployer\Console\Site; + +use PHPDeployer\Contracts\BaseCommand; +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\Traits\PlaybooksTrait; +use PHPDeployer\Traits\ServersTrait; +use PHPDeployer\Traits\SiteSharedPathsTrait; +use PHPDeployer\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -49,7 +49,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->heading('Upload Shared File'); + $this->h1('Upload Shared File'); // // Select site @@ -73,22 +73,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $server; } - $this->displayServerDeets($server); - // // Get server info (verifies SSH connection and validates distribution & permissions) // ---- - $info = $this->serverInfo($server); + $server = $this->serverInfo($server); - if (is_int($info)) { - return $info; + if (is_int($server) || $server->info === null) { + return Command::FAILURE; } [ 'distro' => $distro, 'permissions' => $permissions, - ] = $info; + ] = $server->info; /** @var string $distro */ /** @var string $permissions */ @@ -125,8 +123,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Upload file // ---- - $this->io->info("Uploading {$localPath} to {$remotePath}"); - $this->io->writeln(''); + $this->info("Uploading {$localPath} to {$remotePath}"); + $this->out(''); try { $this->runRemoteCommand($server, sprintf('mkdir -p %s', escapeshellarg($remoteDir))); @@ -170,7 +168,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command replay // ---- - $this->showCommandReplay('site:shared:push', [ + $this->commandReplay('site:shared:push', [ 'domain' => $site->domain, 'local' => $localPath, 'remote' => $remoteRelative, diff --git a/app/Container.php b/app/Container.php index 435d9f5a..dece113c 100644 --- a/app/Container.php +++ b/app/Container.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP; +namespace PHPDeployer; use ReflectionClass; use ReflectionIntersectionType; diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index 7d89d00f..c5d98a34 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Contracts; - -use Bigpixelrocket\DeployerPHP\Container; -use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; -use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; -use Bigpixelrocket\DeployerPHP\Services\DigitalOceanService; -use Bigpixelrocket\DeployerPHP\Services\EnvService; -use Bigpixelrocket\DeployerPHP\Services\FilesystemService; -use Bigpixelrocket\DeployerPHP\Services\GitService; -use Bigpixelrocket\DeployerPHP\Services\HttpService; -use Bigpixelrocket\DeployerPHP\Services\InventoryService; -use Bigpixelrocket\DeployerPHP\Services\IOService; -use Bigpixelrocket\DeployerPHP\Services\ProcessService; -use Bigpixelrocket\DeployerPHP\Services\SSHService; +namespace PHPDeployer\Contracts; + +use PHPDeployer\Container; +use PHPDeployer\Repositories\ServerRepository; +use PHPDeployer\Repositories\SiteRepository; +use PHPDeployer\Services\DigitalOceanService; +use PHPDeployer\Services\EnvService; +use PHPDeployer\Services\FilesystemService; +use PHPDeployer\Services\GitService; +use PHPDeployer\Services\HttpService; +use PHPDeployer\Services\InventoryService; +use PHPDeployer\Services\IOService; +use PHPDeployer\Services\ProcessService; +use PHPDeployer\Services\SSHService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -141,52 +141,160 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Display env and inventory statuses $envStatus = $this->env->getEnvFileStatus(); - $color = str_starts_with($envStatus, 'No .env') ? 'yellow' : 'gray'; - $this->io->writeln([ - ' Environment: ', - " {$envStatus}", - '', - ]); - $inventoryStatus = $this->inventory->getInventoryFileStatus(); - $this->io->writeln([ - ' Inventory: ', - ' '.$inventoryStatus.'', - '', + + $this->out([ + "<|gray>Env: {$envStatus}", + "<|gray>Inventory: {$inventoryStatus}", ]); return Command::SUCCESS; } // ---- - // Helper Methods + // IO Helpers // ---- /** - * Display a heading with a horizontal rule and title. + * Write-out a separator line. */ - protected function heading(string $text): void + protected function hr(): void { - $this->io->hr(); - $this->io->h1($text); + $this->out( + '────────────────────────────────────────────────────────────', + ); } /** - * Display a success message. + * Write-out a main heading. + */ + protected function h1(string $text): void + { + $this->out([ + '', + "# {$text}", + ]); + + $this->hr(); + } + + /** + * Write-out a secondary heading. + */ + protected function h2(string $text): void + { + $h2 = "## {$text}"; + $underline = str_repeat('─', strlen($h2)); + + $this->out([ + $h2, + $underline, + ]); + } + + /** + * Display an info message with info symbol. + */ + protected function info(string $message): void + { + $this->out("ℹ {$message}"); + } + + /** + * Display a message with a checkmark. */ protected function yay(string $message): void { - $this->io->success($message); - $this->io->writeln(''); + $this->out("✓ {$message}"); + } + + /** + * Display a message with a warning symbol. + */ + protected function warn(string $message): void + { + $this->out("! {$message}"); } /** - * Display an error message. + * Display a message with an error symbol. */ protected function nay(string $message): void { - $this->io->error($message); - $this->io->writeln(''); + $this->out("<|red>✗ {$message}"); + } + + /** + * Display a list of items with a bullet point. + * + * @param string|iterable $lines + */ + protected function ul(string|iterable $lines): void + { + $writeLines = is_string($lines) ? [$lines] : $lines; + foreach ($writeLines as &$line) { + $line = "• {$line}"; + } + unset($line); + $this->out($writeLines); + } + + /** + * Display a list of items with numbers. + * + * @param string|iterable $lines + */ + protected function ol(string|iterable $lines): void + { + $writeLines = is_string($lines) ? [$lines] : $lines; + $counter = 1; + foreach ($writeLines as &$line) { + $line = "{$counter}. {$line}"; + $counter++; + } + unset($line); + $this->out($writeLines); + } + + /** + * This wrapper for Symfony's ConsoleOutput::out() method. + * + * @param string|iterable $lines + */ + protected function out(string|iterable $lines): void + { + $this->io->out($lines); + } + + /** + * Display key-value details with aligned formatting. + * + * Formats key-value pairs with proper alignment and gray styling for values. + * + * @param array $details Key-value pairs to display + * @param bool $ul Whether to use a bullet point list + */ + protected function displayDeets(array $details, bool $ul = false): void + { + if (empty($details)) { + return; + } + + // Find longest key for alignment + $maxLength = max(array_map(fn (int|string $k): int => strlen((string) $k), array_keys($details))); + + foreach ($details as $key => $value) { + $paddedKey = str_pad($key.':', $maxLength + 1); + if (is_array($value)) { + $this->out("{$paddedKey}"); + /** @var array $value */ + $this->displayDeets($value, true); + continue; + } + + /** @var string|int|float|bool|null $value */ + $this->out(($ul ? '• ' : '') . "{$paddedKey} {$value}"); + } } /** @@ -194,7 +302,7 @@ protected function nay(string $message): void * * @param array $options Array of option name => value pairs */ - protected function showCommandReplay(string $commandName, array $options): void + protected function commandReplay(string $commandName, array $options): void { // // Build command options @@ -229,16 +337,19 @@ protected function showCommandReplay(string $commandName, array $options): void // // Display command hint - $this->io->write(''); - $this->io->writeln("\$ vendor/bin/deployer {$commandName} \\"); + $hasParts = count($parts) > 1; + + $this->io->write([ + '', + "\$> vendor/bin/deployer {$commandName} " . ($hasParts ? ' \\' : ''), + ], true); foreach ($parts as $index => $part) { $last = $index === count($parts) - 1; $suffix = $last ? '' : ' \\'; - $this->io->writeln(sprintf(' %s%s', $part, $suffix)); + $this->io->write(sprintf(' %s%s', $part, $suffix), true); } $this->io->write(''); - $this->io->writeln(''); } } diff --git a/app/DTOs/ServerDTO.php b/app/DTOs/ServerDTO.php index fe56d040..e5de74c2 100644 --- a/app/DTOs/ServerDTO.php +++ b/app/DTOs/ServerDTO.php @@ -2,10 +2,11 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\DTOs; +namespace PHPDeployer\DTOs; readonly class ServerDTO { + /** @param array|null $info */ public function __construct( public string $name, public string $host, @@ -14,6 +15,26 @@ public function __construct( public ?string $privateKeyPath = null, public ?string $provider = null, public ?int $dropletId = null, // DigitalOcean droplet ID + public ?array $info = null, ) { } + + /** + * Return a new instance with the provided info. + * + * @param array $info + */ + public function withInfo(array $info): self + { + return new self( + name: $this->name, + host: $this->host, + port: $this->port, + username: $this->username, + privateKeyPath: $this->privateKeyPath, + provider: $this->provider, + dropletId: $this->dropletId, + info: $info, + ); + } } diff --git a/app/DTOs/SiteDTO.php b/app/DTOs/SiteDTO.php index 2e1c2ef9..6c6197bc 100644 --- a/app/DTOs/SiteDTO.php +++ b/app/DTOs/SiteDTO.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\DTOs; +namespace PHPDeployer\DTOs; readonly class SiteDTO { diff --git a/app/Enums/Distribution.php b/app/Enums/Distribution.php index 9d1c34c3..21c5605b 100644 --- a/app/Enums/Distribution.php +++ b/app/Enums/Distribution.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Enums; +namespace PHPDeployer\Enums; /** * Supported Linux distributions. diff --git a/app/Enums/DistributionFamily.php b/app/Enums/DistributionFamily.php index 7a4cde91..43db583a 100644 --- a/app/Enums/DistributionFamily.php +++ b/app/Enums/DistributionFamily.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Enums; +namespace PHPDeployer\Enums; /** * Distribution families for server provisioning. diff --git a/app/Exceptions/SSHTimeoutException.php b/app/Exceptions/SSHTimeoutException.php index 5354d1bb..593b55e2 100644 --- a/app/Exceptions/SSHTimeoutException.php +++ b/app/Exceptions/SSHTimeoutException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Exceptions; +namespace PHPDeployer\Exceptions; /** * Exception thrown when SSH command execution times out. diff --git a/app/Repositories/ServerRepository.php b/app/Repositories/ServerRepository.php index 36910c20..b07cad31 100644 --- a/app/Repositories/ServerRepository.php +++ b/app/Repositories/ServerRepository.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Repositories; +namespace PHPDeployer\Repositories; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\Services\InventoryService; /** * Repository for server CRUD operations using inventory storage. @@ -97,7 +97,7 @@ public function findByHost(string $host): ?ServerDTO } /** - * Get all servers from the inventory. + * Get all servers from inventory. * * @return array */ @@ -114,7 +114,7 @@ public function all(): array } /** - * Delete a server from the inventory. + * Delete a server from inventory. */ public function delete(string $name): void { diff --git a/app/Repositories/SiteRepository.php b/app/Repositories/SiteRepository.php index 026e2581..6cc82af4 100644 --- a/app/Repositories/SiteRepository.php +++ b/app/Repositories/SiteRepository.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Repositories; +namespace PHPDeployer\Repositories; -use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; -use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use PHPDeployer\DTOs\SiteDTO; +use PHPDeployer\Services\InventoryService; /** * Repository for site CRUD operations using inventory storage. diff --git a/app/Services/DigitalOcean/BaseDigitalOceanService.php b/app/Services/DigitalOcean/BaseDigitalOceanService.php index 24c7975f..ee234c37 100644 --- a/app/Services/DigitalOcean/BaseDigitalOceanService.php +++ b/app/Services/DigitalOcean/BaseDigitalOceanService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services\DigitalOcean; +namespace PHPDeployer\Services\DigitalOcean; use DigitalOceanV2\Client; diff --git a/app/Services/DigitalOcean/DigitalOceanAccountService.php b/app/Services/DigitalOcean/DigitalOceanAccountService.php index 42a45c85..e489972e 100644 --- a/app/Services/DigitalOcean/DigitalOceanAccountService.php +++ b/app/Services/DigitalOcean/DigitalOceanAccountService.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services\DigitalOcean; +namespace PHPDeployer\Services\DigitalOcean; -use Bigpixelrocket\DeployerPHP\Enums\Distribution; use DigitalOceanV2\Entity\Image as ImageEntity; use DigitalOceanV2\Entity\Region as RegionEntity; use DigitalOceanV2\Entity\Size as SizeEntity; +use PHPDeployer\Enums\Distribution; /** * DigitalOcean account data service. @@ -41,6 +41,8 @@ public function getAvailableRegions(): array } } + asort($options); + return $options; } catch (\Throwable $e) { throw new \RuntimeException('Failed to fetch regions: ' . $e->getMessage(), 0, $e); @@ -60,7 +62,7 @@ public function getAvailableSizes(): array $sizeApi = $client->size(); $sizes = $sizeApi->getAll(); - $options = []; + $sizeData = []; foreach ($sizes as $size) { /** @var SizeEntity $size */ if ($size->available) { @@ -69,10 +71,22 @@ public function getAvailableSizes(): array $disk = $size->disk; $price = $size->priceMonthly; - $options[$size->slug] = "{$size->slug} - {$vcpus} vCPU, {$memory}GB RAM, {$disk}GB SSD (\${$price}/mo)"; + $sizeData[] = [ + 'slug' => $size->slug, + 'price' => $price, + 'label' => "{$size->slug} - {$vcpus} vCPU, {$memory}GB RAM, {$disk}GB SSD (\${$price}/mo)", + ]; } } + // Sort by price ascending + usort($sizeData, fn (array $a, array $b) => $a['price'] <=> $b['price']); + + $options = []; + foreach ($sizeData as $data) { + $options[$data['slug']] = $data['label']; + } + return $options; } catch (\Throwable $e) { throw new \RuntimeException('Failed to fetch sizes: ' . $e->getMessage(), 0, $e); diff --git a/app/Services/DigitalOcean/DigitalOceanDropletService.php b/app/Services/DigitalOcean/DigitalOceanDropletService.php index 55e02037..631daa52 100644 --- a/app/Services/DigitalOcean/DigitalOceanDropletService.php +++ b/app/Services/DigitalOcean/DigitalOceanDropletService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services\DigitalOcean; +namespace PHPDeployer\Services\DigitalOcean; use DigitalOceanV2\Entity\Droplet as DropletEntity; diff --git a/app/Services/DigitalOcean/DigitalOceanKeyService.php b/app/Services/DigitalOcean/DigitalOceanKeyService.php index 9be75110..708d6c29 100644 --- a/app/Services/DigitalOcean/DigitalOceanKeyService.php +++ b/app/Services/DigitalOcean/DigitalOceanKeyService.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services\DigitalOcean; +namespace PHPDeployer\Services\DigitalOcean; -use Bigpixelrocket\DeployerPHP\Services\FilesystemService; +use PHPDeployer\Services\FilesystemService; /** * DigitalOcean SSH key management service. diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php index bcf09a1b..1c1e720a 100644 --- a/app/Services/DigitalOceanService.php +++ b/app/Services/DigitalOceanService.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services; +namespace PHPDeployer\Services; -use Bigpixelrocket\DeployerPHP\Services\DigitalOcean\DigitalOceanAccountService; -use Bigpixelrocket\DeployerPHP\Services\DigitalOcean\DigitalOceanDropletService; -use Bigpixelrocket\DeployerPHP\Services\DigitalOcean\DigitalOceanKeyService; use DigitalOceanV2\Client; +use PHPDeployer\Services\DigitalOcean\DigitalOceanAccountService; +use PHPDeployer\Services\DigitalOcean\DigitalOceanDropletService; +use PHPDeployer\Services\DigitalOcean\DigitalOceanKeyService; /** * DigitalOcean API facade service. diff --git a/app/Services/EnvService.php b/app/Services/EnvService.php index 302cd88d..2c146ada 100644 --- a/app/Services/EnvService.php +++ b/app/Services/EnvService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services; +namespace PHPDeployer\Services; use Symfony\Component\Dotenv\Dotenv; @@ -83,7 +83,7 @@ public function loadEnvFile(): void $this->readDotenv(); - $this->envFileStatus = "Reading variables from {$path}"; + $this->envFileStatus = $path; if (!count($this->dotenv)) { $this->envFileStatus = "No variables found in {$path}"; } diff --git a/app/Services/FilesystemService.php b/app/Services/FilesystemService.php index 8e31b276..47e70f8a 100644 --- a/app/Services/FilesystemService.php +++ b/app/Services/FilesystemService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services; +namespace PHPDeployer\Services; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; diff --git a/app/Services/GitService.php b/app/Services/GitService.php index 5723d222..b7ce0b2e 100644 --- a/app/Services/GitService.php +++ b/app/Services/GitService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services; +namespace PHPDeployer\Services; /** * Git operations service. diff --git a/app/Services/HttpService.php b/app/Services/HttpService.php index 10e19138..3c6e06ad 100644 --- a/app/Services/HttpService.php +++ b/app/Services/HttpService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services; +namespace PHPDeployer\Services; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; diff --git a/app/Services/IOService.php b/app/Services/IOService.php index fc12e3f2..545153ba 100644 --- a/app/Services/IOService.php +++ b/app/Services/IOService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services; +namespace PHPDeployer\Services; use Closure; @@ -45,8 +45,55 @@ public function initialize(Command $command, InputInterface $input, OutputInterf $this->io = new SymfonyStyle($input, $output); } - // - // Input Gathering + // ---- + // Output Methods + // ---- + + /** + * Write-out multiple lines. + * + * @param string|iterable $lines + */ + public function out(string|iterable $lines): void + { + $writeLines = is_string($lines) ? [$lines] : $lines; + foreach ($writeLines as &$line) { + $line = str_replace('<|', ']+>)(.*)$/', $line, $matches)) { + // Preserve existing color tags (like ) + $line = $matches[1] . '▒ ' . $matches[2]; + } else { + $line = '▒ ' . $line; + } + } + + unset($line); + $this->io->write($writeLines, true); + } + + /** + * Display an error message with red X. + */ + public function error(string $message): void + { + $this->out("<|red>✗ {$message}"); + } + + /** + * This wrapper for Symfony's ConsoleOutput::write() method. + * + * @param string|iterable $messages + * @param bool $newline Whether to add a newline after the messages + */ + public function write(string|iterable $messages, bool $newline = false): void + { + $this->io->write($messages, $newline); + } + + // ---- + // Input methods // ---- /** @@ -177,7 +224,7 @@ public function getValidatedOptionOrPrompt( $error = $validator($value); if ($error !== null) { $this->error($error); - $this->writeln(''); + $this->out(''); return null; } @@ -439,130 +486,4 @@ public function promptSpin( message: $message ); } - - // - // Output Methods - // ---- - - /** - * Write output without newline. - * - * Used for streaming output where content arrives in chunks. - * For complete lines, use writeln() instead. - */ - public function write(string $text): void - { - $this->io->write($text); - } - - /** - * Write-out multiple lines. - * - * @param array $lines - */ - public function writeln(string|array $lines): void - { - $writeLines = is_array($lines) ? $lines : [$lines]; - foreach ($writeLines as $line) { - $this->io->writeln(' '.$line); - } - } - - /** - * Display an info message with cyan info symbol. - */ - public function info(string $message): void - { - $this->writeln("ℹ {$message}"); - } - - /** - * Display a success message with green checkmark. - */ - public function success(string $message): void - { - $this->writeln("✓ {$message}"); - } - - /** - * Display a warning message with yellow warning symbol. - */ - public function warning(string $message): void - { - $this->writeln("⚠ {$message}"); - } - - /** - * Display an error message with red X. - */ - public function error(string $message): void - { - $this->writeln("✗ {$message}"); - } - - /** - * Write-out a heading. - */ - public function h1(string $text): void - { - $this->writeln([ - ''.$text.'', - '', - ]); - } - - /** - * Write-out a separator line. - */ - public function hr(): void - { - $this->writeln([ - '╭───────────────────────────────────────────────', - '', - ]); - } - - /** - * Display key-value details with aligned formatting. - * - * Formats key-value pairs with proper alignment and gray styling for values. - * - * @param array> $details Key-value pairs to display - * - * @example - * $this->io->displayDeets([ - * 'Name' => 'production-web-01', - * 'Host' => '192.168.1.100', - * 'Port' => 22, - * ]); - * // Output: - * // Name: production-web-01 - * // Host: 192.168.1.100 - * // Port: 22 - */ - public function displayDeets(array $details): void - { - if (empty($details)) { - return; - } - - // Find longest key for alignment - $maxLength = max(array_map(strlen(...), array_keys($details))); - - $lines = []; - foreach ($details as $key => $value) { - $paddedKey = str_pad($key.':', $maxLength + 1); - if (is_array($value)) { - $lines[] = " {$paddedKey}"; - foreach ($value as $item) { - $lines[] = " • {$item}"; - } - } else { - $lines[] = " {$paddedKey} {$value}"; - } - } - - $this->writeln($lines); - } - } diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php index 2c1b776f..a5e14f49 100644 --- a/app/Services/InventoryService.php +++ b/app/Services/InventoryService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services; +namespace PHPDeployer\Services; use Symfony\Component\Yaml\Yaml; @@ -107,7 +107,7 @@ public function loadInventoryFile(): void } $this->readInventory(); - $this->inventoryFileStatus = "Reading inventory from {$path}"; + $this->inventoryFileStatus = $path; } /** diff --git a/app/Services/ProcessService.php b/app/Services/ProcessService.php index 4fe79bf6..7a1e01c4 100644 --- a/app/Services/ProcessService.php +++ b/app/Services/ProcessService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services; +namespace PHPDeployer\Services; use Symfony\Component\Process\Process; diff --git a/app/Services/SSHService.php b/app/Services/SSHService.php index e5b10de8..aeb09628 100644 --- a/app/Services/SSHService.php +++ b/app/Services/SSHService.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services; +namespace PHPDeployer\Services; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Exceptions\SSHTimeoutException; +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\Exceptions\SSHTimeoutException; use phpseclib3\Crypt\Common\PrivateKey; use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Net\SFTP; diff --git a/app/Services/VersionService.php b/app/Services/VersionService.php index 54236140..a8fee176 100644 --- a/app/Services/VersionService.php +++ b/app/Services/VersionService.php @@ -2,187 +2,38 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Services; +namespace PHPDeployer\Services; use Composer\InstalledVersions; -use Symfony\Component\Process\Process; /** - * Handles version detection from multiple sources with fallback chain. - * - * Priority Order: - * 1. Composer InstalledVersions API - * 2. Git tag detection - * 3. Fallback to 'dev-main' + * Handles version detection using Composer's InstalledVersions API. */ class VersionService { /** - * Create a VersionService configured with process and filesystem services and package/fallback version. + * Create a VersionService configured with the package name to query. * - * @param string $packageName The Composer package name to query for version information (default 'bigpixelrocket/deployer-php'). - * @param string $fallbackVersion The version to return when no other source provides one (default 'dev-main'). + * @param string $packageName The Composer package name to query for version information. */ public function __construct( - private readonly ProcessService $proc, - private readonly FilesystemService $fs, - private readonly string $packageName = 'bigpixelrocket/deployer-php', - private readonly string $fallbackVersion = 'dev-main' + private readonly string $packageName = 'php-deployer/php-deployer' ) { } /** - * Get version from all available sources with fallback chain. + * Get version from Composer's InstalledVersions API with 'dev' fallback. */ public function getVersion(): string - { - // Try Composer's InstalledVersions API first - $composerVersion = $this->getVersionFromComposer(); - if ($composerVersion !== null) { - return $composerVersion; - } - - // Try to get version from git next - $gitVersion = $this->getVersionFromGit(); - if ($gitVersion !== null) { - return $gitVersion; - } - - // Default fallback - return $this->fallbackVersion; - } - - // - // Version Detection Methods - // ---- - - /** - * Get version from Composer's runtime API. - */ - public function getVersionFromComposer(): ?string { if (!class_exists(InstalledVersions::class)) { - return null; + return 'dev'; } try { - $version = InstalledVersions::getPrettyVersion($this->packageName); - return $version; + return InstalledVersions::getPrettyVersion($this->packageName) ?? 'dev'; } catch (\OutOfBoundsException) { - // Package not found in installed.json, continue to fallbacks - return null; - } - } - - /** - * Get version from git repository using multiple strategies. - */ - public function getVersionFromGit(?string $projectRoot = null): ?string - { - $projectRoot ??= $this->fs->getParentDirectory(__DIR__, 2); - - // Check if we're in a git repository - if (!$this->isGitRepository($projectRoot)) { - return null; - } - - // Try exact tag match first - $exactTag = $this->getExactGitTag($projectRoot); - if ($exactTag !== null) { - return $exactTag; - } - - // Try git describe with tags - $describeVersion = $this->getGitDescribeVersion($projectRoot); - if ($describeVersion !== null) { - return $describeVersion; - } - - // Fallback to branch + commit - return $this->getBranchWithCommit($projectRoot); - } - - // - // Git Detection Helpers - // ---- - - /** - * Check if directory is a git repository. - */ - public function isGitRepository(string $projectRoot): bool - { - return $this->fs->isDirectory($projectRoot . '/.git'); - } - - /** - * Retrieve the exact Git tag name that points to HEAD, if present. - * - * @param string $projectRoot Path to the Git repository root. - * @return string|null The exact tag name that points to HEAD, or `null` if HEAD is not tagged or an error occurs. - */ - public function getExactGitTag(string $projectRoot): ?string - { - try { - $process = $this->proc->run(['git', 'describe', '--tags', '--exact-match'], $projectRoot); - - if ($process->isSuccessful()) { - return trim($process->getOutput()); - } - } catch (\Exception) { - // Directory doesn't exist or other process error - } - - return null; - } - - /** - * Determine a human-readable Git reference for the repository at the given path. - * - * Attempts to run `git describe --tags --always` and returns the trimmed output on success. - * - * @param string $projectRoot Path to the repository root where the Git command will run. - * @return string|null The described reference (tag, tag+commit, or short commit) if available, `null` otherwise. - */ - public function getGitDescribeVersion(string $projectRoot): ?string - { - try { - $process = $this->proc->run(['git', 'describe', '--tags', '--always'], $projectRoot); - - if ($process->isSuccessful()) { - return trim($process->getOutput()); - } - } catch (\Exception) { - // Directory doesn't exist or other process error + return 'dev'; } - - return null; - } - - /** - * Produce the current Git branch combined with the short commit hash. - * - * Returns a string in the format "branch@commit" where `branch` is the current branch name - * and `commit` is the short commit hash. Returns `null` if the repository information cannot - * be determined or an error occurs. - * - * @param string $projectRoot Path to the repository root. - * @return string|null The branch and short commit separated by '@', or `null` if unavailable. - */ - public function getBranchWithCommit(string $projectRoot): ?string - { - try { - $branchProcess = $this->proc->run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $projectRoot); - $commitProcess = $this->proc->run(['git', 'rev-parse', '--short', 'HEAD'], $projectRoot); - - if ($branchProcess->isSuccessful() && $commitProcess->isSuccessful()) { - $branch = trim($branchProcess->getOutput()); - $commit = trim($commitProcess->getOutput()); - return $branch . '@' . $commit; - } - } catch (\Exception) { - // Directory doesn't exist or other process error - } - - return null; } } diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index 34bbc6a1..f475f27c 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -2,29 +2,29 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP; - -use Bigpixelrocket\DeployerPHP\Console\HelloCommand; -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; -use Bigpixelrocket\DeployerPHP\Console\Server\ServerInstallCommand; -use Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand; -use Bigpixelrocket\DeployerPHP\Console\Server\ServerLogsCommand; -use Bigpixelrocket\DeployerPHP\Console\Server\ServerProvisionDigitalOceanCommand; -use Bigpixelrocket\DeployerPHP\Console\Server\ServerRunCommand; -use Bigpixelrocket\DeployerPHP\Console\Site\SiteAddCommand; -use Bigpixelrocket\DeployerPHP\Console\Site\SiteDeleteCommand; -use Bigpixelrocket\DeployerPHP\Console\Site\SiteDeployCommand; -use Bigpixelrocket\DeployerPHP\Console\Site\SiteHttpsCommand; -use Bigpixelrocket\DeployerPHP\Console\Site\SiteListCommand; -use Bigpixelrocket\DeployerPHP\Console\Site\SiteSharedPullCommand; -use Bigpixelrocket\DeployerPHP\Console\Site\SiteSharedPushCommand; -use Bigpixelrocket\DeployerPHP\Services\VersionService; +namespace PHPDeployer; + +use PHPDeployer\Console\HelloCommand; +use PHPDeployer\Console\Key\KeyAddDigitalOceanCommand; +use PHPDeployer\Console\Key\KeyDeleteDigitalOceanCommand; +use PHPDeployer\Console\Key\KeyListDigitalOceanCommand; +use PHPDeployer\Console\ScaffoldHooksCommand; +use PHPDeployer\Console\Server\ServerAddCommand; +use PHPDeployer\Console\Server\ServerDeleteCommand; +use PHPDeployer\Console\Server\ServerInfoCommand; +use PHPDeployer\Console\Server\ServerInstallCommand; +use PHPDeployer\Console\Server\ServerListCommand; +use PHPDeployer\Console\Server\ServerLogsCommand; +use PHPDeployer\Console\Server\ServerProvisionDigitalOceanCommand; +use PHPDeployer\Console\Server\ServerRunCommand; +use PHPDeployer\Console\Site\SiteAddCommand; +use PHPDeployer\Console\Site\SiteDeleteCommand; +use PHPDeployer\Console\Site\SiteDeployCommand; +use PHPDeployer\Console\Site\SiteHttpsCommand; +use PHPDeployer\Console\Site\SiteListCommand; +use PHPDeployer\Console\Site\SiteSharedPullCommand; +use PHPDeployer\Console\Site\SiteSharedPushCommand; +use PHPDeployer\Services\VersionService; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -107,23 +107,13 @@ private function displayBanner(): void { $version = $this->getVersion(); - // Simple, compact banner - $banner = [ - '', - '╭───────────────────────────────────────────────', - ' ┌┬┐┌─┐┌─┐┬ ┌─┐┬ ┬┌─┐┬─┐', - ' ││├┤ ├─┘│ │ │└┬┘├┤ ├┬┘', - ' ─┴┘└─┘┴ ┴─┘└─┘ ┴ └─┘┴└─PHP '.$version.'', - '', - ' The Server & Site Deployment Tool for PHP', - '╰───────────────────────────────────────────────', - '', - ]; - - // Display the banner - foreach ($banner as $line) { - $this->io->writeln($line); - } + $this->io->writeln( + [ + '', + '▒ PHP Deployer • '.$version.' • Server & Site Deployment for PHP', + '▒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + ] + ); } /** @@ -140,7 +130,7 @@ private function registerCommands(): void ScaffoldHooksCommand::class, // - // Key management + // Provider key management KeyAddDigitalOceanCommand::class, KeyDeleteDigitalOceanCommand::class, diff --git a/app/Traits/DigitalOceanTrait.php b/app/Traits/DigitalOceanTrait.php index f580979f..62c9b7e1 100644 --- a/app/Traits/DigitalOceanTrait.php +++ b/app/Traits/DigitalOceanTrait.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Traits; +namespace PHPDeployer\Traits; -use Bigpixelrocket\DeployerPHP\Services\DigitalOceanService; -use Bigpixelrocket\DeployerPHP\Services\EnvService; -use Bigpixelrocket\DeployerPHP\Services\IOService; +use PHPDeployer\Services\DigitalOceanService; +use PHPDeployer\Services\EnvService; +use PHPDeployer\Services\IOService; use Symfony\Component\Console\Command\Command; /** @@ -52,15 +52,13 @@ protected function initializeDigitalOceanAPI(): int } catch (\InvalidArgumentException) { // Token configuration issue $this->nay('DigitalOcean API token not found in environment.'); - $this->io->writeln('Set DIGITALOCEAN_API_TOKEN or DO_API_TOKEN in your .env file.'); - $this->io->writeln(''); + $this->nay('Set DIGITALOCEAN_API_TOKEN or DO_API_TOKEN in your .env file.'); return Command::FAILURE; } catch (\RuntimeException $e) { // API authentication failure $this->nay($e->getMessage()); - $this->io->writeln('Check that your API token is valid and has not expired.'); - $this->io->writeln(''); + $this->nay('Check that your DIGITALOCEAN_API_TOKEN or DO_API_TOKEN is valid and has not expired.'); return Command::FAILURE; } @@ -94,11 +92,9 @@ protected function ensureKeysAvailable(?array $keys = null): array|int // Check if no keys are available if (count($keys) === 0) { - $this->io->warning('No public SSH keys found in your DigitalOcean account'); - $this->io->writeln([ - '', + $this->info('No public SSH keys found in your DigitalOcean account'); + $this->ul([ 'Run key:add:digitalocean to add a public SSH key', - '', ]); return Command::FAILURE; @@ -110,24 +106,21 @@ protected function ensureKeysAvailable(?array $keys = null): array|int /** * Select a key from available keys via option or interactive prompt. * - * @param array|null $availableKeys Optional pre-fetched keys; if null, fetches from DigitalOcean API * @return array{id: string|int, description: string}|int Array with selected key ID and description on success, or Command::FAILURE on error */ - protected function selectKey(?array $availableKeys = null): array|int + protected function selectKey(): array|int { // // Get all keys - if ($availableKeys === null) { - $availableKeys = $this->ensureKeysAvailable(); + $availableKeys = $this->ensureKeysAvailable(); - if (is_int($availableKeys)) { - return Command::FAILURE; - } + if (is_int($availableKeys)) { + return Command::FAILURE; } // - // Get key via option or prompt + // Prompt for selection /** @var string $selectedKey */ $selectedKey = $this->io->getOptionOrPrompt( @@ -139,7 +132,7 @@ protected function selectKey(?array $availableKeys = null): array|int ); // - // Validate key exists in available keys + // Validate key exists (in case user passed --key option) if (!isset($availableKeys[$selectedKey])) { $this->nay("Public SSH key '{$selectedKey}' not found"); diff --git a/app/Traits/KeysTrait.php b/app/Traits/KeysTrait.php index 3d17ac88..53f2d102 100644 --- a/app/Traits/KeysTrait.php +++ b/app/Traits/KeysTrait.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Traits; +namespace PHPDeployer\Traits; -use Bigpixelrocket\DeployerPHP\Services\FilesystemService; +use PHPDeployer\Services\FilesystemService; /** * Reusable SSH key things. @@ -181,4 +181,96 @@ protected function validateKeyNameInput(mixed $name): ?string return null; } + + /** + * Validate SSH private key file. + * + * - Checks if file exists (automatically expands tilde ~ to home directory) + * - Validates key format (PEM format with BEGIN/END markers) + * + * @return string|null Error message if invalid, null if valid + */ + protected function validatePrivateKeyPathInput(mixed $path): ?string + { + if (!is_string($path)) { + return 'Key path must be a string'; + } + + if (trim($path) === '') { + return 'Private key path cannot be empty'; + } + + try { + $expandedPath = $this->fs->expandPath($path); + } catch (\Throwable $e) { + return $e->getMessage(); + } + + if (!$this->fs->exists($expandedPath)) { + return "SSH private key file not found: {$path}"; + } + + try { + $key = $this->fs->readFile($expandedPath); + $key = trim((string) $key); + + // Validate key format (should be PEM format with BEGIN marker) + $validPrefixes = [ + '-----BEGIN OPENSSH PRIVATE KEY-----', + '-----BEGIN RSA PRIVATE KEY-----', + '-----BEGIN EC PRIVATE KEY-----', + '-----BEGIN DSA PRIVATE KEY-----', + '-----BEGIN PRIVATE KEY-----', + ]; + + $isValid = false; + foreach ($validPrefixes as $prefix) { + if (str_starts_with($key, $prefix)) { + $isValid = true; + break; + } + } + + if (!$isValid) { + return 'Invalid SSH private key format'; + } + + // Check for DSA key (obsolete) + if (str_starts_with($key, '-----BEGIN DSA PRIVATE KEY-----')) { + return 'DSA keys are obsolete and insecure'; + } + } catch (\Throwable) { + return 'Could not read SSH private key file'; + } + + return null; + } + + /** + * Validate deploy key pair (private key + corresponding public key). + * + * Validates both the private key and its corresponding public key file + * (expected at same path + '.pub'). + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateDeployKeyPairInput(mixed $path): ?string + { + // First validate the private key + $privateKeyError = $this->validatePrivateKeyPathInput($path); + if ($privateKeyError !== null) { + return $privateKeyError; + } + + // Then validate the corresponding public key + /** @var string $path */ + $publicKeyPath = $path . '.pub'; + $publicKeyError = $this->validateKeyPathInput($publicKeyPath); + + if ($publicKeyError !== null) { + return "Public key not found or invalid: {$publicKeyPath}"; + } + + return null; + } } diff --git a/app/Traits/PlaybooksTrait.php b/app/Traits/PlaybooksTrait.php index a148c5aa..f2634199 100644 --- a/app/Traits/PlaybooksTrait.php +++ b/app/Traits/PlaybooksTrait.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Traits; - -use Bigpixelrocket\DeployerPHP\Container; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Exceptions\SSHTimeoutException; -use Bigpixelrocket\DeployerPHP\Services\FilesystemService; -use Bigpixelrocket\DeployerPHP\Services\IOService; -use Bigpixelrocket\DeployerPHP\Services\SSHService; +namespace PHPDeployer\Traits; + +use PHPDeployer\Container; +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\Exceptions\SSHTimeoutException; +use PHPDeployer\Services\FilesystemService; +use PHPDeployer\Services\IOService; +use PHPDeployer\Services\SSHService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Yaml\Yaml; @@ -25,6 +25,32 @@ */ trait PlaybooksTrait { + /** + * Execute a playbook on a server silently. + * + * @param ServerDTO $server + * @param string $playbookName Playbook name without .sh extension (e.g., 'server-info', 'php-install', etc) + * @param string $spinnerMessage Message to display while executing the playbook + * @param array $playbookVars Playbook variables to pass to the playbook (don't pass sensitive data) + * @return array|int Returns parsed YAML on success or Command::FAILURE on error + */ + protected function executePlaybookSilently( + ServerDTO $server, + string $playbookName, + string $spinnerMessage, + array $playbookVars = [], + ): array|int { + $capture = ''; + + return $this->executePlaybook( + $server, + $playbookName, + $spinnerMessage, + $playbookVars, + $capture + ); + } + /** * Execute a playbook on a server. * @@ -37,18 +63,24 @@ trait PlaybooksTrait * - DEPLOYER_DISTRO: Exact distribution (ubuntu|debian) - caller must provide via $playbookVars * - DEPLOYER_PERMS: User permissions (root|sudo|none) - caller must provide via $playbookVars * - * @param string $playbookName Playbook name without .sh extension (e.g., 'server-info', 'install-php', etc) + * @param ServerDTO $server + * @param string $playbookName Playbook name without .sh extension (e.g., 'server-info', 'php-install', etc) + * @param string $statusMessage Message to display while executing the playbook * @param array $playbookVars Playbook variables to pass to the playbook (don't pass sensitive data) - * @param bool $streamOutput Stream output in real-time (true) or show spinner and display all at end (false) + * @param string|null $capture Variable passed by reference to capture raw output. If null, output is streamed to console. If provided, output is captured silently. * @return array|int Returns parsed YAML on success or Command::FAILURE on error */ protected function executePlaybook( ServerDTO $server, string $playbookName, - string $spinnerMessage, + string $statusMessage, array $playbookVars = [], - bool $streamOutput = false + ?string &$capture = null ): array|int { + + // + // Prepare playbook + $projectRoot = dirname(__DIR__, 2); $playbookPath = $projectRoot . '/playbooks/' . $playbookName . '.sh'; $scriptContents = $this->fs->readFile($playbookPath); @@ -82,40 +114,43 @@ protected function executePlaybook( $scriptContents ); - // Execute command + // + // Execution and output + try { - if ($streamOutput) { - // Streaming output in real-time - $this->io->writeln([ - ''.$spinnerMessage.'', - '', - ]); + if (null === $capture) { + // Streaming output in real time + $this->out('$> ' . $statusMessage); $result = $this->ssh->executeCommand( $server, $scriptWithVars, fn (string $chunk) => $this->io->write($chunk) ); + + $this->out('───'); } else { - // No streaming, use spinner and display output at end + // No streaming, use spinner and capture output later $result = $this->io->promptSpin( callback: fn () => $this->ssh->executeCommand( $server, $scriptWithVars ), - message: $spinnerMessage + message: $statusMessage ); + } - $output = trim((string) $result['output']); - if (!empty($output)) { - $this->io->writeln(explode("\n", $output)); - } + // Display output when capturing only if there was an error + if (null !== $capture && 0 !== $result['exit_code']) { + $this->out('$>'); + $this->out(explode("\n", (string) $result['output'])); + $this->out('───'); } - $this->io->writeln(''); // Empty line after output + $capture = trim((string) $result['output']); } catch (SSHTimeoutException $e) { $this->nay($e->getMessage()); - $this->io->writeln([ + $this->out([ '', 'The process took longer than expected to complete. Either:', ' • Server has a slow network connection', @@ -137,7 +172,7 @@ protected function executePlaybook( // Check exit code if ($result['exit_code'] !== 0) { - $this->nay('Playbook execution failed'); + $this->nay('Execution failed'); return Command::FAILURE; } @@ -151,7 +186,7 @@ protected function executePlaybook( null, 30 ), - message: $spinnerMessage + message: $statusMessage ); $yamlContent = trim((string) $yamlResult['output']); @@ -177,7 +212,7 @@ protected function executePlaybook( return $parsed; } catch (\Throwable $e) { $this->nay($e->getMessage()); - $this->io->writeln([ + $this->out([ ''.$yamlContent.'', '', ]); @@ -185,5 +220,4 @@ protected function executePlaybook( return Command::FAILURE; } } - } diff --git a/app/Traits/ServersTrait.php b/app/Traits/ServersTrait.php index 7e10a53f..630e7ae6 100644 --- a/app/Traits/ServersTrait.php +++ b/app/Traits/ServersTrait.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Traits; - -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; -use Bigpixelrocket\DeployerPHP\Enums\Distribution; -use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; -use Bigpixelrocket\DeployerPHP\Services\IOService; -use Bigpixelrocket\DeployerPHP\Services\SSHService; +namespace PHPDeployer\Traits; + +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\DTOs\SiteDTO; +use PHPDeployer\Enums\Distribution; +use PHPDeployer\Repositories\ServerRepository; +use PHPDeployer\Services\IOService; +use PHPDeployer\Services\SSHService; use Symfony\Component\Console\Command\Command; /** @@ -32,500 +32,108 @@ trait ServersTrait // ---- // - // Server info + // UI // ---- /** - * Get server information by executing server-info playbook. - * - * Automatically displays server info and validates that the server is running a supported distribution (Debian/Ubuntu) - * and has sufficient permissions (root or sudo). + * Display a warning to add a server if no servers are available. Otherwise, return all servers. * - * @param ServerDTO $server Server to get information for - * @return array|int Returns parsed server info or failure code on failure + * @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 serverInfo(ServerDTO $server): array|int + protected function ensureServersAvailable(?array $servers = null): array|int { - $info = $this->executePlaybook( - $server, - 'server-info', - 'Retrieving server information...' - ); + // + // Get all servers - if (is_int($info)) { - return $info; - } + $allServers = $servers ?? $this->servers->all(); - // Display server information before validation - $this->displayServerInfo($info); + // + // Check if no servers are available - // Validate server distribution and permissions - $distroResult = $this->validateServerDistribution($info); - $permissionsResult = $this->validateServerPermissions($info); + if (count($allServers) === 0) { + $this->info('No servers found in your inventory:'); + $this->ul([ + 'Run server:provision to provision a server', + 'Or run server:add to add an existing server', + ]); - if (is_int($distroResult) || is_int($permissionsResult)) { return Command::FAILURE; } - return $info; + return $allServers; } /** - * Get configuration for a specific site from server info. + * Display server details, retrieve system inform and validate SSH connection and permissions. * - * @param array $info Server information array - * @param string $domain Site domain - * @return array{php_version: string, www_mode: string, https_enabled: bool}|null Returns config or null if not found + * @param ServerDTO $server The server to inspect + * @return ServerDTO|int Returns updated ServerDTO with info or Command::FAILURE on error */ - protected function getSiteConfig(array $info, string $domain): ?array + protected function serverInfo(ServerDTO $server): ServerDTO|int { - if (! isset($info['sites_config']) || ! is_array($info['sites_config'])) { - return null; - } + $this->displayServerDeets($server); - $config = $info['sites_config'][$domain] ?? null; + // + // Get server info (also validates SSH connection) - if (! is_array($config)) { - return null; - } + $info = $this->executePlaybookSilently( + $server, + 'server-info', + 'Retrieving server information...', + [], + ); - /** @var mixed $phpVer */ - $phpVer = $config['php_version'] ?? 'unknown'; - /** @var mixed $mode */ - $mode = $config['www_mode'] ?? 'unknown'; + if (is_int($info)) { + return Command::FAILURE; + } - return [ - 'php_version' => is_scalar($phpVer) ? (string) $phpVer : 'unknown', - 'www_mode' => is_scalar($mode) ? (string) $mode : 'unknown', - 'https_enabled' => filter_var($config['https_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN), - ]; - } + // + // Validate distribution - /** - * Validate that server is running a supported distribution. - * - * @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 - { /** @var string $distro */ $distro = $info['distro'] ?? 'unknown'; $distribution = Distribution::tryFrom($distro); - if ($distribution === null) { - $this->nay("Unknown distribution: {$distro}"); + if (null === $distribution || ! $distribution->isSupported()) { + $this->info('DeployerPHP only supports Debian and Ubuntu.'); return Command::FAILURE; } - $distroName = $distribution->displayName(); - - if (! $distribution->isSupported()) { - $this->nay("Unsupported distribution: {$distroName}. Only Debian and Ubuntu are supported."); - - return Command::FAILURE; - } - - return $info; - } + // + // Validate permissions - /** - * Validate that server has sufficient permissions (root or sudo). - * - * @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'])) { - $this->nay('Server requires root or passwordless sudo permissions'); - $this->io->writeln([ - '', - 'To enable passwordless sudo on your server:', - ' 1. SSH to your server as root or with sudo access', - ' 2. Run: echo "USERNAME ALL=(ALL) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/USERNAME', - ' 3. Run: sudo chmod 0440 /etc/sudoers.d/USERNAME', - ' ^- Replace USERNAME with your actual username, e.g.: echo "ubuntu ALL=(ALL) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/ubuntu', - '', - ]); - - return Command::FAILURE; - } - - return $info; - } - - /** - * Display formatted server information. - * - * @param array $info - */ - protected function displayServerInfo(array $info): void - { - /** @var string $distroSlug */ - $distroSlug = $info['distro'] ?? 'unknown'; - $distribution = Distribution::tryFrom($distroSlug); - $distroName = $distribution?->displayName() ?? 'Unknown'; - - $permissionsText = match ($info['permissions'] ?? 'none') { - 'root' => 'root', - 'sudo' => 'sudo', - default => 'insufficient', - }; - - $deets = [ - 'Distro' => $distroName, - 'User' => $permissionsText, - ]; - - $this->io->displayDeets($deets); - $this->io->writeln(''); - - // Display hardware information if available - if (isset($info['hardware']) && is_array($info['hardware'])) { - $hardwareItems = []; - - if (isset($info['hardware']['cpu_cores'])) { - /** @var int|string $cpuCores */ - $cpuCores = $info['hardware']['cpu_cores']; - $coresText = $cpuCores === '1' || $cpuCores === 1 ? '1 core' : "{$cpuCores} cores"; - $hardwareItems[] = "CPU: {$coresText}"; - } - - if (isset($info['hardware']['ram_mb'])) { - /** @var int|string $ramMb */ - $ramMb = $info['hardware']['ram_mb']; - $ramGb = round((int) $ramMb / 1024, 1); - $ramText = $ramGb >= 1 ? "{$ramGb} GB" : "{$ramMb} MB"; - $hardwareItems[] = "RAM: {$ramText}"; - } - - if (isset($info['hardware']['disk_type'])) { - /** @var string $diskType */ - $diskType = $info['hardware']['disk_type']; - $diskText = strtoupper($diskType); - $hardwareItems[] = "Disk: {$diskText}"; - } - - if (count($hardwareItems) > 0) { - $this->io->displayDeets(['Hardware' => $hardwareItems]); - $this->io->writeln(''); - } - } - - $services = []; - - // Add listening ports if any - if (isset($info['ports']) && is_array($info['ports']) && count($info['ports']) > 0) { - $portsList = []; - foreach ($info['ports'] as $port => $process) { - if (is_numeric($port) && is_string($process)) { - $portsList[] = "Port {$port}: {$process}"; - } - } - if (count($portsList) > 0) { - $services = $portsList; - } - } - - $this->io->displayDeets(['Services' => $services]); - $this->io->writeln(''); - - // Display Caddy information if available - if (isset($info['caddy']) && is_array($info['caddy']) && ($info['caddy']['available'] ?? false) === true) { - $caddyItems = []; - - if (isset($info['caddy']['version']) && $info['caddy']['version'] !== 'unknown') { - /** @var string $version */ - $version = $info['caddy']['version']; - $caddyItems[] = 'Version: '.$version; - } - - if (isset($info['caddy']['uptime_seconds'])) { - /** @var int|string|float $rawUptime */ - $rawUptime = $info['caddy']['uptime_seconds']; - /** @var int $uptimeSeconds */ - $uptimeSeconds = (int) $rawUptime; - $caddyItems[] = 'Uptime: '.$this->formatUptime($uptimeSeconds); - } - - if (isset($info['caddy']['total_requests'])) { - /** @var int|string|float $rawTotalReq */ - $rawTotalReq = $info['caddy']['total_requests']; - /** @var int $totalReq */ - $totalReq = (int) $rawTotalReq; - $caddyItems[] = 'Total Requests: '.number_format($totalReq); - } - - if (isset($info['caddy']['active_requests'])) { - /** @var int|string $activeRequests */ - $activeRequests = $info['caddy']['active_requests']; - $caddyItems[] = 'Active Requests: '.$activeRequests; - } - - if (isset($info['caddy']['memory_mb']) && $info['caddy']['memory_mb'] !== '0') { - /** @var string $memoryMb */ - $memoryMb = $info['caddy']['memory_mb']; - $caddyItems[] = 'Memory: '.$memoryMb.' MB'; - } - - if (count($caddyItems) > 0) { - $this->io->displayDeets(['Caddy' => $caddyItems]); - $this->io->writeln(''); - } - } - - // Display PHP versions if available - if (isset($info['php']) && is_array($info['php'])) { - $phpItems = []; - - if (isset($info['php']['versions']) && is_array($info['php']['versions']) && count($info['php']['versions']) > 0) { - $versions = $info['php']['versions']; - $defaultVersion = $info['php']['default'] ?? null; - - foreach ($versions as $versionData) { - // Handle both old format (string) and new format (array with version/extensions) - if (is_array($versionData) && isset($versionData['version'])) { - /** @var string|int|float */ - $version = $versionData['version']; - $versionStr = (string) $version; - $extensions = $versionData['extensions'] ?? []; - - // Build version line with extensions - $isDefault = false; - if ($defaultVersion !== null && (is_string($defaultVersion) || is_numeric($defaultVersion))) { - /** @var string|int|float $defaultVersion */ - $isDefault = $versionStr === (string) $defaultVersion; - } - - $versionLine = "PHP {$versionStr}"; - if ($isDefault) { - $versionLine .= ' (default)'; - } - - // Add extensions if available - if (is_array($extensions) && count($extensions) > 0) { - $extCount = count($extensions); - $extList = implode(', ', $extensions); - $versionLine .= " with {$extCount} extensions: {$extList}"; - } - - $phpItems[] = $versionLine; - } elseif (is_string($versionData) || is_numeric($versionData)) { - // Fallback for old format (simple string/numeric version) - $versionStr = (string) $versionData; - if ($defaultVersion !== null && (is_string($defaultVersion) || is_numeric($defaultVersion))) { - /** @var string|int|float $defaultVersion */ - $isDefault = $versionStr === (string) $defaultVersion; - if ($isDefault) { - $phpItems[] = "PHP {$versionStr} (default)"; - } else { - $phpItems[] = "PHP {$versionStr}"; - } - } else { - $phpItems[] = "PHP {$versionStr}"; - } - } - } - } - - if (count($phpItems) > 0) { - $this->io->displayDeets(['PHP' => $phpItems]); - $this->io->writeln(''); - } - } - - // 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)) { - continue; - } - - $phpFpmItems = []; - - if (isset($fpmData['pool']) && $fpmData['pool'] !== 'unknown') { - /** @var string $pool */ - $pool = $fpmData['pool']; - $phpFpmItems[] = 'Pool: '.$pool; - } - - if (isset($fpmData['process_manager']) && $fpmData['process_manager'] !== 'unknown') { - /** @var string $processManager */ - $processManager = $fpmData['process_manager']; - $phpFpmItems[] = 'Process Manager: '.$processManager; - } - - if (isset($fpmData['active_processes'])) { - /** @var int|string $activeProcesses */ - $activeProcesses = $fpmData['active_processes']; - $phpFpmItems[] = 'Active: '.$activeProcesses.' processes'; - } - - if (isset($fpmData['idle_processes'])) { - /** @var int|string $idleProcesses */ - $idleProcesses = $fpmData['idle_processes']; - $phpFpmItems[] = 'Idle: '.$idleProcesses.' processes'; - } - - if (isset($fpmData['total_processes'])) { - /** @var int|string $totalProcesses */ - $totalProcesses = $fpmData['total_processes']; - $phpFpmItems[] = 'Total: '.$totalProcesses.' processes'; - } - - if (isset($fpmData['listen_queue'])) { - /** @var int|string|float $rawQueue */ - $rawQueue = $fpmData['listen_queue']; - /** @var int $queue */ - $queue = (int) $rawQueue; - $queueDisplay = $queue > 0 ? "{$queue} waiting" : '0 waiting'; - $phpFpmItems[] = 'Queue: '.$queueDisplay; - } - - if (isset($fpmData['accepted_conn'])) { - /** @var int|string|float $rawAccepted */ - $rawAccepted = $fpmData['accepted_conn']; - /** @var int $accepted */ - $accepted = (int) $rawAccepted; - $phpFpmItems[] = 'Accepted: '.number_format($accepted); - } - - if (isset($fpmData['max_children_reached'])) { - /** @var int|string|float $rawMaxChildren */ - $rawMaxChildren = $fpmData['max_children_reached']; - /** @var int $maxChildren */ - $maxChildren = (int) $rawMaxChildren; - if ($maxChildren > 0) { - $phpFpmItems[] = "Max Children Reached: {$maxChildren}"; - } - } - - if (isset($fpmData['slow_requests'])) { - /** @var int|string|float $rawSlowReqs */ - $rawSlowReqs = $fpmData['slow_requests']; - /** @var int $slowReqsInt */ - $slowReqsInt = (int) $rawSlowReqs; - if ($slowReqsInt > 0) { - $slowReqs = number_format($slowReqsInt); - $phpFpmItems[] = "Slow Requests: {$slowReqs}"; - } - } - - if (count($phpFpmItems) > 0) { - $this->io->displayDeets(["PHP-FPM {$version}" => $phpFpmItems]); - $this->io->writeln(''); - } - } - } - - // Display Sites Configuration if available - if (isset($info['sites_config']) && is_array($info['sites_config']) && count($info['sites_config']) > 0) { - $sitesItems = []; - foreach (array_keys($info['sites_config']) as $domain) { - $config = $this->getSiteConfig($info, (string) $domain); - - if ($config === null) { - continue; - } - - $php = $config['php_version'] === 'unknown' ? '?' : $config['php_version']; - $mode = $config['www_mode'] === 'unknown' ? '?' : $config['www_mode']; - $https = $config['https_enabled'] ? 'HTTPS' : 'HTTP'; - - $sitesItems[] = "{$domain}: PHP {$php}, {$mode}, {$https}"; - } - - if (count($sitesItems) > 0) { - $this->io->displayDeets(['Sites Config' => $sitesItems]); - $this->io->writeln(''); - } - } - } - - /** - * Format uptime seconds into human-readable string. - */ - private function formatUptime(int $seconds): string - { - if ($seconds < 60) { - return "{$seconds}s"; - } - - if ($seconds < 3600) { - $minutes = floor($seconds / 60); - - return "{$minutes}m"; - } - - if ($seconds < 86400) { - $hours = floor($seconds / 3600); - $minutes = floor(($seconds % 3600) / 60); - - return "{$hours}h {$minutes}m"; - } - - $days = floor($seconds / 86400); - $hours = floor(($seconds % 86400) / 3600); - - return "{$days}d {$hours}h"; - } - - // - // UI - // ---- - - /** - * 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 - * @return array|int Returns array of servers or Command::FAILURE if no servers available - */ - protected function ensureServersAvailable(?array $servers = null): array|int - { - // - // Get all servers - - $allServers = $servers ?? $this->servers->all(); - - // - // Check if no servers are available - - if (count($allServers) === 0) { - $this->io->warning('No servers available'); - $this->io->writeln([ - '', - 'Run server:provision to provision your first server,', - 'or run server:add to add an existing server.', - '', + $this->info('DeployerPHP requires root or passwordless sudo permissions:'); + $this->ol([ + "SSH into your server as {$server->username}", + "Run <|cyan>echo \"{$server->username} ALL=(ALL) NOPASSWD:ALL\" | sudo tee /etc/sudoers.d/{$server->username}", + "Run <|cyan>sudo chmod 0440 /etc/sudoers.d/{$server->username}", ]); return Command::FAILURE; } - return $allServers; + return $server->withInfo($info); } /** * Select a server from inventory by name option or interactive prompt. * - * @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 + protected function selectServer(): ServerDTO|int { // // Get all servers - if ($servers === null) { - $servers = $this->ensureServersAvailable(); + $servers = $this->ensureServersAvailable(); - if (is_int($servers)) { - return Command::FAILURE; - } + if (is_int($servers)) { + return Command::FAILURE; } // @@ -535,24 +143,24 @@ protected function selectServer(?array $servers = null): ServerDTO|int $name = (string) $this->io->getOptionOrPrompt( 'server', - fn () => $this->io->promptSelect( + fn (): int|string => $this->io->promptSelect( label: 'Select server:', options: $serverNames, ) ); // - // Find server by name + // Find server by name (in case user passed the --server option) $server = $this->servers->findByName($name); - if ($server === null) { + if (null === $server) { $this->nay("Server '{$name}' not found in inventory"); return Command::FAILURE; } - return $server; + return $this->serverInfo($server); } /** @@ -576,8 +184,8 @@ protected function displayServerDeets(ServerDTO $server): void $deets['Site'] = $sites[0]->domain; } - $this->io->displayDeets($deets); - $this->io->writeln(''); + $this->displayDeets($deets); + $this->out('───'); } /** @@ -597,63 +205,38 @@ protected function getServerForSite(SiteDTO $site): ServerDTO|int } /** - * Verify SSH connection to a server with proper error handling. - * - * Differentiates between fatal errors (authentication/key issues) and non-fatal - * connection timeouts (expected for newly provisioned servers). + * Get configuration for a specific site from server info. * - * @return int Returns Command::SUCCESS if verification succeeds or connection timeout (non-fatal), Command::FAILURE on fatal errors + * @param array $info Server information array + * @param string $domain Site domain + * @return array{php_version: string, www_mode: string, https_enabled: bool}|null Returns config or null if not found */ - protected function verifySSHConnection(ServerDTO $server): int + protected function getSiteConfig(array $info, string $domain): ?array { - try { - $this->io->promptSpin( - callback: function () use ($server) { - $this->ssh->assertCanConnect($server); - }, - message: 'Verifying SSH connection...' - ); - - $this->yay('SSH connection established'); - - return Command::SUCCESS; - } catch (\RuntimeException $e) { - // Differentiate between connection issues (expected) and configuration errors (fatal) - $message = $e->getMessage(); - if (str_contains($message, 'authentication') || - str_contains($message, 'key does not exist') || - str_contains($message, 'key permissions')) { - $this->nay($e->getMessage()); - - return Command::FAILURE; - } - - // Connection timeout - expected for newly provisioned servers - $this->io->warning('SSH is not responding'); - $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', - '', - ]); + if (! isset($info['sites_config']) || ! is_array($info['sites_config'])) { + return null; + } - return Command::SUCCESS; + $config = $info['sites_config'][$domain] ?? null; + + if (! is_array($config)) { + return null; } - } - // - // Provider helpers - // ---- + /** @var mixed $phpVer */ + $phpVer = $config['php_version'] ?? 'unknown'; + /** @var mixed $mode */ + $mode = $config['www_mode'] ?? 'unknown'; - /** - * Check if a server is provisioned on DigitalOcean. - */ - protected function isDigitalOceanServer(ServerDTO $server): bool - { - return $server->provider === 'digitalocean' && $server->dropletId !== null; + return [ + 'php_version' => is_scalar($phpVer) ? (string) $phpVer : 'unknown', + 'www_mode' => is_scalar($mode) ? (string) $mode : 'unknown', + 'https_enabled' => filter_var($config['https_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN), + ]; } + + // ---- // Validation // ---- diff --git a/app/Traits/SiteSharedPathsTrait.php b/app/Traits/SiteSharedPathsTrait.php index 164b48f0..61ff3c81 100644 --- a/app/Traits/SiteSharedPathsTrait.php +++ b/app/Traits/SiteSharedPathsTrait.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Traits; +namespace PHPDeployer\Traits; -use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; +use PHPDeployer\DTOs\SiteDTO; trait SiteSharedPathsTrait { diff --git a/app/Traits/SitesTrait.php b/app/Traits/SitesTrait.php index 4c6f2269..c54483ac 100644 --- a/app/Traits/SitesTrait.php +++ b/app/Traits/SitesTrait.php @@ -2,15 +2,15 @@ declare(strict_types=1); -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; +namespace PHPDeployer\Traits; + +use PHPDeployer\DTOs\ServerDTO; +use PHPDeployer\DTOs\SiteDTO; +use PHPDeployer\Repositories\ServerRepository; +use PHPDeployer\Repositories\SiteRepository; +use PHPDeployer\Services\IOService; +use PHPDeployer\Services\ProcessService; +use PHPDeployer\Services\SSHService; use Symfony\Component\Console\Command\Command; /** @@ -51,12 +51,8 @@ protected function ensureSitesAvailable(?array $sites = null): array|int // Check if no sites are available if (count($allSites) === 0) { - $this->io->warning('No sites found in inventory'); - $this->io->writeln([ - '', - 'Run site:add to add a site', - '', - ]); + $this->info('This command requires at least one site in inventory:'); + $this->ul('Run site:add to add a site'); return Command::SUCCESS; } @@ -121,8 +117,8 @@ protected function displaySiteDeets(SiteDTO $site): void 'Server' => $site->server, ]; - $this->io->displayDeets($details); - $this->io->writeln(''); + $this->displayDeets($details); + $this->out('───'); } // ---- @@ -246,7 +242,7 @@ protected function validateSiteProvisioned(ServerDTO $server, SiteDTO $site): ?i if ($result['exit_code'] !== 0) { $this->nay("Site '{$site->domain}' has not been provisioned on the server"); - $this->io->writeln([ + $this->out([ 'Run site:add to provision the site first.', '', ]); diff --git a/bin/deployer b/bin/deployer index 978fcde0..3371f5f4 100755 --- a/bin/deployer +++ b/bin/deployer @@ -3,8 +3,8 @@ declare(strict_types=1); -use Bigpixelrocket\DeployerPHP\Container; -use Bigpixelrocket\DeployerPHP\SymfonyApp; +use PHPDeployer\Container; +use PHPDeployer\SymfonyApp; // // Bootstrap the autoloader diff --git a/composer.json b/composer.json index 25bde140..233fdc57 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "bigpixelrocket/deployer-php", - "description": "Deploy your PHP applications with ease", + "name": "php-deployer/php-deployer", + "description": "Server & Site Deployment for PHP", "type": "library", "keywords": [ "deployment", @@ -11,8 +11,8 @@ "license": "MIT", "authors": [ { - "name": "Lucian Vacaroiu", - "email": "lucian@DeployerPlus.com" + "name": "Lucian Văcăroiu", + "email": "lucian@bigpixelrocket.com" } ], "require": { @@ -35,7 +35,7 @@ }, "autoload": { "psr-4": { - "Bigpixelrocket\\DeployerPHP\\": "app" + "PHPDeployer\\": "app" } }, "autoload-dev": { diff --git a/playbooks/install-base.sh b/playbooks/base-install.sh similarity index 100% rename from playbooks/install-base.sh rename to playbooks/base-install.sh diff --git a/playbooks/install-bun.sh b/playbooks/bun-install.sh similarity index 100% rename from playbooks/install-bun.sh rename to playbooks/bun-install.sh diff --git a/playbooks/demo-site.sh b/playbooks/demo-site.sh deleted file mode 100644 index 81954200..00000000 --- a/playbooks/demo-site.sh +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env bash - -# -# Demo Site Setup Playbook - Ubuntu/Debian Only -# -# Provision demo site (requires deployer user and Caddy structure pre-configured) -# ---- -# -# This playbook only supports Ubuntu and Debian distributions (debian family). -# Requires server-install to have run first (creates /etc/caddy/conf.d/sites/ directory). -# -# Required Environment Variables: -# DEPLOYER_OUTPUT_FILE - Output file path -# DEPLOYER_DISTRO - Exact distribution: ubuntu|debian -# DEPLOYER_PERMS - Permissions: root|sudo -# -# Returns YAML with: -# - status: success -# - demo_site_path: /home/deployer/demo/public -# - deployer_user: existing -# - demo_site_configured: true -# - -set -o pipefail -export DEBIAN_FRONTEND=noninteractive - -[[ -z $DEPLOYER_OUTPUT_FILE ]] && echo "Error: DEPLOYER_OUTPUT_FILE required" && exit 1 -[[ -z $DEPLOYER_DISTRO ]] && echo "Error: DEPLOYER_DISTRO required" && exit 1 -[[ -z $DEPLOYER_PERMS ]] && echo "Error: DEPLOYER_PERMS required" && exit 1 -export DEPLOYER_PERMS - -# Shared helpers are automatically inlined when executing playbooks remotely -# source "$(dirname "$0")/helpers.sh" - -# ---- -# Setup Functions -# ---- - -# -# Prerequisites -# ---- - -# -# Verify deployer user and home directory exist - -require_deployer_user() { - if ! id -u deployer > /dev/null 2>&1; then - echo "Error: Deployer user not found. Run server:install before demo-site." >&2 - exit 1 - fi - - if ! run_cmd test -d /home/deployer; then - echo "Error: Deployer home directory missing. Run server:install before demo-site." >&2 - exit 1 - fi -} - -# -# Verify required services are installed - -require_services() { - if ! command -v caddy > /dev/null 2>&1; then - echo "Error: Caddy not found. Run server:install to install base packages first." >&2 - exit 1 - fi - - # Verify PHP is available (detect_php_default returns empty string if not found) - local php_version - php_version=$(detect_php_default) - if [[ -z $php_version ]]; then - echo "Error: No PHP installation found. Run server:install to install PHP first." >&2 - exit 1 - fi - - if ! run_cmd test -d /etc/caddy/conf.d/sites; then - echo "Error: Caddy site directory missing. Run server:install to configure Caddy first." >&2 - exit 1 - fi -} - -# -# Demo Site Setup -# ---- - -# -# Create demo site directory structure and files - -setup_demo_site() { - echo "→ Setting up demo site..." - - # Create directory structure - if ! run_cmd test -d /home/deployer/demo/public; then - if ! run_cmd mkdir -p /home/deployer/demo/public; then - echo "Error: Failed to create demo site directory" >&2 - exit 1 - fi - fi - - # Create index.php - if ! run_cmd test -f /home/deployer/demo/public/index.php; then - if ! run_cmd tee /home/deployer/demo/public/index.php > /dev/null <<- 'EOF'; then - &2 - exit 1 - fi - fi - - # Set ownership and group permissions - # Caddy user is in deployer group, so set group-readable permissions - if ! run_cmd chown -R deployer:deployer /home/deployer/demo; then - echo "Error: Failed to set ownership on demo site" >&2 - exit 1 - fi - - # Set permissions: owner+group can read/execute dirs, read files (750 dirs, 640 files) - if ! run_cmd chmod 750 /home/deployer; then - echo "Error: Failed to set permissions on deployer home" >&2 - exit 1 - fi - - if ! run_cmd chmod 750 /home/deployer/demo; then - echo "Error: Failed to set permissions on demo directory" >&2 - exit 1 - fi - - if ! run_cmd chmod 750 /home/deployer/demo/public; then - echo "Error: Failed to set permissions on public directory" >&2 - exit 1 - fi - - if ! run_cmd chmod 640 /home/deployer/demo/public/index.php; then - echo "Error: Failed to set permissions on index.php" >&2 - exit 1 - fi -} - -# -# Caddy Configuration -# ---- - -# -# Configure Caddy for demo site - -configure_demo_site() { - echo "→ Configuring demo site..." - - # Detect default PHP version - local php_version - php_version=$(detect_php_default) - - if [[ -z $php_version ]]; then - echo "Error: No default PHP version found. Run server:install to install PHP first." >&2 - exit 1 - fi - - echo "→ Using PHP ${php_version} (default)" - - # PHP-FPM socket path (debian family) - local php_fpm_socket="/run/php/php${php_version}-fpm.sock" - - # Create log directory - if [[ ! -d /var/log/caddy ]]; then - if ! run_cmd mkdir -p /var/log/caddy; then - echo "Error: Failed to create Caddy log directory" >&2 - exit 1 - fi - fi - - if ! run_cmd chown -R caddy:caddy /var/log/caddy; then - echo "Error: Failed to set ownership on Caddy log directory" >&2 - exit 1 - fi - - # Create demo.caddy - demo site configuration - if ! run_cmd tee /etc/caddy/conf.d/sites/demo.caddy > /dev/null <<- EOF; then - # Demo site - HTTP only (can't get HTTPS cert for IP address) - http://:80 { - root * /home/deployer/demo/public - encode gzip - - log { - output file /var/log/caddy/demo-access.log { - roll_size 100mb - roll_keep 5 - roll_keep_for 720h - } - format json - } - - # Serve PHP files through FPM - php_fastcgi unix//${php_fpm_socket} - - # Serve static files directly (more efficient than passing through PHP-FPM) - file_server - } - EOF - echo "Error: Failed to create demo.caddy" >&2 - exit 1 - fi - - # Enable and start Caddy - if ! systemctl is-enabled --quiet caddy 2> /dev/null; then - if ! run_cmd systemctl enable --quiet caddy; then - echo "Error: Failed to enable Caddy service" >&2 - exit 1 - fi - fi - - if systemctl is-active --quiet caddy 2> /dev/null; then - if ! run_cmd systemctl reload caddy; then - echo "Error: Failed to reload Caddy service" >&2 - exit 1 - fi - else - if ! run_cmd systemctl start caddy; then - echo "Error: Failed to start Caddy service" >&2 - exit 1 - fi - fi - - # Restart PHP-FPM to ensure proper socket communication with Caddy - if systemctl is-active --quiet "php${php_version}-fpm" 2> /dev/null; then - if ! run_cmd systemctl restart "php${php_version}-fpm"; then - echo "Warning: Failed to restart PHP-FPM service" - fi - fi -} - -# ---- -# Main Execution -# ---- - -main() { - # Execute setup tasks - require_deployer_user - require_services - setup_demo_site - configure_demo_site - - # Write output YAML - if ! cat > "$DEPLOYER_OUTPUT_FILE" <<- EOF; then - status: success - demo_site_path: /home/deployer/demo/public - deployer_user: existing - demo_site_configured: true - EOF - echo "Error: Failed to write output file" >&2 - exit 1 - fi -} - -main "$@" diff --git a/playbooks/install-php.sh b/playbooks/php-install.sh similarity index 97% rename from playbooks/install-php.sh rename to playbooks/php-install.sh index 14b0f105..90e0fa75 100644 --- a/playbooks/install-php.sh +++ b/playbooks/php-install.sh @@ -13,7 +13,7 @@ # - Caddy configuration updates for PHP-FPM endpoints # # Note: User and group management (www-data to deployer group) is handled -# by the install-deployer.sh playbook which runs after all services. +# by the user-install.sh playbook which runs after all services. # # This playbook only supports Ubuntu and Debian distributions (debian family). # Both distributions use apt package manager and follow debian conventions. @@ -67,6 +67,21 @@ install_php_packages() { packages+=("php${DEPLOYER_PHP_VERSION}-${ext}") done + # Install selected packages + if ! apt_get_with_retry install -y "${packages[@]}" 2>&1; then + echo "Error: Failed to install PHP ${DEPLOYER_PHP_VERSION} packages" >&2 + exit 1 + fi +} + +# +# Composer Installation +# ---- + +# +# Install Composer if not present + +install_composer() { # Ensure composer is installed if not already present if ! command -v composer > /dev/null 2>&1; then echo "→ Installing Composer..." @@ -88,12 +103,6 @@ install_php_packages() { exit 1 fi fi - - # Install selected packages - if ! apt_get_with_retry install -y "${packages[@]}" 2>&1; then - echo "Error: Failed to install PHP ${DEPLOYER_PHP_VERSION} packages" >&2 - exit 1 - fi } # @@ -250,6 +259,7 @@ main() { install_php_packages configure_php_fpm set_as_default + install_composer update_caddy_config # Write output YAML diff --git a/playbooks/server-info.sh b/playbooks/server-info.sh index ce88fbab..6e86b521 100755 --- a/playbooks/server-info.sh +++ b/playbooks/server-info.sh @@ -414,7 +414,11 @@ get_sites_config() { # HTTPS Status local https_enabled="true" - if [[ $www_mode == "redirect-to-root" ]]; then + + # IP-based HTTP-only site (e.g., status page listening on http://:80) + if [[ $content =~ http://:80 ]]; then + https_enabled="false" + elif [[ $www_mode == "redirect-to-root" ]]; then if [[ $content =~ http://${domain} ]]; then https_enabled="false" fi diff --git a/playbooks/site-add.sh b/playbooks/site-add.sh index f5cf796e..c081f6c9 100644 --- a/playbooks/site-add.sh +++ b/playbooks/site-add.sh @@ -94,13 +94,13 @@ setup_site_directories() { } # -# Demo Site Setup +# Default Page Setup # ---- # -# Create demo hello-world page for site +# Create default placeholder page for site -setup_demo_page() { +setup_default_page() { local domain=$1 local index_file="/home/deployer/sites/${domain}/current/public/index.php" @@ -284,7 +284,7 @@ main() { # Execute setup tasks setup_site_directories "$domain" - setup_demo_page "$domain" + setup_default_page "$domain" configure_caddy_vhost "$domain" reload_services diff --git a/playbooks/install-deployer.sh b/playbooks/user-install.sh similarity index 79% rename from playbooks/install-deployer.sh rename to playbooks/user-install.sh index cd328b08..5515e7fe 100644 --- a/playbooks/install-deployer.sh +++ b/playbooks/user-install.sh @@ -3,12 +3,12 @@ # # Deployer User Setup Playbook # -# Creates deployer user, generates SSH keys, and configures permissions +# Creates deployer user, sets up SSH keys, and configures permissions # ---- # # This playbook handles all deployer user-related configuration including: # - User creation with home directory -# - SSH key pair generation for git deployments +# - SSH key pair generation or custom key installation # - Group memberships (adding caddy and www-data to deployer group) # - Directory permissions for deployer home # @@ -17,6 +17,14 @@ # DEPLOYER_PERMS - Permissions: root|sudo # DEPLOYER_SERVER_NAME - Server name for deploy key generation # +# Optional Environment Variables: +# DEPLOYER_KEY_PRIVATE - Base64-encoded private key (if provided, overwrites existing) +# DEPLOYER_KEY_PUBLIC - Base64-encoded public key (if provided, overwrites existing) +# +# Idempotency: +# - Custom keys (provided): Always overwrite existing keys +# - Auto-generate (not provided): Skip if keys already exist +# # Returns YAML with: # - status: success # - deploy_public_key: public key for git deployments @@ -45,7 +53,6 @@ export DEPLOYER_PERMS setup_deployer() { local deployer_home=$1 - # Ensure home directory has correct permissions (default is usually 755) if ! run_cmd chmod 750 "$deployer_home"; then echo "Error: Failed to set permissions on deployer home directory" >&2 exit 1 @@ -107,24 +114,35 @@ setup_deployer() { local public_key="${deployer_ssh_dir}/id_ed25519.pub" if ! run_cmd test -d "$deployer_ssh_dir"; then - echo "→ Creating .ssh directory..." + echo "→ Creating ${deployer_ssh_dir} directory..." if ! run_cmd mkdir -p "$deployer_ssh_dir"; then echo "Error: Failed to create .ssh directory" >&2 exit 1 fi fi - if ! run_cmd test -f "$private_key"; then - echo "→ Generating SSH key pair..." - if ! run_cmd ssh-keygen -t ed25519 -C "deployer@${DEPLOYER_SERVER_NAME}" -f "$private_key" -N ""; then - echo "Error: Failed to generate SSH key pair" >&2 + if [[ -n ${DEPLOYER_KEY_PRIVATE:-} && -n ${DEPLOYER_KEY_PUBLIC:-} ]]; then + echo "→ Installing custom SSH key pair..." + + if ! echo "$DEPLOYER_KEY_PRIVATE" | base64 -d | run_cmd tee "$private_key" > /dev/null; then + echo "Error: Failed to write private key" >&2 + exit 1 + fi + + if ! echo "$DEPLOYER_KEY_PUBLIC" | base64 -d | run_cmd tee "$public_key" > /dev/null; then + echo "Error: Failed to write public key" >&2 exit 1 fi else - echo "→ SSH key pair already exists" + if ! run_cmd test -f "$private_key"; then + echo "→ Generating SSH key pair..." + if ! run_cmd ssh-keygen -t ed25519 -C "deployer@${DEPLOYER_SERVER_NAME}" -f "$private_key" -N ""; then + echo "Error: Failed to generate SSH key pair" >&2 + exit 1 + fi + fi fi - # Set ownership and permissions if ! run_cmd chown -R deployer:deployer "$deployer_ssh_dir"; then echo "Error: Failed to set ownership on .ssh directory" >&2 exit 1 @@ -146,19 +164,6 @@ setup_deployer() { fi } -# -# Ensure proper permissions on deploy directories -# ---- - -setup_deploy_directories() { - local deployer_home=$1 - - if ! run_cmd test -d "$deployer_home"; then - echo "Error: Deployer home directory missing" >&2 - exit 1 - fi -} - # ---- # Main Execution # ---- @@ -183,17 +188,13 @@ main() { exit 1 fi - # Execute deployer setup tasks setup_deployer "$deployer_home" - setup_deploy_directories "$deployer_home" - # Get deploy public key if ! deploy_public_key=$(run_cmd cat "${deployer_home}/.ssh/id_ed25519.pub" 2>&1); then echo "Error: Failed to read deploy public key at ${deployer_home}/.ssh/id_ed25519.pub" >&2 exit 1 fi - # Write output YAML if ! cat > "$DEPLOYER_OUTPUT_FILE" <<- EOF; then status: success deploy_public_key: $deploy_public_key diff --git a/tests/Integration/ArchitectureTest.php b/tests/Integration/ArchitectureTest.php index 08d15791..7675b032 100644 --- a/tests/Integration/ArchitectureTest.php +++ b/tests/Integration/ArchitectureTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; +use PHPDeployer\Contracts\BaseCommand; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -11,7 +11,7 @@ // ---- arch('commands extend BaseCommand', function () { - expect('Bigpixelrocket\\DeployerPHP\\Console\\') + expect('PHPDeployer\\Console\\') ->classes() ->toHaveSuffix('Command') ->toExtend(BaseCommand::class); @@ -25,7 +25,7 @@ }); arch('commands expose Symfony metadata', function () { - expect('Bigpixelrocket\\DeployerPHP\\Console\\') + expect('PHPDeployer\\Console\\') ->classes() ->toHaveAttribute(AsCommand::class); }); diff --git a/tests/TestCase.php b/tests/TestCase.php index 6dc5eb31..f21700ed 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Tests; +namespace PHPDeployer\Tests; use PHPUnit\Framework\TestCase as BaseTestCase;