From 48a7382b0b4e34adddfcb97ada93e1f5316c028d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 10:33:14 +0000
Subject: [PATCH 1/3] Initial plan


From 4d0d74871c0c32dc88fa764fb914fb5a07e41909 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 10:58:06 +0000
Subject: [PATCH 2/3] Add interactive cloud provider selection when deploying
 without configured cloud provider

Co-authored-by: vsilent <42473+vsilent@users.noreply.github.com>
---
 src/console/commands/cli/deploy.rs | 198 ++++++++++++++++++++++++++++-
 1 file changed, 192 insertions(+), 6 deletions(-)

diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs
index 57680953..ca43bebe 100644
--- a/src/console/commands/cli/deploy.rs
+++ b/src/console/commands/cli/deploy.rs
@@ -5,7 +5,10 @@ use std::time::Duration;
 use crate::cli::ai_client::{
     build_prompt, create_provider, ollama_complete_streaming, AiTask, PromptContext,
 };
-use crate::cli::config_parser::{AiProviderType, DeployTarget, ServerConfig, StackerConfig};
+use crate::cli::config_parser::{
+    AiProviderType, CloudConfig, CloudOrchestrator, CloudProvider, DeployTarget, ServerConfig,
+    StackerConfig,
+};
 use crate::cli::credentials::CredentialsManager;
 use crate::cli::deployment_lock::DeploymentLock;
 use crate::cli::error::CliError;
@@ -601,6 +604,97 @@ fn print_ai_deploy_help(project_dir: &Path, config_file: Option<&str>, err: &Cli
     }
 }
 
+/// Map a provider code string (as stored in CloudInfo.provider) to a `CloudProvider` enum.
+///
+/// Accepts both short codes ("htz", "do", "aws", "lo", "vu") and full names
+/// ("hetzner", "digitalocean", "aws", "linode", "vultr").
+fn cloud_provider_from_code(code: &str) -> Option<CloudProvider> {
+    match code.to_lowercase().as_str() {
+        "htz" | "hetzner" => Some(CloudProvider::Hetzner),
+        "do" | "digitalocean" => Some(CloudProvider::Digitalocean),
+        "aws" => Some(CloudProvider::Aws),
+        "lo" | "linode" => Some(CloudProvider::Linode),
+        "vu" | "vultr" => Some(CloudProvider::Vultr),
+        _ => None,
+    }
+}
+
+/// Interactively prompt the user to select a saved cloud credential when
+/// no `deploy.cloud` section is present in stacker.yml.
+///
+/// - Fetches the list of saved clouds from the Stacker server.
+/// - Presents an interactive `Select` menu with each cloud plus a
+///   "Connect a new cloud provider" option at the end.
+/// - Returns:
+///   - `Ok(Some(cloud_info))` when the user picks an existing credential.
+///   - `Ok(None)` when the user picks "Connect a new cloud provider".
+///   - `Err(...)` on I/O or network errors.
+fn prompt_select_cloud(
+    access_token: &str,
+) -> Result<Option<stacker_client::CloudInfo>, CliError> {
+    let base_url = crate::cli::install_runner::normalize_stacker_server_url(
+        stacker_client::DEFAULT_STACKER_URL,
+    );
+
+    let rt = tokio::runtime::Builder::new_current_thread()
+        .enable_all()
+        .build()
+        .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?;
+
+    let clouds = rt.block_on(async {
+        let client = StackerClient::new(&base_url, access_token);
+        client.list_clouds().await
+    })?;
+
+    const CONNECT_NEW: &str = "→  Connect a new cloud provider";
+
+    if clouds.is_empty() {
+        eprintln!();
+        eprintln!("  No saved cloud credentials found.");
+        eprintln!("  To add cloud credentials, export your provider token and redeploy:");
+        eprintln!("    HCLOUD_TOKEN=<token> stacker deploy --target cloud   # Hetzner");
+        eprintln!("    DO_API_TOKEN=<token> stacker deploy --target cloud   # DigitalOcean");
+        eprintln!("    AWS_ACCESS_KEY_ID=<key> AWS_SECRET_ACCESS_KEY=<secret> stacker deploy --target cloud  # AWS");
+        eprintln!();
+        return Err(CliError::CloudProviderMissing);
+    }
+
+    /// Column widths for the interactive cloud selection menu.
+    const CLOUD_ID_WIDTH: usize = 6;
+    const CLOUD_NAME_WIDTH: usize = 24;
+
+    let mut items: Vec<String> = clouds
+        .iter()
+        .map(|c| format!("{:<width_id$} {:<width_name$} ({})", c.id, c.name, c.provider,
+            width_id = CLOUD_ID_WIDTH, width_name = CLOUD_NAME_WIDTH))
+        .collect();
+    items.push(CONNECT_NEW.to_string());
+
+    eprintln!();
+    eprintln!("  No cloud provider configured in stacker.yml.");
+    eprintln!("  Select a saved cloud credential to use for this deployment:");
+    eprintln!();
+
+    let selection = dialoguer::Select::new()
+        .with_prompt("Cloud credential")
+        .items(&items)
+        .default(0)
+        .interact()
+        .map_err(|e| CliError::ConfigValidation(format!("Selection error: {}", e)))?;
+
+    if selection == clouds.len() {
+        // User chose "Connect a new cloud provider"
+        return Ok(None);
+    }
+
+    Ok(Some(
+        clouds
+            .into_iter()
+            .nth(selection)
+            .expect("selection index should be within bounds of clouds vector"),
+    ))
+}
+
 /// `stacker deploy [--target local|cloud|server] [--file stacker.yml] [--dry-run] [--force-rebuild]`
 /// `stacker deploy --project=myapp --target cloud --key devops --server bastion`
 ///
