cascade_cli/cli/commands/
setup.rs1use crate::cli::output::Output;
2use crate::config::{get_repo_config_dir, initialize_repo, Settings};
3use crate::errors::{CascadeError, Result};
4use crate::git::{find_repository_root, GitRepository};
5use dialoguer::{theme::ColorfulTheme, Confirm, Input};
6use std::env;
7use tracing::{info, warn};
8
9pub async fn run(force: bool) -> Result<()> {
11 Output::section("Welcome to Cascade CLI Setup!");
12 Output::divider();
13 Output::info("This wizard will help you configure Cascade for your repository.");
14 println!();
15
16 let current_dir = env::current_dir()
17 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
18
19 Output::progress("Step 1: Finding Git repository...");
21 let repo_root = find_repository_root(¤t_dir).map_err(|_| {
22 CascadeError::config(
23 "No Git repository found. Please run this command from within a Git repository.",
24 )
25 })?;
26
27 Output::success(format!("Git repository found at: {}", repo_root.display()));
28
29 let git_repo = GitRepository::open(&repo_root)?;
30
31 let config_dir = get_repo_config_dir(&repo_root)?;
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 Output::success("Setup cancelled. Run with --force to reconfigure.");
42 return Ok(());
43 }
44 }
45
46 Output::progress("Step 2: Configuring Git user settings...");
48 configure_git_user(&git_repo).await?;
49
50 Output::progress("Step 3: Detecting Bitbucket configuration...");
52 let auto_config = detect_bitbucket_config(&git_repo)?;
53
54 if let Some((url, project, repo)) = &auto_config {
55 Output::success("Detected Bitbucket configuration:");
56 Output::sub_item(format!("Server: {url}"));
57 Output::sub_item(format!("Project: {project}"));
58 Output::sub_item(format!("Repository: {repo}"));
59 } else {
60 Output::warning("Could not auto-detect Bitbucket configuration");
61 }
62
63 Output::progress("Step 4: Configure Bitbucket settings");
65 let bitbucket_config = configure_bitbucket_interactive(auto_config).await?;
66
67 Output::progress("Step 5: Initializing Cascade");
69 initialize_repo(&repo_root, Some(bitbucket_config.url.clone()))?;
70
71 let config_path = config_dir.join("config.json");
73 let mut settings = Settings::load_from_file(&config_path).unwrap_or_default();
74
75 settings.bitbucket.url = bitbucket_config.url;
76 settings.bitbucket.project = bitbucket_config.project;
77 settings.bitbucket.repo = bitbucket_config.repo;
78 settings.bitbucket.token = bitbucket_config.token;
79
80 settings.save_to_file(&config_path)?;
81
82 Output::progress("Step 6: Testing connection");
84 if let Some(ref token) = settings.bitbucket.token {
85 if !token.is_empty() {
86 match test_bitbucket_connection(&settings).await {
87 Ok(_) => {
88 Output::success("Connection successful!");
89 }
90 Err(e) => {
91 warn!(" ⚠️ Connection test failed: {}", e);
92 Output::tip("You can test the connection later with: ca doctor");
93 }
94 }
95 } else {
96 Output::warning("No token provided - skipping connection test");
97 }
98 } else {
99 Output::warning("No token provided - skipping connection test");
100 }
101
102 Output::progress("Step 7: Shell completions");
104 let install_completions = Confirm::with_theme(&ColorfulTheme::default())
105 .with_prompt("Would you like to install shell completions?")
106 .default(true)
107 .interact()
108 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
109
110 if install_completions {
111 match crate::cli::commands::completions::install_completions(None) {
112 Ok(_) => {
113 Output::success("Shell completions installed");
114 }
115 Err(e) => {
116 warn!(" ⚠️ Failed to install completions: {}", e);
117 Output::tip("You can install them later with: ca completions install");
118 }
119 }
120 }
121
122 Output::progress("Step 8: Git hooks");
124 let install_hooks = Confirm::with_theme(&ColorfulTheme::default())
125 .with_prompt("Would you like to install Git hooks for enhanced workflow?")
126 .default(true)
127 .interact()
128 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
129
130 if install_hooks {
131 match crate::cli::commands::hooks::install_essential().await {
132 Ok(_) => {
133 Output::success("Essential Git hooks installed");
134 Output::tip("Hooks installed: pre-push, commit-msg, prepare-commit-msg");
135 Output::tip(
136 "Optional: Install post-commit hook with 'ca hooks install post-commit'",
137 );
138 Output::tip("See docs/HOOKS.md for details");
139 }
140 Err(e) => {
141 warn!(" ⚠️ Failed to install hooks: {}", e);
142 if e.to_string().contains("Git hooks directory not found") {
143 Output::tip("This doesn't appear to be a Git repository.");
144 println!(" Please ensure you're running this command from within a Git repository.");
145 println!(" You can initialize git with: git init");
146 } else {
147 Output::tip("You can install them later with: ca hooks install");
148 }
149 }
150 }
151 }
152
153 Output::progress("Step 9: PR Description Template");
155 let setup_template = Confirm::with_theme(&ColorfulTheme::default())
156 .with_prompt(
157 "Would you like to configure a PR description template? (will be used for ALL PRs)",
158 )
159 .default(false)
160 .interact()
161 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
162
163 if setup_template {
164 configure_pr_template(&config_path).await?;
165 } else {
166 Output::tip("You can configure a PR template later with:");
167 Output::command_example("ca config set cascade.pr_description_template \"Your template\"");
168 }
169
170 Output::section("Setup Complete!");
172 Output::success("Cascade CLI is now fully configured for your repository.");
173 println!();
174 Output::info("Configuration includes:");
175 Output::bullet("✅ Git user settings (name and email)");
176 Output::bullet("✅ Bitbucket Server integration");
177 Output::bullet("✅ Essential Git hooks for enhanced workflow");
178 Output::bullet("✅ Shell completions (if selected)");
179 println!();
180 Output::tip("Next steps:");
181 Output::bullet("Create your first stack: ca stack create \"My Feature\"");
182 Output::bullet("Push commits to the stack: ca push");
183 Output::bullet("Submit for review: ca submit");
184 Output::bullet("Check status: ca status");
185 println!();
186 Output::tip("Learn more:");
187 Output::bullet("Run 'ca --help' for all commands");
188 Output::bullet("Run 'ca doctor' to verify your setup");
189 Output::bullet("Use 'ca --verbose <command>' for debug logging");
190 Output::bullet("Run 'ca hooks status' to check hook installation");
191 Output::bullet(
192 "Configure PR templates: ca config set cascade.pr_description_template \"template\"",
193 );
194 Output::bullet("Visit docs/HOOKS.md for hook details");
195 Output::bullet("Visit the documentation for advanced usage");
196
197 Ok(())
198}
199
200async fn configure_git_user(git_repo: &GitRepository) -> Result<()> {
202 let theme = ColorfulTheme::default();
203
204 let repo_path = git_repo.path();
206 let git_repo_inner = git2::Repository::open(repo_path)
207 .map_err(|e| CascadeError::config(format!("Could not open git repository: {e}")))?;
208
209 let mut current_name: Option<String> = None;
210 let mut current_email: Option<String> = None;
211
212 if let Ok(config) = git_repo_inner.config() {
213 if let Ok(name) = config.get_string("user.name") {
215 if !name.trim().is_empty() {
216 current_name = Some(name);
217 }
218 }
219
220 if let Ok(email) = config.get_string("user.email") {
221 if !email.trim().is_empty() {
222 current_email = Some(email);
223 }
224 }
225 }
226
227 match (¤t_name, ¤t_email) {
229 (Some(name), Some(email)) => {
230 Output::success("Git user configuration found:");
231 Output::sub_item(format!("Name: {name}"));
232 Output::sub_item(format!("Email: {email}"));
233
234 let keep_current = Confirm::with_theme(&theme)
235 .with_prompt("Keep current Git user settings?")
236 .default(true)
237 .interact()
238 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
239
240 if keep_current {
241 Output::success("Using existing Git user configuration");
242 return Ok(());
243 }
244 }
245 _ => {
246 if current_name.is_some() || current_email.is_some() {
247 Output::warning("Git user configuration incomplete:");
248 if let Some(name) = ¤t_name {
249 Output::sub_item(format!("Name: {name}"));
250 } else {
251 Output::sub_item("Name: not configured");
252 }
253 if let Some(email) = ¤t_email {
254 Output::sub_item(format!("Email: {email}"));
255 } else {
256 Output::sub_item("Email: not configured");
257 }
258 } else {
259 Output::warning("Git user not configured");
260 Output::info(
261 "Git user name and email are required for commits and Cascade operations",
262 );
263 }
264 }
265 }
266
267 println!("\n👤 Git User Configuration");
269 println!(" This information will be used for all git commits and Cascade operations.");
270
271 let name: String = Input::with_theme(&theme)
272 .with_prompt("Your name")
273 .with_initial_text(current_name.unwrap_or_default())
274 .validate_with(|input: &String| -> std::result::Result<(), &str> {
275 if input.trim().is_empty() {
276 Err("Name cannot be empty")
277 } else {
278 Ok(())
279 }
280 })
281 .interact_text()
282 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
283
284 let email: String = Input::with_theme(&theme)
285 .with_prompt("Your email")
286 .with_initial_text(current_email.unwrap_or_default())
287 .validate_with(|input: &String| -> std::result::Result<(), &str> {
288 if input.trim().is_empty() {
289 Err("Email cannot be empty")
290 } else if !input.contains('@') {
291 Err("Please enter a valid email address")
292 } else {
293 Ok(())
294 }
295 })
296 .interact_text()
297 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
298
299 let use_global = Confirm::with_theme(&theme)
301 .with_prompt("Set globally for all Git repositories? (otherwise only for this repository)")
302 .default(true)
303 .interact()
304 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
305
306 let scope_flag = if use_global { "--global" } else { "--local" };
308
309 let output = std::process::Command::new("git")
311 .args(["config", scope_flag, "user.name", &name])
312 .current_dir(repo_path)
313 .output()
314 .map_err(|e| CascadeError::config(format!("Failed to execute git config: {e}")))?;
315
316 if !output.status.success() {
317 let stderr = String::from_utf8_lossy(&output.stderr);
318 return Err(CascadeError::config(format!(
319 "Failed to set git user.name: {stderr}"
320 )));
321 }
322
323 let output = std::process::Command::new("git")
325 .args(["config", scope_flag, "user.email", &email])
326 .current_dir(repo_path)
327 .output()
328 .map_err(|e| CascadeError::config(format!("Failed to execute git config: {e}")))?;
329
330 if !output.status.success() {
331 let stderr = String::from_utf8_lossy(&output.stderr);
332 return Err(CascadeError::config(format!(
333 "Failed to set git user.email: {stderr}"
334 )));
335 }
336
337 match git_repo.validate_git_user_config() {
339 Ok(_) => {
340 Output::success("Git user configuration updated successfully!");
341 if use_global {
342 Output::sub_item("Configuration applied globally for all Git repositories");
343 } else {
344 Output::sub_item("Configuration applied to this repository only");
345 }
346 Output::sub_item(format!("Name: {name}"));
347 Output::sub_item(format!("Email: {email}"));
348 }
349 Err(e) => {
350 Output::warning(format!("Configuration set but validation failed: {e}"));
351 Output::tip("You may need to check your git configuration manually");
352 }
353 }
354
355 Ok(())
356}
357
358#[derive(Debug)]
359struct BitbucketConfig {
360 url: String,
361 project: String,
362 repo: String,
363 token: Option<String>,
364}
365
366fn detect_bitbucket_config(git_repo: &GitRepository) -> Result<Option<(String, String, String)>> {
368 let remote_url = match git_repo.get_remote_url("origin") {
370 Ok(url) => url,
371 Err(_) => return Ok(None),
372 };
373
374 if let Some(config) = parse_bitbucket_url(&remote_url) {
376 Ok(Some(config))
377 } else {
378 Ok(None)
379 }
380}
381
382fn parse_bitbucket_url(url: &str) -> Option<(String, String, String)> {
384 if url.starts_with("git@") {
386 if let Some(parts) = url.split('@').nth(1) {
387 if let Some((host, path)) = parts.split_once(':') {
388 let base_url = format!("https://{host}");
389 if let Some((project, repo)) = path.split_once('/') {
390 let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
391 return Some((base_url, project.to_string(), repo_name.to_string()));
392 }
393 }
394 }
395 }
396
397 if url.starts_with("https://") {
399 if let Ok(parsed_url) = url::Url::parse(url) {
400 if let Some(host) = parsed_url.host_str() {
401 let base_url = format!("{}://{}", parsed_url.scheme(), host);
402 let path = parsed_url.path();
403
404 if path.starts_with("/scm/") {
406 let path_parts: Vec<&str> =
407 path.trim_start_matches("/scm/").split('/').collect();
408 if path_parts.len() >= 2 {
409 let project = path_parts[0];
410 let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
411 return Some((base_url, project.to_string(), repo.to_string()));
412 }
413 }
414
415 let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
417 if path_parts.len() >= 2 {
418 let project = path_parts[0];
419 let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
420 return Some((base_url, project.to_string(), repo.to_string()));
421 }
422 }
423 }
424 }
425
426 None
427}
428
429async fn configure_bitbucket_interactive(
431 auto_config: Option<(String, String, String)>,
432) -> Result<BitbucketConfig> {
433 let theme = ColorfulTheme::default();
434
435 let default_url = auto_config
437 .as_ref()
438 .map(|(url, _, _)| url.as_str())
439 .unwrap_or("");
440 let url: String = Input::with_theme(&theme)
441 .with_prompt("Bitbucket Server URL")
442 .with_initial_text(default_url)
443 .validate_with(|input: &String| -> std::result::Result<(), &str> {
444 if input.starts_with("http://") || input.starts_with("https://") {
445 Ok(())
446 } else {
447 Err("URL must start with http:// or https://")
448 }
449 })
450 .interact_text()
451 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
452
453 let default_project = auto_config
455 .as_ref()
456 .map(|(_, project, _)| project.as_str())
457 .unwrap_or("");
458 let project: String = Input::with_theme(&theme)
459 .with_prompt("Project key (usually uppercase)")
460 .with_initial_text(default_project)
461 .validate_with(|input: &String| -> std::result::Result<(), &str> {
462 if input.trim().is_empty() {
463 Err("Project key cannot be empty")
464 } else {
465 Ok(())
466 }
467 })
468 .interact_text()
469 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
470
471 let default_repo = auto_config
473 .as_ref()
474 .map(|(_, _, repo)| repo.as_str())
475 .unwrap_or("");
476 let repo: String = Input::with_theme(&theme)
477 .with_prompt("Repository slug")
478 .with_initial_text(default_repo)
479 .validate_with(|input: &String| -> std::result::Result<(), &str> {
480 if input.trim().is_empty() {
481 Err("Repository slug cannot be empty")
482 } else {
483 Ok(())
484 }
485 })
486 .interact_text()
487 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
488
489 println!("\n🔐 Authentication Setup");
491 println!(" Cascade needs a Personal Access Token to interact with Bitbucket.");
492 println!(" You can create one at: {url}/plugins/servlet/access-tokens/manage");
493 println!(" Required permissions: Repository Read, Repository Write");
494
495 let configure_token = Confirm::with_theme(&theme)
496 .with_prompt("Configure authentication token now?")
497 .default(true)
498 .interact()
499 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
500
501 let token = if configure_token {
502 let token: String = Input::with_theme(&theme)
503 .with_prompt("Personal Access Token")
504 .interact_text()
505 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
506
507 if token.trim().is_empty() {
508 None
509 } else {
510 Some(token.trim().to_string())
511 }
512 } else {
513 Output::tip("You can configure the token later with:");
514 Output::command_example("ca config set bitbucket.token YOUR_TOKEN");
515 None
516 };
517
518 Ok(BitbucketConfig {
519 url,
520 project,
521 repo,
522 token,
523 })
524}
525
526async fn test_bitbucket_connection(settings: &Settings) -> Result<()> {
528 use crate::bitbucket::BitbucketClient;
529
530 let client = BitbucketClient::new(&settings.bitbucket)?;
531
532 match client.get_repository_info().await {
534 Ok(_) => {
535 info!("Successfully connected to Bitbucket");
536 Ok(())
537 }
538 Err(e) => Err(CascadeError::config(format!(
539 "Failed to connect to Bitbucket: {e}"
540 ))),
541 }
542}
543
544async fn configure_pr_template(config_path: &std::path::Path) -> Result<()> {
546 let theme = ColorfulTheme::default();
547
548 println!(" Configure a markdown template for PR descriptions.");
549 println!(" This template will be used for ALL PRs (overrides --description).");
550 println!(" You can use markdown formatting, variables, etc.");
551 println!(" ");
552 println!(" Example template:");
553 println!(" ## Summary");
554 println!(" Brief description of changes");
555 println!(" ");
556 println!(" ## Testing");
557 println!(" - [ ] Unit tests pass");
558 println!(" - [ ] Manual testing completed");
559
560 let use_example = Confirm::with_theme(&theme)
561 .with_prompt("Use the example template above?")
562 .default(true)
563 .interact()
564 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
565
566 let template = if use_example {
567 Some("## Summary\nBrief description of changes\n\n## Testing\n- [ ] Unit tests pass\n- [ ] Manual testing completed\n\n## Checklist\n- [ ] Code review completed\n- [ ] Documentation updated".to_string())
568 } else {
569 let custom_template: String = Input::with_theme(&theme)
570 .with_prompt("Enter your PR description template (use \\n for line breaks)")
571 .allow_empty(true)
572 .interact_text()
573 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
574
575 if custom_template.trim().is_empty() {
576 None
577 } else {
578 Some(custom_template.replace("\\n", "\n"))
580 }
581 };
582
583 let mut settings = Settings::load_from_file(config_path)?;
585 settings.cascade.pr_description_template = template;
586 settings.save_to_file(config_path)?;
587
588 if settings.cascade.pr_description_template.is_some() {
589 Output::success("PR description template configured!");
590 Output::tip("This template will be used for ALL future PRs");
591 Output::tip(
592 "Edit later with: ca config set cascade.pr_description_template \"Your template\"",
593 );
594 } else {
595 Output::success("No template configured (will use --description or commit messages)");
596 }
597
598 Ok(())
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 #[test]
606 fn test_parse_bitbucket_ssh_url() {
607 let url = "git@bitbucket.example.com:MYPROJECT/my-repo.git";
608 let result = parse_bitbucket_url(url);
609 assert_eq!(
610 result,
611 Some((
612 "https://bitbucket.example.com".to_string(),
613 "MYPROJECT".to_string(),
614 "my-repo".to_string()
615 ))
616 );
617 }
618
619 #[test]
620 fn test_parse_bitbucket_https_url() {
621 let url = "https://bitbucket.example.com/scm/MYPROJECT/my-repo.git";
622 let result = parse_bitbucket_url(url);
623 assert_eq!(
624 result,
625 Some((
626 "https://bitbucket.example.com".to_string(),
627 "MYPROJECT".to_string(),
628 "my-repo".to_string()
629 ))
630 );
631 }
632
633 #[test]
634 fn test_parse_generic_https_url() {
635 let url = "https://git.example.com/MYPROJECT/my-repo.git";
636 let result = parse_bitbucket_url(url);
637 assert_eq!(
638 result,
639 Some((
640 "https://git.example.com".to_string(),
641 "MYPROJECT".to_string(),
642 "my-repo".to_string()
643 ))
644 );
645 }
646}