cascade_cli/cli/commands/
setup.rs1use crate::config::{get_repo_config_dir, initialize_repo, Settings};
2use crate::errors::{CascadeError, Result};
3use crate::git::GitRepository;
4use dialoguer::{theme::ColorfulTheme, Confirm, Input};
5use std::env;
6use tracing::{info, warn};
7
8pub async fn run(force: bool) -> Result<()> {
10 println!("š Welcome to Cascade CLI Setup!");
11 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
12 println!("This wizard will help you configure Cascade for your repository.\n");
13
14 let current_dir = env::current_dir()
15 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
16
17 println!("š Step 1: Checking Git repository...");
19 let git_repo = match GitRepository::open(¤t_dir) {
20 Ok(repo) => {
21 println!(" ā
Git repository found");
22 repo
23 }
24 Err(_) => {
25 return Err(CascadeError::config(
26 "No Git repository found. Please run this command from within a Git repository.",
27 ));
28 }
29 };
30
31 let config_dir = get_repo_config_dir(¤t_dir)?;
33 if config_dir.exists() && !force {
34 let reinitialize = Confirm::with_theme(&ColorfulTheme::default())
35 .with_prompt("Cascade is already initialized. Do you want to reconfigure?")
36 .default(false)
37 .interact()
38 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
39
40 if !reinitialize {
41 println!("ā
Setup cancelled. Run with --force to reconfigure.");
42 return Ok(());
43 }
44 }
45
46 println!("\nš Step 2: Detecting Bitbucket configuration...");
48 let auto_config = detect_bitbucket_config(&git_repo)?;
49
50 if let Some((url, project, repo)) = &auto_config {
51 println!(" ā
Detected Bitbucket configuration:");
52 println!(" Server: {url}");
53 println!(" Project: {project}");
54 println!(" Repository: {repo}");
55 } else {
56 println!(" ā ļø Could not auto-detect Bitbucket configuration");
57 }
58
59 println!("\nāļø Step 3: Configure Bitbucket settings...");
61 let bitbucket_config = configure_bitbucket_interactive(auto_config).await?;
62
63 println!("\nš Step 4: Initializing Cascade...");
65 initialize_repo(¤t_dir, Some(bitbucket_config.url.clone()))?;
66
67 let config_path = config_dir.join("config.json");
69 let mut settings = Settings::load_from_file(&config_path).unwrap_or_default();
70
71 settings.bitbucket.url = bitbucket_config.url;
72 settings.bitbucket.project = bitbucket_config.project;
73 settings.bitbucket.repo = bitbucket_config.repo;
74 settings.bitbucket.token = bitbucket_config.token;
75
76 settings.save_to_file(&config_path)?;
77
78 println!("\nš Step 5: Testing connection...");
80 if let Some(ref token) = settings.bitbucket.token {
81 if !token.is_empty() {
82 match test_bitbucket_connection(&settings).await {
83 Ok(_) => {
84 println!(" ā
Connection successful!");
85 }
86 Err(e) => {
87 warn!(" ā ļø Connection test failed: {}", e);
88 println!(" š” You can test the connection later with: cc doctor");
89 }
90 }
91 } else {
92 println!(" ā ļø No token provided - skipping connection test");
93 }
94 } else {
95 println!(" ā ļø No token provided - skipping connection test");
96 }
97
98 println!("\nš Step 6: Shell completions...");
100 let install_completions = Confirm::with_theme(&ColorfulTheme::default())
101 .with_prompt("Would you like to install shell completions?")
102 .default(true)
103 .interact()
104 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
105
106 if install_completions {
107 match crate::cli::commands::completions::install_completions(None) {
108 Ok(_) => {
109 println!(" ā
Shell completions installed");
110 }
111 Err(e) => {
112 warn!(" ā ļø Failed to install completions: {}", e);
113 println!(" š” You can install them later with: cc completions install");
114 }
115 }
116 }
117
118 println!("\nšŖ Step 7: Git hooks...");
120 let install_hooks = Confirm::with_theme(&ColorfulTheme::default())
121 .with_prompt("Would you like to install Git hooks for enhanced workflow?")
122 .default(true)
123 .interact()
124 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
125
126 if install_hooks {
127 match crate::cli::commands::hooks::install_with_options(false, true, true, false).await {
128 Ok(_) => {
129 println!(" ā
Essential Git hooks installed");
130 println!(" š” Hooks installed: pre-push, commit-msg, prepare-commit-msg");
131 println!(" š See docs/HOOKS.md for details");
132 }
133 Err(e) => {
134 warn!(" ā ļø Failed to install hooks: {}", e);
135 println!(" š” You can install them later with: cc hooks install");
136 }
137 }
138 }
139
140 println!("\nš Setup Complete!");
142 println!("āāāāāāāāāāāāāāāāā");
143 println!("Cascade CLI is now configured for your repository.");
144 println!();
145 println!("š” Next steps:");
146 println!(" 1. Create your first stack: cc stack create \"My Feature\"");
147 println!(" 2. Push commits to the stack: cc push");
148 println!(" 3. Submit for review: cc submit");
149 println!(" 4. Check status: cc status");
150 println!();
151 println!("š Learn more:");
152 println!(" ⢠Run 'cc --help' for all commands");
153 println!(" ⢠Run 'cc doctor' to verify your setup");
154 println!(" ⢠Run 'cc hooks status' to check hook installation");
155 println!(" ⢠Visit docs/HOOKS.md for hook details");
156 println!(" ⢠Visit the documentation for advanced usage");
157
158 Ok(())
159}
160
161#[derive(Debug)]
162struct BitbucketConfig {
163 url: String,
164 project: String,
165 repo: String,
166 token: Option<String>,
167}
168
169fn detect_bitbucket_config(git_repo: &GitRepository) -> Result<Option<(String, String, String)>> {
171 let remote_url = match git_repo.get_remote_url("origin") {
173 Ok(url) => url,
174 Err(_) => return Ok(None),
175 };
176
177 if let Some(config) = parse_bitbucket_url(&remote_url) {
179 Ok(Some(config))
180 } else {
181 Ok(None)
182 }
183}
184
185fn parse_bitbucket_url(url: &str) -> Option<(String, String, String)> {
187 if url.starts_with("git@") {
189 if let Some(parts) = url.split('@').nth(1) {
190 if let Some((host, path)) = parts.split_once(':') {
191 let base_url = format!("https://{host}");
192 if let Some((project, repo)) = path.split_once('/') {
193 let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
194 return Some((base_url, project.to_string(), repo_name.to_string()));
195 }
196 }
197 }
198 }
199
200 if url.starts_with("https://") {
202 if let Ok(parsed_url) = url::Url::parse(url) {
203 if let Some(host) = parsed_url.host_str() {
204 let base_url = format!("{}://{}", parsed_url.scheme(), host);
205 let path = parsed_url.path();
206
207 if path.starts_with("/scm/") {
209 let path_parts: Vec<&str> =
210 path.trim_start_matches("/scm/").split('/').collect();
211 if path_parts.len() >= 2 {
212 let project = path_parts[0];
213 let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
214 return Some((base_url, project.to_string(), repo.to_string()));
215 }
216 }
217
218 let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
220 if path_parts.len() >= 2 {
221 let project = path_parts[0];
222 let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
223 return Some((base_url, project.to_string(), repo.to_string()));
224 }
225 }
226 }
227 }
228
229 None
230}
231
232async fn configure_bitbucket_interactive(
234 auto_config: Option<(String, String, String)>,
235) -> Result<BitbucketConfig> {
236 let theme = ColorfulTheme::default();
237
238 let default_url = auto_config
240 .as_ref()
241 .map(|(url, _, _)| url.as_str())
242 .unwrap_or("");
243 let url: String = Input::with_theme(&theme)
244 .with_prompt("Bitbucket Server URL")
245 .with_initial_text(default_url)
246 .validate_with(|input: &String| -> std::result::Result<(), &str> {
247 if input.starts_with("http://") || input.starts_with("https://") {
248 Ok(())
249 } else {
250 Err("URL must start with http:// or https://")
251 }
252 })
253 .interact_text()
254 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
255
256 let default_project = auto_config
258 .as_ref()
259 .map(|(_, project, _)| project.as_str())
260 .unwrap_or("");
261 let project: String = Input::with_theme(&theme)
262 .with_prompt("Project key (usually uppercase)")
263 .with_initial_text(default_project)
264 .validate_with(|input: &String| -> std::result::Result<(), &str> {
265 if input.trim().is_empty() {
266 Err("Project key cannot be empty")
267 } else {
268 Ok(())
269 }
270 })
271 .interact_text()
272 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
273
274 let default_repo = auto_config
276 .as_ref()
277 .map(|(_, _, repo)| repo.as_str())
278 .unwrap_or("");
279 let repo: String = Input::with_theme(&theme)
280 .with_prompt("Repository slug")
281 .with_initial_text(default_repo)
282 .validate_with(|input: &String| -> std::result::Result<(), &str> {
283 if input.trim().is_empty() {
284 Err("Repository slug cannot be empty")
285 } else {
286 Ok(())
287 }
288 })
289 .interact_text()
290 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
291
292 println!("\nš Authentication Setup");
294 println!(" Cascade needs a Personal Access Token to interact with Bitbucket.");
295 println!(" You can create one at: {url}/plugins/servlet/access-tokens/manage");
296 println!(" Required permissions: Repository Read, Repository Write");
297
298 let configure_token = Confirm::with_theme(&theme)
299 .with_prompt("Configure authentication token now?")
300 .default(true)
301 .interact()
302 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
303
304 let token = if configure_token {
305 let token: String = Input::with_theme(&theme)
306 .with_prompt("Personal Access Token")
307 .interact_text()
308 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
309
310 if token.trim().is_empty() {
311 None
312 } else {
313 Some(token.trim().to_string())
314 }
315 } else {
316 println!(" š” You can configure the token later with:");
317 println!(" cc config set bitbucket.token YOUR_TOKEN");
318 None
319 };
320
321 Ok(BitbucketConfig {
322 url,
323 project,
324 repo,
325 token,
326 })
327}
328
329async fn test_bitbucket_connection(settings: &Settings) -> Result<()> {
331 use crate::bitbucket::BitbucketClient;
332
333 let client = BitbucketClient::new(&settings.bitbucket)?;
334
335 match client.get_repository_info().await {
337 Ok(_) => {
338 info!("Successfully connected to Bitbucket");
339 Ok(())
340 }
341 Err(e) => Err(CascadeError::config(format!(
342 "Failed to connect to Bitbucket: {e}"
343 ))),
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn test_parse_bitbucket_ssh_url() {
353 let url = "git@bitbucket.example.com:MYPROJECT/my-repo.git";
354 let result = parse_bitbucket_url(url);
355 assert_eq!(
356 result,
357 Some((
358 "https://bitbucket.example.com".to_string(),
359 "MYPROJECT".to_string(),
360 "my-repo".to_string()
361 ))
362 );
363 }
364
365 #[test]
366 fn test_parse_bitbucket_https_url() {
367 let url = "https://bitbucket.example.com/scm/MYPROJECT/my-repo.git";
368 let result = parse_bitbucket_url(url);
369 assert_eq!(
370 result,
371 Some((
372 "https://bitbucket.example.com".to_string(),
373 "MYPROJECT".to_string(),
374 "my-repo".to_string()
375 ))
376 );
377 }
378
379 #[test]
380 fn test_parse_generic_https_url() {
381 let url = "https://git.example.com/MYPROJECT/my-repo.git";
382 let result = parse_bitbucket_url(url);
383 assert_eq!(
384 result,
385 Some((
386 "https://git.example.com".to_string(),
387 "MYPROJECT".to_string(),
388 "my-repo".to_string()
389 ))
390 );
391 }
392}