@@ -741,7 +835,7 @@ pub fn run_deploy(
         None => project_dir.join(DEFAULT_CONFIG_FILE),
     };
 
-    let config = StackerConfig::from_file(&config_path)?;
+    let mut config = StackerConfig::from_file(&config_path)?;
     ensure_env_file_if_needed(&config, project_dir)?;
 
     // 2. Resolve deploy target (flag > config)
@@ -830,11 +924,82 @@ pub fn run_deploy(
         }
     }
 
-    // 3. Cloud/server prerequisites
-    if deploy_target == DeployTarget::Cloud {
-        // Verify login
+    // 3. Cloud/server prerequisites — verify login and keep credentials for later use.
+    let cloud_creds = if deploy_target == DeployTarget::Cloud {
         let cred_manager = CredentialsManager::with_default_store();
-        cred_manager.require_valid_token("cloud deploy")?;
+        Some(cred_manager.require_valid_token("cloud deploy")?)
+    } else {
+        None
+    };
+
+    // 3b. If cloud target but no cloud section in stacker.yml, prompt to select a saved credential.
+    if deploy_target == DeployTarget::Cloud && config.deploy.cloud.is_none() {
+        let access_token = &cloud_creds
+            .as_ref()
+            .expect("cloud_creds should be set when deploy_target is Cloud (verified in step 3)")
+            .access_token;
+
+        match prompt_select_cloud(access_token)? {
+            Some(cloud_info) => {
+                // Map the provider code to a CloudProvider enum value.
+                let provider = cloud_provider_from_code(&cloud_info.provider)
+                    .ok_or_else(|| CliError::ConfigValidation(format!(
+                        "Unrecognised cloud provider '{}' for credential '{}'. \
+                         Supported providers: hetzner (htz), digitalocean (do), aws, linode (lo), vultr (vu).",
+                        cloud_info.provider, cloud_info.name
+                    )))?;
+
+                eprintln!(
+                    "  Selected cloud credential: {} (id={}, provider={})",
+                    cloud_info.name, cloud_info.id, cloud_info.provider
+                );
+
+                // Apply the selected cloud to the in-memory config.
+                config.deploy.target = DeployTarget::Cloud;
+                config.deploy.cloud = Some(CloudConfig {
+                    provider,
+                    orchestrator: CloudOrchestrator::Remote,
+                    region: None,
+                    size: None,
+                    install_image: None,
+                    remote_payload_file: None,
+                    ssh_key: None,
+                    key: Some(cloud_info.name.clone()),
+                    server: None,
+                });
+
+                // Persist the selection to stacker.yml so subsequent deploys
+                // do not prompt again.
+                if config_path.exists() {
+                    let yaml = serde_yaml::to_string(&config).map_err(|e| {
+                        CliError::ConfigValidation(format!(
+                            "Failed to serialize updated config: {}",
+                            e
+                        ))
+                    })?;
+                    std::fs::write(&config_path, yaml)?;
+                    eprintln!(
+                        "  ✓ Updated {} with deploy.cloud.key={}",
+                        config_path.display(),
+                        cloud_info.name
+                    );
+                }
+            }
+            None => {
+                // User chose "Connect a new cloud provider"
+                eprintln!();
+                eprintln!("  To connect a new cloud provider, export your API token and redeploy:");
+                eprintln!("    Hetzner:      HCLOUD_TOKEN=<token>  stacker deploy --target cloud");
+                eprintln!("    DigitalOcean: DO_API_TOKEN=<token>  stacker deploy --target cloud");
+                eprintln!("    Linode:       LINODE_TOKEN=<token>  stacker deploy --target cloud");
+                eprintln!("    Vultr:        VULTR_API_KEY=<key>   stacker deploy --target cloud");
+                eprintln!("    AWS:          AWS_ACCESS_KEY_ID=<key> AWS_SECRET_ACCESS_KEY=<secret>  stacker deploy --target cloud");
+                eprintln!();
+                eprintln!("  Or configure manually with: stacker config setup cloud");
+                eprintln!();
+                return Err(CliError::CloudProviderMissing);
+            }
+        }
     }
 
     // 4. Validate via strategy
@@ -1969,6 +2134,27 @@ services:
         assert_eq!(result.project_id, Some(7));
     }
 
