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: Detecting Bitbucket configuration...");
48 let auto_config = detect_bitbucket_config(&git_repo)?;
49
50 if let Some((url, project, repo)) = &auto_config {
51 Output::success("Detected Bitbucket configuration:");
52 Output::sub_item(format!("Server: {url}"));
53 Output::sub_item(format!("Project: {project}"));
54 Output::sub_item(format!("Repository: {repo}"));
55 } else {
56 Output::warning("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(&repo_root, 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: ca 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: ca 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_essential().await {
128 Ok(_) => {
129 println!(" ā
Essential Git hooks installed");
130 println!(" š” Hooks installed: pre-push, commit-msg, prepare-commit-msg");
131 println!(
132 " š” Optional: Install post-commit hook with 'ca hooks install post-commit'"
133 );
134 println!(" š See docs/HOOKS.md for details");
135 }
136 Err(e) => {
137 warn!(" ā ļø Failed to install hooks: {}", e);
138 if e.to_string().contains("Git hooks directory not found") {
139 println!(" š” This doesn't appear to be a Git repository.");
140 println!(" Please ensure you're running this command from within a Git repository.");
141 println!(" You can initialize git with: git init");
142 } else {
143 println!(" š” You can install them later with: ca hooks install");
144 }
145 }
146 }
147 }
148
149 println!("\nš Step 8: PR Description Template...");
151 let setup_template = Confirm::with_theme(&ColorfulTheme::default())
152 .with_prompt(
153 "Would you like to configure a PR description template? (will be used for ALL PRs)",
154 )
155 .default(false)
156 .interact()
157 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
158
159 if setup_template {
160 configure_pr_template(&config_path).await?;
161 } else {
162 println!(" š” You can configure a PR template later with:");
163 println!(" ca config set cascade.pr_description_template \"Your template\"");
164 }
165
166 println!("\nš Setup Complete!");
168 println!("āāāāāāāāāāāāāāāāā");
169 println!("Cascade CLI is now configured for your repository.");
170 println!();
171 println!("š” Next steps:");
172 println!(" 1. Create your first stack: ca stack create \"My Feature\"");
173 println!(" 2. Push commits to the stack: ca push");
174 println!(" 3. Submit for review: ca submit");
175 println!(" 4. Check status: ca status");
176 println!();
177 println!("š Learn more:");
178 println!(" ⢠Run 'ca --help' for all commands");
179 println!(" ⢠Run 'ca doctor' to verify your setup");
180 println!(" ⢠Use 'ca --verbose <command>' for debug logging");
181 println!(" ⢠Run 'ca hooks status' to check hook installation");
182 println!(
183 " ⢠Configure PR templates: ca config set cascade.pr_description_template \"template\""
184 );
185 println!(" ⢠Visit docs/HOOKS.md for hook details");
186 println!(" ⢠Visit the documentation for advanced usage");
187
188 Ok(())
189}
190
191#[derive(Debug)]
192struct BitbucketConfig {
193 url: String,
194 project: String,
195 repo: String,
196 token: Option<String>,
197}
198
199fn detect_bitbucket_config(git_repo: &GitRepository) -> Result<Option<(String, String, String)>> {
201 let remote_url = match git_repo.get_remote_url("origin") {
203 Ok(url) => url,
204 Err(_) => return Ok(None),
205 };
206
207 if let Some(config) = parse_bitbucket_url(&remote_url) {
209 Ok(Some(config))
210 } else {
211 Ok(None)
212 }
213}
214
215fn parse_bitbucket_url(url: &str) -> Option<(String, String, String)> {
217 if url.starts_with("git@") {
219 if let Some(parts) = url.split('@').nth(1) {
220 if let Some((host, path)) = parts.split_once(':') {
221 let base_url = format!("https://{host}");
222 if let Some((project, repo)) = path.split_once('/') {
223 let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
224 return Some((base_url, project.to_string(), repo_name.to_string()));
225 }
226 }
227 }
228 }
229
230 if url.starts_with("https://") {
232 if let Ok(parsed_url) = url::Url::parse(url) {
233 if let Some(host) = parsed_url.host_str() {
234 let base_url = format!("{}://{}", parsed_url.scheme(), host);
235 let path = parsed_url.path();
236
237 if path.starts_with("/scm/") {
239 let path_parts: Vec<&str> =
240 path.trim_start_matches("/scm/").split('/').collect();
241 if path_parts.len() >= 2 {
242 let project = path_parts[0];
243 let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
244 return Some((base_url, project.to_string(), repo.to_string()));
245 }
246 }
247
248 let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
250 if path_parts.len() >= 2 {
251 let project = path_parts[0];
252 let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
253 return Some((base_url, project.to_string(), repo.to_string()));
254 }
255 }
256 }
257 }
258
259 None
260}
261
262async fn configure_bitbucket_interactive(
264 auto_config: Option<(String, String, String)>,
265) -> Result<BitbucketConfig> {
266 let theme = ColorfulTheme::default();
267
268 let default_url = auto_config
270 .as_ref()
271 .map(|(url, _, _)| url.as_str())
272 .unwrap_or("");
273 let url: String = Input::with_theme(&theme)
274 .with_prompt("Bitbucket Server URL")
275 .with_initial_text(default_url)
276 .validate_with(|input: &String| -> std::result::Result<(), &str> {
277 if input.starts_with("http://") || input.starts_with("https://") {
278 Ok(())
279 } else {
280 Err("URL must start with http:// or https://")
281 }
282 })
283 .interact_text()
284 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
285
286 let default_project = auto_config
288 .as_ref()
289 .map(|(_, project, _)| project.as_str())
290 .unwrap_or("");
291 let project: String = Input::with_theme(&theme)
292 .with_prompt("Project key (usually uppercase)")
293 .with_initial_text(default_project)
294 .validate_with(|input: &String| -> std::result::Result<(), &str> {
295 if input.trim().is_empty() {
296 Err("Project key cannot be empty")
297 } else {
298 Ok(())
299 }
300 })
301 .interact_text()
302 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
303
304 let default_repo = auto_config
306 .as_ref()
307 .map(|(_, _, repo)| repo.as_str())
308 .unwrap_or("");
309 let repo: String = Input::with_theme(&theme)
310 .with_prompt("Repository slug")
311 .with_initial_text(default_repo)
312 .validate_with(|input: &String| -> std::result::Result<(), &str> {
313 if input.trim().is_empty() {
314 Err("Repository slug cannot be empty")
315 } else {
316 Ok(())
317 }
318 })
319 .interact_text()
320 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
321
322 println!("\nš Authentication Setup");
324 println!(" Cascade needs a Personal Access Token to interact with Bitbucket.");
325 println!(" You can create one at: {url}/plugins/servlet/access-tokens/manage");
326 println!(" Required permissions: Repository Read, Repository Write");
327
328 let configure_token = Confirm::with_theme(&theme)
329 .with_prompt("Configure authentication token now?")
330 .default(true)
331 .interact()
332 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
333
334 let token = if configure_token {
335 let token: String = Input::with_theme(&theme)
336 .with_prompt("Personal Access Token")
337 .interact_text()
338 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
339
340 if token.trim().is_empty() {
341 None
342 } else {
343 Some(token.trim().to_string())
344 }
345 } else {
346 println!(" š” You can configure the token later with:");
347 println!(" ca config set bitbucket.token YOUR_TOKEN");
348 None
349 };
350
351 Ok(BitbucketConfig {
352 url,
353 project,
354 repo,
355 token,
356 })
357}
358
359async fn test_bitbucket_connection(settings: &Settings) -> Result<()> {
361 use crate::bitbucket::BitbucketClient;
362
363 let client = BitbucketClient::new(&settings.bitbucket)?;
364
365 match client.get_repository_info().await {
367 Ok(_) => {
368 info!("Successfully connected to Bitbucket");
369 Ok(())
370 }
371 Err(e) => Err(CascadeError::config(format!(
372 "Failed to connect to Bitbucket: {e}"
373 ))),
374 }
375}
376
377async fn configure_pr_template(config_path: &std::path::Path) -> Result<()> {
379 let theme = ColorfulTheme::default();
380
381 println!(" Configure a markdown template for PR descriptions.");
382 println!(" This template will be used for ALL PRs (overrides --description).");
383 println!(" You can use markdown formatting, variables, etc.");
384 println!(" ");
385 println!(" Example template:");
386 println!(" ## Summary");
387 println!(" Brief description of changes");
388 println!(" ");
389 println!(" ## Testing");
390 println!(" - [ ] Unit tests pass");
391 println!(" - [ ] Manual testing completed");
392
393 let use_example = Confirm::with_theme(&theme)
394 .with_prompt("Use the example template above?")
395 .default(true)
396 .interact()
397 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
398
399 let template = if use_example {
400 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())
401 } else {
402 let custom_template: String = Input::with_theme(&theme)
403 .with_prompt("Enter your PR description template (use \\n for line breaks)")
404 .allow_empty(true)
405 .interact_text()
406 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
407
408 if custom_template.trim().is_empty() {
409 None
410 } else {
411 Some(custom_template.replace("\\n", "\n"))
413 }
414 };
415
416 let mut settings = Settings::load_from_file(config_path)?;
418 settings.cascade.pr_description_template = template;
419 settings.save_to_file(config_path)?;
420
421 if settings.cascade.pr_description_template.is_some() {
422 println!(" ā
PR description template configured!");
423 println!(" š” This template will be used for ALL future PRs");
424 println!(" š” Edit later with: ca config set cascade.pr_description_template \"Your template\"");
425 } else {
426 println!(" ā
No template configured (will use --description or commit messages)");
427 }
428
429 Ok(())
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn test_parse_bitbucket_ssh_url() {
438 let url = "git@bitbucket.example.com:MYPROJECT/my-repo.git";
439 let result = parse_bitbucket_url(url);
440 assert_eq!(
441 result,
442 Some((
443 "https://bitbucket.example.com".to_string(),
444 "MYPROJECT".to_string(),
445 "my-repo".to_string()
446 ))
447 );
448 }
449
450 #[test]
451 fn test_parse_bitbucket_https_url() {
452 let url = "https://bitbucket.example.com/scm/MYPROJECT/my-repo.git";
453 let result = parse_bitbucket_url(url);
454 assert_eq!(
455 result,
456 Some((
457 "https://bitbucket.example.com".to_string(),
458 "MYPROJECT".to_string(),
459 "my-repo".to_string()
460 ))
461 );
462 }
463
464 #[test]
465 fn test_parse_generic_https_url() {
466 let url = "https://git.example.com/MYPROJECT/my-repo.git";
467 let result = parse_bitbucket_url(url);
468 assert_eq!(
469 result,
470 Some((
471 "https://git.example.com".to_string(),
472 "MYPROJECT".to_string(),
473 "my-repo".to_string()
474 ))
475 );
476 }
477}