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