Skip to main content

syncable_cli/handlers/
deploy.rs

1//! Non-interactive deployment handlers for `deploy preview` and `deploy run`.
2//!
3//! Wraps the existing `DeployServiceTool` (from the chat agent) as CLI commands
4//! so AI coding agents (Claude Code, Cursor, etc.) can deploy via skills.
5
6use crate::agent::tools::ExecutionContext;
7use crate::agent::tools::platform::{DeployServiceArgs, DeployServiceTool, SecretKeyInput};
8use rig::tool::Tool;
9use std::path::PathBuf;
10
11/// Handle `deploy preview` — returns JSON recommendation without deploying.
12pub async fn handle_deploy_preview(
13    project_path: PathBuf,
14    service_path: PathBuf,
15    service_name: Option<String>,
16    provider: Option<String>,
17    region: Option<String>,
18    machine_type: Option<String>,
19    port: Option<u16>,
20    is_public: bool,
21) -> crate::Result<String> {
22    let rel_path = if service_path == PathBuf::from(".") {
23        None
24    } else {
25        Some(service_path.display().to_string())
26    };
27
28    let args = DeployServiceArgs {
29        path: rel_path,
30        service_name,
31        provider,
32        machine_type,
33        region,
34        port,
35        is_public,
36        cpu: None,
37        memory: None,
38        min_instances: None,
39        max_instances: None,
40        preview_only: true,
41        secret_keys: None,
42    };
43
44    let tool = DeployServiceTool::with_context(project_path, ExecutionContext::HeadlessServer);
45    let result = tool.call(args).await.map_err(|e| {
46        crate::error::IaCGeneratorError::Analysis(crate::error::AnalysisError::InvalidStructure(
47            format!("Deploy preview failed: {}", e),
48        ))
49    })?;
50
51    Ok(result)
52}
53
54/// Handle `deploy run` — triggers actual deployment.
55pub async fn handle_deploy_run(
56    project_path: PathBuf,
57    service_path: PathBuf,
58    service_name: Option<String>,
59    provider: Option<String>,
60    region: Option<String>,
61    machine_type: Option<String>,
62    port: Option<u16>,
63    is_public: bool,
64    cpu: Option<String>,
65    memory: Option<String>,
66    min_instances: Option<i32>,
67    max_instances: Option<i32>,
68    env_vars: Vec<String>,
69    secrets: Vec<String>,
70    env_file: Option<PathBuf>,
71) -> crate::Result<String> {
72    let rel_path = if service_path == PathBuf::from(".") {
73        None
74    } else {
75        Some(service_path.display().to_string())
76    };
77
78    // Build secret_keys from --env and --secret flags
79    let mut secret_keys: Vec<SecretKeyInput> = Vec::new();
80
81    // Parse --env KEY=VALUE pairs (non-secret)
82    for env_str in &env_vars {
83        if let Some((key, value)) = env_str.split_once('=') {
84            secret_keys.push(SecretKeyInput {
85                key: key.to_string(),
86                value: Some(value.to_string()),
87                is_secret: false,
88            });
89        } else {
90            eprintln!(
91                "Warning: ignoring malformed --env '{}' (expected KEY=VALUE)",
92                env_str
93            );
94        }
95    }
96
97    // Parse --secret keys (user will be prompted in terminal)
98    for key in &secrets {
99        secret_keys.push(SecretKeyInput {
100            key: key.to_string(),
101            value: None,
102            is_secret: true,
103        });
104    }
105
106    // Load --env-file if provided
107    if let Some(ref env_file_path) = env_file {
108        if env_file_path.exists() {
109            if let Ok(content) = std::fs::read_to_string(env_file_path) {
110                for line in content.lines() {
111                    let line = line.trim();
112                    if line.is_empty() || line.starts_with('#') {
113                        continue;
114                    }
115                    if let Some((key, value)) = line.split_once('=') {
116                        let key = key.trim().to_string();
117                        let value = value
118                            .trim()
119                            .trim_matches('"')
120                            .trim_matches('\'')
121                            .to_string();
122                        // Detect likely secrets by key name
123                        let looks_secret = key.contains("SECRET")
124                            || key.contains("KEY")
125                            || key.contains("TOKEN")
126                            || key.contains("PASSWORD")
127                            || key.contains("PRIVATE");
128                        if looks_secret {
129                            // Don't include value — user will be prompted
130                            secret_keys.push(SecretKeyInput {
131                                key,
132                                value: None,
133                                is_secret: true,
134                            });
135                        } else {
136                            secret_keys.push(SecretKeyInput {
137                                key,
138                                value: Some(value),
139                                is_secret: false,
140                            });
141                        }
142                    }
143                }
144            } else {
145                eprintln!(
146                    "Warning: could not read env file: {}",
147                    env_file_path.display()
148                );
149            }
150        } else {
151            eprintln!("Warning: env file not found: {}", env_file_path.display());
152        }
153    }
154
155    let args = DeployServiceArgs {
156        path: rel_path,
157        service_name,
158        provider,
159        machine_type,
160        region,
161        port,
162        is_public,
163        cpu,
164        memory,
165        min_instances,
166        max_instances,
167        preview_only: false,
168        secret_keys: if secret_keys.is_empty() {
169            None
170        } else {
171            Some(secret_keys)
172        },
173    };
174
175    // Use InteractiveCli so secrets can be prompted in terminal
176    let tool = DeployServiceTool::with_context(project_path, ExecutionContext::InteractiveCli);
177    let result = tool.call(args).await.map_err(|e| {
178        crate::error::IaCGeneratorError::Analysis(crate::error::AnalysisError::InvalidStructure(
179            format!("Deploy failed: {}", e),
180        ))
181    })?;
182
183    Ok(result)
184}