From 2e64165e57b464b881ce5b941ac0ace10072106b Mon Sep 17 00:00:00 2001 From: Stephen Fritz Date: Wed, 4 Feb 2026 11:59:21 -0500 Subject: [PATCH] feat: add local .forge config file support with named environments This adds project-level configuration via a `.forge` file, enabling teams to commit their Forge server/site mappings directly in their repositories. Key features: - Named environments (production, staging, dev) with per-environment config - Smart deploy detection: `forge deploy staging` auto-detects environment names - Confirmation prompts for production-like environments (`confirm: true`) - Interactive `forge init` command for easy setup - Config management commands: config, config:set, config:remove, config:default - Zsh shell completion with environment awareness - Backward compatible with existing global config Example .forge file: ```json { "default": "staging", "environments": { "production": { "server": 123, "site": 456, "confirm": true }, "staging": { "server": 123, "site": 789 } } } ``` Usage: - `forge deploy` - deploys to default environment - `forge deploy production` - deploys to production (prompts for confirmation) - `forge deploy --force` - skips confirmation prompt - `forge init` - interactive setup - `forge config` - display current configuration --- README.md | 66 +++++ app/Commands/Command.php | 19 ++ app/Commands/CompletionCommand.php | 104 +++++++ .../Concerns/InteractsWithEnvironments.php | 149 ++++++++++ app/Commands/Concerns/InteractsWithIO.php | 13 +- app/Commands/ConfigCommand.php | 101 +++++++ app/Commands/ConfigDefaultCommand.php | 59 ++++ app/Commands/ConfigRemoveCommand.php | 83 ++++++ app/Commands/ConfigSetCommand.php | 207 ++++++++++++++ app/Commands/DeployCommand.php | 120 +++++++- app/Commands/InitCommand.php | 258 ++++++++++++++++++ app/Providers/ConfigServiceProvider.php | 5 + app/Repositories/LocalConfigRepository.php | 231 ++++++++++++++++ completions/forge.zsh | 118 ++++++++ 14 files changed, 1528 insertions(+), 5 deletions(-) create mode 100644 app/Commands/CompletionCommand.php create mode 100644 app/Commands/Concerns/InteractsWithEnvironments.php create mode 100644 app/Commands/ConfigCommand.php create mode 100644 app/Commands/ConfigDefaultCommand.php create mode 100644 app/Commands/ConfigRemoveCommand.php create mode 100644 app/Commands/ConfigSetCommand.php create mode 100644 app/Commands/InitCommand.php create mode 100644 app/Repositories/LocalConfigRepository.php create mode 100644 completions/forge.zsh diff --git a/README.md b/README.md index 3063820..d80969c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,72 @@ In addition, Forge can assist you in managing scheduled jobs, queue workers, SSL Documentation for Forge CLI can be found on the [Laravel Forge website](https://forge.laravel.com/docs/1.0/cli.html). +## Project Configuration + +You can create a `.forge` configuration file in your project directory to define default server and site settings. This eliminates the need to specify server/site IDs for every command. + +### Quick Setup + +```bash +cd /path/to/your/project +forge init +``` + +The `init` command will interactively guide you through setting up environments for your project. + +### Configuration Format + +The `.forge` file supports named environments: + +```json +{ + "default": "staging", + "environments": { + "production": { + "server": 123456, + "site": 789012, + "confirm": true + }, + "staging": { + "server": 123456, + "site": 789013 + } + } +} +``` + +### Deploying + +With a `.forge` file configured, deployments become simple: + +```bash +forge deploy # Deploy to default environment +forge deploy staging # Deploy to staging +forge deploy production # Deploy to production (prompts for confirmation) +forge deploy --force # Skip confirmation prompt +``` + +The `confirm` option requires user confirmation before deploying, helping prevent accidental production deployments. + +### Configuration Commands + +```bash +forge config # Display current configuration +forge config:set # Add or update an environment (interactive) +forge config:set prod 123 456 --confirm # Quick add with IDs +forge config:remove dev # Remove an environment +forge config:default staging # Set default environment +``` + +### Shell Completion + +Enable tab completion for commands and environments: + +```bash +forge completion --install # Add to ~/.zshrc +source ~/.zshrc +``` + ## Contributing Thank you for considering contributing to Forge CLI! You can read the contribution guide [here](.github/CONTRIBUTING.md). diff --git a/app/Commands/Command.php b/app/Commands/Command.php index 4e6c592..6c2e576 100644 --- a/app/Commands/Command.php +++ b/app/Commands/Command.php @@ -5,6 +5,7 @@ use App\Repositories\ConfigRepository; use App\Repositories\ForgeRepository; use App\Repositories\KeyRepository; +use App\Repositories\LocalConfigRepository; use App\Repositories\RemoteRepository; use App\Support\Time; use Laravel\Forge\Forge; @@ -16,6 +17,7 @@ abstract class Command extends BaseCommand { use Concerns\InteractsWithIO, + Concerns\InteractsWithEnvironments, Concerns\InteractsWithVersions; /** @@ -32,6 +34,13 @@ abstract class Command extends BaseCommand */ protected $config; + /** + * The local configuration repository. + * + * @var \App\Repositories\LocalConfigRepository + */ + protected $localConfig; + /** * The forge repository. * @@ -65,6 +74,7 @@ abstract class Command extends BaseCommand */ public function __construct( ConfigRepository $config, + LocalConfigRepository $localConfig, ForgeRepository $forge, KeyRepository $keys, RemoteRepository $remote, @@ -73,6 +83,7 @@ public function __construct( parent::__construct(); $this->config = $config; + $this->localConfig = $localConfig; $this->forge = $forge; $this->keys = $keys; $this->time = $time; @@ -120,6 +131,14 @@ protected function ensureCurrentTeamIsSet() public function currentServer() { return once(function () { + // Priority 1: Environment-based config (named environments or legacy .forge) + $envServerId = $this->getEnvironmentServerId(); + + if ($envServerId) { + return $this->forge->server($envServerId); + } + + // Priority 2: Global config $this->ensureCurrentTeamIsSet(); return $this->forge->server( diff --git a/app/Commands/CompletionCommand.php b/app/Commands/CompletionCommand.php new file mode 100644 index 0000000..a59efc3 --- /dev/null +++ b/app/Commands/CompletionCommand.php @@ -0,0 +1,104 @@ +argument('shell'); + + if ($shell !== 'zsh') { + $this->error("Only 'zsh' is currently supported. Bash coming soon."); + return 1; + } + + $completionFile = $this->getCompletionPath(); + + if (! file_exists($completionFile)) { + $this->error("Completion file not found: {$completionFile}"); + return 1; + } + + if ($this->option('install')) { + return $this->installCompletion($completionFile); + } + + // Output the completion script + $this->line(file_get_contents($completionFile)); + $this->line(''); + $this->comment('# Add to your ~/.zshrc:'); + $this->comment("# source {$completionFile}"); + $this->comment('# Or run: forge completion --install'); + + return 0; + } + + /** + * Get the path to the completion script. + * + * @return string + */ + protected function getCompletionPath() + { + return dirname(__DIR__, 2) . '/completions/forge.zsh'; + } + + /** + * Install the completion to the user's shell config. + * + * @param string $completionFile + * @return int + */ + protected function installCompletion($completionFile) + { + $zshrc = ($_SERVER['HOME'] ?? $_SERVER['USERPROFILE']) . '/.zshrc'; + + if (! file_exists($zshrc)) { + $this->error('.zshrc not found. Please add manually:'); + $this->line(" source {$completionFile}"); + return 1; + } + + $contents = file_get_contents($zshrc); + $sourceLine = "source {$completionFile}"; + + // Check if already installed + if (str_contains($contents, 'completions/forge.zsh')) { + $this->warnStep('Forge completion already installed in ~/.zshrc'); + return 0; + } + + // Add to zshrc + $addition = "\n# Forge CLI completion\n{$sourceLine}\n"; + file_put_contents($zshrc, $contents . $addition); + + $this->successfulStep('Completion installed to ~/.zshrc'); + $this->line(''); + $this->line(' Restart your terminal or run:'); + $this->line(" source ~/.zshrc"); + + return 0; + } +} diff --git a/app/Commands/Concerns/InteractsWithEnvironments.php b/app/Commands/Concerns/InteractsWithEnvironments.php new file mode 100644 index 0000000..403f31b --- /dev/null +++ b/app/Commands/Concerns/InteractsWithEnvironments.php @@ -0,0 +1,149 @@ +hasOption('environment') && $this->option('environment')) { + return $this->option('environment'); + } + + // Check for environment argument + if ($this->hasArgument('environment') && $this->argument('environment')) { + return $this->argument('environment'); + } + + return null; + } + + /** + * Resolve and cache the environment configuration. + * + * @return array|null Returns ['name' => string|null, 'config' => array] or null + */ + protected function resolveEnvironmentConfig() + { + if ($this->resolvedEnvironment !== null) { + return $this->resolvedEnvironment; + } + + $specified = $this->getEnvironmentFromInput(); + $resolved = $this->localConfig->resolveEnvironment($specified); + + // Validate that specified environment exists + if ($specified !== null && $resolved === null) { + $available = $this->localConfig->getEnvironmentNames(); + + if (empty($available)) { + abort(1, "Environment '{$specified}' specified but no environments are configured in .forge"); + } + + abort(1, "Unknown environment '{$specified}'. Available: " . implode(', ', $available)); + } + + $this->resolvedEnvironment = $resolved; + + return $this->resolvedEnvironment; + } + + /** + * Check if confirmation is required and prompt the user. + * + * @param string $action Description of the action (e.g., "deploy", "push environment") + * @return bool True if confirmed or no confirmation needed, false if user declined + */ + protected function confirmEnvironmentAction($action) + { + // Check for --force flag to bypass confirmation + if ($this->hasOption('force') && $this->option('force')) { + return true; + } + + $env = $this->resolveEnvironmentConfig(); + + if ($env === null) { + return true; // No local config, proceed normally + } + + $requiresConfirmation = $env['config']['confirm'] ?? false; + + if (!$requiresConfirmation) { + return true; + } + + $envName = $env['name'] ?? 'this environment'; + $envDisplay = strtoupper($envName); + + $this->line(''); + $this->line(" WARNING: You are about to {$action} to {$envDisplay}"); + $this->line(''); + + return $this->confirmStep( + ["Are you sure you want to {$action} to %s?", $envDisplay], + false + ); + } + + /** + * Get the server ID from the resolved environment. + * + * @return int|string|null + */ + protected function getEnvironmentServerId() + { + $env = $this->resolveEnvironmentConfig(); + + return $env['config']['server'] ?? null; + } + + /** + * Get the site ID from the resolved environment. + * + * @return int|string|null + */ + protected function getEnvironmentSiteId() + { + $env = $this->resolveEnvironmentConfig(); + + return $env['config']['site'] ?? null; + } + + /** + * Get the resolved environment name. + * + * @return string|null + */ + protected function getEnvironmentName() + { + $env = $this->resolveEnvironmentConfig(); + + return $env['name'] ?? null; + } + + /** + * Check if we're using a local environment configuration. + * + * @return bool + */ + protected function hasEnvironmentConfig() + { + return $this->resolveEnvironmentConfig() !== null; + } +} diff --git a/app/Commands/Concerns/InteractsWithIO.php b/app/Commands/Concerns/InteractsWithIO.php index a62c72c..22674ab 100644 --- a/app/Commands/Concerns/InteractsWithIO.php +++ b/app/Commands/Concerns/InteractsWithIO.php @@ -42,16 +42,25 @@ public function table($headers, $rows, $tableStyle = 'default', array $columnSty */ public function askForSite($question) { - $name = $this->argument('site'); + $name = $this->hasArgument('site') ? $this->argument('site') : null; $answers = collect($this->forge->sites($this->currentServer()->id)); abort_if($answers->isEmpty(), 1, 'This server does not have any sites.'); + // Priority 1: Command argument if (! is_null($name)) { return optional($answers->where('name', $name)->first())->id ?: $name; } + // Priority 2: Environment config (named environments or legacy .forge) + $envSiteId = $this->getEnvironmentSiteId(); + + if ($envSiteId) { + return $envSiteId; + } + + // Priority 3: Interactive prompt return $this->choiceStep($question, $answers->mapWithKeys(function ($resource) { return [$resource->id => $resource->name]; })->all()); @@ -65,7 +74,7 @@ public function askForSite($question) */ public function askForServer($question) { - $name = $this->argument('server'); + $name = $this->hasArgument('server') ? $this->argument('server') : null; $answers = collect($this->forge->servers()); diff --git a/app/Commands/ConfigCommand.php b/app/Commands/ConfigCommand.php new file mode 100644 index 0000000..86dd63d --- /dev/null +++ b/app/Commands/ConfigCommand.php @@ -0,0 +1,101 @@ +localConfig->exists()) { + $this->warnStep('No .forge config file found in this directory or parents.'); + $this->line(''); + $this->line(' Run forge init to create one, or use:'); + $this->line(' forge config:set '); + $this->line(''); + return 1; + } + + $path = $this->localConfig->getFoundPath(); + $config = $this->localConfig->all(); + + $this->step(['Config: %s', $path]); + $this->line(''); + + if (isset($config['environments'])) { + $this->displayEnvironmentConfig($config); + } else { + $this->displaySimpleConfig($config); + } + + $this->line(''); + $this->line(' Commands:'); + $this->line(' forge config:set Add/update environment'); + $this->line(' forge config:set --confirm Toggle confirmation'); + $this->line(' forge config:remove Remove environment'); + $this->line(' forge config:default Set default environment'); + + return 0; + } + + /** + * Display environment-based config. + * + * @param array $config + * @return void + */ + protected function displayEnvironmentConfig(array $config) + { + $default = $config['default'] ?? null; + + $rows = []; + foreach ($config['environments'] as $name => $env) { + $isDefault = $name === $default; + $rows[] = [ + $isDefault ? "{$name}" : $name, + $env['server'] ?? '-', + $env['site'] ?? '-', + !empty($env['confirm']) ? 'yes' : 'no', + $isDefault ? '*' : '', + ]; + } + + $this->table( + ['Environment', 'Server', 'Site', 'Confirm', 'Default'], + $rows + ); + } + + /** + * Display simple config. + * + * @param array $config + * @return void + */ + protected function displaySimpleConfig(array $config) + { + $this->line(' Server: ' . ($config['server'] ?? '-')); + $this->line(' Site: ' . ($config['site'] ?? '-')); + $this->line(' Confirm: ' . (!empty($config['confirm']) ? 'yes' : 'no')); + } +} diff --git a/app/Commands/ConfigDefaultCommand.php b/app/Commands/ConfigDefaultCommand.php new file mode 100644 index 0000000..c37a2a9 --- /dev/null +++ b/app/Commands/ConfigDefaultCommand.php @@ -0,0 +1,59 @@ +localConfig->exists()) { + $this->error('No .forge config file found.'); + return 1; + } + + $name = strtolower($this->argument('name')); + $config = $this->localConfig->all(); + + if (!isset($config['environments'])) { + $this->error('Config does not use named environments.'); + $this->line(' Run forge config:set first.'); + return 1; + } + + if (!isset($config['environments'][$name])) { + $this->error("Environment '{$name}' not found."); + $available = array_keys($config['environments']); + $this->line(' Available: ' . implode(', ', $available)); + return 1; + } + + $config['default'] = $name; + + $this->localConfig->create(getcwd(), $config); + $this->successfulStep(['Default environment set to: %s', $name]); + + return 0; + } +} diff --git a/app/Commands/ConfigRemoveCommand.php b/app/Commands/ConfigRemoveCommand.php new file mode 100644 index 0000000..1783b90 --- /dev/null +++ b/app/Commands/ConfigRemoveCommand.php @@ -0,0 +1,83 @@ +localConfig->exists()) { + $this->error('No .forge config file found.'); + return 1; + } + + $name = strtolower($this->argument('name')); + $config = $this->localConfig->all(); + + if (!isset($config['environments'][$name])) { + $this->error("Environment '{$name}' not found."); + $available = array_keys($config['environments'] ?? []); + if (!empty($available)) { + $this->line(' Available: ' . implode(', ', $available)); + } + return 1; + } + + // Confirm removal + if (! $this->option('force')) { + if (! $this->confirmStep(["Remove environment %s?", $name])) { + return 0; + } + } + + // Remove the environment + unset($config['environments'][$name]); + + // Handle default if we removed it + if ($config['default'] === $name) { + $remaining = array_keys($config['environments']); + if (!empty($remaining)) { + $config['default'] = $remaining[0]; + $this->warnStep(['Default changed to %s', $config['default']]); + } else { + unset($config['default']); + } + } + + // If no environments left, delete the file + if (empty($config['environments'])) { + $path = $this->localConfig->getFoundPath(); + unlink($path); + $this->successfulStep('Removed last environment. Config file deleted.'); + return 0; + } + + $this->localConfig->create(getcwd(), $config); + $this->successfulStep(['Removed environment: %s', $name]); + + return 0; + } +} diff --git a/app/Commands/ConfigSetCommand.php b/app/Commands/ConfigSetCommand.php new file mode 100644 index 0000000..b502314 --- /dev/null +++ b/app/Commands/ConfigSetCommand.php @@ -0,0 +1,207 @@ +argument('name'); + $serverId = $this->argument('server'); + $siteId = $this->argument('site'); + + // Interactive mode if no name provided + if (! $name) { + return $this->handleInteractive(); + } + + $name = strtolower($name); + $config = $this->localConfig->all(); + + // Determine if we're updating or creating + $isUpdate = isset($config['environments'][$name]) || + (!isset($config['environments']) && !empty($config['server'])); + + // Get existing environment config if updating + $envConfig = $config['environments'][$name] ?? []; + + // Update server if provided + if ($serverId) { + $envConfig['server'] = (int) $serverId; + } + + // Update site if provided + if ($siteId) { + $envConfig['site'] = (int) $siteId; + } + + // Handle confirm flag + if ($this->option('confirm')) { + $envConfig['confirm'] = true; + } elseif ($this->option('no-confirm')) { + unset($envConfig['confirm']); + } + + // Validate we have at least a server + if (empty($envConfig['server']) && !$serverId) { + $this->error('Server ID is required. Usage: forge config:set [site-id]'); + $this->line(' Or run forge config:set without arguments for interactive mode.'); + return 1; + } + + return $this->saveEnvironment($name, $envConfig, $config, $isUpdate); + } + + /** + * Handle interactive mode. + * + * @return int + */ + protected function handleInteractive() + { + $this->step('Add/update environment'); + $this->line(''); + + // Get environment name + $name = $this->askStep('Environment name (e.g., production, staging)'); + + if (empty($name)) { + $this->error('Environment name is required.'); + return 1; + } + + $name = strtolower(trim($name)); + $config = $this->localConfig->all(); + + $isUpdate = isset($config['environments'][$name]); + + if ($isUpdate) { + $this->warnStep(['Updating existing environment: %s', $name]); + } + + $this->line(''); + + // Select server (interactive with lookup) + $serverId = $this->askForServer("Which server for '{$name}'"); + $server = $this->forge->server($serverId); + + // Get sites for selected server + $sites = collect($this->forge->sites($server->id)); + + $envConfig = [ + 'server' => $server->id, + ]; + + if ($sites->isNotEmpty()) { + $siteId = $this->choiceStep( + "Which site for '{$name}'", + $sites->mapWithKeys(fn ($site) => [$site->id => $site->name])->all() + ); + $envConfig['site'] = $siteId; + } else { + $this->warnStep('No sites found on this server.'); + } + + // Ask about confirmation + $isProduction = in_array($name, ['production', 'prod', 'live', 'main', 'master']); + + if ($this->confirmStep(["Require confirmation before deploying to %s?", $name], $isProduction)) { + $envConfig['confirm'] = true; + } + + return $this->saveEnvironment($name, $envConfig, $config, $isUpdate); + } + + /** + * Save the environment config. + * + * @param string $name + * @param array $envConfig + * @param array $config + * @param bool $isUpdate + * @return int + */ + protected function saveEnvironment($name, $envConfig, $config, $isUpdate) + { + // Build new config structure + if (!isset($config['environments'])) { + // Convert simple config to environment-based or create fresh + $newConfig = [ + 'default' => $name, + 'environments' => [ + $name => $envConfig, + ], + ]; + + // If there was an existing simple config, preserve it + if (!empty($config['server'])) { + $existingName = $this->askStep('Existing simple config found. Name for it?', 'legacy'); + if ($existingName && $existingName !== $name) { + $newConfig['environments'][$existingName] = [ + 'server' => $config['server'], + 'site' => $config['site'] ?? null, + 'confirm' => $config['confirm'] ?? false, + ]; + } + } + + $config = $newConfig; + } else { + // Update existing environments config + $config['environments'][$name] = $envConfig; + + // If this is the first environment, set it as default + if (empty($config['default'])) { + $config['default'] = $name; + } + } + + // Clean up null values + if (isset($config['environments'][$name]['site']) && $config['environments'][$name]['site'] === null) { + unset($config['environments'][$name]['site']); + } + + // Write config + $this->localConfig->create(getcwd(), $config); + + $action = $isUpdate ? 'Updated' : 'Added'; + $this->successfulStep(["{$action} environment: %s", $name]); + + // Show summary + $env = $config['environments'][$name]; + $this->line(''); + $this->line(" Server: {$env['server']}"); + if (isset($env['site'])) { + $this->line(" Site: {$env['site']}"); + } + $this->line(' Confirm: ' . (!empty($env['confirm']) ? 'yes' : 'no')); + + return 0; + } +} diff --git a/app/Commands/DeployCommand.php b/app/Commands/DeployCommand.php index 72e8d4e..28dc46f 100644 --- a/app/Commands/DeployCommand.php +++ b/app/Commands/DeployCommand.php @@ -13,7 +13,10 @@ class DeployCommand extends Command * * @var string */ - protected $signature = 'deploy {site? : The site name}'; + protected $signature = 'deploy + {target? : Environment name (e.g., staging, production) or site name} + {--site= : Explicit site name (bypasses environment detection)} + {--force : Skip confirmation prompt}'; /** * The description of the command. @@ -25,11 +28,107 @@ class DeployCommand extends Command /** * Execute the console command. * - * @return void + * @return int|void */ public function handle() { - $siteId = $this->askForSite('Which site would you like to deploy'); + $target = $this->argument('target'); + $explicitSite = $this->option('site'); + + // If --site is provided, bypass all environment logic + if ($explicitSite) { + return $this->deployToSite($explicitSite); + } + + // Smart detection: is target an environment name? + if ($target && $this->isEnvironmentName($target)) { + $this->setEnvironment($target); + return $this->deployToEnvironment($target); + } + + // If target provided but not an environment, treat as site name + if ($target) { + return $this->deployToSite($target); + } + + // No target: use default environment from .forge if available + if ($this->hasEnvironmentConfig()) { + $envName = $this->getEnvironmentName(); + return $this->deployToEnvironment($envName); + } + + // Fallback: prompt for site (original behavior) + return $this->deployToSite(null); + } + + /** + * Check if the given name matches a configured environment. + * + * @param string $name + * @return bool + */ + protected function isEnvironmentName($name) + { + $envNames = $this->localConfig->getEnvironmentNames(); + return in_array(strtolower($name), array_map('strtolower', $envNames)); + } + + /** + * Set the environment to use for this deployment. + * + * @param string $name + * @return void + */ + protected function setEnvironment($name) + { + // Override the resolved environment + $this->resolvedEnvironment = $this->localConfig->resolveEnvironment($name); + } + + /** + * Deploy to a configured environment. + * + * @param string $envName + * @return int|void + */ + protected function deployToEnvironment($envName) + { + // Check for confirmation if required + if (! $this->confirmEnvironmentAction('deploy')) { + $this->warnStep('Deployment cancelled.'); + return 0; + } + + $this->step(['Deploying to %s environment', strtoupper($envName)]); + + $siteId = $this->getEnvironmentSiteId(); + + if (! $siteId) { + $this->error("No site configured for environment '{$envName}'"); + return 1; + } + + $site = $this->forge->site($this->currentServer()->id, $siteId); + + abort_unless(is_null($site->deploymentStatus), 1, 'This site is already deploying.'); + + $this->deploy($site); + } + + /** + * Deploy to a site by name (original behavior). + * + * @param string|null $siteName + * @return int|void + */ + protected function deployToSite($siteName) + { + // Clear environment config so we don't use it + $this->resolvedEnvironment = ['name' => null, 'config' => []]; + + $siteId = $siteName + ? $this->resolveSiteByName($siteName) + : $this->askForSite('Which site would you like to deploy'); $site = $this->forge->site($this->currentServer()->id, $siteId); @@ -38,6 +137,21 @@ public function handle() $this->deploy($site); } + /** + * Resolve a site ID from its name. + * + * @param string $name + * @return int|string + */ + protected function resolveSiteByName($name) + { + $sites = collect($this->forge->sites($this->currentServer()->id)); + + $site = $sites->where('name', $name)->first(); + + return $site ? $site->id : $name; + } + /** * Deploy an site. * diff --git a/app/Commands/InitCommand.php b/app/Commands/InitCommand.php new file mode 100644 index 0000000..56e3d19 --- /dev/null +++ b/app/Commands/InitCommand.php @@ -0,0 +1,258 @@ +option('force')) { + if (! $this->confirmStep('A .forge file already exists. Would you like to overwrite it?')) { + return 0; + } + } + + $this->step('Setting up local Forge configuration'); + $this->line(''); + + if ($this->option('simple')) { + return $this->createSimpleConfig(); + } + + return $this->createEnvironmentConfig(); + } + + /** + * Create a simple config without named environments. + * + * @return int + */ + protected function createSimpleConfig() + { + $serverId = $this->askForServer('Which server should this project use'); + $server = $this->forge->server($serverId); + + $sites = collect($this->forge->sites($server->id)); + + $config = ['server' => $server->id]; + + if ($sites->isNotEmpty()) { + $siteId = $this->choiceStep( + 'Which site should this project use', + $sites->mapWithKeys(fn ($site) => [$site->id => $site->name])->all() + ); + $config['site'] = $siteId; + + if ($this->confirmStep('Require confirmation before deploying?', true)) { + $config['confirm'] = true; + } + } + + $this->writeConfig($config); + + return 0; + } + + /** + * Create a config with named environments. + * + * @return int + */ + protected function createEnvironmentConfig() + { + $environments = []; + $addMore = true; + + while ($addMore) { + $env = $this->configureEnvironment(count($environments) === 0); + + if ($env) { + $environments[$env['name']] = $env['config']; + $this->successfulStep(['Added environment: %s', $env['name']]); + $this->line(''); + } + + if (count($environments) > 0) { + $addMore = $this->confirmStep('Add another environment?', count($environments) < 2); + } + } + + if (empty($environments)) { + $this->warnStep('No environments configured. Aborting.'); + return 1; + } + + // Determine default environment + $envNames = array_keys($environments); + + if (count($envNames) === 1) { + $default = $envNames[0]; + } else { + // Suggest safest option as default (one without confirm, or staging, or first) + $suggestedDefault = $this->suggestSafeDefault($environments); + + $default = $this->choiceStep( + 'Which environment should be the default', + array_combine($envNames, $envNames), + $suggestedDefault + ); + // choiceStep returns int index, need to map back to name + $default = $envNames[array_search($default, array_values(array_combine($envNames, $envNames)))] ?? $envNames[0]; + } + + $config = [ + 'default' => $default, + 'environments' => $environments, + ]; + + $this->writeConfig($config); + + return 0; + } + + /** + * Configure a single environment. + * + * @param bool $isFirst + * @return array|null + */ + protected function configureEnvironment($isFirst) + { + // Suggest common environment names + $suggestedNames = ['production', 'staging', 'development', 'local']; + $prompt = $isFirst ? 'Environment name (e.g., production, staging)' : 'Environment name'; + + $name = $this->askStep($prompt, $isFirst ? 'production' : null); + + if (empty($name)) { + return null; + } + + $name = strtolower(trim($name)); + + $this->line(''); + $this->step(['Configuring %s environment', $name]); + + // Select server + $serverId = $this->askForServer("Which server for '{$name}'"); + $server = $this->forge->server($serverId); + + // Get sites for selected server + $sites = collect($this->forge->sites($server->id)); + + $envConfig = ['server' => $server->id]; + + if ($sites->isNotEmpty()) { + $siteId = $this->choiceStep( + "Which site for '{$name}'", + $sites->mapWithKeys(fn ($site) => [$site->id => $site->name])->all() + ); + $envConfig['site'] = $siteId; + } + + // Ask about confirmation - default to true for production-like names + $isProduction = in_array($name, ['production', 'prod', 'live', 'main', 'master']); + $confirmDefault = $isProduction; + + if ($this->confirmStep(["Require confirmation before deploying to %s?", $name], $confirmDefault)) { + $envConfig['confirm'] = true; + } + + return [ + 'name' => $name, + 'config' => $envConfig, + ]; + } + + /** + * Suggest the safest default environment. + * + * @param array $environments + * @return string|null + */ + protected function suggestSafeDefault($environments) + { + // Prefer non-production environments as default + $safeNames = ['staging', 'development', 'dev', 'local', 'test']; + + foreach ($safeNames as $safe) { + if (isset($environments[$safe])) { + return $safe; + } + } + + // Otherwise prefer one without confirm flag + foreach ($environments as $name => $config) { + if (empty($config['confirm'])) { + return $name; + } + } + + return array_key_first($environments); + } + + /** + * Write the config file and display summary. + * + * @param array $config + * @return void + */ + protected function writeConfig(array $config) + { + $this->localConfig->create(getcwd(), $config); + + $this->line(''); + $this->successfulStep(['Created %s', LocalConfigRepository::CONFIG_FILE]); + $this->line(''); + + // Display summary + if (isset($config['environments'])) { + $this->line(' Environments:'); + foreach ($config['environments'] as $name => $env) { + $default = ($name === $config['default']) ? ' (default)' : ''; + $confirm = !empty($env['confirm']) ? ' [confirm]' : ''; + $this->line(" - {$name}{$default}{$confirm}"); + } + $this->line(''); + $this->step('Usage:'); + $this->line(' forge deploy Deploy to default environment'); + $this->line(' forge deploy staging Deploy to specific environment'); + $this->line(' forge deploy --force Skip confirmation prompt'); + } else { + $this->line(' Server: ' . $config['server']); + if (isset($config['site'])) { + $this->line(' Site: ' . $config['site']); + } + if (!empty($config['confirm'])) { + $this->line(' Confirm: Yes'); + } + $this->line(''); + $this->step('You can now run forge deploy from this directory'); + } + } +} diff --git a/app/Providers/ConfigServiceProvider.php b/app/Providers/ConfigServiceProvider.php index c8a7104..7884888 100644 --- a/app/Providers/ConfigServiceProvider.php +++ b/app/Providers/ConfigServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Repositories\ConfigRepository; +use App\Repositories\LocalConfigRepository; use Illuminate\Support\ServiceProvider; class ConfigServiceProvider extends ServiceProvider @@ -33,5 +34,9 @@ public function register() return new ConfigRepository($path); }); + + $this->app->singleton(LocalConfigRepository::class, function () { + return new LocalConfigRepository(); + }); } } diff --git a/app/Repositories/LocalConfigRepository.php b/app/Repositories/LocalConfigRepository.php new file mode 100644 index 0000000..8cc38ef --- /dev/null +++ b/app/Repositories/LocalConfigRepository.php @@ -0,0 +1,231 @@ +config !== null) { + return $this->config; + } + + $path = $this->findConfigFile(); + + if ($path === null) { + $this->config = []; + return $this->config; + } + + $this->foundPath = $path; + $contents = file_get_contents($path); + $this->config = json_decode($contents, true) ?: []; + + return $this->config; + } + + /** + * Get a local configuration value. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function get($key, $default = null) + { + $config = $this->all(); + + return $config[$key] ?? $default; + } + + /** + * Check if the config uses named environments. + * + * @return bool + */ + public function hasEnvironments() + { + return isset($this->all()['environments']); + } + + /** + * Get the list of available environment names. + * + * @return array + */ + public function getEnvironmentNames() + { + $config = $this->all(); + + if (!isset($config['environments'])) { + return []; + } + + return array_keys($config['environments']); + } + + /** + * Get the default environment name. + * + * @return string|null + */ + public function getDefaultEnvironment() + { + return $this->get('default'); + } + + /** + * Get an environment configuration by name. + * + * @param string $name + * @return array|null + */ + public function getEnvironment($name) + { + $config = $this->all(); + + return $config['environments'][$name] ?? null; + } + + /** + * Resolve the environment to use. + * + * Priority: + * 1. Explicitly specified environment name + * 2. Default environment from config + * 3. null if no environments configured + * + * @param string|null $specified + * @return array|null Returns ['name' => string, 'config' => array] or null + */ + public function resolveEnvironment($specified = null) + { + $config = $this->all(); + + // If no environments configured, return legacy format if present + if (!$this->hasEnvironments()) { + if (isset($config['server'])) { + return [ + 'name' => null, + 'config' => [ + 'server' => $config['server'], + 'site' => $config['site'] ?? null, + 'confirm' => $config['confirm'] ?? false, + ], + ]; + } + return null; + } + + // Determine which environment to use + $envName = $specified ?? $this->getDefaultEnvironment(); + + if ($envName === null) { + return null; + } + + $envConfig = $this->getEnvironment($envName); + + if ($envConfig === null) { + return null; + } + + return [ + 'name' => $envName, + 'config' => $envConfig, + ]; + } + + /** + * Check if a local config file exists. + * + * @return bool + */ + public function exists() + { + return $this->findConfigFile() !== null; + } + + /** + * Get the path where the config file was found. + * + * @return string|null + */ + public function getFoundPath() + { + $this->all(); // Ensure we've searched for the file + + return $this->foundPath; + } + + /** + * Find the config file by walking up from cwd. + * + * @return string|null + */ + protected function findConfigFile() + { + $directory = getcwd(); + + while (true) { + $configPath = $directory . DIRECTORY_SEPARATOR . self::CONFIG_FILE; + + if (file_exists($configPath)) { + return $configPath; + } + + $parentDirectory = dirname($directory); + + // We've hit the root + if ($parentDirectory === $directory) { + return null; + } + + $directory = $parentDirectory; + } + } + + /** + * Create a new config file in the given directory. + * + * @param string $directory + * @param array $config + * @return string The path to the created file + */ + public function create($directory, array $config) + { + $path = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . self::CONFIG_FILE; + + file_put_contents($path, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + + // Reset cache so next read picks up new file + $this->config = null; + $this->foundPath = null; + + return $path; + } +} diff --git a/completions/forge.zsh b/completions/forge.zsh new file mode 100644 index 0000000..d7a3af3 --- /dev/null +++ b/completions/forge.zsh @@ -0,0 +1,118 @@ +#compdef forge +# Forge CLI Zsh Completion (oh-my-zsh compatible) + +_forge_environments() { + local forge_file="" + local dir="$PWD" + + while [[ "$dir" != "/" ]]; do + if [[ -f "$dir/.forge" ]]; then + forge_file="$dir/.forge" + break + fi + dir="$(dirname "$dir")" + done + + if [[ -n "$forge_file" && -f "$forge_file" ]]; then + cat "$forge_file" 2>/dev/null | grep -o '"[a-zA-Z0-9_-]*"[[:space:]]*:[[:space:]]*{' | grep -v '"environments"' | sed 's/"//g' | sed 's/[[:space:]]*:[[:space:]]*{//' + fi +} + +_forge() { + local -a commands + commands=( + 'deploy:Deploy a site' + 'init:Initialize a .forge config file' + 'config:Display the local .forge configuration' + 'config\:set:Add or update an environment' + 'config\:remove:Remove an environment' + 'config\:default:Set the default environment' + 'completion:Generate shell completion script' + 'login:Authenticate with Laravel Forge' + 'logout:Logout from Laravel Forge' + 'ssh:Start an SSH session' + 'tinker:Tinker with a site' + 'open:Open a site in forge.laravel.com' + 'server\:list:List the servers' + 'server\:switch:Switch to a different server' + 'server\:current:Determine your current server' + 'site\:list:List the sites' + 'site\:logs:Retrieve the latest site log messages' + 'env\:pull:Download the environment file' + 'env\:push:Upload the environment file' + 'deploy\:logs:Retrieve deployment log messages' + 'daemon\:list:List the daemons' + 'daemon\:logs:Retrieve daemon log messages' + 'daemon\:restart:Restart a daemon' + 'daemon\:status:Get daemon status' + 'database\:logs:Retrieve database log messages' + 'database\:restart:Restart the database' + 'database\:shell:Start a database shell' + 'database\:status:Get database status' + 'nginx\:logs:Retrieve Nginx log messages' + 'nginx\:restart:Restart Nginx' + 'nginx\:status:Get Nginx status' + 'php\:logs:Retrieve PHP log messages' + 'php\:restart:Restart PHP' + 'php\:status:Get PHP status' + 'ssh\:configure:Configure SSH key authentication' + 'ssh\:test:Test SSH key authentication' + ) + + # Complete first argument (command) + if (( CURRENT == 2 )); then + _describe -t commands 'forge command' commands + return + fi + + # Complete based on command + case "${words[2]}" in + deploy) + if (( CURRENT == 3 )); then + local -a envs + envs=(${(f)"$(_forge_environments)"}) + if [[ -n "$envs" ]]; then + _describe -t environments 'environment' envs + fi + else + _arguments \ + '--site=[Explicit site name]:site:' \ + '--force[Skip confirmation]' + fi + ;; + config:set) + if (( CURRENT == 3 )); then + local -a envs + envs=(${(f)"$(_forge_environments)"} 'production' 'staging' 'dev') + _describe -t environments 'environment' envs + else + _arguments \ + '--confirm[Require confirmation]' \ + '--no-confirm[Disable confirmation]' + fi + ;; + config:remove|config:default) + if (( CURRENT == 3 )); then + local -a envs + envs=(${(f)"$(_forge_environments)"}) + if [[ -n "$envs" ]]; then + _describe -t environments 'environment' envs + fi + fi + ;; + init) + _arguments \ + '--force[Overwrite existing]' \ + '--simple[Simple config]' + ;; + completion) + if (( CURRENT == 3 )); then + _describe -t shells 'shell' '(zsh bash)' + else + _arguments '--install[Install to shell config]' + fi + ;; + esac +} + +compdef _forge forge