+    #[test]
+    fn test_cloud_provider_from_code() {
+        // Short codes
+        assert_eq!(cloud_provider_from_code("htz"), Some(CloudProvider::Hetzner));
+        assert_eq!(cloud_provider_from_code("do"), Some(CloudProvider::Digitalocean));
+        assert_eq!(cloud_provider_from_code("aws"), Some(CloudProvider::Aws));
+        assert_eq!(cloud_provider_from_code("lo"), Some(CloudProvider::Linode));
+        assert_eq!(cloud_provider_from_code("vu"), Some(CloudProvider::Vultr));
+        // Full names
+        assert_eq!(cloud_provider_from_code("hetzner"), Some(CloudProvider::Hetzner));
+        assert_eq!(cloud_provider_from_code("digitalocean"), Some(CloudProvider::Digitalocean));
+        assert_eq!(cloud_provider_from_code("linode"), Some(CloudProvider::Linode));
+        assert_eq!(cloud_provider_from_code("vultr"), Some(CloudProvider::Vultr));
+        // Case insensitive
+        assert_eq!(cloud_provider_from_code("HTZ"), Some(CloudProvider::Hetzner));
+        assert_eq!(cloud_provider_from_code("AWS"), Some(CloudProvider::Aws));
+        // Unknown
+        assert_eq!(cloud_provider_from_code("unknown"), None);
+        assert_eq!(cloud_provider_from_code(""), None);
+    }
+
     #[test]
     fn test_with_watch_flags() {
         let cmd = DeployCommand::new(None, None, false, false)

From ff9e555f8c4509f5b65cb85df7e8f6d7b693a6b5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 11:03:27 +0000
Subject: [PATCH 3/3] Fix doc comment syntax inside function body

Co-authored-by: vsilent <42473+vsilent@users.noreply.github.com>
---
 src/console/commands/cli/deploy.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs
index ca43bebe..e003e4a5 100644
--- a/src/console/commands/cli/deploy.rs
+++ b/src/console/commands/cli/deploy.rs
@@ -659,7 +659,7 @@ fn prompt_select_cloud(
         return Err(CliError::CloudProviderMissing);
     }
 
-    /// Column widths for the interactive cloud selection menu.
+    // Column widths for the interactive cloud selection menu.
     const CLOUD_ID_WIDTH: usize = 6;
     const CLOUD_NAME_WIDTH: usize = 24;
 
