cascade_cli/cli/commands/
setup.rs1use crate::config::{get_repo_config_dir, initialize_repo, Settings};
2use crate::errors::{CascadeError, Result};
3use crate::git::{find_repository_root, 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: Finding Git repository...");
19 let repo_root = find_repository_root(¤t_dir).map_err(|_| {
20 CascadeError::config(
21 "No Git repository found. Please run this command from within a Git repository.",
22 )
23 })?;
24
25 println!(" ā
Git repository found at: {}", repo_root.display());
26
27 let git_repo = GitRepository::open(&repo_root)?;
28
29 let config_dir = get_repo_config_dir(&repo_root)?;
31 if config_dir.exists() && !force {
32 let reinitialize = Confirm::with_theme(&ColorfulTheme::default())
33 .with_prompt("Cascade is already initialized. Do you want to reconfigure?")
34 .default(false)
35 .interact()
36 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
37
38 if !reinitialize {
39 println!("ā
Setup cancelled. Run with --force to reconfigure.");
40 return Ok(());
41 }
42 }
43
44 println!("\nš Step 2: Detecting Bitbucket configuration...");
46 let auto_config = detect_bitbucket_config(&git_repo)?;
47
48 if let Some((url, project, repo)) = &auto_config {
49 println!(" ā
Detected Bitbucket configuration:");
50 println!(" Server: {url}");
51 println!(" Project: {project}");
52 println!(" Repository: {repo}");
53 } else {
54 println!(" ā ļø Could not auto-detect Bitbucket configuration");
55 }
56
57 println!("\nāļø Step 3: Configure Bitbucket settings...");
59 let bitbucket_config = configure_bitbucket_interactive(auto_config).await?;
60
61 println!("\nš Step 4: Initializing Cascade...");
63 initialize_repo(&repo_root, Some(bitbucket_config.url.clone()))?;
64
65 let config_path = config_dir.join("config.json");
67 let mut settings = Settings::load_from_file(&config_path).unwrap_or_default();
68
69 settings.bitbucket.url = bitbucket_config.url;
70 settings.bitbucket.project = bitbucket_config.project;
71 settings.bitbucket.repo = bitbucket_config.repo;
72 settings.bitbucket.token = bitbucket_config.token;
73
74 settings.save_to_file(&config_path)?;
75
76 println!("\nš Step 5: Testing connection...");
78 if let Some(ref token) = settings.bitbucket.token {
79 if !token.is_empty() {
80 match test_bitbucket_connection(&settings).await {
81 Ok(_) => {
82 println!(" ā
Connection successful!");
83 }
84 Err(e) => {
85 warn!(" ā ļø Connection test failed: {}", e);
86 println!(" š” You can test the connection later with: ca doctor");
87 }
88 }
89 } else {
90 println!(" ā ļø No token provided - skipping connection test");
91 }
92 } else {
93 println!(" ā ļø No token provided - skipping connection test");
94 }
95
96 println!("\nš Step 6: Shell completions...");
98 let install_completions = Confirm::with_theme(&ColorfulTheme::default())
99 .with_prompt("Would you like to install shell completions?")
100 .default(true)
101 .interact()
102 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
103
104 if install_completions {
105 match crate::cli::commands::completions::install_completions(None) {
106 Ok(_) => {
107 println!(" ā
Shell completions installed");
108 }
109 Err(e) => {
110 warn!(" ā ļø Failed to install completions: {}", e);
111 println!(" š” You can install them later with: ca completions install");
112 }
113 }
114 }
115
116 println!("\nšŖ Step 7: Git hooks...");
118 let install_hooks = Confirm::with_theme(&ColorfulTheme::default())
119 .with_prompt("Would you like to install Git hooks for enhanced workflow?")
120 .default(true)
121 .interact()
122 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
123
124 if install_hooks {
125 match crate::cli::commands::hooks::install_essential().await {
126 Ok(_) => {
127 println!(" ā
Essential Git hooks installed");
128 println!(" š” Hooks installed: pre-push, commit-msg, prepare-commit-msg");
129 println!(
130 " š” Optional: Install post-commit hook with 'ca hooks install post-commit'"
131 );
132 println!(" š See docs/HOOKS.md for details");
133 }
134 Err(e) => {
135 warn!(" ā ļø Failed to install hooks: {}", e);
136 if e.to_string().contains("Git hooks directory not found") {
137 println!(" š” This doesn't appear to be a Git repository.");
138 println!(" Please ensure you're running this command from within a Git repository.");
139 println!(" You can initialize git with: git init");
140 } else {
141 println!(" š” You can install them later with: ca hooks install");
142 }
143 }
144 }
145 }
146
147 println!("\nš Step 8: PR Description Template...");
149 let setup_template = Confirm::with_theme(&ColorfulTheme::default())
150 .with_prompt(
151 "Would you like to configure a PR description template? (will be used for ALL PRs)",
152 )
153 .default(false)
154 .interact()
155 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
156
157 if setup_template {
158 configure_pr_template(&config_path).await?;
159 } else {
160 println!(" š” You can configure a PR template later with:");
161 println!(" ca config set cascade.pr_description_template \"Your template\"");
162 }
163
164 println!("\nš Setup Complete!");
166 println!("āāāāāāāāāāāāāāāāā");
167 println!("Cascade CLI is now configured for your repository.");
168 println!();
169 println!("š” Next steps:");
170 println!(" 1. Create your first stack: ca stack create \"My Feature\"");
171 println!(" 2. Push commits to the stack: ca push");
172 println!(" 3. Submit for review: ca submit");
173 println!(" 4. Check status: ca status");
174 println!();
175 println!("š Learn more:");
176 println!(" ⢠Run 'ca --help' for all commands");
177 println!(" ⢠Run 'ca doctor' to verify your setup");
178 println!(" ⢠Use 'ca --verbose <command>' for debug logging");
179 println!(" ⢠Run 'ca hooks status' to check hook installation");
180 println!(
181 " ⢠Configure PR templates: ca config set cascade.pr_description_template \"template\""
182 );
183 println!(" ⢠Visit docs/HOOKS.md for hook details");
184 println!(" ⢠Visit the documentation for advanced usage");
185
186 Ok(())
187}
188
189#[derive(Debug)]
190struct BitbucketConfig {
191 url: String,
192 project: String,
193 repo: String,
194 token: Option<String>,
195}
196
197fn detect_bitbucket_config(git_repo: &GitRepository) -> Result<Option<(String, String, String)>> {
199 let remote_url = match git_repo.get_remote_url("origin") {
201 Ok(url) => url,
202 Err(_) => return Ok(None),
203 };
204
205 if let Some(config) = parse_bitbucket_url(&remote_url) {
207 Ok(Some(config))
208 } else {
209 Ok(None)
210 }
211}
212
213fn parse_bitbucket_url(url: &str) -> Option<(String, String, String)> {
215 if url.starts_with("git@") {
217 if let Some(parts) = url.split('@').nth(1) {
218 if let Some((host, path)) = parts.split_once(':') {
219 let base_url = format!("https://{host}");
220 if let Some((project, repo)) = path.split_once('/') {
221 let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
222 return Some((base_url, project.to_string(), repo_name.to_string()));
223 }
224 }
225 }
226 }
227
228 if url.starts_with("https://") {
230 if let Ok(parsed_url) = url::Url::parse(url) {
231 if let Some(host) = parsed_url.host_str() {
232 let base_url = format!("{}://{}", parsed_url.scheme(), host);
233 let path = parsed_url.path();
234
235 if path.starts_with("/scm/") {
237 let path_parts: Vec<&str> =
238 path.trim_start_matches("/scm/").split('/').collect();
239 if path_parts.len() >= 2 {
240 let project = path_parts[0];
241 let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
242 return Some((base_url, project.to_string(), repo.to_string()));
243 }
244 }
245
246 let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
248 if path_parts.len() >= 2 {
249 let project = path_parts[0];
250 let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
251 return Some((base_url, project.to_string(), repo.to_string()));
252 }
253 }
254 }
255 }
256
257 None
258}
259
260async fn configure_bitbucket_interactive(
262 auto_config: Option<(String, String, String)>,
263) -> Result<BitbucketConfig> {
264 let theme = ColorfulTheme::default();
265
266 let default_url = auto_config
268 .as_ref()
269 .map(|(url, _, _)| url.as_str())
270 .unwrap_or("");
271 let url: String = Input::with_theme(&theme)
272 .with_prompt("Bitbucket Server URL")
273 .with_initial_text(default_url)
274 .validate_with(|input: &String| -> std::result::Result<(), &str> {
275 if input.starts_with("http://") || input.starts_with("https://") {
276 Ok(())
277 } else {
278 Err("URL must start with http:// or https://")
279 }
280 })
281 .interact_text()
282 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
283
284 let default_project = auto_config
286 .as_ref()
287 .map(|(_, project, _)| project.as_str())
288 .unwrap_or("");
289 let project: String = Input::with_theme(&theme)
290 .with_prompt("Project key (usually uppercase)")
291 .with_initial_text(default_project)
292 .validate_with(|input: &String| -> std::result::Result<(), &str> {
293 if input.trim().is_empty() {
294 Err("Project key cannot be empty")
295 } else {
296 Ok(())
297 }
298 })
299 .interact_text()
300 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
301
302 let default_repo = auto_config
304 .as_ref()
305 .map(|(_, _, repo)| repo.as_str())
306 .unwrap_or("");
307 let repo: String = Input::with_theme(&theme)
308 .with_prompt("Repository slug")
309 .with_initial_text(default_repo)
310 .validate_with(|input: &String| -> std::result::Result<(), &str> {
311 if input.trim().is_empty() {
312 Err("Repository slug cannot be empty")
313 } else {
314 Ok(())
315 }
316 })
317 .interact_text()
318 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
319
320 println!("\nš Authentication Setup");
322 println!(" Cascade needs a Personal Access Token to interact with Bitbucket.");
323 println!(" You can create one at: {url}/plugins/servlet/access-tokens/manage");
324 println!(" Required permissions: Repository Read, Repository Write");
325
326 let configure_token = Confirm::with_theme(&theme)
327 .with_prompt("Configure authentication token now?")
328 .default(true)
329 .interact()
330 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
331
332 let token = if configure_token {
333 let token: String = Input::with_theme(&theme)
334 .with_prompt("Personal Access Token")
335 .interact_text()
336 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
337
338 if token.trim().is_empty() {
339 None
340 } else {
341 Some(token.trim().to_string())
342 }
343 } else {
344 println!(" š” You can configure the token later with:");
345 println!(" ca config set bitbucket.token YOUR_TOKEN");
346 None
347 };
348
349 Ok(BitbucketConfig {
350 url,
351 project,
352 repo,
353 token,
354 })
355}
356
357async fn test_bitbucket_connection(settings: &Settings) -> Result<()> {
359 use crate::bitbucket::BitbucketClient;
360
361 let client = BitbucketClient::new(&settings.bitbucket)?;
362
363 match client.get_repository_info().await {
365 Ok(_) => {
366 info!("Successfully connected to Bitbucket");
367 Ok(())
368 }
369 Err(e) => Err(CascadeError::config(format!(
370 "Failed to connect to Bitbucket: {e}"
371 ))),
372 }
373}
374
375async fn configure_pr_template(config_path: &std::path::Path) -> Result<()> {
377 let theme = ColorfulTheme::default();
378
379 println!(" Configure a markdown template for PR descriptions.");
380 println!(" This template will be used for ALL PRs (overrides --description).");
381 println!(" You can use markdown formatting, variables, etc.");
382 println!(" ");
383 println!(" Example template:");
384 println!(" ## Summary");
385 println!(" Brief description of changes");
386 println!(" ");
387 println!(" ## Testing");
388 println!(" - [ ] Unit tests pass");
389 println!(" - [ ] Manual testing completed");
390
391 let use_example = Confirm::with_theme(&theme)
392 .with_prompt("Use the example template above?")
393 .default(true)
394 .interact()
395 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
396
397 let template = if use_example {
398 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())
399 } else {
400 let custom_template: String = Input::with_theme(&theme)
401 .with_prompt("Enter your PR description template (use \\n for line breaks)")
402 .allow_empty(true)
403 .interact_text()
404 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
405
406 if custom_template.trim().is_empty() {
407 None
408 } else {
409 Some(custom_template.replace("\\n", "\n"))
411 }
412 };
413
414 let mut settings = Settings::load_from_file(config_path)?;
416 settings.cascade.pr_description_template = template;
417 settings.save_to_file(config_path)?;
418
419 if settings.cascade.pr_description_template.is_some() {
420 println!(" ā
PR description template configured!");
421 println!(" š” This template will be used for ALL future PRs");
422 println!(" š” Edit later with: ca config set cascade.pr_description_template \"Your template\"");
423 } else {
424 println!(" ā
No template configured (will use --description or commit messages)");
425 }
426
427 Ok(())
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn test_parse_bitbucket_ssh_url() {
436 let url = "git@bitbucket.example.com:MYPROJECT/my-repo.git";
437 let result = parse_bitbucket_url(url);
438 assert_eq!(
439 result,
440 Some((
441 "https://bitbucket.example.com".to_string(),
442 "MYPROJECT".to_string(),
443 "my-repo".to_string()
444 ))
445 );
446 }
447
448 #[test]
449 fn test_parse_bitbucket_https_url() {
450 let url = "https://bitbucket.example.com/scm/MYPROJECT/my-repo.git";
451 let result = parse_bitbucket_url(url);
452 assert_eq!(
453 result,
454 Some((
455 "https://bitbucket.example.com".to_string(),
456 "MYPROJECT".to_string(),
457 "my-repo".to_string()
458 ))
459 );
460 }
461
462 #[test]
463 fn test_parse_generic_https_url() {
464 let url = "https://git.example.com/MYPROJECT/my-repo.git";
465 let result = parse_bitbucket_url(url);
466 assert_eq!(
467 result,
468 Some((
469 "https://git.example.com".to_string(),
470 "MYPROJECT".to_string(),
471 "my-repo".to_string()
472 ))
473 );
474 }
475}